Postbox -> TelegramEngine wave 9: StorageUsageScreen preferences-view rewrite

Closes the first of the two future-wave candidates left open by wave 8 by
rewriting both AccountSpecificCacheStorageSettings preferences-view
observation sites in StorageUsageScreen.swift using engine APIs, and
drops import Postbox from that file.

Site 1 — cacheSettingsExceptionCount (former 1047-1087):
  postbox.combinedView(keys: [.preferences(keys: Set([...]))]) +
  PreferencesView                ->
    context.engine.data.subscribe(TelegramEngine.EngineData.Item
                                    .Configuration.ApplicationSpecific-
                                    Preference(key: ...))
                                   + preferencesEntry?.get(...)

Site 2 — peerExceptions (former 3131-3196):
  - Same preferences-observation replacement as Site 1.
  - postbox.transaction { transaction.getPeer / getPeerCachedData as?
    CachedGroupData / CachedChannelData; FoundPeer(peer:subscribers:) }
    -> context.engine.data.get(EngineDataMap(...TelegramEngine.Engine-
       Data.Item.Peer.Peer.init(id:))) + pattern match on EnginePeer
       .user / .secretChat / .legacyGroup / .channel
  - Signal element: [(peer: FoundPeer, value: Int32)] -> [(peer: Engine-
    Peer, value: Int32)]. FoundPeer wrapper and its `subscribers` field
    dropped — computed but never read downstream (consumers only read
    .isEmpty, .count, and .prefix(3).map { EnginePeer($0.peer.peer) }).

Consumer update:
  peerExceptions.prefix(3).map { EnginePeer($0.peer.peer) } ->
      .prefix(3).map { $0.peer }

Typealias fixup (caught by first-pass build failure):
  var mergedMedia: [MessageId: Int64] -> [EngineMessage.Id: Int64]
  (MessageId is raw Postbox; must use the EngineMessage.Id typealias
   when import Postbox is removed.)

Reusable pattern documented in CLAUDE.md: TelegramEngine.EngineData.Item
.Configuration.ApplicationSpecificPreference(key: ValueBoxKey) is the
general-purpose engine replacement for the postbox.combinedView(keys:
[.preferences(keys: Set([key]))]) + PreferencesView idiom. Works from
any module importing TelegramCore (without import Postbox) because
passing PreferencesKeys.<name> keeps ValueBoxKey as an inferred-only
type that never gets named in the consumer.

Net: 1 file changed, +30 / -54.

StorageUsageScreen.swift is now Postbox-free. The wave 8 outcome's other
future candidate (StorageFileListPanelComponent.swift's Icon.media(Media,
...) enum case) remains — trivial future wave will land the whole-module
drop.

Plan: docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-9.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Isaac 2026-04-20 01:11:50 +02:00
parent 635d392484
commit 1a432362a3
3 changed files with 218 additions and 55 deletions

View file

@ -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 10471087):
- `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 31313196):
- 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.<name>` 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:

View file

@ -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 10471087)**`cacheSettingsExceptionCount` signal. Preserved its downstream `EngineDataMap` + `EnginePeer` per-category counting logic unchanged; only the preferences observation replaced.
2. **Site 2 (former lines 31313196)**`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 10471058).
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 31313196).
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, ...)`).

View file

@ -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<AccountSpecificCacheStorageSettings, NoError> = 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<AccountSpecificCacheStorageSettings, 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
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: {
})