mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
Merge commit '5b013073bf'
# Conflicts: # CLAUDE.md # MODULE.bazel.lock # submodules/TgVoipWebrtc/tgcalls # third-party/td/build-td-bazel.sh # third-party/webrtc/webrtc
This commit is contained in:
commit
8dc06f48ce
1570 changed files with 133567 additions and 34276 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -78,4 +78,6 @@ xcode-files
|
|||
.bsp/**
|
||||
.sourcekit-lsp/**
|
||||
/.claude/
|
||||
**/.claude/settings.local.json
|
||||
**/.vscode/launch.json
|
||||
/buildbox/*
|
||||
|
|
|
|||
|
|
@ -22,12 +22,10 @@ internal:
|
|||
- export PATH=/opt/homebrew/opt/ruby/bin:$PATH
|
||||
- export PATH=`gem environment gemdir`/bin:$PATH
|
||||
- python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="build-system/appcenter-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=adhoc --configuration=release_arm64
|
||||
- python3 -u build-system/Make/DeployToFirebase.py --configuration="$TELEGRAM_PRIVATE_DATA_PATH/firebase-configurations/firebase-internal.json" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip"
|
||||
- python3 -u build-system/Make/DeployBuild.py --configuration="$TELEGRAM_PRIVATE_DATA_PATH/deploy-configurations/internal-configuration.json" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip"
|
||||
- rm -rf build-input/configuration-repository-workdir
|
||||
- rm -rf build-input/configuration-repository
|
||||
- python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="$TELEGRAM_PRIVATE_DATA_PATH/build-configurations/enterprise-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=enterprise --configuration=release_arm64
|
||||
- python3 -u build-system/Make/DeployToFirebase.py --configuration="$TELEGRAM_PRIVATE_DATA_PATH/firebase-configurations/firebase-enterprise.json" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip"
|
||||
- python3 -u build-system/Make/DeployBuild.py --configuration="$TELEGRAM_PRIVATE_DATA_PATH/deploy-configurations/enterprise-configuration.json" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip"
|
||||
environment:
|
||||
name: internal
|
||||
|
|
|
|||
149
CLAUDE.md
Normal file
149
CLAUDE.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to AI assistants when working with code in this repository.
|
||||
|
||||
## Build
|
||||
|
||||
The app is built using Bazel via the `Make.py` wrapper. There is no selective per-module build — the only supported invocation builds the full `Telegram/Telegram` target.
|
||||
|
||||
**Command:**
|
||||
|
||||
```sh
|
||||
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
|
||||
```
|
||||
|
||||
Add `--continueOnError` after `build` (forwards to bazel's `--keep_going`) when verifying changes that may surface errors in many files at once — it lets the full set of errors land in one pass instead of stopping at the first failing target.
|
||||
|
||||
The build needs `TELEGRAM_CODESIGNING_GIT_PASSWORD` in the environment. It is set in `~/.zshrc` but Claude Code's bash tool does NOT source shell config by default. Prefix build commands with `source ~/.zshrc 2>/dev/null;` to pick it up.
|
||||
|
||||
## Code Style Guidelines
|
||||
- **Naming**: PascalCase for types, camelCase for variables/methods
|
||||
- **Imports**: Group and sort imports at the top of files
|
||||
- **Error Handling**: Properly handle errors with appropriate redaction of sensitive data
|
||||
- **Formatting**: Use standard Swift/Objective-C formatting and spacing
|
||||
- **Types**: Prefer strong typing and explicit type annotations where needed
|
||||
- **Documentation**: Document public APIs with comments
|
||||
|
||||
## Project Structure
|
||||
- Core launch and application extensions code is in `Telegram/` directory
|
||||
- Most code is organized into libraries in `submodules/`
|
||||
- External code is located in `third-party/`
|
||||
- No tests are used at the moment
|
||||
|
||||
## Postbox → TelegramEngine refactor (in progress)
|
||||
|
||||
A gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`.
|
||||
|
||||
**Historical record:** Wave-by-wave outcomes, the running tally of Postbox-free modules, and full verbose forms of the guidance subsections below live in [`docs/superpowers/postbox-refactor-log.md`](docs/superpowers/postbox-refactor-log.md). Read that file when you need wave-specific context, a full worked example of a pattern, or the history of a particular module's migration.
|
||||
|
||||
Waves landed so far (as of 2026-04-24): 45 waves plus standalone cleanups. See the log file for per-wave detail; the list of still-open migration opportunities lives in the `project_postbox_refactor_next_wave.md` memory file.
|
||||
|
||||
### Rules that apply to every wave
|
||||
|
||||
1. `TelegramCore` does **not** `@_exported import Postbox`. Once a consumer drops `import Postbox`, every remaining Postbox-type reference must use an engine-typealiased equivalent.
|
||||
2. **Never typealias `Postbox`, `Account`, or `MediaBox`.** These umbrella types rename without encapsulating. Narrow utility typealiases (`MemoryBuffer`, `PostboxDecoder`, `PostboxEncoder`, `AdaptedPostboxDecoder`, `MediaResource`, …) remain allowed and expected.
|
||||
3. No new engine wrapper **structs** unless the wave's spec explicitly allows — only typealiases and thin forwarding methods.
|
||||
4. **Discovery first:** before adding any new engine wrapper/typealias, grep `submodules/TelegramCore/Sources/TelegramEngine/` for existing equivalents. Record the search result in the commit message.
|
||||
5. **Abandonment protocol:** if a module can only be refactored by violating rule 2 or by editing a module outside the current wave's list, mark the task Abandoned with a recorded reason. Do NOT substitute a new module mid-wave.
|
||||
6. Full project build per module. No unit tests exist in this project.
|
||||
7. **TelegramCore never imports UIKit/Display.** `TelegramCore` is shared with the Telegram-Mac codebase; its Bazel `deps` and source files must not reference UIKit, Display, or any Apple-UI framework. UIKit-needing helpers (image scaling, rendering, etc.) stay in consumer-side submodules.
|
||||
|
||||
### Engine typealias cheat sheet (existing aliases)
|
||||
|
||||
```
|
||||
PeerId → EnginePeer.Id
|
||||
MessageId → EngineMessage.Id
|
||||
MessageIndex → EngineMessage.Index
|
||||
MessageTags → EngineMessage.Tags
|
||||
MessageAttribute → EngineMessage.Attribute
|
||||
MessageFlags → EngineMessage.Flags
|
||||
MessageForwardInfo → EngineMessage.ForwardInfo
|
||||
MediaId → EngineMedia.Id
|
||||
PreferencesEntry → EnginePreferencesEntry
|
||||
TempBox → EngineTempBox
|
||||
PinnedItemId → EngineChatList.PinnedItem.Id
|
||||
MemoryBuffer → EngineMemoryBuffer (added 2026-04)
|
||||
PostboxDecoder → EnginePostboxDecoder (added 2026-04)
|
||||
PostboxEncoder → EnginePostboxEncoder (added 2026-04)
|
||||
AdaptedPostboxDecoder → EngineAdaptedPostboxDecoder (added 2026-04)
|
||||
ItemCollectionId → EngineItemCollectionId (added 2026-04-20)
|
||||
FetchResourceSourceType → EngineFetchResourceSourceType (added 2026-04-20)
|
||||
FetchResourceError → EngineFetchResourceError (added 2026-04-20)
|
||||
```
|
||||
|
||||
For the `MediaResource` Postbox protocol, prefer the TelegramCore subtype `TelegramMediaResource` when the consumer's usage allows (note: `EngineMediaResource` is a wrapper **class**, not a typealias, so it is not interchangeable with the protocol).
|
||||
|
||||
### MediaResource → EngineMediaResource consumer migration
|
||||
|
||||
`EngineMediaResource` is a `final class` in `TelegramCore` wrapping a `MediaResource` value. Unlike the typealiases above it is **not** interchangeable with the protocol, but it does provide wrap/unwrap helpers:
|
||||
|
||||
- `EngineMediaResource(rawResource)` — wrap a raw `MediaResource`.
|
||||
- `engineResource._asResource()` — unwrap to the raw `MediaResource`.
|
||||
- `EngineMediaResource.ResourceData(rawResourceData)` — wrap `MediaResourceData`.
|
||||
- `EngineMediaResource.Id(rawMediaResourceId)` — wrap `MediaResourceId`.
|
||||
|
||||
**Pattern for facade functions:** when a `TelegramEngine.<Area>` method leaks raw `MediaResource` in its public signature, **change the facade signature in place** to `EngineMediaResource` (and change any closure parameter types the same way). Bridge inside the facade body by calling the existing `_internal_*` function with `engineResource._asResource()` / wrapping raw inputs from inner closures with `EngineMediaResource(rawResource)`. Update all call sites in the same commit. The `_internal_*` function stays on raw `MediaResource` — it is the Postbox-facing layer.
|
||||
|
||||
Do **not** add opt-in `EngineMediaResource` overloads alongside raw-`MediaResource` overloads. Duplicate signatures fragment the public API and leave the leak in place forever.
|
||||
|
||||
For consumer modules, prefer `EngineMediaResource` as the type in properties, locals, generic arguments and function parameters when the usage is a pure type reference. Do **not** try to use `EngineMediaResource` where a class must conform to `TelegramMediaResource` (Postbox protocol) or override `isEqual(to: MediaResource)` — those remain `import Postbox`.
|
||||
|
||||
### Wave-selection guidance
|
||||
|
||||
Distilled lessons from waves 1–26. Each bullet below has a full-form counterpart in `postbox-refactor-log.md` (same subsection heading) with backstory, example scripts, and per-wave numbers.
|
||||
|
||||
**Shape selection.** The "leaf module, drop Postbox in isolation" approach (wave 1) only works when the candidate's public API doesn't leak Postbox domain types. Most candidates DO leak (`postbox: Postbox` / `account: Account` in public inits, `Media`/`Message` as public parameter types). Grep each candidate for `:\s*Postbox\b`, `:\s*Account\b`, `:\s*MediaBox\b`, and `Media`/`Message` as public parameter types before committing to a wave; abandon candidates whose public API leaks.
|
||||
|
||||
**Inventory at execution time, not just planning time.** Planning-time grep often undercounts. Re-inventory at Task-1 time using the full token set `\b(postbox|mediaBox|transaction|PostboxView|combinedView|MediaResource|PostboxDecoder|PostboxEncoder|MemoryBuffer)\b|^import Postbox` over the module's sources. If the count exceeds the plan, abandon before editing code rather than substituting a different module.
|
||||
|
||||
**Two feasible wave shapes.** Shape 1 = "per-module Postbox drop" (fragile; wave 1 lost 6 of 10 candidates). Shape 2 = "per-engine-facade-API migrate in place, update all call sites in one commit" (validated from wave 2 onward). Prefer shape 2 when the target is an API surface that multiple consumer modules depend on.
|
||||
|
||||
**Enum-payload migrations need full case-site grep.** When changing the payload type of a public enum, grep `case \.` / `let \.` / `\.<caseName>\(` across the enum's defining module — not just call sites of the facade that returns it. Wave 4 undercounted by 6 sites (shortcut constructions and destructures inside the same file as the facade) because the inventory only grepped facade callers.
|
||||
|
||||
**Unused-import sweeps** (wave-shape applied in waves 6, 14). Speculatively drop `^import Postbox$` from every candidate file, build with `--continueOnError`, extract failing files and restore their imports, iterate. After a few iterations, do pattern-based preemptive restores for files naming Postbox-only symbols (`MediaBox`, `PostboxCoding`, `PostboxDecoder`, `PostboxEncoder`, `TempBoxFile`, `ValueBoxKey`, `Postbox\b`, `PeerId`, `MessageId`, `MediaId`, `MessageIndex`, `MessageAndThreadId`, `PeerNameIndex`). Scope never leaves the consumer-module candidate set — halt if errors surface in TelegramCore / Postbox / TelegramApi. Run a matching BUILD-dep sweep immediately after (near-zero execution risk). Full methodology, scripts, and iteration-count history in the log.
|
||||
|
||||
**Public-Postbox-type inventory** (wave-11-pattern planning). Grep candidate modules against the full Postbox public-types allowlist, not just the pattern's target tokens. Waves before 16 missed types like `EngineMessageHistoryThread.Info` (Postbox-defined despite its "Engine" prefix) and `PeerStoryStats`. "Engine"-prefixed types can still be Postbox-defined — grep for the defining module, don't trust naming. Build allowlist with `grep -rhE "^public\s+(class|struct|enum|protocol|typealias)\s+\w+" submodules/Postbox/Sources/ | awk '{print $3}' | sed 's/[(:<].*//' | sort -u`, then grep candidates against it. Full script in the log.
|
||||
|
||||
**Wave-shape G: facade addition + consumer sweep in one commit** (validated across waves 19–26). Recipe:
|
||||
1. Target a `MediaBox` method whose Postbox signature uses clean leaf types (`MediaResourceId`, `Data`, `String`, `Bool`) and whose return type is either non-Postbox or has an existing `Engine*` wrapper.
|
||||
2. Pre-flight inventory: classify each call site as Shape A (`context.account.postbox.mediaBox.X(...)`, migratable), Shape B (different overload via `AccountContext`, migratable), Shape C (raw `account: Account` local, skip — needs per-module rework), Shape D (`self.postbox` stored field, skip). Also check for `accountManager.mediaBox.X(...)` — a separate migration path.
|
||||
3. Design facade with `EngineMediaResource.Id` or `EngineMediaResource` parameters and engine-or-clean return types; preserve default argument values.
|
||||
4. WIP-interference check: `git status --short | grep -v "^??"` — if any Shape-A site is in a WIP file, either skip those sites or wait.
|
||||
5. Name-collision check: if the facade signature names a Swift stdlib type with availability restrictions (`RangeSet`, iOS 18+), verify the third-party module import is present in `TelegramEngineResources.swift`.
|
||||
6. Batch duplicate call expressions with `replace_all=true`.
|
||||
7. Cheapness: 5–50 sites per wave, single atomic commit, expected first-pass-clean build. If post-migration grep for the migrated expression returns empty (excluding Shape C/D) and build is green, commit.
|
||||
|
||||
Full per-shape recipe and wave-specific examples in the log.
|
||||
|
||||
### TelegramEngine.Resources facade inventory (as of wave 32)
|
||||
|
||||
All mediaBox methods with clean signatures (no Postbox-protocol leaks, no complex return-type migrations) have been migrated to `TelegramEngine.Resources`. Quick reference for consumers — all of these live in `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`:
|
||||
|
||||
| Facade | Wave | Wraps |
|
||||
|---|---|---|
|
||||
| `fetch(reference:userLocation:userContentType:)` | 3 | `fetchedMediaResource` |
|
||||
| `status(resource:)` | 3 | `MediaBox.resourceStatus` (resource-based) |
|
||||
| `status(id:, resourceSize:)` | 32 | `MediaBox.resourceStatus(_ id:, resourceSize:)` |
|
||||
| `data(resource:, pathExtension:, waitUntilFetchStatus:)` | 3 | `MediaBox.resourceData` (resource-based) |
|
||||
| `data(id:, attemptSynchronously:)` | 3 | `MediaBox.resourceData` (id-based, defaults to `.complete(waitUntilFetchStatus: false)`) |
|
||||
| `custom(id:, fetch:, cacheTimeout:, attemptSynchronously:)` | pre-wave-21 | `MediaBox.customResourceData` |
|
||||
| `httpData(url:, preserveExactUrl:)` | pre-wave-21 | `fetchHttpResource` |
|
||||
| `shortLivedResourceCachePathPrefix(id:)` | 19 | `MediaBox.shortLivedResourceCachePathPrefix` |
|
||||
| `completedResourcePath(id:, pathExtension:)` | 21 | `MediaBox.completedResourcePath(id:, pathExtension:)` |
|
||||
| `storeResourceData(id:, data:, synchronous:)` | 22 | `MediaBox.storeResourceData(_ id:, data:, synchronous:)` |
|
||||
| `cancelInteractiveResourceFetch(id:)` | 23 | `MediaBox.cancelInteractiveResourceFetch(resourceId:)` |
|
||||
| `moveResourceData(id:, toTempPath:)` | 24 | `MediaBox.moveResourceData(_ id:, toTempPath:)` |
|
||||
| `moveResourceData(from:, to:, synchronous:)` | 24 | `MediaBox.moveResourceData(from:, to:, synchronous:)` |
|
||||
| `copyResourceData(id:, fromTempPath:)` | 25 | `MediaBox.copyResourceData(_ id:, fromTempPath:)` |
|
||||
| `copyResourceData(from:, to:, synchronous:)` | 25 | `MediaBox.copyResourceData(from:, to:, synchronous:)` |
|
||||
| `resourceRangesStatus(resource:)` | 26 | `MediaBox.resourceRangesStatus(_ resource:)` |
|
||||
| `removeCachedResources(ids:, force:, notify:)` | 26 | `MediaBox.removeCachedResources(_ ids:, force:, notify:)` |
|
||||
|
||||
**Facade-shape convention:** all of these take `EngineMediaResource.Id` or `EngineMediaResource` (never raw `MediaResourceId`/`MediaResource`). Return types either don't leak Postbox (`Void`, `String`, `String?`, `Signal<RangeSet<Int64>, NoError>`, `Signal<Float, NoError>`) or wrap via TelegramCore type (`Signal<EngineMediaResource.ResourceData, NoError>`).
|
||||
|
||||
**Swift-stdlib-vs-third-party-module name collisions** (learned in wave 26): `RangeSet<Int64>` collides with Swift stdlib's `RangeSet` (iOS 18+ only). Fix: `import RangeSet` at the file top of any TelegramCore file that names `RangeSet` in a signature. `TelegramCore/BUILD` already depends on `//submodules/Utils/RangeSet:RangeSet`. Future facade additions in TelegramEngineResources.swift should re-check this if new signature types are introduced.
|
||||
288
MODULE.bazel.lock
generated
288
MODULE.bazel.lock
generated
|
|
@ -13,7 +13,6 @@
|
|||
"https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1",
|
||||
"https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215",
|
||||
"https://bcr.bazel.build/modules/abseil-cpp/20250512.1/source.json": "d725d73707d01bb46ab3ca59ba408b8e9bd336642ca77a2269d4bfb8bbfd413d",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d",
|
||||
|
|
@ -23,12 +22,12 @@
|
|||
"https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.36.0/MODULE.bazel": "596cb62090b039caf1cad1d52a8bc35cf188ca9a4e279a828005e7ee49a1bec3",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.36.0/source.json": "279625cafa5b63cc0a8ee8448d93bc5ac1431f6000c50414051173fd22a6df3c",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.42.1/MODULE.bazel": "275a59b5406ff18c01739860aa70ad7ccb3cfb474579411decca11c93b951080",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.42.1/source.json": "fcd4396b2df85f64f2b3bb436ad870793ecf39180f1d796f913cc9276d355309",
|
||||
"https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e",
|
||||
|
|
@ -42,15 +41,11 @@
|
|||
"https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/source.json": "7ebaefba0b03efe59cac88ed5bbc67bcf59a3eff33af937345ede2a38b2d368a",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7",
|
||||
"https://bcr.bazel.build/modules/bazel_skylib/1.9.0/source.json": "7ad77c1e8c1b84222d9b3f3cae016a76639435744c19330b0b37c0a3c9da7dc0",
|
||||
"https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84",
|
||||
"https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.33.0/MODULE.bazel": "a13a0f279b462b784fb8dd52a4074526c4a2afe70e114c7d09066097a46b3350",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.34.0/MODULE.bazel": "abdd8ce4d70978933209db92e436deb3a8b737859e9354fb5fd11fb5c2004c8a",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.36.0/MODULE.bazel": "e375d5d6e9a6ca59b0cb38b0540bc9a05b6aa926d322f2de268ad267a2ee74c0",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.43.0/MODULE.bazel": "846e1fe396eefc0f9ddad2b33e9bd364dd993fc2f42a88e31590fe0b0eefa3f0",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.43.0/source.json": "021a77f6625906d9d176e2fa351175e842622a5d45989312f2ad4924aab72df6",
|
||||
"https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb",
|
||||
"https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4",
|
||||
"https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6",
|
||||
|
|
@ -80,12 +75,9 @@
|
|||
"https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df",
|
||||
"https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92",
|
||||
"https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e",
|
||||
"https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95",
|
||||
"https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0",
|
||||
"https://bcr.bazel.build/modules/protobuf/3.19.2/MODULE.bazel": "532ffe5f2186b69fdde039efe6df13ba726ff338c6bc82275ad433013fa10573",
|
||||
"https://bcr.bazel.build/modules/protobuf/3.19.6/MODULE.bazel": "9233edc5e1f2ee276a60de3eaa47ac4132302ef9643238f23128fea53ea12858",
|
||||
"https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42",
|
||||
"https://bcr.bazel.build/modules/protobuf/33.4/source.json": "555f8686b4c7d6b5ba731fbea13bf656b4bfd9a7ff629c1d9d3f6e1d6155de79",
|
||||
"https://bcr.bazel.build/modules/protobuf/34.0.bcr.1/MODULE.bazel": "74e541b0ba877813da786a11707d4e394433c157841d5111a36be0d44b907931",
|
||||
"https://bcr.bazel.build/modules/protobuf/34.0.bcr.1/source.json": "fc174b3d6215aa14197d1bd779f98bb72d9fd666ee5ec0d6bba6ae986baa4535",
|
||||
"https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e",
|
||||
"https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34",
|
||||
"https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/source.json": "6900fdc8a9e95866b8c0d4ad4aba4d4236317b5c1cd04c502df3f0d33afed680",
|
||||
|
|
@ -107,19 +99,13 @@
|
|||
"https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.2.15/MODULE.bazel": "6a0a4a75a57aa6dc888300d848053a58c6b12a29f89d4304e1c41448514ec6e8",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.2.15/source.json": "197965c6dcca5c98a9288f93849e2e1c69d622e71b0be8deb524e22d48c88e32",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.2.17/MODULE.bazel": "1849602c86cb60da8613d2de887f9566a6d354a6df6d7009f9d04a14402f9a84",
|
||||
"https://bcr.bazel.build/modules/rules_cc/0.2.17/source.json": "3832f45d145354049137c0090df04629d9c2b5493dc5c2bf46f1834040133a07",
|
||||
"https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6",
|
||||
"https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8",
|
||||
"https://bcr.bazel.build/modules/rules_go/0.41.0/MODULE.bazel": "55861d8e8bb0e62cbd2896f60ff303f62ffcb0eddb74ecb0e5c0cbe36fc292c8",
|
||||
"https://bcr.bazel.build/modules/rules_go/0.42.0/MODULE.bazel": "8cfa875b9aa8c6fce2b2e5925e73c1388173ea3c32a0db4d2b4804b453c14270",
|
||||
"https://bcr.bazel.build/modules/rules_go/0.46.0/MODULE.bazel": "3477df8bdcc49e698b9d25f734c4f3a9f5931ff34ee48a2c662be168f5f2d3fd",
|
||||
"https://bcr.bazel.build/modules/rules_go/0.50.1/MODULE.bazel": "b91a308dc5782bb0a8021ad4330c81fea5bda77f96b9e4c117b9b9c8f6665ee0",
|
||||
"https://bcr.bazel.build/modules/rules_go/0.60.0/MODULE.bazel": "4a57ff2ffc2a3570e3c5646575c5a4b07287e91bcdac5d1f72383d51502b48cb",
|
||||
"https://bcr.bazel.build/modules/rules_go/0.60.0/source.json": "1e21368c5e0c3013a110bd79a8fcff8ca46b5bcb2b561713a7273cbfcff7c464",
|
||||
"https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74",
|
||||
"https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86",
|
||||
"https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39",
|
||||
|
|
@ -156,26 +142,24 @@
|
|||
"https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06",
|
||||
"https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7",
|
||||
"https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483",
|
||||
"https://bcr.bazel.build/modules/rules_proto/6.0.0/MODULE.bazel": "b531d7f09f58dce456cd61b4579ce8c86b38544da75184eadaf0a7cb7966453f",
|
||||
"https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73",
|
||||
"https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2",
|
||||
"https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96",
|
||||
"https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e",
|
||||
"https://bcr.bazel.build/modules/rules_proto/7.0.2/source.json": "1e5e7260ae32ef4f2b52fd1d0de8d03b606a44c91b694d2f1afb1d3b28a48ce1",
|
||||
"https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f",
|
||||
"https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300",
|
||||
"https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382",
|
||||
"https://bcr.bazel.build/modules/rules_python/0.27.1/MODULE.bazel": "65dc875cc1a06c30d5bbdba7ab021fd9e551a6579e408a3943a61303e2228a53",
|
||||
"https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed",
|
||||
"https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58",
|
||||
"https://bcr.bazel.build/modules/rules_python/0.33.2/MODULE.bazel": "3e036c4ad8d804a4dad897d333d8dce200d943df4827cb849840055be8d2e937",
|
||||
"https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c",
|
||||
"https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7",
|
||||
"https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13",
|
||||
"https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8",
|
||||
"https://bcr.bazel.build/modules/rules_python/1.6.0/source.json": "e980f654cf66ec4928672f41fc66c4102b5ea54286acf4aecd23256c84211be6",
|
||||
"https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8",
|
||||
"https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32",
|
||||
"https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c",
|
||||
"https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b",
|
||||
"https://bcr.bazel.build/modules/rules_shell/0.3.0/source.json": "c55ed591aa5009401ddf80ded9762ac32c358d2517ee7820be981e2de9756cf3",
|
||||
"https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b",
|
||||
"https://bcr.bazel.build/modules/rules_shell/0.6.1/source.json": "20ec05cd5e592055e214b2da8ccb283c7f2a421ea0dc2acbf1aa792e11c03d0c",
|
||||
"https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8",
|
||||
"https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c",
|
||||
"https://bcr.bazel.build/modules/stardoc/0.5.6/MODULE.bazel": "c43dabc564990eeab55e25ed61c07a1aadafe9ece96a4efabb3f8bf9063b71ef",
|
||||
|
|
@ -183,11 +167,10 @@
|
|||
"https://bcr.bazel.build/modules/stardoc/0.7.1/MODULE.bazel": "3548faea4ee5dda5580f9af150e79d0f6aea934fc60c1cc50f4efdd9420759e7",
|
||||
"https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5",
|
||||
"https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216",
|
||||
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f",
|
||||
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/source.json": "5fba48bbe0ba48761f9e9f75f92876cafb5d07c0ce059cc7a8027416de94a05b",
|
||||
"https://bcr.bazel.build/modules/swift_argument_parser/1.7.0/MODULE.bazel": "40d4e44950e44973dcf8590bcee637591de196b5dbe3696d07eb342b74d53672",
|
||||
"https://bcr.bazel.build/modules/swift_argument_parser/1.7.0/source.json": "b9b952cba0c748083b9b891e6ac46d347c92d37e8a92ead96d2a54b966bacd87",
|
||||
"https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43",
|
||||
"https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0",
|
||||
"https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27",
|
||||
"https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca",
|
||||
"https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/source.json": "22bc55c47af97246cfc093d0acf683a7869377de362b5d1c552c2c2e16b7a806",
|
||||
"https://bcr.bazel.build/modules/zlib/1.3.1/MODULE.bazel": "751c9940dcfe869f5f7274e1295422a34623555916eb98c174c1e945594bf198"
|
||||
|
|
@ -258,10 +241,245 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"@@rules_python+//python/extensions:config.bzl%config": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "xaCns8Qt+8bJqVLy8r6nc/eL2AjEIX/vOdjqoh5xYac=",
|
||||
"usagesDigest": "ZVSXMAGpD+xzVNPuvF1IoLBkty7TROO0+akMapt1pAg=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
"generatedRepoSpecs": {
|
||||
"rules_python_internal": {
|
||||
"repoRuleId": "@@rules_python+//python/private:internal_config_repo.bzl%internal_config_repo",
|
||||
"attributes": {
|
||||
"transition_setting_generators": {},
|
||||
"transition_settings": []
|
||||
}
|
||||
},
|
||||
"pypi__build": {
|
||||
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
|
||||
"attributes": {
|
||||
"url": "https://files.pythonhosted.org/packages/e2/03/f3c8ba0a6b6e30d7d18c40faab90807c9bb5e9a1e3b2fe2008af624a9c97/build-1.2.1-py3-none-any.whl",
|
||||
"sha256": "75e10f767a433d9a86e50d83f418e83efc18ede923ee5ff7df93b6cb0306c5d4",
|
||||
"type": "zip",
|
||||
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
|
||||
}
|
||||
},
|
||||
"pypi__click": {
|
||||
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
|
||||
"attributes": {
|
||||
"url": "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl",
|
||||
"sha256": "ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28",
|
||||
"type": "zip",
|
||||
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
|
||||
}
|
||||
},
|
||||
"pypi__colorama": {
|
||||
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
|
||||
"attributes": {
|
||||
"url": "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl",
|
||||
"sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6",
|
||||
"type": "zip",
|
||||
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
|
||||
}
|
||||
},
|
||||
"pypi__importlib_metadata": {
|
||||
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
|
||||
"attributes": {
|
||||
"url": "https://files.pythonhosted.org/packages/2d/0a/679461c511447ffaf176567d5c496d1de27cbe34a87df6677d7171b2fbd4/importlib_metadata-7.1.0-py3-none-any.whl",
|
||||
"sha256": "30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570",
|
||||
"type": "zip",
|
||||
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
|
||||
}
|
||||
},
|
||||
"pypi__installer": {
|
||||
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
|
||||
"attributes": {
|
||||
"url": "https://files.pythonhosted.org/packages/e5/ca/1172b6638d52f2d6caa2dd262ec4c811ba59eee96d54a7701930726bce18/installer-0.7.0-py3-none-any.whl",
|
||||
"sha256": "05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53",
|
||||
"type": "zip",
|
||||
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
|
||||
}
|
||||
},
|
||||
"pypi__more_itertools": {
|
||||
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
|
||||
"attributes": {
|
||||
"url": "https://files.pythonhosted.org/packages/50/e2/8e10e465ee3987bb7c9ab69efb91d867d93959095f4807db102d07995d94/more_itertools-10.2.0-py3-none-any.whl",
|
||||
"sha256": "686b06abe565edfab151cb8fd385a05651e1fdf8f0a14191e4439283421f8684",
|
||||
"type": "zip",
|
||||
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
|
||||
}
|
||||
},
|
||||
"pypi__packaging": {
|
||||
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
|
||||
"attributes": {
|
||||
"url": "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl",
|
||||
"sha256": "2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5",
|
||||
"type": "zip",
|
||||
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
|
||||
}
|
||||
},
|
||||
"pypi__pep517": {
|
||||
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
|
||||
"attributes": {
|
||||
"url": "https://files.pythonhosted.org/packages/25/6e/ca4a5434eb0e502210f591b97537d322546e4833dcb4d470a48c375c5540/pep517-0.13.1-py3-none-any.whl",
|
||||
"sha256": "31b206f67165b3536dd577c5c3f1518e8fbaf38cbc57efff8369a392feff1721",
|
||||
"type": "zip",
|
||||
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
|
||||
}
|
||||
},
|
||||
"pypi__pip": {
|
||||
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
|
||||
"attributes": {
|
||||
"url": "https://files.pythonhosted.org/packages/8a/6a/19e9fe04fca059ccf770861c7d5721ab4c2aebc539889e97c7977528a53b/pip-24.0-py3-none-any.whl",
|
||||
"sha256": "ba0d021a166865d2265246961bec0152ff124de910c5cc39f1156ce3fa7c69dc",
|
||||
"type": "zip",
|
||||
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
|
||||
}
|
||||
},
|
||||
"pypi__pip_tools": {
|
||||
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
|
||||
"attributes": {
|
||||
"url": "https://files.pythonhosted.org/packages/0d/dc/38f4ce065e92c66f058ea7a368a9c5de4e702272b479c0992059f7693941/pip_tools-7.4.1-py3-none-any.whl",
|
||||
"sha256": "4c690e5fbae2f21e87843e89c26191f0d9454f362d8acdbd695716493ec8b3a9",
|
||||
"type": "zip",
|
||||
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
|
||||
}
|
||||
},
|
||||
"pypi__pyproject_hooks": {
|
||||
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
|
||||
"attributes": {
|
||||
"url": "https://files.pythonhosted.org/packages/ae/f3/431b9d5fe7d14af7a32340792ef43b8a714e7726f1d7b69cc4e8e7a3f1d7/pyproject_hooks-1.1.0-py3-none-any.whl",
|
||||
"sha256": "7ceeefe9aec63a1064c18d939bdc3adf2d8aa1988a510afec15151578b232aa2",
|
||||
"type": "zip",
|
||||
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
|
||||
}
|
||||
},
|
||||
"pypi__setuptools": {
|
||||
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
|
||||
"attributes": {
|
||||
"url": "https://files.pythonhosted.org/packages/90/99/158ad0609729111163fc1f674a5a42f2605371a4cf036d0441070e2f7455/setuptools-78.1.1-py3-none-any.whl",
|
||||
"sha256": "c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561",
|
||||
"type": "zip",
|
||||
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
|
||||
}
|
||||
},
|
||||
"pypi__tomli": {
|
||||
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
|
||||
"attributes": {
|
||||
"url": "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl",
|
||||
"sha256": "939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc",
|
||||
"type": "zip",
|
||||
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
|
||||
}
|
||||
},
|
||||
"pypi__wheel": {
|
||||
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
|
||||
"attributes": {
|
||||
"url": "https://files.pythonhosted.org/packages/7d/cd/d7460c9a869b16c3dd4e1e403cce337df165368c71d6af229a74699622ce/wheel-0.43.0-py3-none-any.whl",
|
||||
"sha256": "55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81",
|
||||
"type": "zip",
|
||||
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
|
||||
}
|
||||
},
|
||||
"pypi__zipp": {
|
||||
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
|
||||
"attributes": {
|
||||
"url": "https://files.pythonhosted.org/packages/da/55/a03fd7240714916507e1fcf7ae355bd9d9ed2e6db492595f1a67f61681be/zipp-3.18.2-py3-none-any.whl",
|
||||
"sha256": "dce197b859eb796242b0622af1b8beb0a722d52aa2f57133ead08edd5bf5374e",
|
||||
"type": "zip",
|
||||
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordedRepoMappingEntries": [
|
||||
[
|
||||
"rules_python+",
|
||||
"bazel_tools",
|
||||
"bazel_tools"
|
||||
],
|
||||
[
|
||||
"rules_python+",
|
||||
"pypi__build",
|
||||
"rules_python++config+pypi__build"
|
||||
],
|
||||
[
|
||||
"rules_python+",
|
||||
"pypi__click",
|
||||
"rules_python++config+pypi__click"
|
||||
],
|
||||
[
|
||||
"rules_python+",
|
||||
"pypi__colorama",
|
||||
"rules_python++config+pypi__colorama"
|
||||
],
|
||||
[
|
||||
"rules_python+",
|
||||
"pypi__importlib_metadata",
|
||||
"rules_python++config+pypi__importlib_metadata"
|
||||
],
|
||||
[
|
||||
"rules_python+",
|
||||
"pypi__installer",
|
||||
"rules_python++config+pypi__installer"
|
||||
],
|
||||
[
|
||||
"rules_python+",
|
||||
"pypi__more_itertools",
|
||||
"rules_python++config+pypi__more_itertools"
|
||||
],
|
||||
[
|
||||
"rules_python+",
|
||||
"pypi__packaging",
|
||||
"rules_python++config+pypi__packaging"
|
||||
],
|
||||
[
|
||||
"rules_python+",
|
||||
"pypi__pep517",
|
||||
"rules_python++config+pypi__pep517"
|
||||
],
|
||||
[
|
||||
"rules_python+",
|
||||
"pypi__pip",
|
||||
"rules_python++config+pypi__pip"
|
||||
],
|
||||
[
|
||||
"rules_python+",
|
||||
"pypi__pip_tools",
|
||||
"rules_python++config+pypi__pip_tools"
|
||||
],
|
||||
[
|
||||
"rules_python+",
|
||||
"pypi__pyproject_hooks",
|
||||
"rules_python++config+pypi__pyproject_hooks"
|
||||
],
|
||||
[
|
||||
"rules_python+",
|
||||
"pypi__setuptools",
|
||||
"rules_python++config+pypi__setuptools"
|
||||
],
|
||||
[
|
||||
"rules_python+",
|
||||
"pypi__tomli",
|
||||
"rules_python++config+pypi__tomli"
|
||||
],
|
||||
[
|
||||
"rules_python+",
|
||||
"pypi__wheel",
|
||||
"rules_python++config+pypi__wheel"
|
||||
],
|
||||
[
|
||||
"rules_python+",
|
||||
"pypi__zipp",
|
||||
"rules_python++config+pypi__zipp"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"@@rules_python+//python/uv:uv.bzl%uv": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "PmZM/pIkZKEDDL68TohlKJrWPYKL5VwUw3MA7kmm6fk=",
|
||||
"usagesDigest": "p80sy6cYQuWxx5jhV3fOTu+N9EyIUFG9+F7UC/nhXic=",
|
||||
"bzlTransitiveDigest": "N8SCcKcL6KnzBLApxvY2jR9vhXjA2VCBZMLZfY3sDRA=",
|
||||
"usagesDigest": "H8dQoNZcoqP+Mu0tHZTi4KHATzvNkM5ePuEqoQdklIU=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
06de25b179c80e59
|
||||
c27f02bf6e413fdc
|
||||
|
|
|
|||
|
|
@ -488,7 +488,7 @@ icloud_fragment = "" if not telegram_enable_icloud else """
|
|||
<string>iCloud.{telegram_bundle_id}</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>{telegram_team_id}.*</string>
|
||||
<string>{telegram_team_id}.{telegram_bundle_id}</string>
|
||||
<key>com.apple.developer.icloud-container-environment</key>
|
||||
<string>{telegram_icloud_environment}</string>
|
||||
""".format(
|
||||
|
|
@ -1635,7 +1635,6 @@ plist_fragment(
|
|||
</array>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>SFCompactRounded-Semibold.otf</string>
|
||||
<string>AremacFS-Regular.otf</string>
|
||||
<string>AremacFS-Semibold.otf</string>
|
||||
</array>
|
||||
|
|
|
|||
|
|
@ -2958,7 +2958,7 @@ extension Customoji {
|
|||
if let cg = (image as UIImage).cgImage { return cg }
|
||||
|
||||
var rendered: CGImage?
|
||||
let work = { rendered = renderCGImage(image as! UIImage) }
|
||||
let work = { rendered = renderCGImage(image) }
|
||||
if Thread.isMainThread {
|
||||
work()
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -815,7 +815,7 @@ class DefaultIntentHandler: INExtension, INSendMessageIntentHandling, INSearchFo
|
|||
|
||||
if let searchTerm = searchTerm {
|
||||
if !searchTerm.isEmpty {
|
||||
for renderedPeer in transaction.searchPeers(query: searchTerm) {
|
||||
for renderedPeer in transaction.searchPeers(query: searchTerm, predicate: nil) {
|
||||
if let peer = renderedPeer.peer, !(peer is TelegramSecretChat), !peer.isDeleted {
|
||||
peers.append(peer)
|
||||
}
|
||||
|
|
@ -988,7 +988,7 @@ private final class WidgetIntentHandler {
|
|||
|
||||
if let searchTerm = searchTerm {
|
||||
if !searchTerm.isEmpty {
|
||||
for renderedPeer in transaction.searchPeers(query: searchTerm) {
|
||||
for renderedPeer in transaction.searchPeers(query: searchTerm, predicate: nil) {
|
||||
if let peer = renderedPeer.peer, !(peer is TelegramSecretChat), !peer.isDeleted {
|
||||
peers.append(peer)
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 712 B |
Binary file not shown.
|
Before Width: | Height: | Size: 455 B |
Binary file not shown.
|
Before Width: | Height: | Size: 788 B |
|
|
@ -2067,6 +2067,7 @@
|
|||
"StickerPack.Share" = "Share";
|
||||
"StickerPack.Send" = "Send Sticker";
|
||||
"StickerPack.AddSticker" = "Add Sticker";
|
||||
"StickerPack.RemoveStickerSet" = "Remove Sticker Set";
|
||||
|
||||
"StickerPack.RemoveStickerCount_1" = "Remove 1 Sticker";
|
||||
"StickerPack.RemoveStickerCount_2" = "Remove 2 Stickers";
|
||||
|
|
@ -2706,6 +2707,7 @@ Unused sets are archived when you add more.";
|
|||
"Channel.AdminLog.AddMembers" = "Add Members";
|
||||
"Channel.AdminLog.SendPolls" = "Send Polls";
|
||||
"Channel.AdminLog.ManageTopics" = "Manage Topics";
|
||||
"Channel.AdminLog.SendReactions" = "Send Reactions";
|
||||
|
||||
"Channel.AdminLog.CanChangeInfo" = "Change Info";
|
||||
"Channel.AdminLog.CanSendMessages" = "Post Messages";
|
||||
|
|
@ -8771,6 +8773,7 @@ Sorry for the inconvenience.";
|
|||
"Premium.CurrentPlan" = "your current plan";
|
||||
"Premium.UpgradeFor" = "Upgrade for %@ / month";
|
||||
"Premium.UpgradeForAnnual" = "Upgrade for %@ / year";
|
||||
"Premium.UpgradeForBiannual" = "Upgrade for %@ / 2 years";
|
||||
|
||||
"ChatList.PremiumAnnualDiscountTitle" = "Telegram Premium with a discount of %@";
|
||||
"ChatList.PremiumAnnualDiscountText" = "Sign up for the annual payment plan for Telegram Premium now to get the discount.";
|
||||
|
|
@ -16153,6 +16156,41 @@ Error: %8$@";
|
|||
"TextProcessing.ResultBadge" = "Result";
|
||||
"TextProcessing.Translate.LanguageStyle" = "%1$@ (%2$@)";
|
||||
"TextProcessing.StyleTooltip" = "Select Style";
|
||||
"TextProcessing.AlertCreatorDeleteStyle.Title" = "Delete Style";
|
||||
"TextProcessing.AlertCreatorDeleteStyle.Text" = "Are you sure you want to delete this style? It will be removed for everyone who installed it.";
|
||||
"TextProcessing.AlertDeleteStyle.Title" = "Delete Style";
|
||||
"TextProcessing.AlertDeleteStyle.Text" = "Are you sure you want to delete this style?";
|
||||
"TextProcessing.StyleMenu.Edit" = "Edit Style";
|
||||
"TextProcessing.StyleMenu.Share" = "Share Style";
|
||||
"TextProcessing.StyleMenu.Delete" = "Delete Style";
|
||||
"TextProcessing.StyleMenu.ButtonClose" = "Close";
|
||||
"TextProcessing.StyleMenu.ButtonAdd" = "Add Style";
|
||||
"TextProcessing.StylePreview.ExampleHeader" = "EXAMPLE";
|
||||
"TextProcessing.StylePreview.ExampleHeaderRefresh" = "ANOTHER EXAMPLE";
|
||||
"TextProcessing.StylePreview.Subtitle" = "Add this style to instantly\nrewrite your messages.";
|
||||
"TextProcessing.StylePreview.Before" = "Before";
|
||||
"TextProcessing.StylePreview.After" = "After";
|
||||
"TextProcessing.AlertTooManyStyles.Title" = "Too Many Styles";
|
||||
"TextProcessing.AlertTooManyStyles.Text" = "Please delete some of your saved styles to create a new one.";
|
||||
"TextProcessing.ToastStyleCreated.Title" = "%@ style created!";
|
||||
"TextProcessing.ToastStyleCreated.Text" = "Press and hold a style to edit or share the link.";
|
||||
"TextProcessing.StyleList.Add" = "Add Style";
|
||||
"TextProcessing.StyleFooterAuthor" = "Style by [%@]()";
|
||||
"TextProcessing.StyleFooterUserCount_1" = "Used by 1 person";
|
||||
"TextProcessing.StyleFooterUserCount_any" = "Used by %d people";
|
||||
"TextProcessing.StyleFooterCreatedByFormat" = "%1$@. %2$@";
|
||||
"TextProcessing.StyleFooterCreatedBy" = "Created by [%@]()";
|
||||
"TextProcessing.StyleFooterCreatedBySimpleFormat" = "%@.";
|
||||
"TextProcessing.EditStyle.NamePlaceholder" = "Style Name (for example, \"Pirate\")";
|
||||
"TextProcessing.EditStyle.TextPlaceholder" = "Instructions (for example, \"Write like a swashbuckling pirate. Use arr, ye, matey, and talk about treasure, the sea, and rum\")";
|
||||
"TextProcessing.EditStyle.TitleCreate" = "New Style";
|
||||
"TextProcessing.EditStyle.TitleEdit" = "Edit Style";
|
||||
"TextProcessing.EditStyle.ActionCreate" = "Create";
|
||||
"TextProcessing.EditStyle.ActionEdit" = "Save";
|
||||
"TextProcessing.EditStyle.Delete" = "Delete Style";
|
||||
"TextProcessing.EditStyle.AddLink" = "Add a link to my account";
|
||||
"TextProcessing.ToastStyleAdded.Title" = "Style Added";
|
||||
"TextProcessing.ToastStyleAdded.Text" = "Tap 'AI' → '%@' when typing your next long message.";
|
||||
|
||||
"Bot.AlertCanNotCreateBots" = "%@ can't manage other bots.";
|
||||
|
||||
|
|
@ -16168,3 +16206,103 @@ Error: %8$@";
|
|||
"PeerInfo.UnofficialSecurityRisk" = "%@ uses an unofficial Telegram client – messages to this user may be less secure.";
|
||||
|
||||
"Gallery.Live" = "LIVE";
|
||||
|
||||
"CreatePoll.QuestionNeeded" = "Enter a question";
|
||||
"CreatePoll.OptionsNeeded" = "Add at least two options";
|
||||
"CreatePoll.OptionsNeededOne" = "Add at least one option";
|
||||
"CreatePoll.QuizCorrectOptionNeeded" = "Select a correct option";
|
||||
"CreatePoll.QuizCorrectOptionNeededMultiple" = "Select at least one correct option";
|
||||
"CreatePoll.QuizCountryNeeded" = "Select at least one country";
|
||||
|
||||
"Stars.Intro.Transaction.Commission.Title" = "%@ commission";
|
||||
|
||||
"Conversation.ViewPollStats" = "View Statistics";
|
||||
"PollStats.Title" = "Poll Stats";
|
||||
"PollStats.GraphHeader" = "VOTE TIMELINE";
|
||||
|
||||
"CreatePoll.RestrictToSubscribers" = "Restrict to Subscribers";
|
||||
"CreatePoll.RestrictToSubscribersInfo" = "Only subscribers who joined 24+ hours ago can vote";
|
||||
|
||||
"CreatePoll.LimitCountry" = "Limit by Country";
|
||||
"CreatePoll.LimitCountryInfo" = "Only users from selected countries can vote";
|
||||
"CreatePoll.AllowedCountries" = "Allowed Countries";
|
||||
"CreatePoll.AllowedCountries.Countries_1" = "%@ country";
|
||||
"CreatePoll.AllowedCountries.Countries_any" = "%@ countries";
|
||||
|
||||
"Chat.Poll.Restriction.Subscribers" = "Only subscribers of **%@** can vote.";
|
||||
"Chat.Poll.Restriction.Subscribers.TimeLimit" = "Only subscribers who joined more than **24 hours** ago can vote.";
|
||||
"Chat.Poll.Restriction.Country" = "Only users from %@ can vote.";
|
||||
"Chat.Poll.Restriction.SubscribersCountry" = "Only subscribers of **%@** from %@ can vote.";
|
||||
"Chat.Poll.Restriction.Country.CountriesDelimiter" = ", ";
|
||||
"Chat.Poll.Restriction.Country.CountriesLastDelimiter" = " and ";
|
||||
|
||||
"Conversation.MessageGuestChatForUser" = "for %@";
|
||||
|
||||
"Settings.About.PrivacyHelpEmpty" = "A few words about you.";
|
||||
"Settings.About.PrivacyHelpEveryone" = "Everyone can see your bio. [Change >]()";
|
||||
"Settings.About.PrivacyHelpContacts" = "Only your contacts can see your bio. [Change >]()";
|
||||
"Settings.About.PrivacyHelpNobody" = "Nobody can see your bio. [Change >]()";
|
||||
|
||||
"Settings.Birthday.PrivacyHelpEveryone" = "Everyone can see your birthday. [Change >]()";
|
||||
"Settings.Birthday.PrivacyHelpContacts" = "Only your contacts can see your birthday. [Change >]()";
|
||||
"Settings.Birthday.PrivacyHelpNobody" = "Nobody can see your birthday. [Change >]()";
|
||||
|
||||
"GroupPermission.NoSendReactions" = "no reactions";
|
||||
"Channel.BanUser.PermissionSendReactions" = "Send Reactions";
|
||||
|
||||
"Chat.AdminAction.ToastReactionsDeletedTitleSingle" = "Reaction Deleted";
|
||||
"Chat.AdminAction.ToastReactionsDeletedTextSingle" = "Reaction Deleted.";
|
||||
"Chat.AdminAction.ToastReactionsDeletedTextMultiple" = "Reactions Deleted.";
|
||||
"Chat.AdminAction.ToastMessagesAndReactionsDeletedText" = "Messages and reactions deleted.";
|
||||
|
||||
"Premium.SignUp.SignUpNewInfo" = "Get Telegram Premium for %@";
|
||||
"Premium.SignUp.SignUpNewInfo.Days_1" = "%@ day";
|
||||
"Premium.SignUp.SignUpNewInfo.Days_any" = "%@ days";
|
||||
"Premium.SignUp.SignUpNewInfoNone" = "Get Telegram Premium";
|
||||
|
||||
"Login.Fee.Support.NewText.Days_1" = "%@ day";
|
||||
"Login.Fee.Support.NewText.Days_any" = "%@ days";
|
||||
"Login.Fee.Support.NewText" = "Sign up for a %@ Telegram Premium subscription to help cover the SMS costs.";
|
||||
"Login.Fee.Support.NewTextNone" = "Sign up for Telegram Premium subscription to help cover the SMS costs.";
|
||||
|
||||
"Login.Fee.GetPremiumNone" = "Get Telegram Premium";
|
||||
"Login.Fee.GetPremiumForDays" = "Get Telegram Premium for %@";
|
||||
"Login.Fee.GetPremiumForDays.Days_1" = "%@ day";
|
||||
"Login.Fee.GetPremiumForDays.Days_any" = "%@ days";
|
||||
|
||||
"PeerInfo.DeleteReaction" = "Delete Reaction";
|
||||
"Chat.DeleteReactionInfo" = "Tap and hold to delete reaction.";
|
||||
|
||||
"Chat.AdminActionSheet.DeleteReactionTitle" = "Delete 1 Reaction";
|
||||
"Chat.AdminActionSheet.DeleteAllMessages" = "Delete All Messages";
|
||||
"Chat.AdminActionSheet.DeleteAllReactions" = "Delete All Reactions";
|
||||
|
||||
"Conversation.CalendarSearch.Title" = "Search";
|
||||
"Conversation.CalendarSearch.Done" = "Done";
|
||||
|
||||
"ScheduleMessage.SilentPosting.YouEnabled" = "You will receive a silent notification";
|
||||
"ScheduleMessage.SilentPosting.YouDisabled" = "You will be notified";
|
||||
"ScheduleMessage.SilentPosting.UserEnabled" = "%@ will receive a silent notification";
|
||||
"ScheduleMessage.SilentPosting.UserDisabled" = "%@ will be notified";
|
||||
"ScheduleMessage.SilentPosting.GroupEnabled" = "Members will receive a silent notification";
|
||||
"ScheduleMessage.SilentPosting.GroupDisabled" = "Members will be notified";
|
||||
"ScheduleMessage.SilentPosting.ChannelEnabled" = "Subscribers will receive a silent notification";
|
||||
"ScheduleMessage.SilentPosting.ChannelDisabled" = "Subscribers will be notified";
|
||||
|
||||
"Settings.ChatAutomation" = "Chat Automation";
|
||||
"Settings.ChatAutomationInfo" = "Add a bot to reply to messages on your behalf.";
|
||||
"Settings.ChatAutomationOff" = "Off";
|
||||
|
||||
"Chat.SendReactionRestricted" = "You cannot send reactions in this chat.";
|
||||
|
||||
"ChatbotSetup.BotInstalled" = "%@ now manages your account.";
|
||||
|
||||
"ChatbotSetup.SetupNotCompleted.Title" = "No Bot Added";
|
||||
"ChatbotSetup.SetupNotCompleted.Text" = "You haven’t added a bot to manage your account. Leave anyway?";
|
||||
"ChatbotSetup.SetupNotCompleted.Leave" = "Leave";
|
||||
|
||||
"Chat.SavedMessagesStatusViewAsChats" = "Tap to view as chats";
|
||||
"Chat.ToastVoiceMessageDeviceMuted" = "Device is muted.";
|
||||
|
||||
"VideoChat.StatusPeerJoined" = "%@ joined";
|
||||
"VideoChat.StatusPeerLeft" = "%@ left";
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class UITests: XCTestCase {
|
|||
}
|
||||
|
||||
func testSignUp() throws {
|
||||
deleteTestAccount(phone: "9996629999")
|
||||
deleteTestAccount(phone: "9996625296")
|
||||
app.launch()
|
||||
|
||||
// Welcome screen — tap Start Messaging
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ swift_library(
|
|||
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
|
||||
"//submodules/Display:Display",
|
||||
"//submodules/TelegramUI/Components/AnimationCache:AnimationCache",
|
||||
"//submodules/TelegramUI/Components/DCTAnimationCacheImpl:DCTAnimationCacheImpl",
|
||||
"//submodules/TelegramUI/Components/VideoAnimationCache:VideoAnimationCache",
|
||||
"//submodules/TelegramUI/Components/LottieAnimationCache:LottieAnimationCache",
|
||||
"//submodules/rlottie:RLottieBinding",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import UIKit
|
|||
|
||||
import Display
|
||||
import AnimationCache
|
||||
import DCTAnimationCacheImpl
|
||||
import SwiftSignalKit
|
||||
import VideoAnimationCache
|
||||
import LottieAnimationCache
|
||||
|
|
@ -50,7 +51,7 @@ public final class ViewController: UIViewController {
|
|||
let basePath = NSTemporaryDirectory() + "/animation-cache"
|
||||
let _ = try? FileManager.default.removeItem(atPath: basePath)
|
||||
let _ = try? FileManager.default.createDirectory(at: URL(fileURLWithPath: basePath), withIntermediateDirectories: true)
|
||||
self.cache = AnimationCacheImpl(basePath: basePath, allocateTempFile: {
|
||||
self.cache = DCTAnimationCacheImpl(basePath: basePath, allocateTempFile: {
|
||||
return basePath + "/\(Int64.random(in: 0 ... Int64.max))"
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -106,13 +106,7 @@ def decrypt_codesigning_directory_recursively(source_base_path, destination_base
|
|||
destination_path = destination_base_path + '/' + file_name
|
||||
allowed_file_extensions = ['.mobileprovision', '.cer', '.p12']
|
||||
if os.path.isfile(source_path) and any(source_path.endswith(ext) for ext in allowed_file_extensions):
|
||||
#print('Decrypting {} to {} with {}'.format(source_path, destination_path, password))
|
||||
os.system('ruby build-system/decrypt.rb "{password}" "{source_path}" "{destination_path}"'.format(
|
||||
password=password,
|
||||
source_path=source_path,
|
||||
destination_path=destination_path
|
||||
))
|
||||
#decrypt_match_data(source_path, destination_path, password)
|
||||
decrypt_match_data(source_path, destination_path, password)
|
||||
elif os.path.isdir(source_path):
|
||||
os.makedirs(destination_path, exist_ok=True)
|
||||
decrypt_codesigning_directory_recursively(source_path, destination_path, password)
|
||||
|
|
|
|||
|
|
@ -1,221 +1,293 @@
|
|||
import os
|
||||
import base64
|
||||
import subprocess
|
||||
import tempfile
|
||||
import hashlib
|
||||
|
||||
class EncryptionV1:
|
||||
ALGORITHM = 'aes-256-cbc'
|
||||
|
||||
def decrypt(self, encrypted_data, password, salt, hash_algorithm="MD5"):
|
||||
try:
|
||||
return self._decrypt_with_algorithm(encrypted_data, password, salt, hash_algorithm)
|
||||
except Exception as e:
|
||||
# Fallback to SHA256 if MD5 fails
|
||||
fallback_hash_algorithm = "SHA256"
|
||||
return self._decrypt_with_algorithm(encrypted_data, password, salt, fallback_hash_algorithm)
|
||||
# FIPS-197 AES S-box and inverse S-box.
|
||||
_SBOX = bytes.fromhex(
|
||||
"637c777bf26b6fc53001672bfed7ab76"
|
||||
"ca82c97dfa5947f0add4a2af9ca472c0"
|
||||
"b7fd9326363ff7cc34a5e5f171d83115"
|
||||
"04c723c31896059a071280e2eb27b275"
|
||||
"09832c1a1b6e5aa0523bd6b329e32f84"
|
||||
"53d100ed20fcb15b6acbbe394a4c58cf"
|
||||
"d0efaafb434d338545f9027f503c9fa8"
|
||||
"51a3408f929d38f5bcb6da2110fff3d2"
|
||||
"cd0c13ec5f974417c4a77e3d645d1973"
|
||||
"60814fdc222a908846eeb814de5e0bdb"
|
||||
"e0323a0a4906245cc2d3ac629195e479"
|
||||
"e7c8376d8dd54ea96c56f4ea657aae08"
|
||||
"ba78252e1ca6b4c6e8dd741f4bbd8b8a"
|
||||
"703eb5664803f60e613557b986c11d9e"
|
||||
"e1f8981169d98e949b1e87e9ce5528df"
|
||||
"8ca1890dbfe6426841992d0fb054bb16"
|
||||
)
|
||||
|
||||
def _decrypt_with_algorithm(self, encrypted_data, password, salt, hash_algorithm):
|
||||
"""
|
||||
Use openssl command-line tool to decrypt the data
|
||||
"""
|
||||
# Create a temporary file for the encrypted data (with salt prefix)
|
||||
with tempfile.NamedTemporaryFile(delete=False) as temp_in:
|
||||
# Prepare the data for openssl (add "Salted__" prefix + salt if not already there)
|
||||
if not encrypted_data.startswith(b"Salted__"):
|
||||
temp_in.write(b"Salted__" + salt + encrypted_data)
|
||||
_INV_SBOX = bytes.fromhex(
|
||||
"52096ad53036a538bf40a39e81f3d7fb"
|
||||
"7ce339829b2fff87348e4344c4dee9cb"
|
||||
"547b9432a6c2233dee4c950b42fac34e"
|
||||
"082ea16628d924b2765ba2496d8bd125"
|
||||
"72f8f66486689816d4a45ccc5d65b692"
|
||||
"6c704850fdedb9da5e154657a78d9d84"
|
||||
"90d8ab008cbcd30af7e45805b8b34506"
|
||||
"d02c1e8fca3f0f02c1afbd0301138a6b"
|
||||
"3a9111414f67dcea97f2cfcef0b4e673"
|
||||
"96ac7422e7ad3585e2f937e81c75df6e"
|
||||
"47f11a711d29c5896fb7620eaa18be1b"
|
||||
"fc563e4bc6d279209adbc0fe78cd5af4"
|
||||
"1fdda8338807c731b11210592780ec5f"
|
||||
"60517fa919b54a0d2de57a9f93c99cef"
|
||||
"a0e03b4dae2af5b0c8ebbb3c83539961"
|
||||
"172b047eba77d626e169146355210c7d"
|
||||
)
|
||||
|
||||
_RCON = bytes.fromhex("01020408102040801b36")
|
||||
|
||||
|
||||
def _xtime(a):
|
||||
return (((a << 1) ^ 0x1b) & 0xff) if (a & 0x80) else (a << 1)
|
||||
|
||||
|
||||
def _gf_mul(a, b):
|
||||
r = 0
|
||||
for _ in range(8):
|
||||
if b & 1:
|
||||
r ^= a
|
||||
b >>= 1
|
||||
a = _xtime(a)
|
||||
return r
|
||||
|
||||
|
||||
def _key_expansion_256(key):
|
||||
# AES-256: Nk=8, Nr=14, total 4 * (Nr + 1) = 60 words = 240 bytes.
|
||||
if len(key) != 32:
|
||||
raise ValueError("AES-256 key must be 32 bytes")
|
||||
w = bytearray(240)
|
||||
w[:32] = key
|
||||
i = 32
|
||||
while i < 240:
|
||||
t = bytearray(w[i - 4:i])
|
||||
if i % 32 == 0:
|
||||
t = bytearray([t[1], t[2], t[3], t[0]])
|
||||
for j in range(4):
|
||||
t[j] = _SBOX[t[j]]
|
||||
t[0] ^= _RCON[i // 32 - 1]
|
||||
elif i % 32 == 16:
|
||||
for j in range(4):
|
||||
t[j] = _SBOX[t[j]]
|
||||
for j in range(4):
|
||||
w[i + j] = w[i - 32 + j] ^ t[j]
|
||||
i += 4
|
||||
return [bytes(w[r * 16:(r + 1) * 16]) for r in range(15)]
|
||||
|
||||
|
||||
def _add_round_key(state, rk):
|
||||
return bytes(s ^ k for s, k in zip(state, rk))
|
||||
|
||||
|
||||
def _sub_bytes(state):
|
||||
return bytes(_SBOX[b] for b in state)
|
||||
|
||||
|
||||
def _inv_sub_bytes(state):
|
||||
return bytes(_INV_SBOX[b] for b in state)
|
||||
|
||||
|
||||
# Column-major state: state[r + 4 * c], r = 0..3 (row), c = 0..3 (column).
|
||||
def _shift_rows(state):
|
||||
s = bytearray(state)
|
||||
s[1], s[5], s[9], s[13] = s[5], s[9], s[13], s[1]
|
||||
s[2], s[6], s[10], s[14] = s[10], s[14], s[2], s[6]
|
||||
s[3], s[7], s[11], s[15] = s[15], s[3], s[7], s[11]
|
||||
return bytes(s)
|
||||
|
||||
|
||||
def _inv_shift_rows(state):
|
||||
s = bytearray(state)
|
||||
s[1], s[5], s[9], s[13] = s[13], s[1], s[5], s[9]
|
||||
s[2], s[6], s[10], s[14] = s[10], s[14], s[2], s[6]
|
||||
s[3], s[7], s[11], s[15] = s[7], s[11], s[15], s[3]
|
||||
return bytes(s)
|
||||
|
||||
|
||||
def _mix_columns(state):
|
||||
s = bytearray(16)
|
||||
for c in range(4):
|
||||
a0, a1, a2, a3 = state[4 * c], state[4 * c + 1], state[4 * c + 2], state[4 * c + 3]
|
||||
s[4 * c] = _xtime(a0) ^ (_xtime(a1) ^ a1) ^ a2 ^ a3
|
||||
s[4 * c + 1] = a0 ^ _xtime(a1) ^ (_xtime(a2) ^ a2) ^ a3
|
||||
s[4 * c + 2] = a0 ^ a1 ^ _xtime(a2) ^ (_xtime(a3) ^ a3)
|
||||
s[4 * c + 3] = (_xtime(a0) ^ a0) ^ a1 ^ a2 ^ _xtime(a3)
|
||||
return bytes(s)
|
||||
|
||||
|
||||
def _inv_mix_columns(state):
|
||||
s = bytearray(16)
|
||||
for c in range(4):
|
||||
a0, a1, a2, a3 = state[4 * c], state[4 * c + 1], state[4 * c + 2], state[4 * c + 3]
|
||||
s[4 * c] = _gf_mul(a0, 0x0e) ^ _gf_mul(a1, 0x0b) ^ _gf_mul(a2, 0x0d) ^ _gf_mul(a3, 0x09)
|
||||
s[4 * c + 1] = _gf_mul(a0, 0x09) ^ _gf_mul(a1, 0x0e) ^ _gf_mul(a2, 0x0b) ^ _gf_mul(a3, 0x0d)
|
||||
s[4 * c + 2] = _gf_mul(a0, 0x0d) ^ _gf_mul(a1, 0x09) ^ _gf_mul(a2, 0x0e) ^ _gf_mul(a3, 0x0b)
|
||||
s[4 * c + 3] = _gf_mul(a0, 0x0b) ^ _gf_mul(a1, 0x0d) ^ _gf_mul(a2, 0x09) ^ _gf_mul(a3, 0x0e)
|
||||
return bytes(s)
|
||||
|
||||
|
||||
def _aes_encrypt_block(block, round_keys):
|
||||
state = _add_round_key(block, round_keys[0])
|
||||
for r in range(1, 14):
|
||||
state = _sub_bytes(state)
|
||||
state = _shift_rows(state)
|
||||
state = _mix_columns(state)
|
||||
state = _add_round_key(state, round_keys[r])
|
||||
state = _sub_bytes(state)
|
||||
state = _shift_rows(state)
|
||||
state = _add_round_key(state, round_keys[14])
|
||||
return state
|
||||
|
||||
|
||||
def _aes_decrypt_block(block, round_keys):
|
||||
state = _add_round_key(block, round_keys[14])
|
||||
for r in range(13, 0, -1):
|
||||
state = _inv_shift_rows(state)
|
||||
state = _inv_sub_bytes(state)
|
||||
state = _add_round_key(state, round_keys[r])
|
||||
state = _inv_mix_columns(state)
|
||||
state = _inv_shift_rows(state)
|
||||
state = _inv_sub_bytes(state)
|
||||
state = _add_round_key(state, round_keys[0])
|
||||
return state
|
||||
|
||||
|
||||
def _evp_bytes_to_key(password, salt, hash_name, key_len=32, iv_len=16):
|
||||
# OpenSSL EVP_BytesToKey with count=1, matching Ruby's
|
||||
# Cipher#pkcs5_keyivgen(password, salt, 1, hash).
|
||||
if isinstance(password, str):
|
||||
password = password.encode('utf-8')
|
||||
required = key_len + iv_len
|
||||
material = b""
|
||||
prev = b""
|
||||
while len(material) < required:
|
||||
h = hashlib.new(hash_name)
|
||||
h.update(prev + password + salt)
|
||||
prev = h.digest()
|
||||
material += prev
|
||||
return material[:key_len], material[key_len:key_len + iv_len]
|
||||
|
||||
|
||||
def _aes_cbc_decrypt(ciphertext, key, iv):
|
||||
if len(ciphertext) == 0 or len(ciphertext) % 16 != 0:
|
||||
raise ValueError("V1 ciphertext length must be a non-zero multiple of 16")
|
||||
round_keys = _key_expansion_256(key)
|
||||
out = bytearray()
|
||||
prev = iv
|
||||
for i in range(0, len(ciphertext), 16):
|
||||
block = ciphertext[i:i + 16]
|
||||
decrypted = _aes_decrypt_block(block, round_keys)
|
||||
out.extend(bytes(d ^ p for d, p in zip(decrypted, prev)))
|
||||
prev = block
|
||||
pad = out[-1]
|
||||
if pad < 1 or pad > 16 or not all(b == pad for b in out[-pad:]):
|
||||
raise ValueError("V1 PKCS#7 padding check failed")
|
||||
return bytes(out[:-pad])
|
||||
|
||||
|
||||
def _ghash(h_bytes, data):
|
||||
# GHASH over GF(2^128) with reduction polynomial x^128 + x^7 + x^2 + x + 1,
|
||||
# using GCM's bit-reversed convention (top-bit-first when encoded as bytes).
|
||||
h = int.from_bytes(h_bytes, 'big')
|
||||
y = 0
|
||||
reduction = 0xe1 << 120
|
||||
for i in range(0, len(data), 16):
|
||||
block = data[i:i + 16].ljust(16, b"\x00")
|
||||
y ^= int.from_bytes(block, 'big')
|
||||
z = 0
|
||||
v = y
|
||||
for bit in range(127, -1, -1):
|
||||
if (h >> bit) & 1:
|
||||
z ^= v
|
||||
if v & 1:
|
||||
v = (v >> 1) ^ reduction
|
||||
else:
|
||||
temp_in.write(encrypted_data)
|
||||
temp_in_path = temp_in.name
|
||||
|
||||
# Create a temporary file for the decrypted output
|
||||
temp_out_fd, temp_out_path = tempfile.mkstemp()
|
||||
os.close(temp_out_fd)
|
||||
|
||||
v >>= 1
|
||||
y = z
|
||||
return y.to_bytes(16, 'big')
|
||||
|
||||
|
||||
def _aes_gcm_decrypt(ciphertext, key, iv, aad, auth_tag):
|
||||
if len(iv) != 12:
|
||||
raise ValueError("V2 requires a 96-bit IV")
|
||||
round_keys = _key_expansion_256(key)
|
||||
H = _aes_encrypt_block(b"\x00" * 16, round_keys)
|
||||
j0 = iv + b"\x00\x00\x00\x01"
|
||||
|
||||
plaintext = bytearray()
|
||||
j0_int = int.from_bytes(j0, 'big')
|
||||
mask32 = (1 << 32) - 1
|
||||
counter_high = j0_int & ~mask32
|
||||
counter_low = j0_int & mask32
|
||||
n_blocks = (len(ciphertext) + 15) // 16
|
||||
for i in range(n_blocks):
|
||||
counter_low = (counter_low + 1) & mask32
|
||||
ctr_bytes = (counter_high | counter_low).to_bytes(16, 'big')
|
||||
keystream = _aes_encrypt_block(ctr_bytes, round_keys)
|
||||
block = ciphertext[i * 16:(i + 1) * 16]
|
||||
plaintext.extend(bytes(c ^ k for c, k in zip(block, keystream[:len(block)])))
|
||||
|
||||
aad_pad = b"\x00" * ((16 - len(aad) % 16) % 16)
|
||||
ct_pad = b"\x00" * ((16 - len(ciphertext) % 16) % 16)
|
||||
length_block = (len(aad) * 8).to_bytes(8, 'big') + (len(ciphertext) * 8).to_bytes(8, 'big')
|
||||
s = _ghash(H, aad + aad_pad + ciphertext + ct_pad + length_block)
|
||||
e_j0 = _aes_encrypt_block(j0, round_keys)
|
||||
computed_tag = bytes(a ^ b for a, b in zip(s, e_j0))
|
||||
if computed_tag != auth_tag:
|
||||
raise ValueError("V2 GCM auth tag mismatch")
|
||||
return bytes(plaintext)
|
||||
|
||||
|
||||
_V1_PREFIX = b"Salted__"
|
||||
_V2_PREFIX = b"match_encrypted_v2__"
|
||||
|
||||
|
||||
def _decrypt_stored(stored_data, password):
|
||||
if stored_data.startswith(_V2_PREFIX):
|
||||
salt = stored_data[20:28]
|
||||
auth_tag = stored_data[28:44]
|
||||
ciphertext = stored_data[44:]
|
||||
material = hashlib.pbkdf2_hmac(
|
||||
'sha256',
|
||||
password.encode('utf-8'),
|
||||
salt,
|
||||
10_000,
|
||||
dklen=32 + 12 + 24,
|
||||
)
|
||||
key = material[0:32]
|
||||
iv = material[32:44]
|
||||
aad = material[44:68]
|
||||
return _aes_gcm_decrypt(ciphertext, key, iv, aad, auth_tag)
|
||||
if stored_data.startswith(_V1_PREFIX):
|
||||
salt = stored_data[8:16]
|
||||
ciphertext = stored_data[16:]
|
||||
try:
|
||||
# Set the hash algorithm flag for openssl
|
||||
md_flag = "-md md5" if hash_algorithm == "MD5" else "-md sha256"
|
||||
|
||||
# Run openssl command
|
||||
command = f"openssl enc -d -aes-256-cbc {md_flag} -in {temp_in_path} -out {temp_out_path} -pass pass:{password}"
|
||||
result = subprocess.run(command, shell=True, check=True, stderr=subprocess.PIPE)
|
||||
|
||||
# Read the decrypted data
|
||||
with open(temp_out_path, 'rb') as f:
|
||||
decrypted_data = f.read()
|
||||
|
||||
return decrypted_data
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise ValueError(f"OpenSSL decryption failed: {e.stderr.decode()}")
|
||||
finally:
|
||||
# Clean up temporary files
|
||||
if os.path.exists(temp_in_path):
|
||||
os.unlink(temp_in_path)
|
||||
if os.path.exists(temp_out_path):
|
||||
os.unlink(temp_out_path)
|
||||
key, iv = _evp_bytes_to_key(password, salt, 'md5', 32, 16)
|
||||
return _aes_cbc_decrypt(ciphertext, key, iv)
|
||||
except ValueError:
|
||||
key, iv = _evp_bytes_to_key(password, salt, 'sha256', 32, 16)
|
||||
return _aes_cbc_decrypt(ciphertext, key, iv)
|
||||
raise ValueError("Unrecognized fastlane match payload (missing V1 'Salted__' or V2 'match_encrypted_v2__' prefix)")
|
||||
|
||||
class EncryptionV2:
|
||||
ALGORITHM = 'aes-256-gcm'
|
||||
|
||||
def decrypt(self, encrypted_data, password, salt, auth_tag):
|
||||
# Initialize variables for cleanup
|
||||
temp_in_path = None
|
||||
temp_out_path = None
|
||||
|
||||
try:
|
||||
# Create temporary files for input, output
|
||||
with tempfile.NamedTemporaryFile(delete=False) as temp_in:
|
||||
temp_in.write(encrypted_data)
|
||||
temp_in_path = temp_in.name
|
||||
|
||||
temp_out_fd, temp_out_path = tempfile.mkstemp()
|
||||
os.close(temp_out_fd)
|
||||
|
||||
# Use Python's built-in PBKDF2 implementation
|
||||
key_material = hashlib.pbkdf2_hmac(
|
||||
'sha256',
|
||||
password.encode('utf-8'),
|
||||
salt,
|
||||
10000,
|
||||
dklen=68
|
||||
)
|
||||
|
||||
key = key_material[0:32]
|
||||
iv = key_material[32:44]
|
||||
auth_data = key_material[44:68]
|
||||
|
||||
# For newer versions of openssl that support GCM, we could use:
|
||||
# decrypt_cmd = (
|
||||
# f"openssl enc -aes-256-gcm -d -K {key.hex()} -iv {iv.hex()} "
|
||||
# f"-in {temp_in_path} -out {temp_out_path}"
|
||||
# )
|
||||
|
||||
# But since GCM is complex with auth tags, we'll fall back to a simpler approach
|
||||
# using a temporary file with the encrypted data for the test case
|
||||
# In a real implementation, we would need to properly implement GCM with auth tags
|
||||
|
||||
with open(temp_out_path, 'wb') as f:
|
||||
# Since we're in a test function, write some placeholder data
|
||||
# that the test can still use
|
||||
f.write(b"TEST_DECRYPTED_CONTENT")
|
||||
|
||||
# Read decrypted data
|
||||
with open(temp_out_path, 'rb') as f:
|
||||
decrypted_data = f.read()
|
||||
|
||||
return decrypted_data
|
||||
except Exception as e:
|
||||
raise ValueError(f"GCM decryption failed: {str(e)}")
|
||||
finally:
|
||||
# Clean up temporary files
|
||||
if temp_in_path and os.path.exists(temp_in_path):
|
||||
os.unlink(temp_in_path)
|
||||
if temp_out_path and os.path.exists(temp_out_path):
|
||||
os.unlink(temp_out_path)
|
||||
|
||||
class MatchDataEncryption:
|
||||
V1_PREFIX = b"Salted__"
|
||||
V2_PREFIX = b"match_encrypted_v2__"
|
||||
|
||||
def decrypt(self, base64encoded_encrypted, password):
|
||||
try:
|
||||
stored_data = base64.b64decode(base64encoded_encrypted)
|
||||
|
||||
if stored_data.startswith(self.V2_PREFIX):
|
||||
# V2 format
|
||||
salt = stored_data[20:28]
|
||||
auth_tag = stored_data[28:44]
|
||||
data_to_decrypt = stored_data[44:]
|
||||
|
||||
e = EncryptionV2()
|
||||
return e.decrypt(encrypted_data=data_to_decrypt, password=password, salt=salt, auth_tag=auth_tag)
|
||||
else:
|
||||
# V1 format
|
||||
salt = stored_data[8:16]
|
||||
data_to_decrypt = stored_data[16:]
|
||||
|
||||
e = EncryptionV1()
|
||||
try:
|
||||
# Try with MD5 hash first
|
||||
return e.decrypt(encrypted_data=data_to_decrypt, password=password, salt=salt)
|
||||
except Exception:
|
||||
# Fall back to SHA256 if MD5 fails
|
||||
fallback_hash_algorithm = "SHA256"
|
||||
return e.decrypt(encrypted_data=data_to_decrypt, password=password, salt=salt, hash_algorithm=fallback_hash_algorithm)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Decryption failed: {str(e)}")
|
||||
|
||||
def decrypt_match_data(source_path: str, destination_path: str, password: str):
|
||||
"""
|
||||
Decrypt a file encrypted by fastlane match
|
||||
|
||||
Args:
|
||||
source_path: Path to the encrypted file
|
||||
destination_path: Path where to save the decrypted file
|
||||
password: Decryption password
|
||||
"""
|
||||
try:
|
||||
# Read the file
|
||||
with open(source_path, 'rb') as f:
|
||||
content_bytes = f.read()
|
||||
|
||||
# Check if content is binary or base64 text
|
||||
try:
|
||||
# Try to decode as UTF-8 to see if it's text
|
||||
content = content_bytes.decode('utf-8').strip()
|
||||
except UnicodeDecodeError:
|
||||
# If it's binary, encode it as base64 for our algorithm
|
||||
content = base64.b64encode(content_bytes).decode('utf-8')
|
||||
|
||||
# Decrypt the content
|
||||
encryption = MatchDataEncryption()
|
||||
decrypted_data = encryption.decrypt(content, password)
|
||||
|
||||
# Write the decrypted data to the destination file
|
||||
with open(destination_path, 'wb') as f:
|
||||
f.write(decrypted_data)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Decryption process failed: {str(e)}")
|
||||
|
||||
def test_decrypt_match_data():
|
||||
profile_name = 'Development_ph.telegra.Telegraph.mobileprovision'
|
||||
source_path = os.path.expanduser('~/build/telegram/telegram-ios/build-input/configuration-repository-workdir/encrypted/profiles/development/{}'.format(profile_name))
|
||||
destination_path = os.path.expanduser('~/build/telegram/telegram-ios/build-input/configuration-repository-workdir/decrypted/profiles/development/{}'.format(profile_name))
|
||||
compare_destination_path = os.path.expanduser('~/build/telegram/telegram-ios/build-input/configuration-repository-workdir/decrypted/profiles/development/{}'.format(profile_name))
|
||||
password = 'sluchainost'
|
||||
|
||||
# Remove the destination file if it exists
|
||||
if os.path.exists(destination_path):
|
||||
os.remove(destination_path)
|
||||
|
||||
if not os.path.exists(source_path):
|
||||
print("Failed (source file does not exist)")
|
||||
return
|
||||
|
||||
try:
|
||||
# Try to decrypt the file
|
||||
decrypt_match_data(
|
||||
source_path=source_path,
|
||||
destination_path=destination_path,
|
||||
password=password
|
||||
)
|
||||
|
||||
if not os.path.exists(destination_path):
|
||||
print("Failed (file was not created)")
|
||||
elif not os.path.exists(compare_destination_path):
|
||||
print("Cannot compare (reference file doesn't exist)")
|
||||
if os.path.getsize(destination_path) > 0:
|
||||
print("But decryption produced a non-empty file of size:", os.path.getsize(destination_path))
|
||||
print("Assuming the test passed")
|
||||
else:
|
||||
with open(destination_path, 'rb') as f1, open(compare_destination_path, 'rb') as f2:
|
||||
if f1.read() == f2.read():
|
||||
print("Passed")
|
||||
else:
|
||||
print("Failed (content is different)")
|
||||
except Exception as e:
|
||||
print(f"Error during decryption: {str(e)}")
|
||||
with open(source_path, 'rb') as f:
|
||||
raw = f.read()
|
||||
stored_data = base64.b64decode(raw)
|
||||
decrypted = _decrypt_stored(stored_data, password)
|
||||
with open(destination_path, 'wb') as f:
|
||||
f.write(decrypted)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_decrypt_match_data()
|
||||
import sys
|
||||
if len(sys.argv) != 4:
|
||||
print('Usage: DecryptMatch.py <password> <source_path> <destination_path>')
|
||||
sys.exit(1)
|
||||
decrypt_match_data(source_path=sys.argv[2], destination_path=sys.argv[3], password=sys.argv[1])
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import sys
|
|||
import json
|
||||
import hashlib
|
||||
import base64
|
||||
import time
|
||||
import requests
|
||||
|
||||
def sha256_file(path):
|
||||
|
|
@ -26,16 +27,55 @@ def init_build(host, token, files, channel):
|
|||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
class ProgressFileReader:
|
||||
def __init__(self, path, size):
|
||||
self._file = open(path, 'rb')
|
||||
self._size = size
|
||||
self._sent = 0
|
||||
self._start_time = time.time()
|
||||
self._last_print = self._start_time
|
||||
|
||||
def __len__(self):
|
||||
return self._size
|
||||
|
||||
def read(self, chunk_size=-1):
|
||||
if chunk_size == -1:
|
||||
chunk_size = self._size
|
||||
data = self._file.read(chunk_size)
|
||||
if not data:
|
||||
elapsed = time.time() - self._start_time
|
||||
speed = self._size / elapsed / 1024 / 1024 if elapsed > 0 else 0
|
||||
print(' 100% - all bytes sent in {:.1f}s ({:.2f} MB/s), waiting for server response...'.format(elapsed, speed))
|
||||
return data
|
||||
self._sent += len(data)
|
||||
now = time.time()
|
||||
if now - self._last_print >= 5:
|
||||
elapsed = now - self._start_time
|
||||
speed = self._sent / elapsed / 1024 / 1024 if elapsed > 0 else 0
|
||||
print(' {:.1f}% ({:.1f} / {:.1f} MB) {:.2f} MB/s'.format(
|
||||
self._sent * 100 / self._size, self._sent / 1024 / 1024, self._size / 1024 / 1024, speed))
|
||||
self._last_print = now
|
||||
return data
|
||||
|
||||
def close(self):
|
||||
self._file.close()
|
||||
|
||||
def upload_file(path, upload_info):
|
||||
url = upload_info.get('url')
|
||||
headers = dict(upload_info.get('headers', {}))
|
||||
|
||||
|
||||
size = os.path.getsize(path)
|
||||
headers['Content-Length'] = str(size)
|
||||
|
||||
print('Uploading', path)
|
||||
with open(path, 'rb') as f:
|
||||
r = requests.put(url, data=f, headers=headers, timeout=900)
|
||||
print('Uploading {} ({:.1f} MB)'.format(path, size / 1024 / 1024))
|
||||
start_time = time.time()
|
||||
|
||||
body = ProgressFileReader(path, size)
|
||||
try:
|
||||
r = requests.put(url, data=body, headers=headers, timeout=900)
|
||||
finally:
|
||||
body.close()
|
||||
print(' Server responded: {} ({:.1f}s total)'.format(r.status_code, time.time() - start_time))
|
||||
if r.status_code != 200:
|
||||
print('Upload failed', r.status_code)
|
||||
print(r.text[:500])
|
||||
|
|
|
|||
|
|
@ -191,6 +191,8 @@ def remote_deploy_testflight(darwin_containers_path, darwin_containers_host, mac
|
|||
if configuration_dict['xcode'] is None:
|
||||
raise Exception('Missing xcode version in {}'.format(configuration_path))
|
||||
xcode_version = configuration_dict['xcode']
|
||||
if configuration_dict['deploy_xcode'] is not None:
|
||||
xcode_version = configuration_dict['deploy_xcode']
|
||||
|
||||
print('Xcode version: {}'.format(xcode_version))
|
||||
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ private struct CodeWriter {
|
|||
}
|
||||
}*/
|
||||
|
||||
private func typeReferenceRepresentation(_ type: Resolver.TypeReference) -> String {
|
||||
private func typeReferenceRepresentation(_ apiPrefix: String, _ type: Resolver.TypeReference) -> String {
|
||||
switch type {
|
||||
case .int32:
|
||||
return "Int32"
|
||||
|
|
@ -106,13 +106,13 @@ private func typeReferenceRepresentation(_ type: Resolver.TypeReference) -> Stri
|
|||
case .boolTrue:
|
||||
return "bool"
|
||||
case let .bareVector(elementType):
|
||||
return "[\(typeReferenceRepresentation(elementType))]"
|
||||
return "[\(typeReferenceRepresentation(apiPrefix, elementType))]"
|
||||
case let .boxedVector(elementType):
|
||||
return "[\(typeReferenceRepresentation(elementType))]"
|
||||
return "[\(typeReferenceRepresentation(apiPrefix, elementType))]"
|
||||
case let .bareConstructor(typeName, _):
|
||||
return "Api.\(typeName)"
|
||||
return "\(apiPrefix).\(typeName)"
|
||||
case let .boxedType(typeName):
|
||||
return "Api.\(typeName)"
|
||||
return "\(apiPrefix).\(typeName)"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,22 +184,340 @@ enum CodeGenerator {
|
|||
}
|
||||
}
|
||||
|
||||
static func generate(types: [Resolver.SumType], functions: [Resolver.Function], constructorOrder: [(typeName: QualifiedName, constructorName: String)], typeOrder: [(types: [(typeName: QualifiedName, constructorNames: [String])], functions: [QualifiedName])], stubFunctions: Bool = false) throws -> [String: String] {
|
||||
static func generate(apiPrefix: String, types: [Resolver.SumType], functions: [Resolver.Function], constructorOrder: [(typeName: QualifiedName, constructorName: String)], typeOrder: [(types: [(typeName: QualifiedName, constructorNames: [String])], functions: [QualifiedName])]) throws -> [String: String] {
|
||||
var files: [String: String] = [:]
|
||||
|
||||
var functions = functions
|
||||
functions.append(Resolver.Function(name: QualifiedName(namespace: "help", value: "test"), id: UInt32(bitPattern: -1058929929), arguments: [], result: .boxedType(QualifiedName(namespace: nil, value: "Bool"))))
|
||||
|
||||
files["Api0.swift"] = try generateMainFile(types: types, functions: functions, constructorOrder: constructorOrder)
|
||||
files["\(apiPrefix)0.swift"] = try generateMainFile(apiPrefix: apiPrefix, types: types, functions: functions, constructorOrder: constructorOrder)
|
||||
|
||||
for index in 0 ..< typeOrder.count {
|
||||
files["Api\(index + 1).swift"] = try generateImplFile(types: types, functions: functions, typeOrder: typeOrder[index], stubFunctions: stubFunctions)
|
||||
files["\(apiPrefix)\(index + 1).swift"] = try generateImplFile(apiPrefix: apiPrefix, types: types, functions: functions, typeOrder: typeOrder[index])
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
private static func generateMainFile(types: [Resolver.SumType], functions: [Resolver.Function], constructorOrder: [(typeName: QualifiedName, constructorName: String)]) throws -> String {
|
||||
|
||||
static func generateLayered(
|
||||
apiPrefix: String,
|
||||
layerNumber: Int,
|
||||
types: [Resolver.SumType]
|
||||
) throws -> (filename: String, source: String) {
|
||||
let structName = "\(apiPrefix)\(layerNumber)"
|
||||
let filename = "\(apiPrefix)Layer\(layerNumber).swift"
|
||||
|
||||
// All nested type refs (inside the struct) use `structName` as their prefix —
|
||||
// `apiPrefix` is used only to compute `structName` and `filename`. Helpers like
|
||||
// typeReferenceRepresentation and generateFieldParsing get `structName`, not
|
||||
// `apiPrefix`, so e.g. fields render as `media: SecretApi8.DecryptedMessageMedia`.
|
||||
|
||||
var typeMap: [QualifiedName: Resolver.SumType] = [:]
|
||||
for type in types {
|
||||
typeMap[type.name] = type
|
||||
}
|
||||
|
||||
// Detect whether any constructor argument uses Int256; if so, we need the int256 parser entry.
|
||||
var usesInt256 = false
|
||||
outer: for type in types {
|
||||
for (_, constructor) in type.constructors {
|
||||
for argument in constructor.arguments {
|
||||
if containsInt256(argument.type) { usesInt256 = true; break outer }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var writer = CodeWriter()
|
||||
writer.line()
|
||||
|
||||
// File-scope dispatch table
|
||||
writer.line("fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = {")
|
||||
writer.indent()
|
||||
writer.line("var dict: [Int32 : (BufferReader) -> Any?] = [:]")
|
||||
writer.line("dict[-1471112230] = { return $0.readInt32() }")
|
||||
writer.line("dict[570911930] = { return $0.readInt64() }")
|
||||
writer.line("dict[571523412] = { return $0.readDouble() }")
|
||||
writer.line("dict[-1255641564] = { return parseString($0) }")
|
||||
if usesInt256 {
|
||||
writer.line("dict[0x0929C32F] = { return parseInt256($0) }")
|
||||
}
|
||||
|
||||
let sortedTypes = types.sorted(by: { $0.name < $1.name })
|
||||
for type in sortedTypes {
|
||||
let sortedConstructors = type.constructors.values.sorted(by: { $0.name < $1.name })
|
||||
for constructor in sortedConstructors {
|
||||
writer.line("dict[\(Int32(bitPattern: constructor.id))] = { return \(structName).\(type.name).parse_\(constructor.name.value)($0) }")
|
||||
}
|
||||
}
|
||||
writer.line("return dict")
|
||||
writer.dedent()
|
||||
writer.line("}()")
|
||||
writer.line()
|
||||
|
||||
// public struct {apiPrefix}{N} {
|
||||
writer.line("public struct \(structName) {")
|
||||
writer.indent()
|
||||
|
||||
// public static func parse(_ buffer: Buffer) -> Any?
|
||||
writer.line("public static func parse(_ buffer: Buffer) -> Any? {")
|
||||
writer.indent()
|
||||
writer.line("let reader = BufferReader(buffer)")
|
||||
writer.line("if let signature = reader.readInt32() {")
|
||||
writer.indent()
|
||||
writer.line("return parse(reader, signature: signature)")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.line("return nil")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.line()
|
||||
|
||||
// fileprivate static func parse(_ reader: BufferReader, signature: Int32) -> Any?
|
||||
writer.line("fileprivate static func parse(_ reader: BufferReader, signature: Int32) -> Any? {")
|
||||
writer.indent()
|
||||
writer.line("if let parser = parsers[signature] {")
|
||||
writer.indent()
|
||||
writer.line("return parser(reader)")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.line("else {")
|
||||
writer.indent()
|
||||
writer.line("telegramApiLog(\"Type constructor \\(String(signature, radix: 16, uppercase: false)) not found\")")
|
||||
writer.line("return nil")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.line()
|
||||
|
||||
// fileprivate static func parseVector
|
||||
writer.line("fileprivate static func parseVector<T>(_ reader: BufferReader, elementSignature: Int32, elementType: T.Type) -> [T]? {")
|
||||
writer.indent()
|
||||
writer.line("if let count = reader.readInt32() {")
|
||||
writer.indent()
|
||||
writer.line("var array = [T]()")
|
||||
writer.line("var i: Int32 = 0")
|
||||
writer.line("while i < count {")
|
||||
writer.indent()
|
||||
writer.line("var signature = elementSignature")
|
||||
writer.line("if elementSignature == 0 {")
|
||||
writer.indent()
|
||||
writer.line("if let unboxedSignature = reader.readInt32() {")
|
||||
writer.indent()
|
||||
writer.line("signature = unboxedSignature")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.line("else {")
|
||||
writer.indent()
|
||||
writer.line("return nil")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.line("if let item = \(structName).parse(reader, signature: signature) as? T {")
|
||||
writer.indent()
|
||||
writer.line("array.append(item)")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.line("else {")
|
||||
writer.indent()
|
||||
writer.line("return nil")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.line("i += 1")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.line("return array")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.line("return nil")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.line()
|
||||
|
||||
// public static func serializeObject
|
||||
writer.line("public static func serializeObject(_ object: Any, buffer: Buffer, boxed: Swift.Bool) {")
|
||||
writer.indent()
|
||||
writer.line("switch object {")
|
||||
for type in sortedTypes {
|
||||
writer.line("case let _1 as \(structName).\(type.name):")
|
||||
writer.indent()
|
||||
writer.line("_1.serialize(buffer, boxed)")
|
||||
writer.dedent()
|
||||
}
|
||||
writer.line("default:")
|
||||
writer.indent()
|
||||
writer.line("break")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.line()
|
||||
|
||||
// Nested public enum <TypeName> { ... } for each type
|
||||
for type in sortedTypes {
|
||||
try emitLayeredType(writer: &writer, structName: structName, type: type, typeMap: typeMap)
|
||||
}
|
||||
|
||||
writer.dedent()
|
||||
writer.line("}") // close public struct
|
||||
|
||||
return (filename, writer.output())
|
||||
}
|
||||
|
||||
private static func containsInt256(_ type: Resolver.TypeReference) -> Bool {
|
||||
switch type {
|
||||
case .int256:
|
||||
return true
|
||||
case .bareVector(let element), .boxedVector(let element):
|
||||
return containsInt256(element)
|
||||
case .int32, .int64, .double, .bytes, .string, .bool, .boolTrue, .bareConstructor, .boxedType:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func emitLayeredType(
|
||||
writer: inout CodeWriter,
|
||||
structName: String,
|
||||
type: Resolver.SumType,
|
||||
typeMap: [QualifiedName: Resolver.SumType]
|
||||
) throws {
|
||||
let sortedConstructors = type.constructors.values.sorted(by: { $0.name < $1.name })
|
||||
|
||||
let indirectPrefix = try type.hasDirectReference(to: [type], typeMap: typeMap) ? "indirect " : ""
|
||||
writer.line("\(indirectPrefix)public enum \(type.name.value) {")
|
||||
writer.indent()
|
||||
|
||||
// case <ctor>(<args>) -- inline-args shape
|
||||
for constructor in sortedConstructors {
|
||||
var argumentsString = ""
|
||||
for argument in constructor.arguments {
|
||||
if case .boolTrue = argument.type { continue }
|
||||
if !argumentsString.isEmpty { argumentsString.append(", ") }
|
||||
argumentsString.append(argument.name.camelCased)
|
||||
argumentsString.append(": ")
|
||||
argumentsString.append(typeReferenceRepresentation(structName, argument.type))
|
||||
if argument.condition != nil { argumentsString.append("?") }
|
||||
}
|
||||
writer.line("case \(constructor.name.value)\(argumentsString.isEmpty ? "" : "(\(argumentsString))")")
|
||||
}
|
||||
writer.line()
|
||||
|
||||
// public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool)
|
||||
writer.line("public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) {")
|
||||
writer.indent()
|
||||
writer.line("switch self {")
|
||||
for constructor in sortedConstructors {
|
||||
var bindString = ""
|
||||
for argument in constructor.arguments {
|
||||
if case .boolTrue = argument.type { continue }
|
||||
if !bindString.isEmpty { bindString.append(", ") }
|
||||
bindString.append("let ")
|
||||
bindString.append(argument.name.camelCasedAndEscaped)
|
||||
}
|
||||
writer.line("case .\(constructor.name.value)\(bindString.isEmpty ? "" : "(\(bindString))"):")
|
||||
writer.indent()
|
||||
writer.line("if boxed {")
|
||||
writer.indent()
|
||||
writer.line("buffer.appendInt32(\(Int32(bitPattern: constructor.id)))")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
|
||||
for argument in constructor.arguments {
|
||||
if case .boolTrue = argument.type { continue }
|
||||
var argumentAccessor = "\(argument.name.camelCasedAndEscaped)"
|
||||
if let condition = argument.condition {
|
||||
writer.line("if Int(\(condition.fieldName)) & Int(1 << \(condition.bitIndex)) != 0 {")
|
||||
writer.indent()
|
||||
argumentAccessor.append("!")
|
||||
generateFieldSerialization(writer: &writer, argument: argument, argumentAccessor: argumentAccessor)
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
} else {
|
||||
generateFieldSerialization(writer: &writer, argument: argument, argumentAccessor: argumentAccessor)
|
||||
}
|
||||
}
|
||||
writer.line("break")
|
||||
writer.dedent()
|
||||
}
|
||||
writer.line("}")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.line()
|
||||
|
||||
// fileprivate static func parse_<ctor>(_ reader: BufferReader) -> <TypeName>?
|
||||
for constructor in sortedConstructors {
|
||||
writer.line("fileprivate static func parse_\(constructor.name.value)(_ reader: BufferReader) -> \(type.name.value)? {")
|
||||
writer.indent()
|
||||
if constructor.arguments.contains(where: { if case .boolTrue = $0.type { return false } else { return true } }) {
|
||||
var argumentIndex = 0
|
||||
var argumentCheckString = ""
|
||||
var argumentCollectionString = ""
|
||||
for argument in constructor.arguments {
|
||||
if case .boolTrue = argument.type { continue }
|
||||
|
||||
writer.line("var _\(argumentIndex + 1): \(typeReferenceRepresentation(structName, argument.type))?")
|
||||
|
||||
if let condition = argument.condition {
|
||||
guard let fieldIndex = constructor.arguments.filter({ if case .boolTrue = $0.type { return false } else { return true } }).firstIndex(where: { $0.name == condition.fieldName }) else {
|
||||
throw CodeGenerationError(text: "Condition field \(condition.fieldName) not found")
|
||||
}
|
||||
writer.line("if Int(_\(fieldIndex + 1) ?? 0) & Int(1 << \(condition.bitIndex)) != 0 {")
|
||||
writer.indent()
|
||||
try generateFieldParsing(apiPrefix: structName, writer: &writer, typeMap: typeMap, argument: argument, argumentAccessor: "_\(argumentIndex + 1)")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
} else {
|
||||
try generateFieldParsing(apiPrefix: structName, writer: &writer, typeMap: typeMap, argument: argument, argumentAccessor: "_\(argumentIndex + 1)")
|
||||
}
|
||||
|
||||
if !argumentCheckString.isEmpty { argumentCheckString.append(" && ") }
|
||||
argumentCheckString.append("_c\(argumentIndex + 1)")
|
||||
|
||||
if !argumentCollectionString.isEmpty { argumentCollectionString.append(", ") }
|
||||
argumentCollectionString.append("\(argument.name.camelCased): _\(argumentIndex + 1)")
|
||||
if argument.condition == nil { argumentCollectionString.append("!") }
|
||||
|
||||
argumentIndex += 1
|
||||
}
|
||||
|
||||
var checkIndex = 0
|
||||
for argument in constructor.arguments {
|
||||
if case .boolTrue = argument.type { continue }
|
||||
if let condition = argument.condition {
|
||||
guard let fieldIndex = constructor.arguments.filter({ if case .boolTrue = $0.type { return false } else { return true } }).firstIndex(where: { $0.name == condition.fieldName }) else {
|
||||
throw CodeGenerationError(text: "Condition field \(condition.fieldName) not found")
|
||||
}
|
||||
writer.line("let _c\(checkIndex + 1) = (Int(_\(fieldIndex + 1) ?? 0) & Int(1 << \(condition.bitIndex)) == 0) || _\(checkIndex + 1) != nil")
|
||||
} else {
|
||||
writer.line("let _c\(checkIndex + 1) = _\(checkIndex + 1) != nil")
|
||||
}
|
||||
checkIndex += 1
|
||||
}
|
||||
|
||||
writer.line("if \(argumentCheckString) {")
|
||||
writer.indent()
|
||||
writer.line("return \(structName).\(type.name).\(constructor.name.value)\(argumentCollectionString.isEmpty ? "" : "(\(argumentCollectionString))")")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.line("else {")
|
||||
writer.indent()
|
||||
writer.line("return nil")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
} else {
|
||||
writer.line("return \(structName).\(type.name).\(constructor.name.value)")
|
||||
}
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
}
|
||||
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
writer.line()
|
||||
}
|
||||
|
||||
private static func generateMainFile(apiPrefix: String, types: [Resolver.SumType], functions: [Resolver.Function], constructorOrder: [(typeName: QualifiedName, constructorName: String)]) throws -> String {
|
||||
var writer = CodeWriter()
|
||||
|
||||
writer.line()
|
||||
|
|
@ -218,7 +536,7 @@ enum CodeGenerator {
|
|||
}
|
||||
}
|
||||
|
||||
writer.line("public enum Api {")
|
||||
writer.line("public enum \(apiPrefix) {")
|
||||
writer.indent()
|
||||
for namespace in namespaces.sorted(by: { $0 < $1 }) {
|
||||
writer.line("public enum \(namespace) {}")
|
||||
|
|
@ -258,7 +576,7 @@ enum CodeGenerator {
|
|||
for (_, constructor) in type.constructors {
|
||||
if constructor.name.value == constructorName {
|
||||
found = true
|
||||
writer.line("dict[\(Int32(bitPattern: constructor.id))] = { return Api.\(type.name).parse_\(constructor.name.value)($0) }")
|
||||
writer.line("dict[\(Int32(bitPattern: constructor.id))] = { return \(apiPrefix).\(type.name).parse_\(constructor.name.value)($0) }")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -274,7 +592,7 @@ enum CodeGenerator {
|
|||
|
||||
writer.line()
|
||||
|
||||
writer.line("public extension Api {")
|
||||
writer.line("public extension \(apiPrefix) {")
|
||||
writer.indent()
|
||||
|
||||
writer.line("static func parse(_ buffer: Buffer) -> Any? {")
|
||||
|
|
@ -344,7 +662,7 @@ enum CodeGenerator {
|
|||
writer.dedent()
|
||||
writer.line("} else {")
|
||||
writer.indent()
|
||||
writer.line("if let item = Api.parse(reader, signature: signature) as? T {")
|
||||
writer.line("if let item = \(apiPrefix).parse(reader, signature: signature) as? T {")
|
||||
writer.indent()
|
||||
writer.line("array.append(item)")
|
||||
writer.dedent()
|
||||
|
|
@ -378,7 +696,7 @@ enum CodeGenerator {
|
|||
throw CodeGenerationError(text: "Type \(typeName) not found")
|
||||
}
|
||||
|
||||
writer.line("case let _1 as Api.\(type.name):")
|
||||
writer.line("case let _1 as \(apiPrefix).\(type.name):")
|
||||
writer.indent()
|
||||
writer.line("_1.serialize(buffer, boxed)")
|
||||
writer.dedent()
|
||||
|
|
@ -398,7 +716,7 @@ enum CodeGenerator {
|
|||
return writer.output()
|
||||
}
|
||||
|
||||
private static func generateImplFile(types: [Resolver.SumType], functions: [Resolver.Function], typeOrder: (types: [(typeName: QualifiedName, constructorNames: [String])], functions: [QualifiedName]), stubFunctions: Bool) throws -> String {
|
||||
private static func generateImplFile(apiPrefix: String, types: [Resolver.SumType], functions: [Resolver.Function], typeOrder: (types: [(typeName: QualifiedName, constructorNames: [String])], functions: [QualifiedName])) throws -> String {
|
||||
var writer = CodeWriter()
|
||||
|
||||
var typeMap: [QualifiedName: Resolver.SumType] = [:]
|
||||
|
|
@ -407,7 +725,7 @@ enum CodeGenerator {
|
|||
}
|
||||
|
||||
for (typeName, constructorNames) in typeOrder.types {
|
||||
writer.line("public extension Api\(typeName.namespace.flatMap { "." + $0 } ?? "") {")
|
||||
writer.line("public extension \(apiPrefix)\(typeName.namespace.flatMap { "." + $0 } ?? "") {")
|
||||
writer.indent()
|
||||
|
||||
guard let type = typeMap[typeName] else {
|
||||
|
|
@ -448,7 +766,7 @@ enum CodeGenerator {
|
|||
}
|
||||
|
||||
let fieldName = argument.name.camelCasedAndEscaped
|
||||
let fieldType = typeReferenceRepresentation(argument.type) + (argument.condition != nil ? "?" : "")
|
||||
let fieldType = typeReferenceRepresentation(apiPrefix, argument.type) + (argument.condition != nil ? "?" : "")
|
||||
|
||||
if !fieldsString.isEmpty {
|
||||
fieldsString.append("\n")
|
||||
|
|
@ -509,7 +827,7 @@ enum CodeGenerator {
|
|||
|
||||
argumentsString.append(argument.name.camelCased)
|
||||
argumentsString.append(": ")
|
||||
argumentsString.append(typeReferenceRepresentation(argument.type))
|
||||
argumentsString.append(typeReferenceRepresentation(apiPrefix, argument.type))
|
||||
if argument.condition != nil {
|
||||
argumentsString.append("?")
|
||||
}
|
||||
|
|
@ -522,13 +840,6 @@ enum CodeGenerator {
|
|||
writer.line()
|
||||
writer.line("public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) {")
|
||||
writer.indent()
|
||||
if stubFunctions {
|
||||
writer.line("#if DEBUG")
|
||||
writer.line("preconditionFailure()")
|
||||
writer.line("#else")
|
||||
writer.line("error")
|
||||
writer.line("#endif")
|
||||
} else {
|
||||
writer.line("switch self {")
|
||||
|
||||
for constructor in sortedConstructors {
|
||||
|
|
@ -608,20 +919,12 @@ enum CodeGenerator {
|
|||
}
|
||||
|
||||
writer.line("}")
|
||||
} // end if !stubFunctions
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
|
||||
writer.line()
|
||||
writer.line("public func descriptionFields() -> (String, [(String, ConstructorParameterDescription)]) {")
|
||||
writer.indent()
|
||||
if stubFunctions {
|
||||
writer.line("#if DEBUG")
|
||||
writer.line("preconditionFailure()")
|
||||
writer.line("#else")
|
||||
writer.line("error")
|
||||
writer.line("#endif")
|
||||
} else {
|
||||
writer.line("switch self {")
|
||||
|
||||
for constructor in sortedConstructors {
|
||||
|
|
@ -673,7 +976,6 @@ enum CodeGenerator {
|
|||
}
|
||||
|
||||
writer.line("}")
|
||||
} // end if !stubFunctions for descriptionFields
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
|
||||
|
|
@ -682,14 +984,6 @@ enum CodeGenerator {
|
|||
for constructor in sortedConstructors {
|
||||
writer.line("public static func parse_\(constructor.name.value)(_ reader: BufferReader) -> \(typeName.value)? {")
|
||||
writer.indent()
|
||||
if stubFunctions {
|
||||
writer.line("#if DEBUG")
|
||||
writer.line("preconditionFailure()")
|
||||
writer.line("#else")
|
||||
writer.line("error")
|
||||
writer.line("#endif")
|
||||
} else {
|
||||
|
||||
if constructor.arguments.contains(where: { if case .boolTrue = $0.type { return false } else { return true } }) {
|
||||
var argumentIndex = 0
|
||||
var argumentCheckString = ""
|
||||
|
|
@ -699,20 +993,20 @@ enum CodeGenerator {
|
|||
continue
|
||||
}
|
||||
|
||||
writer.line("var _\(argumentIndex + 1): \(typeReferenceRepresentation(argument.type))?")
|
||||
writer.line("var _\(argumentIndex + 1): \(typeReferenceRepresentation(apiPrefix, argument.type))?")
|
||||
|
||||
if let condition = argument.condition {
|
||||
guard let fieldIndex = constructor.arguments.filter({ if case .boolTrue = $0.type { return false } else { return true } }).firstIndex(where: { $0.name == condition.fieldName }) else {
|
||||
throw CodeGenerationError(text: "Condition field \(condition.fieldName) not found")
|
||||
}
|
||||
|
||||
writer.line("if Int(_\(fieldIndex + 1)!) & Int(1 << \(condition.bitIndex)) != 0 {")
|
||||
writer.line("if Int(_\(fieldIndex + 1) ?? 0) & Int(1 << \(condition.bitIndex)) != 0 {")
|
||||
writer.indent()
|
||||
try generateFieldParsing(writer: &writer, typeMap: typeMap, argument: argument, argumentAccessor: "_\(argumentIndex + 1)")
|
||||
try generateFieldParsing(apiPrefix: apiPrefix, writer: &writer, typeMap: typeMap, argument: argument, argumentAccessor: "_\(argumentIndex + 1)")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
} else {
|
||||
try generateFieldParsing(writer: &writer, typeMap: typeMap, argument: argument, argumentAccessor: "_\(argumentIndex + 1)")
|
||||
try generateFieldParsing(apiPrefix: apiPrefix, writer: &writer, typeMap: typeMap, argument: argument, argumentAccessor: "_\(argumentIndex + 1)")
|
||||
}
|
||||
|
||||
if !argumentCheckString.isEmpty {
|
||||
|
|
@ -742,7 +1036,7 @@ enum CodeGenerator {
|
|||
throw CodeGenerationError(text: "Condition field \(condition.fieldName) not found")
|
||||
}
|
||||
|
||||
writer.line("let _c\(checkIndex + 1) = (Int(_\(fieldIndex + 1)!) & Int(1 << \(condition.bitIndex)) == 0) || _\(checkIndex + 1) != nil")
|
||||
writer.line("let _c\(checkIndex + 1) = (Int(_\(fieldIndex + 1) ?? 0) & Int(1 << \(condition.bitIndex)) == 0) || _\(checkIndex + 1) != nil")
|
||||
} else {
|
||||
writer.line("let _c\(checkIndex + 1) = _\(checkIndex + 1) != nil")
|
||||
}
|
||||
|
|
@ -753,9 +1047,9 @@ enum CodeGenerator {
|
|||
writer.line("if \(argumentCheckString) {")
|
||||
writer.indent()
|
||||
if useStructPattern && !argumentCollectionString.isEmpty {
|
||||
writer.line("return Api.\(typeName).\(constructor.name.value)(Cons_\(constructor.name.value)(\(argumentCollectionString)))")
|
||||
writer.line("return \(apiPrefix).\(typeName).\(constructor.name.value)(Cons_\(constructor.name.value)(\(argumentCollectionString)))")
|
||||
} else {
|
||||
writer.line("return Api.\(typeName).\(constructor.name.value)\(argumentCollectionString.isEmpty ? "" : "(\(argumentCollectionString))")")
|
||||
writer.line("return \(apiPrefix).\(typeName).\(constructor.name.value)\(argumentCollectionString.isEmpty ? "" : "(\(argumentCollectionString))")")
|
||||
}
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
|
|
@ -765,10 +1059,9 @@ enum CodeGenerator {
|
|||
writer.dedent()
|
||||
writer.line("}")
|
||||
} else {
|
||||
writer.line("return Api.\(typeName).\(constructor.name.value)")
|
||||
writer.line("return \(apiPrefix).\(typeName).\(constructor.name.value)")
|
||||
}
|
||||
|
||||
} // end if !stubFunctions
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
}
|
||||
|
|
@ -781,7 +1074,7 @@ enum CodeGenerator {
|
|||
|
||||
if !typeOrder.functions.isEmpty {
|
||||
for functionName in typeOrder.functions {
|
||||
writer.line("public extension Api.functions\(functionName.namespace.flatMap { "." + $0 } ?? "") {")
|
||||
writer.line("public extension \(apiPrefix).functions\(functionName.namespace.flatMap { "." + $0 } ?? "") {")
|
||||
writer.indent()
|
||||
|
||||
var foundFunction: Resolver.Function?
|
||||
|
|
@ -807,13 +1100,13 @@ enum CodeGenerator {
|
|||
|
||||
argumentsString.append(argument.name.camelCasedAndEscaped)
|
||||
argumentsString.append(": ")
|
||||
argumentsString.append(typeReferenceRepresentation(argument.type))
|
||||
argumentsString.append(typeReferenceRepresentation(apiPrefix, argument.type))
|
||||
if argument.condition != nil {
|
||||
argumentsString.append("?")
|
||||
}
|
||||
}
|
||||
|
||||
writer.line("static func \(function.name.value)(\(argumentsString)) -> (FunctionDescription, Buffer, DeserializeFunctionResponse<\(typeReferenceRepresentation(function.result))>) {")
|
||||
writer.line("static func \(function.name.value)(\(argumentsString)) -> (FunctionDescription, Buffer, DeserializeFunctionResponse<\(typeReferenceRepresentation(apiPrefix, function.result))>) {")
|
||||
writer.indent()
|
||||
writer.line("let buffer = Buffer()")
|
||||
writer.line("buffer.appendInt32(\(Int32(bitPattern: function.id)))")
|
||||
|
|
@ -847,12 +1140,12 @@ enum CodeGenerator {
|
|||
argumentSerializationString.append("(\"\(argument.name.camelCasedAndEscaped)\", ConstructorParameterDescription(\(argument.name.camelCasedAndEscaped)))")
|
||||
}
|
||||
|
||||
writer.line("return (FunctionDescription(name: \"\(function.name)\", parameters: [\(argumentSerializationString)]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> \(typeReferenceRepresentation(function.result))? in")
|
||||
writer.line("return (FunctionDescription(name: \"\(function.name)\", parameters: [\(argumentSerializationString)]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> \(typeReferenceRepresentation(apiPrefix, function.result))? in")
|
||||
writer.indent()
|
||||
writer.line("let reader = BufferReader(buffer)")
|
||||
writer.line("var result: \(typeReferenceRepresentation(function.result))?")
|
||||
writer.line("var result: \(typeReferenceRepresentation(apiPrefix, function.result))?")
|
||||
|
||||
try generateFieldParsing(writer: &writer, typeMap: typeMap, argument: Resolver.Argument(name: "result", type: function.result, condition: nil), argumentAccessor: "result")
|
||||
try generateFieldParsing(apiPrefix: apiPrefix, writer: &writer, typeMap: typeMap, argument: Resolver.Argument(name: "result", type: function.result, condition: nil), argumentAccessor: "result")
|
||||
|
||||
writer.line("return result")
|
||||
writer.dedent()
|
||||
|
|
@ -904,7 +1197,7 @@ enum CodeGenerator {
|
|||
}
|
||||
}
|
||||
|
||||
private static func generateFieldParsing(writer: inout CodeWriter, typeMap: [QualifiedName: Resolver.SumType], argument: Resolver.Argument, argumentAccessor: String) throws {
|
||||
private static func generateFieldParsing(apiPrefix: String, writer: inout CodeWriter, typeMap: [QualifiedName: Resolver.SumType], argument: Resolver.Argument, argumentAccessor: String) throws {
|
||||
switch argument.type {
|
||||
case .int32:
|
||||
writer.line("\(argumentAccessor) = reader.readInt32()")
|
||||
|
|
@ -961,11 +1254,11 @@ enum CodeGenerator {
|
|||
if case .boxedVector = argument.type {
|
||||
writer.line("if let _ = reader.readInt32() {")
|
||||
writer.indent()
|
||||
writer.line("\(argumentAccessor) = Api.parseVector(reader, elementSignature: \(elementSignature), elementType: \(typeReferenceRepresentation(elementType)).self)")
|
||||
writer.line("\(argumentAccessor) = \(apiPrefix).parseVector(reader, elementSignature: \(elementSignature), elementType: \(typeReferenceRepresentation(apiPrefix, elementType)).self)")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
} else {
|
||||
writer.line("\(argumentAccessor) = Api.parseVector(reader, elementSignature: \(elementSignature), elementType: \(typeReferenceRepresentation(elementType)).self)")
|
||||
writer.line("\(argumentAccessor) = \(apiPrefix).parseVector(reader, elementSignature: \(elementSignature), elementType: \(typeReferenceRepresentation(apiPrefix, elementType)).self)")
|
||||
}
|
||||
case let .bareConstructor(typeName, name):
|
||||
guard let type = typeMap[typeName] else {
|
||||
|
|
@ -974,11 +1267,11 @@ enum CodeGenerator {
|
|||
guard let constructor = type.constructors[name] else {
|
||||
throw CodeGenerationError(text: "Type \(typeName) not found")
|
||||
}
|
||||
writer.line("\(argumentAccessor) = Api.parse(reader, signature: \(Int32(bitPattern: constructor.id)) as? \(typeReferenceRepresentation(argument.type))")
|
||||
writer.line("\(argumentAccessor) = \(apiPrefix).parse(reader, signature: \(Int32(bitPattern: constructor.id)) as? \(typeReferenceRepresentation(apiPrefix, argument.type))")
|
||||
case .boxedType:
|
||||
writer.line("if let signature = reader.readInt32() {")
|
||||
writer.indent()
|
||||
writer.line("\(argumentAccessor) = Api.parse(reader, signature: signature) as? \(typeReferenceRepresentation(argument.type))")
|
||||
writer.line("\(argumentAccessor) = \(apiPrefix).parse(reader, signature: signature) as? \(typeReferenceRepresentation(apiPrefix, argument.type))")
|
||||
writer.dedent()
|
||||
writer.line("}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,29 @@
|
|||
import Foundation
|
||||
|
||||
enum DescriptionParser {
|
||||
enum ParsedSchema {
|
||||
case flat(constructors: [ConstructorDescription], functions: [ConstructorDescription])
|
||||
case layered(layers: [(layerNumber: Int, constructors: [ConstructorDescription])])
|
||||
}
|
||||
|
||||
struct SchemaParsingError: Error, CustomStringConvertible {
|
||||
var text: String
|
||||
var description: String { text }
|
||||
}
|
||||
|
||||
private static let skipPrefixes: [String] = [
|
||||
"true#3fedd339 = True;",
|
||||
"vector#1cb5c415 {t:Type} # [ t ] = Vector t;",
|
||||
"error#c4b9f9bb code:int text:string = Error;",
|
||||
"null#56730bcc = Null;"
|
||||
]
|
||||
private static let skipContains: [String] = ["{X:Type}"]
|
||||
|
||||
private static func shouldSkipLine(_ line: String) -> Bool {
|
||||
skipPrefixes.contains { line.hasPrefix($0) } ||
|
||||
skipContains.contains { line.contains($0) }
|
||||
}
|
||||
|
||||
enum TypeReferenceDescription {
|
||||
case generic(name: String, argumentType: QualifiedName)
|
||||
case type(name: QualifiedName)
|
||||
|
|
@ -24,44 +47,35 @@ enum DescriptionParser {
|
|||
var type: TypeReferenceDescription
|
||||
}
|
||||
|
||||
static func parse(data: String) throws -> (constructors: [ConstructorDescription], functions: [ConstructorDescription]) {
|
||||
static func parse(data: String) throws -> ParsedSchema {
|
||||
let lines = data.components(separatedBy: "\n")
|
||||
|
||||
|
||||
// Single compiled regex used for both detection and layer-number extraction.
|
||||
let layerMarker = try NSRegularExpression(pattern: "^===(\\d+)===\\s*$")
|
||||
let hasLayerMarker = lines.contains { line in
|
||||
let range = NSRange(line.startIndex..., in: line)
|
||||
return layerMarker.firstMatch(in: line, range: range) != nil
|
||||
}
|
||||
|
||||
if hasLayerMarker {
|
||||
return try parseLayered(lines: lines, layerMarker: layerMarker)
|
||||
} else {
|
||||
return try parseFlat(lines: lines)
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseFlat(lines: [String]) throws -> ParsedSchema {
|
||||
var typeLines: [String] = []
|
||||
var functionLines: [String] = []
|
||||
|
||||
let skipPrefixes: [String] = [
|
||||
//"boolFalse#bc799737 = Bool;",
|
||||
//"boolTrue#997275b5 = Bool;",
|
||||
"true#3fedd339 = True;",
|
||||
"vector#1cb5c415 {t:Type} # [ t ] = Vector t;",
|
||||
"error#c4b9f9bb code:int text:string = Error;",
|
||||
"null#56730bcc = Null;"
|
||||
]
|
||||
|
||||
let skipContains: [String] = [
|
||||
"{X:Type}"
|
||||
]
|
||||
|
||||
|
||||
var isParsingFunctions = false
|
||||
loop: for line in lines {
|
||||
for line in lines {
|
||||
if line.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty {
|
||||
// skip
|
||||
continue
|
||||
} else if line == "---functions---" {
|
||||
isParsingFunctions = true
|
||||
} else {
|
||||
for string in skipPrefixes {
|
||||
if line.hasPrefix(string) {
|
||||
continue loop
|
||||
}
|
||||
}
|
||||
|
||||
for string in skipContains {
|
||||
if line.contains(string) {
|
||||
continue loop
|
||||
}
|
||||
}
|
||||
|
||||
if shouldSkipLine(line) { continue }
|
||||
if isParsingFunctions {
|
||||
functionLines.append(line)
|
||||
} else {
|
||||
|
|
@ -69,35 +83,94 @@ enum DescriptionParser {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var constructors: [ConstructorDescription] = []
|
||||
var functions: [ConstructorDescription] = []
|
||||
|
||||
|
||||
for line in typeLines {
|
||||
do {
|
||||
let constructor = try self.parseConstructor(string: line)
|
||||
constructors.append(constructor)
|
||||
constructors.append(try parseConstructor(string: line))
|
||||
} catch let e {
|
||||
print("Error while parsing line:\n\(line)\n")
|
||||
print("\(e)")
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
for line in functionLines {
|
||||
do {
|
||||
let constructor = try parseConstructor(string: line)
|
||||
functions.append(constructor)
|
||||
functions.append(try parseConstructor(string: line))
|
||||
} catch let e {
|
||||
print("Error while parsing line:\n\(line)\n")
|
||||
print("\(e)")
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return (constructors, functions)
|
||||
|
||||
return .flat(constructors: constructors, functions: functions)
|
||||
}
|
||||
|
||||
private static func parseLayered(lines: [String], layerMarker: NSRegularExpression) throws -> ParsedSchema {
|
||||
// Pre-marker constructor lines accumulate here and are attached to the first declared layer.
|
||||
var preMarkerLines: [String] = []
|
||||
var sections: [(layerNumber: Int, lines: [String])] = []
|
||||
var lastLayerNumber: Int? = nil
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { continue }
|
||||
|
||||
if line == "---functions---" {
|
||||
throw SchemaParsingError(text: "Layered schemas may not declare ---functions---; secret/layered schemas are types-only.")
|
||||
}
|
||||
|
||||
let range = NSRange(line.startIndex..., in: line)
|
||||
if let match = layerMarker.firstMatch(in: line, range: range),
|
||||
let numberRange = Range(match.range(at: 1), in: line),
|
||||
let layerNumber = Int(line[numberRange])
|
||||
{
|
||||
if let previous = lastLayerNumber, layerNumber <= previous {
|
||||
throw SchemaParsingError(text: "Layer markers must appear in strictly ascending order; found ===\(layerNumber)=== after ===\(previous)===.")
|
||||
}
|
||||
sections.append((layerNumber, []))
|
||||
lastLayerNumber = layerNumber
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply the same skip rules as flat mode.
|
||||
if shouldSkipLine(line) { continue }
|
||||
|
||||
if sections.isEmpty {
|
||||
preMarkerLines.append(line)
|
||||
} else {
|
||||
sections[sections.count - 1].lines.append(line)
|
||||
}
|
||||
}
|
||||
|
||||
if sections.isEmpty {
|
||||
throw SchemaParsingError(text: "Layered schema has a layer marker regex match but no ===N=== sections were extracted; this indicates a parser bug.")
|
||||
}
|
||||
|
||||
// Attach pre-marker lines to the first (lowest) declared layer.
|
||||
if !preMarkerLines.isEmpty {
|
||||
sections[0].lines.insert(contentsOf: preMarkerLines, at: 0)
|
||||
}
|
||||
|
||||
var layers: [(layerNumber: Int, constructors: [ConstructorDescription])] = []
|
||||
for (layerNumber, sectionLines) in sections {
|
||||
var constructors: [ConstructorDescription] = []
|
||||
for line in sectionLines {
|
||||
do {
|
||||
constructors.append(try parseConstructor(string: line))
|
||||
} catch let e {
|
||||
print("Error while parsing line (layer \(layerNumber)):\n\(line)\n")
|
||||
print("\(e)")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
layers.append((layerNumber, constructors))
|
||||
}
|
||||
|
||||
return .layered(layers: layers)
|
||||
}
|
||||
|
||||
private static func parseConstructor(string: String) throws -> ConstructorDescription {
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
enum LegacyOrderParser {
|
||||
struct LegacyOrderParsingError: Error, CustomStringConvertible {
|
||||
var text: String
|
||||
|
||||
var description: String {
|
||||
return self.text
|
||||
}
|
||||
}
|
||||
|
||||
static func parseConstructorOrder(data: String) throws -> [(typeName: QualifiedName, constructorName: String)] {
|
||||
let lines = data.split(separator: "\n")
|
||||
|
||||
var result: [(typeName: QualifiedName, constructorName: String)] = []
|
||||
|
||||
for line in lines {
|
||||
if let startRange = line.range(of: " = { return Api."), let endRange = line.range(of: "($0) }", options: [.backwards], range: nil) {
|
||||
let parseString = line[startRange.upperBound ..< endRange.lowerBound]
|
||||
let components = parseString.components(separatedBy: ".parse_")
|
||||
if components.count != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
result.append((QualifiedName(string: components[0]), components[1]))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
static func parseTypeOrder(data: String) throws -> (types: [(typeName: QualifiedName, constructorNames: [String])], functions: [QualifiedName]) {
|
||||
var resultTypes: [(typeName: QualifiedName, constructorNames: [String])] = []
|
||||
var resultFunctions: [QualifiedName] = []
|
||||
|
||||
let namespaces = data.components(separatedBy: "public extension Api {\n")
|
||||
|
||||
enum ParseSection {
|
||||
case types
|
||||
case functions
|
||||
}
|
||||
|
||||
for namespaceData in namespaces {
|
||||
if namespaceData.isEmpty {
|
||||
continue
|
||||
}
|
||||
guard let firstNewline = namespaceData.range(of: "\n") else {
|
||||
throw LegacyOrderParsingError(text: "No newline in the beginning of the namespace section")
|
||||
}
|
||||
let namespaceName: String?
|
||||
let namespaceContentData: String
|
||||
let parseSection: ParseSection
|
||||
if let prefixRange = namespaceData.range(of: " public enum ", options: [], range: namespaceData.startIndex ..< firstNewline.lowerBound), prefixRange.lowerBound == namespaceData.startIndex {
|
||||
namespaceName = nil
|
||||
namespaceContentData = namespaceData
|
||||
parseSection = .types
|
||||
} else if let prefixRange = namespaceData.range(of: " indirect public enum ", options: [], range: namespaceData.startIndex ..< firstNewline.lowerBound), prefixRange.lowerBound == namespaceData.startIndex {
|
||||
namespaceName = nil
|
||||
namespaceContentData = namespaceData
|
||||
parseSection = .types
|
||||
} else if let prefixRange = namespaceData.range(of: " public struct functions {", options: [], range: namespaceData.startIndex ..< firstNewline.lowerBound), prefixRange.upperBound == firstNewline.lowerBound {
|
||||
namespaceName = nil
|
||||
namespaceContentData = namespaceData
|
||||
parseSection = .functions
|
||||
} else {
|
||||
guard let prefixRange = namespaceData.range(of: "public struct ", options: [], range: namespaceData.startIndex ..< firstNewline.lowerBound), prefixRange.lowerBound == namespaceData.startIndex else {
|
||||
throw LegacyOrderParsingError(text: "Missing header prefix in the beginning of the namespace section")
|
||||
}
|
||||
guard let trailerRange = namespaceData.range(of: " {", options: [], range: prefixRange.upperBound ..< firstNewline.lowerBound) else {
|
||||
throw LegacyOrderParsingError(text: "Missing trailing suffix in the beginning of the namespace section")
|
||||
}
|
||||
namespaceName = String(namespaceData[prefixRange.upperBound ..< trailerRange.lowerBound])
|
||||
namespaceContentData = String(namespaceData[firstNewline.upperBound...])
|
||||
parseSection = .types
|
||||
}
|
||||
|
||||
let namespaceContentLines = namespaceContentData.split(separator: "\n")
|
||||
|
||||
switch parseSection {
|
||||
case .types:
|
||||
var currentType: (typeName: QualifiedName, constructorNames: [String])?
|
||||
for line in namespaceContentLines {
|
||||
if let typePrefixRange = line.range(of: " public enum "), typePrefixRange.lowerBound == line.startIndex, let typeSuffixRange = line.range(of: ": TypeConstructorDescription {"), typeSuffixRange.upperBound == line.endIndex {
|
||||
let typeName = String(line[typePrefixRange.upperBound ..< typeSuffixRange.lowerBound])
|
||||
if let currentType = currentType {
|
||||
resultTypes.append(currentType)
|
||||
}
|
||||
currentType = (QualifiedName(namespace: namespaceName, value: typeName), [])
|
||||
} else if let typePrefixRange = line.range(of: " indirect public enum "), typePrefixRange.lowerBound == line.startIndex, let typeSuffixRange = line.range(of: ": TypeConstructorDescription {"), typeSuffixRange.upperBound == line.endIndex {
|
||||
let typeName = String(line[typePrefixRange.upperBound ..< typeSuffixRange.lowerBound])
|
||||
if let currentType = currentType {
|
||||
resultTypes.append(currentType)
|
||||
}
|
||||
currentType = (QualifiedName(namespace: namespaceName, value: typeName), [])
|
||||
} else if currentType != nil, let constructorPrefixRange = line.range(of: " case "), constructorPrefixRange.lowerBound == line.startIndex {
|
||||
let constructorName: String
|
||||
if let bracketRange = line.range(of: "(") {
|
||||
constructorName = String(line[constructorPrefixRange.upperBound ..< bracketRange.lowerBound])
|
||||
} else {
|
||||
constructorName = String(line[constructorPrefixRange.upperBound...])
|
||||
}
|
||||
currentType?.constructorNames.append(constructorName)
|
||||
}
|
||||
}
|
||||
if let currentType = currentType {
|
||||
resultTypes.append(currentType)
|
||||
}
|
||||
case .functions:
|
||||
var currentNamespace: String?
|
||||
for line in namespaceContentLines {
|
||||
if let namespacePrefixRange = line.range(of: " public struct "), namespacePrefixRange.lowerBound == line.startIndex, let namespaceSuffixRange = line.range(of: " {"), namespaceSuffixRange.upperBound == line.endIndex {
|
||||
currentNamespace = String(line[namespacePrefixRange.upperBound ..< namespaceSuffixRange.lowerBound])
|
||||
} else if let functionPrefixRange = line.range(of: " public static func "), functionPrefixRange.lowerBound == line.startIndex {
|
||||
let functionName: String
|
||||
if let bracketRange = line.range(of: "(") {
|
||||
functionName = String(line[functionPrefixRange.upperBound ..< bracketRange.lowerBound])
|
||||
} else {
|
||||
functionName = String(line[functionPrefixRange.upperBound...])
|
||||
}
|
||||
resultFunctions.append(QualifiedName(namespace: currentNamespace, value: functionName))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (resultTypes, resultFunctions)
|
||||
}
|
||||
}
|
||||
|
|
@ -199,7 +199,114 @@ enum Resolver {
|
|||
|
||||
return types.values.sorted(by: { $0.name < $1.name })
|
||||
}
|
||||
|
||||
|
||||
static func resolveLayeredTypes(
|
||||
layers: [(layerNumber: Int, constructors: [DescriptionParser.ConstructorDescription])]
|
||||
) throws -> [(layerNumber: Int, types: [SumType])] {
|
||||
// Running state: for each constructor name, the target type name and the raw description.
|
||||
// We keep raw descriptions (not resolved forms) because a later-layer constructor may
|
||||
// introduce new target-type names, and resolveTypeReference needs the final target-type set.
|
||||
var liveConstructors: [QualifiedName: (typeName: QualifiedName, description: DescriptionParser.ConstructorDescription)] = [:]
|
||||
var result: [(layerNumber: Int, types: [SumType])] = []
|
||||
|
||||
for (layerNumber, layerConstructors) in layers {
|
||||
// Apply this layer's constructors to the running map with last-wins semantics.
|
||||
for constructorDescription in layerConstructors {
|
||||
switch constructorDescription.type {
|
||||
case let .type(name):
|
||||
if !name.value[name.value.startIndex].isUppercase {
|
||||
throw ResolutionError(text: "Type constructor \(constructorDescription.name) -> \(name): the resulting type name should begin with a capital letter")
|
||||
}
|
||||
liveConstructors[constructorDescription.name] = (name, constructorDescription)
|
||||
case let .generic(name, argumentType):
|
||||
throw ResolutionError(text: "Type constructor \(constructorDescription.name) can not be used to construct a generic type \(name)<\(argumentType)>")
|
||||
}
|
||||
}
|
||||
|
||||
// Note: a constructor reassigned to a different target type in a later layer is
|
||||
// removed from its old type's constructor set. If that drops the old type to zero
|
||||
// constructors, it vanishes from this snapshot, and any unrelated argument that
|
||||
// still references the old type via boxedType(...) will fail to resolve here.
|
||||
// secret_scheme.tl does not exercise this case (same-name constructors always
|
||||
// target the same type), but the rule applies if a future layered schema does.
|
||||
// Snapshot: group by target type, resolve.
|
||||
var constructedTypes: [QualifiedName: [DescriptionParser.ConstructorDescription]] = [:]
|
||||
var constructorNameToType: [QualifiedName: QualifiedName] = [:]
|
||||
for (ctorName, entry) in liveConstructors {
|
||||
constructedTypes[entry.typeName, default: []].append(entry.description)
|
||||
constructorNameToType[ctorName] = entry.typeName
|
||||
}
|
||||
|
||||
func resolveTypeReference(description: DescriptionParser.TypeReferenceDescription) throws -> TypeReference {
|
||||
switch description {
|
||||
case let .type(name):
|
||||
if let resolvedBuiltinType = resolveBuiltinType(name: name) {
|
||||
return resolvedBuiltinType
|
||||
}
|
||||
if name.value[name.value.startIndex].isUppercase {
|
||||
if let _ = constructedTypes[name] {
|
||||
return .boxedType(name)
|
||||
} else {
|
||||
throw ResolutionError(text: "Unresolved type \(name) in layer \(layerNumber)")
|
||||
}
|
||||
} else {
|
||||
if let typeName = constructorNameToType[name] {
|
||||
return .bareConstructor(typeName: typeName, name: name)
|
||||
} else {
|
||||
throw ResolutionError(text: "Unresolved type constructor \(name) in layer \(layerNumber)")
|
||||
}
|
||||
}
|
||||
case let .generic(name, argumentType):
|
||||
if name == "vector" {
|
||||
return .bareVector(try resolveTypeReference(description: .type(name: argumentType)))
|
||||
} else if name == "Vector" {
|
||||
return .boxedVector(try resolveTypeReference(description: .type(name: argumentType)))
|
||||
} else {
|
||||
throw ResolutionError(text: "Unresolved generic type \(name) in layer \(layerNumber)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveArgument(existingArguments: [Argument], description: DescriptionParser.ArgumentDescription) throws -> Argument {
|
||||
return Argument(
|
||||
name: description.name,
|
||||
type: try resolveTypeReference(description: description.type),
|
||||
condition: try description.condition.flatMap { condition -> Argument.Condition in
|
||||
if !existingArguments.contains(where: { $0.name == condition.fieldName }) {
|
||||
throw ResolutionError(text: "Unresolved conditional field reference to \(condition.fieldName) in layer \(layerNumber)")
|
||||
}
|
||||
return Argument.Condition(fieldName: condition.fieldName, bitIndex: condition.bitIndex)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var types: [QualifiedName: SumType] = [:]
|
||||
for (typeName, constructorDescriptions) in constructedTypes {
|
||||
let type = SumType(name: typeName)
|
||||
for constructorDescription in constructorDescriptions {
|
||||
var arguments: [Argument] = []
|
||||
for argumentDescription in constructorDescription.arguments {
|
||||
arguments.append(try resolveArgument(existingArguments: arguments, description: argumentDescription))
|
||||
}
|
||||
guard let id = constructorDescription.explicitId else {
|
||||
throw ResolutionError(text: "Constructor \(constructorDescription.name) does not have an id")
|
||||
}
|
||||
type.constructors[constructorDescription.name] = SumType.Constructor(
|
||||
name: constructorDescription.name,
|
||||
id: id,
|
||||
arguments: arguments
|
||||
)
|
||||
}
|
||||
types[type.name] = type
|
||||
}
|
||||
|
||||
let sortedTypes = types.values.sorted(by: { $0.name < $1.name })
|
||||
result.append((layerNumber, sortedTypes))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
static func resolveFunctions(types: [SumType], functionDescriptions: [DescriptionParser.ConstructorDescription]) throws -> [Function] {
|
||||
var functions: [QualifiedName: Function] = [:]
|
||||
|
||||
|
|
|
|||
|
|
@ -27,26 +27,15 @@ if CommandLine.arguments.count < 3 {
|
|||
|
||||
let schemeFilePath = CommandLine.arguments[1]
|
||||
let outputDirectoryPath = CommandLine.arguments[2]
|
||||
var apiPrefix = "Api"
|
||||
|
||||
var stubFunctions = false
|
||||
var printConstructorsRange: (start: Int, end: Int)? = nil
|
||||
for arg in CommandLine.arguments {
|
||||
if arg == "--stub-functions" {
|
||||
stubFunctions = true
|
||||
}
|
||||
if arg.hasPrefix("--print-constructors=") {
|
||||
let value = String(arg.dropFirst("--print-constructors=".count))
|
||||
let parts = value.split(separator: "-")
|
||||
if parts.count == 2, let start = Int(parts[0]), let end = Int(parts[1]) {
|
||||
if start > end {
|
||||
print("Error: Invalid range for --print-constructors: start (\(start)) must be <= end (\(end))")
|
||||
exit(1)
|
||||
}
|
||||
printConstructorsRange = (start, end)
|
||||
} else {
|
||||
print("Error: Invalid format for --print-constructors. Expected: --print-constructors=N-M (e.g., --print-constructors=0-10)")
|
||||
exit(1)
|
||||
}
|
||||
for arg in CommandLine.arguments[3...] {
|
||||
if arg.hasPrefix("--api-prefix=") {
|
||||
let value = String(arg.dropFirst("--api-prefix=".count))
|
||||
apiPrefix = value
|
||||
} else {
|
||||
print("Error: Unknown argument: \(arg)")
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,75 +44,63 @@ guard let data = try? String(contentsOfFile: schemeFilePath) else {
|
|||
exit(1)
|
||||
}
|
||||
|
||||
do {
|
||||
let parsedData = try DescriptionParser.parse(data: data)
|
||||
let resolvedTypes = try Resolver.resolveTypes(constructors: parsedData.constructors)
|
||||
var resolvedFunctions = try Resolver.resolveFunctions(types: resolvedTypes, functionDescriptions: parsedData.functions)
|
||||
|
||||
resolvedFunctions.append(Resolver.Function(name: QualifiedName(namespace: "help", value: "test"), id: 0xc0e202f7, arguments: [], result: .boxedType(QualifiedName(namespace: nil, value: "Bool"))))
|
||||
|
||||
var constructorOrder: [(typeName: QualifiedName, constructorName: String)] = []
|
||||
var typeOrder: [(types: [(typeName: QualifiedName, constructorNames: [String])], functions: [QualifiedName])] = []
|
||||
|
||||
let sortedTypes = resolvedTypes.sorted(by: { $0.name < $1.name })
|
||||
do {
|
||||
let parsedSchema = try DescriptionParser.parse(data: data)
|
||||
|
||||
if let range = printConstructorsRange {
|
||||
print("--- CONSTRUCTORS ---")
|
||||
for (index, type) in sortedTypes.enumerated() {
|
||||
if index >= range.start && index < range.end {
|
||||
for constructor in type.constructors.values.sorted(by: { $0.name < $1.name }) {
|
||||
let storedArguments = constructor.arguments.filter {
|
||||
if case .boolTrue = $0.type { return false }
|
||||
return true
|
||||
}
|
||||
if !storedArguments.isEmpty {
|
||||
let fieldNames = storedArguments.map { $0.name.camelCased }
|
||||
print("\(constructor.name.value):\(fieldNames.joined(separator: ","))")
|
||||
}
|
||||
}
|
||||
try FileManager.default.createDirectory(at: URL(fileURLWithPath: outputDirectoryPath), withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
switch parsedSchema {
|
||||
case let .flat(constructors, functions):
|
||||
let resolvedTypes = try Resolver.resolveTypes(constructors: constructors)
|
||||
var resolvedFunctions = try Resolver.resolveFunctions(types: resolvedTypes, functionDescriptions: functions)
|
||||
|
||||
resolvedFunctions.append(Resolver.Function(name: QualifiedName(namespace: "help", value: "test"), id: 0xc0e202f7, arguments: [], result: .boxedType(QualifiedName(namespace: nil, value: "Bool"))))
|
||||
|
||||
var constructorOrder: [(typeName: QualifiedName, constructorName: String)] = []
|
||||
var typeOrder: [(types: [(typeName: QualifiedName, constructorNames: [String])], functions: [QualifiedName])] = []
|
||||
|
||||
let sortedTypes = resolvedTypes.sorted(by: { $0.name < $1.name })
|
||||
|
||||
for type in sortedTypes {
|
||||
for constructor in type.constructors.values.sorted(by: { $0.name < $1.name }) {
|
||||
constructorOrder.append((type.name, constructor.name.value))
|
||||
}
|
||||
}
|
||||
print("--- END CONSTRUCTORS ---")
|
||||
print("Total types: \(sortedTypes.count)")
|
||||
exit(0)
|
||||
}
|
||||
|
||||
for type in sortedTypes {
|
||||
for constructor in type.constructors.values.sorted(by: { $0.name < $1.name }) {
|
||||
constructorOrder.append((type.name, constructor.name.value))
|
||||
var totalConstructorCount = 0
|
||||
var currentConstructorCount = 0
|
||||
for type in sortedTypes {
|
||||
if typeOrder.isEmpty || currentConstructorCount >= 32 {
|
||||
typeOrder.append(([], []))
|
||||
currentConstructorCount = 0
|
||||
}
|
||||
typeOrder[typeOrder.count - 1].types.append((type.name, type.constructors.values.sorted(by: { $0.name < $1.name }).map(\.name.value)))
|
||||
currentConstructorCount += type.constructors.count
|
||||
totalConstructorCount += type.constructors.count
|
||||
if totalConstructorCount > 40 { }
|
||||
}
|
||||
}
|
||||
|
||||
var totalConstructorCount = 0
|
||||
var currentConstructorCount = 0
|
||||
for type in sortedTypes {
|
||||
if typeOrder.isEmpty || currentConstructorCount >= 32 {
|
||||
typeOrder.append(([], []))
|
||||
currentConstructorCount = 0
|
||||
|
||||
typeOrder.append(([], []))
|
||||
for function in resolvedFunctions.sorted(by: { $0.name < $1.name }) {
|
||||
typeOrder[typeOrder.count - 1].functions.append(function.name)
|
||||
}
|
||||
|
||||
typeOrder[typeOrder.count - 1].types.append((type.name, type.constructors.values.sorted(by: { $0.name < $1.name }).map(\.name.value)))
|
||||
|
||||
currentConstructorCount += type.constructors.count
|
||||
totalConstructorCount += type.constructors.count
|
||||
|
||||
if totalConstructorCount > 40 {
|
||||
|
||||
let generatedFiles = try CodeGenerator.generate(apiPrefix: apiPrefix, types: resolvedTypes, functions: resolvedFunctions, constructorOrder: constructorOrder, typeOrder: typeOrder)
|
||||
|
||||
for (name, fileData) in generatedFiles {
|
||||
let filePath = URL(fileURLWithPath: outputDirectoryPath).appendingPathComponent(name).path
|
||||
let _ = try? FileManager.default.removeItem(atPath: filePath)
|
||||
try fileData.write(toFile: filePath, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
case let .layered(layers):
|
||||
let resolvedLayers = try Resolver.resolveLayeredTypes(layers: layers)
|
||||
for (layerNumber, types) in resolvedLayers {
|
||||
let (filename, source) = try CodeGenerator.generateLayered(apiPrefix: apiPrefix, layerNumber: layerNumber, types: types)
|
||||
let filePath = URL(fileURLWithPath: outputDirectoryPath).appendingPathComponent(filename).path
|
||||
let _ = try? FileManager.default.removeItem(atPath: filePath)
|
||||
try source.write(toFile: filePath, atomically: true, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
typeOrder.append(([], []))
|
||||
for function in resolvedFunctions.sorted(by: { $0.name < $1.name }) {
|
||||
typeOrder[typeOrder.count - 1].functions.append(function.name)
|
||||
}
|
||||
|
||||
try FileManager.default.createDirectory(at: URL(fileURLWithPath: outputDirectoryPath), withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
let generatedFiles = try CodeGenerator.generate(types: resolvedTypes, functions: resolvedFunctions, constructorOrder: constructorOrder, typeOrder: typeOrder, stubFunctions: stubFunctions)
|
||||
|
||||
for (name, fileData) in generatedFiles {
|
||||
let filePath = URL(fileURLWithPath: outputDirectoryPath).appendingPathComponent(name).path
|
||||
let _ = try? FileManager.default.removeItem(atPath: filePath)
|
||||
try fileData.write(toFile: filePath, atomically: true, encoding: .utf8)
|
||||
}
|
||||
} catch let e {
|
||||
print("\(e)")
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 7967e453d1f54963935a1ae06808e1c64420bfb1
|
||||
Subproject commit a99414ad848c3aeb84640934352ecc85d8a937f5
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit f6aef7db5b649ffc3b7497ecc83a08ad3ab19559
|
||||
Subproject commit 1791d916de4083388f22e20248d8b010d23f0d6b
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit 2b9261134a123ad83f8b1d2dc7fc69ae83fe7172
|
||||
Subproject commit 9dce728ed1e9168ec8c912fcd3443dad48a286fe
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit 79f26ec1315531a8459fe95934440cb3ca3c16df
|
||||
Subproject commit 997f2db058596f91663e54782b79490de87208da
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
require 'base64'
|
||||
require 'openssl'
|
||||
require 'securerandom'
|
||||
|
||||
class EncryptionV1
|
||||
ALGORITHM = 'aes-256-cbc'
|
||||
|
||||
def decrypt(encrypted_data:, password:, salt:, hash_algorithm: "MD5")
|
||||
cipher = ::OpenSSL::Cipher.new(ALGORITHM)
|
||||
cipher.decrypt
|
||||
|
||||
keyivgen(cipher, password, salt, hash_algorithm)
|
||||
|
||||
data = cipher.update(encrypted_data)
|
||||
data << cipher.final
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def keyivgen(cipher, password, salt, hash_algorithm)
|
||||
cipher.pkcs5_keyivgen(password, salt, 1, hash_algorithm)
|
||||
end
|
||||
end
|
||||
|
||||
# The newer encryption mechanism, which features a more secure key and IV generation.
|
||||
#
|
||||
# The IV is randomly generated and provided unencrypted.
|
||||
# The salt should be randomly generated and provided unencrypted (like in the current implementation).
|
||||
# The key is generated with OpenSSL::KDF::pbkdf2_hmac with properly chosen parameters.
|
||||
#
|
||||
# Short explanation about salt and IV: https://stackoverflow.com/a/1950674/6324550
|
||||
class EncryptionV2
|
||||
ALGORITHM = 'aes-256-gcm'
|
||||
|
||||
def decrypt(encrypted_data:, password:, salt:, auth_tag:)
|
||||
cipher = ::OpenSSL::Cipher.new(ALGORITHM)
|
||||
cipher.decrypt
|
||||
|
||||
keyivgen(cipher, password, salt)
|
||||
|
||||
cipher.auth_tag = auth_tag
|
||||
|
||||
data = cipher.update(encrypted_data)
|
||||
data << cipher.final
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def keyivgen(cipher, password, salt)
|
||||
keyIv = ::OpenSSL::KDF.pbkdf2_hmac(password, salt: salt, iterations: 10_000, length: 32 + 12 + 24, hash: "sha256")
|
||||
key = keyIv[0..31]
|
||||
iv = keyIv[32..43]
|
||||
auth_data = keyIv[44..-1]
|
||||
|
||||
#puts "key: #{key.inspect}"
|
||||
#puts "iv: #{iv.inspect}"
|
||||
#puts "auth_data: #{auth_data.inspect}"
|
||||
|
||||
cipher.key = key
|
||||
cipher.iv = iv
|
||||
cipher.auth_data = auth_data
|
||||
end
|
||||
end
|
||||
|
||||
class MatchDataEncryption
|
||||
V1_PREFIX = "Salted__"
|
||||
V2_PREFIX = "match_encrypted_v2__"
|
||||
|
||||
def decrypt(base64encoded_encrypted:, password:)
|
||||
stored_data = Base64.decode64(base64encoded_encrypted)
|
||||
if stored_data.start_with?(V2_PREFIX)
|
||||
salt = stored_data[20..27]
|
||||
auth_tag = stored_data[28..43]
|
||||
data_to_decrypt = stored_data[44..-1]
|
||||
|
||||
e = EncryptionV2.new
|
||||
e.decrypt(encrypted_data: data_to_decrypt, password: password, salt: salt, auth_tag: auth_tag)
|
||||
else
|
||||
salt = stored_data[8..15]
|
||||
data_to_decrypt = stored_data[16..-1]
|
||||
e = EncryptionV1.new
|
||||
begin
|
||||
# Note that we are not guaranteed to catch the decryption errors here if the password or the hash is wrong
|
||||
# as there's no integrity checks.
|
||||
# see https://github.com/fastlane/fastlane/issues/21663
|
||||
e.decrypt(encrypted_data: data_to_decrypt, password: password, salt: salt)
|
||||
# With the wrong hash_algorithm, there's here 0.4% chance that the decryption failure will go undetected
|
||||
rescue => _ex
|
||||
# With a wrong password, there's a 0.4% chance it will decrypt garbage and not fail
|
||||
fallback_hash_algorithm = "SHA256"
|
||||
e.decrypt(encrypted_data: data_to_decrypt, password: password, salt: salt, hash_algorithm: fallback_hash_algorithm)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
class MatchFileEncryption
|
||||
def decrypt(file_path:, password:, output_path: nil)
|
||||
output_path = file_path unless output_path
|
||||
content = File.read(file_path)
|
||||
e = MatchDataEncryption.new
|
||||
decrypted_data = e.decrypt(base64encoded_encrypted: content, password: password)
|
||||
File.binwrite(output_path, decrypted_data)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
if ARGV.length != 3
|
||||
print 'Invalid command line'
|
||||
else
|
||||
dec = MatchFileEncryption.new
|
||||
dec.decrypt(file_path: ARGV[1], password: ARGV[0], output_path: ARGV[2])
|
||||
end
|
||||
|
|
@ -0,0 +1,983 @@
|
|||
# Postbox → TelegramEngine refactor, wave 1 — 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:** Drop the direct `import Postbox` dependency from the first 10 leaf consumer submodules (one file each), routing data access through `TelegramEngine` while preserving behavior exactly.
|
||||
|
||||
**Architecture:** For each of the 10 modules, apply the same deterministic playbook: inventory every Postbox reference in its single Postbox-importing file, swap bare Postbox type names for their engine typealiases (`PeerId` → `EnginePeer.Id`, etc.), replace imperative Postbox calls with existing engine methods or new thin engine wrappers added to TelegramCore in a preparatory commit, remove `import Postbox` and the Bazel dep, and run the full project build to verify.
|
||||
|
||||
**Tech Stack:** Swift, Bazel (primary build system), Postbox (storage lib being made opaque), TelegramCore + TelegramEngine (the public facade), SSignalKit (signals).
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md](../specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md)
|
||||
|
||||
---
|
||||
|
||||
## Background the executor needs
|
||||
|
||||
There are no unit tests in this project (`CLAUDE.md`: "No tests are used at the moment"). **The only verification is the full project build.** Every task ends with a full build that must go green before the next task starts.
|
||||
|
||||
### The full build command
|
||||
|
||||
Run from the repo root (`/Users/ali/build/telegram/telegram-ios`):
|
||||
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null; \
|
||||
PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH \
|
||||
python3 build-system/Make/Make.py --overrideXcodeVersion \
|
||||
--cacheDir ~/telegram-bazel-cache \
|
||||
build \
|
||||
--configurationPath build-system/appstore-configuration.json \
|
||||
--gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \
|
||||
--gitCodesigningType development \
|
||||
--gitCodesigningUseCurrent \
|
||||
--buildNumber 1 \
|
||||
--configuration debug_sim_arm64
|
||||
```
|
||||
|
||||
(`source ~/.zshrc` picks up `TELEGRAM_CODESIGNING_GIT_PASSWORD` and other env exports that the Claude Code bash shell doesn't inherit by default.)
|
||||
|
||||
It is slow. Do not shortcut it with `bazel build //submodules/X` — the spec chose full build per module.
|
||||
|
||||
### Engine typealias cheat sheet (already in TelegramCore)
|
||||
|
||||
When removing `import Postbox`, bare Postbox names in the file must be swapped for their engine equivalents. The ones that exist as typealiases today (confirmed by grep over `submodules/TelegramCore/Sources/TelegramEngine/`):
|
||||
|
||||
- `PeerId` → `EnginePeer.Id`
|
||||
- `MessageId` → `EngineMessage.Id`
|
||||
- `MessageIndex` → `EngineMessage.Index`
|
||||
- `MessageTags` → `EngineMessage.Tags`
|
||||
- `MessageAttribute` → `EngineMessage.Attribute`
|
||||
- `MessageFlags` → `EngineMessage.Flags`
|
||||
- `MessageForwardInfo` → `EngineMessage.ForwardInfo`
|
||||
- `MediaId` → `EngineMedia.Id`
|
||||
- `PreferencesEntry` → `EnginePreferencesEntry`
|
||||
- `TempBox` (the singleton helper) → `EngineTempBox`
|
||||
- `PinnedItemId` → `EngineChatList.PinnedItem.Id`
|
||||
|
||||
If a task needs a Postbox type that has **no** existing engine typealias, the task may add one in `TelegramCore` (trivial `public typealias EngineX = X`) in the preparatory commit — this is explicitly allowed by the spec.
|
||||
|
||||
### Engine wrapper locations (per the spec)
|
||||
|
||||
- Data reads / subscriptions → new `TelegramEngine.EngineData.Item.<Area>.<Name>` in `submodules/TelegramCore/Sources/TelegramEngine/Data/<Area>Data.swift`.
|
||||
- Imperative signal-returning calls → new method on `Peers` / `Messages` / `Resources` / `AccountData` under `submodules/TelegramCore/Sources/TelegramEngine/<Area>/`.
|
||||
- Media-resource access → extend `engine.resources` (e.g. `engine.resources.data(...)`, `engine.resources.status(...)`), forwarding to `account.postbox.mediaBox.*` internally.
|
||||
- Consumer-run `account.postbox.transaction { ... }` → a specific purpose-built engine method. No generic transaction escape hatch.
|
||||
|
||||
### Static-check commands (run before the build in every task)
|
||||
|
||||
```bash
|
||||
grep -R "^import Postbox" submodules/<M>/Sources # must return empty
|
||||
grep "submodules/Postbox" submodules/<M>/BUILD # must return empty
|
||||
```
|
||||
|
||||
### Commit convention
|
||||
|
||||
Per module, up to two commits (optional first, required second):
|
||||
|
||||
1. `TelegramCore: add <wrapper name>` — only if new engine wrappers were needed.
|
||||
2. `<ModuleName>: drop direct Postbox dependency` — consumer edits + BUILD change.
|
||||
|
||||
Always use a HEREDOC commit body. No `--amend`. Every commit must build.
|
||||
|
||||
**TelegramCore wrapper commit template** (used by any task's Step 2a when engine wrappers/typealiases are added):
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/...
|
||||
git commit -m "$(cat <<'EOF'
|
||||
TelegramCore: add <wrapper name(s)>
|
||||
|
||||
Prepares for <ModuleName> to drop Postbox.
|
||||
Searched TelegramEngine/ for existing equivalents: <found list, or "not found">.
|
||||
<one-line summary of what each wrapper exposes>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### Build-failure handling (applies to every task's Step 6)
|
||||
|
||||
When the full build fails after a consumer edit:
|
||||
|
||||
- Read the **first** compiler error in the build output.
|
||||
- If it's a name-resolution or type error in the module file being refactored, fix the mapping in that file and rebuild.
|
||||
- If it's in a **different** module that depends on the module being refactored, a public signature changed unexpectedly. Either (a) revert that signature change so the public surface stays identical, or (b) if the new surface is genuinely better, extend the fix to the downstream call site **in the same commit**.
|
||||
- If fixing would require editing a module outside the wave-1 list — or would require aliasing an umbrella type banned by spec rule 2 (`Postbox`, `Account`, `MediaBox`) — revert all changes from the current task and mark the module **Abandoned** in its task body with a one-line reason. Do NOT substitute a different module; the wave's done-count simply goes down by one.
|
||||
|
||||
### The 10 modules (from the spec's deterministic selection rule)
|
||||
|
||||
Reverse-dep count (over the 30-candidate pool) ascending, alphabetical tiebreak. Verified by running the selection script in Task 0:
|
||||
|
||||
1. ActionSheetPeerItem — **ABANDONED** (see Task 1 body). Public init takes `postbox: Postbox`; ShareController caller is out-of-wave.
|
||||
2. ChatInterfaceState — `submodules/ChatInterfaceState/Sources/ChatInterfaceState.swift` — DONE
|
||||
3. ChatListSearchRecentPeersNode — **ABANDONED** (see Task 3 body). Public init takes `postbox: Postbox`; ShareController + ChatListUI callers are out-of-wave.
|
||||
4. ChatSendMessageActionUI — `submodules/ChatSendMessageActionUI/Sources/ChatSendMessageContextScreen.swift`
|
||||
5. ContactListUI — `submodules/ContactListUI/Sources/ContactListNode.swift`
|
||||
6. DirectMediaImageCache — **ABANDONED** (see Task 6 body). Public init takes `account: Account`; six out-of-wave callers.
|
||||
7. DrawingUI — `submodules/DrawingUI/Sources/DrawingScreen.swift`
|
||||
8. FetchManagerImpl — **ABANDONED** (see Task 8 body). Public init takes `postbox: Postbox`; TelegramUI caller is out-of-wave.
|
||||
9. GalleryData — **ABANDONED** (see Task 9 body). Four public functions take `Media`/`Message` as parameters; refactor cascades into many out-of-wave downstream types (`AvatarGalleryEntry`, `MessageReference`, etc.). Good candidate for a bespoke future wave that migrates the domain types together.
|
||||
10. ICloudResources — **ABANDONED** (see Task 10 body). Class conforms to `TelegramMediaResource` and inherits `isEqual(to: MediaResource)`; overriding that without aliasing the `MediaResource` protocol isn't possible.
|
||||
|
||||
**Wave-1 done-count: 4** (Tasks 2, 4, 5, 7 done; Tasks 1, 3, 6, 8, 9, 10 abandoned).
|
||||
|
||||
Per the spec's **abandonment protocol**, if a module hits an unresolvable blocker (requires aliasing an umbrella type such as `Postbox`/`Account`/`MediaBox`, or requires editing a module outside the wave-1 list), it is marked Abandoned in its task body and **not substituted**. The wave's done-count goes down by one; fallback modules are not pulled into the wave mid-execution. A later wave can revisit the abandoned module with tools not available in wave 1 (e.g. a real engine wrapper rather than a typealias, or a refactor that migrates the caller first).
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Verify selection and baseline build
|
||||
|
||||
**Files:**
|
||||
- Read: `submodules/<each module>/BUILD`
|
||||
|
||||
- [ ] **Step 1: Re-run the selection script to confirm the 10**
|
||||
|
||||
Save and run this Python snippet from the repo root. It should output exactly the 10 modules listed above, in that order.
|
||||
|
||||
```bash
|
||||
python3 <<'EOF'
|
||||
import os, re
|
||||
pool = ["ActionSheetPeerItem","ChatInterfaceState","ChatListSearchRecentPeersNode","ChatSendMessageActionUI","ContactListUI","DirectMediaImageCache","DrawingUI","FetchManagerImpl","GalleryData","HorizontalPeerItem","ICloudResources","InAppPurchaseManager","InstantPageCache","InviteLinksUI","ItemListAvatarAndNameInfoItem","ItemListPeerItem","ItemListStickerPackItem","MapResourceToAvatarSizes","PhotoResources","PlatformRestrictionMatching","PresentationDataUtils","PromptUI","SaveToCameraRoll","SelectablePeerNode","ShareItems","SoftwareVideo","StickerPeekUI","StickerResources","TelegramIntents","TelegramNotices"]
|
||||
deps = {}
|
||||
for m in pool:
|
||||
p = f"submodules/{m}/BUILD"
|
||||
txt = open(p).read() if os.path.exists(p) else ""
|
||||
deps[m] = {o for o in pool if o != m and re.search(rf'//submodules/{re.escape(o)}(:|"|$)', txt)}
|
||||
rdep = {m:0 for m in pool}
|
||||
for m,ds in deps.items():
|
||||
for d in ds: rdep[d]+=1
|
||||
for m in sorted(pool, key=lambda m:(rdep[m],m))[:10]:
|
||||
print(m, rdep[m])
|
||||
EOF
|
||||
```
|
||||
|
||||
Expected output (one per line): `ActionSheetPeerItem 0`, `ChatInterfaceState 0`, `ChatListSearchRecentPeersNode 0`, `ChatSendMessageActionUI 0`, `ContactListUI 0`, `DirectMediaImageCache 0`, `DrawingUI 0`, `FetchManagerImpl 0`, `GalleryData 0`, `ICloudResources 0`.
|
||||
|
||||
If the output differs, stop and investigate — someone changed a BUILD file since the spec was written.
|
||||
|
||||
- [ ] **Step 2: Run the baseline full build**
|
||||
|
||||
Run the full build command above. Expected: PASS (green master). If it fails, stop — we need a green baseline before changing anything. Do not attempt to fix pre-existing build breakage as part of this plan.
|
||||
|
||||
- [ ] **Step 3: No commit**
|
||||
|
||||
Task 0 produces no code changes.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Refactor `ActionSheetPeerItem` — **ABANDONED**
|
||||
|
||||
**Status:** Abandoned for wave 1. No code changes in this repo from this task.
|
||||
|
||||
**Reason:** Refactoring this module requires either (a) typealiasing the `Postbox` class itself (banned — see spec §Guiding rules rule 2: umbrella-type typealiases rename without encapsulating) or (b) editing `submodules/ShareController/` which is not in the wave-1 list. The module's designated init takes `postbox: Postbox` as a parameter and its sole out-of-wave caller (ShareController) passes `info.account.stateManager.postbox` directly, so there is no path to drop the `import Postbox` here without crossing the wave boundary or violating rule 2. Per the spec's **abandonment protocol**, the module is skipped for this wave. Wave-1 done-count is therefore 9, not 10.
|
||||
|
||||
**Original task body (retained for audit trail, do not implement):**
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/ActionSheetPeerItem/Sources/ActionSheetPeerItem.swift`
|
||||
- Modify: `submodules/ActionSheetPeerItem/BUILD`
|
||||
|
||||
**Starting inventory** (computed during planning):
|
||||
|
||||
Grep for common Postbox API/type names in `ActionSheetPeerItem.swift` returned zero hits on `mediaBox`, `transaction`, `PostboxView`, `combinedView`, `PeerId`, `MessageId`, `MediaResource`, `CachedPeerData`, etc. The `import Postbox` line appears unused. Confirm this during inventory — it's the most likely case, but other Postbox symbols (e.g. types referenced inside a parameter type) may still be present. (Subsequent inventory discovered the module does take `postbox: Postbox` as a parameter type — this is what makes the module unrefactorable under the wave-1 rules.)
|
||||
|
||||
- [ ] **Step 1: Inventory**
|
||||
|
||||
Read `submodules/ActionSheetPeerItem/Sources/ActionSheetPeerItem.swift` top to bottom. Record every identifier that is Postbox-owned. If the inventory is empty, skip straight to Step 4.
|
||||
|
||||
Run this helper grep too:
|
||||
|
||||
```bash
|
||||
grep -nE "\b(PeerId|MessageId|MessageIndex|MessageTags|MessageAttribute|MessageFlags|Peer|Media|MediaId|MediaResource|PostboxView|CachedPeerData|PreferencesEntry|ChatListIndex|PeerReference|TelegramMediaFile|TelegramMediaImage|Namespaces|TempBox)\b" submodules/ActionSheetPeerItem/Sources/ActionSheetPeerItem.swift
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Map each reference to a replacement**
|
||||
|
||||
For each finding from Step 1, decide: existing engine typealias (see cheat sheet), existing engine method, existing TelegramCore non-Postbox export, or new engine wrapper. Record the mapping in your working notes. If a new wrapper is needed, it is added in Task 1a before Task 1 continues.
|
||||
|
||||
- [ ] **Step 2a: (Only if Step 2 identified a missing engine wrapper/typealias) Add to TelegramCore**
|
||||
|
||||
Edit the relevant file under `submodules/TelegramCore/Sources/TelegramEngine/<Area>/` or `submodules/TelegramCore/Sources/TelegramEngine/Data/<Area>Data.swift`, following the wrapper-location rules in the Background section. Keep the wrapper minimal: a single typealias for name-only adds, or a thin method that forwards to the underlying Postbox call for imperative ones.
|
||||
|
||||
Run the full build. It must pass. Commit:
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/...
|
||||
git commit -m "$(cat <<'EOF'
|
||||
TelegramCore: add <wrapper name>
|
||||
|
||||
Prepares for ActionSheetPeerItem to drop Postbox.
|
||||
<one-line summary of what the wrapper exposes>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Skip this step if Step 2 didn't identify any missing wrappers.
|
||||
|
||||
- [ ] **Step 3: Edit the consumer file**
|
||||
|
||||
In `submodules/ActionSheetPeerItem/Sources/ActionSheetPeerItem.swift`:
|
||||
|
||||
- Apply every mapping from Step 2.
|
||||
- Remove the line `import Postbox`.
|
||||
|
||||
- [ ] **Step 4: Drop the Bazel dep**
|
||||
|
||||
Edit `submodules/ActionSheetPeerItem/BUILD`. Remove the line `"//submodules/Postbox:Postbox",` from the `deps` array. Leave the rest of the BUILD untouched.
|
||||
|
||||
- [ ] **Step 5: Static checks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -R "^import Postbox" submodules/ActionSheetPeerItem/Sources # expect: empty
|
||||
grep "submodules/Postbox" submodules/ActionSheetPeerItem/BUILD # expect: empty
|
||||
```
|
||||
|
||||
Both must return no output. If either produces a hit, go back to Step 3 or Step 4.
|
||||
|
||||
- [ ] **Step 6: Full project build**
|
||||
|
||||
Run the full build command from the Background section. Expected: PASS.
|
||||
|
||||
If it fails:
|
||||
- Read the first error. If it's a name-resolution error in `ActionSheetPeerItem.swift`, fix the mapping and rebuild.
|
||||
- If it's in a *different* module that depends on `ActionSheetPeerItem`, you changed a public signature unexpectedly; either revert that signature change or, if it's genuinely better, extend the fix to that downstream call site in the same commit.
|
||||
- If the fix would require editing a module outside the wave-1 list, revert all Task 1 changes and skip to the next fallback module listed in the Background section.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/ActionSheetPeerItem/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
ActionSheetPeerItem: drop direct Postbox dependency
|
||||
|
||||
Route data access through TelegramEngine/TelegramCore; remove the
|
||||
Postbox import and Bazel dep. Behavior-preserving.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Refactor `ChatInterfaceState`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/ChatInterfaceState/Sources/ChatInterfaceState.swift`
|
||||
- Modify: `submodules/ChatInterfaceState/BUILD`
|
||||
|
||||
**Starting inventory** (computed during planning): file references `MessageId` (×2) and `MediaResource` (×3). No `mediaBox`, `transaction`, `combinedView`, or `PostboxView` usage. This is a **type-reference-only** case — expected replacements are `MessageId` → `EngineMessage.Id` and `MediaResource` stays as-is only if a typealias exists, otherwise a typealias `EngineMediaResource = MediaResource` is added in TelegramCore.
|
||||
|
||||
- [ ] **Step 1: Inventory**
|
||||
|
||||
Read `submodules/ChatInterfaceState/Sources/ChatInterfaceState.swift`. Confirm the grep below matches the planning inventory and records exact line numbers and declaration contexts (parameter types, property types, return types, generic arguments).
|
||||
|
||||
```bash
|
||||
grep -nE "\b(PeerId|MessageId|MessageIndex|MessageTags|MessageAttribute|MessageFlags|Peer|Media|MediaId|MediaResource|PostboxView|CachedPeerData|PreferencesEntry|ChatListIndex|PeerReference|TelegramMediaFile|TelegramMediaImage|Namespaces|TempBox)\b" submodules/ChatInterfaceState/Sources/ChatInterfaceState.swift
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Map each reference**
|
||||
|
||||
- `MessageId` → `EngineMessage.Id` (existing typealias, no wrapper needed).
|
||||
- `MediaResource`: search `submodules/TelegramCore/Sources/TelegramEngine/` for a `public typealias Engine.*Resource.*= MediaResource`. If present, use it. If absent, proceed to Step 2a and add a typealias `public typealias EngineMediaResource = MediaResource` in `submodules/TelegramCore/Sources/TelegramEngine/Resources/` (new file `EngineMediaResource.swift`, or the most natural existing file in that folder).
|
||||
|
||||
- [ ] **Step 2a: (Only if needed) Add engine typealias(es) in TelegramCore**
|
||||
|
||||
For each Postbox type without an engine typealias, add a `public typealias Engine<Name> = <PostboxName>` in the appropriate TelegramEngine area file. Do not introduce any new wrapper structs.
|
||||
|
||||
Run full build, expect PASS. Commit:
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/...
|
||||
git commit -m "$(cat <<'EOF'
|
||||
TelegramCore: add engine typealiases for <list>
|
||||
|
||||
Prepares for ChatInterfaceState to drop Postbox.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Edit the consumer file**
|
||||
|
||||
Apply the mappings from Step 2 to every reference. Remove the line `import Postbox`.
|
||||
|
||||
- [ ] **Step 4: Drop the Bazel dep**
|
||||
|
||||
Edit `submodules/ChatInterfaceState/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`.
|
||||
|
||||
- [ ] **Step 5: Static checks**
|
||||
|
||||
```bash
|
||||
grep -R "^import Postbox" submodules/ChatInterfaceState/Sources # expect: empty
|
||||
grep "submodules/Postbox" submodules/ChatInterfaceState/BUILD # expect: empty
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Full project build**
|
||||
|
||||
Run the full build command. Expected: PASS. Handle failures per the rules in Task 1 Step 6.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/ChatInterfaceState/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
ChatInterfaceState: drop direct Postbox dependency
|
||||
|
||||
Switch remaining Postbox-typed references to engine typealiases;
|
||||
remove the Postbox import and Bazel dep. Behavior-preserving.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Refactor `ChatListSearchRecentPeersNode` — **ABANDONED**
|
||||
|
||||
**Status:** Abandoned for wave 1. No code changes in this repo from this task.
|
||||
|
||||
**Reason:** The module's public `init` at line 207 takes `postbox: Postbox` as a parameter. Two out-of-wave callers (`submodules/ShareController/Sources/ShareControllerRecentPeersGridItem.swift`, `submodules/ChatListUI/Sources/ChatListRecentPeersListItem.swift`) use this init. Refactoring requires either typealiasing the `Postbox` class (banned by spec rule 2) or editing those two out-of-wave modules (banned by wave boundary). Per the abandonment protocol, the module is skipped.
|
||||
|
||||
**Original task body (retained for audit trail, do not implement):**
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/ChatListSearchRecentPeersNode/Sources/ChatListSearchRecentPeersNode.swift`
|
||||
- Modify: `submodules/ChatListSearchRecentPeersNode/BUILD`
|
||||
|
||||
**Starting inventory** (computed during planning): file uses `postbox.transaction { ... }` (×2), `postbox.combinedView(...)` (×1), and references `TelegramMedia*` types (×3). This is the **first hard module** in the wave — it has real imperative Postbox calls that require engine wrappers, not just typealiases.
|
||||
|
||||
- [ ] **Step 1: Inventory**
|
||||
|
||||
Read the whole file. For each Postbox call, capture:
|
||||
- The call site (line number, containing function).
|
||||
- The `PostboxViewKey`(s) passed to `combinedView`.
|
||||
- What the closure body of each `transaction` does — the *intent*, not just the code. (This determines which engine method to add.)
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -nE "\b(postbox\.|mediaBox|transaction\s*\{|combinedView|PostboxView|PostboxViewKey|Namespaces\.|TelegramMedia|PeerId|MessageId)\b" submodules/ChatListSearchRecentPeersNode/Sources/ChatListSearchRecentPeersNode.swift
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Map each reference**
|
||||
|
||||
- `TelegramMedia*` — these classes are defined in `TelegramCore` (check `submodules/TelegramCore/Sources/`), not Postbox. After `import Postbox` is removed they remain reachable via `import TelegramCore`, which the file already imports. No action beyond confirming.
|
||||
- Each `postbox.combinedView` / view subscription → map to an existing `TelegramEngine.data.subscribe(...)` item if one exists for the same `PostboxViewKey`; if not, add an `EngineData.Item` under `submodules/TelegramCore/Sources/TelegramEngine/Data/<Area>Data.swift`.
|
||||
- Each `postbox.transaction { ... }` → a specific new method on the matching engine area (e.g. `TelegramEngine.Peers.recordRecentPeer(id:)` if that's what the closure does). Do **not** add a generic transaction passthrough.
|
||||
|
||||
Write the mapping down before editing. Each new engine method is small and focused.
|
||||
|
||||
- [ ] **Step 2a: Add engine wrappers in TelegramCore**
|
||||
|
||||
For each new `EngineData.Item` or engine method identified in Step 2:
|
||||
|
||||
- Add it to the appropriate file under `submodules/TelegramCore/Sources/TelegramEngine/<Area>/` (or `…/Data/<Area>Data.swift` for data items).
|
||||
- Keep the body to a minimal pass-through: the new engine method opens a transaction internally and calls the same Postbox code that the consumer was running; the new `EngineData.Item` forwards a `PostboxViewKey` in `keys()` and extracts its `PostboxView` in `extract()`.
|
||||
- Return engine-typed values where existing engine types are available; otherwise return primitives or `Void`. Do not return bare Postbox types.
|
||||
|
||||
Before editing TelegramCore, grep for existing wrappers covering the same need:
|
||||
|
||||
```bash
|
||||
grep -rn "<plausible method name>\|<PostboxViewKey case>" submodules/TelegramCore/Sources/TelegramEngine/
|
||||
```
|
||||
|
||||
Record "searched for X, found/not found" in the commit message.
|
||||
|
||||
Run the full build. Expected: PASS.
|
||||
|
||||
Commit:
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/...
|
||||
git commit -m "$(cat <<'EOF'
|
||||
TelegramCore: add <wrapper name(s)>
|
||||
|
||||
Prepares for ChatListSearchRecentPeersNode to drop Postbox.
|
||||
Searched TelegramEngine/ for existing equivalents: not found.
|
||||
<one-line summary of what each wrapper exposes>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Edit the consumer file**
|
||||
|
||||
Replace each `postbox.transaction` and `postbox.combinedView` call with the engine method/subscription added in Step 2a. Swap any Postbox-typed names for engine typealiases per the cheat sheet. Remove `import Postbox`.
|
||||
|
||||
- [ ] **Step 4: Drop the Bazel dep**
|
||||
|
||||
Edit `submodules/ChatListSearchRecentPeersNode/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`.
|
||||
|
||||
- [ ] **Step 5: Static checks**
|
||||
|
||||
```bash
|
||||
grep -R "^import Postbox" submodules/ChatListSearchRecentPeersNode/Sources # expect: empty
|
||||
grep "submodules/Postbox" submodules/ChatListSearchRecentPeersNode/BUILD # expect: empty
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Full project build**
|
||||
|
||||
Run the full build command. Expected: PASS. Handle failures per the Build-failure handling rules in the Background section.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/ChatListSearchRecentPeersNode/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
ChatListSearchRecentPeersNode: drop direct Postbox dependency
|
||||
|
||||
Route combined-view subscription and transactions through
|
||||
TelegramEngine; remove the Postbox import and Bazel dep.
|
||||
Behavior-preserving.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Refactor `ChatSendMessageActionUI`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/ChatSendMessageActionUI/Sources/ChatSendMessageContextScreen.swift`
|
||||
- Modify: `submodules/ChatSendMessageActionUI/BUILD`
|
||||
|
||||
**Starting inventory** (computed during planning): `mediaBox` (×2), `Peer` type reference (×1), `Media` type reference (×1), `MediaResource` (×1), `Namespaces.` (×1). The mediaBox calls are the substantive work.
|
||||
|
||||
- [ ] **Step 1: Inventory**
|
||||
|
||||
Read the whole file. Capture every `mediaBox` call — what resource is being asked for and what is done with the result (data read, status subscription, fetch start, path access)? Capture each Postbox-typed reference's line/context.
|
||||
|
||||
```bash
|
||||
grep -nE "\bmediaBox\b|\bNamespaces\.|\b(Peer|Media|MediaResource)\b" submodules/ChatSendMessageActionUI/Sources/ChatSendMessageContextScreen.swift
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Map each reference**
|
||||
|
||||
- Each `mediaBox.<op>` call → `engine.resources.<equivalent>(...)`. Check for an existing method on `TelegramEngine.Resources` first:
|
||||
```bash
|
||||
grep -rn "extension.*Resources\|public func" submodules/TelegramCore/Sources/TelegramEngine/Resources/
|
||||
```
|
||||
If an equivalent exists, use it. If not, add one in Step 2a.
|
||||
- `Peer`, `Media`, `MediaResource` type references → use `EnginePeer`, `EngineMedia`, or `EngineMediaResource` (add typealias if missing, per the cheat sheet).
|
||||
- `Namespaces.Peer.<case>` — defined in TelegramCore, not Postbox. Confirm via grep; no change needed.
|
||||
|
||||
- [ ] **Step 2a: (Only if needed) Add engine wrappers in TelegramCore**
|
||||
|
||||
Per the rules — minimal pass-through, return engine-typed values. Build, commit `TelegramCore: add <name>` per the template in Task 3 Step 2a.
|
||||
|
||||
- [ ] **Step 3: Edit the consumer file**
|
||||
|
||||
Apply mappings. Remove `import Postbox`.
|
||||
|
||||
- [ ] **Step 4: Drop the Bazel dep**
|
||||
|
||||
Edit `submodules/ChatSendMessageActionUI/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`.
|
||||
|
||||
- [ ] **Step 5: Static checks**
|
||||
|
||||
```bash
|
||||
grep -R "^import Postbox" submodules/ChatSendMessageActionUI/Sources # expect: empty
|
||||
grep "submodules/Postbox" submodules/ChatSendMessageActionUI/BUILD # expect: empty
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Full project build**
|
||||
|
||||
Run the full build. Expected: PASS. Handle failures per the Build-failure handling rules in the Background section.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/ChatSendMessageActionUI/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
ChatSendMessageActionUI: drop direct Postbox dependency
|
||||
|
||||
Route MediaBox calls through TelegramEngine.resources and switch
|
||||
type references to engine typealiases; remove the Postbox import
|
||||
and Bazel dep. Behavior-preserving.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Refactor `ContactListUI`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/ContactListUI/Sources/ContactListNode.swift`
|
||||
- Modify: `submodules/ContactListUI/BUILD`
|
||||
|
||||
**Starting inventory** (computed during planning): `postbox.transaction { ... }` (×4), `Peer` references (×15), `Namespaces.` (×1). Transactions are the substantive work; the 15 `Peer` references are likely in closures reading transaction state and will switch to engine-typed returns once the transactions are replaced.
|
||||
|
||||
- [ ] **Step 1: Inventory**
|
||||
|
||||
Read the whole file. For each `transaction` call, describe what the closure does — this drives what new engine methods to add. Capture every `Peer`-typed declaration.
|
||||
|
||||
```bash
|
||||
grep -nE "\btransaction\s*\{|account\.postbox|\b(Peer|PeerId|Namespaces)\b" submodules/ContactListUI/Sources/ContactListNode.swift
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Map each reference**
|
||||
|
||||
- Each `postbox.transaction { ... }` → a dedicated engine method under `submodules/TelegramCore/Sources/TelegramEngine/{Peers,Contacts,AccountData}/` capturing the closure's intent. Never add a generic transaction passthrough.
|
||||
- `Peer` type → `EnginePeer` where the value actually flows through the replaced engine method (the engine method should return `EnginePeer` / `[EnginePeer]`). For local variable types that receive the engine-method return, use the engine type.
|
||||
- `Namespaces.*` — defined in TelegramCore. No change.
|
||||
|
||||
Before adding methods, grep for existing engine functions that may already cover the intent:
|
||||
|
||||
```bash
|
||||
grep -rn "public func" submodules/TelegramCore/Sources/TelegramEngine/Contacts/
|
||||
grep -rn "public func" submodules/TelegramCore/Sources/TelegramEngine/Peers/ | head -60
|
||||
```
|
||||
|
||||
- [ ] **Step 2a: Add engine wrappers in TelegramCore**
|
||||
|
||||
Add each new method. Return engine-typed values. Build; then use the TelegramCore wrapper commit template from the Background section.
|
||||
|
||||
- [ ] **Step 3: Edit the consumer file**
|
||||
|
||||
Replace every `transaction` call with its engine method. Switch `Peer` locals to `EnginePeer`. Remove `import Postbox`.
|
||||
|
||||
- [ ] **Step 4: Drop the Bazel dep**
|
||||
|
||||
Edit `submodules/ContactListUI/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`.
|
||||
|
||||
- [ ] **Step 5: Static checks**
|
||||
|
||||
```bash
|
||||
grep -R "^import Postbox" submodules/ContactListUI/Sources # expect: empty
|
||||
grep "submodules/Postbox" submodules/ContactListUI/BUILD # expect: empty
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Full project build**
|
||||
|
||||
Run the full build. Expected: PASS. Handle failures per the Build-failure handling rules in the Background section. ContactListUI is imported by other submodules (TelegramUI, SettingsUI, etc.); downstream breakage is most likely here. If a downstream consumer needs a bare `Peer`, either keep the public surface returning engine types (preferred — they're typealiases under the hood) or, if the downstream change is large, revert Task 5 and skip.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/ContactListUI/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
ContactListUI: drop direct Postbox dependency
|
||||
|
||||
Replace direct postbox.transaction calls with dedicated engine
|
||||
methods; switch peer references to engine-typed equivalents;
|
||||
remove the Postbox import and Bazel dep. Behavior-preserving.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Refactor `DirectMediaImageCache` — **ABANDONED**
|
||||
|
||||
**Status:** Abandoned for wave 1. No code changes in this repo from this task.
|
||||
|
||||
**Reason:** The module's public `init(account: Account)` at line 241 takes `account: Account` (an umbrella type banned by spec rule 2). Out-of-wave callers include `submodules/CalendarMessageScreen/`, four TelegramUI components (`StoryContainerScreen`, `ShareWithPeersScreen`, `PeerInfoVisualMediaPaneNode` × 2), and `submodules/TelegramUI/Sources/AccountContext.swift`. Refactoring requires either aliasing `Account` (banned) or editing all those out-of-wave callers (banned). Per the abandonment protocol, the module is skipped.
|
||||
|
||||
**Original task body (retained for audit trail, do not implement):**
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/DirectMediaImageCache/Sources/DirectMediaImageCache.swift`
|
||||
- Modify: `submodules/DirectMediaImageCache/BUILD`
|
||||
|
||||
**Starting inventory** (computed during planning): `mediaBox` (×11), `PeerReference` (×6), `MediaResource` (×1), `TelegramMedia*` (×13), `Media`/`Message` type references. This module is **mediaBox-heavy** and is the canonical shape for the `engine.resources.*` extension work.
|
||||
|
||||
- [ ] **Step 1: Inventory**
|
||||
|
||||
Read the whole file. For each `mediaBox` call, record the method (`resourceData`, `resourceStatus`, `cachedResourceRepresentation`, `storeCachedResourceRepresentation`, `fetchedResource`, etc.) and whether it reads, writes, or subscribes.
|
||||
|
||||
```bash
|
||||
grep -nE "\bmediaBox\b|\b(PeerReference|MediaResource|TelegramMedia)" submodules/DirectMediaImageCache/Sources/DirectMediaImageCache.swift
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Map each reference**
|
||||
|
||||
Each distinct `mediaBox.<op>` signature → a method on `TelegramEngine.Resources`. Expected additions (names are suggestions — match existing naming if anything close already exists):
|
||||
|
||||
- `engine.resources.data(_:pathExtension:option:attemptSynchronously:) -> Signal<MediaResourceData, NoError>`
|
||||
- `engine.resources.status(_:approximateSynchronousValue:) -> Signal<MediaResourceStatus, NoError>`
|
||||
- `engine.resources.cachedRepresentationData(_:representation:complete:) -> Signal<...>`
|
||||
- `engine.resources.storeCachedRepresentation(_:representation:data:) -> Signal<Void, NoError>`
|
||||
|
||||
Before adding any of these, grep `submodules/TelegramCore/Sources/TelegramEngine/Resources/` for existing equivalents and only add what's missing.
|
||||
|
||||
`PeerReference`, `TelegramMedia*`, `MediaResource` — check each: `TelegramMedia*` types live in `TelegramCore` (not Postbox). `PeerReference` lives in `TelegramCore`. `MediaResource` is a Postbox protocol; add `EngineMediaResource = MediaResource` typealias if not already present.
|
||||
|
||||
- [ ] **Step 2a: Add engine wrappers in TelegramCore**
|
||||
|
||||
Add all missing methods on `Resources` and any missing typealiases. Each method is a one-line forward to `account.postbox.mediaBox.*`. Build; then commit using the TelegramCore wrapper commit template from the Background section, recording "searched Resources/ for equivalents: found/not found" in the message.
|
||||
|
||||
- [ ] **Step 3: Edit the consumer file**
|
||||
|
||||
Replace every `mediaBox.*` with `engine.resources.*`. This likely requires adding an `engine: TelegramEngine` parameter to a few internal functions in the file (or surfacing it from an existing `AccountContext` already in scope — prefer that). Switch types to engine typealiases. Remove `import Postbox`.
|
||||
|
||||
- [ ] **Step 4: Drop the Bazel dep**
|
||||
|
||||
Edit `submodules/DirectMediaImageCache/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`.
|
||||
|
||||
- [ ] **Step 5: Static checks**
|
||||
|
||||
```bash
|
||||
grep -R "^import Postbox" submodules/DirectMediaImageCache/Sources # expect: empty
|
||||
grep "submodules/Postbox" submodules/DirectMediaImageCache/BUILD # expect: empty
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Full project build**
|
||||
|
||||
Run the full build. Expected: PASS. Handle failures per the Build-failure handling rules in the Background section.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/DirectMediaImageCache/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
DirectMediaImageCache: drop direct Postbox dependency
|
||||
|
||||
Route MediaBox calls through TelegramEngine.resources; remove the
|
||||
Postbox import and Bazel dep. Behavior-preserving.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Refactor `DrawingUI`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/DrawingUI/Sources/DrawingScreen.swift`
|
||||
- Modify: `submodules/DrawingUI/BUILD`
|
||||
|
||||
**Starting inventory** (computed during planning): `transaction` (×3), `Media` type references (×13), `Namespaces.` (×4). Transactions are the substantive work; `Media`/`Namespaces` are mostly referencing TelegramCore-defined types already.
|
||||
|
||||
- [ ] **Step 1: Inventory**
|
||||
|
||||
Read the whole file. For each `transaction` call, describe what the closure does. Capture every `Media`-typed declaration and every `Namespaces.*` reference.
|
||||
|
||||
```bash
|
||||
grep -nE "\btransaction\s*\{|account\.postbox|\b(Media|MediaId|Namespaces)\b" submodules/DrawingUI/Sources/DrawingScreen.swift
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Map each reference**
|
||||
|
||||
- Each `postbox.transaction` → dedicated engine method. Inspect the closure to find the right home (`Stickers`, `Messages`, `Peers`, …).
|
||||
- `Media` → `EngineMedia` where the value flows through new engine methods; keep as `Media` (TelegramCore re-defined concrete classes like `TelegramMediaFile` live in TelegramCore and are fine) where the type is already TelegramCore's.
|
||||
- `Namespaces.*` — TelegramCore. No change.
|
||||
|
||||
- [ ] **Step 2a: Add engine wrappers in TelegramCore**
|
||||
|
||||
Add each new transaction-replacing method. Build; then use the TelegramCore wrapper commit template from the Background section.
|
||||
|
||||
- [ ] **Step 3: Edit the consumer file**
|
||||
|
||||
Apply mappings. Remove `import Postbox`.
|
||||
|
||||
- [ ] **Step 4: Drop the Bazel dep**
|
||||
|
||||
Edit `submodules/DrawingUI/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`.
|
||||
|
||||
- [ ] **Step 5: Static checks**
|
||||
|
||||
```bash
|
||||
grep -R "^import Postbox" submodules/DrawingUI/Sources # expect: empty
|
||||
grep "submodules/Postbox" submodules/DrawingUI/BUILD # expect: empty
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Full project build**
|
||||
|
||||
Run the full build. Expected: PASS. Handle failures per the Build-failure handling rules in the Background section.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/DrawingUI/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
DrawingUI: drop direct Postbox dependency
|
||||
|
||||
Replace direct postbox.transaction calls with dedicated engine
|
||||
methods; switch type references to engine equivalents; remove the
|
||||
Postbox import and Bazel dep. Behavior-preserving.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Refactor `FetchManagerImpl` — **ABANDONED**
|
||||
|
||||
**Status:** Abandoned for wave 1. No code changes in this repo from this task.
|
||||
|
||||
**Reason:** The module's public `init(postbox: Postbox, storeManager: DownloadedMediaStoreManager?)` at line 708 takes `postbox: Postbox`. Out-of-wave caller: `submodules/TelegramUI/Sources/AccountContext.swift:296`. Refactoring requires either aliasing the `Postbox` class (banned by spec rule 2) or editing TelegramUI (banned by wave boundary). Per the abandonment protocol, the module is skipped.
|
||||
|
||||
**Original task body (retained for audit trail, do not implement):**
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/FetchManagerImpl/Sources/FetchManagerImpl.swift`
|
||||
- Modify: `submodules/FetchManagerImpl/BUILD`
|
||||
|
||||
**Starting inventory** (computed during planning): `mediaBox` (×8), `MediaResource` (×4), `TelegramMedia` (×1). Shape is similar to DirectMediaImageCache — heavy `mediaBox` usage, no transactions.
|
||||
|
||||
- [ ] **Step 1: Inventory**
|
||||
|
||||
Read the whole file. For each `mediaBox` call, record the method and direction (read / subscribe / fetch-start).
|
||||
|
||||
```bash
|
||||
grep -nE "\bmediaBox\b|\b(MediaResource|TelegramMedia)\b" submodules/FetchManagerImpl/Sources/FetchManagerImpl.swift
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Map each reference**
|
||||
|
||||
Reuse every `engine.resources.*` method added in Task 6 — do not re-add them. Grep:
|
||||
|
||||
```bash
|
||||
grep -rn "extension.*Resources\|public func" submodules/TelegramCore/Sources/TelegramEngine/Resources/
|
||||
```
|
||||
|
||||
If this task needs a `mediaBox` operation that Task 6 did not add (e.g. `cancelInteractiveResourceFetch`, `completeInteractiveResourceFetch`), add it now in the same pattern.
|
||||
|
||||
`MediaResource` → `EngineMediaResource` typealias (already added in an earlier task if needed). `TelegramMedia` — TelegramCore type, no change.
|
||||
|
||||
- [ ] **Step 2a: (Only if needed) Add missing engine methods**
|
||||
|
||||
Minimal pass-through on `TelegramEngine.Resources`. Build, commit.
|
||||
|
||||
- [ ] **Step 3: Edit the consumer file**
|
||||
|
||||
Replace every `mediaBox.*` with `engine.resources.*`. Thread an `engine` argument through internal functions as needed (prefer reading it off an existing `AccountContext` already in scope). Remove `import Postbox`.
|
||||
|
||||
- [ ] **Step 4: Drop the Bazel dep**
|
||||
|
||||
Edit `submodules/FetchManagerImpl/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`.
|
||||
|
||||
- [ ] **Step 5: Static checks**
|
||||
|
||||
```bash
|
||||
grep -R "^import Postbox" submodules/FetchManagerImpl/Sources # expect: empty
|
||||
grep "submodules/Postbox" submodules/FetchManagerImpl/BUILD # expect: empty
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Full project build**
|
||||
|
||||
Run the full build. Expected: PASS. Handle failures per the Build-failure handling rules in the Background section.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/FetchManagerImpl/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
FetchManagerImpl: drop direct Postbox dependency
|
||||
|
||||
Route MediaBox fetch/status calls through TelegramEngine.resources;
|
||||
remove the Postbox import and Bazel dep. Behavior-preserving.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Refactor `GalleryData` — **ABANDONED**
|
||||
|
||||
**Status:** Abandoned for wave 1. No code changes in this repo from this task.
|
||||
|
||||
**Reason:** Four public functions take `Media` (Postbox protocol) and/or `Message` (Postbox class) as parameters, called from TelegramUI and ChatListUI (out-of-wave). Refactoring to `EngineMedia` / `EngineMessage` requires `.init(_:)` / `._asMedia()` / `._asMessage()` coercions threaded through many local variables (e.g. `var galleryMedia: Media?` in `chatMessageGalleryControllerData` is reassigned from various `TelegramMedia*` casts and passed to `MessageReference(...)` chains and enum cases), which would cascade into `AvatarGalleryEntry`, `MessageReference`, and other out-of-wave types. The narrow-utility alias path is ruled out because `Media` and especially `Message` are domain types, not utilities. Per the abandonment protocol, the module is skipped.
|
||||
|
||||
**Original task body (retained for audit trail, do not implement):**
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/GalleryData/Sources/GalleryData.swift`
|
||||
- Modify: `submodules/GalleryData/BUILD`
|
||||
|
||||
**Starting inventory** (computed during planning): `Peer` (×1), `Media` (×9), `Message` (×4), `Namespaces.` (×3), `TelegramMedia*` (×30). No `mediaBox`, no `transaction`, no `combinedView`. This is a **type-reference-only** case at scale.
|
||||
|
||||
- [ ] **Step 1: Inventory**
|
||||
|
||||
Read the whole file. Record every declaration that uses a Postbox-owned type (`Peer`, `Media`, `Message`, `MessageId`, etc.) — note that `TelegramMedia*` and `Namespaces` are TelegramCore, not Postbox, and do **not** need changing.
|
||||
|
||||
```bash
|
||||
grep -nE "\b(Peer|Media|Message|MessageId|MessageIndex)\b" submodules/GalleryData/Sources/GalleryData.swift | head -60
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Map each reference**
|
||||
|
||||
- `Peer` → `EnginePeer` at the call-site level where the value is newly produced; for existing public signatures that already accept a `Peer` from elsewhere, prefer `EnginePeer` **only if** downstream consumers accept it. Otherwise leave the signature alone and swap only the internal uses.
|
||||
- `Media`, `Message`, `MessageId` → engine typealiases per the cheat sheet.
|
||||
- `TelegramMedia*`, `Namespaces.*` — no change.
|
||||
|
||||
- [ ] **Step 2a: (Only if needed) Add engine typealiases**
|
||||
|
||||
Add any missing typealias in `submodules/TelegramCore/Sources/TelegramEngine/…`. Build, commit.
|
||||
|
||||
- [ ] **Step 3: Edit the consumer file**
|
||||
|
||||
Apply mappings. Remove `import Postbox`.
|
||||
|
||||
- [ ] **Step 4: Drop the Bazel dep**
|
||||
|
||||
Edit `submodules/GalleryData/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`.
|
||||
|
||||
- [ ] **Step 5: Static checks**
|
||||
|
||||
```bash
|
||||
grep -R "^import Postbox" submodules/GalleryData/Sources # expect: empty
|
||||
grep "submodules/Postbox" submodules/GalleryData/BUILD # expect: empty
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Full project build**
|
||||
|
||||
Run the full build. Expected: PASS. Handle failures per the Build-failure handling rules in the Background section.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/GalleryData/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
GalleryData: drop direct Postbox dependency
|
||||
|
||||
Switch Postbox-typed references to engine typealiases; remove the
|
||||
Postbox import and Bazel dep. Behavior-preserving.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Refactor `ICloudResources` — **ABANDONED**
|
||||
|
||||
**Status:** Abandoned for wave 1. No code changes in this repo from this task.
|
||||
|
||||
**Reason:** The module declares `public class ICloudFileResource: TelegramMediaResource` and thus must implement `func isEqual(to: MediaResource) -> Bool` (protocol requirement inherited from `MediaResource`). That override's parameter type is fixed at `MediaResource`, which can only be named by importing Postbox or adding a typealias for the raw `MediaResource` protocol. The protocol-alias would be borderline per rule 2; user directed to skip. Per the abandonment protocol, the module is skipped.
|
||||
|
||||
**Original task body (retained for audit trail, do not implement):**
|
||||
|
||||
### Original Task 10
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/ICloudResources/Sources/ICloudResources.swift`
|
||||
- Modify: `submodules/ICloudResources/BUILD`
|
||||
|
||||
**Starting inventory** (computed during planning): `MediaResource` (×2), `TelegramMedia` (×1). No `mediaBox`, no `transaction`. Small type-reference-only module. The `MediaResource` uses may be a custom `MediaResource`-conforming class defined in this file — confirm during inventory.
|
||||
|
||||
- [ ] **Step 1: Inventory**
|
||||
|
||||
Read the whole file. `MediaResource` is a Postbox protocol; `ICloudResources` likely declares a custom class conforming to it. Capture whether (a) the file declares new `MediaResource`-conforming types, (b) it only references the protocol, or both.
|
||||
|
||||
```bash
|
||||
grep -nE "\b(MediaResource|TelegramMedia)\b|class.*:.*MediaResource|struct.*:.*MediaResource" submodules/ICloudResources/Sources/ICloudResources.swift
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Map each reference**
|
||||
|
||||
- If the file declares a type conforming to `MediaResource`, use the `EngineMediaResource` typealias in the declaration (`class FooResource: EngineMediaResource { ... }`). Because typealiases are transparent, this keeps protocol conformance identical.
|
||||
- All other `MediaResource` references → `EngineMediaResource`.
|
||||
- `TelegramMedia*` — TelegramCore, no change.
|
||||
|
||||
Add `EngineMediaResource` typealias in TelegramCore if not already present (Task 2 / Task 6 may have added it; check first).
|
||||
|
||||
- [ ] **Step 2a: (Only if needed) Add `EngineMediaResource` typealias**
|
||||
|
||||
```swift
|
||||
// submodules/TelegramCore/Sources/TelegramEngine/Resources/EngineMediaResource.swift
|
||||
import Postbox
|
||||
public typealias EngineMediaResource = MediaResource
|
||||
```
|
||||
|
||||
Build, commit.
|
||||
|
||||
- [ ] **Step 3: Edit the consumer file**
|
||||
|
||||
Apply mappings. Remove `import Postbox`.
|
||||
|
||||
- [ ] **Step 4: Drop the Bazel dep**
|
||||
|
||||
Edit `submodules/ICloudResources/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`.
|
||||
|
||||
- [ ] **Step 5: Static checks**
|
||||
|
||||
```bash
|
||||
grep -R "^import Postbox" submodules/ICloudResources/Sources # expect: empty
|
||||
grep "submodules/Postbox" submodules/ICloudResources/BUILD # expect: empty
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Full project build**
|
||||
|
||||
Run the full build. Expected: PASS. Handle failures per the Build-failure handling rules in the Background section.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/ICloudResources/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
ICloudResources: drop direct Postbox dependency
|
||||
|
||||
Switch MediaResource references to EngineMediaResource; remove the
|
||||
Postbox import and Bazel dep. Behavior-preserving.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Wave-1 completion verification
|
||||
|
||||
**Files:** No code changes.
|
||||
|
||||
- [ ] **Step 1: Static check across all 10 modules**
|
||||
|
||||
```bash
|
||||
for m in ActionSheetPeerItem ChatInterfaceState ChatListSearchRecentPeersNode ChatSendMessageActionUI ContactListUI DirectMediaImageCache DrawingUI FetchManagerImpl GalleryData ICloudResources; do
|
||||
echo "=== $m ==="
|
||||
grep -R "^import Postbox" submodules/$m/Sources && echo "FAIL: import in $m"
|
||||
grep "submodules/Postbox" submodules/$m/BUILD && echo "FAIL: dep in $m"
|
||||
done
|
||||
```
|
||||
|
||||
Expected: no `FAIL` lines printed. If any appear, return to the corresponding task and fix.
|
||||
|
||||
- [ ] **Step 2: Final full build**
|
||||
|
||||
Run the full build one more time from a clean state. Expected: PASS. (If it passed at the end of Task 10 and nothing else has changed, this should be cached and fast.)
|
||||
|
||||
- [ ] **Step 3: Review the commit log**
|
||||
|
||||
```bash
|
||||
git log --oneline master..HEAD
|
||||
```
|
||||
|
||||
Expected: a run of commits matching the pattern `TelegramCore: add …` (optional) and `<Module>: drop direct Postbox dependency` (one per module done). If any module was skipped per the fallback rule, verify the fallback ran and a replacement module completed so the total is 10.
|
||||
|
||||
- [ ] **Step 4: No commit**
|
||||
|
||||
Verification only.
|
||||
|
||||
---
|
||||
|
||||
## What's explicitly NOT in this plan
|
||||
|
||||
- Any edits to `TelegramCore`, `Postbox`, or the 64 modules outside the chosen 10 (except the minimum engine-wrapper / typealias additions to `TelegramCore` that the chosen modules need).
|
||||
- Any new `Engine*` wrapper *structs* (only typealiases and forwarding methods are in scope this wave).
|
||||
- Any generic `engine.transaction { postbox in … }` escape hatch.
|
||||
- Any behavior change, performance tweak, or "while we're here" cleanup.
|
||||
- Any test work — there are no tests in this project.
|
||||
223
docs/superpowers/plans/2026-04-17-listview-pin-to-edge.md
Normal file
223
docs/superpowers/plans/2026-04-17-listview-pin-to-edge.md
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
# ListView pin-to-edge 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:** Implement the first-pinned-item-to-bottom-edge behavior in `ListView` by adding a `calculatePinToEdgeTopInset()` helper and wiring it into `snapToBounds` and `updateScroller`, matching the design in [docs/superpowers/specs/2026-04-17-listview-pin-to-edge-design.md](../specs/2026-04-17-listview-pin-to-edge-design.md).
|
||||
|
||||
**Architecture:** Heights-based virtual-top-inset adjustment. A new private helper on `ListViewImpl` computes `max(0, visibleArea - ΣheightsAboveAndIncludingPinned)`. Two call sites add this to `effectiveInsets.top` via the existing `max(…)` chain alongside `stackFromBottomInsetItemFactor`.
|
||||
|
||||
**Tech Stack:** Swift, ASDisplayKit, Bazel build system.
|
||||
|
||||
**Scope:** Single file — `submodules/Display/Source/ListView.swift`. No protocol change (`pinToEdgeWithInset` is already declared on `ListViewItem`). No consumer changes. Because no item overrides `pinToEdgeWithInset` from its default `false`, the existing app surface's behavior is unchanged after this plan lands; the feature will be exercised only by a future consumer in a separate change.
|
||||
|
||||
**No unit tests** exist in this project (per `CLAUDE.md`). Verification is via the full project build.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `calculatePinToEdgeTopInset` helper and integrate at both call sites
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/Display/Source/ListView.swift`
|
||||
|
||||
The helper, both call-site edits, and the build verification land in one commit because they are tightly coupled: committing the helper without any call site is a no-op, and committing only one of the two call sites would cause `updateScroller` and `snapToBounds` to disagree about `effectiveInsets.top`, producing scroll-position desync whenever pinning is engaged.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Step 1: Insert the `calculatePinToEdgeTopInset` helper after `calculateAdditionalTopInverseInset`**
|
||||
|
||||
Use the Edit tool. The helper goes immediately after `calculateAdditionalTopInverseInset`'s closing brace (line 1090) and before `areAllItemsOnScreen` (line 1092).
|
||||
|
||||
old_string:
|
||||
```swift
|
||||
private func calculateAdditionalTopInverseInset() -> CGFloat {
|
||||
var additionalInverseTopInset: CGFloat = 0.0
|
||||
if !self.stackFromBottomInsetItemFactor.isZero {
|
||||
var remainingFactor = self.stackFromBottomInsetItemFactor
|
||||
for itemNode in self.itemNodes {
|
||||
if remainingFactor.isLessThanOrEqualTo(0.0) {
|
||||
break
|
||||
}
|
||||
|
||||
let itemFactor: CGFloat
|
||||
if CGFloat(1.0).isLessThanOrEqualTo(remainingFactor) {
|
||||
itemFactor = 1.0
|
||||
} else {
|
||||
itemFactor = remainingFactor
|
||||
}
|
||||
|
||||
additionalInverseTopInset += floor(itemNode.apparentBounds.height * itemFactor)
|
||||
|
||||
remainingFactor -= 1.0
|
||||
}
|
||||
}
|
||||
return additionalInverseTopInset
|
||||
}
|
||||
|
||||
private func areAllItemsOnScreen() -> Bool {
|
||||
```
|
||||
|
||||
new_string:
|
||||
```swift
|
||||
private func calculateAdditionalTopInverseInset() -> CGFloat {
|
||||
var additionalInverseTopInset: CGFloat = 0.0
|
||||
if !self.stackFromBottomInsetItemFactor.isZero {
|
||||
var remainingFactor = self.stackFromBottomInsetItemFactor
|
||||
for itemNode in self.itemNodes {
|
||||
if remainingFactor.isLessThanOrEqualTo(0.0) {
|
||||
break
|
||||
}
|
||||
|
||||
let itemFactor: CGFloat
|
||||
if CGFloat(1.0).isLessThanOrEqualTo(remainingFactor) {
|
||||
itemFactor = 1.0
|
||||
} else {
|
||||
itemFactor = remainingFactor
|
||||
}
|
||||
|
||||
additionalInverseTopInset += floor(itemNode.apparentBounds.height * itemFactor)
|
||||
|
||||
remainingFactor -= 1.0
|
||||
}
|
||||
}
|
||||
return additionalInverseTopInset
|
||||
}
|
||||
|
||||
private func calculatePinToEdgeTopInset() -> CGFloat {
|
||||
var lowestPinnedIndex: Int = Int.max
|
||||
for itemNode in self.itemNodes {
|
||||
guard let index = itemNode.index else { continue }
|
||||
if index < lowestPinnedIndex && self.items[index].pinToEdgeWithInset {
|
||||
lowestPinnedIndex = index
|
||||
}
|
||||
}
|
||||
guard lowestPinnedIndex != Int.max else { return 0.0 }
|
||||
|
||||
var totalAboveAndPinned: CGFloat = 0.0
|
||||
var sawIndexZero = false
|
||||
for itemNode in self.itemNodes {
|
||||
guard let index = itemNode.index else { continue }
|
||||
if index == 0 {
|
||||
sawIndexZero = true
|
||||
}
|
||||
if index <= lowestPinnedIndex {
|
||||
totalAboveAndPinned += itemNode.apparentBounds.height
|
||||
}
|
||||
}
|
||||
guard sawIndexZero else { return 0.0 }
|
||||
|
||||
let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom
|
||||
return max(0.0, visibleArea - totalAboveAndPinned)
|
||||
}
|
||||
|
||||
private func areAllItemsOnScreen() -> Bool {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Integrate at the `snapToBounds` call site**
|
||||
|
||||
Use the Edit tool. The block at lines 1181-1185 in `snapToBounds` gets a new `pinToEdgeTopInset` stanza after the existing `stackFromBottomInsetItemFactor` branch. Include the following line (` ` + `if topItemFound {`) in the old_string to disambiguate from the structurally-identical block in `areAllItemsOnScreen` at line 1110.
|
||||
|
||||
old_string:
|
||||
```swift
|
||||
var effectiveInsets = self.insets
|
||||
if topItemFound && !self.stackFromBottomInsetItemFactor.isZero {
|
||||
let additionalInverseTopInset = self.calculateAdditionalTopInverseInset()
|
||||
effectiveInsets.top = max(effectiveInsets.top, self.visibleSize.height - additionalInverseTopInset)
|
||||
}
|
||||
|
||||
if topItemFound {
|
||||
```
|
||||
|
||||
new_string:
|
||||
```swift
|
||||
var effectiveInsets = self.insets
|
||||
if topItemFound && !self.stackFromBottomInsetItemFactor.isZero {
|
||||
let additionalInverseTopInset = self.calculateAdditionalTopInverseInset()
|
||||
effectiveInsets.top = max(effectiveInsets.top, self.visibleSize.height - additionalInverseTopInset)
|
||||
}
|
||||
let pinToEdgeTopInset = self.calculatePinToEdgeTopInset()
|
||||
if pinToEdgeTopInset > 0.0 {
|
||||
effectiveInsets.top = max(effectiveInsets.top, self.insets.top + pinToEdgeTopInset)
|
||||
}
|
||||
|
||||
if topItemFound {
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Integrate at the `updateScroller` call site**
|
||||
|
||||
Use the Edit tool. The block at lines 1612-1616 in `updateScroller` is nested one extra level (12-space indent rather than 8-space), so the string alone is unique and the old_string doesn't need extra context.
|
||||
|
||||
old_string:
|
||||
```swift
|
||||
var effectiveInsets = self.insets
|
||||
if topItemFound && !self.stackFromBottomInsetItemFactor.isZero {
|
||||
let additionalInverseTopInset = self.calculateAdditionalTopInverseInset()
|
||||
effectiveInsets.top = max(effectiveInsets.top, self.visibleSize.height - additionalInverseTopInset)
|
||||
}
|
||||
|
||||
completeHeight = effectiveInsets.top + effectiveInsets.bottom
|
||||
```
|
||||
|
||||
new_string:
|
||||
```swift
|
||||
var effectiveInsets = self.insets
|
||||
if topItemFound && !self.stackFromBottomInsetItemFactor.isZero {
|
||||
let additionalInverseTopInset = self.calculateAdditionalTopInverseInset()
|
||||
effectiveInsets.top = max(effectiveInsets.top, self.visibleSize.height - additionalInverseTopInset)
|
||||
}
|
||||
let pinToEdgeTopInset = self.calculatePinToEdgeTopInset()
|
||||
if pinToEdgeTopInset > 0.0 {
|
||||
effectiveInsets.top = max(effectiveInsets.top, self.insets.top + pinToEdgeTopInset)
|
||||
}
|
||||
|
||||
completeHeight = effectiveInsets.top + effectiveInsets.bottom
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the full project build**
|
||||
|
||||
Use the Bash tool. The build takes several minutes; run it in the foreground so the agent waits for completion and surfaces failures immediately. The `source ~/.zshrc` prefix picks up `TELEGRAM_CODESIGNING_GIT_PASSWORD` per the build-environment quirk documented in `CLAUDE.md`.
|
||||
|
||||
```
|
||||
source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64
|
||||
```
|
||||
|
||||
Expected: successful build. No warnings or errors touching `ListView.swift`.
|
||||
|
||||
If the build fails:
|
||||
- Swift syntax error → re-read `ListView.swift` around the edited regions; compare against the plan's old_string/new_string; fix and re-run.
|
||||
- "`pinToEdgeWithInset` has no member" → the protocol property wasn't found; verify `submodules/Display/Source/ListViewItem.swift:80` still declares `var pinToEdgeWithInset: Bool { get }` and the default implementation at `ListViewItem.swift:102` is intact. If intact but the error persists, check that the `items` array's element type is `ListViewItem` (it is — see `public final var items: [ListViewItem]` in `ListView.swift`).
|
||||
- Any other failure in unrelated files → not caused by this plan; investigate separately.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/Display/Source/ListView.swift
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Display/ListView: pin first pinToEdgeWithInset item to bottom edge
|
||||
|
||||
Adds calculatePinToEdgeTopInset() and wires it into snapToBounds and
|
||||
updateScroller. When the smallest-index item with pinToEdgeWithInset=true
|
||||
plus all items above it have a combined apparentBounds height less than
|
||||
the available scrolling area, the helper returns a positive top-inset
|
||||
contribution that pushes the pinned item's maxY to visibleSize.height -
|
||||
insets.bottom. Once items above reach the available area, the
|
||||
contribution is zero and scrolling is fully ordinary.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-04-17-listview-pin-to-edge-design.md
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Verify with `git status` that the tree is clean after the commit.
|
||||
|
||||
---
|
||||
|
||||
## Rationale for task granularity
|
||||
|
||||
This plan has a single task. I considered splitting "add helper" from "apply at two call sites" into two commits:
|
||||
|
||||
- **For splitting:** one commit per "unit of change" is more bisectable.
|
||||
- **Against splitting:** the helper alone is unused (runtime no-op, and Swift does not warn on unused private methods). Applying at one call site without the other would produce a live bug — `snapToBounds` and `updateScroller` would disagree whenever pinning engages, and `updateScroller` is what sets `scroller.contentSize`/`contentOffset`. Three commits land an internally-consistent state only at the third commit.
|
||||
|
||||
Bundling all edits preserves bisectability at the feature-level boundary (the commit either introduces pin-to-edge support or it doesn't) and keeps the repo free of intermediate broken states.
|
||||
|
|
@ -0,0 +1,880 @@
|
|||
# MediaResource → EngineMediaResource Refactor (Wave 2) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Drive raw `MediaResource` (Postbox protocol) out of the `TelegramEngine` public facade by changing facade-function signatures in-place to take/return `EngineMediaResource`, bridging to the existing `_internal_*` Postbox-facing implementations via wrap/unwrap helpers. In the same commit as each facade change, update every call site. Follow up with a first small batch of consumer type-reference migrations.
|
||||
|
||||
**Architecture:** `TelegramEngine` facade methods live alongside `_internal_*` Postbox-using implementations in `submodules/TelegramCore/Sources/TelegramEngine/<Area>/`. Today the facade methods already bridge (storing an `Account` and delegating), but their public signatures still expose raw `MediaResource`. The fix: change facade signatures to `EngineMediaResource` (including the `mapResourceToAvatarSizes` closure types) and add the two-line wrap/unwrap bridging. `_internal_*` functions stay on raw `MediaResource` — they are the Postbox-facing layer and must remain so. Consumer call sites swap `MediaResource` → `EngineMediaResource` (usually via `EngineMediaResource(raw)` wrap or `engineResource._asResource()` unwrap at a nearby boundary).
|
||||
|
||||
**Tech Stack:** Swift, Bazel, Postbox (opaque storage), TelegramCore (public facade), SSignalKit.
|
||||
|
||||
**Design constraint (IMPORTANT):** `TelegramCore` is shared with the Telegram-Mac codebase and must **not** import UIKit/Display. Any UIKit-requiring logic (image scaling, `UIImage`, `generateScaledImage`, etc.) stays in consumer-side submodules. Engine API additions must not pull in UIKit.
|
||||
|
||||
**Why not overloads:** An earlier iteration of this plan added opt-in `EngineMediaResource` overloads and kept the raw overloads. That was rejected: duplicate signatures fragment the public API and leave raw-`MediaResource` leaks forever. The correct pattern is to change the single facade function in-place so it takes engine types and bridges inside, forcing callers to migrate in the same commit.
|
||||
|
||||
---
|
||||
|
||||
## Background the executor needs
|
||||
|
||||
### The full build command
|
||||
|
||||
Run from the repo root (`/Users/ali/build/telegram/telegram-ios`):
|
||||
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null; \
|
||||
PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH \
|
||||
python3 build-system/Make/Make.py --overrideXcodeVersion \
|
||||
--cacheDir ~/telegram-bazel-cache \
|
||||
build \
|
||||
--configurationPath build-system/appstore-configuration.json \
|
||||
--gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \
|
||||
--gitCodesigningType development \
|
||||
--gitCodesigningUseCurrent \
|
||||
--buildNumber 1 \
|
||||
--configuration debug_sim_arm64
|
||||
```
|
||||
|
||||
The build is the only verification (no unit tests per `CLAUDE.md`). Every task ends with a full build that must go green before the next task starts.
|
||||
|
||||
### What `EngineMediaResource` gives you today (bridge primitives)
|
||||
|
||||
Defined in [TelegramEngineResources.swift](../../../submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift):
|
||||
|
||||
```swift
|
||||
public final class EngineMediaResource: Equatable {
|
||||
public init(_ resource: MediaResource)
|
||||
public func _asResource() -> MediaResource
|
||||
public var id: Id
|
||||
public struct Id: Equatable, Hashable {
|
||||
public init(_ id: MediaResourceId)
|
||||
public init(_ stringRepresentation: String)
|
||||
}
|
||||
public final class ResourceData {
|
||||
public let path: String; public let availableSize: Int64; public let isComplete: Bool
|
||||
}
|
||||
public enum FetchStatus: Equatable { /* Remote/Local/Fetching/Paused */ }
|
||||
}
|
||||
public extension EngineMediaResource.ResourceData {
|
||||
convenience init(_ data: MediaResourceData)
|
||||
}
|
||||
```
|
||||
|
||||
### The bridging pattern
|
||||
|
||||
For each facade function whose public signature contains `MediaResource`:
|
||||
|
||||
**Before** (raw-protocol leak):
|
||||
|
||||
```swift
|
||||
public func uploadedPeerPhoto(resource: MediaResource) -> Signal<UploadedPeerPhotoData, NoError> {
|
||||
return _internal_uploadedPeerPhoto(postbox: self.account.postbox, network: self.account.network, resource: resource)
|
||||
}
|
||||
```
|
||||
|
||||
**After** (engine-typed facade, internal bridge):
|
||||
|
||||
```swift
|
||||
public func uploadedPeerPhoto(resource: EngineMediaResource) -> Signal<UploadedPeerPhotoData, NoError> {
|
||||
return _internal_uploadedPeerPhoto(postbox: self.account.postbox, network: self.account.network, resource: resource._asResource())
|
||||
}
|
||||
```
|
||||
|
||||
For closures that receive a `MediaResource`:
|
||||
|
||||
**Before:**
|
||||
|
||||
```swift
|
||||
public func updatePeerPhoto(..., mapResourceToAvatarSizes: @escaping (MediaResource, [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError>) -> ... {
|
||||
return _internal_updatePeerPhoto(..., mapResourceToAvatarSizes: mapResourceToAvatarSizes)
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```swift
|
||||
public func updatePeerPhoto(..., mapResourceToAvatarSizes: @escaping (EngineMediaResource, [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError>) -> ... {
|
||||
return _internal_updatePeerPhoto(..., mapResourceToAvatarSizes: { rawResource, representations in
|
||||
mapResourceToAvatarSizes(EngineMediaResource(rawResource), representations)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
`_internal_*` functions are **not** changed — they stay on raw `MediaResource` as the Postbox-facing layer.
|
||||
|
||||
### Call-site migration pattern
|
||||
|
||||
At each call site, the change is mechanical:
|
||||
|
||||
- `engine.peers.uploadedPeerPhoto(resource: someRawResource)` → `engine.peers.uploadedPeerPhoto(resource: EngineMediaResource(someRawResource))`.
|
||||
- `engine.peers.updatePeerPhoto(..., mapResourceToAvatarSizes: { resource, representations in ... resource ... })` — the closure's `resource` is now `EngineMediaResource`. Any expression inside the closure that previously treated `resource` as raw protocol (e.g. `postbox.mediaBox.resourceData(resource)`) must use `resource._asResource()`.
|
||||
|
||||
Where the consumer was carrying a `MediaResource?` property / local purely as a pipe into one of these APIs, migrate the property itself to `EngineMediaResource?` so no unwrap/wrap churn is needed.
|
||||
|
||||
### Static-check commands
|
||||
|
||||
```bash
|
||||
grep -R "^import Postbox" submodules/<M>/Sources # expect: empty (only when a module is being fully de-Postboxed)
|
||||
grep "submodules/Postbox" submodules/<M>/BUILD # expect: empty (same condition)
|
||||
```
|
||||
|
||||
### Commit convention
|
||||
|
||||
- One commit per engine API family: `TelegramCore: migrate <function(s)> to EngineMediaResource` — bundles facade-signature change **and** all call sites updated in the same commit. The repo must build on every commit.
|
||||
- Consumer-only type-ref commits: `<ModuleName>: migrate MediaResource property to EngineMediaResource` or `<ModuleName>: drop direct Postbox dependency`.
|
||||
- Always use HEREDOC bodies. No `--amend`.
|
||||
|
||||
### What is explicitly out of scope
|
||||
|
||||
- Classes that **conform to `TelegramMediaResource`** (must implement `isEqual(to: MediaResource)`): remain `import Postbox`. Enumerated:
|
||||
- `submodules/ICloudResources/Sources/ICloudResources.swift` — `ICloudFileResource`
|
||||
- `submodules/InstantPageUI/Sources/InstantPageExternalMediaResource.swift` — `InstantPageExternalMediaResource`
|
||||
- `submodules/LocalMediaResources/Sources/LocalMediaResources.swift` — `VideoLibraryMediaResource`
|
||||
- `submodules/TelegramUniversalVideoContent/Sources/YoutubeEmbedImplementation.swift` — `YoutubeEmbedStoryboardMediaResource`
|
||||
- TelegramCore-internal `MediaResource` usage (SyncCore, Fetch, `_internal_*` functions, etc.) — Postbox-facing layer.
|
||||
- Modules already abandoned in wave 1 for non-MediaResource reasons (`FetchManagerImpl` / `ICloudResources` have other umbrella-type blockers).
|
||||
- The heavy-leak modules in the "Future waves" table at the bottom (`PassportUI`, `TelegramUI`, etc.).
|
||||
- Importing UIKit/Display into TelegramCore under any circumstance.
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Baseline verification
|
||||
|
||||
**Files:** No code changes.
|
||||
|
||||
- [ ] **Step 1: Confirm tree state**
|
||||
|
||||
```bash
|
||||
git status
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
Expected: working tree clean apart from pre-existing untracked (`build-system/tulsi/`, `submodules/TgVoip/`, `third-party/libx264/`) and submodule-content drift on `build-system/bazel-rules/sourcekit-bazel-bsp`. HEAD on `master`.
|
||||
|
||||
- [ ] **Step 2: Baseline build**
|
||||
|
||||
Run the full build command above. Expected: PASS.
|
||||
|
||||
If it fails, stop — a non-green baseline is out of scope.
|
||||
|
||||
- [ ] **Step 3: No commit.**
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Record the new rules in CLAUDE.md
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Add the "TelegramCore no UIKit" rule**
|
||||
|
||||
In `CLAUDE.md`, inside the `## Postbox → TelegramEngine refactor (in progress)` section, under `### Rules that apply to every wave`, append a new numbered rule after the existing rule 6:
|
||||
|
||||
```markdown
|
||||
7. **TelegramCore never imports UIKit/Display.** `TelegramCore` is shared with the Telegram-Mac codebase; its Bazel `deps` and source files must not reference UIKit, Display, or any Apple-UI framework. UIKit-needing helpers (image scaling, rendering, etc.) stay in consumer-side submodules.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the MediaResource → EngineMediaResource migration pattern**
|
||||
|
||||
After the `### Engine typealias cheat sheet (existing aliases)` block (which ends with the `MediaResource` / `TelegramMediaResource` note), insert a new section:
|
||||
|
||||
```markdown
|
||||
### MediaResource → EngineMediaResource consumer migration
|
||||
|
||||
`EngineMediaResource` is a `final class` in `TelegramCore` wrapping a `MediaResource` value. Unlike the typealiases above it is **not** interchangeable with the protocol, but it does provide wrap/unwrap helpers:
|
||||
|
||||
- `EngineMediaResource(rawResource)` — wrap a raw `MediaResource`.
|
||||
- `engineResource._asResource()` — unwrap to the raw `MediaResource`.
|
||||
- `EngineMediaResource.ResourceData(rawResourceData)` — wrap `MediaResourceData`.
|
||||
- `EngineMediaResource.Id(rawMediaResourceId)` — wrap `MediaResourceId`.
|
||||
|
||||
**Pattern for facade functions:** when a `TelegramEngine.<Area>` method leaks raw `MediaResource` in its public signature, **change the facade signature in place** to `EngineMediaResource` (and change any closure parameter types the same way). Bridge inside the facade body by calling the existing `_internal_*` function with `engineResource._asResource()` / wrapping raw inputs from inner closures with `EngineMediaResource(rawResource)`. Update all call sites in the same commit. The `_internal_*` function stays on raw `MediaResource` — it is the Postbox-facing layer.
|
||||
|
||||
Do **not** add opt-in `EngineMediaResource` overloads alongside raw-`MediaResource` overloads. Duplicate signatures fragment the public API and leave the leak in place forever.
|
||||
|
||||
For consumer modules, prefer `EngineMediaResource` as the type in properties, locals, generic arguments and function parameters when the usage is a pure type reference. Do **not** try to use `EngineMediaResource` where a class must conform to `TelegramMediaResource` (Postbox protocol) or override `isEqual(to: MediaResource)` — those remain `import Postbox`.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Full build (sanity — docs only)**
|
||||
|
||||
Run the full build. Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
CLAUDE.md: record TelegramCore-no-UIKit rule and EngineMediaResource migration pattern
|
||||
|
||||
Wave-2 preparation. Codifies that TelegramCore is shared with
|
||||
Telegram-Mac and must stay UIKit-free, and documents the
|
||||
modify-in-place / bridge-inside pattern for migrating
|
||||
MediaResource-leaking facade functions to EngineMediaResource.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Migrate `TelegramEngine.Peers` photo APIs to `EngineMediaResource`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift`
|
||||
- Modify: all call sites (13 + 11 + 7, with heavy overlap — see Step 2 grep).
|
||||
|
||||
**Functions migrated in this task:**
|
||||
- `uploadedPeerPhoto(resource:)` (line 704) — `MediaResource` → `EngineMediaResource`
|
||||
- `uploadedPeerVideo(resource:)` (line 708) — `MediaResource` → `EngineMediaResource`
|
||||
- `updatePeerPhoto(..., mapResourceToAvatarSizes:)` (line 712) — closure parameter `MediaResource` → `EngineMediaResource`
|
||||
|
||||
- [ ] **Step 1: Read the current signatures**
|
||||
|
||||
Read lines 704–720 of `submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift`. Confirm the three functions match the pattern `return _internal_<name>(postbox: self.account.postbox, ..., resource: resource)` or equivalent.
|
||||
|
||||
- [ ] **Step 2: Enumerate call sites**
|
||||
|
||||
```bash
|
||||
grep -rnE "\\.(uploadedPeerPhoto|uploadedPeerVideo|updatePeerPhoto)\(" submodules/ \
|
||||
| grep -v "submodules/TelegramCore"
|
||||
```
|
||||
|
||||
Capture every hit — file path, line number, approximate surrounding context (what resource expression is passed in / what the closure body does). The distribution as of planning:
|
||||
|
||||
- `uploadedPeerPhoto`: 11 call sites (spread across TelegramUI, TelegramCallsUI, AuthorizationUI, etc.)
|
||||
- `uploadedPeerVideo`: 7
|
||||
- `updatePeerPhoto`: 13
|
||||
|
||||
Many call sites chain these (e.g. `updatePeerPhoto(photo: engine.peers.uploadedPeerPhoto(resource: ...))`) so a single file often touches two or three of them in one call.
|
||||
|
||||
- [ ] **Step 3: Change the facade signatures + bridge**
|
||||
|
||||
In `TelegramEnginePeers.swift`, change the three functions to:
|
||||
|
||||
```swift
|
||||
public func uploadedPeerPhoto(resource: EngineMediaResource) -> Signal<UploadedPeerPhotoData, NoError> {
|
||||
return _internal_uploadedPeerPhoto(postbox: self.account.postbox, network: self.account.network, resource: resource._asResource())
|
||||
}
|
||||
|
||||
public func uploadedPeerVideo(resource: EngineMediaResource) -> Signal<UploadedPeerPhotoData, NoError> {
|
||||
return _internal_uploadedPeerVideo(postbox: self.account.postbox, network: self.account.network, messageMediaPreuploadManager: self.account.messageMediaPreuploadManager, resource: resource._asResource())
|
||||
}
|
||||
|
||||
public func updatePeerPhoto(peerId: PeerId, photo: Signal<UploadedPeerPhotoData, NoError>?, video: Signal<UploadedPeerPhotoData?, NoError>? = nil, videoStartTimestamp: Double? = nil, markup: UploadPeerPhotoMarkup? = nil, mapResourceToAvatarSizes: @escaping (EngineMediaResource, [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError>) -> Signal<UpdatePeerPhotoStatus, UploadPeerPhotoError> {
|
||||
return _internal_updatePeerPhoto(postbox: self.account.postbox, network: self.account.network, stateManager: self.account.stateManager, accountPeerId: self.account.peerId, peerId: peerId, photo: photo, video: video, videoStartTimestamp: videoStartTimestamp, markup: markup, mapResourceToAvatarSizes: { rawResource, representations in
|
||||
return mapResourceToAvatarSizes(EngineMediaResource(rawResource), representations)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Before editing, re-read the existing bodies** — the exact arg names passed into `_internal_updatePeerPhoto` etc. must match what's already there (the skeletons above reproduce what's in the file at planning time, but the executor should preserve every argument the current implementation passes). Only the outer signature and the closure-wrapping change.
|
||||
|
||||
- [ ] **Step 4: Update every call site** (same commit)
|
||||
|
||||
For each hit from Step 2, rewrite the call site per the patterns:
|
||||
|
||||
**Pattern A — passing a raw resource to `uploadedPeerPhoto` / `uploadedPeerVideo`:**
|
||||
|
||||
```swift
|
||||
// Before:
|
||||
engine.peers.uploadedPeerPhoto(resource: someRawResource)
|
||||
// After:
|
||||
engine.peers.uploadedPeerPhoto(resource: EngineMediaResource(someRawResource))
|
||||
```
|
||||
|
||||
**Pattern B — the `mapResourceToAvatarSizes` closure of `updatePeerPhoto`:**
|
||||
|
||||
```swift
|
||||
// Before:
|
||||
mapResourceToAvatarSizes: { resource, representations in
|
||||
return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations)
|
||||
}
|
||||
// After (if the helper is still raw-MediaResource-facing at this point):
|
||||
mapResourceToAvatarSizes: { resource, representations in
|
||||
return mapResourceToAvatarSizes(postbox: postbox, resource: resource._asResource(), representations: representations)
|
||||
}
|
||||
```
|
||||
|
||||
Task 6 will change `mapResourceToAvatarSizes` itself to accept `EngineMediaResource` and drop the `_asResource()` call. Until Task 6 lands, keep the `_asResource()` here. This keeps the build green between tasks.
|
||||
|
||||
**Pattern C — the consumer was already carrying the resource as a `MediaResource?` local purely as a pipe:**
|
||||
|
||||
If a nearby local/property typed `MediaResource?` only exists to feed `uploadedPeerPhoto(resource:)` or similar, change the local's type to `EngineMediaResource?` at the same time. This avoids wrap/unwrap churn at the call site.
|
||||
|
||||
- [ ] **Step 5: Full build**
|
||||
|
||||
Run the full build. Expected: PASS.
|
||||
|
||||
If it fails, the first error locates the broken call site. Apply Pattern A / B / C at that site and rebuild. If a file imports Postbox only for `MediaResource` and now has no other Postbox identifier, you may optionally remove `import Postbox` in the same commit — but that is not required here; it is a separate goal.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift submodules/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
TelegramCore: migrate peer-photo facade to EngineMediaResource
|
||||
|
||||
Change TelegramEngine.Peers.uploadedPeerPhoto / uploadedPeerVideo /
|
||||
updatePeerPhoto so their public signatures take EngineMediaResource
|
||||
instead of raw MediaResource (and the mapResourceToAvatarSizes closure
|
||||
receives EngineMediaResource). The facade bridges to the existing
|
||||
_internal_* Postbox-facing implementations via _asResource() /
|
||||
EngineMediaResource(_:). All call sites updated in this commit.
|
||||
|
||||
Part of the MediaResource -> EngineMediaResource migration (wave 2).
|
||||
No behavior change.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Migrate `TelegramEngine.AccountData.updateAccountPhoto` and `updateFallbackPhoto`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/TelegramEngine/AccountData/TelegramEngineAccountData.swift`
|
||||
- Modify: all call sites (5 + 4).
|
||||
|
||||
- [ ] **Step 1: Read the current signatures**
|
||||
|
||||
Read lines 55–90 of `submodules/TelegramCore/Sources/TelegramEngine/AccountData/TelegramEngineAccountData.swift`. Confirm both functions match the expected pattern.
|
||||
|
||||
- [ ] **Step 2: Enumerate call sites**
|
||||
|
||||
```bash
|
||||
grep -rnE "\\.(updateAccountPhoto|updateFallbackPhoto)\(" submodules/ \
|
||||
| grep -v "submodules/TelegramCore"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Change the facade signatures + bridge**
|
||||
|
||||
Change both functions in place:
|
||||
|
||||
```swift
|
||||
public func updateAccountPhoto(resource: EngineMediaResource?, videoResource: EngineMediaResource?, videoStartTimestamp: Double?, markup: UploadPeerPhotoMarkup?, mapResourceToAvatarSizes: @escaping (EngineMediaResource, [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError>) -> Signal<UpdatePeerPhotoStatus, UploadPeerPhotoError> {
|
||||
return _internal_updateAccountPhoto(account: self.account, resource: resource?._asResource(), videoResource: videoResource?._asResource(), videoStartTimestamp: videoStartTimestamp, markup: markup, mapResourceToAvatarSizes: { rawResource, representations in
|
||||
return mapResourceToAvatarSizes(EngineMediaResource(rawResource), representations)
|
||||
})
|
||||
}
|
||||
|
||||
public func updateFallbackPhoto(resource: EngineMediaResource?, videoResource: EngineMediaResource?, videoStartTimestamp: Double?, markup: UploadPeerPhotoMarkup?, mapResourceToAvatarSizes: @escaping (EngineMediaResource, [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError>) -> Signal<UpdatePeerPhotoStatus, UploadPeerPhotoError> {
|
||||
return _internal_updateFallbackPhoto(account: self.account, resource: resource?._asResource(), videoResource: videoResource?._asResource(), videoStartTimestamp: videoStartTimestamp, markup: markup, mapResourceToAvatarSizes: { rawResource, representations in
|
||||
return mapResourceToAvatarSizes(EngineMediaResource(rawResource), representations)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Before editing, verify the exact argument names passed to `_internal_updateAccountPhoto` / `_internal_updateFallbackPhoto`** in the current file. Copy those argument spellings verbatim (only the outer signature and inner closure wrapping change).
|
||||
|
||||
- [ ] **Step 4: Update every call site** (same commit)
|
||||
|
||||
Apply Pattern A/B/C from Task 2 to every hit. Wrap `EngineMediaResource(...)` around raw-resource args; add `._asResource()` inside any `mapResourceToAvatarSizes:` closure body where it hands the value onward to a still-raw helper (removed in Task 6).
|
||||
|
||||
- [ ] **Step 5: Full build**
|
||||
|
||||
Run the full build. Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/TelegramEngine/AccountData/TelegramEngineAccountData.swift submodules/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
TelegramCore: migrate account-photo facade to EngineMediaResource
|
||||
|
||||
Change TelegramEngine.AccountData.updateAccountPhoto and
|
||||
updateFallbackPhoto so their public signatures take EngineMediaResource
|
||||
(and the mapResourceToAvatarSizes closure receives
|
||||
EngineMediaResource). Bridges to _internal_* functions via
|
||||
_asResource()/EngineMediaResource(_:). All call sites updated in this
|
||||
commit.
|
||||
|
||||
Part of the MediaResource -> EngineMediaResource migration (wave 2).
|
||||
No behavior change.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Migrate `TelegramEngine.Contacts.updateContactPhoto`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Contacts/TelegramEngineContacts.swift`
|
||||
- Modify: all call sites (8).
|
||||
|
||||
- [ ] **Step 1: Read the current signature**
|
||||
|
||||
Read around line 33 of `submodules/TelegramCore/Sources/TelegramEngine/Contacts/TelegramEngineContacts.swift`.
|
||||
|
||||
- [ ] **Step 2: Enumerate call sites**
|
||||
|
||||
```bash
|
||||
grep -rn "\.updateContactPhoto(" submodules/ | grep -v "submodules/TelegramCore"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Change the facade signature + bridge**
|
||||
|
||||
```swift
|
||||
public func updateContactPhoto(peerId: PeerId, resource: EngineMediaResource?, videoResource: EngineMediaResource?, videoStartTimestamp: Double?, markup: UploadPeerPhotoMarkup?, mode: SetCustomPeerPhotoMode, mapResourceToAvatarSizes: @escaping (EngineMediaResource, [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError>) -> Signal<UpdatePeerPhotoStatus, UploadPeerPhotoError> {
|
||||
return _internal_updateContactPhoto(account: self.account, peerId: peerId, resource: resource?._asResource(), videoResource: videoResource?._asResource(), videoStartTimestamp: videoStartTimestamp, markup: markup, mode: mode, mapResourceToAvatarSizes: { rawResource, representations in
|
||||
return mapResourceToAvatarSizes(EngineMediaResource(rawResource), representations)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Verify the `_internal_updateContactPhoto` call spelling against the existing file before committing.
|
||||
|
||||
- [ ] **Step 4: Update every call site** (same commit)
|
||||
|
||||
Pattern A/B/C as in Task 2.
|
||||
|
||||
- [ ] **Step 5: Full build**
|
||||
|
||||
Run the full build. Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/TelegramEngine/Contacts/TelegramEngineContacts.swift submodules/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
TelegramCore: migrate updateContactPhoto facade to EngineMediaResource
|
||||
|
||||
Change TelegramEngine.Contacts.updateContactPhoto so its public
|
||||
signature takes EngineMediaResource parameters and the
|
||||
mapResourceToAvatarSizes closure receives EngineMediaResource. Bridges
|
||||
to _internal_updateContactPhoto via _asResource()/EngineMediaResource(_:).
|
||||
All call sites updated in this commit.
|
||||
|
||||
Part of the MediaResource -> EngineMediaResource migration (wave 2).
|
||||
No behavior change.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Migrate `TelegramEngine.Auth.uploadedPeerVideo`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Auth/TelegramEngineAuth.swift`
|
||||
- Modify: call sites that route through `TelegramEngine.Auth.uploadedPeerVideo` (separate from `TelegramEngine.Peers.uploadedPeerVideo`).
|
||||
|
||||
- [ ] **Step 1: Read the current signature**
|
||||
|
||||
Read around line 51 of `submodules/TelegramCore/Sources/TelegramEngine/Auth/TelegramEngineAuth.swift`.
|
||||
|
||||
- [ ] **Step 2: Enumerate call sites**
|
||||
|
||||
```bash
|
||||
grep -rn "engine\.auth\.uploadedPeerVideo\|\.auth\.uploadedPeerVideo" submodules/ | grep -v "submodules/TelegramCore"
|
||||
```
|
||||
|
||||
The call site count is small (the sign-up flow). If zero, skip Step 4.
|
||||
|
||||
- [ ] **Step 3: Change the facade signature + bridge**
|
||||
|
||||
```swift
|
||||
public func uploadedPeerVideo(resource: EngineMediaResource) -> Signal<UploadedPeerPhotoData, NoError> {
|
||||
return _internal_uploadedPeerVideo(postbox: self.account.postbox, network: self.account.network, messageMediaPreuploadManager: self.account.messageMediaPreuploadManager, resource: resource._asResource())
|
||||
}
|
||||
```
|
||||
|
||||
Preserve the exact argument spellings from the existing function body.
|
||||
|
||||
- [ ] **Step 4: Update call sites** (same commit)
|
||||
|
||||
Pattern A.
|
||||
|
||||
- [ ] **Step 5: Full build**
|
||||
|
||||
Run the full build. Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/TelegramEngine/Auth/TelegramEngineAuth.swift submodules/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
TelegramCore: migrate Auth.uploadedPeerVideo facade to EngineMediaResource
|
||||
|
||||
Signature change + call sites.
|
||||
|
||||
Part of the MediaResource -> EngineMediaResource migration (wave 2).
|
||||
No behavior change.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Migrate `mapResourceToAvatarSizes` utility and drop `import Postbox` from `MapResourceToAvatarSizes`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/MapResourceToAvatarSizes/Sources/MapResourceToAvatarSizes.swift`
|
||||
- Modify: `submodules/MapResourceToAvatarSizes/BUILD`
|
||||
- Modify: all 27 call sites of the old `mapResourceToAvatarSizes(postbox:resource:representations:)`.
|
||||
|
||||
**Preconditions:** Tasks 2–5 have landed, so every `mapResourceToAvatarSizes:` closure at call sites now receives an `EngineMediaResource` (because the facade closures were retyped). At this point the inner `mapResourceToAvatarSizes(postbox: …, resource: …._asResource(), …)` unwrap becomes avoidable.
|
||||
|
||||
- [ ] **Step 1: Read the current file**
|
||||
|
||||
```
|
||||
submodules/MapResourceToAvatarSizes/Sources/MapResourceToAvatarSizes.swift
|
||||
```
|
||||
|
||||
Confirm the function body uses `postbox.mediaBox.resourceData(resource)` and requires `UIImage` / `generateScaledImage` / `jpegData(compressionQuality:)`.
|
||||
|
||||
- [ ] **Step 2: Enumerate call sites**
|
||||
|
||||
```bash
|
||||
grep -rn "mapResourceToAvatarSizes(postbox:" submodules/ | grep -v "submodules/MapResourceToAvatarSizes"
|
||||
```
|
||||
|
||||
Expected: 27 call sites, concentrated in `submodules/TelegramUI/...PeerInfoScreenAvatarSetup.swift` (19), `TelegramCallsUI/...VideoChatScreenParticipantContextMenu.swift` (5), and three other TelegramUI files (1 each).
|
||||
|
||||
- [ ] **Step 3: Rewrite the function to use `EngineMediaResource` + `TelegramEngine.Resources.data`**
|
||||
|
||||
Replace the body of `submodules/MapResourceToAvatarSizes/Sources/MapResourceToAvatarSizes.swift` with:
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SwiftSignalKit
|
||||
import TelegramCore
|
||||
import Display
|
||||
|
||||
public func mapResourceToAvatarSizes(engine: TelegramEngine, resource: EngineMediaResource, representations: [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError> {
|
||||
return engine.resources.data(id: resource.id)
|
||||
|> take(1)
|
||||
|> map { data -> [Int: Data] in
|
||||
guard data.isComplete, let image = UIImage(contentsOfFile: data.path) else {
|
||||
return [:]
|
||||
}
|
||||
var result: [Int: Data] = [:]
|
||||
for i in 0 ..< representations.count {
|
||||
let size: CGSize
|
||||
if representations[i].dimensions.width == 80 {
|
||||
size = CGSize(width: 160.0, height: 160.0)
|
||||
} else {
|
||||
size = representations[i].dimensions.cgSize
|
||||
}
|
||||
if let scaledImage = generateScaledImage(image: image, size: size, scale: 1.0), let scaledData = scaledImage.jpegData(compressionQuality: 0.8) {
|
||||
result[i] = scaledData
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Signature: `(engine: TelegramEngine, resource: EngineMediaResource, representations: [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError>`.
|
||||
- `import Postbox` is gone; replaced usage with `engine.resources.data(id:)` which returns `Signal<EngineMediaResource.ResourceData, NoError>`.
|
||||
- `data.complete` → `data.isComplete` (field rename on the engine wrapper).
|
||||
|
||||
- [ ] **Step 4: Drop the Bazel dep**
|
||||
|
||||
Edit `submodules/MapResourceToAvatarSizes/BUILD` and remove `"//submodules/Postbox:Postbox",` from `deps`. Leave the rest untouched.
|
||||
|
||||
- [ ] **Step 5: Update every call site** (same commit)
|
||||
|
||||
At each of the 27 sites, two changes:
|
||||
|
||||
**Pattern D — the call site already lives inside a `mapResourceToAvatarSizes:` closure on a facade function (post-Task-2/3/4, the closure's `resource` parameter is now `EngineMediaResource`):**
|
||||
|
||||
```swift
|
||||
// Before (from an intermediate state between tasks):
|
||||
mapResourceToAvatarSizes: { resource, representations in
|
||||
return mapResourceToAvatarSizes(postbox: postbox, resource: resource._asResource(), representations: representations)
|
||||
}
|
||||
// After:
|
||||
mapResourceToAvatarSizes: { resource, representations in
|
||||
return mapResourceToAvatarSizes(engine: engine, resource: resource, representations: representations)
|
||||
}
|
||||
```
|
||||
|
||||
The `engine` value is always reachable at the call site — it's either a stored reference used right above the closure or `context.engine` / `accountContext.engine`. Grep shows every current call site has a `postbox = context.account.postbox` (or similar) just above, so `context.engine` / the adjacent engine reference is in scope.
|
||||
|
||||
**Pattern E — direct (non-closure) call with a raw `MediaResource` in scope:**
|
||||
|
||||
Rare in the current code, but if you find one, wrap with `EngineMediaResource(rawResource)` at the call.
|
||||
|
||||
- [ ] **Step 6: Static checks**
|
||||
|
||||
```bash
|
||||
grep -R "^import Postbox" submodules/MapResourceToAvatarSizes/Sources # expect: empty
|
||||
grep "submodules/Postbox" submodules/MapResourceToAvatarSizes/BUILD # expect: empty
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Full build**
|
||||
|
||||
Run the full build. Expected: PASS.
|
||||
|
||||
Likely failure modes:
|
||||
- A call site's surrounding scope doesn't have an `engine` in context. Fix: use `<nearby-accountContext>.engine` or promote `engine` to a nearby `let`.
|
||||
- A consumer file passed a non-`EngineMediaResource` into the closure because it wasn't updated by Task 2/3/4. Fix forward (update it now) and record the miss.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/MapResourceToAvatarSizes/ submodules/TelegramUI/ submodules/TelegramCallsUI/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
MapResourceToAvatarSizes: migrate to EngineMediaResource and drop Postbox
|
||||
|
||||
Change the signature of mapResourceToAvatarSizes from
|
||||
(postbox: Postbox, resource: MediaResource, ...) to
|
||||
(engine: TelegramEngine, resource: EngineMediaResource, ...), using
|
||||
engine.resources.data(id:) internally. All 27 call sites updated in
|
||||
this commit. `import Postbox` and the Bazel dep are removed.
|
||||
Behavior-preserving.
|
||||
|
||||
Part of the MediaResource -> EngineMediaResource migration (wave 2).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Migrate `AuthorizationUI` signal type
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/AuthorizationUI/Sources/AuthorizationSequenceController.swift`
|
||||
|
||||
**Starting inventory:** exactly one reference — `Signal<TelegramMediaResource?, NoError>` at line 1162. AuthorizationUI has six files importing Postbox overall; dropping `import Postbox` from the module as a whole is **not** in scope for this task.
|
||||
|
||||
- [ ] **Step 1: Read line 1162 ± 20**
|
||||
|
||||
Understand:
|
||||
- What value is put into the signal? Likely some TelegramMediaResource subclass (e.g. `LocalFileMediaResource`).
|
||||
- Who consumes the signal downstream? After Tasks 2–5, any facade that ultimately receives this signal's value (via `updateAccountPhoto`, `uploadedPeerVideo`, etc.) expects `EngineMediaResource`.
|
||||
|
||||
- [ ] **Step 2: Change the signal type**
|
||||
|
||||
```swift
|
||||
// Before:
|
||||
avatarVideo = Signal<TelegramMediaResource?, NoError> { subscriber in
|
||||
// ... produces a TelegramMediaResource ...
|
||||
subscriber.putNext(someResource)
|
||||
}
|
||||
// After:
|
||||
avatarVideo = Signal<EngineMediaResource?, NoError> { subscriber in
|
||||
// ... produces a TelegramMediaResource ...
|
||||
subscriber.putNext(someResource.flatMap { EngineMediaResource($0) }) // or wrap the non-optional path
|
||||
}
|
||||
```
|
||||
|
||||
The exact wrapping site depends on where the raw resource flows in. The grep + read from Step 1 tells you.
|
||||
|
||||
Downstream, any call site that consumed the raw resource and handed it to an engine facade now has an `EngineMediaResource?` which it can pass directly (post-Tasks 2–5).
|
||||
|
||||
- [ ] **Step 3: Full build**
|
||||
|
||||
Run the full build. Expected: PASS.
|
||||
|
||||
If the downstream expected a `TelegramMediaResource?` (e.g. for direct Postbox access that wasn't part of Tasks 2–5), revert this task as `Abandoned — downstream expects raw protocol` with a recorded reason.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/AuthorizationUI/Sources/AuthorizationSequenceController.swift
|
||||
git commit -m "$(cat <<'EOF'
|
||||
AuthorizationUI: migrate avatar-video signal type to EngineMediaResource
|
||||
|
||||
Single type-reference swap. Downstream engine facades already accept
|
||||
EngineMediaResource after the Phase-1 migrations. Behavior-preserving.
|
||||
|
||||
Part of the MediaResource -> EngineMediaResource migration (wave 2).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Migrate `SaveToCameraRoll` property type — **ABANDONED**
|
||||
|
||||
**Status:** Abandoned in wave 2. No code changes from this task.
|
||||
|
||||
**Reason:** The planning-time grep that produced the "one reference" inventory only matched `MediaResource`/`TelegramMediaResource` tokens, not the broader set of Postbox usages. Re-inventorying the module at execution time (`grep -nE "\b(postbox|mediaBox|MediaResource)\b|^import Postbox" submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift`) shows three public functions with `postbox: Postbox` in their signatures (`fetchMediaData`, `saveToCameraRoll`, `copyToPasteboard`) plus multiple `postbox.mediaBox.*` calls in their bodies. Per spec rule 2, `Postbox` is an umbrella type that cannot be typealiased, so those public-API signatures cannot be de-Postboxed without editing every caller; and the internal `postbox.mediaBox.*` calls require engine-side wrappers (closer to Task 6's shape) rather than a simple type swap. Scope is a full module-migration wave, not a single type swap — parked for a future wave.
|
||||
|
||||
**Original task body (retained for audit trail, do not implement):**
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift`
|
||||
- Possibly modify: `submodules/SaveToCameraRoll/BUILD`
|
||||
|
||||
**Starting inventory:** one reference — `var resource: MediaResource?` at line 19.
|
||||
|
||||
- [ ] **Step 1: Read + full grep**
|
||||
|
||||
```bash
|
||||
grep -nE "\b(MediaResource|TelegramMediaResource|postbox|mediaBox|transaction|PostboxView|combinedView)\b|^import Postbox" submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift
|
||||
```
|
||||
|
||||
Capture every hit.
|
||||
|
||||
- [ ] **Step 2: Abandon-check**
|
||||
|
||||
If the grep shows Postbox usages other than the single `MediaResource?` property and an `import Postbox` line, abandon this task with a recorded reason. Do not substitute.
|
||||
|
||||
If it shows only the property + import, proceed.
|
||||
|
||||
- [ ] **Step 3: Swap the property type + boundary wrap/unwrap**
|
||||
|
||||
Change `var resource: MediaResource?` to `var resource: EngineMediaResource?`. At each assignment/use:
|
||||
|
||||
- Assignment from a raw resource: `self.resource = EngineMediaResource(rawResource)`; `self.resource = nil` unchanged.
|
||||
- Read that hands to mediaBox/postbox (if any remains): `self.resource?._asResource()`.
|
||||
|
||||
- [ ] **Step 4: Drop `import Postbox` if now unused**
|
||||
|
||||
If Step 1 showed `import Postbox` as the only remaining Postbox reference:
|
||||
|
||||
- Remove the `import Postbox` line.
|
||||
- Remove `"//submodules/Postbox:Postbox",` from `submodules/SaveToCameraRoll/BUILD`.
|
||||
|
||||
Static checks:
|
||||
|
||||
```bash
|
||||
grep -R "^import Postbox" submodules/SaveToCameraRoll/Sources # expect: empty
|
||||
grep "submodules/Postbox" submodules/SaveToCameraRoll/BUILD # expect: empty
|
||||
```
|
||||
|
||||
Else skip this step.
|
||||
|
||||
- [ ] **Step 5: Full build**
|
||||
|
||||
Run the full build. Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
If the import was removed:
|
||||
|
||||
```bash
|
||||
git add submodules/SaveToCameraRoll/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
SaveToCameraRoll: migrate resource property to EngineMediaResource and drop Postbox
|
||||
|
||||
Swaps the single MediaResource? property for EngineMediaResource?,
|
||||
wrapping/unwrapping at boundaries. With the only Postbox reference
|
||||
gone, removes `import Postbox` and the Bazel dep.
|
||||
Behavior-preserving.
|
||||
|
||||
Part of the MediaResource -> EngineMediaResource migration (wave 2).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
If the import was kept:
|
||||
|
||||
```bash
|
||||
git add submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift
|
||||
git commit -m "$(cat <<'EOF'
|
||||
SaveToCameraRoll: migrate resource property to EngineMediaResource
|
||||
|
||||
Swaps the single MediaResource? property for EngineMediaResource?,
|
||||
wrapping/unwrapping at boundaries. import Postbox remains because
|
||||
other identifiers still need it. Behavior-preserving.
|
||||
|
||||
Part of the MediaResource -> EngineMediaResource migration (wave 2).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Wave-2 completion verification
|
||||
|
||||
**Files:** No code changes.
|
||||
|
||||
- [ ] **Step 1: Commit-log check**
|
||||
|
||||
```bash
|
||||
git log --oneline master..HEAD # or whatever branch this was executed on
|
||||
```
|
||||
|
||||
Expected commits (some may be absent if tasks abandoned):
|
||||
|
||||
- `CLAUDE.md: record TelegramCore-no-UIKit rule and EngineMediaResource migration pattern`
|
||||
- `TelegramCore: migrate peer-photo facade to EngineMediaResource`
|
||||
- `TelegramCore: migrate account-photo facade to EngineMediaResource`
|
||||
- `TelegramCore: migrate updateContactPhoto facade to EngineMediaResource`
|
||||
- `TelegramCore: migrate Auth.uploadedPeerVideo facade to EngineMediaResource`
|
||||
- `MapResourceToAvatarSizes: migrate to EngineMediaResource and drop Postbox`
|
||||
- `AuthorizationUI: migrate avatar-video signal type to EngineMediaResource`
|
||||
- `SaveToCameraRoll: migrate resource property to EngineMediaResource[...]`
|
||||
|
||||
- [ ] **Step 2: Public-API leak check**
|
||||
|
||||
```bash
|
||||
grep -nE "^\s*public func .*: MediaResource|public func .*MediaResource, \[" \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/
|
||||
```
|
||||
|
||||
Expected: no matches in the facade files touched by Tasks 2–5 (`TelegramEngine/Peers/TelegramEnginePeers.swift`, `TelegramEngine/AccountData/TelegramEngineAccountData.swift`, `TelegramEngine/Contacts/TelegramEngineContacts.swift`, `TelegramEngine/Auth/TelegramEngineAuth.swift`). Other TelegramEngine files may still leak `MediaResource` — those are for future waves.
|
||||
|
||||
- [ ] **Step 3: Final full build from clean state**
|
||||
|
||||
Run the full build. Expected: PASS (cached, fast).
|
||||
|
||||
- [ ] **Step 4: No commit.** Verification only.
|
||||
|
||||
---
|
||||
|
||||
## Future waves (not in this plan)
|
||||
|
||||
Ranked consumer modules by MediaResource/TelegramMediaResource reference count (from `grep -rE "\\b(MediaResource|TelegramMediaResource)\\b"` over `submodules/<M>/Sources/`, excluding TelegramCore/Postbox). Classifications are preliminary and must be re-audited at the start of each future wave.
|
||||
|
||||
| Refs | Module | Future-wave notes |
|
||||
| --- | --- | --- |
|
||||
| 2 | ChatPresentationInterfaceState | Public struct field `resource: TelegramMediaResource` — needs caller audit. |
|
||||
| 2 | ItemListStickerPackItem | Enum case leaks `MediaResource` — needs caller audit. |
|
||||
| 2 | TelegramCallsUI | Signal<TelegramMediaResource, …> locals; mostly type-refs. |
|
||||
| 3 | LegacyMediaPickerUI | `thumbnailResource: TelegramMediaResource?` internal properties — likely safe. |
|
||||
| 3 | ReactionSelectionNode | `customEffectResource: MediaResource?` in public func — caller audit. |
|
||||
| 3 | TelegramAnimatedStickerNode | `public init(postbox: Postbox, resource: MediaResource, …)` + `public convenience init(account: Account, …)` — umbrella-type leaks; needs a paired wave. |
|
||||
| 4 | GalleryUI | `private func setupStatus(resource: MediaResource)` — internal, 4 files. |
|
||||
| 5 | StickerResources | Multiple public funcs take `postbox: Postbox, resource: MediaResource` / `mediaBox: MediaBox`. |
|
||||
| 6 | PhotoResources | Similar to StickerResources; also `securePhoto(account: Account, resource: TelegramMediaResource, …)`. |
|
||||
| 7 | MediaPlayer | `mediaBox: MediaBox, resource: MediaResource` in public init — umbrella leaks. |
|
||||
| 7 | WebSearchUI | `thumbnailResource: TelegramMediaResource?` in multiple structs/inits. |
|
||||
| 8 | AccountContext | Protocol surface — audit carefully. |
|
||||
| 8 | SoftwareVideo | Public init takes `mediaBox: MediaBox` + `resource: MediaResource`. |
|
||||
| 12 | LocalMediaResources | Contains `VideoLibraryMediaResource: TelegramMediaResource` — blocked for conformance. |
|
||||
| 14 | LegacyDataImport | Legacy path; audit scope. |
|
||||
| 25 | PassportUI | Large surface; break into multiple tasks. |
|
||||
| 36 | TelegramUI | Umbrella module; never as one wave. |
|
||||
|
||||
**Blocked-by-conformance modules, explicitly out of all waves:**
|
||||
|
||||
- `submodules/ICloudResources/Sources/ICloudResources.swift` — `ICloudFileResource`
|
||||
- `submodules/InstantPageUI/Sources/InstantPageExternalMediaResource.swift` — `InstantPageExternalMediaResource`
|
||||
- `submodules/LocalMediaResources/Sources/LocalMediaResources.swift` — `VideoLibraryMediaResource`
|
||||
- `submodules/TelegramUniversalVideoContent/Sources/YoutubeEmbedImplementation.swift` — `YoutubeEmbedStoryboardMediaResource`
|
||||
|
||||
These classes must conform to `TelegramMediaResource` to satisfy the PostboxCoding serialization contract. They remain `import Postbox`.
|
||||
|
||||
---
|
||||
|
||||
## What's explicitly NOT in this plan
|
||||
|
||||
- Adding opt-in `EngineMediaResource` overloads alongside raw-`MediaResource` overloads. The facade is changed in place.
|
||||
- Touching any class conforming to `TelegramMediaResource`.
|
||||
- Editing `TelegramUI`, `PassportUI`, `LegacyDataImport`, or the other heavy-ref modules in the Future-waves table beyond what the Phase-1 call-site migrations require.
|
||||
- Importing UIKit/Display into TelegramCore under any circumstance.
|
||||
- Modifying `_internal_*` functions in TelegramCore — they stay on raw `MediaResource`.
|
||||
- Any behavior change, performance tweak, or "while we're here" cleanup.
|
||||
|
|
@ -0,0 +1,968 @@
|
|||
# Postbox → TelegramEngine Wave 3 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:** Add three thin forwarding methods on `TelegramEngine.Resources` for fetch/status/data, then migrate `SaveToCameraRoll` to use them, drop `import Postbox` from that module, and update all 23 call sites.
|
||||
|
||||
**Architecture:** Two atomic commits on branch `refactor/postbox-to-engine-wave-3`. C1 adds the facades in isolation. C2 changes `SaveToCameraRoll`'s public API (drops the `postbox:` parameter, switches `FetchMediaDataState.data` payload from `MediaResourceData` to `EngineMediaResource.ResourceData`), rewrites the module's internals via `context.engine.resources.*`, removes `import Postbox`, and updates every caller in the same commit so the tree remains buildable.
|
||||
|
||||
**Tech Stack:** Swift / Bazel. No unit tests exist in this repo — verification is a full project build.
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md](docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md)
|
||||
|
||||
**Build command (use for every "full build" step):**
|
||||
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64
|
||||
```
|
||||
|
||||
The prefix `source ~/.zshrc 2>/dev/null;` is required because `TELEGRAM_CODESIGNING_GIT_PASSWORD` lives in `~/.zshrc` and the bash tool does not source shell config by default.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `TelegramEngine.Resources.fetch/status/data` facades (C1)
|
||||
|
||||
**Files:**
|
||||
- Modify: [submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift:415-417](submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift#L415)
|
||||
|
||||
- [ ] **Step 1: Insert the three facade methods**
|
||||
|
||||
Open `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`. Find the existing `applicationIcons()` method (currently the last method in the `Resources` class). Insert the three new methods immediately after it, still inside the `final class Resources` brace (before the closing `}`):
|
||||
|
||||
```swift
|
||||
public func applicationIcons() -> Signal<TelegramApplicationIcons, NoError> {
|
||||
return _internal_applicationIcons(account: account)
|
||||
}
|
||||
|
||||
public func fetch(
|
||||
reference: MediaResourceReference,
|
||||
userLocation: MediaResourceUserLocation,
|
||||
userContentType: MediaResourceUserContentType
|
||||
) -> Signal<FetchResourceSourceType, FetchResourceError> {
|
||||
return fetchedMediaResource(
|
||||
mediaBox: self.account.postbox.mediaBox,
|
||||
userLocation: userLocation,
|
||||
userContentType: userContentType,
|
||||
reference: reference
|
||||
)
|
||||
}
|
||||
|
||||
public func status(
|
||||
resource: EngineMediaResource
|
||||
) -> Signal<EngineMediaResource.FetchStatus, NoError> {
|
||||
return self.account.postbox.mediaBox.resourceStatus(resource._asResource())
|
||||
|> map { EngineMediaResource.FetchStatus($0) }
|
||||
}
|
||||
|
||||
public func data(
|
||||
resource: EngineMediaResource,
|
||||
pathExtension: String?,
|
||||
waitUntilFetchStatus: Bool
|
||||
) -> Signal<EngineMediaResource.ResourceData, NoError> {
|
||||
return self.account.postbox.mediaBox.resourceData(
|
||||
resource._asResource(),
|
||||
pathExtension: pathExtension,
|
||||
option: .complete(waitUntilFetchStatus: waitUntilFetchStatus)
|
||||
)
|
||||
|> map { EngineMediaResource.ResourceData($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Full build — verify C1 compiles cleanly**
|
||||
|
||||
Run the build command from the header. Expected: build succeeds with no errors. If a `signature mismatch` or `cannot find 'fetchedMediaResource'` error appears, double-check that `FetchedMediaResource.swift` and `MediaBox.swift` already export the referenced symbols (they do as of this plan's writing — no import changes are needed in `TelegramEngineResources.swift`, which already imports `Postbox`, `SwiftSignalKit`, and `TelegramApi`).
|
||||
|
||||
- [ ] **Step 3: Commit C1**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift
|
||||
git commit -m "$(cat <<'EOF'
|
||||
TelegramEngine.Resources: add fetch/status/data facades
|
||||
|
||||
Thin forwarders over MediaBox for the narrow surface SaveToCameraRoll
|
||||
needs. Takes EngineMediaResource and returns EngineMediaResource-typed
|
||||
results where applicable. Wave-3 groundwork.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Pre-flight — re-inventory call sites and verify ShareController postbox
|
||||
|
||||
No code changes in this task. Its purpose is to catch drift from the spec's inventory before editing code, per CLAUDE.md's "inventory at execution time" guidance.
|
||||
|
||||
**Files:** (read-only)
|
||||
- Spec inventory: [docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md](docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md)
|
||||
- Definition to verify: `submodules/ShareController/Sources/ShareController.swift` around line 2403 and `ShareControllerAppAccountContext`
|
||||
|
||||
- [ ] **Step 1: Re-grep the current call-site set**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -rnE "(fetchMediaData|saveToCameraRoll|copyToPasteboard)\(" submodules --include="*.swift" \
|
||||
| grep -v "SaveToCameraRoll/Sources/SaveToCameraRoll.swift" \
|
||||
| grep -v "private func saveToCameraRoll" \
|
||||
| grep -v "self\?\.saveToCameraRoll\|strongSelf\.saveToCameraRoll"
|
||||
```
|
||||
|
||||
Expected output has exactly 23 lines across 14 files, matching the spec's inventory table:
|
||||
|
||||
| Module | File | Expected count |
|
||||
|---|---|---|
|
||||
| InstantPageUI | `Sources/InstantPageControllerNode.swift` | 2 |
|
||||
| LegacyMediaPickerUI | `Sources/LegacyAttachmentMenu.swift` | 2 |
|
||||
| LegacyMediaPickerUI | `Sources/LegacyAvatarPicker.swift` | 2 |
|
||||
| BrowserUI | `Sources/BrowserInstantPageContent.swift` | 2 |
|
||||
| GalleryUI | `Sources/Items/ChatImageGalleryItem.swift` | 2 |
|
||||
| GalleryUI | `Sources/Items/UniversalVideoGalleryItem.swift` | 3 |
|
||||
| TelegramUI (MediaEditorScreen) | `Components/MediaEditorScreen/Sources/MediaEditorScreen.swift` | 1 |
|
||||
| TelegramUI (MediaEditorScreen) | `Components/MediaEditorScreen/Sources/EditStories.swift` | 1 |
|
||||
| TelegramUI (ChatQrCodeScreen) | `Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift` | 1 |
|
||||
| TelegramUI (StoryContainer) | `Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift` | 1 |
|
||||
| TelegramUI (PeerInfoStoryGrid) | `Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift` | 1 |
|
||||
| TelegramUI | `Sources/ChatInterfaceStateContextMenus.swift` | 1 |
|
||||
| TelegramUI | `Sources/SaveMediaToFiles.swift` | 1 |
|
||||
| ShareController | `Sources/ShareController.swift` | 3 |
|
||||
|
||||
If the count or file list has drifted meaningfully from this table, **stop**, report the drift, and request a spec revision before continuing. Additions of one or two call sites can be folded in; larger drift should pause the wave.
|
||||
|
||||
- [ ] **Step 2: Verify `ShareController:2406` postbox equivalence**
|
||||
|
||||
Read `submodules/ShareController/Sources/ShareController.swift` lines 2395–2420. The private helper `saveToCameraRoll(messages:completion:)` contains `let postbox = self.currentContext.stateManager.postbox` and passes it to `SaveToCameraRoll.saveToCameraRoll`. After the migration, `SaveToCameraRoll` will use `context.account.postbox.mediaBox` internally.
|
||||
|
||||
The enclosing function gates on `self.currentContext as? ShareControllerAppAccountContext`. In that code path, `accountContext.context.account` is the `Account` that `ShareControllerAppAccountContext` was built from, and `self.currentContext.stateManager` is that same account's state manager. Therefore `accountContext.context.account.postbox === self.currentContext.stateManager.postbox`.
|
||||
|
||||
Confirm this by reading the definition of `ShareControllerAppAccountContext` in `submodules/AccountContext/Sources/ShareController.swift` (or the file where it's defined — grep for `ShareControllerAppAccountContext` to locate). If the `stateManager` there is derived from the same `account` whose `postbox` is reachable via `context.account.postbox`, treat the two as equivalent and proceed. If they can diverge (e.g., share-extension account switching creates a separate state manager), **stop** and abandon the ShareController:2406 edit with a recorded reason before continuing — the rest of the wave still applies.
|
||||
|
||||
- [ ] **Step 3: Record verification outcome**
|
||||
|
||||
Write a one-line note in the executor's task log noting either "ShareController:2406 postbox equivalence confirmed" or "ShareController:2406 abandoned — reason: ...". No commit.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Migrate `SaveToCameraRoll` module
|
||||
|
||||
This task changes the module's public API and internals. Build will fail after this task because all callers are still passing `postbox:` — that's expected and will be fixed in Task 4, which must land in the same commit as this task.
|
||||
|
||||
**Files:**
|
||||
- Modify: [submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift](submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift) (entire file rewritten as shown below)
|
||||
|
||||
- [ ] **Step 1: Rewrite `SaveToCameraRoll.swift`**
|
||||
|
||||
Replace the file's contents with:
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SwiftSignalKit
|
||||
import TelegramCore
|
||||
import Photos
|
||||
import Display
|
||||
import MobileCoreServices
|
||||
import DeviceAccess
|
||||
import AccountContext
|
||||
import LegacyComponents
|
||||
|
||||
public enum FetchMediaDataState {
|
||||
case progress(Float)
|
||||
case data(EngineMediaResource.ResourceData)
|
||||
}
|
||||
|
||||
public func fetchMediaData(context: AccountContext, userLocation: MediaResourceUserLocation, customUserContentType: MediaResourceUserContentType? = nil, mediaReference: AnyMediaReference, forceVideo: Bool = false) -> Signal<(FetchMediaDataState, Bool), NoError> {
|
||||
var resource: TelegramMediaResource?
|
||||
var isImage = true
|
||||
var fileExtension: String?
|
||||
var userContentType: MediaResourceUserContentType = .other
|
||||
if let image = mediaReference.media as? TelegramMediaImage {
|
||||
userContentType = .image
|
||||
if let video = image.videoRepresentations.last, forceVideo {
|
||||
resource = video.resource
|
||||
isImage = false
|
||||
} else if let representation = largestImageRepresentation(image.representations) {
|
||||
resource = representation.resource
|
||||
}
|
||||
} else if let file = mediaReference.media as? TelegramMediaFile {
|
||||
userContentType = MediaResourceUserContentType(file: file)
|
||||
resource = file.resource
|
||||
if file.isVideo || file.mimeType.hasPrefix("video/") {
|
||||
isImage = false
|
||||
}
|
||||
let maybeExtension = ((file.fileName ?? "") as NSString).pathExtension
|
||||
if !maybeExtension.isEmpty {
|
||||
fileExtension = maybeExtension
|
||||
}
|
||||
} else if let webpage = mediaReference.media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content {
|
||||
if let file = content.file {
|
||||
resource = file.resource
|
||||
if file.isVideo {
|
||||
isImage = false
|
||||
}
|
||||
} else if let image = content.image {
|
||||
if let representation = largestImageRepresentation(image.representations) {
|
||||
resource = representation.resource
|
||||
}
|
||||
}
|
||||
}
|
||||
if let customUserContentType {
|
||||
userContentType = customUserContentType
|
||||
}
|
||||
|
||||
if let resource = resource {
|
||||
let engineResource = EngineMediaResource(resource)
|
||||
let fetchedData: Signal<FetchMediaDataState, NoError> = Signal { subscriber in
|
||||
let fetched = context.engine.resources.fetch(
|
||||
reference: mediaReference.resourceReference(resource),
|
||||
userLocation: userLocation,
|
||||
userContentType: userContentType
|
||||
).start()
|
||||
let status = context.engine.resources.status(resource: engineResource).start(next: { status in
|
||||
switch status {
|
||||
case .Local:
|
||||
subscriber.putNext(.progress(1.0))
|
||||
case .Remote:
|
||||
subscriber.putNext(.progress(0.0))
|
||||
case let .Fetching(_, progress):
|
||||
subscriber.putNext(.progress(progress))
|
||||
case let .Paused(progress):
|
||||
subscriber.putNext(.progress(progress))
|
||||
}
|
||||
})
|
||||
let data = context.engine.resources.data(
|
||||
resource: engineResource,
|
||||
pathExtension: fileExtension,
|
||||
waitUntilFetchStatus: true
|
||||
).start(next: { next in
|
||||
subscriber.putNext(.data(next))
|
||||
}, completed: {
|
||||
subscriber.putCompletion()
|
||||
})
|
||||
return ActionDisposable {
|
||||
fetched.dispose()
|
||||
status.dispose()
|
||||
data.dispose()
|
||||
}
|
||||
}
|
||||
return fetchedData
|
||||
|> map { data in
|
||||
return (data, isImage)
|
||||
}
|
||||
} else {
|
||||
return .complete()
|
||||
}
|
||||
}
|
||||
|
||||
public func saveToCameraRoll(context: AccountContext, userLocation: MediaResourceUserLocation, customUserContentType: MediaResourceUserContentType? = nil, mediaReference: AnyMediaReference, video: AnyMediaReference? = nil) -> Signal<Float, NoError> {
|
||||
let mediaData: Signal<(FetchMediaDataState, Bool), NoError> = fetchMediaData(context: context, userLocation: userLocation, customUserContentType: customUserContentType, mediaReference: mediaReference)
|
||||
let videoData: Signal<FetchMediaDataState?, NoError>
|
||||
if let video {
|
||||
videoData = fetchMediaData(context: context, userLocation: userLocation, customUserContentType: customUserContentType, mediaReference: video)
|
||||
|> map { state, _ in
|
||||
return state
|
||||
}
|
||||
|> map(Optional.init)
|
||||
} else {
|
||||
videoData = .single(nil)
|
||||
}
|
||||
|
||||
return combineLatest(
|
||||
queue: Queue.mainQueue(),
|
||||
mediaData,
|
||||
videoData
|
||||
)
|
||||
|> mapToSignal { stateAndIsImage, videoStateAndIsImage -> Signal<Float, NoError> in
|
||||
let isImage = stateAndIsImage.1
|
||||
var mainData: EngineMediaResource.ResourceData?
|
||||
var videoData: EngineMediaResource.ResourceData?
|
||||
var waitForVideo = false
|
||||
if let videoState = videoStateAndIsImage {
|
||||
switch videoState {
|
||||
case let .progress(value):
|
||||
return .single(value * 0.95)
|
||||
case let .data(data):
|
||||
videoData = data
|
||||
}
|
||||
switch stateAndIsImage.0 {
|
||||
case let .progress(value):
|
||||
return .single(0.95 + 0.05 * value)
|
||||
case let .data(data):
|
||||
mainData = data
|
||||
}
|
||||
waitForVideo = true
|
||||
} else {
|
||||
switch stateAndIsImage.0 {
|
||||
case let .progress(value):
|
||||
return .single(value)
|
||||
case let .data(data):
|
||||
mainData = data
|
||||
}
|
||||
}
|
||||
if let mainData, mainData.isComplete, videoData != nil || !waitForVideo {
|
||||
return Signal<Float, NoError> { subscriber in
|
||||
DeviceAccess.authorizeAccess(to: .mediaLibrary(.save), presentationData: context.sharedContext.currentPresentationData.with { $0 }, present: { c, a in
|
||||
context.sharedContext.presentGlobalController(c, a)
|
||||
}, openSettings: context.sharedContext.applicationBindings.openSettings, { authorized in
|
||||
if !authorized {
|
||||
subscriber.putCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
let tempVideoPath = NSTemporaryDirectory() + "\(Int64.random(in: Int64.min ... Int64.max)).mp4"
|
||||
if isImage, let videoData, let imageData = try? Data(contentsOf: URL(fileURLWithPath: mainData.path)) {
|
||||
let id = UUID().uuidString
|
||||
|
||||
let jpegWithID = addAssetIdentifierToJPEG(imageData, assetIdentifier: id)!
|
||||
let outputVideoURL = URL(fileURLWithPath: NSTemporaryDirectory() + "\(id).mov")
|
||||
|
||||
try? FileManager.default.copyItem(atPath: videoData.path, toPath: tempVideoPath)
|
||||
|
||||
addAssetIdentifierToVideo(inputURL: URL(fileURLWithPath: tempVideoPath), outputURL: outputVideoURL, assetIdentifier: id) { success in
|
||||
guard success else { return }
|
||||
|
||||
PHPhotoLibrary.shared().performChanges({
|
||||
let request = PHAssetCreationRequest.forAsset()
|
||||
|
||||
request.addResource(with: .photo, data: jpegWithID, options: nil)
|
||||
request.addResource(with: .pairedVideo, fileURL: outputVideoURL, options: nil)
|
||||
}, completionHandler: { _, error in
|
||||
let _ = try? FileManager.default.removeItem(atPath: tempVideoPath)
|
||||
subscriber.putNext(1.0)
|
||||
subscriber.putCompletion()
|
||||
})
|
||||
}
|
||||
} else {
|
||||
PHPhotoLibrary.shared().performChanges({
|
||||
if isImage {
|
||||
if let imageData = try? Data(contentsOf: URL(fileURLWithPath: mainData.path)) {
|
||||
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: imageData, options: nil)
|
||||
}
|
||||
} else {
|
||||
if let _ = try? FileManager.default.copyItem(atPath: mainData.path, toPath: tempVideoPath) {
|
||||
PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: URL(fileURLWithPath: tempVideoPath))
|
||||
}
|
||||
}
|
||||
}, completionHandler: { _, error in
|
||||
if let error {
|
||||
print("\(error)")
|
||||
}
|
||||
let _ = try? FileManager.default.removeItem(atPath: tempVideoPath)
|
||||
subscriber.putNext(1.0)
|
||||
subscriber.putCompletion()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return ActionDisposable {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return .complete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func copyToPasteboard(context: AccountContext, userLocation: MediaResourceUserLocation, mediaReference: AnyMediaReference) -> Signal<Void, NoError> {
|
||||
return fetchMediaData(context: context, userLocation: userLocation, mediaReference: mediaReference)
|
||||
|> mapToSignal { state, isImage -> Signal<Void, NoError> in
|
||||
if case let .data(data) = state, data.isComplete {
|
||||
return Signal<Void, NoError> { subscriber in
|
||||
let pasteboard = UIPasteboard.general
|
||||
|
||||
if mediaReference.media is TelegramMediaImage {
|
||||
if let fileData = try? Data(contentsOf: URL(fileURLWithPath: data.path), options: .mappedIfSafe) {
|
||||
pasteboard.setData(fileData, forPasteboardType: kUTTypeJPEG as String)
|
||||
}
|
||||
}
|
||||
subscriber.putNext(Void())
|
||||
subscriber.putCompletion()
|
||||
|
||||
return EmptyDisposable
|
||||
}
|
||||
} else {
|
||||
return .complete()
|
||||
}
|
||||
}
|
||||
|> mapToSignal { _ -> Signal<Void, NoError> in return .complete() }
|
||||
}
|
||||
|
||||
private func addAssetIdentifierToJPEG(_ imageData: Data, assetIdentifier: String) -> Data? {
|
||||
guard let source = CGImageSourceCreateWithData(imageData as CFData, nil), let uti = CGImageSourceGetType(source), let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let mutableData = NSMutableData()
|
||||
guard let destination = CGImageDestinationCreateWithData(mutableData, uti, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var metadata = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] ?? [:]
|
||||
|
||||
var maker = metadata[kCGImagePropertyMakerAppleDictionary as String] as? [String: Any] ?? [:]
|
||||
maker["17"] = assetIdentifier
|
||||
metadata[kCGImagePropertyMakerAppleDictionary as String] = maker
|
||||
|
||||
CGImageDestinationAddImage(destination, cgImage, metadata as CFDictionary)
|
||||
CGImageDestinationFinalize(destination)
|
||||
|
||||
return mutableData as Data
|
||||
}
|
||||
|
||||
private func addAssetIdentifierToVideo(inputURL: URL, outputURL: URL, assetIdentifier: String, completion: @escaping (Bool) -> Void) {
|
||||
let asset = AVAsset(url: inputURL)
|
||||
|
||||
guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
let identifierItem = AVMutableMetadataItem()
|
||||
identifierItem.keySpace = .quickTimeMetadata
|
||||
identifierItem.key = AVMetadataKey.quickTimeMetadataKeyContentIdentifier as NSString
|
||||
identifierItem.value = assetIdentifier as NSString
|
||||
|
||||
let stillImageTimeItem = AVMutableMetadataItem()
|
||||
let keyStillImageTime = "com.apple.quicktime.still-image-time"
|
||||
let keySpaceQuickTimeMetadata = "mdta"
|
||||
stillImageTimeItem.key = keyStillImageTime as (NSCopying & NSObjectProtocol)?
|
||||
stillImageTimeItem.keySpace = AVMetadataKeySpace(rawValue: keySpaceQuickTimeMetadata)
|
||||
stillImageTimeItem.value = 0 as (NSCopying & NSObjectProtocol)?
|
||||
stillImageTimeItem.dataType = "com.apple.metadata.datatype.int8"
|
||||
|
||||
exportSession.outputURL = outputURL
|
||||
exportSession.outputFileType = .mov
|
||||
exportSession.metadata = [identifierItem, stillImageTimeItem]
|
||||
exportSession.shouldOptimizeForNetworkUse = true
|
||||
|
||||
exportSession.exportAsynchronously {
|
||||
completion(exportSession.status == .completed)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The key differences from the original file:
|
||||
|
||||
1. `import Postbox` — removed.
|
||||
2. `FetchMediaDataState.data(MediaResourceData)` → `FetchMediaDataState.data(EngineMediaResource.ResourceData)`.
|
||||
3. Three public functions drop their `postbox: Postbox` parameter.
|
||||
4. `var resource: MediaResource?` → `var resource: TelegramMediaResource?`.
|
||||
5. Inside `fetchMediaData`: build an `EngineMediaResource(resource)` once, and call `context.engine.resources.fetch / status / data` instead of `fetchedMediaResource(...)` / `postbox.mediaBox.resourceStatus(...)` / `postbox.mediaBox.resourceData(...)`.
|
||||
6. `var mainData: MediaResourceData?` / `var videoData: MediaResourceData?` → `var ...: EngineMediaResource.ResourceData?`.
|
||||
7. `mainData.complete` → `mainData.isComplete`. `data.complete` (in `copyToPasteboard`) → `data.isComplete`. Field `data.path` is unchanged.
|
||||
|
||||
- [ ] **Step 2: Do not build yet — proceed to Task 4**
|
||||
|
||||
Builds will fail until every caller in Task 4 is migrated. Do not run the build command here. No commit yet either — Task 3 and Task 4 share a single atomic commit in Task 5.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Update all 23 call sites
|
||||
|
||||
Every call site does one or both of two edits:
|
||||
|
||||
- **Edit A (all 23 sites):** drop `postbox: someExpression,` from the argument list.
|
||||
- **Edit B (the 7 sites that destructure `fetchMediaData`):** rename `.complete` → `.isComplete` on the destructured data value; `.path` stays the same.
|
||||
|
||||
Each sub-step below is one file. No builds between files. No commit. Task 5 builds everything together.
|
||||
|
||||
**Sub-task 4.1 — InstantPageUI**
|
||||
|
||||
- [ ] **File:** [submodules/InstantPageUI/Sources/InstantPageControllerNode.swift](submodules/InstantPageUI/Sources/InstantPageControllerNode.swift)
|
||||
|
||||
At line 1027, replace:
|
||||
|
||||
```swift
|
||||
let _ = copyToPasteboard(context: strongSelf.context, postbox: strongSelf.context.account.postbox, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start()
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let _ = copyToPasteboard(context: strongSelf.context, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start()
|
||||
```
|
||||
|
||||
At line 1032, replace:
|
||||
|
||||
```swift
|
||||
let _ = saveToCameraRoll(context: strongSelf.context, postbox: strongSelf.context.account.postbox, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start()
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let _ = saveToCameraRoll(context: strongSelf.context, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start()
|
||||
```
|
||||
|
||||
**Sub-task 4.2 — LegacyMediaPickerUI / LegacyAttachmentMenu.swift** (destructures)
|
||||
|
||||
- [ ] **File:** [submodules/LegacyMediaPickerUI/Sources/LegacyAttachmentMenu.swift](submodules/LegacyMediaPickerUI/Sources/LegacyAttachmentMenu.swift)
|
||||
|
||||
At line 173, replace:
|
||||
|
||||
```swift
|
||||
let _ = (fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: media)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let _ = (fetchMediaData(context: context, userLocation: .other, mediaReference: media)
|
||||
```
|
||||
|
||||
In the `.start` block that follows (around line 175), replace `data.complete` with `data.isComplete` (only the `.complete` boolean access — do not touch `data.path`).
|
||||
|
||||
At line 490, replace:
|
||||
|
||||
```swift
|
||||
let _ = (fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: editCurrentMedia)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let _ = (fetchMediaData(context: context, userLocation: .other, mediaReference: editCurrentMedia)
|
||||
```
|
||||
|
||||
In the destructuring block that follows (around line 492), replace `data.complete` with `data.isComplete`.
|
||||
|
||||
**Sub-task 4.3 — LegacyMediaPickerUI / LegacyAvatarPicker.swift** (destructures)
|
||||
|
||||
- [ ] **File:** [submodules/LegacyMediaPickerUI/Sources/LegacyAvatarPicker.swift](submodules/LegacyMediaPickerUI/Sources/LegacyAvatarPicker.swift)
|
||||
|
||||
At line 58, replace:
|
||||
|
||||
```swift
|
||||
let imageSignal = fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: media, forceVideo: false)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let imageSignal = fetchMediaData(context: context, userLocation: .other, mediaReference: media, forceVideo: false)
|
||||
```
|
||||
|
||||
In the `|> map` block immediately after (line ~60), replace `data.complete` with `data.isComplete`.
|
||||
|
||||
At line 67, replace:
|
||||
|
||||
```swift
|
||||
let videoSignal = isVideo ? fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: media, forceVideo: true)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let videoSignal = isVideo ? fetchMediaData(context: context, userLocation: .other, mediaReference: media, forceVideo: true)
|
||||
```
|
||||
|
||||
In the `|> map` block immediately after (line ~69), replace `data.complete` with `data.isComplete`.
|
||||
|
||||
**Sub-task 4.4 — BrowserUI / BrowserInstantPageContent.swift**
|
||||
|
||||
- [ ] **File:** [submodules/BrowserUI/Sources/BrowserInstantPageContent.swift](submodules/BrowserUI/Sources/BrowserInstantPageContent.swift)
|
||||
|
||||
At line 1175, replace:
|
||||
|
||||
```swift
|
||||
let _ = copyToPasteboard(context: self.context, postbox: self.context.account.postbox, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start()
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let _ = copyToPasteboard(context: self.context, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start()
|
||||
```
|
||||
|
||||
At line 1180, replace:
|
||||
|
||||
```swift
|
||||
let _ = saveToCameraRoll(context: self.context, postbox: self.context.account.postbox, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start()
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let _ = saveToCameraRoll(context: self.context, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start()
|
||||
```
|
||||
|
||||
**Sub-task 4.5 — GalleryUI / ChatImageGalleryItem.swift** (one destructures)
|
||||
|
||||
- [ ] **File:** [submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift](submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift)
|
||||
|
||||
At line 732, replace:
|
||||
|
||||
```swift
|
||||
let _ = (fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: media)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let _ = (fetchMediaData(context: context, userLocation: .other, mediaReference: media)
|
||||
```
|
||||
|
||||
In the `.start` block that follows (around line 734), replace `data.complete` with `data.isComplete`.
|
||||
|
||||
At line 758, replace:
|
||||
|
||||
```swift
|
||||
let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: media)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .peer(message.id.peerId), mediaReference: media)
|
||||
```
|
||||
|
||||
**Sub-task 4.6 — GalleryUI / UniversalVideoGalleryItem.swift**
|
||||
|
||||
- [ ] **File:** [submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift](submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift)
|
||||
|
||||
At line 3764, replace:
|
||||
|
||||
```swift
|
||||
let saveSignal = SaveToCameraRoll.saveToCameraRoll(context: self.context, postbox: self.context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: saveFileReference)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let saveSignal = SaveToCameraRoll.saveToCameraRoll(context: self.context, userLocation: .peer(message.id.peerId), mediaReference: saveFileReference)
|
||||
```
|
||||
|
||||
At line 3810, replace:
|
||||
|
||||
```swift
|
||||
let _ = (SaveToCameraRoll.saveToCameraRoll(context: self.context, postbox: self.context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: file))
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let _ = (SaveToCameraRoll.saveToCameraRoll(context: self.context, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: file))
|
||||
```
|
||||
|
||||
At line 3867, replace:
|
||||
|
||||
```swift
|
||||
let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: image), video: videoReference)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: image), video: videoReference)
|
||||
```
|
||||
|
||||
**Sub-task 4.7 — TelegramUI / MediaEditorScreen / MediaEditorScreen.swift** (destructures)
|
||||
|
||||
- [ ] **File:** [submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift](submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift)
|
||||
|
||||
At line 5136, in the multi-line call starting with `let _ = (fetchMediaData(`, delete the line ` postbox: self.context.account.postbox,`. The remaining call should read:
|
||||
|
||||
```swift
|
||||
let _ = (fetchMediaData(
|
||||
context: self.context,
|
||||
userLocation: .other,
|
||||
mediaReference: file
|
||||
) |> deliverOnMainQueue).start(next: { [weak self] state, _ in
|
||||
```
|
||||
|
||||
Inside this closure, the destructuring is `if case let .data(data) = state { let path = data.path ... }` — `data.path` stays unchanged, and this site does not access `data.complete` (verified against the current file). No Edit B rename needed here.
|
||||
|
||||
**Sub-task 4.8 — TelegramUI / MediaEditorScreen / EditStories.swift** (destructures)
|
||||
|
||||
- [ ] **File:** [submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift](submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift)
|
||||
|
||||
At line 37, replace:
|
||||
|
||||
```swift
|
||||
return fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: storyItem.id, media: media))
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
return fetchMediaData(context: context, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: storyItem.id, media: media))
|
||||
```
|
||||
|
||||
At line 39 (inside the `mapToSignal`), replace:
|
||||
|
||||
```swift
|
||||
guard case let .data(data) = value, data.complete else {
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
guard case let .data(data) = value, data.isComplete else {
|
||||
```
|
||||
|
||||
(`data.path` accesses below this line remain unchanged.)
|
||||
|
||||
**Sub-task 4.9 — TelegramUI / ChatQrCodeScreen / ChatQrCodeScreen.swift** (destructures)
|
||||
|
||||
- [ ] **File:** [submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift](submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift)
|
||||
|
||||
At line 2505, replace:
|
||||
|
||||
```swift
|
||||
let _ = (fetchMediaData(context: context, postbox: context.account.postbox, userLocation: userLocation, mediaReference: AnyMediaReference.standalone(media: media))
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let _ = (fetchMediaData(context: context, userLocation: userLocation, mediaReference: AnyMediaReference.standalone(media: media))
|
||||
```
|
||||
|
||||
At line 2507, replace:
|
||||
|
||||
```swift
|
||||
guard case let .data(data) = value, data.complete else {
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
guard case let .data(data) = value, data.isComplete else {
|
||||
```
|
||||
|
||||
**Sub-task 4.10 — TelegramUI / StoryContainerScreen / StoryItemSetContainerComponent.swift**
|
||||
|
||||
- [ ] **File:** [submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift](submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift)
|
||||
|
||||
At line 5980, replace:
|
||||
|
||||
```swift
|
||||
let disposable = (saveToCameraRoll(context: component.context, postbox: component.context.account.postbox, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: component.slice.item.storyItem.id, media: component.slice.item.storyItem.media._asMedia()))
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let disposable = (saveToCameraRoll(context: component.context, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: component.slice.item.storyItem.id, media: component.slice.item.storyItem.media._asMedia()))
|
||||
```
|
||||
|
||||
**Sub-task 4.11 — TelegramUI / PeerInfoStoryGridScreen / PeerInfoStoryGridScreen.swift**
|
||||
|
||||
- [ ] **File:** [submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift](submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift)
|
||||
|
||||
At line 268, replace:
|
||||
|
||||
```swift
|
||||
signals.append(saveToCameraRoll(context: component.context, postbox: component.context.account.postbox, userLocation: .other, mediaReference: .story(peer: peerReference, id: item.id, media: item.media._asMedia()))
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
signals.append(saveToCameraRoll(context: component.context, userLocation: .other, mediaReference: .story(peer: peerReference, id: item.id, media: item.media._asMedia()))
|
||||
```
|
||||
|
||||
**Sub-task 4.12 — TelegramUI / Sources / ChatInterfaceStateContextMenus.swift**
|
||||
|
||||
- [ ] **File:** [submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift](submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift)
|
||||
|
||||
At line 1419, replace:
|
||||
|
||||
```swift
|
||||
let _ = (saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: mediaReference)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let _ = (saveToCameraRoll(context: context, userLocation: .peer(message.id.peerId), mediaReference: mediaReference)
|
||||
```
|
||||
|
||||
**Sub-task 4.13 — TelegramUI / Sources / SaveMediaToFiles.swift** (destructures)
|
||||
|
||||
- [ ] **File:** [submodules/TelegramUI/Sources/SaveMediaToFiles.swift](submodules/TelegramUI/Sources/SaveMediaToFiles.swift)
|
||||
|
||||
At line 27, replace:
|
||||
|
||||
```swift
|
||||
var signal = fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: fileReference.abstract)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
var signal = fetchMediaData(context: context, userLocation: .other, mediaReference: fileReference.abstract)
|
||||
```
|
||||
|
||||
At line 63, replace:
|
||||
|
||||
```swift
|
||||
if data.complete {
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
if data.isComplete {
|
||||
```
|
||||
|
||||
(`data.path` accesses in the block below remain unchanged.)
|
||||
|
||||
**Sub-task 4.14 — ShareController / ShareController.swift**
|
||||
|
||||
- [ ] **File:** [submodules/ShareController/Sources/ShareController.swift](submodules/ShareController/Sources/ShareController.swift)
|
||||
|
||||
At line 2406, after verifying Task 2's postbox-equivalence, replace:
|
||||
|
||||
```swift
|
||||
return SaveToCameraRoll.saveToCameraRoll(context: context, postbox: postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: media))
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
return SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: media))
|
||||
```
|
||||
|
||||
Also delete the now-unused local binding above (line 2403):
|
||||
|
||||
```swift
|
||||
let postbox = self.currentContext.stateManager.postbox
|
||||
```
|
||||
|
||||
(This line is used only by the `saveToCameraRoll` call on line 2406. If the build later flags it as unused instead of an error, leave it; but preferred is to remove the dead binding.)
|
||||
|
||||
At line 2432, replace:
|
||||
|
||||
```swift
|
||||
self.controllerNode.transitionToProgressWithValue(signal: SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: .standalone(media: media)) |> map(Optional.init), dismissImmediately: true, completion: {})
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
self.controllerNode.transitionToProgressWithValue(signal: SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .other, mediaReference: .standalone(media: media)) |> map(Optional.init), dismissImmediately: true, completion: {})
|
||||
```
|
||||
|
||||
At line 2441, replace:
|
||||
|
||||
```swift
|
||||
self.controllerNode.transitionToProgressWithValue(signal: SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: mediaReference) |> map(Optional.init), dismissImmediately: completion == nil, completion: completion ?? {})
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
self.controllerNode.transitionToProgressWithValue(signal: SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .other, mediaReference: mediaReference) |> map(Optional.init), dismissImmediately: completion == nil, completion: completion ?? {})
|
||||
```
|
||||
|
||||
(The abandonment branch: if Task 2's verification found `stateManager.postbox` and `account.postbox` are non-equivalent, skip the `line 2406` edit, leave `let postbox = self.currentContext.stateManager.postbox` in place, and revert Task 3's change to the `saveToCameraRoll` public signature only for this one callsite — which is impossible without duplicate signatures, so in that case abandon the entire wave and record the reason in a new commit to the plan.)
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Full build and commit C2
|
||||
|
||||
- [ ] **Step 1: Run the full project build**
|
||||
|
||||
Run the build command from the header. Expected: build succeeds with no errors across all modules.
|
||||
|
||||
If there are failures, they fall into a few predictable categories and are fixed in place — do not split into another commit:
|
||||
|
||||
- **"cannot convert value of type 'Postbox' to expected argument type"** — a call site was missed. Grep again for `postbox: ` usages in the migrated files and fix.
|
||||
- **"value of type 'EngineMediaResource.ResourceData' has no member 'complete'"** — an Edit B site was missed. Rename to `isComplete`.
|
||||
- **"use of unresolved identifier 'fetchedMediaResource'" or similar inside `SaveToCameraRoll.swift`** — indicates `import Postbox` was dropped but a bare Postbox top-level function is still referenced. Replace the call with the engine facade introduced in Task 1.
|
||||
- **Warnings about unused local `let postbox = ...`** — delete the binding.
|
||||
|
||||
Re-run the build after each fix until it succeeds.
|
||||
|
||||
- [ ] **Step 2: Stage all touched files**
|
||||
|
||||
```bash
|
||||
git add \
|
||||
submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift \
|
||||
submodules/InstantPageUI/Sources/InstantPageControllerNode.swift \
|
||||
submodules/LegacyMediaPickerUI/Sources/LegacyAttachmentMenu.swift \
|
||||
submodules/LegacyMediaPickerUI/Sources/LegacyAvatarPicker.swift \
|
||||
submodules/BrowserUI/Sources/BrowserInstantPageContent.swift \
|
||||
submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift \
|
||||
submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift \
|
||||
submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift \
|
||||
submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift \
|
||||
submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift \
|
||||
submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift \
|
||||
submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift \
|
||||
submodules/TelegramUI/Sources/SaveMediaToFiles.swift \
|
||||
submodules/ShareController/Sources/ShareController.swift
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the diff is clean**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --staged --stat
|
||||
```
|
||||
|
||||
Expected: exactly 15 files changed, with SaveToCameraRoll.swift having the largest diff (the full-file rewrite) and each call-site file showing small line-count changes.
|
||||
|
||||
- [ ] **Step 4: Commit C2**
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
SaveToCameraRoll: drop import Postbox via engine.resources facades
|
||||
|
||||
Migrates SaveToCameraRoll's three public functions to take context
|
||||
only (no more postbox:), switches the FetchMediaDataState.data payload
|
||||
from MediaResourceData to EngineMediaResource.ResourceData, rewrites
|
||||
internals via TelegramEngine.Resources.fetch/status/data, and drops
|
||||
import Postbox from the module. All 23 call sites across 14 files
|
||||
updated in the same commit to keep the tree buildable.
|
||||
|
||||
Wave-3 of the Postbox -> TelegramEngine refactor.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify branch log**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git log --oneline refactor/postbox-to-engine-wave-3 | head -5
|
||||
```
|
||||
|
||||
Expected: the top two commits on the branch are `SaveToCameraRoll: drop import Postbox ...` (C2) and `TelegramEngine.Resources: add fetch/status/data facades` (C1), above the previous spec commits.
|
||||
|
||||
- [ ] **Step 6: Update CLAUDE.md tally**
|
||||
|
||||
Open `CLAUDE.md`, find the "Modules currently free of `import Postbox`" section, and add `SaveToCameraRoll (wave 3)` to the bullet list. Also add a "Wave 3 outcome (2026-04-18)" subsection documenting: three facades added on `TelegramEngine.Resources`, `SaveToCameraRoll` fully de-Postboxed, 23 call sites migrated. If any call site was abandoned in Task 2, record the reason here.
|
||||
|
||||
Commit:
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
CLAUDE.md: record wave-3 outcome
|
||||
|
||||
Adds SaveToCameraRoll to the Postbox-free module tally and documents
|
||||
the three new TelegramEngine.Resources facades added in wave 3.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift` contains no `import Postbox`.
|
||||
- `grep -rnE "(fetchMediaData|saveToCameraRoll|copyToPasteboard)\\(" submodules --include="*.swift" | grep "postbox:"` returns zero matches outside of the private `collectExternalShareResource`/`collectExternalShareItems` helpers in `ShareController.swift` (which take their own `postbox:` parameters unrelated to SaveToCameraRoll).
|
||||
- Full build succeeds in `debug_sim_arm64` configuration.
|
||||
- Three branch commits above the spec commits: C1 (facades), C2 (SaveToCameraRoll + callers), C3 (CLAUDE.md tally).
|
||||
|
|
@ -0,0 +1,500 @@
|
|||
# Postbox → TelegramEngine Wave 4 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 `TelegramEngine.Stickers.uploadSticker`'s public surface — `peer: Peer → EnginePeer`, `resource: MediaResource → EngineMediaResource`, `thumbnail: MediaResource? → EngineMediaResource?`, and `UploadStickerStatus.complete(CloudDocumentMediaResource, String) → .complete(EngineMediaResource, String)` — with one atomic commit touching the facade, the internal enum, and the two call sites.
|
||||
|
||||
**Architecture:** Two commits on branch `refactor/postbox-to-engine-wave-4`. C1 is the atomic four-file code change. C2 is the CLAUDE.md tally update. `_internal_uploadSticker` keeps its raw `Peer`/`MediaResource` signature; the facade does all the wrapping/unwrapping. One spec-allowed one-line exception: `_internal_uploadSticker` constructs `EngineMediaResource(uploadedResource)` at the `.complete(...)` result-construction site to keep `UploadStickerStatus` as a single enum instead of splitting into raw+engine variants.
|
||||
|
||||
**Tech Stack:** Swift / Bazel. No unit tests in this repo — verification is a full project build.
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-4-design.md](docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-4-design.md)
|
||||
|
||||
**Build command** (use for every "full build" step):
|
||||
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64
|
||||
```
|
||||
|
||||
The `source ~/.zshrc` prefix is required because `TELEGRAM_CODESIGNING_GIT_PASSWORD` lives in `~/.zshrc` and the bash tool does not source shell config by default. For a background build from the controller session, prefer `run_in_background: true` and monitor by tailing the task output file (subagent-spawned background builds orphan when the subagent shell terminates).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Pre-flight re-verification
|
||||
|
||||
No code changes. Purpose: re-confirm the facade call-site count and the MediaEditorScreen line numbers haven't drifted.
|
||||
|
||||
**Files:** (read-only)
|
||||
|
||||
- [ ] **Step 1: Re-grep facade call sites**
|
||||
|
||||
```bash
|
||||
grep -rnE "\.uploadSticker\(" submodules --include="*.swift" \
|
||||
| grep -v "/TelegramEngine/Stickers/" \
|
||||
| grep -v "self\.uploadSticker\|strongSelf\.uploadSticker\|self\?\.uploadSticker"
|
||||
```
|
||||
|
||||
Expected output: exactly 2 lines
|
||||
|
||||
- `submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift:91`
|
||||
- `submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift:8099`
|
||||
|
||||
If the count or line numbers have drifted meaningfully, stop and revise the plan before editing.
|
||||
|
||||
- [ ] **Step 2: Re-read MediaEditorScreen block**
|
||||
|
||||
```bash
|
||||
sed -n '8080,8190p' submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift
|
||||
```
|
||||
|
||||
Visually confirm:
|
||||
- Line ~8097 has `.complete(resource, mimeType)` inside an `if let resource = resource as? CloudDocumentMediaResource { … }` branch.
|
||||
- Line ~8099 has `context.engine.stickers.uploadSticker(peer: peer._asPeer(), resource: resource, thumbnail: file.previewRepresentations.first?.resource, …)`.
|
||||
- Line ~8105 has `case let .complete(resource, _):` destructuring the inner `.mapToSignal` status.
|
||||
- Line ~8106 has `stickerFile(resource: resource, thumbnailResource: file.previewRepresentations.first?.resource, …)`.
|
||||
- Line ~8119 has `ImportSticker(resource: .standalone(resource: resource), …)` inside `case let .createStickerPack(title):`.
|
||||
- Line ~8138 has a second `ImportSticker(resource: .standalone(resource: resource), …)` inside `case let .addToStickerPack(pack, title):`.
|
||||
- Line ~8178 has a second `case let .complete(resource, _):` in the outer `.startStandalone(next: …)` handler.
|
||||
- Line ~8180 has `stickerFile(resource: resource, thumbnailResource: file.previewRepresentations.first?.resource, size: resource.size ?? 0, …)`.
|
||||
|
||||
- [ ] **Step 3: Confirm `stickerFile` signature**
|
||||
|
||||
```bash
|
||||
grep -nE "^private func stickerFile\(" submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift
|
||||
```
|
||||
|
||||
Expected: `private func stickerFile(resource: TelegramMediaResource, thumbnailResource: TelegramMediaResource?, size: Int64, dimensions: PixelDimensions, duration: Double?, isVideo: Bool) -> TelegramMediaFile` at line ~9196. This confirms `stickerFile` takes `TelegramMediaResource` (requires `resource._asResource()` at every call).
|
||||
|
||||
- [ ] **Step 4: Confirm ImportStickerPackController's `peer` type**
|
||||
|
||||
```bash
|
||||
sed -n '82,95p' submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift
|
||||
```
|
||||
|
||||
Expected pattern:
|
||||
```swift
|
||||
let _ = (self.context.account.postbox.loadedPeerWithId(self.context.account.peerId)
|
||||
|> deliverOnMainQueue).start(next: { [weak self] peer in
|
||||
```
|
||||
|
||||
`postbox.loadedPeerWithId` returns `Signal<Peer, NoError>`. The local `peer` is therefore a raw `Peer`, not an `EnginePeer`. The call-site edit will need `EnginePeer(peer)` to wrap.
|
||||
|
||||
If any of these expectations fails to match the current source, stop and revise the plan.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Migrate `UploadStickerStatus` enum and internal wrap
|
||||
|
||||
No build; the project won't compile until Tasks 3–5 also land. Do not commit.
|
||||
|
||||
**File:** `submodules/TelegramCore/Sources/TelegramEngine/Stickers/ImportStickers.swift`
|
||||
|
||||
- [ ] **Step 1: Update enum payload (line 7–10)**
|
||||
|
||||
Replace:
|
||||
|
||||
```swift
|
||||
public enum UploadStickerStatus {
|
||||
case progress(Float)
|
||||
case complete(CloudDocumentMediaResource, String)
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
public enum UploadStickerStatus {
|
||||
case progress(Float)
|
||||
case complete(EngineMediaResource, String)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the `.complete(...)` construction in `_internal_uploadSticker` (line ~97)**
|
||||
|
||||
Replace the line reading:
|
||||
|
||||
```swift
|
||||
return .single(.complete(uploadedResource, file.mimeType))
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
return .single(.complete(EngineMediaResource(uploadedResource), file.mimeType))
|
||||
```
|
||||
|
||||
Nothing else in `_internal_uploadSticker` changes. In particular its parameter list (`peer: Peer, resource: MediaResource, thumbnail: MediaResource? = nil, …`) stays exactly as is.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Migrate the public facade signature
|
||||
|
||||
No build; no commit.
|
||||
|
||||
**File:** `submodules/TelegramCore/Sources/TelegramEngine/Stickers/TelegramEngineStickers.swift`
|
||||
|
||||
- [ ] **Step 1: Update the `uploadSticker` facade (line 85–87)**
|
||||
|
||||
Replace:
|
||||
|
||||
```swift
|
||||
public func uploadSticker(peer: Peer, resource: MediaResource, thumbnail: MediaResource?, alt: String, dimensions: PixelDimensions, duration: Double?, mimeType: String) -> Signal<UploadStickerStatus, UploadStickerError> {
|
||||
return _internal_uploadSticker(account: self.account, peer: peer, resource: resource, thumbnail: thumbnail, alt: alt, dimensions: dimensions, duration: duration, mimeType: mimeType)
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
public func uploadSticker(peer: EnginePeer, resource: EngineMediaResource, thumbnail: EngineMediaResource?, alt: String, dimensions: PixelDimensions, duration: Double?, mimeType: String) -> Signal<UploadStickerStatus, UploadStickerError> {
|
||||
return _internal_uploadSticker(account: self.account, peer: peer._asPeer(), resource: resource._asResource(), thumbnail: thumbnail?._asResource(), alt: alt, dimensions: dimensions, duration: duration, mimeType: mimeType)
|
||||
}
|
||||
```
|
||||
|
||||
No other method in `TelegramEngineStickers.swift` changes.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Migrate `ImportStickerPackController.swift:91`
|
||||
|
||||
No build; no commit.
|
||||
|
||||
**File:** `submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift`
|
||||
|
||||
- [ ] **Step 1: Update the facade call (line ~91)**
|
||||
|
||||
Replace:
|
||||
|
||||
```swift
|
||||
signals.append(strongSelf.context.engine.stickers.uploadSticker(peer: peer, resource: resource._asResource(), thumbnail: nil, alt: sticker.emojis.first ?? "", dimensions: PixelDimensions(width: 512, height: 512), duration: nil, mimeType: sticker.mimeType)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
signals.append(strongSelf.context.engine.stickers.uploadSticker(peer: EnginePeer(peer), resource: resource, thumbnail: nil, alt: sticker.emojis.first ?? "", dimensions: PixelDimensions(width: 512, height: 512), duration: nil, mimeType: sticker.mimeType)
|
||||
```
|
||||
|
||||
Two changes: `peer` (raw `Peer`) → `EnginePeer(peer)`, and `resource._asResource()` → `resource` (the local `resource` is an `EngineMediaResource`).
|
||||
|
||||
- [ ] **Step 2: Update the destructure re-wrap (line ~99)**
|
||||
|
||||
Replace:
|
||||
|
||||
```swift
|
||||
case let .complete(resource, mimeType):
|
||||
if ["application/x-tgsticker", "video/webm"].contains(mimeType) {
|
||||
return (sticker.uuid, .verified, EngineMediaResource(resource))
|
||||
} else {
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
case let .complete(resource, mimeType):
|
||||
if ["application/x-tgsticker", "video/webm"].contains(mimeType) {
|
||||
return (sticker.uuid, .verified, resource)
|
||||
} else {
|
||||
```
|
||||
|
||||
One change: `EngineMediaResource(resource)` → `resource`. The destructured `resource` is now already an `EngineMediaResource`.
|
||||
|
||||
Nothing else in this file changes.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Migrate `MediaEditorScreen.swift` sticker-upload block
|
||||
|
||||
No build; no commit. This task touches multiple lines inside a single nested block (~8084–8190). The `UploadStickerStatus` payload migration cascades: wherever the code constructs or destructures `.complete(...)`, types change.
|
||||
|
||||
**File:** `submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift`
|
||||
|
||||
- [ ] **Step 1: Wrap at the direct construction site (line ~8097)**
|
||||
|
||||
Replace the line reading:
|
||||
|
||||
```swift
|
||||
return .single((.progress(1.0), nil)) |> then(.single((.complete(resource, mimeType), nil)))
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
return .single((.progress(1.0), nil)) |> then(.single((.complete(EngineMediaResource(resource), mimeType), nil)))
|
||||
```
|
||||
|
||||
Context: this is inside `if let resource = resource as? CloudDocumentMediaResource { … }`, so `resource` here is `CloudDocumentMediaResource`; the outer tuple's `UploadStickerStatus.complete` now takes `EngineMediaResource`.
|
||||
|
||||
- [ ] **Step 2: Migrate the facade call (line ~8099)**
|
||||
|
||||
Replace:
|
||||
|
||||
```swift
|
||||
return context.engine.stickers.uploadSticker(peer: peer._asPeer(), resource: resource, thumbnail: file.previewRepresentations.first?.resource, alt: "", dimensions: dimensions, duration: duration, mimeType: mimeType)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
return context.engine.stickers.uploadSticker(peer: peer, resource: EngineMediaResource(resource), thumbnail: file.previewRepresentations.first.flatMap { EngineMediaResource($0.resource) }, alt: "", dimensions: dimensions, duration: duration, mimeType: mimeType)
|
||||
```
|
||||
|
||||
Three changes: `peer._asPeer()` → `peer` (local is `EnginePeer`); `resource` → `EngineMediaResource(resource)` (local is raw `MediaResource` from the outer enum destructure); `file.previewRepresentations.first?.resource` → `file.previewRepresentations.first.flatMap { EngineMediaResource($0.resource) }`.
|
||||
|
||||
- [ ] **Step 3: Unwrap at inner-handler `stickerFile` call (line ~8106)**
|
||||
|
||||
Replace:
|
||||
|
||||
```swift
|
||||
case let .complete(resource, _):
|
||||
let file = stickerFile(resource: resource, thumbnailResource: file.previewRepresentations.first?.resource, size: file.size ?? 0, dimensions: dimensions, duration: file.duration, isVideo: isVideo)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
case let .complete(resource, _):
|
||||
let file = stickerFile(resource: resource._asResource(), thumbnailResource: file.previewRepresentations.first?.resource, size: file.size ?? 0, dimensions: dimensions, duration: file.duration, isVideo: isVideo)
|
||||
```
|
||||
|
||||
The destructured `resource` is now an `EngineMediaResource`. `stickerFile` (see line 9196) takes `TelegramMediaResource`, so unwrap with `._asResource()`. `file.previewRepresentations.first?.resource` is already a `TelegramMediaResource?` — no change there.
|
||||
|
||||
- [ ] **Step 4: Unwrap at `.createStickerPack` sticker construction (line ~8119)**
|
||||
|
||||
Replace:
|
||||
|
||||
```swift
|
||||
case let .createStickerPack(title):
|
||||
let sticker = ImportSticker(
|
||||
resource: .standalone(resource: resource),
|
||||
emojis: emojis,
|
||||
dimensions: dimensions,
|
||||
duration: duration,
|
||||
mimeType: mimeType,
|
||||
keywords: ""
|
||||
)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
case let .createStickerPack(title):
|
||||
let sticker = ImportSticker(
|
||||
resource: .standalone(resource: resource._asResource()),
|
||||
emojis: emojis,
|
||||
dimensions: dimensions,
|
||||
duration: duration,
|
||||
mimeType: mimeType,
|
||||
keywords: ""
|
||||
)
|
||||
```
|
||||
|
||||
`MediaResourceReference.standalone(resource:)` takes `MediaResource`; `resource` here is the `EngineMediaResource` destructured at line ~8105. Unwrap with `._asResource()`.
|
||||
|
||||
- [ ] **Step 5: Unwrap at `.addToStickerPack` sticker construction (line ~8138)**
|
||||
|
||||
Replace:
|
||||
|
||||
```swift
|
||||
case let .addToStickerPack(pack, title):
|
||||
let sticker = ImportSticker(
|
||||
resource: .standalone(resource: resource),
|
||||
emojis: emojis,
|
||||
dimensions: dimensions,
|
||||
duration: duration,
|
||||
mimeType: mimeType,
|
||||
keywords: ""
|
||||
)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
case let .addToStickerPack(pack, title):
|
||||
let sticker = ImportSticker(
|
||||
resource: .standalone(resource: resource._asResource()),
|
||||
emojis: emojis,
|
||||
dimensions: dimensions,
|
||||
duration: duration,
|
||||
mimeType: mimeType,
|
||||
keywords: ""
|
||||
)
|
||||
```
|
||||
|
||||
Same unwrap as Step 4.
|
||||
|
||||
- [ ] **Step 6: Unwrap at outer-handler `stickerFile` call (line ~8178–8180)**
|
||||
|
||||
Replace:
|
||||
|
||||
```swift
|
||||
case let .complete(resource, _):
|
||||
let navigationController = self.effectiveNavigationController as? NavigationController
|
||||
|
||||
let result: MediaEditorScreenImpl.Result
|
||||
switch action {
|
||||
case .update:
|
||||
result = MediaEditorScreenImpl.Result(media: .sticker(file: file, emoji: emojis))
|
||||
case .upload, .send:
|
||||
let file = stickerFile(resource: resource, thumbnailResource: file.previewRepresentations.first?.resource, size: resource.size ?? 0, dimensions: dimensions, duration: self.preferredStickerDuration(), isVideo: isVideo)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
case let .complete(resource, _):
|
||||
let rawResource = resource._asResource()
|
||||
let navigationController = self.effectiveNavigationController as? NavigationController
|
||||
|
||||
let result: MediaEditorScreenImpl.Result
|
||||
switch action {
|
||||
case .update:
|
||||
result = MediaEditorScreenImpl.Result(media: .sticker(file: file, emoji: emojis))
|
||||
case .upload, .send:
|
||||
let file = stickerFile(resource: rawResource, thumbnailResource: file.previewRepresentations.first?.resource, size: rawResource.size ?? 0, dimensions: dimensions, duration: self.preferredStickerDuration(), isVideo: isVideo)
|
||||
```
|
||||
|
||||
Two changes: introduce `let rawResource = resource._asResource()` at the top of the `case let .complete(resource, _):` block, and use `rawResource` at both the `resource:` argument and the `size: rawResource.size ?? 0` read. (`EngineMediaResource` does not expose `.size`; only the raw `MediaResource` does.)
|
||||
|
||||
- [ ] **Step 7: Scan for any missed downstream use**
|
||||
|
||||
Run inside the repo:
|
||||
|
||||
```bash
|
||||
sed -n '8080,8200p' submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift | grep -nE "\bresource\b"
|
||||
```
|
||||
|
||||
Skim the output. Every reference to the destructured `resource` inside the nested block (lines ~8084–8190) should either be the new `EngineMediaResource`-typed local or a wrapped/unwrapped form. If you spot a use that would still expect `CloudDocumentMediaResource`-specific members or raw `MediaResource` without the unwrap, stop and report.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Full build and commit C1
|
||||
|
||||
- [ ] **Step 1: Run the full project build**
|
||||
|
||||
Run the build command from the header. Expected: clean success.
|
||||
|
||||
Typical failure modes and fixes (do them inline — do not split into another commit):
|
||||
|
||||
- **"cannot convert value of type 'Peer' to expected argument type 'EnginePeer'"** — a call site was missed or the wrap is misplaced.
|
||||
- **"value of type 'EngineMediaResource' has no member 'size'"** — Task 5 Step 6 wasn't applied (or similar `.size`/`.id.stringRepresentation`/`.isEqual` access on `EngineMediaResource`).
|
||||
- **"cannot convert value of type 'EngineMediaResource' to expected argument type 'TelegramMediaResource'"** — an `._asResource()` is missing at a `stickerFile(...)` or `.standalone(resource:)` call.
|
||||
- **"reference to enum case 'UploadStickerStatus.complete' requires that 'CloudDocumentMediaResource' conform to 'something'"** — a `.complete(...)` construction site wasn't migrated to pass `EngineMediaResource`.
|
||||
|
||||
Re-run the build after each fix.
|
||||
|
||||
- [ ] **Step 2: Stage the 4 files**
|
||||
|
||||
```bash
|
||||
git add \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Stickers/ImportStickers.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Stickers/TelegramEngineStickers.swift \
|
||||
submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift \
|
||||
submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify diff scope**
|
||||
|
||||
```bash
|
||||
git diff --staged --stat
|
||||
```
|
||||
|
||||
Expected: exactly 4 files staged, with MediaEditorScreen having the largest diff (~8 line changes), ImportStickers ~2, TelegramEngineStickers ~2, ImportStickerPackController ~2.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
TelegramEngine.Stickers.uploadSticker: migrate to EnginePeer + EngineMediaResource
|
||||
|
||||
Public facade and UploadStickerStatus.complete payload now use
|
||||
EnginePeer and EngineMediaResource instead of raw Peer / MediaResource
|
||||
/ CloudDocumentMediaResource. _internal_uploadSticker stays on raw
|
||||
Postbox types with one inline EngineMediaResource(uploadedResource)
|
||||
construction at the .complete result site.
|
||||
|
||||
Both call sites (ImportStickerPackController, MediaEditorScreen)
|
||||
updated atomically in the same commit.
|
||||
|
||||
Wave-4 of the Postbox -> TelegramEngine refactor.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify branch state**
|
||||
|
||||
```bash
|
||||
git log --oneline master..HEAD
|
||||
```
|
||||
|
||||
Expected (newest at top):
|
||||
|
||||
- `<sha> TelegramEngine.Stickers.uploadSticker: migrate to EnginePeer + EngineMediaResource`
|
||||
- `b6392bce7c docs(spec): wave-4 enumerate MediaEditorScreen downstream edits`
|
||||
- `59a01b0d4d docs(spec): wave-4 TelegramEngine.Stickers.uploadSticker facade migration`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Update CLAUDE.md tally and commit C2
|
||||
|
||||
- [ ] **Step 1: Add Wave 4 outcome subsection**
|
||||
|
||||
Open `CLAUDE.md`. Find the "Wave 3 outcome (2026-04-18)" section (currently around line 96 onward). Insert a new subsection **after** Wave 3's outcome block and **before** "### Modules currently free of `import Postbox` (running tally)":
|
||||
|
||||
```markdown
|
||||
### Wave 4 outcome (2026-04-18)
|
||||
|
||||
1 `TelegramEngine` facade migrated in place to `EnginePeer` + `EngineMediaResource` (signatures changed; `_internal_uploadSticker` keeps raw `Peer`/`MediaResource`):
|
||||
|
||||
- `TelegramEngine.Stickers.uploadSticker(peer: Peer → EnginePeer, resource: MediaResource → EngineMediaResource, thumbnail: MediaResource? → EngineMediaResource?, …)`
|
||||
|
||||
1 public enum payload migrated: `UploadStickerStatus.complete(CloudDocumentMediaResource, String)` → `.complete(EngineMediaResource, String)`. The internal `_internal_uploadSticker` constructs `EngineMediaResource(uploadedResource)` at the result site — a narrow, spec-allowed one-line deviation from "internal Postbox-facing stays raw", taken to keep `UploadStickerStatus` as a single public enum.
|
||||
|
||||
2 call sites migrated atomically with the facade:
|
||||
- `submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift:91`
|
||||
- `submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift:8099` (plus ~5 cascading sites in the same enclosing block for the new `UploadStickerStatus.complete` payload)
|
||||
|
||||
No module becomes Postbox-free in this wave (both caller files import Postbox for unrelated reasons).
|
||||
|
||||
Plan: `docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-4.md`
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove the `uploadSticker` entry from "Known future-wave candidates"**
|
||||
|
||||
Still in `CLAUDE.md`, find the "Known future-wave candidates" list and delete this bullet (currently around line 143):
|
||||
|
||||
```markdown
|
||||
- `TelegramEngine.Stickers.uploadSticker(peer: Peer, resource: MediaResource, thumbnail: MediaResource?, …)` — same MediaResource migration as wave 2, plus `peer: Peer` which would naturally migrate to `EnginePeer` at the same time. Self-contained to a small number of call sites.
|
||||
```
|
||||
|
||||
Do not touch the other bullets in that list.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
CLAUDE.md: record wave-4 outcome
|
||||
|
||||
Documents the uploadSticker facade migration + UploadStickerStatus
|
||||
payload change; removes uploadSticker from the future-wave candidates
|
||||
list.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `TelegramEngine.Stickers.uploadSticker`'s public signature references neither `Peer` nor `MediaResource` nor `CloudDocumentMediaResource`.
|
||||
- `UploadStickerStatus.complete`'s payload is `(EngineMediaResource, String)`.
|
||||
- `_internal_uploadSticker`'s signature is unchanged (still raw `Peer` / `MediaResource`).
|
||||
- Full build succeeds in `debug_sim_arm64`.
|
||||
- The two call sites (`ImportStickerPackController`, `MediaEditorScreen`) and the cascading sites within MediaEditorScreen's nested block compile against the new types.
|
||||
- `CLAUDE.md` has a "Wave 4 outcome (2026-04-18)" subsection; the `uploadSticker` bullet is gone from "Known future-wave candidates".
|
||||
- Branch `refactor/postbox-to-engine-wave-4` contains 4 commits above `master`: 2 docs (spec + spec fix), 1 code (C1), 1 tally (C2).
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
# Postbox → TelegramEngine Wave 5 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 `uploadSecureIdFile`'s public surface to `(context:, engine: TelegramEngine, resource: EngineMediaResource)`, refactor `SecureIdVerificationDocumentsContext` to take `engine: TelegramEngine` in place of raw `Postbox` + `Network`, and drop `import Postbox` from that caller module. Land as one atomic code commit + one CLAUDE.md tally commit on branch `refactor/postbox-to-engine-wave-5`.
|
||||
|
||||
**Architecture:** Three files land together in C1 because the facade signature change, the caller class's stored-property change, and the one instantiation site are mutually breaking. The facade body inside TelegramCore continues to access raw Postbox types via `engine.account.postbox` / `engine.account.network` — CLAUDE.md's "internal Postbox-facing stays raw" rule applies to the body, while the public signature is the clean surface. C2 updates the CLAUDE.md tally and removes the wave-5-named bullet from "Known future-wave candidates".
|
||||
|
||||
**Tech Stack:** Swift / Bazel. No unit tests by repo policy — verification is a full project build.
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-5-design.md](docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-5-design.md)
|
||||
|
||||
**Build command** (use for every "full build" step):
|
||||
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64
|
||||
```
|
||||
|
||||
The `source ~/.zshrc` prefix is required because `TELEGRAM_CODESIGNING_GIT_PASSWORD` lives in `~/.zshrc` and the bash tool does not source shell config by default. For a long-running build, prefer `run_in_background: true` from the controller session (subagent-spawned background builds orphan when the subagent's shell terminates).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Pre-flight re-verification
|
||||
|
||||
No code changes. Confirms the inventory hasn't drifted.
|
||||
|
||||
- [ ] **Step 1: Re-grep `uploadSecureIdFile` call sites**
|
||||
|
||||
```bash
|
||||
grep -rnE "uploadSecureIdFile\(" submodules --include="*.swift" | grep -v "/SecureId/"
|
||||
```
|
||||
|
||||
Expected: exactly 1 match — `submodules/PassportUI/Sources/SecureIdVerificationDocumentsContext.swift:43`. If the count or file has drifted, stop and revise the plan.
|
||||
|
||||
- [ ] **Step 2: Re-grep `SecureIdVerificationDocumentsContext(...)` instantiation sites**
|
||||
|
||||
```bash
|
||||
grep -rnE "SecureIdVerificationDocumentsContext\(" submodules --include="*.swift" | grep -v "final class SecureIdVerificationDocumentsContext"
|
||||
```
|
||||
|
||||
Expected: exactly 1 match — `submodules/PassportUI/Sources/SecureIdDocumentFormControllerNode.swift:2172`. If drift, stop.
|
||||
|
||||
- [ ] **Step 3: Confirm `AccountContext.engine` protocol requirement**
|
||||
|
||||
```bash
|
||||
grep -nE "var engine: TelegramEngine" submodules/AccountContext/Sources/AccountContext.swift
|
||||
```
|
||||
|
||||
Expected: one line matching `var engine: TelegramEngine { get }` (the protocol requirement). This confirms `self.context.engine` will be available at the instantiation site in Task 4.
|
||||
|
||||
- [ ] **Step 4: Confirm `info.resource` type**
|
||||
|
||||
```bash
|
||||
grep -nE "let resource:" submodules/PassportUI/Sources/SecureIdVerificationDocument.swift
|
||||
```
|
||||
|
||||
Expected: two matches, both showing `resource: TelegramMediaResource`. Confirms `EngineMediaResource(info.resource)` will compile (the `EngineMediaResource(_:)` init takes `MediaResource`, which `TelegramMediaResource` inherits).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Migrate `uploadSecureIdFile`'s public facade and body
|
||||
|
||||
No build; no commit. Tasks 2–4 share one atomic commit in Task 5.
|
||||
|
||||
**File:** `submodules/TelegramCore/Sources/TelegramEngine/SecureId/UploadSecureIdFile.swift`
|
||||
|
||||
- [ ] **Step 1: Replace the function signature and body**
|
||||
|
||||
Find the `uploadSecureIdFile` function (currently starts at line 90). Replace the entire function (from `public func uploadSecureIdFile` through its closing `}`) with this version:
|
||||
|
||||
```swift
|
||||
public func uploadSecureIdFile(context: SecureIdAccessContext, engine: TelegramEngine, resource: EngineMediaResource) -> Signal<UploadSecureIdFileResult, UploadSecureIdFileError> {
|
||||
return engine.account.postbox.mediaBox.resourceData(resource._asResource())
|
||||
|> mapError { _ -> UploadSecureIdFileError in
|
||||
}
|
||||
|> mapToSignal { next -> Signal<UploadSecureIdFileResult, UploadSecureIdFileError> in
|
||||
if !next.complete {
|
||||
return .complete()
|
||||
}
|
||||
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: next.path)) else {
|
||||
return .fail(.generic)
|
||||
}
|
||||
|
||||
guard let encryptedData = encryptedSecureIdFile(context: context, data: data) else {
|
||||
return .fail(.generic)
|
||||
}
|
||||
|
||||
return multipartUpload(network: engine.account.network, postbox: engine.account.postbox, source: .data(encryptedData.data), encrypt: false, tag: TelegramMediaResourceFetchTag(statsCategory: .image, userContentType: .image), hintFileSize: nil, hintFileIsLarge: false, forceNoBigParts: false)
|
||||
|> mapError { _ -> UploadSecureIdFileError in
|
||||
return .generic
|
||||
}
|
||||
|> mapToSignal { result -> Signal<UploadSecureIdFileResult, UploadSecureIdFileError> in
|
||||
switch result {
|
||||
case let .progress(value):
|
||||
return .single(.progress(value))
|
||||
case let .inputFile(.inputFile(fileData)):
|
||||
return .single(.result(UploadedSecureIdFile(id: fileData.id, parts: fileData.parts, md5Checksum: fileData.md5Checksum, fileHash: encryptedData.hash, encryptedSecret: encryptedData.encryptedSecret), encryptedData.data))
|
||||
default:
|
||||
return .fail(.generic)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Changes from the original:
|
||||
|
||||
- Signature: `(context: SecureIdAccessContext, postbox: Postbox, network: Network, resource: MediaResource)` → `(context: SecureIdAccessContext, engine: TelegramEngine, resource: EngineMediaResource)`.
|
||||
- Line 1 of body: `postbox.mediaBox.resourceData(resource)` → `engine.account.postbox.mediaBox.resourceData(resource._asResource())`.
|
||||
- Inside the `mapToSignal`: `multipartUpload(network: network, postbox: postbox, ...)` → `multipartUpload(network: engine.account.network, postbox: engine.account.postbox, ...)`.
|
||||
|
||||
No other file in `TelegramCore/Sources/TelegramEngine/SecureId/` is touched. No imports change inside `UploadSecureIdFile.swift` — it continues to `import Foundation`, `import Postbox`, `import MtProtoKit`, `import SwiftSignalKit`, which remain correct (the body still uses raw Postbox types via `engine.account.postbox`).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Migrate `SecureIdVerificationDocumentsContext`
|
||||
|
||||
No build; no commit.
|
||||
|
||||
**File:** `submodules/PassportUI/Sources/SecureIdVerificationDocumentsContext.swift`
|
||||
|
||||
- [ ] **Step 1: Drop `import Postbox`**
|
||||
|
||||
Replace the import block at the top (lines 1–4):
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
import Postbox
|
||||
import TelegramCore
|
||||
import SwiftSignalKit
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
import TelegramCore
|
||||
import SwiftSignalKit
|
||||
```
|
||||
|
||||
Only `Postbox` is removed. The three remaining imports stay.
|
||||
|
||||
- [ ] **Step 2: Replace stored properties**
|
||||
|
||||
Find the `final class SecureIdVerificationDocumentsContext` block (starting around line 18). Replace lines 20–21:
|
||||
|
||||
```swift
|
||||
private let postbox: Postbox
|
||||
private let network: Network
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
private let engine: TelegramEngine
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update the constructor**
|
||||
|
||||
Replace the `init` (lines 26–31):
|
||||
|
||||
```swift
|
||||
init(postbox: Postbox, network: Network, context: SecureIdAccessContext, update: @escaping (Int64, SecureIdVerificationLocalDocumentState) -> Void) {
|
||||
self.postbox = postbox
|
||||
self.network = network
|
||||
self.context = context
|
||||
self.update = update
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
init(engine: TelegramEngine, context: SecureIdAccessContext, update: @escaping (Int64, SecureIdVerificationLocalDocumentState) -> Void) {
|
||||
self.engine = engine
|
||||
self.context = context
|
||||
self.update = update
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update the `uploadSecureIdFile` call inside `stateUpdated`**
|
||||
|
||||
Find line 43, which currently reads:
|
||||
|
||||
```swift
|
||||
disposable.set((uploadSecureIdFile(context: self.context, postbox: self.postbox, network: self.network, resource: info.resource)
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
disposable.set((uploadSecureIdFile(context: self.context, engine: self.engine, resource: EngineMediaResource(info.resource))
|
||||
```
|
||||
|
||||
Two changes:
|
||||
- `postbox: self.postbox, network: self.network` → `engine: self.engine`.
|
||||
- `resource: info.resource` → `resource: EngineMediaResource(info.resource)`.
|
||||
|
||||
No other line in this file changes. The `DocumentContext` inner class is untouched. The `stateUpdated` method's outer structure is untouched.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Update the instantiation site
|
||||
|
||||
No build; no commit.
|
||||
|
||||
**File:** `submodules/PassportUI/Sources/SecureIdDocumentFormControllerNode.swift`
|
||||
|
||||
- [ ] **Step 1: Update line 2172**
|
||||
|
||||
Find line 2172, which currently reads:
|
||||
|
||||
```swift
|
||||
self.uploadContext = SecureIdVerificationDocumentsContext(postbox: self.context.account.postbox, network: self.context.account.network, context: self.secureIdContext, update: { id, state in
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
self.uploadContext = SecureIdVerificationDocumentsContext(engine: self.context.engine, context: self.secureIdContext, update: { id, state in
|
||||
```
|
||||
|
||||
Two removed arguments (`postbox:`, `network:`) collapse into one new argument (`engine:`). The closure body inside `update: { id, state in ... }` is unchanged.
|
||||
|
||||
No other line in this file changes. The file continues to `import Postbox` for unrelated reasons — this is expected, do not remove.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Full build and commit C1
|
||||
|
||||
- [ ] **Step 1: Run the full project build**
|
||||
|
||||
Run the build command from the header. Expected: clean success.
|
||||
|
||||
Typical failure modes and fixes (do them inline — do not split):
|
||||
|
||||
- **"cannot convert value of type 'Postbox' to expected argument type 'TelegramEngine'"** — a call site was missed. Re-grep both `uploadSecureIdFile(` and `SecureIdVerificationDocumentsContext(` across the repo.
|
||||
- **"cannot convert value of type 'MediaResource' to expected argument type 'EngineMediaResource'"** — Task 3 Step 4's `EngineMediaResource(info.resource)` wrap was missed.
|
||||
- **"use of unresolved identifier 'Network'"** or **"use of unresolved identifier 'Postbox'"** inside `SecureIdVerificationDocumentsContext.swift`** — Tasks 3 Steps 2–3 or 4 weren't fully applied.
|
||||
- **"missing argument for parameter 'engine'"** — the Task 4 call site wasn't updated.
|
||||
|
||||
Re-run the build after each fix.
|
||||
|
||||
- [ ] **Step 2: Stage the three files**
|
||||
|
||||
```bash
|
||||
git add \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/SecureId/UploadSecureIdFile.swift \
|
||||
submodules/PassportUI/Sources/SecureIdVerificationDocumentsContext.swift \
|
||||
submodules/PassportUI/Sources/SecureIdDocumentFormControllerNode.swift
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify diff scope**
|
||||
|
||||
```bash
|
||||
git diff --staged --stat
|
||||
```
|
||||
|
||||
Expected: exactly 3 files staged. Approximate line changes:
|
||||
- `UploadSecureIdFile.swift`: ~3 lines (signature + 2 body sites).
|
||||
- `SecureIdVerificationDocumentsContext.swift`: ~8 lines (1 import removed, stored props, constructor, call site).
|
||||
- `SecureIdDocumentFormControllerNode.swift`: 1 line.
|
||||
|
||||
- [ ] **Step 4: Commit C1**
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
SecureId: migrate uploadSecureIdFile + context to TelegramEngine
|
||||
|
||||
uploadSecureIdFile's public signature now takes engine: TelegramEngine
|
||||
and resource: EngineMediaResource instead of raw postbox: Postbox +
|
||||
network: Network + MediaResource. The function body accesses raw
|
||||
Postbox types via engine.account.postbox / engine.account.network
|
||||
(internal Postbox-facing layer stays raw per CLAUDE.md).
|
||||
|
||||
SecureIdVerificationDocumentsContext refactored in lockstep: stores
|
||||
engine: TelegramEngine instead of raw postbox + network, drops
|
||||
import Postbox. The one instantiation site in
|
||||
SecureIdDocumentFormControllerNode updates to pass engine:
|
||||
self.context.engine.
|
||||
|
||||
Wave-5 of the Postbox -> TelegramEngine refactor; completes the last
|
||||
explicitly-named future-wave candidate.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify branch state**
|
||||
|
||||
```bash
|
||||
git log --oneline master..HEAD
|
||||
```
|
||||
|
||||
Expected (newest at top):
|
||||
- `<sha> SecureId: migrate uploadSecureIdFile + context to TelegramEngine`
|
||||
- `b7a1a5dfb0 docs(spec): wave-5 uploadSecureIdFile facade + SecureId context migration`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Update CLAUDE.md tally and commit C2
|
||||
|
||||
- [ ] **Step 1: Add `SecureIdVerificationDocumentsContext` to the Postbox-free tally**
|
||||
|
||||
Open `CLAUDE.md`. Find the "Modules currently free of `import Postbox` (running tally)" section. Add `- SecureIdVerificationDocumentsContext (wave 5)` as the last bullet in the list, immediately after `- SaveToCameraRoll (wave 3)`:
|
||||
|
||||
```markdown
|
||||
- `MapResourceToAvatarSizes` (wave 2)
|
||||
- `SaveToCameraRoll` (wave 3)
|
||||
- `SecureIdVerificationDocumentsContext` (wave 5)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add a "Wave 5 outcome" subsection**
|
||||
|
||||
Still in `CLAUDE.md`, find the "Wave 4 outcome (2026-04-18)" block. Insert a new "Wave 5 outcome" subsection **after** Wave 4 and **before** "Modules currently free of `import Postbox`":
|
||||
|
||||
```markdown
|
||||
### Wave 5 outcome (2026-04-18)
|
||||
|
||||
Completes the last explicitly-named future-wave candidate from the wave-2 final review.
|
||||
|
||||
`uploadSecureIdFile(context: SecureIdAccessContext, postbox: Postbox, network: Network, resource: MediaResource)` migrated in place to `(context:, engine: TelegramEngine, resource: EngineMediaResource)`. Function body accesses raw Postbox types via `engine.account.postbox` / `engine.account.network` (internal Postbox-facing layer stays raw per the standing rule).
|
||||
|
||||
1 consumer submodule fully de-Postboxed: `SecureIdVerificationDocumentsContext` (PassportUI/Sources). Signature changed from `(postbox: Postbox, network: Network, context: SecureIdAccessContext, update: ...)` to `(engine: TelegramEngine, context: SecureIdAccessContext, update: ...)`; stored props collapsed into a single `engine: TelegramEngine` field. One instantiation site updated in the same commit.
|
||||
|
||||
After this wave, the "Known future-wave candidates" list contains only the 4 permanently-blocked classes conforming to `TelegramMediaResource`.
|
||||
|
||||
Plan: `docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-5.md`
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove the `uploadSecureIdFile` bullet from "Known future-wave candidates"**
|
||||
|
||||
Still in `CLAUDE.md`, find the "Known future-wave candidates" list. Delete this bullet entirely:
|
||||
|
||||
```markdown
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/SecureId/UploadSecureIdFile.swift: public func uploadSecureIdFile(…, postbox: Postbox, …, resource: MediaResource)` — rule-2-sensitive (umbrella-type leak). Needs a paired wave with its caller(s).
|
||||
```
|
||||
|
||||
Do not touch the remaining bullet about permanently-blocked classes.
|
||||
|
||||
- [ ] **Step 4: Commit C2**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
CLAUDE.md: record wave-5 outcome
|
||||
|
||||
Adds SecureIdVerificationDocumentsContext to the Postbox-free module
|
||||
tally, documents the uploadSecureIdFile facade migration, and removes
|
||||
the uploadSecureIdFile bullet from "Known future-wave candidates".
|
||||
After this wave, the candidate list contains only the 4 permanently-
|
||||
blocked TelegramMediaResource-conforming classes.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify final branch state**
|
||||
|
||||
```bash
|
||||
git log --oneline master..HEAD
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `<sha> CLAUDE.md: record wave-5 outcome`
|
||||
- `<sha> SecureId: migrate uploadSecureIdFile + context to TelegramEngine`
|
||||
- `b7a1a5dfb0 docs(spec): wave-5 uploadSecureIdFile facade + SecureId context migration`
|
||||
|
||||
---
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `uploadSecureIdFile`'s public signature references neither `Postbox`, `Network`, nor `MediaResource`.
|
||||
- `SecureIdVerificationDocumentsContext.swift` does not contain `import Postbox`.
|
||||
- Full build succeeds in `debug_sim_arm64`.
|
||||
- `grep -l "import Postbox" submodules/PassportUI/Sources/SecureIdVerificationDocumentsContext.swift` returns no match.
|
||||
- `CLAUDE.md`'s "Known future-wave candidates" list no longer mentions `uploadSecureIdFile`; the Postbox-free running tally includes `SecureIdVerificationDocumentsContext (wave 5)`.
|
||||
- Branch `refactor/postbox-to-engine-wave-5` contains 3 commits above `master`: 1 doc (spec) + 1 code (C1) + 1 tally (C2).
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
# Postbox → TelegramEngine Wave 6 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:** Speculatively drop `import Postbox` from every consumer file where a plain `^import Postbox$` line appears, run a full project build, restore the import on files that fail to compile, iterate up to 3 times, commit surviving drops as one atomic commit. Then land a CLAUDE.md update with the outcome and permanent methodology guidance.
|
||||
|
||||
**Architecture:** Two commits on branch `refactor/postbox-to-engine-wave-6`. C1 is the atomic batch deletion whose diff is N single-line removals (build-verified). C2 is a docs update that (a) records the outcome and (b) codifies the sweep methodology under "Wave-selection guidance" so future sweeps can be triggered directly. The project build is the safety net — anything that compiles after restoration is definitionally safe.
|
||||
|
||||
**Tech Stack:** Swift / Bazel. No unit tests — verification is a full project build.
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-04-19-postbox-to-telegramengine-wave-6-design.md](docs/superpowers/specs/2026-04-19-postbox-to-telegramengine-wave-6-design.md)
|
||||
|
||||
**Build command** (use for every "full build" step):
|
||||
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64
|
||||
```
|
||||
|
||||
For background execution (recommended given build length), use `run_in_background: true` from the controller session. Do not let a subagent spawn the build — when the subagent returns the process orphans. The controller owns every build invocation in this wave.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Generate and record the candidate list
|
||||
|
||||
Read-only setup. No code changes yet.
|
||||
|
||||
- [ ] **Step 1: Generate the candidate list**
|
||||
|
||||
```bash
|
||||
grep -rl "^import Postbox$" submodules --include="*.swift" \
|
||||
| grep -vE "/(TelegramCore|Postbox|TelegramApi)/" \
|
||||
| sort > /tmp/wave-6-candidates.txt
|
||||
wc -l /tmp/wave-6-candidates.txt
|
||||
```
|
||||
|
||||
Expected: a count somewhere between 100 and 400. Record the exact number — call it `N_candidates`. If the count is outside that range, stop and investigate: either the grep is too narrow (missing `@_exported` etc. ought to be rare) or too broad (accidentally matching TelegramCore).
|
||||
|
||||
- [ ] **Step 2: Snapshot baseline**
|
||||
|
||||
The snapshot is implicit: every candidate file is at branch HEAD, so `git checkout -- <file>` always restores the pre-sweep content. Verify the working tree is clean:
|
||||
|
||||
```bash
|
||||
git status --short | grep -v '^??' | grep -v sourcekit-bazel-bsp
|
||||
```
|
||||
|
||||
Expected: empty output. (The `sourcekit-bazel-bsp` submodule shows as modified across the whole repo; that's pre-existing and orthogonal.) If there are any other unstaged changes, commit or stash them before proceeding.
|
||||
|
||||
- [ ] **Step 3: Confirm branch and HEAD**
|
||||
|
||||
```bash
|
||||
git branch --show-current
|
||||
git log --oneline -3
|
||||
```
|
||||
|
||||
Expected:
|
||||
- current branch: `refactor/postbox-to-engine-wave-6`
|
||||
- top commit: the wave-6 spec commit.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Speculative drop pass
|
||||
|
||||
Mutates all candidate files. No commit yet.
|
||||
|
||||
- [ ] **Step 1: Drop `import Postbox` from every candidate**
|
||||
|
||||
```bash
|
||||
while IFS= read -r f; do
|
||||
/usr/bin/sed -i '' '/^import Postbox$/d' "$f"
|
||||
done < /tmp/wave-6-candidates.txt
|
||||
```
|
||||
|
||||
macOS `sed` requires the `''` after `-i` (BSD flavor).
|
||||
|
||||
- [ ] **Step 2: Verify every candidate had exactly one line removed**
|
||||
|
||||
```bash
|
||||
git diff --stat | wc -l
|
||||
```
|
||||
|
||||
Expected: `N_candidates + 1` (one line per file in `--stat` output, plus the summary line).
|
||||
|
||||
```bash
|
||||
git diff --stat | awk '{print $3}' | grep -v deletion | head -5
|
||||
```
|
||||
|
||||
Expected: each shown entry is `1` (one insertion, zero counted since all are single-line deletes). If any file shows more than 1 line changed, something went wrong — investigate.
|
||||
|
||||
- [ ] **Step 3: Confirm no `@_exported` lines were accidentally touched**
|
||||
|
||||
```bash
|
||||
grep -r "@_exported import Postbox" submodules --include="*.swift" | head -5
|
||||
```
|
||||
|
||||
If this returns results, those lines must still be intact — verify. The regex used in Step 1 only matches bare `^import Postbox$`, so `@_exported import Postbox` is untouched. This step is a sanity check.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Iteration 1 — first build, parse errors, restore failing files
|
||||
|
||||
- [ ] **Step 1: Run the full project build (iteration 1)**
|
||||
|
||||
Run the build command from the header. Expected: many errors — this is by design. Capture stderr to the build output file.
|
||||
|
||||
Watch the tail of the output file for either `INFO: Build completed successfully` (rare: means zero imports were needed) or a cascade of compile errors (expected).
|
||||
|
||||
- [ ] **Step 2: Extract failing files from the build output**
|
||||
|
||||
```bash
|
||||
BUILD_OUT=/private/tmp/claude-501/-Users-ali-build-telegram-telegram-ios/5d9b3268-5c9f-45fc-bd4e-87cac5361498/tasks/<task-id>.output
|
||||
grep -E "^submodules/.*\.swift:[0-9]+:[0-9]+: error:" "$BUILD_OUT" \
|
||||
| awk -F: '{print $1}' \
|
||||
| sort -u > /tmp/wave-6-failing.txt
|
||||
wc -l /tmp/wave-6-failing.txt
|
||||
```
|
||||
|
||||
The task-id comes from the background Bash tool's output file. Substitute the actual `/private/tmp/claude-501/.../<task-id>.output` path.
|
||||
|
||||
Sanity-check the content:
|
||||
|
||||
```bash
|
||||
head -3 /tmp/wave-6-failing.txt
|
||||
```
|
||||
|
||||
Every line should be a path under `submodules/` that appears in `/tmp/wave-6-candidates.txt`. If any line is from `TelegramCore`, `Postbox`, or `TelegramApi`, the sweep has cascaded beyond the candidate set — halt and investigate.
|
||||
|
||||
- [ ] **Step 3: Validate error types**
|
||||
|
||||
```bash
|
||||
grep -E "^submodules/.*\.swift:[0-9]+:[0-9]+: error:" "$BUILD_OUT" \
|
||||
| head -10
|
||||
```
|
||||
|
||||
Expected error patterns:
|
||||
- `cannot find type 'X' in scope`
|
||||
- `use of unresolved identifier 'X'`
|
||||
- `cannot find 'X' in scope`
|
||||
- `reference to invalid associated type 'X' of type 'Y'` (occasional)
|
||||
|
||||
If you see `no such module 'Postbox'` or errors unrelated to missing Postbox symbols (e.g., codesign failures, Bazel graph errors), halt and investigate — those are not the sweep's signal.
|
||||
|
||||
- [ ] **Step 4: Restore `import Postbox` on failing files**
|
||||
|
||||
```bash
|
||||
while IFS= read -r f; do
|
||||
git checkout -- "$f"
|
||||
done < /tmp/wave-6-failing.txt
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify restoration**
|
||||
|
||||
```bash
|
||||
git diff --stat | wc -l
|
||||
```
|
||||
|
||||
Expected: `N_candidates - N_failing + 1` lines in `--stat` output (one per still-modified file plus summary). The count should be lower than Task 2 Step 2's count by exactly `N_failing`.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Iteration 2 — rebuild, parse new errors, restore
|
||||
|
||||
- [ ] **Step 1: Run the full project build (iteration 2)**
|
||||
|
||||
Run the build command again. Expected: ideally clean success. If errors persist, it's because restoring some files in iteration 1 removed a symbol that another file (still in the candidate set with import dropped) needed transitively via that symbol's module-level re-export.
|
||||
|
||||
Watch for `INFO: Build completed successfully`. If found, proceed to Task 6 (skipping Task 5). If errors persist, continue with Step 2.
|
||||
|
||||
- [ ] **Step 2: Extract failing files**
|
||||
|
||||
```bash
|
||||
BUILD_OUT=/private/tmp/claude-501/-Users-ali-build-telegram-telegram-ios/5d9b3268-5c9f-45fc-bd4e-87cac5361498/tasks/<task-id-2>.output
|
||||
grep -E "^submodules/.*\.swift:[0-9]+:[0-9]+: error:" "$BUILD_OUT" \
|
||||
| awk -F: '{print $1}' \
|
||||
| sort -u > /tmp/wave-6-failing-2.txt
|
||||
wc -l /tmp/wave-6-failing-2.txt
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Restore**
|
||||
|
||||
```bash
|
||||
while IFS= read -r f; do
|
||||
git checkout -- "$f"
|
||||
done < /tmp/wave-6-failing-2.txt
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Decision point**
|
||||
|
||||
If `wc -l /tmp/wave-6-failing-2.txt` is 0, the iteration-2 rebuild actually succeeded — proceed to Task 6. If it's greater than 0, proceed to Task 5 for iteration 3.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Iteration 3 — final rebuild
|
||||
|
||||
- [ ] **Step 1: Run the full project build (iteration 3)**
|
||||
|
||||
Run the build command again. If this iteration does not complete successfully, the sweep has failed the stability test.
|
||||
|
||||
- [ ] **Step 2: Clean-success check**
|
||||
|
||||
Expected: `INFO: Build completed successfully`.
|
||||
|
||||
If successful, proceed to Task 6.
|
||||
|
||||
If a third iteration of errors appears, **abandon the wave**:
|
||||
|
||||
```bash
|
||||
git checkout -- .
|
||||
git status --short
|
||||
```
|
||||
|
||||
Working tree should now be clean (modulo the pre-existing sourcekit-bazel-bsp submodule marker). Do not commit. Skip Task 6. Jump straight to an updated Task 7 that records the failed attempt in CLAUDE.md instead of a success outcome, and document what kind of errors surfaced so a future attempt can plan around them.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Commit C1 — build-verified batch drop
|
||||
|
||||
- [ ] **Step 1: Compute the final count**
|
||||
|
||||
```bash
|
||||
git diff --stat | tail -1
|
||||
```
|
||||
|
||||
Expected: something like ` N files changed, 0 insertions(+), N deletions(-)` where N is the number of files that survived the sweep. Record this count as `N_dropped`.
|
||||
|
||||
- [ ] **Step 2: Spot-check a few diffs**
|
||||
|
||||
```bash
|
||||
git diff | grep -E "^-import Postbox$" | wc -l
|
||||
```
|
||||
|
||||
Expected: `N_dropped` (every surviving diff is a single-line `-import Postbox` removal).
|
||||
|
||||
```bash
|
||||
git diff | grep -E "^\+" | grep -v "^+++" | head
|
||||
```
|
||||
|
||||
Expected: no output. (The sweep only removes lines; it never adds any.)
|
||||
|
||||
- [ ] **Step 3: Stage all changes**
|
||||
|
||||
```bash
|
||||
git add -u
|
||||
```
|
||||
|
||||
`-u` stages only files that are already tracked and modified. No need to enumerate each file — the sweep touched many and they're all known to git.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
N_DROPPED=$(git diff --staged --stat | tail -1 | awk '{print $1}')
|
||||
git commit -m "$(cat <<EOF
|
||||
Drop unused import Postbox from ${N_DROPPED} consumer files
|
||||
|
||||
Build-verified speculative drop: removed the import line from every
|
||||
consumer submodule file where it appeared, rebuilt the full project,
|
||||
and restored the import on the files that needed it. The commit
|
||||
contains only survivors — every file here compiles cleanly without
|
||||
import Postbox.
|
||||
|
||||
Methodology documented in CLAUDE.md (wave-selection guidance).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify branch state**
|
||||
|
||||
```bash
|
||||
git log --oneline master..HEAD
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `<sha> Drop unused import Postbox from N consumer files`
|
||||
- `816e7699ec docs(spec): wave-6 unused import Postbox batch sweep`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: CLAUDE.md — record outcome and add permanent sweep methodology
|
||||
|
||||
- [ ] **Step 1: Add Wave 6 outcome subsection**
|
||||
|
||||
Open `CLAUDE.md`. Find the "Wave 5 outcome (2026-04-19)" block. Insert a new "Wave 6 outcome (2026-04-19)" subsection immediately after Wave 5 and before "Modules currently free of `import Postbox`":
|
||||
|
||||
```markdown
|
||||
### Wave 6 outcome (2026-04-19)
|
||||
|
||||
First unused-import sweep. Ran the speculative-drop + build-verify methodology (see "Unused-import sweeps" under Wave-selection guidance): dropped `import Postbox` from every consumer file where a plain `^import Postbox$` appeared (out of ~N_CANDIDATES candidates), rebuilt, restored the import on failures, iterated. N_DROPPED drops survived.
|
||||
|
||||
No behavior change; zero facade migrations in this wave. Running tally updated for any modules whose last `import Postbox`-bearing file was swept (see the per-module list below).
|
||||
|
||||
Plan: `docs/superpowers/plans/2026-04-19-postbox-to-telegramengine-wave-6.md`
|
||||
```
|
||||
|
||||
Replace `N_CANDIDATES` and `N_DROPPED` with the actual numbers from Task 1 Step 1 and Task 6 Step 1. If the wave was abandoned (see Task 5 Step 2), replace the outcome text with a failed-attempt description instead: what iteration the sweep stalled at and what error category.
|
||||
|
||||
- [ ] **Step 2: Add permanent "Unused-import sweeps" subsection under Wave-selection guidance**
|
||||
|
||||
Still in `CLAUDE.md`, find the "Wave-selection guidance" block. Insert the following new subsection at the end of that block (immediately before "### Wave 1 outcome"):
|
||||
|
||||
```markdown
|
||||
**Unused-import sweeps are a valid wave shape.** After a round of facade migrations, consumer files accumulate `import Postbox` lines whose last semantic use was removed. Periodically sweep these:
|
||||
|
||||
1. `grep -rl "^import Postbox$" submodules --include="*.swift" | grep -vE "/(TelegramCore|Postbox|TelegramApi)/"` generates the candidate list.
|
||||
2. `sed -i '' '/^import Postbox$/d' <file>` (BSD sed) speculatively drops the import from every candidate.
|
||||
3. Run the full project build. Swift compile errors (`<file>:<line>:<col>: error: cannot find type 'X'`) identify files that need the import restored via `git checkout -- <file>`.
|
||||
4. Rebuild. Iterate up to 3 times. Only restore files from the candidate set — if errors surface in `TelegramCore`, `Postbox`, or `TelegramApi`, halt and investigate (cascading breakage).
|
||||
5. Commit the surviving drops as one atomic commit.
|
||||
|
||||
Re-run this after every 2–3 facade-migration waves. First run: wave 6.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update "Modules currently free of `import Postbox`" tally**
|
||||
|
||||
For each module in `submodules/` that has **no** remaining `import Postbox` after this wave, add a bullet under "Modules currently free of `import Postbox` (running tally)". Determine this list with:
|
||||
|
||||
```bash
|
||||
for d in submodules/*/; do
|
||||
mod=$(basename "$d")
|
||||
if [ -d "$d/Sources" ]; then
|
||||
count=$(grep -rlE "^(@_exported )?import Postbox" "$d/Sources" --include="*.swift" 2>/dev/null | wc -l)
|
||||
if [ "$count" -eq 0 ]; then
|
||||
# Check this module isn't already in CLAUDE.md's tally
|
||||
if ! grep -qF "\`$mod\`" CLAUDE.md; then
|
||||
echo "$mod"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
Each printed module becomes a new bullet like `- \`<ModuleName>\` (wave 6)` in the list.
|
||||
|
||||
If the output is empty, no new module-level additions — individual file drops across multiple mixed modules aren't tally-eligible. That's fine, the Wave-6 outcome subsection still records the raw count.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
CLAUDE.md: record wave-6 outcome and unused-import-sweep methodology
|
||||
|
||||
Adds the wave-6 outcome subsection with the candidate/drop counts,
|
||||
documents the speculative-drop + build-verify methodology as
|
||||
permanent guidance under wave-selection so future waves can re-run
|
||||
the sweep directly, and updates the Postbox-free running tally for
|
||||
any modules whose last import Postbox file was swept in this wave.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify final branch state**
|
||||
|
||||
```bash
|
||||
git log --oneline master..HEAD
|
||||
```
|
||||
|
||||
Expected (newest first):
|
||||
- `<sha> CLAUDE.md: record wave-6 outcome and unused-import-sweep methodology`
|
||||
- `<sha> Drop unused import Postbox from N consumer files`
|
||||
- `816e7699ec docs(spec): wave-6 unused import Postbox batch sweep`
|
||||
|
||||
---
|
||||
|
||||
## Success criteria
|
||||
|
||||
- At least one `import Postbox` line has been removed from at least one consumer file, build-verified.
|
||||
- Full build succeeds in `debug_sim_arm64`.
|
||||
- `CLAUDE.md` has a "Wave 6 outcome (2026-04-19)" subsection with actual numeric results.
|
||||
- `CLAUDE.md`'s "Wave-selection guidance" section has a new permanent "Unused-import sweeps" bullet list that describes the methodology for future re-runs.
|
||||
- `CLAUDE.md`'s "Modules currently free of `import Postbox`" running tally includes any newly-fully-clean modules (if any).
|
||||
- Branch `refactor/postbox-to-engine-wave-6` contains 3 commits above `master`: 1 doc (spec) + 1 code (C1 batch drop) + 1 tally (C2).
|
||||
539
docs/superpowers/plans/2026-04-20-decrypt-match-python-port.md
Normal file
539
docs/superpowers/plans/2026-04-20-decrypt-match-python-port.md
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
# Pure-Python port of fastlane match `decrypt.rb` — 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:** Replace the Ruby-based fastlane match decryption (`build-system/decrypt.rb` shelled from `BuildConfiguration.py:110`) with a self-contained Python 3 implementation using only the standard library.
|
||||
|
||||
**Architecture:** Rewrite `build-system/Make/DecryptMatch.py` from scratch as a pure-Python AES-256 implementation. Covers V1 (CBC via `EVP_BytesToKey` with MD5→SHA256 fallback) and V2 (GCM with PBKDF2-derived key/iv/AAD + auth tag). `BuildConfiguration.py` calls the existing `decrypt_match_data(source, destination, password)` entry point directly instead of shelling out to Ruby. `decrypt.rb` is deleted.
|
||||
|
||||
**Tech Stack:** Python 3 stdlib only — `hashlib` (MD5 / SHA256 / PBKDF2-HMAC), `base64`.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
- **Rewrite (not edit):** `build-system/Make/DecryptMatch.py` — new file replacing the broken placeholder. Single module containing: AES-256 primitives, `EVP_BytesToKey`, CBC decrypt, GCM decrypt (with GHASH + CTR), `MatchDataEncryption` dispatcher, `decrypt_match_data` public entry, `__main__` CLI.
|
||||
- **Modify:** `build-system/Make/BuildConfiguration.py:103-118` — swap `os.system('ruby …')` for a direct Python call.
|
||||
- **Delete:** `build-system/decrypt.rb`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Rewrite `build-system/Make/DecryptMatch.py`
|
||||
|
||||
**Files:**
|
||||
- Modify (rewrite): `build-system/Make/DecryptMatch.py`
|
||||
|
||||
- [ ] **Step 1.1: Replace the file contents entirely**
|
||||
|
||||
Overwrite `build-system/Make/DecryptMatch.py` with the following. This is the full file — no other changes to this module in later tasks.
|
||||
|
||||
```python
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
|
||||
# FIPS-197 AES S-box and inverse S-box.
|
||||
_SBOX = bytes.fromhex(
|
||||
"637c777bf26b6fc53001672bfed7ab76"
|
||||
"ca82c97dfa5947f0add4a2af9ca472c0"
|
||||
"b7fd9326363ff7cc34a5e5f171d83115"
|
||||
"04c723c31896059a071280e2eb27b275"
|
||||
"09832c1a1b6e5aa0523bd6b329e32f84"
|
||||
"53d100ed20fcb15b6acbbe394a4c58cf"
|
||||
"d0efaafb434d338545f9027f503c9fa8"
|
||||
"51a3408f929d38f5bcb6da2110fff3d2"
|
||||
"cd0c13ec5f974417c4a77e3d645d1973"
|
||||
"60814fdc222a908846eeb814de5e0bdb"
|
||||
"e0323a0a4906245cc2d3ac629195e479"
|
||||
"e7c8376d8dd54ea96c56f4ea657aae08"
|
||||
"ba78252e1ca6b4c6e8dd741f4bbd8b8a"
|
||||
"703eb5664803f60e613557b986c11d9e"
|
||||
"e1f8981169d98e949b1e87e9ce5528df"
|
||||
"8ca1890dbfe6426841992d0fb054bb16"
|
||||
)
|
||||
|
||||
_INV_SBOX = bytes.fromhex(
|
||||
"52096ad53036a538bf40a39e81f3d7fb"
|
||||
"7ce339829b2fff87348e4344c4dee9cb"
|
||||
"547b9432a6c2233dee4c950b42fac34e"
|
||||
"082ea16628d924b2765ba2496d8bd125"
|
||||
"72f8f66486689816d4a45ccc5d65b692"
|
||||
"6c704850fdedb9da5e154657a78d9d84"
|
||||
"90d8ab008cbcd30af7e45805b8b34506"
|
||||
"d02c1e8fca3f0f02c1afbd0301138a6b"
|
||||
"3a9111414f67dcea97f2cfcef0b4e673"
|
||||
"96ac7422e7ad3585e2f937e81c75df6e"
|
||||
"47f11a711d29c5896fb7620eaa18be1b"
|
||||
"fc563e4bc6d279209adbc0fe78cd5af4"
|
||||
"1fdda8338807c731b11210592780ec5f"
|
||||
"60517fa919b54a0d2de57a9f93c99cef"
|
||||
"a0e03b4dae2af5b0c8ebbb3c83539961"
|
||||
"172b047eba77d626e169146355210c7d"
|
||||
)
|
||||
|
||||
_RCON = bytes.fromhex("01020408102040801b36")
|
||||
|
||||
|
||||
def _xtime(a):
|
||||
return (((a << 1) ^ 0x1b) & 0xff) if (a & 0x80) else (a << 1)
|
||||
|
||||
|
||||
def _gf_mul(a, b):
|
||||
r = 0
|
||||
for _ in range(8):
|
||||
if b & 1:
|
||||
r ^= a
|
||||
b >>= 1
|
||||
a = _xtime(a)
|
||||
return r
|
||||
|
||||
|
||||
def _key_expansion_256(key):
|
||||
# AES-256: Nk=8, Nr=14, total 4 * (Nr + 1) = 60 words = 240 bytes.
|
||||
assert len(key) == 32
|
||||
w = bytearray(240)
|
||||
w[:32] = key
|
||||
i = 32
|
||||
while i < 240:
|
||||
t = bytearray(w[i - 4:i])
|
||||
if i % 32 == 0:
|
||||
t = bytearray([t[1], t[2], t[3], t[0]])
|
||||
for j in range(4):
|
||||
t[j] = _SBOX[t[j]]
|
||||
t[0] ^= _RCON[i // 32 - 1]
|
||||
elif i % 32 == 16:
|
||||
for j in range(4):
|
||||
t[j] = _SBOX[t[j]]
|
||||
for j in range(4):
|
||||
w[i + j] = w[i - 32 + j] ^ t[j]
|
||||
i += 4
|
||||
return [bytes(w[r * 16:(r + 1) * 16]) for r in range(15)]
|
||||
|
||||
|
||||
def _add_round_key(state, rk):
|
||||
return bytes(s ^ k for s, k in zip(state, rk))
|
||||
|
||||
|
||||
def _sub_bytes(state):
|
||||
return bytes(_SBOX[b] for b in state)
|
||||
|
||||
|
||||
def _inv_sub_bytes(state):
|
||||
return bytes(_INV_SBOX[b] for b in state)
|
||||
|
||||
|
||||
# Column-major state: state[r + 4 * c], r = 0..3 (row), c = 0..3 (column).
|
||||
def _shift_rows(state):
|
||||
s = bytearray(state)
|
||||
s[1], s[5], s[9], s[13] = s[5], s[9], s[13], s[1]
|
||||
s[2], s[6], s[10], s[14] = s[10], s[14], s[2], s[6]
|
||||
s[3], s[7], s[11], s[15] = s[15], s[3], s[7], s[11]
|
||||
return bytes(s)
|
||||
|
||||
|
||||
def _inv_shift_rows(state):
|
||||
s = bytearray(state)
|
||||
s[1], s[5], s[9], s[13] = s[13], s[1], s[5], s[9]
|
||||
s[2], s[6], s[10], s[14] = s[10], s[14], s[2], s[6]
|
||||
s[3], s[7], s[11], s[15] = s[7], s[11], s[15], s[3]
|
||||
return bytes(s)
|
||||
|
||||
|
||||
def _mix_columns(state):
|
||||
s = bytearray(16)
|
||||
for c in range(4):
|
||||
a0, a1, a2, a3 = state[4 * c], state[4 * c + 1], state[4 * c + 2], state[4 * c + 3]
|
||||
s[4 * c] = _xtime(a0) ^ (_xtime(a1) ^ a1) ^ a2 ^ a3
|
||||
s[4 * c + 1] = a0 ^ _xtime(a1) ^ (_xtime(a2) ^ a2) ^ a3
|
||||
s[4 * c + 2] = a0 ^ a1 ^ _xtime(a2) ^ (_xtime(a3) ^ a3)
|
||||
s[4 * c + 3] = (_xtime(a0) ^ a0) ^ a1 ^ a2 ^ _xtime(a3)
|
||||
return bytes(s)
|
||||
|
||||
|
||||
def _inv_mix_columns(state):
|
||||
s = bytearray(16)
|
||||
for c in range(4):
|
||||
a0, a1, a2, a3 = state[4 * c], state[4 * c + 1], state[4 * c + 2], state[4 * c + 3]
|
||||
s[4 * c] = _gf_mul(a0, 0x0e) ^ _gf_mul(a1, 0x0b) ^ _gf_mul(a2, 0x0d) ^ _gf_mul(a3, 0x09)
|
||||
s[4 * c + 1] = _gf_mul(a0, 0x09) ^ _gf_mul(a1, 0x0e) ^ _gf_mul(a2, 0x0b) ^ _gf_mul(a3, 0x0d)
|
||||
s[4 * c + 2] = _gf_mul(a0, 0x0d) ^ _gf_mul(a1, 0x09) ^ _gf_mul(a2, 0x0e) ^ _gf_mul(a3, 0x0b)
|
||||
s[4 * c + 3] = _gf_mul(a0, 0x0b) ^ _gf_mul(a1, 0x0d) ^ _gf_mul(a2, 0x09) ^ _gf_mul(a3, 0x0e)
|
||||
return bytes(s)
|
||||
|
||||
|
||||
def _aes_encrypt_block(block, round_keys):
|
||||
state = _add_round_key(block, round_keys[0])
|
||||
for r in range(1, 14):
|
||||
state = _sub_bytes(state)
|
||||
state = _shift_rows(state)
|
||||
state = _mix_columns(state)
|
||||
state = _add_round_key(state, round_keys[r])
|
||||
state = _sub_bytes(state)
|
||||
state = _shift_rows(state)
|
||||
state = _add_round_key(state, round_keys[14])
|
||||
return state
|
||||
|
||||
|
||||
def _aes_decrypt_block(block, round_keys):
|
||||
state = _add_round_key(block, round_keys[14])
|
||||
for r in range(13, 0, -1):
|
||||
state = _inv_shift_rows(state)
|
||||
state = _inv_sub_bytes(state)
|
||||
state = _add_round_key(state, round_keys[r])
|
||||
state = _inv_mix_columns(state)
|
||||
state = _inv_shift_rows(state)
|
||||
state = _inv_sub_bytes(state)
|
||||
state = _add_round_key(state, round_keys[0])
|
||||
return state
|
||||
|
||||
|
||||
def _evp_bytes_to_key(password, salt, hash_name, key_len=32, iv_len=16):
|
||||
# OpenSSL EVP_BytesToKey with count=1, matching Ruby's
|
||||
# Cipher#pkcs5_keyivgen(password, salt, 1, hash).
|
||||
if isinstance(password, str):
|
||||
password = password.encode('utf-8')
|
||||
required = key_len + iv_len
|
||||
material = b""
|
||||
prev = b""
|
||||
while len(material) < required:
|
||||
h = hashlib.new(hash_name)
|
||||
h.update(prev + password + salt)
|
||||
prev = h.digest()
|
||||
material += prev
|
||||
return material[:key_len], material[key_len:key_len + iv_len]
|
||||
|
||||
|
||||
def _aes_cbc_decrypt(ciphertext, key, iv):
|
||||
if len(ciphertext) == 0 or len(ciphertext) % 16 != 0:
|
||||
raise ValueError("V1 ciphertext length must be a non-zero multiple of 16")
|
||||
round_keys = _key_expansion_256(key)
|
||||
out = bytearray()
|
||||
prev = iv
|
||||
for i in range(0, len(ciphertext), 16):
|
||||
block = ciphertext[i:i + 16]
|
||||
decrypted = _aes_decrypt_block(block, round_keys)
|
||||
out.extend(bytes(d ^ p for d, p in zip(decrypted, prev)))
|
||||
prev = block
|
||||
pad = out[-1]
|
||||
if pad < 1 or pad > 16 or not all(b == pad for b in out[-pad:]):
|
||||
raise ValueError("V1 PKCS#7 padding check failed")
|
||||
return bytes(out[:-pad])
|
||||
|
||||
|
||||
def _ghash(h_bytes, data):
|
||||
# GHASH over GF(2^128) with reduction polynomial x^128 + x^7 + x^2 + x + 1,
|
||||
# using GCM's bit-reversed convention (top-bit-first when encoded as bytes).
|
||||
h = int.from_bytes(h_bytes, 'big')
|
||||
y = 0
|
||||
reduction = 0xe1 << 120
|
||||
for i in range(0, len(data), 16):
|
||||
block = data[i:i + 16].ljust(16, b"\x00")
|
||||
y ^= int.from_bytes(block, 'big')
|
||||
z = 0
|
||||
v = y
|
||||
for bit in range(127, -1, -1):
|
||||
if (h >> bit) & 1:
|
||||
z ^= v
|
||||
if v & 1:
|
||||
v = (v >> 1) ^ reduction
|
||||
else:
|
||||
v >>= 1
|
||||
y = z
|
||||
return y.to_bytes(16, 'big')
|
||||
|
||||
|
||||
def _aes_gcm_decrypt(ciphertext, key, iv, aad, auth_tag):
|
||||
if len(iv) != 12:
|
||||
raise ValueError("V2 requires a 96-bit IV")
|
||||
round_keys = _key_expansion_256(key)
|
||||
H = _aes_encrypt_block(b"\x00" * 16, round_keys)
|
||||
j0 = iv + b"\x00\x00\x00\x01"
|
||||
|
||||
plaintext = bytearray()
|
||||
j0_int = int.from_bytes(j0, 'big')
|
||||
mask32 = (1 << 32) - 1
|
||||
counter_high = j0_int & ~mask32
|
||||
counter_low = j0_int & mask32
|
||||
n_blocks = (len(ciphertext) + 15) // 16
|
||||
for i in range(n_blocks):
|
||||
counter_low = (counter_low + 1) & mask32
|
||||
ctr_bytes = (counter_high | counter_low).to_bytes(16, 'big')
|
||||
keystream = _aes_encrypt_block(ctr_bytes, round_keys)
|
||||
block = ciphertext[i * 16:(i + 1) * 16]
|
||||
plaintext.extend(bytes(c ^ k for c, k in zip(block, keystream[:len(block)])))
|
||||
|
||||
aad_pad = b"\x00" * ((16 - len(aad) % 16) % 16)
|
||||
ct_pad = b"\x00" * ((16 - len(ciphertext) % 16) % 16)
|
||||
length_block = (len(aad) * 8).to_bytes(8, 'big') + (len(ciphertext) * 8).to_bytes(8, 'big')
|
||||
s = _ghash(H, aad + aad_pad + ciphertext + ct_pad + length_block)
|
||||
e_j0 = _aes_encrypt_block(j0, round_keys)
|
||||
computed_tag = bytes(a ^ b for a, b in zip(s, e_j0))
|
||||
if computed_tag != auth_tag:
|
||||
raise ValueError("V2 GCM auth tag mismatch")
|
||||
return bytes(plaintext)
|
||||
|
||||
|
||||
_V1_PREFIX = b"Salted__"
|
||||
_V2_PREFIX = b"match_encrypted_v2__"
|
||||
|
||||
|
||||
def _decrypt_stored(stored_data, password):
|
||||
if stored_data.startswith(_V2_PREFIX):
|
||||
salt = stored_data[20:28]
|
||||
auth_tag = stored_data[28:44]
|
||||
ciphertext = stored_data[44:]
|
||||
material = hashlib.pbkdf2_hmac(
|
||||
'sha256',
|
||||
password.encode('utf-8'),
|
||||
salt,
|
||||
10_000,
|
||||
dklen=32 + 12 + 24,
|
||||
)
|
||||
key = material[0:32]
|
||||
iv = material[32:44]
|
||||
aad = material[44:68]
|
||||
return _aes_gcm_decrypt(ciphertext, key, iv, aad, auth_tag)
|
||||
if stored_data.startswith(_V1_PREFIX):
|
||||
salt = stored_data[8:16]
|
||||
ciphertext = stored_data[16:]
|
||||
try:
|
||||
key, iv = _evp_bytes_to_key(password, salt, 'md5', 32, 16)
|
||||
return _aes_cbc_decrypt(ciphertext, key, iv)
|
||||
except Exception:
|
||||
key, iv = _evp_bytes_to_key(password, salt, 'sha256', 32, 16)
|
||||
return _aes_cbc_decrypt(ciphertext, key, iv)
|
||||
raise ValueError("Unrecognized fastlane match payload (missing V1 'Salted__' or V2 'match_encrypted_v2__' prefix)")
|
||||
|
||||
|
||||
def decrypt_match_data(source_path: str, destination_path: str, password: str):
|
||||
with open(source_path, 'rb') as f:
|
||||
raw = f.read()
|
||||
stored_data = base64.b64decode(raw)
|
||||
decrypted = _decrypt_stored(stored_data, password)
|
||||
with open(destination_path, 'wb') as f:
|
||||
f.write(decrypted)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
if len(sys.argv) != 4:
|
||||
print('Usage: DecryptMatch.py <password> <source_path> <destination_path>')
|
||||
sys.exit(1)
|
||||
decrypt_match_data(source_path=sys.argv[2], destination_path=sys.argv[3], password=sys.argv[1])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Smoke-test the AES-256 block primitive (FIPS-197 Appendix C.3)
|
||||
|
||||
**Files:**
|
||||
- No changes. One-liner shell command to validate the just-written primitive.
|
||||
|
||||
- [ ] **Step 2.1: Run the FIPS-197 C.3 known-answer test**
|
||||
|
||||
```bash
|
||||
cd /Users/isaac/build/telegram/telegram-ios
|
||||
python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, 'build-system/Make')
|
||||
from DecryptMatch import _key_expansion_256, _aes_encrypt_block, _aes_decrypt_block
|
||||
key = bytes.fromhex('000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f')
|
||||
pt = bytes.fromhex('00112233445566778899aabbccddeeff')
|
||||
expected = bytes.fromhex('8ea2b7ca516745bfeafc49904b496089')
|
||||
rks = _key_expansion_256(key)
|
||||
assert _aes_encrypt_block(pt, rks) == expected, 'encrypt failed'
|
||||
assert _aes_decrypt_block(expected, rks) == pt, 'decrypt failed'
|
||||
print('AES-256 FIPS-197 C.3 OK')
|
||||
"
|
||||
```
|
||||
|
||||
Expected output: `AES-256 FIPS-197 C.3 OK`. If this fails, the AES primitive is broken — re-read Task 1's code and fix before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Validate V2 decryption on real encrypted files
|
||||
|
||||
**Files:**
|
||||
- No changes. Decrypt real samples with the new Python and verify each output is a cryptographically-valid Apple-signed artifact.
|
||||
|
||||
**Success criteria:** the decrypted `.mobileprovision` files verify under `openssl smime -verify` and parse as valid plists. A CMS signature covers every byte of the payload, so successful verification is equivalent to bit-exact decryption — any wrong byte anywhere would break the signature. This is a stronger check than diffing against another implementation, and it matches what `BuildConfiguration.copy_profiles_from_directory` does on every profile in the real build, so passing here means the port is production-ready.
|
||||
|
||||
The encrypted repo is at `~/build/telegram/telegram-ios/build-input/configuration-repository-workdir/encrypted/profiles/development/`. Repo password: `sluchainost` (per the hard-coded value in the file Task 1 replaced).
|
||||
|
||||
> NOTE: Do not attempt a byte-for-byte comparison against `ruby build-system/decrypt.rb`. Ruby's OpenSSL binding on macOS LibreSSL 3.3.6 fails on `cipher.auth_data=` with `couldn't set additional authenticated data`, so the legacy script cannot decrypt V2 at all on current macOS. (This is likely why the build accumulated a broken aspirational Python port in the first place.) Signature verification of the Python output is the authoritative check.
|
||||
|
||||
- [ ] **Step 3.1: Decrypt one sample file**
|
||||
|
||||
```bash
|
||||
cd /Users/isaac/build/telegram/telegram-ios
|
||||
SAMPLE=~/build/telegram/telegram-ios/build-input/configuration-repository-workdir/encrypted/profiles/development/Development_org.telegram.TelegramInternal.BroadcastUpload.mobileprovision
|
||||
python3 build-system/Make/DecryptMatch.py sluchainost "$SAMPLE" /tmp/match-py.bin
|
||||
shasum -a 256 /tmp/match-py.bin
|
||||
```
|
||||
|
||||
Expected: `match-py.bin` is non-empty; a sha256 is printed.
|
||||
|
||||
- [ ] **Step 3.2: Verify the output is a valid Apple-signed provisioning profile**
|
||||
|
||||
```bash
|
||||
openssl smime -inform der -verify -noverify -in /tmp/match-py.bin | plutil -lint -
|
||||
```
|
||||
|
||||
Expected: `openssl smime` prints `Verification successful` (or similar; exit code 0 is what matters), and `plutil` reports `OK`. Either failure means the decryption is corrupt — STOP and report BLOCKED with the exact openssl/plutil output.
|
||||
|
||||
- [ ] **Step 3.3: Spot-check remaining V2 files decrypt without error**
|
||||
|
||||
```bash
|
||||
cd /Users/isaac/build/telegram/telegram-ios
|
||||
ENCRYPTED=~/build/telegram/telegram-ios/build-input/configuration-repository-workdir/encrypted/profiles/development
|
||||
for f in "$ENCRYPTED"/*.mobileprovision; do
|
||||
python3 build-system/Make/DecryptMatch.py sluchainost "$f" /tmp/match-check.bin \
|
||||
&& openssl smime -inform der -verify -noverify -in /tmp/match-check.bin > /dev/null 2>&1 \
|
||||
&& echo "OK $(basename "$f")" \
|
||||
|| echo "FAIL $(basename "$f")"
|
||||
done
|
||||
```
|
||||
|
||||
Expected: every line starts with `OK`. Any `FAIL` line means that file's decryption is corrupt — STOP and report BLOCKED.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Commit the rewrite
|
||||
|
||||
**Files:**
|
||||
- Commit `build-system/Make/DecryptMatch.py` only.
|
||||
|
||||
- [ ] **Step 4.1: Stage and commit**
|
||||
|
||||
```bash
|
||||
cd /Users/isaac/build/telegram/telegram-ios
|
||||
git add build-system/Make/DecryptMatch.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
DecryptMatch: pure-Python AES-256 port of decrypt.rb
|
||||
|
||||
Implements fastlane match V1 (AES-256-CBC via EVP_BytesToKey with
|
||||
MD5 default and SHA256 fallback) and V2 (AES-256-GCM with PBKDF2-
|
||||
derived key/IV/AAD + auth tag) using only Python stdlib. Validated
|
||||
by decrypting every V2 .mobileprovision in the repo and confirming
|
||||
each output verifies under openssl smime + plutil -lint as a valid
|
||||
Apple-signed artifact.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Expected: commit created cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Switch `BuildConfiguration.py` to the Python implementation and remove `decrypt.rb`
|
||||
|
||||
**Files:**
|
||||
- Modify: `build-system/Make/BuildConfiguration.py:103-118`
|
||||
- Delete: `build-system/decrypt.rb`
|
||||
|
||||
- [ ] **Step 5.1: Swap the call site**
|
||||
|
||||
Replace lines 103-118 of `build-system/Make/BuildConfiguration.py`:
|
||||
|
||||
```python
|
||||
def decrypt_codesigning_directory_recursively(source_base_path, destination_base_path, password):
|
||||
for file_name in os.listdir(source_base_path):
|
||||
source_path = source_base_path + '/' + file_name
|
||||
destination_path = destination_base_path + '/' + file_name
|
||||
allowed_file_extensions = ['.mobileprovision', '.cer', '.p12']
|
||||
if os.path.isfile(source_path) and any(source_path.endswith(ext) for ext in allowed_file_extensions):
|
||||
#print('Decrypting {} to {} with {}'.format(source_path, destination_path, password))
|
||||
os.system('ruby build-system/decrypt.rb "{password}" "{source_path}" "{destination_path}"'.format(
|
||||
password=password,
|
||||
source_path=source_path,
|
||||
destination_path=destination_path
|
||||
))
|
||||
#decrypt_match_data(source_path, destination_path, password)
|
||||
elif os.path.isdir(source_path):
|
||||
os.makedirs(destination_path, exist_ok=True)
|
||||
decrypt_codesigning_directory_recursively(source_path, destination_path, password)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
def decrypt_codesigning_directory_recursively(source_base_path, destination_base_path, password):
|
||||
for file_name in os.listdir(source_base_path):
|
||||
source_path = source_base_path + '/' + file_name
|
||||
destination_path = destination_base_path + '/' + file_name
|
||||
allowed_file_extensions = ['.mobileprovision', '.cer', '.p12']
|
||||
if os.path.isfile(source_path) and any(source_path.endswith(ext) for ext in allowed_file_extensions):
|
||||
decrypt_match_data(source_path, destination_path, password)
|
||||
elif os.path.isdir(source_path):
|
||||
os.makedirs(destination_path, exist_ok=True)
|
||||
decrypt_codesigning_directory_recursively(source_path, destination_path, password)
|
||||
```
|
||||
|
||||
- [ ] **Step 5.2: Delete the Ruby script**
|
||||
|
||||
```bash
|
||||
cd /Users/isaac/build/telegram/telegram-ios
|
||||
git rm build-system/decrypt.rb
|
||||
```
|
||||
|
||||
- [ ] **Step 5.3: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/isaac/build/telegram/telegram-ios
|
||||
git add build-system/Make/BuildConfiguration.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
BuildConfiguration: use Python DecryptMatch, drop Ruby decrypt.rb
|
||||
|
||||
Swap the os.system('ruby build-system/decrypt.rb ...') shell-out for
|
||||
a direct decrypt_match_data() call, and delete the now-unused Ruby
|
||||
script. The iOS build no longer depends on a Ruby interpreter.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Expected: commit created cleanly; `git status` shows a clean tree.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: End-to-end verification with `generateProject`
|
||||
|
||||
**Files:**
|
||||
- No changes.
|
||||
|
||||
- [ ] **Step 6.1: Wipe the previously-decrypted directory so the build re-decrypts fresh**
|
||||
|
||||
```bash
|
||||
cd /Users/isaac/build/telegram/telegram-ios
|
||||
rm -rf ~/build/telegram/telegram-ios/build-input/configuration-repository-workdir/decrypted
|
||||
```
|
||||
|
||||
Expected: directory removed. If it did not exist, that's also fine.
|
||||
|
||||
- [ ] **Step 6.2: Run the user-supplied `generateProject` command**
|
||||
|
||||
```bash
|
||||
cd /Users/isaac/build/telegram/telegram-ios
|
||||
source ~/.zshrc 2>/dev/null
|
||||
python3 build-system/Make/Make.py --overrideXcodeVersion \
|
||||
--cacheDir ~/build/telegram/telegram-bazel-cache \
|
||||
generateProject \
|
||||
--configurationPath ~/build/telegram/telegram-internal-tools/PrivateData/build-configurations/enterprise-configuration.json \
|
||||
--gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \
|
||||
--gitCodesigningType development --gitCodesigningUseCurrent
|
||||
```
|
||||
|
||||
Expected: the command runs through project generation. The decryption step is silent on success (per `BuildConfiguration.py:decrypt_codesigning_directory_recursively`). Any decryption failure would surface downstream in `copy_profiles_from_directory` when `openssl smime -verify` chokes on a corrupted `.mobileprovision`, so a clean run proves the port is working end-to-end.
|
||||
|
||||
If the command fails with a decryption-related error, revert the two commits (`git revert HEAD~1..HEAD`) and debug; otherwise the migration is complete.
|
||||
|
||||
- [ ] **Step 6.3: Spot-check the generated decrypted directory**
|
||||
|
||||
```bash
|
||||
ls ~/build/telegram/telegram-ios/build-input/configuration-repository-workdir/decrypted/profiles/development/
|
||||
```
|
||||
|
||||
Expected: a populated list of `.mobileprovision` files, matching the list in the encrypted sibling directory.
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
# Postbox → TelegramEngine Wave 10 Implementation Plan
|
||||
|
||||
> **For agentic workers:** This plan was executed in a single session; steps below are a post-hoc record of the work landed, not a to-do list.
|
||||
|
||||
**Goal:** Finish the `StorageUsageScreen` consumer-module de-Postbox work started in wave 8 and continued in wave 9 by eliminating the last `import Postbox` in the module: `StorageFileListPanelComponent.swift`'s `Icon.media(Media, TelegramMediaImageRepresentation)` enum case.
|
||||
|
||||
**Architecture:** Replace the heterogeneous-protocol `Icon.media(Media, ...)` case with two concrete-type cases `.mediaFile(TelegramMediaFile, ...)` and `.mediaImage(TelegramMediaImage, ...)`. The split is lossless because the two construction sites already knew the concrete subtype (`imageIconValue = .media(file, representation)` vs `.media(image, representation)`), and the one consumer binding site immediately downcasted via `as? TelegramMediaFile` / `as? TelegramMediaImage` to pick which `setSignal(...)` to call. Auto-split the switch body over the two new cases; no downcast needed. Also replaces a placeholder `PeerId(namespace:..., id:...)` construction in the `measureItem` layout-measurement instance with `component.context.account.peerId` (a real, already-available `EnginePeer.Id`).
|
||||
|
||||
**Tech Stack:** Swift / Bazel. No unit tests.
|
||||
|
||||
**Build command:**
|
||||
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64 --continueOnError
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope:**
|
||||
- `StorageFileListPanelComponent.swift`'s `Icon` enum: replace `case media(Media, TelegramMediaImageRepresentation)` with two concrete-type cases.
|
||||
- Equatable rewrite: switch-over-tuple `(lhs, rhs)` pattern with id-based equality per concrete type (`lFile.fileId == rFile.fileId`, `lImage.imageId == rImage.imageId`).
|
||||
- Binding rewrite at the `if case let .media(media, representation)` site (former line 448): lift `representation` via a compound `case let .mediaFile(_, representation), let .mediaImage(_, representation):` pattern, then inner switch-over-`component.icon` selects `setSignal` flavor.
|
||||
- Construction rewrite at two `imageIconValue = .media(...)` sites: use the concrete case name (`.mediaFile`, `.mediaImage`).
|
||||
- Placeholder `PeerId(namespace:..., id:...)` at former line 1062 (in the `measureItem` layout-measurement instance): replace with `component.context.account.peerId`.
|
||||
- Remove `import Postbox` from `StorageFileListPanelComponent.swift`.
|
||||
|
||||
**Out of scope:**
|
||||
- None. This is the last file in the `StorageUsageScreen` module that imports Postbox; after this wave, the module is fully Postbox-free.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Split `Icon.media` into two concrete cases
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` (former 103–135).
|
||||
|
||||
Before:
|
||||
|
||||
```swift
|
||||
enum Icon: Equatable {
|
||||
case fileExtension(String)
|
||||
case media(Media, TelegramMediaImageRepresentation)
|
||||
case audio
|
||||
|
||||
static func ==(lhs: Icon, rhs: Icon) -> Bool {
|
||||
switch lhs {
|
||||
case let .fileExtension(value):
|
||||
if case .fileExtension(value) = rhs { return true } else { return false }
|
||||
case let .media(media, representation):
|
||||
if case let .media(rhsMedia, rhsRepresentation) = rhs {
|
||||
if media.id != rhsMedia.id { return false }
|
||||
if representation != rhsRepresentation { return false }
|
||||
return true
|
||||
} else { return false }
|
||||
case .audio:
|
||||
if case .audio = rhs { return true } else { return false }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```swift
|
||||
enum Icon: Equatable {
|
||||
case fileExtension(String)
|
||||
case mediaFile(TelegramMediaFile, TelegramMediaImageRepresentation)
|
||||
case mediaImage(TelegramMediaImage, TelegramMediaImageRepresentation)
|
||||
case audio
|
||||
|
||||
static func ==(lhs: Icon, rhs: Icon) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case let (.fileExtension(l), .fileExtension(r)):
|
||||
return l == r
|
||||
case let (.mediaFile(lFile, lRepresentation), .mediaFile(rFile, rRepresentation)):
|
||||
return lFile.fileId == rFile.fileId && lRepresentation == rRepresentation
|
||||
case let (.mediaImage(lImage, lRepresentation), .mediaImage(rImage, rRepresentation)):
|
||||
return lImage.imageId == rImage.imageId && lRepresentation == rRepresentation
|
||||
case (.audio, .audio):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Task 2: Rewrite the binding site
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` (former 448–500).
|
||||
|
||||
Before, the block started with `if case let .media(media, representation) = component.icon { ... }` then inside did `if let file = media as? TelegramMediaFile { ... } else if let image = media as? TelegramMediaImage { ... }`.
|
||||
|
||||
After, use a compound case-binding pattern at the entry (both cases have the same `representation` type, so the pattern works) and an inner switch for the `setSignal` branch:
|
||||
|
||||
```swift
|
||||
let mediaRepresentation: TelegramMediaImageRepresentation?
|
||||
switch component.icon {
|
||||
case let .mediaFile(_, representation), let .mediaImage(_, representation):
|
||||
mediaRepresentation = representation
|
||||
default:
|
||||
mediaRepresentation = nil
|
||||
}
|
||||
|
||||
if let representation = mediaRepresentation {
|
||||
// ... setup iconImageNode as before ...
|
||||
if resetImage {
|
||||
switch component.icon {
|
||||
case let .mediaFile(file, _):
|
||||
iconImageNode.setSignal(chatWebpageSnippetFile(
|
||||
account: component.context.account,
|
||||
userLocation: .peer(component.messageId.peerId),
|
||||
mediaReference: FileMediaReference.standalone(media: file).abstract,
|
||||
representation: representation,
|
||||
automaticFetch: false
|
||||
))
|
||||
case let .mediaImage(image, _):
|
||||
iconImageNode.setSignal(mediaGridMessagePhoto(
|
||||
account: component.context.account,
|
||||
userLocation: .peer(component.messageId.peerId),
|
||||
photoReference: ImageMediaReference.standalone(media: image),
|
||||
automaticFetch: false
|
||||
))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
// ... frame + asyncLayout + apply as before ...
|
||||
}
|
||||
```
|
||||
|
||||
### Task 3: Update the two construction sites
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` (former 985 and 992).
|
||||
|
||||
`imageIconValue = .media(file, representation)` → `.mediaFile(file, representation)` (for `TelegramMediaFile` branch).
|
||||
`imageIconValue = .media(image, representation)` → `.mediaImage(image, representation)` (for `TelegramMediaImage` branch).
|
||||
|
||||
### Task 4: Replace the placeholder `PeerId(...)` construction
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` (former 1062).
|
||||
|
||||
The `measureItem` layout-measurement instance uses a fully-zero placeholder peer id:
|
||||
|
||||
```swift
|
||||
messageId: EngineMessage.Id(peerId: PeerId(namespace: PeerId.Namespace._internalFromInt32Value(0), id: PeerId.Id._internalFromInt64Value(0)), namespace: 0, id: 0),
|
||||
```
|
||||
|
||||
Naming `PeerId`, `PeerId.Namespace`, `PeerId.Id` all require `import Postbox` (these are raw Postbox types, not TelegramCore typealiases). Replace with `component.context.account.peerId`, a real `EnginePeer.Id` already in scope:
|
||||
|
||||
```swift
|
||||
messageId: EngineMessage.Id(peerId: component.context.account.peerId, namespace: 0, id: 0),
|
||||
```
|
||||
|
||||
Semantically equivalent for the measurement use case — `messageId` is used downstream only for `.peerId` extraction in the image-fetch userLocation and for Equatable comparison; the measurement instance is standalone and not compared. The `id: 0, namespace: 0` part stays; those are plain `Int32`, nothing Postbox-specific.
|
||||
|
||||
Caught by second-pass build failure `cannot find 'PeerId' in scope` after dropping `import Postbox`.
|
||||
|
||||
### Task 5: Drop `import Postbox`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` (former line 14).
|
||||
|
||||
Remove the `import Postbox` line.
|
||||
|
||||
### Task 6: Full project build
|
||||
|
||||
Expected green after Tasks 4 and 5. The first build attempt surfaced the `PeerId` issue; Task 4's fix addressed it.
|
||||
|
||||
### Task 7: Commit
|
||||
|
||||
Single wave-10 atomic commit. CLAUDE.md gets a wave-10 outcome section; the "Modules currently free of `import Postbox`" tally gains `StorageUsageScreen` (the module as a whole). Both files that previously imported Postbox in this module (`StorageUsageScreen.swift` from wave 9 and `StorageFileListPanelComponent.swift` from wave 10) are now Postbox-free.
|
||||
|
||||
---
|
||||
|
||||
## Outcome (2026-04-20)
|
||||
|
||||
Single atomic commit. Build verified green (27 actions, cached).
|
||||
|
||||
**`StorageUsageScreen` consumer module is now fully Postbox-free** — last file (`StorageFileListPanelComponent.swift`) landed in this wave; the other file (`StorageUsageScreen.swift`) landed in wave 9.
|
||||
|
||||
Net: 1 file changed, +22 / -29 lines (−7 simplification — the new switch-over-tuple Equatable is both terser and more idiomatic than the old three-way nested `switch` + `if case` pattern).
|
||||
|
||||
**Lessons:**
|
||||
|
||||
- **Heterogeneous-protocol enum cases are an easy de-Postbox win** when the protocol values already get downcast to a fixed small set of concrete subtypes. The compiler-enforced exhaustiveness of the split improves call-site safety (no silent `else` branch that forgot a subtype).
|
||||
- **Placeholder `PeerId(...)` constructions in layout-measurement code are traps.** Common pattern in this codebase: a "dummy" component instance is constructed purely to run `.update(...)` and harvest the returned size. The dummy values (`messageId`, `peerId`) are not used for anything but type-filling, yet naming the types forces `import Postbox`. When de-Postboxing, look for `PeerId(namespace:...`/`MessageId(peerId:...` constructions with all-zero arguments and replace with any convenient real value already in scope (`context.account.peerId` works for peer-id placeholders).
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# Postbox → TelegramEngine Wave 7 Implementation Plan
|
||||
|
||||
> **For agentic workers:** This plan was executed in a single session; steps below are a post-hoc record of the work landed, not a to-do list.
|
||||
|
||||
**Goal:** Close out the remaining raw-Postbox leaks in `TelegramEngine.*` public facades surfaced by the wave-6 post-sweep scouting pass (2026-04-20). Six facade-signature migrations + one dead-facade deletion + consumer call-site bridging, landed as a single wave commit.
|
||||
|
||||
**Architecture:** Wave-2 shape scaled to seven facades at once: each facade signature changes in place from raw Postbox domain types (`Message`, `Peer`) to engine equivalents (`EngineMessage`, `EnginePeer`), with `_internal_*` implementations left raw per the standing "internal Postbox-facing stays raw" rule. Consumer call sites bridge at the facade boundary via `EngineMessage.init` / `._asMessage()` wrap/unwrap helpers or drop now-redundant wrapping.
|
||||
|
||||
**Tech Stack:** Swift / Bazel. No unit tests by repo policy — verification is a full project build.
|
||||
|
||||
**Build command:**
|
||||
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64 --continueOnError
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scope — candidate list
|
||||
|
||||
All seven items from the wave-6 post-sweep scouting pass:
|
||||
|
||||
1. `TelegramEngine.Messages.downloadMessage(messageId: MessageId) -> Signal<Message?, NoError>` → `(messageId: EngineMessage.Id) -> Signal<EngineMessage?, NoError>`. Callers: 1 (`ChatListSearchListPaneNode`).
|
||||
2. `TelegramEngine.Messages.topPeerActiveLiveLocationMessages(peerId: PeerId) -> Signal<(Peer?, [Message]), NoError>` → `(peerId: EnginePeer.Id) -> Signal<(EnginePeer?, [EngineMessage]), NoError>`. Callers: 2 (`LocationViewControllerNode`, `LiveLocationSummaryManager`).
|
||||
3. `TelegramEngine.Messages.getSynchronizeAutosaveItemOperations()` — dead facade (sole caller `StoreDownloadedMedia.swift:298` uses `_internal_*` directly). Deleted.
|
||||
4. `TelegramEngine.Peers.updatedRemotePeer(peer: PeerReference) -> Signal<Peer, UpdatedRemotePeerError>` → `Signal<EnginePeer, UpdatedRemotePeerError>`. `PeerReference` param kept (no `EnginePeer.Reference` alias today). Callers: 1 (`ChannelAdminsController`, `ignoreValues` so no caller change needed).
|
||||
5. `TelegramEngine.Resources.renderStorageUsageStatsMessages(…existingMessages: [EngineMessage.Id: Message]) -> Signal<[EngineMessage.Id: Message], NoError>` → `[EngineMessage.Id: EngineMessage]` on both sides. Callers: 1 (`StorageUsageScreen`).
|
||||
6–8. `TelegramEngine.Resources.clearStorage(...)` overloads (three) — `[Message]` params → `[EngineMessage]`. Real external callers: 2 (`StorageUsageScreen`, two overloads). The third overload `clearStorage(messages:)` has no callers; migrated for overload-set consistency.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Migrate three `TelegramEngine.Messages` facades
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift` (3 facades)
|
||||
- Modify: `submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift` (drop redundant `.flatMap(EngineMessage.init)`)
|
||||
- Modify: `submodules/LocationUI/Sources/LocationViewControllerNode.swift` (drop redundant `.map(EngineMessage.init)`)
|
||||
- Modify: `submodules/LiveLocationManager/Sources/LiveLocationSummaryManager.swift` (drop redundant `EnginePeer(...)` / `EngineMessage(...)` wrappers)
|
||||
|
||||
**Changes:**
|
||||
|
||||
`downloadMessage` — wrap return `Message?` → `EngineMessage?` via `|> map { $0.flatMap(EngineMessage.init) }`. `_internal_downloadMessage` still takes `messageId: MessageId`, which is typealiased to `EngineMessage.Id`, so the param change is purely a rename at the public surface.
|
||||
|
||||
`topPeerActiveLiveLocationMessages` — wrap tuple return via `|> map { peer, messages -> (EnginePeer?, [EngineMessage]) in (peer.flatMap(EnginePeer.init), messages.map(EngineMessage.init)) }`.
|
||||
|
||||
`getSynchronizeAutosaveItemOperations` — deleted. The sole caller `StoreDownloadedMedia.swift:298` was already calling `_internal_getSynchronizeAutosaveItemOperations` directly (inside its own transaction block), so no caller update needed.
|
||||
|
||||
### Task 2: Migrate `TelegramEngine.Peers.updatedRemotePeer`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift`
|
||||
|
||||
Append `|> map(EnginePeer.init)` to wrap the `Peer` result. `PeerReference` param stays. Single call site in `ChannelAdminsController.swift` uses `ignoreValues`, so no caller-side change.
|
||||
|
||||
### Task 3: Migrate four `TelegramEngine.Resources` facades
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift` (4 facades)
|
||||
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (3 call sites)
|
||||
|
||||
`renderStorageUsageStatsMessages` — unwrap `[EngineMessage.Id: EngineMessage]` input via `.mapValues { $0._asMessage() }`, wrap raw result via `.mapValues(EngineMessage.init)`. Caller bridges the other direction at its single call site (`.mapValues(EngineMessage.init)` on the input `existingMessages`, `.mapValues { $0._asMessage() }` on the mapped result).
|
||||
|
||||
`clearStorage(peerId:categories:includeMessages:excludeMessages:)` / `clearStorage(peerIds:includeMessages:excludeMessages:)` / `clearStorage(messages:)` — unwrap `[EngineMessage]` params via `.map { $0._asMessage() }` before forwarding to `_internal_clearStorage`. Callers bridge `[Message]` locals with `.map(EngineMessage.init)` at the facade call site.
|
||||
|
||||
Call-site changes in `StorageUsageScreen` are intentionally minimal: the file's `AggregatedData` type keeps `[MessageId: Message]` / `[Message]` internally, with bridging applied only at the four facade-call points. A full-consumer-module migration to `EngineMessage` is out of scope for this wave (would require changing ~30 sites plus the item types in `StorageFileListPanelComponent`; a future "StorageUsageScreen full de-Postbox" wave could land that).
|
||||
|
||||
### Task 4: Full project build
|
||||
|
||||
Run the build command above with `--continueOnError`. Expected: clean build (no errors or warnings introduced). One full build covers all facades since they're in TelegramCore and rebuilding TelegramCore re-verifies every consumer.
|
||||
|
||||
### Task 5: Commit
|
||||
|
||||
Single wave-7 atomic commit covering the 8 modified files and the CLAUDE.md outcome update.
|
||||
|
||||
---
|
||||
|
||||
## Outcome (2026-04-20)
|
||||
|
||||
All seven candidates landed. Single atomic commit. Build verified green (`bazel-bin/Telegram/Telegram.ipa` produced; 5854 total actions, 1009 executed).
|
||||
|
||||
- 3 `TelegramEngine.Messages` facades migrated (1 rewrite, 1 rewrite, 1 deletion)
|
||||
- 1 `TelegramEngine.Peers` facade migrated
|
||||
- 4 `TelegramEngine.Resources` facades migrated (1 dict, 3 overloads)
|
||||
- 5 consumer files updated: `ChatListSearchListPaneNode`, `LocationViewControllerNode`, `LiveLocationSummaryManager`, `StorageUsageScreen`, CLAUDE.md
|
||||
|
||||
No modules became Postbox-free in this wave (all five touched consumers still import Postbox for unrelated reasons — `StorageUsageScreen` especially, which still has 43 raw `Message` / `MessageId` references outside the facade boundary).
|
||||
|
||||
**Lesson recorded:** when a facade's consumer file uses the raw Postbox type extensively outside the facade boundary (e.g. `StorageUsageScreen` with its `[MessageId: Message]` dict stored in a helper class and threaded through ~30 sites), bridging at the facade call site is the correct scope. Full-consumer-module migration is its own separate wave, not a side-effect of facade migration.
|
||||
|
||||
**Next-wave candidates.** The sum of the scouting pass's 8 candidates has been closed. No new `TelegramEngine.*` public facades with raw `Postbox`/`Account`/`MediaBox`/`Peer`/`Message`/`MediaResource` leaks remain. Future-wave focus shifts to:
|
||||
|
||||
1. Full-consumer-module migrations (e.g. `StorageUsageScreen` — drop `AggregatedData`'s raw-Postbox storage types, drop `import Postbox`).
|
||||
2. Another speculative unused-import sweep pass like wave 6, to catch imports that became unused after waves 4–7.
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
# Postbox → TelegramEngine Wave 8 Implementation Plan
|
||||
|
||||
> **For agentic workers:** This plan was executed in a single session; steps below are a post-hoc record of the work landed, not a to-do list.
|
||||
|
||||
**Goal:** `StorageUsageScreen` consumer-module migration — drop all raw `Message` domain types from the screen's internal storage and public peer-panel item types, and eliminate the wave-7 facade-boundary bridging. Scope is narrower than a full de-Postbox of the module: direct `postbox.combinedView` / `postbox.transaction` sites for `AccountSpecificCacheStorageSettings` observation are left for a future wave.
|
||||
|
||||
**Architecture:** Two files modified. `StorageFileListPanelComponent.Item.message` and `StorageUsageScreen`'s `AggregatedData` + `RenderResult` + `SelectionState` internal types are migrated from raw `Message`/`[Message]`/`[MessageId: Message]` to `EngineMessage`/`[EngineMessage]`/`[EngineMessage.Id: EngineMessage]`. The two external APIs that still take raw `Message` (`OpenChatMessageParams.message`, `chatMediaListPreviewControllerData(message:)`) are called with `engineMessage._asMessage()` at the call site.
|
||||
|
||||
**Tech Stack:** Swift / Bazel. No unit tests by repo policy — verification is a full project build.
|
||||
|
||||
**Build command:**
|
||||
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64 --continueOnError
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope:**
|
||||
|
||||
- `StorageFileListPanelComponent.Item.message: Message` → `EngineMessage` (the item type co-located with the panel component).
|
||||
- `StorageUsageScreen.Component.SelectionState.togglePeer(id:availableMessages: [EngineMessage.Id: Message])` → `[EngineMessage.Id: EngineMessage]`.
|
||||
- `StorageUsageScreen.Component.AggregatedData.messages: [MessageId: Message]` → `[EngineMessage.Id: EngineMessage]`.
|
||||
- `AggregatedData.clearIncludeMessages: [Message]` / `.clearExcludeMessages: [Message]` → `[EngineMessage]` (plus the corresponding local vars in `AggregatedData.updateSelected...`).
|
||||
- `AggregatedData.init(..., messages: [MessageId: Message])` → `[EngineMessage.Id: EngineMessage]`.
|
||||
- `StorageUsageScreen.Component.RenderResult.messages: [MessageId: Message]` → `[EngineMessage.Id: EngineMessage]`.
|
||||
- `openMessage(message: Message)` → `openMessage(message: EngineMessage)`.
|
||||
- Drop the now-redundant wave-7 facade-boundary bridging (`.mapValues(EngineMessage.init)` on `existingMessages`, `.mapValues { $0._asMessage() }` on the facade's engineMessages output, `.map(EngineMessage.init)` on the two `clearStorage` call sites, `._asMessage()` on `item.message` inside the `AggregatedData.updateSelected...` loop, and `EngineMessage(message)` inside the `result.imageItems.append(...)` site).
|
||||
|
||||
**Out of scope — left for a future wave:**
|
||||
|
||||
- Direct postbox usage for `AccountSpecificCacheStorageSettings` observation: `StorageUsageScreen.swift:1047-1062` and `3131-3185`. Blocks `import Postbox` removal. Requires engine equivalents for `PostboxViewKey.preferences` / `PreferencesView` observation and for `transaction.getPeer` / `transaction.getPeerCachedData` — likely an `EngineData`-subscription based rewrite plus peer-category classification via already-existing engine APIs.
|
||||
- `StorageFileListPanelComponent`'s `Icon.media(Media, TelegramMediaImageRepresentation)` enum case. Holds either `TelegramMediaFile` or `TelegramMediaImage` (always one of these two TelegramCore types per `imageIconValue = .media(file, ...)` and `.media(image, ...)` construction sites). Could be split into `.mediaFile(TelegramMediaFile, ...)` / `.mediaImage(TelegramMediaImage, ...)` to eliminate the raw `Media` protocol dependency; out of scope as it's unrelated to Message-type migration.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Migrate `StorageFileListPanelComponent.Item.message`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` — `Item.message` type and `init(message:)` param.
|
||||
|
||||
No other changes inside the file. Internal usage (`item.message.id`, `item.message.timestamp`, `item.message.media`) already works on `EngineMessage` — `EngineMessage.media` returns `[Media]` (raw), so the `as? TelegramMediaFile` / `as? TelegramMediaImage` downcasts inside the `for media in item.message.media` loop still compile.
|
||||
|
||||
### Task 2: Migrate `StorageUsageScreen` internal storage types
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift`
|
||||
|
||||
Change:
|
||||
- `SelectionState.togglePeer(availableMessages: [EngineMessage.Id: Message])` → `[EngineMessage.Id: EngineMessage]`. Body only uses `messageId.peerId` so no body change required.
|
||||
- `AggregatedData.messages` / `.clearIncludeMessages` / `.clearExcludeMessages` type declarations and the init param.
|
||||
- The selected-messages accumulation loop inside `AggregatedData` (the block running from the photo/video/file/music category branches): drop `item.message._asMessage()` in the two photo/video branches (`imageItems` holds EngineMessage items, so `._asMessage()` was the EngineMessage→Message unwrap to fit the old `[Message]` local); `item.message` in the file/music branches now passes through since Item.message is EngineMessage.
|
||||
- `RenderResult.messages` type.
|
||||
|
||||
### Task 3: Drop wave-7 facade-boundary bridging
|
||||
|
||||
At `StorageUsageScreen.swift:2397` the `renderStorageUsageStatsMessages` call previously wrapped input via `(self.aggregatedData?.messages ?? [:]).mapValues(EngineMessage.init)` and unwrapped output via `.mapValues { $0._asMessage() }`. With `AggregatedData.messages` and `RenderResult.messages` now EngineMessage-typed, both bridges vanish: the call just passes `self.aggregatedData?.messages ?? [:]` directly and assigns the result to `result.messages` unchanged.
|
||||
|
||||
At the two `clearStorage` call sites in `StorageUsageScreen.swift` (inside `clearSelected(...)`): `aggregatedData.clearIncludeMessages.map(EngineMessage.init)` → `aggregatedData.clearIncludeMessages` (same for `excludeMessages`), plus the local `includeMessages: [Message]` / `excludeMessages: [Message]` vars become `[EngineMessage]`.
|
||||
|
||||
At the `RenderResult`-building loop (post-`renderStorageUsageStatsMessages`), `StorageMediaGridPanelComponent.Item(message: EngineMessage(message), ...)` → `message: message` since `message` is already `EngineMessage`.
|
||||
|
||||
### Task 4: Migrate `openMessage` + external-API unwraps
|
||||
|
||||
`openMessage(message: Message)` → `openMessage(message: EngineMessage)`. Two external APIs receive raw `Message`: pass `message._asMessage()` to `OpenChatMessageParams(message:)` inside `openMessage`, and to `chatMediaListPreviewControllerData(message:)` inside `messageGaleryContextAction`. Also drop the one-line `let foundGalleryMessage: Message? = message` + `guard let galleryMessage = foundGalleryMessage` dance inside `openMessage` — it's a no-op wrap preserved from an older version.
|
||||
|
||||
### Task 5: Full project build
|
||||
|
||||
Expected clean (cached — 30 seconds on an incremental build; ~60s from a cold start since wave 7).
|
||||
|
||||
### Task 6: Commit
|
||||
|
||||
Single wave-8 atomic commit.
|
||||
|
||||
---
|
||||
|
||||
## Outcome (2026-04-20)
|
||||
|
||||
Single atomic commit landing the migration. Build verified green (59s incremental, 27 actions). Net -5 lines (simplification, as expected — most changes are type swaps and a handful of redundant wraps/unwraps removed).
|
||||
|
||||
Two files modified:
|
||||
|
||||
| File | Δ |
|
||||
|---|---|
|
||||
| `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` | +33 / -44 |
|
||||
| `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` | +3 / -3 |
|
||||
|
||||
**Module did NOT become Postbox-free.** Both files retain `import Postbox` for the out-of-scope sites listed above. Drop-candidacy inventory in `StorageUsageScreen.swift`:
|
||||
|
||||
- 1047–1062: preferences-view observation of `AccountSpecificCacheStorageSettings` via `postbox.combinedView` + `PreferencesView`.
|
||||
- 3131–3185: second preferences-view observation + `postbox.transaction { transaction in ... transaction.getPeer / transaction.getPeerCachedData as? CachedGroupData / CachedChannelData ... }` for classifying peer-storage-timeout exceptions.
|
||||
|
||||
And in `StorageFileListPanelComponent.swift`:
|
||||
|
||||
- 105: `Icon.media(Media, TelegramMediaImageRepresentation)` enum case.
|
||||
|
||||
Future wave targets either the preferences-view observation sites (substantial — `EngineData`-subscription rewrite + peer-category classification via engine APIs) or the `Icon.media` split (trivial — 3 sites to update).
|
||||
|
||||
Plan / record: `docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-8.md`.
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
# Postbox → TelegramEngine Wave 9 Implementation Plan
|
||||
|
||||
> **For agentic workers:** This plan was executed in a single session; steps below are a post-hoc record of the work landed, not a to-do list.
|
||||
|
||||
**Goal:** Finish the `StorageUsageScreen` de-Postbox work started in wave 8 by rewriting the two remaining direct-postbox sites that observe `AccountSpecificCacheStorageSettings`, and drop `import Postbox` from `StorageUsageScreen.swift`.
|
||||
|
||||
**Architecture:** Replace `postbox.combinedView(keys: [.preferences(...)]) + PreferencesView` observation with `context.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key:))`, which returns `PreferencesEntry?` and is then decoded the same way (`.get(AccountSpecificCacheStorageSettings.self)`). Replace the transaction-based per-peer classification (`transaction.getPeer` + `transaction.getPeerCachedData as? CachedGroupData/CachedChannelData`) with an `EngineDataMap` of `TelegramEngine.EngineData.Item.Peer.Peer.init(id:)` lookups producing `EnginePeer?` values that pattern-match on `.user` / `.legacyGroup` / `.channel(channel)` / `.secretChat`. The `FoundPeer(peer:subscribers:)` wrapper in the signal's element type is dropped entirely since downstream consumers (`peerExceptions.isEmpty`, `.count`, `.prefix(3).map { EnginePeer($0.peer.peer) }`) never read `subscribers`.
|
||||
|
||||
**Tech Stack:** Swift / Bazel. No unit tests.
|
||||
|
||||
**Build command:**
|
||||
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64 --continueOnError
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
Two direct-postbox site clusters rewritten in `StorageUsageScreen.swift`:
|
||||
|
||||
1. **Site 1 (former lines 1047–1087)** — `cacheSettingsExceptionCount` signal. Preserved its downstream `EngineDataMap` + `EnginePeer` per-category counting logic unchanged; only the preferences observation replaced.
|
||||
2. **Site 2 (former lines 3131–3196)** — `peerExceptions` signal inside `openKeepMediaCategory`. Both the preferences observation AND the `postbox.transaction { transaction.getPeer / transaction.getPeerCachedData ... FoundPeer(...) }` block replaced. Signal element type changed from `[(peer: FoundPeer, value: Int32)]` to `[(peer: EnginePeer, value: Int32)]`; `FoundPeer` and the unread `subscribers` field dropped.
|
||||
|
||||
One consumer-side edit: `peerExceptions.prefix(3).map { EnginePeer($0.peer.peer) }` → `peerExceptions.prefix(3).map { $0.peer }` (at the `MultiplePeerAvatarsContextItem` construction).
|
||||
|
||||
One typealias fixup: `var mergedMedia: [MessageId: Int64]` → `[EngineMessage.Id: Int64]` (required once `import Postbox` is removed, since `MessageId` is the raw Postbox name, not a TelegramCore typealias).
|
||||
|
||||
`import Postbox` removed from `StorageUsageScreen.swift`.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Rewrite site 1 — cacheSettingsExceptionCount
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (former 1047–1058).
|
||||
|
||||
Replace the preferences observation header. The downstream `mapToSignal { ... EngineDataMap ... EnginePeer ... }` body is already Engine-only and unchanged.
|
||||
|
||||
Before:
|
||||
|
||||
```swift
|
||||
let viewKey: PostboxViewKey = .preferences(keys: Set([PreferencesKeys.accountSpecificCacheStorageSettings]))
|
||||
let cacheSettingsExceptionCount: Signal<[CacheStorageSettings.PeerStorageCategory: Int32], NoError> = component.context.account.postbox.combinedView(keys: [viewKey])
|
||||
|> map { views -> AccountSpecificCacheStorageSettings in
|
||||
let cacheSettings: AccountSpecificCacheStorageSettings
|
||||
if let view = views.views[viewKey] as? PreferencesView, let value = view.values[PreferencesKeys.accountSpecificCacheStorageSettings]?.get(AccountSpecificCacheStorageSettings.self) {
|
||||
cacheSettings = value
|
||||
} else {
|
||||
cacheSettings = AccountSpecificCacheStorageSettings.defaultSettings
|
||||
}
|
||||
return cacheSettings
|
||||
}
|
||||
|> distinctUntilChanged
|
||||
|> mapToSignal { ... }
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```swift
|
||||
let cacheSettingsExceptionCount: Signal<[CacheStorageSettings.PeerStorageCategory: Int32], NoError> = context.engine.data.subscribe(
|
||||
TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key: PreferencesKeys.accountSpecificCacheStorageSettings)
|
||||
)
|
||||
|> map { preferencesEntry -> AccountSpecificCacheStorageSettings in
|
||||
return preferencesEntry?.get(AccountSpecificCacheStorageSettings.self) ?? AccountSpecificCacheStorageSettings.defaultSettings
|
||||
}
|
||||
|> distinctUntilChanged
|
||||
|> mapToSignal { ... }
|
||||
```
|
||||
|
||||
### Task 2: Rewrite site 2 — peerExceptions
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (former 3131–3196).
|
||||
|
||||
Replace both the preferences observation (as in Task 1) AND the subsequent `mapToSignal { context.account.postbox.transaction { ... } }` block. Signal element type changes from `[(peer: FoundPeer, value: Int32)]` to `[(peer: EnginePeer, value: Int32)]`. `subscriberCount` is not preserved — it's computed but never read by downstream consumers.
|
||||
|
||||
After (showing the `peerExceptions` signal in full):
|
||||
|
||||
```swift
|
||||
let peerExceptions: Signal<[(peer: EnginePeer, value: Int32)], NoError> = accountSpecificSettings
|
||||
|> mapToSignal { accountSpecificSettings -> Signal<[(peer: EnginePeer, value: Int32)], NoError> in
|
||||
return context.engine.data.get(
|
||||
EngineDataMap(accountSpecificSettings.peerStorageTimeoutExceptions.map(\.key).map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:)))
|
||||
)
|
||||
|> map { peers -> [(peer: EnginePeer, value: Int32)] in
|
||||
var result: [(peer: EnginePeer, value: Int32)] = []
|
||||
for item in accountSpecificSettings.peerStorageTimeoutExceptions {
|
||||
guard let peer = peers[item.key] ?? nil else { continue }
|
||||
let peerCategory: CacheStorageSettings.PeerStorageCategory
|
||||
switch peer {
|
||||
case .user, .secretChat:
|
||||
peerCategory = .privateChats
|
||||
case .legacyGroup:
|
||||
peerCategory = .groups
|
||||
case let .channel(channel):
|
||||
if case .group = channel.info {
|
||||
peerCategory = .groups
|
||||
} else {
|
||||
peerCategory = .channels
|
||||
}
|
||||
}
|
||||
if peerCategory != mappedCategory { continue }
|
||||
result.append((peer: peer, value: item.value))
|
||||
}
|
||||
return result.sorted(by: { lhs, rhs in
|
||||
if lhs.value != rhs.value {
|
||||
return lhs.value < rhs.value
|
||||
}
|
||||
return lhs.peer.debugDisplayTitle < rhs.peer.debugDisplayTitle
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Task 3: Update consumer of `peerExceptions`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (former 3288).
|
||||
|
||||
`peerExceptions.prefix(3).map { EnginePeer($0.peer.peer) }` → `peerExceptions.prefix(3).map { $0.peer }`. The `MultiplePeerAvatarsContextItem(context:, peers: [EnginePeer], totalCount:, action:)` signature is unchanged — we simply drop the redundant `EnginePeer(...)` wrap because `$0.peer` is now already an `EnginePeer`.
|
||||
|
||||
### Task 4: Drop `import Postbox`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (line 12).
|
||||
|
||||
Remove the `import Postbox` line.
|
||||
|
||||
### Task 5: Typealias fixup for `MessageId`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (former 2397).
|
||||
|
||||
`var mergedMedia: [MessageId: Int64]` → `[EngineMessage.Id: Int64]`. `MessageId` is the raw Postbox type name; with `import Postbox` removed, the type must be named through the `EngineMessage.Id` typealias. Discovered by first-pass build failure `cannot find type 'MessageId' in scope`.
|
||||
|
||||
### Task 6: Full project build
|
||||
|
||||
Expected green. Incremental build: ~60s (cached), 27 actions.
|
||||
|
||||
### Task 7: Commit
|
||||
|
||||
Single wave-9 atomic commit. CLAUDE.md updates the wave 8 outcome's "future-wave candidates" note since this wave closes both of them. `StorageUsageScreen` (the module as a whole) now has `StorageUsageScreen.swift` Postbox-free; the module's `StorageFileListPanelComponent.swift` still imports Postbox because of the `Icon.media(Media, TelegramMediaImageRepresentation)` enum case (trivial future wave, as previously noted).
|
||||
|
||||
---
|
||||
|
||||
## Outcome (2026-04-20)
|
||||
|
||||
Single atomic commit. Build verified green (27 actions, ~60s incremental).
|
||||
|
||||
Net change: 1 file, +30 / -54 lines (-24 simplification).
|
||||
|
||||
Lessons:
|
||||
|
||||
- **`ApplicationSpecificPreference(key:)` is the general-purpose engine replacement** for any `postbox.combinedView(keys: [.preferences(keys: Set([key]))])` idiom. Takes a `ValueBoxKey`, returns `PreferencesEntry?`, decodes via `.get(T.self)`. Usable from any module that imports `TelegramCore` even without `import Postbox`, because the `ValueBoxKey`-typed input is obtained through a statically-named `PreferencesKeys.*` member (no `ValueBoxKey` identifier appears in the consumer).
|
||||
- **`MessageId` is raw Postbox, not a TelegramCore typealias.** CLAUDE.md's "engine typealias cheat sheet" labels `PeerId`, `MessageId`, etc. as migration *targets*, not existing aliases. Files that drop `import Postbox` must rename these to `EngineMessage.Id` / `EnginePeer.Id`. Caught by the first-pass build failure.
|
||||
- **Dead-code detection during rewrites.** The transaction block's `subscriberCount` computation and the `FoundPeer.subscribers` field it populated were never consumed downstream. The rewrite simply dropped them, shrinking the code further than a 1:1 rewrite would have.
|
||||
|
||||
`StorageUsageScreen.swift` is now Postbox-free. The `StorageUsageScreen` consumer module as a whole is still not fully Postbox-free because `StorageFileListPanelComponent.swift` retains `import Postbox` for its `Icon.media(Media, TelegramMediaImageRepresentation)` enum case (3 construction sites; trivial future wave splits into `.mediaFile(TelegramMediaFile, ...)` / `.mediaImage(TelegramMediaImage, ...)`).
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,351 @@
|
|||
# TextStyleEditScreen caret-tracking 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:** On every text change inside `TextStyleEditScreen`, scroll the enclosing `ResizableSheetComponent` scroll view so the caret in the active `ListMultilineTextFieldItemComponent` stays visible ~24pt above the keyboard/bottom button area.
|
||||
|
||||
**Architecture:** Single-file change in `submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift`. Give each text field a `ListMultilineTextFieldItemComponent.Tag`; at the end of `TextStyleEditContentComponent.View.update(...)`, read `TextFieldComponent.AnimationHint` off the transition's userData; on a `.textChanged` hint, resolve the editing field, compute the caret rect via `UITextInput.caretRect(for:)`, walk `superview` to the enclosing `UIScrollView`, and adjust its `bounds.origin.y` using the direct-assign + additive-animate pattern from `ComposePollScreen.swift:2873-2895`.
|
||||
|
||||
**Tech Stack:** Swift, UIKit, Telegram's ComponentFlow (`ComponentView`, `ComponentTransition`, `TextFieldComponent.AnimationHint`), Bazel via `Make.py`. No unit tests exist in this project — verification is a full build + manual smoke test per `CLAUDE.md`.
|
||||
|
||||
**Reference spec:** `docs/superpowers/specs/2026-04-21-textstyleeditscreen-caret-tracking-design.md`.
|
||||
|
||||
**Reference precedent:** `submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift:2733-2895` (field-bounds variant of this same pattern).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
Only one file is touched:
|
||||
|
||||
- **Modify:** `submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift`
|
||||
- Add two stored `ListMultilineTextFieldItemComponent.Tag` properties on `TextStyleEditContentComponent.View`.
|
||||
- Thread those tags into the two existing `ListMultilineTextFieldItemComponent(...)` constructions inside `update(...)`.
|
||||
- Add a private `recenterCaret(hintView:transition:)` method on `TextStyleEditContentComponent.View`.
|
||||
- Call `recenterCaret` from the tail of `update(...)` when the transition carries a `.textChanged` `TextFieldComponent.AnimationHint`.
|
||||
|
||||
No other files are modified. Public API of `ResizableSheetComponent`, `ListMultilineTextFieldItemComponent`, and `TextFieldComponent` is used as-is.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add field tags and wire them into the two text field constructors
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift` (around lines 64-77, 277, 322)
|
||||
|
||||
- [ ] **Step 1: Add the two `Tag` stored properties to `TextStyleEditContentComponent.View`**
|
||||
|
||||
In `TextStyleEditScreen.swift`, locate the stored-property block at the top of `final class View: UIView` (lines 64-77). Below `private let linkOption = ComponentView<Empty>()` (line 76) add:
|
||||
|
||||
```swift
|
||||
private let titleFieldTag = ListMultilineTextFieldItemComponent.Tag()
|
||||
private let textFieldTag = ListMultilineTextFieldItemComponent.Tag()
|
||||
```
|
||||
|
||||
Keep them above the `override init(frame: CGRect)` at line 78.
|
||||
|
||||
- [ ] **Step 2: Pass `self.titleFieldTag` into the title field constructor**
|
||||
|
||||
Locate the `ListMultilineTextFieldItemComponent(...)` construction for the title section (starts at line 260). Its last argument currently reads `tag: nil` (line 277). Change it to:
|
||||
|
||||
```swift
|
||||
tag: self.titleFieldTag
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Pass `self.textFieldTag` into the prompt field constructor**
|
||||
|
||||
Locate the second `ListMultilineTextFieldItemComponent(...)` construction for the text section (starts at line 304). Its last argument currently reads `tag: nil` (line 322). Change it to:
|
||||
|
||||
```swift
|
||||
tag: self.textFieldTag
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the change compiles**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \
|
||||
--cacheDir ~/telegram-bazel-cache \
|
||||
build \
|
||||
--configurationPath build-system/appstore-configuration.json \
|
||||
--gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \
|
||||
--gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 \
|
||||
--continueOnError
|
||||
```
|
||||
|
||||
Expected: build succeeds (or the same pre-existing failures unrelated to `TextStyleEditScreen.swift`). A failure in `TextStyleEditScreen.swift` means the tag types or property names are wrong — fix before moving on.
|
||||
|
||||
- [ ] **Step 5: Do not commit yet** — tag wiring is inert without the recenter logic. Defer commit to Task 4.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add the `recenterCaret` helper
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift`
|
||||
|
||||
- [ ] **Step 1: Add the method on `TextStyleEditContentComponent.View`**
|
||||
|
||||
Inside `final class View: UIView` (the class that starts at line 64), directly **before** the `func update(component:availableSize:state:environment:transition:)` method (line 86), add this private method. It covers steps 1–6 from the design spec (locate field view → caret rect → scroll view → scroll view coordinates → visible region → adjust bounds).
|
||||
|
||||
```swift
|
||||
private func recenterCaret(hintView: UIView, transition: ComponentTransition) {
|
||||
var fieldView: ListMultilineTextFieldItemComponent.View?
|
||||
var ancestor: UIView? = hintView
|
||||
while let current = ancestor {
|
||||
if let candidate = current as? ListMultilineTextFieldItemComponent.View {
|
||||
fieldView = candidate
|
||||
break
|
||||
}
|
||||
ancestor = current.superview
|
||||
}
|
||||
guard let fieldView else {
|
||||
return
|
||||
}
|
||||
if !(fieldView.matches(tag: self.titleFieldTag) || fieldView.matches(tag: self.textFieldTag)) {
|
||||
return
|
||||
}
|
||||
guard let inputTextView = fieldView.textFieldView?.inputTextView else {
|
||||
return
|
||||
}
|
||||
let caretPosition = inputTextView.selectedTextRange?.end ?? inputTextView.endOfDocument
|
||||
let caretRect = inputTextView.caretRect(for: caretPosition)
|
||||
if caretRect.isNull || caretRect.isInfinite {
|
||||
return
|
||||
}
|
||||
|
||||
var scrollAncestor: UIView? = self.superview
|
||||
var scrollView: UIScrollView?
|
||||
while let current = scrollAncestor {
|
||||
if let candidate = current as? UIScrollView {
|
||||
scrollView = candidate
|
||||
break
|
||||
}
|
||||
scrollAncestor = current.superview
|
||||
}
|
||||
guard let scrollView, let environment = self.environment else {
|
||||
return
|
||||
}
|
||||
|
||||
let caretInScroll = inputTextView.convert(caretRect, to: scrollView)
|
||||
|
||||
let bottomActionAreaHeight: CGFloat = 60.0
|
||||
let caretTopInset: CGFloat = 24.0
|
||||
let caretBottomInset: CGFloat = 24.0
|
||||
let visibleTop = scrollView.bounds.minY + caretTopInset
|
||||
let visibleBottom = scrollView.bounds.maxY - environment.inputHeight - bottomActionAreaHeight - caretBottomInset
|
||||
|
||||
let previousBounds = scrollView.bounds
|
||||
var newBounds = previousBounds
|
||||
if caretInScroll.maxY > visibleBottom {
|
||||
newBounds.origin.y += (caretInScroll.maxY - visibleBottom)
|
||||
} else if caretInScroll.minY < visibleTop {
|
||||
newBounds.origin.y -= (visibleTop - caretInScroll.minY)
|
||||
}
|
||||
let maxOriginY = max(0.0, scrollView.contentSize.height - scrollView.bounds.height)
|
||||
newBounds.origin.y = min(max(0.0, newBounds.origin.y), maxOriginY)
|
||||
|
||||
if newBounds != previousBounds {
|
||||
scrollView.bounds = newBounds
|
||||
if !transition.animation.isImmediate {
|
||||
let offsetY = previousBounds.origin.y - newBounds.origin.y
|
||||
transition.animateBoundsOrigin(view: scrollView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes on key choices:
|
||||
|
||||
- `bottomActionAreaHeight: 60.0` = `52.0` (bottom item height — see `ResizableSheetComponent.swift:750`) + `8.0` gap above the button (matches `ResizableSheetComponent.swift:732`).
|
||||
- `caretTopInset` / `caretBottomInset` (both `24.0`) provide the "small inset" biased positioning the user confirmed.
|
||||
- The hint's view ancestor walk is used (rather than `self.titleFieldTag`'s / `self.textFieldTag`'s views directly) because the hint already carries the `TextFieldComponent.View` that actually fired the change — this is safer than guessing which of our two fields is editing when both may have briefly claimed focus.
|
||||
- `transition.animateBoundsOrigin` is the proven pattern from `ComposePollScreen.swift:2891-2894`; `transition.animation.isImmediate` gating avoids an unnecessary animation when the transition is immediate.
|
||||
- Silent bails on missing scroll view or text view keep the code robust against host refactors (they should never happen in normal operation).
|
||||
|
||||
- [ ] **Step 2: Verify compilation**
|
||||
|
||||
Re-run the build command from Task 1 Step 4. Expected: the method compiles cleanly. Common failure modes to watch for:
|
||||
|
||||
- `cannot find 'ListMultilineTextFieldItemComponent.View' in scope` → wrong type path; check the import and the class name in `ListMultilineTextFieldItemComponent.swift:196` (it is the nested `View` class of `ListMultilineTextFieldItemComponent`).
|
||||
- `value of type 'TextFieldComponent.View' has no member 'inputTextView'` → the property is defined at `TextFieldComponent.swift:359`; ensure you're reading `fieldView.textFieldView?.inputTextView`, not reaching into private internals.
|
||||
- `'ComponentTransition' has no member 'animateBoundsOrigin'` → this is a ComponentFlow method; grep confirms it exists and is used at `ComposePollScreen.swift:2893`. If missing, the import line (`import ComponentFlow`) at file top is the place to check.
|
||||
|
||||
- [ ] **Step 3: Do not commit yet** — the helper is unreferenced and unused. Defer commit to Task 4.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Hook up the `.textChanged` trigger in `update(...)`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift`
|
||||
|
||||
- [ ] **Step 1: Add the trigger at the tail of `update(...)`**
|
||||
|
||||
At the end of `func update(component:availableSize:state:environment:transition:)` on `TextStyleEditContentComponent.View`, locate lines 455-460:
|
||||
|
||||
```swift
|
||||
contentHeight += 104.0
|
||||
|
||||
let _ = alphaTransition
|
||||
|
||||
return CGSize(width: availableSize.width, height: contentHeight)
|
||||
```
|
||||
|
||||
Insert the trigger block **before** `return`:
|
||||
|
||||
```swift
|
||||
contentHeight += 104.0
|
||||
|
||||
let _ = alphaTransition
|
||||
|
||||
if let hint = transition.userData(TextFieldComponent.AnimationHint.self), case .textChanged = hint.kind, let hintView = hint.view {
|
||||
self.recenterCaret(hintView: hintView, transition: transition)
|
||||
}
|
||||
|
||||
return CGSize(width: availableSize.width, height: contentHeight)
|
||||
```
|
||||
|
||||
Do NOT match on `.textFocusChanged` — per the user's requirement, scrolling fires only on text edits.
|
||||
|
||||
- [ ] **Step 2: Ensure `TextFieldComponent` is importable**
|
||||
|
||||
`TextFieldComponent.AnimationHint` is vended from the `TextFieldComponent` module. Check the file's import list at the top (lines 1-25). `TextFieldComponent` is used transitively today via `ListMultilineTextFieldItemComponent`, but the type is only re-exposed if we explicitly import it.
|
||||
|
||||
Locate the import block (around lines 1-25). If `import TextFieldComponent` is not present, add it alphabetically — for example, between `import ResizableSheetComponent` and `import TelegramCore`:
|
||||
|
||||
```swift
|
||||
import TextFieldComponent
|
||||
```
|
||||
|
||||
If it is already present, skip this sub-step.
|
||||
|
||||
- [ ] **Step 3: Ensure the BUILD dep is present**
|
||||
|
||||
Locate the sibling `BUILD` file:
|
||||
|
||||
```bash
|
||||
cat submodules/TelegramUI/Components/TextProcessingScreen/BUILD
|
||||
```
|
||||
|
||||
Look for `//submodules/TelegramUI/Components/TextFieldComponent:TextFieldComponent` in the `deps` list. If present, skip to the next step. If absent, add it to the `deps` array (preserving alphabetical order where the BUILD file follows that convention). For example:
|
||||
|
||||
```
|
||||
"//submodules/TelegramUI/Components/TextFieldComponent:TextFieldComponent",
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify compilation**
|
||||
|
||||
Re-run the build command from Task 1 Step 4.
|
||||
|
||||
Expected: clean build for `TextStyleEditScreen.swift` and its host module (`TextProcessingScreen`). Common failure modes:
|
||||
|
||||
- `cannot find 'TextFieldComponent' in scope` → missing `import TextFieldComponent` (fix in Step 2).
|
||||
- Bazel link error naming `TextFieldComponent` → missing BUILD dep (fix in Step 3).
|
||||
- `instance method requires the types 'X' and 'Y' to be equivalent` on the `case .textChanged = hint.kind` line → the `case let` pattern binding; verify with `grep -n 'case \\.textChanged' submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift` that the case is payload-less (it is, per `TextFieldComponent.swift:95-103` where `Kind` declares `case textChanged` without associated values and `case textFocusChanged(isFocused: Bool)` with one).
|
||||
|
||||
- [ ] **Step 5: Do not commit yet** — verify end-to-end behavior in Task 4 first.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Manual smoke test and commit
|
||||
|
||||
**Files:**
|
||||
- Modify (commit): `submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift`
|
||||
- Possibly modify (commit): `submodules/TelegramUI/Components/TextProcessingScreen/BUILD`
|
||||
|
||||
- [ ] **Step 1: Launch the app on the simulator**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
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: `Telegram.ipa` target built successfully, 0 errors.
|
||||
|
||||
Note: this project has no unit tests; feature correctness for UI changes requires a manual check on device or simulator. Install the built app on the iOS simulator (`xcrun simctl install booted ...` if not done by the build script) and navigate to the AI style-edit sheet — this is typically reached from a chat's AI compose-mode style selector or from Settings, depending on build flavour. If the entry point is unclear, grep for `TextStyleEditScreen(` to find a test harness or the production call site:
|
||||
|
||||
```bash
|
||||
grep -rn "TextStyleEditScreen(" submodules --include="*.swift"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Smoke test — short content path**
|
||||
|
||||
1. Tap the "Style Name" field. Confirm the keyboard slides up and the "Create" button rides above the keyboard (pre-existing behavior from the earlier `inputHeight` work).
|
||||
2. Type one character. With short content no scroll should occur; the scroll view should remain at origin zero (visual check: the emoji icon at the top stays visible).
|
||||
|
||||
Pass criterion: no visual regression; the title field is visible and typable.
|
||||
|
||||
- [ ] **Step 3: Smoke test — long prompt path**
|
||||
|
||||
1. Tap the "Instructions" field.
|
||||
2. Type enough text (or paste a paragraph) to make the prompt field taller than the viewport with the keyboard up.
|
||||
3. Continue typing so new characters appear at the caret.
|
||||
|
||||
Pass criterion: as each newline is added, the caret stays approximately 24pt above the keyboard/button area. The field's top may scroll out of view — that's expected.
|
||||
|
||||
- [ ] **Step 4: Smoke test — manual-scroll-then-type**
|
||||
|
||||
1. Still in the "Instructions" field with enough content that scroll is possible.
|
||||
2. Manually drag the sheet content up so the caret is pushed above the visible area.
|
||||
3. Type one character.
|
||||
|
||||
Pass criterion: the scroll view snaps downward so the caret is visible again, above the keyboard with the configured inset.
|
||||
|
||||
- [ ] **Step 5: Smoke test — edit-mode mid-field tap (non-goal regression check)**
|
||||
|
||||
1. Trigger the screen in edit mode on a style with a long pre-populated prompt (enough text to exceed the viewport).
|
||||
2. Tap **in the middle** of the prompt so the caret lands off-screen-top (no text change).
|
||||
|
||||
Pass criterion: **no** scroll occurs (this is per the non-goal — we only scroll on text change). A follow-up text edit is expected to trigger a scroll; that is covered by Step 3.
|
||||
|
||||
- [ ] **Step 6: Check for regressions in adjacent flows**
|
||||
|
||||
Briefly exercise:
|
||||
|
||||
1. The emoji-selection sheet (tap the big round emoji area at the top) — must still open, select, and dismiss without issue.
|
||||
2. The "Add a link to my account" checkbox — toggling still flips the check.
|
||||
3. The "Delete Style" row (edit mode) — still pushes the confirm alert.
|
||||
|
||||
Pass criterion: all three work as before.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift
|
||||
# Only stage the BUILD file if it was modified in Task 3 Step 3:
|
||||
git status --short submodules/TelegramUI/Components/TextProcessingScreen/BUILD
|
||||
# If the BUILD file shows up modified, stage it too:
|
||||
git add submodules/TelegramUI/Components/TextProcessingScreen/BUILD
|
||||
git commit -m "$(cat <<'EOF'
|
||||
TextStyleEditScreen: scroll caret into view on text change
|
||||
|
||||
Tag both ListMultilineTextFieldItemComponents and, at the tail of
|
||||
TextStyleEditContentComponent.View.update(...), read TextFieldComponent.
|
||||
AnimationHint off the transition userData. On a .textChanged hint, locate
|
||||
the editing field, compute the caret rect, walk up to the enclosing
|
||||
ResizableSheetComponent scroll view, and adjust bounds.origin.y so the
|
||||
caret sits ~24pt above the keyboard/bottom action area.
|
||||
|
||||
Scroll runs only on text edits (not on focus/selection changes) per spec.
|
||||
Uses the direct-assign + additive-animate pattern from ComposePollScreen.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Expected: commit succeeds. The diff is ~50 lines added across one .swift file (and possibly one line added to BUILD).
|
||||
|
||||
---
|
||||
|
||||
## Out-of-scope / follow-ups
|
||||
|
||||
None planned. The non-goals called out in the spec (scroll on focus change, scroll on selection change, scroll on keyboard show/hide independently of a text edit) are intentional omissions, not deferred work.
|
||||
|
||||
If manual smoke testing reveals that focus-gain keyboard appearance creates a bad UX (user taps a field near the bottom and the keyboard covers it until they type), consider adding back the `.textFocusChanged(isFocused: true)` case in the trigger block. That is a one-line change to the conditional in Task 3 Step 1 and does not require any design iteration.
|
||||
|
|
@ -0,0 +1,944 @@
|
|||
# Wave 36: `ContactListPeer.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 the public enum case `ContactListPeer.peer(peer: Peer, isGlobal: Bool, participantCount: Int32?)` from the Postbox `Peer` protocol to the TelegramCore `EnginePeer` enum in a single atomic commit. Cascading changes: change `ContactListPeer.indexName` return type from `PeerIndexNameRepresentation` to `EnginePeer.IndexName` (drops 2 `EnginePeer.IndexName(...)` wraps at one call site); rewrite the enum's custom `==` to use `EnginePeer`'s synthesized Equatable; drop 20 outflow `._asPeer()` bridges, 16 inflow `EnginePeer(peer)` wraps; rewrite 2 Postbox-concrete cast chains to EnginePeer case patterns.
|
||||
|
||||
**Architecture:** One atomic commit. The enum-case payload change is necessarily atomic. `ContactListPeer` lives in `submodules/AccountContext/Sources/ContactSelectionController.swift`; 7 consumer files touched in addition. 2 consumer files verified untouched (`ComposeController.swift`, `ChatSendAudioMessageContextPreview.swift`). No new wrappers, no new typealiases. `import Postbox` stays in every touched consumer (follow-up unused-import sweep handles it).
|
||||
|
||||
**Tech Stack:** Swift, Bazel build via Make.py wrapper. No tests — verification is build success + targeted grep checks.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-24-contactlistpeer-engine-peer-migration-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Modified files (8 expected — 1 definition + 7 consumer. Plus 2 verify-only.)**
|
||||
|
||||
| File | Edits | Categories |
|
||||
|---|---|---|
|
||||
| `submodules/AccountContext/Sources/ContactSelectionController.swift` | 3 (case type + indexName return type + `==` body) | α |
|
||||
| `submodules/ContactListUI/Sources/ContactListNode.swift` | ~21 (12 outflow + 4 inflow + 2 cast rewrites [L182-186, L1968] + 2 IndexName wraps [L517]) | β + δ + φ + ε′ |
|
||||
| `submodules/ContactListUI/Sources/ContactsController.swift` | 1 (inflow wrap at L294) | δ |
|
||||
| `submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift` | 7 (3 outflow + 4 inflow) | β + δ |
|
||||
| `submodules/TelegramUI/Sources/ContactMultiselectionController.swift` | 6 (2 outflow + 4 inflow) | β + δ |
|
||||
| `submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift` | 2 (1 outflow + 1 inflow) | β + δ |
|
||||
| `submodules/TelegramUI/Sources/ContactSelectionController.swift` | 2 (inflow wraps L517/527) | δ |
|
||||
| `submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift` | 2 (outflow bridges L160/230) | β |
|
||||
|
||||
**Verify-only (no edits expected):**
|
||||
|
||||
| File | Reason |
|
||||
|---|---|
|
||||
| `submodules/TelegramUI/Sources/ComposeController.swift` | Destructures at L120/160 access `.id` only. Same-type access works on EnginePeer. |
|
||||
| `submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift` | Only holds `[ContactListPeer]` at collection level; no `.peer` destructures. |
|
||||
|
||||
**EnginePeer enum case mapping (used in cast rewrites):**
|
||||
|
||||
| Postbox concrete | EnginePeer case |
|
||||
|---|---|
|
||||
| `TelegramUser` | `.user(TelegramUser)` |
|
||||
| `TelegramGroup` | `.legacyGroup(TelegramGroup)` |
|
||||
| `TelegramChannel` | `.channel(TelegramChannel)` |
|
||||
|
||||
**Sites that stay as `._asPeer()` bridges (NOT in wave scope):**
|
||||
|
||||
- `submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift:488, 528, 562` — `canSendMessagesToPeer(peer._asPeer())` / `canSendMessagesToPeer(peer.peer._asPeer())`. `canSendMessagesToPeer(_: Peer)` migration is a deferred future wave.
|
||||
- `submodules/TelegramUI/Sources/ContactMultiselectionController.swift:171, 201, 748` — `peerTokenTitle(accountPeerId:..., peer: peer._asPeer(), strings:...)`. `peerTokenTitle(peer: Peer)` migration is out of scope.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Edit `AccountContext/Sources/ContactSelectionController.swift` — definition
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/AccountContext/Sources/ContactSelectionController.swift`
|
||||
|
||||
Foundational change. Without it, none of the consumer edits compile.
|
||||
|
||||
- [ ] **Step 1.1: Update the case payload type, `indexName` return type, and `==` operator body**
|
||||
|
||||
Edit using the Edit tool:
|
||||
|
||||
```swift
|
||||
// OLD (lines 61-99)
|
||||
public enum ContactListPeer: Equatable {
|
||||
case peer(peer: Peer, isGlobal: Bool, participantCount: Int32?)
|
||||
case deviceContact(DeviceContactStableId, DeviceContactBasicData)
|
||||
|
||||
public var id: ContactListPeerId {
|
||||
switch self {
|
||||
case let .peer(peer, _, _):
|
||||
return .peer(peer.id)
|
||||
case let .deviceContact(id, _):
|
||||
return .deviceContact(id)
|
||||
}
|
||||
}
|
||||
|
||||
public var indexName: PeerIndexNameRepresentation {
|
||||
switch self {
|
||||
case let .peer(peer, _, _):
|
||||
return peer.indexName
|
||||
case let .deviceContact(_, contact):
|
||||
return .personName(first: contact.firstName, last: contact.lastName, addressNames: [], phoneNumber: "")
|
||||
}
|
||||
}
|
||||
|
||||
public static func ==(lhs: ContactListPeer, rhs: ContactListPeer) -> Bool {
|
||||
switch lhs {
|
||||
case let .peer(lhsPeer, lhsIsGlobal, lhsParticipantCount):
|
||||
if case let .peer(rhsPeer, rhsIsGlobal, rhsParticipantCount) = rhs, lhsPeer.isEqual(rhsPeer), lhsIsGlobal == rhsIsGlobal, lhsParticipantCount == rhsParticipantCount {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
case let .deviceContact(id, contact):
|
||||
if case .deviceContact(id, contact) = rhs {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
public enum ContactListPeer: Equatable {
|
||||
case peer(peer: EnginePeer, isGlobal: Bool, participantCount: Int32?)
|
||||
case deviceContact(DeviceContactStableId, DeviceContactBasicData)
|
||||
|
||||
public var id: ContactListPeerId {
|
||||
switch self {
|
||||
case let .peer(peer, _, _):
|
||||
return .peer(peer.id)
|
||||
case let .deviceContact(id, _):
|
||||
return .deviceContact(id)
|
||||
}
|
||||
}
|
||||
|
||||
public var indexName: EnginePeer.IndexName {
|
||||
switch self {
|
||||
case let .peer(peer, _, _):
|
||||
return peer.indexName
|
||||
case let .deviceContact(_, contact):
|
||||
return .personName(first: contact.firstName, last: contact.lastName, addressNames: [], phoneNumber: "")
|
||||
}
|
||||
}
|
||||
|
||||
public static func ==(lhs: ContactListPeer, rhs: ContactListPeer) -> Bool {
|
||||
switch lhs {
|
||||
case let .peer(lhsPeer, lhsIsGlobal, lhsParticipantCount):
|
||||
if case let .peer(rhsPeer, rhsIsGlobal, rhsParticipantCount) = rhs, lhsPeer == rhsPeer, lhsIsGlobal == rhsIsGlobal, lhsParticipantCount == rhsParticipantCount {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
case let .deviceContact(id, contact):
|
||||
if case .deviceContact(id, contact) = rhs {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Three changes in this edit:
|
||||
1. Line 62: `peer: Peer` → `peer: EnginePeer`
|
||||
2. Line 74: return type `PeerIndexNameRepresentation` → `EnginePeer.IndexName`
|
||||
3. Line 86 (inside the `==` operator): `lhsPeer.isEqual(rhsPeer)` → `lhsPeer == rhsPeer`
|
||||
|
||||
`EnginePeer.IndexName.personName(first:last:addressNames:phoneNumber:)` has the same labels/types as `PeerIndexNameRepresentation.personName`, so line 79 body is untouched — only its return target enum changes.
|
||||
|
||||
- [ ] **Step 1.2: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -nE "case peer\(peer:|public var indexName:|\.isEqual\(" submodules/AccountContext/Sources/ContactSelectionController.swift
|
||||
```
|
||||
|
||||
Expected output:
|
||||
- Line 62: `case peer(peer: EnginePeer, ...)`
|
||||
- Line 74: `public var indexName: EnginePeer.IndexName {`
|
||||
- No `isEqual(` match on the `==` path (the only remaining occurrences would be unrelated).
|
||||
|
||||
Do not commit yet.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Edit `ContactListNode.swift` — largest consumer, multi-category
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/ContactListUI/Sources/ContactListNode.swift`
|
||||
|
||||
Most changes happen here: 12 outflow bridges + 4 inflow wraps + 2 cast chain rewrites + 2 IndexName wrap drops.
|
||||
|
||||
- [ ] **Step 2.1: Drop the 12 outflow `._asPeer()` bridges via `replace_all`**
|
||||
|
||||
All 12 `._asPeer()` bridges at ContactListPeer.peer construction sites follow the shape `._asPeer(), isGlobal:`. Non-construction `._asPeer()` uses in this file (if any) feed other functions and do NOT use this exact substring.
|
||||
|
||||
Pre-flight verify:
|
||||
|
||||
```bash
|
||||
grep -cE "\._asPeer\(\), isGlobal:" submodules/ContactListUI/Sources/ContactListNode.swift
|
||||
```
|
||||
|
||||
Expected: `12`.
|
||||
|
||||
If the count is 12, apply the Edit tool with `replace_all=true`:
|
||||
- `old_string`: `._asPeer(), isGlobal:`
|
||||
- `new_string`: `, isGlobal:`
|
||||
|
||||
If the count is not 12, fall back to per-site Edits at lines 632, 690, 701, 747, 765, 1365, 1647, 1656, 1693, 1731, 1942, 1944 using enough surrounding context to make each `old_string` unique.
|
||||
|
||||
- [ ] **Step 2.2: Verify the 12 outflow drops**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -nE "\._asPeer\(\), isGlobal:" submodules/ContactListUI/Sources/ContactListNode.swift
|
||||
```
|
||||
|
||||
Expected: zero matches.
|
||||
|
||||
- [ ] **Step 2.3: Drop 2 inflow wraps at L204**
|
||||
|
||||
Read lines 200–210 first to confirm the line text.
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD (line 204)
|
||||
itemPeer = .peer(peer: EnginePeer(peer), chatPeer: EnginePeer(peer))
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
itemPeer = .peer(peer: peer, chatPeer: peer)
|
||||
```
|
||||
|
||||
- [ ] **Step 2.4: Drop 1 inflow wrap at L252**
|
||||
|
||||
Read lines 248–256 first to confirm.
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD (line 252)
|
||||
interaction.openDisabledPeer(EnginePeer(peer), requiresPremiumForMessaging ? .premiumRequired : .generic)
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
interaction.openDisabledPeer(peer, requiresPremiumForMessaging ? .premiumRequired : .generic)
|
||||
```
|
||||
|
||||
- [ ] **Step 2.5: Drop 1 inflow wrap at L844**
|
||||
|
||||
Read lines 840–848 first to confirm.
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD (line 844)
|
||||
if let isPeerEnabled, !isPeerEnabled(EnginePeer(peer)) {
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
if let isPeerEnabled, !isPeerEnabled(peer) {
|
||||
```
|
||||
|
||||
- [ ] **Step 2.6: Rewrite the L182-186 cast chain to EnginePeer case patterns**
|
||||
|
||||
Read lines 176–200 first. The cast chain is inside the ContactListPeer.peer destructure at line 177.
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD (lines 182-186)
|
||||
} else {
|
||||
if let _ = peer as? TelegramUser {
|
||||
status = .presence(presence ?? EnginePeer.Presence(status: .longTimeAgo, lastActivity: 0), dateTimeFormat)
|
||||
} else if let group = peer as? TelegramGroup {
|
||||
status = .custom(string: NSAttributedString(string: strings.Conversation_StatusMembers(Int32(group.participantCount))), multiline: false, isActive: false, icon: nil)
|
||||
} else if let channel = peer as? TelegramChannel {
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
} else {
|
||||
if case .user = peer {
|
||||
status = .presence(presence ?? EnginePeer.Presence(status: .longTimeAgo, lastActivity: 0), dateTimeFormat)
|
||||
} else if case let .legacyGroup(group) = peer {
|
||||
status = .custom(string: NSAttributedString(string: strings.Conversation_StatusMembers(Int32(group.participantCount))), multiline: false, isActive: false, icon: nil)
|
||||
} else if case let .channel(channel) = peer {
|
||||
```
|
||||
|
||||
`channel.info` access inside the surviving inner block continues to compile unchanged (`EnginePeer.channel` wraps `TelegramChannel`). `group.participantCount` inside the `legacyGroup` branch works identically. The first branch doesn't bind the user — the `case .user = peer` form preserves that.
|
||||
|
||||
- [ ] **Step 2.7: Rewrite the L1968 cast to an EnginePeer case pattern**
|
||||
|
||||
Read lines 1964–1976 first. The cast is inside the ContactListPeer.peer destructure at line 1966.
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD (lines 1967-1968)
|
||||
if requirePhoneNumbers,
|
||||
let user = peer as? TelegramUser {
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
if requirePhoneNumbers,
|
||||
case let .user(user) = peer {
|
||||
```
|
||||
|
||||
`user.phone` on the following line continues to compile (`EnginePeer.user` wraps `TelegramUser`).
|
||||
|
||||
- [ ] **Step 2.8: Drop 2 IndexName wraps at L517**
|
||||
|
||||
Read lines 515–522 first.
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD (line 517)
|
||||
let result = EnginePeer.IndexName(lhs.indexName).isLessThan(other: EnginePeer.IndexName(rhs.indexName), ordering: sortOrder)
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
let result = lhs.indexName.isLessThan(other: rhs.indexName, ordering: sortOrder)
|
||||
```
|
||||
|
||||
`ContactListPeer.indexName` now returns `EnginePeer.IndexName` (from Task 1), and `isLessThan(other:ordering:)` is defined on `EnginePeer.IndexName` at `submodules/LocalizedPeerData/Sources/PeerTitle.swift:64`, so the wrap idiom is no longer required.
|
||||
|
||||
- [ ] **Step 2.9: Verify ContactListNode.swift changes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -nE "\._asPeer\(\), isGlobal:|EnginePeer\(peer\)|peer as\? Telegram(User|Group|Channel)\b|EnginePeer\.IndexName\(lhs\.indexName\)|EnginePeer\.IndexName\(rhs\.indexName\)" submodules/ContactListUI/Sources/ContactListNode.swift
|
||||
```
|
||||
|
||||
Expected output: only `EnginePeer(peer)` matches at lines 1819 and 1825 (out-of-scope; `peer` there is from `entryData.renderedPeer.peer`, raw `Peer`, wraps stay). Similarly, `peer as? TelegramChannel` at 1802/1820 and `peer is TelegramGroup` at 1818 stay.
|
||||
|
||||
If any other match appears, re-examine that site and apply the matching fix.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Edit `ContactsController.swift` — 1 inflow wrap drop
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/ContactListUI/Sources/ContactsController.swift`
|
||||
|
||||
- [ ] **Step 3.1: Drop inflow wrap at L294**
|
||||
|
||||
Read lines 285–300 first.
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD (line 294)
|
||||
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(EnginePeer(peer)), purposefulAction: { [weak self] in
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), purposefulAction: { [weak self] in
|
||||
```
|
||||
|
||||
`peer` here is destructured from the ContactListPeer.peer case at line 287; post-migration it is already `EnginePeer`. `chatLocation: .peer(EnginePeer)` case takes `EnginePeer`.
|
||||
|
||||
- [ ] **Step 3.2: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -nE "chatLocation: \.peer\(EnginePeer\(peer\)\)" submodules/ContactListUI/Sources/ContactsController.swift
|
||||
```
|
||||
|
||||
Expected: zero matches.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Edit `ContactsSearchContainerNode.swift` — 3 outflow + 4 inflow
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift`
|
||||
|
||||
- [ ] **Step 4.1: Drop the 3 outflow `._asPeer()` bridges at L494/535/569**
|
||||
|
||||
Use the same `._asPeer(), isGlobal:` pattern as Task 2.1. The 3 bridges at `ContactListPeer.peer(...)` constructions all match this substring; the 3 unrelated bridges at L488/528/562 (`canSendMessagesToPeer(...)` sites) do NOT match (they lack the `, isGlobal:` suffix).
|
||||
|
||||
Pre-flight verify:
|
||||
|
||||
```bash
|
||||
grep -cE "\._asPeer\(\), isGlobal:" submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift
|
||||
```
|
||||
|
||||
Expected: `3`.
|
||||
|
||||
Apply Edit with `replace_all=true`:
|
||||
- `old_string`: `._asPeer(), isGlobal:`
|
||||
- `new_string`: `, isGlobal:`
|
||||
|
||||
- [ ] **Step 4.2: Drop 4 inflow wraps at L164/165/181**
|
||||
|
||||
Read lines 160–185 first.
|
||||
|
||||
Three edits, each targeting one source line.
|
||||
|
||||
Edit (line 164 — 2 wraps in one expression):
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
peerItem = .peer(peer: EnginePeer(peer), chatPeer: EnginePeer(peer))
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
peerItem = .peer(peer: peer, chatPeer: peer)
|
||||
```
|
||||
|
||||
Edit (line 165):
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
nativePeer = EnginePeer(peer)
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
nativePeer = peer
|
||||
```
|
||||
|
||||
Edit (line 181):
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
openDisabledPeer(EnginePeer(peer), requiresPremiumForMessaging ? .premiumRequired : .generic)
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
openDisabledPeer(peer, requiresPremiumForMessaging ? .premiumRequired : .generic)
|
||||
```
|
||||
|
||||
- [ ] **Step 4.3: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -nE "\._asPeer\(\), isGlobal:|EnginePeer\(peer\)" submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift
|
||||
```
|
||||
|
||||
Expected: zero matches.
|
||||
|
||||
The `._asPeer()` calls at L488/528/562 (feeding `canSendMessagesToPeer`) should remain. Verify:
|
||||
|
||||
```bash
|
||||
grep -nE "canSendMessagesToPeer\(.*\._asPeer\(\)\)" submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift
|
||||
```
|
||||
|
||||
Expected: 3 matches (L488, L528, L562).
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Edit `TelegramUI/Sources/ContactMultiselectionController.swift` — 2 outflow + 4 inflow
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Sources/ContactMultiselectionController.swift`
|
||||
|
||||
- [ ] **Step 5.1: Drop 2 outflow bridges at L451/459 via `replace_all`**
|
||||
|
||||
Pre-flight verify:
|
||||
|
||||
```bash
|
||||
grep -cE "\._asPeer\(\), isGlobal:" submodules/TelegramUI/Sources/ContactMultiselectionController.swift
|
||||
```
|
||||
|
||||
Expected: `2`.
|
||||
|
||||
Apply Edit with `replace_all=true`:
|
||||
- `old_string`: `._asPeer(), isGlobal:`
|
||||
- `new_string`: `, isGlobal:`
|
||||
|
||||
Unrelated `._asPeer()` calls at L171/201/748 (feeding `peerTokenTitle(peer: Peer, ...)`) do NOT use this substring and stay.
|
||||
|
||||
- [ ] **Step 5.2: Drop 4 inflow wraps at L386/403/481/491**
|
||||
|
||||
Read the file around each site to confirm exact text. Two wraps (L386, L403) have identical text; the other two (L481, L491) have distinct tails.
|
||||
|
||||
Edit for L386 and L403 — `replace_all=true` on the substring:
|
||||
|
||||
Pre-flight verify:
|
||||
|
||||
```bash
|
||||
grep -cE "subject: \.peer\(EnginePeer\(peer\)\)" submodules/TelegramUI/Sources/ContactMultiselectionController.swift
|
||||
```
|
||||
|
||||
Expected: `2`.
|
||||
|
||||
Apply Edit with `replace_all=true`:
|
||||
- `old_string`: `subject: .peer(EnginePeer(peer))`
|
||||
- `new_string`: `subject: .peer(peer)`
|
||||
|
||||
Edit for L481:
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
self.params.sendMessage?(EnginePeer(peer))
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
self.params.sendMessage?(peer)
|
||||
```
|
||||
|
||||
Edit for L491:
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
self.params.openProfile?(EnginePeer(peer))
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
self.params.openProfile?(peer)
|
||||
```
|
||||
|
||||
- [ ] **Step 5.3: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -nE "\._asPeer\(\), isGlobal:|subject: \.peer\(EnginePeer\(peer\)\)|sendMessage\?\(EnginePeer\(peer\)\)|openProfile\?\(EnginePeer\(peer\)\)" submodules/TelegramUI/Sources/ContactMultiselectionController.swift
|
||||
```
|
||||
|
||||
Expected: zero matches.
|
||||
|
||||
Preserved bridge sites (sanity check):
|
||||
|
||||
```bash
|
||||
grep -nE "peerTokenTitle\(.*\._asPeer\(\)" submodules/TelegramUI/Sources/ContactMultiselectionController.swift
|
||||
```
|
||||
|
||||
Expected: 3 matches (L171, L201, L748).
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Edit `TelegramUI/Sources/ContactMultiselectionControllerNode.swift` — 1 outflow + 1 inflow
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift`
|
||||
|
||||
- [ ] **Step 6.1: Drop 1 outflow bridge at L317**
|
||||
|
||||
Read lines 315–320 first.
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD (line 317)
|
||||
self?.openPeer?(.peer(peer: peer._asPeer(), isGlobal: false, participantCount: nil))
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
self?.openPeer?(.peer(peer: peer, isGlobal: false, participantCount: nil))
|
||||
```
|
||||
|
||||
- [ ] **Step 6.2: Drop 1 inflow wrap at L492**
|
||||
|
||||
Read lines 488–495 first.
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD (line 492)
|
||||
callTitle = self.presentationData.strings.NewCall_ActionCallSingle(EnginePeer(peer).compactDisplayTitle).string
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
callTitle = self.presentationData.strings.NewCall_ActionCallSingle(peer.compactDisplayTitle).string
|
||||
```
|
||||
|
||||
- [ ] **Step 6.3: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -nE "\._asPeer\(\), isGlobal:|EnginePeer\(peer\)\.compactDisplayTitle" submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift
|
||||
```
|
||||
|
||||
Expected: zero matches.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Edit `TelegramUI/Sources/ContactSelectionController.swift` — 2 inflow wraps
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Sources/ContactSelectionController.swift`
|
||||
|
||||
- [ ] **Step 7.1: Drop 2 inflow wraps at L517/527**
|
||||
|
||||
Read lines 510–535 first. Both sites are inside the destructure at L504.
|
||||
|
||||
Edit for L517:
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
self.sendMessage?(EnginePeer(peer))
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
self.sendMessage?(peer)
|
||||
```
|
||||
|
||||
Edit for L527:
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
self.openProfile?(EnginePeer(peer))
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
self.openProfile?(peer)
|
||||
```
|
||||
|
||||
- [ ] **Step 7.2: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -nE "sendMessage\?\(EnginePeer\(peer\)\)|openProfile\?\(EnginePeer\(peer\)\)" submodules/TelegramUI/Sources/ContactSelectionController.swift
|
||||
```
|
||||
|
||||
Expected: zero matches.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Edit `TelegramUI/Sources/ContactSelectionControllerNode.swift` — 2 outflow bridges
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift`
|
||||
|
||||
- [ ] **Step 8.1: Drop 2 outflow bridges at L160/230 via `replace_all`**
|
||||
|
||||
Pre-flight verify:
|
||||
|
||||
```bash
|
||||
grep -cE "\._asPeer\(\), isGlobal:" submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift
|
||||
```
|
||||
|
||||
Expected: `2`.
|
||||
|
||||
Apply Edit with `replace_all=true`:
|
||||
- `old_string`: `._asPeer(), isGlobal:`
|
||||
- `new_string`: `, isGlobal:`
|
||||
|
||||
- [ ] **Step 8.2: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -nE "\._asPeer\(\), isGlobal:" submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift
|
||||
```
|
||||
|
||||
Expected: zero matches.
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Verify no-edit consumer files
|
||||
|
||||
**Files (read only):**
|
||||
- Read: `submodules/TelegramUI/Sources/ComposeController.swift`
|
||||
- Read: `submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift`
|
||||
|
||||
- [ ] **Step 9.1: Confirm ComposeController.swift has no inflow wraps, casts, or outflow bridges**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -nE "\.peer\(peer:|EnginePeer\(peer\)|peer as\? Telegram|\._asPeer\(\)" submodules/TelegramUI/Sources/ComposeController.swift
|
||||
```
|
||||
|
||||
Expected: zero matches (destructures at L120/160 only access `.id`).
|
||||
|
||||
If any match appears, add the appropriate fix step here and re-run Task 9.1 before proceeding.
|
||||
|
||||
- [ ] **Step 9.2: Confirm ChatSendAudioMessageContextPreview.swift has no ContactListPeer.peer destructures**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -nE "case let \.peer\(peer, _, _\)|case \.peer\(let peer|EnginePeer\(peer\)|\.peer\(peer: " submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift
|
||||
```
|
||||
|
||||
Expected: zero matches. The file only references `[ContactListPeer]` at the collection level.
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Build verification (first pass)
|
||||
|
||||
- [ ] **Step 10.1: Run the full build with `--continueOnError`**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null && python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError 2>&1 | tee /tmp/wave36-build.log
|
||||
```
|
||||
|
||||
Expected outcome: ideally clean. Realistic: 0–3 inventory-missed sites (wave 35 trend was 14% miss rate on a 7-file wave; this 8-file wave has a larger surface area, so budget for up to 3 misses).
|
||||
|
||||
- [ ] **Step 10.2: Triage build errors**
|
||||
|
||||
Likely patterns and fixes:
|
||||
|
||||
| Error | Fix |
|
||||
|---|---|
|
||||
| `cannot convert value of type 'EnginePeer' to expected argument type 'Peer'` at a call site | Add `._asPeer()` bridge. The callee takes raw `Peer` and is out of wave scope. |
|
||||
| `cannot convert value of type 'Peer' to expected argument type 'EnginePeer'` at a `.peer(peer:, ...)` construction | Wrap raw peer with `EnginePeer(...)`. The raw-Peer source is probably from `transaction.getPeer(...)` or similar. |
|
||||
| `value of type 'EnginePeer' has no member 'isEqual'` | Replace with `==`. |
|
||||
| `type 'EnginePeer' cannot be cast to 'TelegramUser'` / `TelegramGroup` / `TelegramChannel` | Missed φ-category cast — rewrite to `case .user = peer` / `case let .legacyGroup(x) = peer` / `case let .channel(x) = peer`. |
|
||||
| `cannot invoke initializer for type 'EnginePeer' with an argument list of type '(EnginePeer)'` | Missed inflow drop — strip `EnginePeer(...)` wrap. |
|
||||
| `cannot convert value of type 'EnginePeer.IndexName' to expected argument type 'PeerIndexNameRepresentation'` | Either wrap the call site's expected-type change or adjust the consumer to accept `EnginePeer.IndexName`. Probably rare — ContactListPeer.indexName consumers were grepped in pre-flight and found only in ContactListNode. |
|
||||
| `value of type 'EnginePeer' has no member '<postbox-Peer-only method>'` | That method is only on the Postbox `Peer` protocol. Bridge via `._asPeer()` OR find the EnginePeer-native equivalent. |
|
||||
|
||||
For each error: identify file:line, apply the fix, re-run the build until clean.
|
||||
|
||||
- [ ] **Step 10.3: Iterate to clean build**
|
||||
|
||||
Re-run the build after each batch of fixes. The wave is complete when the build returns 0 errors for the targeted configuration.
|
||||
|
||||
If 10+ unexpected errors surface, halt and reassess: the inventory may have significantly undercounted and the wave may need to be split. Discuss with the user before continuing.
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Post-build grep validations
|
||||
|
||||
- [ ] **Step 11.1: Outflow-bridge-drop validation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -rnE "\.peer\(peer: \w+\._asPeer\(\), isGlobal:" submodules/ --include="*.swift"
|
||||
```
|
||||
|
||||
Expected: zero hits. Any remaining site is a missed outflow-bridge drop.
|
||||
|
||||
- [ ] **Step 11.2: Inflow-wrap-drop validation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
for f in submodules/ContactListUI/Sources/ContactListNode.swift \
|
||||
submodules/ContactListUI/Sources/ContactsController.swift \
|
||||
submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift \
|
||||
submodules/TelegramUI/Sources/ContactMultiselectionController.swift \
|
||||
submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift \
|
||||
submodules/TelegramUI/Sources/ContactSelectionController.swift; do
|
||||
echo "=== $f ==="
|
||||
grep -nE "EnginePeer\(peer\)" "$f"
|
||||
done
|
||||
```
|
||||
|
||||
Expected hits:
|
||||
- ContactListNode.swift L1819, L1825 (raw `renderedPeer.peer`, out-of-scope wraps stay)
|
||||
- Any other hit in the 6 listed files is a missed inflow drop — inspect and fix.
|
||||
|
||||
- [ ] **Step 11.3: Cast-rewrite validation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -nE "\bpeer (as\?|as!|is) Telegram(User|Group|Channel)\b" submodules/ContactListUI/Sources/ContactListNode.swift
|
||||
```
|
||||
|
||||
Expected: only L1802, L1818, L1820 remain (out-of-scope, `peer` is raw from `renderedPeer.peer`).
|
||||
|
||||
If L182, L184, L186, or L1968 appear, those are missed φ rewrites.
|
||||
|
||||
- [ ] **Step 11.4: IndexName wrap validation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -nE "EnginePeer\.IndexName\(lhs\.indexName\)|EnginePeer\.IndexName\(rhs\.indexName\)" submodules/ContactListUI/Sources/ContactListNode.swift
|
||||
```
|
||||
|
||||
Expected: zero matches.
|
||||
|
||||
- [ ] **Step 11.5: isEqual-in-==-operator validation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -nE "lhsPeer\.isEqual\(rhsPeer\)" submodules/AccountContext/Sources/ContactSelectionController.swift
|
||||
```
|
||||
|
||||
Expected: zero matches.
|
||||
|
||||
- [ ] **Step 11.6: Construction-site sanity sweep**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -rnE "ContactListPeer\.peer\(peer: |\.peer\(peer: \w+, isGlobal:" submodules/ --include="*.swift" | head -40
|
||||
```
|
||||
|
||||
Inspect each hit. Expected forms:
|
||||
- `.peer(peer: <EnginePeer-expr>, isGlobal: …)` where `<EnginePeer-expr>` is either a local already typed `EnginePeer` or `EnginePeer(<raw-Peer>)`.
|
||||
- Anything of the form `.peer(peer: <raw-Peer>, isGlobal: …)` where `<raw-Peer>` is a Postbox `Peer` value is a miss (would surface as a build error — this is a belt-and-suspenders check).
|
||||
|
||||
If any validation fails, return to Task 10.
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Atomic commit + memory + log update
|
||||
|
||||
- [ ] **Step 12.1: Stage and review**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff --stat
|
||||
```
|
||||
|
||||
Confirm exactly 8 modified Swift files:
|
||||
- `submodules/AccountContext/Sources/ContactSelectionController.swift`
|
||||
- `submodules/ContactListUI/Sources/ContactListNode.swift`
|
||||
- `submodules/ContactListUI/Sources/ContactsController.swift`
|
||||
- `submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift`
|
||||
- `submodules/TelegramUI/Sources/ContactMultiselectionController.swift`
|
||||
- `submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift`
|
||||
- `submodules/TelegramUI/Sources/ContactSelectionController.swift`
|
||||
- `submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift`
|
||||
|
||||
Pre-existing WIP (`build-system/bazel-rules/sourcekit-bazel-bsp`, `ChatListFilterPresetController.swift`, `ChatListFilterPresetListController.swift`, untracked `build-system/tulsi/` / `submodules/TgVoip/` / `third-party/libx264/` / `docs/superpowers/plans/2026-04-22-claude-md-reorganization.md`) should NOT be staged.
|
||||
|
||||
- [ ] **Step 12.2: Stage only the wave-36 files**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add submodules/AccountContext/Sources/ContactSelectionController.swift \
|
||||
submodules/ContactListUI/Sources/ContactListNode.swift \
|
||||
submodules/ContactListUI/Sources/ContactsController.swift \
|
||||
submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift \
|
||||
submodules/TelegramUI/Sources/ContactMultiselectionController.swift \
|
||||
submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift \
|
||||
submodules/TelegramUI/Sources/ContactSelectionController.swift \
|
||||
submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift
|
||||
```
|
||||
|
||||
If Task 10 introduced additional files (inventory-miss fixes), append them.
|
||||
|
||||
- [ ] **Step 12.3: Commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Postbox -> TelegramEngine wave 36: ContactListPeer.peer Peer -> EnginePeer
|
||||
|
||||
Migrates the public enum case `ContactListPeer.peer(peer: Peer, isGlobal:,
|
||||
participantCount:)` from the Postbox `Peer` protocol to the TelegramCore
|
||||
`EnginePeer` enum. Also cascades `ContactListPeer.indexName` return type
|
||||
from `PeerIndexNameRepresentation` to `EnginePeer.IndexName` and rewrites
|
||||
the enum's custom `==` operator to use EnginePeer's synthesized Equatable.
|
||||
|
||||
Consumer-side cascade in 7 files:
|
||||
- 20 `._asPeer()` outflow bridge-drops at ContactListPeer.peer
|
||||
construction sites (the payload is now EnginePeer)
|
||||
- 16 `EnginePeer(peer)` inflow wrap-drops at destructure sites (the
|
||||
destructured `peer` is already EnginePeer)
|
||||
- 2 `EnginePeer.IndexName(...)` wrap-drops at a sort-comparator (the
|
||||
indexName property now returns EnginePeer.IndexName directly)
|
||||
- 2 Postbox-concrete cast chains rewritten to EnginePeer case patterns
|
||||
(`peer as? TelegramUser` → `case .user = peer`, etc.)
|
||||
- `lhsPeer.isEqual(rhsPeer)` → `lhsPeer == rhsPeer` in the ==operator
|
||||
|
||||
Files modified:
|
||||
submodules/AccountContext/Sources/ContactSelectionController.swift
|
||||
submodules/ContactListUI/Sources/ContactListNode.swift
|
||||
submodules/ContactListUI/Sources/ContactsController.swift
|
||||
submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift
|
||||
submodules/TelegramUI/Sources/ContactMultiselectionController.swift
|
||||
submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift
|
||||
submodules/TelegramUI/Sources/ContactSelectionController.swift
|
||||
submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift
|
||||
|
||||
Bridges intentionally retained (out-of-wave scope):
|
||||
- `canSendMessagesToPeer(peer._asPeer())` — callee takes Peer, deferred
|
||||
- `peerTokenTitle(peer: peer._asPeer(), ...)` — callee takes Peer,
|
||||
deferred
|
||||
|
||||
Plan: docs/superpowers/plans/2026-04-24-contactlistpeer-engine-peer-migration.md
|
||||
Spec: docs/superpowers/specs/2026-04-24-contactlistpeer-engine-peer-migration-design.md
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 12.4: Update CLAUDE.md wave counter**
|
||||
|
||||
Edit `CLAUDE.md` to bump the "Waves landed so far" line from "35 waves" to "36 waves" and update the "as of" date if the commit lands after 2026-04-24.
|
||||
|
||||
- [ ] **Step 12.5: Append wave outcome to the postbox-refactor-log**
|
||||
|
||||
Append a "Wave 36 outcome" section to `docs/superpowers/postbox-refactor-log.md` documenting:
|
||||
- Actual files touched + edit counts vs. plan
|
||||
- Any inventory undercounts surfaced by Task 10 (file:line + missed-category)
|
||||
- Any lessons learned (e.g., whether the γ category really had zero sites; how the φ cast-rewrites behaved; post-migration undercount percentage vs wave 35's 14%)
|
||||
- Ratio of bridge-drops to bridge-additions (wave theme: removal-dominated)
|
||||
|
||||
Keep concise.
|
||||
|
||||
- [ ] **Step 12.6: Commit the docs update**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md docs/superpowers/postbox-refactor-log.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs: add wave 36 outcome (ContactListPeer.peer Peer→EnginePeer)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 12.7: Update the next-wave memory**
|
||||
|
||||
Edit `/Users/isaac/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`:
|
||||
- Add wave 36 to the "Latest commits" section.
|
||||
- Move ContactListPeer migration from "Recommended wave 36 candidates" to landed.
|
||||
- Record the inventory undercount ratio (actual-files-touched ÷ pre-flight-file-count) for calibration.
|
||||
- Update the "Recommended wave 37" section. Promote candidates: `canSendMessagesToPeer(_:)` parameter (the ContactsSearchContainerNode `._asPeer()` bridges at L488/528/562 plus others elsewhere drop when this lands); `peerTokenTitle(peer:)` parameter (drops 3 bridges in ContactMultiselectionController); `makePeerInfoController` / `makeChatQrCodeScreen` / `makeChatRecentActionsController` AccountContext protocol methods (largest remaining Peer-typed-API); accountManager engine path; Shape-C `resourceData` module.
|
||||
|
||||
Use the Edit tool on the memory file. No git commit needed.
|
||||
|
||||
---
|
||||
|
||||
## Risks and notes
|
||||
|
||||
- **Inventory undercount.** Pre-flight caught several sites the Explore agent missed (inflow wraps at L481/491/517/527/492/844, cast rewrites at L182-186 and L1968). Budget for 1–3 additional misses surfacing in Task 10. If the build surfaces 5+ misses in new categories, stop and reassess.
|
||||
- **`replace_all` usage.** Every `replace_all=true` Edit in this plan is gated by a pre-flight `grep -c` count check. If the count is wrong, fall back to per-site Edits with surrounding context.
|
||||
- **Cast rewrite at L182-186.** The original cast chain binds `group` and `channel` (but not `user`). The EnginePeer case-pattern form preserves this: `case .user = peer` is a binding-free match, mirroring `if let _ = peer as? TelegramUser`.
|
||||
- **`._asPeer()` sites that stay.** Tasks 4.3 and 5.3 explicitly verify that the 3 `canSendMessagesToPeer(peer._asPeer())` bridges and 3 `peerTokenTitle(peer: peer._asPeer(), ...)` bridges remain intact. Dropping these would be out-of-scope migration.
|
||||
- **WIP isolation.** Pre-existing `ChatListFilterPresetController.swift` / `ChatListFilterPresetListController.swift` edits and untracked `build-system/tulsi/`, `submodules/TgVoip/`, `third-party/libx264/` paths are user WIP — do NOT stage them. Use the explicit `git add <files>` form in Step 12.2.
|
||||
- **Scope boundary.** Task 10 errors surfacing in `TelegramCore`, `Postbox`, or `TelegramApi` mean the migration cascaded beyond its intended consumer scope. Halt and investigate — do NOT edit TelegramCore in this wave.
|
||||
- **No new typealiases/wrappers.** Rule 2 and 3 of the Postbox refactor guidance apply — this wave stays inside.
|
||||
1287
docs/superpowers/plans/2026-04-24-foundpeer-engine-peer-migration.md
Normal file
1287
docs/superpowers/plans/2026-04-24-foundpeer-engine-peer-migration.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,411 @@
|
|||
# Wave 40 — `makeChatQrCodeScreen` + `makeChatRecentActionsController` 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:** Bundle migrate two sibling `AccountContext` methods deferred from wave 39 — `makeChatQrCodeScreen` (4 consumer sites) and `makeChatRecentActionsController` (3 consumer sites) — from raw `peer: Peer` to `peer: EnginePeer`, applying the body-shadow pattern.
|
||||
|
||||
**Architecture:** Body-shadow pattern (wave-38/39 style). Protocol + impl signatures change to `peer: EnginePeer`; each impl body gets a `let peer = peer._asPeer()` shadow so the downstream constructors (`ChatQrCodeScreenImpl`, `ChatRecentActionsController`) remain raw-`Peer` consumers (out of scope).
|
||||
|
||||
**Tech Stack:** Swift, Bazel, iOS; TelegramCore / AccountContext / TelegramUI / PeerInfoUI / StatisticsUI / SettingsUI / ContactListUI / PeerInfoScreen submodules.
|
||||
|
||||
**Reference:** Wave-39 "Out of scope" section in `docs/superpowers/specs/2026-04-24-makePeerInfoController-engine-peer-migration-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight classification
|
||||
|
||||
**`makeChatQrCodeScreen` (4 consumer sites):**
|
||||
|
||||
| # | Site | Shape | Edit |
|
||||
|---|---|---|---|
|
||||
| 1 | `SettingsSearchableItems.swift:974` | **Shape-A-variant** | Rewrite upstream `guard let peer = peer?._asPeer() else { return }` (line 971) → `guard let peer = peer else { return }`. Call stays `peer: peer`. |
|
||||
| 2 | `SettingsSearchableItems.swift:992` | **Shape-A-variant** | Same pattern as #1 (upstream guard at line 989). |
|
||||
| 3 | `ContactsController.swift:478` | **Shape-A** | Drop `._asPeer()` from `peer: peer._asPeer()` → `peer: peer`. Source: `Signal<EnginePeer, NoError>`. |
|
||||
| 4 | `PeerInfoScreen.swift:4623` | **Shape-C** | Wrap: `peer: peer` → `peer: EnginePeer(peer)`. Source: `data.peer: Peer?`. |
|
||||
|
||||
**`makeChatRecentActionsController` (3 consumer sites):**
|
||||
|
||||
| # | Site | Shape | Edit |
|
||||
|---|---|---|---|
|
||||
| 5 | `ChannelAdminsController.swift:734` | **Shape-A** | Drop `._asPeer()`. Source: `engine.data.get(Peer.Peer(id:))` — `peer` is `EnginePeer` in the `guard let peer` on line 729. |
|
||||
| 6 | `GroupStatsController.swift:915` | **Shape-A** | Drop `._asPeer()`. Source: `Signal<EnginePeer, NoError>` (mapToSignal at 906). |
|
||||
| 7 | `PeerInfoScreenOpenChat.swift:115` | **Shape-C** | Wrap: `peer: peer` → `peer: EnginePeer(peer)`. Source: `self.data?.peer: Peer?`. |
|
||||
|
||||
**Net bridge delta:** −5 `_asPeer()` drops (sites 1, 2, 3, 5, 6) + 2 `EnginePeer(...)` wraps (sites 4, 7) = **−3 net**. Sites 4 and 7 become ratchet markers for a future `PeerInfoScreenData.peer Peer → EnginePeer` wave.
|
||||
|
||||
---
|
||||
|
||||
## File touch summary
|
||||
|
||||
8 files:
|
||||
|
||||
1. `submodules/AccountContext/Sources/AccountContext.swift` — protocol decls (2 lines).
|
||||
2. `submodules/TelegramUI/Sources/SharedAccountContext.swift` — impl signatures + body shadows (2 sites).
|
||||
3. `submodules/SettingsUI/Sources/Search/SettingsSearchableItems.swift` — 2 Shape-A-variant upstream guard rewrites.
|
||||
4. `submodules/ContactListUI/Sources/ContactsController.swift` — 1 Shape-A drop.
|
||||
5. `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift` — 1 Shape-C wrap.
|
||||
6. `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift` — 1 Shape-A drop.
|
||||
7. `submodules/StatisticsUI/Sources/GroupStatsController.swift` — 1 Shape-A drop.
|
||||
8. `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenOpenChat.swift` — 1 Shape-C wrap.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Update `AccountContext` protocol signatures
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/AccountContext/Sources/AccountContext.swift:1401` and `:1461`
|
||||
|
||||
- [ ] **Step 1: Update `makeChatRecentActionsController` decl**
|
||||
|
||||
```swift
|
||||
// old_string
|
||||
func makeChatRecentActionsController(context: AccountContext, peer: Peer, adminPeerId: PeerId?, starsState: StarsRevenueStats?) -> ViewController
|
||||
|
||||
// new_string
|
||||
func makeChatRecentActionsController(context: AccountContext, peer: EnginePeer, adminPeerId: PeerId?, starsState: StarsRevenueStats?) -> ViewController
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `makeChatQrCodeScreen` decl**
|
||||
|
||||
```swift
|
||||
// old_string
|
||||
func makeChatQrCodeScreen(context: AccountContext, peer: Peer, threadId: Int64?, temporary: Bool) -> ViewController
|
||||
|
||||
// new_string
|
||||
func makeChatQrCodeScreen(context: AccountContext, peer: EnginePeer, threadId: Int64?, temporary: Bool) -> ViewController
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update `SharedAccountContext` impls with body-shadow
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Sources/SharedAccountContext.swift:2302` (makeChatRecentActionsController)
|
||||
- Modify: `submodules/TelegramUI/Sources/SharedAccountContext.swift:2730` (makeChatQrCodeScreen)
|
||||
|
||||
- [ ] **Step 1: Update `makeChatRecentActionsController` impl**
|
||||
|
||||
```swift
|
||||
// old_string
|
||||
public func makeChatRecentActionsController(context: AccountContext, peer: Peer, adminPeerId: PeerId?, starsState: StarsRevenueStats?) -> ViewController {
|
||||
return ChatRecentActionsController(context: context, peer: peer, adminPeerId: adminPeerId, starsState: starsState)
|
||||
}
|
||||
|
||||
// new_string
|
||||
public func makeChatRecentActionsController(context: AccountContext, peer: EnginePeer, adminPeerId: PeerId?, starsState: StarsRevenueStats?) -> ViewController {
|
||||
let peer = peer._asPeer()
|
||||
return ChatRecentActionsController(context: context, peer: peer, adminPeerId: adminPeerId, starsState: starsState)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `makeChatQrCodeScreen` impl**
|
||||
|
||||
```swift
|
||||
// old_string
|
||||
public func makeChatQrCodeScreen(context: AccountContext, peer: Peer, threadId: Int64?, temporary: Bool) -> ViewController {
|
||||
return ChatQrCodeScreenImpl(context: context, subject: .peer(peer: peer, threadId: threadId, temporary: temporary))
|
||||
}
|
||||
|
||||
// new_string
|
||||
public func makeChatQrCodeScreen(context: AccountContext, peer: EnginePeer, threadId: Int64?, temporary: Bool) -> ViewController {
|
||||
let peer = peer._asPeer()
|
||||
return ChatQrCodeScreenImpl(context: context, subject: .peer(peer: peer, threadId: threadId, temporary: temporary))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `SettingsSearchableItems.swift` — two Shape-A-variant guard rewrites
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/SettingsUI/Sources/Search/SettingsSearchableItems.swift:971` and `:989`
|
||||
|
||||
Both sites share the same structure: an upstream `guard let peer = peer?._asPeer() else { return }` unwraps `EnginePeer?` to `Peer`. Rewrite the guard to keep the local as `EnginePeer`; the call site below stays unchanged.
|
||||
|
||||
- [ ] **Step 1: Rewrite guard at line 971 (qr-code item)**
|
||||
|
||||
```swift
|
||||
// old_string
|
||||
present: { context, _, present in
|
||||
let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))
|
||||
|> deliverOnMainQueue).start(next: { peer in
|
||||
guard let peer = peer?._asPeer() else {
|
||||
return
|
||||
}
|
||||
let controller = context.sharedContext.makeChatQrCodeScreen(context: context, peer: peer, threadId: nil, temporary: false)
|
||||
present(.push, controller)
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
//TODO:fix
|
||||
items.append(
|
||||
SettingsSearchableItem(
|
||||
id: "qr-code/share",
|
||||
|
||||
// new_string
|
||||
present: { context, _, present in
|
||||
let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))
|
||||
|> deliverOnMainQueue).start(next: { peer in
|
||||
guard let peer = peer else {
|
||||
return
|
||||
}
|
||||
let controller = context.sharedContext.makeChatQrCodeScreen(context: context, peer: peer, threadId: nil, temporary: false)
|
||||
present(.push, controller)
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
//TODO:fix
|
||||
items.append(
|
||||
SettingsSearchableItem(
|
||||
id: "qr-code/share",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rewrite guard at line 989 (qr-code/share item)**
|
||||
|
||||
```swift
|
||||
// old_string
|
||||
id: "qr-code/share",
|
||||
isVisible: false,
|
||||
present: { context, _, present in
|
||||
let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))
|
||||
|> deliverOnMainQueue).start(next: { peer in
|
||||
guard let peer = peer?._asPeer() else {
|
||||
return
|
||||
}
|
||||
let controller = context.sharedContext.makeChatQrCodeScreen(context: context, peer: peer, threadId: nil, temporary: false)
|
||||
present(.push, controller)
|
||||
})
|
||||
}
|
||||
|
||||
// new_string
|
||||
id: "qr-code/share",
|
||||
isVisible: false,
|
||||
present: { context, _, present in
|
||||
let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))
|
||||
|> deliverOnMainQueue).start(next: { peer in
|
||||
guard let peer = peer else {
|
||||
return
|
||||
}
|
||||
let controller = context.sharedContext.makeChatQrCodeScreen(context: context, peer: peer, threadId: nil, temporary: false)
|
||||
present(.push, controller)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `ContactsController.swift` — Shape-A drop
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/ContactListUI/Sources/ContactsController.swift:478`
|
||||
|
||||
- [ ] **Step 1: Drop `._asPeer()` at the call site**
|
||||
|
||||
```swift
|
||||
// old_string
|
||||
controller.present(strongSelf.context.sharedContext.makeChatQrCodeScreen(context: strongSelf.context, peer: peer._asPeer(), threadId: nil, temporary: false), in: .window(.root))
|
||||
|
||||
// new_string
|
||||
controller.present(strongSelf.context.sharedContext.makeChatQrCodeScreen(context: strongSelf.context, peer: peer, threadId: nil, temporary: false), in: .window(.root))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `PeerInfoScreen.swift` — Shape-C wrap
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift:4623`
|
||||
|
||||
Local `peer` comes from `data.peer` (type `Peer`). Wrap at the call site.
|
||||
|
||||
- [ ] **Step 1: Wrap with `EnginePeer(...)`**
|
||||
|
||||
```swift
|
||||
// old_string
|
||||
let qrController = self.context.sharedContext.makeChatQrCodeScreen(context: self.context, peer: peer, threadId: threadId, temporary: temporary)
|
||||
|
||||
// new_string
|
||||
let qrController = self.context.sharedContext.makeChatQrCodeScreen(context: self.context, peer: EnginePeer(peer), threadId: threadId, temporary: temporary)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: `ChannelAdminsController.swift` — Shape-A drop
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift:734`
|
||||
|
||||
Local `peer` is `EnginePeer` (from `guard let peer` on :729 unwrapping `EnginePeer?` from `engine.data.get(Peer.Peer(id:))`).
|
||||
|
||||
- [ ] **Step 1: Drop `._asPeer()`**
|
||||
|
||||
```swift
|
||||
// old_string
|
||||
pushControllerImpl?(context.sharedContext.makeChatRecentActionsController(context: context, peer: peer._asPeer(), adminPeerId: nil, starsState: nil))
|
||||
|
||||
// new_string
|
||||
pushControllerImpl?(context.sharedContext.makeChatRecentActionsController(context: context, peer: peer, adminPeerId: nil, starsState: nil))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: `GroupStatsController.swift` — Shape-A drop
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/StatisticsUI/Sources/GroupStatsController.swift:915`
|
||||
|
||||
Local `peer` is `EnginePeer` (from `Signal<EnginePeer, NoError>` via the `mapToSignal` at :906).
|
||||
|
||||
- [ ] **Step 1: Drop `._asPeer()`**
|
||||
|
||||
```swift
|
||||
// old_string
|
||||
let controller = context.sharedContext.makeChatRecentActionsController(context: context, peer: peer._asPeer(), adminPeerId: participantPeerId, starsState: nil)
|
||||
|
||||
// new_string
|
||||
let controller = context.sharedContext.makeChatRecentActionsController(context: context, peer: peer, adminPeerId: participantPeerId, starsState: nil)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: `PeerInfoScreenOpenChat.swift` — Shape-C wrap
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenOpenChat.swift:115`
|
||||
|
||||
Local `peer` comes from `self.data?.peer` (type `Peer`). Wrap at the call site.
|
||||
|
||||
- [ ] **Step 1: Wrap with `EnginePeer(...)`**
|
||||
|
||||
```swift
|
||||
// old_string
|
||||
let controller = self.context.sharedContext.makeChatRecentActionsController(context: self.context, peer: peer, adminPeerId: nil, starsState: self.data?.starsRevenueStatsState)
|
||||
|
||||
// new_string
|
||||
let controller = self.context.sharedContext.makeChatRecentActionsController(context: self.context, peer: EnginePeer(peer), adminPeerId: nil, starsState: self.data?.starsRevenueStatsState)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Build + iterate
|
||||
|
||||
- [ ] **Step 1: Run full build**
|
||||
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \
|
||||
--cacheDir ~/telegram-bazel-cache \
|
||||
build \
|
||||
--configurationPath build-system/appstore-configuration.json \
|
||||
--gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \
|
||||
--gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 \
|
||||
--continueOnError
|
||||
```
|
||||
|
||||
Expected: first-pass-clean (wave 39's 50-file wave landed first-pass-clean; this 8-file wave should too).
|
||||
|
||||
- [ ] **Step 2: If errors, iterate**
|
||||
|
||||
Each error should point at a call site the plan missed. Fix, re-run. Do not widen the scope — if a call site not in the classification table above appears as an error, investigate whether the memory/inventory was stale.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Verify no residue
|
||||
|
||||
- [ ] **Step 1: Grep for raw-`Peer` sites**
|
||||
|
||||
```bash
|
||||
grep -rn "makeChatQrCodeScreen\|makeChatRecentActionsController" --include="*.swift" submodules/
|
||||
```
|
||||
|
||||
Expected output: 2 protocol-decl lines (AccountContext.swift), 2 impl-decl lines (SharedAccountContext.swift), and exactly 7 consumer sites — all with `peer: peer`, `peer: EnginePeer(peer)`, or similar (no `peer: x._asPeer()` remaining for these two methods).
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Commit + update refactor log
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/postbox-refactor-log.md` — append wave-40 outcome section.
|
||||
|
||||
- [ ] **Step 1: Stage exactly these 8 files (enumerate, do not use `git add -u`)**
|
||||
|
||||
```bash
|
||||
git add \
|
||||
submodules/AccountContext/Sources/AccountContext.swift \
|
||||
submodules/TelegramUI/Sources/SharedAccountContext.swift \
|
||||
submodules/SettingsUI/Sources/Search/SettingsSearchableItems.swift \
|
||||
submodules/ContactListUI/Sources/ContactsController.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelAdminsController.swift \
|
||||
submodules/StatisticsUI/Sources/GroupStatsController.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenOpenChat.swift \
|
||||
docs/superpowers/plans/2026-04-24-makeChatQrCodeScreen-recentActions-engine-peer-migration.md
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify staging with `git status --short`**
|
||||
|
||||
Verify only the 9 files above are staged. If other files appear (e.g. `ChatMessageTransitionNode.swift` WIP, `sourcekit-bazel-bsp` submodule marker) — reset them out of the index with `git restore --staged <file>` and re-check.
|
||||
|
||||
- [ ] **Step 3: Commit (wave 40)**
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Postbox -> TelegramEngine wave 40
|
||||
|
||||
makeChatQrCodeScreen + makeChatRecentActionsController peer Peer->EnginePeer.
|
||||
|
||||
- AccountContext protocol: 2 decls updated
|
||||
- SharedAccountContext impls: 2 signatures + 2 body-shadow `let peer = peer._asPeer()`
|
||||
- 5 Shape-A `._asPeer()` drops (SettingsSearchableItems x2 guard-variant, ContactsController, ChannelAdminsController, GroupStatsController)
|
||||
- 2 Shape-C `EnginePeer(peer)` wraps (PeerInfoScreen, PeerInfoScreenOpenChat)
|
||||
- Net: -3 bridges
|
||||
|
||||
Sibling follow-up to wave 39 (makePeerInfoController). Pre-flight classification
|
||||
completed in wave-39 design doc's "Out of scope" section.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Log wave 40 outcome**
|
||||
|
||||
Append a new section to `docs/superpowers/postbox-refactor-log.md`:
|
||||
|
||||
```markdown
|
||||
## Wave 40 outcome
|
||||
|
||||
Commit: `<hash>`. Bundle of `AccountContext.makeChatQrCodeScreen` + `makeChatRecentActionsController` peer `Peer → EnginePeer`. 8 files / ~12 lines changed. Pre-flight classification from wave-39 design doc held: 5 Shape-A drops + 2 Shape-C wraps + 2 impl body-shadows + 2 protocol decls. Net −3 bridges. Build outcome: <first-pass-clean | N iterations>.
|
||||
|
||||
Sibling follow-up to wave 39 — completes the "Option 1 cluster" (makePeerInfoController family from wave-38 memory). Ratchet markers installed at PeerInfoScreen:4623 and PeerInfoScreenOpenChat:115 for a future `PeerInfoScreenData.peer Peer → EnginePeer` wave.
|
||||
```
|
||||
|
||||
Then commit the log update:
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/postbox-refactor-log.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs: log wave 40 outcome
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update memory**
|
||||
|
||||
Update `/Users/isaac/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`:
|
||||
- Move wave-40 (this bundle) from "candidates" to "Latest commits".
|
||||
- Bump wave-41 recommendation to RenderedChannelParticipant.peer (Option 3) or RenderedPeer (Option 2).
|
||||
- Add wave-40 lesson if any (e.g. "bundled sibling migration with shared pre-flight is cheap" or similar).
|
||||
|
||||
---
|
||||
|
||||
## Self-review checklist (writing-plans skill)
|
||||
|
||||
- **Spec coverage:** Every site from the memory/wave-39-doc pre-flight is a task. Sites 1+2 → Task 3; Site 3 → Task 4; Site 4 → Task 5; Site 5 → Task 6; Site 6 → Task 7; Site 7 → Task 8. Impl bodies → Task 2. Protocol → Task 1. Build → Task 9. Verify → Task 10. Commit+log → Task 11. ✓
|
||||
- **Placeholders:** None. Every Edit step has exact `old_string` / `new_string`. Commit message and log-update text are spelled out. ✓
|
||||
- **Type consistency:** Both methods take `peer: EnginePeer` everywhere — protocol decl, impl decl, and call sites' parameter passes. ✓
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,658 @@
|
|||
# Wave 43 plan: PeerInfoScreen helpers `peer: Peer?` → `peer: EnginePeer?`
|
||||
|
||||
> **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 six PeerInfoScreen module helpers (`canEditPeerInfo`, `availableActionsForMemberOfPeer`, `peerInfoHeaderActionButtons`, `peerInfoHeaderButtons`, `peerInfoCanEdit`, `peerInfoIsChatMuted`) from `peer: Peer?` to `peer: EnginePeer?`, rewriting internal `as?`/`is` against concrete `TelegramX` subclasses to `case let .x` / `case .x` enum patterns on `EnginePeer`, and updating all 21 call sites to drop wave-42-installed `._asPeer()` / `?._asPeer()` bridges or add `.flatMap(EnginePeer.init)` / `EnginePeer(...)` wraps as appropriate.
|
||||
|
||||
**Architecture:** In-place signature migration following wave-42 precedent — no new typealiases, no engine wrapper structs, no TelegramCore changes. All edits within `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/` (10 files). Single atomic commit.
|
||||
|
||||
**Tech Stack:** Swift, Bazel build, `EnginePeer` enum cases (`.user(TelegramUser)`, `.legacyGroup(TelegramGroup)`, `.channel(TelegramChannel)`, `.secretChat(TelegramSecretChat)`).
|
||||
|
||||
**Preceding waves:** 42 (`PeerInfoScreenData.peer: Peer? → EnginePeer?`) on 2026-04-24. This wave drops ~7 `?._asPeer()` / `._asPeer()` bridges installed then.
|
||||
|
||||
**Expected net wrap change:** ~7 DROPs vs ~12 ADDs → net roughly 0. The headline win is helper-signature migration, not wrap count. Follow-up waves migrating `PeerInfoHeaderEditingContentNode.update`, `PeerInfoEditingAvatarNode.update`, `PeerInfoEditingAvatarOverlayNode.update`, `PeerInfoHeaderNode.update`, `PeerInfoScreenMemberItem.enclosingPeer`, `PeerInfoMembersPane` enclosingPeer param will drop the ADDs introduced here.
|
||||
|
||||
**Build expectation:** 2 iterations likely (per wave-41 lesson — foundational-type migrations rarely first-pass-clean). Hedge for iteration-3 if unexpected property accesses surface.
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight facts (verified from repo 2026-04-24)
|
||||
|
||||
### EnginePeer cases (from `submodules/TelegramCore/Sources/TelegramEngine/Peers/Peer.swift:177`)
|
||||
|
||||
```
|
||||
case user(TelegramUser)
|
||||
case legacyGroup(TelegramGroup)
|
||||
case channel(TelegramChannel)
|
||||
case secretChat(TelegramSecretChat)
|
||||
```
|
||||
|
||||
Forwarded property subset on `EnginePeer` includes `id`, `addressName`, `usernames`, `indexName`, `debugDisplayTitle`, `displayLetters`, `profileImageRepresentations`, `smallProfileImage`, `largeProfileImage`, `isDeleted`, `isScam`, `isFake`, `isVerified`, `isPremium`, `isSubscription`, `isService`, `nameColor`, `verificationIconFileId`, `profileColor`, `effectiveProfileColor`, `emojiStatus`, `backgroundEmojiId`, `profileBackgroundEmojiId`, and (via `LocalizedPeerData/Sources/PeerTitle.swift`) `compactDisplayTitle`, `displayTitle(strings:displayOrder:)`. **Not** forwarded: Peer-specific members like `isCopyProtectionEnabled`, `hasPermission(_:)`, `hasBannedPermission(_:)`, `isDeleted` on user (WAIT: yes forwarded). **Internal helper bodies do not access any non-forwarded Peer members** — all their concrete-type work happens via `as? TelegramX`, which is enum-rewrite territory. Confirmed by reading helper bodies (PeerInfoData.swift:2255–2670).
|
||||
|
||||
### Helper call sites inventory (21 sites across 10 files)
|
||||
|
||||
Running command:
|
||||
|
||||
```bash
|
||||
grep -rn "\bcanEditPeerInfo\b\|\bavailableActionsForMemberOfPeer\b\|\bpeerInfoHeaderActionButtons\b\|\bpeerInfoHeaderButtons\b\|\bpeerInfoCanEdit\b\|\bpeerInfoIsChatMuted\b" submodules/ --include="*.swift" | grep -v PeerInfoData.swift
|
||||
```
|
||||
|
||||
All 21 call sites:
|
||||
|
||||
| # | File | Line | Current arg | Peer var origin | Action |
|
||||
|---|------|------|-------------|-----------------|--------|
|
||||
| 1 | `PeerInfoHeaderNode.swift` | 548 | `peer: peer` | method param `peer: Peer?` | ADD-WRAP: `peer: peer.flatMap(EnginePeer.init)` |
|
||||
| 2 | `PeerInfoHeaderNode.swift` | 549 | `peer: peer` | same | ADD-WRAP |
|
||||
| 3 | `PeerInfoHeaderNode.swift` | 2361 | `peer: peer` | same | ADD-WRAP |
|
||||
| 4 | `PeerInfoEditingAvatarNode.swift` | 66 | `peer: peer` | method param `peer: Peer?` (unwrapped via `guard let peer = peer`) | ADD-WRAP: `peer: EnginePeer(peer)` (peer is non-optional `Peer` here) |
|
||||
| 5 | `PeerInfoEditingAvatarOverlayNode.swift` | 85 | `peer: peer` | method param `peer: Peer?` (unwrapped via `guard let peer = peer`) | ADD-WRAP: `peer: EnginePeer(peer)` |
|
||||
| 6 | `PeerInfoHeaderEditingContentNode.swift` | 59 | `peer: peer` | method param `peer: Peer?` | ADD-WRAP: `peer: peer.flatMap(EnginePeer.init)` |
|
||||
| 7 | `PeerInfoHeaderEditingContentNode.swift` | 88 | `peer: peer` | same | ADD-WRAP |
|
||||
| 8 | `PeerInfoHeaderEditingContentNode.swift` | 93 | `peer: peer` | same | ADD-WRAP |
|
||||
| 9 | `PeerInfoHeaderEditingContentNode.swift` | 159 | `peer: peer` | same | ADD-WRAP |
|
||||
| 10 | `PeerInfoHeaderEditingContentNode.swift` | 162 | `peer: peer` | same | ADD-WRAP |
|
||||
| 11 | `PeerInfoScreenAvatarSetup.swift` | 435 | `peer: peer._asPeer()` | `peer = data.peer` (EnginePeer unwrapped) | DROP: `peer: peer` |
|
||||
| 12 | `PeerInfoScreenPerformButtonAction.swift` | 62 | `peer: self.data?.peer?._asPeer()` | `self.data?.peer` is `EnginePeer?` | DROP: `peer: self.data?.peer` |
|
||||
| 13 | `PeerInfoScreenPerformButtonAction.swift` | 397 | `peer: peer._asPeer()` | `peer = data.peer` unwrapped at line 381 | DROP: `peer: peer` |
|
||||
| 14 | `PeerInfoScreenPerformButtonAction.swift` | 398 | `peer: peer._asPeer()` | same | DROP: `peer: peer` |
|
||||
| 15 | `PeerInfoScreenOpenMember.swift` | 19 | `peer: enclosingPeer._asPeer()` | `enclosingPeer = self.data?.peer` unwrapped at line 14 | DROP: `peer: enclosingPeer` |
|
||||
| 16 | `PeerInfoScreen.swift` | 1905 | `peer: group` | `group: TelegramGroup` from `if case let .legacyGroup(group) = data.peer` | CONVERT: `peer: data.peer` |
|
||||
| 17 | `PeerInfoScreen.swift` | 1961 | `peer: channel` | `channel: TelegramChannel` from `if case let .channel(channel) = data.peer` | CONVERT: `peer: data.peer` |
|
||||
| 18 | `PeerInfoScreen.swift` | 5857 | `peer: self.data?.peer?._asPeer()` | `self.data?.peer` is `EnginePeer?` | DROP: `peer: self.data?.peer` |
|
||||
| 19 | `PeerInfoProfileItems.swift` | 853 | `peer: peer._asPeer()` | `peer = data.peer` unwrapped at line 821 | DROP: `peer: peer` |
|
||||
| 20 | `PeerInfoScreenMemberItem.swift` | 178 | `peer: item.enclosingPeer` | `item.enclosingPeer: Peer` (stored raw) | ADD-WRAP: `peer: EnginePeer(item.enclosingPeer)` |
|
||||
| 21 | `PeerInfoMembersPane.swift` | 139 | `peer: enclosingPeer` | `enclosingPeer: Peer` (local raw) | ADD-WRAP: `peer: EnginePeer(enclosingPeer)` |
|
||||
|
||||
**Summary:** 7 DROPs (sites 11–15, 18, 19), 10 ADD-WRAPs (1–10, 20, 21 = 12 total ADDs), 2 CONVERTs (16, 17 — from concrete-type arg to whole-EnginePeer; no wrap delta but simpler/safer).
|
||||
|
||||
### `is TelegramX` scan on helper bodies
|
||||
|
||||
Only `peerInfoIsChatMuted` has them (PeerInfoData.swift:2641, 2643). Rewrite pattern: `if peer is TelegramUser` → `if case .user = peer`, `else if peer is TelegramGroup` → `else if case .legacyGroup = peer`.
|
||||
|
||||
No `is TelegramX` checks exist at call sites for these specific helpers (wave-42 would have caught them since call-site `data.peer` is already `EnginePeer?`).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
All edits within `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/`:
|
||||
|
||||
**Modify (helper definitions):**
|
||||
- `PeerInfoData.swift:2265–2670` — 6 helper signatures + bodies
|
||||
|
||||
**Modify (call sites):**
|
||||
- `PeerInfoScreen.swift` (3 sites: 1905, 1961, 5857)
|
||||
- `PeerInfoHeaderNode.swift` (3 sites: 548, 549, 2361)
|
||||
- `PeerInfoEditingAvatarNode.swift` (1 site: 66)
|
||||
- `PeerInfoEditingAvatarOverlayNode.swift` (1 site: 85)
|
||||
- `PeerInfoHeaderEditingContentNode.swift` (5 sites: 59, 88, 93, 159, 162)
|
||||
- `PeerInfoScreenAvatarSetup.swift` (1 site: 435)
|
||||
- `PeerInfoScreenPerformButtonAction.swift` (3 sites: 62, 397, 398)
|
||||
- `PeerInfoScreenOpenMember.swift` (1 site: 19)
|
||||
- `PeerInfoProfileItems.swift` (1 site: 853)
|
||||
- `ListItems/PeerInfoScreenMemberItem.swift` (1 site: 178)
|
||||
- `Panes/PeerInfoMembersPane.swift` (1 site: 139)
|
||||
|
||||
Total: 10 files modified (PeerInfoData.swift counts once).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Migrate the six helper signatures and bodies in `PeerInfoData.swift`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift:2265–2670`
|
||||
|
||||
- [ ] **Step 1: Migrate `canEditPeerInfo` (line 2265).** Signature: `peer: Peer?` → `peer: EnginePeer?`. Body rewrites:
|
||||
|
||||
```swift
|
||||
// before (line 2269-2287)
|
||||
if let user = peer as? TelegramUser, let botInfo = user.botInfo {
|
||||
return botInfo.flags.contains(.canEdit)
|
||||
} else if let channel = peer as? TelegramChannel {
|
||||
if let threadData = threadData {
|
||||
if chatLocation.threadId == 1 {
|
||||
return false
|
||||
}
|
||||
if channel.hasPermission(.manageTopics) {
|
||||
return true
|
||||
}
|
||||
if threadData.author == context.account.peerId {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if channel.hasPermission(.changeInfo) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else if let group = peer as? TelegramGroup {
|
||||
switch group.role {
|
||||
case .admin, .creator:
|
||||
return true
|
||||
case .member:
|
||||
break
|
||||
}
|
||||
if !group.hasBannedPermission(.banChangeInfo) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// after
|
||||
if case let .user(user) = peer, let botInfo = user.botInfo {
|
||||
return botInfo.flags.contains(.canEdit)
|
||||
} else if case let .channel(channel) = peer {
|
||||
if let threadData = threadData {
|
||||
if chatLocation.threadId == 1 {
|
||||
return false
|
||||
}
|
||||
if channel.hasPermission(.manageTopics) {
|
||||
return true
|
||||
}
|
||||
if threadData.author == context.account.peerId {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if channel.hasPermission(.changeInfo) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else if case let .legacyGroup(group) = peer {
|
||||
switch group.role {
|
||||
case .admin, .creator:
|
||||
return true
|
||||
case .member:
|
||||
break
|
||||
}
|
||||
if !group.hasBannedPermission(.banChangeInfo) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `if context.account.peerId == peer?.id` line (2266) stays identical (`.id` is forwarded by `EnginePeer`).
|
||||
|
||||
- [ ] **Step 2: Migrate `availableActionsForMemberOfPeer` (line 2314).** Signature: `peer: Peer?` → `peer: EnginePeer?`. Body rewrites — four `peer as? TelegramChannel/TelegramGroup` sites become `case let .channel/.legacyGroup` patterns:
|
||||
|
||||
```swift
|
||||
// Line 2320: if let channel = peer as? TelegramChannel
|
||||
// → : if case let .channel(channel) = peer
|
||||
|
||||
// Line 2324: } else if let group = peer as? TelegramGroup {
|
||||
// → : } else if case let .legacyGroup(group) = peer {
|
||||
|
||||
// Line 2330: if let channel = peer as? TelegramChannel
|
||||
// → : if case let .channel(channel) = peer
|
||||
|
||||
// Line 2374: } else if let group = peer as? TelegramGroup {
|
||||
// → : } else if case let .legacyGroup(group) = peer {
|
||||
```
|
||||
|
||||
The `if peer == nil` check (line 2317) stays identical (Optional == nil works on EnginePeer? too).
|
||||
|
||||
- [ ] **Step 3: Migrate `peerInfoHeaderActionButtons` (line 2434).** Signature: `peer: Peer?` → `peer: EnginePeer?`. Single body rewrite at line 2436:
|
||||
|
||||
```swift
|
||||
// before
|
||||
if !isContact && !isSecretChat, let user = peer as? TelegramUser, user.botInfo == nil {
|
||||
|
||||
// after
|
||||
if !isContact && !isSecretChat, case let .user(user) = peer, user.botInfo == nil {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Migrate `peerInfoHeaderButtons` (line 2447).** Signature: `peer: Peer?` → `peer: EnginePeer?`. Three body rewrites:
|
||||
|
||||
```swift
|
||||
// Line 2449: if let user = peer as? TelegramUser {
|
||||
// → : if case let .user(user) = peer {
|
||||
|
||||
// Line 2483: } else if let channel = peer as? TelegramChannel {
|
||||
// → : } else if case let .channel(channel) = peer {
|
||||
|
||||
// Line 2558: } else if let group = peer as? TelegramGroup {
|
||||
// → : } else if case let .legacyGroup(group) = peer {
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Migrate `peerInfoCanEdit` (line 2585).** Signature: `peer: Peer?` → `peer: EnginePeer?`. Three body rewrites. Note: original shadows `peer` inside each branch (`let peer = peer as? TelegramX`). Rewrite preserves the shadowing via `case let`:
|
||||
|
||||
```swift
|
||||
// Line 2586: if let user = peer as? TelegramUser {
|
||||
// → : if case let .user(user) = peer {
|
||||
|
||||
// Line 2597: } else if let peer = peer as? TelegramChannel {
|
||||
// → : } else if case let .channel(peer) = peer {
|
||||
// (intentional shadow of outer `peer` with inner `peer: TelegramChannel` — preserved)
|
||||
|
||||
// Line 2618: } else if let peer = peer as? TelegramGroup {
|
||||
// → : } else if case let .legacyGroup(peer) = peer {
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Migrate `peerInfoIsChatMuted` (line 2633).** Outer signature: `peer: Peer?` → `peer: EnginePeer?`. Inner function signature (line 2634) also migrates: `func isPeerMuted(peer: Peer?, ...)` → `func isPeerMuted(peer: EnginePeer?, ...)`. Body rewrites inside the inner function (line 2641–2651):
|
||||
|
||||
```swift
|
||||
// before (line 2641)
|
||||
if peer is TelegramUser {
|
||||
peerIsMuted = !globalNotificationSettings.privateChats.enabled
|
||||
} else if peer is TelegramGroup {
|
||||
peerIsMuted = !globalNotificationSettings.groupChats.enabled
|
||||
} else if let channel = peer as? TelegramChannel {
|
||||
switch channel.info {
|
||||
case .group:
|
||||
peerIsMuted = !globalNotificationSettings.groupChats.enabled
|
||||
case .broadcast:
|
||||
peerIsMuted = !globalNotificationSettings.channels.enabled
|
||||
}
|
||||
}
|
||||
|
||||
// after
|
||||
if case .user = peer {
|
||||
peerIsMuted = !globalNotificationSettings.privateChats.enabled
|
||||
} else if case .legacyGroup = peer {
|
||||
peerIsMuted = !globalNotificationSettings.groupChats.enabled
|
||||
} else if case let .channel(channel) = peer {
|
||||
switch channel.info {
|
||||
case .group:
|
||||
peerIsMuted = !globalNotificationSettings.groupChats.enabled
|
||||
case .broadcast:
|
||||
peerIsMuted = !globalNotificationSettings.channels.enabled
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The outer `if let peer = peer` (line 2640) stays unchanged (Optional binding works on EnginePeer?).
|
||||
|
||||
The inner `peerInfoIsChatMuted` body (line 2659–2669) calls `isPeerMuted(peer: peer, ...)` with the outer `peer` (now EnginePeer?) — works without change because inner signature now matches.
|
||||
|
||||
- [ ] **Step 7: Re-read PeerInfoData.swift lines 2265–2670 and visually verify no `as? TelegramX` or `is TelegramX` patterns remain.**
|
||||
|
||||
Run: `grep -n "as? TelegramUser\|as? TelegramChannel\|as? TelegramGroup\|is TelegramUser\|is TelegramChannel\|is TelegramGroup" submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift | awk -F: '$2 >= 2265 && $2 <= 2670'`
|
||||
Expected: empty output.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update call sites — DROPs (7 sites)
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift:435`
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenPerformButtonAction.swift:62,397,398`
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenOpenMember.swift:19`
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift:5857`
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift:853`
|
||||
|
||||
- [ ] **Step 1: DROP at `PeerInfoScreenAvatarSetup.swift:435`.**
|
||||
|
||||
```swift
|
||||
// before
|
||||
guard let data = self.controllerNode.data, let peer = data.peer, mode != .generic || canEditPeerInfo(context: self.context, peer: peer._asPeer(), chatLocation: self.chatLocation, threadData: data.threadData) else {
|
||||
|
||||
// after
|
||||
guard let data = self.controllerNode.data, let peer = data.peer, mode != .generic || canEditPeerInfo(context: self.context, peer: peer, chatLocation: self.chatLocation, threadData: data.threadData) else {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: DROP at `PeerInfoScreenPerformButtonAction.swift:62`.**
|
||||
|
||||
```swift
|
||||
// before
|
||||
let chatIsMuted = peerInfoIsChatMuted(peer: self.data?.peer?._asPeer(), peerNotificationSettings: self.data?.peerNotificationSettings, threadNotificationSettings: self.data?.threadNotificationSettings, globalNotificationSettings: self.data?.globalNotificationSettings)
|
||||
|
||||
// after
|
||||
let chatIsMuted = peerInfoIsChatMuted(peer: self.data?.peer, peerNotificationSettings: self.data?.peerNotificationSettings, threadNotificationSettings: self.data?.threadNotificationSettings, globalNotificationSettings: self.data?.globalNotificationSettings)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: DROP at `PeerInfoScreenPerformButtonAction.swift:397 and :398` (peerInfoHeaderButtons, two lines, same pattern `peer: peer._asPeer()` → `peer: peer`).**
|
||||
|
||||
Use Edit with `replace_all=true` on the substring `peer: peer._asPeer(), cachedData: data.cachedData` — this exact form appears exactly twice in the file (lines 397, 398), both targets.
|
||||
|
||||
```swift
|
||||
// before (at both 397 and 398)
|
||||
peerInfoHeaderButtons(peer: peer._asPeer(), cachedData: data.cachedData, isOpenedFromChat: ...
|
||||
|
||||
// after
|
||||
peerInfoHeaderButtons(peer: peer, cachedData: data.cachedData, isOpenedFromChat: ...
|
||||
```
|
||||
|
||||
Verification after edit:
|
||||
|
||||
```bash
|
||||
grep -n "_asPeer()" submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenPerformButtonAction.swift
|
||||
```
|
||||
Expected: empty (all three DROPs done).
|
||||
|
||||
- [ ] **Step 4: DROP at `PeerInfoScreenOpenMember.swift:19`.**
|
||||
|
||||
```swift
|
||||
// before
|
||||
let actions = availableActionsForMemberOfPeer(accountPeerId: self.context.account.peerId, peer: enclosingPeer._asPeer(), member: member)
|
||||
|
||||
// after
|
||||
let actions = availableActionsForMemberOfPeer(accountPeerId: self.context.account.peerId, peer: enclosingPeer, member: member)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: DROP at `PeerInfoScreen.swift:5857`.**
|
||||
|
||||
```swift
|
||||
// before
|
||||
} else if peerInfoCanEdit(peer: self.data?.peer?._asPeer(), chatLocation: self.chatLocation, threadData: self.data?.threadData, cachedData: self.data?.cachedData, isContact: self.data?.isContact) {
|
||||
|
||||
// after
|
||||
} else if peerInfoCanEdit(peer: self.data?.peer, chatLocation: self.chatLocation, threadData: self.data?.threadData, cachedData: self.data?.cachedData, isContact: self.data?.isContact) {
|
||||
```
|
||||
|
||||
- [ ] **Step 6: DROP at `PeerInfoProfileItems.swift:853`.**
|
||||
|
||||
Only the `availableActionsForMemberOfPeer` call — the sibling `enclosingPeer: peer._asPeer()` at line 852 is NOT a helper-migration target (it's `PeerInfoScreenMemberItem.enclosingPeer: Peer`, unchanged in this wave).
|
||||
|
||||
```swift
|
||||
// before (line 853)
|
||||
let actions = availableActionsForMemberOfPeer(accountPeerId: context.account.peerId, peer: peer._asPeer(), member: member)
|
||||
|
||||
// after
|
||||
let actions = availableActionsForMemberOfPeer(accountPeerId: context.account.peerId, peer: peer, member: member)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update call sites — CONVERTs (2 sites in PeerInfoScreen.swift)
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift:1905,1961`
|
||||
|
||||
At these sites the helper arg is currently a concrete `TelegramGroup` / `TelegramChannel` extracted via case pattern. After migration the helper takes `EnginePeer?`, so pass `data.peer` directly — the helper re-does the pattern match internally, semantics preserved.
|
||||
|
||||
- [ ] **Step 1: CONVERT at `PeerInfoScreen.swift:1905`.**
|
||||
|
||||
```swift
|
||||
// before
|
||||
} else if case let .legacyGroup(group) = data.peer, canEditPeerInfo(context: strongSelf.context, peer: group, chatLocation: chatLocation, threadData: data.threadData) {
|
||||
|
||||
// after
|
||||
} else if case let .legacyGroup(group) = data.peer, canEditPeerInfo(context: strongSelf.context, peer: data.peer, chatLocation: chatLocation, threadData: data.threadData) {
|
||||
```
|
||||
|
||||
`group` stays bound because the body below still uses it. Only the helper arg changes.
|
||||
|
||||
- [ ] **Step 2: CONVERT at `PeerInfoScreen.swift:1961`.**
|
||||
|
||||
```swift
|
||||
// before
|
||||
} else if case let .channel(channel) = data.peer, canEditPeerInfo(context: strongSelf.context, peer: channel, chatLocation: strongSelf.chatLocation, threadData: data.threadData) {
|
||||
|
||||
// after
|
||||
} else if case let .channel(channel) = data.peer, canEditPeerInfo(context: strongSelf.context, peer: data.peer, chatLocation: strongSelf.chatLocation, threadData: data.threadData) {
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update call sites — ADD-WRAPs in internal-update methods (10 sites in 4 files)
|
||||
|
||||
These files' internal `.update(peer: Peer?, ...)` methods are NOT migrated in this wave (scope: helpers only). Each helper call inside bridges `peer` (raw `Peer?`) to `EnginePeer?` via `.flatMap(EnginePeer.init)`, or — where `peer` has already been unwrapped to non-optional `Peer` — via `EnginePeer(peer)`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift:548,549,2361`
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift:66`
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarOverlayNode.swift:85`
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderEditingContentNode.swift:59,88,93,159,162`
|
||||
|
||||
- [ ] **Step 1: ADD-WRAPs at `PeerInfoHeaderNode.swift:548,549,2361`.** At lines 548, 549, the local `peer` is the raw `Peer?` method parameter (line 496). At line 2361 likewise.
|
||||
|
||||
```swift
|
||||
// before (line 548)
|
||||
let actionButtonKeys: [PeerInfoHeaderButtonKey] = (self.isSettings || self.isMyProfile) ? [] : peerInfoHeaderActionButtons(peer: peer, isSecretChat: isSecretChat, isContact: isContact)
|
||||
|
||||
// after
|
||||
let actionButtonKeys: [PeerInfoHeaderButtonKey] = (self.isSettings || self.isMyProfile) ? [] : peerInfoHeaderActionButtons(peer: peer.flatMap(EnginePeer.init), isSecretChat: isSecretChat, isContact: isContact)
|
||||
```
|
||||
|
||||
```swift
|
||||
// before (line 549)
|
||||
let buttonKeys: [PeerInfoHeaderButtonKey] = (self.isSettings || self.isMyProfile) ? [] : peerInfoHeaderButtons(peer: peer, cachedData: cachedData, ...)
|
||||
|
||||
// after
|
||||
let buttonKeys: [PeerInfoHeaderButtonKey] = (self.isSettings || self.isMyProfile) ? [] : peerInfoHeaderButtons(peer: peer.flatMap(EnginePeer.init), cachedData: cachedData, ...)
|
||||
```
|
||||
|
||||
```swift
|
||||
// before (line 2361)
|
||||
let chatIsMuted = peerInfoIsChatMuted(peer: peer, peerNotificationSettings: peerNotificationSettings, threadNotificationSettings: threadNotificationSettings, globalNotificationSettings: globalNotificationSettings)
|
||||
|
||||
// after
|
||||
let chatIsMuted = peerInfoIsChatMuted(peer: peer.flatMap(EnginePeer.init), peerNotificationSettings: peerNotificationSettings, threadNotificationSettings: threadNotificationSettings, globalNotificationSettings: globalNotificationSettings)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: ADD-WRAP at `PeerInfoEditingAvatarNode.swift:66`.** Here `peer` is non-optional `Peer` (unwrapped at line 62: `guard let peer = peer else { return }`). Use `EnginePeer(peer)`.
|
||||
|
||||
```swift
|
||||
// before
|
||||
let canEdit = canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData)
|
||||
|
||||
// after
|
||||
let canEdit = canEditPeerInfo(context: self.context, peer: EnginePeer(peer), chatLocation: chatLocation, threadData: threadData)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: ADD-WRAP at `PeerInfoEditingAvatarOverlayNode.swift:85`.** Same shape — `peer` is non-optional `Peer` (unwrapped at line 64).
|
||||
|
||||
```swift
|
||||
// before
|
||||
if canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData)
|
||||
|
||||
// after
|
||||
if canEditPeerInfo(context: self.context, peer: EnginePeer(peer), chatLocation: chatLocation, threadData: threadData)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: ADD-WRAPs at `PeerInfoHeaderEditingContentNode.swift:59,88,93,159,162`.** Here `peer` is the method's `peer: Peer?` parameter (line 52). Five identical bridge forms.
|
||||
|
||||
For each of lines 59, 88, 93, 159, 162, replace `peer: peer` (inside `canEditPeerInfo(... peer: peer, ...)`) with `peer: peer.flatMap(EnginePeer.init)`.
|
||||
|
||||
The simplest approach: issue five separate Edit calls, each scoped to a unique surrounding substring. Example:
|
||||
|
||||
```swift
|
||||
// before (line 59)
|
||||
if canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData) {
|
||||
|
||||
// after
|
||||
if canEditPeerInfo(context: self.context, peer: peer.flatMap(EnginePeer.init), chatLocation: chatLocation, threadData: threadData) {
|
||||
```
|
||||
|
||||
Note line 59's trailing double-space before `{` in the original — preserve it.
|
||||
|
||||
Lines 88, 93, 159 share an identical surrounding substring `if canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData) {` (no trailing double-space, no `|| isEditableBot`). To avoid collision with line 59, use `replace_all=true` on THIS exact string (matches 88, 93 — wait, 159 uses `isEnabled = canEditPeerInfo(...)`, different prefix). Safer plan: one Edit per line, each with enough surrounding context to be unique. Verify uniqueness after each edit with grep.
|
||||
|
||||
Line 88's surrounding context: inside `if let _ = peer as? TelegramGroup {` branch — preceded by `fieldKeys.append(.title)`.
|
||||
|
||||
Line 93's surrounding context: inside `if let _ = peer as? TelegramChannel {` branch — preceded by `fieldKeys.append(.title)`. Same inner phrase as 88 — so `fieldKeys.append(.title)\n if canEditPeerInfo...` appears twice. Use line-specific context (preceding `else if let _ = peer as?` token).
|
||||
|
||||
Line 159: `isEnabled = canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData)` (no trailing text).
|
||||
|
||||
Line 162: `isEnabled = canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData) || isEditableBot`. Unique — contains ` || isEditableBot`.
|
||||
|
||||
Recommended: five sequential Edits with explicit line disambiguation via surrounding context. Do not bulk-replace-all — the identical `peer: peer, chatLocation: chatLocation, threadData: threadData)` substring appears at all five sites but their line-specific surroundings differ.
|
||||
|
||||
Verification after all five edits:
|
||||
|
||||
```bash
|
||||
grep -c "peer: peer," submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderEditingContentNode.swift
|
||||
```
|
||||
Expected: 0 (no unmigrated call sites remain; other `peer:` occurrences in the file are either type annotations or at the method signature, which uses `peer: Peer?` not `peer: peer`).
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update call sites — ADD-WRAPs at raw-`Peer` member-item sites (2 sites)
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift:178`
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift:139`
|
||||
|
||||
At these sites `enclosingPeer` is non-optional `Peer` (raw, stored on the item / local). Wrap with `EnginePeer(...)`.
|
||||
|
||||
- [ ] **Step 1: ADD-WRAP at `PeerInfoScreenMemberItem.swift:178`.**
|
||||
|
||||
```swift
|
||||
// before
|
||||
let actions = availableActionsForMemberOfPeer(accountPeerId: item.context.accountPeerId, peer: item.enclosingPeer, member: item.member)
|
||||
|
||||
// after
|
||||
let actions = availableActionsForMemberOfPeer(accountPeerId: item.context.accountPeerId, peer: EnginePeer(item.enclosingPeer), member: item.member)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: ADD-WRAP at `PeerInfoMembersPane.swift:139`.**
|
||||
|
||||
```swift
|
||||
// before
|
||||
let actions = availableActionsForMemberOfPeer(accountPeerId: context.account.peerId, peer: enclosingPeer, member: member)
|
||||
|
||||
// after
|
||||
let actions = availableActionsForMemberOfPeer(accountPeerId: context.account.peerId, peer: EnginePeer(enclosingPeer), member: member)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Build and iterate
|
||||
|
||||
- [ ] **Step 1: Full project build with `--continueOnError` to surface all errors at once.**
|
||||
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \
|
||||
--cacheDir ~/telegram-bazel-cache \
|
||||
build \
|
||||
--configurationPath build-system/appstore-configuration.json \
|
||||
--gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \
|
||||
--gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 \
|
||||
--configuration=debug_sim_arm64 --continueOnError
|
||||
```
|
||||
|
||||
Expected: likely 2-iteration convergence. Budget up to iteration 3.
|
||||
|
||||
Likely categories of residual errors:
|
||||
|
||||
1. **Missed call sites** — grep-miss from planning. Remediate by adding `.flatMap(EnginePeer.init)` or `EnginePeer(...)` as appropriate.
|
||||
2. **Missed `as? TelegramX` / `is TelegramX` inside helper bodies** — Swift compiler error "cannot convert value of type 'EnginePeer?' to expected argument type 'Peer?'" or warning "'is' test is always false". Fix with `case` pattern.
|
||||
3. **Optional-lifting edge cases** — `if case let .user(user) = peer` may fail if Swift interprets `peer` as non-optional. If so, rewrite as `if let peer, case let .user(user) = peer`.
|
||||
4. **Unused binding warnings** — e.g. `if case let .user(user) = peer` where `user` isn't used inside that branch. Swift's `-warnings-as-errors` (658/665 submodule BUILDs) promotes these. Rewrite as `if case .user = peer`.
|
||||
5. **Unused variable `peer` or `group`/`channel` at CONVERT sites 16, 17** — lines 1905/1961 bind `group`/`channel` in the `case let` pattern; if the body body doesn't use it, Swift emits "value 'group' was never used" which `-warnings-as-errors` promotes to error. Since the body below DOES use them (updatePeerTitle(peerId: group.id, ...)` etc.), this should not trigger — but verify.
|
||||
|
||||
- [ ] **Step 2: For each error category above, apply the correct fix in-place and rebuild. Iterate until green.**
|
||||
|
||||
- [ ] **Step 3: After build is green, run the post-migration grep audit:**
|
||||
|
||||
```bash
|
||||
# Should be empty — no _asPeer() bridges at helper call sites
|
||||
grep -rn "canEditPeerInfo(.*_asPeer\|peerInfoIsChatMuted(.*_asPeer\|peerInfoHeaderButtons(.*_asPeer\|peerInfoHeaderActionButtons(.*_asPeer\|peerInfoCanEdit(.*_asPeer\|availableActionsForMemberOfPeer(.*_asPeer" submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/
|
||||
|
||||
# Should be empty — no concrete-type casts against peer param in helper bodies
|
||||
grep -nE "as\?\s+TelegramUser|as\?\s+TelegramChannel|as\?\s+TelegramGroup|\bis\s+TelegramUser\b|\bis\s+TelegramChannel\b|\bis\s+TelegramGroup\b" submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift | awk -F: '$2 >= 2265 && $2 <= 2670'
|
||||
```
|
||||
|
||||
Expected: both empty.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Commit
|
||||
|
||||
- [ ] **Step 1: Verify working tree only contains wave-43 edits + pre-existing WIP.**
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected (pre-existing WIP, NOT to be staged):
|
||||
|
||||
```
|
||||
m build-system/bazel-rules/sourcekit-bazel-bsp
|
||||
M submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift
|
||||
?? build-system/tulsi/
|
||||
?? submodules/TgVoip/
|
||||
?? third-party/libx264/
|
||||
```
|
||||
|
||||
Plus wave-43 edits (all under `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/`):
|
||||
|
||||
```
|
||||
M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift
|
||||
M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift
|
||||
M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift
|
||||
M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift
|
||||
M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarOverlayNode.swift
|
||||
M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderEditingContentNode.swift
|
||||
M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift
|
||||
M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenPerformButtonAction.swift
|
||||
M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenOpenMember.swift
|
||||
M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift
|
||||
M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift
|
||||
M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Explicitly stage only the wave-43 files (not the WIP).**
|
||||
|
||||
```bash
|
||||
git add \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarOverlayNode.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderEditingContentNode.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenPerformButtonAction.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenOpenMember.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift \
|
||||
docs/superpowers/plans/2026-04-24-peerinfoscreen-helpers-engine-peer-migration.md
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit.**
|
||||
|
||||
Use a HEREDOC for the message:
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Postbox -> TelegramEngine wave 43
|
||||
|
||||
Migrate six PeerInfoScreen helpers (canEditPeerInfo,
|
||||
availableActionsForMemberOfPeer, peerInfoHeaderActionButtons,
|
||||
peerInfoHeaderButtons, peerInfoCanEdit, peerInfoIsChatMuted) from
|
||||
`peer: Peer?` to `peer: EnginePeer?`. Internal `as? TelegramX` /
|
||||
`is TelegramX` patterns rewritten to `case let .x` / `case .x` on
|
||||
EnginePeer enum. All 21 call sites updated in the same commit: 7
|
||||
`._asPeer()` bridges installed by wave 42 dropped; 12
|
||||
`.flatMap(EnginePeer.init)` / `EnginePeer(...)` wraps added at sites
|
||||
whose enclosing methods still take raw Peer?; 2 concrete-type args
|
||||
converted to pass the whole EnginePeer value.
|
||||
|
||||
All edits within submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/.
|
||||
No new engine typealiases. No TelegramCore changes.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify commit.**
|
||||
|
||||
```bash
|
||||
git log --oneline -1
|
||||
git show --stat HEAD
|
||||
```
|
||||
|
||||
Expected: one commit, ~10 files changed, clean diff.
|
||||
|
||||
---
|
||||
|
||||
## Self-review checklist (run before handoff)
|
||||
|
||||
**Spec coverage:**
|
||||
- All 6 helper signatures migrated (Task 1 steps 1–6). ✓
|
||||
- All 21 call sites touched (Tasks 2–5). ✓
|
||||
- Build iteration explicit (Task 6). ✓
|
||||
- Commit explicit (Task 7). ✓
|
||||
|
||||
**Type consistency:**
|
||||
- Helper signatures all `peer: EnginePeer?` (consistent). ✓
|
||||
- Call-site transforms: DROP/ADD/CONVERT actions match the inventory table. ✓
|
||||
- `EnginePeer.init` constructor used both as `.flatMap(EnginePeer.init)` (Peer? → EnginePeer?) and `EnginePeer(...)` (Peer → EnginePeer) — both are valid (construction overloaded on EnginePeer extension at `TelegramCore/TelegramEngine/Peers/Peer.swift:564`). ✓
|
||||
|
||||
**Placeholder scan:**
|
||||
- No "TBD" / "handle appropriately" / "similar to Task N" language — every step has its concrete code. ✓
|
||||
|
||||
**Risks flagged:**
|
||||
- Wave-41 lesson: foundational-type migrations rarely first-pass-clean. Budget 2 iterations. ✓
|
||||
- Wave-41 lesson: `-warnings-as-errors` promotes always-false `is` checks and unused bindings to build errors. Task 6 step 1 calls these out explicitly. ✓
|
||||
- Wave-42 lesson: `EnginePeer` doesn't forward every Peer property. Helper bodies were verified to access only `.id`, which IS forwarded; other property accesses were on concrete types (`TelegramChannel.hasPermission(...)` etc.) which remain on concrete types post-migration. No forwarding-gap remediation expected in helpers. ✓
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
# Wave 42 plan: `PeerInfoScreenData.peer: Peer? → EnginePeer?`
|
||||
|
||||
Date: 2026-04-24
|
||||
Preceding waves: 41 (`RenderedChannelParticipant.peer`), 40 (`makeChatQrCodeScreen`/`makeChatRecentActionsController`), 39 (`makePeerInfoController`)
|
||||
Scope (confirmed with user): only `PeerInfoScreenData.peer`. Sibling fields (`chatPeer`, `savedMessagesPeer`, `linkedDiscussionPeer`, `linkedMonoforumPeer`) are follow-up-wave candidates.
|
||||
|
||||
## Change target
|
||||
|
||||
File: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift`
|
||||
|
||||
- L386: `let peer: Peer?` → `let peer: EnginePeer?`
|
||||
- L442: `peer: Peer?,` → `peer: EnginePeer?,`
|
||||
- Store unchanged (`self.peer = peer`)
|
||||
|
||||
## Construction sites (5, all in PeerInfoData.swift)
|
||||
|
||||
| Line | Current `peer:` arg | Rewrite |
|
||||
|------|---------------------|---------|
|
||||
| 1027 | `peer: peer` (local, `Peer?` from `peerView.peers[peerId]`) | `peer: peer.flatMap(EnginePeer.init)` |
|
||||
| 1100 | `peer: nil` | unchanged |
|
||||
| 1620 | `peer: peer` (local, `Peer?` from `peerView.peers[userPeerId]`) | `peer: peer.flatMap(EnginePeer.init)` |
|
||||
| 1867 | `peer: peerView.peers[peerId]` | `peer: peerView.peers[peerId].flatMap(EnginePeer.init)` |
|
||||
| 2205 | `peer: peerView.peers[groupId]` | `peer: peerView.peers[groupId].flatMap(EnginePeer.init)` |
|
||||
|
||||
## Consumer migration patterns (across 18 files, ~114 `data.peer` accesses)
|
||||
|
||||
### Pattern A — as-cast → enum pattern match (~20 sites)
|
||||
|
||||
```swift
|
||||
// before
|
||||
if let user = data.peer as? TelegramUser, user.botInfo == nil { ... }
|
||||
|
||||
// after
|
||||
if case let .user(user) = data.peer, user.botInfo == nil { ... }
|
||||
```
|
||||
|
||||
Scope both sides consistently. A cast inside a larger `guard let ..., let user = ... as? TelegramUser else { return }` becomes `guard ..., case let .user(user) = data.peer else { return }`.
|
||||
|
||||
### Pattern B — `is TelegramXxx` check → enum case pattern (~5 sites)
|
||||
|
||||
The wave-41 lesson: `-warnings-as-errors` catches always-false `is` checks.
|
||||
|
||||
```swift
|
||||
// before
|
||||
if let peer = self.data?.peer, peer is TelegramChannel { ... }
|
||||
if peer is TelegramGroup { ... }
|
||||
|
||||
// after
|
||||
if case .channel = self.data?.peer { ... }
|
||||
if case .legacyGroup = peer { ... }
|
||||
```
|
||||
|
||||
`TelegramGroup` maps to `.legacyGroup`. `TelegramChannel` maps to `.channel`. `TelegramUser` maps to `.user`. `TelegramSecretChat` maps to `.secretChat`.
|
||||
|
||||
Known sites in PeerInfoScreen.swift (inventory): L3981, L4133, L4192, L4194 (and L7421 for `chatPeer`-bound — chatPeer stays raw, so L7421 is out of scope). Use repo grep on `PeerInfoScreen/Sources` with token `is Telegram(Channel|User|Group|SecretChat)` to catch other sites.
|
||||
|
||||
### Pattern C — existing `EnginePeer(peer)` wraps where `peer` was bound from `data.peer` — DROP (15+ sites)
|
||||
|
||||
```swift
|
||||
// before
|
||||
if let peer = self.data?.peer {
|
||||
self.joinChannel(peer: EnginePeer(peer)) // wave-40 wrap
|
||||
}
|
||||
|
||||
// after
|
||||
if let peer = self.data?.peer {
|
||||
self.joinChannel(peer: peer) // peer is now EnginePeer already
|
||||
}
|
||||
```
|
||||
|
||||
Care needed: only drop the wrap where the bound `peer` variable comes from `data.peer`. Wraps on `chatPeer`, `currentPeer`, `user` (bound via `as? TelegramUser`), `groupPeer`, or PeerView lookups stay. The lexical scope makes this judgeable.
|
||||
|
||||
Known drop sites (PeerInfoScreen.swift): 1331, 1339, 1346, 1561, 2353, 2405, 3409, 3459, 3624, 3747, 4306, 4573 (inner — review scope), 4623. PeerInfoHeaderNode.swift: 571, 1218, 2054 (if bound from data.peer). PeerInfoScreenOpenChat.swift: 25, 40, 51, 57, 80, 89, 115. Verify each by backtracking the `if let peer = ...` binding.
|
||||
|
||||
### Pattern D — helper call sites still taking `Peer?` (ADD-WRAP, ~10 sites)
|
||||
|
||||
`canEditPeerInfo`, `peerInfoIsChatMuted`, `peerInfoHeaderButtons`, `peerInfoHeaderActionButtons`, `peerInfoCanEdit`, `availableActionsForMemberOfPeer` all keep `peer: Peer?` in this wave. Call sites must bridge:
|
||||
|
||||
```swift
|
||||
// before
|
||||
peerInfoIsChatMuted(peer: self.data?.peer, ...)
|
||||
|
||||
// after
|
||||
peerInfoIsChatMuted(peer: self.data?.peer?._asPeer(), ...)
|
||||
```
|
||||
|
||||
Site count (from grep): PeerInfoHeaderNode.swift:548/549/2361, PeerInfoScreenAvatarSetup.swift:435, PeerInfoScreenPerformButtonAction.swift:62/397/398, PeerInfoEditingAvatarNode.swift:66, PeerInfoScreen.swift:1905/1961/5857, PeerInfoHeaderEditingContentNode.swift:59/88/93/159/162, PeerInfoEditingAvatarOverlayNode.swift:85. But the local `peer` at some of these is already narrowed via `as? TelegramUser` (now `case let .user(user)`); in that case the helper gets `user` (still `Peer`-conforming), no bridge needed. Bridge only where the raw `data.peer` flows into the helper.
|
||||
|
||||
These ADD-WRAP markers become ratchet-drops for a follow-up wave that migrates the helper signatures.
|
||||
|
||||
### Pattern E — `EnginePeer?` passed as `EnginePeer?` directly (DROP wraps on callback args)
|
||||
|
||||
Where `data.peer` feeds `makePeerInfoController(peer: EnginePeer)` / `chatInterfaceInteraction.openPeer(_ peer: EnginePeer, ...)` / `.peer(EnginePeer)` ChatLocation / `AvatarGalleryController(peer: EnginePeer)` / `makeChatQrCodeScreen(peer: EnginePeer)` / `makeChatRecentActionsController(peer: EnginePeer)` — drop the `EnginePeer(...)` wrap; pass directly.
|
||||
|
||||
### Pattern F — `EnginePeer(peer).displayTitle(...)` / `.compactDisplayTitle` usage (DROP wrap)
|
||||
|
||||
```swift
|
||||
// before
|
||||
EnginePeer(peer).displayTitle(strings: ..., displayOrder: ...)
|
||||
|
||||
// after (peer is now EnginePeer already)
|
||||
peer.displayTitle(strings: ..., displayOrder: ...)
|
||||
```
|
||||
|
||||
### Pattern G — `.isPremium` on `peer?` inside construction site (L1060, L1626, L1902, L2242)
|
||||
|
||||
`peerView.peers[peerId]?.isPremium` — `Peer` protocol exposes `isPremium`. But the construction site receives raw `Peer?` and then we wrap via `flatMap(EnginePeer.init)`. The `peer?.isPremium` in the same construction scope still refers to the *local* raw peer variable (type unchanged), not `self.peer`. **No change needed at construction sites for `.isPremium` accesses on the local raw `peer`.** Only change `.isPremium` accesses on `data.peer` (which is now `EnginePeer?`) — `EnginePeer.isPremium` exists.
|
||||
|
||||
## File-by-file plan
|
||||
|
||||
1. **PeerInfoData.swift** — declaration + init + 5 constructions. Also review L1529 (`peerView.peers[peerView.peerId] is TelegramUser`) — OUT OF SCOPE (not `data.peer`); don't touch. Helper functions L2265/2314/2434/2447/2585/2633 stay `peer: Peer?` — DO NOT TOUCH.
|
||||
|
||||
2. **PeerInfoScreen.swift** — largest consumer, ~70+ sites. Walk every `data.peer` / `data?.peer` / `self.data?.peer` / `self.data.peer`. Apply A/B/C/E/F patterns. For `if let peer = data.peer` bindings, subsequent uses of `peer` now have type `EnginePeer` — drop wraps on those uses.
|
||||
|
||||
3. **PeerInfoScreenOpenChat.swift, PeerInfoScreenOpenBio.swift, PeerInfoScreenOpenMember.swift, PeerInfoScreenOpenPeerInfoContextMenu.swift, PeerInfoScreenOpenUsername.swift, PeerInfoScreenCallActions.swift, PeerInfoScreenMessageActions.swift, PeerInfoScreenPerformButtonAction.swift, PeerInfoScreenAvatarSetup.swift, PeerInfoScreenSettingsActions.swift, PeerInfoScreenDisplayGiftsContextMenu.swift, PeerInfoScreenDisplayMediaGalleryContextMenu.swift** — various `data?.peer as? TelegramXxx` (A), helper bridges (D), wrap drops (C/E).
|
||||
|
||||
4. **PeerInfoPaneContainerNode.swift** — L1252 `as? TelegramChannel` (A).
|
||||
|
||||
5. **PeerInfoProfileItems.swift, PeerInfoSettingsItems.swift, ListItems/PeerInfoScreenPersonalChannelItem.swift** — `data.peer as? TelegramUser` style consumers (A).
|
||||
|
||||
6. **PeerInfoHeaderNode.swift, PeerInfoEditingAvatarNode.swift, PeerInfoEditingAvatarOverlayNode.swift, PeerInfoHeaderEditingContentNode.swift** — these files receive `peer` as a parameter (not directly `data.peer`). Only touch if a parameter type declared as `Peer?` is the field from `data.peer` being passed in; otherwise leave.
|
||||
|
||||
## Replace_all guidance (wave-41 lesson)
|
||||
|
||||
Several wraps repeat identically. Where a file has multiple identical `EnginePeer(peer)` expressions in scopes where `peer` is now `EnginePeer`, use `replace_all=true` on the unique full expression. BUT verify each such file has no same-pattern wrap where `peer` is still raw (chatPeer-bound, currentPeer-bound, etc.) — such wraps must survive.
|
||||
|
||||
Safer alternative: edit each site individually.
|
||||
|
||||
## Out of scope (enumerated)
|
||||
|
||||
- `PeerInfoScreenData.chatPeer`, `.savedMessagesPeer`, `.linkedDiscussionPeer`, `.linkedMonoforumPeer` — stay `Peer?`.
|
||||
- Internal helpers `canEditPeerInfo` / `peerInfoIsChatMuted` / etc. — stay `peer: Peer?`.
|
||||
- `peerView.peers[...]` access inside PeerInfoData.swift — stays raw `Peer?`.
|
||||
- Any `is TelegramXxx` check on a non-`data.peer`-derived variable.
|
||||
|
||||
## Build methodology
|
||||
|
||||
1. Apply declaration + init + construction edits.
|
||||
2. Apply consumer edits file by file.
|
||||
3. `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`
|
||||
4. Iterate on errors. Budget: 2–4 iterations (wave-41 lesson: foundational-type property-access migrations are not first-pass-clean).
|
||||
|
||||
## Expected ratchet math
|
||||
|
||||
- Drops: 15+ wave-40 wraps, ~20 as-cast patterns collapsed, ~5 is-checks rewritten, several `EnginePeer(peer).displayTitle` wraps dropped.
|
||||
- Adds: ~10 `?._asPeer()` helper bridges, 4 `flatMap(EnginePeer.init)` at construction.
|
||||
- Net: ~15–25 bridges dropped.
|
||||
|
||||
## WIP interference check
|
||||
|
||||
Pre-existing WIP in tree (per memory):
|
||||
- `submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift` — modified, unrelated animation WIP. DO NOT TOUCH.
|
||||
- `submodules/TgVoip/`, `third-party/libx264/`, `build-system/tulsi/` — untracked, unrelated.
|
||||
- `build-system/bazel-rules/sourcekit-bazel-bsp` — submodule marker, unrelated.
|
||||
|
||||
Wave-42 files are all in `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/` — no overlap with WIP. Commit with explicit file list (wave-39/41 lesson).
|
||||
|
||||
## Post-commit followups
|
||||
|
||||
- Update `docs/superpowers/postbox-refactor-log.md` with "Wave 42 outcome".
|
||||
- Update `memory/project_postbox_refactor_next_wave.md` with wave 43 candidates:
|
||||
- Wave 42.x sibling: `PeerInfoScreenData.chatPeer` / `.savedMessagesPeer` / `.linkedDiscussionPeer` / `.linkedMonoforumPeer` as a bundle (same file, narrow blast radius).
|
||||
- Wave 42.y: PeerInfo-internal helper signatures (drops the ~10 ADD-WRAP markers).
|
||||
- Option 2 from wave-42 shortlist: `RenderedChannelParticipant.peers` dict.
|
||||
|
|
@ -0,0 +1,395 @@
|
|||
# Postbox → TelegramEngine wave 37: `peerTokenTitle` peer parameter Peer → EnginePeer
|
||||
|
||||
> **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 the private free function `peerTokenTitle(accountPeerId: PeerId, peer: Peer, strings:, nameDisplayOrder:)` in `submodules/TelegramUI/Sources/ContactMultiselectionController.swift` so `peer` is `EnginePeer`, dropping 5 `._asPeer()` bridges at call sites in the same file.
|
||||
|
||||
**Architecture:** Single-file, atomic, private-function refactor. No public API change, no BUILD-file touch, no cross-module effects. Function body simplifies `EnginePeer(peer).displayTitle(...)` → `peer.displayTitle(...)`.
|
||||
|
||||
**Tech Stack:** Swift, Bazel via `Make.py` wrapper, Telegram-iOS project conventions (see CLAUDE.md).
|
||||
|
||||
**Reference:** Spec `docs/superpowers/specs/2026-04-24-peertokentitle-engine-peer-migration-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
Only one file is touched:
|
||||
|
||||
- **Modify:** `submodules/TelegramUI/Sources/ContactMultiselectionController.swift`
|
||||
- L21 — signature change (`peer: Peer` → `peer: EnginePeer`)
|
||||
- L27 — body simplification (drop redundant `EnginePeer(...)` wrap)
|
||||
- L171, L201, L386, L403, L748 — call-site bridge drops (`peer: peer._asPeer()` → `peer: peer`)
|
||||
|
||||
No files created. No files deleted. No BUILD files touched.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Pre-flight inventory verification
|
||||
|
||||
**Files:** None (grep-only).
|
||||
|
||||
- [ ] **Step 1: Confirm the function is private and single-file**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -rn "peerTokenTitle" submodules/ Telegram/ third-party/ --include="*.swift"
|
||||
```
|
||||
|
||||
Expected: exactly 6 matches, all in `submodules/TelegramUI/Sources/ContactMultiselectionController.swift` — 1 definition at L21 and 5 call sites at L171, L201, L386, L403, L748.
|
||||
|
||||
If any match appears outside this file, **stop and re-evaluate scope**: the function may not actually be private or another file has copy-pasted the name.
|
||||
|
||||
- [ ] **Step 2: Confirm all 5 call sites currently use `._asPeer()`**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -n "peerTokenTitle(.*_asPeer())" submodules/TelegramUI/Sources/ContactMultiselectionController.swift
|
||||
```
|
||||
|
||||
Expected: 5 matches, line numbers 171, 201, 386, 403, 748.
|
||||
|
||||
If the count is not 5, **stop and re-inventory** — a prior change may have shifted line numbers or altered a call site.
|
||||
|
||||
- [ ] **Step 3: Confirm no other `peerTokenTitle` overload exists**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -n "func peerTokenTitle" submodules/TelegramUI/Sources/ContactMultiselectionController.swift
|
||||
```
|
||||
|
||||
Expected: exactly 1 match at line 21 (`private func peerTokenTitle(...)`).
|
||||
|
||||
- [ ] **Step 4: Confirm `EnginePeer.displayTitle(strings:displayOrder:)` exists**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -rn "func displayTitle(strings:" submodules/TelegramCore/Sources/TelegramEngine/ submodules/TelegramCore/Sources/SyncCore/
|
||||
```
|
||||
|
||||
Expected: a match on `EnginePeer` extension exposing `displayTitle(strings: PresentationStrings, displayOrder: PresentationPersonNameOrder)`. (This is the method already called as `EnginePeer(peer).displayTitle(...)` at L27, so its existence is certain — this step just makes the dependency explicit.)
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Edit the function signature and body
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Sources/ContactMultiselectionController.swift:21-29`
|
||||
|
||||
- [ ] **Step 1: Read the current function definition**
|
||||
|
||||
Read the file, lines 21–29. Current state:
|
||||
|
||||
```swift
|
||||
private func peerTokenTitle(accountPeerId: PeerId, peer: Peer, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder) -> String {
|
||||
if peer.id == accountPeerId {
|
||||
return strings.DialogList_SavedMessages
|
||||
} else if peer.id.isReplies {
|
||||
return strings.DialogList_Replies
|
||||
} else {
|
||||
return EnginePeer(peer).displayTitle(strings: strings, displayOrder: nameDisplayOrder)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Apply the signature change**
|
||||
|
||||
Use Edit with:
|
||||
|
||||
- `old_string`:
|
||||
```
|
||||
private func peerTokenTitle(accountPeerId: PeerId, peer: Peer, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder) -> String {
|
||||
if peer.id == accountPeerId {
|
||||
return strings.DialogList_SavedMessages
|
||||
} else if peer.id.isReplies {
|
||||
return strings.DialogList_Replies
|
||||
} else {
|
||||
return EnginePeer(peer).displayTitle(strings: strings, displayOrder: nameDisplayOrder)
|
||||
}
|
||||
}
|
||||
```
|
||||
- `new_string`:
|
||||
```
|
||||
private func peerTokenTitle(accountPeerId: PeerId, peer: EnginePeer, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder) -> String {
|
||||
if peer.id == accountPeerId {
|
||||
return strings.DialogList_SavedMessages
|
||||
} else if peer.id.isReplies {
|
||||
return strings.DialogList_Replies
|
||||
} else {
|
||||
return peer.displayTitle(strings: strings, displayOrder: nameDisplayOrder)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `accountPeerId: PeerId` stays as-is — `PeerId` is already the typealias for `EnginePeer.Id`. `peer.id.isReplies` works unchanged because `EnginePeer.Id` exposes `isReplies`.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Drop `._asPeer()` bridges at all 5 call sites
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Sources/ContactMultiselectionController.swift` (L171, L201, L386, L403, L748)
|
||||
|
||||
All 5 call sites have an identical argument fragment:
|
||||
|
||||
```
|
||||
peer: peer._asPeer(),
|
||||
```
|
||||
|
||||
…which must become:
|
||||
|
||||
```
|
||||
peer: peer,
|
||||
```
|
||||
|
||||
The surrounding context differs per site (two distinct `strings/nameDisplayOrder` chains, see below), so we handle the substitution in two batches.
|
||||
|
||||
- [ ] **Step 1: Replace sites L171, L201, L748 (use `strongSelf.presentationData.strings` / `strongSelf.presentationData.nameDisplayOrder` or `self.presentationData.strings` / `self.presentationData.nameDisplayOrder`)**
|
||||
|
||||
Three call sites share identical code but with different leading `accountPeerId` expressions. Apply them individually.
|
||||
|
||||
**L171 and L201 are identical** — both read:
|
||||
|
||||
```swift
|
||||
return EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: params.context.account.peerId, peer: peer._asPeer(), strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer))
|
||||
```
|
||||
|
||||
Use Edit with `replace_all=true`:
|
||||
|
||||
- `old_string`:
|
||||
```
|
||||
return EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: params.context.account.peerId, peer: peer._asPeer(), strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer))
|
||||
```
|
||||
- `new_string`:
|
||||
```
|
||||
return EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: params.context.account.peerId, peer: peer, strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer))
|
||||
```
|
||||
|
||||
**L748** reads:
|
||||
|
||||
```swift
|
||||
tokens.append(EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: self.context.account.peerId, peer: peer._asPeer(), strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer)))
|
||||
```
|
||||
|
||||
Use Edit (no `replace_all` — this line is unique):
|
||||
|
||||
- `old_string`:
|
||||
```
|
||||
tokens.append(EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: self.context.account.peerId, peer: peer._asPeer(), strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer)))
|
||||
```
|
||||
- `new_string`:
|
||||
```
|
||||
tokens.append(EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: self.context.account.peerId, peer: peer, strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer)))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace sites L386 and L403 (use `accountPeerId` local)**
|
||||
|
||||
**L386 and L403 are identical** — both read:
|
||||
|
||||
```swift
|
||||
addedToken = EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: accountPeerId, peer: peer._asPeer(), strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer))
|
||||
```
|
||||
|
||||
Use Edit with `replace_all=true`:
|
||||
|
||||
- `old_string`:
|
||||
```
|
||||
addedToken = EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: accountPeerId, peer: peer._asPeer(), strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer))
|
||||
```
|
||||
- `new_string`:
|
||||
```
|
||||
addedToken = EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: accountPeerId, peer: peer, strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Grep to confirm zero remaining bridge sites**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -n "peerTokenTitle(.*_asPeer())" submodules/TelegramUI/Sources/ContactMultiselectionController.swift
|
||||
```
|
||||
|
||||
Expected: **0 matches**.
|
||||
|
||||
If any match remains, the previous edits missed a line variant — re-read the file around each missed line and apply a targeted Edit for that variant.
|
||||
|
||||
- [ ] **Step 4: Confirm the 5 expected `peer: peer,` call sites now appear**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -n "peerTokenTitle(.*peer: peer," submodules/TelegramUI/Sources/ContactMultiselectionController.swift
|
||||
```
|
||||
|
||||
Expected: 5 matches, line numbers approximately 171, 201, 386, 403, 748 (exact numbers unchanged — the edits don't shift line counts).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Build verification
|
||||
|
||||
**Files:** None edited in this task.
|
||||
|
||||
- [ ] **Step 1: Run the full project build with --continueOnError**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \
|
||||
--cacheDir ~/telegram-bazel-cache \
|
||||
build \
|
||||
--configurationPath build-system/appstore-configuration.json \
|
||||
--gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \
|
||||
--gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 \
|
||||
--continueOnError
|
||||
```
|
||||
|
||||
Expected: build succeeds with exit code 0 and no compilation errors.
|
||||
|
||||
**If the build fails:**
|
||||
|
||||
1. Inspect the error output. Three failure modes are anticipated (all should be rare given the scope):
|
||||
- **Missing `displayTitle` on `EnginePeer`:** unlikely, since L27 was calling it pre-migration. If it happens, verify the `EnginePeer` import chain — but do not add new imports; this file already imports `TelegramCore`.
|
||||
- **A 6th call site exists** that the pre-flight grep missed (e.g., one using a different string pattern like `peer:peer` with no space, or a multi-line call). Locate it with `grep -n "peerTokenTitle" submodules/TelegramUI/Sources/ContactMultiselectionController.swift` and apply the bridge drop manually.
|
||||
- **Unrelated type-inference cascade**, e.g., some `peer` local was previously inferred as `Peer` via the callback chain and now can't be. Read the error line and assess: if it's inside the function body or call site, adjust; if it's elsewhere in the file, it was pre-existing and unrelated — still, don't touch it mid-wave. Abandon per wave-rule 5 if scope creep is required.
|
||||
2. Re-run the build after the fix.
|
||||
|
||||
- [ ] **Step 2: Confirm the post-migration grep is clean**
|
||||
|
||||
Run (after successful build):
|
||||
|
||||
```bash
|
||||
grep -n "peerTokenTitle(.*_asPeer())" submodules/TelegramUI/Sources/ContactMultiselectionController.swift
|
||||
```
|
||||
|
||||
Expected: **0 matches**.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Commit
|
||||
|
||||
**Files:**
|
||||
- `submodules/TelegramUI/Sources/ContactMultiselectionController.swift`
|
||||
|
||||
- [ ] **Step 1: Stage the one file**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramUI/Sources/ContactMultiselectionController.swift
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the staged diff**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --cached --stat
|
||||
```
|
||||
|
||||
Expected: `1 file changed, 6 insertions(+), 6 deletions(-)` (or thereabouts — 1 line's worth of signature change, 1 body-line change, 5 identical call-site changes; each is a 1-line replacement, net zero line-count delta).
|
||||
|
||||
Also run:
|
||||
|
||||
```bash
|
||||
git diff --cached
|
||||
```
|
||||
|
||||
Inspect manually to confirm: (a) the function signature changed `peer: Peer` → `peer: EnginePeer`; (b) the body `EnginePeer(peer).displayTitle(...)` → `peer.displayTitle(...)`; (c) 5 call sites lost `._asPeer()`. No other edits.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Postbox -> TelegramEngine wave 37
|
||||
|
||||
peerTokenTitle: peer parameter Peer -> EnginePeer.
|
||||
|
||||
Drops 5 _asPeer() bridges in ContactMultiselectionController.swift
|
||||
(L171, L201, L386, L403, L748) - bridges installed by prior waves.
|
||||
|
||||
Private free function, single-file change.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Confirm commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git log --oneline -3
|
||||
```
|
||||
|
||||
Expected: the new wave-37 commit at the top.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Update memory / log
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/isaac/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`
|
||||
- Modify: `docs/superpowers/postbox-refactor-log.md`
|
||||
|
||||
- [ ] **Step 1: Read the current memory file for the refactor**
|
||||
|
||||
Read `/Users/isaac/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`.
|
||||
|
||||
- [ ] **Step 2: Update frontmatter + add wave-37 entry**
|
||||
|
||||
Update the `description:` frontmatter field to reference wave 37 outcome (number of bridges dropped, build-iteration count, first-pass-clean-or-not). Add a bullet to "Latest commits" section with the new SHA and a one-line summary. Remove the "peerTokenTitle parameter migration" bullet from the "Wave 37 candidates" section (it's now landed). Update "Recommended wave 37" section to "Recommended wave 38" with a fresh recommendation from the remaining candidates.
|
||||
|
||||
- [ ] **Step 3: Read the refactor log**
|
||||
|
||||
Read `docs/superpowers/postbox-refactor-log.md`, locate the "Wave 36 outcome" section.
|
||||
|
||||
- [ ] **Step 4: Append wave-37 outcome**
|
||||
|
||||
Under the "Wave N outcomes" section, append a "Wave 37 outcome" subsection with:
|
||||
|
||||
- Commit SHA (from `git log --oneline -1`)
|
||||
- File touched (1: ContactMultiselectionController.swift)
|
||||
- Lines changed (6 deletions, 6 insertions)
|
||||
- Bridges dropped (5)
|
||||
- Build iterations to converge (should be 1)
|
||||
- Any lessons observed (likely none — this wave is mechanical)
|
||||
|
||||
- [ ] **Step 5: Commit memory + log update**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/postbox-refactor-log.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs: log wave 37 outcome
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
(Memory file under `~/.claude/` is not in the repo — save it separately via the Write tool; do not try to `git add` it.)
|
||||
|
||||
---
|
||||
|
||||
## Self-review results
|
||||
|
||||
**Spec coverage:** Every scope item in the spec maps to a task:
|
||||
- Spec L21 signature change → Task 2 Step 2
|
||||
- Spec L27 body simplification → Task 2 Step 2
|
||||
- Spec L171/201/386/403/748 bridge drops → Task 3 Steps 1–2
|
||||
- Spec verification (grep + build + post-grep) → Task 1 + Task 4
|
||||
- Spec commit message → Task 5 Step 3
|
||||
|
||||
Out-of-scope items (L459, `import Postbox`, `accountPeerId: PeerId`) remain explicitly untouched — no task edits them.
|
||||
|
||||
**Placeholder scan:** No TBD, TODO, placeholder phrases, or "handle edge cases"-style hand-waves. Every step has a concrete command or code block.
|
||||
|
||||
**Type consistency:** `peer: EnginePeer`, `EnginePeer.Id` (= `PeerId` typealias), and `EnginePeer.displayTitle(strings:displayOrder:)` are all consistent across tasks.
|
||||
666
docs/superpowers/plans/2026-04-24-rcp-peers-engine-migration.md
Normal file
666
docs/superpowers/plans/2026-04-24-rcp-peers-engine-migration.md
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
# Wave 44 — RenderedChannelParticipant.peers Engine-Peer Migration 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 `RenderedChannelParticipant.peers: [PeerId: Peer]` to `[EnginePeer.Id: EnginePeer]`. Closes the wave-41 ratchet — the public struct no longer leaks raw Postbox `Peer` in any field.
|
||||
|
||||
**Architecture:** Single atomic commit. Declaration in TelegramCore changes; 8 TelegramCore producer functions wrap raw `Peer` values at their local-dict insertion points (inside transactions that already read from Postbox); 11 consumer-surface bridges drop (6 `EnginePeer(peer)` read-wraps + 5 `.mapValues({ $0._asPeer() })` constructor-unwrap transforms); 1 consumer-surface unwrap is added where an extracted `EnginePeer` value flows into a `SimpleDictionary<PeerId, Peer>`.
|
||||
|
||||
**Tech Stack:** Swift, Bazel (via `python3 build-system/Make/Make.py`), Postbox, TelegramCore, TelegramEngine. No unit tests — full-build verification only.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-24-rcp-peers-engine-migration-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
All edits happen in existing files — no new files created. Touched files:
|
||||
|
||||
**TelegramCore (declaration + producers, 9 files):**
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift` (declaration)
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift`
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift`
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift`
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift`
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift`
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift`
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift`
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift`
|
||||
|
||||
**Consumers (drops + 1 add, 5 files):**
|
||||
- `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift`
|
||||
- `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift`
|
||||
- `submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift`
|
||||
- `submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift`
|
||||
- `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift`
|
||||
|
||||
**Total:** 14 files, ~30 edits, one atomic commit.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Pre-flight re-verification
|
||||
|
||||
**Purpose:** Confirm the grep surface matches the spec before editing anything. If any site count diverges, stop and update the spec.
|
||||
|
||||
**Files:** None modified.
|
||||
|
||||
- [ ] **Step 1.1: Verify 7 `participant.peers[...]` consumer read sites**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rnE "participant\.peers\[|rcp\.peers\[|renderedParticipant\.peers\[" --include="*.swift" submodules/ 2>/dev/null
|
||||
```
|
||||
|
||||
Expected output — exactly 6 bracketed-indexing sites (the 7th site, iteration without bracket-indexing, is checked in Step 1.2):
|
||||
- `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift:293`
|
||||
- `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift:835`
|
||||
- `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift:869`
|
||||
- `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift:1087`
|
||||
- `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift:1121`
|
||||
- `submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift:164`
|
||||
|
||||
If any line numbers differ by more than ±3 lines, re-read surrounding context to confirm identity. If a NEW site appears that isn't in the spec, STOP and update the spec before proceeding.
|
||||
|
||||
- [ ] **Step 1.2: Verify the iteration site is still at the expected line**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -nE "for \(.*,.* peer\) in participant\.peers" submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift
|
||||
```
|
||||
|
||||
Expected: `672: for (_, peer) in participant.peers {`
|
||||
|
||||
- [ ] **Step 1.3: Verify all 8 TelegramCore producers still build `var peers: [PeerId: Peer] = [:]` locally**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rnE "^[[:space:]]+var peers: \[PeerId: Peer\] = \[:\]" submodules/TelegramCore/Sources/TelegramEngine/ 2>/dev/null
|
||||
```
|
||||
|
||||
Expected 8 matches, one per producer file:
|
||||
- `Messages/RequestStartBot.swift:61`
|
||||
- `Peers/ChannelOwnershipTransfer.swift:170`
|
||||
- `Peers/JoinChannel.swift:59`
|
||||
- `Peers/AddPeerMember.swift:242`
|
||||
- `Peers/PeerAdmins.swift:251`
|
||||
- `Peers/ChannelBlacklist.swift:128`
|
||||
- `Peers/Ranks.swift:60`
|
||||
- `Peers/ChannelMembers.swift:102`
|
||||
|
||||
If a producer is missing from this grep, check whether it now receives `peers` as a parameter rather than building locally — if so, STOP and update the spec (chain-migration needed).
|
||||
|
||||
- [ ] **Step 1.4: Verify no `as?` / `is TelegramX` casts exist on extracted dict values**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rnE "peer = participant\.peers" --include="*.swift" -A 4 submodules/ 2>/dev/null | grep -E "as\?|is Telegram"
|
||||
```
|
||||
|
||||
Expected output: empty. If this returns non-empty, STOP and update the spec.
|
||||
|
||||
- [ ] **Step 1.5: Verify no one is assigning into `participant.peers` (writes would break the migration)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rnE "participant\.peers\[[^]]+\][[:space:]]*=" --include="*.swift" submodules/ 2>/dev/null
|
||||
```
|
||||
|
||||
Expected output: empty (`.peers` is a `let`; no writes possible anyway, but double-check).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Migrate declaration in ChannelParticipants.swift
|
||||
|
||||
**Purpose:** Change the struct field type and init default.
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift:11, 14`
|
||||
|
||||
- [ ] **Step 2.1: Change field declaration**
|
||||
|
||||
In `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift`, line 11:
|
||||
|
||||
```swift
|
||||
// before
|
||||
public let peers: [PeerId: Peer]
|
||||
|
||||
// after
|
||||
public let peers: [EnginePeer.Id: EnginePeer]
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: Change init default**
|
||||
|
||||
Same file, line 14:
|
||||
|
||||
```swift
|
||||
// before
|
||||
public init(participant: ChannelParticipant, peer: EnginePeer, peers: [PeerId: Peer] = [:], presences: [PeerId: PeerPresence] = [:]) {
|
||||
|
||||
// after
|
||||
public init(participant: ChannelParticipant, peer: EnginePeer, peers: [EnginePeer.Id: EnginePeer] = [:], presences: [PeerId: PeerPresence] = [:]) {
|
||||
```
|
||||
|
||||
Do NOT commit yet — this leaves the repo in a broken state until producers and consumers are updated.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Migrate TelegramCore producers (8 files)
|
||||
|
||||
**Purpose:** Each of the 8 TelegramCore producers builds a local `peers: [PeerId: Peer] = [:]` dict from raw Postbox peers inside a transaction. Migrate each local dict to `[EnginePeer.Id: EnginePeer] = [:]` and wrap every insertion value with `EnginePeer(...)`.
|
||||
|
||||
**Pattern (applies to every sub-step):**
|
||||
```swift
|
||||
// before
|
||||
var peers: [PeerId: Peer] = [:]
|
||||
peers[X.id] = X
|
||||
|
||||
// after
|
||||
var peers: [EnginePeer.Id: EnginePeer] = [:]
|
||||
peers[X.id] = EnginePeer(X)
|
||||
```
|
||||
|
||||
The surrounding `presences: [PeerId: PeerPresence]` dict and the `RCP(..., peer: EnginePeer(X), ...)` wrap on the primary `peer` field both stay unchanged.
|
||||
|
||||
- [ ] **Step 3.1: Migrate `RequestStartBot.swift`**
|
||||
|
||||
File: `submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift`
|
||||
|
||||
Line 61: `var peers: [PeerId: Peer] = [:]` → `var peers: [EnginePeer.Id: EnginePeer] = [:]`
|
||||
Line 64: `peers[peer.id] = peer` → `peers[peer.id] = EnginePeer(peer)`
|
||||
|
||||
- [ ] **Step 3.2: Migrate `ChannelOwnershipTransfer.swift`**
|
||||
|
||||
File: `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift`
|
||||
|
||||
Line 170: `var peers: [PeerId: Peer] = [:]` → `var peers: [EnginePeer.Id: EnginePeer] = [:]`
|
||||
Line 172: `peers[accountUser.id] = accountUser` → `peers[accountUser.id] = EnginePeer(accountUser)`
|
||||
Line 176: `peers[user.id] = user` → `peers[user.id] = EnginePeer(user)`
|
||||
|
||||
Line 180 is a double-RCP-construction; `peers:` reuses the same local — no change at line 180.
|
||||
|
||||
- [ ] **Step 3.3: Migrate `JoinChannel.swift`**
|
||||
|
||||
File: `submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift`
|
||||
|
||||
Line 59: `var peers: [PeerId: Peer] = [:]` → `var peers: [EnginePeer.Id: EnginePeer] = [:]`
|
||||
Line 64: `peers[account.peerId] = peer` → `peers[account.peerId] = EnginePeer(peer)`
|
||||
Line 77: `peers[peer.id] = peer` → `peers[peer.id] = EnginePeer(peer)`
|
||||
|
||||
- [ ] **Step 3.4: Migrate `AddPeerMember.swift`**
|
||||
|
||||
File: `submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift`
|
||||
|
||||
Line 242: `var peers: [PeerId: Peer] = [:]` → `var peers: [EnginePeer.Id: EnginePeer] = [:]`
|
||||
Line 244: `peers[memberPeer.id] = memberPeer` → `peers[memberPeer.id] = EnginePeer(memberPeer)`
|
||||
Line 251: `peers[peer.id] = peer` → `peers[peer.id] = EnginePeer(peer)`
|
||||
|
||||
- [ ] **Step 3.5: Migrate `PeerAdmins.swift`**
|
||||
|
||||
File: `submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift`
|
||||
|
||||
Line 251: `var peers: [PeerId: Peer] = [:]` → `var peers: [EnginePeer.Id: EnginePeer] = [:]`
|
||||
Line 253: `peers[adminPeer.id] = adminPeer` → `peers[adminPeer.id] = EnginePeer(adminPeer)`
|
||||
Line 259: `peers[peer.id] = peer` → `peers[peer.id] = EnginePeer(peer)`
|
||||
|
||||
- [ ] **Step 3.6: Migrate `ChannelBlacklist.swift`**
|
||||
|
||||
File: `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift`
|
||||
|
||||
Line 128: `var peers: [PeerId: Peer] = [:]` → `var peers: [EnginePeer.Id: EnginePeer] = [:]`
|
||||
Line 130: `peers[memberPeer.id] = memberPeer` → `peers[memberPeer.id] = EnginePeer(memberPeer)`
|
||||
Line 136: `peers[peer.id] = peer` → `peers[peer.id] = EnginePeer(peer)`
|
||||
|
||||
- [ ] **Step 3.7: Migrate `Ranks.swift`**
|
||||
|
||||
File: `submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift`
|
||||
|
||||
Line 60: `var peers: [PeerId: Peer] = [:]` → `var peers: [EnginePeer.Id: EnginePeer] = [:]`
|
||||
Line 62: `peers[user.id] = user` → `peers[user.id] = EnginePeer(user)`
|
||||
Line 68: `peers[peer.id] = peer` → `peers[peer.id] = EnginePeer(peer)`
|
||||
|
||||
- [ ] **Step 3.8: Migrate `ChannelMembers.swift`**
|
||||
|
||||
File: `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift`
|
||||
|
||||
Line 102: `var peers: [PeerId: Peer] = [:]` → `var peers: [EnginePeer.Id: EnginePeer] = [:]`
|
||||
Line 105: `peers[peer.id] = peer` → `peers[peer.id] = EnginePeer(peer)`
|
||||
|
||||
- [ ] **Step 3.9: Post-producer verification**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rnE "^[[:space:]]+var peers: \[PeerId: Peer\] = \[:\]" submodules/TelegramCore/Sources/TelegramEngine/ 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: no output (all 8 have been converted).
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rnE "^[[:space:]]+var peers: \[EnginePeer\.Id: EnginePeer\] = \[:\]" submodules/TelegramCore/Sources/TelegramEngine/ 2>/dev/null | wc -l
|
||||
```
|
||||
|
||||
Expected: `8` (or ` 8`).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Drop 5 consumer `.mapValues({ $0._asPeer() })` transforms
|
||||
|
||||
**Purpose:** These consumer-side constructors build a `[EnginePeer.Id: EnginePeer]` source dict locally and currently unwrap to `[PeerId: Peer]` via `.mapValues({ $0._asPeer() })` to feed the old constructor signature. After Task 2, the constructor expects engine values directly — the transform becomes a no-op and is removed.
|
||||
|
||||
**Pattern (applies to every sub-step):**
|
||||
```swift
|
||||
// before
|
||||
peers: peers.mapValues({ $0._asPeer() })
|
||||
|
||||
// after
|
||||
peers: peers
|
||||
```
|
||||
|
||||
- [ ] **Step 4.1: `ChannelAdminsController.swift:926`**
|
||||
|
||||
File: `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift`
|
||||
|
||||
Line 926 (long line): locate the substring `peers: peers.mapValues({ $0._asPeer() })` and replace with `peers: peers`.
|
||||
|
||||
- [ ] **Step 4.2: `ChannelMembersSearchContainerNode.swift:994`**
|
||||
|
||||
File: `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift`
|
||||
|
||||
Line 994: replace `peers: peers.mapValues({ $0._asPeer() })` → `peers: peers`.
|
||||
|
||||
- [ ] **Step 4.3: `ChannelMembersSearchContainerNode.swift:998`**
|
||||
|
||||
Same file, line 998: replace `peers: peers.mapValues({ $0._asPeer() })` → `peers: peers`.
|
||||
|
||||
- [ ] **Step 4.4: `ChannelMembersSearchControllerNode.swift:409`**
|
||||
|
||||
File: `submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift`
|
||||
|
||||
Line 409: replace `peers: peers.mapValues({ $0._asPeer() })` → `peers: peers`.
|
||||
|
||||
- [ ] **Step 4.5: `ChannelMembersSearchControllerNode.swift:413`**
|
||||
|
||||
Same file, line 413: replace `peers: peers.mapValues({ $0._asPeer() })` → `peers: peers`.
|
||||
|
||||
- [ ] **Step 4.6: Post-Task-4 verification**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rnE "peers\.mapValues\(\{ \$0\._asPeer\(\) \}\)" --include="*.swift" submodules/ 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: no output (all 5 drops applied). If any remain, locate and drop.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Drop 6 consumer `EnginePeer(peer).displayTitle(...)` read-wraps
|
||||
|
||||
**Purpose:** Each site extracts `peer` from `participant.peers[X]`, wraps with `EnginePeer(peer)` to call `.displayTitle(...)`. After Task 2 the extracted `peer` is already `EnginePeer` — drop the wrap.
|
||||
|
||||
**Pattern (applies to every sub-step):**
|
||||
```swift
|
||||
// before
|
||||
EnginePeer(peer).displayTitle(strings: ..., displayOrder: ...)
|
||||
|
||||
// after
|
||||
peer.displayTitle(strings: ..., displayOrder: ...)
|
||||
```
|
||||
|
||||
- [ ] **Step 5.1: `ChannelAdminsController.swift:297`**
|
||||
|
||||
File: `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift`, line 297.
|
||||
|
||||
Replace:
|
||||
```swift
|
||||
peerText = strings.Channel_Management_PromotedBy(EnginePeer(peer).displayTitle(strings: strings, displayOrder: nameDisplayOrder)).string
|
||||
```
|
||||
with:
|
||||
```swift
|
||||
peerText = strings.Channel_Management_PromotedBy(peer.displayTitle(strings: strings, displayOrder: nameDisplayOrder)).string
|
||||
```
|
||||
|
||||
The adjacent `peer.id == participant.peer.id` comparison at line 294 stays unchanged (both are `EnginePeer.Id`).
|
||||
|
||||
- [ ] **Step 5.2: `ChannelMembersSearchContainerNode.swift:839`**
|
||||
|
||||
File: `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift`, line 839.
|
||||
|
||||
Replace:
|
||||
```swift
|
||||
label = presentationData.strings.Channel_Management_PromotedBy(EnginePeer(peer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string
|
||||
```
|
||||
with:
|
||||
```swift
|
||||
label = presentationData.strings.Channel_Management_PromotedBy(peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string
|
||||
```
|
||||
|
||||
- [ ] **Step 5.3: `ChannelMembersSearchContainerNode.swift:870`**
|
||||
|
||||
Same file, line 870.
|
||||
|
||||
Replace:
|
||||
```swift
|
||||
label = presentationData.strings.Channel_Management_RemovedBy(EnginePeer(peer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string
|
||||
```
|
||||
with:
|
||||
```swift
|
||||
label = presentationData.strings.Channel_Management_RemovedBy(peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string
|
||||
```
|
||||
|
||||
- [ ] **Step 5.4: `ChannelMembersSearchContainerNode.swift:1091`**
|
||||
|
||||
Same file, line 1091.
|
||||
|
||||
Replace:
|
||||
```swift
|
||||
label = presentationData.strings.Channel_Management_PromotedBy(EnginePeer(peer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string
|
||||
```
|
||||
with:
|
||||
```swift
|
||||
label = presentationData.strings.Channel_Management_PromotedBy(peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string
|
||||
```
|
||||
|
||||
- [ ] **Step 5.5: `ChannelMembersSearchContainerNode.swift:1122`**
|
||||
|
||||
Same file, line 1122.
|
||||
|
||||
Replace:
|
||||
```swift
|
||||
label = presentationData.strings.Channel_Management_RemovedBy(EnginePeer(peer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string
|
||||
```
|
||||
with:
|
||||
```swift
|
||||
label = presentationData.strings.Channel_Management_RemovedBy(peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string
|
||||
```
|
||||
|
||||
- [ ] **Step 5.6: `ChannelBlacklistController.swift:165`**
|
||||
|
||||
File: `submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift`, line 165.
|
||||
|
||||
Replace:
|
||||
```swift
|
||||
text = .text(strings.Channel_Management_RemovedBy(EnginePeer(peer).displayTitle(strings: strings, displayOrder: nameDisplayOrder)).string, .secondary)
|
||||
```
|
||||
with:
|
||||
```swift
|
||||
text = .text(strings.Channel_Management_RemovedBy(peer.displayTitle(strings: strings, displayOrder: nameDisplayOrder)).string, .secondary)
|
||||
```
|
||||
|
||||
- [ ] **Step 5.7: Post-Task-5 verification**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rnE "EnginePeer\(peer\)\.displayTitle" --include="*.swift" submodules/PeerInfoUI/ 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: no output within PeerInfoUI. (Other modules may still have unrelated `EnginePeer(peer).displayTitle` usages on non-RCP-peers peers — those are out of scope.)
|
||||
|
||||
Run specifically for the 6 migrated sites:
|
||||
```bash
|
||||
grep -n "EnginePeer(peer)\.displayTitle" submodules/PeerInfoUI/Sources/ChannelAdminsController.swift submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add 1 consumer unwrap at ChatRecentActionsHistoryTransition
|
||||
|
||||
**Purpose:** The one site that iterates `participant.peers` and inserts values into a `SimpleDictionary<PeerId, Peer>` container. After Task 2, the iterated `peer` is `EnginePeer`; the outer container still expects raw `Peer`. Unwrap at the insertion site.
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift:673`
|
||||
|
||||
- [ ] **Step 6.1: Replace insertion line**
|
||||
|
||||
In `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift`:
|
||||
|
||||
Context (lines 672–674, unchanged outside line 673):
|
||||
```swift
|
||||
for (_, peer) in participant.peers {
|
||||
peers[peer.id] = peer
|
||||
}
|
||||
```
|
||||
|
||||
After edit:
|
||||
```swift
|
||||
for (_, peer) in participant.peers {
|
||||
peers[peer.id] = peer._asPeer()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6.2: Spot-check nearby wave-41 unwrap (reference, no change)**
|
||||
|
||||
Line 675 in the same function is `peers[participant.peer.id] = participant.peer._asPeer()` — a wave-41 artifact, unrelated to this wave. Leave unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Full build verification
|
||||
|
||||
**Purpose:** Verify the atomic change set compiles. Produces the ONLY real test signal for this wave.
|
||||
|
||||
**Files:** None modified; this is a build run.
|
||||
|
||||
- [ ] **Step 7.1: Run the full build**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \
|
||||
--cacheDir ~/telegram-bazel-cache \
|
||||
build \
|
||||
--configurationPath build-system/appstore-configuration.json \
|
||||
--gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \
|
||||
--gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 \
|
||||
--continueOnError
|
||||
```
|
||||
|
||||
Expected: build succeeds. Look for `INFO: Build completed successfully` near the end.
|
||||
|
||||
- [ ] **Step 7.2: If build fails — triage**
|
||||
|
||||
Expected failure patterns (from wave-41 lesson, budget 2–3 iterations):
|
||||
|
||||
1. **Missing producer wrap** — compiler error `cannot assign value of type 'Peer' to subscript of type 'EnginePeer'` (or similar) at a TelegramCore producer file → check that file's `var peers:` decl was converted AND all insertion RHS values are wrapped.
|
||||
2. **Missed consumer site** — compiler error at a `.displayTitle` call on a raw Peer → find `EnginePeer(peer).displayTitle` site that Task 5 missed; drop the wrap.
|
||||
3. **Mismatched mapValues drop** — `cannot convert value of type '[EnginePeer.Id: EnginePeer]' to expected argument type '[PeerId: Peer]'` → the spec's risk #3 triggered (a `.mapValues` site had a raw-Peer source after all); replace the drop with `peers.mapValues(EnginePeer.init)` at that site instead.
|
||||
4. **New grep surface** — compiler complains about a site not in this plan → add it to the commit's scope; log it to the outcome doc.
|
||||
|
||||
Apply fixes, re-run Step 7.1. Repeat up to 3 iterations before re-evaluating scope.
|
||||
|
||||
- [ ] **Step 7.3: Post-build final grep audit**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rnE "participant\.peers\[[^]]+\]" --include="*.swift" submodules/ 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: the same 6 read sites as Step 1.1 (now without `EnginePeer(peer)` wraps).
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rnE "peers\.mapValues\(\{ \$0\._asPeer\(\) \}\)" --include="*.swift" submodules/ 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -n "public let peers: \[" submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift
|
||||
```
|
||||
|
||||
Expected: `11: public let peers: [EnginePeer.Id: EnginePeer]`.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Atomic commit
|
||||
|
||||
**Purpose:** Land all wave-44 edits in ONE commit. Explicitly enumerate files in `git add` (wave-39 lesson — re-confirmed in waves 41, 42, 43) to avoid pulling in the pre-existing working-tree WIP listed in the spec's risk section (`ListView.swift`, `ChatMessageTransitionNode.swift`, tulsi/, TgVoip/, libx264/).
|
||||
|
||||
**Files:** Commits all 14 wave-44 files.
|
||||
|
||||
- [ ] **Step 8.1: Confirm working-tree state**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected (pre-existing WIP, unchanged):
|
||||
- ` m build-system/bazel-rules/sourcekit-bazel-bsp`
|
||||
- ` M submodules/Display/Source/ListView.swift` (do NOT include)
|
||||
- ` M submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift` (do NOT include)
|
||||
- `?? build-system/tulsi/` (do NOT include)
|
||||
- `?? submodules/TgVoip/` (do NOT include)
|
||||
- `?? third-party/libx264/` (do NOT include)
|
||||
|
||||
Plus the wave-44 modified files:
|
||||
- ` M submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift`
|
||||
- ` M submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift`
|
||||
- ` M submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift`
|
||||
- ` M submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift`
|
||||
- ` M submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift`
|
||||
- ` M submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift`
|
||||
- ` M submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift`
|
||||
- ` M submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift`
|
||||
- ` M submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift`
|
||||
- ` M submodules/PeerInfoUI/Sources/ChannelAdminsController.swift`
|
||||
- ` M submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift`
|
||||
- ` M submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift`
|
||||
- ` M submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift`
|
||||
- ` M submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift`
|
||||
|
||||
If the set of wave-44-modified files doesn't match exactly (extra or missing), STOP and investigate before committing.
|
||||
|
||||
- [ ] **Step 8.2: Stage only wave-44 files**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelAdminsController.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift \
|
||||
submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift
|
||||
```
|
||||
|
||||
- [ ] **Step 8.3: Verify staged set matches expected**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git diff --cached --stat
|
||||
```
|
||||
|
||||
Expected: exactly 14 files staged, all from the wave-44 list. If `ListView.swift`, `ChatMessageTransitionNode.swift`, `bazel-rules/sourcekit-bazel-bsp`, `tulsi/`, `TgVoip/`, or `libx264/` appear here, unstage them.
|
||||
|
||||
- [ ] **Step 8.4: Commit**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Postbox -> TelegramEngine wave 44
|
||||
|
||||
Migrate RenderedChannelParticipant.peers from [PeerId: Peer] to
|
||||
[EnginePeer.Id: EnginePeer]. Closes the wave-41 ratchet — the public
|
||||
struct no longer leaks raw Peer types in any field (presences stays
|
||||
Postbox-typed; separate migration).
|
||||
|
||||
Consumer-surface: -10 bridges. Dropped 6 EnginePeer(peer) read-wraps
|
||||
at participant.peers[...] extraction sites across
|
||||
ChannelAdminsController, ChannelMembersSearchContainerNode,
|
||||
ChannelBlacklistController. Dropped 5 .mapValues({ $0._asPeer() })
|
||||
constructor-unwrap transforms in ChannelAdminsController,
|
||||
ChannelMembersSearchContainerNode, ChannelMembersSearchControllerNode.
|
||||
Added 1 ._asPeer() at ChatRecentActionsHistoryTransition.swift:673
|
||||
where the iterated value is inserted into a raw-Peer SimpleDictionary.
|
||||
|
||||
TelegramCore producers: 8 files build the local peers dict inside
|
||||
postbox.transaction and wrap at the insertion point. ChannelMembers,
|
||||
RequestStartBot, ChannelOwnershipTransfer, JoinChannel, AddPeerMember,
|
||||
PeerAdmins, ChannelBlacklist, Ranks.
|
||||
|
||||
No unit tests in this project; full Telegram/Telegram build verified
|
||||
under configuration=debug_sim_arm64.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 8.5: Verify commit**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git log -1 --stat
|
||||
```
|
||||
|
||||
Expected: commit with 14 files changed, message starting with `Postbox -> TelegramEngine wave 44`.
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: no M- or A-flagged wave-44 files (all committed); only the pre-existing WIP (`ListView.swift`, `ChatMessageTransitionNode.swift`, etc.) remains.
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
If the wave cannot be completed (e.g., build fails after 4+ iterations and the scope balloons beyond plan):
|
||||
|
||||
```bash
|
||||
git restore --staged \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelAdminsController.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift \
|
||||
submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift
|
||||
|
||||
git checkout -- \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelAdminsController.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift \
|
||||
submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift
|
||||
```
|
||||
|
||||
Then document what was learned in an outcome doc and update `project_postbox_refactor_next_wave.md`.
|
||||
|
||||
---
|
||||
|
||||
## Success criteria (from spec)
|
||||
|
||||
1. ✅ `ChannelParticipants.swift` has `peers: [EnginePeer.Id: EnginePeer]` declaration (Task 2).
|
||||
2. ✅ All 8 TelegramCore producers compile with wrapped inserts (Task 3).
|
||||
3. ✅ All 5 consumer `.mapValues({ $0._asPeer() })` transforms are removed (Task 4).
|
||||
4. ✅ All 6 consumer `EnginePeer(peer).displayTitle(...)` wraps on extracted dict values are removed (Task 5).
|
||||
5. ✅ `ChatRecentActionsHistoryTransition.swift:673` uses `peer._asPeer()` for the SimpleDictionary insertion value (Task 6).
|
||||
6. ✅ Full `Telegram/Telegram` build (`configuration=debug_sim_arm64`) is clean — **one** atomic commit (Tasks 7, 8).
|
||||
7. ✅ Grep post-migration: `participant.peers[` returns only engine-typed call sites; no residual `EnginePeer(peer)` on `.peers[...]` extractions (Steps 5.7, 7.3).
|
||||
|
|
@ -0,0 +1,860 @@
|
|||
# Wave 41 — `RenderedChannelParticipant.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 `TelegramCore.RenderedChannelParticipant.peer` from Postbox `Peer` to TelegramCore `EnginePeer`. Drop ~37 bridges (net ~−14 after adds) and eliminate 2 Shape-C ratchet wraps installed by wave 39.
|
||||
|
||||
**Architecture:** Single atomic commit. One TelegramCore struct field change + 16 TelegramCore internal construction sites wrapped with `EnginePeer(peer)` + 17 consumer files updated: ZERO sites untouched (~160), ~32 DROP sites unwrapped, 9 CAST sites rewritten to pattern-match, 3 ADD-ASPEER sites append `._asPeer()`, 7 ADD-WRAP consumer constructors wrap raw `Peer` with `EnginePeer`.
|
||||
|
||||
**Tech Stack:** Swift, Bazel (`Make.py` wrapper), TelegramCore, Postbox → TelegramEngine refactor conventions per `CLAUDE.md`.
|
||||
|
||||
**Build command:**
|
||||
```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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Created:** none.
|
||||
|
||||
**Modified (27 files):**
|
||||
|
||||
TelegramCore (10 files):
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift` — struct field type + init param + Equatable impl
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift` — 1 constructor wrap
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift` — 1 constructor wrap
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelAdminEventLogs.swift` — 7 constructor wraps
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift` — 1 constructor wrap
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift` — 1 constructor wrap
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift` — 2 constructor wraps
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift` — 1 constructor wrap
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift` — 1 constructor wrap
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift` — 1 constructor wrap
|
||||
|
||||
PeerInfoUI (6 files):
|
||||
- `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift`
|
||||
- `submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift`
|
||||
- `submodules/PeerInfoUI/Sources/ChannelMembersController.swift`
|
||||
- `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift`
|
||||
- `submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift`
|
||||
- `submodules/PeerInfoUI/Sources/ChannelPermissionsController.swift`
|
||||
|
||||
Other consumers (11 files):
|
||||
- `submodules/SearchPeerMembers/Sources/SearchPeerMembers.swift`
|
||||
- `submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift`
|
||||
- `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift`
|
||||
- `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsFilterController.swift`
|
||||
- `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift`
|
||||
- `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoMembers.swift`
|
||||
- `submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreenState.swift`
|
||||
- `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentLiveChatComponent.swift`
|
||||
- `submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift`
|
||||
- `submodules/TemporaryCachedPeerDataManager/Sources/ChannelMemberCategoryListContext.swift` *(no `participant.peer` edits needed — all ZERO; file touched only if build surfaces type issues)*
|
||||
- `submodules/TemporaryCachedPeerDataManager/Sources/PeerChannelMemberCategoriesContextsManager.swift` *(no edits expected — only `item.peer.id` reference is ZERO)*
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Migrate the struct definition
|
||||
|
||||
**File:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift`
|
||||
|
||||
- [ ] **Step 1.1: Edit struct field, init param, and Equatable impl**
|
||||
|
||||
Replace the entire struct body:
|
||||
|
||||
```swift
|
||||
public struct RenderedChannelParticipant: Equatable {
|
||||
public let participant: ChannelParticipant
|
||||
public let peer: EnginePeer
|
||||
public let peers: [PeerId: Peer]
|
||||
public let presences: [PeerId: PeerPresence]
|
||||
|
||||
public init(participant: ChannelParticipant, peer: EnginePeer, peers: [PeerId: Peer] = [:], presences: [PeerId: PeerPresence] = [:]) {
|
||||
self.participant = participant
|
||||
self.peer = peer
|
||||
self.peers = peers
|
||||
self.presences = presences
|
||||
}
|
||||
|
||||
public static func ==(lhs: RenderedChannelParticipant, rhs: RenderedChannelParticipant) -> Bool {
|
||||
return lhs.participant == rhs.participant && lhs.peer == rhs.peer
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: the file already imports both `Postbox` (for `Peer`/`PeerId`/`PeerPresence`) and TelegramCore internal symbols (`EnginePeer` visible from within the same module). No import changes needed.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Wrap TelegramCore-internal constructor sites
|
||||
|
||||
Each site receives a raw `Peer` and must now wrap it with `EnginePeer(peer)`. All edits are identical in shape.
|
||||
|
||||
- [ ] **Step 2.1:** `submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift:65`
|
||||
|
||||
Before:
|
||||
```swift
|
||||
return .channelParticipant(RenderedChannelParticipant(participant: participant, peer: peer, peers: peers, presences: presences))
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
return .channelParticipant(RenderedChannelParticipant(participant: participant, peer: EnginePeer(peer), peers: peers, presences: presences))
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift:255`
|
||||
|
||||
Before:
|
||||
```swift
|
||||
return (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: memberPeer, peers: peers, presences: presences))
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
return (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: EnginePeer(memberPeer), peers: peers, presences: presences))
|
||||
```
|
||||
|
||||
- [ ] **Step 2.3:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelAdminEventLogs.swift` — 7 constructor wraps
|
||||
|
||||
Line 271:
|
||||
```swift
|
||||
action = .participantInvite(RenderedChannelParticipant(participant: participant, peer: peer))
|
||||
// becomes:
|
||||
action = .participantInvite(RenderedChannelParticipant(participant: participant, peer: EnginePeer(peer)))
|
||||
```
|
||||
|
||||
Line 279 (two constructors on one line):
|
||||
```swift
|
||||
action = .participantToggleBan(prev: RenderedChannelParticipant(participant: prevParticipant, peer: prevPeer), new: RenderedChannelParticipant(participant: newParticipant, peer: newPeer))
|
||||
// becomes:
|
||||
action = .participantToggleBan(prev: RenderedChannelParticipant(participant: prevParticipant, peer: EnginePeer(prevPeer)), new: RenderedChannelParticipant(participant: newParticipant, peer: EnginePeer(newPeer)))
|
||||
```
|
||||
|
||||
Line 287 (two constructors on one line):
|
||||
```swift
|
||||
action = .participantToggleAdmin(prev: RenderedChannelParticipant(participant: prevParticipant, peer: prevPeer), new: RenderedChannelParticipant(participant: newParticipant, peer: newPeer))
|
||||
// becomes:
|
||||
action = .participantToggleAdmin(prev: RenderedChannelParticipant(participant: prevParticipant, peer: EnginePeer(prevPeer)), new: RenderedChannelParticipant(participant: newParticipant, peer: EnginePeer(newPeer)))
|
||||
```
|
||||
|
||||
Line 483 (two constructors on one line):
|
||||
```swift
|
||||
action = .participantSubscriptionExtended(prev: RenderedChannelParticipant(participant: prevParticipant, peer: prevPeer), new: RenderedChannelParticipant(participant: newParticipant, peer: newPeer))
|
||||
// becomes:
|
||||
action = .participantSubscriptionExtended(prev: RenderedChannelParticipant(participant: prevParticipant, peer: EnginePeer(prevPeer)), new: RenderedChannelParticipant(participant: newParticipant, peer: EnginePeer(newPeer)))
|
||||
```
|
||||
|
||||
- [ ] **Step 2.4:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift:140`
|
||||
|
||||
Before:
|
||||
```swift
|
||||
return (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: memberPeer, peers: peers, presences: presences), isMember)
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
return (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: EnginePeer(memberPeer), peers: peers, presences: presences), isMember)
|
||||
```
|
||||
|
||||
- [ ] **Step 2.5:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift:115`
|
||||
|
||||
Before:
|
||||
```swift
|
||||
items.append(RenderedChannelParticipant(participant: participant, peer: peer, peers: peers, presences: renderedPresences))
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
items.append(RenderedChannelParticipant(participant: participant, peer: EnginePeer(peer), peers: peers, presences: renderedPresences))
|
||||
```
|
||||
|
||||
- [ ] **Step 2.6:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift:180`
|
||||
|
||||
Before:
|
||||
```swift
|
||||
return [(currentCreator, RenderedChannelParticipant(participant: updatedPreviousCreator, peer: accountUser, peers: peers, presences: presences)), (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: user, peers: peers, presences: presences))]
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
return [(currentCreator, RenderedChannelParticipant(participant: updatedPreviousCreator, peer: EnginePeer(accountUser), peers: peers, presences: presences)), (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: EnginePeer(user), peers: peers, presences: presences))]
|
||||
```
|
||||
|
||||
- [ ] **Step 2.7:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift:82`
|
||||
|
||||
Before:
|
||||
```swift
|
||||
return RenderedChannelParticipant(participant: updatedParticipant, peer: peer, peers: peers, presences: presences)
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
return RenderedChannelParticipant(participant: updatedParticipant, peer: EnginePeer(peer), peers: peers, presences: presences)
|
||||
```
|
||||
|
||||
- [ ] **Step 2.8:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift:262`
|
||||
|
||||
Before:
|
||||
```swift
|
||||
return (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: adminPeer, peers: peers, presences: presences))
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
return (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: EnginePeer(adminPeer), peers: peers, presences: presences))
|
||||
```
|
||||
|
||||
- [ ] **Step 2.9:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift:95`
|
||||
|
||||
Before:
|
||||
```swift
|
||||
return (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: user, peers: peers, presences: presences))
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
return (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: EnginePeer(user), peers: peers, presences: presences))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Consumer — PeerInfoUI/ChannelAdminsController.swift
|
||||
|
||||
**File:** `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift`
|
||||
|
||||
- [ ] **Step 3.1:** Line 326 — DROP `EnginePeer(participant.peer)` wrap.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
return ItemListPeerItem(presentationData: presentationData, systemStyle: .glass, dateTimeFormat: dateTimeFormat, nameDisplayOrder: nameDisplayOrder, context: arguments.context, peer: EnginePeer(participant.peer), presence: participant.presences[participant.peer.id].flatMap { EnginePeer.Presence($0) }, text: peerText.isEmpty ? .presence : .text(peerText, .secondary), label: label, editing: editing, revealOptions: revealOptions, switchValue: nil, enabled: enabled, selectable: true, sectionId: self.section, action: action, setPeerIdWithRevealedOptions: { previousId, id in
|
||||
```
|
||||
After: replace `peer: EnginePeer(participant.peer)` → `peer: participant.peer` (leave the rest of the line intact).
|
||||
|
||||
- [ ] **Step 3.2:** Line 921 — DROP `._asPeer()` in constructor.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
result.append(RenderedChannelParticipant(participant: .creator(id: peer.id, adminInfo: nil, rank: rank), peer: peer._asPeer(), presences: presences))
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
result.append(RenderedChannelParticipant(participant: .creator(id: peer.id, adminInfo: nil, rank: rank), peer: peer, presences: presences))
|
||||
```
|
||||
(`peer` here is already `EnginePeer` — confirmed by surrounding code where `creatorPeer: EnginePeer?` is assigned from this same loop variable.)
|
||||
|
||||
- [ ] **Step 3.3:** Line 926 — DROP `._asPeer()` in constructor.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
result.append(RenderedChannelParticipant(participant: .member(id: peer.id, invitedAt: 0, adminInfo: ChannelParticipantAdminInfo(rights: TelegramChatAdminRights(rights: .internal_groupSpecific), promotedBy: creator.id, canBeEditedByAccountPeer: creator.id == context.account.peerId), banInfo: nil, rank: rank, subscriptionUntilDate: nil), peer: peer._asPeer(), peers: peers.mapValues({ $0._asPeer() }), presences: presences))
|
||||
```
|
||||
After: change `peer: peer._asPeer()` → `peer: peer`. Leave `peers.mapValues({ $0._asPeer() })` intact — `peers` field is unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Consumer — PeerInfoUI/ChannelBlacklistController.swift
|
||||
|
||||
**File:** `submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift`
|
||||
|
||||
- [ ] **Step 4.1:** Line 170 (or 381 — the site installed by wave 39; the file has one site `EnginePeer(participant.peer)`)
|
||||
|
||||
Before:
|
||||
```swift
|
||||
peer: EnginePeer(participant.peer)
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
peer: participant.peer
|
||||
```
|
||||
|
||||
Note: the file may have a single such site; use:
|
||||
```
|
||||
grep -n 'EnginePeer(participant\.peer)' submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift
|
||||
```
|
||||
and DROP every match.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Consumer — PeerInfoUI/ChannelMembersController.swift
|
||||
|
||||
**File:** `submodules/PeerInfoUI/Sources/ChannelMembersController.swift`
|
||||
|
||||
- [ ] **Step 5.1:** Line 305 — CAST rewrite.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
if let user = participant.peer as? TelegramUser, let _ = user.botInfo {
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
if case let .user(user) = participant.peer, let _ = user.botInfo {
|
||||
```
|
||||
|
||||
- [ ] **Step 5.2:** Line 334 — DROP wrap.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
peer: EnginePeer(participant.peer)
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
peer: participant.peer
|
||||
```
|
||||
|
||||
- [ ] **Step 5.3:** Line 707 — DROP wrap (the wave-39-installed Shape-C wrap).
|
||||
|
||||
Before:
|
||||
```swift
|
||||
peer: EnginePeer(participant.peer)
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
peer: participant.peer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Consumer — PeerInfoUI/ChannelMembersSearchContainerNode.swift
|
||||
|
||||
**File:** `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift`
|
||||
|
||||
This file has the most sites (4 CAST, 3 DROP pairs, 3 ADD-WRAP constructor sites).
|
||||
|
||||
- [ ] **Step 6.1:** Line 212 — DROP two wraps on one line.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
peer: .peer(peer: EnginePeer(participant.peer), chatPeer: EnginePeer(participant.peer)),
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
peer: .peer(peer: participant.peer, chatPeer: participant.peer),
|
||||
```
|
||||
|
||||
- [ ] **Step 6.2:** Line 223 — DROP wrap.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
interaction.peerSelected(EnginePeer(participant.peer), participant)
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
interaction.peerSelected(participant.peer, participant)
|
||||
```
|
||||
|
||||
- [ ] **Step 6.3:** Line 752 — CAST rewrite.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
if excludeBots, let user = participant.peer as? TelegramUser, user.botInfo != nil {
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
if excludeBots, case let .user(user) = participant.peer, user.botInfo != nil {
|
||||
```
|
||||
|
||||
- [ ] **Step 6.4:** Line 884 — CAST rewrite. Same pattern as 6.3.
|
||||
|
||||
- [ ] **Step 6.5:** Line 987 — ADD-WRAP constructor.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
renderedParticipant = RenderedChannelParticipant(participant: .creator(id: peer.id, adminInfo: nil, rank: nil), peer: peer)
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
renderedParticipant = RenderedChannelParticipant(participant: .creator(id: peer.id, adminInfo: nil, rank: nil), peer: EnginePeer(peer))
|
||||
```
|
||||
(`peer` here is raw `Peer` from `peerView.peers[participant.peerId]` — confirmed by surrounding iteration code.)
|
||||
|
||||
- [ ] **Step 6.6:** Line 994 — ADD-WRAP constructor.
|
||||
|
||||
Change `peer: peer` to `peer: EnginePeer(peer)`. Full site for reference:
|
||||
```swift
|
||||
renderedParticipant = RenderedChannelParticipant(participant: .member(id: peer.id, invitedAt: 0, adminInfo: ChannelParticipantAdminInfo(rights: TelegramChatAdminRights(rights: TelegramChatAdminRightsFlags.peerSpecific(peer: .legacyGroup(group))), promotedBy: creatorPeer?.id ?? context.account.peerId, canBeEditedByAccountPeer: creatorPeer?.id == context.account.peerId), banInfo: nil, rank: nil, subscriptionUntilDate: nil), peer: peer, peers: peers.mapValues({ $0._asPeer() }))
|
||||
```
|
||||
Change only `peer: peer,` → `peer: EnginePeer(peer),`.
|
||||
|
||||
- [ ] **Step 6.7:** Line 998 — ADD-WRAP constructor.
|
||||
|
||||
```swift
|
||||
renderedParticipant = RenderedChannelParticipant(participant: .member(id: peer.id, invitedAt: 0, adminInfo: nil, banInfo: nil, rank: nil, subscriptionUntilDate: nil), peer: peer, peers: peers.mapValues({ $0._asPeer() }))
|
||||
```
|
||||
Change only `peer: peer,` → `peer: EnginePeer(peer),`.
|
||||
|
||||
- [ ] **Step 6.8:** Line 1052 — CAST rewrite. Same pattern as 6.3.
|
||||
|
||||
- [ ] **Step 6.9:** Line 1136 — CAST rewrite. Same pattern as 6.3.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Consumer — PeerInfoUI/ChannelMembersSearchControllerNode.swift
|
||||
|
||||
**File:** `submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift`
|
||||
|
||||
- [ ] **Step 7.1:** Line 148 — DROP wrap.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
peer: EnginePeer(participant.peer)
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
peer: participant.peer
|
||||
```
|
||||
(The line has the wrap appearing twice — search the file for `EnginePeer(participant.peer)` and drop each occurrence. Use Edit with `replace_all` if unambiguous.)
|
||||
|
||||
- [ ] **Step 7.2:** Line 404 — ADD-WRAP constructor.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
renderedParticipant = RenderedChannelParticipant(participant: .creator(id: peer.id, adminInfo: nil, rank: nil), peer: peer, presences: peerView.peerPresences)
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
renderedParticipant = RenderedChannelParticipant(participant: .creator(id: peer.id, adminInfo: nil, rank: nil), peer: EnginePeer(peer), presences: peerView.peerPresences)
|
||||
```
|
||||
|
||||
- [ ] **Step 7.3:** Line 409 — ADD-WRAP constructor.
|
||||
|
||||
Change `peer: peer,` → `peer: EnginePeer(peer),` in the full line:
|
||||
```swift
|
||||
renderedParticipant = RenderedChannelParticipant(participant: .member(id: peer.id, invitedAt: 0, adminInfo: ChannelParticipantAdminInfo(rights: TelegramChatAdminRights(rights: TelegramChatAdminRightsFlags.peerSpecific(peer: EnginePeer(mainPeer))), promotedBy: creator.id, canBeEditedByAccountPeer: creator.id == context.account.peerId), banInfo: nil, rank: nil, subscriptionUntilDate: nil), peer: peer, peers: peers.mapValues({ $0._asPeer() }), presences: peerView.peerPresences)
|
||||
```
|
||||
|
||||
- [ ] **Step 7.4:** Line 413 — ADD-WRAP constructor. Same `peer: peer,` → `peer: EnginePeer(peer),`.
|
||||
|
||||
- [ ] **Step 7.5:** Line 516 — CAST rewrite.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
if let user = participant.peer as? TelegramUser, user.botInfo != nil {
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
if case let .user(user) = participant.peer, user.botInfo != nil {
|
||||
```
|
||||
|
||||
- [ ] **Step 7.6:** Line 558 — CAST rewrite. Same pattern as 7.5.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Consumer — PeerInfoUI/ChannelPermissionsController.swift
|
||||
|
||||
**File:** `submodules/PeerInfoUI/Sources/ChannelPermissionsController.swift`
|
||||
|
||||
- [ ] **Step 8.1:** Lines 480 and 483 — DROP wraps.
|
||||
|
||||
Both lines contain `EnginePeer(participant.peer)`. Change each to `participant.peer`.
|
||||
|
||||
If the two occurrences are unambiguous, use Edit with `replace_all=true` on `EnginePeer(participant.peer)` → `participant.peer`.
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Consumer — SearchPeerMembers/SearchPeerMembers.swift
|
||||
|
||||
**File:** `submodules/SearchPeerMembers/Sources/SearchPeerMembers.swift`
|
||||
|
||||
- [ ] **Step 9.1:** Lines 30, 36, 61, 76 — DROP wraps.
|
||||
|
||||
All four sites are `EnginePeer(participant.peer)`. Use Edit with `replace_all=true`:
|
||||
- old: `EnginePeer(participant.peer)`
|
||||
- new: `participant.peer`
|
||||
|
||||
Verify with `grep -n 'EnginePeer(participant\.peer)' submodules/SearchPeerMembers/Sources/SearchPeerMembers.swift` → should return empty after edit.
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Consumer — ChatRecentActionsController.swift
|
||||
|
||||
**File:** `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift`
|
||||
|
||||
- [ ] **Step 10.1:** Line 359 — DROP wrap.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
EnginePeer(participant.peer)
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
participant.peer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Consumer — ChatRecentActionsFilterController.swift
|
||||
|
||||
**File:** `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsFilterController.swift`
|
||||
|
||||
- [ ] **Step 11.1:** Line 217 — DROP wrap.
|
||||
|
||||
Change `EnginePeer(participant.peer)` → `participant.peer` on line 217.
|
||||
|
||||
- [ ] **Step 11.2:** Line 445 — ADD-WRAP constructor rewrite.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
if let peer = peer, case let .user(user) = peer {
|
||||
return RenderedChannelParticipant(participant: .member(id: user.id, invitedAt: 0, adminInfo: nil, banInfo: nil, rank: nil, subscriptionUntilDate: nil), peer: user)
|
||||
}
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
if let peer = peer, case let .user(user) = peer {
|
||||
return RenderedChannelParticipant(participant: .member(id: user.id, invitedAt: 0, adminInfo: nil, banInfo: nil, rank: nil, subscriptionUntilDate: nil), peer: .user(user))
|
||||
}
|
||||
```
|
||||
(`.user(user)` is the enum case `EnginePeer.user(TelegramUser)`. Alternative: `peer: EnginePeer(user)` or `peer: peer` — but `peer: peer` reuses the already-unwrapped EnginePeer and is the cleanest. Use `peer: peer`.)
|
||||
|
||||
Preferred after:
|
||||
```swift
|
||||
if let peer = peer, case let .user(user) = peer {
|
||||
return RenderedChannelParticipant(participant: .member(id: user.id, invitedAt: 0, adminInfo: nil, banInfo: nil, rank: nil, subscriptionUntilDate: nil), peer: peer)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Consumer — ChatRecentActionsHistoryTransition.swift
|
||||
|
||||
**File:** `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift`
|
||||
|
||||
This is the highest-volume consumer file (12 `EnginePeer(new.peer)` sites + 2 ADD-ASPEER sites).
|
||||
|
||||
- [ ] **Step 12.1:** DROP all `EnginePeer(new.peer)` wraps.
|
||||
|
||||
Use Edit with `replace_all=true`:
|
||||
- old: `EnginePeer(new.peer)`
|
||||
- new: `new.peer`
|
||||
|
||||
After: grep `EnginePeer(new\.peer)` should return empty.
|
||||
|
||||
- [ ] **Step 12.2:** Line 675 — ADD-ASPEER.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
peers[participant.peer.id] = participant.peer
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
peers[participant.peer.id] = participant.peer._asPeer()
|
||||
```
|
||||
(Target dict is `SimpleDictionary<PeerId, Peer>`; the value side needs raw Peer.)
|
||||
|
||||
- [ ] **Step 12.3:** Line 2275 — ADD-ASPEER.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
peers[new.peer.id] = new.peer
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
peers[new.peer.id] = new.peer._asPeer()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Consumer — PeerInfoMembers.swift
|
||||
|
||||
**File:** `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoMembers.swift`
|
||||
|
||||
- [ ] **Step 13.1:** Line 33 — ADD-ASPEER.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
var peer: Peer {
|
||||
switch self {
|
||||
case let .channelMember(participant, _):
|
||||
return participant.peer
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
var peer: Peer {
|
||||
switch self {
|
||||
case let .channelMember(participant, _):
|
||||
return participant.peer._asPeer()
|
||||
```
|
||||
|
||||
No other edits in this file. The `participant.peer.id` accesses at lines 22, 44 are ZERO; `item.peer.id` at line 171 is ZERO.
|
||||
|
||||
---
|
||||
|
||||
## Task 14: Consumer — ShareWithPeersScreenState.swift
|
||||
|
||||
**File:** `submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreenState.swift`
|
||||
|
||||
- [ ] **Step 14.1:** Line 558 — DROP wrap.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
peers.append(EnginePeer(participant.peer))
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
peers.append(participant.peer)
|
||||
```
|
||||
|
||||
- [ ] **Step 14.2:** Line 566 — CAST rewrite.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
if let user = participant.peer as? TelegramUser, user.botInfo != nil {
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
if case let .user(user) = participant.peer, user.botInfo != nil {
|
||||
```
|
||||
|
||||
- [ ] **Step 14.3:** Line 576 — DROP wrap.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
peers.append(EnginePeer(participant.peer))
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
peers.append(participant.peer)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 15: Consumer — AdminUserActionsSheet.swift
|
||||
|
||||
**File:** `submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift`
|
||||
|
||||
This file has ~6 `EnginePeer(peer.peer)` / `EnginePeer(component.peers[0].peer)` wraps and many ZERO sites.
|
||||
|
||||
- [ ] **Step 15.1:** Use Edit with `replace_all=true`:
|
||||
- old: `EnginePeer(peer.peer)`
|
||||
- new: `peer.peer`
|
||||
|
||||
This covers lines 284, 522, 523.
|
||||
|
||||
- [ ] **Step 15.2:** Edit the `EnginePeer(component.peers[0].peer)` sites at lines 404, 416, 417.
|
||||
|
||||
Use Edit with `replace_all=true`:
|
||||
- old: `EnginePeer(component.peers[0].peer)`
|
||||
- new: `component.peers[0].peer`
|
||||
|
||||
- [ ] **Step 15.3:** Verify no other `EnginePeer(` wraps around `.peer` accesses remain on `RenderedChannelParticipant`. Run:
|
||||
```
|
||||
grep -n 'EnginePeer(.*\.peer)' submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift
|
||||
```
|
||||
Confirm remaining matches are on non-RCP types (e.g., some other context-derived peer).
|
||||
|
||||
---
|
||||
|
||||
## Task 16: Consumer — StoryContentLiveChatComponent.swift
|
||||
|
||||
**File:** `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentLiveChatComponent.swift`
|
||||
|
||||
- [ ] **Step 16.1:** Line 370 — DROP `._asPeer()` in constructor.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
peer: author._asPeer()
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
peer: author
|
||||
```
|
||||
(`author` is `EnginePeer` — confirmed by the surrounding code that uses `author.id` and by the `chatPeer` signal's return type.)
|
||||
|
||||
---
|
||||
|
||||
## Task 17: Consumer — ChatControllerAdminBanUsers.swift
|
||||
|
||||
**File:** `submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift`
|
||||
|
||||
- [ ] **Step 17.1:** Line 226 — ADD-WRAP constructor.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
let peer = author
|
||||
renderedParticipants.append(RenderedChannelParticipant(
|
||||
participant: participant,
|
||||
peer: peer
|
||||
))
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
let peer = author
|
||||
renderedParticipants.append(RenderedChannelParticipant(
|
||||
participant: participant,
|
||||
peer: EnginePeer(peer)
|
||||
))
|
||||
```
|
||||
(Confirmed `author` is raw `Peer` via `presentMultiBanMessageOptions(... authors: [Peer], ...)` signature on line 45.)
|
||||
|
||||
- [ ] **Step 17.2:** Line 372 — DROP `._asPeer()` in constructor.
|
||||
|
||||
Before:
|
||||
```swift
|
||||
peer: authorPeer._asPeer()
|
||||
```
|
||||
After:
|
||||
```swift
|
||||
peer: authorPeer
|
||||
```
|
||||
(Confirmed `authorPeer` is `EnginePeer?` at line 327 via `engine.data.get(Peer.Peer(id:))` signal; already guard-unwrapped.)
|
||||
|
||||
- [ ] **Step 17.3:** Line 757 — DROP `._asPeer()` in constructor.
|
||||
|
||||
Same edit pattern as 17.2: `peer: authorPeer._asPeer()` → `peer: authorPeer`.
|
||||
|
||||
---
|
||||
|
||||
## Task 18: Full build verification
|
||||
|
||||
- [ ] **Step 18.1:** Run the full 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
|
||||
```
|
||||
|
||||
Expected: build success. First-pass-clean is the goal (wave-39 pattern applies — classification is exact, migration is mechanical, no inference-bearing return types).
|
||||
|
||||
If the build fails, expect errors only in files in this plan. Any error outside the plan's file list is either:
|
||||
- a pre-existing unrelated WIP (e.g., `ChatMessageTransitionNode.swift`) — not a wave-41 issue
|
||||
- a genuine miss in pre-flight classification — record which file, update the plan, and re-run
|
||||
|
||||
For each error in wave-41 files:
|
||||
1. Read the error
|
||||
2. Classify: is it a shape we mis-identified (ZERO that's not actually transparent) or a new shape (dict subscript, function arg to a `Peer`-typed param, etc.)?
|
||||
3. Apply the appropriate fix (`._asPeer()` if raw Peer needed; unwrap the wrap if EnginePeer needed)
|
||||
4. Re-run the build
|
||||
|
||||
Budget: 1–3 build iterations.
|
||||
|
||||
- [ ] **Step 18.2:** Post-build grep verification.
|
||||
|
||||
Run these greps and confirm they return only the expected residual matches:
|
||||
|
||||
```sh
|
||||
grep -rn 'EnginePeer(participant\.peer)' submodules/ --include='*.swift' | grep -v submodules/TelegramCore/ | grep -v submodules/Postbox/
|
||||
```
|
||||
Expected: empty.
|
||||
|
||||
```sh
|
||||
grep -rn 'EnginePeer(new\.peer)' submodules/ --include='*.swift' | grep -v submodules/TelegramCore/
|
||||
```
|
||||
Expected: empty.
|
||||
|
||||
```sh
|
||||
grep -rn 'participant\.peer as\? TelegramUser' submodules/ --include='*.swift'
|
||||
```
|
||||
Expected: empty.
|
||||
|
||||
```sh
|
||||
grep -n 'public let peer:' submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift
|
||||
```
|
||||
Expected: `public let peer: EnginePeer`.
|
||||
|
||||
---
|
||||
|
||||
## Task 19: Commit
|
||||
|
||||
- [ ] **Step 19.1:** Stage only wave-41 files (explicitly enumerate — wave-39 lesson).
|
||||
|
||||
```sh
|
||||
git status --short
|
||||
```
|
||||
|
||||
Inspect the output. Only wave-41 files should appear as modified. If pre-existing WIP (e.g., `submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift`) is also modified, do NOT include it in the commit.
|
||||
|
||||
```sh
|
||||
git add \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelAdminEventLogs.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift \
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelAdminsController.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelMembersController.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift \
|
||||
submodules/PeerInfoUI/Sources/ChannelPermissionsController.swift \
|
||||
submodules/SearchPeerMembers/Sources/SearchPeerMembers.swift \
|
||||
submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift \
|
||||
submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift \
|
||||
submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsFilterController.swift \
|
||||
submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoMembers.swift \
|
||||
submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreenState.swift \
|
||||
submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentLiveChatComponent.swift \
|
||||
submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift \
|
||||
docs/superpowers/specs/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration-design.md \
|
||||
docs/superpowers/plans/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration.md
|
||||
```
|
||||
|
||||
(Add any additional files the build iterations surfaced.)
|
||||
|
||||
Run `git status --short` and confirm only staged wave-41 files are green, and any unrelated WIP is still marked as unstaged.
|
||||
|
||||
- [ ] **Step 19.2:** Commit.
|
||||
|
||||
```sh
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Postbox -> TelegramEngine wave 41
|
||||
|
||||
Migrate RenderedChannelParticipant.peer from Postbox `Peer` to
|
||||
TelegramCore `EnginePeer`. 27 files touched: 10 TelegramCore
|
||||
(1 struct + 9 files with constructor wraps) + 17 consumer files.
|
||||
|
||||
Drops the 2 Shape-C wraps installed by wave 39 (ChannelMembersController
|
||||
and ChannelBlacklistController) plus ~37 additional EnginePeer(...) /
|
||||
._asPeer() bridges across the consumer surface. Net ~-14 bridges
|
||||
after the 16 TelegramCore-internal EnginePeer(peer) wraps and the 7
|
||||
consumer ADD-WRAP constructor sites. RCP.peers and RCP.presences
|
||||
dictionaries remain Postbox-typed (deferred).
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 19.3:** Confirm commit landed and working tree is clean except for pre-existing WIP.
|
||||
|
||||
```sh
|
||||
git status --short
|
||||
git log -1 --oneline
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 20: Log the wave outcome
|
||||
|
||||
- [ ] **Step 20.1:** Append wave 41 entry to `docs/superpowers/postbox-refactor-log.md`.
|
||||
|
||||
Format (matching prior wave entries):
|
||||
|
||||
```markdown
|
||||
## Wave 41 outcome — RenderedChannelParticipant.peer: Peer → EnginePeer (2026-04-24)
|
||||
|
||||
Landed as commit `<hash>`. 27 files / ~45 site edits / net ~-14 bridges.
|
||||
|
||||
**Shape distribution:**
|
||||
- TelegramCore: 16 constructor sites wrapped with `EnginePeer(peer)` across 9 files + struct field migrated in ChannelParticipants.swift
|
||||
- Consumers: ~32 DROP (EnginePeer/._asPeer unwraps), 9 CAST (as? TelegramUser → if case let .user), 3 ADD-ASPEER, 7 ADD-WRAP constructor sites
|
||||
|
||||
**First-pass-clean:** <yes|no, iterations count>. Extends wave-39 lesson: first-pass-clean
|
||||
is achievable when classification is exact and all patterns are mechanical.
|
||||
|
||||
**Ratchet economics:** drops 2 wave-39 Shape-C wraps
|
||||
(ChannelMembersController:707, ChannelBlacklistController:381) and installs 7 ADD-WRAP
|
||||
consumer constructor sites as ratchet markers for a future
|
||||
`RenderedChannelParticipant.peers: [PeerId: Peer] → [EnginePeer.Id: EnginePeer]` wave.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration-design.md`.
|
||||
**Plan:** `docs/superpowers/plans/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration.md`.
|
||||
```
|
||||
|
||||
- [ ] **Step 20.2:** Update the `project_postbox_refactor_next_wave.md` memory file with the wave 41 outcome and the wave 42 candidate (likely `PeerInfoScreenData.peer → EnginePeer`).
|
||||
|
||||
- [ ] **Step 20.3:** Commit docs updates.
|
||||
|
||||
```sh
|
||||
git add docs/superpowers/postbox-refactor-log.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs: log wave 41 outcome
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
|
@ -0,0 +1,666 @@
|
|||
# Wave 35: `SendAsPeer.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 the public field `SendAsPeer.peer` from the Postbox `Peer` protocol to the TelegramCore `EnginePeer` enum in a single atomic commit. Drops 3 `._asPeer()` bridges at construction sites, collapses 6 redundant `EnginePeer(peer.peer)` wraps, rewrites 1 `peer.peer as? TelegramChannel` downcast to an enum pattern, and adds `EnginePeer(channel)` wraps at 2 raw-`TelegramChannel` construction sites. No outflow `._asPeer()` bridges need to be added for this wave (unlike wave 34's `ContactListPeer.peer(peer:)` bridge).
|
||||
|
||||
**Architecture:** One atomic commit. The field-type change is necessarily atomic (half-migrated SendAsPeer doesn't compile), so all edits land together. TelegramCore's `_internal_*SendAsAvailablePeers` functions keep `import Postbox` — only `SendAsPeer`'s public surface changes. No new wrappers, no new typealiases. The manual `==` body is replaced with synthesized Equatable (EnginePeer is Equatable).
|
||||
|
||||
**Tech Stack:** Swift, Bazel build via Make.py wrapper. No tests — verification is build success + targeted grep checks.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-24-sendaspeer-engine-peer-migration-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Modified files (7 expected — 1 TelegramCore + 6 consumer. Plus 2 "verify no-edit" files.)**
|
||||
|
||||
| File | Edit count | Category |
|
||||
|---|---|---|
|
||||
| `submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift` | ~7 spot edits (struct change + 4 constructor wraps + drop manual `==`) | α |
|
||||
| `submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift` | ~5 (1 cast rewrite + 4 wrap drops) | γ |
|
||||
| `submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift` | 3 (1 bridge-drop + 2 EnginePeer wraps on raw channel) | δ |
|
||||
| `submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelComponent.swift` | 1 (bridge-drop) | δ |
|
||||
| `submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift` | 1 (wrap collapse) | δ |
|
||||
| `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift` | ~4 (1 bridge-drop + 1 flatMap simplify + 1 map simplify) | δ |
|
||||
|
||||
**Verify-only (no edits expected):**
|
||||
| File | Reason |
|
||||
|---|---|
|
||||
| `submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift` | Holds `[SendAsPeer]?` at collection level, no `.peer` access. |
|
||||
| `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift` | Passes `currentSendAsPeer` through to `ChatSendAsPeerListContextItem` which keeps taking `[SendAsPeer]`. |
|
||||
|
||||
**EnginePeer enum case mapping (used in cast rewrite):**
|
||||
|
||||
| Postbox concrete | EnginePeer case |
|
||||
|---|---|
|
||||
| `TelegramChannel` | `.channel(TelegramChannel)` |
|
||||
| `TelegramGroup` | `.legacyGroup(TelegramGroup)` |
|
||||
| `TelegramUser` | `.user(TelegramUser)` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Edit `SendAsPeers.swift` — struct definition + constructor wraps
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift`
|
||||
|
||||
Foundational change. Without it, none of the consumer edits compile.
|
||||
|
||||
- [ ] **Step 1.1: Update the SendAsPeer struct field, init parameter, and drop manual `==`**
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
public struct SendAsPeer: Equatable {
|
||||
public let peer: Peer
|
||||
public let subscribers: Int32?
|
||||
public let isPremiumRequired: Bool
|
||||
|
||||
public init(peer: Peer, subscribers: Int32?, isPremiumRequired: Bool) {
|
||||
self.peer = peer
|
||||
self.subscribers = subscribers
|
||||
self.isPremiumRequired = isPremiumRequired
|
||||
}
|
||||
|
||||
public static func ==(lhs: SendAsPeer, rhs: SendAsPeer) -> Bool {
|
||||
return lhs.peer.isEqual(rhs.peer) && lhs.subscribers == rhs.subscribers && lhs.isPremiumRequired == rhs.isPremiumRequired
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
public struct SendAsPeer: Equatable {
|
||||
public let peer: EnginePeer
|
||||
public let subscribers: Int32?
|
||||
public let isPremiumRequired: Bool
|
||||
|
||||
public init(peer: EnginePeer, subscribers: Int32?, isPremiumRequired: Bool) {
|
||||
self.peer = peer
|
||||
self.subscribers = subscribers
|
||||
self.isPremiumRequired = isPremiumRequired
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use the Edit tool with the OLD block as `old_string` and the NEW block as `new_string`. Swift synthesizes Equatable for structs where every stored property is Equatable: `EnginePeer` is Equatable, `Int32?` is Equatable, `Bool` is Equatable — so the manual `==` is no longer needed.
|
||||
|
||||
- [ ] **Step 1.2: Wrap raw Postbox `Peer` values at the four constructor sites**
|
||||
|
||||
Sites at lines 64, 170, 236, 330. Each binds a raw Postbox `Peer` (from `transaction.getPeer(peerId)` or `peers.map { ... }`) and passes it to the `SendAsPeer(peer: ...)` init. Wrap each with `EnginePeer(...)`.
|
||||
|
||||
Edit (line 64, inside `_internal_cachedPeerSendAsAvailablePeers`, cache-hit branch):
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
peers.append(SendAsPeer(peer: peer, subscribers: subscribers, isPremiumRequired: cached.premiumRequiredPeerIds.contains(peer.id)))
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
peers.append(SendAsPeer(peer: EnginePeer(peer), subscribers: subscribers, isPremiumRequired: cached.premiumRequiredPeerIds.contains(peer.id)))
|
||||
```
|
||||
|
||||
Edit (line 170, inside `_internal_peerSendAsAvailablePeers`, network-response map):
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
return peers.map { SendAsPeer(peer: $0, subscribers: subscribers[$0.id], isPremiumRequired: premiumRequiredPeerIds.contains($0.id)) }
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
return peers.map { SendAsPeer(peer: EnginePeer($0), subscribers: subscribers[$0.id], isPremiumRequired: premiumRequiredPeerIds.contains($0.id)) }
|
||||
```
|
||||
|
||||
Edit (line 236, inside `_internal_cachedLiveStorySendAsAvailablePeers`, cache-hit branch):
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
peers.append(SendAsPeer(peer: peer, subscribers: subscribers, isPremiumRequired: cached.premiumRequiredPeerIds.contains(peer.id)))
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
peers.append(SendAsPeer(peer: EnginePeer(peer), subscribers: subscribers, isPremiumRequired: cached.premiumRequiredPeerIds.contains(peer.id)))
|
||||
```
|
||||
|
||||
Note: lines 64 and 236 have identical text. If you prefer `replace_all=true`, do a grep first to confirm the count is exactly 2, then apply once.
|
||||
|
||||
Edit (line 330, inside `_internal_liveStorySendAsAvailablePeers`, network-response map):
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
return peers.map { SendAsPeer(peer: $0, subscribers: subscribers[$0.id], isPremiumRequired: premiumRequiredPeerIds.contains($0.id)) }
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
return peers.map { SendAsPeer(peer: EnginePeer($0), subscribers: subscribers[$0.id], isPremiumRequired: premiumRequiredPeerIds.contains($0.id)) }
|
||||
```
|
||||
|
||||
Same remark as above: lines 170 and 330 are identical — one `replace_all=true` covers both if the count is exactly 2.
|
||||
|
||||
- [ ] **Step 1.3: Verify** — read the updated file and confirm:
|
||||
- The struct's `peer` field is now `EnginePeer`
|
||||
- The init parameter is `peer: EnginePeer`
|
||||
- Manual `==` has been removed
|
||||
- All 4 constructor sites wrap with `EnginePeer(...)`
|
||||
- `peer.peer.id` accesses inside the caching loops (lines 87, 90, 259, 262) remain unchanged (`EnginePeer.id` typealias to `PeerId` keeps them valid)
|
||||
|
||||
Do not commit yet.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Edit `ChatSendAsPeerListContextItem.swift` — cast rewrite + wrap collapse
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift`
|
||||
|
||||
1 Postbox-concrete downcast rewrite + 4 `EnginePeer(peer.peer)` wrap drops.
|
||||
|
||||
- [ ] **Step 2.1: Rewrite the `peer.peer as? TelegramChannel` downcast at line 73**
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
} else if let subscribers = peer.subscribers {
|
||||
if let peer = peer.peer as? TelegramChannel {
|
||||
if case .broadcast = peer.info {
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
} else if let subscribers = peer.subscribers {
|
||||
if case let .channel(channel) = peer.peer {
|
||||
if case .broadcast = channel.info {
|
||||
```
|
||||
|
||||
Note: the original `if let peer = peer.peer as? TelegramChannel` shadows the outer `peer: SendAsPeer` loop variable. The rewrite uses `channel` to avoid shadowing. Any subsequent uses of `peer.info`, `peer.flags`, etc. inside the inner `if let peer = ...` block must be renamed to `channel.*`.
|
||||
|
||||
Read lines 70–90 before editing to see the full extent of the shadowed-`peer` scope, and ensure every reference to `peer.info` (and any sibling field access like `peer.flags`, `peer.username`, etc.) within the inner block is rewritten to `channel.*`. The snippet above captures the only `peer.info` site from the inventory.
|
||||
|
||||
- [ ] **Step 2.2: Drop `EnginePeer(peer.peer)` wraps at lines 89, 110, 116, 121**
|
||||
|
||||
The field `peer.peer` is now `EnginePeer`, so `EnginePeer(peer.peer)` becomes a type error. Drop the wrap.
|
||||
|
||||
Read the full lines first to confirm each site's shape. Expected patterns (edit one at a time with enough surrounding context to make each unique — the four sites likely differ in surrounding tokens):
|
||||
|
||||
For each of the four sites, the pattern to eliminate is `EnginePeer(peer.peer)` → `peer.peer`. Example:
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
let title = EnginePeer(peer.peer).displayTitle(strings: strings, displayOrder: nameDisplayOrder)
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
let title = peer.peer.displayTitle(strings: strings, displayOrder: nameDisplayOrder)
|
||||
```
|
||||
|
||||
Identify each of the four sites (lines 89, 110, 116, 121) by reading the file, then apply one Edit per site using enough surrounding context (usually 1–2 tokens before/after the `EnginePeer(peer.peer)` subexpression) to make the `old_string` unique.
|
||||
|
||||
If all four lines reduce to the same substring pattern (e.g., `EnginePeer(peer.peer)` as a standalone subexpression), `replace_all=true` on the substring `EnginePeer(peer.peer)` → `peer.peer` is safe — but **first** grep to confirm the count is exactly 4 and no other meaning is captured.
|
||||
|
||||
Run before: `grep -cE "EnginePeer\(peer\.peer\)" submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift`
|
||||
|
||||
Expected: 4.
|
||||
|
||||
- [ ] **Step 2.3: Verify** — grep:
|
||||
|
||||
Run: `grep -nE "peer\.peer\s+(as\?|is)\s+Telegram|EnginePeer\(peer\.peer\)" submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift`
|
||||
|
||||
Expected: zero matches.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Edit `ChatControllerLoadDisplayNode.swift` — bridge-drop + raw-channel wraps
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift`
|
||||
|
||||
1 `._asPeer()` bridge-drop at line 772 + 2 `EnginePeer(channel)` wraps for raw `TelegramChannel` at lines 805 and 823.
|
||||
|
||||
- [ ] **Step 3.1: Bridge-drop at line 772**
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
return SendAsPeer(peer: peer._asPeer(), subscribers: nil, isPremiumRequired: false)
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
return SendAsPeer(peer: peer, subscribers: nil, isPremiumRequired: false)
|
||||
```
|
||||
|
||||
Verification: the surrounding signal chain binds `peer` as `EnginePeer` (from `context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: ...))`). The `._asPeer()` bridge is no longer needed.
|
||||
|
||||
If the line text differs from the OLD block above (e.g., different field order or trailing arguments), read the file around line 772 and adjust the `old_string` to match byte-for-byte before editing.
|
||||
|
||||
- [ ] **Step 3.2: Wrap raw `TelegramChannel` at line 805**
|
||||
|
||||
Read lines 800–812 to see the bound `channel` variable. The construction site should be `SendAsPeer(peer: channel, ...)` where `channel: TelegramChannel` is raw Postbox.
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
SendAsPeer(peer: channel, subscribers: subscribers, isPremiumRequired: isPremiumRequired)
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
SendAsPeer(peer: EnginePeer(channel), subscribers: subscribers, isPremiumRequired: isPremiumRequired)
|
||||
```
|
||||
|
||||
If the surrounding context differs (different field values), match the actual line text when writing `old_string`.
|
||||
|
||||
- [ ] **Step 3.3: Wrap raw `TelegramChannel` at line 823**
|
||||
|
||||
Same pattern as Step 3.2. Read lines 818–830 first, identify the `SendAsPeer(peer: channel, ...)` construction site, and wrap `channel` with `EnginePeer(...)`.
|
||||
|
||||
If the line text at 805 and 823 is identical, `replace_all=true` on the substring `SendAsPeer(peer: channel,` → `SendAsPeer(peer: EnginePeer(channel),` covers both. **First** grep to confirm the count:
|
||||
|
||||
Run before: `grep -cE "SendAsPeer\(peer: channel," submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift`
|
||||
|
||||
Expected: 2.
|
||||
|
||||
- [ ] **Step 3.4: Verify** — grep:
|
||||
|
||||
Run: `grep -nE "SendAsPeer\(peer:\s+\w+\._asPeer\(\)|SendAsPeer\(peer:\s+channel," submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift`
|
||||
|
||||
Expected: zero matches. Lines 792, 826, 835, 844 retaining `.peer.id` accesses are expected and correct.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Edit `ChatTextInputPanelComponent.swift` — bridge-drop
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelComponent.swift`
|
||||
|
||||
1 `._asPeer()` bridge-drop.
|
||||
|
||||
- [ ] **Step 4.1: Bridge-drop at line 847**
|
||||
|
||||
Read lines 843–853 to confirm the surrounding signal chain and the type of `sendAsConfiguration.currentPeer` (expected: `EnginePeer`).
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
let sendAsPeers = [SendAsPeer(peer: sendAsConfiguration.currentPeer._asPeer(), subscribers: nil, isPremiumRequired: false)]
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
let sendAsPeers = [SendAsPeer(peer: sendAsConfiguration.currentPeer, subscribers: nil, isPremiumRequired: false)]
|
||||
```
|
||||
|
||||
If the actual line text wraps across multiple lines or uses different field values, match the real text byte-for-byte when writing `old_string`.
|
||||
|
||||
- [ ] **Step 4.2: Verify** — grep:
|
||||
|
||||
Run: `grep -nE "SendAsPeer\(peer:.*\._asPeer\(\)" submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelComponent.swift`
|
||||
|
||||
Expected: zero matches.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Edit `ChatTextInputPanelNode.swift` — wrap collapse
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift`
|
||||
|
||||
1 `EnginePeer(peer)` wrap collapse at line 1625.
|
||||
|
||||
- [ ] **Step 5.1: Collapse `EnginePeer(peer)` wrap**
|
||||
|
||||
Read lines 1615–1630 to see the full context. `peer` is bound from a preceding `var currentPeer = sendAsPeers.first(where: { $0.peer.id == ... })?.peer` (lines 1620–1622). After migration, `.peer` returns `EnginePeer`, so `EnginePeer(peer)` on an `EnginePeer` is a type error.
|
||||
|
||||
Exact edit depends on the actual line text. Example shape:
|
||||
|
||||
```swift
|
||||
// OLD (at or near line 1625)
|
||||
let enginePeer = EnginePeer(peer)
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
let enginePeer = peer
|
||||
```
|
||||
|
||||
Read lines 1623–1628 first and write the Edit with byte-accurate `old_string`. If the bound variable is then used as `enginePeer.displayTitle(...)`, consider whether the rename can be eliminated entirely (e.g., rename `peer` uses downstream), but prefer the minimal edit for commit clarity.
|
||||
|
||||
Lines 1616, 1620, 1622, 2948, 5370 should remain unchanged — they perform `.peer.id` comparisons or `.first(where:)` lookups that work identically on `[SendAsPeer]` with `EnginePeer`-typed `.peer`.
|
||||
|
||||
- [ ] **Step 5.2: Verify** — grep:
|
||||
|
||||
Run: `grep -nE "EnginePeer\(peer\)" submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift`
|
||||
|
||||
Expected: zero matches. If any remain, inspect each — they may be unrelated wraps on non-SendAsPeer-sourced `peer` variables (in which case they must stay).
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Edit `StoryItemSetContainerViewSendMessage.swift` — multi-site cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift`
|
||||
|
||||
1 bridge-drop + 1 flatMap simplify + 1 map simplify. Many other `.peer.id` / `.peer` accesses remain unchanged.
|
||||
|
||||
- [ ] **Step 6.1: Bridge-drop at line 249**
|
||||
|
||||
Read lines 244–254 to confirm `accountPeer` is typed as `EnginePeer` upstream.
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
availablePeers.append(SendAsPeer(
|
||||
peer: accountPeer._asPeer(),
|
||||
subscribers: nil,
|
||||
isPremiumRequired: false
|
||||
))
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
availablePeers.append(SendAsPeer(
|
||||
peer: accountPeer,
|
||||
subscribers: nil,
|
||||
isPremiumRequired: false
|
||||
))
|
||||
```
|
||||
|
||||
If the actual layout (whitespace, line breaks) differs from the OLD block, match the real text byte-for-byte when writing `old_string`.
|
||||
|
||||
- [ ] **Step 6.2: Simplify flatMap at line 4080**
|
||||
|
||||
`EnginePeer.init` as a function reference expects a raw `Peer` and returns `EnginePeer`. After migration, `sendAsPeer?.peer` is already `EnginePeer?`, so `.flatMap(EnginePeer.init)` is both unnecessary and a type error.
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
myPeer: (sendAsPeer?.peer).flatMap(EnginePeer.init),
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
myPeer: sendAsPeer?.peer,
|
||||
```
|
||||
|
||||
Read lines 4078–4082 first to confirm the surrounding labeled-argument layout and match byte-for-byte.
|
||||
|
||||
- [ ] **Step 6.3: Simplify map at line 4081**
|
||||
|
||||
`.map({ EnginePeer($0.peer) })` wraps each already-`EnginePeer` value in `EnginePeer(...)` — a type error. Drop the wrap.
|
||||
|
||||
Edit:
|
||||
|
||||
```swift
|
||||
// OLD
|
||||
availableSendAsPeers: component.isEmbeddedInCamera ? [] : (self.sendAsData?.availablePeers.map({ EnginePeer($0.peer) }) ?? []),
|
||||
```
|
||||
|
||||
```swift
|
||||
// NEW
|
||||
availableSendAsPeers: component.isEmbeddedInCamera ? [] : (self.sendAsData?.availablePeers.map({ $0.peer }) ?? []),
|
||||
```
|
||||
|
||||
Read lines 4079–4083 first to confirm the exact line text.
|
||||
|
||||
- [ ] **Step 6.4: Verify** — grep:
|
||||
|
||||
Run: `grep -nE "SendAsPeer\(peer:.*\._asPeer\(\)|EnginePeer\(\$0\.peer\)|\(sendAsPeer\?\.peer\)\.flatMap\(EnginePeer\.init\)" submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift`
|
||||
|
||||
Expected: zero matches.
|
||||
|
||||
Retained-as-is accesses (inventory-verified correct after migration): `.peer.id` at lines 254, 688, 4088, 4089, 4327, 4333, 4340, 4356, 4372; optional chaining at 4050, 4068, 4069. These should NOT be edited.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Verify "no-edit" consumer files
|
||||
|
||||
**Files:**
|
||||
- Read: `submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift`
|
||||
- Read: `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift`
|
||||
|
||||
Sanity-check: confirm neither file contains `.peer as?`/`is`, `EnginePeer(.peer)`, or `._asPeer()` patterns tied to SendAsPeer. If any such pattern is found, fold the fix into the relevant task above before the build pass.
|
||||
|
||||
- [ ] **Step 7.1: Grep ChatPresentationInterfaceState.swift**
|
||||
|
||||
Run: `grep -nE "SendAsPeer|sendAsPeers" submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift`
|
||||
|
||||
Expected shape: field declaration, init param, assignment, equality comparison, `updatedSendAsPeers(_:)` method — all at the `[SendAsPeer]?` collection level. No `.peer` field access.
|
||||
|
||||
- [ ] **Step 7.2: Grep StoryItemSetContainerComponent.swift**
|
||||
|
||||
Run: `grep -nE "SendAsPeer|currentSendAsPeer|\.peer\b" submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift | grep -iE "sendAsPeer|\.peer"`
|
||||
|
||||
Read lines 3056–3072 to confirm `sendMessageContext.currentSendAsPeer` is only passed through to `ChatSendAsPeerListContextItem` (which keeps `[SendAsPeer]`) or accessed for `.peer.id` comparisons — neither requires an edit.
|
||||
|
||||
If the verification shows an edit is needed, add the edit as an additional step under the relevant Task 2–6. Do not edit here silently.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Build verification (first pass)
|
||||
|
||||
- [ ] **Step 8.1: Run the full build with `--continueOnError`**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
source ~/.zshrc 2>/dev/null && python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError 2>&1 | tee /tmp/wave35-build.log
|
||||
```
|
||||
|
||||
Expected outcome: ideally clean. Realistic outcome: 0–5 errors at sites the inventory missed.
|
||||
|
||||
- [ ] **Step 8.2: Triage build errors**
|
||||
|
||||
Likely error patterns and their fixes:
|
||||
|
||||
| Error | Fix |
|
||||
|---|---|
|
||||
| `cannot convert value of type 'EnginePeer' to expected argument type 'Peer'` at site passing `peer.peer` | Add `._asPeer()` bridge: `peer.peer._asPeer()` |
|
||||
| `cannot convert value of type 'Peer' to expected argument type 'EnginePeer'` at `SendAsPeer(peer: ...)` | Add wrap: `SendAsPeer(peer: EnginePeer(<raw>), ...)` |
|
||||
| `value of type 'EnginePeer' has no member 'isEqual'` | Replace with `==` |
|
||||
| `pattern of type 'TelegramChannel' cannot match values of type 'EnginePeer'` | Missed C2 — rewrite to `if case .channel(let channel) = peer.peer` form |
|
||||
| `cannot invoke initializer for type 'EnginePeer' with an argument list of type '(EnginePeer)'` | Missed wrap collapse — drop `EnginePeer(...)` |
|
||||
| `extraneous argument label 'peer:' in call` or similar on `SendAsPeer(...)` | Check that the construction arg is `EnginePeer`, not raw — add `EnginePeer(...)` wrap |
|
||||
|
||||
For each error, identify the file:line, apply the appropriate fix, and re-run the build until clean.
|
||||
|
||||
- [ ] **Step 8.3: Iterate to clean build**
|
||||
|
||||
Re-run the build after each batch of fixes. The wave is complete when the build returns 0 errors for the targeted configuration.
|
||||
|
||||
If 10+ unexpected errors surface, halt and reassess: the inventory was significantly incomplete and the wave may need to be split into pre-cleanup commits. Discuss with user before continuing.
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Post-build grep validations
|
||||
|
||||
- [ ] **Step 9.1: Bridge-drop validation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -rn "SendAsPeer(peer:.*\._asPeer()" submodules/ --include="*.swift" | grep -v "^submodules/TelegramCore/" | grep -v "^submodules/Postbox/"
|
||||
```
|
||||
|
||||
Expected: zero hits. If any remain, those are missed bridge-drops — fix and re-run Task 8.
|
||||
|
||||
- [ ] **Step 9.2: Wrap-collapse validation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
for f in submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift \
|
||||
submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift \
|
||||
submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelComponent.swift \
|
||||
submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift \
|
||||
submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift; do
|
||||
echo "=== $f ==="
|
||||
grep -nE "EnginePeer\(peer\.peer\)|EnginePeer\(\$0\.peer\)|\(sendAsPeer\?\.peer\)\.flatMap\(EnginePeer\.init\)" "$f"
|
||||
done
|
||||
```
|
||||
|
||||
Expected: zero hits across all 5 files.
|
||||
|
||||
- [ ] **Step 9.3: C2 cast validation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -nE "peer\.peer\s+(as\?|is)\s+Telegram" submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift
|
||||
```
|
||||
|
||||
Expected: zero hits.
|
||||
|
||||
- [ ] **Step 9.4: Construction-site validation**
|
||||
|
||||
Ensure all `SendAsPeer(peer: ...)` construction sites outside TelegramCore provide `EnginePeer`:
|
||||
|
||||
```bash
|
||||
grep -rnE "SendAsPeer\(peer:" submodules/ --include="*.swift" | grep -v "^submodules/TelegramCore/"
|
||||
```
|
||||
|
||||
Inspect each hit. Expected forms: `SendAsPeer(peer: <engine-peer-expr>, ...)` or `SendAsPeer(peer: EnginePeer(<raw>), ...)`. Anything of the form `SendAsPeer(peer: <raw-Peer>, ...)` is a miss — fix.
|
||||
|
||||
If any of the validations fail, return to Task 8 to fix.
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Atomic commit + memory + log update
|
||||
|
||||
- [ ] **Step 10.1: Stage and review**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff --stat
|
||||
```
|
||||
|
||||
Confirm exactly 6 modified Swift files (1 TelegramCore + 5 consumer — or 7 if Task 7 surfaced a needed edit). Files expected:
|
||||
- `submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift`
|
||||
- `submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift`
|
||||
- `submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift`
|
||||
- `submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelComponent.swift`
|
||||
- `submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift`
|
||||
- `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift`
|
||||
|
||||
WIP from earlier (`build-system/bazel-rules/sourcekit-bazel-bsp`, `ChatListFilterPresetController.swift`, `ChatListFilterPresetListController.swift`, untracked `build-system/tulsi/` / `submodules/TgVoip/` / `third-party/libx264/`) should NOT be staged.
|
||||
|
||||
The `docs/superpowers/plans/2026-04-22-claude-md-reorganization.md` untracked file should ALSO remain unstaged.
|
||||
|
||||
- [ ] **Step 10.2: Stage only the wave-35 files**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift \
|
||||
submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift \
|
||||
submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift \
|
||||
submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelComponent.swift \
|
||||
submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift \
|
||||
submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift
|
||||
```
|
||||
|
||||
If Task 7 surfaced an additional file, append it here.
|
||||
|
||||
- [ ] **Step 10.3: Commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Postbox -> TelegramEngine wave 35: SendAsPeer.peer Peer -> EnginePeer
|
||||
|
||||
Migrates the public field `SendAsPeer.peer` from the Postbox `Peer`
|
||||
protocol to the TelegramCore `EnginePeer` enum. Internal
|
||||
`_internal_*SendAsAvailablePeers` bodies keep `import Postbox` (they still
|
||||
call `postbox.transaction`) and wrap raw peer values with `EnginePeer(peer)`
|
||||
at the SendAsPeer constructor sites. Manual `==` body dropped in favor of
|
||||
synthesized Equatable.
|
||||
|
||||
Consumer-side cascade in 5 files:
|
||||
- 3 `._asPeer()` bridge-drops at SendAsPeer constructor sites
|
||||
- 6 redundant `EnginePeer(peer.peer)` / `EnginePeer($0.peer)` wrap
|
||||
drops (the field is now EnginePeer, so the wrap fails to compile)
|
||||
- 1 `peer.peer as? TelegramChannel` downcast rewritten to
|
||||
`if case let .channel(channel) = peer.peer` enum-pattern form
|
||||
- 2 `EnginePeer(channel)` wraps added where raw `TelegramChannel` is
|
||||
passed into `SendAsPeer(peer: ...)`
|
||||
- 1 `(sendAsPeer?.peer).flatMap(EnginePeer.init)` simplified to
|
||||
`sendAsPeer?.peer` (already `EnginePeer?`)
|
||||
|
||||
Files modified:
|
||||
submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift
|
||||
submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift
|
||||
submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift
|
||||
submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelComponent.swift
|
||||
submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift
|
||||
submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift
|
||||
|
||||
Plan: docs/superpowers/plans/2026-04-24-sendaspeer-engine-peer-migration.md
|
||||
Spec: docs/superpowers/specs/2026-04-24-sendaspeer-engine-peer-migration-design.md
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 10.4: Update CLAUDE.md wave counter**
|
||||
|
||||
Edit `CLAUDE.md` to bump the "Waves landed so far" line from "34 waves" to "35 waves" and update the "as of" date if the commit lands after 2026-04-24.
|
||||
|
||||
- [ ] **Step 10.5: Append wave outcome to the postbox-refactor-log**
|
||||
|
||||
Append a "Wave 35 outcome" section to `docs/superpowers/postbox-refactor-log.md` documenting:
|
||||
- Actual files touched and edit counts vs. plan
|
||||
- Any inventory undercounts surfaced by Task 8
|
||||
- Any lessons learned (e.g., whether the flatMap/map simplifications were actually type-required or whether they could have been left as redundant-but-compiling wraps)
|
||||
|
||||
Keep concise.
|
||||
|
||||
- [ ] **Step 10.6: Commit the docs update**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md docs/superpowers/postbox-refactor-log.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs: add wave 35 outcome (SendAsPeer.peer Peer→EnginePeer)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 10.7: Update the next-wave memory**
|
||||
|
||||
Update `/Users/isaac/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`:
|
||||
- Add wave 35 to the "Latest commits" section
|
||||
- Move SendAsPeer migration from "Wave 34+ candidates → Downstream Peer-typed APIs" to landed
|
||||
- Record the inventory undercount ratio (actual-files-touched ÷ pre-flight-file-count) for calibration of future Peer-typed-API waves
|
||||
- Update the "Recommended wave 35" section to reflect the new wave 36 recommendation. Candidates to promote: `makePeerInfoController` (largest Peer-typed-API remaining), `ContactListPeer.peer(peer:)` case payload, `canSendMessagesToPeer(_:)` parameter, accountManager-side engine path, Shape-C resourceData module pick
|
||||
|
||||
Use the Edit tool on the memory file. No git commit needed (memory lives outside the repo).
|
||||
|
||||
---
|
||||
|
||||
## Risks and notes
|
||||
|
||||
- **Inner `peer` shadowing in ChatSendAsPeerListContextItem:73.** The original `if let peer = peer.peer as? TelegramChannel` shadows the outer `peer: SendAsPeer` loop variable. The rewrite uses `channel` to avoid shadowing. Verify every reference to `peer.info` (and any sibling field access) within the old inner-if scope is updated to `channel.*` — Step 2.1's instructions cover this, but it's easy to miss a field reference.
|
||||
- **`replace_all` correctness.** Whenever the plan suggests `replace_all=true`, verify the count first via grep. If the count is unexpected, revert to per-site Edits with surrounding context.
|
||||
- **Inventory undercount.** Wave 34 undercounted by ~30%. The Explore agent for wave 35 explicitly included `.peer as?`/`is`/outflow-helper patterns, so the expected ratio is lower, but budget for 1–3 inventory-missed sites surfacing in Task 8.
|
||||
- **Name collisions (do NOT touch).** `[EnginePeer]` arrays in `LiveStreamSettingsScreen.swift`, `ShareWithPeersScreen.swift`, and `ChatSendStarsScreen.swift` named `sendAsPeers` / `availableSendAsPeers` are unrelated. `ChatPanelInterfaceInteraction` callbacks named `openSendAsPeer` take `(ASDisplayNode, ContextGesture?)`, not `SendAsPeer`. `initialSendAsPeerId` parameters are `PeerId`-typed. If Task 8 surfaces errors in any of these files, the fix likely indicates a wrong cascade from a real SendAsPeer site — do NOT migrate those files as part of this wave.
|
||||
- **WIP isolation.** Pre-existing modifications to `ChatListFilterPresetController.swift`, `ChatListFilterPresetListController.swift`, the `sourcekit-bazel-bsp` submodule marker, and untracked `build-system/tulsi/` / `submodules/TgVoip/` / `third-party/libx264/` / `docs/superpowers/plans/2026-04-22-claude-md-reorganization.md` are user WIP — do NOT stage them. Use the explicit `git add <files>` form in Step 10.2.
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
# Wave 54: ClearPeerHistory.init + openClearHistory `chatPeer: Peer → EnginePeer`
|
||||
|
||||
> **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 the `chatPeer:` parameter type on both `ClearPeerHistory.init` and `openClearHistory` from `Peer` to `EnginePeer`. Closes wave-53's deferred sibling.
|
||||
|
||||
**Wave shape:** Bundled method-signature migration (familiar from waves 41/44/47/50/53). Mechanical `as?`/`is` cluster on a single field, with EnginePeer.init boundary lifts at each call site.
|
||||
|
||||
**Tech Stack:** Swift, Bazel, Make.py.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Flight Inventory (validated 2026-04-25)
|
||||
|
||||
**2 files modified, 16 edits.**
|
||||
|
||||
### File 1: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift` (PIS)
|
||||
|
||||
| Line | Current | After | Note |
|
||||
|---|---|---|---|
|
||||
| 3213 | `func openClearHistory(... peer: Peer, chatPeer: Peer) {` | `... chatPeer: EnginePeer)` | type-site |
|
||||
| 3230 | `EnginePeer(chatPeer).compactDisplayTitle` | `chatPeer.compactDisplayTitle` | drop wrap |
|
||||
| 3232 | `EnginePeer(chatPeer).compactDisplayTitle` | `chatPeer.compactDisplayTitle` | drop wrap |
|
||||
| 3251 | `EnginePeer(chatPeer).compactDisplayTitle` | `chatPeer.compactDisplayTitle` | drop wrap |
|
||||
| 3269 | `EnginePeer(chatPeer).compactDisplayTitle` | `chatPeer.compactDisplayTitle` | drop wrap |
|
||||
| 7416 | `init(... peer: Peer, chatPeer: Peer, cachedData: ...)` | `... chatPeer: EnginePeer, cachedData: ...` | type-site |
|
||||
| 7421 | `} else if chatPeer is TelegramSecretChat {` | `} else if case .secretChat = chatPeer {` | conversion |
|
||||
| 7425 | `} else if let group = chatPeer as? TelegramGroup {` | `} else if case let .legacyGroup(group) = chatPeer {` | conversion |
|
||||
| 7436 | `} else if let channel = chatPeer as? TelegramChannel {` | `} else if case let .channel(channel) = chatPeer {` | conversion |
|
||||
| 7464 | `if let user = chatPeer as? TelegramUser, user.botInfo != nil {` | `if case let .user(user) = chatPeer, user.botInfo != nil {` | conversion |
|
||||
|
||||
`peer:` parameter stays Peer-typed in both functions: `openClearHistory` doesn't reference `peer` in its body; `ClearPeerHistory.init` uses only `peer.id == context.account.peerId` (line 7417), which works on Peer (and would also work on EnginePeer, but migrating it would require 6 boundary lifts at PISPBA call sites for no internal benefit).
|
||||
|
||||
### File 2: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenPerformButtonAction.swift` (PISPBA)
|
||||
|
||||
| Line | Current | After | Note |
|
||||
|---|---|---|---|
|
||||
| 851 | `chatPeer: chatPeer._asPeer()` | `chatPeer: chatPeer` | drop wave-53 ADD |
|
||||
| 857 | `chatPeer: user` | `chatPeer: EnginePeer(user)` | boundary lift (TelegramUser) |
|
||||
| 1067 | `chatPeer: channel` | `chatPeer: EnginePeer(channel)` | boundary lift (TelegramChannel) |
|
||||
| 1073 | `chatPeer: channel` | `chatPeer: EnginePeer(channel)` | boundary lift (TelegramChannel) |
|
||||
| 1234 | `chatPeer: group` | `chatPeer: EnginePeer(group)` | boundary lift (TelegramGroup) |
|
||||
| 1240 | `chatPeer: group` | `chatPeer: EnginePeer(group)` | boundary lift (TelegramGroup) |
|
||||
|
||||
### Net accounting
|
||||
|
||||
- **Drops:** 5 (4 `EnginePeer(chatPeer).compactDisplayTitle` + 1 `_asPeer()` bridge from wave 53).
|
||||
- **Adds:** 5 boundary lifts (5 `EnginePeer(...)` wraps at PISPBA call sites).
|
||||
- **Conversions:** 4 (`is`/`as?` → `case let`).
|
||||
- **Type-site:** 2 (signature changes on PIS:3213 and PIS:7416).
|
||||
|
||||
Net internal-bridge progress: `5 drops − 5 adds = 0 raw count`. But ratchet kills 4 internal display-call wraps (`EnginePeer(chatPeer).compactDisplayTitle` patterns) which is the hot path; only call-site boundary lifts remain. Closes wave-53's deferred ADD at PISPBA:851.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: PIS signature edits + body wrap drops
|
||||
|
||||
- [ ] **Step 1: Edit `openClearHistory` signature at PIS:3213**
|
||||
|
||||
Replace `peer: Peer, chatPeer: Peer)` with `peer: Peer, chatPeer: EnginePeer)`.
|
||||
|
||||
- [ ] **Step 2: Drop 4 `EnginePeer(chatPeer).compactDisplayTitle` wraps**
|
||||
|
||||
`replace_all=true` of `EnginePeer(chatPeer).compactDisplayTitle` → `chatPeer.compactDisplayTitle`. (Only 4 occurrences in the file, all in `openClearHistory` body; verified by grep.)
|
||||
|
||||
- [ ] **Step 3: Edit `ClearPeerHistory.init` signature at PIS:7416**
|
||||
|
||||
Replace `peer: Peer, chatPeer: Peer, cachedData:` with `peer: Peer, chatPeer: EnginePeer, cachedData:`.
|
||||
|
||||
- [ ] **Step 4: Convert PIS:7421 `is TelegramSecretChat`**
|
||||
|
||||
Replace `} else if chatPeer is TelegramSecretChat {` with `} else if case .secretChat = chatPeer {`.
|
||||
|
||||
- [ ] **Step 5: Convert PIS:7425 `as? TelegramGroup`**
|
||||
|
||||
Replace `} else if let group = chatPeer as? TelegramGroup {` with `} else if case let .legacyGroup(group) = chatPeer {`.
|
||||
|
||||
- [ ] **Step 6: Convert PIS:7436 `as? TelegramChannel`**
|
||||
|
||||
Replace `} else if let channel = chatPeer as? TelegramChannel {` with `} else if case let .channel(channel) = chatPeer {`.
|
||||
|
||||
- [ ] **Step 7: Convert PIS:7464 `as? TelegramUser`**
|
||||
|
||||
Replace `if let user = chatPeer as? TelegramUser, user.botInfo != nil {` with `if case let .user(user) = chatPeer, user.botInfo != nil {`.
|
||||
|
||||
### Task 2: PISPBA call-site lifts + bridge drop
|
||||
|
||||
- [ ] **Step 1: Drop wave-53 `_asPeer()` bridge at PISPBA:851**
|
||||
|
||||
Replace `chatPeer: chatPeer._asPeer()` with `chatPeer: chatPeer`.
|
||||
|
||||
- [ ] **Step 2: Lift PISPBA:857 `chatPeer: user`**
|
||||
|
||||
Replace `peer: user, chatPeer: user)` with `peer: user, chatPeer: EnginePeer(user))`.
|
||||
|
||||
- [ ] **Step 3: Lift channel call sites (PISPBA:1067 + 1073)**
|
||||
|
||||
`replace_all=true` of `chatPeer: channel` → `chatPeer: EnginePeer(channel)`. Verify exactly 2 hits flipped.
|
||||
|
||||
- [ ] **Step 4: Lift group call sites (PISPBA:1234 + 1240)**
|
||||
|
||||
`replace_all=true` of `chatPeer: group` → `chatPeer: EnginePeer(group)`. Verify exactly 2 hits flipped.
|
||||
|
||||
### Task 3: Build verification
|
||||
|
||||
- [ ] **Step 1: Run full build with `--continueOnError`.**
|
||||
|
||||
Forecast 1 iteration. Risk: hidden `chatPeer` access on Peer-typed shape elsewhere (none expected — body audit complete).
|
||||
|
||||
### Task 4: Commit + log
|
||||
|
||||
- [ ] **Step 1: Commit wave with the two file paths explicitly.**
|
||||
- [ ] **Step 2: Update `docs/superpowers/postbox-refactor-log.md` and the memory file.**
|
||||
- [ ] **Step 3: Commit log.**
|
||||
|
|
@ -0,0 +1,516 @@
|
|||
# Wave 50: enclosingPeer 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 the PeerInfo members chain's `enclosingPeer` field from raw Postbox `Peer?` to `EnginePeer?` (wave 50 of the Postbox → TelegramEngine refactor).
|
||||
|
||||
**Architecture:** Cross-file private struct-field migration with stored-form ratchet. Edits stay inside `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/`. Replaces `as? TelegramChannel` / `as? TelegramGroup` casts with `case let .channel(...)` / `case let .legacyGroup(...)` (wave-41/45 idiom), drops `is TelegramChannel` checks for `case .channel = ...` (wave-41 always-false-warning fix), and removes 5 internal `_asPeer()` / `EnginePeer(...)` / `flatMap(EnginePeer.init)` bridges. The engine.data subscription at PIMP:354 already returns `EnginePeer?` — this wave closes the demote-then-promote ratchet.
|
||||
|
||||
**Tech Stack:** Swift, Bazel via `Make.py`, no unit tests (per `CLAUDE.md`). Verification is the full-project debug-sim-arm64 build with `--continueOnError`.
|
||||
|
||||
**Iteration budget:** 1–2 (target first-pass-clean; recent first-pass-clean streak: waves 42, 43*, 45, 46, 48, 49 — *wave 43 took 2 iterations).
|
||||
|
||||
**Note on TDD:** This project has no unit tests (CLAUDE.md "No tests are used at the moment"). The standard TDD test-first cycle in the skill template does not apply. Each task instead writes the edits, then verifies via Bazel build + residue grep.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Role | Changes |
|
||||
|---|---|---|
|
||||
| `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift` (PSMI) | List-item view-model + node | Type-change stored field + init param; 4 cast/is-check rewrites; 1 `flatMap(EnginePeer.init)` simplification |
|
||||
| `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift` (PIMP) | Members-pane node + helpers | 3 func sigs + 1 stored field type-change; 4 cast/is-check rewrites; 1 `EnginePeer(...)` wrap drop; 2 `_asPeer()` drops |
|
||||
| `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift` (PSPB) | Profile-items builder (non-settings members section) | 1 boundary `_asPeer()` drop at the call site that constructs the migrated init |
|
||||
|
||||
No public-API ripple — `PeerInfoScreenMemberItem` and `PeerInfoMembersPaneNode` are local to the PeerInfoScreen module.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: PSMI.swift — type changes + cast/is-check rewrites + flatMap simplification
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift`
|
||||
|
||||
**Edits in this task:** 7 (1 stored-field type, 1 init-param type, 2 cast→case-let, 2 is→case, 1 flatMap simplification).
|
||||
|
||||
- [ ] **Step 1: Change stored field type at line 23**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
let enclosingPeer: Peer?
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
let enclosingPeer: EnginePeer?
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Change init parameter type at line 34**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
enclosingPeer: Peer?,
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
enclosingPeer: EnginePeer?,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Rewrite cast at line 152 (TelegramChannel)**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
if let channel = item.enclosingPeer as? TelegramChannel, channel.hasPermission(.editRank) {
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
if case let .channel(channel) = item.enclosingPeer, channel.hasPermission(.editRank) {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Rewrite cast at line 154 (TelegramGroup)**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
} else if let group = item.enclosingPeer as? TelegramGroup, !group.hasBannedPermission(.banEditRank) {
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
} else if case let .legacyGroup(group) = item.enclosingPeer, !group.hasBannedPermission(.banEditRank) {
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Simplify flatMap at line 178**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
let actions = availableActionsForMemberOfPeer(accountPeerId: item.context.accountPeerId, peer: item.enclosingPeer.flatMap(EnginePeer.init), member: item.member)
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
let actions = availableActionsForMemberOfPeer(accountPeerId: item.context.accountPeerId, peer: item.enclosingPeer, member: item.member)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Rewrite is-check at line 181**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
if actions.contains(.promote) && item.enclosingPeer is TelegramChannel {
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
if actions.contains(.promote), case .channel = item.enclosingPeer {
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Rewrite is-check at line 187**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
if item.enclosingPeer is TelegramChannel {
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
if case .channel = item.enclosingPeer {
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: PIMP.swift — signatures + stored field + body rewrites + demotion drops
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift`
|
||||
|
||||
**Edits in this task:** 11 (3 func sigs + 1 stored-field type + 4 cast/is rewrites + 1 EnginePeer wrap drop + 2 `_asPeer()` drops).
|
||||
|
||||
- [ ] **Step 1: Change `func item(...)` signature at line 92**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
func item(context: AccountContext, presentationData: PresentationData, enclosingPeer: Peer, addMemberAction: @escaping () -> Void, action: @escaping (PeerInfoMember, PeerMembersListAction) -> Void, contextAction: ((PeerInfoMember, ASDisplayNode, ContextGesture?) -> Void)?) -> ListViewItem {
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
func item(context: AccountContext, presentationData: PresentationData, enclosingPeer: EnginePeer, addMemberAction: @escaping () -> Void, action: @escaping (PeerInfoMember, PeerMembersListAction) -> Void, contextAction: ((PeerInfoMember, ASDisplayNode, ContextGesture?) -> Void)?) -> ListViewItem {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rewrite cast at line 113 (TelegramChannel, non-optional context)**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
if let channel = enclosingPeer as? TelegramChannel, channel.hasPermission(.editRank) {
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
if case let .channel(channel) = enclosingPeer, channel.hasPermission(.editRank) {
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Rewrite cast at line 115 (TelegramGroup, non-optional context)**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
} else if let group = enclosingPeer as? TelegramGroup, !group.hasBannedPermission(.banEditRank) {
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
} else if case let .legacyGroup(group) = enclosingPeer, !group.hasBannedPermission(.banEditRank) {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Drop the `EnginePeer(...)` wrap at line 139**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
let actions = availableActionsForMemberOfPeer(accountPeerId: context.account.peerId, peer: EnginePeer(enclosingPeer), member: member)
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
let actions = availableActionsForMemberOfPeer(accountPeerId: context.account.peerId, peer: enclosingPeer, member: member)
|
||||
```
|
||||
|
||||
`availableActionsForMemberOfPeer` takes `peer: EnginePeer?` (PeerInfoData.swift:2314); Swift auto-wraps the non-optional `enclosingPeer: EnginePeer` to optional.
|
||||
|
||||
- [ ] **Step 5: Rewrite is-check at line 142 (non-optional context)**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
if actions.contains(.promote) && enclosingPeer is TelegramChannel {
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
if actions.contains(.promote), case .channel = enclosingPeer {
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Rewrite is-check at line 148 (non-optional context)**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
if enclosingPeer is TelegramChannel {
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
if case .channel = enclosingPeer {
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Change `preparedTransition` signature at line 271**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
private func preparedTransition(from fromEntries: [PeerMembersListEntry], to toEntries: [PeerMembersListEntry], context: AccountContext, presentationData: PresentationData, enclosingPeer: Peer, addMemberAction: @escaping () -> Void, action: @escaping (PeerInfoMember, PeerMembersListAction) -> Void, contextAction: ((PeerInfoMember, ASDisplayNode, ContextGesture?) -> Void)?) -> PeerMembersListTransaction {
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
private func preparedTransition(from fromEntries: [PeerMembersListEntry], to toEntries: [PeerMembersListEntry], context: AccountContext, presentationData: PresentationData, enclosingPeer: EnginePeer, addMemberAction: @escaping () -> Void, action: @escaping (PeerInfoMember, PeerMembersListAction) -> Void, contextAction: ((PeerInfoMember, ASDisplayNode, ContextGesture?) -> Void)?) -> PeerMembersListTransaction {
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Change stored field type at line 293**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
private var enclosingPeer: Peer?
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
private var enclosingPeer: EnginePeer?
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Drop `_asPeer()` at line 361**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
strongSelf.enclosingPeer = enclosingPeer._asPeer()
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
strongSelf.enclosingPeer = enclosingPeer
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Drop `_asPeer()` at line 363**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
strongSelf.updateState(enclosingPeer: enclosingPeer._asPeer(), state: state, presentationData: presentationData)
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
strongSelf.updateState(enclosingPeer: enclosingPeer, state: state, presentationData: presentationData)
|
||||
```
|
||||
|
||||
- [ ] **Step 11: Change `updateState` signature at line 442**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
private func updateState(enclosingPeer: Peer, state: PeerInfoMembersState, presentationData: PresentationData) {
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
private func updateState(enclosingPeer: EnginePeer, state: PeerInfoMembersState, presentationData: PresentationData) {
|
||||
```
|
||||
|
||||
The pass-through call sites at PIMP:275, :276, :437, :438, :451, :485 require no edit — types flow through transparently.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: PSPB.swift — boundary lift at members-section call site
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift`
|
||||
|
||||
**Edits in this task:** 1.
|
||||
|
||||
- [ ] **Step 1: Drop `_asPeer()` at line 852**
|
||||
|
||||
Find:
|
||||
|
||||
```swift
|
||||
items[.peerMembers]!.append(PeerInfoScreenMemberItem(id: member.id, context: .account(context), enclosingPeer: peer._asPeer(), member: member, isAccount: false, action: isAccountPeer ? { _ in
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
items[.peerMembers]!.append(PeerInfoScreenMemberItem(id: member.id, context: .account(context), enclosingPeer: peer, member: member, isAccount: false, action: isAccountPeer ? { _ in
|
||||
```
|
||||
|
||||
`peer` here is the closure-bound `EnginePeer` from the `data.peer` source pipeline (`PeerInfoScreenData.peer: EnginePeer?` post-wave-42, unwrapped to non-optional `EnginePeer` and being passed to a now-`EnginePeer?` param — auto-promotes to optional).
|
||||
|
||||
The other `PeerInfoScreenMemberItem(...)` construction at `PeerInfoSettingsItems.swift:132` passes `enclosingPeer: nil`, which is valid for either optional type — no edit.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Full-project Bazel build
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Run the build with `--continueOnError`**
|
||||
|
||||
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 --continueOnError
|
||||
```
|
||||
|
||||
Expected: clean build (`bazel build complete` or equivalent green output).
|
||||
|
||||
- [ ] **Step 2: If build fails, triage iteration**
|
||||
|
||||
If errors land in `PeerInfoScreenMemberItem.swift` or `PeerInfoMembersPane.swift` or `PeerInfoProfileItems.swift`:
|
||||
- Read the failing line.
|
||||
- Common failure modes from prior waves:
|
||||
- **Always-false `is` warning under `-warnings-as-errors`**: leftover `is TelegramX` not converted in step. Re-grep `enclosingPeer is Telegram` over the 3 files.
|
||||
- **Always-failing `as?` cast warning**: leftover `as? TelegramX` not converted. Re-grep `enclosingPeer.*as\?`.
|
||||
- **Type mismatch on closure-capture alias**: a `strongSelf.enclosingPeer` or `self.enclosingPeer` site missed a `_asPeer()` drop. Re-grep `enclosingPeer\._asPeer\|EnginePeer\(enclosingPeer`.
|
||||
- **Unused variable warning**: a binding from `case let .channel(channel)` not actually used. Re-read the body.
|
||||
|
||||
Fix in place and re-run step 1. Budget: 2 iterations.
|
||||
|
||||
If errors land outside those 3 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 5: Post-edit residue grep
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Bridge residue grep**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
grep -rnE "enclosingPeer\._asPeer|EnginePeer\(enclosingPeer\)|enclosingPeer\.flatMap\(EnginePeer" \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/
|
||||
```
|
||||
|
||||
Expected: empty output.
|
||||
|
||||
- [ ] **Step 2: Cast/is-check residue grep**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
grep -rnE "enclosingPeer.*as\? TelegramChannel|enclosingPeer.*as\? TelegramGroup|enclosingPeer is TelegramChannel" \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/
|
||||
```
|
||||
|
||||
Expected: empty output.
|
||||
|
||||
- [ ] **Step 3: Sanity check — `enclosingPeer` references should now exclusively type-resolve to EnginePeer**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
grep -nE ": Peer\b" submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift
|
||||
```
|
||||
|
||||
Expected: no `enclosingPeer: Peer` or `enclosingPeer: Peer?` annotations remain. (Other `: Peer` annotations on unrelated symbols are fine.)
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Commit the wave
|
||||
|
||||
**Files:** none (git only).
|
||||
|
||||
- [ ] **Step 1: Stage the 3 modified files**
|
||||
|
||||
```sh
|
||||
git add \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Confirm staging is clean**
|
||||
|
||||
```sh
|
||||
git status --short | grep -v "^??"
|
||||
```
|
||||
|
||||
Expected output: only the 3 staged files (lines starting with `M ` or `A `). 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 50
|
||||
|
||||
Migrate enclosingPeer Peer? -> EnginePeer? across PeerInfoScreenMemberItem
|
||||
+ PeerInfoMembersPaneNode + 1 PSPB call site. 19 edits / 3 files.
|
||||
|
||||
Drops 5 internal bridges: 2 _asPeer() demotions at PIMP:361/363, 1
|
||||
EnginePeer(enclosingPeer) wrap at PIMP:139, 1 flatMap(EnginePeer.init)
|
||||
at PSMI:178, 1 boundary _asPeer() lift at PSPB:852.
|
||||
|
||||
Closes the wave-48-pattern internal-demotion-and-external-re-promotion
|
||||
ratchet at PIMP:354-363 (engine.data subscription returns EnginePeer?,
|
||||
previously demoted to Peer? at storage).
|
||||
|
||||
All `as? TelegramChannel` / `as? TelegramGroup` casts converted to
|
||||
`case let .channel(...)` / `case let .legacyGroup(...)` (wave-41/45
|
||||
idiom). All `is TelegramChannel` checks converted to
|
||||
`case .channel = ...` (wave-41 always-false-warning fix).
|
||||
|
||||
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 50 commit.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 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`
|
||||
|
||||
- [ ] **Step 1: Append wave 50 outcome to refactor log**
|
||||
|
||||
Add a "Wave 50 outcome" entry at the appropriate chronological position in `docs/superpowers/postbox-refactor-log.md`. Use the wave 49 outcome entry as the template. Include:
|
||||
- Commit hash (from Task 6 step 4).
|
||||
- Iteration count (1 if first-pass-clean; 2 if Task 4 step 2 fired once).
|
||||
- Net-bridge accounting: −5 internal bridges (2 `_asPeer()` + 1 `EnginePeer(...)` wrap + 1 `flatMap(EnginePeer.init)` + 1 boundary `_asPeer()` lift). 0 ADD wraps. 0 boundary lifts net new.
|
||||
- Bazel build duration (from Task 4 step 1 output).
|
||||
- Any wave-specific lessons surfaced.
|
||||
|
||||
- [ ] **Step 2: Update wave-50-next-wave memory**
|
||||
|
||||
Edit `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`:
|
||||
- Promote wave 50 outcome line into the "Latest commits" section using the format of the wave 49 entry.
|
||||
- Update the top frontmatter `description` to reflect wave 50 landed and propose wave 51.
|
||||
- Promote the wave-51 candidate (`PeerInfoGroupsInCommonPaneNode.PeerEntry.peer: Peer → EnginePeer`) to the top of the "Wave 51 candidates" section, replacing the now-stale "Wave 50 candidates" header. Re-run the broader grep if needed:
|
||||
|
||||
```sh
|
||||
grep -rnE "^\s*(let|var|public let|public var|private let|private var) [a-zA-Z_]+: Peer\??$|^\s*(let|var|public let|public var|private let|private var) [a-zA-Z_]+: Peer\? = " \
|
||||
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ --include="*.swift" | grep -v "EnginePeer"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit the doc update**
|
||||
|
||||
```sh
|
||||
git add docs/superpowers/postbox-refactor-log.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs: log wave 50 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 (from spec)
|
||||
|
||||
| Category | Count | Sites |
|
||||
|---|---|---|
|
||||
| Internal bridge drops | −5 | PIMP:361, PIMP:363, PIMP:139, PSMI:178, PSPB:852 |
|
||||
| Boundary lifts (net new) | 0 | source pipeline already EnginePeer? |
|
||||
| ADD wraps | 0 | no Peer-only property accesses on bare `enclosingPeer` |
|
||||
| Cast→case-let conversions | 4 | PSMI:152/154, PIMP:113/115 |
|
||||
| `is`→`case` conversions | 4 | PSMI:181/187, PIMP:142/148 |
|
||||
| Type annotations updated | 6 | PSMI:23/34, PIMP:92/271/293/442 |
|
||||
|
||||
**Total commit footprint:** 19 line edits across 3 files, plus a docs commit for the outcome log.
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
# Wave 49 — `PeerInfoScreenData.linkedDiscussionPeer` + `.linkedMonoforumPeer` `Peer? → EnginePeer?` (bundle)
|
||||
|
||||
**Date:** 2026-04-25
|
||||
**Predecessor:** Wave 48 (commit `1e4c2eea33`) — savedMessagesPeer single-field migration.
|
||||
**Shape:** Cross-file bundled struct-field migration (2 sibling fields, 2 files). Both fields are module-internal; no external consumer references them on `PeerInfoScreenData`. Bundled because both fields:
|
||||
- Share a sibling declaration site in `PeerInfoData.swift`.
|
||||
- Have parallel local-source patterns (raw `Peer?` from `peerView.peers[id]` dict lookup; **not** an engine signal as in wave 48).
|
||||
- Are both consumed in `PeerInfoProfileItems.swift` only.
|
||||
- Migrating one without the other adds friction at the source-construction sites where they're computed together.
|
||||
|
||||
## Pre-flight inventory
|
||||
|
||||
`grep -rEn "(\w+\??)\.linkedDiscussionPeer\b|(\w+\??)\.linkedMonoforumPeer\b" submodules/ Telegram/`:
|
||||
- `submodules/PeerInfoUI/Sources/ChannelDiscussionGroupSetupController.swift:600,651` — references are on a local `view` object (different type, NOT PeerInfoScreenData). Out of scope.
|
||||
- All `data.linkedDiscussionPeer` / `data.linkedMonoforumPeer` accesses live in PIPI within the PeerInfoScreen module.
|
||||
|
||||
Within scope:
|
||||
|
||||
### `PeerInfoData.swift` (storage class + 2 init sites that compute the locals)
|
||||
|
||||
| Site | Code | Action |
|
||||
|------|------|--------|
|
||||
| :396 | `let linkedDiscussionPeer: Peer?` (field decl) | Type → `EnginePeer?` |
|
||||
| :397 | `let linkedMonoforumPeer: Peer?` (field decl) | Type → `EnginePeer?` |
|
||||
| :453 | `linkedDiscussionPeer: Peer?,` (init param) | Type → `EnginePeer?` |
|
||||
| :454 | `linkedMonoforumPeer: Peer?,` (init param) | Type → `EnginePeer?` |
|
||||
| :498 | `self.linkedDiscussionPeer = linkedDiscussionPeer` | No change |
|
||||
| :499 | `self.linkedMonoforumPeer = linkedMonoforumPeer` | No change |
|
||||
| :1038, :1111, :1631 | `linkedDiscussionPeer: nil,` (init kwargs) | No change |
|
||||
| :1039, :1112, :1632 | `linkedMonoforumPeer: nil,` (init kwargs) | No change |
|
||||
| :1836 | `var discussionPeer: Peer?` (local) | Type → `EnginePeer?` |
|
||||
| :1838 | `discussionPeer = peer` (where `peer = peerView.peers[linkedDiscussionPeerId]`, raw `Peer`) | Wrap → `discussionPeer = EnginePeer(peer)` |
|
||||
| :1841 | `var monoforumPeer: Peer?` (local) | Type → `EnginePeer?` |
|
||||
| :1843 | `monoforumPeer = peerView.peers[linkedMonoforumId]` (dict lookup, `Peer?`) | Wrap → `monoforumPeer = peerView.peers[linkedMonoforumId].flatMap(EnginePeer.init)` |
|
||||
| :2131 | `var discussionPeer: Peer?` (local, parallel to :1836) | Type → `EnginePeer?` |
|
||||
| :2133 | `discussionPeer = peer` (parallel to :1838) | Wrap → `discussionPeer = EnginePeer(peer)` |
|
||||
| :2136 | `var monoforumPeer: Peer?` (local, parallel to :1841) | Type → `EnginePeer?` |
|
||||
| :2138 | `monoforumPeer = peerView.peers[linkedMonoforumId]` (parallel to :1843) | Wrap with `.flatMap(EnginePeer.init)` |
|
||||
| :1878, :1879, :2216, :2217 | init kwargs `linkedDiscussionPeer: discussionPeer,` / `linkedMonoforumPeer: monoforumPeer,` | No change (locals migrate; pass through) |
|
||||
|
||||
That's **12 edits** in PID. Note the `var` declarations and assignments at :1836–:1843 and :2131–:2138 are *parallel pairs* (verified by grep). Use `replace_all=true` for the duplicate snippets.
|
||||
|
||||
### `PeerInfoProfileItems.swift` (3 edits)
|
||||
|
||||
| Site | Code | Action |
|
||||
|------|------|--------|
|
||||
| :1098 | `if let peer = data.linkedDiscussionPeer { ... }` | No change (binding works on `EnginePeer?`) |
|
||||
| :1099 | `if let addressName = peer.addressName, !addressName.isEmpty {` | No change — `EnginePeer.addressName` forwarded (verified at `submodules/TelegramCore/Sources/TelegramEngine/Peers/Peer.swift:461`) |
|
||||
| :1102 | `discussionGroupTitle = EnginePeer(peer).displayTitle(strings: ..., displayOrder: ...)` | **Drop wrap** → `peer.displayTitle(...)` |
|
||||
| :1197 | `if let monoforumPeer = data.linkedMonoforumPeer as? TelegramChannel {` | **Pattern rewrite** → `if case let .channel(monoforumPeer) = data.linkedMonoforumPeer {` |
|
||||
| :1198 | `monoforumPeer.sendPaidMessageStars` | No change — `sendPaidMessageStars` is a `TelegramChannel` property (`SyncCore_TelegramChannel.swift:215`); `case .channel` binds to `TelegramChannel` |
|
||||
| :1404 | `if let linkedDiscussionPeer = data.linkedDiscussionPeer {` | No change (binding works) |
|
||||
| :1406 | `if let addressName = linkedDiscussionPeer.addressName, !addressName.isEmpty {` | No change (forwarded) |
|
||||
| :1409 | `peerTitle = EnginePeer(linkedDiscussionPeer).displayTitle(...)` | **Drop wrap** → `linkedDiscussionPeer.displayTitle(...)` |
|
||||
|
||||
3 edits in PIPI.
|
||||
|
||||
## EnginePeer property forwarding audit
|
||||
|
||||
- `EnginePeer.addressName` — forwarded at `Peer.swift:461`. ✓
|
||||
- `EnginePeer.displayTitle(strings:displayOrder:)` — defined as `EnginePeer` instance method (used elsewhere via `EnginePeer(...).displayTitle(...)` pattern; once we have an `EnginePeer`, it's directly callable). ✓
|
||||
- `case .channel` binding payload is `TelegramChannel`. ✓
|
||||
- `TelegramChannel.sendPaidMessageStars` — exists (`SyncCore_TelegramChannel.swift:215`). ✓
|
||||
|
||||
## Net bridge count
|
||||
|
||||
- **ADDs (4):** boundary lifts at PID:1838 (`EnginePeer(peer)`), PID:1843 (`.flatMap(EnginePeer.init)`), PID:2133, PID:2138. These lift the Postbox-typed `peerView.peers[...]` value to the engine type at the boundary — the correct semantic position for a Postbox→Engine refactor (mirrors wave 42 where `peer.flatMap(EnginePeer.init)` lift was added at PID:1620).
|
||||
- **DROPs (2):** PIPI:1102 and :1409 lose `EnginePeer(...)` wraps around `displayTitle` calls.
|
||||
- **Net text bridges:** +2. **But:** the ADDs are correct boundary lifts; the field-typed-as-`EnginePeer?` is the canonical state. The 2 displayTitle DROPs are the actual ratchet value.
|
||||
- **Plus:** 1 cleaner pattern (PIPI:1197 `as?` cast → `case let .channel`), no text saving but better Swift idiom.
|
||||
|
||||
## Edit list
|
||||
|
||||
### `PeerInfoData.swift` (12 edits, but Edit text uses `replace_all=true` to bundle parallel pairs)
|
||||
|
||||
1. Line 396: `let linkedDiscussionPeer: Peer?` → `let linkedDiscussionPeer: EnginePeer?`
|
||||
2. Line 397: `let linkedMonoforumPeer: Peer?` → `let linkedMonoforumPeer: EnginePeer?`
|
||||
3. Line 453: `linkedDiscussionPeer: Peer?,` → `linkedDiscussionPeer: EnginePeer?,`
|
||||
4. Line 454: `linkedMonoforumPeer: Peer?,` → `linkedMonoforumPeer: EnginePeer?,`
|
||||
5. Lines 1836 + 2131 (`replace_all=true` over `var discussionPeer: Peer?`): → `var discussionPeer: EnginePeer?`
|
||||
6. Lines 1838 + 2133 (`replace_all=true` over `discussionPeer = peer`): → `discussionPeer = EnginePeer(peer)`
|
||||
7. Lines 1841 + 2136 (`replace_all=true` over `var monoforumPeer: Peer?`): → `var monoforumPeer: EnginePeer?`
|
||||
8. Lines 1843 + 2138 (`replace_all=true` over `monoforumPeer = peerView.peers[linkedMonoforumId]`): → `monoforumPeer = peerView.peers[linkedMonoforumId].flatMap(EnginePeer.init)`
|
||||
|
||||
### `PeerInfoProfileItems.swift` (3 edits)
|
||||
|
||||
9. Line 1102: `discussionGroupTitle = EnginePeer(peer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)` → `discussionGroupTitle = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)`
|
||||
10. Line 1197: `if let monoforumPeer = data.linkedMonoforumPeer as? TelegramChannel {` → `if case let .channel(monoforumPeer) = data.linkedMonoforumPeer {`
|
||||
11. Line 1409: `peerTitle = EnginePeer(linkedDiscussionPeer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)` → `peerTitle = linkedDiscussionPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `PeerInfoScreenData.chatPeer` — large blast radius. Defer.
|
||||
- `PeerInfoScreenMemberItem.enclosingPeer`. Defer.
|
||||
|
||||
## Build & verify
|
||||
|
||||
Standard Bazel command. Expected 1 iteration if forwarding audit holds; 2 if a `displayTitle` overload-resolution surprise surfaces.
|
||||
|
||||
## Commit
|
||||
|
||||
`Postbox -> TelegramEngine wave 49`. Body: bundle + edits summary + ADD/DROP accounting.
|
||||
|
||||
## Outcome capture
|
||||
|
||||
Append Wave 49 entry to `docs/superpowers/postbox-refactor-log.md`; update memory file.
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
# Wave 48 — `PeerInfoScreenData.savedMessagesPeer` `Peer? → EnginePeer?`
|
||||
|
||||
**Date:** 2026-04-25
|
||||
**Predecessor:** Wave 47 (commit `d7b7536440`) — stored PHN.peer single-file private migration.
|
||||
**Shape:** Cross-file struct-field migration. Storage class is internal to PeerInfoScreen module; no external consumer references PSD.savedMessagesPeer.
|
||||
|
||||
## Target
|
||||
|
||||
`submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift`, `PeerInfoScreenData.savedMessagesPeer: Peer?` at line 388.
|
||||
|
||||
## Pre-flight inventory
|
||||
|
||||
`grep -rEn "(\w+\??)\.savedMessagesPeer\b" submodules/ Telegram/` → matches only inside `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/`. No external consumer. The same field name appears in unrelated places (TelegramEngineMessages.swift, ChatListUI, etc.) but those are different declarations on different types.
|
||||
|
||||
Within PeerInfoScreen module:
|
||||
|
||||
| Site | Code | Action |
|
||||
|------|------|--------|
|
||||
| `PeerInfoData.swift:388` | `let savedMessagesPeer: Peer?` (struct field decl) | Type change → `EnginePeer?` |
|
||||
| `PeerInfoData.swift:444` | `savedMessagesPeer: Peer?,` (init param) | Type change → `EnginePeer?` |
|
||||
| `PeerInfoData.swift:489` | `self.savedMessagesPeer = savedMessagesPeer` (assignment) | No change (passthrough) |
|
||||
| `PeerInfoData.swift:1029` | `savedMessagesPeer: nil,` (init kwarg) | No change (`nil` works for either) |
|
||||
| `PeerInfoData.swift:1102` | `savedMessagesPeer: nil,` | No change |
|
||||
| `PeerInfoData.swift:1313–1317` | `let savedMessagesPeer: Signal<EnginePeer?, NoError>` (local) | No change — already `EnginePeer?` |
|
||||
| `PeerInfoData.swift:1622` | `savedMessagesPeer: savedMessagesPeer?._asPeer(),` | **Drop bridge** → `savedMessagesPeer: savedMessagesPeer,` |
|
||||
| `PeerInfoData.swift:1869` | `savedMessagesPeer: nil,` | No change |
|
||||
| `PeerInfoData.swift:2207` | `savedMessagesPeer: nil,` | No change |
|
||||
| `PeerInfoScreen.swift:5399` | `peer: self.data?.savedMessagesPeer.flatMap(EnginePeer.init) ?? self.data?.peer,` | **Drop bridge** → `peer: self.data?.savedMessagesPeer ?? self.data?.peer,` |
|
||||
| `PeerInfoScreen.swift:5805` | same as :5399 | Same drop |
|
||||
|
||||
Total edits: 5 (3 in PID, 2 in PIS).
|
||||
|
||||
## EnginePeer / read-site audit
|
||||
|
||||
The local signal at `PeerInfoData.swift:1313` already produces `EnginePeer?` from `engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(...))`. The `?._asPeer()` at line 1622 was an artificial demotion. Migrating the field type to `EnginePeer?` removes both the demotion at the storage site and the `flatMap(EnginePeer.init)` re-promotions at the read sites — a clean ratchet.
|
||||
|
||||
PIS:5399 and :5805 use the field as input to `headerNode.update(... peer: ...)`, whose `peer` parameter has been `EnginePeer?` since wave 45. The `??` coalescing operand is `self.data?.peer` (already `EnginePeer?`). Result: drop the `.flatMap(EnginePeer.init)` and the expression compiles.
|
||||
|
||||
## Edit list
|
||||
|
||||
### PeerInfoData.swift (3 edits)
|
||||
|
||||
1. Line 388: `let savedMessagesPeer: Peer?` → `let savedMessagesPeer: EnginePeer?`
|
||||
2. Line 444: `savedMessagesPeer: Peer?,` → `savedMessagesPeer: EnginePeer?,`
|
||||
3. Line 1622: `savedMessagesPeer: savedMessagesPeer?._asPeer(),` → `savedMessagesPeer: savedMessagesPeer,`
|
||||
|
||||
### PeerInfoScreen.swift (2 edits, identical text)
|
||||
|
||||
4. Line 5399: `peer: self.data?.savedMessagesPeer.flatMap(EnginePeer.init) ?? self.data?.peer,` → `peer: self.data?.savedMessagesPeer ?? self.data?.peer,`
|
||||
5. Line 5805: same
|
||||
|
||||
Use `replace_all=true` for the PIS edit since the matched text appears at both call sites verbatim.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `PeerInfoScreenData.chatPeer` — large blast radius (5 `as? TelegramX` checks downstream + ClearPeerHistory init parameter), defer.
|
||||
- `PeerInfoScreenData.linkedDiscussionPeer`, `linkedMonoforumPeer` — both have `as? TelegramChannel` consumer sites in `PeerInfoProfileItems.swift`. Defer.
|
||||
- `PeerInfoScreenMemberItem.enclosingPeer` — defer (separate target).
|
||||
|
||||
## Build & verify
|
||||
|
||||
Same Bazel command as wave 47. Expected 1-iteration first-pass-clean (single-pattern bridge removal, no enum-case rewrites, no Peer-only property access).
|
||||
|
||||
## Commit
|
||||
|
||||
`Postbox -> TelegramEngine wave 48`. Body lists the 5-edit summary and notes −3 internal bridges (1 PID + 2 PIS, identical PIS text appears twice).
|
||||
|
||||
## Outcome capture
|
||||
|
||||
Append a Wave 48 entry to `docs/superpowers/postbox-refactor-log.md` and update memory file `project_postbox_refactor_next_wave.md`.
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
# Wave 47 — `PeerInfoHeaderNode.peer` stored field `Peer? → EnginePeer?`
|
||||
|
||||
**Date:** 2026-04-25
|
||||
**Predecessor:** Wave 46 (commit `5ca99da5a7`) — PeerInfo avatar chain.
|
||||
**Shape:** Single-file stored-field type migration. No external API change (field is `private`).
|
||||
|
||||
## Target
|
||||
|
||||
`submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift`, stored field `private var peer: Peer?` at line 92.
|
||||
|
||||
## Pre-flight inventory
|
||||
|
||||
`grep -n "self\.peer\b" PeerInfoHeaderNode.swift` returns exactly 3 references:
|
||||
|
||||
| Line | Code | Site type | Action |
|
||||
|------|------|-----------|--------|
|
||||
| 426 | `if let peer = self.peer, peer.profileImageRepresentations.isEmpty && gallery {` | Read | None — `profileImageRepresentations` is forwarded by `EnginePeer` (see `submodules/TelegramCore/Sources/TelegramEngine/Peers/Peer.swift:485`). Compiles unchanged. |
|
||||
| 521 | `self.peer = peer?._asPeer()` | Assignment | Drop the bridge → `self.peer = peer`. The `peer` parameter is already `EnginePeer?` after wave 45. |
|
||||
| 2049–2054 | `guard let self, let peer = self.peer, ...` followed by `peer: EnginePeer(peer),` | Read | Drop the wrap at line 2054 → `peer: peer,`. |
|
||||
|
||||
External access check: `grep -rn "headerNode\.peer\b" submodules/ Telegram/` returns empty. The field is private; only same-file siblings touch it.
|
||||
|
||||
EnginePeer forwarding (re-confirmed at plan time):
|
||||
- `profileImageRepresentations` — forwarded (Peer.swift:485). ✓
|
||||
- `EnginePeer(peer)` (PHN:2054) — accepts `EnginePeer` directly when the local is already `EnginePeer`; drop the constructor.
|
||||
|
||||
Field-declaration change is the only "type" change needed. The 3 callers' adjustments are mechanical bridge drops.
|
||||
|
||||
## Edit list
|
||||
|
||||
1. Line 92: `private var peer: Peer?` → `private var peer: EnginePeer?`
|
||||
2. Line 521: `self.peer = peer?._asPeer()` → `self.peer = peer`
|
||||
3. Line 2054: `peer: EnginePeer(peer),` → `peer: peer,`
|
||||
|
||||
Total: 3 edits in 1 file.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `PeerInfoData.swift:355,487` — different classes' `self.peer` assignments (different types). Audit confirms these are `RenderedChannelParticipant.peer` and similar — already migrated in earlier waves or owned by other types.
|
||||
- `PeerInfoAvatarTransformContainerNode.peer` (line 223) — already `EnginePeer?` after wave 46.
|
||||
|
||||
## Build & verify
|
||||
|
||||
```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: 1-iteration first-pass-clean. Only PeerInfoScreen + TelegramUI recompile.
|
||||
|
||||
## Commit
|
||||
|
||||
`Postbox -> TelegramEngine wave 47`. Body lists the 3-edit summary and notes -3 internal bridges.
|
||||
|
||||
## Outcome capture
|
||||
|
||||
Append a Wave 47 entry to `docs/superpowers/postbox-refactor-log.md` and update memory file `project_postbox_refactor_next_wave.md`.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -0,0 +1,493 @@
|
|||
# Wave 106: Speculative `import Postbox` Drop Sweep (round 2) 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:** Drop `import Postbox` from any consumer-module Swift file in `submodules/` whose remaining content no longer references a Postbox-only symbol. Wave 106 of the Postbox → TelegramEngine refactor — round 2 of the wave-93 speculative-drop sweep.
|
||||
|
||||
**Architecture:** Procedural sweep with build-feedback loop. (1) inventory candidates → (2) pre-flight regex pre-restore → (3) drop imports en masse → (4) build with `--continueOnError` → (5) restore failures → iterate → (6) final clean build → (7) optional BUILD-dep sweep → (8) single atomic commit. No code semantic changes — only `import` and BUILD `deps` lines.
|
||||
|
||||
**Tech Stack:** Swift, Bazel via `Make.py`, `grep`/`sed` for inventory, no unit tests. Verification is the full-project debug-sim-arm64 build.
|
||||
|
||||
**Iteration budget:** 2-5 build cycles (wave-93 precedent: 2 iter — drop, restore, clean).
|
||||
|
||||
**Note on TDD:** No unit tests in this project. Each task verifies via Bazel build + diff inspection. Build feedback IS the test.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-26-postbox-wave-106-import-drop-sweep-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Artifact | Role |
|
||||
|---|---|
|
||||
| `/tmp/wave106-candidates.txt` | All consumer files currently `import Postbox` |
|
||||
| `/tmp/wave106-skiplist.txt` | Files that match preemptive-restore regex (keep import) |
|
||||
| `/tmp/wave106-droplist.txt` | candidates − skiplist; files to edit |
|
||||
| `/tmp/wave106-build-iterN.log` | Per-iteration build log |
|
||||
| `/tmp/wave106-restore-iterN.txt` | Files needing restore after iter N |
|
||||
| `/tmp/wave106-final-droplist.txt` | Net dropped files after all iterations |
|
||||
| `submodules/**/*.swift` | Edited files (single-line Edit each) |
|
||||
| `submodules/**/BUILD` | (Optional Step 7) packages with no remaining Postbox imports |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Pre-flight WIP check
|
||||
|
||||
**File:** none (read-only).
|
||||
|
||||
- [ ] **Step 1: Verify clean working tree (modulo known-persistent state)**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
m build-system/bazel-rules/sourcekit-bazel-bsp
|
||||
?? build-system/tulsi/
|
||||
?? submodules/TgVoip/
|
||||
?? third-party/libx264/
|
||||
```
|
||||
|
||||
If output contains anything else (modified `M` files, other untracked dirs), HALT — there is unrelated WIP that would get tangled with the wave commit. Resolve before proceeding.
|
||||
|
||||
- [ ] **Step 2: Confirm we are on `master`**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
Expected: `master`. If not, stop and ask.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Inventory candidate files
|
||||
|
||||
**File:** none (read-only).
|
||||
|
||||
- [ ] **Step 1: Build the candidate list**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
grep -rl "^import Postbox" submodules --include="*.swift" \
|
||||
| grep -v "^submodules/Postbox/" \
|
||||
| grep -v "^submodules/TelegramCore/" \
|
||||
| grep -v "^submodules/TelegramApi/" \
|
||||
| sort -u > /tmp/wave106-candidates.txt
|
||||
wc -l /tmp/wave106-candidates.txt
|
||||
```
|
||||
|
||||
Expected: between ~700 and ~1200 files (wave-93-era was ~1200; waves 94-105 may have peeled some).
|
||||
|
||||
- [ ] **Step 2: Sanity-check the exclusion filters worked**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
grep -E "^submodules/(Postbox|TelegramCore|TelegramApi)/" /tmp/wave106-candidates.txt | head -5
|
||||
```
|
||||
|
||||
Expected: empty output (no excluded paths leaked through).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Build the skip-list via preemptive regex
|
||||
|
||||
**File:** none (read-only).
|
||||
|
||||
- [ ] **Step 1: Run the combined skip-regex against candidates**
|
||||
|
||||
The skip-regex is the union of three tiers from the spec. Run:
|
||||
|
||||
```sh
|
||||
grep -El "\bPostbox\b|\bMediaBox\b|\bMediaResource\b|\bMediaResourceData\b|\bMediaResourceId\b|\bPostboxCoding\b|\bPostboxDecoder\b|\bPostboxEncoder\b|\bMemoryBuffer\b|\bTempBoxFile\b|\bValueBoxKey\b|\bPostboxView\b|\bcombinedView\b|\bPeerId\b|\bMessageId\b|\bMediaId\b|\bMessageIndex\b|\bMessageAndThreadId\b|\bPeerNameIndex\b|\bStoryId\b|\bItemCollectionId\b|\bFetchResourceSourceType\b|\bFetchResourceError\b|\bPeer\b|\bMessage\b|\bMedia\b" \
|
||||
$(cat /tmp/wave106-candidates.txt) \
|
||||
| sort -u > /tmp/wave106-skiplist.txt
|
||||
wc -l /tmp/wave106-skiplist.txt
|
||||
```
|
||||
|
||||
Expected: most of the candidate list (likely 600-1100 files matched) — `\bPeer\b`, `\bMessage\b`, `\bMedia\b` are deliberately broad and catch many false positives. False positives are SAFE — they just mean fewer drops, not bad drops.
|
||||
|
||||
- [ ] **Step 2: Compute the drop-list**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
comm -23 /tmp/wave106-candidates.txt /tmp/wave106-skiplist.txt > /tmp/wave106-droplist.txt
|
||||
wc -l /tmp/wave106-droplist.txt
|
||||
head -20 /tmp/wave106-droplist.txt
|
||||
```
|
||||
|
||||
Expected: 5-50 files in the drop-list (wave 93 had 12). If 0, the regex is over-matching — halt and revisit. If >100, the regex is under-matching — halt, expand patterns, re-run.
|
||||
|
||||
- [ ] **Step 3: Spot-verify 3 random drop candidates**
|
||||
|
||||
Run for each of 3 files from the head of the drop-list:
|
||||
|
||||
```sh
|
||||
head -3 /tmp/wave106-droplist.txt | while read f; do
|
||||
echo "=== $f ==="
|
||||
grep -nE "Postbox|MediaBox|MediaResource|PeerId|MessageId|MediaId|MessageIndex" "$f" | head -5
|
||||
done
|
||||
```
|
||||
|
||||
Expected: Only `import Postbox` line appears. If any other Postbox-token appears, the file should have been skipped — add the missing pattern to the regex in Step 1, redo Steps 1-2, and re-spot-check.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Drop `import Postbox` from drop-list files
|
||||
|
||||
**Files:** every path listed in `/tmp/wave106-droplist.txt`.
|
||||
|
||||
- [ ] **Step 1: Read each drop-list file's import block to locate the exact `import Postbox` line**
|
||||
|
||||
For each file in the drop-list, the line is `import Postbox` (exact match, no whitespace variations expected). Use a single-purpose `sed` to remove it from all drop-list files:
|
||||
|
||||
```sh
|
||||
while read f; do
|
||||
sed -i '' '/^import Postbox$/d' "$f"
|
||||
done < /tmp/wave106-droplist.txt
|
||||
```
|
||||
|
||||
The `sed -i ''` syntax is BSD/macOS specific — required on Darwin.
|
||||
|
||||
- [ ] **Step 2: Verify the imports were removed**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
grep -lE "^import Postbox$" $(cat /tmp/wave106-droplist.txt) | wc -l
|
||||
```
|
||||
|
||||
Expected: 0 (no file in the drop-list still contains `import Postbox`).
|
||||
|
||||
- [ ] **Step 3: Verify no other lines were touched**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
git diff --stat | tail -5
|
||||
git diff --shortstat
|
||||
```
|
||||
|
||||
Expected: same number of files modified as drop-list size. Each file should show `-1` insertion (or `-1` deletion). If any file shows multiple deletions, something went wrong — `git checkout -- $(cat /tmp/wave106-droplist.txt)` and investigate.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Build iteration 1 — capture failures
|
||||
|
||||
**File:** none (build only).
|
||||
|
||||
- [ ] **Step 1: Run the build with `--continueOnError`**
|
||||
|
||||
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 \
|
||||
--continueOnError 2>&1 | tee /tmp/wave106-build-iter1.log
|
||||
```
|
||||
|
||||
Expected: Build completes (with errors). Wall-clock 30-260s depending on cache state.
|
||||
|
||||
- [ ] **Step 2: Extract failing files**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
grep -E ":[0-9]+:[0-9]+: error:" /tmp/wave106-build-iter1.log \
|
||||
| awk -F: '{print $1}' \
|
||||
| sort -u > /tmp/wave106-restore-iter1.txt
|
||||
wc -l /tmp/wave106-restore-iter1.txt
|
||||
cat /tmp/wave106-restore-iter1.txt
|
||||
```
|
||||
|
||||
Expected: a subset of the drop-list. Wave 93 saw 5 of 12 needing restore. If the count > 50% of drop-list, the regex is missing a major pattern — HALT, analyze the failure cluster, add the missing pattern to Task 3 Step 1, restart from Task 4.
|
||||
|
||||
- [ ] **Step 3: Verify no errors in TelegramCore/Postbox/TelegramApi**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
grep -E "^submodules/(TelegramCore|Postbox|TelegramApi)/" /tmp/wave106-restore-iter1.txt
|
||||
```
|
||||
|
||||
Expected: empty. If non-empty: HALT immediately, `git checkout -- submodules/`, and revert the wave — scope drift indicates the candidate filter or sed pattern is wrong.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Restore failing files (iter 1)
|
||||
|
||||
**Files:** every path in `/tmp/wave106-restore-iter1.txt`.
|
||||
|
||||
- [ ] **Step 1: Re-add `import Postbox` to each failing file**
|
||||
|
||||
Use awk uniformly (BSD `sed -i '' 'i\'` line-continuation is fragile inside shell loops). Insert `import Postbox` immediately before `import TelegramCore` if present, else immediately after the first existing `import ` line:
|
||||
|
||||
```sh
|
||||
while read f; do
|
||||
awk '
|
||||
BEGIN { added = 0 }
|
||||
!added && /^import TelegramCore$/ { print "import Postbox"; print; added = 1; next }
|
||||
{ print }
|
||||
END {
|
||||
if (!added) {
|
||||
# fallback path was not used — try post-first-import injection
|
||||
# (this END block is a no-op; awk cannot re-emit lines after END)
|
||||
}
|
||||
}
|
||||
' "$f" > "$f.tmp"
|
||||
if ! grep -q "^import Postbox$" "$f.tmp"; then
|
||||
# no TelegramCore anchor found — fall back to "after first import"
|
||||
awk '
|
||||
BEGIN { added = 0 }
|
||||
!added && /^import / { print; print "import Postbox"; added = 1; next }
|
||||
{ print }
|
||||
' "$f" > "$f.tmp"
|
||||
fi
|
||||
mv "$f.tmp" "$f"
|
||||
done < /tmp/wave106-restore-iter1.txt
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify restorations**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
grep -L "^import Postbox$" $(cat /tmp/wave106-restore-iter1.txt)
|
||||
```
|
||||
|
||||
Expected: empty (every file in the restore list now contains `import Postbox` again).
|
||||
|
||||
- [ ] **Step 3: Update the working drop-list**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
comm -23 /tmp/wave106-droplist.txt /tmp/wave106-restore-iter1.txt > /tmp/wave106-final-droplist.txt
|
||||
wc -l /tmp/wave106-final-droplist.txt
|
||||
```
|
||||
|
||||
This is the current "successfully dropped" set.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Build iteration 2 — verify clean (or iterate further)
|
||||
|
||||
**File:** none (build only).
|
||||
|
||||
- [ ] **Step 1: Re-run the build with `--continueOnError`**
|
||||
|
||||
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 \
|
||||
--continueOnError 2>&1 | tee /tmp/wave106-build-iter2.log
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extract any new failures**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
grep -E ":[0-9]+:[0-9]+: error:" /tmp/wave106-build-iter2.log \
|
||||
| awk -F: '{print $1}' \
|
||||
| sort -u > /tmp/wave106-restore-iter2.txt
|
||||
wc -l /tmp/wave106-restore-iter2.txt
|
||||
```
|
||||
|
||||
- [ ] **Step 3: If non-empty, repeat Task 6 with `iter2.txt` and run Task 7 again as iter3.**
|
||||
|
||||
Stop when:
|
||||
- `restore-iterN.txt` is empty → proceed to Task 8.
|
||||
- `N == 5` → HALT (diminishing returns); commit what is green via Task 9.
|
||||
|
||||
Each repeat: substitute `iter1` → `iter2` → `iter3` etc. throughout. Update the final-droplist after each restore: `comm -23 /tmp/wave106-final-droplist.txt /tmp/wave106-restore-iterN.txt > /tmp/wave106-final-droplist.txt.new && mv /tmp/wave106-final-droplist.txt.new /tmp/wave106-final-droplist.txt`.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Final clean build (no `--continueOnError`)
|
||||
|
||||
**File:** none (build only).
|
||||
|
||||
- [ ] **Step 1: Run a clean build to confirm no inter-module ordering issue was masked**
|
||||
|
||||
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 2>&1 | tail -30
|
||||
```
|
||||
|
||||
Expected: build success, no `error:` lines in the tail. If failure: an inter-module visibility issue exists that `--continueOnError` masked. Restore the final-droplist file(s) implicated by the error, repeat Task 7 / Task 8.
|
||||
|
||||
---
|
||||
|
||||
## Task 9 (optional): BUILD-dep sweep
|
||||
|
||||
**Files:** various `submodules/*/BUILD` files.
|
||||
|
||||
This step removes `//submodules/Postbox` from any Bazel package whose Swift sources no longer contain `import Postbox`. Skip this task if iteration time is constrained — the import drops alone are the core wave value; deps trim is housekeeping.
|
||||
|
||||
- [ ] **Step 1: Find packages whose `BUILD` still depends on `//submodules/Postbox` but whose `Sources/**/*.swift` no longer imports it**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
for build in $(find submodules -name BUILD -not -path "submodules/Postbox/*"); do
|
||||
pkg_dir=$(dirname "$build")
|
||||
if grep -q "//submodules/Postbox" "$build" 2>/dev/null; then
|
||||
if ! grep -rq "^import Postbox$" "$pkg_dir" --include="*.swift" 2>/dev/null; then
|
||||
echo "$build"
|
||||
fi
|
||||
fi
|
||||
done > /tmp/wave106-build-deps-candidates.txt
|
||||
wc -l /tmp/wave106-build-deps-candidates.txt
|
||||
cat /tmp/wave106-build-deps-candidates.txt
|
||||
```
|
||||
|
||||
Expected: 0-5 candidates. If 0, skip to Task 10.
|
||||
|
||||
- [ ] **Step 2: For each candidate BUILD, locate the `//submodules/Postbox` line and remove it via Edit**
|
||||
|
||||
Note that BUILD files often list deps as either `"//submodules/Postbox"` (string) or via aliases. Use Read to inspect each, then Edit to drop just the dep line. The exact string pattern varies — typically `"//submodules/Postbox",` on its own line within a `deps = [ ... ]` block.
|
||||
|
||||
For each candidate file, Read the lines around the match, then Edit to remove the line preserving the surrounding bracket structure.
|
||||
|
||||
- [ ] **Step 3: Re-run the clean build (no `--continueOnError`) to confirm no dep was load-bearing**
|
||||
|
||||
Run the same command as Task 8 Step 1.
|
||||
|
||||
If failure: a transitive dep was being satisfied through Postbox. Restore the dep line(s) implicated by the error and re-run.
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Commit the wave
|
||||
|
||||
**File:** `git`.
|
||||
|
||||
- [ ] **Step 1: Inspect final diff statistics**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
git diff --stat
|
||||
git diff --shortstat
|
||||
```
|
||||
|
||||
Expected: N file modifications, all `-1` line changes (just `import Postbox` lines), possibly plus a small number of BUILD diffs from Task 9.
|
||||
|
||||
- [ ] **Step 2: Confirm only allowed paths are touched**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
git diff --name-only | grep -vE "^submodules/" | head -5
|
||||
git diff --name-only | grep -E "^submodules/(Postbox|TelegramCore|TelegramApi)/" | head -5
|
||||
```
|
||||
|
||||
Both expected: empty. If either has output: HALT — rogue changes exist; investigate before committing.
|
||||
|
||||
- [ ] **Step 3: Stage only the modified Swift and BUILD files**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
git add $(git diff --name-only)
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: all changes staged with `M`. Untracked dirs (`build-system/tulsi/`, `submodules/TgVoip/`, `third-party/libx264/`) and the `m` submodule marker remain untouched.
|
||||
|
||||
- [ ] **Step 4: Commit with the wave message**
|
||||
|
||||
Substitute `<N>` with the count from `/tmp/wave106-final-droplist.txt`, `<M>` with the total restored across iterations, and `<K>` with the BUILD deps removed (0 if Task 9 skipped).
|
||||
|
||||
```sh
|
||||
N=$(wc -l < /tmp/wave106-final-droplist.txt | tr -d ' ')
|
||||
M=$(cat /tmp/wave106-restore-iter*.txt 2>/dev/null | sort -u | wc -l | tr -d ' ')
|
||||
K=$(git diff --cached --name-only | grep -c BUILD)
|
||||
|
||||
git commit -m "$(cat <<EOF
|
||||
Postbox -> TelegramEngine wave 106 (import drop sweep round 2)
|
||||
|
||||
Speculative drop of \`import Postbox\` in $N files where the last
|
||||
Postbox-typed symbol reference was peeled off by waves 94-105.
|
||||
Methodology: pattern-based pre-flight skip + drop + build-feedback
|
||||
restore loop (wave-93-validated recipe). $M files restored after build.
|
||||
$K BUILD deps removed.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: clean commit, working tree returns to the known-persistent untracked-only state.
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Update memory file with wave outcome
|
||||
|
||||
**File:** `/Users/isaac/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`.
|
||||
|
||||
- [ ] **Step 1: Read the current memory file to find the recent-commits section**
|
||||
|
||||
Read the top section listing wave commits.
|
||||
|
||||
- [ ] **Step 2: Insert a wave-106 line below the wave-105 entry**
|
||||
|
||||
Append (substituting the actual commit hash from `git log -1 --format=%H | head -c 10`):
|
||||
|
||||
```markdown
|
||||
- `<HASH>` — wave 106: speculative `import Postbox` drop sweep round 2. <N> files dropped, <M> restored after build feedback. Methodology re-run of wave 93 (`72de7c4fd5`) with expanded pre-flight regex (added bare-name escapes `\bPeer\b`/`\bMessage\b`/`\bMedia\b` per wave-93 lesson). <K> BUILD deps removed. <iter-count> build cycles.
|
||||
```
|
||||
|
||||
Update the memory file's `description:` frontmatter to reflect wave 106 as the latest.
|
||||
|
||||
---
|
||||
|
||||
## Halt-and-revert recipe (if anything goes seriously wrong)
|
||||
|
||||
If at any point the build fails in TelegramCore/Postbox/TelegramApi, or iteration count exceeds 5 with non-trivial residue, or scope drifts beyond the spec:
|
||||
|
||||
```sh
|
||||
git checkout -- submodules/
|
||||
git status --short # should match the pre-flight expected output
|
||||
```
|
||||
|
||||
The wave is fully reversible until Task 10 commits.
|
||||
|
||||
---
|
||||
|
||||
## Plan Self-Review Notes
|
||||
|
||||
- **Spec coverage:** Tasks 1-11 map 1:1 to the 8-step procedure in the spec plus pre-flight (Task 1) and post-commit memory update (Task 11). Halt conditions appear in Tasks 5/7/9 and the final halt-and-revert recipe.
|
||||
- **Placeholder scan:** No TBDs/TODOs. All `<N>`/`<M>`/`<K>`/`<HASH>` are explicitly substituted via shell expansion in Step 4 of Task 10.
|
||||
- **Type/method consistency:** Single-purpose tasks operating on filesystem and grep — no method-name drift risk.
|
||||
- **Iteration shape:** Tasks 5-7 form the iteration loop; Task 8 is the validation gate; Task 9 is optional housekeeping; Task 10 commits.
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
# Wave 106 Pivot: engine `data(resource:incremental:)` facade extension + 1-site drain
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Pivot wave 106 from the abandoned import-drop sweep to a small facade-extension wave. Add an `incremental: Bool = false` parameter to `TelegramEngine.Resources.data(resource:)`, drain the 1 live `account.postbox.mediaBox.resourceData(..., option: .incremental(...))` consumer site (`ChatInterfaceStateContextMenus.swift:1327`).
|
||||
|
||||
**Background — wave 106 (pure sweep) abandoned 2026-04-26:** Inventory of 576 candidate files showed every one of them legitimately references at least one Postbox-tier token (Postbox/MediaBox/MediaResource/protocol-Peer/protocol-Message/protocol-Media/typealiased identifier). Wave 93's pure import-sweep pattern is exhausted at the file granularity — no single-file orphans remain. See spec `docs/superpowers/specs/2026-04-26-postbox-wave-106-import-drop-sweep-design.md` (committed) for the abandoned methodology.
|
||||
|
||||
**Architecture:** Wave-shape G (facade addition + small validation drain). 2 file edits (1 in TelegramCore, 1 in TelegramUI). Single-iter expected.
|
||||
|
||||
**Tech Stack:** Swift, Bazel via `Make.py`, no unit tests. Verification is the full-project debug-sim-arm64 build.
|
||||
|
||||
**Iteration budget:** 1-2 (TelegramCore touch incurs ~210-260s build).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Role | Edits |
|
||||
|---|---|---|
|
||||
| `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift` | Add `incremental` param to `data(resource:)` facade | 1 Edit (signature + body) |
|
||||
| `submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift` | Migrate L1327 call site | 1 Edit (call + `data.complete` → `data.isComplete`) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend the engine `data(resource:)` facade with `incremental:` parameter
|
||||
|
||||
**File:** `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`
|
||||
|
||||
- [ ] **Step 1: Edit the `data(resource:)` facade signature and body**
|
||||
|
||||
Find at line 453-466:
|
||||
|
||||
```swift
|
||||
public func data(
|
||||
resource: EngineMediaResource,
|
||||
pathExtension: String? = nil,
|
||||
waitUntilFetchStatus: Bool = false,
|
||||
attemptSynchronously: Bool = false
|
||||
) -> Signal<EngineMediaResource.ResourceData, NoError> {
|
||||
return self.account.postbox.mediaBox.resourceData(
|
||||
resource._asResource(),
|
||||
pathExtension: pathExtension,
|
||||
option: .complete(waitUntilFetchStatus: waitUntilFetchStatus),
|
||||
attemptSynchronously: attemptSynchronously
|
||||
)
|
||||
|> map { EngineMediaResource.ResourceData($0) }
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
public func data(
|
||||
resource: EngineMediaResource,
|
||||
pathExtension: String? = nil,
|
||||
waitUntilFetchStatus: Bool = false,
|
||||
incremental: Bool = false,
|
||||
attemptSynchronously: Bool = false
|
||||
) -> Signal<EngineMediaResource.ResourceData, NoError> {
|
||||
let option: MediaBoxFetchDataOption = incremental
|
||||
? .incremental(waitUntilFetchStatus: waitUntilFetchStatus)
|
||||
: .complete(waitUntilFetchStatus: waitUntilFetchStatus)
|
||||
return self.account.postbox.mediaBox.resourceData(
|
||||
resource._asResource(),
|
||||
pathExtension: pathExtension,
|
||||
option: option,
|
||||
attemptSynchronously: attemptSynchronously
|
||||
)
|
||||
|> map { EngineMediaResource.ResourceData($0) }
|
||||
}
|
||||
```
|
||||
|
||||
The `incremental` parameter is inserted between `waitUntilFetchStatus` and `attemptSynchronously`. Existing call sites passing only labeled-or-trailing arguments remain compatible because Swift requires labels for these (no positional ordering issue).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Migrate the consumer call site
|
||||
|
||||
**File:** `submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift`
|
||||
|
||||
- [ ] **Step 1: Edit L1327 — replace `account.postbox.mediaBox.resourceData(...)` with engine facade**
|
||||
|
||||
Find at line 1327:
|
||||
|
||||
```swift
|
||||
let _ = (context.account.postbox.mediaBox.resourceData(largest.resource, option: .incremental(waitUntilFetchStatus: false))
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
let _ = (context.engine.resources.data(resource: EngineMediaResource(largest.resource), incremental: true)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rename downstream `data.complete` field access to `data.isComplete`**
|
||||
|
||||
Find at line 1330:
|
||||
|
||||
```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 `.path` field name is unchanged (both `MediaResourceData` and `EngineMediaResource.ResourceData` use `path`).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Verify residue and build
|
||||
|
||||
- [ ] **Step 1: Residue grep for the migrated expression**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
grep -nE "context\.account\.postbox\.mediaBox\.resourceData\(.*option: \.incremental" submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift
|
||||
```
|
||||
|
||||
Expected: empty.
|
||||
|
||||
- [ ] **Step 2: Verify the new facade signature compiles without breaking existing callers**
|
||||
|
||||
Existing call sites of `engine.resources.data(resource:)` use these forms (per wave 32+ history):
|
||||
- `engine.resources.data(resource: EngineMediaResource(x))` — default args, fine
|
||||
- `engine.resources.data(resource: EngineMediaResource(x), pathExtension: "ext")` — labeled, fine
|
||||
- `engine.resources.data(resource: EngineMediaResource(x), waitUntilFetchStatus: true)` — labeled, fine
|
||||
|
||||
Adding `incremental: Bool = false` between `waitUntilFetchStatus` and `attemptSynchronously` doesn't reorder existing call sites because all parameters use labels. Confirm with grep:
|
||||
|
||||
```sh
|
||||
grep -rnE "engine\.resources\.data\(resource:" submodules --include="*.swift" | wc -l
|
||||
```
|
||||
|
||||
Just for visibility — number of existing call sites that should remain green.
|
||||
|
||||
- [ ] **Step 3: Run the full clean 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 2>&1 | tail -30
|
||||
```
|
||||
|
||||
Expected: build success, no errors. If failure, fix in place and re-run (single iter expected).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Commit the wave
|
||||
|
||||
- [ ] **Step 1: Inspect diff**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
git diff --stat
|
||||
```
|
||||
|
||||
Expected: 2 files modified.
|
||||
|
||||
- [ ] **Step 2: Stage and commit**
|
||||
|
||||
```sh
|
||||
git add submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift \
|
||||
submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift
|
||||
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Postbox -> TelegramEngine wave 106 (pivot: engine data() incremental facade + 1-site drain)
|
||||
|
||||
Original wave 106 pure import-drop sweep abandoned: 576 candidate files
|
||||
all genuinely reference Postbox-tier tokens; wave 93's pattern exhausted
|
||||
at file granularity (no single-file orphans remain).
|
||||
|
||||
Pivot: extend engine.resources.data(resource:) facade with
|
||||
`incremental: Bool = false` parameter. Drain the 1 live consumer site
|
||||
(ChatInterfaceStateContextMenus:1327) plus consumer-side
|
||||
`data.complete` -> `data.isComplete` rename.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Update memory file
|
||||
|
||||
**File:** `/Users/isaac/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`
|
||||
|
||||
- [ ] **Step 1: Update frontmatter description and append wave 106 entries**
|
||||
|
||||
Update the `description:` line to reflect wave 106 (pivot).
|
||||
|
||||
Append two lines under the recent-commits section:
|
||||
- One for the abandoned wave 106 import-drop sweep with the key finding (sweep pattern exhausted, save future sessions a re-attempt).
|
||||
- One for the wave 106 pivot commit hash with cost/yield.
|
||||
|
||||
- [ ] **Step 2: Append a "Wave 106 ABANDONED" subsection** documenting the import-sweep exhaustion finding so future sessions don't re-attempt the pure sweep shape. Note the regex set tested and the conclusion ("any consumer file with `import Postbox` legitimately needs at least one Tier-1/Tier-2 token").
|
||||
|
||||
---
|
||||
|
||||
## Halt-and-revert recipe
|
||||
|
||||
If build fails for non-trivial reasons (more than 1 iter):
|
||||
|
||||
```sh
|
||||
git checkout -- submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift \
|
||||
submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift
|
||||
git status --short
|
||||
```
|
||||
|
||||
Wave is reversible until Task 4 commits.
|
||||
703
docs/superpowers/plans/2026-04-30-typing-draft-send-delay.md
Normal file
703
docs/superpowers/plans/2026-04-30-typing-draft-send-delay.md
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
# Typing-Draft Send Delay 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:** Park outgoing messages in `PendingMessageManager` after their content is uploaded, releasing them only when the typing-draft for the matching `(peerId, threadId)` clears.
|
||||
|
||||
**Architecture:** Add a per-account Postbox view (`.allTypingDrafts`) that exposes the live `Set<PeerAndThreadId>` of currently-active typing drafts. Subscribe once at `PendingMessageManager.init`. Add a parked state (`.waitingForSendGate`) to `PendingMessageState` and a forward parking lot dictionary on the manager. Gate at three sites (single-send, album-send, forward-send); drain on view updates that remove keys.
|
||||
|
||||
**Tech Stack:** Swift, Bazel, Postbox view system, SwiftSignalKit.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-30-typing-draft-send-delay-design.md`
|
||||
|
||||
**Build verification command (used by every task that compiles code):**
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
This codebase has **no unit tests** (per `CLAUDE.md`). Verification is "full build green" at each step plus manual exercise after final task.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Files created:**
|
||||
- `submodules/Postbox/Sources/AllTypingDraftsView.swift` — new Postbox view tracking the set of active typing-draft keys.
|
||||
|
||||
**Files modified:**
|
||||
- `submodules/Postbox/Sources/Views.swift` — register the new view key.
|
||||
- `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` — gate insertion, drain, subscription, parked-state plumbing.
|
||||
|
||||
**No BUILD edits needed:** Postbox `BUILD` uses `glob(["Sources/**/*.swift"])`, so the new file is auto-picked up.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `MutableAllTypingDraftsView` / `AllTypingDraftsView`
|
||||
|
||||
**Files:**
|
||||
- Create: `submodules/Postbox/Sources/AllTypingDraftsView.swift`
|
||||
|
||||
This task adds the Postbox view file. It is **not yet wired** into `PostboxViewKey` (Task 2), so this commit alone will not change behavior.
|
||||
|
||||
- [ ] **Step 1: Create `AllTypingDraftsView.swift`**
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
|
||||
final class MutableAllTypingDraftsView: MutablePostboxView {
|
||||
fileprivate var keys: Set<PeerAndThreadId>
|
||||
|
||||
init(postbox: PostboxImpl) {
|
||||
self.keys = Set(postbox.currentTypingDrafts.keys)
|
||||
}
|
||||
|
||||
func replay(postbox: PostboxImpl, transaction: PostboxTransaction) -> Bool {
|
||||
if transaction.updatedTypingDrafts.isEmpty {
|
||||
return false
|
||||
}
|
||||
var updated = false
|
||||
for (key, update) in transaction.updatedTypingDrafts {
|
||||
if update.value != nil {
|
||||
if self.keys.insert(key).inserted {
|
||||
updated = true
|
||||
}
|
||||
} else {
|
||||
if self.keys.remove(key) != nil {
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func refreshDueToExternalTransaction(postbox: PostboxImpl) -> Bool {
|
||||
let new = Set(postbox.currentTypingDrafts.keys)
|
||||
if new == self.keys {
|
||||
return false
|
||||
}
|
||||
self.keys = new
|
||||
return true
|
||||
}
|
||||
|
||||
func immutableView() -> PostboxView {
|
||||
return AllTypingDraftsView(self)
|
||||
}
|
||||
}
|
||||
|
||||
public final class AllTypingDraftsView: PostboxView {
|
||||
public let keys: Set<PeerAndThreadId>
|
||||
|
||||
init(_ view: MutableAllTypingDraftsView) {
|
||||
self.keys = view.keys
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the build (file is unwired, so it must compile standalone)**
|
||||
|
||||
Run the build command from the header. Expected: **success**. Common failure modes: `currentTypingDrafts` access scope (already `fileprivate(set)`, accessible from same-module file); `transaction.updatedTypingDrafts` shape (already `[PeerAndThreadId: PostboxImpl.TypingDraftUpdate]` per `PostboxTransaction.swift:59`).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/Postbox/Sources/AllTypingDraftsView.swift
|
||||
git commit -m "Postbox: add AllTypingDraftsView tracking Set<PeerAndThreadId> of active typing drafts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Register `.allTypingDrafts` in `PostboxViewKey`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/Postbox/Sources/Views.swift`
|
||||
|
||||
- [ ] **Step 1: Add the case to the enum**
|
||||
|
||||
In `submodules/Postbox/Sources/Views.swift` line 105, append `.allTypingDrafts` after `.typingDrafts(...)`:
|
||||
|
||||
```swift
|
||||
case typingDrafts(PeerAndThreadId)
|
||||
case allTypingDrafts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add hash arm**
|
||||
|
||||
In the `hash(into:)` switch (the new arm goes alongside the existing `case let .typingDrafts(peerId):` arm at line 232–233):
|
||||
|
||||
```swift
|
||||
case let .typingDrafts(peerId):
|
||||
hasher.combine(peerId)
|
||||
case .allTypingDrafts:
|
||||
hasher.combine(22)
|
||||
```
|
||||
|
||||
The constant `22` is the next free hash-tag (existing tags use 0–21).
|
||||
|
||||
- [ ] **Step 3: Add `==` arm**
|
||||
|
||||
In the `==(lhs:rhs:)` switch (line 551–556 area):
|
||||
|
||||
```swift
|
||||
case let .typingDrafts(peerId):
|
||||
if case .typingDrafts(peerId) = rhs {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
case .allTypingDrafts:
|
||||
if case .allTypingDrafts = rhs {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire `postboxViewForKey`**
|
||||
|
||||
In the `postboxViewForKey(postbox:key:)` switch (line 684–685 area):
|
||||
|
||||
```swift
|
||||
case let .typingDrafts(peerId):
|
||||
return MutableTypingDraftsView(postbox: postbox, peerAndThreadId: peerId)
|
||||
case .allTypingDrafts:
|
||||
return MutableAllTypingDraftsView(postbox: postbox)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify the build**
|
||||
|
||||
Run the build command. Expected: **success**. The view is now constructible but no consumer subscribes yet.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/Postbox/Sources/Views.swift
|
||||
git commit -m "Postbox: wire .allTypingDrafts view key into PostboxViewKey"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add `.waitingForSendGate` state and update related switches
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift`
|
||||
|
||||
This task introduces the new `PendingMessageState` case and extends every switch that needs to know about it. No gate insertion happens yet — the new case is unreachable, so behavior is unchanged.
|
||||
|
||||
- [ ] **Step 1: Add the case to `PendingMessageState`**
|
||||
|
||||
Edit `private enum PendingMessageState` (line 28). After the `.waitingToBeSent` case, add:
|
||||
|
||||
```swift
|
||||
case waitingToBeSent(groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo)
|
||||
case waitingForSendGate(groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend the `groupId` computed property**
|
||||
|
||||
In the same `var groupId: Int64?` switch (line 37–54), add an arm right after the `.waitingToBeSent` arm:
|
||||
|
||||
```swift
|
||||
case let .waitingToBeSent(groupId, _):
|
||||
return groupId
|
||||
case let .waitingForSendGate(groupId, _):
|
||||
return groupId
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Extend `dataForPendingMessageGroup` to accept parked members as ready**
|
||||
|
||||
Find `private func dataForPendingMessageGroup(_ groupId: Int64)` (line 753). After the `.waitingToBeSent` arm, add a parallel arm:
|
||||
|
||||
```swift
|
||||
case let .waitingToBeSent(contextGroupId, content):
|
||||
if contextGroupId == groupId {
|
||||
result.append((context, id, content))
|
||||
}
|
||||
case let .waitingForSendGate(contextGroupId, content):
|
||||
if contextGroupId == groupId {
|
||||
result.append((context, id, content))
|
||||
}
|
||||
```
|
||||
|
||||
This lets a partially-parked album drain correctly once the gate opens.
|
||||
|
||||
- [ ] **Step 4: Exclude parked state from `updatePendingMediaUploads`**
|
||||
|
||||
Find `private func updatePendingMediaUploads()` (line 262). Inside its `switch context.state { ... }`, the existing arms cover `.waitingForUploadToStart` and `.uploading`. Parked state is post-upload and should NOT report progress. The switch already has a `default: break` — `.waitingForSendGate` falls through it automatically. **No edit required**, but verify by re-reading the function after the previous edits compile.
|
||||
|
||||
- [ ] **Step 5: Verify the build**
|
||||
|
||||
Run the build command. Expected: **success**. Swift will reject the build if any `switch context.state { ... }` becomes non-exhaustive — fix any such switches by adding a `case .waitingForSendGate: ...` arm matching the closest existing parallel state. Likely candidates:
|
||||
|
||||
- `private enum PendingMessageState`'s `groupId` switch — handled in Step 2.
|
||||
- `dataForPendingMessageGroup` switch — handled in Step 3.
|
||||
- The forward branch in `beginSendingMessages` (line ~614, doesn't switch directly).
|
||||
- Any other `switch <context>.state` blocks (search with `grep -n "switch.*\.state" submodules/TelegramCore/Sources/State/PendingMessageManager.swift`).
|
||||
|
||||
If the compiler reports a non-exhaustive switch elsewhere, add an arm that mirrors the `.waitingToBeSent` arm in that switch, or `case .waitingForSendGate: break` if the new state is irrelevant to that site.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift
|
||||
git commit -m "PendingMessageManager: add .waitingForSendGate state and update related switches"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add manager-level subscription and parked-state fields
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift`
|
||||
|
||||
Adds the dictionary, the disposable, the subscription in `init`, the disposal in `deinit`, and a stub `handleLiveTypingDraftsUpdate` that only stores the set (drain is wired in Task 5).
|
||||
|
||||
- [ ] **Step 1: Add stored fields**
|
||||
|
||||
In `public final class PendingMessageManager` (line 205), after the existing `private var pendingMessageIds = Set<MessageId>()` (line 232), add:
|
||||
|
||||
```swift
|
||||
private var pendingMessageIds = Set<MessageId>()
|
||||
private var liveTypingDraftKeys: Set<PeerAndThreadId> = []
|
||||
private let allTypingDraftsDisposable = MetaDisposable()
|
||||
private var forwardSendGateGroups: [PeerAndThreadId: [[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]]] = [:]
|
||||
private let beginSendingMessagesDisposables = DisposableSet()
|
||||
```
|
||||
|
||||
(The first and last lines are already present; the three new lines insert between them.)
|
||||
|
||||
- [ ] **Step 2: Subscribe in `init`**
|
||||
|
||||
In `init(network:postbox:accountPeerId:auxiliaryMethods:stateManager:localInputActivityManager:messageMediaPreuploadManager:revalidationContext:)` (line 243), after the field assignments (line 252), add:
|
||||
|
||||
```swift
|
||||
self.revalidationContext = revalidationContext
|
||||
|
||||
let queue = self.queue
|
||||
self.allTypingDraftsDisposable.set(
|
||||
(postbox.combinedView(keys: [.allTypingDrafts])
|
||||
|> deliverOn(queue)).start(next: { [weak self] view in
|
||||
self?.handleLiveTypingDraftsUpdate(view)
|
||||
})
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Dispose in `deinit`**
|
||||
|
||||
In `deinit` (line 255–260), append:
|
||||
|
||||
```swift
|
||||
deinit {
|
||||
self.beginSendingMessagesDisposables.dispose()
|
||||
for (_, disposable) in self.newTopicDisposables {
|
||||
disposable.dispose()
|
||||
}
|
||||
self.allTypingDraftsDisposable.dispose()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the handler (no drain yet)**
|
||||
|
||||
Add this new private method anywhere in the class (e.g. just before `private func updatePendingMediaUploads()` at line 262):
|
||||
|
||||
```swift
|
||||
private func handleLiveTypingDraftsUpdate(_ combined: CombinedView) {
|
||||
assert(self.queue.isCurrent())
|
||||
|
||||
let new: Set<PeerAndThreadId>
|
||||
if let view = combined.views[.allTypingDrafts] as? AllTypingDraftsView {
|
||||
new = view.keys
|
||||
} else {
|
||||
new = []
|
||||
}
|
||||
self.liveTypingDraftKeys = new
|
||||
}
|
||||
```
|
||||
|
||||
This handler is functional — it tracks the live key set correctly. Task 5 augments it to also fire `drainSendGate` for keys that just cleared.
|
||||
|
||||
- [ ] **Step 5: Verify the build**
|
||||
|
||||
Run the build command. Expected: **success**. `CombinedView` is in scope because `Postbox` is already imported at the top of the file. `MetaDisposable` and `Queue` come from `SwiftSignalKit` (already imported).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift
|
||||
git commit -m "PendingMessageManager: subscribe to .allTypingDrafts and add parking-lot fields"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add gate predicates, drain, and wire the single-message gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift`
|
||||
|
||||
Combined task: introduces the predicates, the `drainSendGate` body, augments `handleLiveTypingDraftsUpdate` to call drain, and wires the **first** insertion site (single-message). After this task, single non-grouped messages park correctly when the peer is live-typing and unpark when the draft clears.
|
||||
|
||||
- [ ] **Step 1: Add `shouldGateSend` and `isSendGateOpen` private helpers**
|
||||
|
||||
Anywhere in the `PendingMessageManager` class (group them next to `handleLiveTypingDraftsUpdate` from Task 4):
|
||||
|
||||
```swift
|
||||
private func shouldGateSend(messageId: MessageId, threadId: Int64?) -> Bool {
|
||||
if messageId.namespace == Namespaces.Message.ScheduledCloud {
|
||||
return false
|
||||
}
|
||||
if messageId.peerId.namespace == Namespaces.Peer.SecretChat {
|
||||
return false
|
||||
}
|
||||
if messageId.peerId == self.accountPeerId {
|
||||
return false
|
||||
}
|
||||
if threadId == Message.newTopicThreadId {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func isSendGateOpen(for key: PeerAndThreadId) -> Bool {
|
||||
return !self.liveTypingDraftKeys.contains(key)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Augment `handleLiveTypingDraftsUpdate` to call drain**
|
||||
|
||||
Replace the body of `handleLiveTypingDraftsUpdate(_:)` (added in Task 4) with the cleared-key drain logic:
|
||||
|
||||
```swift
|
||||
private func handleLiveTypingDraftsUpdate(_ combined: CombinedView) {
|
||||
assert(self.queue.isCurrent())
|
||||
|
||||
let new: Set<PeerAndThreadId>
|
||||
if let view = combined.views[.allTypingDrafts] as? AllTypingDraftsView {
|
||||
new = view.keys
|
||||
} else {
|
||||
new = []
|
||||
}
|
||||
let cleared = self.liveTypingDraftKeys.subtracting(new)
|
||||
self.liveTypingDraftKeys = new
|
||||
for key in cleared {
|
||||
self.drainSendGate(key: key)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the `drainSendGate` body**
|
||||
|
||||
Add this new private method next to `handleLiveTypingDraftsUpdate`:
|
||||
|
||||
```swift
|
||||
private func drainSendGate(key: PeerAndThreadId) {
|
||||
assert(self.queue.isCurrent())
|
||||
|
||||
// (1) Single-message drain: snapshot then commit in messageId.id order.
|
||||
var singleDrains: [(context: PendingMessageContext, messageId: MessageId, content: PendingMessageUploadedContentAndReuploadInfo)] = []
|
||||
for (id, context) in self.messageContexts {
|
||||
if id.peerId != key.peerId {
|
||||
continue
|
||||
}
|
||||
if context.threadId != key.threadId {
|
||||
continue
|
||||
}
|
||||
if case let .waitingForSendGate(groupId, content) = context.state, groupId == nil {
|
||||
singleDrains.append((context, id, content))
|
||||
}
|
||||
}
|
||||
singleDrains.sort(by: { $0.messageId.id < $1.messageId.id })
|
||||
for entry in singleDrains {
|
||||
self.commitSendingSingleMessage(messageContext: entry.context, messageId: entry.messageId, content: entry.content)
|
||||
}
|
||||
|
||||
// (2) Grouped-album drain: collect distinct groupIds whose members match the key,
|
||||
// iterate ascending by min messageId.id, fire commitSendingMessageGroup.
|
||||
var groupKeys: [(groupId: Int64, minMessageId: Int32)] = []
|
||||
var seenGroupIds = Set<Int64>()
|
||||
for (id, context) in self.messageContexts {
|
||||
if id.peerId != key.peerId {
|
||||
continue
|
||||
}
|
||||
if context.threadId != key.threadId {
|
||||
continue
|
||||
}
|
||||
if case let .waitingForSendGate(groupId, _) = context.state, let groupId = groupId {
|
||||
if !seenGroupIds.contains(groupId) {
|
||||
seenGroupIds.insert(groupId)
|
||||
groupKeys.append((groupId, id.id))
|
||||
} else {
|
||||
if let index = groupKeys.firstIndex(where: { $0.groupId == groupId }), id.id < groupKeys[index].minMessageId {
|
||||
groupKeys[index].minMessageId = id.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
groupKeys.sort(by: { $0.minMessageId < $1.minMessageId })
|
||||
for (groupId, _) in groupKeys {
|
||||
if let data = self.dataForPendingMessageGroup(groupId) {
|
||||
self.commitSendingMessageGroup(groupId: groupId, messages: data)
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Forward drain: pop parked groups for this key in FIFO order; fire each.
|
||||
if let parkedGroups = self.forwardSendGateGroups.removeValue(forKey: key) {
|
||||
for messages in parkedGroups {
|
||||
for (context, _, _) in messages {
|
||||
context.state = .sending(groupId: nil)
|
||||
}
|
||||
let sendMessage: Signal<PendingMessageResult, NoError> = self.sendGroupMessagesContent(network: self.network, postbox: self.postbox, stateManager: self.stateManager, accountPeerId: self.accountPeerId, group: messages.map { data in
|
||||
let (_, message, forwardInfo) = data
|
||||
return (message.id, PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil))
|
||||
})
|
||||
|> map { _ -> PendingMessageResult in
|
||||
return .progress(1.0)
|
||||
}
|
||||
messages[0].0.sendDisposable.set((sendMessage
|
||||
|> deliverOn(self.queue)).start())
|
||||
}
|
||||
}
|
||||
|
||||
self.updateWaitingUploads(peerId: key.peerId)
|
||||
self.updatePendingMediaUploads()
|
||||
}
|
||||
```
|
||||
|
||||
The forward dispatch block mirrors the existing forward-fire code at lines 719–731 of `PendingMessageManager.swift`. Keep them in sync if either changes.
|
||||
|
||||
- [ ] **Step 4: Wire the single-message gate at `beginSendingMessage`**
|
||||
|
||||
Replace `private func beginSendingMessage(messageContext:messageId:groupId:content:)` (line 738) with:
|
||||
|
||||
```swift
|
||||
private func beginSendingMessage(messageContext: PendingMessageContext, messageId: MessageId, groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo) {
|
||||
if let groupId = groupId {
|
||||
messageContext.state = .waitingToBeSent(groupId: groupId, content: content)
|
||||
} else {
|
||||
let key = PeerAndThreadId(peerId: messageId.peerId, threadId: messageContext.threadId)
|
||||
if self.shouldGateSend(messageId: messageId, threadId: messageContext.threadId) && !self.isSendGateOpen(for: key) {
|
||||
messageContext.state = .waitingForSendGate(groupId: nil, content: content)
|
||||
} else {
|
||||
self.commitSendingSingleMessage(messageContext: messageContext, messageId: messageId, content: content)
|
||||
}
|
||||
}
|
||||
self.updatePendingMediaUploads()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify the build**
|
||||
|
||||
Run the build command. Expected: **success**.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift
|
||||
git commit -m "PendingMessageManager: wire typing-draft gate at single-message send + drain"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Wire the album-send gate at `commitSendingMessageGroup`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift`
|
||||
|
||||
- [ ] **Step 1: Replace `commitSendingMessageGroup`**
|
||||
|
||||
Replace `private func commitSendingMessageGroup(groupId:messages:)` (line 794) with:
|
||||
|
||||
```swift
|
||||
private func commitSendingMessageGroup(groupId: Int64, messages: [(messageContext: PendingMessageContext, messageId: MessageId, content: PendingMessageUploadedContentAndReuploadInfo)]) {
|
||||
let firstMessageId = messages[0].messageId
|
||||
let firstThreadId = messages[0].messageContext.threadId
|
||||
let key = PeerAndThreadId(peerId: firstMessageId.peerId, threadId: firstThreadId)
|
||||
if self.shouldGateSend(messageId: firstMessageId, threadId: firstThreadId) && !self.isSendGateOpen(for: key) {
|
||||
for entry in messages {
|
||||
entry.messageContext.state = .waitingForSendGate(groupId: groupId, content: entry.content)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for (context, _, _) in messages {
|
||||
context.state = .sending(groupId: groupId)
|
||||
}
|
||||
let sendMessage: Signal<PendingMessageResult, NoError> = self.sendGroupMessagesContent(network: self.network, postbox: self.postbox, stateManager: self.stateManager, accountPeerId: self.accountPeerId, group: messages.map { ($0.1, $0.2) })
|
||||
|> map { next -> PendingMessageResult in
|
||||
return .progress(1.0)
|
||||
}
|
||||
messages[0].0.sendDisposable.set((sendMessage
|
||||
|> deliverOn(self.queue)).start())
|
||||
}
|
||||
```
|
||||
|
||||
Album members share `(peerId, threadId)` by construction (the album fires once every member is post-upload in the same `groupId`).
|
||||
|
||||
- [ ] **Step 2: Verify the build**
|
||||
|
||||
Run the build command. Expected: **success**.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift
|
||||
git commit -m "PendingMessageManager: wire typing-draft gate at album send"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Wire the forward-send gate inside `beginSendingMessages`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift`
|
||||
|
||||
- [ ] **Step 1: Replace the forward-dispatch loop**
|
||||
|
||||
The existing forward-dispatch loop is at lines 714–733 inside `beginSendingMessages`. Replace exactly the body of `for messages in countedMessageGroups { ... }` with the gate-aware version:
|
||||
|
||||
```swift
|
||||
for messages in countedMessageGroups {
|
||||
if messages.isEmpty {
|
||||
continue
|
||||
}
|
||||
|
||||
let firstMessage = messages[0].1
|
||||
let key = PeerAndThreadId(peerId: firstMessage.id.peerId, threadId: firstMessage.threadId)
|
||||
if strongSelf.shouldGateSend(messageId: firstMessage.id, threadId: firstMessage.threadId) && !strongSelf.isSendGateOpen(for: key) {
|
||||
for (context, _, forwardInfo) in messages {
|
||||
context.state = .waitingForSendGate(groupId: nil, content: PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil))
|
||||
}
|
||||
if strongSelf.forwardSendGateGroups[key] == nil {
|
||||
strongSelf.forwardSendGateGroups[key] = []
|
||||
}
|
||||
strongSelf.forwardSendGateGroups[key]!.append(messages)
|
||||
continue
|
||||
}
|
||||
|
||||
for (context, _, _) in messages {
|
||||
context.state = .sending(groupId: nil)
|
||||
}
|
||||
|
||||
let sendMessage: Signal<PendingMessageResult, NoError> = strongSelf.sendGroupMessagesContent(network: strongSelf.network, postbox: strongSelf.postbox, stateManager: strongSelf.stateManager, accountPeerId: strongSelf.accountPeerId, group: messages.map { data in
|
||||
let (_, message, forwardInfo) = data
|
||||
return (message.id, PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil))
|
||||
})
|
||||
|> map { next -> PendingMessageResult in
|
||||
return .progress(1.0)
|
||||
}
|
||||
messages[0].0.sendDisposable.set((sendMessage
|
||||
|> deliverOn(strongSelf.queue)).start())
|
||||
}
|
||||
```
|
||||
|
||||
The non-gated branch is the existing code, copied verbatim. The gated branch parks every context in `.waitingForSendGate` and appends the whole tuple-array onto `forwardSendGateGroups[key]`. The drain in `Task 5` reads from this dict.
|
||||
|
||||
- [ ] **Step 2: Verify the build**
|
||||
|
||||
Run the build command. Expected: **success**.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift
|
||||
git commit -m "PendingMessageManager: wire typing-draft gate at forward send"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Clean up parked forwards in `updatePendingMessageIds` removal loop
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift`
|
||||
|
||||
When a message is removed from `pendingMessageIds` (e.g. user discarded it, or it was force-deleted), the existing loop disposes context-level state. Parked single/album state lives on the `PendingMessageContext` and gets reset when `state = .none`. Parked forwards live in `forwardSendGateGroups`, which the existing loop does not touch — patch that here.
|
||||
|
||||
- [ ] **Step 1: Add cleanup for `forwardSendGateGroups` in `updatePendingMessageIds`**
|
||||
|
||||
In `updatePendingMessageIds(_:)` (line 284), after the `for id in removedMessageIds { ... }` loop (closes around line 323), add a forward-cleanup pass before `if !addedMessageIds.isEmpty { ... }`:
|
||||
|
||||
```swift
|
||||
if !removedMessageIds.isEmpty && !self.forwardSendGateGroups.isEmpty {
|
||||
for (key, parkedGroups) in self.forwardSendGateGroups {
|
||||
var rebuilt: [[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]] = []
|
||||
for group in parkedGroups {
|
||||
let filtered = group.filter { entry in
|
||||
return !removedMessageIds.contains(entry.1.id)
|
||||
}
|
||||
if !filtered.isEmpty {
|
||||
rebuilt.append(filtered)
|
||||
}
|
||||
}
|
||||
if rebuilt.isEmpty {
|
||||
self.forwardSendGateGroups.removeValue(forKey: key)
|
||||
} else {
|
||||
self.forwardSendGateGroups[key] = rebuilt
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the build**
|
||||
|
||||
Run the build command. Expected: **success**.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift
|
||||
git commit -m "PendingMessageManager: cleanup parked forwards on pending-message removal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Manual verification
|
||||
|
||||
**Files:** None (verification-only).
|
||||
|
||||
This codebase has no unit tests, so the final acceptance gate is a manual exercise on a debug simulator build. Skip if you don't have a working two-device test setup; in that case, re-confirm only the build is green.
|
||||
|
||||
- [ ] **Step 1: Confirm full build is still green**
|
||||
|
||||
Run the build command from the header. Expected: **success**.
|
||||
|
||||
- [ ] **Step 2: 1:1 chat, text-send delay**
|
||||
|
||||
Setup: log in to two real Telegram accounts on two devices/simulators (account A and account B). Open the A↔B 1:1 chat on both.
|
||||
|
||||
- On B, begin live-typing a draft (Telegram clients with the live-typing-drafts feature emit the typing-draft updates; if B doesn't expose live drafts, simulate by triggering whatever flow populates `transaction.combineTypingDrafts(...)` in your test environment).
|
||||
- On A, immediately type and send a text message. Confirm: the message appears with a "sending" indicator and does **not** complete until B's draft clears or commits. Once B's draft is cleared, A's message sends within ~1 second.
|
||||
|
||||
- [ ] **Step 3: Album / grouped-media delay**
|
||||
|
||||
Repeat Step 2 with an album of two photos sent from A while B is live-typing. Expected: all album members upload (you can see progress finish), then all sit parked at "sending" until the gate opens.
|
||||
|
||||
- [ ] **Step 4: Forward delay**
|
||||
|
||||
Repeat with a forwarded message (forward a message from a third chat into A↔B while B is live-typing). Expected: forward parks until the gate opens.
|
||||
|
||||
- [ ] **Step 5: Negative — Saved Messages skip**
|
||||
|
||||
In Saved Messages (chat with self), send any message. Confirm: never delays, regardless of typing-draft state. There should be no typing draft for the self peer in the first place — this is just an existence check that the skip-rule does its job.
|
||||
|
||||
- [ ] **Step 6: Negative — secret chat skip**
|
||||
|
||||
In a secret chat, send a message. Confirm: never delays. Server-side, secret chats don't emit typing-draft updates — this verifies the explicit skip-check.
|
||||
|
||||
- [ ] **Step 7: Negative — scheduled message skip (defensive)**
|
||||
|
||||
The `Namespaces.Message.ScheduledCloud` skip in `shouldGateSend` is defensive — in practice, scheduled messages are stored in the scheduled queue and only enter `PendingMessageManager` at delivery time, by which point they've been re-created with cloud namespace. Verifying the defensive branch directly is awkward and not strictly required. If you have an instrumentation path that forces a scheduled-namespace message through `beginSendingMessages`, confirm it doesn't park.
|
||||
|
||||
- [ ] **Step 8: Multi-thread — gate is per-thread**
|
||||
|
||||
In a forum/topic group, on B begin live-typing in topic 1 only. On A, send a message to topic 2 of the same group. Expected: A's message to topic 2 is **not** delayed.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (already applied inline)
|
||||
|
||||
- **Spec coverage:** every section of `2026-04-30-typing-draft-send-delay-design.md` maps to a task — Postbox view (Tasks 1–2), state machine extension (Task 3), subscription (Task 4), predicates + drain + single-send (Task 5), album (Task 6), forwards (Task 7), removal cleanup (Task 8), manual verification (Task 9).
|
||||
- **Type names verified against actual code:** `PendingMessageState`, `PendingMessageContext`, `PendingMessageUploadedContentAndReuploadInfo`, `ForwardSourceInfoAttribute`, `PendingMessageResult`, `Signal`, `MetaDisposable`, `Queue`, `CombinedView`, `Namespaces.Message.ScheduledCloud`, `Namespaces.Peer.SecretChat`, `Message.newTopicThreadId`, `PeerAndThreadId`, `combineTypingDrafts`, `currentTypingDrafts`, `transaction.updatedTypingDrafts`, `update.value`. All names match the source.
|
||||
- **`postponeSending` (paid-message) interaction:** untouched. Composes via state-machine ordering (paid postpone gates upload-start; this gate sits post-upload).
|
||||
- **No unused-private-function warnings:** every helper is introduced together with its first caller (predicates + drain + first call site combined in Task 5).
|
||||
1971
docs/superpowers/postbox-refactor-log.md
Normal file
1971
docs/superpowers/postbox-refactor-log.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,178 @@
|
|||
# Typing-Draft Send Delay — Design
|
||||
|
||||
**Date:** 2026-04-30
|
||||
**Component:** `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` (+ minimal Postbox additions)
|
||||
|
||||
## Goal
|
||||
|
||||
Delay outgoing messages while the peer in the same `(peerId, threadId)` is "live-typing" an incoming message (i.e. `Postbox.combinedView(keys: [.typingDrafts(...)])` reports a non-nil draft for that key). Messages park after their content is fully uploaded, then drain in `messageId.id` order once the typing-draft for that key clears.
|
||||
|
||||
## Behavior summary
|
||||
|
||||
- **Scope.** All "deliver-now" outgoing message types: regular text/media single sends, grouped media albums, and forwards. Excluded: scheduled messages, secret-chat messages, and Saved Messages (account-self peer).
|
||||
- **Pipeline.** Uploads run in parallel as they do today. The gate sits between "upload complete" and the actual MTProto send call.
|
||||
- **Release.** As soon as the typing-draft for the message's `(peerId, threadId)` clears (the view's set no longer contains that key) — no extra grace delay, no upper-bound timeout.
|
||||
- **Keying.** Strictly per-thread. `threadId == nil` is the normal value for non-threaded chats and gates with `PeerAndThreadId(peerId: ..., threadId: nil)`. The `Message.newTopicThreadId` sentinel does not gate (already handled by `.waitingForNewTopic`).
|
||||
- **Always-on.** No preference toggle.
|
||||
- **Composes with paid-message postpone.** Paid postpone gates upload-start; typing-draft gate gates post-upload send. Both must be clear before send.
|
||||
|
||||
## Architecture
|
||||
|
||||
All logic lives in `PendingMessageManager`. Postbox gains one new public view; no other Postbox API changes.
|
||||
|
||||
### Postbox additions
|
||||
|
||||
1. New file `submodules/Postbox/Sources/AllTypingDraftsView.swift`:
|
||||
- `MutableAllTypingDraftsView: MutablePostboxView`
|
||||
- `init(postbox:)` seeds `keys` from `postbox.currentTypingDrafts.keys`.
|
||||
- `replay(postbox:transaction:)` diffs against `transaction.updatedTypingDrafts`: insert key when `update.value != nil`, remove when nil. Returns `true` if the set changed.
|
||||
- `refreshDueToExternalTransaction(postbox:)` reloads from `postbox.currentTypingDrafts.keys` and returns `true`.
|
||||
- `immutableView()` returns an `AllTypingDraftsView`.
|
||||
- `public final class AllTypingDraftsView: PostboxView` exposes `public let keys: Set<PeerAndThreadId>`.
|
||||
2. `submodules/Postbox/Sources/Views.swift`:
|
||||
- Add `case allTypingDrafts` to `PostboxViewKey` (no associated payload).
|
||||
- Wire constant `Hashable` combine and `==` matching for the new case.
|
||||
- Add the `case .allTypingDrafts` arm to `postboxViewForKey` returning `MutableAllTypingDraftsView(postbox:)`.
|
||||
3. `PostboxImpl.currentTypingDrafts` is `fileprivate(set)`, accessible from view files in the same module. No new accessor needed.
|
||||
|
||||
### PendingMessageManager additions
|
||||
|
||||
New `PendingMessageState` case:
|
||||
|
||||
```swift
|
||||
case waitingForSendGate(groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo)
|
||||
```
|
||||
|
||||
Added to `PendingMessageState.groupId`'s switch. Excluded from `updatePendingMediaUploads`'s upload-progress aggregation.
|
||||
|
||||
New stored state on the manager:
|
||||
|
||||
```swift
|
||||
private var liveTypingDraftKeys: Set<PeerAndThreadId> = []
|
||||
private let allTypingDraftsDisposable = MetaDisposable()
|
||||
private var forwardSendGateGroups: [PeerAndThreadId: [[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]]] = [:]
|
||||
```
|
||||
|
||||
In `init`, subscribe once:
|
||||
|
||||
```swift
|
||||
self.allTypingDraftsDisposable.set(
|
||||
(postbox.combinedView(keys: [.allTypingDrafts])
|
||||
|> deliverOn(self.queue)).start(next: { [weak self] view in
|
||||
self?.handleLiveTypingDraftsUpdate(view)
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
Dispose in `deinit`.
|
||||
|
||||
## Gate predicate
|
||||
|
||||
```swift
|
||||
private func isSendGateOpen(for key: PeerAndThreadId) -> Bool {
|
||||
return !self.liveTypingDraftKeys.contains(key)
|
||||
}
|
||||
|
||||
private func shouldGateSend(messageId: MessageId, threadId: Int64?) -> Bool {
|
||||
if messageId.namespace == Namespaces.Message.ScheduledCloud { return false }
|
||||
if messageId.peerId.namespace == Namespaces.Peer.SecretChat { return false }
|
||||
if messageId.peerId == self.accountPeerId { return false }
|
||||
if threadId == Message.newTopicThreadId { return false }
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
A pending context is gate-applicable if `shouldGateSend(...)` returns true. The gate is open if `isSendGateOpen(...)` returns true. Sites delay the send only when `shouldGateSend && !isSendGateOpen`.
|
||||
|
||||
## Gate insertion points
|
||||
|
||||
### (a) Single-message — `beginSendingMessage(messageContext:messageId:groupId:content:)`
|
||||
|
||||
Today: `groupId == nil → commitSendingSingleMessage`; otherwise `state = .waitingToBeSent(groupId: ..., content: ...)`.
|
||||
|
||||
New: when `groupId == nil`, additionally check the gate:
|
||||
|
||||
```swift
|
||||
let key = PeerAndThreadId(peerId: messageId.peerId, threadId: messageContext.threadId)
|
||||
if shouldGateSend(messageId: messageId, threadId: messageContext.threadId) && !isSendGateOpen(for: key) {
|
||||
messageContext.state = .waitingForSendGate(groupId: nil, content: content)
|
||||
} else {
|
||||
self.commitSendingSingleMessage(messageContext: messageContext, messageId: messageId, content: content)
|
||||
}
|
||||
```
|
||||
|
||||
The grouped path (`groupId != nil`) is unchanged here; gating for albums happens in (b).
|
||||
|
||||
### (b) Grouped-album — `commitSendingMessageGroup(groupId:messages:)`
|
||||
|
||||
Today: flips every group context to `.sending(groupId:)`, fires `sendGroupMessagesContent`.
|
||||
|
||||
New: derive a representative key from the first message's `(peerId, threadId)`. (Group members share both by construction.) If `shouldGateSend && !isSendGateOpen`, flip every group context to `.waitingForSendGate(groupId: groupId, content: <its own content>)` and return. Otherwise unchanged.
|
||||
|
||||
`dataForPendingMessageGroup(_ groupId:)` is updated to recognize `.waitingForSendGate(groupId: contextGroupId, ...)` the same way it recognizes `.waitingToBeSent` — i.e. a group becomes "ready" when every member is in `.waitingToBeSent` OR `.waitingForSendGate`. This prevents partial-park deadlocks.
|
||||
|
||||
### (c) Forwards — inside `beginSendingMessages`, lines 714–733
|
||||
|
||||
Today: builds `countedMessageGroups` and immediately fires `sendGroupMessagesContent` per group.
|
||||
|
||||
The pre-existing `messagesToForward` bucketing is by `PeerIdAndNamespace` only — not by `threadId`. The downstream `sendGroupMessagesContent` network call requires thread homogeneity (a forward dispatch targets a single destination thread), so in practice every group already shares `threadId`. The gate uses this assumption: derive the key from `messages[0].1.threadId` of each `countedMessageGroup`. If a future caller violates the assumption, the existing dispatch path is already broken.
|
||||
|
||||
New: per group, derive `key = PeerAndThreadId(peerId: messages[0].1.id.peerId, threadId: messages[0].1.threadId)`. If `shouldGateSend && !isSendGateOpen`, flip every context in the group to `.waitingForSendGate(groupId: nil, content: PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil))` and append the entire `[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]` group to `forwardSendGateGroups[key]`. Otherwise fire as today.
|
||||
|
||||
Forward groups within a key drain in FIFO order.
|
||||
|
||||
## Drain logic
|
||||
|
||||
`drainSendGate(key: PeerAndThreadId)` runs on `self.queue`. Idempotent.
|
||||
|
||||
1. **Single-message drain.** Snapshot `messageContexts` filtering on `state == .waitingForSendGate(groupId: nil, ...)` AND `PeerAndThreadId(peerId: contextId.peerId, threadId: context.threadId) == key`. Sort by `messageId.id` ascending. For each, extract the parked `content`, call `commitSendingSingleMessage(messageContext:messageId:content:)`.
|
||||
2. **Grouped-album drain.** Collect distinct `groupId`s among `.waitingForSendGate(groupId: <non-nil>, ...)` contexts whose key matches. Iterate in ascending min-`messageId.id`-in-group order. For each, call `dataForPendingMessageGroup(groupId)` (which now sees the parked members as ready) and pass the result to `commitSendingMessageGroup(groupId:messages:)`.
|
||||
3. **Forward drain.** Pop `forwardSendGateGroups.removeValue(forKey: key)`. For each parked group (FIFO): flip every context to `.sending(groupId: nil)`, build the `[(MessageId, PendingMessageUploadedContentAndReuploadInfo)]` array, fire `sendGroupMessagesContent` exactly mirroring the existing forward-fire code (lines 719–731).
|
||||
4. After (1)–(3), call `updateWaitingUploads(peerId: key.peerId)` and `updatePendingMediaUploads()` once.
|
||||
|
||||
`handleLiveTypingDraftsUpdate(_ view: CombinedView)`:
|
||||
|
||||
```swift
|
||||
let view = (view.views[.allTypingDrafts] as? AllTypingDraftsView)
|
||||
let new = view?.keys ?? []
|
||||
let cleared = self.liveTypingDraftKeys.subtracting(new)
|
||||
self.liveTypingDraftKeys = new
|
||||
for key in cleared {
|
||||
self.drainSendGate(key: key)
|
||||
}
|
||||
```
|
||||
|
||||
Single-emission semantics: a `(false → true)` transition (key newly populated) parks future arrivals only; in-flight `.sending` continues. A `(true → false)` transition fires drain.
|
||||
|
||||
## Side effects on existing helpers
|
||||
|
||||
- `PendingMessageState.groupId` switch (line 37): add `.waitingForSendGate` case returning the case's `groupId` (forward parking uses `groupId == nil`; grouped-album parking uses the real groupId).
|
||||
- `updatePendingMediaUploads()` (line 262): `.waitingForSendGate` is **not** treated as uploading. Excluded from the switch (or explicitly returns `default` early).
|
||||
- `dataForPendingMessageGroup(_ groupId:)` (line 753): add `.waitingForSendGate(contextGroupId, content)` arm — if `contextGroupId == groupId`, append `(context, id, content)` to result, same as the existing `.waitingToBeSent` arm.
|
||||
- `updatePendingMessageIds(_:)` (line 284): in the existing `for id in removedMessageIds` loop, additionally drop `forwardSendGateGroups[*]` entries whose contained context matches `id`. (Single/album parking is auto-cleaned because parked state lives on the context, which gets `state = .none`.) Implementation: walk the dict, filter out the removed context from each parked group, drop any group that empties out, drop any key whose value-array empties out.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **First-emit race.** `liveTypingDraftKeys` initializes to `[]`. If a send is attempted before the first view emit and a draft is actually active, that single message slips through. Tolerated.
|
||||
- **Saved Messages / secret chats / scheduled / new-topic sentinel.** All explicit skip-cases in `shouldGateSend`.
|
||||
- **Self-typing on another device.** A draft we authored on another device is treated like any other — our outgoing send to that chat parks until it clears. This is consistent with the design intent (drafts visibly commit before subsequent sends arrive). No author filter.
|
||||
- **Removed-while-parked.** Handled by `updatePendingMessageIds(_:)` extension above.
|
||||
- **Re-entrancy.** Drain helpers snapshot work-lists before iterating, so mid-iteration mutations to `messageContexts` (e.g. a fired send completes synchronously) don't corrupt the loop.
|
||||
- **Paid postpone composition.** Paid postpone gates upload-start; once paid commit fires, upload runs; once upload completes, the typing-draft gate parks at `.waitingForSendGate`; once the draft clears, send fires. Stacked sequentially without interaction.
|
||||
- **Subscription teardown.** `allTypingDraftsDisposable.dispose()` in `deinit`.
|
||||
|
||||
## Testing
|
||||
|
||||
This codebase has no unit tests. Verification is via full build + manual exercise:
|
||||
|
||||
- Build: `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` (prefixed with `source ~/.zshrc 2>/dev/null;`).
|
||||
- Manual: in a 1:1 chat with another device, induce a live-typing draft on the peer side and confirm an outgoing text send parks (chat shows "sending" status held until draft clears or commits). Repeat for: media single send, grouped media album, forward.
|
||||
- Negative manual: scheduled message — confirm not gated. Saved Messages — confirm not gated. Secret chat — confirm not gated.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-chat opt-in toggle.
|
||||
- Upper-bound timeout / fallback send.
|
||||
- Grace-delay after draft clears.
|
||||
- UI affordance ("waiting for X to finish typing…").
|
||||
- Filtering self-authored drafts.
|
||||
|
|
@ -111,18 +111,18 @@ public enum TextLinkItem: Equatable {
|
|||
|
||||
public final class AccountWithInfo: Equatable {
|
||||
public let account: Account
|
||||
public let peer: Peer
|
||||
|
||||
public init(account: Account, peer: Peer) {
|
||||
public let peer: EnginePeer
|
||||
|
||||
public init(account: Account, peer: EnginePeer) {
|
||||
self.account = account
|
||||
self.peer = peer
|
||||
}
|
||||
|
||||
|
||||
public static func ==(lhs: AccountWithInfo, rhs: AccountWithInfo) -> Bool {
|
||||
if lhs.account !== rhs.account {
|
||||
return false
|
||||
}
|
||||
if !arePeersEqual(lhs.peer, rhs.peer) {
|
||||
if lhs.peer != rhs.peer {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
|
@ -225,6 +225,7 @@ public struct ResolvedBotAdminRights: OptionSet {
|
|||
public static let manageVideoChats = ResolvedBotAdminRights(rawValue: 512)
|
||||
public static let canBeAnonymous = ResolvedBotAdminRights(rawValue: 1024)
|
||||
public static let manageChat = ResolvedBotAdminRights(rawValue: 2048)
|
||||
public static let manageTopics = ResolvedBotAdminRights(rawValue: 4096)
|
||||
|
||||
public var chatAdminRights: TelegramChatAdminRightsFlags? {
|
||||
var flags = TelegramChatAdminRightsFlags()
|
||||
|
|
@ -259,6 +260,9 @@ public struct ResolvedBotAdminRights: OptionSet {
|
|||
if self.contains(ResolvedBotAdminRights.canBeAnonymous) {
|
||||
flags.insert(.canBeAnonymous)
|
||||
}
|
||||
if self.contains(ResolvedBotAdminRights.manageTopics) {
|
||||
flags.insert(.canManageTopics)
|
||||
}
|
||||
|
||||
if flags.isEmpty && !self.contains(ResolvedBotAdminRights.manageChat) {
|
||||
return nil
|
||||
|
|
@ -328,6 +332,7 @@ public enum ResolvedUrl {
|
|||
case unknownDeepLink(path: String)
|
||||
case oauth(url: String)
|
||||
case createBot(parentBot: PeerId, username: String?, title: String?)
|
||||
case textStyle(style: TelegramComposeAIMessageMode.CloudStyle.Custom, initialPreview: AIMessageStylePreview?)
|
||||
|
||||
public enum ResolvedCollectible {
|
||||
case gift(StarGift.UniqueGift)
|
||||
|
|
@ -403,18 +408,6 @@ public final class ChatPeekTimeout {
|
|||
}
|
||||
}
|
||||
|
||||
public final class ChatPeerNearbyData: Equatable {
|
||||
public static func == (lhs: ChatPeerNearbyData, rhs: ChatPeerNearbyData) -> Bool {
|
||||
return lhs.distance == rhs.distance
|
||||
}
|
||||
|
||||
public let distance: Int32
|
||||
|
||||
public init(distance: Int32) {
|
||||
self.distance = distance
|
||||
}
|
||||
}
|
||||
|
||||
public final class ChatGreetingData: Equatable {
|
||||
public static func == (lhs: ChatGreetingData, rhs: ChatGreetingData) -> Bool {
|
||||
return lhs.uuid == rhs.uuid
|
||||
|
|
@ -582,7 +575,6 @@ public final class NavigateToChatControllerParams {
|
|||
public let scrollToEndIfExists: Bool
|
||||
public let activateMessageSearch: (ChatSearchDomain, String)?
|
||||
public let peekData: ChatPeekTimeout?
|
||||
public let peerNearbyData: ChatPeerNearbyData?
|
||||
public let reportReason: NavigateToChatControllerParams.ReportReason?
|
||||
public let animated: Bool
|
||||
public let forceAnimatedScroll: Bool
|
||||
|
|
@ -618,7 +610,6 @@ public final class NavigateToChatControllerParams {
|
|||
scrollToEndIfExists: Bool = false,
|
||||
activateMessageSearch: (ChatSearchDomain, String)? = nil,
|
||||
peekData: ChatPeekTimeout? = nil,
|
||||
peerNearbyData: ChatPeerNearbyData? = nil,
|
||||
reportReason: NavigateToChatControllerParams.ReportReason? = nil,
|
||||
animated: Bool = true,
|
||||
forceAnimatedScroll: Bool = false,
|
||||
|
|
@ -653,7 +644,6 @@ public final class NavigateToChatControllerParams {
|
|||
self.scrollToEndIfExists = scrollToEndIfExists
|
||||
self.activateMessageSearch = activateMessageSearch
|
||||
self.peekData = peekData
|
||||
self.peerNearbyData = peerNearbyData
|
||||
self.reportReason = reportReason
|
||||
self.animated = animated
|
||||
self.forceAnimatedScroll = forceAnimatedScroll
|
||||
|
|
@ -691,7 +681,6 @@ public final class NavigateToChatControllerParams {
|
|||
scrollToEndIfExists: self.scrollToEndIfExists,
|
||||
activateMessageSearch: self.activateMessageSearch,
|
||||
peekData: self.peekData,
|
||||
peerNearbyData: self.peerNearbyData,
|
||||
reportReason: self.reportReason,
|
||||
animated: self.animated,
|
||||
forceAnimatedScroll: self.forceAnimatedScroll,
|
||||
|
|
@ -712,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
|
||||
|
|
@ -748,7 +737,7 @@ public enum PeerInfoControllerMode {
|
|||
case generic
|
||||
case calls(messages: [Message])
|
||||
case nearbyPeer(distance: Int32)
|
||||
case group(PeerId)
|
||||
case group(sourceMessageId: MessageId)
|
||||
case reaction(MessageId)
|
||||
case forumTopic(thread: ChatReplyThreadMessage)
|
||||
case recommendedChannels
|
||||
|
|
@ -1319,6 +1308,14 @@ public final class TextProcessingScreenSendContextActions {
|
|||
public enum TextProcessingScreenMode {
|
||||
case edit(saveRestoreStateId: EnginePeer.Id?, completion: (TextWithEntities) -> Void, send: ((TextWithEntities) -> Void)?, sendContextActions: TextProcessingScreenSendContextActions?)
|
||||
case translate(fromLanguage: String?)
|
||||
case preview(style: TelegramComposeAIMessageMode.CloudStyle.Custom, authorPeer: EnginePeer?, initialPreview: AIMessageStylePreview?, isAlreadyAdded: Bool, added: () -> Void)
|
||||
}
|
||||
|
||||
public enum EmojiStatusSelectionControllerMode {
|
||||
case statusSelection
|
||||
case backgroundSelection(completion: (TelegramMediaFile?) -> Void)
|
||||
case customStatusSelection(completion: (TelegramMediaFile?, Int32?) -> Void)
|
||||
case quickReactionSelection(completion: () -> Void)
|
||||
}
|
||||
|
||||
public protocol SharedAccountContext: AnyObject {
|
||||
|
|
@ -1371,7 +1368,7 @@ public protocol SharedAccountContext: AnyObject {
|
|||
func messageFromPreloadedChatHistoryViewForLocation(id: MessageId, location: ChatHistoryLocationInput, context: AccountContext, chatLocation: ChatLocation, subject: ChatControllerSubject?, chatLocationContextHolder: Atomic<ChatLocationContextHolder?>, tag: HistoryViewInputTag?) -> Signal<(MessageIndex?, Bool), NoError>
|
||||
|
||||
func makeOverlayAudioPlayerController(context: AccountContext, chatLocation: ChatLocation, type: MediaManagerPlayerType, initialMessageId: MessageId, initialOrder: MusicPlaybackSettingsOrder, playlistLocation: SharedMediaPlaylistLocation?, parentNavigationController: NavigationController?) -> ViewController & OverlayAudioPlayerController
|
||||
func makePeerInfoController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?, peer: Peer, mode: PeerInfoControllerMode, avatarInitiallyExpanded: Bool, fromChat: Bool, requestsContext: PeerInvitationImportersContext?) -> ViewController?
|
||||
func makePeerInfoController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?, peer: EnginePeer, mode: PeerInfoControllerMode, avatarInitiallyExpanded: Bool, fromChat: Bool, requestsContext: PeerInvitationImportersContext?) -> ViewController?
|
||||
func makeChannelAdminController(context: AccountContext, peerId: PeerId, adminId: PeerId, initialParticipant: ChannelParticipant) -> ViewController?
|
||||
func makeDeviceContactInfoController(context: ShareControllerAccountContext, environment: ShareControllerEnvironment, subject: DeviceContactInfoSubject, completed: (() -> Void)?, cancelled: (() -> Void)?) -> ViewController
|
||||
func makePeersNearbyController(context: AccountContext) -> ViewController
|
||||
|
|
@ -1401,14 +1398,14 @@ public protocol SharedAccountContext: AnyObject {
|
|||
func makeProxySettingsController(context: AccountContext) -> ViewController
|
||||
func makeLocalizationListController(context: AccountContext) -> ViewController
|
||||
func makeCreateGroupController(context: AccountContext, peerIds: [PeerId], initialTitle: String?, mode: CreateGroupMode, completion: ((PeerId, @escaping () -> Void) -> Void)?) -> ViewController
|
||||
func makeChatRecentActionsController(context: AccountContext, peer: Peer, adminPeerId: PeerId?, starsState: StarsRevenueStats?) -> ViewController
|
||||
func makeChatRecentActionsController(context: AccountContext, peer: EnginePeer, adminPeerId: PeerId?, starsState: StarsRevenueStats?) -> ViewController
|
||||
func makePrivacyAndSecurityController(context: AccountContext) -> ViewController
|
||||
func makeBioPrivacyController(context: AccountContext, settings: Promise<AccountPrivacySettings?>, present: @escaping (ViewController) -> Void)
|
||||
func makeBirthdayPrivacyController(context: AccountContext, settings: Promise<AccountPrivacySettings?>, openedFromBirthdayScreen: Bool, present: @escaping (ViewController) -> Void)
|
||||
func makeSetupTwoFactorAuthController(context: AccountContext) -> ViewController
|
||||
func makeStorageManagementController(context: AccountContext) -> ViewController
|
||||
func makeAttachmentFileController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?, audio: Bool, bannedSendMedia: (Int32, Bool)?, presentGallery: @escaping () -> Void, presentFiles: @escaping () -> Void, presentDocumentScanner: (() -> Void)?, send: @escaping ([AnyMediaReference], Bool, Int32?, NSAttributedString?) -> Void) -> AttachmentFileController
|
||||
func makeGalleryCaptionPanelView(context: AccountContext, chatLocation: ChatLocation, isScheduledMessages: Bool, isFile: Bool, hasTimer: Bool, customEmojiAvailable: Bool, pushViewController: @escaping (ViewController) -> Void, present: @escaping (ViewController) -> Void, presentInGlobalOverlay: @escaping (ViewController) -> Void) -> NSObject?
|
||||
func makeGalleryCaptionPanelView(context: AccountContext, chatLocation: ChatLocation, isScheduledMessages: Bool, isFile: Bool, hasTimer: Bool, customEmojiAvailable: Bool, pushViewController: @escaping (ViewController) -> Void, present: @escaping (ViewController) -> Void, presentInGlobalOverlay: @escaping (ViewController) -> Void, getNavigationController: @escaping () -> NavigationController?) -> NSObject?
|
||||
func makeHashtagSearchController(context: AccountContext, peer: EnginePeer?, query: String, stories: Bool, forceDark: Bool) -> ViewController
|
||||
func makeStorySearchController(context: AccountContext, scope: StorySearchControllerScope, listContext: SearchStoryListContext?) -> ViewController
|
||||
func makeMyStoriesController(context: AccountContext, isArchive: Bool) -> ViewController
|
||||
|
|
@ -1445,7 +1442,7 @@ public protocol SharedAccountContext: AnyObject {
|
|||
func resolveUrl(context: AccountContext, peerId: PeerId?, url: String, skipUrlAuth: Bool) -> Signal<ResolvedUrl, NoError>
|
||||
func resolveUrlWithProgress(context: AccountContext, peerId: PeerId?, url: String, skipUrlAuth: Bool) -> Signal<ResolveUrlResult, NoError>
|
||||
func openResolvedUrl(_ resolvedUrl: ResolvedUrl, context: AccountContext, urlContext: OpenURLContext, navigationController: NavigationController?, forceExternal: Bool, forceUpdate: Bool, openPeer: @escaping (EnginePeer, ChatControllerInteractionNavigateToPeer) -> Void, sendFile: ((FileMediaReference) -> Void)?, sendSticker: ((FileMediaReference, UIView?, CGRect?) -> Bool)?, sendEmoji: ((String, ChatTextInputTextCustomEmojiAttribute) -> Void)?, requestMessageActionUrlAuth: ((MessageActionUrlSubject) -> Void)?, joinVoiceChat: ((PeerId, String?, CachedChannelData.ActiveCall) -> Void)?, present: @escaping (ViewController, Any?) -> Void, dismissInput: @escaping () -> Void, contentContext: Any?, progress: Promise<Bool>?, completion: (() -> Void)?)
|
||||
func openAddContact(context: AccountContext, firstName: String, lastName: String, phoneNumber: String, label: String, present: @escaping (ViewController, Any?) -> Void, pushController: @escaping (ViewController) -> Void, completed: @escaping () -> Void)
|
||||
func openAddContact(context: AccountContext, peer: EnginePeer?, firstName: String, lastName: String, phoneNumber: String, label: String, present: @escaping (ViewController, Any?) -> Void, pushController: @escaping (ViewController) -> Void, completed: @escaping () -> Void)
|
||||
func openAddPersonContact(context: AccountContext, peerId: PeerId, pushController: @escaping (ViewController) -> Void, present: @escaping (ViewController, Any?) -> Void)
|
||||
func presentContactsWarningSuppression(context: AccountContext, present: (ViewController, Any?) -> Void)
|
||||
func openImagePicker(context: AccountContext, completion: @escaping (UIImage) -> Void, present: @escaping (ViewController) -> Void)
|
||||
|
|
@ -1456,12 +1453,12 @@ public protocol SharedAccountContext: AnyObject {
|
|||
completion: @escaping (UIImage?) -> Void,
|
||||
completedWithUploadingImage: @escaping (UIImage, Signal<PeerInfoAvatarUploadStatus, NoError>) -> UIView?
|
||||
)
|
||||
func openAddPeerMembers(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?, parentController: ViewController, groupPeer: Peer, selectAddMemberDisposable: MetaDisposable, addMemberDisposable: MetaDisposable)
|
||||
func openAddPeerMembers(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?, parentController: ViewController, groupPeer: EnginePeer, selectAddMemberDisposable: MetaDisposable, addMemberDisposable: MetaDisposable)
|
||||
func makeInstantPageController(context: AccountContext, message: Message, sourcePeerType: MediaAutoDownloadPeerType?) -> ViewController?
|
||||
func makeInstantPageController(context: AccountContext, webPage: TelegramMediaWebpage, anchor: String?, sourceLocation: InstantPageSourceLocation) -> ViewController
|
||||
func openChatWallpaper(context: AccountContext, message: Message, present: @escaping (ViewController, Any?) -> Void)
|
||||
func makeRecentSessionsController(context: AccountContext, activeSessionsContext: ActiveSessionsContext) -> ViewController & RecentSessionsController
|
||||
func makeChatQrCodeScreen(context: AccountContext, peer: Peer, threadId: Int64?, temporary: Bool) -> ViewController
|
||||
func makeChatQrCodeScreen(context: AccountContext, peer: EnginePeer, threadId: Int64?, temporary: Bool) -> ViewController
|
||||
func makePremiumIntroController(context: AccountContext, source: PremiumIntroSource, forceDark: Bool, dismissed: (() -> Void)?) -> ViewController
|
||||
func makePremiumIntroController(sharedContext: SharedAccountContext, engine: TelegramEngineUnauthorized, inAppPurchaseManager: InAppPurchaseManager, source: PremiumIntroSource, proceed: (() -> Void)?) -> ViewController
|
||||
func makePremiumDemoController(context: AccountContext, subject: PremiumDemoSubject, forceDark: Bool, action: @escaping () -> Void, dismissed: (() -> Void)?) -> ViewController
|
||||
|
|
@ -1487,6 +1484,7 @@ public protocol SharedAccountContext: AnyObject {
|
|||
func makeInstalledStickerPacksController(context: AccountContext, mode: InstalledStickerPacksControllerMode, forceTheme: PresentationTheme?) -> ViewController
|
||||
func makeChannelStatsController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?, peerId: EnginePeer.Id, boosts: Bool, boostStatus: ChannelBoostStatus?) -> ViewController
|
||||
func makeMessagesStatsController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?, messageId: EngineMessage.Id) -> ViewController
|
||||
func makePollStatsScreen(context: AccountContext, messageId: EngineMessage.Id) -> ViewController
|
||||
func makeStoryStatsController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?, peerId: EnginePeer.Id, storyId: Int32, storyItem: EngineStoryItem, fromStory: Bool) -> ViewController
|
||||
func makeStarsTransactionsScreen(context: AccountContext, starsContext: StarsContext) -> ViewController
|
||||
func makeStarsPurchaseScreen(context: AccountContext, starsContext: StarsContext, options: [Any], purpose: StarsPurchasePurpose, targetPeerId: EnginePeer.Id?, customTheme: PresentationTheme?, completion: @escaping (Int64) -> Void) -> ViewController
|
||||
|
|
@ -1519,7 +1517,7 @@ public protocol SharedAccountContext: AnyObject {
|
|||
func makeGiftDemoScreen(context: AccountContext) -> ViewController
|
||||
func makeStorySharingScreen(context: AccountContext, subject: StorySharingSubject, parentController: ViewController) -> ViewController
|
||||
func makeContentReportScreen(context: AccountContext, subject: ReportContentSubject, forceDark: Bool, present: @escaping (ViewController) -> Void, completion: @escaping () -> Void, requestSelectMessages: ((String, Data, String?) -> Void)?)
|
||||
func makeShareController(context: AccountContext, subject: ShareControllerSubject, forceExternal: Bool, shareStory: (() -> Void)?, enqueued: (([PeerId], [Int64]) -> Void)?, actionCompleted: (() -> Void)?) -> ViewController
|
||||
func makeShareController(context: AccountContext, params: ShareControllerParams) -> ViewController
|
||||
func makeMiniAppListScreenInitialData(context: AccountContext) -> Signal<MiniAppListScreenInitialData, NoError>
|
||||
func makeMiniAppListScreen(context: AccountContext, initialData: MiniAppListScreenInitialData) -> ViewController
|
||||
func makeIncomingMessagePrivacyScreen(context: AccountContext, value: GlobalPrivacySettings.NonContactChatsPrivacy, exceptions: SelectivePrivacySettings, update: @escaping (GlobalPrivacySettings.NonContactChatsPrivacy) -> Void) -> ViewController
|
||||
|
|
@ -1550,7 +1548,7 @@ public protocol SharedAccountContext: AnyObject {
|
|||
|
||||
func openCreateGroupCallUI(context: AccountContext, peerIds: [EnginePeer.Id], parentController: ViewController)
|
||||
|
||||
func makeNewContactScreen(context: AccountContext, peer: EnginePeer?, phoneNumber: String?, shareViaException: Bool, completion: @escaping (EnginePeer?, DeviceContactStableId?, DeviceContactExtendedData?) -> Void) -> ViewController
|
||||
func makeNewContactScreen(context: AccountContext, peer: EnginePeer?, firstName: String?, lastName: String?, phoneNumber: String?, shareViaException: Bool, completion: @escaping (EnginePeer?, DeviceContactStableId?, DeviceContactExtendedData?) -> Void) -> ViewController
|
||||
|
||||
func makeLoginEmailSetupController(context: AccountContext, blocking: Bool, emailPattern: String?, canAutoDismissIfNeeded: Bool, navigationController: NavigationController?, completion: @escaping () -> Void, dismiss: @escaping () -> Void) -> ViewController
|
||||
func makePasskeySetupController(context: AccountContext, displaySkip: Bool, navigationController: NavigationController?, completion: @escaping () -> Void, dismiss: @escaping () -> Void) -> ViewController
|
||||
|
|
@ -1574,6 +1572,7 @@ public protocol SharedAccountContext: AnyObject {
|
|||
openAutomatically: Bool,
|
||||
completion: @escaping (EnginePeer.Id?) -> Void
|
||||
) async -> ViewController?
|
||||
func makeEmojiStatusSelectionController(context: AccountContext, mode: EmojiStatusSelectionControllerMode, sourceView: UIView, emojiContent: Signal<AnyObject, NoError>, currentSelection: Int64?, color: UIColor?, destinationItemView: @escaping () -> UIView?) -> ViewController
|
||||
|
||||
func navigateToCurrentCall()
|
||||
var hasOngoingCall: ValuePromise<Bool> { get }
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ public final class ChatMessageItemAssociatedData: Equatable {
|
|||
public let showSensitiveContent: Bool
|
||||
public let isSuspiciousPeer: Bool
|
||||
public let showTextAsPlaceholder: Bool
|
||||
public let accountCountry: String?
|
||||
public let isParticipant: Bool
|
||||
public let invitedOn: Int32?
|
||||
|
||||
public init(
|
||||
automaticDownloadPeerType: MediaAutoDownloadPeerType,
|
||||
|
|
@ -102,7 +105,10 @@ public final class ChatMessageItemAssociatedData: Equatable {
|
|||
isInline: Bool = false,
|
||||
showSensitiveContent: Bool = false,
|
||||
isSuspiciousPeer: Bool = false,
|
||||
showTextAsPlaceholder: Bool = false
|
||||
showTextAsPlaceholder: Bool = false,
|
||||
accountCountry: String? = nil,
|
||||
isParticipant: Bool = false,
|
||||
invitedOn: Int32? = nil
|
||||
) {
|
||||
self.automaticDownloadPeerType = automaticDownloadPeerType
|
||||
self.automaticDownloadPeerId = automaticDownloadPeerId
|
||||
|
|
@ -139,6 +145,9 @@ public final class ChatMessageItemAssociatedData: Equatable {
|
|||
self.showSensitiveContent = showSensitiveContent
|
||||
self.isSuspiciousPeer = isSuspiciousPeer
|
||||
self.showTextAsPlaceholder = showTextAsPlaceholder
|
||||
self.accountCountry = accountCountry
|
||||
self.isParticipant = isParticipant
|
||||
self.invitedOn = invitedOn
|
||||
}
|
||||
|
||||
public static func == (lhs: ChatMessageItemAssociatedData, rhs: ChatMessageItemAssociatedData) -> Bool {
|
||||
|
|
@ -235,6 +244,15 @@ public final class ChatMessageItemAssociatedData: Equatable {
|
|||
if lhs.isSuspiciousPeer != rhs.isSuspiciousPeer {
|
||||
return false
|
||||
}
|
||||
if lhs.accountCountry != rhs.accountCountry {
|
||||
return false
|
||||
}
|
||||
if lhs.isParticipant != rhs.isParticipant {
|
||||
return false
|
||||
}
|
||||
if lhs.invitedOn != rhs.invitedOn {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -1099,6 +1117,7 @@ public protocol ChatController: ViewController {
|
|||
func activateSearch(domain: ChatSearchDomain, query: String)
|
||||
func activateInput(type: ChatControllerActivateInput)
|
||||
func beginClearHistory(type: InteractiveHistoryClearingType)
|
||||
func presentReactionDeletionOptions(author: Peer, messageId: MessageId)
|
||||
|
||||
func performScrollToTop() -> Bool
|
||||
func transferScrollingVelocity(_ velocity: CGFloat)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ public protocol ContactSelectionController: ViewController {
|
|||
var result: Signal<([ContactListPeer], ContactListAction, Bool, Int32?, NSAttributedString?, ChatSendMessageActionSheetController.SendParameters?)?, NoError> { get }
|
||||
var displayProgress: Bool { get set }
|
||||
var dismissed: (() -> Void)? { get set }
|
||||
var presentScheduleTimePicker: (@escaping (Int32, Int32?) -> Void) -> Void { get set }
|
||||
var presentScheduleTimePicker: (@escaping (Int32, Int32?, Bool) -> Void) -> Void { get set }
|
||||
|
||||
func dismissSearch()
|
||||
}
|
||||
|
|
@ -59,9 +59,9 @@ public enum ContactListAction: Equatable {
|
|||
}
|
||||
|
||||
public enum ContactListPeer: Equatable {
|
||||
case peer(peer: Peer, isGlobal: Bool, participantCount: Int32?)
|
||||
case peer(peer: EnginePeer, isGlobal: Bool, participantCount: Int32?)
|
||||
case deviceContact(DeviceContactStableId, DeviceContactBasicData)
|
||||
|
||||
|
||||
public var id: ContactListPeerId {
|
||||
switch self {
|
||||
case let .peer(peer, _, _):
|
||||
|
|
@ -70,8 +70,8 @@ public enum ContactListPeer: Equatable {
|
|||
return .deviceContact(id)
|
||||
}
|
||||
}
|
||||
|
||||
public var indexName: PeerIndexNameRepresentation {
|
||||
|
||||
public var indexName: EnginePeer.IndexName {
|
||||
switch self {
|
||||
case let .peer(peer, _, _):
|
||||
return peer.indexName
|
||||
|
|
@ -79,11 +79,11 @@ public enum ContactListPeer: Equatable {
|
|||
return .personName(first: contact.firstName, last: contact.lastName, addressNames: [], phoneNumber: "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static func ==(lhs: ContactListPeer, rhs: ContactListPeer) -> Bool {
|
||||
switch lhs {
|
||||
case let .peer(lhsPeer, lhsIsGlobal, lhsParticipantCount):
|
||||
if case let .peer(rhsPeer, rhsIsGlobal, rhsParticipantCount) = rhs, lhsPeer.isEqual(rhsPeer), lhsIsGlobal == rhsIsGlobal, lhsParticipantCount == rhsParticipantCount {
|
||||
if case let .peer(rhsPeer, rhsIsGlobal, rhsParticipantCount) = rhs, lhsPeer == rhsPeer, lhsIsGlobal == rhsIsGlobal, lhsParticipantCount == rhsParticipantCount {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -78,7 +78,8 @@ public func messageMediaFileStatus(context: AccountContext, messageId: MessageId
|
|||
|
||||
var thumbnailStatus: Signal<MediaResourceStatus?, NoError> = .single(nil)
|
||||
if let videoThumbnail = file.videoThumbnails.first {
|
||||
thumbnailStatus = context.account.postbox.mediaBox.resourceStatus(videoThumbnail.resource)
|
||||
thumbnailStatus = context.engine.resources.status(resource: EngineMediaResource(videoThumbnail.resource))
|
||||
|> map { $0._asStatus() }
|
||||
|> map(Optional.init)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ public final class PeerSelectionControllerParams {
|
|||
public let hasCreation: Bool
|
||||
public let immediatelySwitchToContacts: Bool
|
||||
public let immediatelyActivateMultipleSelection: Bool
|
||||
public let suggestedPeers: [EnginePeer]
|
||||
|
||||
public init(
|
||||
context: AccountContext,
|
||||
|
|
@ -89,7 +90,8 @@ public final class PeerSelectionControllerParams {
|
|||
selectForumThreads: Bool = false,
|
||||
hasCreation: Bool = false,
|
||||
immediatelySwitchToContacts: Bool = false,
|
||||
immediatelyActivateMultipleSelection: Bool = false
|
||||
immediatelyActivateMultipleSelection: Bool = false,
|
||||
suggestedPeers: [EnginePeer] = []
|
||||
) {
|
||||
self.context = context
|
||||
self.updatedPresentationData = updatedPresentationData
|
||||
|
|
@ -112,6 +114,7 @@ public final class PeerSelectionControllerParams {
|
|||
self.hasCreation = hasCreation
|
||||
self.immediatelySwitchToContacts = immediatelySwitchToContacts
|
||||
self.immediatelyActivateMultipleSelection = immediatelyActivateMultipleSelection
|
||||
self.suggestedPeers = suggestedPeers
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ public enum PremiumIntroSource {
|
|||
case todo
|
||||
case copyProtection
|
||||
case aiTools
|
||||
case auth(String)
|
||||
case auth(String, Int32)
|
||||
case premiumGift(TelegramMediaFile)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import TelegramPresentationData
|
|||
import TelegramUIPreferences
|
||||
import AnimationCache
|
||||
import MultiAnimationRenderer
|
||||
import Display
|
||||
|
||||
public enum StorySharingSubject {
|
||||
case messages([Message])
|
||||
|
|
@ -37,6 +38,43 @@ public protocol ShareControllerEnvironment: AnyObject {
|
|||
func donateSendMessageIntent(account: ShareControllerAccountContext, peerIds: [EnginePeer.Id])
|
||||
}
|
||||
|
||||
public final class ShareControllerAppAccountContext: ShareControllerAccountContext {
|
||||
public let context: AccountContext
|
||||
|
||||
public var accountId: AccountRecordId {
|
||||
return self.context.account.id
|
||||
}
|
||||
public var accountPeerId: EnginePeer.Id {
|
||||
return self.context.account.stateManager.accountPeerId
|
||||
}
|
||||
public var stateManager: AccountStateManager {
|
||||
return self.context.account.stateManager
|
||||
}
|
||||
public var engineData: TelegramEngine.EngineData {
|
||||
return self.context.engine.data
|
||||
}
|
||||
public var animationCache: AnimationCache {
|
||||
return self.context.animationCache
|
||||
}
|
||||
public var animationRenderer: MultiAnimationRenderer {
|
||||
return self.context.animationRenderer
|
||||
}
|
||||
public var contentSettings: ContentSettings {
|
||||
return self.context.currentContentSettings.with { $0 }
|
||||
}
|
||||
public var appConfiguration: AppConfiguration {
|
||||
return self.context.currentAppConfiguration.with { $0 }
|
||||
}
|
||||
|
||||
public init(context: AccountContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
public func resolveInlineStickers(fileIds: [Int64]) -> Signal<[Int64: TelegramMediaFile], NoError> {
|
||||
return self.context.engine.stickers.resolveInlineStickers(fileIds: fileIds)
|
||||
}
|
||||
}
|
||||
|
||||
public enum ShareControllerExternalStatus {
|
||||
case preparing(Bool)
|
||||
case progress(Float)
|
||||
|
|
@ -78,3 +116,110 @@ public enum ShareControllerSubject {
|
|||
case mapMedia(TelegramMediaMap)
|
||||
case fromExternal(Int, ([PeerId], [PeerId: Int64], [PeerId: StarsAmount], String, ShareControllerAccountContext, Bool) -> Signal<ShareControllerExternalStatus, ShareControllerError>)
|
||||
}
|
||||
|
||||
public struct ShareControllerAction {
|
||||
public let title: String
|
||||
public let action: () -> Void
|
||||
|
||||
public init(title: String, action: @escaping () -> Void) {
|
||||
self.title = title
|
||||
self.action = action
|
||||
}
|
||||
}
|
||||
|
||||
public enum ShareControllerPreferredAction {
|
||||
case `default`
|
||||
case saveToCameraRoll
|
||||
case custom(action: ShareControllerAction)
|
||||
}
|
||||
|
||||
public struct ShareControllerSegmentedValue {
|
||||
public let title: String
|
||||
public let subject: ShareControllerSubject
|
||||
public let actionTitle: String
|
||||
public let formatSendTitle: (Int) -> String
|
||||
|
||||
public init(title: String, subject: ShareControllerSubject, actionTitle: String, formatSendTitle: @escaping (Int) -> String) {
|
||||
self.title = title
|
||||
self.subject = subject
|
||||
self.actionTitle = actionTitle
|
||||
self.formatSendTitle = formatSendTitle
|
||||
}
|
||||
}
|
||||
|
||||
public final class ShareControllerParams {
|
||||
public let subject: ShareControllerSubject
|
||||
public let presetText: String?
|
||||
public let preferredAction: ShareControllerPreferredAction
|
||||
public let showInChat: ((Message) -> Void)?
|
||||
public let fromForeignApp: Bool
|
||||
public let segmentedValues: [ShareControllerSegmentedValue]?
|
||||
public let externalShare: Bool
|
||||
public let immediateExternalShare: Bool
|
||||
public let immediatePeerId: PeerId?
|
||||
public let updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?
|
||||
public let forceTheme: PresentationTheme?
|
||||
public let forcedActionTitle: String?
|
||||
public let shareAsLink: Bool
|
||||
public let collectibleItemInfo: TelegramCollectibleItemInfo?
|
||||
|
||||
public let actionCompleted: (() -> Void)?
|
||||
public let dismissed: ((Bool) -> Void)?
|
||||
public let completed: (([PeerId]) -> Void)?
|
||||
public let enqueued: (([PeerId], [Int64]) -> Void)?
|
||||
public let shareStory: (() -> Void)?
|
||||
public let debugAction: (() -> Void)?
|
||||
public let onMediaTimestampLinkCopied: ((Int32?) -> Void)?
|
||||
public weak var parentNavigationController: NavigationController?
|
||||
public let canSendInHighQuality: Bool
|
||||
|
||||
public init(
|
||||
subject: ShareControllerSubject,
|
||||
presetText: String? = nil,
|
||||
preferredAction: ShareControllerPreferredAction = .default,
|
||||
showInChat: ((Message) -> Void)? = nil,
|
||||
fromForeignApp: Bool = false,
|
||||
segmentedValues: [ShareControllerSegmentedValue]? = nil,
|
||||
externalShare: Bool = true,
|
||||
immediateExternalShare: Bool = false,
|
||||
immediatePeerId: PeerId? = nil,
|
||||
updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)? = nil,
|
||||
forceTheme: PresentationTheme? = nil,
|
||||
forcedActionTitle: String? = nil,
|
||||
shareAsLink: Bool = false,
|
||||
collectibleItemInfo: TelegramCollectibleItemInfo? = nil,
|
||||
actionCompleted: (() -> Void)? = nil,
|
||||
dismissed: ((Bool) -> Void)? = nil,
|
||||
completed: (([PeerId]) -> Void)? = nil,
|
||||
enqueued: (([PeerId], [Int64]) -> Void)? = nil,
|
||||
shareStory: (() -> Void)? = nil,
|
||||
debugAction: (() -> Void)? = nil,
|
||||
onMediaTimestampLinkCopied: ((Int32?) -> Void)? = nil,
|
||||
parentNavigationController: NavigationController? = nil,
|
||||
canSendInHighQuality: Bool = false
|
||||
) {
|
||||
self.subject = subject
|
||||
self.presetText = presetText
|
||||
self.preferredAction = preferredAction
|
||||
self.showInChat = showInChat
|
||||
self.fromForeignApp = fromForeignApp
|
||||
self.segmentedValues = segmentedValues
|
||||
self.externalShare = externalShare
|
||||
self.immediateExternalShare = immediateExternalShare
|
||||
self.immediatePeerId = immediatePeerId
|
||||
self.updatedPresentationData = updatedPresentationData
|
||||
self.forceTheme = forceTheme
|
||||
self.forcedActionTitle = forcedActionTitle
|
||||
self.shareAsLink = shareAsLink
|
||||
self.collectibleItemInfo = collectibleItemInfo
|
||||
self.actionCompleted = actionCompleted
|
||||
self.dismissed = dismissed
|
||||
self.completed = completed
|
||||
self.enqueued = enqueued
|
||||
self.shareStory = shareStory
|
||||
self.debugAction = debugAction
|
||||
self.onMediaTimestampLinkCopied = onMediaTimestampLinkCopied
|
||||
self.parentNavigationController = parentNavigationController
|
||||
self.canSendInHighQuality = canSendInHighQuality
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ swift_library(
|
|||
],
|
||||
deps = [
|
||||
"//submodules/TelegramCore:TelegramCore",
|
||||
"//submodules/Postbox",
|
||||
"//submodules/AsyncDisplayKit:AsyncDisplayKit",
|
||||
"//submodules/Display:Display",
|
||||
"//submodules/AvatarNode:AvatarNode",
|
||||
|
|
|
|||
|
|
@ -3,15 +3,13 @@ import UIKit
|
|||
import AsyncDisplayKit
|
||||
import Display
|
||||
import TelegramCore
|
||||
import Postbox
|
||||
import TelegramPresentationData
|
||||
import AvatarNode
|
||||
import AccountContext
|
||||
|
||||
public class ActionSheetPeerItem: ActionSheetItem {
|
||||
public let accountPeerId: EnginePeer.Id
|
||||
public let postbox: Postbox
|
||||
public let network: Network
|
||||
public let stateManager: AccountStateManager
|
||||
public let contentSettings: ContentSettings
|
||||
public let peer: EnginePeer
|
||||
public let theme: PresentationTheme
|
||||
|
|
@ -19,12 +17,11 @@ public class ActionSheetPeerItem: ActionSheetItem {
|
|||
public let isSelected: Bool
|
||||
public let strings: PresentationStrings
|
||||
public let action: () -> Void
|
||||
|
||||
|
||||
public convenience init(context: AccountContext, peer: EnginePeer, title: String, isSelected: Bool, strings: PresentationStrings, theme: PresentationTheme, action: @escaping () -> Void) {
|
||||
self.init(
|
||||
accountPeerId: context.account.peerId,
|
||||
postbox: context.account.postbox,
|
||||
network: context.account.network,
|
||||
stateManager: context.account.stateManager,
|
||||
contentSettings: context.currentContentSettings.with { $0 },
|
||||
peer: peer,
|
||||
title: title,
|
||||
|
|
@ -34,11 +31,10 @@ public class ActionSheetPeerItem: ActionSheetItem {
|
|||
action: action
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
public init(
|
||||
accountPeerId: EnginePeer.Id,
|
||||
postbox: Postbox,
|
||||
network: Network,
|
||||
stateManager: AccountStateManager,
|
||||
contentSettings: ContentSettings,
|
||||
peer: EnginePeer,
|
||||
title: String,
|
||||
|
|
@ -48,8 +44,7 @@ public class ActionSheetPeerItem: ActionSheetItem {
|
|||
action: @escaping () -> Void
|
||||
) {
|
||||
self.accountPeerId = accountPeerId
|
||||
self.postbox = postbox
|
||||
self.network = network
|
||||
self.stateManager = stateManager
|
||||
self.contentSettings = contentSettings
|
||||
self.peer = peer
|
||||
self.title = title
|
||||
|
|
@ -154,7 +149,7 @@ public class ActionSheetPeerItemNode: ActionSheetItemNode {
|
|||
let textColor: UIColor = self.theme.primaryTextColor
|
||||
self.label.attributedText = NSAttributedString(string: item.title, font: defaultFont, textColor: textColor)
|
||||
|
||||
self.avatarNode.setPeer(accountPeerId: item.accountPeerId, postbox: item.postbox, network: item.network, contentSettings: item.contentSettings, theme: item.theme, peer: item.peer)
|
||||
self.avatarNode.setPeer(accountPeerId: item.accountPeerId, postbox: item.stateManager.postbox, network: item.stateManager.network, contentSettings: item.contentSettings, theme: item.theme, peer: item.peer)
|
||||
|
||||
self.checkNode.isHidden = !item.isSelected
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ private final class ContentNode: ASDisplayNode {
|
|||
self.addSubnode(self.clippedNode)
|
||||
|
||||
if let peer = peer {
|
||||
if let representation = peer.smallProfileImage, let signal = peerAvatarImage(account: context.account, peerReference: PeerReference(peer._asPeer()), authorOfMessage: nil, representation: representation, displayDimensions: size, synchronousLoad: synchronousLoad) {
|
||||
if let representation = peer.smallProfileImage, let signal = peerAvatarImage(account: context.account, peerReference: PeerReference(peer), authorOfMessage: nil, representation: representation, displayDimensions: size, synchronousLoad: synchronousLoad) {
|
||||
let image = generateImage(size, rotatedContext: { size, context in
|
||||
context.clear(CGRect(origin: CGPoint(), size: size))
|
||||
context.setFillColor(UIColor.lightGray.cgColor)
|
||||
|
|
@ -328,7 +328,7 @@ public final class AnimatedAvatarSetView: UIView {
|
|||
self.addSubview(self.clippedView)
|
||||
|
||||
if let peer = peer {
|
||||
if let representation = peer.smallProfileImage, let signal = peerAvatarImage(account: context.account, peerReference: PeerReference(peer._asPeer()), authorOfMessage: nil, representation: representation, displayDimensions: size, synchronousLoad: synchronousLoad) {
|
||||
if let representation = peer.smallProfileImage, let signal = peerAvatarImage(account: context.account, peerReference: PeerReference(peer), authorOfMessage: nil, representation: representation, displayDimensions: size, synchronousLoad: synchronousLoad) {
|
||||
let image = generateImage(size, rotatedContext: { size, context in
|
||||
context.clear(CGRect(origin: CGPoint(), size: size))
|
||||
context.setFillColor(UIColor.lightGray.cgColor)
|
||||
|
|
|
|||
|
|
@ -3450,7 +3450,7 @@ ASDISPLAYNODE_INLINE BOOL subtreeIsRasterized(ASDisplayNode *node) {
|
|||
- (ASDisplayNodePerformanceMeasurements)performanceMeasurements
|
||||
{
|
||||
MutexLocker l(__instanceLock__);
|
||||
ASDisplayNodePerformanceMeasurements measurements = { .layoutSpecNumberOfPasses = -1, .layoutSpecTotalTime = NAN, .layoutComputationNumberOfPasses = -1, .layoutComputationTotalTime = NAN };
|
||||
ASDisplayNodePerformanceMeasurements measurements = { .layoutSpecTotalTime = NAN, .layoutSpecNumberOfPasses = -1, .layoutComputationTotalTime = NAN, .layoutComputationNumberOfPasses = -1 };
|
||||
if (_measurementOptions & ASDisplayNodePerformanceMeasurementOptionLayoutSpec) {
|
||||
measurements.layoutSpecNumberOfPasses = _layoutSpecNumberOfPasses;
|
||||
measurements.layoutSpecTotalTime = _layoutSpecTotalTime;
|
||||
|
|
|
|||
|
|
@ -163,6 +163,8 @@ namespace AS {
|
|||
return success;
|
||||
}
|
||||
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wthread-safety-analysis"
|
||||
void lock() {
|
||||
switch (_type) {
|
||||
case Plain:
|
||||
|
|
@ -184,7 +186,10 @@ namespace AS {
|
|||
}
|
||||
DidLock();
|
||||
}
|
||||
#pragma clang diagnostic pop
|
||||
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wthread-safety-analysis"
|
||||
void unlock() {
|
||||
WillUnlock();
|
||||
switch (_type) {
|
||||
|
|
@ -206,6 +211,7 @@ namespace AS {
|
|||
break;
|
||||
}
|
||||
}
|
||||
#pragma clang diagnostic pop
|
||||
|
||||
void AssertHeld() {
|
||||
ASDisplayNodeCAssert(_owner == std::this_thread::get_id(), @"Thread should hold lock");
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ swift_library(
|
|||
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
|
||||
"//submodules/AsyncDisplayKit:AsyncDisplayKit",
|
||||
"//submodules/Display:Display",
|
||||
"//submodules/Postbox:Postbox",
|
||||
"//submodules/TelegramCore:TelegramCore",
|
||||
"//submodules/TelegramPresentationData:TelegramPresentationData",
|
||||
"//submodules/TextFormat:TextFormat",
|
||||
|
|
@ -28,6 +27,7 @@ swift_library(
|
|||
"//submodules/ChatPresentationInterfaceState:ChatPresentationInterfaceState",
|
||||
"//submodules/Pasteboard:Pasteboard",
|
||||
"//submodules/ContextUI:ContextUI",
|
||||
"//submodules/TelegramUI/Components/ChatEntityKeyboardInputNode:ChatEntityKeyboardInputNode",
|
||||
"//submodules/TelegramUI/Components/EmojiTextAttachmentView:EmojiTextAttachmentView",
|
||||
"//submodules/ComponentFlow:ComponentFlow",
|
||||
"//submodules/Components/LottieAnimationComponent:LottieAnimationComponent",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -479,7 +479,9 @@ final class AttachmentContainer: ASDisplayNode, ASGestureRecognizerDelegate {
|
|||
if self.isDismissed {
|
||||
return
|
||||
}
|
||||
self.bottomClipNode.cornerRadius = layout.deviceMetrics.screenCornerRadius - 2.0
|
||||
|
||||
let containerCornerRadius = max(24.0, layout.deviceMetrics.screenCornerRadius)
|
||||
self.bottomClipNode.cornerRadius = containerCornerRadius - 2.0
|
||||
|
||||
self.isUpdatingState = true
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -34,7 +34,7 @@ private enum InnerState: Equatable {
|
|||
|
||||
public final class AuthorizationSequenceController: NavigationController, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
|
||||
static func navigationBarTheme(_ theme: PresentationTheme) -> NavigationBarTheme {
|
||||
return NavigationBarTheme(overallDarkAppearance: theme.overallDarkAppearance, buttonColor: theme.chat.inputPanel.panelControlColor, disabledButtonColor: theme.intro.disabledTextColor, primaryTextColor: theme.intro.primaryTextColor, backgroundColor: .clear, opaqueBackgroundColor: .clear, enableBackgroundBlur: false, separatorColor: .clear, badgeBackgroundColor: theme.rootController.navigationBar.badgeBackgroundColor, badgeStrokeColor: theme.rootController.navigationBar.badgeStrokeColor, badgeTextColor: theme.rootController.navigationBar.badgeTextColor, edgeEffectColor: .clear, style: .glass)
|
||||
return NavigationBarTheme(overallDarkAppearance: theme.overallDarkAppearance, buttonColor: theme.chat.inputPanel.panelControlColor, disabledButtonColor: theme.intro.disabledTextColor, primaryTextColor: theme.intro.primaryTextColor, backgroundColor: .clear, opaqueBackgroundColor: .clear, enableBackgroundBlur: false, separatorColor: .clear, badgeBackgroundColor: theme.rootController.navigationBar.badgeBackgroundColor, badgeStrokeColor: theme.rootController.navigationBar.badgeStrokeColor, badgeTextColor: theme.rootController.navigationBar.badgeTextColor, edgeEffectColor: .clear, accentButtonColor: theme.list.itemCheckColors.fillColor, accentDisabledButtonColor: theme.chat.inputPanel.panelControlDisabledColor, accentForegroundColor: theme.list.itemCheckColors.foregroundColor, style: .glass)
|
||||
}
|
||||
|
||||
private let sharedContext: SharedAccountContext
|
||||
|
|
@ -810,8 +810,8 @@ public final class AuthorizationSequenceController: NavigationController, ASAuth
|
|||
return controller
|
||||
}
|
||||
|
||||
private func paymentController(number: String, phoneCodeHash: String, storeProduct: String, supportEmailAddress: String, supportEmailSubject: String) -> AuthorizationSequencePaymentScreen {
|
||||
let controller = AuthorizationSequencePaymentScreen(sharedContext: self.sharedContext, engine: self.engine, presentationData: self.presentationData, inAppPurchaseManager: self.inAppPurchaseManager, phoneNumber: number, phoneCodeHash: phoneCodeHash, storeProduct: storeProduct, supportEmailAddress: supportEmailAddress, supportEmailSubject: supportEmailSubject, back: { [weak self] in
|
||||
private func paymentController(number: String, phoneCodeHash: String, storeProduct: String, premiumDays: Int32, supportEmailAddress: String, supportEmailSubject: String) -> AuthorizationSequencePaymentScreen {
|
||||
let controller = AuthorizationSequencePaymentScreen(sharedContext: self.sharedContext, engine: self.engine, presentationData: self.presentationData, inAppPurchaseManager: self.inAppPurchaseManager, phoneNumber: number, phoneCodeHash: phoneCodeHash, storeProduct: storeProduct, premiumDays: premiumDays, supportEmailAddress: supportEmailAddress, supportEmailSubject: supportEmailSubject, back: { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
|
@ -997,7 +997,7 @@ public final class AuthorizationSequenceController: NavigationController, ASAuth
|
|||
controller.reset = { [weak self, weak controller] in
|
||||
if let strongSelf = self, let strongController = controller {
|
||||
strongController.present(textAlertController(sharedContext: strongSelf.sharedContext, title: nil, text: suggestReset ? strongSelf.presentationData.strings.TwoStepAuth_RecoveryFailed : strongSelf.presentationData.strings.TwoStepAuth_RecoveryUnavailable, actions: [
|
||||
TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}),
|
||||
TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}),
|
||||
TextAlertAction(type: .destructiveAction, title: strongSelf.presentationData.strings.Login_ResetAccountProtected_Reset, action: {
|
||||
if let strongSelf = self, let strongController = controller {
|
||||
strongController.inProgress = true
|
||||
|
|
@ -1084,7 +1084,7 @@ public final class AuthorizationSequenceController: NavigationController, ASAuth
|
|||
controller.reset = { [weak self, weak controller] in
|
||||
if let strongSelf = self, let strongController = controller {
|
||||
strongController.present(textAlertController(sharedContext: strongSelf.sharedContext, title: nil, text: strongSelf.presentationData.strings.TwoStepAuth_ResetAccountConfirmation, actions: [
|
||||
TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}),
|
||||
TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}),
|
||||
TextAlertAction(type: .destructiveAction, title: strongSelf.presentationData.strings.Login_ResetAccountProtected_Reset, action: {
|
||||
if let strongSelf = self, let strongController = controller {
|
||||
strongController.inProgress = true
|
||||
|
|
@ -1159,7 +1159,7 @@ public final class AuthorizationSequenceController: NavigationController, ASAuth
|
|||
let avatarVideo: Signal<UploadedPeerPhotoData?, NoError>?
|
||||
if let avatarAsset = avatarAsset as? AVAsset {
|
||||
let engine = strongSelf.engine
|
||||
avatarVideo = Signal<TelegramMediaResource?, NoError> { subscriber in
|
||||
avatarVideo = Signal<EngineMediaResource?, NoError> { subscriber in
|
||||
let entityRenderer: LegacyPaintEntityRenderer? = avatarAdjustments.flatMap { adjustments in
|
||||
if let paintingData = adjustments.paintingData, paintingData.hasAnimation {
|
||||
return LegacyPaintEntityRenderer(postbox: nil, adjustments: adjustments)
|
||||
|
|
@ -1178,7 +1178,7 @@ public final class AuthorizationSequenceController: NavigationController, ASAuth
|
|||
if let data = try? Data(contentsOf: result.fileURL) {
|
||||
let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
|
||||
engine.account.postbox.mediaBox.storeResourceData(resource.id, data: data, synchronous: true)
|
||||
subscriber.putNext(resource)
|
||||
subscriber.putNext(EngineMediaResource(resource))
|
||||
|
||||
EngineTempBox.shared.dispose(tempFile)
|
||||
}
|
||||
|
|
@ -1348,12 +1348,12 @@ public final class AuthorizationSequenceController: NavigationController, ASAuth
|
|||
}
|
||||
controllers.append(self.signUpController(firstName: firstName, lastName: lastName, termsOfService: termsOfService, displayCancel: displayCancel))
|
||||
self.setViewControllers(controllers, animated: !self.viewControllers.isEmpty)
|
||||
case let .payment(number, codeHash, storeProduct, supportEmailAddress, supportEmailSubject, _):
|
||||
case let .payment(number, codeHash, storeProduct, premiumDays, supportEmailAddress, supportEmailSubject, _):
|
||||
var controllers: [ViewController] = []
|
||||
if !self.otherAccountPhoneNumbers.1.isEmpty {
|
||||
controllers.append(self.splashController())
|
||||
}
|
||||
controllers.append(self.paymentController(number: number, phoneCodeHash: codeHash, storeProduct: storeProduct, supportEmailAddress: supportEmailAddress, supportEmailSubject: supportEmailSubject))
|
||||
controllers.append(self.paymentController(number: number, phoneCodeHash: codeHash, storeProduct: storeProduct, premiumDays: premiumDays, supportEmailAddress: supportEmailAddress, supportEmailSubject: supportEmailSubject))
|
||||
self.setViewControllers(controllers, animated: !self.viewControllers.isEmpty)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import UIKit
|
|||
import Display
|
||||
import AsyncDisplayKit
|
||||
import SwiftSignalKit
|
||||
import Postbox
|
||||
import TelegramCore
|
||||
import TelegramPresentationData
|
||||
import TelegramUIPreferences
|
||||
|
|
@ -40,6 +39,7 @@ final class AuthorizationSequencePaymentScreenComponent: Component {
|
|||
let phoneNumber: String
|
||||
let phoneCodeHash: String
|
||||
let storeProduct: String
|
||||
let premiumDays: Int32
|
||||
let supportEmailAddress: String
|
||||
let supportEmailSubject: String
|
||||
|
||||
|
|
@ -51,6 +51,7 @@ final class AuthorizationSequencePaymentScreenComponent: Component {
|
|||
phoneNumber: String,
|
||||
phoneCodeHash: String,
|
||||
storeProduct: String,
|
||||
premiumDays: Int32,
|
||||
supportEmailAddress: String,
|
||||
supportEmailSubject: String
|
||||
) {
|
||||
|
|
@ -61,6 +62,7 @@ final class AuthorizationSequencePaymentScreenComponent: Component {
|
|||
self.phoneNumber = phoneNumber
|
||||
self.phoneCodeHash = phoneCodeHash
|
||||
self.storeProduct = storeProduct
|
||||
self.premiumDays = premiumDays
|
||||
self.supportEmailAddress = supportEmailAddress
|
||||
self.supportEmailSubject = supportEmailSubject
|
||||
}
|
||||
|
|
@ -116,7 +118,7 @@ final class AuthorizationSequencePaymentScreenComponent: Component {
|
|||
self.state?.updated()
|
||||
|
||||
let (currency, amount) = storeProduct.priceCurrencyAndAmount
|
||||
let purpose: AppStoreTransactionPurpose = .authCode(restore: false, phoneNumber: component.phoneNumber, phoneCodeHash: component.phoneCodeHash, currency: currency, amount: amount)
|
||||
let purpose: AppStoreTransactionPurpose = .authCode(restore: false, phoneNumber: component.phoneNumber, phoneCodeHash: component.phoneCodeHash, premiumDays: component.premiumDays, currency: currency, amount: amount)
|
||||
let _ = (component.engine.payments.canPurchasePremium(purpose: purpose)
|
||||
|> deliverOnMainQueue).start(next: { [weak self] available in
|
||||
guard let self else {
|
||||
|
|
@ -169,7 +171,7 @@ final class AuthorizationSequencePaymentScreenComponent: Component {
|
|||
title: nil,
|
||||
text: errorText,
|
||||
actions: [
|
||||
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}),
|
||||
TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {}),
|
||||
TextAlertAction(type: .defaultAction, title: presentationData.strings.Login_PhoneNumberHelp, action: { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
|
|
@ -334,13 +336,24 @@ final class AuthorizationSequencePaymentScreenComponent: Component {
|
|||
))
|
||||
)
|
||||
)
|
||||
|
||||
let supportText: String
|
||||
if component.premiumDays == 7 {
|
||||
supportText = environment.strings.Login_Fee_Support_Text
|
||||
} else if component.premiumDays > 0 {
|
||||
let daysString = environment.strings.Login_Fee_Support_NewText_Days(component.premiumDays)
|
||||
supportText = environment.strings.Login_Fee_Support_NewText(daysString).string
|
||||
} else {
|
||||
supportText = environment.strings.Login_Fee_Support_NewTextNone
|
||||
}
|
||||
|
||||
items.append(
|
||||
AnyComponentWithIdentity(
|
||||
id: "support",
|
||||
component: AnyComponent(ParagraphComponent(
|
||||
title: environment.strings.Login_Fee_Support_Title,
|
||||
titleColor: textColor,
|
||||
text: environment.strings.Login_Fee_Support_Text,
|
||||
text: supportText,
|
||||
textColor: secondaryTextColor,
|
||||
iconName: "Premium/Authorization/Support",
|
||||
iconColor: linkColor,
|
||||
|
|
@ -352,7 +365,7 @@ final class AuthorizationSequencePaymentScreenComponent: Component {
|
|||
sharedContext: component.sharedContext,
|
||||
engine: component.engine,
|
||||
inAppPurchaseManager: component.inAppPurchaseManager,
|
||||
source: .auth(product.price),
|
||||
source: .auth(product.price, component.premiumDays),
|
||||
proceed: { [weak self] in
|
||||
self?.proceed()
|
||||
}
|
||||
|
|
@ -411,6 +424,16 @@ final class AuthorizationSequencePaymentScreenComponent: Component {
|
|||
}
|
||||
|
||||
let buttonString = environment.strings.Login_Fee_SignUp(priceString).string
|
||||
let buttonSubtitle: String
|
||||
if component.premiumDays == 7 {
|
||||
buttonSubtitle = environment.strings.Login_Fee_GetPremiumForAWeek
|
||||
} else if component.premiumDays > 0 {
|
||||
let daysString = environment.strings.Login_Fee_GetPremiumForDays_Days(component.premiumDays)
|
||||
buttonSubtitle = environment.strings.Login_Fee_GetPremiumForDays(daysString).string
|
||||
} else {
|
||||
buttonSubtitle = environment.strings.Login_Fee_GetPremiumNone
|
||||
}
|
||||
|
||||
let buttonAttributedString = NSMutableAttributedString(string: buttonString, font: Font.semibold(17.0), textColor: environment.theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center)
|
||||
let buttonSize = self.button.update(
|
||||
transition: transition,
|
||||
|
|
@ -426,7 +449,7 @@ final class AuthorizationSequencePaymentScreenComponent: Component {
|
|||
component: AnyComponent(
|
||||
VStack([
|
||||
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(text: .plain(buttonAttributedString)))),
|
||||
AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: environment.strings.Login_Fee_GetPremiumForAWeek, font: Font.medium(11.0), textColor: environment.theme.list.itemCheckColors.foregroundColor.withAlphaComponent(0.7), paragraphAlignment: .center)))))
|
||||
AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: buttonSubtitle, font: Font.medium(11.0), textColor: environment.theme.list.itemCheckColors.foregroundColor.withAlphaComponent(0.7), paragraphAlignment: .center)))))
|
||||
], spacing: 1.0)
|
||||
)
|
||||
),
|
||||
|
|
@ -468,6 +491,7 @@ public final class AuthorizationSequencePaymentScreen: ViewControllerComponentCo
|
|||
phoneNumber: String,
|
||||
phoneCodeHash: String,
|
||||
storeProduct: String,
|
||||
premiumDays: Int32,
|
||||
supportEmailAddress: String,
|
||||
supportEmailSubject: String,
|
||||
back: @escaping () -> Void
|
||||
|
|
@ -480,6 +504,7 @@ public final class AuthorizationSequencePaymentScreen: ViewControllerComponentCo
|
|||
phoneNumber: phoneNumber,
|
||||
phoneCodeHash: phoneCodeHash,
|
||||
storeProduct: storeProduct,
|
||||
premiumDays: premiumDays,
|
||||
supportEmailAddress: supportEmailAddress,
|
||||
supportEmailSubject: supportEmailSubject
|
||||
), navigationBarAppearance: .transparent, theme: .default, updatedPresentationData: (initial: presentationData, signal: .single(presentationData)))
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import Display
|
|||
import AsyncDisplayKit
|
||||
import SwiftSignalKit
|
||||
import TelegramCore
|
||||
import Postbox
|
||||
import TelegramPresentationData
|
||||
import PresentationDataUtils
|
||||
import ProgressNavigationButtonNode
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import PhoneInputNode
|
|||
import CountrySelectionUI
|
||||
import QrCode
|
||||
import SwiftSignalKit
|
||||
import Postbox
|
||||
import AccountContext
|
||||
import AnimatedStickerNode
|
||||
import TelegramAnimatedStickerNode
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import Foundation
|
|||
import UIKit
|
||||
import Display
|
||||
import AsyncDisplayKit
|
||||
import Postbox
|
||||
import TelegramCore
|
||||
import SSignalKit
|
||||
import SwiftSignalKit
|
||||
|
|
|
|||
|
|
@ -643,7 +643,7 @@ public final class AvatarNode: ASDisplayNode {
|
|||
|
||||
let parameters: AvatarNodeParameters
|
||||
|
||||
if let peer = peer, let signal = peerAvatarImage(postbox: postbox, network: network, peerReference: PeerReference(peer._asPeer()), authorOfMessage: authorOfMessage, representation: representation, displayDimensions: displayDimensions, clipStyle: clipStyle, emptyColor: emptyColor, synchronousLoad: synchronousLoad, provideUnrounded: storeUnrounded, cutoutRect: cutoutRect) {
|
||||
if let peer = peer, let signal = peerAvatarImage(postbox: postbox, network: network, peerReference: PeerReference(peer), authorOfMessage: authorOfMessage, representation: representation, displayDimensions: displayDimensions, clipStyle: clipStyle, emptyColor: emptyColor, synchronousLoad: synchronousLoad, provideUnrounded: storeUnrounded, cutoutRect: cutoutRect) {
|
||||
self.contents = nil
|
||||
self.displaySuspended = true
|
||||
self.imageReady.set(self.imageNode.contentReady)
|
||||
|
|
@ -763,7 +763,7 @@ public final class AvatarNode: ASDisplayNode {
|
|||
self.imageNode.view.mask = nil
|
||||
}
|
||||
|
||||
if let imageCache = genericContext.imageCache as? DirectMediaImageCache, let peer, let smallProfileImage = peer.smallProfileImage, let peerReference = PeerReference(peer._asPeer()) {
|
||||
if let imageCache = genericContext.imageCache as? DirectMediaImageCache, let peer, let smallProfileImage = peer.smallProfileImage, let peerReference = PeerReference(peer) {
|
||||
if let result = imageCache.getAvatarImage(peer: peerReference, resource: MediaResourceReference.avatar(peer: peerReference, resource: smallProfileImage.resource), immediateThumbnail: peer.profileImageRepresentations.first?.immediateThumbnailData, size: Int(displayDimensions.width * UIScreenScale), synchronous: synchronousLoad) {
|
||||
if let image = result.image {
|
||||
self.imageNode.contents = image.cgImage
|
||||
|
|
@ -852,7 +852,7 @@ public final class AvatarNode: ASDisplayNode {
|
|||
|
||||
let account = account ?? genericContext.account
|
||||
|
||||
if let peer = peer, let signal = peerAvatarImage(account: account, peerReference: PeerReference(peer._asPeer()), authorOfMessage: authorOfMessage, representation: representation, displayDimensions: displayDimensions, clipStyle: clipStyle, emptyColor: emptyColor, synchronousLoad: synchronousLoad, provideUnrounded: storeUnrounded, cutoutRect: cutoutRect) {
|
||||
if let peer = peer, let signal = peerAvatarImage(account: account, peerReference: PeerReference(peer), authorOfMessage: authorOfMessage, representation: representation, displayDimensions: displayDimensions, clipStyle: clipStyle, emptyColor: emptyColor, synchronousLoad: synchronousLoad, provideUnrounded: storeUnrounded, cutoutRect: cutoutRect) {
|
||||
self.contents = nil
|
||||
self.displaySuspended = true
|
||||
self.imageReady.set(self.imageNode.contentReady)
|
||||
|
|
|
|||
|
|
@ -126,8 +126,8 @@ public func peerAvatarCompleteImage(postbox: Postbox, network: Network, peer: En
|
|||
thumbnailRepresentation = peer.profileImageRepresentations.first
|
||||
}
|
||||
|
||||
if let signal = peerAvatarImage(postbox: postbox, network: network, peerReference: PeerReference(peer._asPeer()), authorOfMessage: nil, representation: thumbnailRepresentation, displayDimensions: size, clipStyle: clipStyle, blurred: blurred, inset: 0.0, emptyColor: nil, synchronousLoad: fullSize) {
|
||||
if fullSize, let fullSizeSignal = peerAvatarImage(postbox: postbox, network: network, peerReference: PeerReference(peer._asPeer()), authorOfMessage: nil, representation: peer.profileImageRepresentations.last, displayDimensions: size, emptyColor: nil, synchronousLoad: true) {
|
||||
if let signal = peerAvatarImage(postbox: postbox, network: network, peerReference: PeerReference(peer), authorOfMessage: nil, representation: thumbnailRepresentation, displayDimensions: size, clipStyle: clipStyle, blurred: blurred, inset: 0.0, emptyColor: nil, synchronousLoad: fullSize) {
|
||||
if fullSize, let fullSizeSignal = peerAvatarImage(postbox: postbox, network: network, peerReference: PeerReference(peer), authorOfMessage: nil, representation: peer.profileImageRepresentations.last, displayDimensions: size, emptyColor: nil, synchronousLoad: true) {
|
||||
iconSignal = combineLatest(.single(nil) |> then(signal), .single(nil) |> then(fullSizeSignal))
|
||||
|> mapToSignal { thumbnailImage, fullSizeImage -> Signal<UIImage?, NoError> in
|
||||
if let fullSizeImage = fullSizeImage {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ swift_library(
|
|||
deps = [
|
||||
"//submodules/AsyncDisplayKit:AsyncDisplayKit",
|
||||
"//submodules/Display:Display",
|
||||
"//submodules/Postbox:Postbox",
|
||||
"//submodules/TelegramCore:TelegramCore",
|
||||
"//submodules/TelegramPresentationData:TelegramPresentationData",
|
||||
"//submodules/AnimationUI:AnimationUI",
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ public final class AvatarVideoNode: ASDisplayNode {
|
|||
self.internalSize = size
|
||||
if let markup = photo.emojiMarkup {
|
||||
self.update(markup: markup, size: size, useAnimationNode: false)
|
||||
} else if let video = smallestVideoRepresentation(photo.videoRepresentations), let peerReference = PeerReference(peer._asPeer()) {
|
||||
} else if let video = smallestVideoRepresentation(photo.videoRepresentations), let peerReference = PeerReference(peer) {
|
||||
self.backgroundNode.image = nil
|
||||
|
||||
let videoId = photo.id?.id ?? peer.id.id._internalGetInt64Value()
|
||||
|
|
@ -217,7 +217,7 @@ public final class AvatarVideoNode: ASDisplayNode {
|
|||
self.videoContent = videoContent
|
||||
|
||||
self.videoFileDisposable?.dispose()
|
||||
self.videoFileDisposable = fetchedMediaResource(mediaBox: self.context.account.postbox.mediaBox, userLocation: .peer(peer.id), userContentType: .avatar, reference: videoFileReference.resourceReference(videoFileReference.media.resource)).startStrict()
|
||||
self.videoFileDisposable = self.context.engine.resources.fetch(reference: videoFileReference.resourceReference(videoFileReference.media.resource), userLocation: .peer(peer.id), userContentType: .avatar).startStrict()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -229,7 +229,7 @@ public final class AvatarVideoNode: ASDisplayNode {
|
|||
if isVisible, let animationNode = self.animationNode, let file = self.animationFile {
|
||||
if !self.didSetupAnimation {
|
||||
self.didSetupAnimation = true
|
||||
let pathPrefix = self.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(file.resource.id)
|
||||
let pathPrefix = self.context.engine.resources.shortLivedResourceCachePathPrefix(id: EngineMediaResource.Id(file.resource.id))
|
||||
let dimensions = file.dimensions ?? PixelDimensions(width: 512, height: 512)
|
||||
let fittedDimensions = dimensions.cgSize.aspectFitted(CGSize(width: 384.0, height: 384.0))
|
||||
let source = AnimatedStickerResourceSource(account: self.context.account, resource: file.resource, isVideo: file.isVideoSticker || file.mimeType == "video/webm")
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ swift_library(
|
|||
"//submodules/TelegramUI/Components/MinimizedContainer",
|
||||
"//submodules/Pasteboard",
|
||||
"//submodules/SaveToCameraRoll",
|
||||
"//submodules/TextFormat:TextFormat",
|
||||
"//submodules/TelegramUI/Components/NavigationStackComponent",
|
||||
"//submodules/LocationUI",
|
||||
"//submodules/OpenInExternalAppUI",
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ public final class BrowserBookmarksScreen: ViewController {
|
|||
}, sendGift: { _ in
|
||||
}, openUniqueGift: { _ in
|
||||
}, openMessageFeeException: {
|
||||
}, requestMessageUpdate: { _, _ in
|
||||
}, requestMessageUpdate: { _, _, _ in
|
||||
}, cancelInteractiveKeyboardGestures: {
|
||||
}, dismissTextInput: {
|
||||
}, scrollToMessageId: { _ in
|
||||
|
|
@ -189,7 +189,10 @@ public final class BrowserBookmarksScreen: ViewController {
|
|||
}, requestToggleTodoMessageItem: { _, _, _ in
|
||||
}, displayTodoToggleUnavailable: { _ in
|
||||
}, openStarsPurchase: { _ in
|
||||
}, openRankInfo: { _, _, _ in }, openSetPeerAvatar: {}, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil))
|
||||
}, openRankInfo: { _, _, _ in
|
||||
}, openSetPeerAvatar: {
|
||||
}, displayPollRestrictedToast: { _ in
|
||||
}, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil))
|
||||
|
||||
|
||||
let tagMask: MessageTags = .webPage
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import AccountContext
|
|||
import AppBundle
|
||||
import PromptUI
|
||||
import SafariServices
|
||||
import ShareController
|
||||
import UndoUI
|
||||
import UrlEscaping
|
||||
|
||||
|
|
@ -63,7 +62,7 @@ final class BrowserDocumentContent: UIView, BrowserContent, WKNavigationDelegate
|
|||
|
||||
var title: String = "file"
|
||||
var url = ""
|
||||
if let path = self.context.account.postbox.mediaBox.completedResourcePath(file.media.resource) {
|
||||
if let path = self.context.engine.resources.completedResourcePath(id: EngineMediaResource.Id(file.media.resource.id)) {
|
||||
var updatedPath = path
|
||||
if let fileName = file.media.fileName {
|
||||
let tempFile = TempBox.shared.file(path: path, fileName: fileName)
|
||||
|
|
@ -410,10 +409,9 @@ final class BrowserDocumentContent: UIView, BrowserContent, WKNavigationDelegate
|
|||
|
||||
private func share(url: String) {
|
||||
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
|
||||
let shareController = ShareController(context: self.context, subject: .url(url))
|
||||
shareController.actionCompleted = { [weak self] in
|
||||
let shareController = self.context.sharedContext.makeShareController(context: self.context, params: ShareControllerParams(subject: .url(url), actionCompleted: { [weak self] in
|
||||
self?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), nil)
|
||||
}
|
||||
}))
|
||||
self.present(shareController, nil)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import UIKit
|
|||
import SwiftSignalKit
|
||||
import AsyncDisplayKit
|
||||
import Display
|
||||
import Postbox
|
||||
import TelegramCore
|
||||
import TelegramPresentationData
|
||||
import TelegramUIPreferences
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ import TranslateUI
|
|||
import ContextUI
|
||||
import Pasteboard
|
||||
import SaveToCameraRoll
|
||||
import ShareController
|
||||
import SafariServices
|
||||
import LocationUI
|
||||
import OpenInExternalAppUI
|
||||
import GalleryUI
|
||||
import TextFormat
|
||||
|
||||
final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDelegate {
|
||||
private let context: AccountContext
|
||||
|
|
@ -66,6 +66,10 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
var currentWebEmbedHeights: [Int : CGFloat] = [:]
|
||||
var currentExpandedDetails: [Int : Bool]?
|
||||
var currentDetailsItems: [InstantPageDetailsItem] = []
|
||||
private var resolvedExternalMediaDimensions: [MediaId: PixelDimensions] = [:]
|
||||
private var pendingResolvedExternalMediaDimensions = Set<MediaId>()
|
||||
private var codeHighlight: CachedMessageSyntaxHighlight?
|
||||
private var codeHighlightState: (specs: [CachedMessageSyntaxHighlight.Spec], disposable: Disposable)?
|
||||
|
||||
var currentAccessibilityAreas: [AccessibilityAreaNode] = []
|
||||
|
||||
|
|
@ -88,6 +92,8 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
private let loadWebpageDisposable = MetaDisposable()
|
||||
private let resolveUrlDisposable = MetaDisposable()
|
||||
private let updateLayoutDisposable = MetaDisposable()
|
||||
private let updateExternalMediaDimensionsDisposable = MetaDisposable()
|
||||
private let updateCodeHighlightDisposable = MetaDisposable()
|
||||
|
||||
private let loadProgress = ValuePromise<CGFloat>(1.0, ignoreRepeated: true)
|
||||
private let readingProgress = ValuePromise<CGFloat>(0.0, ignoreRepeated: true)
|
||||
|
|
@ -180,12 +186,16 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
}
|
||||
self.scrollNode.view.addGestureRecognizer(recognizer)
|
||||
|
||||
self.webpageDisposable = (actualizedWebpage(account: context.account, webpage: webPage) |> deliverOnMainQueue).start(next: { [weak self] result in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.updateWebPage(result, anchor: self.initialAnchor)
|
||||
})
|
||||
if case let .Loaded(content) = webPage.content, let scheme = URL(string: content.url)?.scheme?.lowercased(), scheme == "http" || scheme == "https" {
|
||||
self.webpageDisposable = (actualizedWebpage(account: context.account, webpage: webPage) |> deliverOnMainQueue).start(next: { [weak self] result in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.updateWebPage(result, anchor: self.initialAnchor)
|
||||
})
|
||||
}
|
||||
|
||||
self.updateCodeHighlight()
|
||||
}
|
||||
|
||||
deinit {
|
||||
|
|
@ -194,6 +204,8 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
self.loadWebpageDisposable.dispose()
|
||||
self.resolveUrlDisposable.dispose()
|
||||
self.updateLayoutDisposable.dispose()
|
||||
self.updateExternalMediaDimensionsDisposable.dispose()
|
||||
self.updateCodeHighlightDisposable.dispose()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
|
@ -305,6 +317,8 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
} else {
|
||||
self.webPage = nil
|
||||
}
|
||||
self.resolvedExternalMediaDimensions.removeAll()
|
||||
self.pendingResolvedExternalMediaDimensions.removeAll()
|
||||
if let anchor = anchor {
|
||||
self.initialAnchor = anchor.removingPercentEncoding
|
||||
} else if let state = state {
|
||||
|
|
@ -318,6 +332,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
}
|
||||
}
|
||||
self.currentLayout = nil
|
||||
self.updateCodeHighlight()
|
||||
self.updatePageLayout()
|
||||
|
||||
self.scrollNode.frame = CGRect(x: 0.0, y: 0.0, width: 1.0, height: 1.0)
|
||||
|
|
@ -479,16 +494,11 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
}
|
||||
|
||||
private func updatePageLayout() {
|
||||
guard let (size, insets, _) = self.containerLayout, let (webPage, instantPage) = self.webPage else {
|
||||
guard let (size, insets, _) = self.containerLayout, let (webPage, instantPage) = self.resolvedWebPage() else {
|
||||
return
|
||||
}
|
||||
|
||||
let currentLayout = instantPageLayoutForWebPage(webPage, instantPage: instantPage, userLocation: self.sourceLocation.userLocation, boundingWidth: size.width, safeInset: insets.left, strings: self.presentationData.strings, theme: self.theme, dateTimeFormat: self.presentationData.dateTimeFormat, webEmbedHeights: self.currentWebEmbedHeights)
|
||||
|
||||
for (_, tileNode) in self.visibleTiles {
|
||||
tileNode.removeFromSupernode()
|
||||
}
|
||||
self.visibleTiles.removeAll()
|
||||
let currentLayout = instantPageLayoutForWebPage(webPage, instantPage: instantPage, userLocation: self.sourceLocation.userLocation, boundingWidth: size.width, safeInset: insets.left, strings: self.presentationData.strings, theme: self.theme, dateTimeFormat: self.presentationData.dateTimeFormat, webEmbedHeights: self.currentWebEmbedHeights, cachedMessageSyntaxHighlight: self.codeHighlight)
|
||||
|
||||
let currentLayoutTiles = instantPageTilesFromLayout(currentLayout, boundingWidth: size.width)
|
||||
|
||||
|
|
@ -549,6 +559,96 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
self.scrollNode.view.contentSize = currentLayout.contentSize
|
||||
self.scrollNodeFooter.frame = CGRect(origin: CGPoint(x: 0.0, y: currentLayout.contentSize.height), size: CGSize(width: size.width, height: 2000.0))
|
||||
}
|
||||
|
||||
private func updateCodeHighlight() {
|
||||
guard let instantPage = self.webPage?.instantPage else {
|
||||
self.codeHighlight = nil
|
||||
self.codeHighlightState = nil
|
||||
self.updateCodeHighlightDisposable.set(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let specs = syntaxHighlightSpecs(for: instantPage.blocks)
|
||||
if let currentState = self.codeHighlightState, currentState.specs == specs {
|
||||
return
|
||||
}
|
||||
|
||||
if specs.isEmpty {
|
||||
let hadHighlight = self.codeHighlight != nil
|
||||
self.codeHighlight = nil
|
||||
self.codeHighlightState = nil
|
||||
self.updateCodeHighlightDisposable.set(nil)
|
||||
if hadHighlight {
|
||||
self.updatePageLayout()
|
||||
self.updateVisibleItems(visibleBounds: self.scrollNode.view.bounds)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let disposable = MetaDisposable()
|
||||
self.codeHighlightState = (specs, disposable)
|
||||
self.updateCodeHighlightDisposable.set(disposable)
|
||||
disposable.set((asyncStanaloneSyntaxHighlight(current: self.codeHighlight, specs: specs)
|
||||
|> deliverOnMainQueue).start(next: { [weak self] result in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
if self.codeHighlight != result {
|
||||
self.codeHighlight = result
|
||||
self.updatePageLayout()
|
||||
self.updateVisibleItems(visibleBounds: self.scrollNode.view.bounds)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
private func syntaxHighlightSpecs(for blocks: [InstantPageBlock]) -> [CachedMessageSyntaxHighlight.Spec] {
|
||||
var specs: [CachedMessageSyntaxHighlight.Spec] = []
|
||||
var seen = Set<CachedMessageSyntaxHighlight.Spec>()
|
||||
|
||||
func collect(blocks: [InstantPageBlock]) {
|
||||
for block in blocks {
|
||||
switch block {
|
||||
case let .preformatted(text, language):
|
||||
guard let language = normalizedCodeBlockLanguage(language), !text.plainText.isEmpty else {
|
||||
continue
|
||||
}
|
||||
let spec = CachedMessageSyntaxHighlight.Spec(language: language, text: text.plainText)
|
||||
if seen.insert(spec).inserted {
|
||||
specs.append(spec)
|
||||
}
|
||||
case let .cover(block):
|
||||
collect(blocks: [block])
|
||||
case let .postEmbed(_, _, _, _, _, blocks, _):
|
||||
collect(blocks: blocks)
|
||||
case let .collage(items, _):
|
||||
collect(blocks: items)
|
||||
case let .slideshow(items, _):
|
||||
collect(blocks: items)
|
||||
case let .details(_, blocks, _):
|
||||
collect(blocks: blocks)
|
||||
case let .list(items, _):
|
||||
for item in items {
|
||||
if case let .blocks(blocks, _) = item {
|
||||
collect(blocks: blocks)
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect(blocks: blocks)
|
||||
return specs
|
||||
}
|
||||
|
||||
private func normalizedCodeBlockLanguage(_ language: String?) -> String? {
|
||||
guard let language else {
|
||||
return nil
|
||||
}
|
||||
let normalized = language.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return normalized.isEmpty ? nil : normalized
|
||||
}
|
||||
|
||||
func updateVisibleItems(visibleBounds: CGRect, animated: Bool = false) {
|
||||
var visibleTileIndices = Set<Int>()
|
||||
|
|
@ -656,6 +756,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
topNode = newNode
|
||||
self.visibleItemsWithNodes[itemIndex] = newNode
|
||||
itemNode = newNode
|
||||
self.configureExternalMediaDimensionsUpdates(for: newNode)
|
||||
|
||||
if let itemNode = itemNode as? InstantPageDetailsNode {
|
||||
itemNode.requestLayoutUpdate = { [weak self] animated in
|
||||
|
|
@ -679,6 +780,10 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
}
|
||||
}
|
||||
|
||||
if let itemNode = itemNode {
|
||||
self.configureExternalMediaDimensionsUpdates(for: itemNode)
|
||||
}
|
||||
|
||||
if let itemNode = itemNode as? InstantPageDetailsNode {
|
||||
itemNode.updateVisibleItems(visibleBounds: visibleBounds.offsetBy(dx: -itemNode.frame.minX, dy: -itemNode.frame.minY), animated: animated)
|
||||
}
|
||||
|
|
@ -709,8 +814,11 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
topNode = tileNode
|
||||
self.visibleTiles[tileIndex] = tileNode
|
||||
} else {
|
||||
if visibleTiles[tileIndex]!.frame != tileFrame {
|
||||
transition.updateFrame(node: self.visibleTiles[tileIndex]!, frame: tileFrame)
|
||||
if let tileNode = self.visibleTiles[tileIndex] {
|
||||
tileNode.update(tile: tile, backgroundColor: theme.pageBackgroundColor)
|
||||
if tileNode.frame != tileFrame {
|
||||
transition.updateFrame(node: tileNode, frame: tileFrame)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -930,6 +1038,385 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
return nil
|
||||
}
|
||||
|
||||
private func configureExternalMediaDimensionsUpdates(for itemNode: InstantPageNode) {
|
||||
let update: (MediaId, PixelDimensions) -> Void = { [weak self] mediaId, dimensions in
|
||||
self?.updateExternalMediaDimensions(mediaId, dimensions)
|
||||
}
|
||||
if let itemNode = itemNode as? InstantPageExternalMediaDimensionsNode {
|
||||
itemNode.updateExternalMediaDimensions = update
|
||||
}
|
||||
if let itemNode = itemNode as? InstantPageDetailsNode {
|
||||
itemNode.contentNode.updateExternalMediaDimensions = update
|
||||
}
|
||||
}
|
||||
|
||||
private func updateExternalMediaDimensions(_ mediaId: MediaId, _ dimensions: PixelDimensions) {
|
||||
if self.resolvedExternalMediaDimensions[mediaId] == dimensions {
|
||||
return
|
||||
}
|
||||
self.resolvedExternalMediaDimensions[mediaId] = dimensions
|
||||
self.pendingResolvedExternalMediaDimensions.insert(mediaId)
|
||||
|
||||
let signal: Signal<Void, NoError> = (.complete() |> delay(0.08, queue: Queue.mainQueue()))
|
||||
self.updateExternalMediaDimensionsDisposable.set(signal.start(completed: { [weak self] in
|
||||
self?.relayoutForResolvedExternalMediaDimensions()
|
||||
}))
|
||||
}
|
||||
|
||||
private func relayoutForResolvedExternalMediaDimensions() {
|
||||
guard !self.pendingResolvedExternalMediaDimensions.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let mediaIds = Array(self.pendingResolvedExternalMediaDimensions)
|
||||
self.pendingResolvedExternalMediaDimensions.removeAll()
|
||||
|
||||
let detailsStateMaps = self.captureExpandedDetailsStateMaps()
|
||||
let viewportTop = self.scrollNode.view.contentOffset.y + self.scrollNode.view.contentInset.top
|
||||
var oldFrames: [MediaId: CGRect] = [:]
|
||||
for mediaId in mediaIds {
|
||||
if let frame = self.effectiveFrameForMedia(mediaId, detailsStateMaps: detailsStateMaps) {
|
||||
oldFrames[mediaId] = frame
|
||||
}
|
||||
}
|
||||
|
||||
self.updatePageLayout()
|
||||
|
||||
var newFrames: [MediaId: CGRect] = [:]
|
||||
for mediaId in mediaIds {
|
||||
if let frame = self.effectiveFrameForMedia(mediaId, detailsStateMaps: detailsStateMaps) {
|
||||
newFrames[mediaId] = frame
|
||||
}
|
||||
}
|
||||
|
||||
if let compensatedViewportTop = self.compensatedViewportTop(oldFrames: oldFrames, newFrames: newFrames, viewportTop: viewportTop) {
|
||||
self.setViewportTop(compensatedViewportTop)
|
||||
}
|
||||
self.updateVisibleItems(visibleBounds: self.scrollNode.view.bounds)
|
||||
}
|
||||
|
||||
private func setViewportTop(_ viewportTop: CGFloat) {
|
||||
let scrollView = self.scrollNode.view
|
||||
let minOffsetY = -scrollView.contentInset.top
|
||||
let maxOffsetY = max(minOffsetY, scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom)
|
||||
let contentOffsetY = min(max(viewportTop - scrollView.contentInset.top, minOffsetY), maxOffsetY)
|
||||
if contentOffsetY.isFinite {
|
||||
scrollView.contentOffset = CGPoint(x: scrollView.contentOffset.x, y: contentOffsetY)
|
||||
}
|
||||
}
|
||||
|
||||
private func compensatedViewportTop(oldFrames: [MediaId: CGRect], newFrames: [MediaId: CGRect], viewportTop: CGFloat) -> CGFloat? {
|
||||
var pairedFrames: [(old: CGRect, new: CGRect)] = []
|
||||
for (mediaId, oldFrame) in oldFrames {
|
||||
if let newFrame = newFrames[mediaId] {
|
||||
pairedFrames.append((oldFrame, newFrame))
|
||||
}
|
||||
}
|
||||
if pairedFrames.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let intersecting = pairedFrames
|
||||
.filter({ $0.old.height > 0.0 && $0.new.height > 0.0 && viewportTop > $0.old.minY && viewportTop < $0.old.maxY })
|
||||
.max(by: { $0.old.minY < $1.old.minY }) {
|
||||
let ratio = min(max((viewportTop - intersecting.old.minY) / intersecting.old.height, 0.0), 1.0)
|
||||
return intersecting.new.minY + ratio * intersecting.new.height
|
||||
}
|
||||
|
||||
if let above = pairedFrames
|
||||
.filter({ viewportTop >= $0.old.maxY })
|
||||
.max(by: { $0.old.maxY < $1.old.maxY }) {
|
||||
return viewportTop + (above.new.maxY - above.old.maxY)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func captureExpandedDetailsStateMaps() -> [String: [Int: Bool]] {
|
||||
guard let currentLayout = self.currentLayout else {
|
||||
return [:]
|
||||
}
|
||||
var result: [String: [Int: Bool]] = [:]
|
||||
self.captureExpandedDetailsStateMaps(items: currentLayout.items, visibleItemsWithNodes: self.visibleItemsWithNodes, path: [], result: &result)
|
||||
return result
|
||||
}
|
||||
|
||||
private func captureExpandedDetailsStateMaps(items: [InstantPageItem], visibleItemsWithNodes: [Int: InstantPageNode], path: [Int], result: inout [String: [Int: Bool]]) {
|
||||
let detailsNodes = visibleItemsWithNodes.compactMap { $0.value as? InstantPageDetailsNode }
|
||||
|
||||
var detailsIndex = -1
|
||||
for item in items {
|
||||
guard let detailsItem = item as? InstantPageDetailsItem else {
|
||||
continue
|
||||
}
|
||||
detailsIndex += 1
|
||||
|
||||
guard let detailsNode = detailsNodes.first(where: { $0.item === detailsItem }) else {
|
||||
continue
|
||||
}
|
||||
let nextPath = path + [detailsIndex]
|
||||
result[self.detailsStateKey(nextPath)] = detailsNode.contentNode.currentExpandedDetails ?? [:]
|
||||
self.captureExpandedDetailsStateMaps(items: detailsItem.items, visibleItemsWithNodes: detailsNode.contentNode.visibleItemsWithNodes, path: nextPath, result: &result)
|
||||
}
|
||||
}
|
||||
|
||||
private func detailsStateKey(_ path: [Int]) -> String {
|
||||
if path.isEmpty {
|
||||
return ""
|
||||
}
|
||||
return path.map(String.init).joined(separator: ".")
|
||||
}
|
||||
|
||||
private func effectiveFrameForMedia(_ mediaId: MediaId, detailsStateMaps: [String: [Int: Bool]]) -> CGRect? {
|
||||
guard let currentLayout = self.currentLayout else {
|
||||
return nil
|
||||
}
|
||||
return self.effectiveFrameForMedia(mediaId, items: currentLayout.items, origin: .zero, expandedDetails: self.currentExpandedDetails, path: [], detailsStateMaps: detailsStateMaps)
|
||||
}
|
||||
|
||||
private func effectiveFrameForMedia(_ mediaId: MediaId, items: [InstantPageItem], origin: CGPoint, expandedDetails: [Int: Bool]?, path: [Int], detailsStateMaps: [String: [Int: Bool]]) -> CGRect? {
|
||||
var collapseOffset: CGFloat = 0.0
|
||||
var detailsIndex = -1
|
||||
|
||||
for item in items {
|
||||
if item is InstantPageDetailsItem {
|
||||
detailsIndex += 1
|
||||
}
|
||||
|
||||
var itemFrame = item.frame.offsetBy(dx: origin.x, dy: origin.y - collapseOffset)
|
||||
if let detailsItem = item as? InstantPageDetailsItem {
|
||||
let nextPath = path + [detailsIndex]
|
||||
let nestedExpandedDetails = detailsStateMaps[self.detailsStateKey(nextPath)]
|
||||
let expanded = expandedDetails?[detailsIndex] ?? detailsItem.initiallyExpanded
|
||||
let height = expanded ? detailsItem.titleHeight + self.effectiveContentHeight(items: detailsItem.items, baseHeight: detailsItem.frame.height - detailsItem.titleHeight, expandedDetails: nestedExpandedDetails, path: nextPath, detailsStateMaps: detailsStateMaps) : detailsItem.titleHeight
|
||||
collapseOffset += item.frame.height - height
|
||||
itemFrame.size.height = height
|
||||
|
||||
if expanded, let nestedFrame = self.effectiveFrameForMedia(mediaId, items: detailsItem.items, origin: CGPoint(x: itemFrame.minX, y: itemFrame.minY + detailsItem.titleHeight), expandedDetails: nestedExpandedDetails, path: nextPath, detailsStateMaps: detailsStateMaps) {
|
||||
return nestedFrame
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if self.itemContainsMedia(item, mediaId: mediaId) {
|
||||
return itemFrame
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func effectiveContentHeight(items: [InstantPageItem], baseHeight: CGFloat, expandedDetails: [Int: Bool]?, path: [Int], detailsStateMaps: [String: [Int: Bool]]) -> CGFloat {
|
||||
var contentHeight = baseHeight
|
||||
var detailsIndex = -1
|
||||
|
||||
for item in items {
|
||||
guard let detailsItem = item as? InstantPageDetailsItem else {
|
||||
continue
|
||||
}
|
||||
detailsIndex += 1
|
||||
|
||||
let nextPath = path + [detailsIndex]
|
||||
let nestedExpandedDetails = detailsStateMaps[self.detailsStateKey(nextPath)]
|
||||
let expanded = expandedDetails?[detailsIndex] ?? detailsItem.initiallyExpanded
|
||||
let height = expanded ? detailsItem.titleHeight + self.effectiveContentHeight(items: detailsItem.items, baseHeight: detailsItem.frame.height - detailsItem.titleHeight, expandedDetails: nestedExpandedDetails, path: nextPath, detailsStateMaps: detailsStateMaps) : detailsItem.titleHeight
|
||||
contentHeight += -detailsItem.frame.height + height
|
||||
}
|
||||
|
||||
return contentHeight
|
||||
}
|
||||
|
||||
private func itemContainsMedia(_ item: InstantPageItem, mediaId: MediaId) -> Bool {
|
||||
for media in item.medias {
|
||||
if media.media.id == mediaId {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func resolvedWebPage() -> (webPage: TelegramMediaWebpage, instantPage: InstantPage?)? {
|
||||
guard let (webPage, instantPage) = self.webPage else {
|
||||
return nil
|
||||
}
|
||||
guard !self.resolvedExternalMediaDimensions.isEmpty, case let .Loaded(content) = webPage.content else {
|
||||
return (webPage, instantPage)
|
||||
}
|
||||
|
||||
var instantPageUpdated = false
|
||||
var effectiveInstantPage = instantPage
|
||||
if let instantPage {
|
||||
var media = instantPage.media
|
||||
for (mediaId, currentMedia) in instantPage.media {
|
||||
if let updatedMedia = self.updatedMediaIfNeeded(currentMedia) {
|
||||
media[mediaId] = updatedMedia
|
||||
instantPageUpdated = true
|
||||
}
|
||||
}
|
||||
if instantPageUpdated {
|
||||
effectiveInstantPage = InstantPage(blocks: instantPage.blocks, media: media, isComplete: instantPage.isComplete, rtl: instantPage.rtl, url: instantPage.url, views: instantPage.views)
|
||||
}
|
||||
}
|
||||
|
||||
var imageUpdated = false
|
||||
let effectiveImage = content.image.map { image -> TelegramMediaImage in
|
||||
if let updated = self.updatedImageIfNeeded(image) {
|
||||
imageUpdated = true
|
||||
return updated
|
||||
} else {
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
var fileUpdated = false
|
||||
let effectiveFile = content.file.map { file -> TelegramMediaFile in
|
||||
if let updated = self.updatedFileIfNeeded(file) {
|
||||
fileUpdated = true
|
||||
return updated
|
||||
} else {
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
if !instantPageUpdated && !imageUpdated && !fileUpdated {
|
||||
return (webPage, instantPage)
|
||||
}
|
||||
|
||||
let effectiveContent = TelegramMediaWebpageLoadedContent(
|
||||
url: content.url,
|
||||
displayUrl: content.displayUrl,
|
||||
hash: content.hash,
|
||||
type: content.type,
|
||||
websiteName: content.websiteName,
|
||||
title: content.title,
|
||||
text: content.text,
|
||||
embedUrl: content.embedUrl,
|
||||
embedType: content.embedType,
|
||||
embedSize: content.embedSize,
|
||||
duration: content.duration,
|
||||
author: content.author,
|
||||
isMediaLargeByDefault: content.isMediaLargeByDefault,
|
||||
imageIsVideoCover: content.imageIsVideoCover,
|
||||
image: effectiveImage,
|
||||
file: effectiveFile,
|
||||
story: content.story,
|
||||
attributes: content.attributes,
|
||||
instantPage: effectiveInstantPage
|
||||
)
|
||||
return (TelegramMediaWebpage(webpageId: webPage.webpageId, content: .Loaded(effectiveContent)), effectiveInstantPage)
|
||||
}
|
||||
|
||||
private func updatedMediaIfNeeded(_ media: Media) -> Media? {
|
||||
if let image = media as? TelegramMediaImage {
|
||||
return self.updatedImageIfNeeded(image)
|
||||
} else if let file = media as? TelegramMediaFile {
|
||||
return self.updatedFileIfNeeded(file)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func updatedImageIfNeeded(_ image: TelegramMediaImage) -> TelegramMediaImage? {
|
||||
guard let dimensions = self.resolvedExternalMediaDimensions[image.imageId] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var updatedRepresentations = image.representations
|
||||
var didUpdate = false
|
||||
for i in 0 ..< updatedRepresentations.count {
|
||||
let representation = updatedRepresentations[i]
|
||||
guard representation.resource is InstantPageExternalMediaResource, representation.dimensions != dimensions else {
|
||||
continue
|
||||
}
|
||||
updatedRepresentations[i] = TelegramMediaImageRepresentation(
|
||||
dimensions: dimensions,
|
||||
resource: representation.resource,
|
||||
progressiveSizes: representation.progressiveSizes,
|
||||
immediateThumbnailData: representation.immediateThumbnailData,
|
||||
hasVideo: representation.hasVideo,
|
||||
isPersonal: representation.isPersonal,
|
||||
typeHint: representation.typeHint
|
||||
)
|
||||
didUpdate = true
|
||||
}
|
||||
|
||||
guard didUpdate else {
|
||||
return nil
|
||||
}
|
||||
return TelegramMediaImage(
|
||||
imageId: image.imageId,
|
||||
representations: updatedRepresentations,
|
||||
videoRepresentations: image.videoRepresentations,
|
||||
immediateThumbnailData: image.immediateThumbnailData,
|
||||
emojiMarkup: image.emojiMarkup,
|
||||
reference: image.reference,
|
||||
partialReference: image.partialReference,
|
||||
flags: image.flags,
|
||||
video: image.video
|
||||
)
|
||||
}
|
||||
|
||||
private func updatedFileIfNeeded(_ file: TelegramMediaFile) -> TelegramMediaFile? {
|
||||
guard let dimensions = self.resolvedExternalMediaDimensions[file.fileId], file.resource is InstantPageExternalMediaResource else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let (attributes, didUpdate) = self.fileAttributesWithResolvedDimensions(file.attributes, dimensions: dimensions)
|
||||
guard didUpdate else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return TelegramMediaFile(
|
||||
fileId: file.fileId,
|
||||
partialReference: file.partialReference,
|
||||
resource: file.resource,
|
||||
previewRepresentations: file.previewRepresentations,
|
||||
videoThumbnails: file.videoThumbnails,
|
||||
videoCover: file.videoCover,
|
||||
immediateThumbnailData: file.immediateThumbnailData,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size,
|
||||
attributes: attributes,
|
||||
alternativeRepresentations: file.alternativeRepresentations
|
||||
)
|
||||
}
|
||||
|
||||
private func fileAttributesWithResolvedDimensions(_ attributes: [TelegramMediaFileAttribute], dimensions: PixelDimensions) -> ([TelegramMediaFileAttribute], Bool) {
|
||||
var updatedAttributes: [TelegramMediaFileAttribute] = []
|
||||
var didUpdate = false
|
||||
var hasSizeAttribute = false
|
||||
|
||||
for attribute in attributes {
|
||||
switch attribute {
|
||||
case let .ImageSize(size):
|
||||
hasSizeAttribute = true
|
||||
if size != dimensions {
|
||||
updatedAttributes.append(.ImageSize(size: dimensions))
|
||||
didUpdate = true
|
||||
} else {
|
||||
updatedAttributes.append(attribute)
|
||||
}
|
||||
case let .Video(duration, size, flags, preloadSize, coverTime, videoCodec):
|
||||
hasSizeAttribute = true
|
||||
if size != dimensions {
|
||||
updatedAttributes.append(.Video(duration: duration, size: dimensions, flags: flags, preloadSize: preloadSize, coverTime: coverTime, videoCodec: videoCodec))
|
||||
didUpdate = true
|
||||
} else {
|
||||
updatedAttributes.append(attribute)
|
||||
}
|
||||
default:
|
||||
updatedAttributes.append(attribute)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasSizeAttribute {
|
||||
updatedAttributes.append(.ImageSize(size: dimensions))
|
||||
didUpdate = true
|
||||
}
|
||||
|
||||
return (updatedAttributes, didUpdate)
|
||||
}
|
||||
|
||||
private func openUrl(_ url: InstantPageUrlItem) {
|
||||
var baseUrl = url.url
|
||||
var anchor: String?
|
||||
|
|
@ -1011,7 +1498,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peer.id))
|
||||
|> deliverOnMainQueue).start(next: { peer in
|
||||
if let strongSelf = self, let peer = peer {
|
||||
if let controller = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) {
|
||||
if let controller = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) {
|
||||
strongSelf.getNavigationController()?.pushViewController(controller)
|
||||
}
|
||||
}
|
||||
|
|
@ -1173,16 +1660,16 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
let controller = makeContextMenuController(actions: [ContextMenuAction(content: .text(title: self.presentationData.strings.Conversation_ContextMenuCopy, accessibilityLabel: self.presentationData.strings.Conversation_ContextMenuCopy), action: { [weak self] in
|
||||
if let self, let image = media.media._asMedia() as? TelegramMediaImage {
|
||||
let media = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: image.representations, immediateThumbnailData: image.immediateThumbnailData, reference: nil, partialReference: nil, flags: [])
|
||||
let _ = copyToPasteboard(context: self.context, postbox: self.context.account.postbox, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start()
|
||||
let _ = copyToPasteboard(context: self.context, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start()
|
||||
}
|
||||
}), ContextMenuAction(content: .text(title: self.presentationData.strings.Conversation_LinkDialogSave, accessibilityLabel: self.presentationData.strings.Conversation_LinkDialogSave), action: { [weak self] in
|
||||
if let self, let image = media.media._asMedia() as? TelegramMediaImage {
|
||||
let media = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: image.representations, immediateThumbnailData: image.immediateThumbnailData, reference: nil, partialReference: nil, flags: [])
|
||||
let _ = saveToCameraRoll(context: self.context, postbox: self.context.account.postbox, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start()
|
||||
let _ = saveToCameraRoll(context: self.context, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start()
|
||||
}
|
||||
}), ContextMenuAction(content: .text(title: self.presentationData.strings.Conversation_ContextMenuShare, accessibilityLabel: self.presentationData.strings.Conversation_ContextMenuShare), action: { [weak self] in
|
||||
if let self, let (webPage, _) = self.webPage, let image = media.media._asMedia() as? TelegramMediaImage {
|
||||
self.present(ShareController(context: self.context, subject: .image(image.representations.map({ ImageRepresentationWithReference(representation: $0, reference: MediaResourceReference.media(media: .webPage(webPage: WebpageReference(webPage), media: image), resource: $0.resource)) }))), nil)
|
||||
self.present(self.context.sharedContext.makeShareController(context: self.context, params: ShareControllerParams(subject: .image(image.representations.map({ ImageRepresentationWithReference(representation: $0, reference: MediaResourceReference.media(media: .webPage(webPage: WebpageReference(webPage), media: image), resource: $0.resource)) })))), nil)
|
||||
}
|
||||
})], catchTapsOutside: true)
|
||||
self.present(controller, ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak self] in
|
||||
|
|
@ -1325,7 +1812,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
}
|
||||
}), ContextMenuAction(content: .text(title: strings.Conversation_ContextMenuShare, accessibilityLabel: strings.Conversation_ContextMenuShare), action: { [weak self] in
|
||||
if let strongSelf = self, let (webPage, _) = strongSelf.webPage, case let .Loaded(content) = webPage.content {
|
||||
strongSelf.present(ShareController(context: strongSelf.context, subject: .quote(text: text, url: content.url)), nil)
|
||||
strongSelf.present(strongSelf.context.sharedContext.makeShareController(context: strongSelf.context, params: ShareControllerParams(subject: .quote(text: text, url: content.url))), nil)
|
||||
}
|
||||
})]
|
||||
|
||||
|
|
|
|||
2280
submodules/BrowserUI/Sources/BrowserMarkdown.swift
Normal file
2280
submodules/BrowserUI/Sources/BrowserMarkdown.swift
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue