WinterGram/CLAUDE.md
Isaac 34c2c8c8a4 Postbox -> TelegramEngine wave 10: StorageFileListPanelComponent drop
Closes the last future-wave candidate from wave 8 by eliminating the
Icon.media(Media, ...) enum case in StorageFileListPanelComponent.swift
and dropping import Postbox. StorageUsageScreen (the module as a whole)
is now fully Postbox-free.

Icon enum split:
  case media(Media, TelegramMediaImageRepresentation)  ->
    case mediaFile(TelegramMediaFile, TelegramMediaImageRepresentation)
    case mediaImage(TelegramMediaImage, TelegramMediaImageRepresentation)

Equatable rewritten as switch-over-tuple with id-based equality per
concrete type (lFile.fileId == rFile.fileId / lImage.imageId ==
rImage.imageId), same semantics as the old media.id comparison.

Binding site: `if case let .media(media, representation)` +
`as? TelegramMediaFile` / `as? TelegramMediaImage` downcasts ->
compound case-binding `case let .mediaFile(_, representation), let
.mediaImage(_, representation):` to lift the shared representation
variable, plus an inner switch for the setSignal branch. The compiler-
enforced exhaustiveness of the split improves call-site safety.

Construction sites (2): `.media(file, representation)` -> `.mediaFile(
file, representation)`, `.media(image, representation)` -> `.mediaImage(
image, representation)`.

Placeholder fixup:
  messageId: EngineMessage.Id(peerId: PeerId(namespace: PeerId.Namespace
    ._internalFromInt32Value(0), id: PeerId.Id._internalFromInt64Value(0)
    ), namespace: 0, id: 0)
  ->
  messageId: EngineMessage.Id(peerId: component.context.account.peerId,
    namespace: 0, id: 0)

Inside a measureItem layout-measurement instance. Caught by second-pass
build failure `cannot find 'PeerId' in scope`. PeerId / PeerId.Namespace
/ PeerId.Id are raw Postbox types (not TelegramCore typealiases —
consistent with wave 9's MessageId -> EngineMessage.Id fixup). Using
context.account.peerId is semantically equivalent for the measurement
use case (messageId only feeds image-fetch userLocation and Equatable
comparison, neither exercised for this standalone instance).

Net: 1 file changed, +22 / -29 lines.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:46:18 +02:00

34 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to AI assistants when working with code in this repository.

Build

The app is built using Bazel.

Code Style Guidelines

  • Naming: PascalCase for types, camelCase for variables/methods
  • Imports: Group and sort imports at the top of files
  • Error Handling: Properly handle errors with appropriate redaction of sensitive data
  • Formatting: Use standard Swift/Objective-C formatting and spacing
  • Types: Prefer strong typing and explicit type annotations where needed
  • Documentation: Document public APIs with comments

Project Structure

  • Core launch and application extensions code is in Telegram/ directory
  • Most code is organized into libraries in submodules/
  • External code is located in third-party/
  • No tests are used at the moment

Postbox → TelegramEngine refactor (in progress)

A gradual migration is underway to eliminate direct import Postbox from consumer submodules in favor of TelegramEngine. Waves landed so far:

  • Wave 1 (2026-04-16): first leaf-module cohort — 4 done, 6 abandoned.
    • Spec: docs/superpowers/specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md
    • Plan: docs/superpowers/plans/2026-04-16-postbox-to-telegramengine-refactor-wave-1.md
  • Wave 2 (2026-04-17): MediaResourceEngineMediaResource facade migration — 5 TelegramEngine facades migrated, 1 utility module fully de-Postboxed, 1 consumer signal type swapped, 1 task abandoned.
    • Plan: docs/superpowers/plans/2026-04-17-mediaresource-to-enginemediaresource-wave-2.md

Rules that apply to every wave

  1. TelegramCore does not @_exported import Postbox. Once a consumer drops import Postbox, every remaining Postbox-type reference must use an engine-typealiased equivalent.
  2. Never typealias Postbox, Account, or MediaBox. These umbrella types rename without encapsulating. Narrow utility typealiases (MemoryBuffer, PostboxDecoder, PostboxEncoder, AdaptedPostboxDecoder, MediaResource, …) remain allowed and expected.
  3. No new engine wrapper structs unless the wave's spec explicitly allows — only typealiases and thin forwarding methods.
  4. Discovery first: before adding any new engine wrapper/typealias, grep submodules/TelegramCore/Sources/TelegramEngine/ for existing equivalents. Record the search result in the commit message.
  5. Abandonment protocol: if a module can only be refactored by violating rule 2 or by editing a module outside the current wave's list, mark the task Abandoned with a recorded reason. Do NOT substitute a new module mid-wave.
  6. Full project build per module. No unit tests exist in this project.
  7. TelegramCore never imports UIKit/Display. TelegramCore is shared with the Telegram-Mac codebase; its Bazel deps and source files must not reference UIKit, Display, or any Apple-UI framework. UIKit-needing helpers (image scaling, rendering, etc.) stay in consumer-side submodules.

Engine typealias cheat sheet (existing aliases)

PeerId              → EnginePeer.Id
MessageId           → EngineMessage.Id
MessageIndex        → EngineMessage.Index
MessageTags         → EngineMessage.Tags
MessageAttribute    → EngineMessage.Attribute
MessageFlags        → EngineMessage.Flags
MessageForwardInfo  → EngineMessage.ForwardInfo
MediaId             → EngineMedia.Id
PreferencesEntry    → EnginePreferencesEntry
TempBox             → EngineTempBox
PinnedItemId        → EngineChatList.PinnedItem.Id
MemoryBuffer        → EngineMemoryBuffer           (added 2026-04)
PostboxDecoder      → EnginePostboxDecoder         (added 2026-04)
PostboxEncoder      → EnginePostboxEncoder         (added 2026-04)
AdaptedPostboxDecoder → EngineAdaptedPostboxDecoder (added 2026-04)

For the MediaResource Postbox protocol, prefer the TelegramCore subtype TelegramMediaResource when the consumer's usage allows (note: EngineMediaResource is a wrapper class, not a typealias, so it is not interchangeable with the protocol).

MediaResource → EngineMediaResource consumer migration

EngineMediaResource is a final class in TelegramCore wrapping a MediaResource value. Unlike the typealiases above it is not interchangeable with the protocol, but it does provide wrap/unwrap helpers:

  • EngineMediaResource(rawResource) — wrap a raw MediaResource.
  • engineResource._asResource() — unwrap to the raw MediaResource.
  • EngineMediaResource.ResourceData(rawResourceData) — wrap MediaResourceData.
  • EngineMediaResource.Id(rawMediaResourceId) — wrap MediaResourceId.

Pattern for facade functions: when a TelegramEngine.<Area> method leaks raw MediaResource in its public signature, change the facade signature in place to EngineMediaResource (and change any closure parameter types the same way). Bridge inside the facade body by calling the existing _internal_* function with engineResource._asResource() / wrapping raw inputs from inner closures with EngineMediaResource(rawResource). Update all call sites in the same commit. The _internal_* function stays on raw MediaResource — it is the Postbox-facing layer.

Do not add opt-in EngineMediaResource overloads alongside raw-MediaResource overloads. Duplicate signatures fragment the public API and leave the leak in place forever.

For consumer modules, prefer EngineMediaResource as the type in properties, locals, generic arguments and function parameters when the usage is a pure type reference. Do not try to use EngineMediaResource where a class must conform to TelegramMediaResource (Postbox protocol) or override isEqual(to: MediaResource) — those remain import Postbox.

Wave-selection guidance (learned from waves 1, 2, 4, and 6)

The "leaf module, drop Postbox in isolation" approach only works for modules whose public API doesn't leak Postbox domain types. Most candidate leaf modules DO leak such types (postbox: Postbox / account: Account in public inits, Media/Message in public function parameters). Those modules need paired caller-migration waves, not isolated refactors.

Before selecting a wave's module list, grep each candidate for:

  • :\s*Postbox\b, :\s*Account\b, :\s*MediaBox\b in public signatures → abandon candidate
  • Media/Message as public parameter types → likely needs paired wave with callers

Inventory at execution time, not just planning time. Wave 2's SaveToCameraRoll task was planned from a narrow grep that only matched MediaResource/TelegramMediaResource and missed three postbox: Postbox public-function leaks plus multiple postbox.mediaBox.* bodies. Planning-time inventory should grep the full set \b(postbox|mediaBox|transaction|PostboxView|combinedView|MediaResource|PostboxDecoder|PostboxEncoder|MemoryBuffer)\b|^import Postbox over the module's Sources, not just the tokens specific to that wave's goal. If the planning inventory under-counts, the executor should re-inventory at Task-1 time and abandon early before editing code.

Two feasible wave shapes. Wave 1 tried "per-module Postbox drop". Wave 2 tried "per-engine-facade-API migrate MediaResource to EngineMediaResource (modify in place, update all call sites in one commit)". The second shape worked well: narrow, clean commits, no abandonment cascade. Prefer it when the refactor target is an API surface that multiple consumer modules depend on.

Enum-payload migrations need a full case-site grep, not just a facade call-site grep. If a wave changes the payload type of a public enum (wave 4 changed UploadStickerStatus.complete's payload from CloudDocumentMediaResource to EngineMediaResource), inventory ALL construction and destructure sites of the enum across TelegramCore, not just call sites of the facade that returns it. Wave 4's plan undercounted by 6 consumer sites inside ImportStickers.swift itself (3 shortcut .complete(...) constructions in guard branches, 3 destructure+field-access sites using CloudDocumentMediaResource-specific members). For enum-payload waves, grep case \.|let \.|\.<caseName>\( over the enum's defining module before execution and add those sites to the plan.

Unused-import sweeps are a valid wave shape. After a round of facade migrations, consumer files accumulate import Postbox lines whose last semantic use was removed. Periodically sweep these:

  1. grep -rl "^import Postbox$" submodules --include="*.swift" | grep -vE "/(TelegramCore|Postbox|TelegramApi)/" generates the candidate list.
  2. sed -i '' '/^import Postbox$/d' <file> (BSD sed) speculatively drops the import from every candidate.
  3. Run the full build with --continueOnError — without --keep_going, bazel stops at the first failing target and surfaces only a few errors per iteration. Make.py forwards --continueOnError to --keep_going; always use it.
  4. Each iteration: extract failing files via grep -E "^submodules/.*\.swift:[0-9]+:[0-9]+: error:" <build-out> | awk -F: '{print $1}' | sort -u, restore via git checkout -- <file>, rebuild.
  5. The dependency graph has many layers (wave 6 needed ~18 rebuilds to reach a clean build). Per-iteration failures shrink roughly: 18 → 4 → 5 → 3 → 12 → 4 → 13 → 9 → 11 → ... Accelerate by doing pattern-based preemptive restores after the first few iterations: scan still-dropped files for tokens that are definitively Postbox-only (MediaBox, PostboxCoding, PostboxDecoder, PostboxEncoder, TempBoxFile, ValueBoxKey, Postbox\b, PeerId, MessageId, MediaId, MessageIndex, MessageAndThreadId, PeerNameIndex, etc. — note that CLAUDE.md's "engine typealias cheat sheet" arrows are migration targets, not typealiases in TelegramCore — PeerId etc. are still raw Postbox types and files using them need import Postbox) and restore those files in bulk.
  6. Only restore files from the candidate set. If errors surface in TelegramCore, Postbox, or TelegramApi, halt — the sweep has cascaded beyond its scope.
  7. Commit the surviving drops as one atomic commit.

Tally impact from a sweep: dozens of consumer modules can become Postbox-free in a single commit. First run (wave 6): 782 candidates, 18 iterations, 183 survivors, 189 modules newly Postbox-free. Re-run after every 2-3 facade-migration waves.

Wave 1 outcome (2026-04-16)

4 modules done: ChatInterfaceState, ChatSendMessageActionUI, ContactListUI, DrawingUI. 6 modules abandoned with recorded reasons in the wave-1 plan: ActionSheetPeerItem, ChatListSearchRecentPeersNode, DirectMediaImageCache, FetchManagerImpl, GalleryData, ICloudResources.

Wave 2 outcome (2026-04-17)

5 TelegramEngine facades migrated to EngineMediaResource (signatures changed in place; _internal_* Postbox layer unchanged):

  • TelegramEngine.Peers.uploadedPeerPhoto, uploadedPeerVideo, updatePeerPhoto
  • TelegramEngine.AccountData.updateAccountPhoto, updateFallbackPhoto
  • TelegramEngine.Contacts.updateContactPhoto
  • TelegramEngine.Auth.uploadedPeerVideo

1 consumer submodule fully de-Postboxed: MapResourceToAvatarSizes (signature changed from (postbox: Postbox, resource: MediaResource, …) to (engine: TelegramEngine, resource: EngineMediaResource, …); 27 call sites migrated).

1 consumer signal type swapped: AuthorizationUI/AuthorizationSequenceController.swift (Signal<TelegramMediaResource?>Signal<EngineMediaResource?>).

1 task abandoned with recorded reason in the wave-2 plan: SaveToCameraRoll (full-module Postbox coupling, needs its own wave).

Wave 3 outcome (2026-04-18)

3 thin forwarders added on TelegramEngine.Resources over MediaBox:

  • fetch(reference:userLocation:userContentType:)Signal<FetchResourceSourceType, FetchResourceError> (Postbox return types remain a documented accepted leak)
  • status(resource: EngineMediaResource)Signal<EngineMediaResource.FetchStatus, NoError>
  • data(resource: EngineMediaResource, pathExtension:, waitUntilFetchStatus:)Signal<EngineMediaResource.ResourceData, NoError> (takes a Bool rather than exposing ResourceDataRequestOption, per YAGNI)

1 consumer submodule fully de-Postboxed: SaveToCameraRoll. Public signatures changed from (context:, postbox: Postbox, userLocation:, …) to (context:, userLocation:, …); FetchMediaDataState.data payload changed from MediaResourceData to EngineMediaResource.ResourceData; internals rewired through context.engine.resources.*. 23 call sites across 14 files migrated atomically with the module.

Pre-flight verified that ShareController.swift:2406's self.currentContext.stateManager.postbox is equivalent to context.account.postbox in the ShareControllerAppAccountContext path (because AccountStateManager is constructed with the account's own postbox), so the postbox: argument could be dropped without behavior change.

No tasks abandoned. Shape validated: "per-engine-facade-API migration + full consumer module rewrite" (the wave-2 shape, scaled up to a full module drop).

Plan: docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-3.md

Wave 4 outcome (2026-04-18)

1 TelegramEngine facade migrated in place to EnginePeer + EngineMediaResource (signature changed; _internal_uploadSticker keeps its raw Peer/MediaResource parameter list):

  • TelegramEngine.Stickers.uploadSticker(peer: Peer → EnginePeer, resource: MediaResource → EngineMediaResource, thumbnail: MediaResource? → EngineMediaResource?, …)

1 public enum payload migrated: UploadStickerStatus.complete(CloudDocumentMediaResource, String).complete(EngineMediaResource, String). _internal_uploadSticker wraps EngineMediaResource(uploadedResource) at its one .complete(...) result-construction site — a narrow, spec-allowed one-line deviation from "internal Postbox-facing stays raw", taken to keep UploadStickerStatus as a single public enum.

Plan-time inventory undercount — worth recording as a lesson. The spec and plan enumerated 2 external call sites and 1 internal construction site. Execution uncovered 6 additional consumer sites inside ImportStickers.swift itself that also needed adapting: 3 shortcut .complete(...) construction sites (lines 204, 371, 492, each emitting .complete(CloudDocumentMediaResource, String) directly from as? CloudDocumentMediaResource guards) and 3 destructure sites (lines 216, 384, 505) that accessed CloudDocumentMediaResource-specific fields. Each construction site now wraps via EngineMediaResource(resource); each destructure site unwraps with let rawResource = resource._asResource() as? CloudDocumentMediaResource. MediaEditorScreen's two stickerFile(resource:) calls also needed as! TelegramMediaResource casts because _asResource() returns the Postbox MediaResource protocol while stickerFile takes the TelegramCore TelegramMediaResource sub-protocol. Future planning-time inventory for enum-payload migrations should grep not only call-sites of the facade but every case .complete / case let .complete of the migrated enum across the whole TelegramCore source tree.

2 external call sites migrated atomically with the facade:

  • submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift:91 (plus a peer: Peer → EnginePeer(peer) wrap, since the local peer comes from postbox.loadedPeerWithId(...) which returns raw Peer)
  • submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift:8099 (plus 6 cascading sites inside the enclosing block for the new UploadStickerStatus.complete payload)

No module becomes Postbox-free in this wave (both caller files import Postbox for unrelated reasons).

Plan: docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-4.md

Wave 5 outcome (2026-04-18)

Completes the last explicitly-named future-wave candidate from the wave-2 final review.

uploadSecureIdFile(context: SecureIdAccessContext, postbox: Postbox, network: Network, resource: MediaResource) migrated in place to (context:, engine: TelegramEngine, resource: EngineMediaResource). Function body accesses raw Postbox types via engine.account.postbox / engine.account.network (internal Postbox-facing layer stays raw per the standing rule).

1 consumer submodule fully de-Postboxed: SecureIdVerificationDocumentsContext (PassportUI/Sources). Signature changed from (postbox: Postbox, network: Network, context: SecureIdAccessContext, update: ...) to (engine: TelegramEngine, context: SecureIdAccessContext, update: ...); stored props collapsed into a single engine: TelegramEngine field. One instantiation site updated in the same commit.

After this wave, the "Known future-wave candidates" list contains only the 4 permanently-blocked classes conforming to TelegramMediaResource.

Plan: docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-5.md

Wave 6 outcome (2026-04-19)

First build-verified unused-import sweep. Ran the speculative-drop + build-verify methodology (see "Unused-import sweeps" under Wave-selection guidance above): dropped import Postbox from all 782 consumer files where a plain ^import Postbox$ line appeared, iterated 18 full builds with --continueOnError, restoring imports on files that failed to compile.

183 drops survived (single atomic commit 7b2b74e79b, 0 insertions / 183 deletions). 189 modules transitioned to Postbox-free status — full list is inferable by running the methodology's module-scan against HEAD. Representative additions spanning alphabetically: AccountUtils, ActivityIndicator, AdUI, AlertUI, AnimatedStickerNode, AppLock, AttachmentTextInputPanelNode, BotPaymentsUI, CalendarMessageScreen, CallListUI, Camera, ChatImportUI, etc. The running tally below preserves the per-module enumeration only for the ~10 individually-documented waves 15 modules. Wave 6's 189 additions are not re-enumerated here because the size would overwhelm the doc; see git show 7b2b74e79b --stat for the per-file breakdown and grep -rL "^(@_exported )?import Postbox" submodules/*/Sources --include="*.swift" for the current per-module status.

Deviation from plan: the plan capped at 3 iterations; execution needed 18 because the dependency graph is deep and each bazel build surfaces only the currently-compilable layer. Pattern-based preemptive restores (using the symbol list in the "Unused-import sweeps" guidance) were used from iteration 9 onward to accelerate convergence from iteration-by-iteration single-file restores to bulk restores. No unexpected path cascades; no abandoned state.

Plan: docs/superpowers/plans/2026-04-19-postbox-to-telegramengine-wave-6.md

Wave 7 outcome (2026-04-20)

Closed out the seven remaining raw-Postbox leaks in TelegramEngine.* public facades surfaced by a post-wave-6 scouting pass. Single atomic commit, one full build, zero abandonment.

Seven TelegramEngine facades migrated in place (all _internal_* implementations kept raw per the standing rule):

Messages (3):

  • downloadMessage(messageId:) — return Signal<Message?, NoError>Signal<EngineMessage?, NoError>. Return-side wrap via |> map { $0.flatMap(EngineMessage.init) }.
  • topPeerActiveLiveLocationMessages(peerId:) — return Signal<(Peer?, [Message]), NoError>Signal<(EnginePeer?, [EngineMessage]), NoError>. Return-side tuple wrap.
  • getSynchronizeAutosaveItemOperations()deleted. Dead facade: sole caller (StoreDownloadedMedia.swift) already bypassed it by calling _internal_getSynchronizeAutosaveItemOperations directly inside its own transaction block.

Peers (1):

  • updatedRemotePeer(peer:) — return Signal<Peer, UpdatedRemotePeerError>Signal<EnginePeer, UpdatedRemotePeerError>. PeerReference param kept as-is (no EnginePeer.Reference alias today). The sole call site in ChannelAdminsController.swift uses ignoreValues, so no caller change was needed.

Resources (4):

  • renderStorageUsageStatsMessages(…existingMessages:)[EngineMessage.Id: Message][EngineMessage.Id: EngineMessage] on both sides. Facade unwraps input via .mapValues { $0._asMessage() }, wraps output via .mapValues(EngineMessage.init).
  • clearStorage(peerId:categories:includeMessages:excludeMessages:)[Message][EngineMessage]. Facade unwraps via .map { $0._asMessage() }.
  • clearStorage(peerIds:includeMessages:excludeMessages:) — same shape.
  • clearStorage(messages:) — same shape. No external callers; migrated for overload-set consistency.

Consumer call-site updates (5 files):

  • ChatListUI/Sources/ChatListSearchListPaneNode.swift: dropped now-redundant .flatMap(EngineMessage.init) wrap at the downloadMessage call site.
  • LocationUI/Sources/LocationViewControllerNode.swift: dropped now-redundant .map(EngineMessage.init) at the topPeerActiveLiveLocationMessages call site.
  • LiveLocationManager/Sources/LiveLocationSummaryManager.swift: dropped redundant EnginePeer(author) / EngineMessage(message) construction (author, message are now already EnginePeer / EngineMessage).
  • TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift: bridged at 4 facade-call points (1 renderStorageUsageStatsMessages, 2 clearStorage overloads with message arrays; the includeMessages: [], excludeMessages: [] site at line 3038 needed no change as empty arrays infer to [EngineMessage] just as well).

Minimal-scope bridging. StorageUsageScreen.swift still has 43 raw Message/MessageId references inside its AggregatedData helper class and surrounding logic — not touched in this wave. A future "StorageUsageScreen full de-Postbox" wave would drop those (migrate AggregatedData.messages: [MessageId: Message][EngineMessage.Id: EngineMessage], clearIncludeMessages: [Message][EngineMessage], etc.) and potentially drop import Postbox. Out of scope here.

No modules became Postbox-free in this wave — all five touched consumer files still import Postbox for reasons unrelated to the migrated facades.

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

After this wave, the "Known future-wave candidates" list contains only the 4 permanently-blocked classes conforming to TelegramMediaResource. The full public TelegramEngine.* facade surface is now engine-typed (modulo those four types).

Wave 8 outcome (2026-04-20)

StorageUsageScreen consumer-module migration of raw Message domain types to EngineMessage. Scope explicitly narrower than a full de-Postbox: two files touched, module remains import Postbox due to two out-of-scope site clusters.

Types migrated:

  • StorageFileListPanelComponent.Item.message: MessageEngineMessage (item type co-located with the panel component).
  • StorageUsageScreen.Component.AggregatedData.messages: [MessageId: Message][EngineMessage.Id: EngineMessage]; .clearIncludeMessages / .clearExcludeMessages: [Message][EngineMessage]. Init param updated to match.
  • StorageUsageScreen.Component.SelectionState.togglePeer(availableMessages:) param: [EngineMessage.Id: Message][EngineMessage.Id: EngineMessage].
  • StorageUsageScreen.Component.RenderResult.messages: [MessageId: Message][EngineMessage.Id: EngineMessage].
  • openMessage(message: Message)openMessage(message: EngineMessage) (external OpenChatMessageParams.message / chatMediaListPreviewControllerData(message:) calls unwrap via message._asMessage() at the two call sites — those APIs still take raw Message).

Wave-7 facade-boundary bridging dropped: the renderStorageUsageStatsMessages call-site's (…).mapValues(EngineMessage.init) / .mapValues { $0._asMessage() } bridges and the two clearStorage call sites' .map(EngineMessage.init) wraps all vanish — AggregatedData.messages / .clearIncludeMessages / .clearExcludeMessages are now engine-typed and pass through the facade unchanged. Inside the AggregatedData.updateSelected... selected-messages accumulation loop, four item.message._asMessage() calls (for imageItems, which hold EngineMessage) drop back to plain item.message since the target array is now [EngineMessage]. And StorageMediaGridPanelComponent.Item(message: EngineMessage(message), …) drops the EngineMessage(…) wrap since message is already EngineMessage.

Out of scope — future-wave candidates (module still imports Postbox):

  • StorageUsageScreen.swift:1047-1062 and 3131-3185: preferences-view observation of AccountSpecificCacheStorageSettings via postbox.combinedView + PreferencesView, and a postbox.transaction { transaction in transaction.getPeer / transaction.getPeerCachedData as? CachedGroupData / CachedChannelData } block classifying peer-storage-timeout exceptions. Substantial: requires EngineData-subscription rewrite for the preferences observation, plus engine-API equivalents for peer-category classification + cached-data subscriber counts.
  • StorageFileListPanelComponent.swift:105: Icon.media(Media, TelegramMediaImageRepresentation) enum case, constructed only as .media(TelegramMediaFile, …) or .media(TelegramMediaImage, …) (both TelegramCore types). Trivial future wave: split into .mediaFile(TelegramMediaFile, …) / .mediaImage(TelegramMediaImage, …), drop import Postbox.

Single atomic commit. Build verified green (59s incremental build, 27 actions). Net 11 lines in StorageUsageScreen.swift (simplification).

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.

Wave 10 outcome (2026-04-20)

Closes the second (and last) future-wave candidate from wave 8: eliminates StorageFileListPanelComponent.swift's Icon.media(Media, TelegramMediaImageRepresentation) enum case. StorageUsageScreen (the module as a whole) is now fully Postbox-free — the other in-module file (StorageUsageScreen.swift) landed in wave 9.

Split the enum case. Icon.media(Media, TelegramMediaImageRepresentation) → two concrete cases case mediaFile(TelegramMediaFile, TelegramMediaImageRepresentation) + case mediaImage(TelegramMediaImage, TelegramMediaImageRepresentation). Lossless split: the two construction sites already knew the concrete subtype (imageIconValue = .media(file, representation) from a as? TelegramMediaFile branch, .media(image, representation) from a as? TelegramMediaImage branch), and the consumer binding site immediately downcast via as? to pick which setSignal(...) flavor to call. New split removes the downcast; exhaustiveness-checked switch is both safer and terser.

Equatable rewritten. Old: manual outer-switch + inner if case dispatch, comparing media by media.id only. New: switch-over-tuple (lhs, rhs) with id-based equality per concrete type (lFile.fileId == rFile.fileId, lImage.imageId == rImage.imageId). Same id-based equality semantics as before.

Binding-site rewrite. Old: if case let .media(media, representation) = component.icon { ... if let file = media as? TelegramMediaFile { ... } else if let image = media as? TelegramMediaImage { ... } }. New: a compound case-binding pattern case let .mediaFile(_, representation), let .mediaImage(_, representation): lifts the shared representation variable, then an inner switch dispatches to the right setSignal branch. Works because both cases carry the same TelegramMediaImageRepresentation payload type; Swift allows compound case patterns when the bindings have identical types.

Placeholder PeerId(...) construction fixup. Second-pass build failure after dropping import Postbox surfaced a placeholder PeerId(namespace: PeerId.Namespace._internalFromInt32Value(0), id: PeerId.Id._internalFromInt64Value(0)) in the measureItem layout-measurement instance at former line 1062. Naming PeerId, PeerId.Namespace, and PeerId.Id all require import Postbox (these are raw Postbox types, not TelegramCore typealiases — consistent with wave 9's MessageIdEngineMessage.Id fixup). Replaced with component.context.account.peerId (a real EnginePeer.Id already in scope). Semantically equivalent since the measurement instance's messageId is only used for .peerId extraction inside image-fetch userLocation and for Equatable comparison (the measurement instance isn't compared to anything).

Lesson. Placeholder PeerId(...) / MessageId(peerId:...) constructions in layout-measurement code are a recurring trap for de-Postbox work. Common pattern in this codebase: construct a dummy component instance purely to call .update(...) and read back the returned size. The dummy values are not used meaningfully but naming the types pins import Postbox. When de-Postboxing, grep for PeerId(namespace:/MessageId(peerId: with all-zero args and replace with any convenient real value in scope (context.account.peerId is almost always available).

Net: 1 file changed, +22 / -29 lines (7 simplification — new switch-over-tuple Equatable is both terser and more idiomatic).

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

Modules currently free of import Postbox (running tally)

Consumer modules that no longer import Postbox, across all waves and standalone commits:

  • ChatInterfaceState (wave 1)
  • ChatSendMessageActionUI (wave 1)
  • ContactListUI (wave 1)
  • DrawingUI (wave 1)
  • StickerPeekUI (standalone cleanup, 2026-04-17 — import was unused)
  • PromptUI (standalone cleanup)
  • PresentationDataUtils (standalone cleanup)
  • MapResourceToAvatarSizes (wave 2)
  • SaveToCameraRoll (wave 3)
  • SecureIdVerificationDocumentsContext (wave 5)
  • Wave 6 batch: 189 additional modules — see git show 7b2b74e79b --stat for the commit that swept unused import Postbox lines across 183 files in 16 consumer submodules. Not individually enumerated here for brevity.
  • StorageUsageScreen (waves 810)

Known future-wave candidates

Surfaced by the wave-2 final review:

  • Classes conforming to TelegramMediaResource (need isEqual(to: MediaResource) override) remain permanently blocked from consumer-side migration: ICloudFileResource, InstantPageExternalMediaResource, VideoLibraryMediaResource, YoutubeEmbedStoryboardMediaResource. Either move the class into TelegramCore or keep import Postbox in its module.

(The seven TelegramEngine.* facade leaks surfaced by the 2026-04-20 post-wave-6 scouting pass — downloadMessage, topPeerActiveLiveLocationMessages, getSynchronizeAutosaveItemOperations, updatedRemotePeer, renderStorageUsageStatsMessages, and three clearStorage overloads — landed in wave 7; see "Wave 7 outcome" above.)

Build environment quirk

The build needs TELEGRAM_CODESIGNING_GIT_PASSWORD in the environment. It is set in ~/.zshrc but Claude Code's bash tool does NOT source shell config by default. Prefix build commands with source ~/.zshrc 2>/dev/null; to pick it up.