Add the RichText.textCustomEmoji(fileId:alt:) case end-to-end: model + Postbox coding, FlatBuffers schema/codec, and Api.RichText parsing/serialization (lossless), plus display in the InstantPage V2 renderer. The emoji renders as an InlineStickerItemLayer that participates in the streaming reveal (pops in as the reveal cursor crosses it) and is gated by the bubble's visibility rect, propagated recursively through the nested V2 view tree. Also frees the CTRunDelegate extent buffers for the image/formula/custom-emoji attachment arms and documents the feature in CLAUDE.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
28 KiB
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:
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
View frame ownership
A 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.
This matters in two places specifically:
- Reusable components (
UIView/ASDisplayNodesubclasses). Public methods likeupdate(...)/apply(...)rebuild internal state, mutate child frames, and readself.boundsto lay them out — but they do not writeself.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. 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 writesself.framefromupdate()corrupts the size the parent just measured.
Rare 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.
AI streaming animation (rich-text bubbles)
ChatMessageRichDataBubbleContentNode shows a "Thinking…" shimmer header and progressively reveals InstantPage V2 content while TypingDraftMessageAttribute is on the message. Mirrors the older animation in ChatMessageTextBubbleContentNode, adapted to the heterogeneous V2 layout.
Spec: docs/superpowers/specs/2026-05-19-richdata-streaming-animation-design.md. Plan: docs/superpowers/plans/2026-05-19-richdata-streaming-animation.md.
Where things live
| File | Responsibility |
|---|---|
submodules/TelegramUI/Components/StreamingTextReveal/Sources/TextRevealController.swift |
Pacing controller, shared by both bubbles. EWMA inter-arrival → velocity-smoothed cursor. |
submodules/InstantPageUI/Sources/InstantPageRenderer.swift (InstantPageV2TextView) |
Drawing split: private TextRenderView does draw(_) inside a renderContainer whose layer carries a revealMaskLayer; new chars spawn cropped SnippetLayer siblings of the render container that animate in (blur + alpha + scale + position) and are absorbed into the mask on completion. Ported from InteractiveTextComponent. |
submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift |
InstantPageV2RevealCostMap + InstantPageV2View.applyReveal(revealedCount:costMap:animated:). Bridges the global width-based cursor to per-text-view char counts (via charCountForWidthBudget) and per-item visibility / table-row pop-in. |
submodules/InstantPageUI/Sources/InstantPageV2Layout.swift |
InstantPageTextLine.characterRects (line-local CT coords, baseline-relative positive-up) populated when computeRevealCharacterRects: true is passed to layoutInstantPageV2(...). Uses CTFontGetBoundingRectsForGlyphs for actual glyph ink, not advance widths. |
submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/... |
Streaming detection (TypingDraftMessageAttribute), "Thinking…" header layout, display-link wiring, container sizing. |
Non-obvious invariants
- Cost unit is points of width, not characters. Each item's cost = its width in points along the reading direction. Text contributes sum of glyph ink widths; non-text items contribute
frame.width. Table cells are floored atcell.frame.widthso narrow- or empty-cell tables don't race through the cursor. Reveal pace becomes "points per second" — uniform across content types. - Mask uses per-glyph ink bounds, unioned per line. Each revealed glyph's mask rect comes from
CTFontGetBoundingRectsForGlyphs(not advance widths) so italics, accents, descenders are covered exactly. Per line, glyphs are unioned into one mask rect; consecutive fully-revealed lines union further — fully-revealed prefix is always oneCALayer. containerNodedoes ALL the clipping. During streaming, containerNode is sized tostreamingHeaderOffset + revealedItemsMaxY(no closing pad). The bubble itself is taller (revealedContentSize.height + 2) — the strip below containerNode is empty bubble background. pageView keeps its fullpageLayout.contentSize; anything past containerNode's bottom is clipped at containerNode (clipsToBounds = trueset in init). Do NOT shorten the pageView or setpageView.clipsToBounds.- The pageView is rebuilt on every
stableVersionbump. V2View's render context is constructor-fixed, so each AI chunk creates a brand-newInstantPageV2View. The reveal cursor survives onTextRevealController(owned by the bubble). The seed callapplyReveal(revealedCount: previousAnimateGlyphCount, …, animated: false)afterensurePageViewre-applies state to the fresh V2View so there's no flash of full-text-then-mask. - Layout cache key includes
message.stableVersion. Each AI chunk bumps stableVersion; without this the cached layout would shadow newly-arrived content. TypingDraftMessageAttributeis the streaming gate. Same trigger TextBubble uses. The InstantPage'sisCompleteflag is informational only.- Width-based cost → char count bridge. Mask APIs (
updateRevealCharacterCount) still take character counts.applyRevealEntrycallscharCountForWidthBudget(textItem:widthBudget:)to translate the width-based local cursor into the per-text-view character count. Thinking…header positioning matches TextBubble.streamingTextFrame.origin = (bubbleInsets.left - textInsets.left, topInset - textInsets.top).streamingHeaderOffset= visible bottom + 1pt spacing = where pageView'sframe.origin.yand statusFrame y-shift attach. Bubble minimum width includesvisible_thinking_width + bubbleInsets.left + bubbleInsets.right + 2.- Display-link tick re-layouts on extent change. Tick reads
revealedContentSizeat the new cursor; if the height differs from the previous cursor, callsrequestFullUpdate. So the bubble grows in flight when the cursor crosses a line/item boundary, not just between chunks. Tick passesanimated: truetoapplyRevealto fire the snippet pop-in.
Inline custom emoji (RichText.textCustomEmoji)
RichText.textCustomEmoji(fileId:alt:) renders an inline animated custom emoji inside rich-data bubbles. Covers API parsing, Postbox + FlatBuffers serialization, and display in the InstantPage V2 renderer; the emoji participates in the streaming reveal above.
Where things live
| File | Responsibility |
|---|---|
submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift |
Enum case textCustomEmoji(fileId: Int64, alt: String) + Postbox coding (discriminator 17, keys ce.f/ce.a), ==, plainText (returns alt), and FlatBuffers codec. |
submodules/TelegramCore/FlatSerialization/Models/RichText.fbs |
FlatBuffers schema — RichText_CustomEmoji union member + table. Source of truth; the Bazel flatc genrule regenerates *_generated.swift at build time (the checked-in Sources/*_generated.swift is stale). |
submodules/TelegramCore/Sources/ApiUtils/RichText.swift |
Api.RichText.textCustomEmoji ⇄ Swift, lossless both ways. |
submodules/InstantPageUI/Sources/InstantPageTextItem.swift (attributedStringForRichText) |
Emits a single placeholder char carrying ChatTextInputAttributes.customEmoji (a ChatTextInputTextCustomEmojiAttribute) + a CTRunDelegate sized font.pointSize · 24/17. |
submodules/InstantPageUI/Sources/InstantPageV2Layout.swift (line-breaker) |
Collects per-line InstantPageTextLine.emojiItems; overwrites each placeholder char's characterRect with a full cell (width = itemSize) so it feeds the reveal cost map. |
submodules/InstantPageUI/Sources/InstantPageRenderer.swift (InstantPageV2View) |
Owns the InlineStickerItemLayers: updateInlineEmoji (create/reuse/remove/position), updateEmojiReveal (reveal-driven pop-in), updateEmojiVisibility + propagateVisibilityRect. Layers attach to each text view's emojiContainerView. |
Non-obvious invariants
- flatc casing/
requiredgotchas. EditRichText.fbs, not the generated Swift. Scalars (long) cannot be(required)— only strings/tables can. A union memberRichText_CustomEmojigenerates the Swift enum case.richtextCustomemoji(everything after the suffix's first letter is lowercased); the table type staysTelegramCore_RichText_CustomEmojiand field accessors keep.fbscasing (value.fileId). See theflatbuffers-codegenmemory. ChatTextInputTextCustomEmojiAttributeis reused end-to-end (display layer ⇄ layout model). The attribute is written to the placeholder inattributedStringForRichTextand read back by the V2 line-breaker under the SAME key (ChatTextInputAttributes.customEmoji);InlineStickerItemLayer.initconsumes it directly and resolves the file lazily fromfileId.- Emoji participates in the streaming reveal. Its placeholder char's
characterRectis overwritten to a full cell (width =itemSize, baseline-relative bottom aty=0), so the width-based cost map charges it like other content.updateEmojiRevealpops the layer in (alpha 0→1 + scale) whencharIndexInItem < currentRevealCharacterCount; unrevealed → opacity 0. - Layers sit ABOVE the reveal mask. They attach to
InstantPageV2TextView.emojiContainerView(a sibling aboverenderContainer), NOT inside it — so the reveal mask wipes glyphs while emoji pop in independently. Adding a CTRunDelegate-glyph to the mask would clip-wipe them instead. - Layers are owned by
InstantPageV2View, not the text view. Keyed byInlineStickerItemLayer.Key(id: fileId, index: occurrence). The pageView is rebuilt perstableVersionbump (see streaming section), so the dict starts fresh each chunk — no orphan/leak across rebuilds; within one view, stale keys are pruned. visibilityRectgates looping;nilmeans "not visible". The bubble'svisibilityoverride pushes a full-width sub-rect to the rootpageView.visibilityRect, re-pushed in the apply closure afterpageView.frameis set (becausestreamingHeaderOffsetshifts across chunks without avisibilitychange).propagateVisibilityRectconverts the rect into each nested V2View's coordinate space (self.convert(_:to:)) for details bodies / table cells+title, fanning out via each child'sdidSet.- CTRunDelegate extent buffers must be freed. Every inline-attachment arm (
.image/.formula/.textCustomEmoji) inattributedStringForRichTextallocates anextentBuffer; thedealloccallback mustdeallocate()it (it re-runs per layout pass).
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. 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-05-04): 238 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
TelegramCoredoes not@_exported import Postbox. Once a consumer dropsimport Postbox, every remaining Postbox-type reference must use an engine-typealiased equivalent.- Never typealias
Postbox,Account, orMediaBox. These umbrella types rename without encapsulating. Narrow utility typealiases (MemoryBuffer,PostboxDecoder,PostboxEncoder,AdaptedPostboxDecoder,MediaResource, …) remain allowed and expected. - No new engine wrapper structs unless the wave's spec explicitly allows — only typealiases and thin forwarding methods.
- 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. - 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.
- Full project build per module. No unit tests exist in this project.
- TelegramCore never imports UIKit/Display.
TelegramCoreis shared with the Telegram-Mac codebase; its Bazeldepsand source files must not reference UIKit, Display, or any Apple-UI framework. UIKit-needing helpers (image scaling, rendering, etc.) stay in consumer-side submodules. - Never substitute Postbox protocols (
Media,Peer,Message) withAny/AnyObjectin 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. ExistingAny/AnyObjectparameters predating the refactor are not in scope for this rule.
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)
StoryId → EngineStoryId (added 2026-05-02)
ChatListIndex → EngineChatListIndex (added 2026-05-03)
TempBoxFile → EngineTempBoxFile (added 2026-05-03)
ItemCollectionItemIndex → EngineItemCollectionItemIndex (added 2026-05-03)
ItemCollectionViewEntryIndex → EngineItemCollectionViewEntryIndex (added 2026-05-03)
ValueBoxEncryptionParameters → EngineValueBoxEncryptionParameters (added 2026-05-03)
MessageAndThreadId → EngineMessageAndThreadId (added 2026-05-03)
PeerStoryStats → EnginePeerStoryStats (added 2026-05-03)
MessageHistoryAnchorIndex → EngineMessageHistoryAnchorIndex (added 2026-05-03)
ChatListTotalUnreadStateCategory → EngineChatListTotalUnreadStateCategory (added 2026-05-03)
ChatListTotalUnreadStateStats → EngineChatListTotalUnreadStateStats (added 2026-05-03)
PeerSummaryCounterTags → EnginePeerSummaryCounterTags (added 2026-05-03)
ChatListTotalUnreadState → EngineChatListTotalUnreadState (added 2026-05-04)
ItemCacheEntryId → EngineItemCacheEntryId (added 2026-05-04)
HashFunctions → EngineHashFunctions (added 2026-05-04 wave 251)
CachedMediaResourceRepresentationResult → EngineCachedMediaResourceRepresentationResult (added 2026-05-04 wave 265)
MediaResourceDataFetchResult → EngineMediaResourceDataFetchResult (added 2026-05-04 wave 266)
MediaResourceDataFetchError → EngineMediaResourceDataFetchError (added 2026-05-04 wave 266)
MediaResourceStatus → EngineMediaResourceStatus (added 2026-05-04 wave 272)
Free-function thin forwarders in TelegramCore (rule 3 allows):
engineFileSize(_ path:, useTotalFileAllocatedSize: Bool = false)— forwards to Postbox'sfileSize(...)(added 2026-05-04 wave 268)
TelegramEngineUnauthorized.resources facade: UnauthorizedResources.storeResourceData(id: EngineMediaResource.Id, data:, synchronous:) — bridges to account.postbox.mediaBox.storeResourceData (added 2026-05-04 wave 271)
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 rawMediaResource.engineResource._asResource()— unwrap to the rawMediaResource.EngineMediaResource.ResourceData(rawResourceData)— wrapMediaResourceData.EngineMediaResource.Id(rawMediaResourceId)— wrapMediaResourceId.
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:
- Target a
MediaBoxmethod whose Postbox signature uses clean leaf types (MediaResourceId,Data,String,Bool) and whose return type is either non-Postbox or has an existingEngine*wrapper. - Pre-flight inventory: classify each call site as Shape A (
context.account.postbox.mediaBox.X(...), migratable), Shape B (different overload viaAccountContext, migratable), Shape C (rawaccount: Accountlocal, skip — needs per-module rework), Shape D (self.postboxstored field, skip). Also check foraccountManager.mediaBox.X(...)— a separate migration path. - Design facade with
EngineMediaResource.IdorEngineMediaResourceparameters and engine-or-clean return types; preserve default argument values. - WIP-interference check:
git status --short | grep -v "^??"— if any Shape-A site is in a WIP file, either skip those sites or wait. - 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 inTelegramEngineResources.swift. - Batch duplicate call expressions with
replace_all=true. - 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:) |
clearCachedMediaResources(mediaResourceIds:) |
223 | _internal_clearCachedMediaResources |
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.
tgcalls Testbench
This 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:
submodules/TgVoipWebrtc/tgcalls/CLAUDE.md— top-level testbench overview, build/run commandssubmodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md— CLI test tool architecturesubmodules/TgVoipWebrtc/tgcalls/tools/go_sfu/CLAUDE.md— Go SFU internalssubmodules/TgVoipWebrtc/CLAUDE.md— tgcalls library internals + macOS/Linux build patches
Build the test binary from this directory with:
./build-input/bazel-8.4.2 build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli