Thread PresentationStrings through RichText/InstantPageBlock/InstantPage
previewText(), replacing the hardcoded //TODO:localize placeholders
("Photo", "Fx", "Table", "Map", ...) with localized keys and reusing the
existing Message.Photo/Video/Location strings. Add RichTextPreview.Formula
("[formula]"), RichTextPreview.Table ("[table]"), and RichTextPreview.Music
("Music").
The .audio block previously rendered the wrong label (Message.Audio is
"Voice Message"). Thread InstantPage.media down so the block can resolve
media[id] as TelegramMediaFile and split: isVoice -> "Voice Message",
otherwise -> "Music", mirroring MessageContentKind's voice/music handling.
Update both previewText() call sites (MessageContentKind, ChatListItemStrings)
to pass strings, and complete the InstantPageListItem migration that was
left on the old signature.
Also remove the dead streaming-status ("Thinking...") rendering block from
ChatMessageTextBubbleContentNode (guarded by an always-false `!"".isEmpty`)
along with the now-orphaned streamingTextFrame layout machinery it fed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Message/EngineMessage.effectiveMedia (= message.media when non-empty, else
richText.instantPage.allMedia()) and route the media-consuming sites through it
so a rich message's instant-page media participates in the same pipelines as
normal message.media: shared-media grids/file-rows, search media grid, gallery
open + item nodes + footer, the peer audio/voice playlist, secret-media preview,
resource-by-id resolution, recent downloads, downloaded-media store, delete-time
resource cleanup, cache-usage stats, the in-chat download manager, and the
context-menu / share actions (Save to Camera Roll, copy image, save audio/music
to files). For normal messages effectiveMedia == message.media, so each swap is
behavior-preserving; rich messages render their own bubble via
ChatMessageRichDataBubbleContentNode (not the text/file bubbles), so those paths
are deliberately untouched, as are the forward path (the attribute travels with
the forward) and the markdown-based rich-edit path. First-media scope for now.
See docs/instantpage-richtext.md for the full architecture + invariants.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add InstantPage.allMedia() (recursive gatherer over the page's blocks —
audio/collage/cover/details/image/list/slideshow/video — resolving each via
the page's [MediaId: Media] dict) and feed it into tagsForStoreMessage, so a
rich message's instant-page media is indexed into MessageTags
(photo/photoOrVideo/video/gif/voice/file). Rich messages now appear in the
per-peer shared-media tabs and tag-queried surfaces.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
openInstantPageMedia builds the gallery from allMedias() filtered to
.image / .file. An audio track is an EngineMedia.file (a TelegramMediaFile
with an .Audio attribute), so it passed the .file case and appeared as a
gallery entry. Audio is enrolled in allMedias() only so the audio player
can gather sibling tracks for its playlist (handleOpenAudioTap); it is not
visual gallery content.
Split the .file case to keep videos/gifs/documents but drop music and
voice (!file.isMusic && !file.isVoice). No-op for V1 full-page Instant
View (its mediasFromItems never enrolled audio); fixes the V2 rich bubble.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inline attachments anchored their left edge at
CTLineGetOffsetForStringIndex(line, range.location), which is the glyph's
LEFT edge for LTR runs but its RIGHT edge for RTL runs (string index
increases leftward). On an RTL line (e.g. an Arabic thinking block) this
shoved emoji/images/formulas ~one advance (~24pt) too far right while the
CoreText-drawn text stayed correct.
Add v2LeadingOffsetForRange(_:range:), which returns
min(offset(start), offset(end)) with directional-boundary secondary-offset
handling — the true leading edge in both directions. Mirrors
Display.TextNode.addEmbeddedItem and the strikethrough/underline/spoiler
decorations already in this file (which used the min/abs form; the inline
attachments had regressed to a single offset). Applied at all 5 sites:
the emoji/image/formula display frames and the emoji/image characterRect
(reveal mask). Widths unchanged; only x corrected. LTR is byte-identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Server-sent rich messages can arrive partial (RichMessage isPartial ->
instantPage.isComplete == false). The bubble renders the partial page with
an inline "Show more" link; tapping it fetches the full page once and
expands in place.
- RichTextMessageAttribute keeps the partial instantPage and gains an
optional fullInstantPage, filled by engine.messages.requestFullRichText
via transaction.updateMessage. The seed-config merge preserves a fetched
fullInstantPage across later server updates.
- ChatMessageRichDataBubbleContentNode: node-local, per-message expand
state (collapsed on every fresh display, even when fullInstantPage is
already cached); renders (expanded ? fullInstantPage : nil) ?? instantPage;
gates the link on !expanded && !isComplete (+ not streaming, Cloud-only,
not preview/messageOptions); expand state threaded through both layout
caches; shimmer while fetching (instant when cached); bubble grows
downward on expand via setInvertOffsetDirection.
- New localized string Chat.RichText.ShowMore; docs in
docs/instantpage-richtext.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A details block nested inside another rendered (title + chevron) but could not
expand: its body is hosted by a fresh InstantPageV2View whose detailsTapped
closure was never wired (only the top-level view's is, by the bubble), so the
nested title's tap hit a nil closure and was dropped. Wire the body view's
detailsTapped to chain through this view's onTitleTapped, which makeItemView
already routes to the owning view's detailsTapped -> the bubble's toggle
handler; this chains up through arbitrary nesting depth.
Also make the bubble's defaultExpanded(forDetailsIndex:) recurse into expanded
bodies so a nested details whose model default is expanded toggles from the
correct state (the flat top-level scan missed nested indices).
Known pre-existing limitation (unchanged): V2 keys expansion state by a flat
DFS index, so expanding a nested details can shift sibling indices.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A code block inside a blockquote was pinned to local x=0 / full width, so its
background bled out under the quote bar instead of insetting to the quote's
content gutter like the quote's text. Detect quote nesting via the raised
child inset (threaded as LayoutContext.pageHorizontalInset) and, when nested,
inset the background to honor horizontalInset and give it an 8pt rounded
corner. Top-level and <details> code blocks stay flush, full-width, and square
(matching V1) — the bubble's own rounded clip handles their edges.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a rich message's page-level rtl flag is set, mirror every V2 block
ornament and content column onto the trailing (right) edge, gated solely on
the explicit flag (no content auto-detection, no send/wire change, V2 only):
- Two centralized leading/trailing geometry helpers (instantPageV2ContentColumnX
/ instantPageV2LeadingEdgeX) as the single source of truth for gutter side.
- Lists: content column mirrors to match the marker (8pt trailing gap).
- Blockquotes: bar + text gutter on the trailing edge, single- and multi-block
(multi-block child band rigid-translated; caption uses its own single-inset
delta).
- <details>: chevron + title indented from the right (rtl flag on the item).
- Uniform trailing alignment for paragraph/heading/simple-text.
- Keep the full bounding width for non-leading alignment so display-time
alignment lands at the true edge: .right (RTL text) was collapsing short
content to the leading edge under fitToWidth (the list-gap), and .center
(pull quotes) was failing to center body + author. Only .natural shrinks.
LTR output is unchanged (.right/.center are only produced under RTL contexts /
pull quotes). Builds clean; runtime-verified on an rtl message with lists,
blockquotes, pull quotes, and inline formatting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render RichText.textDate (API constructor textDate(flags, text, date)) as a
locale/timezone-correct, relative-aware, tappable date inside V2 rich-message
bubbles, reusing the existing stringForEntityFormattedDate autoformatter.
- Model: add RichText.textDate(text📅format:) with Postbox coding,
FlatBuffers schema (RichText.fbs; flatc regenerates), Equatable and plainText;
parse flags->DateTimeFormat? at the API boundary (flags==0 => nil, matching
the messageEntityFormattedDate convention) with a symmetric apiRichText()
round-trip. The new case is handled in every exhaustive RichText switch
(SyncCore, ApiUtils, InstantPageTextItem, InstantPageAnchorPath,
InstantPagePreviewText, BrowserReadability, BrowserMarkdown).
- Render: attributedStringForRichText gains an optional formatDate closure;
the V2 layout builds it from the message's strings/dateTimeFormat and threads
it to every text-building call site, formatting the timestamp and tagging the
run with TelegramTextAttributes.Date.
- Tappable: the Date attribute is added to linkSelectionRects hit-testing and
mapped in the rich bubble's entityTapContent to the existing .date tap action
(opens the date context menu).
- Live update: the V2 layout accumulates a page-wide formattedDateUpdatePeriod
(>=10s) for relative dates; the rich bubble schedules a refresh timer on it,
recreating it only when the period changes. The currentPageLayout cache is
bypassed for relative-date pages so the timer-driven relayout actually
re-formats the date.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tapping an intra-message #fragment link in a rich-data bubble now scrolls
the chat so the matching anchor lands just below the content-area top,
expanding any enclosing collapsed <details> first. Anchors come from
server/AI-sent InstantPages (block .anchor or inline RichText.anchor); the
compose path is unchanged.
- InstantPageV2View.anchorFrame(name:) resolves an anchor's frame in the
live layout (text/codeBlock/thinking/details/table), mirroring findTextItem.
- instantPageAnchorPath(in:name:) is a pure model walk returning the
<details>-sibling-ordinal path to an anchor; its recursion set matches
exactly the containers the V2 layout flattens through layoutBlock
(.blockQuote/.cover/.list .blocks), keeping ordinals consistent with the
layout's detailsIndexCounter.
- InstantPageV2View.firstCollapsedDetails(forOrdinalPath:) maps that path to
the first not-yet-expanded details' live index (read, never reproduced).
- The rich bubble fills the two stubbed seams: getAnchorRect, and a
fragment-link route in tapActionAtPoint that drives a resolve -> expand ->
scroll state machine (pendingScrollAnchor + progress guard + a
post-relayout hook). Taps are gated off while the message streams.
Verified by the full Bazel build; runtime behavior not yet exercised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Non-premium users are blocked at send/edit when typed markdown would
produce a RichTextMessageAttribute, and shown a .premiumPaywall toast
(todo-gate pattern). Saved Messages and premium-disabled regions are
exempt. Receiving/rendering rich messages is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render and play InstantPageBlock.audio in the InstantPage V2 renderer
(rich-data message bubbles + the rich send preview), where audio was
previously an inert grey placeholder.
- New InstantPageV2AudioContentNode replicates the standard music message
bubble (ChatMessageInteractiveFileNode's music layout): a SemanticStatusNode
control (album art via playerAlbumArt + play/pause) with a download/progress
overlay, title + "duration · performer" lines, exact fonts/colors from
theme.chat.message.{incoming|outgoing}. Tap is driven by a
UITapGestureRecognizer (ASControl .touchUpInside is cancelled by the chat
ListView's gesture system). V1's InstantPageAudioNode is unchanged.
- Playback runs on InstantPageMediaPlaylist with a discriminated, message-scoped
InstantPageMediaPlaylistId (.instantPage / .richMessage) so concurrent
rich-message audio bubbles don't collide; the big control's play/pause comes
from filteredPlaylistState, the overlay's download/progress from
messageMediaFileStatus, and fetch goes through the fetch manager.
- Rich-message audio fetches via a MessageReference (threaded through the V2
render context) instead of the synthesized webpage; FetchedMediaResource's
.message revalidation arm now also searches RichTextMessageAttribute instant
pages, so a stale instant-page audio/image reference can recover. Corrected a
dormant inverted InstantPagePlaylistLocation.isEqual.
- New .mediaAudio laid-out item + layout/reveal-cost arms; the audio block lays
out full-width at the file node's music normHeight.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
InstantImageGalleryItem.animateIn animated the transition surface-copy view
to transformedCopyViewFinalFrame (a point in the gallery item node's own
coordinate space) instead of transformedSurfaceFinalFrame (that point expressed
in the transition surface's space). ChatImageGalleryItem and
UniversalVideoGalleryItem both use the surface-space frame here; only
InstantImageGalleryItem (the InstantView photo item) had the slip.
It was harmless in V1's full-page Instant View because the surface was
window-scale and roughly aligned with the gallery, but in the V2 rich-data
chat bubble the surface is the bubble's container inside the scrolled,
flipped chat list (observed window frame ~ y:-1097 h:2107), so the
gallery-space target landed ~1100pt off-screen and the photo snapshot flew
away on open. Video used UniversalVideoGalleryItem and was unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port V1's collage and slideshow InstantPage blocks into the V2 renderer
(previously grey-box placeholders).
- Collage: layoutCollage computes the mosaic (MosaicLayout, the grouped-message
engine) over inner image/video sizes and flattens it into existing top-level
.mediaImage/.mediaVideo items + a caption, so gallery / reveal-cost / registry
/ hidden-media all handle the cells with no collage-specific code. Right-edge
cells bleed 4pt for the bubble's rounded clip.
- Slideshow: a new .slideshow laid-out item + InstantPageV2SlideshowView, an
eager paged carousel (UIScrollView + PageControlNode) of InstantPageImageNode
pages, wired through frame/offsetBy/collectMedias/stableId/reuse/makeItemView
and the reveal-cost non-text list.
- Gallery transitions generalized onto InstantPageItemView via
instantPageTransitionNode(for:)/instantPageUpdateHiddenMedia(_:) (default
nil/no-op; explicit per-class witnesses on the 4 static media views, the
slideshow forwards to its live pages) so the multi-media slideshow can
participate alongside single-media views.
Docs: document both in docs/instantpage-richtext.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AI/server-sent .map blocks can arrive with dimensions == 0x0 (the wire
w/h are required Int32, but the sender may put 0; our parse and both
serializers preserve whatever arrives). A zero naturalSize.height hit
instantPageV2MediaFrame's else branch and returned a height-0 frame:
the map collapsed to no space, the caption slid up into it, and the V1
node's pin floated over the caption. A zero-sized MapSnapshotMediaResource
would also make MKMapSnapshotter render nothing.
Substitute PixelDimensions(600, 300) (2:1) whenever width <= 0 ||
height <= 0, feeding effectiveDimensions to BOTH the layout naturalSize
AND the InstantPageMapAttribute so the snapshot resource is non-zero and
actually renders. Scoped to the V2 .map arm; V1 (real web articles)
always carries real dimensions and never trips this.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All block-media kinds except .audio now lay out edge-to-edge (0 inset) with
cornerRadius 0; the bubble's existing rounded containerNode clip rounds media at
the bubble edge. Small images keep natural size (not upscaled); captions stay
inset. Shared instantPageV2MediaFrame helper + flush flag on the two media
layout helpers. V1 unchanged. Invariants documented in docs/instantpage-richtext.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove stale data from CLAUDE.md and split out the large InstantPage V2 /
rich-text feature documentation into its own file.
- Drop two dead spec links (table inset/corner-radius design docs no longer
exist), the orphaned debugRichText cleanup note (flag is read by nothing),
and the stale "238 waves as of 2026-05-04" count.
- Remove the duplicated Postbox "Wave-selection guidance" and "facade
inventory" subsections; both live in docs/superpowers/postbox-refactor-log.md
and TelegramEngineResources.swift (pointer folded into the Historical record).
- Move the 13 InstantPage V2 / rich-text sections to
docs/instantpage-richtext.md, leaving a brief pointer in CLAUDE.md.
CLAUDE.md: 507 -> 161 lines.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-applies commit 1120b9084e (reverted in ef2c1d47cc during install
debugging). The install failure was proven to be the oversized watch
binary (fixed by DEAD_CODE_STRIPPING), not the bundle-id mechanism — which
was verified to produce a byte-identical artifact for ph.telegra.Telegraph.
xcodebuild bakes PRODUCT_BUNDLE_IDENTIFIER=<host>.watchkitapp and the watch
Info.plist derives WKCompanionAppBundleIdentifier via
$(PRODUCT_BUNDLE_IDENTIFIER:base), so the embedded watch app is correct for
any host (ph.telegra.Telegraph and org.telegram.TelegramInternal/enterprise)
with no post-build plist patching.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- layoutTextItem now sizes a text item to the true font line height (A + D)
instead of the cap box: the line stack starts at lineBoxTopInset (the
ascender headroom, max(0, fontAscent - fontLineHeight)) and the returned
height is padded by the last line's descender. Inter-line advance is
unchanged and per-line frames stay the cap box, so the baseline draw,
decorations, reveal mask, and inline attachments translate consistently;
the page grows.
- Size inline custom emoji to ~the font line height
(font.ascender - font.descender + 4*pointSize/17) so they fit the taller
line box instead of overflowing it; the line is not inflated.
- Add a dedicated code-block text style for rich messages (monospace font +
codeBlock theme attribute) threaded through the rich-data bubble and send
preview; unify the bubble code-block/separator colors.
- Adjust inter-block spacings and assorted V2 layout/divider details.
- Document the true-font-height box and emoji sizing in CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shift the line stack down by the ascender headroom (A - L, as
`lineBoxTopInset` to avoid colliding with the existing formula-bleed
`topInset` local) and pad the returned height by the last line's
descender (D), so a single-line V2 text item is a true ascent/descent
line box measuring exactly A + D instead of the cap box A - L. Inter-line
advance unchanged; formulas still inflate via lineAscent/extraDescent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Local in-progress work (committed at the author's request as a checkpoint
before unrelated true-font-height work lands on top): introduces a
dedicated `codeBlock` InstantPage font/text style (.monospace / .codeBlock
font cases + monospace font resolution), adds a `codeBlock` field to the
InstantPage text-attributes theme and threads it through the rich-data
bubble and the send preview, and unifies the rich-bubble code-block
background/secondary-control colors.
Task 1: 3-line metric change in layoutTextItem (topInset shift + descender
pad). Task 2: re-tune the streaming chat-bubble bottom inset (the now-
contained descender makes the +6.0 hack partly redundant). Task 3: broad
visual verification of the remaining consumers (decorations, reveal mask,
lists, blockquotes, tables, title) with one gated table-inset fix. Task 4:
spec acceptance checklist. Verification model adapted to this repo: full
Bazel build + simulator visual checks (no unit tests exist).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Approach 2 (true proper line box): shift the line stack down by the
ascender headroom (exact A - L, not pixel-snapped) and pad the returned
height by the last line's descender, so a single-line V2 text item
measures exactly the true font height (A + D) instead of the cap box
(A - D). Inter-line advance unchanged; formulas still inflate.
Documents the affected height/frame consumers (chat-bubble height +
status node, streaming clip, table cells, title centering, reveal cost)
and the verification plan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The V2 line-breaker used `lineAscent` as both the line height and the
baseline offset, then inflated it to each inline emoji/image's full visual
size and bottom-aligned the attachment on that inflated baseline. A 24pt
emoji on a ~17pt line therefore doubled the line height and shoved the
text baseline (and all text on the line) down.
Stop inflating the line for emoji/images (only formulas, which carry their
own metrics, still grow it) and center each attachment on the font line
box at `baselineY - fontLineHeight/2 - size/2`, matching V1
`layoutTextItemWithString` and the chat `InteractiveTextComponent`. The
attachment now bleeds symmetrically instead of moving the baseline.
`extraDescent` absorbs tall-attachment bottom overflow so the next line is
not overlapped, and the streaming-reveal `characterRect` is centered in
lockstep so the reveal mask tracks the cell (reveal cost stays width-only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Revert the universal/dynamic watch bundle id change (commit 1120b9084e)
back to the static 0682 shape, for install testing.
This reverts the PRODUCT_BUNDLE_IDENTIFIER override + $(...:base) companion-id
derivation; the watch app again bakes the hardcoded ph.telegra.Telegraph.watchkitapp
and literal WKCompanionAppBundleIdentifier=ph.telegra.Telegraph.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Parse custom emoji into RichText.textCustomEmoji when sending markdown
rich messages, and round-trip them through edit, copy, and paste using a
shared tg://emoji?id=<fileId> markdown-link marker.
- Send: rewrite each customEmoji input attribute into a
[<alt>](tg://emoji?id=<fileId>) marker before the CommonMark parse, then
intercept the marker URL afterward to emit .textCustomEmoji. Only rich
messages are affected; a custom emoji alone stays on the entity path.
- Reverse: InstantPageToMarkdown (whole-message copy + edit reconstruction)
and InstantPageMultiTextAdapter (selection copy) emit the marker;
edit-load and chat paste reattach it as a live customEmoji attribute.
- Marker helpers shared in TextFormat/CustomEmojiMarkdownMarker.swift.
- Rich sends now pass inlineStickers so recipients can fetch the files.
Follow-up to verify at runtime: recipient rendering goes out with
Api.InputRichMessage.documents: nil; if recipients see only the fallback
glyph, populate documents: in apiInputRichMessage().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the thinking block render consistently with a normal text item:
- Size the `.thinking` item frame text-width at the page inset
(`(horizontalInset, 0, textWidth, height)`), identical to a `.text`
item, instead of a full-bleed `(0, 0, boundingWidth, height)` box, by
laying the text out flush and moving the inset onto the block frame.
The shimmer (sized to `item.frame.size`) now hugs the text.
- Size the shimmer + its gradient mask to the clipping-inset-expanded
frame (shifted `-inset`) so the mask no longer crops glyph ascenders,
descenders and the last line's underline, mirroring how a `.text`
view's frame is inset-expanded.
- Visit the thinking block's shimmer-wrapped inner text view in
updateInlineEmoji / updateInlineImages so its inline custom emoji and
images get layers created, positioned and revealed. They were skipped
because the page only iterated top-level InstantPageV2TextView items.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a first-class InstantPageBlock.thinking(RichText) case mirroring .kicker:
- enum case + InstantPageBlockType.thinking = 30; Postbox coding & equality
- FlatBuffers InstantPageBlock_Thinking schema (appended last) + Swift codecs
- API parse at InstantPage.swift; output-only on send (apiInputBlock returns nil)
- no-op stubs in the two exhaustive switches (preview text, V2 layout)
Rendering is out of scope. Verified by a full Bazel build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the user long-presses Send in the main chat and the composed text passes the
same rich-markdown gate used at send time (richMarkdownAttributeIfNeeded), the
send-options preview bubble renders the message as rich InstantPage content,
crossfading from the raw text as the menu animates in. Plain text and custom chat
contents / edit previews are unchanged.
ChatSendMessageActionUI cannot depend on InstantPageUI (it closes a dependency
cycle via LocationUI -> AttachmentUI -> ... -> ChatTextInputActionButtonsNode), so
the rich view is built in TelegramUI (ChatSendMessageRichTextPreview, wrapping
InstantPageV2View + the outgoing message theme) and injected into the screen via a
new ChatSendMessageContextScreenRichTextPreview protocol, mirroring the existing
media-preview pattern. The main-chat send call site classifies the compose text
and injects the preview; MessageItemView lays it out within the send-button-bounded
bubble envelope and crossfades between the raw text and the rich layout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rich messages (RichTextMessageAttribute, text == "") are copyable as
markdown two ways: the context-menu Copy action copies the whole
message, and a text selection inside the rich-data bubble copies just
the selected range. Both reconstruct markdown mirroring the edit
round-trip (markdownStringFromInstantPage).
Implements the full RichText entity case set
(mention/hashtag/cashtag/bot-command/bank-card/auto url/email/phone) with
tap interaction, the InstantPage -> markdown inverse converter and edit
round-trip, markdown-context stamping during V2 layout
(InstantPageMarkdownBlockContext: heading level, list/code/table/quote
depth), partial-selection markdown emission
(InstantPageMultiTextAdapter.markdownForRange), and numerous converter
edge-case fixes (tables, links, fenced code, blockquote line coalescing,
compact nested >> markers).
CLAUDE.md documents the feature; the spec/plan scratch docs generated
during development are not committed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Upgrade InstantPageBlock.blockQuote from (text, caption) to
(blocks: [InstantPageBlock], caption). Legacy inbound shapes (API
pageBlockBlockquote, Postbox "t", FlatBuffers text) lift into
[.paragraph(text)]; outbound stays on the legacy wire constructor for
empty/single-paragraph quotes and uses pageBlockBlockquoteBlocks
otherwise. V1/V2 renderers recurse into child blocks with a fixed 10pt
inter-child gap; markdown forward/reverse, entity-expressibility, and
preview text updated. pullQuote is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Upgrades the InstantPageBlock.blockQuote case from a text-only
payload to a nested-blocks payload, covering API parse, Postbox +
FlatBuffers serialization, API encode, V1/V2 layout, markdown
forward/reverse, entity-expressibility, and preview text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bubble's bottom inset is supplied by the `statusBottomEdge + 6.0`
max() in the measure closure, but that branch is gated by `!hasDraft`.
During streaming (status hidden, alpha=0) the gate skips the max() and
nothing else supplies the inset — boundingSize.height ends up at only
`revealedContentSize.height + 2` (= bounds.maxY + closingPad + rim),
so descenders of the last revealed line sit cramped against the bubble's
bottom edge and the bubble visibly grows by 6pt the moment streaming
ends and the status node fades in.
Adds an explicit `if hasDraft { boundingSize.height += 6.0 }` after
the streamingHeaderOffset application — same 6pt the status max() would
contribute, so the streaming bubble's bottom breathing room matches its
post-stream height and the grow-pop disappears.
The `hadDraft && !hasDraft` finalize pass already gets the inset via
the status max() (since `!hasDraft` is true), so it's untouched. The
non-streaming path is also untouched.
TextBubble doesn't have this issue because it computes a position-based
`bottomInset` and adds it to boundingSize.height unconditionally (see
ChatMessageTextBubbleContentNode.swift:234-256, :827). RichData absorbed
that value into the `+ 6.0` constant inside its `!hasDraft` status
max(), which made it disappear during streaming. CLAUDE.md gains a
sibling bullet flagging that future refactor-the-constant-into-bottomInset
work must remove both ends in the same commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renders `RichText.image(id:, dimensions:)` runs as real fetched images
inside `ChatMessageRichDataBubbleContentNode` / `InstantPageV2View`,
replacing the gray `InstantPageV2MediaPlaceholderView`. Source is
whatever populates `instantPage.media[id]` (server-pushed webpage
InstantPages); the markdown converter is unchanged
(`markdownImageParsingEnabled` stays false).
Architecture mirrors inline custom emoji: inline images are NOT
top-level `InstantPageV2LaidOutItem`s. `InstantPageV2View` walks each
text view's `line.imageItems`, resolves each MediaId against
`layout.media`, and creates `InstantPageV2InlineImageView` children
inside a new `imageContainerView` on each `InstantPageV2TextView`
(sibling of `renderContainer`, above the reveal mask, below
`emojiContainerView`).
Streaming reveal participation: image cells contribute their full
width to the per-line character rects (mirrors emoji), so the cost map
charges the cursor by the image's width when crossing it. When the
cursor crosses the cell, `updateImageReveal` pops the view in
(opacity 0→1 + scale 0.1→1.0 over 0.2s ease-out), matching
`updateEmojiReveal` exactly. `applyReveal` calls both alongside each
other.
Non-interactive (V1 parity): `isUserInteractionEnabled = false` lets
URL-wrapping `RichText.url(text: .image(...))` route taps through to
the underlying text view's URL handler.
Key files:
- New `InstantPageV2InlineImageView.swift`: lightweight
`TransformImageNode`-backed view. Picks the fetch signal by
EngineMedia kind (.image → chatMessagePhoto; image-mime .file →
instantPageImageFile; video .file → chatMessageVideo single frame).
`MetaDisposable` cancels fetches on dealloc.
- `InstantPageV2Layout.swift`: `media`/`webpage` fields on the layout
struct; image cells contribute to char rects; the gray-placeholder
branch in `layoutTextItem` is removed; the dead `media`/`webpage`
params on `layoutTextItem` are dropped along with their 13 call
sites.
- `InstantPageRenderer.swift`: `imageContainerView` on text view
(sibling of `renderContainer`, between it and `emojiContainerView`);
`InlineImageKey` + `InstantPageInlineImageData` types;
`updateInlineImages()` + `updateImageReveal()` on
`InstantPageV2View`; wired into `update(layout:theme:animation:)`.
- `InstantPageV2RevealCost.swift`: `applyReveal` calls
`updateImageReveal` next to `updateEmojiReveal` in both the
clear-path branch and the entry-walking branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Thread the chat list's animated transition through InstantPageV2View.update so
reused-item frame changes animate: the toggled details view's own frame-height
(clipsToBounds) clip-reveals/clip-hides its body, sibling items below slide, and
the chevron rotates — all on the one transition the bubble already drives for its
outer height. On expand the body is added up-front; on collapse it is kept and
torn down in finalizePendingCollapse(), called from the completion of the
frame-shrink (clip) animation that hides it. Drop the forced scroll-to-center on
toggle so the bubble grows in place.
Also render the chevron as a UIImageView with a template image + tintColor (the
message's primary text color) instead of baking the tint into a CALayer's cgImage
contents, which dropped the color and rendered it black.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a section covering the first-class InstantPageListItem.checked field:
where things live, the API flags.0/flags.1 transmission, tri-state Postbox/
FlatBuffers persistence, V1/V2 detection + V2 CheckNode hosting, and the
markdown round-trip contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a first-class `checked: Bool?` to InstantPageListItem (orthogonal to the
ordered-list `num`), replacing the prior sentinel-string-in-num prototype.
- Data model: enum third associated value on .text/.blocks; Postbox + a new
FlatBuffers `checkState` tri-state field (0=nil,1=unchecked,2=checked,
backward-compatible); Equatable.
- Transmission: read/write the native API `checkbox`=flags.0 / `checked`=flags.1
bits on all four PageListItem / PageListOrderedItem constructors, so checkbox
state survives the server round-trip for sender and recipients.
- Markdown: forward parser routes detected `[ ]`/`[x]` into `checked` (keeping
the real number for ordered items); reverse converter emits `- [ ]`/`- [x]`
so editing a rich message round-trips.
- Display: V1 + V2 layout detect via `item.checked`; the V2 renderer draws real
CheckNode artwork (was a placeholder); preview text shows a checkbox glyph
instead of leaking the old sentinel.
Spec: docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md
Plan: docs/superpowers/plans/2026-05-27-instantpage-list-checkbox.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 tasks: data model (enum + Postbox + FlatBuffers + Equatable), API
transmission via checkbox/checked flag bits, markdown forward/reverse,
preview text, V1/V2 layout detection, V2 CheckNode artwork, build-to-green,
and manual round-trip verification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First-class checked: Bool? on InstantPageListItem (orthogonal to num),
replacing the prior sentinel-in-num prototype. Covers parsing, Postbox +
FlatBuffers serialization, API transmission via the native checkbox/checked
flag bits, V2 CheckNode artwork, edit round-trip, and preview text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vendor the standalone tgwatch watch-app sources into Telegram/WatchApp/ as a
tracked snapshot and build it from there by default (tracked Bazel inputs).
Make.py drives embedding via --embedWatchApp (--watchAppSourcePath removed);
the filegroup excludes .swiftpm/.build. Documented in CLAUDE.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the apple_prebuilt_watchos_application rule (prebuilt_watchos.bzl + its
worker) that runs xcodebuild on the watch app, codesigns the .app and nested
framework, and feeds it to the Telegram ios_application's watch_application
slot, gated on //Telegram:embedWatchApp. Make.py gains device-gated watch
embed flags (--watchApiId/--watchApiHash/--watchSigningIdentity/
--watchProvisioningProfile), and the embedded app's version is matched to the
host (versions.json + buildNumber).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
$…$/$$…$$ (and \(…\)/\[…\]) math now sends a rich message, gated by a
strict boundary rule so casual $ usage stays plain: inline detection uses a
4-way boundary check (rejecting $5-$10, $FOO=$BAR) and block $$ requires an
exact/bare opener. Math delimiters are added to the rich pre-filter.
Documented in CLAUDE.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reconstruct markdown source from a stored InstantPage to populate the edit
field, then re-classify on save (the inverse of the send path). Adds the
InstantPageToMarkdown converter, edit-field population and save-time
re-classification in ChatControllerLoadDisplayNode, and a shared InstantPage
previewText surfaced through MessageContentKind for reply/pinned/forward
previews. Documented in CLAUDE.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Classify typed markdown on send: structure the message-entity set can't
represent (headings, lists, tables) is sent as a rich message
(RichTextMessageAttribute carrying an InstantPage); everything else takes
the existing entity path. Adds the parse-then-inspect classifier in
BrowserMarkdown, gates it in ChatControllerNode.sendCurrentMessage
(replacing the debugRichText flag), drops formulas/dividers as triggers,
and cleans up heading blocks for the chat path. Documented in CLAUDE.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the RichText.textSpoiler case end-to-end: model + Postbox coding,
FlatBuffers schema/codec, and Api.RichText parsing/serialization (lossless).
Display attaches the spoiler attribute, collects per-line spoiler rects in
the V2 layout, clips obscured glyphs and draws an energy-gated dust overlay
in the V2 text view, propagates the reveal flag across the nested V2 tree
(building dust for freshly-created text views), and wires tap-to-reveal
routing in the rich-data bubble.
Also seeds the streaming reveal cursor across chunk rebuilds and refines
status date positioning.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implement the nine message-entity RichText cases (textMention,
textMentionName, textHashtag, textCashtag, textBotCommand, textBankCard,
textAutoUrl, textAutoEmail, textAutoPhone): model + Postbox coding,
FlatBuffers schema/codec, Api.RichText parsing/serialization (lossless),
InstantPage display attaching the matching TelegramTextAttributes keys, and
tap routing in the rich-data bubble mirroring ChatMessageTextBubbleContentNode.
mentionName display resolves via EnginePeer.Id (PeerId not in scope).
Also lets rich-data text selection reach a line's trailing edge and fixes
the date/status node positioning to match TextBubble.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Spec, plan, and full implementation of the AI-message streaming
animation in ChatMessageRichDataBubbleContentNode. Extracts
TextRevealController into a shared StreamingTextReveal submodule;
precomputes per-character rects in V2 layout (RTL-safe, glyph-ink
bounds); splits InstantPageV2TextView into render container + render
view with a reveal mask layer; implements mask + snippet pop-in
reveal; adds a per-text-view reveal cost map + applyReveal extension;
switches reveal cost to a width-based unit; sizes / clips the bubble
to the revealed prefix during streaming; aligns the Thinking… header
with TextBubble; floors table cell reveal cost at cell frame width;
includes layout closing pad in revealedContentSize; documents the
non-obvious invariants in CLAUDE.md.
Snapshot list marker subviews/sublayers before iteration, anchor
strike/underline drawing to item frame height, baseline-align inline
text, images, and formulas. Also: implementation plan and two spec
refreshes (clarify extraDescent nudge removal; refresh stale line
numbers).
Design and plan, then implementation: include the details title in
selectableTextItems, restore V2 text selection in the rich bubble,
drop the InstantPageMultiTextAdapter init parameter-name split, and
split LinkHighlightingNode modern-path groups on disjoint rects.
Also includes the IP V2 inline-formula baseline alignment design doc.
Design and plan, then the implementation: extract a shared
openInstantPageMedia helper for V1+V2 reuse, add typed media item
cases + allMedias() helper, introduce InstantPageV2RenderContext and
thread it through the view tree, add four media wrapper views, route
layout dispatch through typed media items, let the chat bubble build
the render context so real media renders, wire gallery tap routing,
cache V2View by message id + stableVersion, add stable-id identity +
per-class update(item:theme:), reuse item views on re-layouts via
stable-id harvest, preserve TextView clipping inset on reuse, and let
the use site control media-reference construction.
Design, plan, and implementation. Adds .formula item case +
InstantPageV2FormulaView, bumps InstantPageMathAttachment fields to
public, routes block and inline formulas through .formula items,
aligns the scroll view with V1 (alwaysBounceHorizontal = false), and
reports narrow block formula natural width so the fitToWidth bubble
shrinks.
Initial design, plan, scaffold, and the full incremental implementation
of InstantPage V2 used by ChatMessageRichDataBubbleContentNode: layout
data types and driver, shared text helpers, text view / divider /
anchor, lists, code blocks, block/pull quotes, media placeholders,
details (with recursion), tables, hit-test / selection / last-line
helpers, V1→V2 swap in the rich-data bubble, and post-swap fix-ups
(cache key, RTL paragraphs, list geometry, lazy pageView construction,
fitToWidth horizontal inset, V2View.update() no longer writes
self.frame).
Spec, plan, and implementation of the date / checks / reactions /
effects status node on ChatMessageRichDataBubbleContentNode. Hoists
layout work out of the inner closure and anchors the status frame top
at text maxY.
Smooth-pace reveal of incoming live typing drafts. Adds the design
doc, implementation plan, the EWMA inter-arrival → velocity-smoothed
cursor core, handling for text shrink / item loss, and debug logs.
Sweep 14 BUILD files whose modules no longer have any source file with
`import Postbox`. All targets are ChatMessage*BubbleContentNode subclasses
plus WallpaperPreviewMedia — modules whose Swift sources stopped importing
Postbox in earlier waves but whose BUILD deps were not cleaned up.
Pre-flight: for each `BUILD` containing `"//submodules/Postbox"`, verified
no source file under `<module>/Sources` matches `^import Postbox$`. 14
modules met the criterion.
Build: 15s warm-cache verify, 0 errors.
Modules:
- ChatMessageActionBubbleContentNode
- ChatMessageEventLogPreviousDescriptionContentNode
- ChatMessageEventLogPreviousLinkContentNode
- ChatMessageEventLogPreviousMessageContentNode
- ChatMessageFileBubbleContentNode
- ChatMessageGameBubbleContentNode
- ChatMessageInvoiceBubbleContentNode
- ChatMessageMapBubbleContentNode
- ChatMessageMediaBubbleContentNode
- ChatMessageProfilePhotoSuggestionContentNode
- ChatMessageStoryMentionContentNode
- ChatMessageWallpaperBubbleContentNode
- ChatMessageWebpageBubbleContentNode
- WallpaperPreviewMedia
Pre-wave attempt failure note: tried 5 source-side `import Postbox` drops
first (StatsMessageItem, StarsAvatarComponent, PeerListItemComponent,
WebAppMessagePreviewScreen, OpenChatMessage). All 5 hit hidden bare
`Media`/`Message`/`PeerStoryStats`/`areMediaArraysEqual` references that
the prior pre-flight regex missed. Reverted source edits; only the
risk-free BUILD-dep sweep survives in this commit.
Lesson: pre-flight regex MUST include bare Postbox protocols
`\bMedia\b`, `\bMessage\b`, `\bPeer\b`, plus the Postbox helper
`areMediaArraysEqual` and the `PeerStoryStats` type. The previous
identifier-typealias-only regex was insufficient.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight-task plan covering ContextUI struct field additions,
PortalTransitionStaging helper, CCEPN animateIn/animateOut wiring,
ChatControllerNode contextTransitionContainer, two adopter sources,
and manual visual verification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three task plan: (1) trim ShimmeringMask BUILD deps, (2) replace stub
with full reveal-mask CAGradientLayer implementation, (3) wrap
streamingStatusTextNode in ChatMessageTextBubbleContentNode. Plus a
manual-verification task since the project has no unit-test harness.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reusable view that applies a moving alpha-mask shimmer (rest=1.0,
dip=peakAlpha) to its contentView. First consumer: the streaming-status
text node in ChatMessageTextBubbleContentNode for ChatGPT-style
"thinking" effect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces CCEPN's manual visible-area clipping with a portal-based
transition mirroring CMTN's primitive. Adds optional
sourceTransitionSurface to TakeViewInfo/PutBackInfo; chat is the
first adopter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrate `cachedWallpaper(account: Account, ...)` in WallpaperCache.swift to
take `(engine: TelegramEngine, network: Network, ...)` instead of the umbrella
Account parameter. Body drops the local `let engine = TelegramEngine(account:)`
since engine is now a parameter; `account.network` is now `network`.
19 context-based callers (`account: context.account` /
`self.context.account` / `component.context.account`) update via perl
sweep to pass `engine: context.engine, network: context.account.network`.
3 internal-Account-typed callers (WallpaperResources.swift × 2,
ThemeUpdateManager.swift × 1) bridge with adhoc
`engine: TelegramEngine(account: account), network: account.network`
since they're inside functions that still take `account: Account` and have
heavier Postbox uses we can't migrate yet.
Drops `import Postbox` from WallpaperCache.swift since the only remaining
Postbox-ish identifier was `ValueBoxKey`, which swaps to `EngineDataBuffer`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
drawRectsImageContent's modern path computed nextRadius and
prevRadius as min(outerRadius, floor(|Δx| * 0.5)). When |Δx| < 2
the floor produces 0 and the addArc call becomes a no-op,
leaving an unsmoothed corner at the stair-step. Replace floor
with ceil so any non-zero edge mismatch rounds up to at least
1 px. Exact-equality cases (Δx == 0) are unaffected — they take
the else branch with a straight addLine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In drawRectsImageContent's modern path the snap loop runs midY
trimming first, leaving rects[i].maxY == rects[i+1].minY for
adjacent line rects. The X-edge snap guard then evaluated
rects[i].insetBy(dx: 0.0, dy: 1.0).intersects(rects[i+1]) — but
positive dy shrinks the rect, so after the trim the guarded
rectangle no longer intersects its neighbor (CGRect.intersects
requires positive-area overlap). Flip dy to -1.0 so the temp
rect grows and touching neighbors satisfy the guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan covers two single-line edits in LinkHighlightingNode.swift's
modern branch (X-snap dy direction; floor → ceil for stair-step
fillet radii), each landed as its own commit, with a final
full-project build for validation since this repo has no tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Document the analysis and intended fixes for two bugs in
LinkHighlightingNode's modern path branch: the X-edge snap is
unreachable after the midY snap (positive dy in insetBy shrinks
rect[i] so it can't intersect the touching neighbor), and the
floor() in nextRadius/prevRadius can produce zero-radius arcs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
drawInTile previously stroked each cell's full perimeter, double-drawing
every interior gridline; visible now that the rich-data chat bubble uses
tableBorderColor at 0.25 alpha. Stroking each segment in its own
strokePath call would also have left ~1pt² overdraw at every interior
4-cell junction (where a horizontal divider crosses a vertical one) and
at every T-junction with the outer rounded rect — each strokePath
rasterizes independently and composites against the previous result.
Build a single CGMutablePath containing each cell's interior top/left
segment (skipping cells on the table's top/left boundary) plus the outer
rounded perimeter rect, and call strokePath once. CGContextStrokePath
fills the union of all stroke geometries as a single fill op, so each
pixel is painted exactly once regardless of how many segments overlap.
Empty cells (text == nil) are no longer skipped wholesale: their fill
and text remain gated on text != nil (preserves today's no-fill-for-
empty behavior), but their interior divider segments still get appended
so divider continuity is preserved around them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds drag-handle text selection driven by TextSelectionNode. Exposes
attributedString and selection helpers on InstantPage text items, and
introduces a multi-text adapter aggregating items as a TextNodeProtocol.
Gates selection actions on reply-options and fixes highlight z-order.
Adds a base getAnchorRect on ChatMessageBubbleContentNode, the rich-bubble
override (including titleHeight in details recursion), bubble-item
forwarding to content nodes, ChatControllerInteraction.scrollToMessageIdWithAnchor,
and a scrollToAnchor that lands the anchor at the top of the content area /
its line. Threads anchor/scroll params through ChatController and related
call sites.
Documents a two-pass refactor for InstantPageTableItem.drawInTile that
draws each interior divider exactly once (top+left of each cell that
isn't on the table boundary) plus a single rounded-rect outer-perimeter
stroke. Needed now that tableBorderColor is being made semi-transparent
(0.25 alpha) by the rich-data chat bubble; current per-cell whole-bounds
strokes overdraw shared edges.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds ChatControllerInteraction dependency, link-progress state, URL tap
detection, press-highlight separation from in-flight URL shimmer, media-tap
routing through openMessage with explicit IV media subject, and gallery↔bubble
transition with hidden-media coordination. Stops re-appending to
currentLayoutItemsWithNodes across re-layouts. Drops a leftover MetalEngine
debug print as well.
When a pinToEdgeWithInset item is taller than half of the visible
area (visibleSize.height - insets.top - insets.bottom), cap its
visible portion at halfArea. The remaining height extends past
visibleSize - insets.bottom into the bottom-inset region (occluded
by overlay UI like the chat input panel).
A new private helper `pinToEdgeBottomExtension(forPinnedHeight:)`
returns max(0, pinnedHeight - halfArea). Three sites consume it:
- calculatePinToEdgeTopInset caps the pinned item's contribution
to totalAboveAndPinned via `pinnedHeight - extension`.
- replayOperations isPinToEdgeTarget anchors the pinned item's
apparent maxY at visibleSize - insets.bottom + extension.
- isStrictlyScrolledToPinToEdgeItem matches the new anchor.
Both isPinToEdgeTarget and isStrictlyScrolledToPinToEdgeItem fire
when either calculatePinToEdgeTopInset() > 0 OR extension > 0, so
the cap also applies when items above the pinned item overflow
the visible area (in which case calculatePinToEdgeTopInset returns
0 but extension is still positive).
When the pinned item fits within halfArea, extension == 0 and
behavior is identical to before this change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implementation plan for the spec from
docs/superpowers/specs/2026-05-01-listview-pin-to-edge-half-cap-design.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire URL tap detection, link-highlight feedback, and item-callback
routing in ChatMessageRichDataBubbleContentNode, with stubbed
intra-page anchor handling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Squashed buildout of the tgcalls testbench:
- CLI test tool with --mode p2p/reflector/group/group-churn,
cross-version interop (--version, --version2), and quiet/summary output
- Linux toolchain + Docker multi-stage build, AWS Fargate mass test harness,
local parallel mass test harness with signaling loss simulation
- SCTP writable gate, retransmission timer tuning, role-based handshake
- InstanceV2CompatImpl (PeerConnection backend with V2Impl signaling) and
SignalingTranslator for v14.0.0 interop
- In-process Go/Pion SFU (ICE+DTLS+SRTP+SCTP per participant) with audio
RTP forwarding, ActiveAudio/VideoSsrcs data channel broadcast, RTCP
feedback path, and CGo c-archive integration
- GroupInstanceReferenceImpl (PeerConnection group-call) and mixed-impl
group mode (--reference-participants), with SDP munging for simulcast
- H264 simulcast group video (FakeVideoTrackSource pattern generator,
FakeVideoSink frame counting, --video flag, two-pass channel setup,
reactive video setup from ActiveVideoSsrcs)
- Group churn stress mode (--mode group-churn, --churn-cycles)
- SFU stream-quality adaptation: BandwidthEstimator, LayerSelector
state machine, RtxRingBuffer, simulcast SSRC rewrite
- Transport-cc feedback generation, NetworkSimulator (delay/jitter/loss/
token-bucket bandwidth), --network-scenario step-down-up
- CLAUDE.md updates throughout
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Delay outgoing messages while the peer in the same (peerId, threadId)
chat is live-typing an incoming message, gated via a new per-account
Postbox `.allTypingDrafts` view feeding a synchronous Set membership
check inside PendingMessageManager.
Postbox:
- Add `AllTypingDraftsView` tracking `Set<PeerAndThreadId>` of active
typing drafts; expose via `PostboxViewKey.allTypingDrafts`.
- Disambiguate `PostboxViewKey` hash arms for `.typingDrafts` (constant
prefix 23) and `.contacts` (16 → 24) to avoid runtime collisions
with peerId-only arms and `.combinedReadState`. Hash values are
runtime-only, so this is safe.
PendingMessageManager:
- Add `.waitingForSendGate(groupId:content:)` parking state for single
messages and albums whose upload finished but are gated on a
typing-draft-clearing event; update `groupId` accessor and
`dataForPendingMessageGroup` so partially-parked albums drain on
gate open.
- Add `.waitingForForwardSendGate` parking state for forwards (no
associated values) so drain section (1) routes single non-grouped
messages via `commitSendingSingleMessage` and section (3) routes
forwards via `sendGroupMessagesContent`, avoiding the double-dispatch
that occurred when forwards reused `.waitingForSendGate`.
- Subscribe to `.allTypingDrafts`; wire the gate at single-message,
album, and forward send paths and at drain.
- Cleanup parked forwards on pending-message removal; snapshot
Dictionary keys before iterating to avoid mutation during iteration.
Specs and plan documents are included under `docs/superpowers/`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Design doc for filling in the empty stub at ListView.swift:2674.
Defines the "strictly scrolled" condition as the equation that the
pin-to-edge scroll math at line 3115 lands on:
apparentFrame.maxY == (visibleSize.height - insets.bottom) + scrollPositioningInsets.bottom
with a 0.5pt tolerance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Original wave 106 pure import-drop sweep abandoned: 576 candidate files
all genuinely reference Postbox-tier tokens; wave 93's pattern exhausted
at file granularity (no single-file orphans remain).
Pivot: extend engine.resources.data(resource:) facade with
`incremental: Bool = false` parameter (wires to ResourceDataRequestOption).
Drain the 1 live consumer site (ChatInterfaceStateContextMenus:1327)
plus consumer-side `data.complete` -> `data.isComplete` rename.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the wave-71-shadow ratchet: caller already holds EnginePeer and
demotes once at the boundary. Migration drops the demote, drops `import
Postbox` from the node, and rewrites 3 `as? TelegramChannel` downcasts
to `case let .channel(channel)`. 7 edits across 2 files, 1-iter target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrate RenderedChannelParticipant.peers from [PeerId: Peer] to
[EnginePeer.Id: EnginePeer]. Closes the wave-41 ratchet — the public
struct no longer leaks raw Peer types in any field (presences stays
Postbox-typed; separate migration).
Consumer-surface: -10 bridges. Dropped 6 EnginePeer(peer) read-wraps
at participant.peers[...] extraction sites across
ChannelAdminsController, ChannelMembersSearchContainerNode,
ChannelBlacklistController. Dropped 5 .mapValues({ $0._asPeer() })
constructor-unwrap transforms in ChannelAdminsController,
ChannelMembersSearchContainerNode, ChannelMembersSearchControllerNode.
Added 2 ._asPeer() unwraps in ChatRecentActionsHistoryTransition at
the two iteration sites (line 673 via participant.peers, line 2273
via new.peers in participantSubscriptionExtended) where the iterated
value is inserted into a raw-Peer SimpleDictionary.
TelegramCore producers: 8 files build the local peers dict inside
postbox.transaction and wrap at the insertion point. ChannelMembers,
RequestStartBot, ChannelOwnershipTransfer, JoinChannel, AddPeerMember,
PeerAdmins, ChannelBlacklist, Ranks.
2-iteration build convergence. Iteration-1 surfaced new.peers at
ChatRecentActionsHistoryTransition:2272 that the plan's participant.peers
pre-flight grep missed; wider grep now confirms the two iteration sites
are the complete surface.
No unit tests in this project; full Telegram/Telegram build verified
under configuration=debug_sim_arm64.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bubble-in-text-input morph ran concurrently with listNode's
scroll-to-item sublayerTransform animation, but its source rect was
computed via CALayer.convert which uses model transforms only and so
missed the pending additive sublayerTransform. The morph's `from`
ended up calibrated to the post-scroll state while playback started
from the pre-scroll state, offsetting the bubble at t=0.
Add convertAnimatingSourceRectFromWindow: walks the layer chain
top-down and at each parent->child step subtracts the parent's pending
additive sublayerTransform translation in the parent's bounds space
before descending, so the result reflects the t=0 presentation state
even through rotations/scales later in the chain. Parent
animatingItemNode under itemNode so listNode is on its ancestor chain
and its pending sublayerTransform is picked up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Private free function in ContactMultiselectionController.swift. Five
_asPeer() bridges drop in one atomic single-file commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trims the Postbox-refactor section of CLAUDE.md from ~750 lines to ~160
by moving per-wave outcomes, the running tally of Postbox-free modules,
and verbose wave-selection guidance backstory into
docs/superpowers/postbox-refactor-log.md. Also adds a spec document for
the reorganization.
Kept in-place in CLAUDE.md: standing rules, engine typealias cheat sheet,
MediaResource consumer-migration patterns, TelegramEngine.Resources facade
inventory, and terse bullet-form guidance with log cross-references.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task-by-task implementation plan for the caret-tracking auto-scroll
design. Single-file change in TextStyleEditScreen.swift: tag both text
fields, walk the transition's TextFieldComponent.AnimationHint userData
on .textChanged, compute caret rect, and adjust the enclosing
ResizableSheetComponent scroll view's bounds.
Design for auto-scrolling the ResizableSheetComponent scroll view on text
change so the caret in either ListMultilineTextFieldItemComponent stays
visible above the soft keyboard.
Mechanical regeneration after the SwiftTL fix. Every
'if Int(_N!) & Int(1 << K) != 0 {...}' gate and matching
'_cM = (Int(_N!) & ...) == 0) || ...' validation in parse_<ctor>
now reads 'Int(_N ?? 0)' instead of force-unwrapping. ~2168 sites
changed across the 41 flat Api*.swift files plus the 11 layered
SecretApiLayer*.swift files. No other content change.
Full Bazel build verified green with the regenerated sources.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generated parse_<ctor>(...) for constructors with conditional fields
used to emit 'if Int(_N!) & Int(1 << K) != 0 { ... }' as the gate,
and 'let _cM = (Int(_N!) & Int(1 << K) == 0) || _M != nil' as the
per-field validation. Both force-unwrap _N (the flags field read)
before the bottom-of-function '_cN = _N != nil' validation runs,
so a buffer short enough to fail the flags read traps
deterministically instead of returning nil.
Replace the force-unwraps with (_N ?? 0). Missing flags then reads
as "all bits off": every gated branch is skipped, each flag-gated
_cM short-circuits to true via the '== 0' clause, and the flags
field's own _cN = _N != nil still fails so the overall constructor
validation falls through to return nil — matching the intended
"return nil on truncated buffer" contract.
Touches both generator emit paths (flat generateImplFile and layered
emitLayeredType). Regenerated Api*/SecretApiLayer*.swift follow in a
separate commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the hand-written layer 8/46/73/101/144 files with SwiftTL
output and adds the previously-unpublished layers 17/20/23/45/66/143.
Per-layer struct names (SecretApi8, SecretApi46, ...) and public
enum case signatures are unchanged for the consumers.
The regenerated cumulative snapshots now include every constructor
declared in secret_scheme.tl, including decryptedMessageActionTyping
(layer 17) which the hand-written files had elided. Switch statements
over DecryptedMessageAction in ProcessSecretChatIncomingDecryptedOperations
gain the missing case alongside .decryptedMessageActionNoop (both are
no-ops in the consumer-side state-machine).
BUILD uses glob("Sources/**/*.swift") so no BUILD update required.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents the actual build invocation (with codesigning + cache +
configuration arguments) at the top of the file, alongside the
existing notes that only Bazel + the codesigning env var were
mentioned. Removes the duplicate "Build environment quirk" footer
section since its content now lives at the top.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flat schemas keep the existing generate(...) pipeline. Layered
schemas iterate resolveLayeredTypes and write one
{apiPrefix}Layer{N}.swift per layer via generateLayered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Emits one {apiPrefix}Layer{N}.swift file per layer: file-scope
dispatch table, public struct {apiPrefix}{N} with parse/parseVector/
serializeObject, nested public enums for each sum type using the
inline-args shape. Int256 dispatch entry emitted only when a layer's
constructors reference it. Reuses typeReferenceRepresentation /
generateFieldSerialization / generateFieldParsing unchanged, passing
the struct name as the apiPrefix so nested type refs render correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks layer sections in order, threads a running constructor-name map
with last-wins semantics, and snapshots [SumType] at each layer
boundary. Constructors appearing only in later layers do not leak into
earlier layers' snapshots.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DescriptionParser.parse(data:) now returns ParsedSchema (.flat or
.layered) based on the presence of ===N=== markers. Layered schemas
split constructor lines per layer; pre-marker constructors attach to
the lowest-numbered layer; ---functions--- is rejected in layered
mode; non-ascending markers throw.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six-task implementation plan covering the parser / resolver / codegen
extension path described in the 2026-04-21 design spec. Tasks land
end-to-end, with a final Bazel build verification of the regenerated
SecretApiLayer*.swift files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Preparation for layered schema generation. Threads apiPrefix: String
through CodeGenerator.generate / generateMainFile / generateImplFile
/ typeReferenceRepresentation so the output's namespace name can
come from the --api-prefix=<prefix> CLI flag rather than a hard-coded
"Api". Drops the --stub-functions and --print-constructors=N-M flags
(both unused) and the LegacyOrderParser.swift file (obsolete migration
helper that re-parsed hand-emitted Api0.swift).
No behavior change for the flat Api*.swift output when
--api-prefix=Api (the default). Enables the upcoming layered secret-
scheme pipeline to reuse the same generator helpers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the design spec for extending SwiftTL to parse TL schemas
with ===N=== layer markers (secret_scheme.tl), emitting one
cumulative-snapshot Swift file per declared layer. Flat schemas
are unchanged. No CLI surface changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two TelegramEngine.Resources facades and migrates 4 Shape-A sites across 3 consumer files.
Also imports RangeSet in TelegramEngineResources.swift to disambiguate the RangeSet type from
Swift stdlib's iOS-18-only type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two TelegramEngine.Resources.copyResourceData overloads and migrates 4 Shape-A sites
across 3 consumer files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two TelegramEngine.Resources.moveResourceData overloads and migrates 6 Shape-A sites
across 4 consumer files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds TelegramEngine.Resources.cancelInteractiveResourceFetch(id:) and migrates 5 of 7 Shape-A
sites. Two sites in ChatMessageInteractiveMediaNode.swift skipped to avoid mixing with
pre-existing uncommitted WIP in that file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same shape as wave 21. Adds TelegramEngine.Resources.storeResourceData(id:, data:, synchronous:)
and sweeps 46 context.account.postbox.mediaBox.storeResourceData sites across 17 files.
The range-store overload and accountManager.mediaBox sites are explicitly out of scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Combined wave-19+wave-20 shape. Adds TelegramEngine.Resources.completedResourcePath(id:, pathExtension:)
facade and sweeps 29 consumer sites across 14 files in one atomic commit.
Shape A/B migrated. Shape C (5 sites with raw account: Account) and Shape D
(3 sites with local postbox: Postbox field) intentionally skipped — need
module-scoped init-signature rework rather than per-site sweep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
22 call sites across 16 consumer modules migrated to the wave-19 facade:
context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(resource.id)
->
context.engine.resources.shortLivedResourceCachePathPrefix(id: EngineMediaResource.Id(resource.id))
ItemListStickerPackItem additionally drops the wave-18 `let rawResource` intermediate.
Skipped: MediaEditorComposerEntity.swift:245 (uses local `postbox:` init-param,
not `context.account.postbox`; needs its own wave). No modules become Postbox-free
this wave — each still has other Postbox usage.
Additive-only. Adds TelegramEngine.Resources.shortLivedResourceCachePathPrefix(id: EngineMediaResource.Id) -> String as a thin forwarder over MediaBox.shortLivedResourceCachePathPrefix.
Unblocks consumer migration across 25+ call sites in ~15 modules; consumers migrate in follow-up waves.
Applies the wave-11/12/15 collapse pattern to ItemListAvatarAndNameInfoItem.
ItemContext.other payload changes from (accountPeerId:, postbox: Postbox,
network: Network) to (accountPeerId:, stateManager: AccountStateManager).
Internal AvatarNode.setPeer forward rewires through stateManager.postbox /
.network. Module drops import Postbox (source) and //submodules/Postbox:Postbox
(BUILD). Sole external caller — DeviceContactInfoController.swift:413 at the
Share-Extension boundary — migrated atomically.
Handle choice is stateManager: (not engine:) because the sole caller fires
specifically when the context is not a ShareControllerAppAccountContext;
ShareControllerAccountContext protocol exposes stateManager: AccountStateManager
but not engine: TelegramEngine, and no full TelegramEngine is constructible in
the Share Extension (no Account). Per feedback_postbox_refactor_handle.md and
the wave-15 precedent.
Pre-flight public-Postbox-type inventory grep (per CLAUDE.md's Wave-selection
guidance) returned only Postbox itself — no hidden leaks like wave 16's
EngineMessageHistoryThread / PeerStoryStats surprises.
Module fully Postbox-free after this wave. Running tally updated in CLAUDE.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaced by wave 16 -- grepping only for Postbox/Network tokens undercounts
the hidden public-surface leaks (e.g., EngineMessageHistoryThread, PeerStoryStats).
Document the allowlist-grep recipe and the three paths for handling hits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ItemListPeerItem.Context.Custom's init took (postbox: Postbox, network: Network)
purely to forward them to downstream AvatarNode.setPeer and EmojiStatusComponent.
Collapse to engine: TelegramEngine (wave-11 pattern, engine handle preferred per
the standing guidance since the sole external caller is main-app, not
Share-Extension).
Surface changes:
- Context.Custom: two stored fields (postbox, network) -> one (engine); init
param pair -> single engine:.
- Context: computed postbox/network -> computed engine.
- Six internal forwards route via item.context.engine.account.postbox/.network.
External caller (sole .custom(Custom(...)) construction site codebase-wide):
- PeerInfoSettingsItems.swift:121 -> pass engine: peerAccountContext.engine
(the accountsAndPeers tuple already carries AccountContext, which exposes
.engine: TelegramEngine).
All 37 other ItemListPeerItem(...) sites use the .account(context: AccountContext)
convenience overload unchanged.
Module does NOT become Postbox-free: PeerStoryStats? (public surface on
storyStats) and, via the now-unavoidable Postbox import, a handful of other
transitive Postbox types remain reachable. PeerStoryStats is deeply baked into
Postbox view types (PeerView, PeerStoryStatsView, ChatListEntry, MessageHistoryView,
Postbox.getPeerStoryStats) and moving it would require a cross-module wrapper
rewrite out of scope here. EngineMessageHistoryThread.Info (the other
previously-blocking public-surface type) was moved to TelegramCore in the
preceding wave 16a commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promote Postbox's internal MutableMessageHistoryThreadIndexView.Item to a
public top-level type MessageHistoryThreadIndexItem; drop Postbox's empty
EngineMessageHistoryThread namespace class and its nested Item.
MessageHistoryThreadIndexView.items is now [MessageHistoryThreadIndexItem]
directly (no wrap/unwrap step in the init). The two consumer iteration
sites (ChatListNodeLocation.swift, ShareControllerNode.swift) don't need
changes -- they iterate without type annotation and only read fields that
exist on both the old nested Item and the new top-level type.
Collapse TelegramCore's public extension EngineMessageHistoryThread into
a proper class definition:
public final class EngineMessageHistoryThread { class Info { ... } }.
After this, EngineMessageHistoryThread is a pure TelegramCore type --
consumers that only use EngineMessageHistoryThread.Info no longer need
import Postbox for the outer name to resolve.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Applies the wave-11/12 AccountStateManager collapse pattern to
SelectablePeerNode. Module is now fully Postbox-free (source + BUILD).
- SelectablePeerNode.setup(... postbox: Postbox, network: Network, ...)
-> setup(... stateManager: AccountStateManager, ...). Same for
setupStoryRepost. AvatarNode.setPeer and EmojiStatusComponent are
forwarded as stateManager.postbox / .network without naming Postbox.
- Three Namespaces.Peer.SecretChat == checks rewritten to use the
existing PeerId.isSecretChat extension (as in wave 13).
- ShareControllerPeerGridItem.setup / setupStoryRepost and
HorizontalPeerItem.setup call sites collapse to stateManager:.
JoinLinkPreviewPeerContentNode uses the convenience
setup(context: AccountContext) and is unchanged.
- Drop import Postbox in SelectablePeerNode.swift and the
//submodules/Postbox:Postbox dep in SelectablePeerNode/BUILD.
The stateManager fallback (over the normally-preferred engine:
TelegramEngine) is used because SelectablePeerNode crosses the Share
Extension boundary: ShareControllerAccountContextExtension has no
Account, so TelegramEngine(account:) is physically unreachable there.
This matches the "rare but genuine fallback" clause of
feedback_postbox_refactor_handle.md.
Build verified green (debug_sim_arm64, 193 actions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wave 11: ActionSheetPeerItem de-Postboxed. Revisits wave-1 abandonment.
`postbox: Postbox, network: Network` init params collapse to
`stateManager: AccountStateManager`; avatar setPeer call routes via
`item.stateManager.postbox` / `.network`. Module never names Postbox.
Sole caller (ShareController.swift:1146) migrated in place.
Wave 12: HorizontalPeerItem de-Postboxed (same pattern). Ripples the
collapse up into ChatListSearchRecentPeersNode's public init
(`postbox:/network:` -> `stateManager:`). That module still imports
Postbox for PostboxViewKey/UnreadMessageCountsView internals but its
public surface simplifies. 3 external caller sites migrated.
Wave 13: AttachmentTextInputPanelNode minor cleanup. Module was already
Postbox-free at source level (wave 6) but carried a dead BUILD dep and
had 2 raw `peerId?.namespace == Namespaces.Peer.SecretChat` checks.
Both now use existing `PeerId.isSecretChat` extension in TelegramCore.
Wave 14: BUILD-dep sweep mirroring wave 6's source sweep. 98 modules
had `//submodules/Postbox:Postbox` (or `//submodules/Postbox`) BUILD
deps despite no source file importing Postbox since wave 6. Single
iteration, zero restores -- Bazel Swift requires source-level `import`
for symbol resolution, so redundant BUILD deps are pure metadata.
Net: 110 files, +116/-149. Build verified green (debug_sim_arm64).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the Ruby decrypt.rb shell-out with a direct Python call to
decrypt_match_data(). The iOS build no longer depends on a Ruby
interpreter. Includes the spec, plan, AES-256 port, tightened error
surfaces for key length and V1 fallback, and the BuildConfiguration
wire-up that drops decrypt.rb.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last future-wave candidate from wave 8 by eliminating the
Icon.media(Media, ...) enum case in StorageFileListPanelComponent.swift
and dropping import Postbox. StorageUsageScreen (the module as a whole)
is now fully Postbox-free.
Icon enum split:
case media(Media, TelegramMediaImageRepresentation) ->
case mediaFile(TelegramMediaFile, TelegramMediaImageRepresentation)
case mediaImage(TelegramMediaImage, TelegramMediaImageRepresentation)
Equatable rewritten as switch-over-tuple with id-based equality per
concrete type (lFile.fileId == rFile.fileId / lImage.imageId ==
rImage.imageId), same semantics as the old media.id comparison.
Binding site: `if case let .media(media, representation)` +
`as? TelegramMediaFile` / `as? TelegramMediaImage` downcasts ->
compound case-binding `case let .mediaFile(_, representation), let
.mediaImage(_, representation):` to lift the shared representation
variable, plus an inner switch for the setSignal branch. The compiler-
enforced exhaustiveness of the split improves call-site safety.
Construction sites (2): `.media(file, representation)` -> `.mediaFile(
file, representation)`, `.media(image, representation)` -> `.mediaImage(
image, representation)`.
Placeholder fixup:
messageId: EngineMessage.Id(peerId: PeerId(namespace: PeerId.Namespace
._internalFromInt32Value(0), id: PeerId.Id._internalFromInt64Value(0)
), namespace: 0, id: 0)
->
messageId: EngineMessage.Id(peerId: component.context.account.peerId,
namespace: 0, id: 0)
Inside a measureItem layout-measurement instance. Caught by second-pass
build failure `cannot find 'PeerId' in scope`. PeerId / PeerId.Namespace
/ PeerId.Id are raw Postbox types (not TelegramCore typealiases —
consistent with wave 9's MessageId -> EngineMessage.Id fixup). Using
context.account.peerId is semantically equivalent for the measurement
use case (messageId only feeds image-fetch userLocation and Equatable
comparison, neither exercised for this standalone instance).
Net: 1 file changed, +22 / -29 lines.
Plan: docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-10.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the first of the two future-wave candidates left open by wave 8 by
rewriting both AccountSpecificCacheStorageSettings preferences-view
observation sites in StorageUsageScreen.swift using engine APIs, and
drops import Postbox from that file.
Site 1 — cacheSettingsExceptionCount (former 1047-1087):
postbox.combinedView(keys: [.preferences(keys: Set([...]))]) +
PreferencesView ->
context.engine.data.subscribe(TelegramEngine.EngineData.Item
.Configuration.ApplicationSpecific-
Preference(key: ...))
+ preferencesEntry?.get(...)
Site 2 — peerExceptions (former 3131-3196):
- Same preferences-observation replacement as Site 1.
- postbox.transaction { transaction.getPeer / getPeerCachedData as?
CachedGroupData / CachedChannelData; FoundPeer(peer:subscribers:) }
-> context.engine.data.get(EngineDataMap(...TelegramEngine.Engine-
Data.Item.Peer.Peer.init(id:))) + pattern match on EnginePeer
.user / .secretChat / .legacyGroup / .channel
- Signal element: [(peer: FoundPeer, value: Int32)] -> [(peer: Engine-
Peer, value: Int32)]. FoundPeer wrapper and its `subscribers` field
dropped — computed but never read downstream (consumers only read
.isEmpty, .count, and .prefix(3).map { EnginePeer($0.peer.peer) }).
Consumer update:
peerExceptions.prefix(3).map { EnginePeer($0.peer.peer) } ->
.prefix(3).map { $0.peer }
Typealias fixup (caught by first-pass build failure):
var mergedMedia: [MessageId: Int64] -> [EngineMessage.Id: Int64]
(MessageId is raw Postbox; must use the EngineMessage.Id typealias
when import Postbox is removed.)
Reusable pattern documented in CLAUDE.md: TelegramEngine.EngineData.Item
.Configuration.ApplicationSpecificPreference(key: ValueBoxKey) is the
general-purpose engine replacement for the postbox.combinedView(keys:
[.preferences(keys: Set([key]))]) + PreferencesView idiom. Works from
any module importing TelegramCore (without import Postbox) because
passing PreferencesKeys.<name> keeps ValueBoxKey as an inferred-only
type that never gets named in the consumer.
Net: 1 file changed, +30 / -54.
StorageUsageScreen.swift is now Postbox-free. The wave 8 outcome's other
future candidate (StorageFileListPanelComponent.swift's Icon.media(Media,
...) enum case) remains — trivial future wave will land the whole-module
drop.
Plan: docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-9.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Narrow consumer-module migration of raw Message types to EngineMessage in
the StorageUsageScreen component. Two files touched; the module keeps
import Postbox because of two out-of-scope site clusters (preferences-
view observation + a Media-carrying Icon enum case) — both flagged in
CLAUDE.md and the wave doc as future-wave targets.
StorageUsageScreen.swift:
SelectionState.togglePeer(availableMessages:)
[EngineMessage.Id: Message] -> [EngineMessage.Id: EngineMessage]
AggregatedData.messages
[MessageId: Message] -> [EngineMessage.Id: EngineMessage]
AggregatedData.clearIncludeMessages / .clearExcludeMessages
[Message] -> [EngineMessage]
AggregatedData.init messages param — same swap
RenderResult.messages
[MessageId: Message] -> [EngineMessage.Id: EngineMessage]
openMessage(message: Message) -> openMessage(message: EngineMessage)
(unwrap to raw at OpenChatMessageParams
/ chatMediaListPreviewControllerData
call sites via ._asMessage())
StorageFileListPanelComponent.swift:
Item.message: Message -> EngineMessage
(internal .id / .timestamp / .media
usage compiles unchanged against the
EngineMessage class).
Wave-7 facade-boundary bridging dropped:
- renderStorageUsageStatsMessages call site: the .mapValues(EngineMessage.init)
on existingMessages and .mapValues { $0._asMessage() } on the result vanish;
AggregatedData.messages and RenderResult.messages are now engine-typed on
both sides of the facade.
- clearStorage call sites (2): the .map(EngineMessage.init) wraps around
includeMessages / excludeMessages vanish; locals become [EngineMessage].
- Inside AggregatedData.updateSelected... accumulation loop, four
item.message._asMessage() calls (where item.message was EngineMessage
and the target was [Message]) drop back to plain item.message.
- StorageMediaGridPanelComponent.Item(message: EngineMessage(message), ...)
at the RenderResult-build loop loses the EngineMessage(...) wrap since
`message` is already EngineMessage.
Out of scope (module keeps import Postbox):
- StorageUsageScreen.swift:1047-1062 / 3131-3185 — AccountSpecificCache-
StorageSettings observation via postbox.combinedView + PreferencesView
and a postbox.transaction block doing transaction.getPeer / getPeerCached-
Data as? CachedGroupData/CachedChannelData for peer-category classification.
- StorageFileListPanelComponent.swift:105 — Icon.media(Media, ...) enum case.
Constructed only as .media(TelegramMediaFile, ...) or .media(TelegramMedia-
Image, ...); trivial future wave to split into two cases.
Build verified green: 59s incremental, 27 actions, Telegram.ipa produced.
Plan: docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-8.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the seven remaining raw-Postbox leaks in TelegramEngine public
facades surfaced by a post-wave-6 scouting pass (all non-permanently-
blocked candidates).
Facades migrated in place (6 rewrites + 1 deletion; all _internal_*
implementations unchanged per the "internal Postbox-facing stays raw"
rule):
Messages:
downloadMessage Signal<Message?> -> Signal<EngineMessage?>
topPeerActiveLiveLocationMessages Signal<(Peer?, [Message])> -> Signal<(EnginePeer?, [EngineMessage])>
getSynchronizeAutosaveItemOperations deleted (dead facade; sole caller uses _internal_ directly)
Peers:
updatedRemotePeer Signal<Peer> -> Signal<EnginePeer>
(PeerReference param kept; no EnginePeer.Reference alias today)
Resources:
renderStorageUsageStatsMessages [EngineMessage.Id: Message] -> [EngineMessage.Id: EngineMessage]
clearStorage(peerId: ...) [Message] -> [EngineMessage]
clearStorage(peerIds: ...) [Message] -> [EngineMessage]
clearStorage(messages:) [Message] -> [EngineMessage]
(no external callers; migrated for overload-set consistency)
Consumer call-site updates (5 files):
- ChatListSearchListPaneNode drop redundant .flatMap(EngineMessage.init) wrap
- LocationViewControllerNode drop redundant .map(EngineMessage.init) wrap
- LiveLocationSummaryManager drop redundant EnginePeer(...) / EngineMessage(...) ctors
- StorageUsageScreen bridge [Message] <-> [EngineMessage] at the 4 facade-call points
(internal [MessageId: Message] / [Message] storage kept;
full-consumer-module migration is out of scope)
Discovery: grep of TelegramEngine/*/TelegramEngine*.swift public signatures
for `: Postbox|: Account|: MediaBox|: MediaResource|: Peer\b|: Message\b|
-> Signal<.*(Peer|Message)` turned up these seven candidates and no others.
After this wave, the full TelegramEngine.* facade surface is engine-typed
modulo the four permanently-blocked TelegramMediaResource-conforming
classes recorded in CLAUDE.md (ICloudFileResource, InstantPageExternal-
MediaResource, VideoLibraryMediaResource, YoutubeEmbedStoryboardMedia-
Resource).
No modules became Postbox-free in this wave. Plan: docs/superpowers/
plans/2026-04-20-postbox-to-telegramengine-wave-7.md.
Full project build verified green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wave-6 commits (spec, plan, C1 batch drop, C2 tally) were
squashed into a single commit, shifting the SHA referenced twice in
the wave-6 outcome section. Updates the two "see git show <sha>"
pointers to the new squashed commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First build-verified unused-import sweep: speculatively dropped
import Postbox from 782 consumer files (plain ^import Postbox$ lines,
excluding TelegramCore/Postbox/TelegramApi paths), iterated 18 full
project builds with --continueOnError, restored the import on every
file that failed to compile. 183 drops survived; 189 consumer modules
newly Postbox-free.
Bundled: spec + plan + C1 atomic batch drop + C2 CLAUDE.md outcome and
permanent methodology guidance under Wave-selection. The methodology
subsection captures the reusable playbook (--continueOnError is
essential, dependency graphs are deep so expect many iterations,
pattern-based preemptive restores accelerate convergence, and
CLAUDE.md's engine typealias cheat sheet arrows are migration targets
rather than typealiases in TelegramCore).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrates uploadSecureIdFile's public signature from (context:,
postbox: Postbox, network: Network, resource: MediaResource) to
(context:, engine: TelegramEngine, resource: EngineMediaResource).
SecureIdVerificationDocumentsContext refactored to hold
engine: TelegramEngine instead of raw postbox + network, drops
import Postbox (tally +1). The one instantiation site in
SecureIdDocumentFormControllerNode updates to pass
engine: self.context.engine.
Completes the last explicitly-named future-wave candidate from
CLAUDE.md.
Bundled: spec + plan + C1 atomic migration + CLAUDE.md outcome.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds three thin forwarding methods on TelegramEngine.Resources
(fetch, status, data) over MediaBox, then migrates SaveToCameraRoll's
three public functions to use them, drops import Postbox from the
module (source + Bazel dep), and updates all 23 call sites across 14
caller files atomically.
Bundled: spec + fix + plan + C1 facades + C2 SaveToCameraRoll rewrite
+ BUILD dep drop + CLAUDE.md outcome.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an experimentalSnapScrollToPinnedItem path that resnaps scroll to
the first pinToEdgeWithInset item after inset/visible-size updates and
on empty itemNodes. Promotes ListViewItem to AnyObject and adds a
pinToEdgeWithInset protocol requirement with a default of false.
Also runs chat history transactions synchronously when updating scroll
around correlated messages, and tightens beganInteractiveDragging weak-
self handling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires up TextProcessingScreen to the edit/delete AI text style facades,
renders AI text style webpage previews in chat bubbles, and exposes a
shared makeEmojiStatusSelectionController on SharedAccountContext so
the screen can present the emoji picker when assigning a style emoji.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds TelegramEngine.Messages.editAITextStyle and deleteAITextStyle
facades and decodes webPageAttributeAiComposeTone into a new
TelegramMediaWebpageAITextStyleAttribute. Makes CloudStyle.prompt
optional and requires emojiFileId on create.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds aicompose.createTone/updateTone/deleteTone functions,
InputAiComposeTone constructor, and webPageAttributeAiComposeTone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces a pin-to-edge mode for list items: items whose
pinToEdgeWithInset flag is true are pinned to the bottom edge of the
visible area, with the list geometry (pin inset, scrollToItem,
inset-transition offsetFix, visible-size updates) adjusted to handle
this new mode consistently.
Collected from spec, plan, and 12 iterative fixes during
implementation that refined pin-inset helpers, scroll landings, and
transition math. Pin-inset computation uses frame.size.height;
scrollToItem lands on the item's pinned position via apparentBounds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reflects the current state of the Postbox → TelegramEngine refactor:
- Header now lists waves 1 and 2 with their plans.
- New "Wave 2 outcome (2026-04-17)" section enumerates the five
migrated TelegramEngine facades, the one fully de-Postboxed
consumer submodule (MapResourceToAvatarSizes), the AuthorizationUI
signal type swap, and the abandoned Task 8 (SaveToCameraRoll).
- Wave-selection guidance extended with two lessons learned:
(a) planning-time inventories should grep the full Postbox token
set, not just the tokens specific to the wave's goal, to avoid
execution-time abandonments like wave 2's SaveToCameraRoll; and
(b) facade-API migrations (wave 2's shape) work better than
per-module Postbox drops (wave 1's shape) when the target is a
shared API surface.
- New "Modules currently free of import Postbox" tally lists the
8 consumer modules now clean across all waves and the standalone
PromptUI / PresentationDataUtils / StickerPeekUI cleanups.
- New "Known future-wave candidates" section records the three
leaks surfaced by the wave-2 final review and the four permanently
blocked TelegramMediaResource-conforming classes.
Docs-only change. No behavior change.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Drive raw `MediaResource` out of the `TelegramEngine` public facade for
photo-upload APIs, and complete a first batch of consumer-side
migrations. Behavior-preserving. The `_internal_*` Postbox-facing
functions are untouched — only the facade signatures change, bridging
inside via `_asResource()` / `EngineMediaResource(_:)`.
TelegramEngine facades migrated (signatures now take EngineMediaResource):
- TelegramEngine.Peers.uploadedPeerPhoto / uploadedPeerVideo
- TelegramEngine.Peers.updatePeerPhoto (closure param too)
- TelegramEngine.AccountData.updateAccountPhoto / updateFallbackPhoto
- TelegramEngine.Contacts.updateContactPhoto
- TelegramEngine.Auth.uploadedPeerVideo
Consumer-side changes:
- MapResourceToAvatarSizes utility: signature is now
(engine: TelegramEngine, resource: EngineMediaResource, ...). Uses
engine.resources.data(id:) internally. The submodule drops
`import Postbox` and the Bazel dep.
- AuthorizationUI: avatar-video signal retyped from
Signal<TelegramMediaResource?> to Signal<EngineMediaResource?>.
- 27 `mapResourceToAvatarSizes(postbox:...)` call sites across 5
TelegramUI/TelegramCallsUI files migrated to the new form.
Deferred:
- SaveToCameraRoll (planned Task 8) abandoned — module has three
public functions taking `postbox: Postbox` (umbrella-type leak,
banned by rule 2) and requires a full module-migration wave, not a
type swap. Reason recorded in the wave-2 plan doc.
- Other TelegramEngine facades still leaking MediaResource
(TelegramEngineStickers.uploadSticker; UploadSecureIdFile.* —
additionally leaks Postbox) flagged for a future wave.
Docs:
- CLAUDE.md: new rule 7 (TelegramCore never imports UIKit/Display,
shared with Telegram-Mac), and a new "MediaResource →
EngineMediaResource consumer migration" section describing the
wrap/unwrap helpers and the modify-in-place facade-bridging
pattern.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The only Postbox usage in Sources/ was a `PeerId?` parameter in the
public signature of openUserGeneratedUrl. Switch to the
EnginePeer.Id typealias (same type as PeerId, so no call sites
break) and remove the Postbox import. Add TelegramCore to BUILD
deps since EnginePeer lives there; Postbox was not in BUILD (was
transitively supplied). Behavior-preserving.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The `import Postbox` line and `//submodules/Postbox` BUILD dep were
unused — no Postbox types or APIs are referenced in the module.
Removing both. Behavior-preserving.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The `import Postbox` line and `//submodules/Postbox` BUILD dep were
unused — no Postbox types or APIs are referenced in the module.
Removing both. Behavior-preserving.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Drop direct `import Postbox` from four leaf consumer submodules, routing
data access through TelegramEngine/TelegramCore. Behavior-preserving.
Consumers migrated:
- ChatInterfaceState
- ChatSendMessageActionUI
- ContactListUI
- DrawingUI
TelegramCore additions (typealiases + one EngineData item):
- EngineMemoryBuffer, EnginePostboxDecoder, EnginePostboxEncoder,
EngineAdaptedPostboxDecoder (narrow utility aliases for coding support)
- EngineData.Item.Configuration.ContactsSettings
Six planned modules were abandoned in wave 1 with reasons recorded in
the plan: ActionSheetPeerItem, ChatListSearchRecentPeersNode,
DirectMediaImageCache, FetchManagerImpl, GalleryData, ICloudResources.
Each either has a public API that leaks `Postbox`/`Account`/`MediaBox`
(banned umbrella-type aliases) or cascades into out-of-wave modules.
CLAUDE.md now records the wave rules, typealias cheat sheet, and the
wave-selection guidance learned from this pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
10 tasks, one per chosen leaf module, each with inventory-map-edit-
build-commit steps. Common build command, commit template, and
failure-handling rules centralized in a Background section.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
TelegramCore does not re-export Postbox, so dropping the Postbox
import forces every Postbox-type reference to switch to an engine
typealias. Spell this out, and clarify that new typealiases are in
scope while new engine wrapper structs are not.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Covers the first 10 single-import leaf submodules, in bottom-up
dependency order, with full-build verification per module.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace characterToGlyphMapping with characterRects ([CGRect]?) on InteractiveTextNodeLine
- Add computeCharacterRects flag to InteractiveTextNodeLayoutArguments
- Compute actual glyph bounding boxes via CTFontGetBoundingRectsForGlyphs during layout
- Implement computeRevealedLines, layoutForCharacterCount, sizeForCharacterCount
- Implement updateRevealCharacterCount to distribute character budget across content item layers
- Update snippet layers to animate per-character rects with SnippetLayer tracking character index
- Mask limit driven by lowest animating snippet index for correct reveal ordering
- Fix listViewAnimationCurveEaseIn to match iOS default ease-in curve (0.42, 0.0, 1.0, 1.0)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fix compilation errors in SubcodecAnimationCacheImpl and
SubcodecMultiAnimationRendererImpl: NSData->Data sink closure types,
withSink->sink argument labels for advanceFrame/emitFrameIfNeeded,
unused variable warning, and redundant Void return assignment.
Add both modules as deps to ChatEntityKeyboardInputNode to verify
they compile in the full build.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements utility helpers, BT.709 ARGB→YUV 4:2:0 conversion with alpha
un-premultiplication, and AnimationCacheItemWriterImpl that feeds frames
to SCSprite and writes metadata alongside the .mbs output.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without this, SWelsSvcCodingParam::ParamTranscode was not copying
bSubcodecMode from SEncParamExt, so the encoder's internal pSvcParam
always had bSubcodecMode=false regardless of what the caller set.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Migrated Api.phone.*, Api.photos.*, Api.stats.*, Api.premium.* constructors
to use struct-wrapped enum pattern with --struct-count=560.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Refactored Api constructors in authorization and channel-related code:
- authorization, authorizationSignUpRequired, sentCode, sentCodeSuccess,
sentCodePaymentRequired, sentCodeTypeFirebaseSms, and related types
- channelParticipant, channelParticipants, sendAsPeers, adminLogResults
- chatlistInvite, chatlistInviteAlready, chatlistUpdates, exportedChatlistInvite
- popularAppBots, sponsoredMessageReportResultChooseOption, and others
Fixed variable shadowing issues where 'user' was extracted and then
immediately shadowed by TelegramUser initialization.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Updates TelegramCore to work with --struct-count=360. This batch includes
story types, update types, user types, theme types, and various other
constructors in the 340-359 type range.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update pattern matches to use struct-based extraction for inputPeer*,
inputMedia*, inputReplyTo*, inputSticker*, inputPhoto, inputUser, etc.
- Wrap constructor calls with .init() for struct-wrapped enum cases
- 47 TelegramCore source files modified
- 5 TelegramApi files regenerated with --struct-count=180
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Types refactored:
- AvailableReaction, BankCardOpenUrl, Birthday, Boost
- BotApp, BotAppSettings, BotBusinessConnection, BotCommand
Updated pattern matching and construction sites in TelegramCore.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- AccountDaysTTL, AttachMenuBot, AttachMenuBotIcon now use Cons_* classes
- Updated use sites in TelegramCore to destructure from wrapper
- Generated Api files with stub functions for faster compilation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
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.
--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
## Embedded watch app (`Telegram/WatchApp`)
A 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.
**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.
**`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.
**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).
**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`.
**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.
## 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`/`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.
- **`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.
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.
## InstantPage V2 & rich-text messages
Typed 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).
## 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, 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.
See 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.
### Rules that apply to every wave
1. `TelegramCore` does **not**`@_exported import Postbox`. Once a consumer drops `import Postbox`, every remaining Postbox-type reference must use an engine-typealiased equivalent.
2. **Never typealias `Postbox`, `Account`, or `MediaBox`.** These umbrella types rename without encapsulating. Narrow utility typealiases (`MemoryBuffer`, `PostboxDecoder`, `PostboxEncoder`, `AdaptedPostboxDecoder`, `MediaResource`, …) remain allowed and expected.
3. No new engine wrapper **structs** unless the wave's spec explicitly allows — only typealiases and thin forwarding methods.
4. **Discovery first:** before adding any new engine wrapper/typealias, grep `submodules/TelegramCore/Sources/TelegramEngine/` for existing equivalents. Record the search result in the commit message.
5. **Abandonment protocol:** if a module can only be refactored by violating rule 2 or by editing a module outside the current wave's list, mark the task Abandoned with a recorded reason. Do NOT substitute a new module mid-wave.
6. Full project build per module. No unit tests exist in this project.
7. **TelegramCore never imports UIKit/Display.**`TelegramCore` is shared with the Telegram-Mac codebase; its Bazel `deps` and source files must not reference UIKit, Display, or any Apple-UI framework. UIKit-needing helpers (image scaling, rendering, etc.) stay in consumer-side submodules.
8. **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.
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).
`EngineMediaResource` is a `final class` in `TelegramCore` wrapping a `MediaResource` value. Unlike the typealiases above it is **not** interchangeable with the protocol, but it does provide wrap/unwrap helpers:
- `EngineMediaResource(rawResource)` — wrap a raw `MediaResource`.
- `engineResource._asResource()` — unwrap to the raw `MediaResource`.
**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`.
## 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:
"NSLocationAlwaysAndWhenInUseUsageDescription" = "When you choose to share your Live Location with friends in a chat, Telegram needs background access to your location to keep them updated for the duration of the live sharing.";
"NSLocationAlwaysUsageDescription" = "When you choose to share your live location with friends in a chat, Telegram needs background access to your location to keep them updated for the duration of the live sharing. You also need this to send locations from an Apple Watch.";
"NSCameraUsageDescription" = "We need this so that you can take and share photos and videos, as well as make video calls.";
"NSPhotoLibraryUsageDescription" = "We need this so that you can share photos and videos from your photo library.";
"NSPhotoLibraryAddUsageDescription" = "We need this so that you can save photos and videos to your photo library.";
"NSPhotoLibraryUsageDescription" = "We need this so that you can share photos and videos from your photo library and save the ones you capture.";
"NSPhotoLibraryAddUsageDescription" = "We need this so that you can save photos and videos to your photo library and save the ones you capture.";
"NSMicrophoneUsageDescription" = "We need this so that you can record and share voice messages and videos with sound.";
"NSSiriUsageDescription" = "You can use Siri to send messages.";
"NSFaceIDUsageDescription" = "You can use Face ID to unlock the app.";