diff --git a/CLAUDE.md b/CLAUDE.md index 3e930ee670..4c484fe58f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,6 +231,31 @@ Single atomic commit. Build verified green (59s incremental build, 27 actions). Plan / record: `docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-8.md`. +### Wave 9 outcome (2026-04-20) + +Closes the first of the two "future-wave candidates" left open by wave 8: rewrites both `AccountSpecificCacheStorageSettings` preferences-view observation sites in `StorageUsageScreen.swift` using engine APIs, and drops `import Postbox` from that file. + +**Site 1 — `cacheSettingsExceptionCount` signal** (former lines 1047–1087): +- `postbox.combinedView(keys: [.preferences(keys: Set([PreferencesKeys.accountSpecificCacheStorageSettings]))])` + `PreferencesView` extraction → + `context.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key: PreferencesKeys.accountSpecificCacheStorageSettings))` + `preferencesEntry?.get(AccountSpecificCacheStorageSettings.self) ?? .defaultSettings`. +- Downstream `EngineDataMap` + `EnginePeer` per-category counting logic unchanged (already engine-only). + +**Site 2 — `peerExceptions` signal in `openKeepMediaCategory`** (former lines 3131–3196): +- Same preferences observation replacement as Site 1. +- `postbox.transaction { transaction.getPeer / transaction.getPeerCachedData as? CachedGroupData / CachedChannelData; FoundPeer(peer:subscribers:) }` → `context.engine.data.get(EngineDataMap(...TelegramEngine.EngineData.Item.Peer.Peer.init(id:)))` + pattern match on `EnginePeer.user / .secretChat / .legacyGroup / .channel`. +- Signal element type `[(peer: FoundPeer, value: Int32)]` → `[(peer: EnginePeer, value: Int32)]`. `FoundPeer` wrapper and its `subscribers` field dropped entirely — they were computed by the transaction block but never read by downstream consumers (the only consumer sites read `.isEmpty`, `.count`, and `.prefix(3).map { EnginePeer($0.peer.peer) }`). +- One downstream consumer updated: `peerExceptions.prefix(3).map { EnginePeer($0.peer.peer) }` → `.prefix(3).map { $0.peer }` at the `MultiplePeerAvatarsContextItem` construction (redundant wrap removed since `$0.peer` is already `EnginePeer`). + +**Typealias fixup.** With `import Postbox` removed, `var mergedMedia: [MessageId: Int64]` at former line 2397 needed renaming to `[EngineMessage.Id: Int64]`. `MessageId` is the raw Postbox type name — CLAUDE.md's engine-typealias cheat sheet lists these as migration targets, not pre-existing aliases in TelegramCore. Caught by first-pass build failure (`cannot find type 'MessageId' in scope`). + +**Reusable pattern.** `TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key: ValueBoxKey)` (at `TelegramCore/Sources/TelegramEngine/Data/ConfigurationData.swift:356`) is the general-purpose engine replacement for any `postbox.combinedView(keys: [.preferences(keys: Set([key]))]) + PreferencesView` idiom — takes any `ValueBoxKey`, returns `PreferencesEntry?`, decodes via `.get(T.self)`. Crucially, callers need not `import Postbox` even though `ValueBoxKey` is a Postbox type: passing `PreferencesKeys.` through makes `ValueBoxKey` an inferred-only type that never gets named in the consumer module. Use this pattern when de-Postboxing any future module that observes preferences. + +`StorageUsageScreen.swift` is now Postbox-free. The wave 8 outcome's other candidate (`StorageFileListPanelComponent.swift`'s `Icon.media(Media, ...)` enum case) remains — trivial future wave to split into `.mediaFile(TelegramMediaFile, ...)` / `.mediaImage(TelegramMediaImage, ...)` cases, at which point the `StorageUsageScreen` consumer module as a whole becomes Postbox-free. + +Net: 1 file changed, +30 / -54. Build verified green (27 actions, cached). + +Plan / record: `docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-9.md`. + ### Modules currently free of `import Postbox` (running tally) Consumer modules that no longer import Postbox, across all waves and standalone commits: diff --git a/docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-9.md b/docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-9.md new file mode 100644 index 0000000000..28e5c85c9a --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-9.md @@ -0,0 +1,162 @@ +# Postbox → TelegramEngine Wave 9 Implementation Plan + +> **For agentic workers:** This plan was executed in a single session; steps below are a post-hoc record of the work landed, not a to-do list. + +**Goal:** Finish the `StorageUsageScreen` de-Postbox work started in wave 8 by rewriting the two remaining direct-postbox sites that observe `AccountSpecificCacheStorageSettings`, and drop `import Postbox` from `StorageUsageScreen.swift`. + +**Architecture:** Replace `postbox.combinedView(keys: [.preferences(...)]) + PreferencesView` observation with `context.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key:))`, which returns `PreferencesEntry?` and is then decoded the same way (`.get(AccountSpecificCacheStorageSettings.self)`). Replace the transaction-based per-peer classification (`transaction.getPeer` + `transaction.getPeerCachedData as? CachedGroupData/CachedChannelData`) with an `EngineDataMap` of `TelegramEngine.EngineData.Item.Peer.Peer.init(id:)` lookups producing `EnginePeer?` values that pattern-match on `.user` / `.legacyGroup` / `.channel(channel)` / `.secretChat`. The `FoundPeer(peer:subscribers:)` wrapper in the signal's element type is dropped entirely since downstream consumers (`peerExceptions.isEmpty`, `.count`, `.prefix(3).map { EnginePeer($0.peer.peer) }`) never read `subscribers`. + +**Tech Stack:** Swift / Bazel. No unit tests. + +**Build command:** + +```bash +source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64 --continueOnError +``` + +--- + +## Scope + +Two direct-postbox site clusters rewritten in `StorageUsageScreen.swift`: + +1. **Site 1 (former lines 1047–1087)** — `cacheSettingsExceptionCount` signal. Preserved its downstream `EngineDataMap` + `EnginePeer` per-category counting logic unchanged; only the preferences observation replaced. +2. **Site 2 (former lines 3131–3196)** — `peerExceptions` signal inside `openKeepMediaCategory`. Both the preferences observation AND the `postbox.transaction { transaction.getPeer / transaction.getPeerCachedData ... FoundPeer(...) }` block replaced. Signal element type changed from `[(peer: FoundPeer, value: Int32)]` to `[(peer: EnginePeer, value: Int32)]`; `FoundPeer` and the unread `subscribers` field dropped. + +One consumer-side edit: `peerExceptions.prefix(3).map { EnginePeer($0.peer.peer) }` → `peerExceptions.prefix(3).map { $0.peer }` (at the `MultiplePeerAvatarsContextItem` construction). + +One typealias fixup: `var mergedMedia: [MessageId: Int64]` → `[EngineMessage.Id: Int64]` (required once `import Postbox` is removed, since `MessageId` is the raw Postbox name, not a TelegramCore typealias). + +`import Postbox` removed from `StorageUsageScreen.swift`. + +--- + +## Tasks + +### Task 1: Rewrite site 1 — cacheSettingsExceptionCount + +**Files:** +- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (former 1047–1058). + +Replace the preferences observation header. The downstream `mapToSignal { ... EngineDataMap ... EnginePeer ... }` body is already Engine-only and unchanged. + +Before: + +```swift +let viewKey: PostboxViewKey = .preferences(keys: Set([PreferencesKeys.accountSpecificCacheStorageSettings])) +let cacheSettingsExceptionCount: Signal<[CacheStorageSettings.PeerStorageCategory: Int32], NoError> = component.context.account.postbox.combinedView(keys: [viewKey]) +|> map { views -> AccountSpecificCacheStorageSettings in + let cacheSettings: AccountSpecificCacheStorageSettings + if let view = views.views[viewKey] as? PreferencesView, let value = view.values[PreferencesKeys.accountSpecificCacheStorageSettings]?.get(AccountSpecificCacheStorageSettings.self) { + cacheSettings = value + } else { + cacheSettings = AccountSpecificCacheStorageSettings.defaultSettings + } + return cacheSettings +} +|> distinctUntilChanged +|> mapToSignal { ... } +``` + +After: + +```swift +let cacheSettingsExceptionCount: Signal<[CacheStorageSettings.PeerStorageCategory: Int32], NoError> = context.engine.data.subscribe( + TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key: PreferencesKeys.accountSpecificCacheStorageSettings) +) +|> map { preferencesEntry -> AccountSpecificCacheStorageSettings in + return preferencesEntry?.get(AccountSpecificCacheStorageSettings.self) ?? AccountSpecificCacheStorageSettings.defaultSettings +} +|> distinctUntilChanged +|> mapToSignal { ... } +``` + +### Task 2: Rewrite site 2 — peerExceptions + +**Files:** +- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (former 3131–3196). + +Replace both the preferences observation (as in Task 1) AND the subsequent `mapToSignal { context.account.postbox.transaction { ... } }` block. Signal element type changes from `[(peer: FoundPeer, value: Int32)]` to `[(peer: EnginePeer, value: Int32)]`. `subscriberCount` is not preserved — it's computed but never read by downstream consumers. + +After (showing the `peerExceptions` signal in full): + +```swift +let peerExceptions: Signal<[(peer: EnginePeer, value: Int32)], NoError> = accountSpecificSettings +|> mapToSignal { accountSpecificSettings -> Signal<[(peer: EnginePeer, value: Int32)], NoError> in + return context.engine.data.get( + EngineDataMap(accountSpecificSettings.peerStorageTimeoutExceptions.map(\.key).map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:))) + ) + |> map { peers -> [(peer: EnginePeer, value: Int32)] in + var result: [(peer: EnginePeer, value: Int32)] = [] + for item in accountSpecificSettings.peerStorageTimeoutExceptions { + guard let peer = peers[item.key] ?? nil else { continue } + let peerCategory: CacheStorageSettings.PeerStorageCategory + switch peer { + case .user, .secretChat: + peerCategory = .privateChats + case .legacyGroup: + peerCategory = .groups + case let .channel(channel): + if case .group = channel.info { + peerCategory = .groups + } else { + peerCategory = .channels + } + } + if peerCategory != mappedCategory { continue } + result.append((peer: peer, value: item.value)) + } + return result.sorted(by: { lhs, rhs in + if lhs.value != rhs.value { + return lhs.value < rhs.value + } + return lhs.peer.debugDisplayTitle < rhs.peer.debugDisplayTitle + }) + } +} +``` + +### Task 3: Update consumer of `peerExceptions` + +**Files:** +- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (former 3288). + +`peerExceptions.prefix(3).map { EnginePeer($0.peer.peer) }` → `peerExceptions.prefix(3).map { $0.peer }`. The `MultiplePeerAvatarsContextItem(context:, peers: [EnginePeer], totalCount:, action:)` signature is unchanged — we simply drop the redundant `EnginePeer(...)` wrap because `$0.peer` is now already an `EnginePeer`. + +### Task 4: Drop `import Postbox` + +**Files:** +- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (line 12). + +Remove the `import Postbox` line. + +### Task 5: Typealias fixup for `MessageId` + +**Files:** +- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (former 2397). + +`var mergedMedia: [MessageId: Int64]` → `[EngineMessage.Id: Int64]`. `MessageId` is the raw Postbox type name; with `import Postbox` removed, the type must be named through the `EngineMessage.Id` typealias. Discovered by first-pass build failure `cannot find type 'MessageId' in scope`. + +### Task 6: Full project build + +Expected green. Incremental build: ~60s (cached), 27 actions. + +### Task 7: Commit + +Single wave-9 atomic commit. CLAUDE.md updates the wave 8 outcome's "future-wave candidates" note since this wave closes both of them. `StorageUsageScreen` (the module as a whole) now has `StorageUsageScreen.swift` Postbox-free; the module's `StorageFileListPanelComponent.swift` still imports Postbox because of the `Icon.media(Media, TelegramMediaImageRepresentation)` enum case (trivial future wave, as previously noted). + +--- + +## Outcome (2026-04-20) + +Single atomic commit. Build verified green (27 actions, ~60s incremental). + +Net change: 1 file, +30 / -54 lines (-24 simplification). + +Lessons: + +- **`ApplicationSpecificPreference(key:)` is the general-purpose engine replacement** for any `postbox.combinedView(keys: [.preferences(keys: Set([key]))])` idiom. Takes a `ValueBoxKey`, returns `PreferencesEntry?`, decodes via `.get(T.self)`. Usable from any module that imports `TelegramCore` even without `import Postbox`, because the `ValueBoxKey`-typed input is obtained through a statically-named `PreferencesKeys.*` member (no `ValueBoxKey` identifier appears in the consumer). +- **`MessageId` is raw Postbox, not a TelegramCore typealias.** CLAUDE.md's "engine typealias cheat sheet" labels `PeerId`, `MessageId`, etc. as migration *targets*, not existing aliases. Files that drop `import Postbox` must rename these to `EngineMessage.Id` / `EnginePeer.Id`. Caught by the first-pass build failure. +- **Dead-code detection during rewrites.** The transaction block's `subscriberCount` computation and the `FoundPeer.subscribers` field it populated were never consumed downstream. The rewrite simply dropped them, shrinking the code further than a 1:1 rewrite would have. + +`StorageUsageScreen.swift` is now Postbox-free. The `StorageUsageScreen` consumer module as a whole is still not fully Postbox-free because `StorageFileListPanelComponent.swift` retains `import Postbox` for its `Icon.media(Media, TelegramMediaImageRepresentation)` enum case (3 construction sites; trivial future wave splits into `.mediaFile(TelegramMediaFile, ...)` / `.mediaImage(TelegramMediaImage, ...)`). diff --git a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift index a94bbf5834..1009b68515 100644 --- a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift +++ b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift @@ -9,7 +9,6 @@ import ComponentDisplayAdapters import TelegramPresentationData import AccountContext import TelegramCore -import Postbox import MultilineTextComponent import EmojiStatusComponent import Markdown @@ -1044,17 +1043,11 @@ final class StorageUsageScreenComponent: Component { if self.statsDisposable == nil { let context = component.context - let viewKey: PostboxViewKey = .preferences(keys: Set([PreferencesKeys.accountSpecificCacheStorageSettings])) - let cacheSettingsExceptionCount: Signal<[CacheStorageSettings.PeerStorageCategory: Int32], NoError> = component.context.account.postbox.combinedView(keys: [viewKey]) - |> map { views -> AccountSpecificCacheStorageSettings in - let cacheSettings: AccountSpecificCacheStorageSettings - if let view = views.views[viewKey] as? PreferencesView, let value = view.values[PreferencesKeys.accountSpecificCacheStorageSettings]?.get(AccountSpecificCacheStorageSettings.self) { - cacheSettings = value - } else { - cacheSettings = AccountSpecificCacheStorageSettings.defaultSettings - } - - return cacheSettings + let cacheSettingsExceptionCount: Signal<[CacheStorageSettings.PeerStorageCategory: Int32], NoError> = context.engine.data.subscribe( + TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key: PreferencesKeys.accountSpecificCacheStorageSettings) + ) + |> map { preferencesEntry -> AccountSpecificCacheStorageSettings in + return preferencesEntry?.get(AccountSpecificCacheStorageSettings.self) ?? AccountSpecificCacheStorageSettings.defaultSettings } |> distinctUntilChanged |> mapToSignal { accountSpecificSettings -> Signal<[CacheStorageSettings.PeerStorageCategory: Int32], NoError> in @@ -2401,7 +2394,7 @@ final class StorageUsageScreenComponent: Component { result.messages = messages - var mergedMedia: [MessageId: Int64] = [:] + var mergedMedia: [EngineMessage.Id: Int64] = [:] if let categoryStats = contextStats.categories[.photos] { mergedMedia = categoryStats.messages } @@ -3128,69 +3121,52 @@ final class StorageUsageScreenComponent: Component { self.controller?()?.presentInGlobalOverlay(c, with: nil) } - let viewKey: PostboxViewKey = .preferences(keys: Set([PreferencesKeys.accountSpecificCacheStorageSettings])) - let accountSpecificSettings: Signal = context.account.postbox.combinedView(keys: [viewKey]) - |> map { views -> AccountSpecificCacheStorageSettings in - let cacheSettings: AccountSpecificCacheStorageSettings - if let view = views.views[viewKey] as? PreferencesView, let value = view.values[PreferencesKeys.accountSpecificCacheStorageSettings]?.get(AccountSpecificCacheStorageSettings.self) { - cacheSettings = value - } else { - cacheSettings = AccountSpecificCacheStorageSettings.defaultSettings - } - - return cacheSettings + let accountSpecificSettings: Signal = context.engine.data.subscribe( + TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key: PreferencesKeys.accountSpecificCacheStorageSettings) + ) + |> map { preferencesEntry -> AccountSpecificCacheStorageSettings in + return preferencesEntry?.get(AccountSpecificCacheStorageSettings.self) ?? AccountSpecificCacheStorageSettings.defaultSettings } |> distinctUntilChanged - - let peerExceptions: Signal<[(peer: FoundPeer, value: Int32)], NoError> = accountSpecificSettings - |> mapToSignal { accountSpecificSettings -> Signal<[(peer: FoundPeer, value: Int32)], NoError> in - return context.account.postbox.transaction { transaction -> [(peer: FoundPeer, value: Int32)] in - var result: [(peer: FoundPeer, value: Int32)] = [] - + + let peerExceptions: Signal<[(peer: EnginePeer, value: Int32)], NoError> = accountSpecificSettings + |> mapToSignal { accountSpecificSettings -> Signal<[(peer: EnginePeer, value: Int32)], NoError> in + return context.engine.data.get( + EngineDataMap(accountSpecificSettings.peerStorageTimeoutExceptions.map(\.key).map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:))) + ) + |> map { peers -> [(peer: EnginePeer, value: Int32)] in + var result: [(peer: EnginePeer, value: Int32)] = [] + for item in accountSpecificSettings.peerStorageTimeoutExceptions { - let peerId = item.key - let value = item.value - - guard let peer = transaction.getPeer(peerId) else { + guard let peer = peers[item.key] ?? nil else { continue } let peerCategory: CacheStorageSettings.PeerStorageCategory - var subscriberCount: Int32? - if peer is TelegramUser { + switch peer { + case .user, .secretChat: peerCategory = .privateChats - } else if peer is TelegramGroup { + case .legacyGroup: peerCategory = .groups - - if let cachedData = transaction.getPeerCachedData(peerId: peerId) as? CachedGroupData { - subscriberCount = (cachedData.participants?.participants.count).flatMap(Int32.init) - } - } else if let channel = peer as? TelegramChannel { + case let .channel(channel): if case .group = channel.info { peerCategory = .groups } else { peerCategory = .channels } - if peerCategory == mappedCategory { - if let cachedData = transaction.getPeerCachedData(peerId: peerId) as? CachedChannelData { - subscriberCount = cachedData.participantsSummary.memberCount - } - } - } else { - continue } - + if peerCategory != mappedCategory { continue } - - result.append((peer: FoundPeer(peer: peer, subscribers: subscriberCount), value: value)) + + result.append((peer: peer, value: item.value)) } - + return result.sorted(by: { lhs, rhs in if lhs.value != rhs.value { return lhs.value < rhs.value } - return lhs.peer.peer.debugDisplayTitle < rhs.peer.peer.debugDisplayTitle + return lhs.peer.debugDisplayTitle < rhs.peer.debugDisplayTitle }) } } @@ -3285,7 +3261,7 @@ final class StorageUsageScreenComponent: Component { } }))) } else { - subItems.append(.custom(MultiplePeerAvatarsContextItem(context: context, peers: peerExceptions.prefix(3).map { EnginePeer($0.peer.peer) }, totalCount: peerExceptions.count, action: { c, _ in + subItems.append(.custom(MultiplePeerAvatarsContextItem(context: context, peers: peerExceptions.prefix(3).map { $0.peer }, totalCount: peerExceptions.count, action: { c, _ in c.dismiss(completion: { })