diff --git a/.github/workflows/approximate-presence-tests.yml b/.github/workflows/approximate-presence-tests.yml new file mode 100644 index 00000000000..c2c6bc0289d --- /dev/null +++ b/.github/workflows/approximate-presence-tests.yml @@ -0,0 +1,28 @@ +name: Approximate presence tests + +on: + pull_request: + branches: [master] + paths: + - 'submodules/TelegramCore/Sources/Donutgram/DonutgramApproximatePresence.swift' + - 'submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramUserPresence.swift' + - 'submodules/TelegramCore/Sources/UpdatePeers.swift' + - 'submodules/TelegramStringFormatting/Sources/PresenceStrings.swift' + - 'submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift' + - 'Tests/approximate_presence/**' + - '.github/workflows/approximate-presence-tests.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + regression: + runs-on: macos-26 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Check message activity, persistence and status formatting + run: python3 Tests/approximate_presence/run.py diff --git a/Donutgram/DGSettingsUI/Sources/DGFeatureControllers.swift b/Donutgram/DGSettingsUI/Sources/DGFeatureControllers.swift index 08370a179b5..44ad2e27e02 100644 --- a/Donutgram/DGSettingsUI/Sources/DGFeatureControllers.swift +++ b/Donutgram/DGSettingsUI/Sources/DGFeatureControllers.swift @@ -481,13 +481,16 @@ func dgProfilesSettingsController(context: AccountContext, focusKey: String? = n .toggle(12, 1, "regDate", "Показывать дату регистрации", s.showRegistrationDate, true), .toggle(13, 1, "chatDate", "Показывать дату создания чата", s.showChatCreationDate, true), .toggle(14, 1, "mutualContact", "Показывать взаимный контакт", s.showMutualContact, true), - .toggle(15, 1, "relativeOnlineTime", "Относительное время онлайна", s.relativeOnlineTime, true), .toggle(16, 1, "hidePhoneNumber", "Скрыть номер телефона", s.hidePhoneNumber, true), .info(17, 1, "Номера скрываются только на экране этого устройства. Кто видит ваш номер, настраивается в «Конфиденциальности» Telegram."), - .header(20, 2, "ЗНАЧКИ У ИМЁН"), - .toggle(21, 2, "premiumStatuses", "Скрыть премиум-статусы", s.hidePremiumStatuses, true), - .toggle(22, 2, "hideVerification", "Скрыть верификацию", s.hideVerification, true), - .info(23, 2, "Без галочек официальные аккаунты и подделки выглядят одинаково. Telegram никогда не просит код входа в сообщениях.") + .header(20, 2, "ВРЕМЯ В СЕТИ"), + .toggle(21, 2, "relativeOnlineTime", "Относительное время онлайна", s.relativeOnlineTime, true), + .toggle(22, 2, "approximateLastSeen", "Примерный онлайн", s.approximateLastSeen, true), + .info(23, 2, "«Примерный онлайн» показывает вместо «был(а) недавно» время последнего нового сообщения человека в личном чате или общей группе, с отметкой ~. Отметка держится 3 дня, считаются только сообщения после включения."), + .header(30, 3, "ЗНАЧКИ У ИМЁН"), + .toggle(31, 3, "premiumStatuses", "Скрыть премиум-статусы", s.hidePremiumStatuses, true), + .toggle(32, 3, "hideVerification", "Скрыть верификацию", s.hideVerification, true), + .info(33, 3, "Без галочек официальные аккаунты и подделки выглядят одинаково. Telegram никогда не просит код входа в сообщениях.") ]) return result }, restartRequiredKeys: ["premiumStatuses", "hideVerification"], toggle: { key, value in @@ -498,6 +501,7 @@ func dgProfilesSettingsController(context: AccountContext, focusKey: String? = n case "chatDate": s.showChatCreationDate = value case "mutualContact": s.showMutualContact = value case "relativeOnlineTime": s.relativeOnlineTime = value + case "approximateLastSeen": s.approximateLastSeen = value case "hidePhoneNumber": s.hidePhoneNumber = value case "premiumStatuses": s.hidePremiumStatuses = value case "hideVerification": s.hideVerification = value diff --git a/Donutgram/DGSimpleSettings/Sources/DGLocalization.swift b/Donutgram/DGSimpleSettings/Sources/DGLocalization.swift index 090bc268fb8..78572fc33ec 100644 --- a/Donutgram/DGSimpleSettings/Sources/DGLocalization.swift +++ b/Donutgram/DGSimpleSettings/Sources/DGLocalization.swift @@ -86,6 +86,8 @@ private let dgEnglishStrings: [String: String] = [ "Показывать дату создания чата": "Show Chat Creation Date", "Показывать взаимный контакт": "Show Mutual Contact", "Относительное время онлайна": "Relative Last Seen Time", + "Примерный онлайн": "Approximate Last Seen", + "«Примерный онлайн» показывает вместо «был(а) недавно» время последнего нового сообщения человека в личном чате или общей группе, с отметкой ~. Отметка держится 3 дня, считаются только сообщения после включения.": "“Approximate Last Seen” shows the time of the person's latest new message in your private chat or a shared group, marked with ~, instead of “last seen recently”. The mark lasts 3 days, and only messages after you turn it on count.", "Скрыть номер телефона": "Hide Phone Number", "Отключить рекламу": "Disable Ads", "Предпросмотр": "Preview", @@ -261,6 +263,7 @@ private let dgEnglishStrings: [String: String] = [ "Скрыть реакции в личных чатах": "Hide Reactions in Private Chats", "Информация": "Information", "Значки у имён": "Badges Next to Names", + "Время в сети": "Last Seen", "Пауза музыки": "Pause Music", "При записи кружков": "While Recording Round Videos", "При записи голосовых": "While Recording Voice Messages", diff --git a/Donutgram/DGSimpleSettings/Sources/DGSimpleSettings.swift b/Donutgram/DGSimpleSettings/Sources/DGSimpleSettings.swift index 96f6960bb76..2d4868bb61e 100644 --- a/Donutgram/DGSimpleSettings/Sources/DGSimpleSettings.swift +++ b/Donutgram/DGSimpleSettings/Sources/DGSimpleSettings.swift @@ -145,6 +145,8 @@ public final class DGSimpleSettings { static let saveInBotChats = "donutgram.spy.saveInBotChats" static let saveOwnDeletedMessages = "donutgram.spy.saveOwnDeletedMessages" static let showDisappearedGifts = "donutgram.spy.showDisappearedGifts" + static let approximateLastSeen = "donutgram.spy.approximateLastSeen" + static let approximateLastSeenStartedAt = "donutgram.spy.approximateLastSeenStartedAt" static let ghostModeEnabled = "donutgram.ghost.enabled" static let ghostReadMessages = "donutgram.ghost.readMessages" @@ -264,6 +266,10 @@ public final class DGSimpleSettings { init(defaults: UserDefaults = .standard) { self.defaults = defaults + if defaults.bool(forKey: Key.approximateLastSeen) && defaults.object(forKey: Key.approximateLastSeenStartedAt) == nil { + defaults.set(Int32(Date().timeIntervalSince1970), forKey: Key.approximateLastSeenStartedAt) + } + if defaults.object(forKey: Key.saveDeletedMessages) == nil { defaults.set(true, forKey: Key.saveDeletedMessages) } @@ -675,6 +681,26 @@ public final class DGSimpleSettings { public var showProfileId: Bool { get { bool(Key.showProfileId) } set { setBool(newValue, Key.showProfileId) } } public var dialogIdFormat: DialogIdFormat { get { DialogIdFormat(rawValue: integer(Key.dialogIdFormat)) ?? .telegramApi } set { setInteger(newValue.rawValue, Key.dialogIdFormat) } } public var relativeOnlineTime: Bool { get { bool(Key.relativeOnlineTime) } set { setBool(newValue, Key.relativeOnlineTime) } } + public var approximateLastSeen: Bool { + get { bool(Key.approximateLastSeen) } + set { + guard newValue != self.approximateLastSeen else { + return + } + if newValue { + self.defaults.set(Int32(Date().timeIntervalSince1970), forKey: Key.approximateLastSeenStartedAt) + } else { + self.defaults.removeObject(forKey: Key.approximateLastSeenStartedAt) + } + setBool(newValue, Key.approximateLastSeen) + } + } + public var approximateLastSeenStartedAt: Int32? { + guard self.approximateLastSeen, self.defaults.object(forKey: Key.approximateLastSeenStartedAt) != nil else { + return nil + } + return Int32(clamping: self.defaults.integer(forKey: Key.approximateLastSeenStartedAt)) + } public var hidePhoneNumber: Bool { get { bool(Key.hidePhoneNumber) } set { setBool(newValue, Key.hidePhoneNumber) } } public var showDc: Bool { get { bool(Key.showDc) } set { setBool(newValue, Key.showDc) } } public var showRegistrationDate: Bool { get { bool(Key.showRegistrationDate) } set { setBool(newValue, Key.showRegistrationDate) } } diff --git a/Tests/approximate_presence/fixtures.swift b/Tests/approximate_presence/fixtures.swift new file mode 100644 index 00000000000..1f7035a1968 --- /dev/null +++ b/Tests/approximate_presence/fixtures.swift @@ -0,0 +1,212 @@ +import Foundation + +public protocol PostboxCoding { + init(decoder: PostboxDecoder) + func encode(_ encoder: PostboxEncoder) +} +public protocol PeerPresence: PostboxCoding {} +public final class PostboxEncoder { + var values: [String: Int32] = [:] + public func encodeInt32(_ value: Int32, forKey key: String) { values[key] = value } + public func encodeBool(_ value: Bool, forKey key: String) { values[key] = value ? 1 : 0 } +} +public final class PostboxDecoder { + let values: [String: Int32] + init(_ values: [String: Int32]) { self.values = values } + public func decodeInt32ForKey(_ key: String, orElse value: Int32) -> Int32 { values[key] ?? value } + public func decodeBoolForKey(_ key: String, orElse value: Bool) -> Bool { values[key].map { $0 != 0 } ?? value } +} +struct PeerId: Hashable { let namespace: Int; let id: Int } +enum Namespaces { + enum Peer { static let CloudUser = 0; static let CloudGroup = 1; static let CloudChannel = 2; static let SecretChat = 3 } + enum Message { static let Cloud = 0; static let ScheduledCloud = 1 } +} +protocol Peer {} +struct UserFlags: OptionSet { + let rawValue: Int + static let isSupport = UserFlags(rawValue: 1) +} +struct TelegramUser: Peer { + var botInfo: Int? = nil + var isDeleted = false + var flags: UserFlags = [] +} +struct TelegramGroup: Peer { + enum Membership { case Member, Left, Removed } + var membership: Membership = .Member +} +struct TelegramChannel: Peer { + enum Info { case group, broadcast } + enum Participation { case member, left, kicked } + var info: Info = .group + var participationStatus: Participation = .member +} +protocol Media {} +struct TelegramMediaAction: Media {} +protocol MessageAttribute {} +struct NotificationInfoMessageAttribute: MessageAttribute { + struct Flags: OptionSet { + let rawValue: Int + static let automaticMessage = Flags(rawValue: 1) + } + var flags: Flags +} +struct InlineBusinessBotMessageAttribute: MessageAttribute {} +struct StoreMessageFlags: OptionSet { + let rawValue: Int + static let Incoming = StoreMessageFlags(rawValue: 1) + static let WasScheduled = StoreMessageFlags(rawValue: 2) +} +struct MessageId { var peerId: PeerId; var namespace: Int = 0 } +enum StoreMessageId { + case Id(MessageId) + case Partial(PeerId) + var peerId: PeerId { + switch self { case let .Id(id): return id.peerId; case let .Partial(id): return id } + } +} +struct StoreMessage { + var id: StoreMessageId + var authorId: PeerId? + var timestamp: Int32 + var flags: StoreMessageFlags = [.Incoming] + var media: [Media] = [] + var attributes: [MessageAttribute] = [] +} +final class Transaction { + var peers: [PeerId: Peer] = [:] + var presences: [PeerId: PeerPresence] = [:] + func getPeer(_ id: PeerId) -> Peer? { peers[id] } + func getPeerPresence(peerId: PeerId) -> PeerPresence? { presences[peerId] } + func updatePeerPresencesInternal(presences: [PeerId: PeerPresence], merge: (PeerPresence, PeerPresence) -> PeerPresence) { + for (id, presence) in presences { + self.presences[id] = self.presences[id].map { merge($0, presence) } ?? presence + } + } +} +final class DGSimpleSettings { + static let shared = DGSimpleSettings(defaults: UserDefaults(suiteName: "approximate-presence-\(UUID().uuidString)")!) + static let didChangeNotification = Notification.Name("settings-changed") + let defaults: UserDefaults + private enum Key { + static let approximateLastSeen = "donutgram.spy.approximateLastSeen" + static let approximateLastSeenStartedAt = "donutgram.spy.approximateLastSeenStartedAt" + } + init(defaults: UserDefaults) { + self.defaults = defaults + SETTINGS_MIGRATION + } + SETTINGS_MEMBERS +} +public enum EnginePeer { + public struct Presence { + var status: UserPresenceStatus + var donutgramLastMessageTimestamp: Int32 + } +} +public struct PresentationDateTimeFormat { + enum DateFormat { case monthFirst, dayFirst } + var dateSeparator = "." + var dateSuffix = "" + var requiresFullYear = true + var dateFormat: DateFormat = .dayFirst +} +public struct PresentationStrings { + var baseLanguageCode: String + struct Formatted { var string: String } + func LastSeen_AtDate(_ value: String) -> Formatted { Formatted(string: "last seen \(value)") } +} + +PRODUCTION_CODE + +func check(_ condition: @autoclosure () -> Bool, _ name: String) { + precondition(condition(), name) +} + +let account = PeerId(namespace: 0, id: 1) +let user = PeerId(namespace: 0, id: 2) +let bot = PeerId(namespace: 0, id: 3) +let other = PeerId(namespace: 0, id: 4) +let group = PeerId(namespace: 1, id: 10) +let leftGroup = PeerId(namespace: 1, id: 11) +let supergroup = PeerId(namespace: 2, id: 12) +let channel = PeerId(namespace: 2, id: 13) +let secret = PeerId(namespace: 3, id: 14) +let transaction = Transaction() +transaction.peers = [account: TelegramUser(), user: TelegramUser(), bot: TelegramUser(botInfo: 1), other: TelegramUser(), group: TelegramGroup(), leftGroup: TelegramGroup(membership: .Left), supergroup: TelegramChannel(), channel: TelegramChannel(info: .broadcast)] +let now = Int32(Date().timeIntervalSince1970) +func message(_ chat: PeerId, author: PeerId? = user, time: Int32 = now - 60, flags: StoreMessageFlags = [.Incoming], media: [Media] = [], attributes: [MessageAttribute] = [], namespace: Int = 0) -> StoreMessage { + StoreMessage(id: .Id(MessageId(peerId: chat, namespace: namespace)), authorId: author, timestamp: time, flags: flags, media: media, attributes: attributes) +} +func recorded() -> Int32 { (transaction.presences[user] as? TelegramUserPresence)?.donutgramLastMessageTimestamp ?? 0 } +func record(_ messages: [StoreMessage]) { donutgramRecordMessageActivity(transaction: transaction, accountPeerId: account, messages: messages) } + +DGSimpleSettings.shared.approximateLastSeen = true +let firstActivation = DGSimpleSettings.shared.approximateLastSeenStartedAt! +transaction.presences[user] = TelegramUserPresence(status: .recently(isHidden: true), lastActivity: now - 50) +record([message(user, time: firstActivation - 1), message(group, time: firstActivation)]) +check(recorded() == 0, "ignore messages from before activation including the boundary second") +let oldEvidence = EnginePeer.Presence(status: .recently(isHidden: true), donutgramLastMessageTimestamp: firstActivation - 1) +check(donutgramApproximateLastSeenTimestamp(oldEvidence, relativeTo: now) == nil, "hide cached evidence from before activation") +DGSimpleSettings.shared.defaults.set(now - 120, forKey: "donutgram.spy.approximateLastSeenStartedAt") +DGSimpleSettings.shared.approximateLastSeen = true +check(DGSimpleSettings.shared.approximateLastSeenStartedAt == now - 120, "setting an enabled toggle again keeps the activation") +record([message(user)]) +check(recorded() == now - 60, "private message") +record([message(group, time: now - 40), message(supergroup, time: now - 20)]) +check(recorded() == now - 20, "newest mutual group message") +record([message(user, time: now - 90)]) +check(recorded() == now - 20, "older history cannot move time backwards") +record([message(leftGroup, time: now - 1), message(channel, time: now - 1), message(secret, time: now - 1), message(other, time: now - 1)]) +check(recorded() == now - 20, "exclude left groups, broadcasts, secret chats and unrelated private chats") +record([message(user, time: now - 1, flags: [.WasScheduled]), message(user, time: now - 1, media: [TelegramMediaAction()]), message(user, time: now - 1, attributes: [NotificationInfoMessageAttribute(flags: [.automaticMessage])]), message(user, time: now - 1, attributes: [InlineBusinessBotMessageAttribute()]), message(user, time: now - 1, namespace: 1)]) +check(recorded() == now - 20, "exclude scheduled, service, automatic and business bot messages") +transaction.presences[bot] = TelegramUserPresence(status: .recently(isHidden: false), lastActivity: 0) +transaction.presences[account] = TelegramUserPresence(status: .recently(isHidden: false), lastActivity: 0) +record([message(group, author: nil), message(group, author: channel), message(group, author: bot), message(group, author: account)]) +check((transaction.presences[bot] as! TelegramUserPresence).donutgramLastMessageTimestamp == 0 && (transaction.presences[account] as! TelegramUserPresence).donutgramLastMessageTimestamp == 0 && transaction.presences[channel] == nil, "exclude bots, anonymous senders and self") +record([message(other, author: other)]) +check(transaction.presences[other] == nil, "no presence is made up for a user the server gave none") +DGSimpleSettings.shared.approximateLastSeen = false +record([message(user, time: now - 1)]) +check(recorded() == now - 20, "disabled collection") +check(DGSimpleSettings.shared.approximateLastSeenStartedAt == nil, "disabled tracking has no activation") +DGSimpleSettings.shared.approximateLastSeen = true +let nextActivation = DGSimpleSettings.shared.approximateLastSeenStartedAt! +check(nextActivation >= firstActivation && nextActivation > now - 120, "reenabling starts a new tracking period") +check(donutgramApproximateLastSeenTimestamp(EnginePeer.Presence(status: .recently(isHidden: true), donutgramLastMessageTimestamp: recorded()), relativeTo: now) == nil, "reenabling hides previous tracking data") +record([message(user, time: now - 10)]) +check(recorded() == now - 20, "delayed messages from a previous period are ignored") +let reopenedSettings = DGSimpleSettings(defaults: DGSimpleSettings.shared.defaults) +check(reopenedSettings.approximateLastSeenStartedAt == nextActivation, "restart preserves activation") +DGSimpleSettings.shared.defaults.set(now - 120, forKey: "donutgram.spy.approximateLastSeenStartedAt") +record([message(user, time: now - 10)]) +check(recorded() == now - 10, "new messages during the tracking period update evidence") +record([message(user, time: now + 60)]) +check(recorded() == now + 60, "a message dated ahead of this device's clock counts") +let legacyDefaults = UserDefaults(suiteName: "approximate-presence-migration-\(UUID().uuidString)")! +legacyDefaults.set(true, forKey: "donutgram.spy.approximateLastSeen") +let migratedSettings = DGSimpleSettings(defaults: legacyDefaults) +check(migratedSettings.approximateLastSeenStartedAt != nil && migratedSettings.approximateLastSeenStartedAt! >= firstActivation, "an already enabled old version starts tracking at migration") +let stored = transaction.presences[user] as! TelegramUserPresence +check(stored.status == .recently(isHidden: true) && stored.lastActivity == now - 50, "message evidence leaves real presence unchanged") +let encoder = PostboxEncoder() +stored.encode(encoder) +check(TelegramUserPresence(decoder: PostboxDecoder(encoder.values)) == stored, "persist through restart") +check(TelegramUserPresence(decoder: PostboxDecoder(["v": 2, "la": 5])).donutgramLastMessageTimestamp == 0, "read old database format") +check(stored != TelegramUserPresence(status: stored.status, lastActivity: stored.lastActivity), "evidence changes presence equality") +let recent = EnginePeer.Presence(status: .recently(isHidden: true), donutgramLastMessageTimestamp: now - 60) +check(donutgramApproximateLastSeenTimestamp(recent, relativeTo: now) == now - 60, "recent evidence") +check(donutgramApproximateLastSeenTimestamp(recent, relativeTo: now + 3 * 86400) == nil, "expire old evidence") +check(donutgramApproximateLastSeenTimestamp(recent, relativeTo: now - 120) == nil, "reject future evidence") +check(donutgramApproximateLastSeenTimestamp(EnginePeer.Presence(status: .present(until: now + 60), donutgramLastMessageTimestamp: now - 60), relativeTo: now) == nil, "keep exact online status") +check(donutgramApproximateLastSeenTimestamp(EnginePeer.Presence(status: .lastWeek(isHidden: false), donutgramLastMessageTimestamp: now - 60), relativeTo: now) == nil, "keep last week status") +check(donutgramApproximateLastSeenTimestamp(EnginePeer.Presence(status: .recently(isHidden: false), donutgramLastMessageTimestamp: 0), relativeTo: now) == nil, "no invented estimate") +NSTimeZone.default = TimeZone(secondsFromGMT: 0)! +let iso = ISO8601DateFormatter() +let sample = Int32(iso.date(from: "2026-10-24T00:12:41Z")!.timeIntervalSince1970) +let format = PresentationDateTimeFormat() +check(stringForDonutgramApproximatePresence(strings: PresentationStrings(baseLanguageCode: "ru"), dateTimeFormat: format, timestamp: sample, relativeTo: sample + 3600) == "был(а) в ~00:12:41", "screenshot format including seconds") +check(stringForDonutgramApproximatePresence(strings: PresentationStrings(baseLanguageCode: "ru-custom"), dateTimeFormat: format, timestamp: sample, relativeTo: sample + 86400) == "был(а) 24.10.2026 в ~00:12:41", "date for older messages and custom language") +check(stringForDonutgramApproximatePresence(strings: PresentationStrings(baseLanguageCode: "en"), dateTimeFormat: format, timestamp: sample, relativeTo: sample + 3600) == "last seen ~00:12:41", "English fallback") +print("approximate presence regression checks passed") diff --git a/Tests/approximate_presence/run.py b/Tests/approximate_presence/run.py new file mode 100644 index 00000000000..81230c8ecac --- /dev/null +++ b/Tests/approximate_presence/run.py @@ -0,0 +1,64 @@ +from pathlib import Path +import shutil +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parents[2] +CORE = ROOT / "submodules/TelegramCore/Sources" +FORMATTING = ROOT / "submodules/TelegramStringFormatting/Sources/PresenceStrings.swift" + + +def extract(source, marker): + start = source.index(marker) + opening = source.index("{", start) + depth = 1 + end = opening + 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[start:end] + + +def main(): + compiler = shutil.which("swiftc") + if compiler is None: + raise SystemExit("swiftc is required; run on macOS with Xcode.") + activity = (CORE / "Donutgram/DonutgramApproximatePresence.swift").read_text(encoding="utf-8") + presence = (CORE / "SyncCore/SyncCore_TelegramUserPresence.swift").read_text(encoding="utf-8").replace("import Postbox", "") + formatting = FORMATTING.read_text(encoding="utf-8") + functions = [extract(activity, marker) for marker in ( + "func donutgramActivityGroup(", + "func donutgramRecordMessageActivity(", + "public func donutgramApproximateLastSeenTimestamp(", + )] + functions += [extract(formatting, marker) for marker in ( + "public func stringForTimestamp(day: Int32, month: Int32, year: Int32,", + "public func stringForDonutgramApproximatePresence(", + )] + fixture = Path(__file__).with_name("fixtures.swift").read_text(encoding="utf-8") + settings = (ROOT / "Donutgram/DGSimpleSettings/Sources/DGSimpleSettings.swift").read_text(encoding="utf-8") + fixture = fixture.replace("SETTINGS_MIGRATION", extract(settings, "if defaults.bool(forKey: Key.approximateLastSeen)")) + fixture = fixture.replace("SETTINGS_MEMBERS", "\n".join(extract(settings, marker) for marker in ( + "private func bool(", + "private func setBool(", + "public var approximateLastSeen: Bool", + "public var approximateLastSeenStartedAt: Int32?", + ))) + fixture = fixture.replace("PRODUCTION_CODE", presence + "\n" + "\n".join(functions)) + with tempfile.TemporaryDirectory(prefix="approximate-presence-") as directory: + directory = Path(directory) + main_file = directory / "main.swift" + executable = directory / "presence-tests" + main_file.write_text(fixture, encoding="utf-8") + subprocess.run([compiler, str(main_file), "-o", str(executable)], check=True) + subprocess.run([str(executable)], check=True) + for path in subprocess.check_output(["git", "diff", "--name-only", "origin/master", "--", "*.swift"], cwd=ROOT, text=True).splitlines(): + if not path.startswith(("Donutgram/", "submodules/")): + continue + subprocess.run([compiler, "-frontend", "-parse", str(ROOT / path)], check=True) + subprocess.run([compiler, "-frontend", "-parse", str(CORE / "Donutgram/DonutgramApproximatePresence.swift")], check=True) + + +if __name__ == "__main__": + main() diff --git a/submodules/PeerPresenceStatusManager/Sources/PeerPresenceStatusManager.swift b/submodules/PeerPresenceStatusManager/Sources/PeerPresenceStatusManager.swift index e173f2f2f4b..538c2b7178a 100644 --- a/submodules/PeerPresenceStatusManager/Sources/PeerPresenceStatusManager.swift +++ b/submodules/PeerPresenceStatusManager/Sources/PeerPresenceStatusManager.swift @@ -25,6 +25,10 @@ private func suggestedUserPresenceStringRefreshTimeout(_ presence: EnginePeer.Pr let activeUntil = presence.lastActivity + 30 if activeUntil >= timestamp { return Double(activeUntil - timestamp + 1) + } else if let lastMessage = donutgramApproximateLastSeenTimestamp(presence, relativeTo: timestamp) { + let now = Date(timeIntervalSince1970: TimeInterval(timestamp)) + let midnight = Calendar.current.date(byAdding: .day, value: 1, to: Calendar.current.startOfDay(for: now)) ?? now.addingTimeInterval(24 * 60 * 60) + return max(1.0, min(midnight.timeIntervalSince(now), Double(lastMessage) + 3 * 24 * 60 * 60 - Double(timestamp))) } else { return Double.infinity } diff --git a/submodules/TelegramCore/Sources/Donutgram/DonutgramApproximatePresence.swift b/submodules/TelegramCore/Sources/Donutgram/DonutgramApproximatePresence.swift new file mode 100644 index 00000000000..a572d5d92b6 --- /dev/null +++ b/submodules/TelegramCore/Sources/Donutgram/DonutgramApproximatePresence.swift @@ -0,0 +1,83 @@ +import Foundation +import Postbox +import SwiftSignalKit +import DGSimpleSettings + +func donutgramActivityGroup(_ peer: Peer?) -> Bool { + if let group = peer as? TelegramGroup { + return group.membership == .Member + } + if let channel = peer as? TelegramChannel, case .group = channel.info { + return channel.participationStatus == .member + } + return false +} + +func donutgramRecordMessageActivity(transaction: Transaction, accountPeerId: PeerId, messages: [StoreMessage]) { + guard let startedAt = DGSimpleSettings.shared.approximateLastSeenStartedAt else { + return + } + var timestamps: [PeerId: Int32] = [:] + for message in messages { + // Message dates are server time, never compared with this device's clock: one that runs behind would drop every new message. + guard case let .Id(id) = message.id, id.namespace == Namespaces.Message.Cloud, + let authorId = message.authorId, authorId != accountPeerId, + authorId.namespace == Namespaces.Peer.CloudUser, + let author = transaction.getPeer(authorId) as? TelegramUser, + author.botInfo == nil, !author.isDeleted, !author.flags.contains(.isSupport), + message.timestamp > startedAt, + !message.flags.contains(.WasScheduled) else { + continue + } + if id.peerId != authorId && !donutgramActivityGroup(transaction.getPeer(id.peerId)) { + continue + } + // Away and greeting messages and a business account's chatbot replies are sent without the person. + if message.media.contains(where: { $0 is TelegramMediaAction }) || message.attributes.contains(where: { + $0 is InlineBusinessBotMessageAttribute || ($0 as? NotificationInfoMessageAttribute)?.flags.contains(.automaticMessage) == true + }) { + continue + } + timestamps[authorId] = max(timestamps[authorId] ?? 0, message.timestamp) + } + for (peerId, timestamp) in timestamps { + // Only a presence the server gave is updated: a made-up one would keep a min user's status from being stored (UpdatePeers). + guard let previous = transaction.getPeerPresence(peerId: peerId) as? TelegramUserPresence, timestamp > previous.donutgramLastMessageTimestamp else { + continue + } + let updated = TelegramUserPresence(status: previous.status, lastActivity: previous.lastActivity, donutgramLastMessageTimestamp: timestamp) + transaction.updatePeerPresencesInternal(presences: [peerId: updated], merge: { _, updated in + return updated + }) + } +} + +public func donutgramApproximateLastSeenTimestamp(_ presence: EnginePeer.Presence, relativeTo timestamp: Int32) -> Int32? { + guard let startedAt = DGSimpleSettings.shared.approximateLastSeenStartedAt, + case .recently = presence.status else { + return nil + } + let lastMessage = presence.donutgramLastMessageTimestamp + let age = Int64(timestamp) - Int64(lastMessage) + guard lastMessage > startedAt, age >= 0, age < 3 * 24 * 60 * 60 else { + return nil + } + return lastMessage +} + +public func donutgramActivityPeerView(account: Account, peerId: PeerId) -> Signal { + let enabled = Signal { subscriber in + let observer = NotificationCenter.default.addObserver(forName: DGSimpleSettings.didChangeNotification, object: DGSimpleSettings.shared, queue: nil) { _ in + subscriber.putNext(DGSimpleSettings.shared.approximateLastSeen) + } + subscriber.putNext(DGSimpleSettings.shared.approximateLastSeen) + return ActionDisposable { + NotificationCenter.default.removeObserver(observer) + } + } + |> distinctUntilChanged + return enabled + |> mapToSignal { _ -> Signal in + return account.viewTracker.peerView(peerId) + } +} diff --git a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift index 85eb4d345ad..952070360fb 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift @@ -4261,11 +4261,13 @@ func replayFinalState( } var liveTypingDraftUpdates: [PeerAndThreadId: [LiveTypingDraftUpdate]] = [:] + var donutgramActivityMessages: [StoreMessage] = [] for operation in finalState.state.operations { switch operation { case let .AddMessages(messages, location): if case .UpperHistoryBlock = location { + donutgramActivityMessages.append(contentsOf: messages) for message in messages { if case let .Id(id) = message.id { if message.flags.contains(.Incoming) { @@ -5950,6 +5952,8 @@ func replayFinalState( if !peerActivityTimestamps.isEmpty { updatePeerPresenceLastActivities(transaction: transaction, accountPeerId: accountPeerId, activities: peerActivityTimestamps) } + // After the replay: the authors and groups that came with these messages, and their presences, are stored by now. + donutgramRecordMessageActivity(transaction: transaction, accountPeerId: accountPeerId, messages: donutgramActivityMessages) if !stickerPackOperations.isEmpty { if stickerPackOperations.contains(where: { diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramUserPresence.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramUserPresence.swift index 628fdef8bc6..10208cf1ceb 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramUserPresence.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramUserPresence.swift @@ -83,24 +83,28 @@ public enum UserPresenceStatus: Comparable, PostboxCoding { public final class TelegramUserPresence: PeerPresence, Equatable { public let status: UserPresenceStatus public let lastActivity: Int32 + public let donutgramLastMessageTimestamp: Int32 - public init(status: UserPresenceStatus, lastActivity: Int32) { + public init(status: UserPresenceStatus, lastActivity: Int32, donutgramLastMessageTimestamp: Int32 = 0) { self.status = status self.lastActivity = lastActivity + self.donutgramLastMessageTimestamp = donutgramLastMessageTimestamp } public init(decoder: PostboxDecoder) { self.status = UserPresenceStatus(decoder: decoder) self.lastActivity = decoder.decodeInt32ForKey("la", orElse: 0) + self.donutgramLastMessageTimestamp = decoder.decodeInt32ForKey("dgma", orElse: 0) } public func encode(_ encoder: PostboxEncoder) { self.status.encode(encoder) encoder.encodeInt32(self.lastActivity, forKey: "la") + encoder.encodeInt32(self.donutgramLastMessageTimestamp, forKey: "dgma") } public static func ==(lhs: TelegramUserPresence, rhs: TelegramUserPresence) -> Bool { - return lhs.status == rhs.status && lhs.lastActivity == rhs.lastActivity + return lhs.status == rhs.status && lhs.lastActivity == rhs.lastActivity && lhs.donutgramLastMessageTimestamp == rhs.donutgramLastMessageTimestamp } public func isEqual(to: PeerPresence) -> Bool { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/Peer.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/Peer.swift index 9e33a8900ca..94745502b96 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/Peer.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/Peer.swift @@ -51,10 +51,12 @@ public enum EnginePeer: Equatable { public var status: Status public var lastActivity: Int32 + public var donutgramLastMessageTimestamp: Int32 - public init(status: Status, lastActivity: Int32) { + public init(status: Status, lastActivity: Int32, donutgramLastMessageTimestamp: Int32 = 0) { self.status = status self.lastActivity = lastActivity + self.donutgramLastMessageTimestamp = donutgramLastMessageTimestamp } } @@ -391,7 +393,7 @@ public extension EnginePeer.Presence { mappedStatus = .lastMonth(isHidden: isHidden) } - self.init(status: mappedStatus, lastActivity: presence.lastActivity) + self.init(status: mappedStatus, lastActivity: presence.lastActivity, donutgramLastMessageTimestamp: presence.donutgramLastMessageTimestamp) } else { preconditionFailure() } @@ -411,7 +413,7 @@ public extension EnginePeer.Presence { case let .lastMonth(isHidden): mappedStatus = .lastMonth(isHidden: isHidden) } - return TelegramUserPresence(status: mappedStatus, lastActivity: self.lastActivity) + return TelegramUserPresence(status: mappedStatus, lastActivity: self.lastActivity, donutgramLastMessageTimestamp: self.donutgramLastMessageTimestamp) } } diff --git a/submodules/TelegramCore/Sources/UpdatePeers.swift b/submodules/TelegramCore/Sources/UpdatePeers.swift index 0f487be56b3..d66f4c5212d 100644 --- a/submodules/TelegramCore/Sources/UpdatePeers.swift +++ b/submodules/TelegramCore/Sources/UpdatePeers.swift @@ -488,8 +488,8 @@ func updatePeerPresences(transaction: Transaction, accountPeerId: PeerId, peerPr parsedPresences.removeValue(forKey: accountPeerId) transaction.updatePeerPresencesInternal(presences: parsedPresences, merge: { previous, updated in - if let previous = previous as? TelegramUserPresence, let updated = updated as? TelegramUserPresence, previous.lastActivity != updated.lastActivity { - return TelegramUserPresence(status: updated.status, lastActivity: max(previous.lastActivity, updated.lastActivity)) + if let previous = previous as? TelegramUserPresence, let updated = updated as? TelegramUserPresence { + return TelegramUserPresence(status: updated.status, lastActivity: max(previous.lastActivity, updated.lastActivity), donutgramLastMessageTimestamp: max(previous.donutgramLastMessageTimestamp, updated.donutgramLastMessageTimestamp)) } return updated }) @@ -515,8 +515,8 @@ func updatePeerPresencesClean(transaction: Transaction, accountPeerId: PeerId, p } transaction.updatePeerPresencesInternal(presences: parsedPresences, merge: { previous, updated in - if let previous = previous as? TelegramUserPresence, let updated = updated as? TelegramUserPresence, previous.lastActivity != updated.lastActivity { - return TelegramUserPresence(status: updated.status, lastActivity: max(previous.lastActivity, updated.lastActivity)) + if let previous = previous as? TelegramUserPresence, let updated = updated as? TelegramUserPresence { + return TelegramUserPresence(status: updated.status, lastActivity: max(previous.lastActivity, updated.lastActivity), donutgramLastMessageTimestamp: max(previous.donutgramLastMessageTimestamp, updated.donutgramLastMessageTimestamp)) } return updated }) @@ -539,7 +539,7 @@ func updatePeerPresenceLastActivities(transaction: Transaction, accountPeerId: P default: break } - return TelegramUserPresence(status: updatedStatus, lastActivity: timestamp) + return TelegramUserPresence(status: updatedStatus, lastActivity: timestamp, donutgramLastMessageTimestamp: previous.donutgramLastMessageTimestamp) } return previous }) diff --git a/submodules/TelegramStringFormatting/Sources/PresenceStrings.swift b/submodules/TelegramStringFormatting/Sources/PresenceStrings.swift index 2b314e694ef..944573bc6db 100644 --- a/submodules/TelegramStringFormatting/Sources/PresenceStrings.swift +++ b/submodules/TelegramStringFormatting/Sources/PresenceStrings.swift @@ -571,7 +571,26 @@ public func stringForStoryActivityTimestamp(strings: PresentationStrings, dateTi } } -public func stringAndActivityForUserPresence(strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, presence: EnginePeer.Presence, relativeTo timestamp: Int32, expanded: Bool = false, includeExactTime: Bool = false) -> (String, Bool) { +public func stringForDonutgramApproximatePresence(strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, timestamp: Int32, relativeTo now: Int32) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) + let calendar = Calendar.current + let components = calendar.dateComponents([.hour, .minute, .second], from: date) + let time = String(format: "~%02d:%02d:%02d", components.hour ?? 0, components.minute ?? 0, components.second ?? 0) + if calendar.isDate(date, inSameDayAs: Date(timeIntervalSince1970: TimeInterval(now))) { + if strings.baseLanguageCode.lowercased().hasPrefix("ru") { + return "был(а) в \(time)" + } + return strings.LastSeen_AtDate(time).string + } + let dateComponents = calendar.dateComponents([.day, .month, .year], from: date) + let day = stringForTimestamp(day: Int32(dateComponents.day ?? 1), month: Int32(dateComponents.month ?? 1), year: Int32((dateComponents.year ?? 1900) - 1900), dateTimeFormat: dateTimeFormat) + if strings.baseLanguageCode.lowercased().hasPrefix("ru") { + return "был(а) \(day) в \(time)" + } + return strings.LastSeen_AtDate("\(day), \(time)").string +} + +public func stringAndActivityForUserPresence(strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, presence: EnginePeer.Presence, relativeTo timestamp: Int32, expanded: Bool = false, includeExactTime: Bool = false, approximateLastSeen: Bool = false) -> (String, Bool) { switch presence.status { case let .present(statusTimestamp): if statusTimestamp >= timestamp { @@ -627,6 +646,8 @@ public func stringAndActivityForUserPresence(strings: PresentationStrings, dateT let activeUntil = presence.lastActivity + 30 if activeUntil >= timestamp { return (strings.Presence_online, true) + } else if approximateLastSeen, let lastMessage = donutgramApproximateLastSeenTimestamp(presence, relativeTo: timestamp) { + return (stringForDonutgramApproximatePresence(strings: strings, dateTimeFormat: dateTimeFormat, timestamp: lastMessage, relativeTo: timestamp), false) } else { return (strings.LastSeen_Lately, false) } diff --git a/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleComponent.swift b/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleComponent.swift index f788246e4a8..ca5429cd936 100644 --- a/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleComponent.swift +++ b/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleComponent.swift @@ -985,7 +985,7 @@ public final class ChatTitleComponent: Component { } else { userPresence = TelegramUserPresence(status: .none, lastActivity: 0) } - let (string, activity) = stringAndActivityForUserPresence(strings: component.strings, dateTimeFormat: component.dateTimeFormat, presence: EnginePeer.Presence(userPresence), relativeTo: Int32(timestamp), expanded: !DGSimpleSettings.shared.relativeOnlineTime) + let (string, activity) = stringAndActivityForUserPresence(strings: component.strings, dateTimeFormat: component.dateTimeFormat, presence: EnginePeer.Presence(userPresence), relativeTo: Int32(timestamp), expanded: !DGSimpleSettings.shared.relativeOnlineTime, approximateLastSeen: peerView.approximateLastSeen) let attributedString = NSAttributedString(string: string, font: subtitleFont, textColor: activity ? component.theme.rootController.navigationBar.accentTextColor : component.theme.chat.inputPanel.inputControlColor) state = .info(attributedString, activity ? .online : .lastSeenTime) } else { diff --git a/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift b/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift index e03a1053c4c..87b56ad77c9 100644 --- a/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift +++ b/submodules/TelegramUI/Components/ChatTitleView/Sources/ChatTitleView.swift @@ -36,6 +36,8 @@ public enum ChatTitleContent: Equatable { public var notificationSettings: TelegramPeerNotificationSettings? public var peerPresences: [EnginePeer.Id: EngineRawPeerPresence] public var cachedData: EngineCachedPeerData? + // «Примерный онлайн» when the data was made: the setting's toggle gives unequal data, so an open chat redraws its status. + public var approximateLastSeen: Bool public init(peerId: EnginePeer.Id, peer: EngineRawPeer?, isContact: Bool, isSavedMessages: Bool, notificationSettings: TelegramPeerNotificationSettings?, peerPresences: [EnginePeer.Id: EngineRawPeerPresence], cachedData: EngineCachedPeerData?) { self.peerId = peerId @@ -45,6 +47,7 @@ public enum ChatTitleContent: Equatable { self.notificationSettings = notificationSettings self.peerPresences = peerPresences self.cachedData = cachedData + self.approximateLastSeen = DGSimpleSettings.shared.approximateLastSeen } public init(peerView: EngineRawPeerView) { @@ -84,6 +87,9 @@ public enum ChatTitleContent: Equatable { if lhs.cachedData !== rhs.cachedData { return false } + if lhs.approximateLastSeen != rhs.approximateLastSeen { + return false + } return true } } @@ -661,7 +667,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView { } else { userPresence = TelegramUserPresence(status: .none, lastActivity: 0) } - let (string, activity) = stringAndActivityForUserPresence(strings: self.strings, dateTimeFormat: self.dateTimeFormat, presence: EnginePeer.Presence(userPresence), relativeTo: Int32(timestamp), expanded: !DGSimpleSettings.shared.relativeOnlineTime) + let (string, activity) = stringAndActivityForUserPresence(strings: self.strings, dateTimeFormat: self.dateTimeFormat, presence: EnginePeer.Presence(userPresence), relativeTo: Int32(timestamp), expanded: !DGSimpleSettings.shared.relativeOnlineTime, approximateLastSeen: peerView.approximateLastSeen) let attributedString = NSAttributedString(string: string, font: subtitleFont, textColor: activity ? titleTheme.rootController.navigationBar.accentTextColor : titleTheme.rootController.navigationBar.secondaryTextColor) state = .info(attributedString, activity ? .online : .lastSeenTime) } else { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift index c8eb086973a..d328429924b 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift @@ -1239,7 +1239,7 @@ func peerInfoScreenData( enum StatusInputData: Equatable { case none - case presence(TelegramUserPresence) + case presence(TelegramUserPresence, approximateLastSeen: Bool) case bot(subscriberCount: Int32?) case support } @@ -1257,7 +1257,7 @@ func peerInfoScreenData( } if let presence = manager.currentValue { let timestamp = context.account.network.globalTime - let (text, isActivity) = stringAndActivityForUserPresence(strings: strings, dateTimeFormat: dateTimeFormat, presence: EnginePeer.Presence(presence), relativeTo: Int32(timestamp), expanded: !DGSimpleSettings.shared.relativeOnlineTime, includeExactTime: isOwnHiddenPresence) + let (text, isActivity) = stringAndActivityForUserPresence(strings: strings, dateTimeFormat: dateTimeFormat, presence: EnginePeer.Presence(presence), relativeTo: Int32(timestamp), expanded: !DGSimpleSettings.shared.relativeOnlineTime, includeExactTime: isOwnHiddenPresence, approximateLastSeen: DGSimpleSettings.shared.approximateLastSeen) var isHiddenStatus = false switch presence.status { case .recently(let isHidden), .lastWeek(let isHidden), .lastMonth(let isHidden): @@ -1288,7 +1288,7 @@ func peerInfoScreenData( } } } - let disposable = (combineLatest(context.account.viewTracker.peerView(userPeerId, updateData: false), ownPresenceUpdates) + let disposable = (combineLatest(donutgramActivityPeerView(account: context.account, peerId: userPeerId), ownPresenceUpdates) |> deliverOnMainQueue |> map { view, _ -> StatusInputData in guard let user = view.peers[userPeerId] as? TelegramUser else { @@ -1301,14 +1301,14 @@ func peerInfoScreenData( let now = Int32(context.account.network.globalTime) let serverOnlineUntil = DGSimpleSettings.shared.serverOnlineUntil(accountId: context.account.id.int64) if let serverOnlineUntil, serverOnlineUntil > now { - return .presence(TelegramUserPresence(status: .present(until: serverOnlineUntil), lastActivity: 0)) + return .presence(TelegramUserPresence(status: .present(until: serverOnlineUntil), lastActivity: 0), approximateLastSeen: false) } // An online the server reported and nothing ended lasted until it expired. let lastSeen = max(DGSimpleSettings.shared.lastOnlineTimestamp(accountId: context.account.id.int64) ?? 0, serverOnlineUntil ?? 0) if lastSeen > 0 { - return .presence(TelegramUserPresence(status: .present(until: min(lastSeen, now - 1)), lastActivity: 0)) + return .presence(TelegramUserPresence(status: .present(until: min(lastSeen, now - 1)), lastActivity: 0), approximateLastSeen: false) } - return .presence(TelegramUserPresence(status: .recently(isHidden: false), lastActivity: 0)) + return .presence(TelegramUserPresence(status: .recently(isHidden: false), lastActivity: 0), approximateLastSeen: false) } if user.isDeleted { return .none @@ -1322,7 +1322,7 @@ func peerInfoScreenData( guard let presence = view.peerPresences[userPeerId] as? TelegramUserPresence else { return .none } - return .presence(presence) + return .presence(presence, approximateLastSeen: DGSimpleSettings.shared.approximateLastSeen) } |> distinctUntilChanged).start(next: { inputData in switch inputData { @@ -1336,7 +1336,7 @@ func peerInfoScreenData( subscriber.putNext(PeerInfoStatusData(text: strings.Bot_GenericSupportStatus, isActivity: false, key: nil)) default: var presence: TelegramUserPresence? - if case let .presence(value) = inputData { + if case let .presence(value, _) = inputData { presence = value } let _ = statusManager.with { manager -> Void in diff --git a/submodules/TelegramUI/Sources/ChatControllerContentData.swift b/submodules/TelegramUI/Sources/ChatControllerContentData.swift index b303218869b..7b374b40d2e 100644 --- a/submodules/TelegramUI/Sources/ChatControllerContentData.swift +++ b/submodules/TelegramUI/Sources/ChatControllerContentData.swift @@ -294,7 +294,7 @@ extension ChatControllerImpl { } if case let .peer(peerView) = self.chatLocationInfoData, let peerId = peerId { - peerView.set(context.account.viewTracker.peerView(peerId)) + peerView.set(donutgramActivityPeerView(account: context.account, peerId: peerId)) var onlineMemberCount: Signal<(total: Int32?, recent: Int32?), NoError> = .single((nil, nil)) var hasScheduledMessages: Signal = .single(false) var hasTopics: Signal = .single(false)