{"owner":"TelegramMessenger","repo":"Telegram-iOS","hasSkills":true,"hasMcp":false,"mcpConfig":null,"found":["CLAUDE.md"],"skills":{"CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to AI assistants when working with code in this repository.\n\n## Build\n\nThe 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.\n\n**Command:**\n\n```sh\npython3 build-system/Make/Make.py --overrideXcodeVersion \\\n --cacheDir ~/telegram-bazel-cache \\\n build \\\n --configurationPath build-system/appstore-configuration.json \\\n --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \\\n --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64\n```\n\nAdd `--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.\n\nThe 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.\n\n**Running tests.** `Make.py test` runs Bazel test targets (same config + codesigning as `build`, forced `debug_sim_arm64`). It accepts `--target <label>` (added 2026-06-19; default `Tests/AllTests`) so a single `ios_unit_test` can run in isolation, e.g.:\n\n```sh\nsource ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache \\\n test --configurationPath build-system/appstore-configuration.json \\\n --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \\\n --gitCodesigningType development --gitCodesigningUseCurrent --target //submodules/TextFormat:TextFormatTests\n```\n\nThe first app-side `ios_unit_test` is `//submodules/TextFormat:TextFormatTests` (the mention/date link codecs). An `ios_unit_test` here needs an `ios_test_runner` pinned to a real device/OS (e.g. `iPhone 17` / `26.5`) — the default runner picks an invalid device and the test process exits 15. **Run new targets via `--target`, not the default suite:** `Tests/AllTests` currently references a dangling `//submodules/TgVoipWebrtc:TgCallsTests`, so the default would fail to build until that suite is repaired.\n\n### Updating the running simulator after a rebuild (whole-`.app` copy)\n\n`simctl install` will NOT replace an already-installed app when the build number is unchanged (installd keeps a hard-link cache), so a rebuilt binary silently doesn't take effect. **Preferred fix: copy the whole freshly-built `.app` over the installed bundle in place.** This is more robust than swapping only the `Frameworks/TelegramUIFramework` binary (no risk of app↔framework version skew), and it preserves the account/login because the **data container is a separate path** (`.../data/Containers/Data/Application/<uuid>/`, keyed by bundle id) — only the **bundle** container is replaced, and the install-DB entry stays valid since the path + bundle id are unchanged.\n\n```sh\nK3=FA6F7462-AA97-42FE-9E57-8DA0593CE756   # iPhone 17 Pro K3 (use the dedicated K-sims, not the shared default)\nBUNDLE=ph.telegra.Telegraph\n# Fresh build output (unzipped bundle, not the .ipa). `-L` is REQUIRED — `bazel-out` is a symlink,\n# so a plain `find bazel-out …` silently returns nothing:\nSRC=\"$(find -L bazel-out -maxdepth 14 -path '*/Telegram_archive-root/Payload/Telegram.app' -type d | head -1)\"\nDEST=\"$(xcrun simctl get_app_container \"$K3\" \"$BUNDLE\" app)\"   # installed bundle path\n# GUARD before the destructive rm: never rm the installed app unless SRC actually resolved,\n# or a failed cp leaves the sim with NO app installed (relaunch then fails).\n[ -x \"$SRC/Telegram\" ] || { echo \"no fresh bundle at SRC=$SRC — aborting\"; exit 1; }\nxcrun simctl terminate \"$K3\" \"$BUNDLE\" 2>/dev/null            # terminate before replacing the running binary\nrm -rf \"$DEST\" && cp -Rp \"$SRC\" \"$DEST\"                        # replace bundle in place; data container untouched\nxcrun simctl launch \"$K3\" \"$BUNDLE\"\n```\n\nThe sim ignores code signing, so the unsigned `Telegram_archive-root` bundle runs fine. Bazel stamps a reproducible `Jan 1 1980` mtime on the copied binary — that's expected, not a stale copy. The `Telegram_archive-root` is regenerated by the Make.py wrapper's post-build packaging; if it's stale/missing after an incremental build, unzip `Payload/Telegram.app` out of `bazel-bin/Telegram/Telegram.ipa` instead. (The older framework-only `cp` of `TelegramUIFramework` still works and is faster, but prefer the whole-`.app` copy to avoid version skew.)\n\n## Code Style Guidelines\n- **Naming**: PascalCase for types, camelCase for variables/methods\n- **Imports**: Group and sort imports at the top of files\n- **Error Handling**: Properly handle errors with appropriate redaction of sensitive data\n- **Formatting**: Use standard Swift/Objective-C formatting and spacing\n- **Types**: Prefer strong typing and explicit type annotations where needed\n- **Documentation**: Document public APIs with comments\n\n## Project Structure\n- Core launch and application extensions code is in `Telegram/` directory\n- Most code is organized into libraries in `submodules/`\n- External code is located in `third-party/`\n- App-side unit tests are minimal: the first `ios_unit_test` (`//submodules/TextFormat:TextFormatTests`) was added 2026-06-19 (run via `Make.py test --target` — see Build). The RichTextEditor SwiftPM package keeps its own suite (`swift test` / `Scripts/iostest.sh`). Most modules still have no tests.\n\n## RichTextEditor editor & the `ChatInputContent` composer\n\nA from-scratch WYSIWYG rich-text editor (`submodules/TelegramUI/Components/RichTextEditor`) is the native chat-composer backend — by default a **dual-field switch** (the composer uses the legacy input and latches to the native editor only when content becomes legacy-non-representable); the `forceNewTextInput` experimental flag (Debug Settings ▸ \"Force Text Field v2\") forces always-native. (This inverted the earlier default+`forceLegacyTextInput`-opt-out scheme.) `ChatInputContent` (a TelegramCore-native value model) replaced `NSAttributedString` as the composer currency. The app-side integration — the model and its load-bearing invariants, composer ↔ editor wiring, the formatting-menu / custom-emoji-mention-date / code-block / inline-media round-trips, rich-message send / edit / pending-display, the long-press-Send send-options preview, and draft persistence (local, cross-device media sync, re-login restore) — lives in [`docs/richtext-composer.md`](docs/richtext-composer.md). Editor internals (the TextKit seam, layout) are the editor's own `submodules/TelegramUI/Components/RichTextEditor/CLAUDE.md`; message **rendering** is [`docs/instantpage-richtext.md`](docs/instantpage-richtext.md).\n\n## Embedded watch app (`Telegram/WatchApp`)\n\nA standalone watchOS Telegram client (developed in the separate `~/build/tgwatch` repo) is vendored into this repo at `Telegram/WatchApp/` and can be embedded into the **device** IPA under `Telegram.app/Watch/`. It is built by `xcodebuild` (not Bazel) and codesigned by the Bazel build.\n\n**Build it:** add `--embedWatchApp` to a Make.py **device** build (`--configuration=debug_arm64` or `release_arm64`) together with `--watchApiId`, `--watchApiHash`, `--watchSigningIdentity`, `--watchProvisioningProfile`. Off by default (it adds a ~4-min xcodebuild step); simulator builds never embed, and the default `debug_sim_arm64` build is unaffected.\n\n**`Telegram/WatchApp/` is a synced snapshot — do not hand-edit it.** The source of truth and dev tooling live in the `tgwatch` repo. To change the watch app, edit it there, then re-sync with `tgwatch/tools/export-sources.sh /abs/path/to/telegram-ios/Telegram/WatchApp` and commit the result. The committed `tgwatch.xcodeproj` is generated (kept via a `!tgwatch.xcodeproj` negation in `Telegram/WatchApp/.gitignore`, since the root `.gitignore` ignores `*.xcodeproj`); `.build`/`.swiftpm`/`xcuserdata` are excluded.\n\n**How it's wired:** `//Telegram:TelegramWatchApp` (rule in `Telegram/prebuilt_watchos.bzl`) runs in **two actions**: `PrebuiltWatchosCompile` (`Telegram/prebuilt_watchos_compile.sh`) runs xcodebuild on the snapshot in a writable temp copy with PLACEHOLDER version/api values (the bundle ids are baked from the snapshot's pbxproj/Info.plist — `ph.telegra.Telegraph.watchkitapp` / `ph.telegra.Telegraph`), emitting an unsigned `.app`; `PrebuiltWatchosPatchSign` (`Telegram/prebuilt_watchos_patch.sh`) then rewrites **six** per-build Info.plist keys (`CFBundleShortVersionString`, `CFBundleVersion`, `TG_API_ID`, `TG_API_HASH`, `CFBundleIdentifier`, `WKCompanionAppBundleIdentifier`) and codesigns the `.app` + nested `TDLibFramework.framework` (identity + the watchkitapp profile from `--define`s). The result feeds the `Telegram` `ios_application`'s `watch_application` slot (gated by the `//Telegram:embedWatchApp` flag). The rule takes `bundle_id` (set to `\"{telegram_bundle_id}.watchkitapp\"` in `Telegram/BUILD`) and derives the host bundle id by stripping the `.watchkitapp` suffix; both are passed to the patch worker as args (not action inputs), so the patch action re-runs when the host bundle id changes but the (expensive) compile stays cached. **The compile action's only inputs are the snapshot (+ its worker)** — so changing the version, build number, api id/hash, host bundle id, or signing identity re-runs only the cheap patch+sign action, not xcodebuild; xcodebuild re-runs only when the snapshot changes. This is correct because none of those values reach the compiled binary: each lands only in the Info.plist (via `$(...)` substitution and a runtime `Bundle.main.object(forInfoDictionaryKey:)` lookup in `Secrets.swift`, except for the bundle-id keys which only Info.plist consumers read).\n\n**Non-obvious invariants** (also in the `.bzl` comments): `AppleBundleInfo`'s public init is banned — use the internal `new_applebundleinfo`; `watch_application` requires BOTH `AppleBundleInfo` (with a non-None `infoplist` File) AND `WatchosApplicationBundleInfo`; the embedded watch app's `CFBundleShortVersionString`/`CFBundleVersion` must exactly equal the host's (sourced from `versions.json['app']` + `--define=buildNumber`); the host does NOT re-sign the embedded watch app, so the worker must sign it; the watch bundle id `ph.telegra.Telegraph.watchkitapp` must track the host `telegram_bundle_id`.\n\n**Status:** verified with **development** signing on `debug_arm64` only. Open follow-ups before App Store shipping: secure timestamp (drop `codesign --timestamp=none`), distribution profile (`get-task-allow=false`), `release_arm64` + `altool --validate-app`, and committing a `Package.resolved` for hermetic remote-SwiftPM resolution.\n\n## View frame ownership\n\nA view does not control its own `frame`. The parent (or a layout system) sets the frame; the view positions its own subviews against `self.bounds` in response.\n\nThis matters in two places specifically:\n\n- **Reusable components (`UIView`/`ASDisplayNode` subclasses).** Public methods like `update(...)` / `apply(...)` rebuild internal state, mutate child frames, and read `self.bounds` to lay them out — but they do not write `self.frame`. The caller has already chosen the frame; mutating it from inside the component overrides that choice and fights the parent's next layout pass.\n- **`asyncLayout`-style content nodes.** The measure pass runs off-main and returns a size; the apply step runs on main and the chat layout system positions the node. A child view that writes `self.frame` from `update()` corrupts the size the parent just measured.\n\nRare exceptions: top-level view-controller views integrating with the system's first-responder/inset model. If you find yourself wanting `self.frame = …` from inside a child view, refactor so the parent positions it instead.\n\n## ChatHistoryListNode composition\n\n`ChatHistoryListNodeImpl` (`submodules/TelegramUI/Sources/ChatHistoryListNode.swift`) **composes** rather than inherits `ListViewImpl` (`submodules/Display/Source/ListView.swift`): it is an `ASDisplayNode` wrapper holding `private let listView: ListViewImpl` and exposes a deliberately narrowed surface (the `ChatHistoryListNode` protocol in `AccountContext` + curated concrete forwarders) instead of the full `ListView` API. `ListViewImpl` gained a `getCustomItemDeleteAnimationDuration` closure hook so the one former `override` works via composition.\n\nThese invariants are **compiler-invisible** — getting them wrong silently breaks the app's primary scroll surface:\n\n- **The π rotation stays on the wrapper** (chat is bottom-up). The wrapper keeps `transform = π` + a `rotated` flag; the child `listView` gets only `rotated = true` (identity transform). So `historyNode.view`/`.layer` remain the rotated surface, and rotation-coupled code — hitTest coordinate conversions, the blur `drawHierarchy` flip, the dust/delete layer, `.layer` animations, and the overscroll-overlay + snapshot-slide reparenting — **stays on `self` (the wrapper)** unchanged.\n- **Only genuine scroll-surface concerns route to the child:** gesture recognizers (selection pan; external taps via `addContentGestureRecognizer`) attach to `self.listView.view` to share the scroll pan's simultaneity environment, and scroller access goes to `self.listView.scroller`.\n- **`let _ = self.view` in `init` is load-bearing.** The old inherited node was view-loaded eagerly (so `self.isNodeLoaded` was always true); `enqueueHistoryViewTransition` gates the history dequeue on it. The wrapper must force-load its view in init or off-screen nodes (created during thread switches) never become ready and `reloadChatLocation`'s completion never fires.\n- **Item nodes are one level deeper.** Any `.supernode` chain / hierarchy-depth assumption passing through the history node gained one level (item → child `listView` → wrapper). E.g. `ChatMessageTransitionNode` converts item rects up `supernode?.supernode?.supernode?.view` (was 2 hops) so the wrapper's rotation is applied as an intermediate transform; a missing hop reflects effect-burst overlays ~180°.\n- Child geometry is driven inside `updateLayout` via `transition.updateFrame(node: self.listView, …)` — the project never relies on ASDisplayNode's automatic `layout()`.\n\nThe public surface is being narrowed incrementally (e.g. `scroller` → `bounces`/`contentHeight`; the `trackingOffset`/`beganTrackingAtTopOrigin` pair → `didInteractivelyDragFromTopOrigin`). Prefer intent-named accessors over re-exposing raw `ListView` state.\n\n## InstantPage V2 & rich-text messages\n\nTyped markdown with structure the regular message-entity set can't represent (headings, lists, tables, formulas, nested blockquotes) is sent as a **rich message** — a `RichTextMessageAttribute` carrying an `InstantPage`, drawn by `ChatMessageRichDataBubbleContentNode` via the **InstantPage V2** renderer (with AI-streaming progressive reveal, inline custom emoji, and entity cases). The detailed architecture and non-obvious invariants — streaming reveal, V2 table/text-box layout, custom-emoji & entity round-trips, task-list checkboxes, nested blockquotes, thinking blocks, the markdown send / edit / copy / paste paths, and surfacing rich-message media through the shared-media/gallery/preview pipelines via `Message.effectiveMedia` — live in [`docs/instantpage-richtext.md`](docs/instantpage-richtext.md).\n\n## Postbox → TelegramEngine refactor (in progress)\n\nA gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`.\n\n**Historical record:** Wave-by-wave outcomes, the running tally of Postbox-free modules, the full wave-selection guidance, and the `TelegramEngine.Resources` facade inventory (also authoritatively defined in `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`) 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.\n\nSee the log for per-wave detail; the current wave count and the list of still-open migration opportunities live in the `project_postbox_refactor_next_wave.md` memory file.\n\n### Rules that apply to every wave\n\n1. `TelegramCore` does **not** `@_exported import Postbox`. Once a consumer drops `import Postbox`, every remaining Postbox-type reference must use an engine-typealiased equivalent.\n2. **Never typealias `Postbox`, `Account`, or `MediaBox`.** These umbrella types rename without encapsulating. Narrow utility typealiases (`MemoryBuffer`, `PostboxDecoder`, `PostboxEncoder`, `AdaptedPostboxDecoder`, `MediaResource`, …) remain allowed and expected.\n3. No new engine wrapper **structs** unless the wave's spec explicitly allows — only typealiases and thin forwarding methods.\n4. **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.\n5. **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.\n6. Full project build per module. No unit tests exist in this project.\n7. **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.\n8. **Never substitute Postbox protocols (`Media`, `Peer`, `Message`) with `Any` / `AnyObject`** in code that previously used them. Type erasure throws away the domain semantics that the next reader expects. Use the matching engine wrapper (`EngineMedia`, `EnginePeer`, `EngineMessage`) — extending it as needed (e.g. add a missing case-init or convenience). If neither typealias nor wrapper covers the use site, restore the original Postbox import + type for now and flag the case for a future facade. Existing `Any`/`AnyObject` parameters predating the refactor are not in scope for this rule.\n\n### Engine typealias cheat sheet (existing aliases)\n\n```\nPeerId              → EnginePeer.Id\nMessageId           → EngineMessage.Id\nMessageIndex        → EngineMessage.Index\nMessageTags         → EngineMessage.Tags\nMessageAttribute    → EngineMessage.Attribute\nMessageFlags        → EngineMessage.Flags\nMessageForwardInfo  → EngineMessage.ForwardInfo\nMediaId             → EngineMedia.Id\nPreferencesEntry    → EnginePreferencesEntry\nTempBox             → EngineTempBox\nPinnedItemId        → EngineChatList.PinnedItem.Id\nMemoryBuffer        → EngineMemoryBuffer           (added 2026-04)\nPostboxDecoder      → EnginePostboxDecoder         (added 2026-04)\nPostboxEncoder      → EnginePostboxEncoder         (added 2026-04)\nAdaptedPostboxDecoder → EngineAdaptedPostboxDecoder (added 2026-04)\nItemCollectionId    → EngineItemCollectionId       (added 2026-04-20)\nFetchResourceSourceType → EngineFetchResourceSourceType (added 2026-04-20)\nFetchResourceError  → EngineFetchResourceError     (added 2026-04-20)\nStoryId             → EngineStoryId                (added 2026-05-02)\nChatListIndex       → EngineChatListIndex          (added 2026-05-03)\nTempBoxFile         → EngineTempBoxFile            (added 2026-05-03)\nItemCollectionItemIndex → EngineItemCollectionItemIndex (added 2026-05-03)\nItemCollectionViewEntryIndex → EngineItemCollectionViewEntryIndex (added 2026-05-03)\nValueBoxEncryptionParameters → EngineValueBoxEncryptionParameters (added 2026-05-03)\nMessageAndThreadId  → EngineMessageAndThreadId      (added 2026-05-03)\nPeerStoryStats      → EnginePeerStoryStats          (added 2026-05-03)\nMessageHistoryAnchorIndex → EngineMessageHistoryAnchorIndex (added 2026-05-03)\nChatListTotalUnreadStateCategory → EngineChatListTotalUnreadStateCategory (added 2026-05-03)\nChatListTotalUnreadStateStats → EngineChatListTotalUnreadStateStats (added 2026-05-03)\nPeerSummaryCounterTags → EnginePeerSummaryCounterTags (added 2026-05-03)\nChatListTotalUnreadState → EngineChatListTotalUnreadState (added 2026-05-04)\nItemCacheEntryId    → EngineItemCacheEntryId        (added 2026-05-04)\nHashFunctions       → EngineHashFunctions           (added 2026-05-04 wave 251)\nCachedMediaResourceRepresentationResult → EngineCachedMediaResourceRepresentationResult (added 2026-05-04 wave 265)\nMediaResourceDataFetchResult → EngineMediaResourceDataFetchResult (added 2026-05-04 wave 266)\nMediaResourceDataFetchError → EngineMediaResourceDataFetchError (added 2026-05-04 wave 266)\nMediaResourceStatus → EngineMediaResourceStatus     (added 2026-05-04 wave 272)\n```\n\n**Free-function thin forwarders in TelegramCore** (rule 3 allows):\n- `engineFileSize(_ path:, useTotalFileAllocatedSize: Bool = false)` — forwards to Postbox's `fileSize(...)` (added 2026-05-04 wave 268)\n\n**TelegramEngineUnauthorized.resources facade**: `UnauthorizedResources.storeResourceData(id: EngineMediaResource.Id, data:, synchronous:)` — bridges to `account.postbox.mediaBox.storeResourceData` (added 2026-05-04 wave 271)\n\nFor 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).\n\n### MediaResource → EngineMediaResource consumer migration\n\n`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:\n\n- `EngineMediaResource(rawResource)` — wrap a raw `MediaResource`.\n- `engineResource._asResource()` — unwrap to the raw `MediaResource`.\n- `EngineMediaResource.ResourceData(rawResourceData)` — wrap `MediaResourceData`.\n- `EngineMediaResource.Id(rawMediaResourceId)` — wrap `MediaResourceId`.\n\n**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.\n\nDo **not** add opt-in `EngineMediaResource` overloads alongside raw-`MediaResource` overloads. Duplicate signatures fragment the public API and leave the leak in place forever.\n\nFor 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`.\n\n## tgcalls Testbench\n\nThis repo includes a tgcalls testbench (CLI tool, Go/Pion SFU, Docker build) layered on top of the iOS source. All testbench code, build instructions, and architecture docs live inside the tgcalls submodule:\n\n- `submodules/TgVoipWebrtc/tgcalls/CLAUDE.md` — top-level testbench overview, build/run commands\n- `submodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md` — CLI test tool architecture\n- `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/CLAUDE.md` — Go SFU internals\n- `submodules/TgVoipWebrtc/CLAUDE.md` — tgcalls library internals + macOS/Linux build patches\n\nBuild the test binary from this directory with:\n\n`./build-input/bazel-8.4.2 build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli`\n"},"files":{"CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to AI assistants when working with code in this repository.\n\n## Build\n\nThe 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.\n\n**Command:**\n\n```sh\npython3 build-system/Make/Make.py --overrideXcodeVersion \\\n --cacheDir ~/telegram-bazel-cache \\\n build \\\n --configurationPath build-system/appstore-configuration.json \\\n --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \\\n --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64\n```\n\nAdd `--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.\n\nThe 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.\n\n**Running tests.** `Make.py test` runs Bazel test targets (same config + codesigning as `build`, forced `debug_sim_arm64`). It accepts `--target <label>` (added 2026-06-19; default `Tests/AllTests`) so a single `ios_unit_test` can run in isolation, e.g.:\n\n```sh\nsource ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache \\\n test --configurationPath build-system/appstore-configuration.json \\\n --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \\\n --gitCodesigningType development --gitCodesigningUseCurrent --target //submodules/TextFormat:TextFormatTests\n```\n\nThe first app-side `ios_unit_test` is `//submodules/TextFormat:TextFormatTests` (the mention/date link codecs). An `ios_unit_test` here needs an `ios_test_runner` pinned to a real device/OS (e.g. `iPhone 17` / `26.5`) — the default runner picks an invalid device and the test process exits 15. **Run new targets via `--target`, not the default suite:** `Tests/AllTests` currently references a dangling `//submodules/TgVoipWebrtc:TgCallsTests`, so the default would fail to build until that suite is repaired.\n\n### Updating the running simulator after a rebuild (whole-`.app` copy)\n\n`simctl install` will NOT replace an already-installed app when the build number is unchanged (installd keeps a hard-link cache), so a rebuilt binary silently doesn't take effect. **Preferred fix: copy the whole freshly-built `.app` over the installed bundle in place.** This is more robust than swapping only the `Frameworks/TelegramUIFramework` binary (no risk of app↔framework version skew), and it preserves the account/login because the **data container is a separate path** (`.../data/Containers/Data/Application/<uuid>/`, keyed by bundle id) — only the **bundle** container is replaced, and the install-DB entry stays valid since the path + bundle id are unchanged.\n\n```sh\nK3=FA6F7462-AA97-42FE-9E57-8DA0593CE756   # iPhone 17 Pro K3 (use the dedicated K-sims, not the shared default)\nBUNDLE=ph.telegra.Telegraph\n# Fresh build output (unzipped bundle, not the .ipa). `-L` is REQUIRED — `bazel-out` is a symlink,\n# so a plain `find bazel-out …` silently returns nothing:\nSRC=\"$(find -L bazel-out -maxdepth 14 -path '*/Telegram_archive-root/Payload/Telegram.app' -type d | head -1)\"\nDEST=\"$(xcrun simctl get_app_container \"$K3\" \"$BUNDLE\" app)\"   # installed bundle path\n# GUARD before the destructive rm: never rm the installed app unless SRC actually resolved,\n# or a failed cp leaves the sim with NO app installed (relaunch then fails).\n[ -x \"$SRC/Telegram\" ] || { echo \"no fresh bundle at SRC=$SRC — aborting\"; exit 1; }\nxcrun simctl terminate \"$K3\" \"$BUNDLE\" 2>/dev/null            # terminate before replacing the running binary\nrm -rf \"$DEST\" && cp -Rp \"$SRC\" \"$DEST\"                        # replace bundle in place; data container untouched\nxcrun simctl launch \"$K3\" \"$BUNDLE\"\n```\n\nThe sim ignores code signing, so the unsigned `Telegram_archive-root` bundle runs fine. Bazel stamps a reproducible `Jan 1 1980` mtime on the copied binary — that's expected, not a stale copy. The `Telegram_archive-root` is regenerated by the Make.py wrapper's post-build packaging; if it's stale/missing after an incremental build, unzip `Payload/Telegram.app` out of `bazel-bin/Telegram/Telegram.ipa` instead. (The older framework-only `cp` of `TelegramUIFramework` still works and is faster, but prefer the whole-`.app` copy to avoid version skew.)\n\n## Code Style Guidelines\n- **Naming**: PascalCase for types, camelCase for variables/methods\n- **Imports**: Group and sort imports at the top of files\n- **Error Handling**: Properly handle errors with appropriate redaction of sensitive data\n- **Formatting**: Use standard Swift/Objective-C formatting and spacing\n- **Types**: Prefer strong typing and explicit type annotations where needed\n- **Documentation**: Document public APIs with comments\n\n## Project Structure\n- Core launch and application extensions code is in `Telegram/` directory\n- Most code is organized into libraries in `submodules/`\n- External code is located in `third-party/`\n- App-side unit tests are minimal: the first `ios_unit_test` (`//submodules/TextFormat:TextFormatTests`) was added 2026-06-19 (run via `Make.py test --target` — see Build). The RichTextEditor SwiftPM package keeps its own suite (`swift test` / `Scripts/iostest.sh`). Most modules still have no tests.\n\n## RichTextEditor editor & the `ChatInputContent` composer\n\nA from-scratch WYSIWYG rich-text editor (`submodules/TelegramUI/Components/RichTextEditor`) is the native chat-composer backend — by default a **dual-field switch** (the composer uses the legacy input and latches to the native editor only when content becomes legacy-non-representable); the `forceNewTextInput` experimental flag (Debug Settings ▸ \"Force Text Field v2\") forces always-native. (This inverted the earlier default+`forceLegacyTextInput`-opt-out scheme.) `ChatInputContent` (a TelegramCore-native value model) replaced `NSAttributedString` as the composer currency. The app-side integration — the model and its load-bearing invariants, composer ↔ editor wiring, the formatting-menu / custom-emoji-mention-date / code-block / inline-media round-trips, rich-message send / edit / pending-display, the long-press-Send send-options preview, and draft persistence (local, cross-device media sync, re-login restore) — lives in [`docs/richtext-composer.md`](docs/richtext-composer.md). Editor internals (the TextKit seam, layout) are the editor's own `submodules/TelegramUI/Components/RichTextEditor/CLAUDE.md`; message **rendering** is [`docs/instantpage-richtext.md`](docs/instantpage-richtext.md).\n\n## Embedded watch app (`Telegram/WatchApp`)\n\nA standalone watchOS Telegram client (developed in the separate `~/build/tgwatch` repo) is vendored into this repo at `Telegram/WatchApp/` and can be embedded into the **device** IPA under `Telegram.app/Watch/`. It is built by `xcodebuild` (not Bazel) and codesigned by the Bazel build.\n\n**Build it:** add `--embedWatchApp` to a Make.py **device** build (`--configuration=debug_arm64` or `release_arm64`) together with `--watchApiId`, `--watchApiHash`, `--watchSigningIdentity`, `--watchProvisioningProfile`. Off by default (it adds a ~4-min xcodebuild step); simulator builds never embed, and the default `debug_sim_arm64` build is unaffected.\n\n**`Telegram/WatchApp/` is a synced snapshot — do not hand-edit it.** The source of truth and dev tooling live in the `tgwatch` repo. To change the watch app, edit it there, then re-sync with `tgwatch/tools/export-sources.sh /abs/path/to/telegram-ios/Telegram/WatchApp` and commit the result. The committed `tgwatch.xcodeproj` is generated (kept via a `!tgwatch.xcodeproj` negation in `Telegram/WatchApp/.gitignore`, since the root `.gitignore` ignores `*.xcodeproj`); `.build`/`.swiftpm`/`xcuserdata` are excluded.\n\n**How it's wired:** `//Telegram:TelegramWatchApp` (rule in `Telegram/prebuilt_watchos.bzl`) runs in **two actions**: `PrebuiltWatchosCompile` (`Telegram/prebuilt_watchos_compile.sh`) runs xcodebuild on the snapshot in a writable temp copy with PLACEHOLDER version/api values (the bundle ids are baked from the snapshot's pbxproj/Info.plist — `ph.telegra.Telegraph.watchkitapp` / `ph.telegra.Telegraph`), emitting an unsigned `.app`; `PrebuiltWatchosPatchSign` (`Telegram/prebuilt_watchos_patch.sh`) then rewrites **six** per-build Info.plist keys (`CFBundleShortVersionString`, `CFBundleVersion`, `TG_API_ID`, `TG_API_HASH`, `CFBundleIdentifier`, `WKCompanionAppBundleIdentifier`) and codesigns the `.app` + nested `TDLibFramework.framework` (identity + the watchkitapp profile from `--define`s). The result feeds the `Telegram` `ios_application`'s `watch_application` slot (gated by the `//Telegram:embedWatchApp` flag). The rule takes `bundle_id` (set to `\"{telegram_bundle_id}.watchkitapp\"` in `Telegram/BUILD`) and derives the host bundle id by stripping the `.watchkitapp` suffix; both are passed to the patch worker as args (not action inputs), so the patch action re-runs when the host bundle id changes but the (expensive) compile stays cached. **The compile action's only inputs are the snapshot (+ its worker)** — so changing the version, build number, api id/hash, host bundle id, or signing identity re-runs only the cheap patch+sign action, not xcodebuild; xcodebuild re-runs only when the snapshot changes. This is correct because none of those values reach the compiled binary: each lands only in the Info.plist (via `$(...)` substitution and a runtime `Bundle.main.object(forInfoDictionaryKey:)` lookup in `Secrets.swift`, except for the bundle-id keys which only Info.plist consumers read).\n\n**Non-obvious invariants** (also in the `.bzl` comments): `AppleBundleInfo`'s public init is banned — use the internal `new_applebundleinfo`; `watch_application` requires BOTH `AppleBundleInfo` (with a non-None `infoplist` File) AND `WatchosApplicationBundleInfo`; the embedded watch app's `CFBundleShortVersionString`/`CFBundleVersion` must exactly equal the host's (sourced from `versions.json['app']` + `--define=buildNumber`); the host does NOT re-sign the embedded watch app, so the worker must sign it; the watch bundle id `ph.telegra.Telegraph.watchkitapp` must track the host `telegram_bundle_id`.\n\n**Status:** verified with **development** signing on `debug_arm64` only. Open follow-ups before App Store shipping: secure timestamp (drop `codesign --timestamp=none`), distribution profile (`get-task-allow=false`), `release_arm64` + `altool --validate-app`, and committing a `Package.resolved` for hermetic remote-SwiftPM resolution.\n\n## View frame ownership\n\nA view does not control its own `frame`. The parent (or a layout system) sets the frame; the view positions its own subviews against `self.bounds` in response.\n\nThis matters in two places specifically:\n\n- **Reusable components (`UIView`/`ASDisplayNode` subclasses).** Public methods like `update(...)` / `apply(...)` rebuild internal state, mutate child frames, and read `self.bounds` to lay them out — but they do not write `self.frame`. The caller has already chosen the frame; mutating it from inside the component overrides that choice and fights the parent's next layout pass.\n- **`asyncLayout`-style content nodes.** The measure pass runs off-main and returns a size; the apply step runs on main and the chat layout system positions the node. A child view that writes `self.frame` from `update()` corrupts the size the parent just measured.\n\nRare exceptions: top-level view-controller views integrating with the system's first-responder/inset model. If you find yourself wanting `self.frame = …` from inside a child view, refactor so the parent positions it instead.\n\n## ChatHistoryListNode composition\n\n`ChatHistoryListNodeImpl` (`submodules/TelegramUI/Sources/ChatHistoryListNode.swift`) **composes** rather than inherits `ListViewImpl` (`submodules/Display/Source/ListView.swift`): it is an `ASDisplayNode` wrapper holding `private let listView: ListViewImpl` and exposes a deliberately narrowed surface (the `ChatHistoryListNode` protocol in `AccountContext` + curated concrete forwarders) instead of the full `ListView` API. `ListViewImpl` gained a `getCustomItemDeleteAnimationDuration` closure hook so the one former `override` works via composition.\n\nThese invariants are **compiler-invisible** — getting them wrong silently breaks the app's primary scroll surface:\n\n- **The π rotation stays on the wrapper** (chat is bottom-up). The wrapper keeps `transform = π` + a `rotated` flag; the child `listView` gets only `rotated = true` (identity transform). So `historyNode.view`/`.layer` remain the rotated surface, and rotation-coupled code — hitTest coordinate conversions, the blur `drawHierarchy` flip, the dust/delete layer, `.layer` animations, and the overscroll-overlay + snapshot-slide reparenting — **stays on `self` (the wrapper)** unchanged.\n- **Only genuine scroll-surface concerns route to the child:** gesture recognizers (selection pan; external taps via `addContentGestureRecognizer`) attach to `self.listView.view` to share the scroll pan's simultaneity environment, and scroller access goes to `self.listView.scroller`.\n- **`let _ = self.view` in `init` is load-bearing.** The old inherited node was view-loaded eagerly (so `self.isNodeLoaded` was always true); `enqueueHistoryViewTransition` gates the history dequeue on it. The wrapper must force-load its view in init or off-screen nodes (created during thread switches) never become ready and `reloadChatLocation`'s completion never fires.\n- **Item nodes are one level deeper.** Any `.supernode` chain / hierarchy-depth assumption passing through the history node gained one level (item → child `listView` → wrapper). E.g. `ChatMessageTransitionNode` converts item rects up `supernode?.supernode?.supernode?.view` (was 2 hops) so the wrapper's rotation is applied as an intermediate transform; a missing hop reflects effect-burst overlays ~180°.\n- Child geometry is driven inside `updateLayout` via `transition.updateFrame(node: self.listView, …)` — the project never relies on ASDisplayNode's automatic `layout()`.\n\nThe public surface is being narrowed incrementally (e.g. `scroller` → `bounces`/`contentHeight`; the `trackingOffset`/`beganTrackingAtTopOrigin` pair → `didInteractivelyDragFromTopOrigin`). Prefer intent-named accessors over re-exposing raw `ListView` state.\n\n## InstantPage V2 & rich-text messages\n\nTyped markdown with structure the regular message-entity set can't represent (headings, lists, tables, formulas, nested blockquotes) is sent as a **rich message** — a `RichTextMessageAttribute` carrying an `InstantPage`, drawn by `ChatMessageRichDataBubbleContentNode` via the **InstantPage V2** renderer (with AI-streaming progressive reveal, inline custom emoji, and entity cases). The detailed architecture and non-obvious invariants — streaming reveal, V2 table/text-box layout, custom-emoji & entity round-trips, task-list checkboxes, nested blockquotes, thinking blocks, the markdown send / edit / copy / paste paths, and surfacing rich-message media through the shared-media/gallery/preview pipelines via `Message.effectiveMedia` — live in [`docs/instantpage-richtext.md`](docs/instantpage-richtext.md).\n\n## Postbox → TelegramEngine refactor (in progress)\n\nA gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`.\n\n**Historical record:** Wave-by-wave outcomes, the running tally of Postbox-free modules, the full wave-selection guidance, and the `TelegramEngine.Resources` facade inventory (also authoritatively defined in `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`) 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.\n\nSee the log for per-wave detail; the current wave count and the list of still-open migration opportunities live in the `project_postbox_refactor_next_wave.md` memory file.\n\n### Rules that apply to every wave\n\n1. `TelegramCore` does **not** `@_exported import Postbox`. Once a consumer drops `import Postbox`, every remaining Postbox-type reference must use an engine-typealiased equivalent.\n2. **Never typealias `Postbox`, `Account`, or `MediaBox`.** These umbrella types rename without encapsulating. Narrow utility typealiases (`MemoryBuffer`, `PostboxDecoder`, `PostboxEncoder`, `AdaptedPostboxDecoder`, `MediaResource`, …) remain allowed and expected.\n3. No new engine wrapper **structs** unless the wave's spec explicitly allows — only typealiases and thin forwarding methods.\n4. **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.\n5. **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.\n6. Full project build per module. No unit tests exist in this project.\n7. **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.\n8. **Never substitute Postbox protocols (`Media`, `Peer`, `Message`) with `Any` / `AnyObject`** in code that previously used them. Type erasure throws away the domain semantics that the next reader expects. Use the matching engine wrapper (`EngineMedia`, `EnginePeer`, `EngineMessage`) — extending it as needed (e.g. add a missing case-init or convenience). If neither typealias nor wrapper covers the use site, restore the original Postbox import + type for now and flag the case for a future facade. Existing `Any`/`AnyObject` parameters predating the refactor are not in scope for this rule.\n\n### Engine typealias cheat sheet (existing aliases)\n\n```\nPeerId              → EnginePeer.Id\nMessageId           → EngineMessage.Id\nMessageIndex        → EngineMessage.Index\nMessageTags         → EngineMessage.Tags\nMessageAttribute    → EngineMessage.Attribute\nMessageFlags        → EngineMessage.Flags\nMessageForwardInfo  → EngineMessage.ForwardInfo\nMediaId             → EngineMedia.Id\nPreferencesEntry    → EnginePreferencesEntry\nTempBox             → EngineTempBox\nPinnedItemId        → EngineChatList.PinnedItem.Id\nMemoryBuffer        → EngineMemoryBuffer           (added 2026-04)\nPostboxDecoder      → EnginePostboxDecoder         (added 2026-04)\nPostboxEncoder      → EnginePostboxEncoder         (added 2026-04)\nAdaptedPostboxDecoder → EngineAdaptedPostboxDecoder (added 2026-04)\nItemCollectionId    → EngineItemCollectionId       (added 2026-04-20)\nFetchResourceSourceType → EngineFetchResourceSourceType (added 2026-04-20)\nFetchResourceError  → EngineFetchResourceError     (added 2026-04-20)\nStoryId             → EngineStoryId                (added 2026-05-02)\nChatListIndex       → EngineChatListIndex          (added 2026-05-03)\nTempBoxFile         → EngineTempBoxFile            (added 2026-05-03)\nItemCollectionItemIndex → EngineItemCollectionItemIndex (added 2026-05-03)\nItemCollectionViewEntryIndex → EngineItemCollectionViewEntryIndex (added 2026-05-03)\nValueBoxEncryptionParameters → EngineValueBoxEncryptionParameters (added 2026-05-03)\nMessageAndThreadId  → EngineMessageAndThreadId      (added 2026-05-03)\nPeerStoryStats      → EnginePeerStoryStats          (added 2026-05-03)\nMessageHistoryAnchorIndex → EngineMessageHistoryAnchorIndex (added 2026-05-03)\nChatListTotalUnreadStateCategory → EngineChatListTotalUnreadStateCategory (added 2026-05-03)\nChatListTotalUnreadStateStats → EngineChatListTotalUnreadStateStats (added 2026-05-03)\nPeerSummaryCounterTags → EnginePeerSummaryCounterTags (added 2026-05-03)\nChatListTotalUnreadState → EngineChatListTotalUnreadState (added 2026-05-04)\nItemCacheEntryId    → EngineItemCacheEntryId        (added 2026-05-04)\nHashFunctions       → EngineHashFunctions           (added 2026-05-04 wave 251)\nCachedMediaResourceRepresentationResult → EngineCachedMediaResourceRepresentationResult (added 2026-05-04 wave 265)\nMediaResourceDataFetchResult → EngineMediaResourceDataFetchResult (added 2026-05-04 wave 266)\nMediaResourceDataFetchError → EngineMediaResourceDataFetchError (added 2026-05-04 wave 266)\nMediaResourceStatus → EngineMediaResourceStatus     (added 2026-05-04 wave 272)\n```\n\n**Free-function thin forwarders in TelegramCore** (rule 3 allows):\n- `engineFileSize(_ path:, useTotalFileAllocatedSize: Bool = false)` — forwards to Postbox's `fileSize(...)` (added 2026-05-04 wave 268)\n\n**TelegramEngineUnauthorized.resources facade**: `UnauthorizedResources.storeResourceData(id: EngineMediaResource.Id, data:, synchronous:)` — bridges to `account.postbox.mediaBox.storeResourceData` (added 2026-05-04 wave 271)\n\nFor 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).\n\n### MediaResource → EngineMediaResource consumer migration\n\n`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:\n\n- `EngineMediaResource(rawResource)` — wrap a raw `MediaResource`.\n- `engineResource._asResource()` — unwrap to the raw `MediaResource`.\n- `EngineMediaResource.ResourceData(rawResourceData)` — wrap `MediaResourceData`.\n- `EngineMediaResource.Id(rawMediaResourceId)` — wrap `MediaResourceId`.\n\n**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.\n\nDo **not** add opt-in `EngineMediaResource` overloads alongside raw-`MediaResource` overloads. Duplicate signatures fragment the public API and leave the leak in place forever.\n\nFor 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`.\n\n## tgcalls Testbench\n\nThis repo includes a tgcalls testbench (CLI tool, Go/Pion SFU, Docker build) layered on top of the iOS source. All testbench code, build instructions, and architecture docs live inside the tgcalls submodule:\n\n- `submodules/TgVoipWebrtc/tgcalls/CLAUDE.md` — top-level testbench overview, build/run commands\n- `submodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md` — CLI test tool architecture\n- `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/CLAUDE.md` — Go SFU internals\n- `submodules/TgVoipWebrtc/CLAUDE.md` — tgcalls library internals + macOS/Linux build patches\n\nBuild the test binary from this directory with:\n\n`./build-input/bazel-8.4.2 build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli`\n"},"items":[{"name":"CLAUDE.md","path":"CLAUDE.md","title":"CLAUDE.md","content":"# CLAUDE.md\n\nThis file provides guidance to AI assistants when working with code in this repository.\n\n## Build\n\nThe 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.\n\n**Command:**\n\n```sh\npython3 build-system/Make/Make.py --overrideXcodeVersion \\\n --cacheDir ~/telegram-bazel-cache \\\n build \\\n --configurationPath build-system/appstore-configuration.json \\\n --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \\\n --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64\n```\n\nAdd `--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.\n\nThe 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.\n\n**Running tests.** `Make.py test` runs Bazel test targets (same config + codesigning as `build`, forced `debug_sim_arm64`). It accepts `--target <label>` (added 2026-06-19; default `Tests/AllTests`) so a single `ios_unit_test` can run in isolation, e.g.:\n\n```sh\nsource ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache \\\n test --configurationPath build-system/appstore-configuration.json \\\n --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \\\n --gitCodesigningType development --gitCodesigningUseCurrent --target //submodules/TextFormat:TextFormatTests\n```\n\nThe first app-side `ios_unit_test` is `//submodules/TextFormat:TextFormatTests` (the mention/date link codecs). An `ios_unit_test` here needs an `ios_test_runner` pinned to a real device/OS (e.g. `iPhone 17` / `26.5`) — the default runner picks an invalid device and the test process exits 15. **Run new targets via `--target`, not the default suite:** `Tests/AllTests` currently references a dangling `//submodules/TgVoipWebrtc:TgCallsTests`, so the default would fail to build until that suite is repaired.\n\n### Updating the running simulator after a rebuild (whole-`.app` copy)\n\n`simctl install` will NOT replace an already-installed app when the build number is unchanged (installd keeps a hard-link cache), so a rebuilt binary silently doesn't take effect. **Preferred fix: copy the whole freshly-built `.app` over the installed bundle in place.** This is more robust than swapping only the `Frameworks/TelegramUIFramework` binary (no risk of app↔framework version skew), and it preserves the account/login because the **data container is a separate path** (`.../data/Containers/Data/Application/<uuid>/`, keyed by bundle id) — only the **bundle** container is replaced, and the install-DB entry stays valid since the path + bundle id are unchanged.\n\n```sh\nK3=FA6F7462-AA97-42FE-9E57-8DA0593CE756   # iPhone 17 Pro K3 (use the dedicated K-sims, not the shared default)\nBUNDLE=ph.telegra.Telegraph\n# Fresh build output (unzipped bundle, not the .ipa). `-L` is REQUIRED — `bazel-out` is a symlink,\n# so a plain `find bazel-out …` silently returns nothing:\nSRC=\"$(find -L bazel-out -maxdepth 14 -path '*/Telegram_archive-root/Payload/Telegram.app' -type d | head -1)\"\nDEST=\"$(xcrun simctl get_app_container \"$K3\" \"$BUNDLE\" app)\"   # installed bundle path\n# GUARD before the destructive rm: never rm the installed app unless SRC actually resolved,\n# or a failed cp leaves the sim with NO app installed (relaunch then fails).\n[ -x \"$SRC/Telegram\" ] || { echo \"no fresh bundle at SRC=$SRC — aborting\"; exit 1; }\nxcrun simctl terminate \"$K3\" \"$BUNDLE\" 2>/dev/null            # terminate before replacing the running binary\nrm -rf \"$DEST\" && cp -Rp \"$SRC\" \"$DEST\"                        # replace bundle in place; data container untouched\nxcrun simctl launch \"$K3\" \"$BUNDLE\"\n```\n\nThe sim ignores code signing, so the unsigned `Telegram_archive-root` bundle runs fine. Bazel stamps a reproducible `Jan 1 1980` mtime on the copied binary — that's expected, not a stale copy. The `Telegram_archive-root` is regenerated by the Make.py wrapper's post-build packaging; if it's stale/missing after an incremental build, unzip `Payload/Telegram.app` out of `bazel-bin/Telegram/Telegram.ipa` instead. (The older framework-only `cp` of `TelegramUIFramework` still works and is faster, but prefer the whole-`.app` copy to avoid version skew.)\n\n## Code Style Guidelines\n- **Naming**: PascalCase for types, camelCase for variables/methods\n- **Imports**: Group and sort imports at the top of files\n- **Error Handling**: Properly handle errors with appropriate redaction of sensitive data\n- **Formatting**: Use standard Swift/Objective-C formatting and spacing\n- **Types**: Prefer strong typing and explicit type annotations where needed\n- **Documentation**: Document public APIs with comments\n\n## Project Structure\n- Core launch and application extensions code is in `Telegram/` directory\n- Most code is organized into libraries in `submodules/`\n- External code is located in `third-party/`\n- App-side unit tests are minimal: the first `ios_unit_test` (`//submodules/TextFormat:TextFormatTests`) was added 2026-06-19 (run via `Make.py test --target` — see Build). The RichTextEditor SwiftPM package keeps its own suite (`swift test` / `Scripts/iostest.sh`). Most modules still have no tests.\n\n## RichTextEditor editor & the `ChatInputContent` composer\n\nA from-scratch WYSIWYG rich-text editor (`submodules/TelegramUI/Components/RichTextEditor`) is the native chat-composer backend — by default a **dual-field switch** (the composer uses the legacy input and latches to the native editor only when content becomes legacy-non-representable); the `forceNewTextInput` experimental flag (Debug Settings ▸ \"Force Text Field v2\") forces always-native. (This inverted the earlier default+`forceLegacyTextInput`-opt-out scheme.) `ChatInputContent` (a TelegramCore-native value model) replaced `NSAttributedString` as the composer currency. The app-side integration — the model and its load-bearing invariants, composer ↔ editor wiring, the formatting-menu / custom-emoji-mention-date / code-block / inline-media round-trips, rich-message send / edit / pending-display, the long-press-Send send-options preview, and draft persistence (local, cross-device media sync, re-login restore) — lives in [`docs/richtext-composer.md`](docs/richtext-composer.md). Editor internals (the TextKit seam, layout) are the editor's own `submodules/TelegramUI/Components/RichTextEditor/CLAUDE.md`; message **rendering** is [`docs/instantpage-richtext.md`](docs/instantpage-richtext.md).\n\n## Embedded watch app (`Telegram/WatchApp`)\n\nA standalone watchOS Telegram client (developed in the separate `~/build/tgwatch` repo) is vendored into this repo at `Telegram/WatchApp/` and can be embedded into the **device** IPA under `Telegram.app/Watch/`. It is built by `xcodebuild` (not Bazel) and codesigned by the Bazel build.\n\n**Build it:** add `--embedWatchApp` to a Make.py **device** build (`--configuration=debug_arm64` or `release_arm64`) together with `--watchApiId`, `--watchApiHash`, `--watchSigningIdentity`, `--watchProvisioningProfile`. Off by default (it adds a ~4-min xcodebuild step); simulator builds never embed, and the default `debug_sim_arm64` build is unaffected.\n\n**`Telegram/WatchApp/` is a synced snapshot — do not hand-edit it.** The source of truth and dev tooling live in the `tgwatch` repo. To change the watch app, edit it there, then re-sync with `tgwatch/tools/export-sources.sh /abs/path/to/telegram-ios/Telegram/WatchApp` and commit the result. The committed `tgwatch.xcodeproj` is generated (kept via a `!tgwatch.xcodeproj` negation in `Telegram/WatchApp/.gitignore`, since the root `.gitignore` ignores `*.xcodeproj`); `.build`/`.swiftpm`/`xcuserdata` are excluded.\n\n**How it's wired:** `//Telegram:TelegramWatchApp` (rule in `Telegram/prebuilt_watchos.bzl`) runs in **two actions**: `PrebuiltWatchosCompile` (`Telegram/prebuilt_watchos_compile.sh`) runs xcodebuild on the snapshot in a writable temp copy with PLACEHOLDER version/api values (the bundle ids are baked from the snapshot's pbxproj/Info.plist — `ph.telegra.Telegraph.watchkitapp` / `ph.telegra.Telegraph`), emitting an unsigned `.app`; `PrebuiltWatchosPatchSign` (`Telegram/prebuilt_watchos_patch.sh`) then rewrites **six** per-build Info.plist keys (`CFBundleShortVersionString`, `CFBundleVersion`, `TG_API_ID`, `TG_API_HASH`, `CFBundleIdentifier`, `WKCompanionAppBundleIdentifier`) and codesigns the `.app` + nested `TDLibFramework.framework` (identity + the watchkitapp profile from `--define`s). The result feeds the `Telegram` `ios_application`'s `watch_application` slot (gated by the `//Telegram:embedWatchApp` flag). The rule takes `bundle_id` (set to `\"{telegram_bundle_id}.watchkitapp\"` in `Telegram/BUILD`) and derives the host bundle id by stripping the `.watchkitapp` suffix; both are passed to the patch worker as args (not action inputs), so the patch action re-runs when the host bundle id changes but the (expensive) compile stays cached. **The compile action's only inputs are the snapshot (+ its worker)** — so changing the version, build number, api id/hash, host bundle id, or signing identity re-runs only the cheap patch+sign action, not xcodebuild; xcodebuild re-runs only when the snapshot changes. This is correct because none of those values reach the compiled binary: each lands only in the Info.plist (via `$(...)` substitution and a runtime `Bundle.main.object(forInfoDictionaryKey:)` lookup in `Secrets.swift`, except for the bundle-id keys which only Info.plist consumers read).\n\n**Non-obvious invariants** (also in the `.bzl` comments): `AppleBundleInfo`'s public init is banned — use the internal `new_applebundleinfo`; `watch_application` requires BOTH `AppleBundleInfo` (with a non-None `infoplist` File) AND `WatchosApplicationBundleInfo`; the embedded watch app's `CFBundleShortVersionString`/`CFBundleVersion` must exactly equal the host's (sourced from `versions.json['app']` + `--define=buildNumber`); the host does NOT re-sign the embedded watch app, so the worker must sign it; the watch bundle id `ph.telegra.Telegraph.watchkitapp` must track the host `telegram_bundle_id`.\n\n**Status:** verified with **development** signing on `debug_arm64` only. Open follow-ups before App Store shipping: secure timestamp (drop `codesign --timestamp=none`), distribution profile (`get-task-allow=false`), `release_arm64` + `altool --validate-app`, and committing a `Package.resolved` for hermetic remote-SwiftPM resolution.\n\n## View frame ownership\n\nA view does not control its own `frame`. The parent (or a layout system) sets the frame; the view positions its own subviews against `self.bounds` in response.\n\nThis matters in two places specifically:\n\n- **Reusable components (`UIView`/`ASDisplayNode` subclasses).** Public methods like `update(...)` / `apply(...)` rebuild internal state, mutate child frames, and read `self.bounds` to lay them out — but they do not write `self.frame`. The caller has already chosen the frame; mutating it from inside the component overrides that choice and fights the parent's next layout pass.\n- **`asyncLayout`-style content nodes.** The measure pass runs off-main and returns a size; the apply step runs on main and the chat layout system positions the node. A child view that writes `self.frame` from `update()` corrupts the size the parent just measured.\n\nRare exceptions: top-level view-controller views integrating with the system's first-responder/inset model. If you find yourself wanting `self.frame = …` from inside a child view, refactor so the parent positions it instead.\n\n## ChatHistoryListNode composition\n\n`ChatHistoryListNodeImpl` (`submodules/TelegramUI/Sources/ChatHistoryListNode.swift`) **composes** rather than inherits `ListViewImpl` (`submodules/Display/Source/ListView.swift`): it is an `ASDisplayNode` wrapper holding `private let listView: ListViewImpl` and exposes a deliberately narrowed surface (the `ChatHistoryListNode` protocol in `AccountContext` + curated concrete forwarders) instead of the full `ListView` API. `ListViewImpl` gained a `getCustomItemDeleteAnimationDuration` closure hook so the one former `override` works via composition.\n\nThese invariants are **compiler-invisible** — getting them wrong silently breaks the app's primary scroll surface:\n\n- **The π rotation stays on the wrapper** (chat is bottom-up). The wrapper keeps `transform = π` + a `rotated` flag; the child `listView` gets only `rotated = true` (identity transform). So `historyNode.view`/`.layer` remain the rotated surface, and rotation-coupled code — hitTest coordinate conversions, the blur `drawHierarchy` flip, the dust/delete layer, `.layer` animations, and the overscroll-overlay + snapshot-slide reparenting — **stays on `self` (the wrapper)** unchanged.\n- **Only genuine scroll-surface concerns route to the child:** gesture recognizers (selection pan; external taps via `addContentGestureRecognizer`) attach to `self.listView.view` to share the scroll pan's simultaneity environment, and scroller access goes to `self.listView.scroller`.\n- **`let _ = self.view` in `init` is load-bearing.** The old inherited node was view-loaded eagerly (so `self.isNodeLoaded` was always true); `enqueueHistoryViewTransition` gates the history dequeue on it. The wrapper must force-load its view in init or off-screen nodes (created during thread switches) never become ready and `reloadChatLocation`'s completion never fires.\n- **Item nodes are one level deeper.** Any `.supernode` chain / hierarchy-depth assumption passing through the history node gained one level (item → child `listView` → wrapper). E.g. `ChatMessageTransitionNode` converts item rects up `supernode?.supernode?.supernode?.view` (was 2 hops) so the wrapper's rotation is applied as an intermediate transform; a missing hop reflects effect-burst overlays ~180°.\n- Child geometry is driven inside `updateLayout` via `transition.updateFrame(node: self.listView, …)` — the project never relies on ASDisplayNode's automatic `layout()`.\n\nThe public surface is being narrowed incrementally (e.g. `scroller` → `bounces`/`contentHeight`; the `trackingOffset`/`beganTrackingAtTopOrigin` pair → `didInteractivelyDragFromTopOrigin`). Prefer intent-named accessors over re-exposing raw `ListView` state.\n\n## InstantPage V2 & rich-text messages\n\nTyped markdown with structure the regular message-entity set can't represent (headings, lists, tables, formulas, nested blockquotes) is sent as a **rich message** — a `RichTextMessageAttribute` carrying an `InstantPage`, drawn by `ChatMessageRichDataBubbleContentNode` via the **InstantPage V2** renderer (with AI-streaming progressive reveal, inline custom emoji, and entity cases). The detailed architecture and non-obvious invariants — streaming reveal, V2 table/text-box layout, custom-emoji & entity round-trips, task-list checkboxes, nested blockquotes, thinking blocks, the markdown send / edit / copy / paste paths, and surfacing rich-message media through the shared-media/gallery/preview pipelines via `Message.effectiveMedia` — live in [`docs/instantpage-richtext.md`](docs/instantpage-richtext.md).\n\n## Postbox → TelegramEngine refactor (in progress)\n\nA gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`.\n\n**Historical record:** Wave-by-wave outcomes, the running tally of Postbox-free modules, the full wave-selection guidance, and the `TelegramEngine.Resources` facade inventory (also authoritatively defined in `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`) 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.\n\nSee the log for per-wave detail; the current wave count and the list of still-open migration opportunities live in the `project_postbox_refactor_next_wave.md` memory file.\n\n### Rules that apply to every wave\n\n1. `TelegramCore` does **not** `@_exported import Postbox`. Once a consumer drops `import Postbox`, every remaining Postbox-type reference must use an engine-typealiased equivalent.\n2. **Never typealias `Postbox`, `Account`, or `MediaBox`.** These umbrella types rename without encapsulating. Narrow utility typealiases (`MemoryBuffer`, `PostboxDecoder`, `PostboxEncoder`, `AdaptedPostboxDecoder`, `MediaResource`, …) remain allowed and expected.\n3. No new engine wrapper **structs** unless the wave's spec explicitly allows — only typealiases and thin forwarding methods.\n4. **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.\n5. **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.\n6. Full project build per module. No unit tests exist in this project.\n7. **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.\n8. **Never substitute Postbox protocols (`Media`, `Peer`, `Message`) with `Any` / `AnyObject`** in code that previously used them. Type erasure throws away the domain semantics that the next reader expects. Use the matching engine wrapper (`EngineMedia`, `EnginePeer`, `EngineMessage`) — extending it as needed (e.g. add a missing case-init or convenience). If neither typealias nor wrapper covers the use site, restore the original Postbox import + type for now and flag the case for a future facade. Existing `Any`/`AnyObject` parameters predating the refactor are not in scope for this rule.\n\n### Engine typealias cheat sheet (existing aliases)\n\n```\nPeerId              → EnginePeer.Id\nMessageId           → EngineMessage.Id\nMessageIndex        → EngineMessage.Index\nMessageTags         → EngineMessage.Tags\nMessageAttribute    → EngineMessage.Attribute\nMessageFlags        → EngineMessage.Flags\nMessageForwardInfo  → EngineMessage.ForwardInfo\nMediaId             → EngineMedia.Id\nPreferencesEntry    → EnginePreferencesEntry\nTempBox             → EngineTempBox\nPinnedItemId        → EngineChatList.PinnedItem.Id\nMemoryBuffer        → EngineMemoryBuffer           (added 2026-04)\nPostboxDecoder      → EnginePostboxDecoder         (added 2026-04)\nPostboxEncoder      → EnginePostboxEncoder         (added 2026-04)\nAdaptedPostboxDecoder → EngineAdaptedPostboxDecoder (added 2026-04)\nItemCollectionId    → EngineItemCollectionId       (added 2026-04-20)\nFetchResourceSourceType → EngineFetchResourceSourceType (added 2026-04-20)\nFetchResourceError  → EngineFetchResourceError     (added 2026-04-20)\nStoryId             → EngineStoryId                (added 2026-05-02)\nChatListIndex       → EngineChatListIndex          (added 2026-05-03)\nTempBoxFile         → EngineTempBoxFile            (added 2026-05-03)\nItemCollectionItemIndex → EngineItemCollectionItemIndex (added 2026-05-03)\nItemCollectionViewEntryIndex → EngineItemCollectionViewEntryIndex (added 2026-05-03)\nValueBoxEncryptionParameters → EngineValueBoxEncryptionParameters (added 2026-05-03)\nMessageAndThreadId  → EngineMessageAndThreadId      (added 2026-05-03)\nPeerStoryStats      → EnginePeerStoryStats          (added 2026-05-03)\nMessageHistoryAnchorIndex → EngineMessageHistoryAnchorIndex (added 2026-05-03)\nChatListTotalUnreadStateCategory → EngineChatListTotalUnreadStateCategory (added 2026-05-03)\nChatListTotalUnreadStateStats → EngineChatListTotalUnreadStateStats (added 2026-05-03)\nPeerSummaryCounterTags → EnginePeerSummaryCounterTags (added 2026-05-03)\nChatListTotalUnreadState → EngineChatListTotalUnreadState (added 2026-05-04)\nItemCacheEntryId    → EngineItemCacheEntryId        (added 2026-05-04)\nHashFunctions       → EngineHashFunctions           (added 2026-05-04 wave 251)\nCachedMediaResourceRepresentationResult → EngineCachedMediaResourceRepresentationResult (added 2026-05-04 wave 265)\nMediaResourceDataFetchResult → EngineMediaResourceDataFetchResult (added 2026-05-04 wave 266)\nMediaResourceDataFetchError → EngineMediaResourceDataFetchError (added 2026-05-04 wave 266)\nMediaResourceStatus → EngineMediaResourceStatus     (added 2026-05-04 wave 272)\n```\n\n**Free-function thin forwarders in TelegramCore** (rule 3 allows):\n- `engineFileSize(_ path:, useTotalFileAllocatedSize: Bool = false)` — forwards to Postbox's `fileSize(...)` (added 2026-05-04 wave 268)\n\n**TelegramEngineUnauthorized.resources facade**: `UnauthorizedResources.storeResourceData(id: EngineMediaResource.Id, data:, synchronous:)` — bridges to `account.postbox.mediaBox.storeResourceData` (added 2026-05-04 wave 271)\n\nFor 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).\n\n### MediaResource → EngineMediaResource consumer migration\n\n`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:\n\n- `EngineMediaResource(rawResource)` — wrap a raw `MediaResource`.\n- `engineResource._asResource()` — unwrap to the raw `MediaResource`.\n- `EngineMediaResource.ResourceData(rawResourceData)` — wrap `MediaResourceData`.\n- `EngineMediaResource.Id(rawMediaResourceId)` — wrap `MediaResourceId`.\n\n**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.\n\nDo **not** add opt-in `EngineMediaResource` overloads alongside raw-`MediaResource` overloads. Duplicate signatures fragment the public API and leave the leak in place forever.\n\nFor 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`.\n\n## tgcalls Testbench\n\nThis repo includes a tgcalls testbench (CLI tool, Go/Pion SFU, Docker build) layered on top of the iOS source. All testbench code, build instructions, and architecture docs live inside the tgcalls submodule:\n\n- `submodules/TgVoipWebrtc/tgcalls/CLAUDE.md` — top-level testbench overview, build/run commands\n- `submodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md` — CLI test tool architecture\n- `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/CLAUDE.md` — Go SFU internals\n- `submodules/TgVoipWebrtc/CLAUDE.md` — tgcalls library internals + macOS/Linux build patches\n\nBuild the test binary from this directory with:\n\n`./build-input/bazel-8.4.2 build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli`\n","category":"root","tokens":5988}]}