diff --git a/docs/superpowers/specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md b/docs/superpowers/specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md deleted file mode 100644 index cfacad1740..0000000000 --- a/docs/superpowers/specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md +++ /dev/null @@ -1,170 +0,0 @@ -# Postbox → TelegramEngine refactor, wave 1 - -## Goal - -Gradually eliminate direct `import Postbox` from consumer submodules by routing all data access through `TelegramEngine` (`.data.get` / `.data.subscribe` and engine-owned functions). Behavior must be preserved exactly — this is a dependency-shape refactor, not a semantic change. - -This spec covers **wave 1**: the first 10 single-import leaf modules, refactored in bottom-up dependency order. Subsequent waves will be covered by their own specs. - -## Non-goals - -- No refactor inside `TelegramCore` itself — it owns `TelegramEngine` and will keep importing Postbox. -- No refactor inside `Postbox`. -- No behavior or UX changes. No unrelated cleanup. -- No edits to modules outside the 10 chosen by the selection rule. -- Within wave-1 modules, switching Postbox-typed names to their engine typealiases (`PeerId` → `EnginePeer.Id`, etc.) is required and in scope; introducing new engine *wrapper types* that re-encode data is out of scope. -- No generic `engine.transaction { postbox in … }` escape hatch. - -## Guiding rules - -1. Consumers only. `TelegramCore` does **not** `@_exported import Postbox`, so once a module drops its Postbox import every remaining Postbox-type reference must be switched to the engine-typealiased equivalent (`PeerId` → `EnginePeer.Id`, `MessageId` → `EngineMessage.Id`, `MessageIndex` → `EngineMessage.Index`, `MessageTags` → `EngineMessage.Tags`, `MediaId` → `EngineMedia.Id`, etc.). These aliases are identical to their Postbox originals, so the swap is behavior-preserving. -2. **Never typealias the `Postbox` class itself** (or other large umbrella API surfaces such as `Account` or `MediaBox`). Aliasing an umbrella type with something like `EnginePostbox = Postbox` renames without encapsulating — the consumer still has access to the full Postbox API through the alias. It defeats the purpose of the refactor. Narrow utility typealiases (`MemoryBuffer`, `PostboxDecoder`, `PostboxEncoder`, `AdaptedPostboxDecoder`, `MediaResource`, etc.) remain allowed and expected; the ban is specifically on aliasing the large facade types. -3. Prefer existing `Engine*` wrapper types (`EnginePeer`, `EngineMessage`, `EngineMediaResource`) and engine methods; add new engine wrappers only when a call site clearly needs one. -4. Before adding any new engine wrapper, search `submodules/TelegramCore/Sources/TelegramEngine/` for an equivalent by name and shape. Record the search result in the commit that adds the wrapper. -5. Bottom-up dependency order across modules. -6. Full project build after each module, using the command from the global `CLAUDE.md`. -7. A module is done when: no `import Postbox` in its `.swift` files, no `//submodules/Postbox:Postbox` entry in its `BUILD`, full build green, commits landed. - -### Abandonment protocol - -If a module can only be refactored by either (a) typealiasing an umbrella type banned by rule 2, or (b) editing a module outside the wave-1 list, the module is **abandoned for this wave**. Record it in the plan (mark the task Abandoned with the reason) and reduce the wave's done-count accordingly. Do not substitute a new module mid-wave; the wave's scope is fixed at plan time. - -## Wave-1 scope: selecting the 10 modules - -### Candidate pool - -The 30 submodules that currently have Postbox imports in exactly one `.swift` file: - -`ActionSheetPeerItem, ChatInterfaceState, ChatListSearchRecentPeersNode, ChatSendMessageActionUI, ContactListUI, DirectMediaImageCache, DrawingUI, FetchManagerImpl, GalleryData, HorizontalPeerItem, ICloudResources, InAppPurchaseManager, InstantPageCache, InviteLinksUI, ItemListAvatarAndNameInfoItem, ItemListPeerItem, ItemListStickerPackItem, MapResourceToAvatarSizes, PhotoResources, PlatformRestrictionMatching, PresentationDataUtils, PromptUI, SaveToCameraRoll, SelectablePeerNode, ShareItems, SoftwareVideo, StickerPeekUI, StickerResources, TelegramIntents, TelegramNotices` - -### Selection rule - -The implementation plan runs a deterministic selection pass up-front: - -1. Parse each candidate's `BUILD` to compute a reverse-dependency count over the candidate pool (how many other candidates depend on it). Leaves have count 0. -2. Sort by reverse-dep count ascending, then alphabetical. Take the first 10. Write the chosen list into the plan so execution is reproducible. -3. If during execution a chosen module transitively needs a Postbox type not yet exposed via a `TelegramCore` re-export, stop, record the blocker, and skip to the next candidate in the selection-rule ordering — keeping the wave at 10 completed modules. - -### Explicitly deferred (future waves, not this spec) - -- `TelegramUI` (478 files), `SettingsUI` (44), `TelegramCallsUI` (23), `GalleryUI` (16), `PassportUI` (14), `ChatListUI` (13), `AccountContext` (13), and every other module not in the chosen 10. -- `TelegramCore` (non-goal, ever). - -## Per-module playbook - -Each of the 10 modules follows the same deterministic sequence. - -### 1. Inventory - -List every Postbox API referenced in the module. Each reference falls into one of: - -- **Type reference only** — signature or local variable uses a Postbox-defined type (`Peer`, `MessageId`, `Media`, `CachedPeerData`, …). Usually resolvable by `TelegramCore` re-exports or an existing `Engine*` type. -- **`postbox.mediaBox.*`** — media resource access. -- **`account.postbox.transaction { … }`** — read/write transaction. -- **`postbox.combinedView / subscribe(...)` with `PostboxViewKey`** — view subscription. -- **`account.postbox.mediaBox` / `postbox.mediaBox` as a parameter** — plumbing through a public signature. - -### 2. Map each call site to its replacement - -In this priority order: - -1. An existing `TelegramEngine.data.get` / `.subscribe` on a `TelegramEngine.EngineData.Item…`. -2. An existing engine function under `TelegramEngine.{peers, messages, accountData, resources, …}`. -3. An existing re-export from `TelegramCore` (for type-only references). -4. A **new** thin wrapper added to the appropriate `TelegramEngine//` file (see wrapper policy below). Added in `TelegramCore` in a separate preparatory commit before the consumer edit. - -### 3. Edit the consumer - -Replace call sites. Function signatures that took `Postbox` as a parameter become signatures taking `TelegramEngine` (or `AccountContext` where already available in the call site). Public-API changes in these leaf modules are acceptable because no other module currently imports them in a way that depends on Postbox types — verified during step 1. Any break discovered at build time is fixed at the call site in the same commit, or the module is skipped if the fix would require changing a module outside the wave. - -### 4. Drop the dependency - -- Remove `import Postbox` from every file. -- Remove `"//submodules/Postbox:Postbox"` from the module's `BUILD`. - -### 5. Build - -Run the full project build from the global `CLAUDE.md`: - -``` -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 -``` - -The module is not marked done until the full build is green. Fix failures in place before moving on. - -### 6. Commit - -Commit structure per module, one or two commits: - -1. `TelegramCore: add ` — optional, only if new engine wrappers were needed. -2. `: drop direct Postbox dependency` — consumer edits plus BUILD change. - -## Engine-wrapper policy - -When a call site has no existing engine equivalent, the wrapper is added in `TelegramCore` **before** the consumer edit, in a **separate commit**. - -### Where wrappers go - -- **Data reads and subscriptions** → new `TelegramEngine.EngineData.Item..` struct alongside its peers in `submodules/TelegramCore/Sources/TelegramEngine/Data/Data.swift`. The item's `extract` maps the underlying `PostboxView` to an engine-typed result. -- **Imperative signal-returning calls** → new method on the matching area class (`Peers`, `Messages`, `Resources`, `AccountData`, …) inside `submodules/TelegramCore/Sources/TelegramEngine//`. -- **Media-resource access** → extended on `TelegramEngine.resources` rather than exposing `MediaBox` to consumers. For example: `engine.resources.data(…)`, `engine.resources.fetch(…)`, `engine.resources.status(…)`. Each forwards to `account.postbox.mediaBox.*` internally. -- **Ad-hoc transactions that a consumer was running directly** → a specific purpose-built method on the appropriate area, never a generic transaction escape hatch. If only one call site needs the logic and it's trivial, inline it into a new area method rather than creating a helper. - -### Rules for the wrapper itself - -- Minimal pass-through. No caching, no extra signal plumbing, no bonus features. -- Return type must be nameable from the consumer **without** importing Postbox. That means: an existing `Engine*` wrapper type (`EnginePeer`, `EngineMessage`, `EngineMediaResource`), an existing engine typealias (`EnginePeer.Id`, `EngineMessage.Id`, …), a Swift primitive, or a new `Engine*` typealias added in the same commit. Do **not** return a bare Postbox type. -- Do not introduce new engine wrapper *structs/classes* that re-encode data (those are out of scope for this wave). A new typealias to make an existing Postbox type reachable under an `Engine*` name is allowed and expected. -- Public. -- Consumer must not need anything else from Postbox after the wrapper is in place. -- No deprecation shim. Existing Postbox-using code paths elsewhere in the codebase stay untouched. - -### Discovery step (always runs first) - -Before writing any new wrapper, search `submodules/TelegramCore/Sources/TelegramEngine/` for an existing match by name and shape. Document "searched for X, found/not found" in the commit that adds the wrapper, so future waves don't re-invent it. - -## Verification - -### Per-module static checks (must pass before running the build) - -- `grep -R "^import Postbox" submodules//Sources` returns empty. -- `grep "submodules/Postbox" submodules//BUILD` returns empty. - -### Per-module build check - -- Full project build (the command in §Per-module playbook step 5) is green. -- No warnings-as-errors regressions introduced by the refactor. - -### Wave-completion check - -- All 10 chosen modules satisfy the per-module checks. -- Any new engine wrappers are documented in their respective commits. - -## Risks and mitigations - -- **Public signature changes in a leaf module break an unexpected caller.** Mitigated by the full build per module. Fix at the call site in the same commit, or skip and move on if the fix would pull in scope beyond the wave. -- **A Postbox view has no equivalent engine data item.** Add a new `EngineData.Item` per the wrapper policy. If the mapping is non-trivial (needs its own result type), skip the module and flag it for a future spec. -- **Transitive Postbox usage through a type the module re-exposes publicly.** Caught during Inventory (step 1). If fixing would require editing another module in the wave's dependency graph, skip. -- **A Postbox type has no engine typealias.** Add the typealias in `TelegramCore` (`EngineXxx = Xxx`) in the preparatory commit, then use it in the consumer. Typealias-only additions are explicitly allowed and cheap. -- **Build times.** Full project build per module is slow but accepted — it gives the strongest signal. - -## Follow-ups (not this spec) - -- Successive waves for the remaining ~64 modules in bottom-up order, each its own spec. -- `TelegramUI` (478 files) and `SettingsUI` (44) will likely need a bespoke approach because of scale; they get their own spec when the time comes. -- Whether `AccountContext` itself should eventually stop importing Postbox is deferred. - -## Done definition for this spec - -- Every module in the wave-1 list is either **done** (zero `import Postbox` in its sources, no `//submodules/Postbox:Postbox` in its `BUILD`) or explicitly marked **abandoned** with a recorded reason in the plan. -- Full project build is green at wave end. -- Any new engine wrappers added along the way are documented in their commits. diff --git a/docs/superpowers/specs/2026-04-17-listview-pin-to-edge-design.md b/docs/superpowers/specs/2026-04-17-listview-pin-to-edge-design.md deleted file mode 100644 index 511d52ff9b..0000000000 --- a/docs/superpowers/specs/2026-04-17-listview-pin-to-edge-design.md +++ /dev/null @@ -1,245 +0,0 @@ -# ListView pin-to-edge first-pinned-item design - -## Goal - -Give `submodules/Display/Source/ListView.swift` the ability to pin a single "first pinned item" to the bottom edge of the scrolling area. The item's `apparentFrame.maxY` should sit at `visibleSize.height - insets.bottom` when the combined height of items with smaller indices ("items above", in list coordinates) is less than the available scrolling-area height. When items above grow past that threshold, the pinning gracefully disengages and the list scrolls normally. - -This produces, in a flipped-chat consumer, the "AI-chat" UX in which a newly-sent outgoing message appears pinned to the visual top of the viewport while later additions fill in toward it. - -## Non-goals - -- Changes to `ListViewItemLayoutParams.availableHeight` or any item-side sizing API. -- A new per-item inset value. The existing `pinToEdgeWithInset: Bool` protocol property (declared on `ListViewItem`, default `false`, currently unread) is repurposed as the trigger; there is no numeric inset argument. -- Coordinating with `stackFromBottom` or `stackFromBottomInsetItemFactor`. Where both mechanisms contribute a top-inset, the existing `max(effectiveInsets.top, …)` chain combines them without additional logic. -- Defining behavior for items with an index greater than the pinned item's. Consumer contract: when a consumer sets `pinToEdgeWithInset = true` on an item, that item must be the highest-index flagged item (in practice, the last item in the data array). Items beyond the pinned item render at their natural frames; the pinning guarantee applies only relative to items with smaller indices. -- Unit tests. The project has no test harness; verification is via full-project build plus manual exercise in a consumer (see "Verification"). - -## Mechanism - -### Trigger rule - -Among materialized item nodes (`self.itemNodes`), find the one with the smallest `index` whose `self.items[index].pinToEdgeWithInset == true`. Call it the pinned node. If there is no such node (no flagged item, or the flagged item is outside the recycling window), pin-to-edge behavior is inert for that frame. - -### Adjustment formula - -``` -visibleArea = visibleSize.height - self.insets.top - self.insets.bottom -totalAboveAndPinned = Σ apparentBounds.height for itemNodes with index ≤ lowestPinnedIndex -pinTopAdjustment = max(0, visibleArea - totalAboveAndPinned) -``` - -**Height source: `apparentBounds.height`, not `frame.size.height`.** `apparentBounds.height` returns `self.apparentHeight`, which `insertNodeAtIndex` (at [ListView.swift:2439](submodules/Display/Source/ListView.swift:2439)) sets to `0.0` for animated insertions and grows via `addApparentHeightAnimation` over the insertion animation's duration. It is *essential* that the helper use this animated value: the ListView's per-tick `vSync` handler (around [ListView.swift:4842](submodules/Display/Source/ListView.swift:4842)) calls `snapToBounds` after each apparentHeight update, so the pin inset is recomputed with the current animated height every frame. With `apparentBounds.height`: - -- **Insertion above pinned** (the critical case): at insertion, new item `X` has `apparentHeight = 0`, so `totalAboveAndPinned` is unchanged, `pinTopAdjustment` is unchanged, `effectiveInsets.top` is unchanged, and the pinned item stays exactly where it was. As `X`'s apparentHeight grows by `dh` per tick, `totalAboveAndPinned` grows by `dh`, `pinTopAdjustment` shrinks by `dh`, and `snapToBounds` shifts items by `-dh` via its `topItemEdge > effectiveInsets.top` clamp; the pinned item's `origin.y` decreases by `dh` while items after `X` (which offsetRanges shifts by `+dh` earlier in the same vSync) are at `pinned.y + dh − dh = pinned.y` — stationary throughout the animation. -- **Initial animated insertion of a pinned item**: `X` starts at `origin.y` = pinned bottom-edge with `apparentHeight = 0` (invisible). As apparentHeight grows by `dh`, `effectiveInsets.top` shrinks by `dh` and `snapToBounds` shifts `origin.y` by `-dh`. `apparentFrame.maxY = origin.y + apparentHeight` stays exactly at the bottom edge for the whole animation; the item appears to grow upward from the bottom edge into its final pinned position. - -Using `frame.size.height` would freeze `totalAboveAndPinned` at its final value on the first tick, so `effectiveInsets.top` would jump to its post-animation value immediately. Insertion above pinned would drag the pinned item up by the new item's full real height on frame 0, then the apparentHeight animation would leave it there — breaking the "pinned stays put" invariant. Initial animated layout would land the item at its final `origin.y` with `apparentHeight = 0`, then grow its content downward into a fixed slot instead of upward from the bottom edge, which is the wrong visual. - -The formula is built from item *heights*, not positions, so it is idempotent: re-running `snapToBounds` or `updateScroller` after a snap offset has already been applied yields the same `pinTopAdjustment`. (A position-based formula would read the post-snap `maxY` and compute 0 on the next pass, undoing the shift.) - -`visibleArea` uses `self.insets`, not `effectiveInsets`, so the threshold at which pinning disengages is purely geometric and is not coupled to other contributors to `effectiveInsets.top` (e.g. `stackFromBottomInsetItemFactor`). Combining with those contributors happens at the application site via `max(…)`. - -### Partial-materialization guard - -Pinning requires the full height of items `[0, lowestPinnedIndex]` to be known. If items[0] is not materialized (not in `self.itemNodes`), some leading items are off-screen above — which can only happen when the scroll area is already full of content above the pinned item — and pinning is then inert for that frame. The helper therefore returns 0 unless an itemNode with `index == 0` is present among the materialized nodes. `ListView`'s recycling window is a contiguous range of indices, so `items[0]` materialized implies `items[0…lowestPinnedIndex]` all materialized. - -### Application - -At each call site that computes `effectiveInsets`, after the existing `stackFromBottomInsetItemFactor` branch: - -```swift -let pinToEdgeTopInset = self.calculatePinToEdgeTopInset() -if pinToEdgeTopInset > 0.0 { - effectiveInsets.top = max(effectiveInsets.top, self.insets.top + pinToEdgeTopInset) -} -``` - -This piggybacks on the virtual-top-inset mechanism that `stackFromBottomInsetItemFactor` already uses: raising `effectiveInsets.top` shifts the scroll content downward by that amount, positioning the pinned item's `maxY` at the bottom edge. When `pinTopAdjustment` is 0 (items above have reached the available area, or the guard tripped), no contribution is made and scrolling is ordinary. - -### Inset-transition correction - -There is a third integration point, separate from the two `effectiveInsets` call sites: the inset-transition code in `deleteAndInsertItemsTransaction` around [ListView.swift:3167-3188](submodules/Display/Source/ListView.swift:3167). This block runs whenever `updateSizeAndInsets` is non-nil and shifts every item's frame by an `offsetFix` in order to keep the list visually coherent across the inset/size change. - -In the "top-inset" branch (the `else` at line 3173), the existing formula is: - -```swift -offsetFix = updateSizeAndInsets.insets.top - self.insets.top -``` - -When pinning is engaged, this is wrong: a change in `self.insets.top` is exactly compensated by an opposite change in `pinTopAdjustment` (since `visibleArea = visibleSize.height - insets.top - insets.bottom` moves in lockstep with `insets.top`), so the *effective* top inset (`insets.top + pinTopAdjustment`) doesn't move. But `offsetFix` uses only the raw top delta, so it shifts every item's frame by `top_delta`. The list visibly jumps by that amount until the next `snapToBounds`/`updateScroller` pass corrects it — a keyboard toggle produces a jitter equal to the keyboard's top-inset contribution. - -The correction: capture `self.calculatePinToEdgeTopInset()` before *either* `self.visibleSize` or `self.insets` is reassigned; compute it again after both are updated; and, in the top-inset branch only, add `(updated - previous)` to `offsetFix`. This makes `offsetFix` equal the *effective* top-inset delta rather than the raw one. - -**Ordering matters.** The existing code updates `self.visibleSize` on [ListView.swift:3165](submodules/Display/Source/ListView.swift:3165) (right after entering the inset-transition branch) and `self.insets` on [ListView.swift:3183](submodules/Display/Source/ListView.swift:3183) (later, just before the `offsetFix` shift is applied). The `previousPinToEdgeTopInset` measurement must happen before line 3165 — not between 3165 and 3183 — because `calculatePinToEdgeTopInset` reads `self.visibleSize` to form `visibleArea`. Measuring after 3165 captures a hybrid state (new visibleSize, old insets) that never existed; the resulting delta is wrong whenever `visibleSize` changes in the same transaction. - -Initial layout is the case that surfaces this: the old `self.visibleSize = CGSize.zero`, so `visibleArea ≤ 0` and the helper correctly returns 0 ("no prior pinning") when measured before line 3165. Measured after line 3165, `visibleArea` is the full new screen size and the helper returns a fake "previous" pin inset roughly equal to the real post-transaction pin inset — delta cancels out, and the sign of `offsetFix` flips negative, shifting items in the wrong direction. `snapToBounds` then pulls them back to the right resting position, but the intermediate `offsetFix` value propagates into `sizeAndInsetsOffset` and the `-completeOffset` animation `fromValue`, producing a visible mis-offset on the first frame. - -Rotation (which changes both visibleSize and insets in one transaction) has the same structural issue but is less dramatic because the old visibleSize is non-zero. - -The other two branches don't need the correction: - -- `snapToBottomInsetUntilFirstInteraction` branch (line 3172): its formula `offsetFix = -(new.bottom - old.bottom)` already coincidentally equals `effective_top_delta` when pin is engaged. Working through it: `effective_top_delta = top_delta + pin_delta = top_delta - (top_delta + bottom_delta) = -bottom_delta`. -- `isTracking` branch (line 3170): intentionally sets `offsetFix = 0` and defers repositioning to `snapToBounds`, which already consults pin-aware `effectiveInsets` via the helper. - -### scrollToItem override - -`scrollToItem` at [ListView.swift:3058-3104](submodules/Display/Source/ListView.swift:3058) computes an offset from the target item's `apparentFrame` and the raw `self.insets`, then shifts every item's frame by that offset. When the target is the pin-to-edge target, most `ListViewScrollPosition` variants (`.top`, `.center`, `.visible`, and `.bottom(nonzero)`) compute an offset that drags the pinned item away from its pinned position; the subsequent `snapToBounds` at [ListView.swift:3198](submodules/Display/Source/ListView.swift:3198) / [3360](submodules/Display/Source/ListView.swift:3360) re-imposes pinning on the next pass. The net visible effect is a transient shift in the pre-snap direction, spurious `didScrollWithOffset` callbacks with the wrong value, and (for animated scrolls) a wrong starting frame for the insertion/scroll animation. - -The override: when the target item is the pin-to-edge target (the smallest-index materialized item with `pinToEdgeWithInset == true`, and pinning is actually engaged per `calculatePinToEdgeTopInset() > 0`), bypass the `switch scrollToItem.position` and compute the offset directly as: - -```swift -offset = (self.visibleSize.height - insets.bottom) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom -``` - -This matches the existing `.bottom(0)` case shape. `apparentFrame.maxY` is the right value because of the same per-tick invariant that makes the helper use `apparentBounds.height`: throughout the animation, `apparentFrame.maxY = origin.y + apparentHeight` stays at the pinned bottom edge, so the offset is `0` at every tick — no spurious shift during an in-flight animation. Using `frame.maxY` instead would produce a nonzero offset equal to `apparentHeight − real_height` during the animation, which would fight the `vSync` snap logic and break the animation. - -For non-pinned targets, or for the pinned target when pinning is disengaged (`calculatePinToEdgeTopInset() == 0`), the existing `switch` runs unchanged. - -## Code changes - -### New helper on `ListViewImpl` - -```swift -private func calculatePinToEdgeTopInset() -> CGFloat { - // Pass 1: find the smallest-index flagged item among materialized nodes. - var lowestPinnedIndex: Int = Int.max - for itemNode in self.itemNodes { - guard let index = itemNode.index else { continue } - if index < lowestPinnedIndex && self.items[index].pinToEdgeWithInset { - lowestPinnedIndex = index - } - } - guard lowestPinnedIndex != Int.max else { return 0.0 } - - // Pass 2: sum heights of items[0 ... lowestPinnedIndex], and require items[0] - // to be materialized (guarantees items[0 ... lowestPinnedIndex] are all present, - // since the recycling window is contiguous in index). - var totalAboveAndPinned: CGFloat = 0.0 - var sawIndexZero = false - for itemNode in self.itemNodes { - guard let index = itemNode.index else { continue } - if index == 0 { - sawIndexZero = true - } - if index <= lowestPinnedIndex { - totalAboveAndPinned += itemNode.apparentBounds.height - } - } - guard sawIndexZero else { return 0.0 } - - let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom - return max(0.0, visibleArea - totalAboveAndPinned) -} -``` - -### Call-site diffs - -**`snapToBounds(…)` — around [ListView.swift:1181-1185](../../submodules/Display/Source/ListView.swift):** -After the existing `stackFromBottomInsetItemFactor` adjustment of `effectiveInsets.top`, add the `pinToEdgeTopInset` block shown above. - -**`updateScroller(…)` — around [ListView.swift:1612-1616](../../submodules/Display/Source/ListView.swift):** -Same diff. - -**`deleteAndInsertItemsTransaction(…)` inset-transition block — around [ListView.swift:3162-3188](../../submodules/Display/Source/ListView.swift):** -Capture the pin inset before *either* `self.visibleSize` or `self.insets` is reassigned (i.e., before line 3165 in the current code); track whether the top-inset branch was taken; and after `self.insets` and `self.visibleSize` are updated, add the pin-inset delta to `offsetFix` if the top-inset branch was taken: - -```swift -let previousPinToEdgeTopInset = self.calculatePinToEdgeTopInset() -let previousVisibleSize = self.visibleSize -self.visibleSize = updateSizeAndInsets.size - -var offsetFix: CGFloat -var offsetFixUsesEffectiveTopInset = false -let insetDeltaOffsetFix: CGFloat = 0.0 -if (self.isTracking && !self.allowInsetFixWhileTracking) || isExperimentalSnapToScrollToItem { - offsetFix = 0.0 -} else if self.snapToBottomInsetUntilFirstInteraction { - offsetFix = -updateSizeAndInsets.insets.bottom + self.insets.bottom -} else { - offsetFix = updateSizeAndInsets.insets.top - self.insets.top - offsetFixUsesEffectiveTopInset = true -} - -offsetFix += additionalScrollDistance - -self.insets = updateSizeAndInsets.insets -self.headerInsets = updateSizeAndInsets.headerInsets ?? self.insets -self.scrollIndicatorInsets = updateSizeAndInsets.scrollIndicatorInsets ?? self.insets -self.itemOffsetInsets = updateSizeAndInsets.itemOffsetInsets -self.ensureTopInsetForOverlayHighlightedItems = updateSizeAndInsets.ensureTopInsetForOverlayHighlightedItems -self.visibleSize = updateSizeAndInsets.size - -if offsetFixUsesEffectiveTopInset { - let updatedPinToEdgeTopInset = self.calculatePinToEdgeTopInset() - offsetFix += updatedPinToEdgeTopInset - previousPinToEdgeTopInset -} -``` - -**`scrollToItem` handler — around [ListView.swift:3058-3104](../../submodules/Display/Source/ListView.swift):** -Inside the existing `for itemNode in self.itemNodes { if ... index == scrollToItem.index { ... } }` block, right after `let insets = self.insets` and before the `switch scrollToItem.position`: - -```swift -var isPinToEdgeTarget = false -if self.calculatePinToEdgeTopInset() > 0.0, - index >= 0, index < self.items.count, - self.items[index].pinToEdgeWithInset { - isPinToEdgeTarget = true - for otherNode in self.itemNodes { - guard let otherIndex = otherNode.index else { continue } - guard otherIndex >= 0, otherIndex < self.items.count else { continue } - if otherIndex < index, self.items[otherIndex].pinToEdgeWithInset { - isPinToEdgeTarget = false - break - } - } -} - -var offset: CGFloat -if isPinToEdgeTarget { - offset = (self.visibleSize.height - insets.bottom) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom -} else { - switch scrollToItem.position { - // … existing .bottom / .top / .center / .visible cases, unchanged … - } -} -``` - -The `isPinToEdgeTarget` check re-derives "smallest-index materialized flagged item" rather than factoring a shared helper, to keep the pin-to-edge API surface small (the existing `calculatePinToEdgeTopInset()` helper is the only public-within-file surface). The duplication is ~6 lines. - -No other source file is modified. `ListViewItem.pinToEdgeWithInset` stays declared where it already is ([ListViewItem.swift:80](../../submodules/Display/Source/ListViewItem.swift), default `false` via the protocol extension). - -## Behavioral consequences - -- **Pinning engaged** (items above shorter than available area): the pinned item's `maxY` lands on `visibleSize.height - insets.bottom`; virtual empty space sits above items[0] so the combined content fills the visible area. -- **Pinning disengaging** (items above reach the available area): `totalAboveAndPinned` grows with each insertion above until it meets `visibleArea`; at that point `pinTopAdjustment` is exactly 0 and the list scrolls normally. The transition through the threshold is continuous in `totalAboveAndPinned`, so there is no visual jump. -- **Drag / rubber-band / deceleration**: the pinned item behaves like any other item during gesture-driven scroll; on settle, `snapToBounds` returns the content to its resting position, which (while pinning is engaged) places the pinned item at the bottom edge. -- **Insertion at index 0 while pinning is engaged**: the pinned item's index increments by one; `totalAboveAndPinned` grows by the inserted item's height; `pinTopAdjustment` therefore shrinks by the same amount; and the inflated `effectiveInsets.top` shrinks by the same amount in turn. Working through the arithmetic: the pinned item's final `origin.y = effectiveInsets.top + (totalAboveAndPinned - pinned.height)` stays identical before and after, so the pinned item is visually stationary across the insertion. The newly-inserted item appears above it. -- **Flag toggle on an item**: update/layout path triggers `snapToBounds` and `updateScroller`; the helper recomputes. -- **Multiple flagged items**: only the smallest-index materialized flagged node anchors. Others render normally. -- **`stackFromBottom` + `pinToEdgeWithInset` both active**: both mechanisms contribute to `effectiveInsets.top` via `max(…)`; the larger contribution wins. No coordination logic is added. -- **Flagged item outside recycling window**: helper returns 0 at the `lowestPinnedIndex != Int.max` guard; pinning re-engages when the node re-materializes. -- **items[0] outside recycling window**: helper returns 0 at the `sawIndexZero` guard. This state can only arise when content above the pinned item has already exceeded the available area (pushing leading items out of the recycling window), at which point pinning should be inert anyway — the guard makes that explicit and protects against under-counting heights. -- **Empty list**: no `itemNode` has an `index`; both loops complete with `lowestPinnedIndex == Int.max`; returns 0. - -## Verification - -No unit tests exist in the project (per CLAUDE.md). Verification path: - -1. Full-project build: - ``` - 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 - ``` -2. Manual exercise in a consumer that sets `pinToEdgeWithInset = true` on one item. Confirm: the flagged item sits at the bottom edge; inserting non-flagged items at index 0 keeps the flagged item visually anchored; once items above fill the available area, further scrolling is ordinary. - -Because no existing item overrides `pinToEdgeWithInset` from its default `false`, the existing app surface is unaffected; any regression can only appear in a new consumer. - -## Risk - -The `effectiveInsets.top` contribution has to be computed identically at both call sites. Divergence (for example, `snapToBounds` adding the pin inset but `updateScroller` not) would cause the scroller's `contentSize` / `contentOffset` to disagree with the target scroll position produced by `snapToBounds`, producing scroll jumps. A shared helper — `calculatePinToEdgeTopInset` — and the identical diff applied at both sites is the defense against that. diff --git a/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md b/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md deleted file mode 100644 index 2109717f29..0000000000 --- a/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md +++ /dev/null @@ -1,237 +0,0 @@ -# Postbox → TelegramEngine refactor, Wave 3: MediaBox fetch/status/data facades + SaveToCameraRoll - -**Date:** 2026-04-18 -**Status:** Design approved; awaiting implementation plan. -**Predecessors:** Waves 1 and 2 (`docs/superpowers/specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md`, `docs/superpowers/plans/2026-04-17-mediaresource-to-enginemediaresource-wave-2.md`). - -## Goal - -1. Unblock the full-module de-Postboxing of `submodules/SaveToCameraRoll` (abandoned in Wave 2) by adding engine-side facades for the `mediaBox` methods it uses. -2. Migrate `SaveToCameraRoll`'s three public functions to use those facades, drop `import Postbox` from the module, and update all call sites. - -This wave follows the validated Wave-2 shape ("per-API migration, modify in place, update all call sites in one commit"), not the Wave-1 shape ("per-module Postbox drop"). - -## Non-goals - -- Migrating any caller file (`InstantPageUI`, `BrowserUI`, `GalleryUI`, `ShareController`, `TelegramUI`, etc.) to drop its `import Postbox`. Each imports Postbox for many unrelated reasons; this wave only changes how they invoke `SaveToCameraRoll`. -- Adding facades for other `mediaBox` methods beyond the three SaveToCameraRoll needs (`cachedResourceRepresentation`, `completedResourcePath`, `storeResourceData`, etc.). Additive work belongs in future waves when a consumer needs them. -- Wrapping `FetchResourceSourceType` / `FetchResourceError` — these remain Postbox types, exposed by the `fetch` facade as a documented accepted leak. SaveToCameraRoll does not inspect these values. -- Adding `.incremental(waitUntilFetchStatus:)` to the `data` facade, or any of `range` / `statsCategory` / `reportResultStatus` / `preferBackgroundReferenceRevalidation` / `continueInBackground` to the `fetch` facade. - -## Scope and inventory - -### New engine surface - -Three thin forwarding methods added to `TelegramEngine.Resources` in `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`. No new wrapper structs or classes. - -```swift -public extension TelegramEngine { - final class Resources { - // ...existing methods... - - public func fetch( - reference: MediaResourceReference, - userLocation: MediaResourceUserLocation, - userContentType: MediaResourceUserContentType - ) -> Signal { - return fetchedMediaResource( - mediaBox: self.account.postbox.mediaBox, - userLocation: userLocation, - userContentType: userContentType, - reference: reference - ) - } - - public func status( - resource: EngineMediaResource - ) -> Signal { - return self.account.postbox.mediaBox.resourceStatus(resource._asResource()) - |> map { EngineMediaResource.FetchStatus($0) } - } - - public func data( - resource: EngineMediaResource, - pathExtension: String?, - waitUntilFetchStatus: Bool - ) -> Signal { - return self.account.postbox.mediaBox.resourceData( - resource._asResource(), - pathExtension: pathExtension, - option: .complete(waitUntilFetchStatus: waitUntilFetchStatus) - ) - |> map { EngineMediaResource.ResourceData($0) } - } - } -} -``` - -Design choices: - -- **`data` takes a `waitUntilFetchStatus: Bool`**, not Postbox's `ResourceDataRequestOption` enum. SaveToCameraRoll only ever uses `.complete(waitUntilFetchStatus:)`. If a future consumer needs `.incremental(...)`, extend the facade at that point. -- **`fetch` takes only the 4 parameters SaveToCameraRoll uses.** `range`, `statsCategory`, `reportResultStatus`, `preferBackgroundReferenceRevalidation`, `continueInBackground` can be added additively when a consumer requires them. -- **`reference:` keeps the `MediaResourceReference` Postbox type.** Callers construct it inline via `mediaReference.resourceReference(resource)` and pass it without a local binding; no `import Postbox` is induced at the call site. -- **No wrapping of `FetchResourceSourceType` / `FetchResourceError`.** SaveToCameraRoll calls `.start()` on the `fetch` signal without inspecting the value; it does not import Postbox merely to use these types. Recorded here as an accepted leak. - -### SaveToCameraRoll public API changes - -The enum payload and three public function signatures change. Every caller breaks until updated. - -Before: - -```swift -public enum FetchMediaDataState { - case progress(Float) - case data(MediaResourceData) -} - -public func fetchMediaData( - context: AccountContext, postbox: Postbox, - userLocation: MediaResourceUserLocation, - customUserContentType: MediaResourceUserContentType? = nil, - mediaReference: AnyMediaReference, forceVideo: Bool = false -) -> Signal<(FetchMediaDataState, Bool), NoError> - -public func saveToCameraRoll( - context: AccountContext, postbox: Postbox, - userLocation: MediaResourceUserLocation, - customUserContentType: MediaResourceUserContentType? = nil, - mediaReference: AnyMediaReference, video: AnyMediaReference? = nil -) -> Signal - -public func copyToPasteboard( - context: AccountContext, postbox: Postbox, - userLocation: MediaResourceUserLocation, - mediaReference: AnyMediaReference -) -> Signal -``` - -After: - -```swift -public enum FetchMediaDataState { - case progress(Float) - case data(EngineMediaResource.ResourceData) -} - -public func fetchMediaData( - context: AccountContext, - userLocation: MediaResourceUserLocation, - customUserContentType: MediaResourceUserContentType? = nil, - mediaReference: AnyMediaReference, forceVideo: Bool = false -) -> Signal<(FetchMediaDataState, Bool), NoError> - -public func saveToCameraRoll( - context: AccountContext, - userLocation: MediaResourceUserLocation, - customUserContentType: MediaResourceUserContentType? = nil, - mediaReference: AnyMediaReference, video: AnyMediaReference? = nil -) -> Signal - -public func copyToPasteboard( - context: AccountContext, - userLocation: MediaResourceUserLocation, - mediaReference: AnyMediaReference -) -> Signal -``` - -### SaveToCameraRoll internal changes - -- `var resource: MediaResource?` → `var resource: TelegramMediaResource?` (TelegramCore protocol; matches CLAUDE.md cheat-sheet guidance). `representation.resource` and `file.resource` already return `TelegramMediaResource`, so no wrapping is needed at assignment. -- `fetchedMediaResource(mediaBox: postbox.mediaBox, …)` → `context.engine.resources.fetch(reference: mediaReference.resourceReference(resource), userLocation: userLocation, userContentType: userContentType)`. -- `postbox.mediaBox.resourceStatus(resource)` → `context.engine.resources.status(resource: EngineMediaResource(resource))`. The `switch status { case .Local … }` body is unchanged because `EngineMediaResource.FetchStatus` has the same cases (`.Local`, `.Remote(progress:)`, `.Fetching(isActive:progress:)`, `.Paused(progress:)`). -- `postbox.mediaBox.resourceData(resource, pathExtension: fileExtension, option: .complete(waitUntilFetchStatus: true))` → `context.engine.resources.data(resource: EngineMediaResource(resource), pathExtension: fileExtension, waitUntilFetchStatus: true)`. -- Local `MediaResourceData` bindings (`mainData: MediaResourceData?`, `videoData: MediaResourceData?`) and `case let .data(data):` destructurings → use `EngineMediaResource.ResourceData`. -- Field renames inside SaveToCameraRoll: `data.complete` → `data.isComplete`. `data.path` unchanged. `data.size` is not used internally. -- `import Postbox` removed from the file. - -### Call-site migration (23 sites, 14 files) - -Two mechanical edits per site. - -Edit A — drop the `postbox:` argument: - -```swift -// Before -saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: …, mediaReference: …) -// After -saveToCameraRoll(context: context, userLocation: …, mediaReference: …) -``` - -Edit B — update `FetchMediaDataState.data` field accesses at the ~7 sites that destructure `fetchMediaData` results: - -- `data.complete` → `data.isComplete` -- `data.path` → unchanged -- `data.size` → `data.availableSize` (if used; likely not) - -Inventory (captured 2026-04-18): - -| Module | File | Calls | -|---|---|---| -| InstantPageUI | `Sources/InstantPageControllerNode.swift` | 2 | -| LegacyMediaPickerUI | `Sources/LegacyAttachmentMenu.swift` | 2 (destructures) | -| LegacyMediaPickerUI | `Sources/LegacyAvatarPicker.swift` | 2 (destructures) | -| BrowserUI | `Sources/BrowserInstantPageContent.swift` | 2 | -| GalleryUI | `Sources/Items/ChatImageGalleryItem.swift` | 2 (one destructures) | -| GalleryUI | `Sources/Items/UniversalVideoGalleryItem.swift` | 3 | -| TelegramUI (MediaEditorScreen) | `Components/MediaEditorScreen/Sources/MediaEditorScreen.swift` | 1 (destructures) | -| TelegramUI (MediaEditorScreen) | `Components/MediaEditorScreen/Sources/EditStories.swift` | 1 (destructures) | -| TelegramUI (ChatQrCodeScreen) | `Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift` | 1 (destructures) | -| TelegramUI (StoryContainer) | `Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift` | 1 | -| TelegramUI (PeerInfoStoryGrid) | `Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift` | 1 | -| TelegramUI | `Sources/ChatInterfaceStateContextMenus.swift` | 1 | -| TelegramUI | `Sources/SaveMediaToFiles.swift` | 1 (destructures) | -| ShareController | `Sources/ShareController.swift` | 3 | - -**Execution-time re-inventory:** before editing any code, the executor must re-grep for `fetchMediaData|saveToCameraRoll|copyToPasteboard` call sites across `submodules/`. If the count or file list drifts meaningfully from this table, abandon editing and revise the plan. - -### Postbox-drop tally update - -- `SaveToCameraRoll` joins the tally of modules free of `import Postbox`. -- No caller file is expected to drop `import Postbox` in this wave. - -## Commit plan - -Two commits, landing in order on `refactor/postbox-to-engine-wave-3`. - -### C1 — `TelegramEngine.Resources: add fetch/status/data facades` - -- Touches only `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`. -- Adds the three methods from the "New engine surface" section above. No behavior changes; no consumer changes. -- Buildable in isolation. - -### C2 — `SaveToCameraRoll: drop import Postbox via engine.resources facades` - -Atomic; must land as one commit because signature changes break every unmigrated caller. - -- `submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift`: public signature changes, `FetchMediaDataState.data` payload switch, internal rewrites, `import Postbox` removal. -- All 23 call sites in the inventory table updated in the same commit. -- ~7 destructuring sites also get the `data.complete` → `data.isComplete` rename. - -## Build verification - -Per CLAUDE.md, the only verification available is a full project build. No unit tests exist in the repo. - -- After C1: full build. -- After C2: full build. - -Both builds use the standard command from `CLAUDE.md` (Telegram build recipe with `--configuration debug_sim_arm64`), prefixed with `source ~/.zshrc 2>/dev/null;` to pick up `TELEGRAM_CODESIGNING_GIT_PASSWORD`. - -## Risks and mitigations - -- **New call site appears between planning and execution.** Mitigation: re-grep at execution time before editing; abandon & revise if count drifts meaningfully. -- **`FetchResourceSourceType` / `FetchResourceError` are Postbox types.** Mitigation: SaveToCameraRoll never inspects these; future consumers that need to pattern-match will wrap these types in a later wave. -- **A consumer turns out to need a mediaBox facade not in this spec** (e.g., `cachedResourceRepresentation`). Mitigation: out of scope. Abandon that caller's migration; the facade commit still stands on its own. -- **`context.engine` unavailable at some call site.** Risk minimal: `AccountContext.engine` is a protocol requirement in `submodules/AccountContext/Sources/AccountContext.swift`, so it is universally available at any site that already has `context: AccountContext`. All 23 sites match. -- **ShareController:2406 uses a non-`context.account.postbox` Postbox.** At `submodules/ShareController/Sources/ShareController.swift:2406`, the call reads `let postbox = self.currentContext.stateManager.postbox` and passes that as `postbox:`. After migration, SaveToCameraRoll internally uses `context.account.postbox.mediaBox` via the engine. In the gated `ShareControllerAppAccountContext` path, `accountContext.context.account.stateManager` should match `self.currentContext.stateManager`, so the two postboxes are equivalent; verify this at execution time before editing. If they can diverge (e.g., during share-extension account switching), this specific call site must be abandoned with a recorded reason — the rest of the wave is unaffected. -- **Umbrella-type rule-2 compliance.** No `Postbox` / `Account` / `MediaBox` typealias is added. No new wrapper struct is introduced. ✅ - -## Abandonment criteria - -If any call site cannot be migrated mechanically — for example, it passes a non-`context.account.postbox` custom `Postbox`, or constructs a `MediaResourceReference` in a way that forces a retained `import Postbox` in a file the wave intends to de-Postbox — abandon that specific call site with a recorded reason in the plan. The facade commit (C1) still stands on its own; SaveToCameraRoll's internal migration still lands if at least the other callers migrate. If too many call sites abandon, abandon the whole wave and record lessons. - -## Expected outcome - -- `TelegramEngine.Resources` has three new thin forwarders. -- `SaveToCameraRoll` no longer imports Postbox. -- Running tally of Postbox-free consumer modules: Wave 1 cohort + `StickerPeekUI`, `PromptUI`, `PresentationDataUtils` (standalone) + `MapResourceToAvatarSizes` (Wave 2) + **`SaveToCameraRoll` (Wave 3)**. -- Zero behavior change. diff --git a/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-4-design.md b/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-4-design.md deleted file mode 100644 index a57c892b02..0000000000 --- a/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-4-design.md +++ /dev/null @@ -1,204 +0,0 @@ -# Postbox → TelegramEngine refactor, Wave 4: `TelegramEngine.Stickers.uploadSticker` facade migration - -**Date:** 2026-04-18 -**Status:** Design approved; awaiting implementation plan. -**Predecessors:** Waves 1–3. -- `docs/superpowers/specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md` -- `docs/superpowers/plans/2026-04-17-mediaresource-to-enginemediaresource-wave-2.md` -- `docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md` - -## Goal - -Migrate the public facade `TelegramEngine.Stickers.uploadSticker` so its signature and its return-enum payload no longer leak Postbox-domain types: - -- `peer: Peer → EnginePeer` -- `resource: MediaResource → EngineMediaResource` -- `thumbnail: MediaResource? → EngineMediaResource?` -- `UploadStickerStatus.complete(CloudDocumentMediaResource, String) → .complete(EngineMediaResource, String)` - -Follows the validated Wave-2 shape ("per-facade-API migration, modify in place, update call sites in the same commit"). - -## Non-goals - -- Migrating the caller files (`ImportStickerPackUI/Sources/ImportStickerPackController.swift`, `TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift`) to drop `import Postbox`. Each imports Postbox for unrelated reasons; this wave only changes how they invoke `uploadSticker`. -- Migrating other `TelegramEngine.Stickers` facades (e.g. `createStickerSet`, `addStickerToStickerSet`) that have similar Peer/MediaResource leaks. Future-wave work. -- Wrapping or renaming `CloudDocumentMediaResource` itself (it's a TelegramCore-defined class conforming to the `TelegramMediaResource` protocol). It stays usable internally; this wave just stops exposing it in the public enum payload. -- Changes to `_internal_uploadSticker`'s signature. It continues to take raw `Peer` and `MediaResource`, consistent with CLAUDE.md's "internal Postbox-facing layer stays raw" rule — with one intentional one-line exception documented below. - -## Scope - -### Core change: `UploadStickerStatus` enum - -In `submodules/TelegramCore/Sources/TelegramEngine/Stickers/ImportStickers.swift`: - -```swift -// Before (line 7-10) -public enum UploadStickerStatus { - case progress(Float) - case complete(CloudDocumentMediaResource, String) -} - -// After -public enum UploadStickerStatus { - case progress(Float) - case complete(EngineMediaResource, String) -} -``` - -`UploadStickerStatus` is both the public return type of the facade and the return type of `_internal_uploadSticker`. Rather than split it into two enums (one raw for the internal layer, one engine-wrapped for the public facade), this wave keeps one enum and wraps at the single `.complete(...)` construction site inside `_internal_uploadSticker` (line ~97 of the same file): - -```swift -// Before -return .single(.complete(uploadedResource, file.mimeType)) - -// After -return .single(.complete(EngineMediaResource(uploadedResource), file.mimeType)) -``` - -**This one-line construction of `EngineMediaResource` inside `_internal_uploadSticker` is a narrow, spec-allowed exception** to CLAUDE.md's "internal Postbox-facing stays raw" guideline. The alternative (splitting the enum) fragments a simple public surface and duplicates bookkeeping. `EngineMediaResource` is defined in `TelegramCore` and already accessible without additional imports. - -### Facade signature migration - -In `submodules/TelegramCore/Sources/TelegramEngine/Stickers/TelegramEngineStickers.swift`: - -```swift -// Before (line 85-87) -public func uploadSticker(peer: Peer, resource: MediaResource, thumbnail: MediaResource?, alt: String, dimensions: PixelDimensions, duration: Double?, mimeType: String) -> Signal { - return _internal_uploadSticker(account: self.account, peer: peer, resource: resource, thumbnail: thumbnail, alt: alt, dimensions: dimensions, duration: duration, mimeType: mimeType) -} - -// After -public func uploadSticker(peer: EnginePeer, resource: EngineMediaResource, thumbnail: EngineMediaResource?, alt: String, dimensions: PixelDimensions, duration: Double?, mimeType: String) -> Signal { - return _internal_uploadSticker(account: self.account, peer: peer._asPeer(), resource: resource._asResource(), thumbnail: thumbnail?._asResource(), alt: alt, dimensions: dimensions, duration: duration, mimeType: mimeType) -} -``` - -The facade bridges all three type swaps (`peer._asPeer()`, `resource._asResource()`, `thumbnail?._asResource()`). `_internal_uploadSticker`'s own signature does not change. - -### Call-site migration (2 sites, 2 files) - -**1. `submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift:91`** — argument simplification and destructure simplification: - -```swift -// Before -signals.append(strongSelf.context.engine.stickers.uploadSticker(peer: peer, resource: resource._asResource(), thumbnail: nil, alt: sticker.emojis.first ?? "", dimensions: PixelDimensions(width: 512, height: 512), duration: nil, mimeType: sticker.mimeType) - |> map { result -> (UUID, StickerVerificationStatus, EngineMediaResource?) in - switch result { - case .progress: - return (sticker.uuid, .loading, nil) - case let .complete(resource, mimeType): - if ["application/x-tgsticker", "video/webm"].contains(mimeType) { - return (sticker.uuid, .verified, EngineMediaResource(resource)) - } else { - return (sticker.uuid, .declined, nil) - } - } - } -``` - -becomes: - -```swift -signals.append(strongSelf.context.engine.stickers.uploadSticker(peer: peer, resource: resource, thumbnail: nil, alt: sticker.emojis.first ?? "", dimensions: PixelDimensions(width: 512, height: 512), duration: nil, mimeType: sticker.mimeType) - |> map { result -> (UUID, StickerVerificationStatus, EngineMediaResource?) in - switch result { - case .progress: - return (sticker.uuid, .loading, nil) - case let .complete(resource, mimeType): - if ["application/x-tgsticker", "video/webm"].contains(mimeType) { - return (sticker.uuid, .verified, resource) - } else { - return (sticker.uuid, .declined, nil) - } - } - } -``` - -Three changes: -- `peer` in this enclosing closure is a raw `Peer` (from `postbox.loadedPeerWithId(...)`, whose signature is `Signal`). Currently the facade takes `Peer` so the identifier is passed as-is. After the facade moves to `EnginePeer`, wrap at the call: `peer: EnginePeer(peer)`. -- `resource._asResource()` → `resource` (the local `resource` is already `EngineMediaResource`). -- `EngineMediaResource(resource)` → `resource` in the destructure (the destructured `resource` is now `EngineMediaResource` directly). - -**2. `submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift:8099`** — unwrap removed, wraps added: - -```swift -// Before -return context.engine.stickers.uploadSticker(peer: peer._asPeer(), resource: resource, thumbnail: file.previewRepresentations.first?.resource, alt: "", dimensions: dimensions, duration: duration, mimeType: mimeType) - -// After -return context.engine.stickers.uploadSticker(peer: peer, resource: EngineMediaResource(resource), thumbnail: file.previewRepresentations.first.flatMap { EngineMediaResource($0.resource) }, alt: "", dimensions: dimensions, duration: duration, mimeType: mimeType) -``` - -The enclosing block is a nested `mapToSignal` chain starting at line ~8084. The `UploadStickerStatus` payload migration cascades through several lines in this block: - -- **Line 8097** — `.complete(resource, mimeType)` where `resource` was narrowed via `if let resource = resource as? CloudDocumentMediaResource`. After the payload migration this `.complete(...)` constructor takes `EngineMediaResource`, so wrap: `.complete(EngineMediaResource(resource), mimeType)`. -- **Line 8099** — the main facade call. `peer._asPeer()` → `peer` (the local `peer` is an `EnginePeer`, confirmed by the current `_asPeer()`); `resource` → `EngineMediaResource(resource)` (the local `resource` here is a raw `MediaResource` from the outer enum's `.complete(resource)` case); `file.previewRepresentations.first?.resource` → `file.previewRepresentations.first.flatMap { EngineMediaResource($0.resource) }`. -- **Line 8105** — `case let .complete(resource, _):` destructures the inner `UploadStickerStatus`. After migration, `resource` has type `EngineMediaResource`. -- **Line 8106** — `stickerFile(resource: resource, thumbnailResource: file.previewRepresentations.first?.resource, …)` — `stickerFile` is declared with `resource: TelegramMediaResource, thumbnailResource: TelegramMediaResource?, …`, so unwrap: `stickerFile(resource: resource._asResource(), thumbnailResource: file.previewRepresentations.first?.resource, …)`. The `thumbnailResource` argument is already a `TelegramMediaResource?` and needs no change. -- **Line 8119** — `ImportSticker(resource: .standalone(resource: resource), …)`. `MediaResourceReference.standalone(resource:)` takes `MediaResource`, so unwrap: `.standalone(resource: resource._asResource())`. -- **Line 8138** — same as 8119 inside the `.addToStickerPack` case. -- **Line 8178** — outer-handler destructure `case let .complete(resource, _):`. After migration, `resource` is `EngineMediaResource`. -- **Line 8180** — `stickerFile(resource: resource, thumbnailResource: file.previewRepresentations.first?.resource, size: resource.size ?? 0, …)`. Two unwrap sites here: `resource: resource._asResource()` for the first argument, and `size: resource._asResource().size ?? 0` for the size read (`EngineMediaResource` does not expose `.size`; only `MediaResource` does). Introduce a local `let rawResource = resource._asResource()` at the top of the `case` to avoid calling `_asResource()` twice. - -**Execution-time check:** before editing MediaEditorScreen, re-read the full block (roughly lines 8080–8190) and the `stickerFile` function signature (line 9196) to confirm these assumptions. If any additional downstream use of the destructured `resource` appears that wasn't caught above, decide inline whether it needs `._asResource()` or can take `EngineMediaResource` directly. - -## Execution-time re-inventory - -Before editing, re-grep to catch any new call sites: - -```bash -grep -rnE "\.uploadSticker\(" submodules --include="*.swift" | grep -v "/TelegramEngine/Stickers/" -``` - -Expected output lines that pattern-match the facade (not the `MediaEditorScreen`'s private `self.uploadSticker(file, action:)` helper): - -- `submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift:91` -- `submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift:8099` - -Other `self.uploadSticker(...)` lines in `MediaEditorScreen.swift` (7771, 7808, 7852, 7896, 7913, 7931, 8019) are calls to a private helper method, not the engine facade — leave those untouched. - -If the facade call-site count drifts beyond these two, stop and revise the plan. - -## Commit plan - -One atomic commit covering all four files: - -**C1 — `TelegramEngine.Stickers.uploadSticker: migrate to EnginePeer + EngineMediaResource`** - -- `submodules/TelegramCore/Sources/TelegramEngine/Stickers/ImportStickers.swift` — enum payload + one `.complete(...)` construction site. -- `submodules/TelegramCore/Sources/TelegramEngine/Stickers/TelegramEngineStickers.swift` — facade signature. -- `submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift` — 1 call site + destructure. -- `submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift` — 1 call site. - -Atomicity is required because the enum payload change, facade signature change, and call-site changes are all mutually breaking. - -**C2 — `CLAUDE.md: record wave-4 outcome`** - -- Add a "Wave 4 outcome (2026-04-18)" subsection documenting the facade migrated. -- Remove the `uploadSticker` bullet from "Known future-wave candidates". -- No change to the "Modules currently free of `import Postbox`" running tally (no module is de-Postboxed in this wave). - -## Build verification - -One full project build after the edits, before committing C1. The bazel command from CLAUDE.md, prefixed with `source ~/.zshrc 2>/dev/null;` to pick up `TELEGRAM_CODESIGNING_GIT_PASSWORD`. - -## Risks and mitigations - -- **Risk:** a new call site of `engine.stickers.uploadSticker` appears between planning and execution. **Mitigation:** the re-grep above catches this; abandon or extend the plan if so. -- **Risk:** `UploadStickerStatus.complete` is destructured somewhere that accesses `CloudDocumentMediaResource`-specific members. **Mitigation:** grep confirms both known sites use the value generically (wrap or assign directly); no `.stringRepresentation`-style `CloudDocumentMediaResource`-specific access is expected. If found, abandon the wave. -- **Risk:** `MediaEditorScreen:8099`'s `resource` local is already an `EngineMediaResource`. **Mitigation:** inspect the enclosing function at execution time and adjust the wrap accordingly. -- **Risk:** the one-line `EngineMediaResource(uploadedResource)` wrap inside `_internal_uploadSticker` is a narrow deviation from "Postbox-facing layer stays raw". **Mitigation:** spec explicitly calls this out. The alternative (splitting the enum) is worse for a single-line gain; documented and accepted. -- **Rule-2 compliance:** no `Postbox`/`Account`/`MediaBox` typealias introduced; no new wrapper struct. ✅ - -## Abandonment criteria - -If any call-site edit turns out to require a cascading type change elsewhere (e.g. a struct field or signature typed as `CloudDocumentMediaResource`), abandon the wave and record the reason. The single-commit shape means either the whole thing lands or none of it does. - -## Expected outcome - -- `TelegramEngine.Stickers.uploadSticker`'s public surface no longer references `Peer`, `MediaResource`, or `CloudDocumentMediaResource`. -- `UploadStickerStatus.complete`'s payload becomes `(EngineMediaResource, String)`. -- `_internal_uploadSticker`'s signature stays as-is (raw `Peer`/`MediaResource`), with one inline `EngineMediaResource(uploadedResource)` wrap at the result-construction site. -- Two call sites updated; no caller module becomes Postbox-free. -- CLAUDE.md records the outcome and removes the `uploadSticker` entry from "Known future-wave candidates". -- Zero behavior change. diff --git a/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-5-design.md b/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-5-design.md deleted file mode 100644 index 3442a2ba95..0000000000 --- a/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-5-design.md +++ /dev/null @@ -1,270 +0,0 @@ -# Postbox → TelegramEngine refactor, Wave 5: `uploadSecureIdFile` facade + SecureId context migration - -**Date:** 2026-04-18 -**Status:** Design approved; awaiting implementation plan. -**Predecessors:** Waves 1–4. - -## Goal - -Complete the last explicitly-named future-wave candidate from CLAUDE.md: migrate `uploadSecureIdFile`'s public surface to stop leaking the `Postbox`/`Network`/`MediaResource` Postbox-domain types, and refactor its caller `SecureIdVerificationDocumentsContext` so the caller stops importing Postbox. - -- `uploadSecureIdFile(context: SecureIdAccessContext, postbox: Postbox, network: Network, resource: MediaResource)` → `(context:, engine: TelegramEngine, resource: EngineMediaResource)`. -- `SecureIdVerificationDocumentsContext` drops its `postbox: Postbox` + `network: Network` stored properties, takes `engine: TelegramEngine` instead, and drops `import Postbox` from the file. -- The one instantiation site updates to pass `engine: self.context.engine`. - -## Non-goals - -- Migrating `SecureIdAccessContext`, `SecureIdVerificationDocument`, `SecureIdVerificationLocalDocument`, or other SecureId-domain types. They live in TelegramCore (not Postbox) already and do not leak. -- Migrating other SecureId-family functions in TelegramCore (e.g., `_internal_requestSecureIdVerification`, etc.). Future-wave work. -- Dropping `import Postbox` from `SecureIdDocumentFormControllerNode.swift`. That file imports Postbox for unrelated reasons. - -## Scope and inventory - -### Files touched (3 in the code commit) - -1. `submodules/TelegramCore/Sources/TelegramEngine/SecureId/UploadSecureIdFile.swift` — facade signature change, 3-line body bridge. -2. `submodules/PassportUI/Sources/SecureIdVerificationDocumentsContext.swift` — stored props, constructor, internal call, drop `import Postbox`. -3. `submodules/PassportUI/Sources/SecureIdDocumentFormControllerNode.swift` — one-line instantiation call. - -### Facade signature migration (`UploadSecureIdFile.swift`) - -Before: - -```swift -public func uploadSecureIdFile(context: SecureIdAccessContext, postbox: Postbox, network: Network, resource: MediaResource) -> Signal { - return postbox.mediaBox.resourceData(resource) - |> mapError { _ -> UploadSecureIdFileError in - } - |> mapToSignal { next -> Signal in - if !next.complete { - return .complete() - } - - guard let data = try? Data(contentsOf: URL(fileURLWithPath: next.path)) else { - return .fail(.generic) - } - - guard let encryptedData = encryptedSecureIdFile(context: context, data: data) else { - return .fail(.generic) - } - - return multipartUpload(network: network, postbox: postbox, source: .data(encryptedData.data), encrypt: false, tag: TelegramMediaResourceFetchTag(statsCategory: .image, userContentType: .image), hintFileSize: nil, hintFileIsLarge: false, forceNoBigParts: false) - |> mapError { _ -> UploadSecureIdFileError in - return .generic - } - |> mapToSignal { result -> Signal in - switch result { - case let .progress(value): - return .single(.progress(value)) - case let .inputFile(.inputFile(fileData)): - return .single(.result(UploadedSecureIdFile(id: fileData.id, parts: fileData.parts, md5Checksum: fileData.md5Checksum, fileHash: encryptedData.hash, encryptedSecret: encryptedData.encryptedSecret), encryptedData.data)) - default: - return .fail(.generic) - } - } - } -} -``` - -After: - -```swift -public func uploadSecureIdFile(context: SecureIdAccessContext, engine: TelegramEngine, resource: EngineMediaResource) -> Signal { - return engine.account.postbox.mediaBox.resourceData(resource._asResource()) - |> mapError { _ -> UploadSecureIdFileError in - } - |> mapToSignal { next -> Signal in - if !next.complete { - return .complete() - } - - guard let data = try? Data(contentsOf: URL(fileURLWithPath: next.path)) else { - return .fail(.generic) - } - - guard let encryptedData = encryptedSecureIdFile(context: context, data: data) else { - return .fail(.generic) - } - - return multipartUpload(network: engine.account.network, postbox: engine.account.postbox, source: .data(encryptedData.data), encrypt: false, tag: TelegramMediaResourceFetchTag(statsCategory: .image, userContentType: .image), hintFileSize: nil, hintFileIsLarge: false, forceNoBigParts: false) - |> mapError { _ -> UploadSecureIdFileError in - return .generic - } - |> mapToSignal { result -> Signal in - switch result { - case let .progress(value): - return .single(.progress(value)) - case let .inputFile(.inputFile(fileData)): - return .single(.result(UploadedSecureIdFile(id: fileData.id, parts: fileData.parts, md5Checksum: fileData.md5Checksum, fileHash: encryptedData.hash, encryptedSecret: encryptedData.encryptedSecret), encryptedData.data)) - default: - return .fail(.generic) - } - } - } -} -``` - -Three substantive body changes, all in line with the CLAUDE.md rule that "internal Postbox-facing stays raw" — the body is inside TelegramCore itself so it accesses raw Postbox types through `engine.account.postbox` without going through the wave-3 facades: - -- `postbox.mediaBox.resourceData(resource)` → `engine.account.postbox.mediaBox.resourceData(resource._asResource())` (unwrap the engine resource before handing to raw MediaBox). -- `network: network` → `network: engine.account.network`. -- `postbox: postbox` → `postbox: engine.account.postbox`. - -The `_internal_*` convention does not apply here because `uploadSecureIdFile` is itself the facade — there is no separate raw-typed `_internal_uploadSecureIdFile` helper, and this wave does not introduce one. The function continues to have a single definition serving both internal TelegramCore wiring and consumer use. - -### Caller-class migration (`SecureIdVerificationDocumentsContext.swift`) - -Before: - -```swift -import Foundation -import Postbox -import TelegramCore -import SwiftSignalKit - -private final class DocumentContext { - private let disposable: Disposable - - init(disposable: Disposable) { - self.disposable = disposable - } - - deinit { - self.disposable.dispose() - } -} - -final class SecureIdVerificationDocumentsContext { - private let context: SecureIdAccessContext - private let postbox: Postbox - private let network: Network - private let update: (Int64, SecureIdVerificationLocalDocumentState) -> Void - private var contexts: [Int64: DocumentContext] = [:] - private(set) var uploadedFiles: [Data: Data] = [:] - - init(postbox: Postbox, network: Network, context: SecureIdAccessContext, update: @escaping (Int64, SecureIdVerificationLocalDocumentState) -> Void) { - self.postbox = postbox - self.network = network - self.context = context - self.update = update - } - - func stateUpdated(_ documents: [SecureIdVerificationDocument]) { - // ... - disposable.set((uploadSecureIdFile(context: self.context, postbox: self.postbox, network: self.network, resource: info.resource) - // ... - } -} -``` - -After: - -```swift -import Foundation -import TelegramCore -import SwiftSignalKit - -private final class DocumentContext { - private let disposable: Disposable - - init(disposable: Disposable) { - self.disposable = disposable - } - - deinit { - self.disposable.dispose() - } -} - -final class SecureIdVerificationDocumentsContext { - private let context: SecureIdAccessContext - private let engine: TelegramEngine - private let update: (Int64, SecureIdVerificationLocalDocumentState) -> Void - private var contexts: [Int64: DocumentContext] = [:] - private(set) var uploadedFiles: [Data: Data] = [:] - - init(engine: TelegramEngine, context: SecureIdAccessContext, update: @escaping (Int64, SecureIdVerificationLocalDocumentState) -> Void) { - self.engine = engine - self.context = context - self.update = update - } - - func stateUpdated(_ documents: [SecureIdVerificationDocument]) { - // ... - disposable.set((uploadSecureIdFile(context: self.context, engine: self.engine, resource: EngineMediaResource(info.resource)) - // ... - } -} -``` - -Changes: - -1. Drop `import Postbox` (line 2). -2. Replace `private let postbox: Postbox` and `private let network: Network` with `private let engine: TelegramEngine`. -3. Constructor: `postbox:, network:, context:, update:` → `engine:, context:, update:`. -4. Constructor body: `self.postbox = postbox; self.network = network` → `self.engine = engine`. -5. Inside `stateUpdated`: `postbox: self.postbox, network: self.network` → `engine: self.engine`; `resource: info.resource` → `resource: EngineMediaResource(info.resource)` (wrap; `info.resource` is `TelegramMediaResource` per `SecureIdVerificationLocalDocument` definition). - -`DocumentContext` inner class is untouched. Other methods in the file are untouched. - -### Instantiation-site edit (`SecureIdDocumentFormControllerNode.swift`) - -Single line, [line 2172](submodules/PassportUI/Sources/SecureIdDocumentFormControllerNode.swift#L2172): - -```swift -// Before -self.uploadContext = SecureIdVerificationDocumentsContext(postbox: self.context.account.postbox, network: self.context.account.network, context: self.secureIdContext, update: { id, state in - -// After -self.uploadContext = SecureIdVerificationDocumentsContext(engine: self.context.engine, context: self.secureIdContext, update: { id, state in -``` - -`self.context` is the outer `AccountContext`. `self.context.engine` is the `TelegramEngine` (universally available via the `AccountContext` protocol). `self.secureIdContext` is the unrelated inner `SecureIdAccessContext` — kept as the `context:` argument. - -## Execution-time re-inventory - -Before editing, re-grep to catch any new call sites: - -```bash -grep -rnE "uploadSecureIdFile\(" submodules --include="*.swift" | grep -v "/SecureId/" -grep -rnE "SecureIdVerificationDocumentsContext\(" submodules --include="*.swift" | grep -v "final class SecureIdVerificationDocumentsContext" -``` - -Expected: exactly 1 match for each — `SecureIdVerificationDocumentsContext.swift:43` and `SecureIdDocumentFormControllerNode.swift:2172` respectively. If either count has drifted, stop and revise the plan. - -## Commit plan - -**C1 — `SecureId: migrate uploadSecureIdFile + context to TelegramEngine`** (atomic) - -- All three files listed above, landing together. - -**C2 — `CLAUDE.md: record wave-5 outcome`** - -- Add `SecureIdVerificationDocumentsContext` to the "Modules currently free of `import Postbox`" running tally. -- Add a "Wave 5 outcome (2026-04-18)" subsection describing the migration. -- Remove the `uploadSecureIdFile` bullet from "Known future-wave candidates". After this, only the 4 permanently-blocked classes remain. - -## Build verification - -One full project build after C1's edits, before committing. Bazel command from CLAUDE.md with `source ~/.zshrc 2>/dev/null;` prefix. - -## Risks and mitigations - -- **Risk:** an additional call site of `uploadSecureIdFile` appears between planning and execution. **Mitigation:** the execution-time re-grep catches this. Expected 1 match. -- **Risk:** `SecureIdDocumentFormControllerNode.swift`'s `self.context` isn't an `AccountContext` at the instantiation site. **Mitigation:** confirm at execution time. The `AccountContext` protocol mandates `var engine: TelegramEngine { get }`, so any concrete `AccountContext` has it. -- **Risk:** behavior regression from `multipartUpload(network: engine.account.network, postbox: engine.account.postbox, …)`. **Mitigation:** these are the same underlying instances as the pre-migration `self.network` / `self.postbox` values (both originate from `self.context.account.network` / `.postbox`). Zero behavior change. -- **Risk:** after `import Postbox` is dropped from `SecureIdVerificationDocumentsContext.swift`, an implicit `Network` type (used elsewhere in the file?) fails to resolve. **Mitigation:** the file's only `Network` usage is in the stored `private let network` and the constructor parameter — both removed. No other `Network` reference survives. -- **Rule-2 compliance:** no `Postbox`/`Account`/`MediaBox` typealias introduced. No new wrapper struct. The facade body's `engine.account.postbox.mediaBox` and `engine.account.network` are internal expressions inside TelegramCore (not public surface). ✅ - -## Abandonment criteria - -If any of the 3 files cannot be migrated mechanically (e.g. `SecureIdDocumentFormControllerNode.swift`'s enclosing class doesn't have an `AccountContext`), abandon the wave and record the reason. The one-commit atomic shape means either the whole thing lands or none of it does. - -## Expected outcome - -- `uploadSecureIdFile`'s public signature references neither `Postbox` nor `Network` nor `MediaResource`. -- `SecureIdVerificationDocumentsContext` no longer imports Postbox and joins the Postbox-free running tally. -- `SecureIdDocumentFormControllerNode.swift` continues to import Postbox for unrelated reasons (no tally impact). -- `uploadSecureIdFile` bullet is removed from CLAUDE.md's "Known future-wave candidates"; after this wave, only the 4 permanently-blocked `TelegramMediaResource`-conforming classes remain in the candidate list. -- Full build succeeds in `debug_sim_arm64`. -- Zero behavior change. diff --git a/docs/superpowers/specs/2026-04-19-postbox-to-telegramengine-wave-6-design.md b/docs/superpowers/specs/2026-04-19-postbox-to-telegramengine-wave-6-design.md deleted file mode 100644 index 9de9c52aad..0000000000 --- a/docs/superpowers/specs/2026-04-19-postbox-to-telegramengine-wave-6-design.md +++ /dev/null @@ -1,134 +0,0 @@ -# Postbox → TelegramEngine refactor, Wave 6: unused `import Postbox` batch sweep - -**Date:** 2026-04-19 -**Status:** Design approved; awaiting implementation plan. -**Predecessors:** Waves 1–5. - -## Goal - -Clear `import Postbox` lines that have become unused across consumer submodules. Previous waves migrated facades and caller-side usages; in many files the last semantic use of a Postbox type was removed but the `import Postbox` line remained. This wave identifies and removes all such dangling imports in a single build-verified commit. - -Unlike waves 1–5, this is not a per-facade or per-module migration. It's a large-blast-radius, zero-semantic-change sweep where the project build is the safety net: anything that compiles is definitionally safe to drop. - -## Non-goals - -- Migrating any facade API or adding typealiases, wrappers, or `engine` plumbing. Pure deletion. -- Touching files inside `submodules/TelegramCore/`, `submodules/Postbox/`, or `submodules/TelegramApi/`. Their `import Postbox` lines are structural, not cleanup-eligible. -- Dropping `import Postbox` from files whose Postbox uses are indirect but real (`Media`, `ItemCollectionId`, `Peer` as a protocol not yet typealiased, etc.). The build tells us which these are. -- Dropping any other unused imports (Foundation, UIKit, etc.). Postbox only. -- Resolving or editing `@_exported import Postbox` lines. Only the plain `import Postbox` on its own line is in scope. - -## Scope - -### Candidate discovery - -Candidate list is generated by: - -```bash -grep -rl "^import Postbox$" submodules --include="*.swift" \ - | grep -vE "/(TelegramCore|Postbox|TelegramApi)/" -``` - -Expected candidate count: on the order of 200–300 files. Only a fraction are actually unused imports; the rest need their `import Postbox` restored after speculative drop. - -### Methodology: speculative-drop + build-verify - -1. **Snapshot** every candidate file's current content. Any of the following is acceptable: - - Copy files to a temp directory keyed by relative path. - - Record the candidate file list plus a single `git stash --keep-index` that captures the pre-edit state. - - Simply rely on `git checkout -- ` to restore, since every snapshot is the committed state at branch HEAD before the wave. - The last option is simplest and chosen for this wave. -2. **Speculative drop:** remove the `import Postbox` line from every candidate. Exact edit: delete the full line matching `^import Postbox$` (not `@_exported import Postbox`, not `import Postbox // comment` — the plain form only). -3. **Full build.** Expect some compile errors — these identify files that actually need the import. -4. **Parse errors.** Each Swift compile error begins with `::: error:`. Collect the set of unique files that have any error. Each of these gets `import Postbox` restored via `git checkout -- `. -5. **Rebuild.** Goal: clean success. -6. **Iterate.** If new errors surface (rare — should only happen when a symbol was exported transitively), restore those files too. Hard cap: 3 iterations. If iteration 3 still fails, abandon and revert the whole wave. -7. **Stage and commit** all surviving per-file diffs (each will be a single-line deletion) as one atomic C1 commit. - -### Error-parsing discipline - -Only restore a file for one of these error categories: - -- `cannot find type 'X' in scope` where `X` is a Postbox-exported symbol. -- `use of unresolved identifier 'X'` where `X` is a Postbox-exported symbol. -- `cannot find 'X' in scope` for Postbox symbols. -- `no such module 'Postbox'` — shouldn't occur unless Bazel deps are broken; if it does, halt and investigate. - -Do NOT restore imports for: - -- Codesign / dependency-graph failures unrelated to Swift compilation. -- Errors in files that weren't among the candidate set (those indicate cascading breakage — halt and investigate). -- Warnings about unused imports (those are the OPPOSITE signal — keep the drop). - -### Automation - -Because the candidate set is large (~200+ files), manual editing is impractical. Use a short helper script (plain bash + `sed`) to: - -1. Write the candidate list to `/tmp/wave-6-candidates.txt`. -2. Run `sed -i '' '/^import Postbox$/d' ` for each candidate. -3. Run the full build, capturing stderr. -4. Extract file paths from error lines. -5. `git checkout --` those files. -6. Rebuild, extract more error paths, repeat. - -Script-wise it's 20–30 lines of bash. The plan will include the exact commands. - -### Out-of-scope files - -- `submodules/TelegramCore/` — never touched. -- `submodules/Postbox/` — never touched. -- `submodules/TelegramApi/` — never touched. -- Files with `@_exported import Postbox` — never touched (but the regex `^import Postbox$` would not match them anyway). - -## Commit plan - -**C1 — `Drop unused import Postbox from N consumer files`** (atomic, one commit, build-verified) - -- All surviving per-file deletions. Each file's only change is a single-line deletion. -- Commit message notes the count (N) and confirms the build-verified methodology. - -**C2 — `CLAUDE.md: record wave-6 outcome and unused-import-sweep methodology`** - -- New "Wave 6 outcome (2026-04-19)" subsection. -- **New permanent guidance subsection** added under "Wave-selection guidance" (not tied to wave 6's specific results), capturing the methodology for future re-runs. Text along the lines of: - - > **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)/"` to generate candidates. - > 2. `sed -i '' '/^import Postbox$/d' ` to speculatively drop the import from all candidates. - > 3. Run a full project build. Parse `::: error:` lines to identify files that need the import restored. Restore via `git checkout -- `. - > 4. Rebuild. Iterate up to 3 times. - > 5. Commit surviving drops as one atomic commit. - > - > Re-run after every 2–3 facade-migration waves to convert accumulated cleanup into tally wins. - -- **Running tally update:** add to the "Modules currently free of `import Postbox`" list any module where, after the sweep, no file contains `import Postbox`. Module-level inclusion is stricter than per-file cleanup — only counts when ALL Swift files in a module are clean. - -## Build verification - -- Iteration 1: full build with all candidates dropped. Expect errors. -- Iteration 2 (and optionally 3): full build with failed files restored. -- Final iteration: clean build, required before commit. - -Use the standard CLAUDE.md build command, prefixed with `source ~/.zshrc 2>/dev/null;`. - -## Risks and mitigations - -- **Very long candidate list causes a very long first build iteration.** Bazel will recompile any module whose sources changed, plus downstream dependents. Nearly every consumer module is dropping at least one file's import, so the first build touches most modules. Mitigation: accept the cost; subsequent iterations only touch files where errors surfaced — far fewer recompilations. Total: expect 2 full project rebuilds. -- **Error cascading: a single missing import in an upstream module breaks many downstream files.** Restoring the upstream file's import may silence a large batch of errors at once. Mitigation: restore one pass at a time, rebuild, reassess. -- **Parser brittleness: build output format shifts.** Swift/Bazel output is stable, but diagnostic-rendering flags could differ. Mitigation: after iteration 1, visually inspect a handful of error lines to confirm the `::: error:` pattern holds before automating iteration 2. -- **Stashing/snapshot failure** leaves the working tree in a half-dropped state. Mitigation: since every snapshot is branch HEAD, `git checkout -- ` always restores correctly. If the working tree is hopelessly messed up, `git checkout -- .` restores everything from HEAD — the whole wave can be safely restarted from scratch with zero loss. -- **Hidden `@_exported import Postbox` would bypass the sweep without being touched.** Intentional: those re-export Postbox and must stay. The `^import Postbox$` regex matches only plain imports. -- **Rule-2 compliance:** no new typealiases, no wrapper structs, no public API changes. ✅ - -## Abandonment criteria - -- After 3 iterations, if new errors keep surfacing, the sweep's underlying assumption (per-file isolation) is broken for some module. Abandon: `git checkout -- .`, and record the blocker in CLAUDE.md's wave-6 outcome (not as a success). Consider manual per-file exploration in a future wave. -- If any iteration produces an error that isn't "cannot find type" / "use of unresolved identifier" / similar — halt, investigate, do not blindly restore. - -## Expected outcome - -- Dozens of `import Postbox` lines removed, all build-verified. -- Some consumer modules join the "Postbox-free" running tally when their last Postbox-importing file is swept. -- CLAUDE.md records the outcome and, for future waves, captures the methodology as permanent guidance so subsequent unused-import sweeps can be triggered any time imports accumulate. -- Zero behavior change. diff --git a/docs/superpowers/specs/2026-04-20-decrypt-match-python-port-design.md b/docs/superpowers/specs/2026-04-20-decrypt-match-python-port-design.md deleted file mode 100644 index e8a654b4e5..0000000000 --- a/docs/superpowers/specs/2026-04-20-decrypt-match-python-port-design.md +++ /dev/null @@ -1,89 +0,0 @@ -# Pure-Python port of `decrypt.rb` for fastlane match - -## Goal - -Drop the Ruby toolchain dependency from the iOS build. Replace the `ruby build-system/decrypt.rb` call in `BuildConfiguration.py:110` with a self-contained Python 3 implementation. No new third-party dependencies (no `cryptography` package, no Ruby). - -## Current state - -- `build-system/decrypt.rb` (115 lines) implements fastlane match's V1 (AES-256-CBC via `pkcs5_keyivgen` with MD5→SHA256 fallback) and V2 (AES-256-GCM with PBKDF2-derived key/iv/AAD + auth tag) decryption. -- `BuildConfiguration.py:103-118`'s `decrypt_codesigning_directory_recursively` shells out via `os.system('ruby build-system/decrypt.rb …')` per file. -- `build-system/Make/DecryptMatch.py` already exists as an aspirational Python port but is broken — its V2 implementation writes a literal placeholder string (`b"TEST_DECRYPTED_CONTENT"`) and the call site in `BuildConfiguration.py:115` is commented out. -- The production fastlane repo at `git@gitlab.com:peter-iakovlev/fastlanematch.git` stores files in V2 format (verified: base64 prefix decodes to `match_encrypted_v2__`). V2 must work. - -## Constraints - -- Stock macOS `python3` (3.9.6). Only Python stdlib may be used (`hashlib`, `hmac`, `base64`, `os`). -- Apple-shipped `openssl enc` CLI rules out the shell-out path for V2 because it does not accept AAD for GCM. -- The Ruby script's semantics are authoritative; the port must be byte-identical on the existing repo contents. - -## Approach - -Rewrite `build-system/Make/DecryptMatch.py` from scratch as a pure-Python AES implementation. - -**AES-256 primitive.** Standard tables-based implementation: -- `_SBOX` / `_INV_SBOX` (256 bytes each), `_RCON` (10 bytes). -- `_key_expansion(key)` → 15 × 16-byte round keys (Nk=8, Nr=14, Nb=4 for AES-256). -- `_aes_encrypt_block(block, rks)` and `_aes_decrypt_block(block, rks)` operating on 16-byte state via SubBytes / ShiftRows / MixColumns (and their inverses) plus AddRoundKey. -- MixColumns via the standard `xtime`-based GF(2^8) multiply. - -**V1 — AES-256-CBC with OpenSSL's `EVP_BytesToKey`.** Ruby's `pkcs5_keyivgen(password, salt, 1, hash)` is `EVP_BytesToKey` with `count=1`: - -``` -D_0 = empty -D_i = hash(D_{i-1} || password || salt) # no inner iteration when count=1 -material = D_1 || D_2 || ... # until ≥ 48 bytes -key = material[0:32]; iv = material[32:48] -``` - -CBC decrypt: per 16-byte block, inverse-cipher then XOR with previous ciphertext block (seed = `iv`). Strip PKCS#7 padding at the end (validate `1 ≤ pad ≤ 16` and all pad bytes equal). Try `md5` first; on failure (non-PKCS#7 tail or downstream error), retry with `sha256`, mirroring the Ruby `rescue` fallback. - -**V2 — AES-256-GCM with PBKDF2-derived key + IV + AAD.** Key schedule matches Ruby exactly: - -``` -material = hashlib.pbkdf2_hmac('sha256', password, salt, 10_000, dklen=32+12+24) -key = material[0:32]; iv = material[32:44]; aad = material[44:68] -``` - -GCM decrypt (IV is 96-bit, the common case): -- `H = AES_encrypt(key, 0^128)` (GHASH subkey) -- `J0 = iv || 0x00000001` -- Stream the ciphertext via CTR starting from `inc32(J0)`; counter is the low 32 bits of the block, rolled over mod 2^32. -- `GHASH(H, aad, ciphertext)` = fold AAD (zero-padded to 16), then ciphertext (zero-padded to 16), then `len(aad)_64 || len(ct)_64` bits, via GF(2^128) multiplication with reduction polynomial `0xe1…00`. -- `T = GHASH output XOR AES_encrypt(key, J0)`; raise if `T != auth_tag`. - -GF(2^128) multiply is the standard right-shift-with-conditional-reduce loop (per-bit; fine for the kilobytes-at-most we're decrypting). - -**File I/O.** The fastlane match file is ASCII base64 (confirmed on the live repo). Read as text, strip whitespace, base64-decode, dispatch on the 20-byte V2 magic prefix vs. the 8-byte `Salted__` V1 prefix. Replace the text-vs-binary heuristic in the current broken implementation — that heuristic was wrong and is unnecessary. - -**Public API.** Keep `decrypt_match_data(source_path, destination_path, password)` signature so `BuildConfiguration.py` can swap the shell-out for a direct call with a one-line change. - -## Changes - -1. **Rewrite `build-system/Make/DecryptMatch.py`** end to end: AES primitives, `EVP_BytesToKey`, CBC decrypt, GCM decrypt, MatchDataEncryption dispatch, `decrypt_match_data` entry point. Drop the `subprocess`/`tempfile` and placeholder-V2 code paths entirely. -2. **Flip `BuildConfiguration.py:103-118`** — replace the `os.system('ruby build-system/decrypt.rb …')` call with `decrypt_match_data(source_path, destination_path, password)`. Remove the dead commented line. -3. **Delete `build-system/decrypt.rb`**. - -## Verification - -Run the user-supplied command: - -``` -python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/build/telegram/telegram-bazel-cache \ - generateProject \ - --configurationPath ~/build/telegram/telegram-internal-tools/PrivateData/build-configurations/enterprise-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent -``` - -Success criteria: `generateProject` completes, the `decrypted/profiles/development/*.mobileprovision` files are valid plists parseable by `openssl smime` (which `copy_profiles_from_directory` does immediately after, so any decryption corruption would surface there), and the generated Xcode project has correct signing settings. - -Cross-check during development: decrypt one sample file with both the old Ruby script and the new Python and compare `sha256sum`s byte-for-byte before running the full command. - -## Non-goals - -- V1 with salt-less files (the fastlane "no salt" format variant): the Ruby script doesn't handle it either. -- GCM with non-96-bit IV: PBKDF2 derivation fixes IV length at 12 bytes, so this case cannot arise. -- Streaming decryption for huge files: match files are at most a few MB. -- AES-128 / AES-192: unused by fastlane match. diff --git a/docs/superpowers/specs/2026-04-21-swifttl-layered-schema-generation-design.md b/docs/superpowers/specs/2026-04-21-swifttl-layered-schema-generation-design.md deleted file mode 100644 index f94aec40b4..0000000000 --- a/docs/superpowers/specs/2026-04-21-swifttl-layered-schema-generation-design.md +++ /dev/null @@ -1,195 +0,0 @@ -# SwiftTL — Optional Layered Schema Generation - -**Date:** 2026-04-21 -**Tool:** `build-system/SwiftTL` -**Inputs this unblocks:** `telegram-ios-shared/tools/secret_scheme.tl`, invoked by `telegram-ios-shared/tools/generate_and_copy_scheme.sh` with `--api-prefix=SecretApi`. -**Consumers this targets:** `submodules/TelegramCore/Sources/State/ManagedSecretChatOutgoingOperations.swift`, `submodules/TelegramCore/Sources/State/ProcessSecretChatIncomingDecryptedOperations.swift` — both reference `SecretApi{8,46,73,101,144}..`, symbols currently provided by hand-maintained `submodules/TelegramApi/Sources/SecretApiLayer{8,46,73,101,144}.swift` files. - -## Problem - -`SwiftTL` parses a flat `.tl` schema and emits one flat `Api` namespace. `secret_scheme.tl` is not flat — it's a multi-version schema separated by `===N===` layer markers (11 layers: 8, 17, 20, 23, 45, 46, 66, 73, 101, 143, 144), where the same constructor name can reappear in later layers with a new constructor ID and new fields (e.g. `decryptedMessage` exists at layers 8, 17, 45, 73, each with a different ID and argument list). - -Running `SwiftTL secret_scheme.tl … --api-prefix=SecretApi` today fails: `DescriptionParser` doesn't recognize `===N===` markers, and `Resolver` throws on the first duplicate constructor name. The secret-chat `SecretApi{N}..` structs that downstream code already uses are hand-maintained and out-of-sync with what SwiftTL would naturally produce. - -## Goal - -Extend `SwiftTL` with optional layered-schema support so that `secret_scheme.tl` round-trips through the same CLI: one invocation produces one Swift file per declared layer. Flat schemas (`swift_scheme.tl`) continue to produce byte-identical output. - -Non-goal: a complete rewrite of the legacy hand-written `SecretApiLayer*.swift` format. Output is "close enough" — same sum-type enums, same constructor IDs, same serialize/parse bodies — not byte-for-byte identical to the legacy files. Existing consumers compile unchanged because they reference the public symbols (`SecretApi8.DecryptedMessage.decryptedMessage(...)`), which the generator preserves. - -## Architecture - -Four files change in `build-system/SwiftTL/Sources/SwiftTL/`. No new files, no new CLI flags. - -### `DescriptionParsing.swift` - -The public `parse(data:)` return type changes from a tuple `(constructors, functions)` to a new enum: - -```swift -enum ParsedSchema { - case flat(constructors: [ConstructorDescription], functions: [ConstructorDescription]) - case layered(layers: [(layerNumber: Int, constructors: [ConstructorDescription])]) -} -``` - -**Detection rule.** If any non-empty line matches the regex `^===\d+===\s*$`, the schema is layered. Every non-skipped constructor must sit under a marker; constructors appearing before the first marker are attached to the lowest-numbered layer. Otherwise the schema is flat (today's behavior, unchanged). - -**Input validation** (only enforced in the layered branch): -- Layer numbers must be positive integers and appear in strictly ascending order in the source. Parser throws otherwise. -- `---functions---` is forbidden in layered mode. Parser throws if seen. -- Empty layers (marker followed immediately by the next marker or EOF) are allowed. They produce an output file whose cumulative snapshot is identical to the previous layer's. - -The existing `skipPrefixes` / `skipContains` filter (for `true`, `vector`, `error`, `null`, `{X:Type}`) applies unchanged to both branches. - -### `Resolution.swift` - -A new static method on `Resolver`: - -```swift -static func resolveLayeredTypes( - layers: [(layerNumber: Int, constructors: [DescriptionParser.ConstructorDescription])] -) throws -> [(layerNumber: Int, types: [SumType])] -``` - -Algorithm — walks layers in input order, maintaining a running map `constructorsByName: [QualifiedName: (typeName: QualifiedName, constructor: DescriptionParser.ConstructorDescription)]`. For each layer: - -1. For each constructor in the layer: if the name already exists in the running map with a different target type, remove it from the old type's entry before inserting under the new target type. -2. Insert or overwrite the constructor in the running map. -3. At the end of the layer section, build `[SumType]` from the current running map by grouping constructors by their target type and resolving argument type references (same machinery `resolveTypes(constructors:)` already uses, factored into shared helpers). - -The output preserves per-layer IDs: layer 8's `decryptedMessage` has ID `0x1f814f1f`, layer 17's has `0x204d3878`, layer 46's has `0x36b091de`, layer 73's has `0x91cc4674` — each landing in its own independent `[SumType]` snapshot. - -The existing `resolveTypes(constructors:)` and `resolveFunctions(…)` stay unchanged for the flat path. - -### `CodeGeneration.swift` - -A new static method on `CodeGenerator`: - -```swift -static func generateLayered( - apiPrefix: String, - layerNumber: Int, - types: [SumType] -) throws -> (filename: String, source: String) -``` - -Returns filename `"\(apiPrefix)Layer\(layerNumber).swift"` and a source string in the shape described below. Reuses the existing private helpers `typeReferenceRepresentation`, `generateFieldSerialization`, `generateFieldParsing`, and `SumType.hasDirectReference(to:typeMap:)` unchanged — the per-argument serialize/parse logic is byte-identical between flat and layered output. - -The flat `CodeGenerator.generate(…)` entry point is untouched. - -### `main.swift` - -Branches on the parser's return value: - -```swift -switch try DescriptionParser.parse(data: data) { -case let .flat(constructors, functions): - // existing flow, unchanged -case let .layered(layers): - let resolved = try Resolver.resolveLayeredTypes(layers: layers) - try FileManager.default.createDirectory( - at: URL(fileURLWithPath: outputDirectoryPath), - withIntermediateDirectories: true) - for (layerNumber, types) in resolved { - let (filename, source) = try CodeGenerator.generateLayered( - apiPrefix: apiPrefix, layerNumber: layerNumber, types: types) - let filePath = URL(fileURLWithPath: outputDirectoryPath) - .appendingPathComponent(filename).path - _ = try? FileManager.default.removeItem(atPath: filePath) - try source.write(toFile: filePath, atomically: true, encoding: .utf8) - } -} -``` - -## Layer semantics - -For each emitted layer `N`, the effective constructor set is the ordered union of all constructors declared in layers `L ≤ N`, where a constructor with a given `QualifiedName` in a later layer **replaces** the earlier entry (new ID, new arguments, potentially new target sum type). The latest winner is the only one that appears in layer `N`'s output; earlier IDs are not included in layer `N`'s dispatch table. - -Constructors declared only in layers `> N` do not appear in layer `N`. - -Pre-marker constructors (e.g. `boolFalse`, `boolTrue` in `secret_scheme.tl`) are attached to the lowest-numbered layer. Rationale: (1) keeps the rule uniform ("every constructor belongs to exactly one declared layer"), (2) matches the natural reading of the schema file, (3) has no observable effect today since no downstream consumer references `Bool` from a secret-schema layer. - -## Output format (per layer) - -Matches the shape of the existing hand-written `SecretApiLayer{N}.swift` files. One file per layer, named `{apiPrefix}Layer{N}.swift`. - -``` - - -fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { - var dict: [Int32 : (BufferReader) -> Any?] = [:] - dict[-1471112230] = { return $0.readInt32() } - dict[570911930] = { return $0.readInt64() } - dict[571523412] = { return $0.readDouble() } - dict[-1255641564] = { return parseString($0) } - // dict[0x0929C32F] = { return parseInt256($0) } — emitted iff any constructor - // in this layer's cumulative snapshot has a field of type Int256. - dict[] = { return {apiPrefix}{N}..parse_($0) } - // ... one entry per (latest) constructor in the cumulative snapshot - return dict -}() - -public struct {apiPrefix}{N} { - public static func parse(_ buffer: Buffer) -> Any? { ... } - fileprivate static func parse(_ reader: BufferReader, signature: Int32) -> Any? { ... } - fileprivate static func parseVector(_ reader: BufferReader, elementSignature: Int32, elementType: T.Type) -> [T]? { ... } - public static func serializeObject(_ object: Any, buffer: Buffer, boxed: Swift.Bool) { ... } - - public enum { /* cases, serialize, parse_* */ } - public enum { ... } - // ... -} -``` - -**Deliberate differences from the flat-mode `Api0/1/….swift` output:** - -- Single file instead of `Api0` header + `Api{1..N}` sharded impl files. -- `public struct` for the namespace instead of `public enum`. -- Nested `public enum ` declarations instead of extensions. -- No `Cons_*` helper classes; enum cases use the inline-args shape — i.e. `case decryptedMessage(randomId: Int64, randomBytes: Buffer, message: String, media: …)`. Note the flat generator has a dormant inline-args branch guarded by `useStructPattern = false` that is never taken today; the layered generator renders this shape directly rather than sharing that branch. -- No `descriptionFields()` method, no `TypeConstructorDescription` conformance on the enums. -- `parse_*` methods are `fileprivate`, not `public static`. -- No `---functions---` section (rejected upstream). - -The `indirect` keyword is still emitted when a type transitively references itself, via the existing `SumType.hasDirectReference(to:typeMap:)` helper. - -## CLI - -Unchanged. `swift run SwiftTL [--api-prefix=]`. Layered behavior auto-triggers on `===N===` marker presence. With `--api-prefix=SecretApi` on `secret_scheme.tl`, SwiftTL emits 11 files: `SecretApiLayer{8,17,20,23,45,46,66,73,101,143,144}.swift`. - -## Out-of-scope follow-ups - -### `generate_and_copy_scheme.sh` - -Lives in `telegram-ios-shared/tools/` (sibling repo). Currently invokes SwiftTL on both schemas but only copies `NewScheme/Api*.swift` into `submodules/TelegramApi/Sources/`. After this SwiftTL change lands, the script gains: - -```sh -rm -f ../../telegram-ios/submodules/TelegramApi/Sources/SecretApiLayer*.swift -cp NewSecretScheme/SecretApiLayer*.swift ../../telegram-ios/submodules/TelegramApi/Sources/ -``` - -The SwiftTL change produces the right files; the shell-script wiring is a follow-up commit in the sibling repo. - -### `submodules/TelegramApi/BUILD` - -If `submodules/TelegramApi/BUILD` lists the existing `SecretApiLayer{8,46,73,101,144}.swift` explicitly, it must be updated to include the 6 new layer files (17, 20, 23, 45, 66, 143) before the project will build. Implementation step: grep BUILD for `SecretApiLayer` at the start of implementation — if explicit, either add the 6 new file entries or switch to a `glob(["Sources/SecretApiLayer*.swift"])` pattern, in the same commit that introduces the files. - -## Verification - -No unit tests exist in this repo (per `CLAUDE.md`). Verification steps: - -1. **Layered schema compiles.** `swift run SwiftTL /secret_scheme.tl /tmp/out --api-prefix=SecretApi` succeeds and produces 11 files. -2. **Generated files match legacy by semantics.** Spot-check `SecretApiLayer8.swift`, `SecretApiLayer46.swift`, `SecretApiLayer73.swift`, `SecretApiLayer101.swift`, `SecretApiLayer144.swift` against their hand-written counterparts in `submodules/TelegramApi/Sources/`. Confirm: - - Same set of enum case names per sum type. - - Same constructor IDs in the dispatch table (latest per name only). - - Same argument ordering and types. - - Same indirect-ness for self-referential types. - Cosmetic differences (whitespace, per-helper indentation quirks, absence of `Cons_*`) are acceptable. -3. **Project builds.** Copy the generated files over the hand-written ones in `submodules/TelegramApi/Sources/`, run the full Bazel build (`source ~/.zshrc 2>/dev/null; Make.py build --continueOnError`), and confirm zero compilation errors. `ManagedSecretChatOutgoingOperations.swift` and `ProcessSecretChatIncomingDecryptedOperations.swift` reference `SecretApi{8,46,73,101,144}..` symbols that the generator preserves. -4. **Flat schema is unchanged.** `swift run SwiftTL /swift_scheme.tl /tmp/out-main` succeeds; diff the generated `Api*.swift` against `submodules/TelegramApi/Sources/Api*.swift`. Expected: byte-identical (flat codepath untouched). - -## Risks - -- **Legacy-file semantic drift.** The hand-written `SecretApiLayer*.swift` files may contain micro-deviations from what the schema strictly implies (a constructor sneaked in by hand, an ID typo, an argument order tweak). Any such deviations will surface as compile or runtime-parse errors after regeneration. Mitigation: verification step 2 surfaces these before building; if found, the spec takes the schema as authoritative — legacy hand-edits get reverted, not preserved. -- **BUILD glob vs. explicit file list.** If BUILD lists files explicitly, adding the 6 new layer files (17, 20, 23, 45, 66, 143) requires a BUILD update in the same commit. Verification step during implementation. -- **Pre-marker constructor attribution.** `boolFalse`/`boolTrue` land in layer 8 under the spec. If the existing hand-written `SecretApiLayer8.swift` does not contain `Bool` (likely, since no consumer references it), the generator will add a nested `public enum Bool { case boolFalse; case boolTrue }` to layer-8 (and cumulatively to every subsequent layer) and two entries to each cumulative layer's dispatch dict. Harmless addition — build unaffected; diff noise only. diff --git a/docs/superpowers/specs/2026-04-21-textstyleeditscreen-caret-tracking-design.md b/docs/superpowers/specs/2026-04-21-textstyleeditscreen-caret-tracking-design.md deleted file mode 100644 index 52336bab10..0000000000 --- a/docs/superpowers/specs/2026-04-21-textstyleeditscreen-caret-tracking-design.md +++ /dev/null @@ -1,137 +0,0 @@ -# TextStyleEditScreen caret-tracking auto-scroll — design - -## Background - -`submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift` hosts a sheet built on `ResizableSheetComponent` with two `ListMultilineTextFieldItemComponent` fields (a title and a multi-line prompt). The sheet's `inputHeight` plumbing and scroll-content sizing have already been wired up: - -- `TextStyleEditSheetComponent.View.update` passes `environmentValue.inputHeight` into `ResizableSheetComponentEnvironment(inputHeight:)` instead of a hardcoded `0.0`. -- `ResizableSheetComponent` now subtracts `inputHeight` from `topInset` and adds it to `scrollContentHeight` so the scroll view has enough room to pan past the keyboard. - -What remains: when the user types, the caret in the focused field must be scrolled into the visible area above the soft keyboard. Without this, typing near the bottom of the prompt field hides the caret under the keyboard. - -## Goal - -Whenever a text edit occurs in either `ListMultilineTextFieldItemComponent` inside `TextStyleEditContentComponent`, adjust the enclosing scroll view's `bounds.origin.y` so that the caret rect sits comfortably above the keyboard and bottom action button. - -## Scope - -All changes live in `submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift`. No changes to `ResizableSheetComponent`, `ListMultilineTextFieldItemComponent`, or `TextFieldComponent` — their existing public surfaces are sufficient: - -- `ListMultilineTextFieldItemComponent.Tag` (constructor param `tag:`, plus `matches(tag:)` on the view). -- `ListMultilineTextFieldItemComponent.View.textFieldView: TextFieldComponent.View?`. -- `TextFieldComponent.View.inputTextView: UITextView`. -- `TextFieldComponent.AnimationHint` attached as `userData` on the transition whenever `TextFieldComponent` fires a state update on text change (`TextFieldComponent.swift:471/491/504/542/620/1077`). - -## Non-goals - -- No scroll on focus change alone (user requirement — text-change only). -- No scroll on selection change without an edit. -- No scroll-triggering on keyboard show/hide independently of text changes. -- No changes to shared infrastructure (`ResizableSheetComponent` stays as-is after the user's sizing work). - -## Design - -### 1. Field tagging - -`TextStyleEditContentComponent.View` stores two tags created once at init: - -```swift -private let titleFieldTag = ListMultilineTextFieldItemComponent.Tag() -private let textFieldTag = ListMultilineTextFieldItemComponent.Tag() -``` - -The two `ListMultilineTextFieldItemComponent(...)` constructions in `update(...)` pass the corresponding tag via the existing `tag:` parameter (currently `tag: nil` at both sites). This lets us identify which of our fields a hint's originating `TextFieldComponent.View` belongs to. - -### 2. Recenter trigger - -At the end of `TextStyleEditContentComponent.View.update(...)`, after all sub-component layout is complete and all frames are set, evaluate the incoming transition's user data: - -```swift -if let hint = transition.userData(TextFieldComponent.AnimationHint.self), - case .textChanged = hint.kind, - let hintView = hint.view { - self.recenterCaret(hintView: hintView, availableSize: availableSize, environment: environment, transition: transition) -} -``` - -`hint.kind` is either `.textChanged` or `.textFocusChanged(isFocused:)`; we match only `.textChanged`. - -### 3. Scroll-to-caret logic - -`recenterCaret(hintView:availableSize:environment:transition:)` is a private method on `TextStyleEditContentComponent.View` that performs these steps: - -1. **Locate field view.** Walk ancestors of `hintView` up to the first `ListMultilineTextFieldItemComponent.View`. Confirm it matches one of `self.titleFieldTag` / `self.textFieldTag` via `fieldView.matches(tag:)`. If neither matches, bail silently. - -2. **Compute caret rect in text-view space.** From the field view, grab `textFieldView?.inputTextView`. Retrieve the caret rect: - ```swift - let endPosition = inputTextView.selectedTextRange?.end ?? inputTextView.endOfDocument - let caretRect = inputTextView.caretRect(for: endPosition) - ``` - If `caretRect.isNull` or `caretRect.isInfinite`, bail (text view hasn't laid out yet). - -3. **Locate enclosing scroll view.** Walk `self.superview` chain until the first `UIScrollView` is found (this is `ResizableSheetComponent`'s private scroll view). If no scroll view is found, bail. - -4. **Convert caret rect to scroll-view coordinates.** - ```swift - let caretInScroll = inputTextView.convert(caretRect, to: scrollView) - ``` - -5. **Compute visible region.** Within the scroll view's current bounds, determine the vertical range in which the caret should sit: - ```swift - let bottomActionAreaHeight: CGFloat = 52.0 + 8.0 // matches ResizableSheetComponent bottom layout - let caretTopInset: CGFloat = 24.0 // small cushion below keyboard/button - let caretBottomInset: CGFloat = 24.0 // small cushion above keyboard/button - let visibleTop = scrollView.bounds.minY + caretTopInset - let visibleBottom = scrollView.bounds.maxY - environment.inputHeight - bottomActionAreaHeight - caretBottomInset - ``` - -6. **Adjust `bounds.origin.y`.** Using the direct-assign + additive-animate pattern proven in `ComposePollScreen.swift:2873-2895`: - ```swift - let previousBounds = scrollView.bounds - var newBounds = previousBounds - if caretInScroll.maxY > visibleBottom { - newBounds.origin.y += (caretInScroll.maxY - visibleBottom) - } else if caretInScroll.minY < visibleTop { - newBounds.origin.y -= (visibleTop - caretInScroll.minY) - } - let maxOriginY = max(0.0, scrollView.contentSize.height - scrollView.bounds.height) - newBounds.origin.y = min(max(0.0, newBounds.origin.y), maxOriginY) - if newBounds != previousBounds { - scrollView.bounds = newBounds - if !transition.animation.isImmediate { - let offsetY = previousBounds.origin.y - newBounds.origin.y - transition.animateBoundsOrigin(view: scrollView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true) - } - } - ``` - This keeps the scroll animation in sync with the text-change spring carried by the hint's transition, and matches existing precedent in the codebase. - -## Edge cases - -- **Caret rect unavailable.** `caretRect(for:)` returns `CGRect.null` or `CGRect.infinite` when the text view hasn't laid out. Skip — the next hint will cover it. -- **No enclosing scroll view.** Defensive bail; should never happen in normal operation but keeps the code robust against host refactors. -- **Hint from unrelated field.** Tag mismatch → bail. Keeps the scroll view untouched if a future nested text input is added. -- **Over/under-scroll.** `newBounds.origin.y` clamped to `[0, contentSize.height − bounds.height]`. -- **Caret already visible.** No-op — `newBounds != scrollView.bounds` guards against churn. - -## File-level changes summary - -Only `TextStyleEditScreen.swift` is edited: - -- Add two stored `ListMultilineTextFieldItemComponent.Tag` properties on `TextStyleEditContentComponent.View`. -- Pass those tags into the existing two `ListMultilineTextFieldItemComponent(...)` calls in `update(...)`. -- Add a private `recenterCaret(...)` method on `TextStyleEditContentComponent.View`. -- Add a small block at the end of `update(...)` that reads `transition.userData(TextFieldComponent.AnimationHint.self)` and invokes `recenterCaret` when `.textChanged`. - -Estimated diff size: ~40–60 lines added, no deletions. - -## Verification - -No unit tests exist in this project (per `CLAUDE.md`). Verification is a full `Make.py build` plus a manual smoke test: - -1. Open `TextStyleEditScreen` in create mode on a simulator/device. -2. Tap the "Style Name" field. Confirm keyboard slides up and the "Create" button sits above the keyboard (pre-existing behavior from the user's `inputHeight` work). -3. Type a character — with short content no scroll should occur; the scroll view remains at origin zero. -4. Tap the "Instructions" field. Type enough text to push the field past the viewport. Confirm the caret stays ~24pt above the keyboard/button as each newline is added. -5. Scroll up manually to push the active field off-screen, then type one character — confirm the scroll view snaps back so the caret sits above the keyboard. -6. In edit mode on a long pre-populated prompt, tap in the middle of the prompt (no scroll expected per non-goals), then type one character — confirm the caret's line is pulled into view. diff --git a/docs/superpowers/specs/2026-04-22-claude-md-reorganization-design.md b/docs/superpowers/specs/2026-04-22-claude-md-reorganization-design.md deleted file mode 100644 index 3cf7a3576f..0000000000 --- a/docs/superpowers/specs/2026-04-22-claude-md-reorganization-design.md +++ /dev/null @@ -1,104 +0,0 @@ -# CLAUDE.md reorganization — design - -**Date:** 2026-04-22 -**Status:** approved (brainstorm), pending plan - -## Problem - -`CLAUDE.md` has grown to 804 lines / ~99KB. It is loaded into every AI session in this repository, so its size directly consumes context budget that could be used for actual code work. It is also hard to navigate and maintain — the bulk is a per-wave changelog of the Postbox → TelegramEngine refactor, which obscures the rules, cheat sheets, and patterns that future waves actually need. - -Two goals, weighted equally: - -1. **Reduce always-loaded context size.** Target: CLAUDE.md shrinks to roughly ~200 lines / ~20KB (an ~80% reduction). -2. **Improve discoverability.** What remains in CLAUDE.md should be tight enough that an AI assistant can scan it and find the applicable rule or pattern without wading through narrative history. - -## Current content breakdown - -- Build / Code Style / Project Structure: ~35 lines — pure guidance, stays. -- Postbox refactor section: ~750 lines, further split: - - Standing rules 1–7: ~20 lines — active rules. - - Engine typealias cheat sheet: ~25 lines — active reference. - - MediaResource → EngineMediaResource patterns: ~30 lines — active patterns. - - Wave-selection guidance: ~150 lines — distilled lessons mixed with narrative backstory. - - Wave 1–26 outcomes: ~500 lines — history. - - Running tally of Postbox-free modules: ~30 lines — changelog-style enumeration. - - TelegramEngine.Resources facade inventory table: ~30 lines — active reference table. - - Known future-wave candidates: ~40 lines — planning state, duplicates memory file. - - Build command pointer: ~2 lines — duplicate of top-of-file section. - -## Final structure - -### CLAUDE.md (stays; slimmed) - -Sections, in order: - -1. **Build** — unchanged. -2. **Code Style Guidelines** — unchanged. -3. **Project Structure** — unchanged. -4. **Postbox → TelegramEngine refactor (in progress)**, containing: - - A brief intro paragraph plus a pointer: "Wave-by-wave history, full narrative lessons, running tallies, and example scripts live in `docs/superpowers/postbox-refactor-log.md` — read that file when you need wave-specific context or a full worked example." - - **Standing rules 1–7** — unchanged. - - **Engine typealias cheat sheet** — unchanged. - - **MediaResource → EngineMediaResource consumer migration** — unchanged. - - **Wave-selection guidance** — trimmed. Keep rules and recipes as terse bullets; drop narrative backstory, wave-specific iteration counts, full example scripts. Cross-reference the log file for backstory. Target: ~40–60 lines instead of ~150. - - **TelegramEngine.Resources facade inventory table** — unchanged (active reference table). - - The duplicate "Build command" pointer at the end is dropped (already covered at the top). - -Everything that gets removed either moves to the log file or (for future-wave candidates) merges into the existing memory file. - -### `docs/superpowers/postbox-refactor-log.md` (new file, not loaded by default) - -- Short header explaining purpose: "Historical record of the Postbox → TelegramEngine refactor. Not loaded by default into AI sessions. AI assistants should read this file when they need wave-specific context, full worked examples of a pattern, or the running tally of module Postbox-freeness." -- Wave 1–26 outcomes verbatim (no edits). -- Running tally of Postbox-free modules. -- Full self-contained forms of each guidance subsection that gets trimmed in CLAUDE.md — the rule, the backstory, example scripts, iteration-count stories, and pre-migration inventories together, so a reader of the log file doesn't need to jump back to CLAUDE.md to know what rule the backstory supports. Each subsection has a stable anchor that the trimmed CLAUDE.md bullet can cross-reference. - -### `project_postbox_refactor_next_wave.md` (existing memory file; updated) - -- Merge in the four categories from CLAUDE.md's "Known future-wave candidates" section: - - Permanently blocked (4 classes conforming to `TelegramMediaResource`). - - Higher-friction mediaBox methods (cached representations, resourceData/resourceStatus sweeps, storageBox wrapping). - - Non-mediaBox established patterns (preferencesView sweep, `loadedPeerWithId` sweep). - - Standalone Postbox-class-move opportunities. - - Unused-import sweep re-run. -- Keep existing wave-27+ shortlist content. - -## What "trim the guidance" concretely means - -For each subsection under "Wave-selection guidance", the rule is **keep the actionable rule/recipe; drop the story**. - -Worked example — current "Unused-import sweeps are a valid wave shape" is a ~35-line block with numbered methodology (steps 1–7), a script snippet, and an iteration-count anecdote ("18 → 4 → 5 → 3 → 12 → ..."). After trim in CLAUDE.md: - -> **Unused-import sweeps** (wave-shape applied in waves 6, 14): speculatively drop `^import Postbox$`, build with `--continueOnError`, restore failures, iterate. After a few iterations, do pattern-based preemptive restores for files naming Postbox-only symbols. Scope never leaves the consumer-module candidate set — halt if errors surface in TelegramCore/Postbox/TelegramApi. Full methodology, scripts, and iteration stats in `postbox-refactor-log.md`. - -Same treatment for the other guidance subsections: - -- "Wave-selection guidance" (top-level "leaf module, drop Postbox in isolation" commentary) -- "Two feasible wave shapes" paragraph -- "Enum-payload migrations need a full case-site grep" paragraph -- "Public-Postbox-type inventory (wave-11-pattern planning)" paragraph — including the `postbox-public-types.txt` script -- "Wave-shape G: facade addition + consumer sweep in one commit" paragraph — keep the seven-step recipe, drop prose about wave-26 `RangeSet` example - -## Implementation approach - -Three commits, each self-contained: - -1. **Create the log file.** Write `docs/superpowers/postbox-refactor-log.md` containing: header, Wave 1–26 outcomes verbatim, running tally of Postbox-free modules, verbose guidance passages extracted from CLAUDE.md. Commit. -2. **Rewrite CLAUDE.md.** Trim the guidance section to terse bullets with log-file cross-references, drop the wave outcomes and running tally sections, drop the duplicate build-command pointer at the bottom, add the log-file pointer near the start of the Postbox section. Commit. -3. **Update memory files.** Merge "Known future-wave candidates" into `project_postbox_refactor_next_wave.md`. Update `MEMORY.md` one-line index if its description of that file changes materially. Commit. - -Commits are ordered so that if anyone reads HEAD at any point between commits, nothing is lost: commit 1 adds content without removing any, commit 2 removes content that's now in the log, commit 3 moves planning state to where it belongs. - -## Non-goals - -- No pruning or editing of the wave outcomes themselves. Verbatim move. -- No restructuring of the rest of `docs/` or of the `memory/` directory beyond the one-section merge. -- No changes to the build, code style, or project structure sections of CLAUDE.md. - -## Success criteria - -- CLAUDE.md ≤ ~250 lines / ~25KB. (Hard cap; stretch target ~200 lines / ~20KB.) -- Every guidance bullet in the trimmed CLAUDE.md either stands alone or has an explicit cross-reference to `postbox-refactor-log.md`. -- `postbox-refactor-log.md` contains Wave 1–26 outcomes verbatim — a diff between the removed-from-CLAUDE.md text and the added-to-log text should be empty. -- `project_postbox_refactor_next_wave.md` contains all five categories of future-wave candidates that previously lived in CLAUDE.md. -- No information is lost across the three commits. diff --git a/docs/superpowers/specs/2026-04-24-contactlistpeer-engine-peer-migration-design.md b/docs/superpowers/specs/2026-04-24-contactlistpeer-engine-peer-migration-design.md deleted file mode 100644 index 9c8b8a3c6d..0000000000 --- a/docs/superpowers/specs/2026-04-24-contactlistpeer-engine-peer-migration-design.md +++ /dev/null @@ -1,227 +0,0 @@ -# Wave 36 — `ContactListPeer.peer` `Peer` → `EnginePeer` - -Date: 2026-04-24 -Status: approved design, awaiting plan -Wave shape: Peer-typed-API enum-case payload migration, single atomic commit (waves 34/35 pattern) - -## Goal - -Eliminate the Postbox-protocol `Peer` leak in the `ContactListPeer.peer(peer:isGlobal:participantCount:)` case payload by migrating the `peer` field from `Peer` to `EnginePeer`. Drop the outflow `._asPeer()` bridges that waves 33/34 installed at construction sites, and the inflow `EnginePeer(...)` wrappings at destructure sites. Apply wave 35's validated pre-flight pattern set (literal token + `.peer as?`/`is` + outflow-args + `EnginePeer(.peer)` + `._asPeer()`) to keep undercount below wave 35's 14%. - -## Non-goals - -- `ContactListPeerId.peer(PeerId)` (sibling enum, different payload) — unchanged; `PeerId == EnginePeer.Id` makes it already-clean. -- `canSendMessagesToPeer(_ peer: Peer, ignoreDefault: Bool) -> Bool` parameter migration — broader blast radius, deferred. -- `makePeerInfoController` / `makeChatQrCodeScreen` / `makeChatRecentActionsController` protocol-method migrations — broader blast radius, deferred. -- `openPeer(peer: Peer, ...)` / other Peer-typed APIs called from destructured bodies — if any destructured `peer` outflows into a raw-`Peer`-typed API after migration, add a `._asPeer()` bridge at that call site. Migrating those APIs is its own future wave. -- No new engine wrappers, typealiases, or facades introduced in this wave. -- No `import Postbox` drops in this wave — deferred to a follow-on unused-import sweep. - -## Type change - -```swift -// Before -public enum ContactListPeer: Equatable { - case peer(peer: Peer, isGlobal: Bool, participantCount: Int32?) - case deviceContact(DeviceContactStableId, DeviceContactBasicData) - - public var id: ContactListPeerId { … } - public var indexName: PeerIndexNameRepresentation { … } - - public static func ==(lhs: ContactListPeer, rhs: ContactListPeer) -> Bool { - switch lhs { - case let .peer(lhsPeer, lhsIsGlobal, lhsParticipantCount): - if case let .peer(rhsPeer, rhsIsGlobal, rhsParticipantCount) = rhs, - lhsPeer.isEqual(rhsPeer), // Postbox protocol method - lhsIsGlobal == rhsIsGlobal, lhsParticipantCount == rhsParticipantCount { - return true - } else { return false } - case let .deviceContact(id, contact): - if case .deviceContact(id, contact) = rhs { return true } else { return false } - } - } -} - -// After -public enum ContactListPeer: Equatable { - case peer(peer: EnginePeer, isGlobal: Bool, participantCount: Int32?) - case deviceContact(DeviceContactStableId, DeviceContactBasicData) - - public var id: ContactListPeerId { … } // body unchanged; peer.id is EnginePeer.Id == PeerId - public var indexName: EnginePeer.IndexName { … } // return type changed — body unchanged but type flows from EnginePeer.indexName - - public static func ==(lhs: ContactListPeer, rhs: ContactListPeer) -> Bool { - switch lhs { - case let .peer(lhsPeer, lhsIsGlobal, lhsParticipantCount): - if case let .peer(rhsPeer, rhsIsGlobal, rhsParticipantCount) = rhs, - lhsPeer == rhsPeer, // EnginePeer is Equatable - lhsIsGlobal == rhsIsGlobal, lhsParticipantCount == rhsParticipantCount { - return true - } else { return false } - case let .deviceContact(id, contact): - if case .deviceContact(id, contact) = rhs { return true } else { return false } - } - } -} -``` - -The custom `==` is retained (rather than relying on synthesis) because `DeviceContactStableId` / `DeviceContactBasicData` conformance to Equatable is not verified here; minimising unrelated change. Only the `lhsPeer.isEqual(rhsPeer)` clause is rewritten. - -## In-scope files - -Scope based on the pre-flight Explore inventory plus a manual deep-scan pass that caught additional inflow wraps and Postbox-concrete casts the Explore agent missed. One definition file plus nine consumer files; seven of the consumer files need edits. Two (ComposeController, ChatSendAudioMessageContextPreview) have only `.id`-level accesses and should need no body change — plan verifies each during implementation. - -### Category α — Definition (`AccountContext`) - -**`submodules/AccountContext/Sources/ContactSelectionController.swift`** -- Line 62: enum case signature change `peer: Peer` → `peer: EnginePeer`. -- Line 74: computed property return type change `PeerIndexNameRepresentation` → `EnginePeer.IndexName`. Rationale: after the payload migration, `peer.indexName` at line 77 returns `EnginePeer.IndexName` (from `EnginePeer.indexName`), not `PeerIndexNameRepresentation`. Changing the return type up rather than re-bridging via `peer._asPeer().indexName` eliminates a Postbox-typed API from AccountContext and incidentally lets two `EnginePeer.IndexName(...)` wraps at ContactListNode:517 drop. The two enum case shapes match exactly — `EnginePeer.IndexName.title(title:addressNames:)` and `EnginePeer.IndexName.personName(first:last:addressNames:phoneNumber:)` are defined at `submodules/TelegramCore/Sources/TelegramEngine/Peers/Peer.swift:145-147` with the same parameter labels and types as `PeerIndexNameRepresentation`'s cases. -- Line 77: `return peer.indexName` — body unchanged; type now flows `EnginePeer → EnginePeer.IndexName`. -- Line 79: `return .personName(first: contact.firstName, last: contact.lastName, addressNames: [], phoneNumber: "")` — body unchanged; case resolution retargets to `EnginePeer.IndexName.personName`. -- Line 86: `==` operator — rewrite `lhsPeer.isEqual(rhsPeer)` to `lhsPeer == rhsPeer`. -- Line 67: `peer.id` same-type access (EnginePeer.id returns EnginePeer.Id ≡ PeerId) — unchanged. - -### Category β — Outflow-bridge drops (the dominant pattern) - -Every site below is `.peer(peer: ._asPeer(), isGlobal: …, participantCount: …)` → `.peer(peer: , …)`, because `` is already `EnginePeer` at the call site. - -**`submodules/ContactListUI/Sources/ContactListNode.swift`** — 12 sites: 632, 690, 701, 747, 765, 1365, 1647, 1656, 1693, 1731, 1942, 1944. - -**`submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift`** — 3 sites: 494, 535, 569. - -**`submodules/TelegramUI/Sources/ContactMultiselectionController.swift`** — 2 bridged sites: 451, 459. - -**`submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift`** — 1 site: 317. - -**`submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift`** — 2 sites: 160, 230. - -Total: 20 outflow-bridge drops. - -### Category γ — Removed - -Earlier draft flagged `TelegramUI/ContactMultiselectionController.swift:379` as a raw-`Peer` construction needing `EnginePeer(peer)` promotion. Rechecked: line 379 is inside a destructure at line 347 (`case let .peer(peer, _, _) = peer`), so post-migration the inner `peer` is already `EnginePeer` and the existing `.peer(peer: peer, ...)` continues to compile without wrapping. No edit needed. - -### Category δ — Inflow-wrapping drops at destructure sites - -Every site is `EnginePeer(peer)` applied to a destructured peer that becomes `EnginePeer` directly post-migration → drop each wrap. - -- **ContactListNode.swift**: 4 wraps total. - - Line 204 wraps `peer` twice inside `.peer(peer: EnginePeer(peer), chatPeer: EnginePeer(peer))` (inside destructure at line 177). - - Line 252 wraps once inside `interaction.openDisabledPeer(EnginePeer(peer), …)` (inside destructure at line 251). - - Line 844 wraps once inside `isPeerEnabled(EnginePeer(peer))` (inside destructure at line 833). -- **ContactsController.swift**: 1 wrap — line 294 `chatLocation: .peer(EnginePeer(peer))` where `peer` is destructured at line 287. -- **ContactsSearchContainerNode.swift**: 4 wraps total. - - Line 164 `peerItem = .peer(peer: EnginePeer(peer), chatPeer: EnginePeer(peer))` (2 wraps, inside destructure at line 163). - - Line 165 `nativePeer = EnginePeer(peer)` (1 wrap, same destructure). - - Line 181 `openDisabledPeer(EnginePeer(peer), …)` (1 wrap, inside destructure at line 180). -- **TelegramUI/Sources/ContactMultiselectionController.swift**: 4 wraps total. - - Line 386 `subject: .peer(EnginePeer(peer))` (inside destructure at line 347). - - Line 403 `subject: .peer(EnginePeer(peer))` (same destructure). - - Line 481 `self.params.sendMessage?(EnginePeer(peer))` (inside destructure at line 468). - - Line 491 `self.params.openProfile?(EnginePeer(peer))` (same destructure). -- **TelegramUI/Sources/ContactMultiselectionControllerNode.swift**: 1 wrap — line 492 `EnginePeer(peer).compactDisplayTitle` (inside destructure at line 491). -- **TelegramUI/Sources/ContactSelectionController.swift**: 2 wraps total. - - Line 517 `self.sendMessage?(EnginePeer(peer))` (inside destructure at line 504). - - Line 527 `self.openProfile?(EnginePeer(peer))` (same destructure). - -Total: 16 inflow-wrap drops. - -### Category φ — Postbox-concrete cast rewrites - -Destructured `peer` post-migration is `EnginePeer`. Existing `peer as? TelegramUser`/`TelegramGroup`/`TelegramChannel` casts no longer compile; rewrite to `EnginePeer` case-pattern matches. Both sites are in `ContactListNode.swift`. - -- **ContactListNode.swift:182-186** — inside destructure at line 177. Rewrite the `if let _ = peer as? TelegramUser { … } else if let group = peer as? TelegramGroup { … } else if let channel = peer as? TelegramChannel { … }` chain to `switch peer { case .user: … case let .legacyGroup(group): … case let .channel(channel): … default: break }`, or equivalently to the `if case .user = peer / if case let .legacyGroup(group) = peer / if case let .channel(channel) = peer` chain. Inner `group.participantCount`, `channel.info`, `case .group = channel.info` continue to compile unchanged because `EnginePeer.channel` / `.legacyGroup` wrap the exact same concrete types (`TelegramChannel`, `TelegramGroup`) and `.user` wraps `TelegramUser`. Note: the original `if let _ = peer as? TelegramUser` branch doesn't bind the user — rewrite keeps that (either `case .user = peer` or `if case .user = peer`). -- **ContactListNode.swift:1968** — inside destructure at line 1966. Rewrite `let user = peer as? TelegramUser` to `case let .user(user) = peer`. Inner `user.phone` continues to compile (`EnginePeer.user` wraps `TelegramUser`). - -EnginePeer enum case mapping (reference): - -| Postbox concrete | EnginePeer case | -|---|---| -| `TelegramUser` | `.user(TelegramUser)` | -| `TelegramGroup` | `.legacyGroup(TelegramGroup)` | -| `TelegramChannel` | `.channel(TelegramChannel)` | - -Lines 1802, 1818, 1820 in ContactListNode.swift also contain `peer as? TelegramChannel`/`peer is TelegramGroup` casts but these are on `peer` values sourced from `entryData.renderedPeer.peer` (raw Postbox `Peer`), not from a ContactListPeer destructure. They stay unchanged — out of wave scope. - -### Category ε′ — `ContactListPeer.indexName` return-type cascade - -Because category α changes the return type of `ContactListPeer.indexName` to `EnginePeer.IndexName`, call sites that currently wrap that return in `EnginePeer.IndexName(...)` can drop the wrap: - -- **ContactListNode.swift:517** — `let result = EnginePeer.IndexName(lhs.indexName).isLessThan(other: EnginePeer.IndexName(rhs.indexName), ordering: sortOrder)` → `let result = lhs.indexName.isLessThan(other: rhs.indexName, ordering: sortOrder)`. Two wraps drop. The `isLessThan(other:ordering:)` extension is defined on `EnginePeer.IndexName` only (see `submodules/LocalizedPeerData/Sources/PeerTitle.swift:64`), so the existing wrap idiom was required pre-migration. - -- **ContactListNode.swift:539, 590** — `switch peer.indexName` / `switch orderedPeers[i].indexName` with `case let .title(…)` and `case let .personName(…)` — continues to compile unchanged. Same case names and shapes. - -### Category ε — Same-type field access (no edit) - -Destructured peer bindings whose only uses are `.id`, `.addressName`, value equality via `.id`, etc. All of these exist on `EnginePeer` with identical semantics. - -Known sites from inventory (accept as same-type): -- **ContactSelectionController.swift**: 67, 76 — `.id`, `.indexName`. -- **ContactListNode.swift**: 121, 177, 209, 216, 251, 255, 491, 505, 519, 520, 782, 787, 827, 833, 1636, 1966 — `.id`/`.addressName`/value comparisons on `.id`. Sites 204 and 251 also appear in category δ because the same binding is used both ways in the same block. -- **ContactsSearchContainerNode.swift**: 151 — `.addressName`. -- **ContactMultiselectionController.swift**: 347, 468 — `.id`. -- **ContactMultiselectionControllerNode.swift**: 491 — `selectedPeers.first` destructure to access `.id`. -- **ContactSelectionController.swift (TelegramUI)**: 504 — context-action passthrough. -- **ComposeController.swift**: 120, 160 — `.id` for chat creation. -- **ChatSendAudioMessageContextPreview.swift**: 88 — `.contact`/name accessors. - -These need no code edits; they are listed only to record coverage. - -### Category ζ — Outflow-to-`Peer`-typed-API (bridge required) - -Any destructured `peer` (now `EnginePeer`) passed to a function that takes raw `Peer` needs `._asPeer()` appended at the call site. - -Known candidate from inventory: -- **ContactsSearchContainerNode.swift:180** — `isPeerEnabled(peer)`. Verify the parameter type at edit time. If it is `(EnginePeer) -> Bool`, no bridge needed; if `(ContactListPeer) -> Bool`, also no bridge (the destructured value is discarded for the overall `peer` value anyway). If `(Peer) -> Bool`, add `._asPeer()`. - -Plan-time step 7 verifies each category-ε site against the API it feeds into; any surprise is resolved by adding `._asPeer()` inline. - -## Out-of-scope — name collisions - -Files listed in the 20-file grep but not touched in this wave: -- **PeerInfoUI/ChannelMembersController.swift**, **PeerInfoUI/ChannelVisibilityController.swift**, **SettingsUI/…/GlobalAutoremoveScreen.swift**, **IncomingMessagePrivacyScreen.swift**, **SelectivePrivacySettingsController.swift**, **SelectivePrivacySettingsPeersController.swift**, **PresentAddMembers.swift**, **ComposeController.swift (TelegramUI)**, **OpenResolvedUrl.swift**, **ChatSendAudioMessageContextPreview.swift** — the inventory found only `ContactListPeerId.peer(…)` destructures or pass-throughs of the entire `ContactListPeer` enum value, not `ContactListPeer.peer` payload access. The payload-type migration does not affect these. - -Plan-time verification: re-grep these files for `case .peer(let peer`, `case let .peer(peer,`, and `.peer(peer:` before declaring "no edits needed". If a missed payload destructure surfaces, promote the file into scope. - -## Execution plan outline (for writing-plans) - -Single atomic commit ordering: - -1. Edit `AccountContext/ContactSelectionController.swift` — change case payload type (L62); change `indexName` property return type to `EnginePeer.IndexName` (L74); rewrite `lhsPeer.isEqual(rhsPeer)` to `lhsPeer == rhsPeer` (L86). -2. Edit `ContactListNode.swift` — drop 12 `._asPeer()` bridges (outflow); drop 4 inflow `EnginePeer(peer)` wraps (2 on L204, 1 on L252, 1 on L844); rewrite cast chain at L182-186 to EnginePeer case patterns; rewrite cast at L1968; drop 2 `EnginePeer.IndexName(...)` wraps on L517. -3. Edit `ContactsController.swift` — drop 1 inflow `EnginePeer(peer)` wrap at L294. -4. Edit `ContactsSearchContainerNode.swift` — drop 3 `._asPeer()` bridges at L494/535/569; drop 4 inflow `EnginePeer(peer)` wraps (2 on L164, 1 on L165, 1 on L181). Do NOT drop `._asPeer()` at L488/528/562 (these feed `canSendMessagesToPeer(_: Peer)` — deferred wave). -5. Edit `TelegramUI/ContactMultiselectionController.swift` — drop 2 outflow bridges at L451/459; drop 4 inflow wraps at L386/403/481/491. Do NOT edit L171/201/748 (these feed `peerTokenTitle(peer: Peer)` — deferred). -6. Edit `TelegramUI/ContactMultiselectionControllerNode.swift` — drop 1 outflow bridge at L317; drop 1 inflow wrap at L492. -7. Edit `TelegramUI/ContactSelectionController.swift` — drop 2 inflow wraps at L517/527. -8. Edit `TelegramUI/ContactSelectionControllerNode.swift` — drop 2 outflow bridges at L160/230. -9. Verify `ComposeController.swift` and `ChatSendAudioMessageContextPreview.swift` need no body edits. If build surfaces a leak, fold the fix into an additional task step. -10. Build: `source ~/.zshrc 2>/dev/null; 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`. -11. Address undercount misses (expected ≤3 — pre-flight was thorough but file count is large) and commit once build is green. - -## Risk register - -| Risk | Mitigation | -|------|------------| -| Inventory undercount (wave 35 had 14%; trend decreasing) | Pre-flight already uses validated pattern set. `--continueOnError` on the build surfaces all misses in one pass. Expected ≤2 missed sites. | -| Destructure sites that flow a peer into a raw-`Peer`-typed API (category ζ) not caught by inventory | Build will flag the type mismatch; fix inline with `._asPeer()` at the flagged call site. Plan step 8 is the explicit verification gate. | -| `ContactListPeer` Equatable semantic regression | Replacing `lhsPeer.isEqual(rhsPeer)` (Postbox dynamic dispatch) with `lhsPeer == rhsPeer` (EnginePeer synthesized `==`) compares the same underlying concrete types (`.user(TelegramUser)`, `.channel(TelegramChannel)`, etc.) via their own Equatable conformances. Truth table preserved. | -| `ContactListPeer.indexName` return-type change cascades beyond ContactListNode:517/539/590 | Consumers of `ContactListPeer.indexName` enumerated via `grep -rn "\.indexName" submodules/ --include="*.swift"` filtered for ContactListPeer-typed receivers: only ContactListNode has such uses. No other submodule destructures or pattern-matches on this property. Build will flag any miss immediately. | -| `peer.isEqual` used elsewhere in scope files but on non-ContactListPeer bindings | Inventory confirmed ContactListNode:306 uses `!=` on a `ContactListNodeEntry.peer` binding, not `ContactListPeer.peer`. Scope boundary respected. No other `isEqual` call on a ContactListPeer-destructured binding was found. | -| Files flagged "no ContactListPeer.peer payload access" turn out to have one | Plan step 8 re-greps these files; any hit gets promoted into scope without rerunning the wave. | -| Pre-existing WIP on `ChatListFilterPresetController.swift` / `ChatListFilterPresetListController.swift` | Out of wave scope — untouched. No ContactListPeer reference expected in those files. | - -## Validation - -- Full Bazel build (`--configuration=debug_sim_arm64 --continueOnError`). -- No TelegramCore/Postbox/TelegramApi errors (scope boundary check — halt if they surface). -- Grep post-commit: `rg "ContactListPeer\.peer\(peer: .*\._asPeer" submodules/` returns empty. -- Grep post-commit: `rg "case \.peer\(peer: .*\._asPeer" submodules/` returns empty (catch shortcut constructions). -- Grep post-commit: no surviving `EnginePeer\(peer\)` in the 10 touched files where `peer` was destructured from a `ContactListPeer.peer` case (manual spot-check — automated grep too noisy). - -## Lessons to carry forward - -- Wave 35's pre-flight pattern set (literal token + `.peer as?`/`is` + outflow-args + `EnginePeer(.peer)` + `._asPeer()`) applied to this wave; record the post-commit undercount percentage to continue the calibration trend (wave 34: ~33%, wave 35: ~14%). -- This wave is dominated by **bridge removal** — 20 outflow `._asPeer()` drops + 16 inflow `EnginePeer(peer)` drops + 2 `EnginePeer.IndexName(...)` drops + 1 `.isEqual` → `==` fix + 2 Postbox-cast chain rewrites. Zero bridge additions. Updated tallies supersede earlier draft counts in this spec. Confirms the ratchet effect: earlier waves added bridges at Peer/EnginePeer boundaries precisely so future waves like this one can drop them atomically. Record the ratio (bridge drops : bridge additions) as a health metric across Peer-typed-API waves. -- Custom enum `==` operators using `Peer.isEqual(_:)` are a predictable Category-F leak in every Peer-payload migration. Future Peer-typed-API waves should grep the enum's defining module for `\.isEqual\(` specifically. -- **Computed properties on the enum that return Postbox types (e.g., `PeerIndexNameRepresentation`) are a second predictable leak** — discovered mid-spec for `ContactListPeer.indexName`. Future Peer-typed-enum waves should grep the enum's definition file for `public var` / `public func` returning any Postbox-defined type (`PeerIndexNameRepresentation`, `PeerNameIndex`, `MessageId`, etc.) before committing to the inventory — changing the return type to the Engine equivalent frequently cascades into consumer-side wrap drops (here, 2 wraps at ContactListNode:517). diff --git a/docs/superpowers/specs/2026-04-24-foundpeer-engine-peer-migration-design.md b/docs/superpowers/specs/2026-04-24-foundpeer-engine-peer-migration-design.md deleted file mode 100644 index 6172953547..0000000000 --- a/docs/superpowers/specs/2026-04-24-foundpeer-engine-peer-migration-design.md +++ /dev/null @@ -1,193 +0,0 @@ -# Wave 34 Design: `FoundPeer.peer: Peer → EnginePeer` - -**Date:** 2026-04-24 -**Wave:** 34 (Postbox → TelegramEngine refactor) -**Predecessor:** Wave 33 (loadedPeerWithId consumer sweep, commit `16d017853a`) - -## Goal - -Migrate the public field `FoundPeer.peer` from the Postbox `Peer` protocol to the TelegramCore `EnginePeer` enum. Drops 4 of the 5 `._asPeer()` bridges introduced by wave 33 and eliminates one Postbox-protocol leak from a `TelegramEngine.Contacts` / `TelegramEngine.Calls` return type. - -## Non-Goals - -- Migrating other Peer-typed-API surfaces (`SendAsPeer`, `makePeerInfoController`, `makeChatRecentActionsController`, `makeChatQrCodeScreen`, `FoundPeer` is the smallest probe in this class — those are separate future waves). -- Dropping `import Postbox` from `SearchPeers.swift`. The `_internal_*` functions in that file still call `postbox.transaction`, `parseTelegramGroupOrChannel`, `AccumulatedPeers`, `updatePeers`. They remain the Postbox-facing layer per project rule. -- Dropping `import Postbox` from any consumer module. None of the touched files reach zero Postbox use through this change alone. -- Auto-synthesizing `Equatable` for `FoundPeer`. Manual `==` is preserved per user decision. - -## Scope - -One atomic commit. Approximately 46 semantic edits plus type-name continuations across: - -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/SearchPeers.swift` (definition + `_internal_searchPeers` body) -- 7 consumer files in `submodules/`: - - `submodules/TelegramCallsUI/Sources/VideoChatScreen.swift` - - `submodules/TelegramCallsUI/Sources/VideoChatScreenMoreMenu.swift` - - `submodules/ContactListUI/Sources/ContactListNode.swift` - - `submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift` - - `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenCallActions.swift` - - `submodules/TelegramBaseController/Sources/TelegramBaseController.swift` - - `submodules/SettingsUI/Sources/Data and Storage/StorageUsageExceptionsScreen.swift` - -The remaining ~10 files identified by `grep -rln "FoundPeer\b"` (StorageUsageExceptionsScreen field-only refs aside, the file IS in the touched list above) contain only C5 type-name mentions or unrelated `.peer.peer` accesses on other types and require no edit. - -**Verification (performed 2026-04-24)** that nearby `EnginePeer(peer.peer)` patterns in other files are NOT FoundPeer access: those sites bind `peer` to `SelectivePrivacyPeer`, `SendAsPeer`, `InactiveChannel`, `RenderedChannelParticipant`, or `RenderedPeer` — all of which still expose `.peer: Peer`. They remain unchanged by this wave. - -## Changes - -### 1. `submodules/TelegramCore/Sources/TelegramEngine/Peers/SearchPeers.swift` - -**Struct:** - -```swift -public struct FoundPeer: Equatable { - public let peer: EnginePeer // was: Peer - public let subscribers: Int32? - - public init(peer: EnginePeer, subscribers: Int32?) { // was: peer: Peer - self.peer = peer - self.subscribers = subscribers - } - - public static func ==(lhs: FoundPeer, rhs: FoundPeer) -> Bool { - return lhs.peer == rhs.peer && lhs.subscribers == rhs.subscribers - // was: lhs.peer.isEqual(rhs.peer) && lhs.subscribers == rhs.subscribers - } -} -``` - -**`_internal_searchPeers` body changes:** - -- All four `FoundPeer(peer: peer, subscribers: …)` constructions (lines 70, 72, 85, 87) wrap the raw `peer` value with `EnginePeer(peer)`. -- Six scope-filter expressions (2 per non-trivial scope × 3 scopes — `.channels` lines 96–109, `.groups` lines 110–128, `.privateChats` lines 129–143) rewrite to enum pattern matching: - - `as? TelegramChannel, case .broadcast = channel.info` → `if case let .channel(channel) = item.peer, case .broadcast = channel.info` - - `as? TelegramChannel, case .group = channel.info` plus `else if item.peer is TelegramGroup` → `if case let .channel(channel) = item.peer, case .group = channel.info` plus `else if case .legacyGroup = item.peer` - - `if item.peer is TelegramUser` → `if case .user = item.peer` - -Filter behavior is preserved exactly; only the destructuring form changes. - -### 2. Consumer-side edits (by category) - -Inventory was performed on 2026-04-24 via Explore agent against the 10 files identified by `grep -rln "FoundPeer\b" submodules/ Telegram/`. An additional 3 files surfaced (`ShareControllerNode.swift`, `SharePeersContainerNode.swift`, `PeerSelectionControllerNode.swift`, `ContactSelectionControllerNode.swift`, `ChatListNode.swift`) — most are C5 type-name mentions or false positives in field names that don't reference the type. - -**C1 — peer-protocol method reads (~28 sites): no edit required.** -`peer.peer.id`, `peer.peer.displayTitle`, `peer.peer.namespace`, `peer.peer.debugDisplayTitle`, `peer.peer.smallProfileImage` — all available on `EnginePeer` with the same signatures. - -**C5 — type-signature mentions (~60 sites): no edit required.** -`[FoundPeer]`, `Signal<([FoundPeer], [FoundPeer]), NoError>`, `Atomic<([FoundPeer], [FoundPeer])?>`, `case globalPeer(FoundPeer, …)`, etc. The type continues to compile under the new field. - -**C2 — downcast rewrites (30 sites).** - -EnginePeer is an enum, so `peer.peer as? TelegramX` / `peer.peer is TelegramX` patterns must rewrite to `if case .X = peer.peer` (or `if case let .X(x) = peer.peer` when the bound value is reused). Case mapping: - -- `TelegramUser` → `.user` -- `TelegramSecretChat` → `.secretChat` -- `TelegramGroup` → `.legacyGroup` -- `TelegramChannel` → `.channel` - -| File | Line | Current pattern | After (representative) | -|---|---|---|---| -| `TelegramCallsUI/VideoChatScreenMoreMenu.swift` | 628 | `peer.peer is TelegramGroup` | `if case .legacyGroup = peer.peer` | -| `TelegramCallsUI/VideoChatScreenMoreMenu.swift` | 631 | `as? TelegramChannel, case .group = peer.info` | `if case let .channel(channel) = peer.peer, case .group = channel.info` | -| `TelegramCallsUI/VideoChatScreenMoreMenu.swift` | 648 | `as? TelegramChannel, case .broadcast = peer.info` | `if case let .channel(channel) = peer.peer, case .broadcast = channel.info` | -| `ContactListUI/ContactListNode.swift` | 1501 | `if let _ = peer.peer as? TelegramChannel` | `if case .channel = peer.peer` | -| `ContactListUI/ContactListNode.swift` | 1563, 1569, 1574 | `if let user = peer.peer as? TelegramUser, user.flags.contains(.requirePremium)` | `if case let .user(user) = peer.peer, user.flags.contains(.requirePremium)` | -| `ContactListUI/ContactListNode.swift` | 1658, 1665, 1695, 1703, 1733 | `let user = peer.peer as? TelegramUser` (in if-let chains) | `if case let .user(user) = peer.peer, …` | -| `ContactListUI/ContactListNode.swift` | 1673, 1711 | `if peer.peer is TelegramGroup` (with possible `&& `) | `if case .legacyGroup = peer.peer` (with `, `) | -| `ContactListUI/ContactListNode.swift` | 1675, 1713 | `else if let channel = peer.peer as? TelegramChannel` | `else if case let .channel(channel) = peer.peer` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 1024 | `!(peer.peer is TelegramUser \|\| peer.peer is TelegramSecretChat)` | rewrite to combined enum-pattern (×2 within the line) | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 1029, 1030 | `if let _ = peer.peer as? TelegramGroup` / `else if let peer = peer.peer as? TelegramChannel, case .group = peer.info` | `if case .legacyGroup = peer.peer` / `else if case let .channel(channel) = peer.peer, case .group = channel.info` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 1038, 1040 | `if peer.peer is TelegramUser` / `else if let channel = peer.peer as? TelegramChannel, case .broadcast = channel.info` | `if case .user = peer.peer` / `else if case let .channel(channel) = peer.peer, case .broadcast = channel.info` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 1500, 1507 | `if let channel = peer.peer as? TelegramChannel, case .broadcast = channel.info` | `if case let .channel(channel) = peer.peer, case .broadcast = channel.info` | -| `PeerInfoScreen/PeerInfoScreenCallActions.swift` | 175, 178, 193 | (see prior lines, same pattern set) | (same) | -| `TelegramBaseController/TelegramBaseController.swift` | 243, 246, 258 | `peer.peer is TelegramGroup` / `as? TelegramChannel, case .group = peer.info` / `as? TelegramChannel, case .broadcast = peer.info` | (same enum-pattern rewrites as above) | - -Two name-shadowing notes: - -- **Inner `peer` shadowing.** Several rewrites (e.g., `else if let peer = peer.peer as? TelegramChannel`) currently shadow the loop variable with a new `peer` of type `TelegramChannel`. After rewrite these become `else if case let .channel(channel) = peer.peer` — the binding name moves from `peer` to `channel` to avoid further shadowing of the EnginePeer loop variable. Adjust subsequent body references inside the if-let scope (they currently say `peer.info` referring to `TelegramChannel.info`; they become `channel.info`). Spot-check each rewrite within its block. -- **`channel.info` references.** When a downcast block uses the bound `peer` for `.info` access (e.g., line 178: `peer.info`), update those references to use the new binding name (`channel.info`). Block-internal-only — no cascade. - -Plus 6 filter sites inside `SearchPeers.swift` `_internal_searchPeers` body (already counted under §1). - -**C4 — constructor edits (6 sites):** - -Bridge-drop sites — wave-33 added `._asPeer()` because the value was already `EnginePeer`; with this wave the field accepts EnginePeer directly: - -| File | Line | Current | After | -|---|---|---|---| -| `TelegramCallsUI/VideoChatScreen.swift` | 1833 | `FoundPeer(peer: peer._asPeer(), subscribers: nil)` | `FoundPeer(peer: peer, subscribers: nil)` | -| `ContactListUI/ContactListNode.swift` | 1485 | `FoundPeer(peer: mainPeer._asPeer(), subscribers: nil)` | `FoundPeer(peer: mainPeer, subscribers: nil)` | -| `ContactListUI/ContactListNode.swift` | 1517 | `FoundPeer(peer: $0._asPeer(), subscribers: nil)` (inside `peers.map { … }`) | `FoundPeer(peer: $0, subscribers: nil)` | -| `TelegramBaseController/TelegramBaseController.swift` | 208 | `FoundPeer(peer: peer._asPeer(), subscribers: nil)` | `FoundPeer(peer: peer, subscribers: nil)` | -| `PeerInfoScreen/PeerInfoScreenCallActions.swift` | 156 | `FoundPeer(peer: peer._asPeer(), subscribers: nil)` | `FoundPeer(peer: peer, subscribers: nil)` | -| `PeerInfoScreen/PeerInfoScreenCallActions.swift` | 265 | `FoundPeer(peer: peer._asPeer(), subscribers: nil)` | `FoundPeer(peer: peer, subscribers: nil)` | - -Wrap-needed sites — value at the call site is raw `Peer`, must be wrapped: - -| File | Line | Current | After | -|---|---|---|---| -| `ContactListUI/ContactListNode.swift` | 1506 | `mappedPeers.append(FoundPeer(peer: peer.peer, subscribers: subscribers))` | already-EnginePeer (since `peer: FoundPeer` after migration) → `mappedPeers.append(FoundPeer(peer: peer.peer, subscribers: subscribers))` — **no edit** | -| `SettingsUI/StorageUsageExceptionsScreen.swift` | 288 | `FoundPeer(peer: peer, subscribers: subscriberCount)` | `FoundPeer(peer: EnginePeer(peer), subscribers: subscriberCount)` | - -Note: ContactListNode:1506 is inside a `for peer in mappedPeers` over `[FoundPeer]`, so `peer.peer` is already `EnginePeer` after migration. No edit. Re-classified from C4-wrap-needed to no-op. - -So: 4 bridge-drop edits + 1 actual wrap (StorageUsageExceptionsScreen:288) = 5 C4 edits, not 6. - -**C3 — drop redundant `EnginePeer(peer.peer)` wrap (22 sites).** - -After migration `peer.peer` is already `EnginePeer`, and `EnginePeer.init(_ peer: Peer)` does not accept an EnginePeer argument — so each `EnginePeer(peer.peer)` wrap MUST be dropped to just `peer.peer` or the build fails. - -| File | Line | Wraps | Pattern (representative) | -|---|---|---|---| -| `SettingsUI/StorageUsageExceptionsScreen.swift` | 173 | 1 | `EnginePeer(peer.peer).displayTitle(…)` → `peer.peer.displayTitle(…)` | -| `SettingsUI/StorageUsageExceptionsScreen.swift` | 176 | 1 | `iconPeer: EnginePeer(peer.peer)` → `iconPeer: peer.peer` | -| `TelegramBaseController/TelegramBaseController.swift` | 265 | 2 | `peer: EnginePeer(peer.peer), title: EnginePeer(peer.peer).displayTitle(…)` → `peer: peer.peer, title: peer.peer.displayTitle(…)` | -| `PeerInfoScreen/PeerInfoScreenCallActions.swift` | 201 | 1 | `peerAvatarCompleteImage(… peer: EnginePeer(peer.peer), …)` → `peerAvatarCompleteImage(… peer: peer.peer, …)` | -| `PeerInfoScreen/PeerInfoScreenCallActions.swift` | 202 | 1 | `text: EnginePeer(peer.peer).displayTitle(…)` → `text: peer.peer.displayTitle(…)` | -| `PeerInfoScreen/PeerInfoScreenCallActions.swift` | 288 | 2 | `.secondLineWithValue(EnginePeer(peer.peer).displayTitle(…))` and `peerAvatarCompleteImage(… peer: EnginePeer(peer.peer), …)` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 1075 | 2 | `peer: .peer(peer: EnginePeer(peer.peer), chatPeer: EnginePeer(peer.peer))` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 1076 | 1 | `interaction.peerSelected(EnginePeer(peer.peer), nil, nil, nil, false)` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 1078 | 1 | `interaction.disabledPeerSelected(EnginePeer(peer.peer), nil, …)` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 1081 | 1 | `peerContextAction(EnginePeer(peer.peer), .search(nil), node, gesture, location)` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 3088 | 1 | `filteredPeer(EnginePeer(peer.peer), EnginePeer(accountPeer))` (only the FoundPeer wrap drops; the `EnginePeer(accountPeer)` wrap stays — `accountPeer` is a raw Peer) | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 3096 | 1 | same pattern as 3088 | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 3214 | 1 | same pattern as 3088 | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 3216 | 1 | `entries.append(.localPeer(EnginePeer(peer.peer), …))` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 3241 | 1 | same pattern as 3088 | -| `TelegramCallsUI/VideoChatScreenMoreMenu.swift` | 171 | 2 | `.secondLineWithValue(EnginePeer(peer.peer).displayTitle(…))` and `peerAvatarCompleteImage(… peer: EnginePeer(peer.peer), …)` | -| `TelegramCallsUI/VideoChatScreenMoreMenu.swift` | 658 | 1 | `peerAvatarCompleteImage(… peer: EnginePeer(peer.peer), …)` | -| `TelegramCallsUI/VideoChatScreenMoreMenu.swift` | 679 | 1 | `text: EnginePeer(peer.peer).displayTitle(…)` | -| **Total** | | **22** | | - -Note: only the inner `EnginePeer(peer.peer)` is dropped. Adjacent `EnginePeer()` wraps (e.g., `EnginePeer(accountPeer)` at lines 3088/3096/3214/3241) are unrelated to this wave and remain. - -### Total semantic-edit count - -- §1 (TelegramCore): struct (3 lines) + 6 filter rewrites + 4 constructor wraps = ~13 spot edits in one file -- §2 C2: 30 consumer-site downcast rewrites -- §2 C4: 5 consumer-site constructor edits (4 bridge-drops + 1 wrap) -- §2 C3: 22 consumer-site `EnginePeer(peer.peer)` wrap drops - -**Total: ~70 semantic edits** across 1 TelegramCore file + 7 consumer files. Type-name mentions in signal/collection signatures need no edit; the type continues to compile. - -## Verification - -- **Build:** `source ~/.zshrc 2>/dev/null; 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` -- **Expected outcome:** first-pass-clean build. Errors that surface most likely indicate (a) a missed C2 site, (b) a FoundPeer field-access I missed in the inventory, or (c) a downstream API receiving `peer.peer` that requires raw `Peer` (would need a `._asPeer()` bridge added). -- **Post-build grep validations:** - - `grep -rn "FoundPeer(peer:.*\._asPeer()" submodules/` → expect zero hits in production code (the 4 bridge-drops succeeded). - - `grep -nE "peer\.peer\s+(as\?|is)\s+Telegram" ` → expect zero hits in the 7 touched consumer files (FoundPeer-relevant downcasts all rewritten). Other unrelated `something_else.peer.peer as?` patterns may remain on `RenderedPeer` etc. - - `grep -rn "EnginePeer(peer\.peer)" submodules/ --include="*.swift" | grep -v "^submodules/TelegramCore/"` → expect zero hits in the 7 touched consumer files (other files keep their wraps because their `peer` is non-FoundPeer). - -## Risks and mitigations - -- **Misnamed enum case bindings (C2).** A wrong binding name (e.g. `if case let .channel(c) = peer.peer` then accessing `channel.info`) compiles but is a typo. *Mitigation:* the rewrites are mechanical and each table-row in §2 above shows the exact target form. Each binding is reused inside the same `if case let` clause. -- **Hidden field accesses missed by the inventory.** *Mitigation:* `--continueOnError` build catches everything in one pass. If 5+ unexpected error sites surface, abandon and re-inventory. If only 1–2 surface, fix in place. -- **Downstream APIs requiring raw `Peer`.** Some consumer code may pass `foundPeer.peer` to a function taking the `Peer` protocol. Inventory found 2 such sites already simplified (C3), but unknown sites may exist. *Mitigation:* if surfaced by build errors, bridge with `._asPeer()` at the call site (acceptable transitional pattern — these become next-wave candidates for downstream migration). -- **Equatable behavior change.** `Peer.isEqual(_:)` is the protocol's polymorphic identity test; `EnginePeer.==` is the synthesized-or-manual enum equality. *Mitigation:* `EnginePeer.==` is the canonical equality on the enum and is used throughout the engine codebase. The two should agree on identity-relevant fields (peer id, namespace), and FoundPeer equality is used in `Equatable` set/array dedup contexts where both forms produce the same answer for distinct peers. If tests existed, this would be the place to add one — they don't, so we accept the substitution. - -## Out-of-scope cleanups (for future waves) - -- The downstream `peerAvatarCompleteImage(account:peer:size:)` in `PeerInfoScreenCallActions.swift:202` accepts `EnginePeer` — no change needed there. -- Wave 33's 5th `._asPeer()` bridge (the one not at a `FoundPeer` constructor) remains. It is at a different downstream API — separate wave. -- `SendAsPeer`, `makePeerInfoController`, `makeChatRecentActionsController`, `makeChatQrCodeScreen` migrations — each is its own wave, larger blast radius. diff --git a/docs/superpowers/specs/2026-04-24-makePeerInfoController-engine-peer-migration-design.md b/docs/superpowers/specs/2026-04-24-makePeerInfoController-engine-peer-migration-design.md deleted file mode 100644 index 4ac458691a..0000000000 --- a/docs/superpowers/specs/2026-04-24-makePeerInfoController-engine-peer-migration-design.md +++ /dev/null @@ -1,158 +0,0 @@ -# Wave 39 — `makePeerInfoController` peer: Peer → EnginePeer migration - -Date: 2026-04-24 - -## Context - -Ring-2 cleanup of the `AccountContext` Peer-typed-API surface. Waves 34 (FoundPeer.peer), 35 (SendAsPeer.peer), 36 (ContactListPeer.peer), 37 (peerTokenTitle), and 38 (canSendMessagesToPeer) migrated adjacent Peer-typed APIs to `EnginePeer`. `makePeerInfoController` is the largest remaining Peer-typed-API surface on `AccountContext` and a natural follow-up. - -Scope: only `makePeerInfoController` this wave. The sibling methods `makeChatQrCodeScreen` (4 consumer sites) and `makeChatRecentActionsController` (3 consumer sites) are deferred to a trivial follow-up wave. - -## Signature change - -`AccountContext` protocol declaration (`submodules/AccountContext/Sources/AccountContext.swift:1371`) and its `SharedAccountContextImpl` implementation (`submodules/TelegramUI/Sources/SharedAccountContext.swift:1937`): - -```swift -// before -func makePeerInfoController( - context: AccountContext, - updatedPresentationData: (initial: PresentationData, signal: Signal)?, - peer: Peer, - mode: PeerInfoControllerMode, - avatarInitiallyExpanded: Bool, - fromChat: Bool, - requestsContext: PeerInvitationImportersContext? -) -> ViewController? - -// after -func makePeerInfoController( - context: AccountContext, - updatedPresentationData: (initial: PresentationData, signal: Signal)?, - peer: EnginePeer, - mode: PeerInfoControllerMode, - avatarInitiallyExpanded: Bool, - fromChat: Bool, - requestsContext: PeerInvitationImportersContext? -) -> ViewController? -``` - -Implementation body adds `let peer = peer._asPeer()` shadow at body-top. `peerInfoControllerImpl` (private, same file) and all downstream Peer-typed helpers keep raw `Peer` — out of scope for this wave. - -```swift -public func makePeerInfoController(... peer: EnginePeer ...) -> ViewController? { - let peer = peer._asPeer() - let controller = peerInfoControllerImpl(context: context, updatedPresentationData: updatedPresentationData, peer: peer, mode: mode, avatarInitiallyExpanded: avatarInitiallyExpanded, isOpenedFromChat: fromChat) - controller?.navigationPresentation = .modalInLargeLayout - return controller -} -``` - -## Consumer-side changes - -**73 total consumer call sites** (75 raw occurrences minus 1 protocol declaration and 1 implementation). Classification (confirmed via full-repo grep): - -- **58 Shape-A** — inline `peer: x._asPeer()` drops to `peer: x`. Mechanical edits. -- **3 Shape-A-variant** — `SettingsSearchableItems.swift` lines 1023, 1049, 1083. The upstream `guard let peer = peer?._asPeer() else` changes to `guard let peer = peer else`, making the local `peer` stay `EnginePeer`. The call-site line does not change. -- **12 Shape-C** — raw Peer local, add `EnginePeer(...)` wrap at call site. - -### Shape-C site list - -| File | Line | Current peer argument | New | -|---|---|---|---| -| `submodules/SettingsUI/Sources/Privacy and Security/BlockedPeersController.swift` | 270 | `peer: peer` | `peer: EnginePeer(peer)` | -| `submodules/PeerInfoUI/Sources/ChannelMembersController.swift` | 707 | `peer: participant.peer` | `peer: EnginePeer(participant.peer)` | -| `submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift` | 381 | `peer: participant.peer` | `peer: EnginePeer(participant.peer)` | -| `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift` | 1011 | `peer: peer` | `peer: EnginePeer(peer)` | -| `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift` | 4306 | `peer: peer` | `peer: EnginePeer(peer)` | -| `submodules/TelegramUI/Sources/Chat/ChatControllerNavigationButtonAction.swift` | 441 | `peer: peer` | `peer: EnginePeer(peer)` | -| `submodules/TelegramUI/Sources/Chat/ChatControllerNavigationButtonAction.swift` | 461 | `peer: peer` | `peer: EnginePeer(peer)` | -| `submodules/TelegramUI/Sources/Chat/ChatControllerNavigationButtonAction.swift` | 471 | `peer: peer` | `peer: EnginePeer(peer)` | -| `submodules/TelegramUI/Sources/Chat/ChatControllerNavigationButtonAction.swift` | 492 | `peer: channel` | `peer: EnginePeer(channel)` | -| `submodules/TelegramUI/Sources/Chat/ChatControllerOpenPeer.swift` | 218 | `peer: peer` | `peer: EnginePeer(peer)` | -| `submodules/TelegramUI/Sources/Chat/ChatControllerOpenPeer.swift` | 359 | `peer: peer` | `peer: EnginePeer(peer)` | -| `submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift` | 4362 | `peer: peer` | `peer: EnginePeer(peer)` | - -Each Shape-C wrap is a future-wave drop candidate once the raw-Peer source (stored field, `participant.peer`, `renderedPeer.chatMainPeer`, etc.) migrates upstream. - -### Shape-A-variant detail - -`SettingsSearchableItems.swift` three sites share the same structure: - -```swift -// before -let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) -|> deliverOnMainQueue).start(next: { peer in // peer: EnginePeer? - guard let peer = peer?._asPeer() else { // peer: Peer (shadowed) - return - } - let controller = context.sharedContext.makePeerInfoController( - context: context, - updatedPresentationData: nil, - peer: peer, - mode: .myProfile, - ... - ) - ... -}) - -// after -let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) -|> deliverOnMainQueue).start(next: { peer in // peer: EnginePeer? - guard let peer = peer else { // peer: EnginePeer (shadowed) - return - } - let controller = context.sharedContext.makePeerInfoController( - context: context, - updatedPresentationData: nil, - peer: peer, - mode: .myProfile, - ... - ) - ... -}) -``` - -## Files touched (≈50) - -Inventoried from the grep output. Not exhaustive here; per-site enumeration lives in the implementation plan. - -Signature files: `AccountContext/Sources/AccountContext.swift`, `TelegramUI/Sources/SharedAccountContext.swift`. - -Shape-A consumer files (sample, not exhaustive): `SelectivePrivacySettingsPeersController.swift`, `InstantPageControllerNode.swift`, `CallListController.swift`, `ContactsController.swift`, `ContactContextMenus.swift`, `SecureIdAuthController.swift`, `ChannelAdminController.swift`, `ChannelMembersController.swift`, `ChannelBannedMemberController.swift`, `ChannelPermissionsController.swift`, `MessageStatsController.swift`, `GroupStatsController.swift`, `InviteRequestsController.swift`, `BrowserInstantPageContent.swift`, `WebAppController.swift`, `PeersNearbyController.swift`, `ChatSendStarsScreen.swift`, `ChatRecentActionsControllerNode.swift`, `MiniAppListScreen.swift`, `JoinSubjectScreen.swift`, `NewContactScreen.swift`, `StarsTransactionScreen.swift`, `StoryItemSetContainerViewSendMessage.swift`, `StoryItemSetContainerComponent.swift`, `GiftViewScreen.swift`, `GiftOptionsScreen.swift`, `StorageUsageScreen.swift`, `TextProcessingScreen.swift`, `PeerInfoScreen.swift`, `PeerInfoScreenOpenURL.swift`, `JoinAffiliateProgramScreen.swift`, `ChatControllerScrollToPointInHistory.swift`, `OpenUrl.swift`, `OpenResolvedUrl.swift`, `TextLinkHandling.swift`, `ChatController.swift`, `OpenAddContact.swift`, `ChatManagingBotTitlePanelNode.swift`, `NavigateToChatController.swift`, `SharedAccountContext.swift` (3 self-call sites), `OverlayAudioPlayerControllerNode.swift`, `PollResultsController.swift`, `ChatControllerOpenWebApp.swift`, `ChatControllerNavigationButtonAction.swift`, `ChatListController.swift`, `ChatListSearchListPaneNode.swift`. - -Shape-A-variant file: `SettingsSearchableItems.swift`. - -Shape-C-only files (other than those with mixed shapes above): `BlockedPeersController.swift`, `ChannelBlacklistController.swift`, `ChatControllerOpenPeer.swift`, `ChatControllerLoadDisplayNode.swift`. - -## Build/verification plan - -1. Apply all edits atomically. Mechanical Edit-tool string replaces for the 58 Shape-A drops; focused Edits for the 3 Shape-A-variants (guard line) and 12 Shape-C wraps. -2. Full project build: `source ~/.zshrc 2>/dev/null; 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`. -3. Fix any iteration-surfaced errors. Budget 2–4 iterations. -4. Clean build → atomic commit with wave-39 message. -5. Update `project_postbox_refactor_next_wave.md` memory, `docs/superpowers/postbox-refactor-log.md`, and `CLAUDE.md` wave tally. -6. No test runs (project has no unit tests). - -## Risks / watch-out - -- **Destructure/binding cascades.** Locals named `peer` declared as `Peer` somewhere in a call chain and fed to `makePeerInfoController`. The body-shadow pattern contains divergence at the public API boundary, but transient Swift inference errors may surface at intermediate points. -- **`chatMainPeer` / `renderedPeer.peer` property types.** Shape-C sites at `ChatControllerNavigationButtonAction.swift:441/461/471/492` and `ChatControllerLoadDisplayNode.swift:4362` assume these properties return raw `Peer`. If they already return `EnginePeer` in the current repo (unlikely but possible after earlier waves), the wrap should be `peer: peer` with no wrap. Verify in plan phase. -- **Outflow sites in Shape-C files.** Some Shape-C files may have additional `peer: Peer` flows elsewhere that are unrelated to this wave. Do not chase — only touch the listed sites. - -## Abandonment criteria - -- Iteration count exceeds 5. -- A cascade requires editing `peerInfoControllerImpl` (violates body-shadow boundary). -- Any non-consumer file (e.g., anything in `TelegramCore`, `Postbox`, `TelegramApi`) surfaces an error. - -## Net effect - -- Public API: `AccountContext.makePeerInfoController` takes `EnginePeer` instead of raw `Peer`. -- Bridges: -58 inline `_asPeer()` + -3 upstream-guard `_asPeer()` + 12 new `EnginePeer(...)` wraps = **net -49 bridges**. -- Ratchet: 12 Shape-C wraps become future-wave drop candidates (e.g., `RenderedPeer → EngineRenderedPeer` migration, participant-object migrations). - -## Out of scope - -- `makeChatQrCodeScreen` (4 sites), `makeChatRecentActionsController` (3 sites) — deferred to a trivial follow-up wave. -- `peerInfoControllerImpl` and downstream Peer-typed helpers. -- Shape-C source migrations (participant objects, `renderedPeer.chatMainPeer`, etc.). diff --git a/docs/superpowers/specs/2026-04-24-peertokentitle-engine-peer-migration-design.md b/docs/superpowers/specs/2026-04-24-peertokentitle-engine-peer-migration-design.md deleted file mode 100644 index 06ff615cc8..0000000000 --- a/docs/superpowers/specs/2026-04-24-peertokentitle-engine-peer-migration-design.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: "Postbox → TelegramEngine wave 37: peerTokenTitle peer parameter Peer → EnginePeer" -date: 2026-04-24 -status: draft ---- - -# Wave 37 design — `peerTokenTitle` peer parameter Peer → EnginePeer - -## Context - -Wave 36 (commit `069a060de1`, squashed into `8408e0ae19`) migrated `ContactListPeer.peer` from `Peer` to `EnginePeer` and added two new `peer._asPeer()` bridges at `ContactMultiselectionController.swift:386` and `:403`, feeding the private free function `peerTokenTitle(accountPeerId: PeerId, peer: Peer, ...)` at `:21`. - -Wave 37 migrates `peerTokenTitle`'s `peer` parameter so those two new bridges — plus three older bridges at `:171`, `:201`, and `:748` — can all drop to zero in one atomic commit. This is a ring-2 cleanup: it consumes bridges that prior waves installed. - -## Scope - -All changes are confined to `submodules/TelegramUI/Sources/ContactMultiselectionController.swift`. - -### Changes - -| Location | Before | After | -|---|---|---| -| L21 | `peer: Peer` | `peer: EnginePeer` | -| L27 | `EnginePeer(peer).displayTitle(strings: strings, displayOrder: nameDisplayOrder)` | `peer.displayTitle(strings: strings, displayOrder: nameDisplayOrder)` | -| L171 | `peer: peer._asPeer()` | `peer: peer` | -| L201 | `peer: peer._asPeer()` | `peer: peer` | -| L386 | `peer: peer._asPeer()` | `peer: peer` | -| L403 | `peer: peer._asPeer()` | `peer: peer` | -| L748 | `peer: peer._asPeer()` | `peer: peer` | - -All 5 call-site bindings `peer` are already `EnginePeer` at the call site — verified by the existing `._asPeer()` bridge. - -The function body at L22–L28 stays semantically identical: `peer.id`, `peer.id.isReplies`, and `EnginePeer.displayTitle(strings:displayOrder:)` are all available on `EnginePeer`. - -### Intentionally out of scope - -- **`accountPeerId: PeerId`** — `PeerId` is already typealiased to `EnginePeer.Id`; not a Postbox-type leak. -- **`import Postbox` at L5** — other parts of the file still use Postbox-typed APIs (e.g., `.peer(peer: Peer, ...)` at L459 feeding the `SelectedPeer` enum). File-level Postbox-free is a later wave. -- **L459's `peer._asPeer()`** — feeds a different, not-yet-migrated Peer-typed API (`SelectedPeer.peer(peer: Peer, ...)`), outside this wave. -- **Other callers** — `peerTokenTitle` is `private` to this file; a full-codebase grep confirmed zero external call sites. - -## Verification - -1. **Pre-build grep** — confirm zero remaining `peerTokenTitle(.*_asPeer())` matches in the file and the broader codebase. -2. **Single full project build** via `Make.py` with `--continueOnError`. Expected first-pass-clean. -3. **Post-build grep** — same `peerTokenTitle(.*_asPeer())` pattern should remain empty. - -## Risk - -**Very low.** Private free function, single file, fully self-contained, all call sites mechanical bridge drops. No public-API change, no BUILD-file touch, no other modules affected. - -Expected outcome: first-pass-clean build. Good reset after wave 36's 6-iteration convergence. - -## Commit message - -``` -Postbox → TelegramEngine wave 37 - -peerTokenTitle: peer parameter Peer → EnginePeer. - -Drops 5 _asPeer() bridges in ContactMultiselectionController.swift -(L171, L201, L386, L403, L748) — bridges installed by prior waves. - -Private free function, single-file change. -``` - -## References - -- CLAUDE.md — "Postbox → TelegramEngine refactor (in progress)" -- `docs/superpowers/postbox-refactor-log.md` — wave history -- Memory `project_postbox_refactor_next_wave.md` — wave-37 candidate list diff --git a/docs/superpowers/specs/2026-04-24-rcp-peers-engine-migration-design.md b/docs/superpowers/specs/2026-04-24-rcp-peers-engine-migration-design.md deleted file mode 100644 index cf7472b690..0000000000 --- a/docs/superpowers/specs/2026-04-24-rcp-peers-engine-migration-design.md +++ /dev/null @@ -1,187 +0,0 @@ -# Wave 44 — `RenderedChannelParticipant.peers: [PeerId: Peer] → [EnginePeer.Id: EnginePeer]` - -**Date:** 2026-04-24 -**Status:** Approved, pending plan -**Predecessor:** Wave 41 (commit `32573c9808`) migrated `RenderedChannelParticipant.peer` from `Peer` to `EnginePeer` and installed ADD-WRAP markers at consumer-side read sites that this wave drops. -**Goal:** Close out the wave-41 ratchet by migrating the sibling `peers: [PeerId: Peer]` field to `[EnginePeer.Id: EnginePeer]`. After this wave, `RenderedChannelParticipant` has no raw `Peer` types in its public surface. - -## Context - -`RenderedChannelParticipant` is declared in `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift`: - -```swift -public struct RenderedChannelParticipant: Equatable { - public let participant: ChannelParticipant - public let peer: EnginePeer // migrated in wave 41 - public let peers: [PeerId: Peer] // target of this wave - public let presences: [PeerId: PeerPresence] // out of scope (PeerPresence is Postbox protocol) - - public init(participant: ChannelParticipant, peer: EnginePeer, peers: [PeerId: Peer] = [:], presences: [PeerId: PeerPresence] = [:]) { ... } -} -``` - -`peers` is a supplementary dict of "referenced peers" (e.g. the admin who promoted this member, the admin who banned them). Consumers use it to render relationships — never with `as?`/`is` casts, only `.id` and `.displayTitle(...)` on extracted values. - -## Migration target - -- `peers: [PeerId: Peer]` → `peers: [EnginePeer.Id: EnginePeer]` -- init default: `[:]` on both sides (type changes transparently) -- `presences` field stays unchanged. - -## Scope - -### Declaration (1 file, 2 edits) - -**`submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift`** - -- Line 11: `public let peers: [PeerId: Peer]` → `public let peers: [EnginePeer.Id: EnginePeer]` -- Line 14: `peers: [PeerId: Peer] = [:]` → `peers: [EnginePeer.Id: EnginePeer] = [:]` - -### TelegramCore producer sites (8 files, 8 construction sites, ~16 edits) - -All 8 producers follow the identical pattern of building a local `peers: [PeerId: Peer] = [:]` dict inside a `postbox.transaction` and passing it to an `RCP(peers: peers, ...)` constructor. Per-site edits: change local dict type, wrap each insertion value with `EnginePeer(...)`. - -| File | `var peers:` decl | `peers[X.id] = X` insertions | RCP construction | -|---|---|---|---| -| `TelegramEngine/Messages/RequestStartBot.swift` | line 61 | line 64 | line 65 | -| `TelegramEngine/Peers/ChannelOwnershipTransfer.swift` | line 170 | lines 172, 176 | line 180 (2 RCP constructions share `peers`) | -| `TelegramEngine/Peers/JoinChannel.swift` | line 59 | lines 64, 77 | line 82 | -| `TelegramEngine/Peers/AddPeerMember.swift` | line 242 | lines 244, 251 | line 255 | -| `TelegramEngine/Peers/PeerAdmins.swift` | line 251 | lines 253, 259 | line 262 | -| `TelegramEngine/Peers/ChannelBlacklist.swift` | line 128 | lines 130, 136 | line 140 | -| `TelegramEngine/Peers/Ranks.swift` | line 60 | lines 62, 68 | line 95 | -| `TelegramEngine/Peers/ChannelMembers.swift` | line 102 | line 105 | line 115 | - -**Per-site rewrite:** -```swift -// before -var peers: [PeerId: Peer] = [:] -peers[peer.id] = peer - -// after -var peers: [EnginePeer.Id: EnginePeer] = [:] -peers[peer.id] = EnginePeer(peer) -``` - -### Consumer-side DROPs: `.mapValues({ $0._asPeer() })` transforms (5 sites) - -These consumer-side constructors start from a `[PeerId: EnginePeer]` source dict and currently unwrap to `[PeerId: Peer]` to feed into the RCP constructor. After migration, the unwrap transform is a no-op and can be dropped. - -| File | Line | Before → after | -|---|---|---| -| `PeerInfoUI/Sources/ChannelAdminsController.swift` | 926 | `peers: peers.mapValues({ $0._asPeer() })` → `peers: peers` | -| `PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` | 994 | same | -| `PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` | 998 | same | -| `PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift` | 409 | same | -| `PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift` | 413 | same | - -**Verification required at plan time:** for each of these 5 sites, grep back up in the enclosing function to confirm the local `peers` variable is declared `[PeerId: EnginePeer]` (the source of the mapValues transform). If any of the sources turn out to be `[PeerId: Peer]` rather than `[PeerId: EnginePeer]`, that site's transform is NOT a no-op and instead becomes a wrap (`.mapValues(EnginePeer.init)`) — still a net-zero or gain depending on where the source originates. - -### Consumer-side DROPs: `EnginePeer(peer).displayTitle(...)` wraps (6 sites) - -These are the wave-41 ADD-WRAP markers. Pattern: extract `peer` from `participant.peers[X]`, wrap with `EnginePeer(peer)` to call `.displayTitle(...)`. After migration, `peer` is already `EnginePeer` — drop the wrap. - -| File | Line | Pattern | -|---|---|---| -| `PeerInfoUI/Sources/ChannelAdminsController.swift` | 297 | `EnginePeer(peer).displayTitle(strings: strings, ...)` → `peer.displayTitle(strings: strings, ...)` | -| `PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` | 839 | same | -| `PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` | 870 | same | -| `PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` | 1091 | same | -| `PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` | 1122 | same | -| `PeerInfoUI/Sources/ChannelBlacklistController.swift` | 165 | same | - -The adjacent `peer.id == participant.peer.id` comparisons are unchanged: both sides are `EnginePeer.Id` (already a typealias of `PeerId`). - -### Consumer-side ADD-UNWRAP (1 site) - -**`submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift`**, lines 672–674: - -```swift -for (_, peer) in participant.peers { - peers[peer.id] = peer // `peers` is SimpleDictionary -} -``` - -After migration `peer` is `EnginePeer`; the outer `peers` SimpleDictionary is still `[PeerId: Peer]`. Rewrite: - -```swift -for (_, peer) in participant.peers { - peers[peer.id] = peer._asPeer() -} -``` - -### Constructor sites with no `peers:` arg — no change (12 sites) - -Default value's *type* changes (`[PeerId: Peer] = [:]` → `[EnginePeer.Id: EnginePeer] = [:]`) but the literal `[:]` works for either. These sites compile unchanged: - -- TelegramCore: `ChannelAdminEventLogs.swift:271, 279` (x2), `:287` (x2), `:483` (x2) — 7 constructions -- `PeerInfoUI/.../ChannelAdminsController.swift:921` -- `PeerInfoUI/.../ChannelMembersSearchContainerNode.swift:987` -- `PeerInfoUI/.../ChannelMembersSearchControllerNode.swift:404` -- `TelegramUI/.../ChatRecentActionsController/.../ChatRecentActionsFilterController.swift:445` -- `TelegramUI/.../ChatControllerAdminBanUsers.swift:224, :370, :755` (3 constructions) -- `TelegramUI/.../StoryContainerScreen/.../StoryContentLiveChatComponent.swift:361` - -## Net impact - -**Consumer-surface bridges:** −6 wraps + −5 unwrap transforms + +1 unwrap = **−10 bridges**. - -**TelegramCore-internal bridges:** +~12 wraps (`EnginePeer(peer)` at producer insertion points, inside `import Postbox` modules). These do not regress Postbox-hygiene since every producer file already imports Postbox. - -**Structural:** `RenderedChannelParticipant` public surface contains no raw `Peer` types after this wave (only `ChannelParticipant`, `EnginePeer`, `[EnginePeer.Id: EnginePeer]`, `[PeerId: PeerPresence]`). `presences` still leaks `PeerPresence` — separate future migration. - -## Iteration budget - -**2–3 iterations** (wave-41 foundational-type lesson: field migrations on passed-around structs budget 2–4 iterations, not first-pass-clean). - -Verified absence of hidden grep surface: -- No `as?` / `is TelegramX` casts on `participant.peers[X]` extractions (grepped). -- No Peer-only properties accessed on extractions (uses `.id` and `.displayTitle(...)` only — both EnginePeer-forwarded). -- All 8 TelegramCore producers build locally (verified) — no chain-migration. - -## Risks - -1. **Producer local-dict migration under `continueOnError`.** If a producer builds the dict with more than two insertions and misses one, the build flags mismatched dict-value types. Low blast radius (per-file local). -2. **Hidden consumer site.** If a grep miss surfaces a `participant.peers` site not enumerated here, the wrap/unwrap balance changes. Mitigation: plan document must re-run the narrow grep (`participant\.peers|rcp\.peers|renderedParticipant\.peers`) at plan-write time and iteration-0 time. -3. **mapValues source-dict check.** If any of the 5 consumer-side `.mapValues({ $0._asPeer() })` sites has a source `[PeerId: Peer]` (not `[PeerId: EnginePeer]`), the migration at that site inverts (becomes a wrap instead of a drop). Plan-time per-site verification required. -4. **SimpleDictionary import.** The one ADD-UNWRAP site in `ChatRecentActionsHistoryTransition.swift` already uses `SimpleDictionary` — no new Postbox exposure. - -## Out of scope - -- `RenderedChannelParticipant.presences: [PeerId: PeerPresence]` — `PeerPresence` is a Postbox protocol; separate migration with different shape. -- `RenderedPeer → EngineRenderedPeer` foundational-type migration (listed in wave-44 memo as candidate 6; save for a dedicated session). -- `PeerInfoHeader*` bundle (wave-44 memo candidate 1) — considered but not selected for wave 44; candidate for wave 45. - -## Success criteria - -1. `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift` has `peers: [EnginePeer.Id: EnginePeer]` declaration. -2. All 8 TelegramCore producers compile with wrapped inserts. -3. All 5 consumer `.mapValues({ $0._asPeer() })` transforms are removed. -4. All 6 consumer `EnginePeer(peer).displayTitle(...)` wraps on extracted dict values are removed (`peer.displayTitle(...)`). -5. `ChatRecentActionsHistoryTransition.swift:673` uses `peer._asPeer()` for the SimpleDictionary insertion value. -6. Full `Telegram/Telegram` build (`configuration=debug_sim_arm64`) is clean — **one** atomic commit. -7. Grep post-migration: `participant\.peers\[` returns only engine-typed call sites; no residual `EnginePeer(peer)` on `.peers[...]` extractions. - -## Commit message template - -``` -Postbox -> TelegramEngine wave 44 - -Migrate RenderedChannelParticipant.peers from [PeerId: Peer] to -[EnginePeer.Id: EnginePeer]. Closes the wave-41 ratchet — the public -struct no longer leaks raw Peer types in any field (presences stays -Postbox-typed; separate migration). - -Consumer-surface: -10 bridges (6 EnginePeer(peer) wraps dropped at -read sites, 5 .mapValues({ $0._asPeer() }) transforms dropped at -constructor sites, 1 ._asPeer() added at -ChatRecentActionsHistoryTransition.swift:673 where the value is -inserted into a raw-Peer SimpleDictionary). - -TelegramCore producers: 8 files, each builds a local -[EnginePeer.Id: EnginePeer] dict from transaction.getPeer() wrapping -at the insertion point. - -No unit tests in this project; full Telegram/Telegram build verified -under configuration=debug_sim_arm64. -``` diff --git a/docs/superpowers/specs/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration-design.md b/docs/superpowers/specs/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration-design.md deleted file mode 100644 index d981ca3f83..0000000000 --- a/docs/superpowers/specs/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration-design.md +++ /dev/null @@ -1,175 +0,0 @@ -# Wave 41 — `RenderedChannelParticipant.peer: Peer → EnginePeer` migration — Design - -**Date:** 2026-04-24 -**Wave:** 41 -**Status:** spec - -## Goal - -Migrate the `peer` field of `TelegramCore.RenderedChannelParticipant` from the Postbox protocol `Peer` to the TelegramCore enum `EnginePeer`. All construction sites and consumer accesses are updated in one atomic commit. - -## Motivation - -- Drops 2 Shape-C `EnginePeer(participant.peer)` wraps installed by wave 39 (`ChannelMembersController.swift:707`, `ChannelBlacklistController.swift:381`). -- Drops ~37 additional `EnginePeer(...)` / `._asPeer()` bridges across the consumer surface (total ~39 bridge drops after counting `EnginePeer(peer.peer).compactDisplayTitle` sites in `AdminUserActionsSheet.swift`). -- Aligns `RenderedChannelParticipant.peer` with the pattern established for `FoundPeer.peer` (wave 34), `SendAsPeer.peer` (wave 35), `ContactListPeer.peer` (wave 36), and all `AccountContext.makeX(peer: ...)` facades (waves 37–40). -- Ratchet candidate for future waves: once `.peer` is `EnginePeer`, the `peers: [PeerId: Peer]` dict field becomes the only Postbox-typed field on the struct — a follow-up wave can migrate `peers: [EnginePeer.Id: EnginePeer]` in isolation. - -## Scope - -### In scope - -**TelegramCore:** -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift` — change struct field + init param + Equatable impl -- 9 TelegramCore files containing 16 construction sites where `RenderedChannelParticipant(... peer: peer, ...)` is called with a raw `Peer` from `transaction.getPeer()` — wrap with `EnginePeer(peer)`: - - `Messages/RequestStartBot.swift:65` - - `Peers/AddPeerMember.swift:255` - - `Peers/ChannelAdminEventLogs.swift:271, 279, 287, 483` (7 constructor calls total) - - `Peers/ChannelBlacklist.swift:140` - - `Peers/ChannelMembers.swift:115` - - `Peers/ChannelOwnershipTransfer.swift:180` (2 constructor calls) - - `Peers/JoinChannel.swift:82` - - `Peers/PeerAdmins.swift:262` - - `Peers/Ranks.swift:95` - -**Consumer (17 files):** all sites accessing `participant.peer` or constructing `RenderedChannelParticipant`: -- `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift` -- `submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift` -- `submodules/PeerInfoUI/Sources/ChannelMembersController.swift` -- `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` -- `submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift` -- `submodules/PeerInfoUI/Sources/ChannelPermissionsController.swift` -- `submodules/SearchPeerMembers/Sources/SearchPeerMembers.swift` -- `submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift` -- `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift` -- `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsFilterController.swift` -- `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift` -- `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoMembers.swift` -- `submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreenState.swift` -- `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentLiveChatComponent.swift` -- `submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift` -- `submodules/TemporaryCachedPeerDataManager/Sources/ChannelMemberCategoryListContext.swift` -- `submodules/TemporaryCachedPeerDataManager/Sources/PeerChannelMemberCategoriesContextsManager.swift` - -### Out of scope (deferred) - -- `RenderedChannelParticipant.peers: [PeerId: Peer]` — still `[PeerId: Peer]` dict. Not migrated this wave. -- `RenderedChannelParticipant.presences: [PeerId: PeerPresence]` — still `[PeerId: PeerPresence]` dict. Not migrated this wave. -- `PeerInfoScreenData.peer → EnginePeer` — future wave 42 candidate (drops 2 wave-40 wraps). -- `RenderedPeer → EngineRenderedPeer` — future major wave; saved for a dedicated session. -- `PeerInfoMember.peer: Peer` enum accessor in `PeerInfoMembers.swift:30-39` — retained as `Peer` for this wave (contained by a single `._asPeer()` inside the `.channelMember` branch). Migration of this accessor is a separate follow-up. - -## Design - -### Struct change - -```swift -// submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift - -public struct RenderedChannelParticipant: Equatable { - public let participant: ChannelParticipant - public let peer: EnginePeer // ← was: Peer - public let peers: [PeerId: Peer] // unchanged - public let presences: [PeerId: PeerPresence] // unchanged - - public init(participant: ChannelParticipant, peer: EnginePeer, peers: [PeerId: Peer] = [:], presences: [PeerId: PeerPresence] = [:]) { - self.participant = participant - self.peer = peer - self.peers = peers - self.presences = presences - } - - public static func ==(lhs: RenderedChannelParticipant, rhs: RenderedChannelParticipant) -> Bool { - return lhs.participant == rhs.participant && lhs.peer == rhs.peer // ← was: lhs.peer.isEqual(rhs.peer) - } -} -``` - -`EnginePeer` is `Equatable` by enum synthesis (verified — each associated value type is Equatable: `TelegramUser`, `TelegramGroup`, `TelegramChannel`, `TelegramSecretChat`). `==` becomes cleaner. - -### Consumer-site shapes - -Per the pre-flight classification, sites fall into these shapes: - -- **ZERO** (transparent) — `.id`, `.isDeleted`, `.indexName`, `.addressName`, `.compactDisplayTitle`, `.displayTitle(strings:displayOrder:)`, `.displayLetters`, `.debugDisplayTitle`, etc. All exposed on `EnginePeer`. ~160 sites. **No edit.** - -- **DROP** — `EnginePeer(participant.peer)` → `participant.peer`. ~32 consumer sites + 2 `.peer._asPeer()` downgrades that also drop. The biggest class of edits. Key sites: - - `ChannelAdminsController.swift:326, 921, 926` (921, 926 drop `._asPeer()` from constructor) - - `ChannelBlacklistController.swift:170, 381` - - `ChannelMembersController.swift:334, 707` - - `ChannelMembersSearchContainerNode.swift:212 (×2), 223` - - `ChannelMembersSearchControllerNode.swift:148` - - `ChannelPermissionsController.swift:480, 483` - - `SearchPeerMembers.swift:30, 36, 61, 76` - - `ChatRecentActionsController.swift:359` - - `ChatRecentActionsFilterController.swift:217` - - `ChatRecentActionsHistoryTransition.swift:719, 730, 740, 828, 842, 870, 943, 955, 973, 990, 1026` (one `EnginePeer(new.peer)` drop per site) - - `ShareWithPeersScreenState.swift:558, 576` - - `AdminUserActionsSheet.swift:284, 404, 416, 417, 522, 523` (EnginePeer(peer.peer) wraps) - - `StoryContentLiveChatComponent.swift:370` (drops `._asPeer()`) - - `ChatControllerAdminBanUsers.swift:372, 757` (drops `._asPeer()`) - -- **CAST** — `if let user = participant.peer as? TelegramUser, user.botInfo != nil` → `if case let .user(user) = participant.peer, user.botInfo != nil`. 9 sites across 4 files: - - `ChannelMembersController.swift:305` - - `ChannelMembersSearchContainerNode.swift:752, 884, 1052, 1136` - - `ChannelMembersSearchControllerNode.swift:516, 558` - - `ShareWithPeersScreenState.swift:566` - - All 9 follow the identical 2-clause pattern (`as? TelegramUser`, `user.botInfo != nil`). Pattern-match rewrite is mechanically safe. - -- **ADD-ASPEER** — site needs raw `Peer`. 3 sites: - - `ChatRecentActionsHistoryTransition.swift:675` — `peers[participant.peer.id] = participant.peer` → `peers[participant.peer.id] = participant.peer._asPeer()` (assigning into `SimpleDictionary`). - - `ChatRecentActionsHistoryTransition.swift:2275` — same pattern. - - `PeerInfoMembers.swift:33` — `return participant.peer` → `return participant.peer._asPeer()` (outer enum accessor returns `Peer`; deliberately contained — migration of `PeerInfoMember.peer` deferred). - -- **ADD-WRAP** — consumer construction site where the local is raw `Peer` but the field is now `EnginePeer`. 7 sites across 3 files: - - `ChannelMembersSearchContainerNode.swift:987, 994, 998` — `peer: peer` where `peer = peerView.peers[participant.peerId]` is raw `Peer`. → `peer: EnginePeer(peer)`. - - `ChannelMembersSearchControllerNode.swift:404, 409, 413` — same pattern. - - `ChatRecentActionsFilterController.swift:445` — `peer: user` where `user: TelegramUser` (from `case let .user(user) = peer`). → `peer: .user(user)` or `peer: EnginePeer(user)`. Use `peer: .user(user)` (direct enum case) for clarity. - - `ChatControllerAdminBanUsers.swift:226` — `peer: peer` where `peer = author: Peer`. → `peer: EnginePeer(peer)`. - -### TelegramCore-internal constructor sites - -All 16 sites receive a raw `Peer` (from `transaction.getPeer()` / `peers[id]`) and pass it as `peer:`. All become `peer: EnginePeer(peer)`: - -```swift -// Before: -RenderedChannelParticipant(participant: participant, peer: peer, peers: peers, presences: presences) -// After: -RenderedChannelParticipant(participant: participant, peer: EnginePeer(peer), peers: peers, presences: presences) -``` - -No shape-selection judgment required — all 16 sites follow this exact template. The `peers` and `presences` dictionaries are unchanged. - -## Risks - -- **R1: CAST semantic preservation.** The 9 `as? TelegramUser` sites all gate on `user.botInfo != nil`. Pattern-match rewrite is `if case let .user(user) = participant.peer, user.botInfo != nil`. Verified: `EnginePeer.user(TelegramUser)` gives access to the same `TelegramUser` instance; `.botInfo` is a `TelegramUser` property. Semantically equivalent. - -- **R2: `==` implementation change.** The struct's `==` goes from `lhs.peer.isEqual(rhs.peer)` (protocol dispatch) to `lhs.peer == rhs.peer` (synthesized). `EnginePeer.==` uses Swift-synthesized enum equality: each case compares associated values. Each associated-value type (`TelegramUser`, `TelegramGroup`, `TelegramChannel`, `TelegramSecretChat`) is `Equatable` via its own `==` implementation. Semantically equivalent to the protocol `isEqual`. - -- **R3: PeerInfoMembers.swift:33 cascade.** `PeerInfoMember.peer: Peer` enum accessor at line 30-39 returns `participant.peer` on the `.channelMember` branch. Fix is a single `._asPeer()`. The outer enum's API stays unchanged — no cascade beyond this file. Future wave can migrate `PeerInfoMember.peer` to `EnginePeer`. - -- **R4: Consumer-side constructor sites in ChannelMembersSearch*Node.** 3 sites each in the `Container` and `Controller` node files construct `RenderedChannelParticipant` for the legacy-group search path. The `peer` local is raw `Peer` from `peerView.peers`. Mechanical wrap with `EnginePeer(peer)` at the `peer:` argument. - -- **R5: `participant.peers` dict staying `[PeerId: Peer]`.** Current code uses `peers.mapValues({ $0._asPeer() })` at construction sites where the local dict is `[EnginePeer.Id: EnginePeer]`. This pattern is unchanged by the wave — the `peers` field is not being migrated. - -- **R6: Hidden consumer sites.** Pre-flight searched: `RenderedChannelParticipant(` constructors across `submodules/`, `participant.peer` access (subagent classification), all files that import TelegramCore/Postbox and reference `RenderedChannelParticipant`. 17 consumer files + 10 TelegramCore files confirmed. Risk of overlooked third-party or sparse consumer: low. - -- **R7: Pre-existing WIP contamination.** `git status` shows unrelated WIP: `submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift`, `build-system/bazel-rules/sourcekit-bazel-bsp` submodule marker, several untracked dirs. Wave-39 lesson: enumerate files explicitly in `git add`; run `git status --short` after staging. - -## Verification - -- Single full Bazel build with `--continueOnError` after all edits (extends wave-39 / wave-40 pattern). -- Expected outcome: **first-pass-clean build** based on wave-39 precedent — 52 files / 73 sites / non-propagating signature migration → first-pass-clean. This wave is comparable scale (27 files / ~200+ sites including ZEROs) with even cleaner mechanics: ZERO sites are literally no edit; DROP/CAST/ADD-WRAP/ADD-ASPEER patterns are all mechanical; no inference-dependent return types. -- Budget: 3–5 iterations if classification is wrong; first-pass-clean if classification is exact. - -## Net ratchet economics - -- Bridges dropped: ~37–39 (32 consumer DROPs + 2 `._asPeer()` drops in ChannelAdminsController + ~6 `EnginePeer(peer.peer).X` drops in AdminUserActionsSheet, possibly double-counted; final net post-commit grep will settle the number). -- Bridges added: ~23 (16 TelegramCore `EnginePeer(peer)` wraps at constructor call sites + 4 ADD-WRAP consumer constructors + 3 ADD-ASPEER). -- **Net:** ~−14 to −16 bridges. Positive economics even counting TelegramCore-internal adds. -- Ratchet marker: the 4 consumer ADD-WRAP constructor sites (`ChannelMembersSearch*Node` + `ChatControllerAdminBanUsers:226`) are candidates for drop in a future wave that migrates the `peerView.peers[id]` / `authors: [Peer]` upstream flows to EnginePeer. - -## Out-of-scope inventory (for the next wave) - -If a follow-up wave migrates **`RenderedChannelParticipant.peers: [PeerId: Peer] → [EnginePeer.Id: EnginePeer]`**, the ADD-WRAP sites in this wave (all `peers: peers.mapValues({ $0._asPeer() })`) simplify to `peers: peers`. That's a high-ratchet candidate wave that becomes mechanical once this wave lands. diff --git a/docs/superpowers/specs/2026-04-24-sendaspeer-engine-peer-migration-design.md b/docs/superpowers/specs/2026-04-24-sendaspeer-engine-peer-migration-design.md deleted file mode 100644 index 6a89aa635c..0000000000 --- a/docs/superpowers/specs/2026-04-24-sendaspeer-engine-peer-migration-design.md +++ /dev/null @@ -1,141 +0,0 @@ -# Wave 35 — `SendAsPeer.peer` `Peer` → `EnginePeer` - -Date: 2026-04-24 -Status: approved design, awaiting plan -Wave shape: Peer-typed-API single atomic commit (wave 34 pattern replayed on a smaller target) - -## Goal - -Eliminate the Postbox-protocol `Peer` leak in the public `SendAsPeer` struct by migrating its `peer` field from `Peer` to `EnginePeer`. Apply wave 34's lessons — comprehensive pre-flight grep including `.peer as?`/`is` casts, outflow-arg patterns, and loop-body `.peer` accesses — to keep post-commit build iterations low. - -## Non-goals - -- `ContactListPeer.peer(peer: Peer, ...)` case-payload migration — broader blast radius, deferred. -- `canSendMessagesToPeer(_:)` parameter migration — broader blast radius, deferred. -- `makePeerInfoController` / `makeChatQrCodeScreen` / `makeChatRecentActionsController` protocol-method migrations — broader blast radius, deferred. -- `CachedSendAsPeers` cache entry — already `PeerId`-based, entirely inside TelegramCore; no change needed. -- No new engine wrappers, typealiases, or facades introduced in this wave. - -## Type change - -```swift -// Before -public struct SendAsPeer: Equatable { - public let peer: Peer // Postbox protocol - public let subscribers: Int32? - public let isPremiumRequired: Bool - public init(peer: Peer, subscribers: Int32?, isPremiumRequired: Bool) { … } - public static func ==(lhs: SendAsPeer, rhs: SendAsPeer) -> Bool { - return lhs.peer.isEqual(rhs.peer) && lhs.subscribers == rhs.subscribers && lhs.isPremiumRequired == rhs.isPremiumRequired - } -} - -// After -public struct SendAsPeer: Equatable { - public let peer: EnginePeer // TelegramCore value type - public let subscribers: Int32? - public let isPremiumRequired: Bool - public init(peer: EnginePeer, subscribers: Int32?, isPremiumRequired: Bool) { … } - // Equatable synthesized — EnginePeer is Equatable. -} -``` - -## In-scope files - -### Category α — TelegramCore (definition + internal construction) - -**`submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift`** -- Lines 7–21: struct definition. Change `peer: Peer` → `peer: EnginePeer`. Remove manual `==`; rely on synthesized Equatable. -- Line 64 (`_internal_cachedPeerSendAsAvailablePeers`): `SendAsPeer(peer: peer, …)` — wrap raw Postbox `Peer` with `EnginePeer(peer)`. -- Line 170 (`_internal_peerSendAsAvailablePeers`): same wrap. -- Line 236 (`_internal_cachedLiveStorySendAsAvailablePeers`): same wrap. -- Line 330 (`_internal_liveStorySendAsAvailablePeers`): same wrap. -- Lines 87, 90, 259, 262: `peer.peer.id` accesses inside the caching loop — `EnginePeer.id` returns `EnginePeer.Id` which is a typealias for `PeerId`; code keeps compiling. - -No other TelegramCore files reference `SendAsPeer`. - -### Category β — Pure token/init/access (no body edits expected) - -**`submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift`** -- Line 553: `public let sendAsPeers: [SendAsPeer]?` — field typed at collection level, no `.peer` access in this file. -- Lines 751–752 / 848 / 1068 / 1408: init parameter, assignment, equality comparison at `[SendAsPeer]?` level, and `updatedSendAsPeers(_:)` method. None reference the inner `.peer` field. -- Expected edits: zero. This file should remain untouched if the field-type migration is clean. - -**`submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift`** -- Out of scope: its `openSendAsPeer: (ASDisplayNode, ContextGesture?) -> Void` callback does NOT take a `SendAsPeer`; name-collision only. - -### Category γ — Cast-downstream - -**`submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift`** -- Lines 20, 26: `peers: [SendAsPeer]` field and constructor — no edit needed. -- Lines 68–82: iteration body. - - Line 70: `peer.peer.id.namespace == Namespaces.Peer.CloudUser` — unchanged (EnginePeer.Id retains `.namespace`). - - Line 73: **`if let peer = peer.peer as? TelegramChannel`** → rewrite as `if case let .channel(channelData) = peer.peer`, matching on the `EnginePeer` enum case. Downstream `channelData.info` access behaves the same; `case .broadcast = channelData.info` continues to compile because `EnginePeer.channel` wraps the same `TelegramChannel.Info` enum. -- Lines 89 / 110 / 116 / 121: `EnginePeer(peer.peer)` — drop the wrap, use `peer.peer` directly. - -### Category δ — Outflow (construction and field access) - -**`submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift`** -- Line 772: `SendAsPeer(peer: peer._asPeer(), …)` — drop `._asPeer()`; construction now takes `EnginePeer` directly. `peer` at this site is already an `EnginePeer` upstream. -- Lines 805, 823: `SendAsPeer(peer: channel, …)` where `channel` is a raw `TelegramChannel` — wrap with `EnginePeer(channel)`. -- Lines 792 / 826 / 835 / 844: `allPeers` array ops and `.peer.id` filter/find — unchanged. - -**`submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelComponent.swift`** -- Line 847: `SendAsPeer(peer: sendAsConfiguration.currentPeer._asPeer(), …)` — drop `._asPeer()`. `sendAsConfiguration.currentPeer` is `EnginePeer` upstream. -- Line 851: `updatedSendAsPeers([…])` — unchanged. - -**`submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift`** -- Line 1625: `EnginePeer(peer)` where `peer` is now `EnginePeer` → collapses to `peer`. -- Lines 1616 / 1620 / 1622 / 2948 / 5370: `.peer.id` comparisons, `sendAsPeers.first(where:)` — unchanged. - -**`submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift`** -- Line 249: `SendAsPeer(peer: accountPeer._asPeer(), …)` — drop `._asPeer()`. -- Line 4080: `(sendAsPeer?.peer).flatMap(EnginePeer.init)` → simplifies to `sendAsPeer?.peer` (already `EnginePeer?`). -- Line 4081: `.map({ EnginePeer($0.peer) })` → `.map({ $0.peer })`. -- Line 254 / 688 / 701 / 702 / 705 / 4050 / 4068 / 4069 / 4088 / 4089 / 4327 / 4333 / 4340 / 4356 / 4372: `.peer.id` accesses, variable bindings, optional access — unchanged. -- Line 4340: `call.sendStars(fromId: sendAsPeer?.peer.id, …)` — `EnginePeer.Id == PeerId`, unchanged. - -**`submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift`** -- Lines 3056–3072: `sendMessageContext.currentSendAsPeer` pass-through to context-menu item. Verify call-site type expectations during implementation; likely no edit needed since `ChatSendAsPeerListContextItem` keeps taking `[SendAsPeer]`. - -## Out-of-scope — name collisions (do not touch) - -- `submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/LiveStreamSettingsScreen.swift:271-272` — `screenState.sendAsPeers` is `[EnginePeer]` (see `ShareWithPeersScreen.swift:1114`). Different type, same name. -- `submodules/TelegramUI/Components/Chat/ChatSendStarsScreen/Sources/ChatSendStarsScreen.swift:1515,2749,2958` — `availableSendAsPeers: [EnginePeer]` enum-case payload. Different type, same name. -- `submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift:7070`, `ShareWithPeersScreen.swift:39,57,74,817,1301,2352,3284,3453` — `initialSendAsPeerId: EnginePeer.Id?` / method names containing "SendAsPeer". PeerId parameter, not the struct. -- Callback declarations in `ChatPanelInterfaceInteraction.swift`, `AttachmentPanel.swift`, `PeerSelectionControllerNode.swift`, `ChatRecentActionsController.swift`, `PeerInfoSelectionPanelNode.swift` named `updateShowSendAsPeers` / `openSendAsPeer` — these take `(Bool)`/`(ASDisplayNode, ContextGesture?)`, not `SendAsPeer` values. - -## Execution plan outline (for writing-plans) - -Single atomic commit ordering: - -1. Edit `SendAsPeers.swift` — change field type, init parameter, drop manual `==`, wrap raw `Peer` at the 4 construction sites with `EnginePeer(peer)`. -2. Edit `ChatSendAsPeerListContextItem.swift` — rewrite line 73 cast to EnginePeer case match; drop `EnginePeer(peer.peer)` wraps at 89/110/116/121. -3. Edit `ChatControllerLoadDisplayNode.swift` — drop `._asPeer()` at 772; wrap `channel` with `EnginePeer(channel)` at 805/823. -4. Edit `ChatTextInputPanelComponent.swift` — drop `._asPeer()` at 847. -5. Edit `ChatTextInputPanelNode.swift` — collapse `EnginePeer(peer)` at 1625 to `peer`. -6. Edit `StoryItemSetContainerViewSendMessage.swift` — drop `._asPeer()` at 249; simplify flatMap at 4080; simplify map at 4081. -7. Verify `ChatPresentationInterfaceState.swift` and `StoryItemSetContainerComponent.swift` need no body edits. -8. Build: `source ~/.zshrc 2>/dev/null; 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`. -9. Fix any files the inventory undercounted (expect scalar `.peer` accesses in closure bodies). Commit once build is green. - -## Risk register - -| Risk | Mitigation | -|------|------------| -| Inventory undercount (wave 34 lost ~30%) | Pre-flight grep already includes `.peer as?`/`is`/outflow; use `--continueOnError` on first build to surface all sites in one pass. | -| Cast at `ChatSendAsPeerListContextItem:73` doesn't round-trip | `EnginePeer.channel(TelegramChannel)` wraps the exact same concrete type; the `if case let .channel(ch)` rewrite preserves all `ch.info`/`ch.flags`/etc. semantics. | -| `SendAsPeer` Equatable synthesis regression | `EnginePeer` and `Int32?` and `Bool` are all Equatable; synthesized `==` produces the same truth table modulo replacing `Peer.isEqual` with `EnginePeer ==` (which for `.channel(a)` vs `.channel(b)` compares the underlying `TelegramChannel` via its own Equatable). No behavior change expected. | -| `StoryItemSetContainerComponent.swift:3056-3072` outflow missed | Plan step 7 verifies this during implementation; if a wrap/unwrap is needed at the context-menu boundary, add it inline. | - -## Validation - -- Full Bazel build (`--configuration=debug_sim_arm64 --continueOnError`). -- No TelegramCore/Postbox/TelegramApi errors (scope boundary check — halt if they surface). -- Grep post-commit: `rg "SendAsPeer\(peer: .*\._asPeer" submodules/` returns empty. -- Grep post-commit: `rg "EnginePeer\(.*\.peer\b" submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu` returns empty. - -## Lessons to carry forward - -- Wave 34's grep pattern (``-literal token only) undercounted ~30%. This wave's Explore inventory explicitly included `.peer as?`/`is`/outflow-helper/`EnginePeer(.peer)` / `._asPeer()` patterns. Record the post-commit file count vs. pre-commit inventory to calibrate future Peer-typed-API waves. -- Name collisions (different types, same identifier) are a recurring scoping hazard — confirmed in this wave for `sendAsPeers: [EnginePeer]` and `availableSendAsPeers: [EnginePeer]`. Future Peer-typed-API waves should include a name-collision disambiguation pass during inventory. diff --git a/docs/superpowers/specs/2026-04-25-peerinfo-enclosingpeer-engine-peer.md b/docs/superpowers/specs/2026-04-25-peerinfo-enclosingpeer-engine-peer.md deleted file mode 100644 index 1d9a3fe296..0000000000 --- a/docs/superpowers/specs/2026-04-25-peerinfo-enclosingpeer-engine-peer.md +++ /dev/null @@ -1,127 +0,0 @@ -# Wave 50 — `enclosingPeer` Peer? → EnginePeer? - -**Date:** 2026-04-25 -**Pattern:** struct-field + stored-form `Peer?` → `EnginePeer?` (wave-47/48 shape). -**Module:** `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/` only — no public-API leaks. - -## Goal - -Migrate the PeerInfo members chain's `enclosingPeer` field from raw Postbox `Peer?` to `EnginePeer?`. Drops 2 `_asPeer()` demotions, 1 `EnginePeer(...)` wrap, 1 `flatMap(EnginePeer.init)` simplification, and 1 PSPB boundary `_asPeer()` lift. Closes the wave-48-pattern internal-demotion-and-external-re-promotion ratchet at PIMP:354–363 (engine.data subscription returns `EnginePeer?`, currently demoted to `Peer?` at the storage boundary). - -## Type changes - -| File | Site | Before | After | -|---|---|---|---| -| `PeerInfoScreenMemberItem.swift:23` | stored `let enclosingPeer` | `Peer?` | `EnginePeer?` | -| `PeerInfoScreenMemberItem.swift:34` | init param | `Peer?` | `EnginePeer?` | -| `PeerInfoMembersPane.swift:92` | `func item(... enclosingPeer:)` | `Peer` | `EnginePeer` | -| `PeerInfoMembersPane.swift:271` | `func preparedTransition(... enclosingPeer:)` | `Peer` | `EnginePeer` | -| `PeerInfoMembersPane.swift:293` | `private var enclosingPeer` | `Peer?` | `EnginePeer?` | -| `PeerInfoMembersPane.swift:442` | `func updateState(enclosingPeer:)` | `Peer` | `EnginePeer` | - -`PeerInfoScreenMemberItem` and `PeerInfoMembersPaneNode` are local to the module — no cross-module signature ripple. - -## Edit patterns - -### A. Conditional cast → case-let (wave-41/45 idiom) - -| File:Line | Before | After | -|---|---|---| -| PSMI:152 | `if let channel = item.enclosingPeer as? TelegramChannel, channel.hasPermission(.editRank)` | `if case let .channel(channel) = item.enclosingPeer, channel.hasPermission(.editRank)` | -| PSMI:154 | `else if let group = item.enclosingPeer as? TelegramGroup, !group.hasBannedPermission(.banEditRank)` | `else if case let .legacyGroup(group) = item.enclosingPeer, !group.hasBannedPermission(.banEditRank)` | -| PIMP:113 | `if let channel = enclosingPeer as? TelegramChannel, channel.hasPermission(.editRank)` | `if case let .channel(channel) = enclosingPeer, channel.hasPermission(.editRank)` | -| PIMP:115 | `else if let group = enclosingPeer as? TelegramGroup, !group.hasBannedPermission(.banEditRank)` | `else if case let .legacyGroup(group) = enclosingPeer, !group.hasBannedPermission(.banEditRank)` | - -The `case let` pattern binds `channel: TelegramChannel` / `group: TelegramGroup` directly — `.hasPermission(.editRank)` and `.hasBannedPermission(.banEditRank)` are class methods on the bound concrete types. No `_asPeer()` bridge needed. - -### B. `is`-check → `case` (wave-41 always-false-warning fix) - -| File:Line | Before | After | -|---|---|---| -| PSMI:181 | `if actions.contains(.promote) && item.enclosingPeer is TelegramChannel` | `if actions.contains(.promote), case .channel = item.enclosingPeer` | -| PSMI:187 | `if item.enclosingPeer is TelegramChannel` | `if case .channel = item.enclosingPeer` | -| PIMP:142 | `if actions.contains(.promote) && enclosingPeer is TelegramChannel` | `if actions.contains(.promote), case .channel = enclosingPeer` | -| PIMP:148 | `if enclosingPeer is TelegramChannel` | `if case .channel = enclosingPeer` | - -PIMP:113/115/142/148 are inside `func item(... enclosingPeer: EnginePeer ...)`, so `enclosingPeer` is non-optional inside that body; PSMI sites are against `item.enclosingPeer: EnginePeer?`. `case let .channel(channel)` and `case .channel` both compile cleanly against optional and non-optional EnginePeer. - -### C. Drop wraps / unwraps - -| File:Line | Before | After | -|---|---|---| -| PSMI:178 | `peer: item.enclosingPeer.flatMap(EnginePeer.init)` | `peer: item.enclosingPeer` | -| PIMP:139 | `peer: EnginePeer(enclosingPeer)` | `peer: enclosingPeer` | -| PIMP:361 | `strongSelf.enclosingPeer = enclosingPeer._asPeer()` | `strongSelf.enclosingPeer = enclosingPeer` | -| PIMP:363 | `updateState(enclosingPeer: enclosingPeer._asPeer(), state: state, presentationData: presentationData)` | `updateState(enclosingPeer: enclosingPeer, state: state, presentationData: presentationData)` | -| PSPB:852 | `enclosingPeer: peer._asPeer()` | `enclosingPeer: peer` | - -### D. No-op call sites (type flows through transparently) - -- `PeerInfoSettingsItems.swift:132` — `enclosingPeer: nil` (nil literal works for any optional) -- `PeerInfoMembersPane.swift:275/276` — pass-through `enclosingPeer: enclosingPeer` -- `PeerInfoMembersPane.swift:437/438` — `if let enclosingPeer = self.enclosingPeer ... self.updateState(enclosingPeer: enclosingPeer, ...)` (both stored-form and `updateState` param shift to EnginePeer; type carries through) -- `PeerInfoMembersPane.swift:451` — pass-through -- `PeerInfoMembersPane.swift:485` — `self.enclosingPeer = enclosingPeer` (param and stored-form both EnginePeer) -- `PeerInfoScreenOpenMember.swift` — uses `self.data?.peer` (already `EnginePeer?` post-wave-42), unrelated to this migration - -**Total edits:** 19 across 3 files (PSMI, PIMP, PSPB) — 6 type-change edits in the table at the top of this spec + 4 (Pattern A) + 4 (Pattern B) + 5 (Pattern C). - -## Risk register - -| Risk | Mitigation | -|---|---| -| `case .channel = item.enclosingPeer` against `EnginePeer?` semantics | Wave-45 lesson confirms `case let .x(y) = peer` compiles cleanly against `EnginePeer?`. Matches `.some(.channel)`, rejects `nil` and other cases — equivalent to `is TelegramChannel` semantics. | -| `if actions.contains(.promote), case .channel = ...` mixed boolean + pattern condition | Standard Swift if-case syntax (introduced in wave 41 idiom for this codebase). | -| Hidden Peer-only property access on bare `enclosingPeer` | Pre-flight grep complete: only access patterns are `.id` (EnginePeer has it), and cast-bound `channel.hasPermission` / `group.hasBannedPermission`. No `_asPeer()` bridges expected. | -| Closure capture aliases (wave-47 lesson) | Pre-flight grep covered `strongSelf.enclosingPeer` (PIMP:361) and `self.enclosingPeer` (PIMP:437/485). | -| `enclosingPeer: nil` literal at PSI:132 | `nil` is valid for any optional — no edit. | -| `availableActionsForMemberOfPeer` signature compatibility | Confirmed `EnginePeer?` at `PeerInfoData.swift:2314`. Both PSMI:178 and PIMP:139 are pure simplifications. | -| Always-false `is` check warning under `-warnings-as-errors` | Wave-41 lesson — handled by Pattern B. | - -## Wave shape - -**Classification:** cross-file private struct-field migration with stored-form ratchet (wave-47 taxonomy: "cross-file private"). -**Iteration budget:** 1–2 (target first-pass-clean per wave 48/49 streak). -**Subagent dispatch:** not needed — 17 edits / 3 files is single-implementer scope. - -## Verification - -### Build - -```sh -source ~/.zshrc 2>/dev/null; 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 -``` - -### Post-edit residue grep (expect empty) - -```sh -grep -rnE "enclosingPeer\._asPeer|EnginePeer\(enclosingPeer\)|enclosingPeer\.flatMap\(EnginePeer" \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ - -grep -rnE "enclosingPeer.*as\? TelegramChannel|enclosingPeer.*as\? TelegramGroup|enclosingPeer is TelegramChannel" \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ -``` - -## Net delta projection - -- **Internal bridges:** −5 (2× `_asPeer()` at PIMP:361/363, 1× `EnginePeer(...)` at PIMP:139, 1× `flatMap(EnginePeer.init)` at PSMI:178, 1× boundary `_asPeer()` at PSPB:852). -- **Boundary lifts:** 0 net new — the source pipeline (engine.data subscription at PIMP:354) already yields `EnginePeer?`. Migration just removes the demote-then-promote dance. -- **ADD wraps:** 0 expected (no Peer-only property accesses on bare `enclosingPeer`). - -## Out of scope - -- `PeerInfoScreenData.chatPeer: Peer?` — large cascade (PSPB `as? TelegramX` × 5, ClearPeerHistory cascade, openClearHistory wraps × 4, PSOC × 2). Memory's wave-50 candidate Option 3, deferred for a multi-iteration wave. -- `PeerInfoGroupsInCommonPaneNode.PeerEntry.peer: Peer` — separate single-file migration, not bundled (wave-49 source-of-truth-coherence rule: unrelated chains stay in their own waves). Candidate for wave 51. -- `RenderedPeer → EngineRenderedPeer` foundational refactor — dedicated session. - -## Memory file update - -After landing, update `project_postbox_refactor_next_wave.md`: -- Move wave 50 outcome into the recent-waves list. -- Promote wave 51 candidate (`PeerInfoGroupsInCommonPaneNode.PeerEntry.peer` likely; otherwise re-scan the module with the standard grep). diff --git a/docs/superpowers/specs/2026-04-26-postbox-wave-103-chat-recent-actions-controller-node-design.md b/docs/superpowers/specs/2026-04-26-postbox-wave-103-chat-recent-actions-controller-node-design.md deleted file mode 100644 index 5d7800d814..0000000000 --- a/docs/superpowers/specs/2026-04-26-postbox-wave-103-chat-recent-actions-controller-node-design.md +++ /dev/null @@ -1,120 +0,0 @@ -# Wave 103 — `ChatRecentActionsControllerNode.peer: Peer → EnginePeer` - -**Date:** 2026-04-26 -**Pattern:** close-the-shadow boundary unwrap drop (wave-71-shadow). Single-file private stored-field migration with caller-side `_asPeer()` removal at the module boundary. -**Module:** `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/` only — no public-API leak. - -## Goal - -Migrate `ChatRecentActionsControllerNode`'s stored `peer: Peer` to `EnginePeer`, dropping the `_asPeer()` boundary call inside `ChatRecentActionsController`. Net effect: −1 `_asPeer()` boundary wrap, −1 `import Postbox`, −1 module from the Postbox-importing list. - -The caller (`ChatRecentActionsController`) already holds `peer: EnginePeer` and demotes it once at line 277 before passing into the ControllerNode init. This is the wave-71-shadow shape: the public API is already `EnginePeer`, but a private internal storage form was left as `Peer` at wave-71 time. Closing it now is a clean, contained migration. - -## Type changes - -| File | Site | Before | After | -|---|---|---|---| -| `ChatRecentActionsControllerNode.swift:46` | stored `private let peer` | `Peer` | `EnginePeer` | -| `ChatRecentActionsControllerNode.swift:111` | init param `peer:` | `Peer` | `EnginePeer` | -| `ChatRecentActionsControllerNode.swift:5` | `import Postbox` | present | removed | -| `ChatRecentActionsController.swift:277` | call `peer: self.peer._asPeer()` | demoted | `peer: self.peer` | - -`ChatRecentActionsControllerNode` has no public-API consumers outside `ChatRecentActionsController` (single caller site verified by grep `ChatRecentActionsControllerNode\(`). - -## Edit patterns - -### A. Conditional cast → case-let (wave-41/45 idiom) - -| File:Line | Before | After | -|---|---|---| -| ChatRecentActionsControllerNode.swift:899 | `if let peer = strongSelf.peer as? TelegramChannel { ... }` | `if case let .channel(peer) = strongSelf.peer { ... }` | -| ChatRecentActionsControllerNode.swift:948 | `if let channel = self.peer as? TelegramChannel, case .broadcast = channel.info { ... }` | `if case let .channel(channel) = self.peer, case .broadcast = channel.info { ... }` | -| ChatRecentActionsControllerNode.swift:1088 | `if let channel = self.peer as? TelegramChannel { ... }` | `if case let .channel(channel) = self.peer { ... }` | - -The `case let .channel(channel)` pattern binds `channel: TelegramChannel` directly. Inner code (`channel.info`, etc.) ports verbatim because `EnginePeer.channel`'s associated value is the concrete `TelegramChannel` class. - -`self.peer` is non-optional `EnginePeer` post-migration, so all three case-let conditions compile cleanly without optional-chaining. - -### B. Pass-through (no edit, type flows transparently) - -- `self.peer.id` — 4 sites (lines 145, 161, 1138, 1490). `EnginePeer.id` is an `EnginePeer.Id` typealias of `PeerId`, identical at the call sites that consume it (`channelAdminEventLog(peerId:)`, `admins(peerId:)`, `updateChannelMemberBannedRights(peerId:)`, et al. all accept the typealiased form). - -### C. Caller boundary drop - -| File:Line | Before | After | -|---|---|---| -| ChatRecentActionsController.swift:277 | `ChatRecentActionsControllerNode(... peer: self.peer._asPeer(), ...)` | `ChatRecentActionsControllerNode(... peer: self.peer, ...)` | - -`ChatRecentActionsController.peer` is already declared `EnginePeer` (init signature at line 42 confirmed). - -**Total edits:** 7 across 2 files. 4 type-change edits (3 in node + 1 caller) + 3 case-let rewrites. - -## Risk register - -| Risk | Mitigation | -|---|---| -| Other unrelated `_asPeer()` and `EnginePeer(peer)` sites in the same file (lines 357, 368, 1005 / 263, 1009, 1011, 1208, 1222) | Pre-flight grep verified these all operate on DIFFERENT `peer` locals (callback-bound search results, not `self.peer`). They are unaffected by this migration. | -| Hidden `Peer`-only property access on `self.peer` | Pre-flight grep complete: only attribute access is `.id` (EnginePeer-compatible). 3 `as? TelegramChannel` downcasts are the only conversion sites, all handled by Pattern A. | -| `as? TelegramGroup` or `as? TelegramUser` downcasts on `self.peer` | None present (verified by grep `self\.peer as\?` returning only the 3 TelegramChannel sites). | -| `is TelegramChannel`-style always-false warning under `-warnings-as-errors` | None present (no `is`-checks on `self.peer` — verified by grep). | -| Closure capture alias migration (wave-47 lesson) | Only `strongSelf.peer` and `self.peer` aliases — both ride the type change. No locally-bound `let peer = self.peer` aliases that would need separate type-flow tracking (verified by grep). | -| Caller side-effects from `_asPeer()` removal | `ChatRecentActionsController.swift:277` is the only call site (verified). The `_asPeer()` is pure conversion with no side effects. | -| Build cascade beyond the two files | Consumer-only — both files are inside `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/`. No TelegramCore touch, no cross-module ripple. Build cost ~25s. | - -## Wave shape - -**Classification:** wave-71-shadow close (single-file private stored-form migration with single-caller boundary drop). -**Iteration budget:** 1 (target first-pass-clean given the contained scope and validated pre-flight grep). -**Subagent dispatch:** not needed — 7 edits across 2 files is single-implementer scope. - -## Verification - -### Build - -```sh -source ~/.zshrc 2>/dev/null; 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 -``` - -(No `--continueOnError` — single-iter target with small scope.) - -### Post-edit residue grep (expect empty) - -```sh -# No remaining as? TelegramChannel on self.peer / strongSelf.peer -grep -nE "(self|strongSelf)\.peer as\? Telegram(Channel|Group|User)" \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ - -# No remaining _asPeer() on self.peer -grep -nE "self\.peer\._asPeer\(\)" \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ - -# No remaining import Postbox in the module -grep -rn "^import Postbox$" \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ -``` - -## Net delta projection - -- **Internal bridges:** −1 (the `_asPeer()` at `ChatRecentActionsController.swift:277`). -- **`import Postbox` drops:** −1 (`ChatRecentActionsControllerNode.swift:5`). -- **ADD wraps:** 0 (no Peer-only property accesses on bare `self.peer`). -- **Module Postbox-free count:** +1. - -## Out of scope - -- Other `Peer`-typed locals in the same file (search-callback-bound `peer` at lines 357, 368, 1005, etc.) — these belong to separate signatures (`Signal`, search result destructures from APIs that still return raw `Peer`). Migrating them is gated on those upstream APIs migrating first. -- `context.account.postbox.network` and similar Shape-D Postbox accesses — unrelated to this wave's `peer` field migration. -- `EnginePeer(peer)` boundary wraps inside callbacks (lines 263, 1009, 1011, 1208, 1222) — these wrap callback-bound search results, not `self.peer`. Out of scope for the same reason as above. - -## Memory file update - -After landing, update `project_postbox_refactor_next_wave.md`: -- Move wave 103 outcome into the recent-waves list (commit hash + 7-edit single-iter summary). -- Update the "Wave 103+ Shape-C/D candidates" line in `MEMORY.md` since this is technically a wave-71-shadow close, not a Shape-C/D refactor — the candidates listed there (NativeVideoContent, DirectMediaImageCache, SecureIdDocumentFormControllerNode) carry forward to wave 104+. -- The `ChatRecentActionsControllerNode.peer: Peer -> EnginePeer` candidate line in the next-wave file (currently bullet 5) gets removed. diff --git a/docs/superpowers/specs/2026-04-26-postbox-wave-103-retry-account-manager-store-resource-data-drain.md b/docs/superpowers/specs/2026-04-26-postbox-wave-103-retry-account-manager-store-resource-data-drain.md deleted file mode 100644 index bc24de86be..0000000000 --- a/docs/superpowers/specs/2026-04-26-postbox-wave-103-retry-account-manager-store-resource-data-drain.md +++ /dev/null @@ -1,149 +0,0 @@ -# Wave 103 (retry) — accountManager.mediaBox.storeResourceData drain - -**Date:** 2026-04-26 -**Pattern:** wave-shape-G drain of an existing TelegramCore facade (the wave-94 `AccountManagerResources.storeResourceData(id:data:synchronous:)`). -**Module:** `submodules/TelegramUI/Sources/ThemeUpdateManager.swift` + `submodules/WallpaperResources/Sources/WallpaperResources.swift` only — no TelegramCore touch, no public-API change. - -## Goal - -Drain the 5 remaining `accountManager.mediaBox.storeResourceData(...)` Shape-A sites that the wave-94/95-99 sweep didn't catch. Migrate each to `accountManager.resources.storeResourceData(id: EngineMediaResource.Id(...), data: ..., synchronous: ...)` against the existing wave-94 facade. Net effect: −5 raw `accountManager.mediaBox.X` accesses, +5 facade calls. Consumer-only build. - -This is the wave-103 retry after the abandonment of `ChatRecentActionsControllerNode.peer` migration (see `postbox-refactor-log.md` "Wave 103 outcome (2026-04-26): ABANDONED"). - -## Wave-71-shadow risk inventory (per `feedback_wave71_shadow_risk.md`) - -| Layer | Applicable? | Notes | -|---|---|---| -| 1. Downcasts (`as?` / `is`) | N/A | No Peer migration, no type-level change | -| 2. Peer-protocol extension method calls | N/A | No stored field retype | -| 3. Field flow into Peer-typed function parameters | N/A | No `peer` param involved | -| 4. Message-builder cascade via `SimpleDictionary` | N/A | No `Message(...)` construction touched | - -Wave shape (call-site rewrite against an existing facade) is orthogonal to the wave-71-shadow risk layers. The wave-94 lesson and wave-shape-G recipe are the relevant precedents. - -## Sites (5 total) - -### ThemeUpdateManager.swift (1 site) - -| Line | Existing | Migrated | -|---|---|---| -| 112 | `accountManager.mediaBox.storeResourceData(file.file.resource.id, data: fullSizeData, synchronous: true)` | `accountManager.resources.storeResourceData(id: EngineMediaResource.Id(file.file.resource.id), data: fullSizeData, synchronous: true)` | - -`accountManager` flows from the enclosing `presentationThemeSettingsUpdated(_:)` method's closure-captured scope. `accountManager: AccountManager` typed (Shape-A). - -### WallpaperResources.swift (4 sites) - -All four sites use the same call-text pattern (same arity, no `synchronous:` arg) but different argument expressions: - -| Line | Argument expression | -|---|---| -| 973 | `reference.resource.id, data: data` | -| 1214 | `reference.resource.id, data: data` | -| 1260 | `file.file.resource.id, data: fullSizeData` | -| 1523 | `file.file.resource.id, data: fullSizeData` | - -Lines 973 and 1214 share identical text (`accountManager.mediaBox.storeResourceData(reference.resource.id, data: data)`) — `Edit replace_all=true` bundles them. Lines 1260 and 1523 share identical text (`accountManager.mediaBox.storeResourceData(file.file.resource.id, data: fullSizeData)`) — same. - -Each migrated to: `accountManager.resources.storeResourceData(id: EngineMediaResource.Id(), data: )`. - -`accountManager` flows from `wallpaperDatas(account:accountManager:...)` and other public functions in the file, all parameter-typed `AccountManager` (Shape-A). - -## Edit patterns - -### A. ThemeUpdateManager (1 site) - -Single Edit: - -| File:Line | Before | After | -|---|---|---| -| ThemeUpdateManager.swift:112 | `accountManager.mediaBox.storeResourceData(file.file.resource.id, data: fullSizeData, synchronous: true)` | `accountManager.resources.storeResourceData(id: EngineMediaResource.Id(file.file.resource.id), data: fullSizeData, synchronous: true)` | - -### B. WallpaperResources (4 sites in 2 replace_all batches) - -Two `Edit` calls, each with `replace_all=true`: - -| Pattern | Before | After | -|---|---|---| -| Pattern 1 (lines 973, 1214) | `accountManager.mediaBox.storeResourceData(reference.resource.id, data: data)` | `accountManager.resources.storeResourceData(id: EngineMediaResource.Id(reference.resource.id), data: data)` | -| Pattern 2 (lines 1260, 1523) | `accountManager.mediaBox.storeResourceData(file.file.resource.id, data: fullSizeData)` | `accountManager.resources.storeResourceData(id: EngineMediaResource.Id(file.file.resource.id), data: fullSizeData)` | - -**Total edits:** 3 Edit calls (1 single + 2 replace_all batches), 5 sites migrated. - -## Facade signature reference - -From `submodules/TelegramCore/Sources/AccountManager/AccountManagerResources.swift` (added wave 94): - -```swift -public func storeResourceData(id: EngineMediaResource.Id, data: Data, synchronous: Bool = false) { - self.mediaBox.storeResourceData(MediaResourceId(id.stringRepresentation), data: data, synchronous: synchronous) -} -``` - -`EngineMediaResource.Id(_ id: MediaResourceId)` constructor at `TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift:179`. - -`accountManager.resources` is a computed property that constructs a fresh `AccountManagerResources` wrapper holding only a `MediaBox` reference — cheap. - -## Risk register - -| Risk | Mitigation | -|---|---| -| `replace_all=true` matching the wrong site | Two patterns are scoped narrowly enough (full call expressions including the closing paren). Pre-flight grep confirmed exactly 2 instances of each pattern across the file. | -| `EngineMediaResource.Id(...)` constructor missing for the argument expression's type | Verified: `init(_ id: MediaResourceId)` exists. `MediaResource.id` returns `MediaResourceId` per Postbox protocol. Construction is canonical. | -| `synchronous:` default mismatch | Facade default is `synchronous: false`, matching `MediaBox.storeResourceData`'s underlying default. Sites without explicit `synchronous:` keep behavior. | -| Build cascade beyond touched files | Consumer-only — both files are leaf consumers (no public re-export of touched symbols). No TelegramCore touch. WallpaperResources is foundational so its rebuild fans out, but the public API is unchanged so dependent modules don't need recompilation. | -| WIP-interference at staging | Pre-existing WIP markers (`build-system/bazel-rules/sourcekit-bazel-bsp` + 3 untracked dirs) are in unrelated paths — no overlap. Stage by explicit file list. | - -## Wave shape - -**Classification:** wave-shape-G drain of an existing TelegramCore facade (waves 84-93 cohort, validated wave 94 + waves 95-99 drains). -**Iteration budget:** 1 (target first-pass-clean given mechanical scope and 5-site footprint). -**Subagent dispatch:** not needed — 3 Edit calls is single-implementer scope. - -## Verification - -### Build - -```sh -source ~/.zshrc 2>/dev/null; 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 -``` - -(No `--continueOnError` — small atomic scope.) WallpaperResources is a foundational submodule with a wide rebuild fan-out, but its public API is unchanged so dependents don't recompile. Build cost projection: ~30-60s. - -### Post-edit residue grep (expect empty) - -```sh -grep -rn "accountManager\.mediaBox\.storeResourceData" \ - submodules/TelegramUI/Sources/ThemeUpdateManager.swift \ - submodules/WallpaperResources/Sources/WallpaperResources.swift -``` - -Expected: empty output across both files. - -## Net delta projection - -- **Raw `mediaBox.X` accesses:** −5 -- **Facade `resources.X` calls:** +5 -- **`EngineMediaResource.Id(...)` wraps:** +5 (these are canonical engine-side constructs, not Postbox bridges — they don't count as `_asPeer()`-style ADD wraps) -- **`import Postbox` drops:** 0 (both files retain `import Postbox` for unrelated symbols — this wave doesn't promise an import drop) -- **Postbox-free module count:** 0 net change - -## Out of scope - -- 7 `accountManager.mediaBox.resourceData(...)` sites — could use the existing `AccountManagerResources.data(resource:)` facade or a future `data(id:)` facade. Defer to a future drain wave. -- 22 `accountManager.mediaBox.cachedResourceRepresentation(...)` sites — Option A holdover, blocked by `CachedMediaResourceRepresentation` Postbox protocol leak. Needs facade-design pass. -- 3 `accountManager.mediaBox.storeCachedResourceRepresentation(...)` sites — same blocker. -- 2 `accountManager.mediaBox.cachedRepresentationCompletePath(...)` sites — same blocker. -- The `accountManager.mediaBox.cachedResourceRepresentation(...)` call at WallpaperResources:1261 and :1524 — directly adjacent to two of our migrated sites but blocked. Leave in place; the migrated `storeResourceData` call directly above it does not depend on it. - -## Memory file update - -After landing, update `project_postbox_refactor_next_wave.md`: -- Add wave 103 (retry) outcome line into the recent-waves section. -- Mark the 5 sites as drained; remove from candidate inventories. -- Promote the next candidate (likely the 7-site `resourceData` drain or one of the foundational waves). diff --git a/docs/superpowers/specs/2026-04-26-postbox-wave-104-account-manager-resource-data-drain-3-sites.md b/docs/superpowers/specs/2026-04-26-postbox-wave-104-account-manager-resource-data-drain-3-sites.md deleted file mode 100644 index defde5f1a7..0000000000 --- a/docs/superpowers/specs/2026-04-26-postbox-wave-104-account-manager-resource-data-drain-3-sites.md +++ /dev/null @@ -1,148 +0,0 @@ -# Wave 104 — accountManager.mediaBox.resourceData drain (3 clean sites) - -**Date:** 2026-04-26 -**Pattern:** wave-shape-G drain of an existing TelegramCore facade (the wave-32 / wave-94 `AccountManagerResources.data(resource:pathExtension:waitUntilFetchStatus:attemptSynchronously:)`) with a documented field rename at consumer sites (`.complete` → `.isComplete`). -**Module:** `submodules/WallpaperResources/Sources/WallpaperResources.swift` only. - -## Goal - -Drain 3 of 8 `accountManager.mediaBox.resourceData(...)` Shape-A sites against the existing facade. Net effect: −3 raw `accountManager.mediaBox.X` accesses, +3 facade calls, +3 `EngineMediaResource(...)` wraps, +3 consumer `.complete` → `.isComplete` renames. - -The remaining 5 sites are deferred: 2 (`FetchCachedRepresentations.swift:482, 490`) flow `data: MediaResourceData` into `fetchCachedScaledImageRepresentation` / `fetchCachedBlurredWallpaperRepresentation` — both expect raw Postbox `MediaResourceData`, so migration would force a cascade or boundary reconstruction. 3 (`WallpaperResources.swift:33, 59, 401`) are coupled to postbox-side via `combineLatest(accountManager.mediaBox.resourceData, account.postbox.mediaBox.resourceData)` returning typed `Signal<(MediaResourceData, MediaResourceData), NoError>` — migrating one side without the other breaks the tuple type. - -## Wave-71-shadow risk inventory (per `feedback_wave71_shadow_risk.md`) - -| Layer | Applicable? | Notes | -|---|---|---| -| 1. Downcasts | N/A | No type-level migration | -| 2. Peer-protocol extension method calls | N/A | Not a peer migration | -| 3. Field flow into Peer-typed function parameters | Adapted: result-type flow into MediaResourceData-typed params | **Cleared:** all 3 sites consume `maybeData.complete` and `maybeData.path` inline within the closure — no flow-out to functions taking raw `MediaResourceData`. Sites that DO flow out (482/490) are deferred. | -| 4. Message-builder cascade | N/A | No `Message(...)` construction touched | - -The "data: MediaResourceData" parameter at `fetchCachedScaledImageRepresentation:311` / `fetchCachedBlurredWallpaperRepresentation:453, 502` is the analogue of the wave-103 `Message.peers` constructor barrier — a Postbox-typed function-parameter barrier that forces ADD bridges if upstream migrates. The 3 chosen sites do not cross this barrier; the deferred 2 sites do. - -## Sites (3 total) - -### Call rewrites - -| Line | Existing call | Migrated call | -|---|---|---| -| 957 | `let maybeFetched = accountManager.mediaBox.resourceData(reference.resource, option: .complete(waitUntilFetchStatus: false), attemptSynchronously: synchronousLoad)` | `let maybeFetched = accountManager.resources.data(resource: EngineMediaResource(reference.resource), attemptSynchronously: synchronousLoad)` | -| 1164 | `let maybeFetched = accountManager.mediaBox.resourceData(fileReference.media.resource, option: .complete(waitUntilFetchStatus: false), attemptSynchronously: synchronousLoad)` | `let maybeFetched = accountManager.resources.data(resource: EngineMediaResource(fileReference.media.resource), attemptSynchronously: synchronousLoad)` | -| 1264 | `return accountManager.mediaBox.resourceData(file.file.resource)` | `return accountManager.resources.data(resource: EngineMediaResource(file.file.resource))` | - -**`waitUntilFetchStatus: false` is omitted** in the migrated form — the facade signature has `waitUntilFetchStatus: Bool = false` as default. Sites 957/1164 explicitly pass `false`; site 1264 uses the underlying default. - -### Consumer-side renames (`.complete` → `.isComplete`) - -| Line | Existing | Migrated | -|---|---|---| -| 961 | ` if maybeData.complete {` | ` if maybeData.isComplete {` | -| 1168 | ` if maybeData.complete && isSupportedTheme {` | ` if maybeData.isComplete && isSupportedTheme {` | -| 1266 | ` if data.complete, let imageData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) {` | ` if data.isComplete, let imageData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) {` | - -### Sites NOT migrated (deferred) - -- L33, L59, L401 — `combineLatest(accountManager.mediaBox.resourceData(X), account.postbox.mediaBox.resourceData(X))` (typed tuple return) -- L482, L490 (in FetchCachedRepresentations.swift, not WallpaperResources.swift) — `data: MediaResourceData` flow-out cascade -- The closure bodies at sites 957, 1164 contain INNER `account.postbox.mediaBox.resourceData(...)` calls and inner `data.complete` accesses on a different binding (the postbox-side result) — those stay raw and are NOT touched by this wave. Only the OUTER `maybeFetched`-typed result is migrated. - -## Type reference - -Facade signature (existing, wave-32 / wave-94): - -```swift -public func data( - resource: EngineMediaResource, - pathExtension: String? = nil, - waitUntilFetchStatus: Bool = false, - attemptSynchronously: Bool = false -) -> Signal -``` - -`EngineMediaResource.ResourceData` (final class at `TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift:149`): -- `public let path: String` (matches `MediaResourceData.path`) -- `public let availableSize: Int64` -- `public let isComplete: Bool` (renamed from `MediaResourceData.complete`) - -`EngineMediaResource(_ resource: MediaResource)` constructor — canonical wrap (CLAUDE.md cheat sheet). - -## Edit patterns - -6 separate Edit calls in 1 file. No `replace_all=true` opportunity — each call/rename has unique surrounding text. - -**Order (recommended):** call rewrites first (3 edits), then consumer renames (3 edits). This sequence keeps the file in a half-migrated but compilable state between batches if interrupted (call site uses new facade, consumer still on old field name → swift compile error caught quickly). - -Alternative order: per-site bundled (call + rename pair, then next pair) — also fine. - -## Risk register - -| Risk | Mitigation | -|---|---| -| `EngineMediaResource(rawResource)` constructor missing | Verified: constructor exists per CLAUDE.md cheat sheet ("EngineMediaResource(rawResource) — wrap a raw MediaResource"). | -| `.path` field mismatch | Verified: both `MediaResourceData.path` and `EngineMediaResource.ResourceData.path` are `String`. No edit needed at any `data.path` usage site. | -| `.availableSize` not exposed by `MediaResourceData` | None of the 3 consumers use `.availableSize`. Only `.complete` (renamed) and `.path` (unchanged) are used. | -| Inner `data.complete` accesses on postbox-side bindings get renamed by accident | The 3 renames are on distinct bindings (`maybeData`, `maybeData`, `data`) within distinct outer scopes. The inner `data.complete` at L968 (postbox-side closure body inside site 957) is on a DIFFERENT `data` binding — its surrounding text differs (`return data.complete ? try? Data(...)` vs the migrated `if data.complete, let imageData = try? Data(...)`). Each Edit's `old_string` includes enough surrounding text to disambiguate. | -| `Signal.complete()` confused with field rename | The renames target `.complete` (property access). `Signal.complete()` is a method call, syntactically distinct (`return .complete()`). No regex collision. | -| `attemptSynchronously: synchronousLoad` arg flows | Facade exposes `attemptSynchronously: Bool = false`. Site 957/1164 pass `synchronousLoad` (a function param of the same name, Bool-typed) — flows through unchanged. | -| Build cascade beyond touched file | WallpaperResources is foundational with wide rebuild fan-out, but the public API is unchanged so dependents don't recompile. Build cost projection: ~30-60s. | - -## Wave shape - -**Classification:** wave-shape-G drain of an existing TelegramCore facade with a documented consumer field rename. Mid-difficulty between wave-103-retry (pure mechanical) and wave-71-shadow (cascade-prone). -**Iteration budget:** 1 (target first-pass-clean given small footprint and verified pre-flight inventory). -**Subagent dispatch:** not needed — 6 edits in 1 file is single-implementer scope. - -## Verification - -### Build - -```sh -source ~/.zshrc 2>/dev/null; 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 -``` - -(No `--continueOnError` — small atomic scope.) Build cost projection: ~30-60s. - -### Post-edit residue grep (expect specific output, NOT empty) - -```sh -grep -rn "accountManager\.mediaBox\.resourceData" submodules/WallpaperResources/Sources/WallpaperResources.swift -``` - -Expected: 3 lines remaining (L33, L59, L401 — the deferred combineLatest sites). Sites 957, 1164, 1264 should NOT appear. - -```sh -grep -nE "maybeData\.complete\b|^.*\bdata\.complete\b" submodules/WallpaperResources/Sources/WallpaperResources.swift -``` - -Expected: a small number of lines remaining at unrelated sites (the postbox-side inner closure at site-957's L968 still uses `data.complete` on a postbox-side binding — that stays). Lines 961, 1168, 1266 should NOT appear. - -## Net delta projection - -| Category | Count | Sites | -|---|---|---| -| Raw `mediaBox.resourceData` accesses dropped | −3 | WR:957, 1164, 1264 | -| Facade calls added | +3 | same sites, migrated form | -| `EngineMediaResource(...)` wraps added | +3 | canonical engine-side wraps, not Postbox bridges | -| Consumer `.complete` → `.isComplete` renames | +3 | WR:961, 1168, 1266 | -| `import Postbox` drops | 0 | WallpaperResources retains Postbox import for unrelated symbols | -| Postbox-free module count | 0 | unchanged | - -## Out of scope - -- Sites 482/490 in FetchCachedRepresentations.swift — `data: MediaResourceData` cascade through `fetchCachedScaled*Representation` family. Defer to a session that designs the appropriate facade or migrates the cascade as a co-wave. -- Sites 33/59/401 in WallpaperResources.swift — `combineLatest(accountManager.mediaBox.resourceData, account.postbox.mediaBox.resourceData)` typed-tuple coupling. Defer until postbox-side `account.postbox.mediaBox.resourceData` is also drainable (Shape-C territory) or a paired-resource facade is designed. -- The 22 `cachedResourceRepresentation` accountManager-side sites — blocked by `CachedMediaResourceRepresentation` Postbox protocol leak. - -## Memory file update - -After landing, update `project_postbox_refactor_next_wave.md`: -- Add wave 104 outcome line into the recent-waves section. -- Update accountManager-side facade drain status table: `resourceData` count drops from 8 → 5 (3 drained, 5 deferred). -- Note the `fetchCachedScaled*Representation` cascade barrier — adds it to the list of "Postbox-typed-function-parameter barriers" alongside `Message.peers: SimpleDictionary`. diff --git a/docs/superpowers/specs/2026-04-26-postbox-wave-105-device-contact-info-subject-engine-peer.md b/docs/superpowers/specs/2026-04-26-postbox-wave-105-device-contact-info-subject-engine-peer.md deleted file mode 100644 index 5147af412a..0000000000 --- a/docs/superpowers/specs/2026-04-26-postbox-wave-105-device-contact-info-subject-engine-peer.md +++ /dev/null @@ -1,183 +0,0 @@ -# Wave 105 — DeviceContactInfoSubject enum payload Peer? → EnginePeer? - -**Date:** 2026-04-26 -**Pattern:** Multi-module enum-payload migration with completion-callback signature change (wave-91 shape — `ItemListWebsiteItem.peer + RecentSessionsController.website case payload + openWebSession callback`). -**Modules:** `AccountContext` (enum + computed property), `PeerInfoUI` (`DeviceContactInfoController.swift` primary consumer), `TelegramUI` (4 construction sites across 4 files). - -## Goal - -Migrate `DeviceContactInfoSubject` enum's 3 case payloads from `Peer?` to `EnginePeer?`, plus 2 callback signatures (`.filter`'s `(Peer?, DeviceContactExtendedData) -> Void` and `.create`'s `(Peer?, DeviceContactStableId, DeviceContactExtendedData) -> Void`) and the public `peer: Peer?` computed property. Net effect: −10 wraps dropped, +2 wraps added (Chat-side construction barriers), +1 `as? TelegramUser` → `case let .user(...)` rewrite. **Net wrap delta: −8.** - -## Wave-71-shadow risk inventory (per `feedback_wave71_shadow_risk.md`) - -| Layer | Result | Notes | -|---|---|---| -| 1. Downcasts (`as?` / `is`) on the migrated value | **1 site** | `DeviceContactInfoController.swift:849` — `if let peer = peer as? TelegramUser` becomes `if case let .user(peer) = peer`. Inner body accesses `peer.firstName`, `peer.lastName`, `peer.phone` — all `TelegramUser` fields, work after rebinding via case-let. | -| 2. Peer-protocol extension method calls | **0 blockers** | Inventory pass found ALL consumer-side access on the migrated bindings is `.id` only. No `Peer`-protocol-only methods (no `canSetupAutoremoveTimeout`, `displayTitle`, `addressName`, etc. on the migrated bindings). | -| 3. Field flow into `Peer`-typed function parameters | **2 ADD bridges** | `ChatControllerOpenAttachmentMenu.swift:683` and `:1850` — both pass `peerAndContactData.0` directly to `.filter(peer:)` constructor. The upstream signal type is explicitly `(Peer?, DeviceContactExtendedData?)` (see L634, L1822). After migration, the construction must wrap: `peerAndContactData.0.flatMap(EnginePeer.init)`. **Accepted barrier — net-negative wave delta still wins.** | -| 4. `Message`-builder / `SimpleDictionary` barriers | **0** | No `Message(...)` constructor calls or dict-store patterns on the migrated bindings. | - -The 2 ADD bridges in Layer 3 are the only wave cost; net delta after accounting for them is still −8. - -## Type changes - -### AccountContext.swift (lines 703-718) - -| Line | Before | After | -|---|---|---| -| 704 | `case vcard(Peer?, DeviceContactStableId?, DeviceContactExtendedData)` | `case vcard(EnginePeer?, DeviceContactStableId?, DeviceContactExtendedData)` | -| 705 | `case filter(peer: Peer?, contactId: DeviceContactStableId?, contactData: DeviceContactExtendedData, completion: (Peer?, DeviceContactExtendedData) -> Void)` | `case filter(peer: EnginePeer?, contactId: DeviceContactStableId?, contactData: DeviceContactExtendedData, completion: (EnginePeer?, DeviceContactExtendedData) -> Void)` | -| 706 | `case create(peer: Peer?, contactData: DeviceContactExtendedData, isSharing: Bool, shareViaException: Bool, completion: (Peer?, DeviceContactStableId, DeviceContactExtendedData) -> Void)` | `case create(peer: EnginePeer?, contactData: DeviceContactExtendedData, isSharing: Bool, shareViaException: Bool, completion: (EnginePeer?, DeviceContactStableId, DeviceContactExtendedData) -> Void)` | -| 708 | `public var peer: Peer? {` | `public var peer: EnginePeer? {` | - -The `contactData: DeviceContactExtendedData` computed property at L719 is unchanged. - -## Edit patterns - -### Pattern A — `_asPeer()` drops at construction sites (5 sites) - -| File:Line | Before | After | -|---|---|---| -| DeviceContactInfoController.swift:1289 | `subject: .vcard(peer?._asPeer(), contactId, contactData)` | `subject: .vcard(peer, contactId, contactData)` | -| DeviceContactInfoController.swift:1443 | `subject: .create(peer: peer?._asPeer(), contactData: contactData, isSharing: false, shareViaException: false, completion: { peer, stableId, contactData in` | `subject: .create(peer: peer, contactData: contactData, isSharing: false, shareViaException: false, completion: { peer, stableId, contactData in` | -| DeviceContactInfoController.swift:1489 | `subject: .create(peer: peer?._asPeer(), contactData: contactData, isSharing: peer != nil, shareViaException: false, completion: { _, _, _ in` | `subject: .create(peer: peer, contactData: contactData, isSharing: peer != nil, shareViaException: false, completion: { _, _, _ in` | -| StoryItemSetContainerViewSendMessage.swift:2132 | `subject: .filter(peer: peerAndContactData.0?._asPeer(), contactId: nil, contactData: contactData, completion: { [weak self, weak view] peer, contactData in` | `subject: .filter(peer: peerAndContactData.0, contactId: nil, contactData: contactData, completion: { [weak self, weak view] peer, contactData in` | -| OpenChatMessage.swift:443 | `subject: .vcard(peer?._asPeer(), nil, contactData)` | `subject: .vcard(peer, nil, contactData)` | - -Each source `peer` is already `EnginePeer?` (verified per-site: line 1289 in `addContactToExisting` callback already typed `(EnginePeer?, ...)` at L1409; line 1443 in `dataSignal` callback that returns `(EnginePeer?, ...)`; line 1489 in `addContactOptionsController(peer: EnginePeer?, ...)`; line 2132 in `(EnginePeer?, ...)` signal; line 443 in `peer: EnginePeer?` source). - -### Pattern B — `_asPeer()` drops at completion-call sites (2 sites) - -| File:Line | Before | After | -|---|---|---| -| DeviceContactInfoController.swift:1105 | `completion(peerAndContactData.0?._asPeer(), filteredData)` | `completion(peerAndContactData.0, filteredData)` | -| DeviceContactInfoController.swift:1224 | `completion(contactIdAndData.2?._asPeer(), contactIdAndData.0, contactIdAndData.1)` | `completion(contactIdAndData.2, contactIdAndData.0, contactIdAndData.1)` | - -The completion's first parameter type changes from `Peer?` to `EnginePeer?` per the enum migration. Source values (`peerAndContactData.0` and `contactIdAndData.2`) are already `EnginePeer?` (typed signal pipelines), so dropping `_asPeer()` is a clean simplification. - -### Pattern C — `.flatMap(EnginePeer.init)` simplifications (3 sites) - -DeviceContactInfoController.swift:941-946 region: - -| File:Line | Before | After | -|---|---|---| -| 941-942 | `case let .vcard(peer, id, data):\n contactData = .single((peer.flatMap(EnginePeer.init), id, data))` | `case let .vcard(peer, id, data):\n contactData = .single((peer, id, data))` | -| 943-944 | `case let .filter(peer, id, data, _):\n contactData = .single((peer.flatMap(EnginePeer.init), id, data))` | `case let .filter(peer, id, data, _):\n contactData = .single((peer, id, data))` | -| 945-946 | `case let .create(peer, data, share, shareViaExceptionValue, _):\n contactData = .single((peer.flatMap(EnginePeer.init), nil, data))` | `case let .create(peer, data, share, shareViaExceptionValue, _):\n contactData = .single((peer, nil, data))` | - -After migration, the destructured `peer: EnginePeer?` is already the target type — the `.flatMap(EnginePeer.init)` round-trip becomes redundant. - -### Pattern D — Downcast → case-let (1 site) - -| File:Line | Before | After | -|---|---|---| -| DeviceContactInfoController.swift:849 | `if let peer = peer as? TelegramUser {` | `if case let .user(peer) = peer {` | - -The outer `peer` is bound from `case let .create(peer, contactData, _, _, _) = subject` at L845, which becomes `EnginePeer?` post-migration. `case let .user(peer) = peer` rebinds the inner `peer` to `TelegramUser` (the `.user` case associated value). Inner body accesses `peer.firstName`, `peer.lastName`, `peer.phone` — all `TelegramUser` instance methods/properties, work transparently after rebinding. - -### Pattern E — ADD wraps at Chat-side construction (2 sites) - -| File:Line | Before | After | -|---|---|---| -| ChatControllerOpenAttachmentMenu.swift:683 | `subject: .filter(peer: peerAndContactData.0, contactId: nil, contactData: contactData, completion: { peer, contactData in` | `subject: .filter(peer: peerAndContactData.0.flatMap(EnginePeer.init), contactId: nil, contactData: contactData, completion: { peer, contactData in` | -| ChatControllerOpenAttachmentMenu.swift:1850 | `subject: .filter(peer: peerAndContactData.0, contactId: nil, contactData: contactData, completion: { peer, contactData in` | `subject: .filter(peer: peerAndContactData.0.flatMap(EnginePeer.init), contactId: nil, contactData: contactData, completion: { peer, contactData in` | - -Both sites have identical text — `Edit replace_all=true` bundles them. The upstream signal type is explicitly `(Peer?, DeviceContactExtendedData?)` (verified at L634 and L1822). The `.flatMap(EnginePeer.init)` wraps the optional `Peer?` to optional `EnginePeer?`. - -### Pattern F — Pass-through (no edit needed) - -These flow transparently through the type change: -- DeviceContactInfoController.swift:897, 1041, 1047 — `subject.peer` access (returns `EnginePeer?` post-migration, consumers use `.id` or `if let peer = subject.peer`) -- DeviceContactInfoController.swift:1041 — `.create(peer: subject.peer, ...)` — both sides EnginePeer? after migration -- DeviceContactInfoController.swift:1149-1163, 1183-1189 — destructured `peer` from `.create` becomes `EnginePeer?`, body accesses `peer.id` and passes to `completion(peer, ...)` (now `EnginePeer?`-accepting) -- ContactsController.swift:312, 785; OpenAddContact.swift:32; ComposeController.swift:220; ShareExtensionContext.swift:532 — `peer: nil` or `.vcard(nil, ...)` constructions, `nil` works for both optional types -- All callback consumer bodies that use `peer?.id` (StoryItemSetContainerViewSendMessage:2141, ChatControllerOpenAttachmentMenu:689, :1856) — `EnginePeer?.id` is `EnginePeer.Id` typealiased to `PeerId`, identical at usage sites - -**Total edits: 17 across 5 files.** AccountContext.swift (4) + DeviceContactInfoController.swift (9) + ChatControllerOpenAttachmentMenu.swift (1 with replace_all) + StoryItemSetContainerViewSendMessage.swift (1) + OpenChatMessage.swift (1) = ~16 Edit calls. - -## Risk register - -| Risk | Mitigation | -|---|---| -| `_asPeer()` source not actually `EnginePeer?` at one of the drop sites | Per-site source typing verified during inventory: 1289 (addContactToExisting callback typed `(EnginePeer?, ...)` at L1409), 1443 (dataSignal returns `(EnginePeer?, ...)`), 1489 (function param `peer: EnginePeer?` at L1481), 2132 (signal callback typed `(EnginePeer?, ...)`), 443 (local `peer: EnginePeer?`). All confirmed. | -| `subject.peer` consumers break | 3 access sites, all pattern `if let peer = subject.peer { ... peer.id ... }`. Body uses `.id` (transparent). | -| Closure capture aliases of destructured `peer` flow into untyped contexts | Inventory found 8 destructure sites; all body uses are `.id` access or pass-through to completion calls (whose signature also migrates). | -| Build cascade through AccountContext consumers | AccountContext is foundational. The enum + computed property changes cascade ALL consumers. Build cost projection: 60-180s. | -| `case let .user(peer)` rebinding shadow at L849 | The outer `peer` (EnginePeer?) is shadowed by the inner `peer` (TelegramUser). Inner body uses `peer.firstName`, `peer.lastName`, `peer.phone` — all TelegramUser fields. No reference to the outer EnginePeer? inside the if-body. Safe. | -| `.flatMap(EnginePeer.init)` simplification leaves wrong type | After migration, destructured `peer: EnginePeer?`. `.flatMap(EnginePeer.init)` would re-wrap to `EnginePeer?` (a no-op). Dropping is safe. | -| Pre-existing `import Postbox` removable from any of the 5 touched files | `import Postbox` should NOT be dropped speculatively — these files use Postbox for unrelated symbols (most consumers retain `Peer` references for non-DeviceContactInfoSubject paths). Defer Postbox-import drops to dedicated cleanup waves. | - -## Wave shape - -**Classification:** wave-91-pattern multi-module enum-payload + callback-signature migration. -**Iteration budget:** 1-3 (target 1; wave 91 took 2; this wave is similar size, slightly more complex). -**Subagent dispatch:** not needed — 17 edits in 5 files is single-implementer scope, but coordinator should review the diff carefully before commit given multi-module footprint. - -## Verification - -### Build - -```sh -source ~/.zshrc 2>/dev/null; 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 -``` - -`--continueOnError` flag enabled given multi-module scope — surface all errors at once if iter-1 fails. - -### Post-edit residue grep (expect specific patterns) - -```sh -# Construction-site _asPeer drops complete: -grep -nE "subject:\s*\.(vcard|filter|create)\(.*_asPeer\(\)" \ - submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift \ - submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift \ - submodules/TelegramUI/Sources/OpenChatMessage.swift -# Expected: empty. - -# Completion _asPeer drops complete: -grep -nE "completion\(.*_asPeer\(\)" submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift -# Expected: empty. - -# .flatMap(EnginePeer.init) simplifications complete in DeviceContactInfoController: -grep -nE "peer\.flatMap\(EnginePeer\.init\)" submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift -# Expected: empty. - -# Downcast rewrite complete: -grep -nE "peer as\? TelegramUser" submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift -# Expected: empty. - -# ADD wraps present at the 2 Chat sites: -grep -nE "peerAndContactData\.0\.flatMap\(EnginePeer\.init\)" submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift -# Expected: 2 lines (683 and 1850, line numbers may shift slightly). -``` - -## Net delta projection - -| Category | Count | Sites | -|---|---|---| -| `_asPeer()` drops at construction | −5 | DeviceContactInfoController:1289, 1443, 1489 + StoryItemSetContainerViewSendMessage:2132 + OpenChatMessage:443 | -| `_asPeer()` drops at completion calls | −2 | DeviceContactInfoController:1105, 1224 | -| `.flatMap(EnginePeer.init)` simplifications | −3 | DeviceContactInfoController:942, 944, 946 | -| `EnginePeer.init` wraps added (Pattern E) | +2 | ChatControllerOpenAttachmentMenu:683, 1850 | -| Downcast → case-let conversions | +1 | DeviceContactInfoController:849 | -| Type annotations migrated | 4 | AccountContext: 3 enum cases + 1 computed property | - -**Net wrap delta:** **−8** (10 drops minus 2 adds). - -## Out of scope - -- `import Postbox` drops in any of the 5 touched files — they use Postbox for unrelated symbols. Defer to dedicated cleanup waves. -- Migrating the `peerAndContactData` upstream signal type from `(Peer?, DeviceContactExtendedData?)` to `(EnginePeer?, ...)` — would drop the 2 ADD bridges at Chat sites but cascades into multiple closures. Separate wave. -- `addContactToExisting`'s internal completion call sites — already typed `(EnginePeer?, ...)` per L1409, no migration needed in this wave. - -## Memory file update - -After landing, update `project_postbox_refactor_next_wave.md`: -- Add wave 105 outcome line into the recent-waves section. -- Mark `DeviceContactInfoSubject` candidate as drained. -- Note the wave-91-shape success — multi-module enum-payload migrations remain viable when pre-flight inventory clears layers 1-4. diff --git a/docs/superpowers/specs/2026-04-26-postbox-wave-106-import-drop-sweep-design.md b/docs/superpowers/specs/2026-04-26-postbox-wave-106-import-drop-sweep-design.md deleted file mode 100644 index e37760d6de..0000000000 --- a/docs/superpowers/specs/2026-04-26-postbox-wave-106-import-drop-sweep-design.md +++ /dev/null @@ -1,172 +0,0 @@ -# Postbox → TelegramEngine wave 106 — speculative `import Postbox` drop sweep (round 2) - -## Context - -Wave 93 (`72de7c4fd5`) ran the first speculative `import Postbox` drop sweep across the consumer modules in `submodules/`. It used a "drop blindly, restore on build feedback" methodology: 12 files dropped, 5 restored after the first build cycle, 7 net imports removed in a single commit. - -Since wave 93, waves 94–105 have removed many further Postbox-typed references from consumer files (storeResourceData/moveResourceData/etc. drains, DeviceContactInfoSubject enum-payload migration, and several Shape-C/D mini-refactors). A second sweep should now find newly-orphaned `import Postbox` lines in files where the last Postbox reference was peeled off by an intervening wave. - -This spec covers wave 106: the round-2 sweep applying the same methodology with an expanded pre-flight regex set incorporating wave 93's escape-case lessons. - -## Goal - -Drop `import Postbox` from any consumer-module Swift file in `submodules/` whose remaining content no longer references a Postbox-only symbol. Single atomic wave commit. No semantic code changes — only `import` and BUILD `deps` lines. - -## Out of scope - -- `submodules/Postbox/` (the module being phased out — never drop its self-references). -- `submodules/TelegramCore/` (different rules; TelegramCore must not `@_exported import Postbox` per wave-1 rule but its internal files retain `import Postbox` as needed). -- `submodules/TelegramApi/` (out of scope for the refactor). -- New typealiases or facade additions — wave 106 is import-cleanup-only. -- Code changes that swap a remaining Postbox-typed reference for an engine equivalent (those are dedicated waves). - -## Methodology - -### Step 1. Inventory candidates - -```sh -grep -rl "^import Postbox" submodules --include="*.swift" \ - | grep -v "^submodules/Postbox/" \ - | grep -v "^submodules/TelegramCore/" \ - | grep -v "^submodules/TelegramApi/" \ - > /tmp/wave106-candidates.txt -``` - -Expected size: roughly 1100–1200 files based on wave-93-era counts adjusted for waves 94–105's drops. - -### Step 2. Pattern-based preemptive restore - -For each candidate file, skip (do NOT drop the import) if it contains any of the following regex patterns. The patterns are split into three tiers; matching ANY one is sufficient to skip. - -**Tier 1 — hard Postbox infrastructure tokens:** -- `\bPostbox\b` -- `\bMediaBox\b` -- `\bMediaResource\b` (the protocol; `TelegramMediaResource` does not match because `\b` boundary) -- `\bMediaResourceData\b` -- `\bMediaResourceId\b` -- `\bPostboxCoding\b` -- `\bPostboxDecoder\b` (rarely escapes — `EnginePostboxDecoder` available, but file may still need import) -- `\bPostboxEncoder\b` (same) -- `\bMemoryBuffer\b` (same) -- `\bTempBoxFile\b` -- `\bValueBoxKey\b` -- `\bPostboxView\b` -- `\bcombinedView\b` - -**Tier 2 — identifier types still defined in Postbox:** -- `\bPeerId\b` -- `\bMessageId\b` -- `\bMediaId\b` -- `\bMessageIndex\b` -- `\bMessageAndThreadId\b` -- `\bPeerNameIndex\b` -- `\bStoryId\b` -- `\bItemCollectionId\b` -- `\bFetchResourceSourceType\b` -- `\bFetchResourceError\b` - -**Tier 3 — bare-name escapes (wave 93 lesson):** -- `\bPeer\b` (the protocol; `EnginePeer` and `TelegramPeer*` and `peer` lowercase do not match) -- `\bMessage\b` (the protocol/struct; `EngineMessage` and `TelegramMessage*` do not match) -- `\bMedia\b` (the protocol; `EngineMedia` and `TelegramMedia*` do not match) - -Skip-list construction: build the regex by joining all three tiers with `|` and run a single `grep -E -l "" $(cat /tmp/wave106-candidates.txt) > /tmp/wave106-skiplist.txt`. The drop-list is `comm -23 <(sort /tmp/wave106-candidates.txt) <(sort /tmp/wave106-skiplist.txt)`. Files in the skip-list keep their `import Postbox`. Over-skipping (false positives from comments or string literals) is safe — it just lowers yield; under-skipping is caught by the build feedback loop in steps 4-5. - -### Step 3. Drop imports - -For each file in `candidates - skip-list`, drop the `import Postbox` line via `Edit` (one Edit per file). All drops happen in a single batch before the first build — wave 93 validated that build feedback handles a large failure batch without manual triage difficulty (`grep "error:" /tmp/build.log | awk -F: '{print $1}' | sort -u` produces the restore list). - -### Step 4. Build with `--continueOnError` - -```sh -source ~/.zshrc 2>/dev/null; \ -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 2>&1 | tee /tmp/wave106-build-iter1.log -``` - -Parse the log for `error:` lines. Group by file path; restore `import Postbox` to any file with a missing-symbol error. - -### Step 5. Iterate - -Re-run the build. Repeat restore-and-rebuild until clean. Halt-on-iter-5 (see halt conditions). - -### Step 6. Final clean build (no `--continueOnError`) - -Confirm green build to validate that no inter-module ordering issue was masked. - -### Step 7. BUILD-dep sweep (optional, if time permits) - -For each Bazel package whose Swift sources no longer reference `import Postbox`: - -```sh -# enumerate packages with no remaining Postbox imports -for build in $(find submodules -name BUILD); do - pkg_dir=$(dirname "$build") - if ! grep -rq "^import Postbox" "$pkg_dir" --include="*.swift" 2>/dev/null; then - if grep -q "//submodules/Postbox" "$build"; then - echo "$build" - fi - fi -done -``` - -For each match: drop the `//submodules/Postbox` entry from the package's `BUILD` `deps` list. Re-run the full clean build to confirm. - -### Step 8. Single commit - -All file edits and BUILD changes land in one commit: - -``` -Postbox -> TelegramEngine wave 106 (import drop sweep round 2) - -Speculative drop of `import Postbox` in N files where the last -Postbox-typed symbol reference was peeled off by waves 94-105. -Methodology: pattern-based pre-flight skip + drop + build feedback + -restore loop (wave-93-validated recipe). M files restored after build. -+ K BUILD deps removed. -``` - -## Halt conditions - -1. **Scope drift to TelegramCore/Postbox/TelegramApi.** If build errors surface in any of these three modules, halt immediately and `git reset --hard` the wave. The candidate filter is wrong somewhere. -2. **First-pass failure rate > 50%.** Indicates the pre-flight regex set is missing a major escape pattern. Halt, analyze the failure cluster, expand regex, re-run from step 2. -3. **Iteration count > 5.** Diminishing returns; commit what is green and defer the rest to wave 107. - -## Pre-flight WIP check - -Before any edits: - -```sh -git status --short | grep -v "^??" | grep -v "^ m build-system/bazel-rules/sourcekit-bazel-bsp" -``` - -If output is non-empty, halt — there is unrelated WIP that would get tangled. The known persistent state (untracked `build-system/tulsi/`, `submodules/TgVoip/`, `third-party/libx264/` and the `m` submodule marker) is acceptable and recorded in memory. - -## Expected outcome - -- 5–30 net `import Postbox` drops in `submodules/`. -- 0–3 BUILD `deps` removals. -- 2–3 build iterations. -- Single commit. -- Wall-clock 30–90 min. - -## Risks - -- **Regex misses bare type names** → caught by build feedback at cost of 1 extra cycle. Acceptable. -- **A file holds a Postbox reference only inside a comment** that the regex doesn't distinguish from real code → safe (false positive: file would have been skipped unnecessarily; sweep just leaves the import in). -- **Cross-file dependency where dropping module A's import breaks module B compilation** → caught by build cycle; restore in the failing module. -- **Bazel cache state inconsistency** → unlikely with `--cacheDir ~/telegram-bazel-cache` already in steady state, but if surfaces, full clean build will catch it. - -## Success criteria - -- `git diff --stat` shows only `import Postbox` line removals (and possibly BUILD `deps` line removals). -- Final clean build (no `--continueOnError`) is green. -- No file outside `submodules/` modified. -- No file in `submodules/Postbox/`, `submodules/TelegramCore/`, or `submodules/TelegramApi/` modified. -- Memory file `project_postbox_refactor_next_wave.md` updated to record the wave outcome. diff --git a/docs/superpowers/specs/2026-04-29-isStrictlyScrolledToPinToEdgeItem-design.md b/docs/superpowers/specs/2026-04-29-isStrictlyScrolledToPinToEdgeItem-design.md deleted file mode 100644 index 544020e7b5..0000000000 --- a/docs/superpowers/specs/2026-04-29-isStrictlyScrolledToPinToEdgeItem-design.md +++ /dev/null @@ -1,68 +0,0 @@ -# `ListViewImpl.isStrictlyScrolledToPinToEdgeItem` — design - -## Goal - -Implement the public method `isStrictlyScrolledToPinToEdgeItem()` on `ListViewImpl` (in `submodules/Display/Source/ListView.swift`). - -The method returns `true` when the list is currently scrolled to the exact resting position of its pin-to-edge target — i.e. the item that lies on the edge of `insets.bottom` is the current pin-to-edge target (the lowest-index item with `pinToEdgeWithInset == true`). - -A stub already exists at `submodules/Display/Source/ListView.swift:2674` (uncommitted, in the working tree) and is the slot to fill in. - -## Definition of "lies on the edge" - -Aligned with the existing pin-to-edge scroll math (`ListView.swift:3115`): - -```swift -offset = (self.visibleSize.height - insets.bottom) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom -``` - -When that offset has been applied, the target item satisfies: - -``` -itemNode.apparentFrame.maxY == (visibleSize.height - insets.bottom) + itemNode.scrollPositioningInsets.bottom -``` - -That equation is the "strictly scrolled" condition. - -## Implementation - -```swift -public func isStrictlyScrolledToPinToEdgeItem() -> Bool { - guard let targetIndex = self.items.firstIndex(where: { $0.pinToEdgeWithInset }) else { - return false - } - for itemNode in self.itemNodes { - if itemNode.index == targetIndex { - let expectedMaxY = (self.visibleSize.height - self.insets.bottom) + itemNode.scrollPositioningInsets.bottom - return abs(itemNode.apparentFrame.maxY - expectedMaxY) < 0.5 - } - } - return false -} -``` - -## Behavior table - -| Situation | Return value | -|---|---| -| No item in `self.items` has `pinToEdgeWithInset == true` | `false` | -| Pin-to-edge target exists but its `itemNode` is not currently materialized | `false` | -| Pin-to-edge target's `apparentFrame.maxY` differs from the expected resting `maxY` by ≥ 0.5 pt | `false` | -| Pin-to-edge target's `apparentFrame.maxY` differs from the expected resting `maxY` by < 0.5 pt | `true` | - -## Design choices - -1. **Algorithm shape: target-first, not edge-first.** "Find target, check whether it's at the edge" rather than the user's literal "find item at the edge, check whether it's the target". The two are equivalent in practice (items don't share `maxY` in a stacked list) and target-first is cheaper / clearer. -2. **`apparentFrame`, not the layout frame.** `apparentFrame` already accounts for in-flight animation offsets and matches the property the scroll-target math at line 3115 uses. The result reflects the true visible state, not a possibly-scheduled layout that hasn't taken effect. -3. **Tolerance: 0.5 pt.** Half a logical point — well under any visible misalignment, generous enough for floating-point and pixel-snap noise on 2x/3x displays. No project-wide convention found; 0.5 pt is the chosen default. -4. **Includes `scrollPositioningInsets.bottom`.** Mirrors line 3115 exactly so that an item with non-zero `scrollPositioningInsets.bottom` is reported as "strictly scrolled" at the same position the scroll-to logic would have left it at. - -## Out of scope - -- No new callers are introduced in this spec. The method is added as public API; consumers will be wired up in subsequent work. -- No changes to `ListViewItem.pinToEdgeWithInset`, `calculatePinToEdgeTopInset`, or any pin-to-edge scroll logic. -- No tests — the codebase has no unit tests and this lives in the rendering layer. - -## Verification - -Build the full app target with the standard `Make.py` invocation in `CLAUDE.md` to confirm the addition compiles. diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index 4dccd9bd73..3ebe7a80e2 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -2671,6 +2671,22 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur return value } + public func isStrictlyScrolledToPinToEdgeItem() -> Bool { + if self.calculatePinToEdgeTopInset() <= 0.0 { + return false + } + guard let targetIndex = self.items.firstIndex(where: { $0.pinToEdgeWithInset }) else { + return false + } + for itemNode in self.itemNodes { + if itemNode.index == targetIndex { + let expectedMaxY = (self.visibleSize.height - self.insets.bottom) + itemNode.scrollPositioningInsets.bottom + return abs(itemNode.apparentFrame.maxY - expectedMaxY) < 0.5 + } + } + return false + } + private func replayOperations(animated: Bool, animateAlpha: Bool, animateCrossfade: Bool, animateFullTransition: Bool, customAnimationTransition: ControlledTransition?, synchronous: Bool, synchronousLoads: Bool, animateTopItemVerticalOrigin: Bool, operations: [ListViewStateOperation], requestItemInsertionAnimationsIndices: Set, scrollToItem originalScrollToItem: ListViewScrollToItem?, additionalScrollDistance: CGFloat, updateSizeAndInsets: ListViewUpdateSizeAndInsets?, stationaryItemIndex: Int?, updateOpaqueState: Any?, forceInvertOffsetDirection: Bool = false, completion: () -> Void) { var scrollToItem: ListViewScrollToItem? var isExperimentalSnapToScrollToItem = false diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift index 1934f56d65..d99dbe3ecd 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift @@ -878,6 +878,12 @@ extension ChatControllerImpl { self?.requestLayout(transition: transition) } + var enableSendAnimationV2 = false + if let data = self.context.currentAppConfiguration.with({ $0 }).data, let _ = data["ios_killswitch_disable_send_animation_v2"] { + } else { + enableSendAnimationV2 = true + } + self.chatDisplayNode.setupSendActionOnViewUpdate = { [weak self] f, messageCorrelationId in //print("setup layoutActionOnViewTransition") @@ -886,7 +892,11 @@ extension ChatControllerImpl { } self.layoutActionOnViewTransitionAction = f - self.chatDisplayNode.historyNode.pinToTopStableId = nil + if !enableSendAnimationV2 { + self.chatDisplayNode.historyNode.pinToTopStableId = nil + } else if !self.chatDisplayNode.historyNode.isStrictlyScrolledToPinToEdgeItem() { + self.chatDisplayNode.historyNode.pinToTopStableId = nil + } self.chatDisplayNode.historyNode.layoutActionOnViewTransition = ({ [weak self] transition in f() if let strongSelf = self, let validLayout = strongSelf.validLayout { @@ -906,7 +916,7 @@ extension ChatControllerImpl { let shouldUseFastMessageSendAnimation = strongSelf.chatDisplayNode.shouldUseFastMessageSendAnimation - strongSelf.chatDisplayNode.containerLayoutUpdated(validLayout, navigationBarHeight: strongSelf.navigationLayout(layout: validLayout).navigationFrame.maxY, transition: .animated(duration: duration, curve: curve), listViewTransaction: { + strongSelf.chatDisplayNode.containerLayoutUpdated(validLayout, navigationBarHeight: strongSelf.navigationLayout(layout: validLayout).navigationFrame.maxY, transition: .animated(duration: duration, curve: curve), listViewTransaction: { [weak strongSelf] updateSizeAndInsets, _, _, _ in var options = transition.options let _ = options.insert(.Synchronous) @@ -941,6 +951,11 @@ extension ChatControllerImpl { scrollToItem = ListViewScrollToItem(index: insertedIndex, position: .visible, animated: true, curve: .Custom(duration: duration, controlPoints.0, controlPoints.1, controlPoints.2, controlPoints.3), directionHint: .Down) } else if transition.historyView.originalView.laterId == nil { scrollToItem = ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Custom(duration: duration, controlPoints.0, controlPoints.1, controlPoints.2, controlPoints.3), directionHint: .Up) + if enableSendAnimationV2, Thread.isMainThread, let strongSelf { + if strongSelf.chatDisplayNode.historyNode.isStrictlyScrolledToPinToEdgeItem() { + scrollToItem = nil + } + } } if let maxInsertedItem = maxInsertedItem {