Postbox -> TelegramEngine waves 103-105 (squashed)

Wave 103 (original): ChatRecentActionsControllerNode.peer Peer ->
EnginePeer migration ABANDONED after pre-flight discovered a 75-site
ADD-bridge cascade through chatRecentActionsHistoryPreparedTransition
into Message.peers SimpleDictionary<PeerId, Peer> store sites.
Lessons captured in docs/superpowers/postbox-refactor-log.md "Wave 103
ABANDONED" section + ~/.claude/projects/-Users-isaac-build-telegram
-telegram-ios/memory/feedback_wave71_shadow_risk.md (4-layer pre-flight
checklist for stored-Peer-field migrations).

Wave 103 (retry, 92230b0691): drained 5 accountManager.mediaBox
.storeResourceData Shape-A sites against the wave-94 facade. 2 files /
3 Edit calls (1 single + 2 replace_all) / 1-iter / 29.5s build. Closes
the storeResourceData accountManager-side drain entirely.

Wave 104 (08fc3f721e): drained 3 of 8 accountManager.mediaBox.resourceData
Shape-A sites against the wave-32/wave-94 AccountManagerResources.data
(resource:) facade + 3 consumer-side .complete -> .isComplete renames
(EngineMediaResource.ResourceData field rename). 1 file / 6 Edit calls
/ 1-iter / 11.7s build. 5 of 8 candidates deferred behind Postbox-typed
-function-parameter barriers (fetchCachedScaled*Representation cascade,
combineLatest typed-tuple coupling). Established the "Postbox-typed
-function-parameter barrier registry" pattern.

Wave 105 (0c76724409): DeviceContactInfoSubject enum 3 case Peer?
payloads + 2 callback signatures + 1 computed property migrate to
EnginePeer?. 5 files / 17 edits / 1-iter / 203s build (foundational
AccountContext touch). Net wrap delta -8 (10 drops, 2 ADD bridges, 1
downcast->case-let). First wave-71-shadow-style migration after the
wave-103 abandonment forced a discipline reset; first-pass-clean via
thorough pre-flight inventory (~15 min).

Net session progress: -16 wraps across 4 wave attempts plus durable
scaffolding (feedback memory, barrier registry, 4-layer pre-flight
checklist).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-04-26 16:57:55 +04:00
parent 0a0ca093f4
commit 7ccb382f53
15 changed files with 2011 additions and 29 deletions

View file

@ -0,0 +1,347 @@
# Wave 103: ChatRecentActionsControllerNode peer Peer → EnginePeer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Migrate `ChatRecentActionsControllerNode`'s stored `peer: Peer` field to `EnginePeer`, dropping the `_asPeer()` boundary call at the single caller site (wave 103 of the Postbox → TelegramEngine refactor).
**Architecture:** Wave-71-shadow close. Single-file private stored-form migration plus a 1-line caller drop. The caller (`ChatRecentActionsController`) already holds `peer: EnginePeer` and demotes once before passing into the node init. The wave drops the demotion and rewrites 3 `as? TelegramChannel` downcasts inside the node body to `case let .channel(...)` (wave-41/45 idiom). All scope is within `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/`.
**Tech Stack:** Swift, Bazel via `Make.py`, no unit tests (per `CLAUDE.md`). Verification is the full-project debug-sim-arm64 build.
**Iteration budget:** 1 (target first-pass-clean given the 7-edit scope and validated pre-flight grep).
**Note on TDD:** This project has no unit tests. The standard TDD test-first cycle does not apply. Each task writes the edits, then verifies via Bazel build + residue grep.
---
## File Structure
| File | Role | Changes |
|---|---|---|
| `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift` (CRACN) | Recent-actions screen controller node | Drop `import Postbox`, retype stored field + init param, rewrite 3 `as? TelegramChannel` downcasts (6 edits) |
| `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift` (CRAC) | Recent-actions screen controller (caller) | Drop `_asPeer()` at the node init (1 edit) |
No public-API ripple — `ChatRecentActionsControllerNode` is local to the module and has a single caller verified by grep.
---
## Task 1: CRACN.swift — drop `import Postbox` + type changes + downcast rewrites
**Files:**
- Modify: `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift`
**Edits in this task:** 6 (1 import drop, 1 stored-field retype, 1 init-param retype, 3 cast → case-let).
- [ ] **Step 1: Drop `import Postbox` at line 5**
Find:
```swift
import Postbox
```
Replace with: (delete the line entirely)
This file imports `TelegramCore` at line 4, which provides the `EnginePeer` type and the typealiases needed for the rest of this task.
- [ ] **Step 2: Retype stored field at line 46**
Find:
```swift
private let peer: Peer
```
Replace with:
```swift
private let peer: EnginePeer
```
- [ ] **Step 3: Retype init parameter at line 111**
Find:
```swift
init(context: AccountContext, controller: ChatRecentActionsController, peer: Peer, presentationData: PresentationData, pushController: @escaping (ViewController) -> Void, presentController: @escaping (ViewController, PresentationContextType, Any?) -> Void, getNavigationController: @escaping () -> NavigationController?) {
```
Replace with:
```swift
init(context: AccountContext, controller: ChatRecentActionsController, peer: EnginePeer, presentationData: PresentationData, pushController: @escaping (ViewController) -> Void, presentController: @escaping (ViewController, PresentationContextType, Any?) -> Void, getNavigationController: @escaping () -> NavigationController?) {
```
- [ ] **Step 4: Rewrite downcast at line 899**
Find:
```swift
if let peer = strongSelf.peer as? TelegramChannel {
```
Replace with:
```swift
if case let .channel(peer) = strongSelf.peer {
```
The bound name `peer` is preserved so the inner block (`switch peer.info { case .group: ... }`) ports verbatim. `case let .channel(peer)` binds `peer: TelegramChannel` directly (the associated value of `EnginePeer.channel`).
- [ ] **Step 5: Rewrite downcast at line 948**
Find:
```swift
if let channel = self.peer as? TelegramChannel, case .broadcast = channel.info {
```
Replace with:
```swift
if case let .channel(channel) = self.peer, case .broadcast = channel.info {
```
The compound condition (`, case .broadcast = channel.info`) ports verbatim because the bound `channel` is still `TelegramChannel`-typed.
- [ ] **Step 6: Rewrite downcast at line 1088**
Find:
```swift
if let channel = self.peer as? TelegramChannel {
```
Replace with:
```swift
if case let .channel(channel) = self.peer {
```
The inner block (`channel.hasPermission(.banMembers)`, `case .broadcast = channel.info`) ports verbatim.
The `self.peer.id` accesses at lines 145, 161, 1138, 1490 require no edit — `EnginePeer.id` is a typealiased `PeerId`, identical at the call sites.
---
## Task 2: CRAC.swift — drop boundary `_asPeer()`
**Files:**
- Modify: `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift`
**Edits in this task:** 1.
- [ ] **Step 1: Drop `_asPeer()` at line 277**
Find:
```swift
self.displayNode = ChatRecentActionsControllerNode(context: self.context, controller: self, peer: self.peer._asPeer(), presentationData: self.presentationData, pushController: { [weak self] c in
```
Replace with:
```swift
self.displayNode = ChatRecentActionsControllerNode(context: self.context, controller: self, peer: self.peer, presentationData: self.presentationData, pushController: { [weak self] c in
```
`ChatRecentActionsController.peer` is already declared `EnginePeer` at line 42 (`public init(context: AccountContext, peer: EnginePeer, ...)`) — the type carries through to the now-`EnginePeer`-typed init parameter.
---
## Task 3: Full-project Bazel build
**Files:** none (verification only).
- [ ] **Step 1: Run the build**
Run:
```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
```
Expected: clean build (`bazel build complete` or equivalent green output). No `--continueOnError` because the small scope makes the first error informative.
Build cost projection: consumer-only, ~25s. If it exceeds ~60s, suspect a cascade leak.
- [ ] **Step 2: If build fails, triage iteration**
If errors land in `ChatRecentActionsControllerNode.swift` or `ChatRecentActionsController.swift`:
- Read the failing line.
- Common failure modes from prior waves:
- **Always-false `is` warning under `-warnings-as-errors`:** none expected here (pre-flight grep confirmed no `is TelegramChannel` checks on `self.peer`). If one surfaces anyway, convert to `case .channel = self.peer`.
- **Always-failing `as?` cast warning:** leftover `as? TelegramX` not converted in step 4/5/6. Re-grep `(self|strongSelf)\.peer as\?` over the file.
- **Type mismatch on closure-capture alias:** none expected here (pre-flight grep confirmed only `strongSelf.peer` and `self.peer` aliases, both ride the type change).
- **Type mismatch on `.id` access:** would indicate a regression in the `EnginePeer.Id` typealias — STOP and re-read CLAUDE.md, this is not a wave-103 issue.
- **Unused-variable warning under `-warnings-as-errors`:** a `case let .channel(peer)` binding not used inside the body. Re-read step 4/5/6 — if the inner block never references the bound name, switch to `case .channel = ...` and remove the binding.
Fix in place and re-run step 1. Budget: 2 iterations.
If errors land outside those 2 files: **STOP**. The wave was supposed to be self-contained. Re-read the spec, identify the missed call site, decide whether to add it or abandon the wave.
---
## Task 4: Post-edit residue grep
**Files:** none (verification only).
- [ ] **Step 1: Cast residue grep**
Run:
```sh
grep -nE "(self|strongSelf)\.peer as\? Telegram(Channel|Group|User)" \
submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/
```
Expected: empty output.
- [ ] **Step 2: Boundary `_asPeer()` residue grep**
Run:
```sh
grep -nE "self\.peer\._asPeer\(\)" \
submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/
```
Expected: empty output.
- [ ] **Step 3: `import Postbox` residue grep**
Run:
```sh
grep -rn "^import Postbox$" \
submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/
```
Expected: empty output. The module is now Postbox-import-free.
- [ ] **Step 4: Sanity check — `peer: Peer` annotations**
Run:
```sh
grep -nE "peer: Peer\b" \
submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift
```
Expected: empty output. (The 3 `as? TelegramChannel` downcasts on `self.peer` were the only sources; both `peer: Peer` annotations on stored field and init param are now `peer: EnginePeer`.)
---
## Task 5: Commit the wave
**Files:** none (git only).
- [ ] **Step 1: Stage the 2 modified files**
```sh
git add \
submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift \
submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift
```
- [ ] **Step 2: Confirm staging is clean**
```sh
git status --short | grep -v "^??"
```
Expected output: only the 2 staged files (lines starting with `M `). If other modified files appear, they predate the wave (per CLAUDE.md memory: `build-system/bazel-rules/sourcekit-bazel-bsp` submodule marker is pre-existing WIP).
- [ ] **Step 3: Commit**
```sh
git commit -m "$(cat <<'EOF'
Postbox -> TelegramEngine wave 103
Migrate ChatRecentActionsControllerNode.peer Peer -> EnginePeer.
Closes the wave-71 shadow: caller already held EnginePeer and demoted
at the boundary. 7 edits / 2 files.
Drops 1 boundary _asPeer() at ChatRecentActionsController:277, drops
import Postbox at ChatRecentActionsControllerNode:5, rewrites 3
`as? TelegramChannel` downcasts to `case let .channel(...)` (wave-41/45
idiom).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
- [ ] **Step 4: Verify commit**
```sh
git log --oneline -1
```
Expected: shows the wave 103 commit as HEAD.
---
## Task 6: Update outcome log + memory
**Files:**
- Modify: `docs/superpowers/postbox-refactor-log.md`
- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`
- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/MEMORY.md`
- [ ] **Step 1: Append wave 103 outcome to refactor log**
Append a "Wave 103 outcome" entry at the chronological end of `docs/superpowers/postbox-refactor-log.md`. Use the most recent wave-outcome entry as a structural template. Include:
- Commit hash (from Task 5 step 4).
- Iteration count (1 if first-pass-clean; 2 if Task 3 step 2 fired).
- Net-bridge accounting: 1 boundary `_asPeer()` (CRAC:277), 1 `import Postbox` (CRACN:5). 0 ADD wraps. 3 cast → case-let conversions (CRACN:899/948/1088).
- Bazel build duration (from Task 3 step 1 output).
- Wave-shape note: wave-71-shadow close, single-iter target validated.
- [ ] **Step 2: Update next-wave memory**
Edit `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`:
- Add the wave 103 outcome line into the recent-waves section (commit hash + 7-edit / 2-file / 1-iter summary).
- Remove the now-stale `ChatRecentActionsControllerNode.peer: Peer -> EnginePeer` candidate line (currently bullet 5 in the candidates list).
- Update the top frontmatter `description` to reflect wave 103 landed and propose wave 104.
- Promote the next candidate (likely one of: `cachedResourceRepresentation` foundational facade, `RenderedPeer` cascade kickoff, `SelectivePrivacyPeer` foundational, or another Shape-C/D mini-refactor) to the top of the candidates list.
- [ ] **Step 3: Update MEMORY.md index**
Edit `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/MEMORY.md`:
- Update the `[Postbox refactor next wave]` line to mention wave 103 landed and shift the "Wave 103+ Shape-C/D candidates" framing forward to "Wave 104+ candidates".
- [ ] **Step 4: Commit the doc update**
```sh
git add docs/superpowers/postbox-refactor-log.md
git commit -m "$(cat <<'EOF'
docs: log wave 103 outcome
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
(Memory file updates at `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/` are not committed — they live outside the repo.)
---
## Net delta projection (from spec)
| Category | Count | Sites |
|---|---|---|
| Internal bridge drops | 1 | CRAC:277 (`_asPeer()`) |
| `import Postbox` drops | 1 | CRACN:5 |
| ADD wraps | 0 | no Peer-only property accesses on bare `self.peer` |
| Cast → case-let conversions | 3 | CRACN:899, CRACN:948, CRACN:1088 |
| Type annotations updated | 2 | CRACN:46 (stored field), CRACN:111 (init param) |
| Postbox-free module count | +1 | `Components/Chat/ChatRecentActionsController/` joins the list |
**Total commit footprint:** 7 line edits across 2 files, plus a docs commit for the outcome log.

View file

@ -0,0 +1,260 @@
# Wave 103 (retry): accountManager.mediaBox.storeResourceData drain Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Drain 5 remaining `accountManager.mediaBox.storeResourceData(...)` Shape-A sites against the wave-94 `AccountManagerResources.storeResourceData(id:data:synchronous:)` facade. Wave 103 (retry) of the Postbox → TelegramEngine refactor, after the abandonment of the original wave-103 plan.
**Architecture:** Wave-shape-G drain. Pure call-site rewrite; no facade addition, no TelegramCore touch, no public-API change. 5 sites across 2 consumer files (`ThemeUpdateManager.swift`, `WallpaperResources.swift`) migrated via 3 `Edit` calls (1 single + 2 `replace_all=true` batches).
**Tech Stack:** Swift, Bazel via `Make.py`, no unit tests (per `CLAUDE.md`). Verification is the full-project debug-sim-arm64 build.
**Iteration budget:** 1 (target first-pass-clean given mechanical scope and validated facade).
**Note on TDD:** This project has no unit tests. Each task writes the edits, then verifies via Bazel build + residue grep.
---
## File Structure
| File | Role | Changes |
|---|---|---|
| `submodules/TelegramUI/Sources/ThemeUpdateManager.swift` | Theme-update background sync | 1 site migrated |
| `submodules/WallpaperResources/Sources/WallpaperResources.swift` | Wallpaper resource pipeline | 4 sites migrated via 2 `replace_all=true` batches |
No public-API ripple — both files are leaf consumers of the wave-94 facade.
---
## Task 1: ThemeUpdateManager.swift — single-site migration
**Files:**
- Modify: `submodules/TelegramUI/Sources/ThemeUpdateManager.swift`
**Edits in this task:** 1.
- [ ] **Step 1: Migrate the storeResourceData call at line 112**
Find:
```swift
accountManager.mediaBox.storeResourceData(file.file.resource.id, data: fullSizeData, synchronous: true)
```
Replace with:
```swift
accountManager.resources.storeResourceData(id: EngineMediaResource.Id(file.file.resource.id), data: fullSizeData, synchronous: true)
```
`accountManager` here is closure-captured from `presentationThemeSettingsUpdated(_:)` scope, typed `AccountManager<TelegramAccountManagerTypes>`. The facade is exposed via `public extension AccountManager { var resources: AccountManagerResources }`.
---
## Task 2: WallpaperResources.swift — two batched migrations
**Files:**
- Modify: `submodules/WallpaperResources/Sources/WallpaperResources.swift`
**Edits in this task:** 2 (each `replace_all=true`, covering 2 sites apiece).
- [ ] **Step 1: Migrate the `reference.resource.id` pattern (lines 973, 1214)**
Use `Edit` with `replace_all=true`:
Find:
```swift
accountManager.mediaBox.storeResourceData(reference.resource.id, data: data)
```
Replace with:
```swift
accountManager.resources.storeResourceData(id: EngineMediaResource.Id(reference.resource.id), data: data)
```
Both sites share identical text (verified by pre-flight grep). `replace_all=true` handles both atomically.
- [ ] **Step 2: Migrate the `file.file.resource.id` pattern (lines 1260, 1523)**
Use `Edit` with `replace_all=true`:
Find:
```swift
accountManager.mediaBox.storeResourceData(file.file.resource.id, data: fullSizeData)
```
Replace with:
```swift
accountManager.resources.storeResourceData(id: EngineMediaResource.Id(file.file.resource.id), data: fullSizeData)
```
Both sites share identical text. `replace_all=true` handles both atomically.
---
## Task 3: Full-project Bazel build
**Files:** none (verification only).
- [ ] **Step 1: Run the 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
```
Expected: clean build (`bazel build complete` / `INFO: Build completed successfully`). No `--continueOnError` because the small scope makes the first error informative.
Build cost projection: WallpaperResources is foundational with wide rebuild fan-out; expect ~30-90s.
- [ ] **Step 2: If build fails, triage iteration**
Common failure modes:
- **`EngineMediaResource.Id` not in scope** — verify `import TelegramCore` is at the top of the failing file (it should be — pre-flight inventoried both files have it). If absent, add it.
- **Type mismatch on `id:` parameter** — would suggest an unexpected `MediaResourceId` subtype. STOP and re-read; the migration assumed `MediaResource.id: MediaResourceId` for both `reference.resource` and `file.file.resource`. Both should resolve to `MediaResourceId` per Postbox protocol.
- **`accountManager.resources` not in scope** — the `public extension AccountManager` exists in TelegramCore (wave 94). If unreachable, the consumer's BUILD might be missing a TelegramCore dep — but both files already use TelegramCore types, so this should not happen. STOP if it does.
If errors land outside those 2 files: **STOP and report BLOCKED**. The wave is supposed to be self-contained.
Fix in place and re-run step 1. Budget: 2 iterations.
---
## Task 4: Post-edit residue grep
**Files:** none (verification only).
- [ ] **Step 1: Verify zero remaining `accountManager.mediaBox.storeResourceData` in the 2 touched files**
Run:
```sh
grep -rn "accountManager\.mediaBox\.storeResourceData" \
submodules/TelegramUI/Sources/ThemeUpdateManager.swift \
submodules/WallpaperResources/Sources/WallpaperResources.swift
```
Expected: empty output.
---
## Task 5: Commit the wave
**Files:** none (git only).
- [ ] **Step 1: Stage the 2 modified files**
```sh
git add \
submodules/TelegramUI/Sources/ThemeUpdateManager.swift \
submodules/WallpaperResources/Sources/WallpaperResources.swift
```
- [ ] **Step 2: Confirm staging is clean**
```sh
git status --short | grep -v "^??"
```
Expected output: only the 2 staged files (lines starting with `M `). The line `m build-system/bazel-rules/sourcekit-bazel-bsp` is pre-existing WIP and should NOT appear in the staged list.
- [ ] **Step 3: Commit**
```sh
git commit -m "$(cat <<'EOF'
Postbox -> TelegramEngine wave 103 (retry)
Drain 5 accountManager.mediaBox.storeResourceData(...) Shape-A sites
that the wave-94/95-99 sweep missed. All 5 migrated to
accountManager.resources.storeResourceData(id: EngineMediaResource.Id(...))
against the existing wave-94 facade.
Sites: ThemeUpdateManager:112 (with synchronous: true),
WallpaperResources:973, 1214 (reference.resource.id pattern, replace_all),
WallpaperResources:1260, 1523 (file.file.resource.id pattern, replace_all).
5 sites / 2 files / 3 Edit calls. Consumer-only build.
Wave-103 retry after the abandonment of ChatRecentActionsControllerNode
peer migration; see postbox-refactor-log "Wave 103 outcome" for the
forensics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
- [ ] **Step 4: Verify commit**
```sh
git log --oneline -1
```
Expected: shows the wave 103 (retry) commit as HEAD.
---
## Task 6: Update outcome log + memory
**Files:**
- Modify: `docs/superpowers/postbox-refactor-log.md`
- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`
- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/MEMORY.md`
- [ ] **Step 1: Append wave 103 (retry) outcome to refactor log**
Append a "Wave 103 (retry) outcome" entry to `docs/superpowers/postbox-refactor-log.md`. Include:
- Commit hash (from Task 5 step 4).
- Iteration count (1 if first-pass-clean; 2 if Task 3 step 2 fired).
- Bazel build duration.
- Net-delta accounting: 5 raw `mediaBox.X` accesses, +5 facade calls, +5 `EngineMediaResource.Id(...)` wraps (canonical engine-side, not Postbox bridges).
- Wave-shape note: G drain, validates the wave-94 facade across an additional 2-module footprint.
- [ ] **Step 2: Update next-wave memory**
Edit `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 (the file currently lists "Wave 95+ candidates" with stale storeResourceData entries — clean those up).
- Update the top frontmatter `description` to reflect wave 103 (retry) landed.
- Promote next candidate. Options: 7-site `resourceData(...)` drain (would need a new facade method or use existing `data(resource:)`), DirectMediaImageCache Shape-C/D, or pivot to a foundational wave.
- [ ] **Step 3: Update MEMORY.md index**
Edit `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/MEMORY.md`:
- Update the `[Postbox refactor next wave]` line to mention wave 103 (retry) landed.
- [ ] **Step 4: Commit the doc update**
```sh
git add docs/superpowers/postbox-refactor-log.md
git commit -m "$(cat <<'EOF'
docs: log wave 103 (retry) outcome
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
(Memory file updates are not committed — they live outside the repo.)
---
## Net delta projection
| Category | Count | Sites |
|---|---|---|
| Raw `mediaBox.X` access drops | 5 | TUM:112 + WR:973, 1214, 1260, 1523 |
| Facade calls added | +5 | same sites, migrated form |
| `EngineMediaResource.Id(...)` wraps | +5 | canonical engine-side constructs (not Postbox bridges) |
| `import Postbox` drops | 0 | both files retain Postbox import for unrelated symbols |
| Postbox-free module count | 0 | no module dropped from the import list |
**Total commit footprint:** 5 line edits (3 Edit calls) across 2 files, plus a docs commit for the outcome log.

View file

@ -0,0 +1,331 @@
# Wave 104: accountManager.mediaBox.resourceData drain (3 clean sites) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Drain 3 of 8 `accountManager.mediaBox.resourceData(...)` Shape-A sites against the existing wave-32 / wave-94 `AccountManagerResources.data(resource:)` facade. Wave 104 of the Postbox → TelegramEngine refactor.
**Architecture:** Wave-shape-G drain with a documented consumer field rename. Single-file consumer migration in `submodules/WallpaperResources/Sources/WallpaperResources.swift`. 3 call rewrites + 3 consumer-side `.complete``.isComplete` renames, 6 Edit calls total. The remaining 5 of the original 8 `resourceData` candidates are deferred (2 cross a `MediaResourceData` flow-out cascade, 3 are coupled to postbox-side via `combineLatest` typed tuples).
**Tech Stack:** Swift, Bazel via `Make.py`, no unit tests. Verification is the full-project debug-sim-arm64 build.
**Iteration budget:** 1 (target first-pass-clean given verified pre-flight inventory).
**Note on TDD:** This project has no unit tests. Each task writes the edits, then verifies via Bazel build + residue grep.
---
## File Structure
| File | Role | Changes |
|---|---|---|
| `submodules/WallpaperResources/Sources/WallpaperResources.swift` | Wallpaper resource pipeline | 3 call rewrites + 3 consumer renames |
No public-API ripple — leaf-consumer migration against an existing facade.
---
## Task 1: WallpaperResources.swift — call rewrites (3 edits)
**Files:**
- Modify: `submodules/WallpaperResources/Sources/WallpaperResources.swift`
**Edits in this task:** 3.
- [ ] **Step 1: Migrate the call at line 957 (`reference.resource` argument)**
Find:
```swift
let maybeFetched = accountManager.mediaBox.resourceData(reference.resource, option: .complete(waitUntilFetchStatus: false), attemptSynchronously: synchronousLoad)
```
Replace with:
```swift
let maybeFetched = accountManager.resources.data(resource: EngineMediaResource(reference.resource), attemptSynchronously: synchronousLoad)
```
Note: `waitUntilFetchStatus: false` is omitted because the facade default is `false`. The site explicitly passed `false`, so behavior is preserved.
- [ ] **Step 2: Migrate the call at line 1164 (`fileReference.media.resource` argument)**
Find:
```swift
let maybeFetched = accountManager.mediaBox.resourceData(fileReference.media.resource, option: .complete(waitUntilFetchStatus: false), attemptSynchronously: synchronousLoad)
```
Replace with:
```swift
let maybeFetched = accountManager.resources.data(resource: EngineMediaResource(fileReference.media.resource), attemptSynchronously: synchronousLoad)
```
Same `waitUntilFetchStatus: false` omission rationale.
- [ ] **Step 3: Migrate the call at line 1264 (`file.file.resource` argument, no option)**
Find:
```swift
return accountManager.mediaBox.resourceData(file.file.resource)
```
Replace with:
```swift
return accountManager.resources.data(resource: EngineMediaResource(file.file.resource))
```
The original used the underlying `MediaBox.resourceData(_ resource:)` overload's defaults — facade defaults match exactly (`pathExtension: nil`, `waitUntilFetchStatus: false`, `attemptSynchronously: false`).
---
## Task 2: WallpaperResources.swift — consumer-side `.complete``.isComplete` renames (3 edits)
**Files:**
- Modify: `submodules/WallpaperResources/Sources/WallpaperResources.swift`
**Edits in this task:** 3.
`EngineMediaResource.ResourceData` exposes `.isComplete` (renamed from `MediaResourceData.complete`). All three migrated call sites have a single consumer-side `.complete` access on the migrated result that needs renaming.
- [ ] **Step 1: Rename `maybeData.complete` at line 961 (consumer of site 957)**
Find:
```swift
if maybeData.complete {
```
Replace with:
```swift
if maybeData.isComplete {
```
The leading whitespace (8 spaces) must match exactly.
- [ ] **Step 2: Rename `maybeData.complete` at line 1168 (consumer of site 1164)**
Find:
```swift
if maybeData.complete && isSupportedTheme {
```
Replace with:
```swift
if maybeData.isComplete && isSupportedTheme {
```
The leading whitespace (16 spaces) must match exactly.
- [ ] **Step 3: Rename `data.complete` at line 1266 (consumer of site 1264)**
Find:
```swift
if data.complete, let imageData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) {
```
Replace with:
```swift
if data.isComplete, let imageData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) {
```
The leading whitespace (36 spaces) must match exactly.
The `data.path` access on the same line is unchanged — both `MediaResourceData.path` and `EngineMediaResource.ResourceData.path` are `String`.
---
## Sites NOT touched (deferred)
For the implementer's awareness — these `.complete` accesses on UNRELATED bindings stay raw and are NOT to be renamed:
- `WallpaperResources.swift:968``return data.complete ? try? Data(contentsOf: URL(fileURLWithPath: data.path)) : nil` — this `data` is bound from `account.postbox.mediaBox.resourceData(...)` (postbox-side, not migrated). STAYS `.complete`.
- Other `.complete` accesses elsewhere in the file that aren't on the 3 migrated bindings — STAY.
The 3 renames target only the 3 specific lines listed in Task 2 steps 1-3. Do NOT use `replace_all=true` for renames — bindings differ per scope.
---
## Task 3: Full-project Bazel build
**Files:** none (verification only).
- [ ] **Step 1: Run the 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
```
Expected: clean build (`bazel build complete` / `INFO: Build completed successfully`). No `--continueOnError`. Build cost projection: ~30-60s (consumer-only, foundational module rebuild fan-out).
- [ ] **Step 2: If build fails, triage iteration**
Common failure modes:
- **`EngineMediaResource` constructor not found** — verify `import TelegramCore` at the top of WallpaperResources.swift (it should already be there). If missing, add it.
- **Type mismatch on `resource:` parameter** — would suggest the argument expression isn't `MediaResource`-typed. STOP and check the actual type at the failing site.
- **Type mismatch on `.isComplete` rename** — if the closure parameter binding is somehow inferred wrong (e.g., Swift inferred the OLD `MediaResourceData` type because the call rewrite didn't take effect), the rename will fail. Re-read the diff and verify the call rewrite landed.
- **`data.path` type mismatch** — should not happen; both types expose `path: String`. If it does, STOP and re-read.
If errors land outside WallpaperResources.swift: STOP and report BLOCKED. The wave is supposed to be self-contained.
Iteration budget: 2.
---
## Task 4: Post-edit residue grep
**Files:** none (verification only).
- [ ] **Step 1: Verify the 3 migrated call sites are gone**
Run:
```sh
grep -nE "accountManager\.mediaBox\.resourceData\(" submodules/WallpaperResources/Sources/WallpaperResources.swift
```
Expected: exactly 3 lines remaining (L33, L59, L401 — the deferred combineLatest sites). The migrated lines (originally 957, 1164, 1264) should NOT appear.
- [ ] **Step 2: Verify the 3 renames are applied**
Run:
```sh
grep -nE "maybeData\.complete\b" submodules/WallpaperResources/Sources/WallpaperResources.swift
```
Expected: empty output. Both `maybeData.complete` accesses (originally L961, L1168) should be gone.
```sh
grep -nE "if data\.complete," submodules/WallpaperResources/Sources/WallpaperResources.swift
```
Expected: no line at L1266 (the migrated site). Other `data.complete` accesses on postbox-side bindings (e.g., L968) may remain — those are out of scope.
---
## Task 5: Commit the wave
**Files:** none (git only).
- [ ] **Step 1: Stage the 1 modified file**
```sh
git add submodules/WallpaperResources/Sources/WallpaperResources.swift
```
- [ ] **Step 2: Confirm staging is clean**
```sh
git status --short | grep -v "^??"
```
Expected: only the 1 staged file (line starting with `M `). The line `m build-system/bazel-rules/sourcekit-bazel-bsp` is pre-existing WIP and should NOT appear in the staged list.
- [ ] **Step 3: Commit**
```sh
git commit -m "$(cat <<'EOF'
Postbox -> TelegramEngine wave 104
Drain 3 accountManager.mediaBox.resourceData(...) Shape-A sites against
the existing wave-32 / wave-94 AccountManagerResources.data(resource:)
facade. Sites: WallpaperResources:957 (reference.resource), :1164
(fileReference.media.resource), :1264 (file.file.resource).
Migration: accountManager.mediaBox.resourceData(X, option: .complete(
waitUntilFetchStatus: false)[, attemptSynchronously: Y]) -> accountManager
.resources.data(resource: EngineMediaResource(X)[, attemptSynchronously:
Y]). Plus 3 consumer-side .complete -> .isComplete renames at L961,
L1168, L1266 to match EngineMediaResource.ResourceData field name.
3 sites / 1 file / 6 Edit calls. Consumer-only build.
Deferred: 2 sites in FetchCachedRepresentations.swift (482, 490) flow
data: MediaResourceData into fetchCachedScaled*Representation cascade;
3 sites in WallpaperResources (33, 59, 401) coupled to postbox-side via
combineLatest typed tuples.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
- [ ] **Step 4: Verify commit**
```sh
git log --oneline -1
```
Expected: shows the wave 104 commit as HEAD.
---
## Task 6: Update outcome log + memory
**Files:**
- Modify: `docs/superpowers/postbox-refactor-log.md`
- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`
- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/MEMORY.md`
- [ ] **Step 1: Append wave 104 outcome to refactor log**
Append a "Wave 104 outcome" entry to `docs/superpowers/postbox-refactor-log.md` matching the format of "Wave 103 (retry) outcome". Include:
- Commit hash (from Task 5 step 4).
- Iteration count (1 if first-pass-clean; 2 if Task 3 step 2 fired).
- Bazel build duration (from Task 3 step 1 output).
- Net-delta accounting: 3 raw `mediaBox.X` accesses, +3 facade calls, +3 `EngineMediaResource(...)` wraps, +3 consumer field renames.
- Wave-shape note: G drain with documented consumer field rename. The pre-flight identified a `MediaResourceData`-typed-function-parameter barrier (`fetchCachedScaled*Representation` family) that forced 2 sites into the deferred bucket — illustrates the wave-71-shadow lesson applied to result-type cascades, not just peer migrations.
- [ ] **Step 2: Update next-wave memory**
Edit `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).
- Add a new section (or extend an existing one) documenting the "Postbox-typed-function-parameter barrier" pattern, with `Message.peers: SimpleDictionary<PeerId, Peer>` (wave-103 lesson) and now `fetchCachedScaled*Representation(resourceData: MediaResourceData)` as the two known instances.
- [ ] **Step 3: Update MEMORY.md index**
Edit `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/MEMORY.md`:
- Update the `[Postbox refactor next wave]` line to mention wave 104 landed.
- [ ] **Step 4: Commit the doc update**
```sh
git add docs/superpowers/postbox-refactor-log.md
git commit -m "$(cat <<'EOF'
docs: log wave 104 outcome
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
(Memory file updates are not committed — they live outside the repo.)
---
## Net delta projection
| Category | Count | Sites |
|---|---|---|
| Raw `mediaBox.X` access drops | 3 | WR:957, 1164, 1264 |
| Facade calls added | +3 | same sites, migrated form |
| `EngineMediaResource(...)` wraps | +3 | canonical engine-side, not Postbox bridges |
| Consumer field renames | +3 | WR:961 (`maybeData.complete``.isComplete`), WR:1168 (same), WR:1266 (`data.complete``.isComplete`) |
| `import Postbox` drops | 0 | WallpaperResources retains import for unrelated symbols |
**Total commit footprint:** 6 line edits in 1 file, plus a docs commit for the outcome log.

View file

@ -0,0 +1,489 @@
# Wave 105: DeviceContactInfoSubject enum payload Peer? → EnginePeer? Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Migrate `DeviceContactInfoSubject` enum's 3 case payloads + 2 callback signatures + 1 computed property from raw Postbox `Peer?` to `EnginePeer?`. Wave 105 of the Postbox → TelegramEngine refactor.
**Architecture:** Multi-module enum-payload migration (wave-91 shape). 17 edits across 5 files. AccountContext.swift hosts the enum + property. DeviceContactInfoController.swift is the primary consumer. 4 construction sites in TelegramUI/PeerInfoUI/StoryContainerScreen/ChatController. Net wrap delta: 8 (drops 10, adds 2 at Chat-side construction barriers documented per spec).
**Tech Stack:** Swift, Bazel via `Make.py`, no unit tests. Verification is the full-project debug-sim-arm64 build.
**Iteration budget:** 1-3 (wave-91 precedent: 2 iter for similar shape).
**Note on TDD:** No unit tests in this project. Each task writes the edits, then verifies via Bazel build + residue grep.
---
## File Structure
| File | Role | Edits |
|---|---|---|
| `submodules/AccountContext/Sources/AccountContext.swift` | Enum definition + computed property | 4 type-line edits |
| `submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift` | Primary consumer | 9 edits (5 `_asPeer` drops + 3 `.flatMap` simplifications + 1 downcast rewrite) |
| `submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift` | Chat-side construction (Pattern E ADD bridges) | 1 Edit (replace_all=true covers 2 sites) |
| `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift` | Story-side construction | 1 edit |
| `submodules/TelegramUI/Sources/OpenChatMessage.swift` | OpenChatMessage construction | 1 edit |
---
## Task 1: AccountContext.swift — enum + computed property type changes
**File:** `submodules/AccountContext/Sources/AccountContext.swift`
- [ ] **Step 1: Migrate the 3 enum case payloads (single Edit covers consecutive lines)**
Find:
```swift
public enum DeviceContactInfoSubject {
case vcard(Peer?, DeviceContactStableId?, DeviceContactExtendedData)
case filter(peer: Peer?, contactId: DeviceContactStableId?, contactData: DeviceContactExtendedData, completion: (Peer?, DeviceContactExtendedData) -> Void)
case create(peer: Peer?, contactData: DeviceContactExtendedData, isSharing: Bool, shareViaException: Bool, completion: (Peer?, DeviceContactStableId, DeviceContactExtendedData) -> Void)
public var peer: Peer? {
```
Replace with:
```swift
public enum DeviceContactInfoSubject {
case vcard(EnginePeer?, DeviceContactStableId?, DeviceContactExtendedData)
case filter(peer: EnginePeer?, contactId: DeviceContactStableId?, contactData: DeviceContactExtendedData, completion: (EnginePeer?, DeviceContactExtendedData) -> Void)
case create(peer: EnginePeer?, contactData: DeviceContactExtendedData, isSharing: Bool, shareViaException: Bool, completion: (EnginePeer?, DeviceContactStableId, DeviceContactExtendedData) -> Void)
public var peer: EnginePeer? {
```
This single Edit covers all 4 type-line changes in `AccountContext.swift`. The `contactData: DeviceContactExtendedData` computed property (lines 719-727) is unaffected.
---
## Task 2: DeviceContactInfoController.swift — Pattern D downcast rewrite (1 edit)
**File:** `submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift`
- [ ] **Step 1: Rewrite the `as? TelegramUser` downcast at line 849**
Find:
```swift
if let peer = peer as? TelegramUser {
```
Replace with:
```swift
if case let .user(peer) = peer {
```
The leading whitespace (8 spaces) must match exactly. The outer `peer: EnginePeer?` (from `case let .create(peer, ...) = subject` at L845) is shadowed inside the if-body by `peer: TelegramUser` (the `.user` case associated value). Inner body access (`peer.firstName`, `peer.lastName`, `peer.phone`) works on the rebinding.
---
## Task 3: DeviceContactInfoController.swift — Pattern C `.flatMap` simplifications (3 edits)
**File:** `submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift`
- [ ] **Step 1: Simplify `.vcard` case body at line 942**
Find:
```swift
case let .vcard(peer, id, data):
contactData = .single((peer.flatMap(EnginePeer.init), id, data))
```
Replace with:
```swift
case let .vcard(peer, id, data):
contactData = .single((peer, id, data))
```
- [ ] **Step 2: Simplify `.filter` case body at line 944**
Find:
```swift
case let .filter(peer, id, data, _):
contactData = .single((peer.flatMap(EnginePeer.init), id, data))
```
Replace with:
```swift
case let .filter(peer, id, data, _):
contactData = .single((peer, id, data))
```
- [ ] **Step 3: Simplify `.create` case body at line 946**
Find:
```swift
case let .create(peer, data, share, shareViaExceptionValue, _):
contactData = .single((peer.flatMap(EnginePeer.init), nil, data))
```
Replace with:
```swift
case let .create(peer, data, share, shareViaExceptionValue, _):
contactData = .single((peer, nil, data))
```
After Task 1's enum migration, the destructured `peer: EnginePeer?` is the target type — `.flatMap(EnginePeer.init)` becomes a redundant round-trip.
---
## Task 4: DeviceContactInfoController.swift — Pattern B `_asPeer` drops at completion calls (2 edits)
**File:** `submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift`
- [ ] **Step 1: Drop `_asPeer()` at completion call line 1105**
Find:
```swift
completion(peerAndContactData.0?._asPeer(), filteredData)
```
Replace with:
```swift
completion(peerAndContactData.0, filteredData)
```
`peerAndContactData.0` is `EnginePeer?` from the typed signal at L939. Completion's first parameter type changes from `Peer?` to `EnginePeer?` per Task 1.
- [ ] **Step 2: Drop `_asPeer()` at completion call line 1224**
Find:
```swift
completion(contactIdAndData.2?._asPeer(), contactIdAndData.0, contactIdAndData.1)
```
Replace with:
```swift
completion(contactIdAndData.2, contactIdAndData.0, contactIdAndData.1)
```
`contactIdAndData.2` is `EnginePeer?` per the typed signal `(DeviceContactStableId, DeviceContactExtendedData, EnginePeer?)?` declared at L1175.
---
## Task 5: DeviceContactInfoController.swift — Pattern A `_asPeer` drops at construction (3 edits)
**File:** `submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift`
- [ ] **Step 1: Drop `_asPeer()` at line 1289**
Find:
```swift
replaceControllerImpl?(deviceContactInfoController(context: context, environment: environment, subject: .vcard(peer?._asPeer(), contactId, contactData), completed: nil, cancelled: nil))
```
Replace with:
```swift
replaceControllerImpl?(deviceContactInfoController(context: context, environment: environment, subject: .vcard(peer, contactId, contactData), completed: nil, cancelled: nil))
```
- [ ] **Step 2: Drop `_asPeer()` at line 1443**
Find:
```swift
parentController.present(deviceContactInfoController(context: ShareControllerAppAccountContext(context: context), environment: ShareControllerAppEnvironment(sharedContext: context.sharedContext), subject: .create(peer: peer?._asPeer(), contactData: contactData, isSharing: false, shareViaException: false, completion: { peer, stableId, contactData in
```
Replace with:
```swift
parentController.present(deviceContactInfoController(context: ShareControllerAppAccountContext(context: context), environment: ShareControllerAppEnvironment(sharedContext: context.sharedContext), subject: .create(peer: peer, contactData: contactData, isSharing: false, shareViaException: false, completion: { peer, stableId, contactData in
```
- [ ] **Step 3: Drop `_asPeer()` at line 1489**
Find:
```swift
controller?.present(context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: context), environment: ShareControllerAppEnvironment(sharedContext: context.sharedContext), subject: .create(peer: peer?._asPeer(), contactData: contactData, isSharing: peer != nil, shareViaException: false, completion: { _, _, _ in
```
Replace with:
```swift
controller?.present(context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: context), environment: ShareControllerAppEnvironment(sharedContext: context.sharedContext), subject: .create(peer: peer, contactData: contactData, isSharing: peer != nil, shareViaException: false, completion: { _, _, _ in
```
All 3 sites have `peer` source already typed as `EnginePeer?` per inventory.
---
## Task 6: ChatControllerOpenAttachmentMenu.swift — Pattern E ADD wraps (1 Edit, 2 sites via replace_all=true)
**File:** `submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift`
- [ ] **Step 1: Add `.flatMap(EnginePeer.init)` wrap at lines 683 and 1850**
Use Edit with `replace_all=true`. Find:
```swift
subject: .filter(peer: peerAndContactData.0, contactId: nil, contactData: contactData, completion: { peer, contactData in
```
Replace with:
```swift
subject: .filter(peer: peerAndContactData.0.flatMap(EnginePeer.init), contactId: nil, contactData: contactData, completion: { peer, contactData in
```
`replace_all=true` is required — both sites at L683 and L1850 share identical text. The upstream signal type is `(Peer?, DeviceContactExtendedData?)` (verified at L634 and L1822); `.flatMap(EnginePeer.init)` wraps `Peer?` to `EnginePeer?` to satisfy the migrated `.filter(peer: EnginePeer?, ...)` signature.
---
## Task 7: StoryItemSetContainerViewSendMessage.swift — Pattern A `_asPeer` drop (1 edit)
**File:** `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift`
- [ ] **Step 1: Drop `_asPeer()` at line 2132**
Find:
```swift
let contactController = component.context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: component.context), environment: ShareControllerAppEnvironment(sharedContext: component.context.sharedContext), subject: .filter(peer: peerAndContactData.0?._asPeer(), contactId: nil, contactData: contactData, completion: { [weak self, weak view] peer, contactData in
```
Replace with:
```swift
let contactController = component.context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: component.context), environment: ShareControllerAppEnvironment(sharedContext: component.context.sharedContext), subject: .filter(peer: peerAndContactData.0, contactId: nil, contactData: contactData, completion: { [weak self, weak view] peer, contactData in
```
`peerAndContactData.0` is `EnginePeer?` from the typed signal at this site (the presence of `?._asPeer()` confirms it).
---
## Task 8: OpenChatMessage.swift — Pattern A `_asPeer` drop (1 edit)
**File:** `submodules/TelegramUI/Sources/OpenChatMessage.swift`
- [ ] **Step 1: Drop `_asPeer()` at line 443**
Find:
```swift
let controller = deviceContactInfoController(context: ShareControllerAppAccountContext(context: params.context), environment: ShareControllerAppEnvironment(sharedContext: params.context.sharedContext), updatedPresentationData: params.updatedPresentationData, subject: .vcard(peer?._asPeer(), nil, contactData), completed: nil, cancelled: nil)
```
Replace with:
```swift
let controller = deviceContactInfoController(context: ShareControllerAppAccountContext(context: params.context), environment: ShareControllerAppEnvironment(sharedContext: params.context.sharedContext), updatedPresentationData: params.updatedPresentationData, subject: .vcard(peer, nil, contactData), completed: nil, cancelled: nil)
```
`peer` source is already `EnginePeer?` (the `?._asPeer()` confirms the source type).
---
## Task 9: Full-project Bazel build
**Files:** none (verification only).
- [ ] **Step 1: Run the 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
```
`--continueOnError` enabled — multi-module wave; surface all errors at once if iter-1 fails.
Expected: clean build. AccountContext is foundational; expect 60-180s build cost.
- [ ] **Step 2: If build fails, triage iteration**
Common failure modes (per wave-91 precedent):
- **Type mismatch on a destructured `peer`** — a destructure body may use `peer.X` where `X` is a Peer-protocol-only method not on EnginePeer. Pre-flight inventory found ZERO such sites, but verify the failing line.
- **`.id` access on EnginePeer? doesn't compile** — would indicate an EnginePeer.Id typealias regression (very unlikely; would have failed all prior waves).
- **`case let .user(peer) = peer` doesn't compile** — verify the outer `peer` is `EnginePeer?` (after migration) and not still `Peer?`.
- **A construction site missed an `_asPeer()` drop** — re-grep `_asPeer\(\)` over the 5 touched files.
- **Hidden `Peer?`-typed completion call site** — would indicate an unmigrated callback consumer. Re-grep across consumer module sources.
If errors land outside the 5 touched files: STOP and report BLOCKED — the wave is supposed to be self-contained.
Iteration budget: 3.
---
## Task 10: Post-edit residue grep
**Files:** none (verification only).
- [ ] **Step 1: Construction-site `_asPeer` residue (expected empty)**
```sh
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
```
- [ ] **Step 2: Completion `_asPeer` residue (expected empty)**
```sh
grep -nE "completion\(.*_asPeer\(\)" submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift
```
- [ ] **Step 3: `.flatMap(EnginePeer.init)` simplification residue (expected empty)**
```sh
grep -nE "peer\.flatMap\(EnginePeer\.init\)" submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift
```
- [ ] **Step 4: Downcast residue (expected empty)**
```sh
grep -nE "peer as\? TelegramUser" submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift
```
- [ ] **Step 5: ADD wraps applied (expected 2 lines)**
```sh
grep -nE "peerAndContactData\.0\.flatMap\(EnginePeer\.init\)" submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift
```
Expected: 2 lines (originally L683 and L1850, line numbers may have shifted slightly).
---
## Task 11: Commit the wave
**Files:** none (git only).
- [ ] **Step 1: Stage the 5 modified files**
```sh
git add \
submodules/AccountContext/Sources/AccountContext.swift \
submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift \
submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift \
submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift \
submodules/TelegramUI/Sources/OpenChatMessage.swift
```
- [ ] **Step 2: Confirm staging**
```sh
git status --short | grep -v "^??"
```
Expected: 5 staged files (lines starting with `M `). The pre-existing `m build-system/bazel-rules/sourcekit-bazel-bsp` WIP marker should NOT appear in staged.
- [ ] **Step 3: Commit**
```sh
git commit -m "$(cat <<'EOF'
Postbox -> TelegramEngine wave 105
Migrate DeviceContactInfoSubject enum 3 case Peer? payloads + 2 callback
(Peer?, ...) -> Void signatures + 1 computed peer: Peer? property to
EnginePeer?. Wave-91-pattern multi-module enum-payload migration.
Drops 10 wraps:
- 5 _asPeer() at construction sites: DeviceContactInfoController:1289,
1443, 1489 + StoryItemSetContainerViewSendMessage:2132 +
OpenChatMessage:443.
- 2 _asPeer() at completion-call sites:
DeviceContactInfoController:1105, 1224.
- 3 .flatMap(EnginePeer.init) simplifications at
DeviceContactInfoController:942, 944, 946.
Adds 2 ADD bridges: ChatControllerOpenAttachmentMenu:683, 1850 — both
construct .filter(peer:) from peerAndContactData.0 typed (Peer?, ...);
.flatMap(EnginePeer.init) wraps to EnginePeer?. Net wrap delta: -8.
Plus 1 downcast rewrite: DeviceContactInfoController:849 — `if let peer
= peer as? TelegramUser` to `if case let .user(peer) = peer`.
5 files / 17 edits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
- [ ] **Step 4: Verify commit**
```sh
git log --oneline -1
```
---
## Task 12: Update outcome log + memory
**Files:**
- Modify: `docs/superpowers/postbox-refactor-log.md`
- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`
- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/MEMORY.md`
- [ ] **Step 1: Append wave 105 outcome to refactor log**
Include:
- Commit hash (from Task 11 step 4).
- Iteration count (1 if first-pass-clean; 2-3 if Task 9 step 2 fired).
- Bazel build duration.
- Net-delta accounting: 10 wrap drops, +2 ADD wraps, +1 downcast rewrite. Net 8 wraps.
- Wave-shape note: wave-91-pattern multi-module enum-payload migration with full pre-flight inventory clearing layers 1-4 of the wave-71-shadow checklist. Documents the value of thorough pre-flight inventory: 17 mechanical edits with 0 surprises.
- [ ] **Step 2: Update next-wave memory**
Edit `project_postbox_refactor_next_wave.md`:
- Add wave 105 outcome line into the recent-waves section.
- Mark `DeviceContactInfoSubject` candidate as drained (currently bullet 9 in deferred list).
- Promote next candidate.
- [ ] **Step 3: Update MEMORY.md index**
Update the `[Postbox refactor next wave]` line.
- [ ] **Step 4: Commit the doc update**
```sh
git add docs/superpowers/postbox-refactor-log.md
git commit -m "$(cat <<'EOF'
docs: log wave 105 outcome
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
(Memory file updates not committed — they live outside the repo.)
---
## Net delta projection
| Category | Count | Sites |
|---|---|---|
| `_asPeer()` drops at construction | 5 | DCIC:1289, 1443, 1489 + SISCVSM:2132 + OCM:443 |
| `_asPeer()` drops at completion calls | 2 | DCIC:1105, 1224 |
| `.flatMap(EnginePeer.init)` simplifications | 3 | DCIC:942, 944, 946 |
| `.flatMap(EnginePeer.init)` ADD wraps | +2 | CCOAM:683, 1850 |
| Downcast → case-let | +1 | DCIC:849 |
| Type annotations migrated | 4 | AccountContext: 3 enum cases + 1 computed property |
**Total commit footprint:** 17 line edits across 5 files, plus a docs commit for the outcome log.
**Net wrap delta:** **8** (the wave's headline metric).

View file

@ -1871,6 +1871,81 @@ All four forwarding implementations live in `submodules/TelegramCore/Sources/Uti
---
## Wave 103 outcome (2026-04-26): ABANDONED
`ChatRecentActionsControllerNode.peer: Peer → EnginePeer` (wave-71-shadow close). Implementation built and committed (`e60a8692a7`, build clean at iter-3 / 41s), then **reverted** (`git reset --hard HEAD~1`) after pre-flight failure surfaced.
**Spec promised:** 1 boundary `_asPeer()` (CRAC:277) + 1 `import Postbox` (CRACN:5) + 0 ADD wraps. 7 edits / 2 files / 1 iter.
**Actual outcome before revert:** 1 boundary `_asPeer()` (CRAC:277) + 1 `EnginePeer(strongSelf.peer)` wrap (CRACN:535, bonus) + 0 `import Postbox` drops (raw `Message`/`MessageId` references for `AdminLogEventAction` payloads block the drop) + **+2 ADD `_asPeer()` wraps** (CRACN:228 for `canSetupAutoremoveTimeout` Peer-protocol extension, CRACN:737 for `chatRecentActionsHistoryPreparedTransition(peer: Peer)` helper). Net wrap delta: **0**, not the promised 1. Net `import Postbox` drop: **0**, not the promised 1.
**Why "extend in follow-up" was rejected:** Migrating `chatRecentActionsHistoryPreparedTransition(peer:)` to `EnginePeer` to drop the CRACN:737 ADD bridge would cascade into `ChatRecentActionsEntry.item(peer:)`. Inventory found 75 ADD-bridge sites in the helper body: 72 `peers[peer.id] = peer` stores into a `SimpleDictionary<PeerId, Peer>` (the type required by `Message(...)` constructor's `peers:` parameter) plus 3 `filterMessageChannelPeer(peer)` calls. Net cost of dropping 1 bridge: **+74 new bridges**. Catastrophic regression.
**Pre-flight failure modes (lessons):**
- **Pre-flight grep for `self.<field>.X` access must enumerate Peer-protocol extension methods, not just downcast patterns.** Wave-103 spec grepped `self.peer as\?` and `self.peer\.id\b` but missed `self.peer.canSetupAutoremoveTimeout(...)` at CRACN:228. `canSetupAutoremoveTimeout` is a `public extension Peer` method in `AccountContext/Sources/ChatController.swift:1013` — not on `EnginePeer`. **Mitigation:** future wave specs that retype a stored field from `Peer` to `EnginePeer` MUST grep for ALL `self.<field>.<method>(` patterns and verify each method is reachable on `EnginePeer` (not just on the `Peer` protocol). Build a method-allowlist for `EnginePeer` from `extension EnginePeer` declarations in TelegramCore and intersect.
- **Pre-flight grep for `<field>` flowing into Peer-typed function parameters.** Wave-103 spec missed that `peer` was passed to `chatRecentActionsHistoryPreparedTransition(peer: Peer)` at CRACN:737. The helper sits in a different file (`ChatRecentActionsHistoryTransition.swift`) but the same submodule. **Mitigation:** grep `: peer\b|, peer:|peer: peer\b|peer:\s*self\.peer` across the entire submodule, classify each call site as (a) target accepts EnginePeer (clean), (b) target accepts Peer (would need `_asPeer()` ADD or co-migration of target), (c) target accepts `EnginePeer(peer)` wrap (clean drop).
- **Wave-71-shadow close is NOT always cheap.** The implicit assumption ("caller already has EnginePeer; just drop the boundary `_asPeer()` and retype the storage") only holds when ALL of: (a) every `self.<field>.X` access has an EnginePeer-compatible counterpart, (b) every function call passing `self.<field>` accepts EnginePeer, (c) no protocol-conformance constraint on the stored field exists. Wave 71 itself worked because `peerInfoControllerImpl` was a single private function with mechanical body. Wave 103 failed because the file participates in a deep helper-cascade that builds Postbox `Message` values — and `Message`'s `peers: SimpleDictionary<PeerId, Peer>` constructor parameter is a hard barrier.
- **The `Message(... peers: SimpleDictionary<PeerId, Peer>, ...)` constructor is a hard wave barrier.** Any helper that builds Message values from a peer-typed parameter has Cat-3 ADD-bridge sites everywhere it stores the migrated peer into the peers dict. ChatRecentActionsHistoryTransition.swift has 70+ such constructions. Future waves should treat Message-building helpers as red-flagged candidates and pre-flight inventory their `peers[X.id] = X` store sites before promising any `peer: Peer → EnginePeer` migration upstream.
- **Spec self-review must include an "ADD wraps inventory" pass.** The wave-103 spec claimed "ADD wraps: 0" but the pre-flight grep didn't actually verify this — only the cast/`is` patterns were enumerated. Future spec template: a dedicated "ADD-wrap risk grep" section listing the regex patterns checked (Peer-protocol method calls, function calls accepting `: Peer`, dictionary stores into `[PeerId: Peer]`).
**Outcome:** wave-103 candidate marked **abandoned** in `project_postbox_refactor_next_wave.md`. Spec and plan docs retained at `docs/superpowers/specs/2026-04-26-postbox-wave-103-chat-recent-actions-controller-node-design.md` and `docs/superpowers/plans/2026-04-26-postbox-wave-103-chat-recent-actions-controller-node.md` as record of the failed attempt.
---
## Wave 103 (retry) outcome (2026-04-26)
`accountManager.mediaBox.storeResourceData(...)` Shape-A drain against the wave-94 `AccountManagerResources.storeResourceData(id:data:synchronous:)` facade. 5 sites / 2 files / 3 Edit calls (1 single + 2 `replace_all=true` batches) / **first-pass-clean** Bazel 29.5s (warm cache). Commit `92230b0691`. Sites: ThemeUpdateManager:112 (with `synchronous: true`), WallpaperResources:973+1214 (`reference.resource.id` pattern, replace_all), WallpaperResources:1260+1523 (`file.file.resource.id` pattern, replace_all).
**Net delta:** 5 raw `mediaBox.X` accesses, +5 facade calls, +5 `EngineMediaResource.Id(...)` wraps (canonical engine-side, not Postbox bridges).
**Lesson reinforced:** wave-shape-G drain against an existing facade is the cheapest reliable wave shape. 1-iter, ~30s build, single atomic commit. Pre-flight risk inventory takes ~5 minutes; implementation takes ~30s. Use this shape after a difficult abandonment to rebuild momentum.
**Wave-shape-G drain after a failed wave-71-shadow:** the contrast between the abandoned wave-103 (`peer: Peer → EnginePeer` field migration with hidden 75-site cascade) and the retry (5-site call-rewrite drain) illustrates why facade-drain waves and field-migration waves are categorically different. Drains have bounded scope (only sites matching a literal text pattern); field migrations have unbounded scope (any consumer of any field-typed value). Future wave selection should prefer drains when the goal is consistent forward progress and reserve field migrations for sessions with explicit budget for cascade investigation.
---
## Wave 104 outcome (2026-04-26)
`accountManager.mediaBox.resourceData(...)` Shape-A drain (3 of 8 candidate sites) against the wave-32 / wave-94 `AccountManagerResources.data(resource:)` facade. 1 file / 6 Edit calls (3 call rewrites + 3 consumer-side `.complete``.isComplete` renames) / **first-pass-clean** Bazel 11.7s. Commit `08fc3f721e`. Sites: WallpaperResources:957 (`reference.resource`), :1164 (`fileReference.media.resource`), :1264 (`file.file.resource`).
**Net delta:** 3 raw `mediaBox.X` accesses, +3 facade calls, +3 `EngineMediaResource(...)` wraps, +3 consumer field renames (`.complete``.isComplete` to match `EngineMediaResource.ResourceData`'s renamed field).
**Deferred (from the original 8-site candidate set):**
- 2 sites in `FetchCachedRepresentations.swift:482, 490` — flow `data: MediaResourceData` into `fetchCachedScaledImageRepresentation` / `fetchCachedBlurredWallpaperRepresentation` (raw-`MediaResourceData`-typed `resourceData:` parameter). Migration would cascade those functions OR require boundary `MediaResourceData` reconstruction. Defer.
- 3 sites in `WallpaperResources.swift:33, 59, 401` — coupled to postbox-side via `combineLatest(accountManager.mediaBox.resourceData(X), account.postbox.mediaBox.resourceData(X))` returning typed `Signal<(MediaResourceData, MediaResourceData), NoError>`. Migrating one side without the other breaks the tuple type. Defer until postbox-side is also drainable or a paired-resource facade is designed.
**Lesson — "Postbox-typed-function-parameter barrier" pattern (generalized):** wave 103's abandonment introduced this concept for `Message(... peers: SimpleDictionary<PeerId, Peer>, ...)` — any helper that builds a `Message` value forces ADD bridges at every `peers[X.id] = X` store. Wave 104 finds the same pattern at the result-type-flow level: `fetchCachedScaled*Representation(resourceData: MediaResourceData)` is a barrier that forces ADD bridges at every site whose closure flows the migrated result into it. Both are instances of "a Postbox-typed function parameter blocks upstream migration of values that flow into it." Pre-flight inventory must enumerate not just type-level uses of the migrated symbol but also the function-parameter-typed barriers it transitively flows into.
**Lesson — "field rename at wrapper-type boundaries":** `EngineMediaResource.ResourceData.isComplete` is renamed from `MediaResourceData.complete`. Each migrated call site has a paired consumer rename. This is a small but mandatory step the spec must call out per-site; an undocumented rename would surface as a build error referencing a property that doesn't exist on the new wrapper. The rename is verifiable per-site by reading the closure body that consumes the result.
---
## Wave 105 outcome (2026-04-26)
`DeviceContactInfoSubject` enum-payload Peer? → EnginePeer? migration. 5 files / 17 edits / **first-pass-clean** Bazel 203s (foundational AccountContext touch). Commit `0c76724409`. Wave-91-pattern multi-module enum-payload + completion-callback signature migration.
**Type changes:** 3 enum case payloads (`Peer?``EnginePeer?`), 2 completion-callback signatures (`(Peer?, ...) -> Void``(EnginePeer?, ...) -> Void`), 1 computed property (`peer: Peer?``peer: EnginePeer?`). All in `AccountContext.swift`.
**Net delta:** 10 wraps dropped, +2 wraps added (Pattern E ADD bridges at Chat-side construction sites where `peerAndContactData.0` is raw `Peer?`), +1 downcast → case-let conversion. **Net wrap delta: 8.**
Per-pattern breakdown:
- Pattern A (5 sites): `_asPeer()` drops at construction sites where source is already `EnginePeer?`. DeviceContactInfoController.swift:1289, 1443, 1489 + StoryItemSetContainerViewSendMessage.swift:2132 + OpenChatMessage.swift:443.
- Pattern B (2 sites): `_asPeer()` drops at completion-call sites. DeviceContactInfoController.swift:1105, 1224.
- Pattern C (3 sites): `.flatMap(EnginePeer.init)` simplifications when destructured `peer` is already `EnginePeer?` post-migration. DeviceContactInfoController.swift:942, 944, 946.
- Pattern D (1 site): downcast `as? TelegramUser``case let .user(peer)`. DeviceContactInfoController.swift:849.
- Pattern E (2 sites): ADD `.flatMap(EnginePeer.init)` wraps at construction sites where source is raw `Peer?`. ChatControllerOpenAttachmentMenu.swift:683, 1850.
**Lesson — "thorough pre-flight inventory pays for itself":** wave 105 was the first wave-71-shadow-style (field/payload migration with cascade risk) attempted after the wave-103 abandonment forced a discipline reset. Pre-flight inventory took ~15 minutes (full 4-layer wave-71-shadow-checklist sweep, including verifying 8 destructure sites + 5 callback consumers + 3 `subject.peer` accesses + 12 construction sites across 8 files). The inventory caught the 2 ADD bridges at Chat sites that an Explore-agent pass had initially miscategorized — verified by direct grep of `peerAndContactData` source signal types. Cost-of-care: 15 minutes of pre-flight + 5 minutes of one-line spec fix vs. the wave-103 cost of a build-and-revert cycle. Recommendation: apply the same discipline to every future wave-71-shadow candidate. The inventory cost is a tiny fraction of the abandoned-wave cost.
**Lesson — "documented ADD bridges are net-positive":** ADD bridges are not always disqualifying. The wave-71-shadow risk feedback emphasizes inventorying them, not avoiding them entirely. When the migration delivers net-negative wrap delta even after accounting for the ADD bridges (here: 10 drops vs. +2 adds = 8 net), the wave is still worth doing. The ADD bridges are also documented in the spec and commit message, so future waves migrating the upstream `peerAndContactData` source can drop them as part of that migration's natural cascade.
---
## Modules currently free of `import Postbox` (running tally)
Consumer modules that no longer import Postbox, across all waves and standalone commits:

View file

@ -0,0 +1,149 @@
# 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<PeerId, Peer>` | 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<TelegramAccountManagerTypes>` 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(<argument expression>), data: <data expression>)`.
`accountManager` flows from `wallpaperDatas(account:accountManager:...)` and other public functions in the file, all parameter-typed `AccountManager<TelegramAccountManagerTypes>` (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).

View file

@ -0,0 +1,148 @@
# 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, NoError>
```
`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 `<binding>.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<PeerId, Peer>`.

View file

@ -0,0 +1,183 @@
# 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<PeerId, Peer>` 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.

View file

@ -701,11 +701,11 @@ public final class NavigateToChatControllerParams {
}
public enum DeviceContactInfoSubject {
case vcard(Peer?, DeviceContactStableId?, DeviceContactExtendedData)
case filter(peer: Peer?, contactId: DeviceContactStableId?, contactData: DeviceContactExtendedData, completion: (Peer?, DeviceContactExtendedData) -> Void)
case create(peer: Peer?, contactData: DeviceContactExtendedData, isSharing: Bool, shareViaException: Bool, completion: (Peer?, DeviceContactStableId, DeviceContactExtendedData) -> Void)
public var peer: Peer? {
case vcard(EnginePeer?, DeviceContactStableId?, DeviceContactExtendedData)
case filter(peer: EnginePeer?, contactId: DeviceContactStableId?, contactData: DeviceContactExtendedData, completion: (EnginePeer?, DeviceContactExtendedData) -> Void)
case create(peer: EnginePeer?, contactData: DeviceContactExtendedData, isSharing: Bool, shareViaException: Bool, completion: (EnginePeer?, DeviceContactStableId, DeviceContactExtendedData) -> Void)
public var peer: EnginePeer? {
switch self {
case let .vcard(peer, _, _):
return peer

View file

@ -846,7 +846,7 @@ public func deviceContactInfoController(context: ShareControllerAccountContext,
var peerPhoneNumber: String?
var firstName = contactData.basicData.firstName
var lastName = contactData.basicData.lastName
if let peer = peer as? TelegramUser {
if case let .user(peer) = peer {
firstName = peer.firstName ?? ""
lastName = peer.lastName ?? ""
if let phone = peer.phone {
@ -939,11 +939,11 @@ public func deviceContactInfoController(context: ShareControllerAccountContext,
var shareViaException = false
switch subject {
case let .vcard(peer, id, data):
contactData = .single((peer.flatMap(EnginePeer.init), id, data))
contactData = .single((peer, id, data))
case let .filter(peer, id, data, _):
contactData = .single((peer.flatMap(EnginePeer.init), id, data))
contactData = .single((peer, id, data))
case let .create(peer, data, share, shareViaExceptionValue, _):
contactData = .single((peer.flatMap(EnginePeer.init), nil, data))
contactData = .single((peer, nil, data))
isShare = share
shareViaException = shareViaExceptionValue
}
@ -1102,7 +1102,7 @@ public func deviceContactInfoController(context: ShareControllerAccountContext,
} else if case let .filter(_, _, _, completion) = subject {
let filteredData = filteredContactData(contactData: peerAndContactData.2, excludedComponents: state.excludedComponents)
rightNavigationButton = ItemListNavigationButton(content: .text(presentationData.strings.ShareMenu_Send), style: .bold, enabled: !filteredData.basicData.phoneNumbers.isEmpty, action: {
completion(peerAndContactData.0?._asPeer(), filteredData)
completion(peerAndContactData.0, filteredData)
dismissImpl?(true)
})
} else if case let .create(createForPeer, _, _, _, completion) = subject {
@ -1221,7 +1221,7 @@ public func deviceContactInfoController(context: ShareControllerAccountContext,
return state
}
if let contactIdAndData = contactIdAndData {
completion(contactIdAndData.2?._asPeer(), contactIdAndData.0, contactIdAndData.1)
completion(contactIdAndData.2, contactIdAndData.0, contactIdAndData.1)
}
completed?()
dismissImpl?(true)
@ -1286,7 +1286,7 @@ public func deviceContactInfoController(context: ShareControllerAccountContext,
return
}
addContactToExisting(context: accountContext, parentController: controller, contactData: subject.contactData, completion: { peer, contactId, contactData in
replaceControllerImpl?(deviceContactInfoController(context: context, environment: environment, subject: .vcard(peer?._asPeer(), contactId, contactData), completed: nil, cancelled: nil))
replaceControllerImpl?(deviceContactInfoController(context: context, environment: environment, subject: .vcard(peer, contactId, contactData), completed: nil, cancelled: nil))
})
}
openChatImpl = { [weak controller] peerId in
@ -1440,7 +1440,7 @@ private func addContactToExisting(context: AccountContext, parentController: Vie
let _ = (dataSignal
|> deliverOnMainQueue).start(next: { peer, stableId in
guard let stableId = stableId else {
parentController.present(deviceContactInfoController(context: ShareControllerAppAccountContext(context: context), environment: ShareControllerAppEnvironment(sharedContext: context.sharedContext), subject: .create(peer: peer?._asPeer(), contactData: contactData, isSharing: false, shareViaException: false, completion: { peer, stableId, contactData in
parentController.present(deviceContactInfoController(context: ShareControllerAppAccountContext(context: context), environment: ShareControllerAppEnvironment(sharedContext: context.sharedContext), subject: .create(peer: peer, contactData: contactData, isSharing: false, shareViaException: false, completion: { peer, stableId, contactData in
}), completed: nil, cancelled: nil), in: .window(.root))
return
}
@ -1486,7 +1486,7 @@ func addContactOptionsController(context: AccountContext, peer: EnginePeer?, con
controller.setItemGroups([
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: presentationData.strings.Profile_CreateNewContact, action: { [weak controller] in
controller?.present(context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: context), environment: ShareControllerAppEnvironment(sharedContext: context.sharedContext), subject: .create(peer: peer?._asPeer(), contactData: contactData, isSharing: peer != nil, shareViaException: false, completion: { _, _, _ in
controller?.present(context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: context), environment: ShareControllerAppEnvironment(sharedContext: context.sharedContext), subject: .create(peer: peer, contactData: contactData, isSharing: peer != nil, shareViaException: false, completion: { _, _, _ in
}), completed: nil, cancelled: nil), in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
dismissAction()
}),

View file

@ -2129,7 +2129,7 @@ final class StoryItemSetContainerSendMessage: @unchecked(Sendable) {
self.sendMessages(view: view, peer: targetPeer, messages: enqueueMessages, silentPosting: silent, scheduleTime: scheduleTime)
})
} else {
let contactController = component.context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: component.context), environment: ShareControllerAppEnvironment(sharedContext: component.context.sharedContext), subject: .filter(peer: peerAndContactData.0?._asPeer(), contactId: nil, contactData: contactData, completion: { [weak self, weak view] peer, contactData in
let contactController = component.context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: component.context), environment: ShareControllerAppEnvironment(sharedContext: component.context.sharedContext), subject: .filter(peer: peerAndContactData.0, contactId: nil, contactData: contactData, completion: { [weak self, weak view] peer, contactData in
guard let self, let view else {
return
}

View file

@ -680,7 +680,7 @@ extension ChatControllerImpl {
strongSelf.sendMessages(strongSelf.transformEnqueueMessages(enqueueMessages, silentPosting: silent, scheduleTime: scheduleTime, postpone: postpone), postpone: postpone)
})
} else {
let contactController = strongSelf.context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: strongSelf.context), environment: ShareControllerAppEnvironment(sharedContext: strongSelf.context.sharedContext), subject: .filter(peer: peerAndContactData.0, contactId: nil, contactData: contactData, completion: { peer, contactData in
let contactController = strongSelf.context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: strongSelf.context), environment: ShareControllerAppEnvironment(sharedContext: strongSelf.context.sharedContext), subject: .filter(peer: peerAndContactData.0.flatMap(EnginePeer.init), contactId: nil, contactData: contactData, completion: { peer, contactData in
guard let strongSelf = self, !contactData.basicData.phoneNumbers.isEmpty else {
return
}
@ -1847,7 +1847,7 @@ extension ChatControllerImpl {
strongSelf.sendMessages([message], postpone: postpone)
})
} else {
let contactController = strongSelf.context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: strongSelf.context), environment: ShareControllerAppEnvironment(sharedContext: strongSelf.context.sharedContext), subject: .filter(peer: peerAndContactData.0, contactId: nil, contactData: contactData, completion: { peer, contactData in
let contactController = strongSelf.context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: strongSelf.context), environment: ShareControllerAppEnvironment(sharedContext: strongSelf.context.sharedContext), subject: .filter(peer: peerAndContactData.0.flatMap(EnginePeer.init), contactId: nil, contactData: contactData, completion: { peer, contactData in
guard let strongSelf = self, !contactData.basicData.phoneNumbers.isEmpty else {
return
}

View file

@ -440,7 +440,7 @@ func openChatMessageImpl(_ params: OpenChatMessageParams) -> Bool {
} else {
contactData = DeviceContactExtendedData(basicData: DeviceContactBasicData(firstName: contact.firstName, lastName: contact.lastName, phoneNumbers: [DeviceContactPhoneNumberData(label: "_$!<Mobile>!$_", value: contact.phoneNumber)]), middleName: "", prefix: "", suffix: "", organization: "", jobTitle: "", department: "", emailAddresses: [], urls: [], addresses: [], birthdayDate: nil, socialProfiles: [], instantMessagingProfiles: [], note: "")
}
let controller = deviceContactInfoController(context: ShareControllerAppAccountContext(context: params.context), environment: ShareControllerAppEnvironment(sharedContext: params.context.sharedContext), updatedPresentationData: params.updatedPresentationData, subject: .vcard(peer?._asPeer(), nil, contactData), completed: nil, cancelled: nil)
let controller = deviceContactInfoController(context: ShareControllerAppAccountContext(context: params.context), environment: ShareControllerAppEnvironment(sharedContext: params.context.sharedContext), updatedPresentationData: params.updatedPresentationData, subject: .vcard(peer, nil, contactData), completed: nil, cancelled: nil)
params.navigationController?.pushViewController(controller)
})
return true

View file

@ -109,7 +109,7 @@ final class ThemeUpdateManagerImpl: ThemeUpdateManager {
guard complete, let fullSizeData = fullSizeData else {
return .complete()
}
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)
return .single((.cloud(PresentationCloudTheme(theme: theme, resolvedWallpaper: wallpaper, creatorAccountId: theme.isCreator ? account.id : nil)), presentationTheme))
}
} else {

View file

@ -954,11 +954,11 @@ public func photoWallpaper(postbox: Postbox, photoLibraryResource: PhotoLibraryM
}
public func telegramThemeData(account: Account, accountManager: AccountManager<TelegramAccountManagerTypes>, reference: MediaResourceReference, synchronousLoad: Bool = false) -> Signal<Data?, NoError> {
let maybeFetched = accountManager.mediaBox.resourceData(reference.resource, option: .complete(waitUntilFetchStatus: false), attemptSynchronously: synchronousLoad)
let maybeFetched = accountManager.resources.data(resource: EngineMediaResource(reference.resource), attemptSynchronously: synchronousLoad)
return maybeFetched
|> take(1)
|> mapToSignal { maybeData in
if maybeData.complete {
if maybeData.isComplete {
let loadedData: Data? = try? Data(contentsOf: URL(fileURLWithPath: maybeData.path), options: [])
return .single(loadedData)
} else {
@ -970,7 +970,7 @@ public func telegramThemeData(account: Account, accountManager: AccountManager<T
return data.complete ? try? Data(contentsOf: URL(fileURLWithPath: data.path)) : nil
}).start(next: { next in
if let data = next {
accountManager.mediaBox.storeResourceData(reference.resource.id, data: data)
accountManager.resources.storeResourceData(id: EngineMediaResource.Id(reference.resource.id), data: data)
}
subscriber.putNext(next)
}, error: { _ in
@ -1161,11 +1161,11 @@ public func themeImage(account: Account, accountManager: AccountManager<Telegram
switch source {
case let .file(fileReference):
let isSupportedTheme = fileReference.media.mimeType == "application/x-tgtheme-ios"
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)
theme = maybeFetched
|> take(1)
|> mapToSignal { maybeData -> Signal<(PresentationTheme?, Data?), NoError> in
if maybeData.complete && isSupportedTheme {
if maybeData.isComplete && isSupportedTheme {
let loadedData: Data? = try? Data(contentsOf: URL(fileURLWithPath: maybeData.path), options: [])
return .single((loadedData.flatMap { makePresentationTheme(data: $0) }, nil))
} else {
@ -1211,7 +1211,7 @@ public func themeImage(account: Account, accountManager: AccountManager<Telegram
return data.complete ? try? Data(contentsOf: URL(fileURLWithPath: data.path)) : nil
}).start(next: { next in
if let data = next {
accountManager.mediaBox.storeResourceData(reference.resource.id, data: data)
accountManager.resources.storeResourceData(id: EngineMediaResource.Id(reference.resource.id), data: data)
}
subscriber.putNext(next)
}, error: { _ in
@ -1257,13 +1257,13 @@ public func themeImage(account: Account, accountManager: AccountManager<Telegram
guard complete, let fullSizeData = fullSizeData else {
return .complete()
}
accountManager.mediaBox.storeResourceData(file.file.resource.id, data: fullSizeData)
accountManager.resources.storeResourceData(id: EngineMediaResource.Id(file.file.resource.id), data: fullSizeData)
let _ = accountManager.mediaBox.cachedResourceRepresentation(file.file.resource, representation: CachedScaledImageRepresentation(size: CGSize(width: 720.0, height: 720.0), mode: .aspectFit), complete: true, fetch: true).start()
if wallpaper.wallpaper.isPattern, !file.settings.colors.isEmpty, let intensity = file.settings.intensity {
return accountManager.mediaBox.resourceData(file.file.resource)
return accountManager.resources.data(resource: EngineMediaResource(file.file.resource))
|> mapToSignal { data in
if data.complete, let imageData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) {
if data.isComplete, let imageData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) {
return .single((theme, .pattern(data: imageData, colors: file.settings.colors, intensity: intensity), thumbnailData))
} else {
return .complete()
@ -1520,7 +1520,7 @@ public func themeIconImage(account: Account, accountManager: AccountManager<Tele
guard complete, let fullSizeData = fullSizeData else {
return .complete()
}
accountManager.mediaBox.storeResourceData(file.file.resource.id, data: fullSizeData)
accountManager.resources.storeResourceData(id: EngineMediaResource.Id(file.file.resource.id), data: fullSizeData)
let _ = accountManager.mediaBox.cachedResourceRepresentation(file.file.resource, representation: CachedScaledImageRepresentation(size: CGSize(width: 720.0, height: 720.0), mode: .aspectFit), complete: true, fetch: true).start()
if wallpaper.wallpaper.isPattern {