Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
223 KiB
InstantPage V2 & rich-text message rendering
This file documents the rich-text message pipeline and the InstantPage V2 renderer that backs it.
A rich message is a RichTextMessageAttribute carrying an InstantPage (sent with text: ""), produced when typed markdown contains structure the regular message-entity set can't represent (headings, lists, tables, formulas, nested blockquotes) and drawn by ChatMessageRichDataBubbleContentNode via the InstantPage V2 layout/renderer — including AI-streaming progressive reveal, inline custom emoji, and entity (mention / hashtag / …) cases. It also covers the send / edit / copy / paste round-trips between markdown and InstantPage.
These are detailed, non-obvious invariants — read the relevant section before touching the corresponding code. (Moved out of CLAUDE.md to keep that file focused; CLAUDE.md retains a brief pointer back to here.)
Text Size / content scale (bugs.telegram.org/c/62776, 2026-09-22)
A rich message follows Settings ▸ Appearance ▸ Text Size like a plain bubble. Before this, nothing on the
rich path read a font size: the bubble built its theme from the fixed chat-message table, layoutInstantPageV2
always used InstantPageMetrics.unscaled, "Show more" was Font.regular(17.0), and the node's layout cache
was keyed on theme identity only — so even a scaled theme would have kept serving the old layout.
Where things live
| File | What |
|---|---|
InstantPageUI/Sources/InstantPageV2ContentScale.swift |
InstantPageV2ScaledLayoutInputs — page theme, page metrics, quote theme, quote metrics from the BASE theme and ONE contentScale. The testable seam (layoutInstantPageV2 needs PresentationStrings, which no test bundle can build). |
InstantPageUI/Sources/InstantPageV2Layout.swift |
layoutInstantPageV2(…, contentScale: CGFloat = 1.0). theme must be the UNSCALED theme. |
InstantPageUI/Sources/InstantPageChatMessageTheme.swift |
instantPageChatMessageAuthoredFontSize (17) — the divisor; codeBlock category now 15. |
InstantPageUI/Sources/InstantPageTheme.swift |
fontSizeMultiplier stored on the theme; the H1–H6 ladder scales by it. |
InstantPageUI/Sources/InstantPageMetrics.swift |
codeBlockFontSize = floor(15 · scale); init(scale:screenScale:). |
ChatMessageRichDataBubbleContentNode.swift, ChatSendMessageRichTextPreview.swift, ButtonEditorScreen.swift |
Pass instantPageChatMessageContentScale(baseFontSize:) (the button editor previews the bubble's 17pt table, so it scales with it); the bubble's and the send preview's layout caches are keyed on the font size; "Show more" at the base size. |
InstantPageUI/Tests/InstantPageContentScaleTests.swift |
The rounding contract, all seven steps. |
Non-obvious invariants
- One scale, applied once.
contentScale = baseDisplaySize / 17is fractional at six of the seven steps (0.824 … 1.529). It is consumed at exactly two rounding chokepoints:withUpdatedFontStylesfloors every font to whole points, andInstantPageMetrics(scale:)snaps every geometry constant to the pixel grid — the same two paths the nested-quote scale (15/17) already went through. Nothing downstream multiplies by it again; a quote inside a scaled page isbase × (contentScale · quoteScale), rounded once, never a scaled theme scaled again. Rounding twice happens to agree for the paragraph at all seven steps, which is luck, not a property. - Code = table = quote body, all "one step below body" (15 at 17), and they stay equal at every step AND
inside a quote at every step. Code rides
InstantPageMetrics, so it must take the fonts' whole-point floor, not the pixel snap —floorToScreenPixels(15 × 19/17)is 16.67 against a 16pt table. - The theme's stored
fontSizeMultiplieris what the heading ladder scales by. Recovering it from the floored subheader drifts by a point (H2 = 21 instead of 22 at.large). This also moves the full-page Instant View reader (V1InstantPageLayoutheading blocks) at every non-standard reader font size — e.g. reader.large(1.15): H2 was floor(20 × 25/22) = 22, is floor(20 × 1.15) = 23. Deliberate: the ladder now scales by the same number as the body text beside it.InstantPageContentScaleTests.testInstantViewReaderLadderScalesWithItsBodyAtEveryReaderSizepins it. layoutInstantPageV2assertstheme.fontSizeMultiplier == 1.0 || contentScale == 1.0. A pre-scaled theme scaled again floors its categories twice while the multiplier compounds exactly, and the ladder and body drift apart. The reader's themes arrive pre-scaled and pass 1.0; chat hosts pass the authored theme plus the scale.- The test process reports a 1x screen (
UIScreen.main.scale == 1.0, measured 2026-09-22), on which a pixel snap IS a floor. Any test about the grid passesscreenScale: 3.0explicitly, or it cannot fail. - Text Size does not change the theme object, so the bubble's and the preview's layout caches carry the font size as their own key.
- Not scaled, on purpose: inline/block button labels (15/16pt, see "Inline buttons"); the NATIVE editor,
which lays out at
RichTextRenderMetrics.default(17pt, pinned equal to the unscaled chat table byRichTextV2MetricsParityTests) and carries its ownbaseFontSize = 17.0inRichTextEditorChatInputNode. Scaling it is a separate decision — it needs the presentation font size plumbed in and the parity tests widened to a scale parameter. Until then the dual-field switch latching to native shows a size jump for non-.regularusers. - The LEGACY composer follows Text Size everywhere (2026-09-22).
ChatTextInputPanelNoderead the font size at fourteen sites and eight of them — inherited from upstream, which still has them — carried an always-trueif "".isEmpty { baseFontSize = 17.0 }pin: the placeholder font, the empty-field minimum height, the vertical text insets and the initial rendering config. The per-keystroke re-decoration was not pinned, so typed text scaled on the first keystroke while the placeholder stayed 17pt and an empty field opened at 31 and animated to the scaled height when its text node loaded. All reads now go throughchatTextInputBaseFontSize(for:)/chatTextInputFieldMinHeight(for:)/chatTextInputFieldVerticalInsets(for:)(ChatTextInputFontMetrics.swift; the latter two switch exhaustively onPresentationFontSize, so a new step fails to compile rather than silently taking 31), a Text Size change rebuilds the placeholder and re-decorates live text (fontSizeUpdated, besidethemeUpdated), andChatTextInputFontMetricsTestsasserts the minimum height equals what the legacy text view measures for an empty field at every step — the exact property whose violation was the animation. Two knock-ons of the field growing. The panel holds twoChatTextInputActionButtonsNodes:mediaActionButtons(the mic, OUTSIDE the field beside the attachment button) andsendActionButtons(the send capsule, INSIDE the field). Both used to be sized to the field's minimal height, equal to 40 only while pinned. The mic is now a fixed 40×40 like the attachment button; the send node keeps following the field so its capsule (inset 3pt) fits it at every step — 40×34 at 17pt, 40×41 at 23pt. And that capsule's background was a stretchable circle baked at diameter 34, a constant 17pt radius: it is now regenerated formin(width, height)of its frame, and the slowmode ring and stars effect layer take the same radius. - Two
layoutInstantPageV2callers stay at 1.0 on purpose:TextProcessingRichContentView(the text-processing screen, not a chat bubble) andFormulaEditorScreen, whose preview theme is authored at a 22pt paragraph as a stylised preview — the bubble's divisor would compound onto a base that is not 17. The bubble, the send preview and the button editor preview pass the chat scale.
AI streaming animation (rich-text bubbles)
ChatMessageRichDataBubbleContentNode progressively reveals InstantPage V2 content while TypingDraftMessageAttribute is on the message. Mirrors the older animation in ChatMessageTextBubbleContentNode, adapted to the heterogeneous V2 layout. The "Thinking…" indicator is now server-sent as InstantPageBlock.thinking rendered inside the pageView (see "InstantPage thinking blocks" section).
Where things live
| File | Responsibility |
|---|---|
submodules/TelegramUI/Components/StreamingTextReveal/Sources/TextRevealController.swift |
Pacing controller, shared by both bubbles. EWMA inter-arrival → velocity-smoothed cursor. |
submodules/InstantPageUI/Sources/InstantPageRenderer.swift (InstantPageV2TextView) |
Drawing split: private TextRenderView does draw(_) inside a renderContainer whose layer carries a revealMaskLayer; new chars spawn cropped SnippetLayer siblings of the render container that animate in (blur + alpha + scale + position) and are absorbed into the mask on completion. Ported from InteractiveTextComponent. |
submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift |
InstantPageV2RevealCostMap + InstantPageV2View.applyReveal(revealedCount:costMap:animated:). Bridges the global width-based cursor to per-text-view char counts (via charCountForWidthBudget) and per-item visibility / table-row pop-in. |
submodules/InstantPageUI/Sources/InstantPageV2Layout.swift |
InstantPageTextLine.characterRects (line-local CT coords, baseline-relative positive-up) populated when computeRevealCharacterRects: true is passed to layoutInstantPageV2(...). Uses CTFontGetBoundingRectsForGlyphs for actual glyph ink, not advance widths. |
submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/... |
Streaming detection (TypingDraftMessageAttribute), display-link wiring, container sizing. The hardcoded "Thinking…" header was removed; thinking is now rendered by the pageView via InstantPageBlock.thinking. |
Non-obvious invariants
- Cost unit is points of width, not characters. Each item's cost = its width in points along the reading direction. Text contributes sum of glyph ink widths; non-text items contribute
frame.width. Table cells are floored atcell.frame.widthso narrow- or empty-cell tables don't race through the cursor. Reveal pace becomes "points per second" — uniform across content types. - Mask uses per-glyph ink bounds, unioned per line. Each revealed glyph's mask rect comes from
CTFontGetBoundingRectsForGlyphs(not advance widths) so italics, accents, descenders are covered exactly. Per line, glyphs are unioned into one mask rect; consecutive fully-revealed lines union further — fully-revealed prefix is always oneCALayer. containerNodedoes ALL the clipping. During streaming, containerNode is sized torevealedItemsMaxY(no header offset, no closing pad;streamingHeaderOffsetis0.0). The bubble itself is taller (revealedContentSize.height + 2) — the strip below containerNode is empty bubble background. pageView keeps its fullpageLayout.contentSize; anything past containerNode's bottom is clipped at containerNode (clipsToBounds = trueset in init). Do NOT shorten the pageView or setpageView.clipsToBounds.- The pageView is REUSED across
stableVersionbumps for the same message id.ensurePageViewcallsexisting.renderContext?.updateContent(webpage:)(wherewebpageis now apublic private(set) varwith anupdateContentmutator) and returns the existing view;update(layout:)then diffs item views by stable id, tearing down only views whose block was removed. The pageView is rebuilt only when the bubble is recycled with a different message or webpage. The reveal cursor onTextRevealControllerpersists across chunks; the seed re-apply (applyReveal(revealedCount: previousAnimateGlyphCount, …, animated: false)) is now a continuation from the reused views' state, eliminating the per-chunk flash-of-full-text-then-mask that required the earlier from-scratch re-seed. - Layout cache key includes
message.stableVersion. Each AI chunk bumps stableVersion; without this the cached layout would shadow newly-arrived content. TypingDraftMessageAttributeis the streaming gate. Same trigger TextBubble uses. The InstantPage'sisCompleteflag is informational only.- Width-based cost → char count bridge. Mask APIs (
updateRevealCharacterCount) still take character counts.applyRevealEntrycallscharCountForWidthBudget(textItem:widthBudget:)to translate the width-based local cursor into the per-text-view character count. - The hardcoded "Thinking…" header was removed.
streamingStatusTextNode,streamingStatusShimmerView, and the header-layout machinery no longer exist.streamingHeaderOffsetis now a constant0.0— the pageView starts at the top of the bubble. The "Thinking…" indicator is now server-sent asInstantPageBlock.thinkingand rendered inside the pageView (see "InstantPage thinking blocks" section below). - Display-link tick re-layouts on extent change. Tick reads
revealedContentSizeat the new cursor; if the height differs from the previous cursor, callsrequestFullUpdate. So the bubble grows in flight when the cursor crosses a line/item boundary, not just between chunks. Tick passesanimated: truetoapplyRevealto fire the snippet pop-in.
Send-time media continuity (no blink on Local→Cloud)
A rich message with media used to blink on send because ensurePageView keyed pageView
reuse on message.id, which flips namespace Local→Cloud at send time, forcing a full pageView
rebuild → fresh InstantPageImageNodes → setSignal re-run → fade-in flash.
- The reuse key is
message.stableId, NOTmessage.id.stableIdis preserved across the Local→Cloud transition (it is also whatChatMessageBubbleItemNodeuses to reuse the content node instance), so the pageView and its positional.media(index)media views survive send; the reused views keep their already-rendered pixels. A genuinely recycled bubble has a differentstableId, so recycling still rebuilds. - The data layer already re-homes the bytes.
ApplyUpdateMessage.swiftcallsapplyMediaResourceChangesforRichTextMessageAttribute, moving the local upload bytes onto the new Cloud resource ids — so even the reference node's pattern (reload only if not semantically equal) would resolve from cache. Here we avoid the reload entirely by keeping the views. - The render context's
MessageReferenceis refreshed on the id flip.InstantPageV2RenderContext.updateContent(webpage:message:imageReference:fileReference:)swaps the message-scoped reference + closures whenmessageIdchanges (Local→Cloud), so LIVE consumers — inline videoNativeVideoContentId.message/fetch, audio playlist key/fetch, gallery — use the Cloud reference immediately. The webpage-onlyupdateContent(webpage:)remains for streamed AI chunks (message id stable → no refresh). - The reused media node's INTERACTIVE bindings are refreshed too — its image is NOT. The
poster
InstantPageImageNodeis reused (its already-decoded pixels are byte-identical to the Cloud image, sinceApplyUpdateMessagemoved the bytes onto the new resource id — so the image signal is deliberately never re-set, which is what avoids the blink). But itsself.mediaidentity and fetch-status subscription are still bound to the stale local resource, and tap-to-open depends on both:openInstantPageMedia'scentralIndexmatch compares the tappedself.mediaagainst the fresh (Cloud) gallery entries viaInstantPageMedia ==(fullEngineMediaequality), and the image tap gate keys onfetchStatus. Left stale, tap silently no-ops (image) or opens nothing (video) until a scroll-recycle rebuild. So the V2 media views (InstantPageV2MediaImageView/VideoView/CoverImageView) callInstantPageImageNode.updateInteractiveMediaBinding(sourceLocation:media:imageReferenceForMedia:fileReferenceForMedia:)fromupdate(item:)only whenitem.media.media.idchanged — re-pointingself.media, the status subscription, andfetchControlsat the Cloud resource without touching the image signal. (Gated on the id change so it never churns during AI-streaming relayouts, where the id is stable.) - Coverage. Single image, video, and collage cells are covered — collage flattens into
ordinary positional
.mediaImage/.mediaVideoviews, so each cell gets the reuse + binding refresh for free. Slideshow was verified to send with no blink and working tap-to-open with no slideshow-specific code (its container view + pages reconcile through the same positional reuse). If a future change makes a slideshow rich message blink or break tap-to-open on send, apply the sameupdateInteractiveMediaBindingrefresh toInstantPageV2SlideshowView's pages.
Whole-content vs same-content updates
ensurePageView splits a same-stableId content change two ways. A same-content update keeps
the view and lets InstantPageV2View.update diff item views by stable id — a streamed chunk, the
Local→Cloud send flip, a text edit that preserves shape, a checkbox tap, a <details> toggle. A
whole-content update rebuilds the view and crossfades: the outgoing view stays live (0.12s out)
under the new one (0.1s in), the same numbers ChatMessageTextBubbleContentNode uses.
Load-bearing details, none of which the compiler checks:
- Compare
richPageKey.caseTag, neverrichPageKey. The key carriesObjectIdentifiers of the attribute and page, and a streamed chunk produces a freshRichTextMessageAttributeon every tick — comparing keys would dissolve the bubble on every chunk. - The fingerprint (
instantPageStructureFingerprint) reads strings, not identities. In: case tags, child counts, optional-child presence, and every string the user reads —RichTextleaves and their wrapper tags (so adding bold counts), urls, captions, table cell text, button labels,formulalatex. Out:MediaId,webpageId,fileId,peerId, invisibleanchornames.- Excluding media identity is load-bearing. The send flip rewrites every
MediaIdin the page, including the inline ones insideRichText.image, while nothing visible changes. Fold them in and every rich message with media dissolves on send. - Optional value is payload, which is why ticking a checkbox (
checked: Bool?goingfalse→true) does not dissolve but a checkbox marker appearing does. RichText.textDatecontributes its modeldate, never the formatted string — so the relative-date refresh timer re-lays-out ("3 minutes ago" → "4 minutes ago") without dissolving.- Still excluded as presentation payload: heading
level, listordered, preformattedlanguage,blockQuote.collapsed,details.expanded,image.spoiler.
- Excluding media identity is load-bearing. The send flip rewrites every
- The
InstantPage ==guard is what makes the pending-edit exit safe..pendingEdit → .originalfires when the server confirms an edit, at which point the content usually matches the optimistic local page already on screen; without the guard that is a flash for nothing. - The outgoing view is live, not a snapshot — a snapshot would freeze a playing inline video and
stop custom-emoji loops mid-dissolve. It is safe because
mediaRegistryis per-root-view and every bubble-side lookup goes throughself.pageView, which already points at the new view. - The fade-in is gated on a local
crossfadeInflag, not onfadingOutPageView != nil. A dissolve from a previous update can still be in flight when a scroll recycle rebuilds for a different message; keying off the slot would fade the recycled bubble in for no reason. The recycle path also drops the stale dissolve outright. - A non-animated pass takes the same-content path.
update(layout:theme:)re-renders every reused view, so the content is correct either way; rebuilding would only re-create media wrappers and their fetches for a transition nobody sees.
Status node (date/time/checks) positioning
The ChatMessageDateAndStatusNode mirrors TextBubble's placement, adapted to the heterogeneous V2 layout. The node is a child of self (the content node), not of the clipping containerNode, so it is never clipped — the bubble height must be grown to contain it.
- X is a fixed left edge, not the last line's
minX. Anchor x =pageHorizontalInset(10pt, the page layout's text inset; pageView sits at self-x 0). The status layout is measured withboundingWidth - 2·pageHorizontalInset(mirrors TextBubble'sboundingWidth - sideInsets) so the right-aligned date lands at the right inset instead of off the bubble. UsinglastTextLineFrame.minX(which is large for nested/indented last lines) shoved the date off to the right. - Trail the last line only when the bottom-most item is text.
lastTextLineFrameIfLastItemIsText(in:)(inInstantPageV2Layout.swift) returns the last line frame only when the bottom-most top-level item (maxmaxY) is a.text; otherwise nil, so the date wraps below all content (anchored atcontentSize.height). For tables/images/etc. the date must not trail text buried above the final item. - InstantPage draws the baseline at the line frame's
maxY(InstantPageRendererdraws each line atlineOrigin.y + lineFrame.height), so the visible text of a plain line sits ~5pt belowmaxY. A date that trails on the line (statusHeight == 0) addstrailingBottomPadding(5pt) to align with the text; a date that wraps onto its own line below (statusHeight > 0) sits at the baremaxY. The pad is 0 for lines taller than their font line height (a tall inline attachment, e.g. a formula, already pushesmaxYdown).lastTextLineFrameIfLastItemIsTextreturns(frame, trailingBottomPadding); the bubble applies the pad only in the trailing case. - Bubble height leaves ~6pt below the date. One unified formula for all cases:
boundingSize.height = max(boundingSize.height, statusBottomEdge + 6.0), wherestatusBottomEdge = statusAnchorY + max(1, statusHeight). ThestatusAnchorYin the measure (continue) closure must mirror thestatusFrameYin the apply closure exactly, or the date will be clipped/misplaced. (streamingHeaderOffsetis0.0— there is no header offset to add.) 6pt matches TextBubble's bottom bubble inset. hasDraftadds the same 6pt at the streaming site. The status max() above is gated by!hasDraft, so during streaming (status hidden, alpha=0) it can't supply the bubble's bottom inset. A separateboundingSize.height += 6.0insideif hasDraftin the SizeBlock closure does it instead — same 6pt, so the streaming bubble's bottom breathing room matches its post-stream height and there's no 6pt grow-pop when the status node fades in at finalize. ThehadDraft && !hasDraftfinalize pass doesn't need it because!hasDraftre-enables the status max(). If you ever refactor the+6.0constant out of the status max() into abottomInset(TextBubble's pattern), kill this separate term at the same time — they're two ends of the same invariant.- Trailing full-width media → overlaid pill, no reserved space. When the bottom-most laid-out item is full-width visual media (
.mediaImage/.mediaVideo/.mediaCoverImage/.mediaMap/.slideshow/.mediaPlaceholder),lastFullWidthMediaFrame(in:)(InstantPageV2Layout.swift) returns its frame and the status switches from.Bubble{Incoming,Outgoing}to the image-style.Image{Incoming,Outgoing}pill, positioned at the media's bottom-right corner (layoutConstants.image.statusInsets, with the X anchor clamped tocontentSize.widthto drop the 4ptinstantPageV2MediaEdgeBleed), and reserving no vertical space — the width-growth and thestatusBottomEdge + 6.0height reservation are both gated onmediaStatusFrame == nil, and the status layout input becomes.standalone(reactionSettings:). The full-width gate (frame.width >= contentSize.width - 12.0) excludes a narrow/centered trailing media, which keeps the normal below-content bubble status. The check is over the bottom ROW, not the bottom item: a.collagelays out one media item per cell, so a mosaic ends in several items side by side and the single bottom-most item is a ~half-width cell — which failed the gate and fell back to the inline text-time style.lastFullWidthMediaFramecollects every item within 2pt of the bottom edge, requires all of them to be overlay-eligible media, and gates on the union's width. That tolerance is load-bearing in both directions: mosaic cells in a row share a bottom edge to within rounding, while a caption or text line sits a whole line-height lower — which is what keeps a captioned collage on the text-time style. - A pill can't host multi-row reactions, so non-inline reactions move outside the bubble. The
wantsReactionsOutsidedecision must be made before layout (it feedsChatMessageBubbleItemNode's pre-layoutneedReactions), so it can't consult laid-out items — hence two detectors. The prepare phase does a model-only structural check (the effectiveInstantPage's last block is empty-caption visual media) and reportsChatMessageBubbleContentProperties.wantsReactionsOutside = endsWithMedia && hasNonInlineReactions;ChatMessageBubbleItemNodeORs that flag intoneedReactions(if needReactions || forceReactionsOutside), routing reactions to the external reaction-buttons node. The layout phase does the authoritative full-width check for the visual pill (above) and passes emptyreactions/reactionPeersto the status node. Inline reactions stay in the pill via the.standalonereaction settings, mirroringChatMessageInteractiveMediaNode. The two detectors can disagree only for a pathologically narrow trailing media (structural says "external", layout declines the pill) — accepted and documented, does not occur with server rich media (always full-width). The collage case used to be the realistic instance of that disagreement and is now closed (see the bottom-row check above); the decoupling remains as the defence, so reactions are unaffected either way — they key offwantsReactionsOutside, which is structural.
InstantPage V2 table — flush frame, inset borders, rounded corners
A V2 .table block's item frame is full-width / flush with the bubble interior (so a horizontally-scrollable wide table's scroll container bleeds edge-to-edge), but the actual grid borders start at the body-text side inset — matching the V1 renderer. The grid card also has a 10pt rounded outer border.
Non-obvious invariants
InstantPageV2TableItem.contentInset(= pagehorizontalInset) is the linchpin.layoutTable(InstantPageV2Layout.swift) sizes columns againstcontentBoundingWidth = boundingWidth − horizontalInset·2(so a fitting table aligns with body text on both sides) and storescontentInseton the item; the itemframe.widthis the flushboundingWidth, andcontentSize.widthstays the bare grid width (totalWidth, no inset).- The renderer (
InstantPageV2TableView) realizes the inset as a view shift, not baked coordinates. IninitANDupdateit shifts the gridcontentViewtox: contentInset, setsscrollView.contentSize.width = contentSize.width + contentInset * 2.0(margin on both sides, mirroring V1'sInstantPageScrollableNode), andscrollView.clipsToBounds = true. Cells, inner border lines, and the title stay x=0-relative insidecontentView, so the single shift carries them all; the rounded outer border iscontentView.layer's own border (see below), which wraps the shifted layer automatically. - Scrollable tables clip to the full width with no inset on the clip. The inset lives inside the scroll content as a symmetric margin on both sides (
contentInset * 2.0): a fitting table (grid + 2·inset ≤ boundingWidth) doesn't scroll and shows both-side inset; an overflowing table rests with its left border at the inset and scrolls until its right border reaches a matching trailing inset (it does not jam flush against the screen edge — matches V1). The scroll-indicator threshold andcontentSize.widthuse the same+ contentInset * 2.0, so "does it scroll" is exactlygrid > boundingWidth − 2·inset. - Overflowing tables compress instead of scrolling wide (V2 second pass). When the sum of columns' minimum (maximally-wrapped) widths exceeds the content width,
layoutTable(InstantPageV2Layout.swift) runscompressTableColumnsToFit(...): it scales columns proportionally to their natural (maxColumnWidths) widths down to a per-column floor (v2TableMinCompressedColumnWidth = 60.0, of which 26pt is cell padding), clamping any column that would drop below the floor and redistributing the remaining shrink (water-filling). A column narrower than its widest word wraps that word (taller cell). Two outcomes: (a) fits (Σ min(floor, naturalWidth) ≤ contentBoundingWidth) → the widths sum to exactlycontentBoundingWidth, no scroll; (b) doesn't fit (floors still overflow) → columns stay at the fully-compressed floored width and the table still scrolls, but at that narrow floored width (totalWidth = Σ floored + borderWidth), NOT the full natural widths — i.e. the compressed shape is always preferred over the wide one, minimizing the scroll extent. Natural widths are used only for a degenerate zero-column table. The floor is an absolute value, not derived from glyph widths, so a single very long token can still overflow its cell horizontally (accepted trade-off; no word-overflow guard). - Manual cell-coordinate helpers MUST add
contentInset. Because the shift is a realcontentViewframe change, UIKithitTestandself.convert(_:to:)paths (propagateVisibilityRect, the row-reveal mask) handle it automatically — but the manual coordinate helpersfindTextItem/collectSelectableTextItems(the live tap / URL / text-selection path) compute cell/title positions arithmetically and must addtable.contentInsetto the x-offset, or in-cell hit-testing is off by the inset. (These helpers still do not account for the table's live horizontalscrollView.contentOffset— a pre-existing limitation, so in-cell hit-testing is only correct at scroll offset 0.) The dead-but-symmetriclastTextLineFrame(in:)table branch has the same omission but has no callers. - The 10pt rounded outer border is
contentView.layer's own border, NOT sublayers.v2TableCornerRadius = 10.0(InstantPageV2Layout.swift). The renderer setscontentView.layer.cornerRadius/borderColor/borderWidth = bordered ? v2TableBorderWidth : 0.0in BOTHinitandupdate(the four straight outer-edge rect layers were removed;lineLayersnow holds only inner grid lines). Border-only — deliberately nomasksToBounds:cornerRadiusrounds the layer's border without clipping contents (filled corner cells round their own fills separately — see next bullet), and there is zero interaction with the streaming reveal mask (contentView.layer.mask, set only during AI streaming) — the border reveals row-by-row with the rows and is part of the masked layer. The rounded card belongs to the grid (scrolls with it). For a non-empty-title table (never produced by markdown/AI), the border wraps title+grid sincecontentViewincludes the title region — an accepted, approved nuance. - Filled corner cells round their own fills to match the border. A header/striped cell's background is a stripe
CALayer;tableStripeCornerMask(cellFrame:gridWidth:gridHeight:effectiveBorderWidth:)detects which grid corners the cell's (grid-local) frame touches —firstCol/firstRowviaframe.min{X,Y} <= effectiveBorderWidth/2 + 0.5,lastCol/lastRowviaframe.max{X,Y} >= grid{Width,Height} - …(gridWidth =item.contentSize.width, gridHeight =item.contentSize.height - gridOffsetY) — and rounds only those corners:stripe.cornerRadius = max(0, v2TableCornerRadius - effectiveBorderWidth)(the-borderWidthleaves an even border ring; borderless → full radius) +stripe.maskedCorners, in BOTHinitandupdate. ACALayer'sbackgroundColorhonorscornerRadius+maskedCornerswith nomasksToBounds. A full-width (colspan) header rounds both top corners; a one-row filled table rounds all four; bottom corners round only when the last row is filled. The empty-mask branch resetscornerRadius = 0andmaskedCorners = []so reused stripes (persist across streaming chunks) don't keep stale rounding. Detection is grid-local, so it's independent of thecontentInsetshift / horizontal scroll.
InstantPage V2 code block — edge-to-edge plain band
A .preformatted block renders as a plain, square, codeBlockBackgroundColor rectangle spanning its container's interior edge to edge, with its monospace text — and a bold, lowercased language line above it — at exactly the x a paragraph occupies at that nesting level. So the band's interior side padding IS the paragraph inset; it is not a code-block constant. No accent bar, no tint, no corner radius: a code block reads as a highlighted table row, not as a quote variant (it was one, from the 2026-07-06 reskin until 2026-08-18).
The message hosts pass codeBlockBackgroundColor the same value they give tableHeaderColor. The field is revived, not new — V2 stopped reading it in the July reskin while V1 full-page Instant View still does, so the presets in InstantPageTheme.swift are untouched and V1 is unaffected.
Non-obvious invariants
LayoutContext.childBleedis how a full-bleed block reaches its container's edges. V2 lays a container's children out flush against a band athorizontalInset: 0and then translates them, precisely so no block needs to know it is nested — that is what makes media, tables, collages and slideshows land inside a quote rather than at the page's leading edge. A block can therefore only reach the container's interior if the container tells it how far it may go. Each container sets and restoreschildBleedwith the samelet saved = …; defer { … }idiomthemeandmetricsalready use.layoutCodeBlockis the only consumer today.- The bleed is GEOMETRIC, not logical.
minXSideis always the smaller-x edge; a container that mirrors itself for RTL (a block quote moves its bar to the trailing edge) resolves that when it sets the value, so no consumer readscontext.rtl. Getting this backwards is invisible in LTR. - Every non-top-level sequence RESETS the bleed rather than inheriting it.
layoutBlockSequenceassigns the page inset for.topLeveland.nonefor everything else (.cell,.detail, the table title), andlayoutListdoes the same for its sub-blocks — which reachlayoutBlockdirectly and so never pass through that reset. An inherited page-level bleed would send a band punching out through a table cell's own edge. The.nonedefault onLayoutContextmeans a missed container degrades to the old inset geometry rather than to an escaping band. - A block quote's bleed stops just INSIDE its accent bar (
instantPageV2QuoteBarWidth, shared with the quote frames that draw it) so the bar stays continuous down the whole quote instead of being interrupted for the child's height. Note the quote's child band ends 45pt from the page's right edge while its fill ends at 9pt, so a nested band carries ~36pt of empty fill on its right — pre-existing quote geometry, accepted. - In the
fitToWidthshrink a band contributes its INNER content, not its own frame — and "exclude the band" is NOT enough.layoutBlockSequencesizes a rich bubble frommax(item.frame.maxX) + horizontalInset; a band reachingboundingWidthwould clamp every message containing a code block to a full-width bubble, however short its text. ButlayoutCodeBlockreturns a SINGLE.codeBlockitem with the text and language line nested inside it — they are not members ofitems— so dropping the band outright drops the code text from the shrink entirely. A message whose widest content is its code then gets a bubble narrower than the width that text was laid out against, and the code text is clipped at the bubble's edge (shipped and caught on device, 2026-08-18).instantPageV2FitWidthMaxXtherefore reaches in and returnsblock.frame.minX + inner.frame.maxXover the text and language items — the offset matters because the nested frames are block-local.instantPageV2StretchCodeBandsthen re-widens the band to the survivingcontentSize.width— the same reasoncenterBlockFormulasruns aftercontentSizerather than before it. The stretch moves the TRAILING edge only: the leading edge carries the bleed and the text's block-local x is measured from it. Regression:InstantPageV2CodeBandTests.testBubbleIsWideEnoughForCodeWiderThanEveryOtherBlock. - The language line is a real laid-out text item, not a view-built label. It used to be the only font in the V2 renderer not baked into an attributed string at layout time — the one place the content scale could leak past the layout — which is why
codeBlockLanguageFontSizehad to travel on the item at all. It now mirrors the quote author's derivation (thecaptioncategory pushed to bold at theparagraphsize), so the editor's copy cannot be set to something different, and it is lowercased at layout time so the model's casing never reaches the screen. - Editor parity travels through
RichTextRenderMetrics.code(RichTextCodeMetrics) — the vertical inset and the language-line gap, sourced by the adapter fromInstantPageMetrics. The bleed deliberately does NOT: it is a property of the host's container geometry (canvas margins, bubble insets), not of the shared type scale, so each surface computes its own. The editor half is in the RichTextEditor's ownCLAUDE.md.
Design record: docs/superpowers/specs/2026-08-18-code-block-edge-to-edge-design.md.
Syntax highlighting (added 2026-08-25)
V2 code blocks are syntax-highlighted from the same cache regular text bubbles use: libprisma behind
Syntaxer, CachedMessageSyntaxHighlight keyed by Spec(language, text), generated off the main queue and
applied synchronously from cache. Nothing highlights on a layout path; a MISS renders plain and the colours
arrive on the next layout after the async job lands. layoutCodeBlock overlays the cached entities onto the
string it just built (applyInstantPageSyntaxHighlight), and ChatMessageRichDataBubbleContentNode drives
generation exactly as the text bubble does — extract specs, read
DerivedDataMessageAttribute.data["code"], start asyncUpdateMessageSyntaxHighlight.
Three pre-existing, silent defects had to be fixed before any of this was visible:
InstantPageBlock(apiBlock:)hard-codedlanguage: nilwhen decodingpageBlockPreformatted, discarding the field the outgoingapiBlock()correctly sends. Every page decoded from the API came back language-less, so a code block's language survived the send and was lost on the echo — which disabled highlighting for every rich message AND every web Instant View article, and showed an empty language field when editing a sent message. Nothing could author a language for a rich block before, so nothing surfaced it.generateMessageSyntaxHighlighthanded libprisma the raw language.LanguageTree::findis an exactstd::maplookup with lowercase keys, so"Swift"— or" swift "— resolved to no grammar, returned the text untokenized, and produced zero entities. Silent: no error, just a block that never highlights. Measured:"swift"→ 3 entities,"Swift"→ 0. Normalizing at that one call fixes every caller.- V2 never read
cachedMessageSyntaxHighlight, though it has been a parameter ofinstantPageV2Layoutand a field on itsLayoutContextsince V2 was written. V1'sattributedStringForPreformattedTextdid apply it, so web articles highlighted and V2 did not.
Non-obvious invariants
- Extraction and application must normalize the language the same way.
instantPageSyntaxHighlightSpecs(hoisted out ofBrowserUIintoTextFormat, where three consumers can reach it) and the V2 apply both run it throughnormalizedCodeBlockLanguage. A page storing under"swift"and looking up"Swift"misses every time, silently. - That extractor's recursion previously ended in
default: break, so code nested in ablockQuotewas collected by NOBODY — unhighlighted even in the browser. Rich messages nest code in quotes routinely. - A cached highlight can outlive the text it described (it is persisted per message), so every apply validates each range against the current string and drops the WHOLE highlight on any mismatch. Applying one partially colours arbitrary spans of unrelated code.
- The rich bubble's cached-layout key includes the highlight. It otherwise keys on
messageStableVersion, and whether astoreLocallyDerivedDatawrite bumps that is Postbox's business — without the clause a newly-arrived highlight can be computed, persisted, and never painted, because the node keeps serving the layout it cached before the job finished. - The palette is libprisma's LIGHT one everywhere, including dark mode, because that is what the message path has always baked and it keeps the editor WYSIWYG against the sent message. libprisma ships a dark palette that nothing uses.
The editor half — the authorable language field and the host-provided highlighter seam — is in the
RichTextEditor's own CLAUDE.md.
InstantPage V2 block media — flush (edge-to-edge), un-rounded
Every V2 block-media kind lays out flush with the bubble interior (0 inset, full bounding width) and un-rounded (cornerRadius 0). The bubble's existing rounded clipping container rounds any media that meets the bubble's top/bottom edge. V1 (InstantPageLayout.swift) is unchanged. (Audio is also full-width / x = 0 as of the V2 audio port, but it does not use this helper — it has its own layoutAudio arm; the wrapped InstantPageAudioNode supplies its own 17pt internal content inset. See the "InstantPage V2 audio/music" section below.)
Where things live
| File | Responsibility |
|---|---|
submodules/InstantPageUI/Sources/InstantPageV2Layout.swift |
instantPageV2MediaFrame(naturalSize:flush:cornerRadius:boundingWidth:horizontalInset:) — the shared frame helper; instantPageV2MediaEdgeBleed constant; the flush: Bool parameter on layoutTypedMediaWithCaption (image/video/webEmbed-cover/map) and layoutMediaWithCaption (webEmbed-placeholder/postEmbed/channelBanner/relatedArticles). (Collage/slideshow and audio no longer route through these — see their dedicated sections.) |
submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift, …/InstantPageRenderer.swift (InstantPageV2MediaPlaceholderView) |
Renderer — no change needed: every media view + the placeholder view already does clipsToBounds = item.cornerRadius > 0.0, so cornerRadius 0 means the view doesn't self-clip; the bubble's containerNode clips. |
…/Chat/ChatMessageRichDataBubbleContentNode/… |
The clipping container: containerNode (clipsToBounds = true, cornerRadius = layoutConstants.image.defaultCornerRadius ≈ 15–16pt) is what rounds flush media at the bubble edge. |
Non-obvious invariants
flushis a parameter, not inferred from cornerRadius. Every remaining media call site now passesflush: true. Audio — the former loneflush: falsecaller — was moved to its ownlayoutAudioarm in the V2 audio port, soinstantPageV2MediaFrame'sflush == falsebranch is now dead code (a candidate for a follow-up cleanup: drop theflushparameter and the inset branch entirely). On the flush path the helper forces the returned corner radius to0regardless of the caller'scornerRadiusargument (the legacy8.0/0.0args at the call sites are now inert — kept as-is, documented in the helper).- Small images are NOT upscaled. The
scale = min(availableWidth / naturalSize.width, 1.0)cap is kept (now againstavailableWidth = boundingWidth). A small image stays at natural size, flush-left at x = 0 (not stretched to full width). Large images (the common server/AI case) fill the width. - Full-width media bleeds
instantPageV2MediaEdgeBleed(4pt) past the trailing edge. The pageView sits atx: -1insidecontainerNode(a border-hiding hairline), so a frame atx: 0, width: boundingWidthfalls ~1px short of the container's right rounded-clip edge → a 1px corner notch. A small over-bleed on full-width items only (fillsWidth = scaledSize.width >= availableWidth - 1.0) closes it; a genuinely small image gets no bleed. The bleed never widens the bubble becauselayoutInstantPageV2clampscontentSize.width = min(maxX, boundingWidth)(gated bycontext.fitToWidth, which both callers — the rich bubble and the send preview — passtrue). - Captions stay inset.
layoutCaptionAndCreditis still called with the pagehorizontalInsetand offset by the un-bledscaledSize.height; the caption/credit text is inset under a full-bleed image. TheisCover && captionHeight > 0cover-padding block is unchanged. - Audio is no longer routed through this helper. As of the V2 audio port it has a dedicated
layoutAudioarm emitting a typed.mediaAudioitem at a full-width (x = 0), height-48 frame (matching V1InstantPageLayout.swift); the wrappedInstantPageAudioNodeself-insets its content by 17pt, and audio does not participate ininstantPageV2MediaEdgeBleed(its node background is transparent). See the dedicated "InstantPage V2 audio/music" section below. .mapblocks get a 600×300 (2:1) fallback when the sender omits dimensions. AI/server-sent.mapblocks can arrive withdimensions == 0×0(the wirew/hare requiredInt32, but the sender may put 0; ourpageBlockMapparse and both serializers — Postboxsw/sh, FlatBuffersrequired dimensions— preserve whatever arrives, so the zero originates upstream). A zeronaturalSize.heighthitsinstantPageV2MediaFrame'selsebranch and returns a height-0 frame: the map collapses to no space, the caption slides up into it, and the V1 node's pin (positioned atsize.height*0.5 − 10 − pinSize/2) floats over the caption. The.maparm inInstantPageV2Layout.swiftsubstitutesPixelDimensions(600, 300)wheneverwidth <= 0 || height <= 0, and feeds thateffectiveDimensionsto BOTH the layoutnaturalSizeAND theInstantPageMapAttribute— the latter is essential because aMapSnapshotMediaResource(width:0,height:0)makesMKMapSnapshotterrender nothing, so fixing only the frame would yield a correctly-sized blank box. Real web-article maps (the V1 renderer) always carry real dimensions, so V1 never trips this; the fallback is deliberately scoped to the V2.maparm rather than V1 or the wire/parse layer..mapblocks show a theme placeholder while the snapshot loads (2026-06-27). The map image is generated on-demand byMKMapSnapshotter(chatMapSnapshotImage→chatMapSnapshotDataself-fetches viaengine.resources.custom), which takes ~seconds; the pin (ChatMessageLiveLocationPositionNode) draws immediately, so the map area was blank (pin over transparent) until the fetch completed.InstantPageImageNode.layout()'s.geoarm now passesemptyColor: theme.list.mediaPlaceholderColor, andchatMapSnapshotImageemits an initialnilframe that fillsemptyColorwhen there's no image yet — corner-safe (addCornersruns after the fill, unlike a nodebackgroundColorwhich would bleed past rounded corners) and gated onemptyColorso callers that don't opt in keep the prior transparent loading state. Shared, so it applies to every map render: web instant pages, rich messages, AND the RichTextEditor's own.locationblocks, which now author.map(MediaKind.location→.map; seerichtext-composer.md).
InstantPage V2 audio/music
InstantPageBlock.audio renders in V2 as a control styled exactly like the standard music message bubble (ChatMessageInteractiveFileNode's music layout) — a dedicated InstantPageV2AudioContentNode, NOT the V1 InstantPageAudioNode (which V2 used in the first iteration and which still backs V1's full-page Instant View). It replaces the earlier inert grey .mediaPlaceholder(kind: .audio). Playback stays on InstantPageMediaPlaylist, with two deliberate behavior changes for the rich-message context: the shared playlist identity is message-scoped so concurrent rich-message audio bubbles don't collide, and rich-message audio files are fetched via a message reference (not the synthesized webpage) so a stale file reference can revalidate.
Specs: 2026-06-02-instantpage-v2-audio-design.md (initial port) + 2026-06-02-instantpage-v2-audio-file-style-design.md (file-bubble styling). Plans: 2026-06-02-instantpage-v2-audio.md + 2026-06-02-instantpage-v2-audio-file-style.md.
Where things live
| File | Responsibility |
|---|---|
submodules/InstantPageUI/Sources/InstantPageMediaPlaylist.swift |
InstantPageMediaPlaylistId is a public enum — .instantPage(webpageId:) (V1 full-page IV) / .richMessage(messageId:) (V2 rich bubble). InstantPageMediaPlaylist.init takes an injected playlistId: (no longer derived from the webpage) and a messageReference: MessageReference? threaded into each InstantPageMediaPlaylistItem. The item's fileReference(_:) helper builds a .message(message:media:) file reference when a (resolvable-id) message reference is present, else the legacy .webPage(...). |
submodules/InstantPageUI/Sources/InstantPageV2AudioContentNode.swift |
The V2 control — replicates ChatMessageInteractiveFileNode's music layout: a Ø44 SemanticStatusNode (album art via playerAlbumArt + play/pause) + a small bottom-right streamingStatusNode download/progress overlay + title/performer TextNodes + a line MediaPlayerScrubbingNode. Big control play/pause from our filteredPlaylistState; small overlay download/progress from messageMediaFileStatus; tap via a UITapGestureRecognizer (controlTapped routes fetch / play / togglePlayPause); fetch via messageMediaFileInteractiveFetched(fetchManager:…). |
submodules/InstantPageUI/Sources/InstantPageAudioNode.swift |
V1 only (full-page Instant View) — unchanged except init takes an injected playlistId:. No longer used by V2. |
submodules/InstantPageUI/Sources/InstantPageV2Layout.swift |
InstantPageV2MediaAudioItem (frame/media/webPage — no cornerRadius/attributes); the .mediaAudio InstantPageV2LaidOutItem case + its frame/offsetBy/collectMedias arms; the .audio block's layoutAudio arm (full-width x = 0, height 44 — the file node's music normHeight; the InstantPageMedia carries caption: nil/credit: nil, the visible caption is a separate item via layoutCaptionAndCredit). |
submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift |
InstantPageV2MediaAudioView (hosts InstantPageV2AudioContentNode via the shared WrapperRef weak-box pattern; wires its play/togglePlayPause/seek/fetch closures + the filteredPlaylistState playback signal) + handleOpenAudioTap (builds the playlist + setPlaylist, mirroring V1's InstantPageControllerNode.openMedia). |
submodules/InstantPageUI/Sources/InstantPageRenderer.swift |
InstantPageV2RenderContext.message: MessageReference? (carries both the playlist-key id via .id AND the file-fetch reference); the .mediaAudio arms in stableId/reuse/makeItemView. |
submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift |
.mediaAudio is a non-text reveal entry charging frame.width (like other media). |
submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift |
The .message media-reference revalidation arm also searches RichTextMessageAttribute.instantPage.media (not just message.media), so a stale instant-page file reference inside a rich message can recover. |
| rich bubble + send preview | ChatMessageRichDataBubbleContentNode passes message: MessageReference(item.message); ChatSendMessageRichTextPreview passes message: nil. |
Non-obvious invariants
- The playlist key is message-scoped, NOT webpage-scoped, for rich bubbles. Every rich message synthesizes its
TelegramMediaWebpagewith the SAME constant id(namespace: 0, id: 0)(ChatMessageRichDataBubbleContentNode), andmediaIndexrestarts at 0 per page — so keying playback by(webpageId, mediaIndex)(V1's scheme) would make two audio bubbles on screen share/fight playback state (scrubber + play/pause icon). The discriminatedInstantPageMediaPlaylistId.richMessage(messageId)isolates them. The audio view resolvesrenderContext.message?.id→.richMessage(messageId), else.instantPage(webpageId:); the send preview (no message) takes the webpage fallback — harmless since only one preview is ever on screen. The V1 full-page IV path is byte-identical (always.instantPage(...)). InstantPageMediaPlaylistIdhad to becomepublic. It is exposed throughInstantPageMediaPlaylist'spublic init, which BrowserUI constructs cross-module; an internal type in a public initializer is a hard Swift compile error (independent of-warnings-as-errors). This surfaced only at full-build time — the per-module reasoning didn't catch it.- The big control's play/pause comes from OUR playlist, the small overlay's download/progress from the resource status — two separate signals. The file node (
ChatMessageInteractiveFileNode) for music keys its play/pause off the peer-messages playback model (messageFileMediaPlaybackStatus→peerMessagesMediaPlaylistAndItemId), which our attribute-embedded audio is NOT part of — soInstantPageV2AudioContentNodedrives the bigstatusNode.play↔.pausefrom ourfilteredPlaylistState(keyed by the message-scopedplaylistId+InstantPageMediaPlaylistItemId(index:)) and the smallstreamingStatusNodefrommessageMediaFileStatus. This split (rather than reusing the file node) is why the redesign is a replicated layout, not a hostedChatMessageInteractiveFileNode. - Fetch MUST go through the fetch manager, not
freeMediaFileInteractiveFetched.messageMediaFileStatus's progress (.Fetching) is derived from the fetch manager'shasEntryflag;freeMediaFileInteractiveFetchedbypasses the manager (hasEntrystays false), so the overlay would stick on the static download icon and never show the animated ring. The control fetches viamessageMediaFileInteractiveFetched(fetchManager:messageId:messageReference:file:…). - Tap is a
UITapGestureRecognizer, never an ASControl (same invariant as the V1InstantPageAudioNodeplay button): ASControl.touchUpInsideis cancelled by the chatListView's gesture system. The plaintapViewcovers the whole control →controlTapped(fetch-when-remote /togglePlayPause-when-playing /play-else). InstantPageV2AudioContentNode.updatePresentationDatamust refresh EVERYTHING theme/incoming-dependent.TextNode(unlikeASTextNode) has no storedattributedText— the strings live intitleAttributedString/descriptionAttributedStringand are fed toTextNode.asyncLayout. On an in-place theme/direction changeupdatePresentationDatarebuilds those strings ANDstatusNode.backgroundNodeColor+foregroundNodeColor+overlayForegroundNodeColor+scrubbingNode.updateColors(…); missing any leaves a stale-colored control. Font size ispresentationData.chatFontSize.baseDisplaySize(plainPresentationDatahas no.fontSize).- Audio is NOT a gallery item.
InstantPageV2MediaAudioViewdoes not register in the root media registry (nodidMoveToWindow/registerInRootRegistry) and returnsnilfrominstantPageTransitionNode/ no-opsinstantPageUpdateHiddenMedia— explicit per-class witnesses, not the protocol-extension default. Its media IS enrolled incollectMedias/allMedias()sohandleOpenAudioTapcan gather the page's sibling voice/music files for the playlist (matching V1'smediasFromItems). TheWrapperRefweak box breaks the wrapper → node → closure → wrapper retain cycle (theplayclosure captures only the box + value locals, neverself). - Full-width item frame, file-node internal layout. The
.audioarm lays the item atx = 0, width = boundingWidth, height = 44(the file node's musicnormHeight), NOT inset byhorizontalInset. The control's internal geometry is copied from the file node's non-thumbnail music branch (Ø44 control at x = 3,controlAreaWidth = 55, title at x = 55). Music-only: any voice file renders music-style (no waveform/transcription). No edge-bleed. - Audio files fetch via a message reference (the former recipient-fetch risk is resolved).
InstantPageMediaPlaylistItem.fileReference(_:)builds.message(message: messageReference, media: file)when the playlist carries a resolvable-idMessageReference(rich bubbles), else the legacy.webPage(...)(V1 full-page IV, whose webpage is real). The fetch-reference fallback uses the samemessage?.id != niltest as the playlist-key fallback, so a.none-content reference degrades to the webpage path consistently. Because the rich-message file lives inRichTextMessageAttribute.instantPage.media(notmessage.media),FetchedMediaResource.swift's.messagerevalidation arm was taught to search the attribute's instant page too — so a stale file reference can re-fetch the message and recover (a synthetic-(0,0)-webpage reference never could, because that webpage doesn't exist server-side). This also fixes a latent pre-existing bug: instant-page image references in rich messages couldn't revalidate either. - Fixed a dormant inverted
InstantPagePlaylistLocation.isEqual(it returnedfalsefor equal locations andtruefor unequal — backwards).areSharedMediaPlaylistsEqualANDs the playlistidandlocation; it gates only seek-forwarding insidesetPlaylist, a path the instant-page audio scrubber doesn't take (it usesplaylistControl(.seek)), so the bug was inert. The corrected equality is safe even though all rich-message locations share the synthetic(0,0)webpageId: the.richMessage(messageId)id (ANDed in) disambiguates different rich-message playlists.
InstantPage V2 collage & slideshow blocks
InstantPageBlock.collage and .slideshow (grouped photos/videos with a caption) render in V2 by porting V1. Collage flattens into the existing media-item machinery; slideshow is a dedicated interactive carousel.
.collage is no longer web-IV-only (added 2026-07-08). The RichText editor's multi-media containers
(a MediaBlock/ChatInputMedia holding items.count >= 2 photos/videos with one shared caption — see
docs/richtext-composer.md §4 "Inline media") now also emit .collage on send/edit/draft, from both the
composer (ChatInputContentInstantPage) and the article editor (RichTextEditorMessageConversion's
InstantPageBuilder) converters — the first editor/rich-message path to produce a .collage block
(needed zero codec work; it was already first-class through Postbox/FlatBuffers/upload). A container of
exactly 1 item still sends the plain .image/.video block, byte-identical to before.
.slideshow is now editor-produced too (added 2026-07-17). A multi-media container carries a
displayMode (.mosaic default / .slideshow; mirrored MediaBlock.displayMode ↔
ChatInputMedia.displayMode, Codable back-compat → .mosaic). Both forward converters branch on it:
.mosaic → .collage, .slideshow → .slideshow (same inner .image/.video blocks); the reverse
chatInputBlocks(fromInstantPageBlocks:) gained a .slideshow arm (sharing the collage arm's item
helper) so the mode round-trips on edit. The mode is toggled in the article editor via a top-right
button (mosaic↔slideshow); the composer stays mosaic-only. So .slideshow is now produced both by real
web Instant View articles and by editor-authored slideshow albums. Design + plan:
docs/superpowers/{specs,plans}/2026-07-17-richtext-media-layout-toggle*.
Where things live
| File | Responsibility |
|---|---|
submodules/InstantPageUI/Sources/InstantPageV2Layout.swift |
layoutCollage(...) — mosaic via chatMessageBubbleMosaicLayout (the MosaicLayout module, same engine grouped messages use), emitting one existing .mediaImage/.mediaVideo item per cell. layoutSlideshow(...) + the InstantPageV2SlideshowItem laid-out item (+ its frame/offsetBy/collectMedias arms). |
submodules/InstantPageUI/Sources/InstantPageV2SlideshowView.swift |
The carousel view: a paged UIScrollView of InstantPageImageNode pages + a PageControlNode, with all pages created eagerly. |
…/InstantPageRenderer.swift |
InstantPageItemView.instantPageTransitionNode(for:) / instantPageUpdateHiddenMedia(_:) (gallery hooks, nil/no-op defaults); transitionArgsFor/applyHiddenMedia dispatch through them. The .slideshow arms in InstantPageV2ItemKind/stableId/reuse/makeItemView. |
…/InstantPageV2RevealCost.swift |
.slideshow is a non-text reveal entry (collage cells already are, being top-level media items). |
Non-obvious invariants
- Collage is a flatten, not a container.
layoutCollagecomputes the mosaic, then emits each cell as an ordinary top-level.mediaImage/.mediaVideoitem (cornerRadius 0) into the parent layout — exactly as V1 does (flattenedItemsWithOrigin). Consequence: gallery enumeration (allMedias), the media registry, hidden-media, the reveal-cost map, and view reuse all handle collage cells for free, with no collage-specific code in any of those subsystems. There is no.collagelaid-out item or view. - Right-edge collage cells bleed 4pt (
instantPageV2MediaEdgeBleed, applied only toMosaicItemPosition.rightcells) for the same bubble-rounded-clip reason as full-width single media; interior gaps are the mosaic's 1pt spacing; outer corners are rounded by the bubble'scontainerNode. - A collage with an undecodable item is not laid out as a collage at all (2026-08-18). The
.collagearm oflayoutBlockchecksblockRendersAsUnsupported(_:)first and returns the "please update" pill for the whole block, caption included — a mosaic reserves a slot per inner block but emits a cell only per resolvable one, so an.unsupporteditem would leave a hole and shift the remaining tiles..slideshowis deliberately excluded (it degrades quietly instead). See "Unsupported blocks" below for the predicate and its other two readers. - Slideshow IS a container (it's swipeable), so it gets its own laid-out item + view, unlike collage. Adding the
.slideshowcase toInstantPageV2LaidOutItemforces a.slideshowarm in every no-defaultswitch over it:frame,offsetBy,stableId,reuse,makeItemView, and the reveal-costcomputeEntries(pluscollectMedias, which has adefaultbut needs the arm to enumerate slideshow medias for the gallery). - Slideshow pages are created eagerly, deviating from V1's lazy central±1 paging. In a chat bubble a slideshow is a handful of images, so eager creation avoids V1's index bookkeeping and makes the gallery transition source available for every page (even off-screen). Height = the tallest image
fitted(boundingWidth × 1200); only.imageinner blocks render (matches V1 — videos become empty pages). - The slideshow registers under EVERY contained media index, and re-registers on an in-window rebuild. Its stableId is positional (
.positional(.slideshow, position), not.media(index)like the static media views), so it can be reused for a different slideshow at the same block position;rebuildPages()re-runsregisterMedias()(guarded bywindow != nil) so the new indices land in the registry. The gallery hooks iterate the live page nodes and match byInstantPageMediaidentity, so registering one view under N indices is idempotent. - The 4 static media views answer the gallery hooks with explicit per-class witnesses, NOT a shared protocol-extension override — an extension-only implementation is statically dispatched and would silently bind to the nil default when invoked through the
InstantPageItemView-typed registry wrapper.
InstantPage V2 media spoiler (revealable dust)
A pageBlockPhoto/pageBlockVideo (and thus a collage cell) can carry a spoiler flag — the medium is
hidden behind an animated "dust" cover until the recipient taps it, mirroring the regular
MediaSpoilerMessageAttribute path in ChatMessageInteractiveMediaNode. This is how a rich message
(RichTextMessageAttribute → InstantPage) carries a media spoiler; the composer-authoring side is in
docs/richtext-composer.md §4.
Where things live
| Concern | Location |
|---|---|
| model flag | InstantPageBlock.image/.video gain spoiler: Bool (SyncCore_InstantPage.swift); Postbox key "sp", flatBuffers Models/InstantPageBlock.fbs spoiler:bool (id:4) |
| wire | ApiUtils/InstantPage.swift reads/ORs pageBlockPhoto flags.1 / pageBlockVideo flags.2 — no TelegramApi change (the bit rides the existing flags Int32; constructor ids 1759c560/7c8fe7b6 unchanged) |
| laid-out item | InstantPageV2MediaImageItem/VideoItem gain spoiler: Bool (InstantPageV2Layout.swift); single-media + collage item-constructing cases thread it |
| render | InstantPageV2MediaViews.swift — MediaSpoilerDustOverlay hosts a MediaDustNode (import InvisibleInkDustNode) in both InstantPageV2MediaImageView/VideoView |
Non-obvious invariants
- The dust cover is NON-interactive; reveal is driven through the wrapped node's own tap. The overlay
(
containerNode+dustNode) isisUserInteractionEnabled = false, so taps fall through to the siblingInstantPageImageNodebelow it. Each view'sopenMediaclosure is gated: whileoverlay.concealed, the first tap callsoverlay.reveal()(which setsconcealed = falsesynchronously and drivesMediaDustNode.tap(at:)→ the wipe animation → therevealedcallback removes the cover) and returns without opening the gallery; once revealed, taps fall through tohandleOpenMediaTap(gallery). This mirrorsExtendedMediaOverlayNode.reveal(animated:)but with a non-interactive cover instead of an interactive button. - Reuse resets reveal state by media id. A positionally-reused media view (
stableId = .media(index), reconciled throughupdate(item:)→updateSpoiler) keyed onEngineMedia.Id: a different id orspoiler == falsetears down the cover; the same spoiler medium keeps its (possibly already-revealed) state — so scrolling can't bleed a stale reveal onto a different photo, and a re-layout of the same photo doesn't re-hide it. NoInstantPageRenderer.reuse(existingView:)change was needed — it already routes throughupdate(item:). - Collage cells inherit spoiler for free.
layoutCollageflattens inner.image/.videointo ordinary top-level.mediaImage/.mediaVideoitems (see the collage section above), threading each inner block'sspoiler— so an album with one spoiler cell just works, with no collage-specific spoiler code. - The long-press-Send options preview renders through the same
InstantPageV2View, so a spoiler'd media shows the dust cover in the preview bubble automatically.
InstantPage V2 rich-message video (auto-download & inline autoplay)
Video block media in a rich message (RichTextMessageAttribute → InstantPage V2, drawn by
ChatMessageRichDataBubbleContentNode) now auto-downloads and auto-plays inline like regular chat
media (ChatMessageInteractiveMediaNode) — previously it was a static poster + play glyph that only
downloaded/played on tap-to-gallery, and image auto-download ignored per-message settings. The fix is
layering-safe: it lives entirely in InstantPageUI + the chat bubble, with no dependency on
ChatMessageInteractiveMediaNode (that would invert the module layering — InstantPageUI is a
low-level module also used by web Instant View / BrowserUI). HLS inline-range preloading and
live-photo are deliberately out of scope (they fall back to tap-to-gallery).
Spec: docs/superpowers/specs/2026-07-09-instantpage-v2-video-autodownload-autoplay-design.md.
Plan: docs/superpowers/plans/2026-07-09-instantpage-v2-video-autodownload-autoplay.md.
Where things live
| File | Responsibility |
|---|---|
submodules/InstantPageUI/Sources/InstantPageRenderer.swift |
InstantPageV2RenderContext gains 3 policy closures — shouldAutoDownloadImage/shouldAutoDownloadFile/shouldAutoplayVideo (all default { _ in false }). InstantPageItemView gains instantPageUpdateIsVisible(_:) (default no-op), driven by updateItemVisibility() on every visibilityRect change (alongside updateEmojiVisibility). |
submodules/InstantPageUI/Sources/InstantPageImageNode.swift |
init gains optional autoDownloadImage/autoDownloadFile overrides (default nil ⇒ current V1/web-IV global-settings behavior); the image + image-file fetch gates consult them. The video (else) branch stays poster-only. |
submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift |
makeMediaWrapper forwards the two download overrides. InstantPageV2MediaVideoView hosts an inline UniversalVideoNode/NativeVideoContent (updateInlineVideo/tearDownVideoNode) layered above the poster. |
…/Chat/ChatMessageRichDataBubbleContentNode/… |
Computes the 3 closures from the chat item and sets a real sourceLocation. |
ChatSendMessageRichTextPreview.swift |
Unchanged — keeps the defaults (message: nil + off closures) → static poster. |
Non-obvious invariants
- Policy is computed in the bubble, consumed as booleans in
InstantPageUI. The bubble has the chat item, so it computes download viashouldDownloadMediaAutomatically(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: associatedData.automaticDownloadPeerType, networkType: …automaticDownloadNetworkType, authorPeerId: message.author?.id, contactsPeerIds: …, media:)and autoplay via(file.isAnimated ? energyUsageSettings.autoplayGif : .autoplayVideo) && completedResourcePath(file) != nil.InstantPageUInever learns chat semantics — it just calls the closures. The closure types mirror the existingimageReference/fileReferencesplit (concreteTelegramCoretypes) becauseEngineMedia's wrap/unwrap helpers areinternaltoTelegramCore. - The inline player must be framed to the aspect-FITTED rect, not to
bounds.instantPageV2MediaFramecaps a portrait item's height at its display width, so the box deliberately stops matching the media aspect; the poster (wrappedNode) compensates by rendering aspect-fit over a blurred backdrop. TheUniversalVideoNodeis layered ABOVE that poster, so sizing it toself.boundssquashed the video into the capped box and hid the blurred backdrop — a portrait video rendered square and stretched while images did not.InstantPageV2MediaVideoView.inlineVideoFrame(in:)now centres the player on the same fitted rect the poster uses. It is gated onitem.fit, which only single media sets — collage cells construct the item without it and keep filling their bounds, preserving mosaic crop-to-fill and the 1pt-bleed clipping documented at the construction site. - Video content id MUST be message-scoped, not webpage-scoped. Every rich message synthesizes the
same webpage id
(0,0), so keying the player by webpage would make two on-screen rich-message videos collide in the universal video manager.InstantPageV2MediaVideoViewuses the existingNativeVideoContentId.message(UInt32(bitPattern: messageId.id), file.fileId)— same trap the V2 audio port hit (.richMessage(messageId)). The inline player is only built whenrenderContext.message?.idexists, so the send-preview (nil message) never needs one. - Visibility gating has no dead-attach window. The player's
canAttachContentis toggled byinstantPageUpdateIsVisible(from the root'svisibilityRect→updateItemVisibility). A player built before the first visibility tick starts withcanAttachContent = false, but first display always flipsvisibilityRectnil→rect (a change) → the tick attaches it. A player built on a later update (e.g. after a fetch completes, with no visibility change) is seededcanAttachContent = self.localIsVisible, which is already true for an on-screen view. Both orderings attach. - Auto-download is decoupled from autoplay (fixes the "never downloads" gap). When
shouldAutoDownloadFile(file)is true the video bytes are fetched (once per media id, viafreeMediaFileInteractiveFetchedwith the render-context.messagefileReference) even if autoplay is off — so a rich-message video prefetches per settings instead of only on tap.videoFetchMediaIddedups; theMetaDisposableset()cancels a stale fetch on media switch and is disposed indeinit. - Reuse & spoiler. Positional reuse keeps the player only when
videoNodeMediaId == mediaId(else teardown + rebuild); a concealed spoiler cover suppresses the player (wantAutoplay = shouldAutoplayVideo && !concealed), and the spoiler reveal path re-runsupdateInlineVideoso autoplay starts after the dust clears. Gallery transition hides the player viainstantPageUpdateHiddenMedia. - Collage cells inherit this for free —
.collageflattens into ordinary.mediaVideoitems, so each cell is anInstantPageV2MediaVideoViewwith no collage-specific code.
Copy protection (screenshot-protected rich-message media & secure gallery)
Media in a rich message honors the chat's copy protection the way regular media does
(ChatMessageInteractiveMediaNode's captureProtected): every rendered image/video/thumbnail layer
is excluded from screenshots and screen recordings via setLayerDisableScreenshots, and tapping one
opens a secure InstantPageGalleryController — protected content, no share / save-to-camera-roll.
V1 Instant View and web IV are untouched (their pages are public web content), and so is the send
preview.
Where things live
| File | Responsibility |
|---|---|
submodules/InstantPageUI/Sources/InstantPageRenderer.swift |
InstantPageV2RenderContext.captureProtected (default false) + updateCaptureProtected(_:). updateInlineImages() seeds and refreshes each InstantPageV2InlineImageView. |
…/Chat/ChatMessageRichDataBubbleContentNode/… |
isCaptureProtected(item:) = associatedData.isCopyProtectionEnabled || message.isCopyProtected(); passed to the render-context initializer and refreshed at the top of ensurePageView on every apply. |
submodules/InstantPageUI/Sources/InstantPageImageNode.swift |
captureProtected drives the inner TransformImageNode and the (weakly tracked) spoiler blur node; transitionNode(media:) does the protected-snapshot dance. |
submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift |
makeMediaWrapper seeds it; the image/video/map/cover views re-read it in update(item:theme:renderContext:); the inline autoplay NativeVideoContent takes captureProtected:; handleOpenMediaTap forwards it to the gallery. |
InstantPageV2SlideshowView.swift, InstantPageV2DocumentContentNode.swift, InstantPageV2InlineImageView.swift |
Slideshow pages, the document row's thumbnail, and inline RichText.image cells. |
InstantPageMediaOpen.swift → InstantPageGalleryController.swift → InstantImageGalleryItem.swift |
captureProtected threads to each gallery entry: protected zoomable image node, NativeVideoContent(captureProtected:), and setShareMedia(nil) to withhold the footer action button. |
Non-obvious invariants
- The flag is mutable state on the render context, not a constructor snapshot.
isCopyProtectionEnabledis a peer setting that can be toggled while the message is on screen. That changes neither the webpage nor the page layout, so nothing rebuilds the V2View — the bubble refreshes the context up front inensurePageView(ahead of every reuse branch, including the two early returns) and each media view re-reads it in its ownupdate(…). Seeding it only at construction leaves an on-screen message unprotected until it is scroll-recycled. - A capture-protected layer is excluded from
snapshotContentTreetoo. So the gallery open/close animation would fly a blank rect.InstantPageImageNode.transitionNode(media:)mirrorsChatMessageInteractiveMediaNode.transitionNode(adjustRect:): add an unprotectedUIImageViewcopy ofimageNode.imageover the protected node, snapshot, remove the stand-in, thensetLayerDisableScreenshotsthe resulting snapshot so the transition itself stays uncapturable. - A concealed spoiler must be protected as well. The blur cover is a sibling
TransformImageNodethe enclosing view owns (makeSpoilerBlurredNode()), not a child of the sharp node, so protecting the sharp node alone leaves a screenshot of the blurred cover — enough to read the media's shape.InstantPageImageNodekeeps a weak reference to the node it vends and keeps the two in sync. NativeVideoContenttakescaptureProtectedat construction, so a toggle has to rebuild the inline player.InstantPageV2MediaVideoViewtherefore tracksvideoNodeCaptureProtectedalongsidevideoNodeMediaIdand includes it in the "player is still current" early-out.- The video gallery's footer needs no gating.
InstantPageGalleryEntry.itempassesoriginData: nil, andChatItemGalleryFooterContentNode.setup(origin:caption:)zeroes its wholebuttonsStatewhen origin is nil — so no share button exists on that path to begin with. Only the image path (InstantPageGalleryFooterContentNode) neededsetShareMedia(nil). - Document blocks were already covered. Tapping one routes through
openMessage(…, mediaSubject: .richTextMedia(fileId))→ the chat'sGalleryController, which derives protection from the message itself. Only the row's thumbnail is protected in the bubble; the file name/size text is metadata, and the regular chat file bubble does not protect its label either. InstantPageUIgained a directUIKitRuntimeUtilsdep (forsetLayerDisableScreenshots); everything else reaches protection throughTransformImageNode.captureProtected.
InstantPage V2 text item height (true font line box)
layoutTextItem (InstantPageV2Layout.swift) sizes a .text item to the true font line height, not the cap box. A single-line item measures exactly fontAscent + fontDescentBelowBaseline (A + D); the old behavior was the cap box fontLineHeight = floor(fontAscent + fontDescent) (A − D).
Non-obvious invariants
- Two edits in
layoutTextItem: the line stack starts atlineBoxTopInset = max(0, fontAscent − fontLineHeight)(was0), and the returned height islines.last.frame.maxY + extraDescent + fontDescentBelowBaseline(the+ fontDescentBelowBaselinecontains the last line's descender). Net: every text item grows ~(A − L) + D(~8pt @17pt) and its glyphs draw ~lineBoxTopInset(~4pt) lower within their box; the page grows. - Per-line frames stay the cap box (
height = lineAscent = fontLineHeight). Only the stack's starting origin moves and the total is padded — so the baseline is still drawn at each line frame'smaxY, inter-line advance (lineAscent + fontLineSpacing + extraDescent) is unchanged, and decorations / inline attachments /characterRect/ the reveal mask (all line-frame-relative) translate consistently. lineBoxTopInsetis exact, NOT pixel-snapped — it is an intra-item line offset; crispness rides on the item's own pixel-snapped frame origin (intra-item line positions may already be fractional, e.g. after a non-integralextraDescent).- Formulas / tall inline content still inflate via
lineAscent/extraDescent; the"\u{200b}"+anchorsheight = 0case is preserved. - Inline custom emoji are sized to ≈ the line box so they fit the taller box rather than overflowing it (see "Inline custom emoji").
Inline custom emoji (RichText.textCustomEmoji)
RichText.textCustomEmoji(fileId:alt:) renders an inline animated custom emoji inside rich-data bubbles. Covers API parsing, Postbox + FlatBuffers serialization, and display in the InstantPage V2 renderer; the emoji participates in the streaming reveal above. (The send / edit / copy / paste round-trip that produces .textCustomEmoji from typed markdown is a separate section below: "Custom emoji in markdown messages".)
Where things live
| File | Responsibility |
|---|---|
submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift |
Enum case textCustomEmoji(fileId: Int64, alt: String) + Postbox coding (discriminator 17, keys ce.f/ce.a), ==, plainText (returns alt), and FlatBuffers codec. |
submodules/TelegramCore/FlatSerialization/Models/RichText.fbs |
FlatBuffers schema — RichText_CustomEmoji union member + table. Source of truth; the Bazel flatc genrule regenerates *_generated.swift at build time (the checked-in Sources/*_generated.swift is stale). |
submodules/TelegramCore/Sources/ApiUtils/RichText.swift |
Api.RichText.textCustomEmoji ⇄ Swift, lossless both ways. |
submodules/InstantPageUI/Sources/InstantPageTextItem.swift (attributedStringForRichText) |
Emits a single placeholder char carrying ChatTextInputAttributes.customEmoji (a ChatTextInputTextCustomEmojiAttribute) + a CTRunDelegate sized to the font line height (font.ascender − font.descender + 4·pointSize/17 ≈ 24pt @17pt). |
submodules/InstantPageUI/Sources/InstantPageV2Layout.swift (line-breaker) |
Collects per-line InstantPageTextLine.emojiItems; overwrites each placeholder char's characterRect with a full cell (width = itemSize) so it feeds the reveal cost map. |
submodules/InstantPageUI/Sources/InstantPageRenderer.swift (InstantPageV2View) |
Owns the InlineStickerItemLayers: updateInlineEmoji (create/reuse/remove/position), updateEmojiReveal (reveal-driven pop-in), updateEmojiVisibility + propagateVisibilityRect. Layers attach to each text view's emojiContainerView. |
Non-obvious invariants
- flatc casing/
requiredgotchas. EditRichText.fbs, not the generated Swift. Scalars (long) cannot be(required)— only strings/tables can. A union memberRichText_CustomEmojigenerates the Swift enum case.richtextCustomemoji(everything after the suffix's first letter is lowercased); the table type staysTelegramCore_RichText_CustomEmojiand field accessors keep.fbscasing (value.fileId). See theflatbuffers-codegenmemory. ChatTextInputTextCustomEmojiAttributeis reused end-to-end (display layer ⇄ layout model). The attribute is written to the placeholder inattributedStringForRichTextand read back by the V2 line-breaker under the SAME key (ChatTextInputAttributes.customEmoji);InlineStickerItemLayer.initconsumes it directly and resolves the file lazily fromfileId.- Emoji participates in the streaming reveal. Its placeholder char's
characterRectis overwritten to a full cell (width =itemSize), so the width-based cost map charges it like other content.updateEmojiRevealpops the layer in (alpha 0→1 + scale) whencharIndexInItem < currentRevealCharacterCount; unrevealed → opacity 0. - Inline emoji/images are CENTERED on the font line box, NOT baseline-aligned, and do NOT inflate the line. The line-breaker keeps
lineAscent = fontLineHeight(only formulas grow it) and places each attachment atbaselineY − fontLineHeight/2 − size/2, so it bleeds symmetrically about the line box instead of doubling the line height and shoving the text baseline down (the priorlineAscent = emoji.sizebehavior was a regression from V1layoutTextItemWithString, which centers via(fontLineHeight − imageHeight)/2). Custom emoji are sized to ≈ the line box (size = font.ascender − font.descender + 4·pointSize/17) so they fit the true-font-height item box (see "InstantPage V2 text item height") with minimal bleed. Mirrors the chatInteractiveTextComponent. The cell'scharacterRectis centered the same way (y = fontLineHeight/2 − size/2) so the reveal mask (renderer: y = minY + lineAscent − rect.maxY) tracks it; a tall attachment growsextraDescentso the next line isn't overlapped. Three things must stay in lockstep: the display frame, thecharacterRect, andextraDescent. - Inline-attachment x must be the LEADING edge, computed RTL-safely via
v2LeadingOffsetForRange(InstantPageV2Layout.swift). An attachment's left edge ismin(CTLineGetOffsetForStringIndex(start), CTLineGetOffsetForStringIndex(end))— NOT the bare start-index offset.CTLineGetOffsetForStringIndexat the start index returns the glyph's LEFT edge in LTR but its RIGHT edge in RTL (string index increases leftward), so the old single-offset form (…, range.location, nil) shoved emoji/images/formulas ~one advance (≈ the attachment width) too far right on RTL lines — e.g. an emoji in an Arabic thinking-block line, while the CoreText-drawn text stayed correct. The helper mirrorsDisplay.TextNode'saddEmbeddedItem(incl. directional-boundary secondary-offset handling) and the strikethrough/underline/marked/spoiler decorations in this same file, which already used themin/absform. For pure-LTR lines it returns exactly the start-index offset, so LTR is byte-identical. Applies to all 5 attachment sites: the emoji/image/formula display frames AND the emoji/imagecharacterRect(reveal mask). The widths stay the fixedsize/rendered.sizevalues (the run-delegate advance), only the x is corrected. - Layers sit ABOVE the reveal mask. They attach to
InstantPageV2TextView.emojiContainerView(a sibling aboverenderContainer), NOT inside it — so the reveal mask wipes glyphs while emoji pop in independently. Adding a CTRunDelegate-glyph to the mask would clip-wipe them instead. - Layers are owned by
InstantPageV2View, not the text view. Keyed byInlineStickerItemLayer.Key(id: fileId, index: occurrence). The pageView is now REUSED acrossstableVersionbumps (see streaming section), so the inline-emoji dict PERSISTS across chunks;updateInlineEmojiprunes stale keys (emoji whose blocks have been removed) and creates/repositions layers for new or unchanged emoji each update pass. visibilityRectgates looping;nilmeans "not visible". The bubble'svisibilityoverride pushes a full-width sub-rect to the rootpageView.visibilityRect, re-pushed in the apply closure afterpageView.frameis set.propagateVisibilityRectconverts the rect into each nested V2View's coordinate space (self.convert(_:to:)) for details bodies / table cells+title, fanning out via each child'sdidSet.- CTRunDelegate extent buffers must be freed. Every inline-attachment arm (
.image/.formula/.textCustomEmoji) inattributedStringForRichTextallocates anextentBuffer; thedealloccallback mustdeallocate()it (it re-runs per layout pass). - The inline-attachment placeholder MUST NOT be whitespace (
instantPageInlineAttachmentPlaceholder = U+FFFC, fixed 2026-08-17; all four arms were" "). CoreText lets TRAILING WHITESPACE hang past the container's right edge at a break opportunity — correct for a real space, catastrophic for a space carrying a run-delegate advance: an attachment at the end of a line rendered outside the bubble instead of wrapping. Measured over a width sweep against the pre-fix code, an emoji overflowed by up to 28pt and an image or pill by ~4–5pt (the atom's own advance was charged, but the real space in front of it hung, carrying the atom past the edge with it) — so the emoji is the loud symptom and the other two are the quiet ones.U+FFFCis line-break class CB: break opportunities on both sides, width charged to the line. A no-break space (U+00A0, class GL) would NOT do — glue suppresses breaks on both sides, so it would fuse an attachment to its neighbouring words and make a whole sentence unbreakable. All four inline arms share the one constant —.textCustomEmoji,.image,.formulaand the.textButtonpill — because they share the bug exactly. Three consequences ride along:InstantPageTextLine.drawInTilemust SKIP any runinstantPageRunIsInlineAttachmentmatches (emoji / media-id / formula / button attribute — mirroringInteractiveTextComponent'sAttribute__EmbeddedItemskip) or a.notdefbox paints under the hosted view, and skipping is positionally safe because CTRun glyph positions are line-relative;InstantPageMultiTextAdapter.inlineMarkdownmust map the placeholder out of the copied text (to the emoji marker, or to a space for the attachments it has no markdown for); andcurrentText— what Translate / Share / Look Up receive — substitutes a space, which is safe only because it is length-preserving in UTF-16 so every offset it hands out stays valid. Deliberately still a real space:instantPageInlineButtonSpacerString, the pill-to-pill gap. It IS whitespace, and a gap that hangs at a line end is the correct behaviour for it. Guarded byInstantPageInlineAttachmentWrappingTests. - A
RichText.textDaterenders as a LINK in V2 whatever itsformat(.link(false)— colour, no underline), matching whatStringWithAppliedEntitiesdoes for aFormattedDateentity in a regular text message: the link colour and theTelegramTextAttributes.Datetap stamp are unconditional, andformatdecides ONLY whether the displayed text is replaced by an autoformatted one (nil ⇒ the literal inner text stands). A format-less date is the common case, not an edge case —ChatInputContentInstantPage.richText(from:)emitsformat: nilfor every client-composed rich message (ChatInputInlineEntity.datecarries only the timestamp, as do thetg://timestampDocument marker andGenerateTextEntities), and the wire mapsflags == 0to nil as well — so gating the colour+stamp onformat(as the first version of this did) left composed dates as dead body text: not a link, not tappable. The one thing that does gate styling is theformatDateclosure: the V2 path always supplies it, the V1 reader supplies none, renders the server's literal text, and is deliberately left in body colour rather than turning every reader date blue. Tap dispatch is unchanged (ChatMessageRichDataBubbleContentNode.entityTapContent→.date(date, ""); the display string is unused byChatMessageBubbleItemNode). Guarded byInstantPageDateEntityTests. - Known limitation: the client never round-trips a date's
format. Every client-side carrier of a date is timestamp-only, so composing/editing a message that contained a.relativeor.full(…)date re-emits it format-less and it stops autoformatting (this is symmetric with plain text messages —GenerateTextEntitiesdrops it there too — and is why the styling above must not depend on the format). Restoring it means wideningChatInputInlineEntity.date, thetg://timestampmarker andGenerateTextEntitiestogether.
RichText entity cases (mention / hashtag / bot command / bank card / auto link)
RichText.textMention, .textMentionName(text:peerId:), .textHashtag, .textCashtag, .textBotCommand, .textBankCard, .textAutoUrl, .textAutoEmail, .textAutoPhone render the message-entity flavors of rich text inside rich-data bubbles with full tap interaction mirroring ChatMessageTextBubbleContentNode. Covers API parsing, Postbox + FlatBuffers serialization, display, and tap routing. (textDate and textSpoiler are implemented too and follow the same shape — see the RichText.textDate bullet above and the spoiler handling in attributedStringForRichText / ChatMessageRichDataBubbleContentNode.revealSpoilers.)
Where things live
| File | Responsibility |
|---|---|
submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift |
The 9 enum cases (each wraps text: RichText; textMentionName adds raw peerId: Int64) + Postbox coding (discriminators 18–26, wrapped text under key "t", mention-name peerId under "mn.p"), ==, plainText, FlatBuffers codec. |
submodules/TelegramCore/FlatSerialization/Models/RichText.fbs |
Union members + tables (RichText_MentionName adds peerId:long). Source of truth — same flatc gotchas as the custom-emoji section above. |
submodules/TelegramCore/Sources/ApiUtils/RichText.swift |
Api.RichText ⇄ Swift, lossless. textMentionName carries userId ⇄ peerId. |
submodules/InstantPageUI/Sources/InstantPageTextItem.swift (attributedStringForRichText) |
Display: auto url/email/phone reuse the InstantPageUrlItem (url:) path; the six entity cases push .link(false), recurse, then attach the matching TelegramTextAttributes.* key over the produced range. |
submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/... |
Tap routing: entityForTapLocation reads the attribute dict at the tapped point; entityTapContent maps keys → ChatMessageBubbleContentTapAction.Content. |
Non-obvious invariants
- Display attaches the same
TelegramTextAttributes.*keys the chat text bubble uses; the bubble reads them back. Contract:textMention→PeerTextMention(String);textMentionName→PeerMention(TelegramPeerMention, peerId built asEnginePeer.Id(namespace: Namespaces.Peer.CloudUser, …)—InstantPageTextItemimports TelegramCore but NOT Postbox, so barePeerIdis out of scope);textHashtagANDtextCashtag→Hashtag(TelegramHashtag; no dedicated cashtag key/tap-action — the leading$distinguishes them);textBotCommand→BotCommand;textBankCard→BankCard. Auto url/email/phone go through the URL path (mailto:/tel:/raw), NOT an entity key. linkSelectionRectsand the bubble tap path check EVERY interactive key, not just URL: URL, the five entity keys,Date, andInstantPageButtonActionAttribute— so press-highlight and the link-loading shimmer cover entities, dates and page buttons too. A key omitted there loses its highlight and shimmer silently.- Rich-data text selection must reach a line's trailing edge. This is general to rich-data selection, not just entities:
InstantPageTextItem.attributesAtPoint(_:orNearest:)'sorNearest: true(selection-drag) path returnsline.range.upperBound(viaCTLineGetStringRange) when the point is at/pastlineFrame.maxX.TextSelectionNodeuses that index as the exclusive upper bound, so clamping to the last character's index — as theorNearest: falsehit-testing path correctly does — would leave the last character/item of every line unselectable. MirrorsDisplay.TextNode. Do not collapse the twoorNearestpaths back together.
Markdown send: entity vs. rich detection
On message send, the app auto-decides: if the typed markdown maps onto the regular message-entity set (bold/italic/code/strikethrough/spoiler/links/blockquote/fenced-code) it sends a normal message via the existing entity path; if it contains structure the entity set can't represent it sends a rich message (RichTextMessageAttribute carrying an InstantPage, rendered by ChatMessageRichDataBubbleContentNode). Always-on (no flag). Effective rich triggers are headings, lists, and tables only.
Where things live
| File | Responsibility |
|---|---|
submodules/BrowserUI/Sources/BrowserMarkdown.swift |
The classifier richMarkdownAttributeIfNeeded(context:text:) (pre-filter markdownMightNeedRichLayout → parse via existing inputRichTextAttributeFromText → block inspection instantPageNeedsRichLayout/blockIsEntityExpressible/richTextIsEntityExpressible), plus the markdown→InstantPage conversion (markdownWebpage, markdownBlocks(from:), markdownBlocksWithGeneratedAnchors). |
submodules/TelegramUI/Sources/ChatControllerNode.swift (sendCurrentMessage, ~line 4860) |
The gate: if !isSpecialChatContents, let attribute = richMarkdownAttributeIfNeeded(context:, text: effectiveInputText.string) routes to the rich branch; the unchanged else is the entity path. |
Non-obvious invariants
- Boundary rule: send rich iff the parse yields an
InstantPageBlockwith no entity equivalent. Entity-expressible whitelist (→ normal):.paragraph,.preformatted,.blockQuote(empty caption),.anchor,.unsupported, and.divider(---is too common in casual text to trigger rich)..formula(block and inline) DOES trigger rich, gated by strict math detection (see "Formulas trigger rich messages" below) so casual$usage ($5-$10,$FOO=$BAR) stays plain. So effective triggers = headings, lists, tables, formulas. - Approach A (parse-then-inspect): the classifier reuses the real parser, so "what triggers rich" can't drift from "what the rich renderer shows."
markdownMightNeedRichLayoutis a cheap necessary-condition over-approximation — it may over-trigger a parse but must never false-negative. It detects#, list markers, dash-lines (-{1,}, which also catches setext-H2 underlines → heading blocks),\n=(setext H1),|,— because re-send re-parses the text through the rich path (richMarkdownAttributeIfNeeded→NSAttributedString(markdown:), Apple CommonMark), notconvertMarkdownToAttributes(whose dialect is__italic__/||spoiler||). The two parsers disagree on__/*; the rich round-trip is the contract. - Re-classify every edit (edit ≡ send).
editMessageruns the samerichMarkdownAttributeIfNeededon the edit field's attributed text (so reattached custom emoji round-trip — see the custom-emoji section). Rich →pendingUpdateMessageManager.add(text: "", entities: nil, richText: attr, …); else the unchanged plain path. So normal→rich (add a table) and rich→plain (drop all triggers) both work. Bypassed for.customChatContents. - Change-detection compares the rich attribute. The save guard adds
currentRichText != richTextAttribute(rich branch — skips no-op rich edits) andcurrentRichText != nil(plain branch — so rich→plain still saves even whentext.stringlooks unchanged).RichTextMessageAttributeisEquatableoninstantPage. - The
text.length == 0early-return guard is safe for rich.convertMarkdownToAttributesonly rewrites inline tokens, never strips#/-/|, so a rich message's markdown source stays non-empty and passes; the rich branch then sendstext: "". - Known limitation: a rich→plain edit that leaves only inline-formatted text loses
*italic*(the entity path recognizes only__…__). Rare edge; the rich round-trip contract holds. previewText()lives in TelegramStringFormatting, not TextFormat/TelegramCore. It will gain astrings: PresentationStringsparam (to localize the"Photo"/"Video"/"Table"placeholders), so it must sit in a UI-string module —messageContentKind/descriptionStringForMessage(same module) already takestrings:. TeachingmessageContentKindabout rich cascades the preview to the edit accessory panel, reply/pinned panels, and forward preview in one place (those surfaces need no individual change).
Copying rich messages as markdown (whole message + partial selection)
Rich messages (RichTextMessageAttribute, text == "") are copyable as markdown two ways: the context-menu Copy action copies the whole message; a text selection inside the rich-data bubble copies just the selected range. Both reconstruct markdown that mirrors the edit round-trip (markdownStringFromInstantPage). Always-on.
Where things live
| File | Responsibility |
|---|---|
submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift |
Whole-message Copy (three action sites: the regular menu, the anchored menu, and the quick-reply/welcome menu). Each computes richMessageMarkdown / richMessageInstantPage from the message's RichTextMessageAttribute.instantPage, opens the Copy gate with richMessageMarkdown != nil, and short-circuits to UIPasteboard.general.items = [richMessagePasteboardItem(fromInstantPage:)] — the WYSIWYG fragment + RTF + plain, not raw markdown text (the old storeMessageTextInPasteboard(markdown, …) behaviour). A rich message is sent with text: "", so any messageText.isEmpty gate on that path must also test richMessageInstantPage != nil or the image-copy branch throws the document away (fixed 2026-08-17 in the regular menu's resourceAvailable arm). |
submodules/BrowserUI/Sources/InstantPageToMarkdown.swift |
markdownStringFromInstantPage — the block-tree → markdown converter (also used by the edit round-trip). Blocks joined by \n\n; nested blockquotes via recursive > wrapping. |
submodules/InstantPageUI/Sources/InstantPageTextItem.swift |
InstantPageMarkdownBlockContext (kind + quoteDepth) and the markdownContext: InstantPageMarkdownBlockContext? field on InstantPageTextItem. |
submodules/InstantPageUI/Sources/InstantPageV2Layout.swift |
stampMarkdownContext/bumpQuoteDepth; stamps markdownContext during layout (heading/title/code/list/blockQuote/layoutQuoteText/table-cell). |
submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift |
markdownForRange(_ range: NSRange) + the private attributed-substring→inline-markdown converter inlineMarkdown(from:). |
submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/.../ChatMessageRichDataBubbleContentNode.swift |
Intercepts .copy in the TextSelectionNode performAction closure: textSelectionNode.getSelection() → adapter.markdownForRange(range) → stores as plain NSAttributedString(string:). |
Non-obvious invariants
- The V2 layout discards block role. A
.textlayout item from anH2heading is byte-identical to a body paragraph — heading level and the title category are dropped with no back-reference to the sourceInstantPageBlock. Precise structural markdown for a selection therefore requires stampingmarkdownContextat layout time (lists/code/tables/details are structurally recoverable; heading level and.titleare not, so they MUST be stamped). Plain paragraphs staynil(≡ plain). quoteDepthis orthogonal tokindso a heading/list/code line inside a blockquote round-trips (e.g.> ## Title).bumpQuoteDepthlifts a quote's children by 1; nested quotes accumulate.layoutQuoteText(single-paragraph blockquote fast path AND.pullQuote) bumps once — it is never reached by the multi-block recursion, so no double-count.- A blockquote is exploded into one text item per line.
markdownForRangemust re-coalesce a run of consecutivequoteDepth > 0segments into ONE\n-joined block (each line prefixed at its own depth); otherwise every quote line becomes its own block separated by a blank line. Code/table/list runs are likewise coalesced (one fence; one pipe table; one tight list). - Both converters emit compact nested-quote markers (
>>, not> >). Selection:String(repeating: ">", count: depth) + " ". Whole-message: when wrapping a line that already starts with>, prepend a bare>. Keep the two in sync. - Inline markdown is read from display attributes, not the RichText tree.
inlineMarkdowninspects the slice'sUIFont(bold/italic/mono — font-based, no symbolic-trait flag for named fonts),.strikethroughStyle, andTelegramTextAttributes.URL(→InstantPageUrlItem.url, angle-bracketed if it contains(/)/space). Custom-emoji placeholders now emit the[<alt>](tg://emoji?id=…)marker from the display attribute'sfileId(alt is best-effort — the display placeholder may be a bare space; see the custom-emoji round-trip section). .copystores plain text. PassingNSAttributedString(string: markdown)through the existingperformTextSelectionAction(.copy)path (storeAttributedTextInPasteboard) generates no entities, so the literal**/#/>/|survive. The whole-message Copy usesstoreMessageTextInPasteboard(_, entities: nil)directly.- Fidelity caveats (intentional): custom emoji are now preserved as
[<alt>](tg://emoji?id=…)markers (selection copy uses a best-effort alt — see the custom-emoji round-trip section below); ordered list + checkbox loses the ordinal (-wins); a partial table selection emits touched cells as rows (no forced header---separator); block prefixes apply to the whole touched line on a mid-line selection (correct markdown).
Collapsed quotes (blockQuote.collapsed) in V2
A quote the author marked collapsed renders as a three-line preview that fades out into an expand chevron, matching InteractiveTextComponent so a rich bubble and a regular text message in the same chat collapse to the same size and look the same doing it — same arrow asset (Item List/ExpandingItemVerticalRegularArrow, 6pt in / 3pt up from the trailing-bottom corner, rotated π when expanded), same bottom-fade tile, same trailing "…". Tapping anywhere in the quote toggles it, animated by the chat list's own item-update animation. Added 2026-08-18.
Where things live
| File | Responsibility |
|---|---|
submodules/InstantPageUI/Sources/InstantPageV2QuoteCollapse.swift |
The whole testable core: instantPageV2QuoteCollapseState, instantPageV2TextLineCount, instantPageV2QuoteBudgetedItems, the truncation ellipsis, instantPageV2QuoteChevronSideInset, and the 3-line budget + chevron-clearance constants. Free of LayoutContext on purpose — see the testability note below. |
submodules/InstantPageUI/Sources/InstantPageV2QuoteFade.swift |
The bottom-fade tile (generateBlockMaskImage() ported verbatim) and InstantPageV2QuoteFadeMaskLayer. |
submodules/InstantPageUI/Sources/InstantPageV2Layout.swift |
layoutBlockQuote applies the budget in its child loop and reserves the expanded chevron's clearance; InstantPageV2QuoteFrameItem carries collapseState + path; LayoutContext/layoutInstantPageV2 carry expandedQuotePaths. |
submodules/InstantPageUI/Sources/InstantPageRenderer.swift |
InstantPageV2QuoteFrameView draws + animates the chevron; InstantPageV2View.applyCollapsedQuoteFades hangs the fade off content views; collapsibleQuoteAt(point:) is the hit lookup; TextRenderView.draw paints additionalTrailingLine. |
submodules/InstantPageUI/Sources/InstantPageTextItem.swift |
InstantPageTextLine.additionalTrailingLine — the appended "…" token. |
…/Chat/ChatMessageRichDataBubbleContentNode/… |
Owns currentExpandedQuotePaths, passes it into layout AND into the layout cache key, and resolves the toggle last in tapActionAtPoint. |
Non-obvious invariants
- Collapsible is not the same as collapsed. A quote shows a control only when the author marked it AND it exceeds three text lines (
instantPageV2QuoteCollapseState). Mirrors the reference'ssegmentLines.count > 3. Collapsibility is author-set; the reader cannot collapse a quote the author left open. - Expansion is a LAYOUT INPUT, not view state —
layoutInstantPageV2(expandedQuotePaths:), exactly like the olderexpandedDetails. Clipping at view time cannot work: the page's height would size for the expanded content and the bubble would reserve space it does not draw. expandedQuotePathsis part of thecurrentPageLayoutCACHE KEY. The bubble caches its laid-out page and reuses it when the inputs match; omitting the set means a toggle mutates state, requests an update, and then gets the stale layout back — a dead tap that looks like a broken gesture rather than a stale cache.- Keyed by structural path, not ordinal.
InteractiveTextComponentkeys by block index; V2 keys by thepathPrefixit already threads (the same addressingcheckboxTappeduses) because AI streaming appends blocks and shifts ordinals under the state. - Truncation happens INSIDE
layoutBlockQuote's child loop, so nothing needs re-positioning — the siblings after a truncated item do not exist yet — andcontentHeightis right by construction. The truncated item's height comes from the uniform line pitch (lines[1].minY − lines[0].minY), not a re-measure. - The ellipsis is APPENDED, never folded into the line.
InstantPageTextLine.additionalTrailingLinecarries a "…" drawn after the line's own glyphs, the way the reference does it — rewriting the line withCTLineCreateTruncatedLinewould invalidaterange, the per-line attachment/spoiler/underline frames and the character rects all at once, breaking hit-testing, inline emoji placement and the streaming reveal. It is dropped when the line already fills the band (no room, and the reference's own token lands inside the faded corner there anyway). - The fade is a MASK, not an overlay. A quote's fill is translucent accent over the chat wallpaper, so painting a background-coloured gradient over the last line would show as a grey smear.
InstantPageV2QuoteFadeMaskLayercarves the bottom 8pt and a radial hole around the chevron out of the content's alpha instead. ItsbackgroundColoris the dissolve control, not decoration: a mask composites its background BEHIND its contents, so animating clear → white fills the carve-out back in and the fade cross-fades away as the quote expands. - The fade is applied by a POST-PASS over positioned views (
applyCollapsedQuoteFades), because a quote's content is not grouped —layoutBlockQuoteemits the frame and its children as flat siblings, so geometry is the only thing tying a text view to the quote it sits in. Innermost containing quote wins; a quote never masks its own frame view (which is what carries the chevron). Details bodies and table cells nest their ownInstantPageV2View, and masking the container masks the subtree, so nesting needs no special case. - An expanding quote's mask must keep tracking the quote. The dissolve animates
backgroundColor, but the mask's FRAME is animated to the new (taller) rect at the same time — leave it at the old rect and the newly revealed lines are clipped for the length of the expand animation. The teardown is token-guarded so re-collapsing mid-animation cannot let the stale completion strip the mask. - Nothing past the cut is DISCARDED — it is drawn and not reserved for.
layoutBlockQuoteruns two heights:contentHeight(what the quote reserves, and therefore how tall it is) anddrawnHeight(where the next child is positioned). They diverge past the three-line cut. So every child sits at the SAME coordinates collapsed and expanded, no view is created or destroyed by a toggle, and the whole transition is the quote's height plus the mask. A medium, table or nested quote below the cut is carried asoverflowrather than dropped — dropping it tears its view down on collapse and rebuilds it on expand, which blinks. Inline content (custom emoji, images, formulas, buttons) needs nothing extra: it lives in the text view's own container subviews, which the mask covers. - A collapsing quote keeps its cut lines DRAWN, as
InstantPageV2TextItem.overflowHeight. Truncating at layout time is what makes the quote three lines tall, but lines that no longer exist cannot be animated away — they can only blink out, which is what a collapse looked like before this. The truncated item therefore keeps every line and shrinks only its laid-out box; the surplus spills past the view's bounds (nothing there clips) and the quote's mask hides it. The mask is created at the quote's PRE-toggle rect and animated closed, because a mask born already closed reproduces the very snap the overflow exists to remove. - The "…" is the one drawn difference between the two states, and it CROSSFADES. Because the cut lines stay drawn as overflow, every line and every line position is identical collapsed and expanded; only the token differs, and it would otherwise blink in and out while the geometry around it animated.
InstantPageV2TextViewkeeps the bitmap already inrenderView.layer.contents, forces the redraw, and dissolves between the two — the mechanismInteractiveTextComponentuses for its spoiler reveal (animateContents(layer:from:)), which is far cheaper than a snapshot because the old backing store is already there. Gated on the box being the same size, which is the honest precondition for a contents crossfade (a resized layer squashes the old bitmap) and also keeps it off the AI-streaming path, which grows the box and has its own reveal. - Overflow is only safe when nothing follows the cut inside the quote. The spill is invisible because the mask is transparent below the quote's bottom edge — above it the mask is opaque, so a caption under the cut would be overlapped. Once the budget is exhausted every later child is dropped, so the caption is the only thing that can sit there:
layoutBlockQuotepasseskeepingOverflowonly for an empty caption, and the captioned case keeps the old lossy truncation (and its snap). - Only the EXPANDED state reserves room for the chevron. A collapsed quote's fade already clears that corner. Expanded, the layout measures the last row's
instantPageV2QuoteChevronSideInset— the last LINE's drawn edge for text, since a paragraph's frame always spans the band — and adds 10pt only when the arrow would land on content. - Only text costs budget. A medium / table / nested quote is kept whole if it begins before the budget is exhausted and dropped entirely if not — never clipped. So a collapsed quote whose first child is an image is taller than three lines. Deliberate; see the design doc.
- The caption is never budgeted. It is the quote's attribution — chrome, not content.
- The frame view stays
isUserInteractionEnabled = false. An interactive view would take the touch beforetapActionAtPointran, so a link inside the visible three lines would toggle the quote instead of opening. The host resolves the tap throughcollapsibleQuoteAt(point:), after its URL and entity hits. layoutQuoteTextis NOT the single-paragraph blockquote path, whatever the comment there says: its only caller is.pullQuote, and every.blockQuotegoes throughlayoutBlockQuote. Its frame item is pinned.notCollapsible.- Recipients only see this for SINGLE-PARAGRAPH quotes.
pageBlockBlockquoteBlockshas nocollapsedfield in the schema, so a multi-block quote loses the flag in transit (apiInputBlockpicks that form for anything but a lone.paragraph). Local surfaces — composer, send preview, article editor, drafts — honour it for every shape. Closing the gap needspageBlockBlockquoteBlocks flags:# collapsed:flags.0?trueserver-side.
Testability
layoutBlockQuote is private and a LayoutContext needs a TelegramMediaWebpage, PresentationStrings and a themed InstantPageTheme, so no test drives the layout — nothing in the repo calls layoutInstantPageV2 from a test. That is why the budgeting arithmetic, the ellipsis and the chevron clearance live in their own file as pure functions, tested by InstantPageV2QuoteCollapseTests against items built with layoutTextItem. InstantPageV2QuoteFadeTests samples the fade tile's alpha directly — the tile is written in blend modes (.copy radial, .destinationIn linear), and getting one wrong still yields a plausible greyscale image that masks the wrong pixels. The view wiring (mask attachment, chevron animation, hit lookup, bubble state) has no automated coverage; it was verified on the simulator instead — tap-to-toggle, the chevron, the fade, and the collapse/expand animation all confirmed working 2026-08-18, after four rounds in which each remaining artifact was reported and fixed in turn (chevron asset → background chrome → the clip → the ellipsis). Still unverified by anyone: RTL, a quote with an author caption, and nested collapsible quotes.
Custom emoji in markdown messages (send + edit/copy/paste round-trip)
Custom emoji typed into the compose field survive when a message is sent as a rich message (heading/list/table/formula), rendering as RichText.textCustomEmoji (the display side is the "Inline custom emoji" section above). The carrier across Apple's CommonMark parser is a shared markdown-link marker [<alt>](tg://emoji?id=<fileId>), used identically by the forward (send) and reverse (edit/copy/paste) paths so encode and decode cannot drift. Always-on. Scope: only rich messages — a custom emoji alone never forces a rich message (it stays on the entity path as a .CustomEmoji entity, the pre-existing behavior).
Where things live
| File | Responsibility |
|---|---|
submodules/TextFormat/Sources/CustomEmojiMarkdownMarker.swift |
The marker format — single source of truth: customEmojiMarkdownURL(fileId:), parseCustomEmojiFileId(fromMarkdownURL:), escapeCustomEmojiMarkdownAlt(_:), and chatInputTextWithReattachedCustomEmoji(_:) (markers → live customEmoji attributes). In TextFormat so both BrowserUI and InstantPageUI can import it. |
submodules/BrowserUI/Sources/BrowserMarkdown.swift |
Forward: markdownSourceInjectingCustomEmojiMarkers rewrites each customEmoji run into the marker; richMarkdownAttributeIfNeeded(context:attributedText:) (signature changed from text:); the marker-URL intercept in markdownInlineContent → .textCustomEmoji. |
submodules/BrowserUI/Sources/InstantPageToMarkdown.swift |
Reverse (whole-message copy + edit reconstruction): .textCustomEmoji → emit the marker. |
submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift |
Reverse (text-selection copy): emit the marker from the display attribute's fileId (alt best-effort). |
submodules/TelegramUI/Sources/ChatControllerNode.swift, …/Chat/ChatMessageDisplaySendMessageOptions.swift |
Send + send-options-preview call sites pass the NSAttributedString (effectiveInputText / textInputView.attributedText); the rich send now passes inlineStickers. |
submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift |
Edit-load (setupEditMessage) reattaches markers via chatInputTextWithReattachedCustomEmoji; edit-save (editMessage) re-classifies the attributed edit text. |
submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift |
Paste (chatInputTextNodeShouldPaste) reattaches plain-text markdown markers → live emoji. |
Non-obvious invariants
- One shared marker, one set of helpers. All emit sites (forward normalize, reverse copy/edit, selection copy) use
customEmojiMarkdownURL+escapeCustomEmojiMarkdownAlt; the forward intercept and both reattach sites useparseCustomEmojiFileId. The marker is internal/transient — it exists only in the rich-conversion source string and on the clipboard, never persisted as a URL entity. - CommonMark preserves the
tg://emoji?id=Nlink URL verbatim under theNSLinkattribute (spike-verified).markdownLink'sas? NSURLbranch returnsurl.absoluteString, whichparseCustomEmojiFileIdmatches by strict prefix. Negative (signed Int64) file ids survive too (the reattach regex is(-?\d+)). - Scope guard is structural.
markdownSourceInjectingCustomEmojiMarkersworks on a LOCAL copy —effectiveInputTextis never mutated. A marker is an entity-expressible link, so an emoji-only message classifies not-rich (markdownMightNeedRichLayoutfinds no#/|/(no run carries the link attribute), which would silently lose the emoji; every emit site and the reattach substitute a space when the alt is empty. - Rich send attaches
inlineStickers(was[:]) + bubble-up packs, so the local store has the files. OPEN runtime risk: the wire send usesApi.InputRichMessage.documents: nil(apiInputRichMessage()inSyncCore_RichTextMessageAttribute.swift), so recipient rendering depends on the server back-fillingdocumentsfrom the embeddeddocumentId— UNVERIFIED. If recipients see only the fallback glyph, populatedocuments:there. - Accepted limitations: edit-load reattaches with
file: nil(renders via lazy fileId resolution, but the premium-emoji gate is bypassed on edit); an alt containing a literal]won't reattach on edit-load (cosmetic — re-save still parses it);parseCustomEmojiFileId(strict prefix) vsPasteboard.swift'sURLComponentsparse could drift if the marker format ever changes.
Formulas trigger rich messages (strict math detection)
$…$/$$…$$ (and \(…\)/\[…\]) math triggers a rich message, gated by a
strict boundary rule so casual $ stays plain. Inverse companion of the
markdown-send gate above.
Non-obvious invariants
- Inline
$…$/$$…$$detection requires a 4-way boundary (inmarkdownReplacingInlineFormulas,BrowserMarkdown.swift): outer side of each delimiter = line edge OR non-alphanumeric; inner side = non-whitespace; opener/closer$-counts must match (1 or 2). This is what rejects$5-$10/$FOO=$BAR/cost$5$total(alphanumeric outer) while keeping$x$,($x$),the answer is $x$.. The outer check is the addition over a plain "no-space-inside" rule. - Block
$$detection (markdownBlockFormulaReplacement): single-line$$…$$requires an exact$$opener (not$$$) and trailing whitespace only; multi-line requires a bare$$opener line.$$x$$ trailing textfalls through to the inline rule. The\[…\]opener path is unchanged and exempt from these$$-only guards. - Detection is shared with the document path; the gate is chat-only.
markdownPreparedSource(detection) runs for both chat and document attachments. The triggers (richTextIsEntityExpressible/blockIsEntityExpressible→.formulais non-expressible;$/\(/\[inmarkdownMightNeedRichLayout) are read only by the chat classifierrichMarkdownAttributeIfNeeded.
InstantPageListItem task-list checkboxes (- [ ] / - [x])
InstantPageListItem carries a first-class checked: Bool? — the third associated value of .text(RichText, String?, Bool?) / .blocks([InstantPageBlock], String?, Bool?), orthogonal to the ordered-list num — representing a GitHub-style task-list checkbox. nil = not a checkbox item, false = unchecked, true = checked. Covers markdown parse, Postbox + FlatBuffers serialization, Telegram API transmission, display (V1 + V2), the edit round-trip, and previews.
Spec: docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md. Plan: docs/superpowers/plans/2026-05-27-instantpage-list-checkbox.md.
Where things live
| File | Responsibility |
|---|---|
submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift |
The checked: Bool? enum payload; Postbox coding (key "ck", tri-state Int32); ==; FlatBuffers codec. Internal tri-state helpers checkedFromTriState/triState(fromChecked:). |
submodules/TelegramCore/FlatSerialization/Models/InstantPageBlock.fbs |
checkState:int32 (id: 2) on InstantPageListItem_Text + _Blocks. Source of truth; the Bazel flatc genrule regenerates the Swift (checked-in *_generated.swift is stale). |
submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift |
checked / num accessors; reads & writes the API checkbox=flags.0 / checked=flags.1 bits via checkedFromApiFlags / apiFlags(fromChecked:) across all four list-item types. |
submodules/BrowserUI/Sources/BrowserMarkdown.swift |
Forward parse: markdownTaskListMarker detects [ ]/[x]/[X]; the result routes into checked (NOT num). |
submodules/BrowserUI/Sources/InstantPageToMarkdown.swift |
Reverse: emits - [ ] / - [x] from item.checked for the edit round-trip. |
submodules/InstantPageUI/Sources/InstantPageV2Layout.swift |
V2 detection via item.checked; .checklist(checked:colors:) marker carrying InstantPageV2CheckboxColors. |
submodules/InstantPageUI/Sources/InstantPageRenderer.swift |
V2 marker view (InstantPageV2ListMarkerView) hosts a real CheckNode. |
submodules/InstantPageUI/Sources/InstantPageLayout.swift |
V1 detection via item.checked (renders the existing InstantPageChecklistMarkerItem). |
submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift |
previewText() renders a ☐/☑︎ glyph + body for checkbox items. |
Non-obvious invariants
checkedis orthogonal tonum. The API keepscheckbox/checkedas flags separate from the list number, so an ordered item can be both numbered AND a checkbox. This is exactly why the first-class field replaced an earlier sentinel-string-in-numprototype (which could not represent both). No\u{001f}tg-md-task:*sentinel remains anywhere.- API bits are
checkbox=flags.0,checked=flags.1 on ALL FOUR list-item constructors (pageListItemText/BlocksandpageListOrderedItemText/Blocks, in and out —pageListItemText#2f58683c,pageListOrderedItemText#cd3ea036, etc.). The iOSApi.*layer exposes onlyflags: Int32; mask the bits (apiFlags(fromChecked:)/checkedFromApiFlags). Because state rides the flags (not the text), it survives the server round-trip for sender + recipients — including the sender's own send-confirmation echo (applyUpdateMessagereplaces local attributes with the server's reconstruction,ApplyUpdateMessage.swift). - Tri-state persistence
0=nil, 1=unchecked, 2=checkedin BOTH Postbox (key"ck", decoded withdecodeInt32ForKey(orElse: 0)) and FlatBuffers (checkState:int32, default 0). Absent/0 →nil, so pre-existing stored pages decode unchanged. - Detection reads
item.checked != nilin both layout engines (wasinstantPageTaskListMarkerState(item.num)); the V2 marker kind is.checklist(checked: item.checked == true, colors:). The empty-blocks.blocks → .text(.plain(" "), num, checked)promotion must carrycheckedthrough, not drop it. - V2
CheckNodeis hosted directly in a plainUIView, not an ASDisplayNode tree, socheckNode.displaysAsynchronously = falseis set to avoid a first-draw blank flash. (The V2 pageView is now REUSED across streaming chunks via stable-id diffing — see the AI streaming section;CheckNodeviews survive across chunks as long as their list item is present.)InstantPageV2CheckboxColors(background←panelAccentColor, stroke←pageBackgroundColor, border←controlColor) is carried on the.checklistpayload and mirrors the V1instantPageChecklistMarkerTheme. - Forward parser keeps
[ ]detection but routes tochecked.markdownApplyTaskListMarker/markdownStrippingTaskListMarker/markdownTaskListMarkerstill strip the marker from the item text; the state flows intocheckedwhile ordered items keep their real"\(ordinal)"number. The reverse converter emits lowercase[x]/[ ], which the forwardhasPrefixguards re-parse — that is the round-trip contract. - The enum-arity change is compile-enforced. Adding the third associated value broke every
.text/.blocksconstruction/destructure; the full build is the completeness gate. Read-only consumers outside the core set exist (BrowserInstantPageContent.swift,CachedFaqInstantPage.swift) — grep\.(text|blocks)\(repo-wide when touching the enum again.
Tap-to-toggle (editable rich messages)
Task-list checkboxes in a rendered rich message are interactive when the message is editable: tapping one flips it and persists the change by editing the message.
Where things live:
| File | Responsibility |
|---|---|
submodules/TelegramCore/Sources/InstantPageCheckboxToggle.swift |
Pure transform InstantPage.togglingCheckbox(at: [Int], to: Bool) -> InstantPage — rebuilds the block tree following a structural path and flips the target list item's checked (no-op on an unresolvable/non-checkbox path). |
submodules/InstantPageUI/Sources/InstantPageV2Layout.swift |
InstantPageV2ListMarkerItem.checkboxPath: [Int]?; a pathPrefix: [Int] threaded through layoutBlockSequence/layoutBlock/layoutList/layoutDetails/layoutBlockQuote stamps each .checklist marker with its path. |
submodules/InstantPageUI/Sources/InstantPageRenderer.swift |
InstantPageV2ListMarkerView installs a tap gesture when interactive, flips its own CheckNode optimistically, and fires onCheckboxTapped(path, newValue); InstantPageV2View.checkboxTapped routes it up (mirrors detailsTapped). |
submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift |
canEditMessageRichText: (EngineRawMessage) -> Bool (sync gate, mirrors canSetupReply) + toggleMessageRichTextCheckbox: (EngineMessage.Id, [Int], Bool) -> Void (the edit action). Both have no-op defaults so only the real chat wires them. |
submodules/TelegramUI/Sources/ChatController.swift |
Implements both closures. The toggle looks up the message, re-checks canEditMessage, applies togglingCheckbox, and submits via pendingUpdateMessageManager.add(text: "", richText:) — the composer's rich-edit path (mirrors the native-todo requestToggleTodoMessageItem). |
submodules/TelegramUI/Components/Chat/.../ChatMessageRichDataBubbleContentNode.swift |
checkboxesInteractive(item:resolved:) gate + sets pageView.checkboxTapped per apply. |
Non-obvious invariants:
- Checkbox identity is a structural
[Int]path, not an ordinal: each element indexes the current container's children — block-array index at page/.details/.blockQuote/list-item-.blockslevels; item index at.listlevel. The layout stamping (InstantPageV2Layout) and the toggle walker (InstantPageCheckboxToggle) MUST keep identical semantics — they were built and reviewed as a matched pair. Decoupled from<details>expand/collapse state. - The path is absolute-from-root only because every
layoutBlockSequencethat can reach a list is entered with a correct prefix. The two secondarylayoutBlockSequencecall sites (table cells, hard-coded[.paragraph]; and the details title) contain no lists, so no checkbox is produced there; akind != .cellguard inlayoutListis belt-and-suspenders against future misrouting. - The toggle applies to
attribute.instantPage, so taps must only ever fire against that page. The bubble'scheckboxesInteractivegate enforces this: interactive only whenresolved.keyis.originalANDresolved.instantPage === attribute.instantPage(class identity — excludes the show-morefullInstantPagerendering, which shares the.originalkey but is a differentInstantPageobject) ANDcanEditMessageRichText(item.message). Translations (.translated) and in-flight pending edits (.pendingEdit) are inert. Non-editable messages (incoming, past the edit window) are inert — and AI-streamed rich messages are incoming, hence never tappable. - Optimistic flip, model supersedes. The marker view flips its own
CheckNodeon tap; the pending/edited attribute re-render then supersedes (or, on failure, reverts, since the model was unchanged). Known minor edges of this state-free optimism: (1) an unrelatedupdate()before the pending edit lands rebuilds the marker from the oldchecked, briefly reverting the visual; (2) a rapid double-tap re-reads the still-stalecheckedand re-sends the same value rather than toggling back — the pending-edit manager coalesces, so the net state is consistent, just not a double-toggle. Both are acceptable given the edit lands promptly. - Gating uses closures because
canEditMessageisinternalto theTelegramUImodule and the rich-data bubble lives in a separate component module that cannot call it. The two closures bridge the boundary; their init-parameter order must match theChatControllercall-site order (Swift requirement) — they sit betweendisplayTodoToggleUnavailableandopenStarsPurchase.
InstantPageBlock.blockQuote nested blocks
InstantPageBlock.blockQuote carries (blocks: [InstantPageBlock], caption: RichText) — a sequence of nested page blocks (paragraphs, headings, lists, code, even nested quotes), not the legacy text-only payload. .pullQuote is unchanged (still (text: RichText, caption: RichText); the TL API has no pullQuoteBlocks constructor).
Spec: docs/superpowers/specs/2026-05-29-instantpage-blockquote-blocks-design.md.
Where things live
| File | Responsibility |
|---|---|
submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift |
Enum case shape; Postbox coding (legacy "t" lift → new "b" object array); equality (array-aware, mirrors .collage); FlatBuffers codec. |
submodules/TelegramCore/FlatSerialization/Models/InstantPageBlock.fbs |
InstantPageBlock_BlockQuote: text (now optional, legacy fallback) + caption (required) + new blocks:[InstantPageBlock] (id: 2). Source of truth; Bazel regenerates the *_generated.swift. |
submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift |
Parse both pageBlockBlockquote (lift text→[.paragraph]) and pageBlockBlockquoteBlocks; encode legacy-when-possible. |
submodules/InstantPageUI/Sources/InstantPageV2Layout.swift |
layoutBlockQuote(blocks:…) recurses into children; legacy single-paragraph fast path delegates to layoutQuoteText (the renamed shared text core, also used by .pullQuote). |
submodules/InstantPageUI/Sources/InstantPageLayout.swift |
V1 .blockQuote arm recurses via layoutInstantPageBlock(...); same single-paragraph fast path. |
submodules/BrowserUI/Sources/BrowserMarkdown.swift |
Forward: one quote carrying all child blocks. Entity-expressibility gate (below). |
submodules/BrowserUI/Sources/InstantPageToMarkdown.swift |
Reverse: markdownBlockQuoteBlocks(_:) recurses per child and prefixes > per line. |
submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift |
Concatenates child previewText()s + caption. |
Non-obvious invariants
- Legacy shapes lift to
[.paragraph(text)]at every decode boundary. APIpageBlockBlockquote, the Postbox"t"key (old cached pages), and the FlatBufferstextfield (now optional) each lift into a single-paragraph blocks array. New writes emit onlyblocks("b"/ the FB vector). So pre-existing stored pages and older senders decode unchanged. - Outbound stays on the legacy wire constructor when the shape allows.
apiInputBlock()emitspageBlockBlockquotefor empty or single-.paragraphquotes (so older recipients understand the common chat case) andpageBlockBlockquoteBlocksonly for genuinely nested quotes. - Both renderers share one text core for the single-paragraph fast path.
layoutQuoteText(V2; the function formerly namedlayoutBlockQuote,isPull:distinguishes pull vs block) and the V1 fast-path branch keep the legacy italicized-body styling; nested children render with their own normal category styling. - V1 and V2 have SEPARATE spacing functions, and must keep them (
InstantPageLayoutSpacings.swift, split back apart 2026-08-17).spacingBetweenBlocksV1(upper:lower:)is the Instant View reader's original flat table of absolute gaps (20 / 25 / 27 / 31 / 32 / 34), tuned against the reader's large page type;spacingBetweenBlocks(upper:lower:kind:metrics:)is V2's padding-plus-base model tuned against the chat bubble's 15/17pt type. They were briefly unified behind the V2 model, which collapsed the reader's rhythm — the two share no rule, so any common body would just be a switch on which renderer is asking. The six V1 call sites are all inInstantPageLayout.swift; every other caller is V2. Guarded byInstantPageV1SpacingTests, which pins V1's literals and asserts the two functions still disagree. The V2 model below is unchanged. - Block spacing (V2) is one base gap plus a per-type padding (
InstantPageLayoutSpacings.swift).spacingBetweenBlocksisupper.verticalPadding + instantPageBaseBlockSpacing + lower.verticalPadding, with two overrides ahead of it: a flush side yields 0, and two.paragraph(body) blocks have no gap at all — not even their padding. At a sequence edge only the present block's padding applies; the base is strictly a between-two-blocks quantity.InstantPageBlock.spacingis the single tuning surface — every type seedsverticalPaddingat 8.0, so a non-paragraph pair currently reads 24pt. The flush flags are directional (flushAbove/flushBelow) because each structural zero is one-sided:.cover/.channelBannerare flush above but take a real gap below,.relatedArticlesis flush below,.anchoris flush both ways. Consecutive.detailsrows deliberately LOST their flush (0 → 24pt) — that is flush-against-its-own-kind, which no per-type property expresses; the fix, if it matters, is astacksWithSameTypeproperty, not a reinstated pairwise case.spacingBetweenBlocksstill takeskindbut does not read it, kept for container-specific spacing later. - Nested children use a FIXED 10pt inter-child gap, not
spacingBetweenBlocks. The full page-flow spacing (24pt around quotes) is too airy when nested, and 0 is too tight.childSpacing = 10.0lives in both layout files; the first child hugs the container'sverticalInset(no leading gap). Combined with a nested quote's own 4pt top inset this gives ~14pt effective separation. - Entity-expressibility: a quote is entity-expressible (→ regular message path) only if its caption is empty AND every child is an entity-expressible
.paragraph. A nested-structure or multi-paragraph quote is not, so it sends via the rich path. Behavior change: markdown> p1\n>\n> p2is now ONE quote with two paragraphs (rich) rather than two consecutive entity quotes — correct semantics. - The enum-arity change is compile-enforced across all modules; the full Bazel build is the completeness gate (no per-module build).
CachedFaqInstantPage.swiftmatchescase .blockQuote:payload-less and needs no edit.BrowserReadability.swiftconstructs.blockQuote(blocks: [.paragraph(.italic(...))], …)and is easy to miss in the spec's file list — grep\.blockQuote(repo-wide when touching the case again.
Inline buttons & document blocks
The TL schema that reshaped keyboardButton/keyboardInlineButton also added inline buttons inside
RichText (textButton), block-level button rows (pageBlockButtonRow), and a generic file block
(pageBlockDocument). All three are modelled losslessly (Postbox + FlatBuffers + both Api
directions) and rendered in V2; V1 Instant View still skips them via its default: arms.
Because the models round-trip, no cached page needed re-fetching when the rendering landed.
A later revision split keyboardInlineButton back out into its own KeyboardInlineButton type
(plus keyboardInlineButtonRow, which replyInlineMarkup.rows now carries). That split does not
reach this module: pageBlockButtonRow carries Api.PageButton, a separate type, and the reply-markup
domain model merges keyboard and inline buttons anyway.
The models were added first and left unrendered on purpose, so that the rendering could land as a pure view change with no cache migration. That is why the sections below separate the (lossless, tested) model layer from the V2 rendering built on top of it.
Where things live
| File | Responsibility |
|---|---|
submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPageButton.swift |
InstantPageButton (text/action/color) + Postbox coding, and the ReplyMarkupButtonAction FlatBuffers codec — which exists only to serve page buttons, hence living beside its consumer. |
submodules/TelegramCore/Sources/ApiUtils/InstantPageButton.swift |
Api.PageButton ⇄ model, richButtonStyle ⇄ ReplyMarkupButton.Style.Color, and apiInlineButtonType() for the outgoing direction. |
submodules/TelegramCore/FlatSerialization/Models/RichText.fbs |
RichText_TextButton, InstantPageButton, and the 10-member InstantPageButtonAction union + tables. |
submodules/TelegramCore/FlatSerialization/Models/InstantPageBlock.fbs |
InstantPageBlock_ButtonRow, InstantPageBlock_Document. |
submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift |
case textButton (Postbox tag 29) + == + plainText (returns the label) + FlatBuffers codec. |
submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift |
case buttonRow (tag 31), case document (tag 32), codecs, and the allMedia(mediaDict:) arm. |
submodules/TextFormat/Tests/ |
InstantPageButtonModelTests, RichTextButtonTests, InstantPageBlockNewCasesTests — 24 tests over both codecs. |
submodules/InstantPageUI/Sources/InstantPageInlineButton.swift |
InstantPageInlineButtonAttachment (the measured payload) + instantPageInlineButtonAttachment(button:labelString:maxWidth:), the single construction path for both inline and row pills, incl. the ellipsis truncation + instantPageButtonColors(_:theme:isInline:isDisabled:) + the padding and font-size constants. |
submodules/InstantPageUI/Sources/InstantPageV2ButtonViews.swift |
InstantPageV2ButtonPillView (one pill: backgroundColor fill + press state + label recolour + the TextLoadingEffectView shimmer and its Promise<Bool> subscription), InstantPageV2ButtonPillContentView (draws the label + type icon, at whichever placement its isInline selects), InstantPageV2InlineButtonView, InstantPageV2ButtonRowView. |
submodules/TelegramUI/Components/RichTextButtonIcons/ |
The action → icon mapping, its sizes/insets, and the richTextEditorButtonIcon bridge the editor hosts register. A leaf module because both the renderer and the editor need it — see "The type icon". |
submodules/InstantPageUI/Sources/InstantPageV2DocumentContentNode.swift |
InstantPageV2DocumentContentNode (file row) + InstantPageV2DocumentView (item view). |
submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift |
.semibold / .medium baseline weights (button labels are semibold regardless of the surrounding paragraph) + the InstantPageInlineButtonAttribute key. |
submodules/InstantPageUI/Sources/InstantPageTheme.swift |
buttonDanger{Background,Foreground}Color, buttonSuccess{Background,Foreground}Color (split so a host can pass a solid fill: the default 15% tint is unreadable over a saturated outgoing bubble), checkboxFill, checkboxForeground — all defaulted, all threaded through withUpdatedFontStyles. |
Geometry (as shipped; tuned by eye, not derived)
Inline textButton |
Block pageBlockButtonRow |
|
|---|---|---|
| Label font | 15pt semibold | 16pt semibold |
| Horizontal inner padding | 7pt | 7pt |
| Vertical inner padding | 1pt | implied by the fixed height |
| Height | derived: label ink + 2·vPad | fixed 40pt (a touch target) |
| Corner radius | bounds.height / 2 (capsule) |
bounds.height / 2 → 20pt |
| Width | label ink + 2·hPad + (icon ? 14 : 0), capped at the line width | justify: equal share of the row, wrapping at 8; left/center/right: label ink + 2·(badge ? 18 : 7), greedy wrap |
| Type icon | trailing the label: 4pt gap + a 10×10 box, centred on the label's cap box | 10×10 badge inset 8/6 from the top-right corner |
Font sizes are fixed, not scaled by the Instant View font-size setting and not by the chat's Text Size
(contentScale, see "Text Size / content scale") — a pill is a control with its own typography, and a wider
pill would move line breaks the editor has to mirror. The two pill shapes therefore read differently side by side: a block pill
is much taller than an inline one, and its radius is correspondingly larger. If they ever need to look
related, a fixed radius rather than height / 2 is the lever.
The type icon
Both pill kinds carry the bot-keyboard type icon for their action, from the one mapping in
submodules/TelegramUI/Components/RichTextButtonIcons (richTextButtonIconName(for:)). The
placement differs by kind, and that is forced, not stylistic: an inline pill is the label's ink box
plus 2pt of padding — about 20pt tall — so a corner badge has nowhere to sit without being shaved by
the capsule clip. An inline icon therefore trails the label on the same optical line and the pill is
measured richTextInlineButtonIconReserve (14pt) wider to hold it.
That module exists as its own leaf rather than living in InstantPageUI because both the V2
renderer and the rich-text editor draw these, and the editor's composer host cannot import
InstantPageUI — InstantPageUI already depends on ChatRichTextEditorComposer, so that edge would
be a cycle.
- The inline reserve is unconditional width, so it moves line breaks. Unlike the block badge's
side inset (which only binds in the row layout's tight-padding fallback), it widens every
icon-bearing inline pill, and therefore re-wraps the paragraph the pill sits in. This is why the
editor must reserve the identical amount even where it draws nothing —
RichTextButtonMetricscarriesinlineIconReserve, pinned to the renderer's constant byRichTextV2ButtonParityTests. - The reserve belongs to the label+icon GROUP, not to the label.
instantPageButtonLabelOriginrecovers the label's ink width assize.width − 2·padding − iconReserveand centres the whole group. Omitting either subtraction slides the label right by half the reserve — and drags the custom-emoji squares with it, since they are derived from that same origin. - The icon centres on the label's cap box, not the pill box (
baseline − capHeight/2). The pill's box is asymmetric around the text because it also holds the descender, so pill-centring sits the icon visibly low against the letters it follows. The two differ by ~0.6pt at 15pt. - Whether there is an icon is a separate question from whether its image loaded. The editor's seam
(
RichTextButtonIcon) answers existence from the pure action → name lookup and produces the ink lazily through a tint closure. A bareUIImage?would conflate the two: an asset that fails to load (an unbundled unit test, a renamed file) would silently re-flow every paragraph holding a button, and only in the editor — the renderer decides the same question from the name and would keep reserving. The closure also lets the row packer ask the question per padding attempt without rasterising. - The editor's icon must be resolved through
ButtonActionCodec, not fromButtonActiondirectly.RichTextEditorCorecan name onlyurl/copyText/disabled; every other action travels as an opaque.unsupported(kind:payload:).richTextEditorButtonIcondecodes it back so a.openUserProfilebutton draws the profile icon in the composer, and — the case that actually bites — so an iconless.callbackreserves nothing on either side.richTextButtonHasBadgeremains as the coarse fallback for an editor with no provider registered, and is wrong for exactly that case.
Non-obvious invariants
InstantPageButtonandInstantPageButtonActiontables MUST live inRichText.fbs, notInstantPageBlock.fbs.RichText_TextButtonneeds them andInstantPageBlock.fbsalreadyincludesRichText.fbs, so defining them there creates an include cycle.ReplyMarkupButtonActionis deliberately reused as the page-button action type, so a Stage 2 page-button tap can call the existingChatMessageItemView.performMessageButtonActioninstead of a parallel dispatch. The type is therefore wider than the schema allows:text,requestPhone,requestMap,setupPollandrequestPeercome only fromApi.ButtonTypeand cannot occur on a page button. Only 10 cases are reachable, which is exactly the FlatBuffers union's membership; the five unreachable ones collapse ontodisabledwhen encoded (Postbox stays lossless).richTextIsEntityExpressiblereturnsfalsefortextButton(BrowserMarkdown.swift), so a button forces the rich send path. That file hasdefault:clauses — had this defaulted totrue, button-bearing content would be sent as plain message entities and the buttons destroyed at send time.allMedia(mediaDict:)andapplyMediaResourceChangesboth need.document, and neither is fully compiler-protected.allMediaends indefault:(omitting.documentcompiles and then silently stops the page fetching its file).applyMediaResourceChanges(State/ApplyUpdateMessage.swift) hands a locally uploaded file's resource data to the cloud resource on send confirmation — omitting.documentre-downloads a just-sent file.InstantPageAnchorPathintentionally does NOT recurse into.buttonRow. That file's recursion set must match whatInstantPageV2Layout.layoutBlockrecurses through, because both drive a shareddetailsIndexCounterordinal; since V2 does not lay out.buttonRow, recursing would desynchronise<details>anchor navigation. Anchors inside aRichText.textButtondo resolve — that descent lives inrichTextContainsAnchor.- A button behaves as a discrete atom, never as flowing text, in every text-shaping helper: not
split by a prefix drop (
markdownDroppingPrefixLength), always displayable (markdownHasDisplayableContent), never whitespace-only (markdownIsWhitespaceOnly), and not trimmed internally (BrowserReadability'strimStart/trimEnd/trim/addNewLine). It sits with.imageand.textCustomEmoji. - KNOWN LOSSY, deferred: editing a button-bearing rich message drops its buttons. Markdown has
no spelling for a button, so the InstantPage → markdown → InstantPage round-trip keeps the label
and loses action + style.
InstantPageToMarkdown.swiftstates this at the site. The fix, if picked up, is a positiveInstantPage.containsButtonscheck gating the edit affordance — not adefault:clause. keyboardButtonStyleis bit-identical atflags.10to the pre-unification constructors, soReplyMarkupButton.Styleand its rendering inChatMessageActionButtonsNodewere untouched by the migration.
Rendering invariants (V2)
- A button row's alignment comes from
pageBlockButtonRow's flag bits, read asInstantPageButtonRowAlignment(justify = 0, so cached pages keep the old layout with no migration; precedenceleft > center > rightwhen a malformed row sets several bits). Justify keeps the equal-column split and its fixed 8-per-row chunking; left/center/right hug the label and wrap greedily by width, still capped at 8. Each wrapped row is aligned on its own width, so a short last row re-centres rather than staying flush with the row above. - One RTL rule covers all four modes: a row lays out in the page's reading direction.
align_leftmeans leading (the right edge on an RTL page),align_rightmeans trailing, and the first button of a row sits at the reading start — so pills run right-to-left on RTL pages, justify included. This deliberately differs from V2 table cells, which apply.left/.rightliterally. - Pass 1's
maxWidthis what makes pass 2 safe. Every pill is measured capped toavailableWidth − 2·extra, so no single pill can exceed the row; greedy packing therefore never produces an overflowing row, and an over-long label ellipsises instead.extrais the badge reserve beyond the padding the attachment builder already adds (iconReserve − hPad), because the pill centres its label under a top-right badge. - Justify's truncation cap is knowingly 14pt more conservative than the hug path's: it passes
columnWidth − 2·iconReserveand the attachment builder subtracts2·hPadagain. Unifying them would shift where ellipses appear on already-published pages, so it is left alone and documented at the site. textButtonfollows the inline-FORMULA path, not the inline-image path. The two disagree twice, and both choices matter. (1) The formula run delegate reports real ascent/descent (InstantPageTextItem.swift:854) so CoreText grows the line box; the image one reports0/0(:818) and then owns manual centring with symmetric bleed. A pill is taller than its glyphs, so the image model would overlap the lines above and below. (2) Formulas are emitted as top-level items intoadditionalItems; images are created at view-update time into a sibling container above the reveal mask. A button must be a view (own press state, own tap target), which the formula path already gives.- The attachment must carry its own measurements. The V2 line-breaker raises
lineAscent/lineDescentfrom the attachment itself and has nostyleStackto re-measure with — henceInstantPageInlineButtonAttachmentholdssize/ascent/descent, mirroringInstantPageMathAttachment.rendered. instantPageInlineButtonAttachment(button:labelString:)is the ONLY construction path. Inline and row pills must agree on whethersizeincludes padding, because the pill view's label centring reads exactlysize.width - 2 · hPad. Two independent constructions drifted apart once already.- The pill recolours its label in the view, not at construction.
attributedStringForRichTextbakes the paragraph colour and has noInstantPageTheme, soinstantPageButtonColors(...).labelcan only be applied where the theme exists — the view. Skipping this silently rendersdanger/successlabels in body-text colour and never dims disabled ones. clipsToBoundsis what makes the pill a pill. The view implementsdraw(_:), so UIKit paintsbackgroundColorinto the layer'scontentsbitmap;cornerRadiusrounds the layer's own background but does not clipcontentswithoutmasksToBounds. Without the clip the rounded corners are drawn and then covered by the square bitmap, and the pill renders as a rect.- Line-height inflation: never compare
attachment.ascentagainstlineAscentdirectly.lineAscentstarts atfontLineHeight, which is the reducedfloor(ascender + descender)box (~12.4pt at 17pt —descenderis negative), whereasattachment.ascentcomes fromCTLineGetTypographicBoundsand is a full font ascent (~16.3pt for a 15pt label plus padding). Comparing them grew every button-bearing line by ~4pt. The button loop therefore discountslineBoxTopInset(the ascender headroom the line stack is already shifted down by) andbaselineToNextTopSlack(inter-line spacing the next line does not need) — the same reserves inline formulas bleed into — and only genuine overflow grows the line. - An overflowing pill needs an arm in the re-break block. A pill's entire width lives in a
CTRunDelegateon one placeholder character, soCTTypesetterSuggestLineBreakcan suggest a break after it. The layout recovers by discarding a line wider than the bound and re-breaking before the attachment — but only for attachment kinds listed there. Images, formulas and buttons each have an arm; a new inline attachment kind needs one too or it will silently spill past the bubble. - A pill wider than the whole line is truncated, because it cannot be re-broken. That recovery path
is guarded by
lineCharacterCount > 1, so a pill alone on a line is left alone. HenceinstantPageInlineButtonAttachment(maxWidth:)truncates the label with a tail ellipsis on cluster boundaries (CTTypesetterSuggestClusterBreak), the ellipsis inheriting the label's own attributes. - The cap arrives via
inlineButtonMaxWidth, deliberately NOTboundingWidth. Three traps here: (1)boundingWidthisnilon the V2 paragraph path — only table cells pass it — so a cap routed through it silently never applies;layoutParagraphpasses the width explicitly. (2) The 31 recursiveattributedStringForRichTextcalls must forward the cap, or a button nested in a.concat(the normal case) loses it. (3)boundingWidthalso drives the inline-image clamp (fittedToWidthOrSmaller), which has never been active on this path, so reusing it would silently resize existing inline images. - KNOWN BUG, pre-existing and unfixed: recursion never forwards
boundingWidth. A nested inline image therefore never gets itsfittedToWidthOrSmallerclamp — only a top-level one, and only on paths that pass the width at all (table cells). Independent of the button work. checkboxFill/checkboxForegroundare misnamed for their current use. They are the.primarybutton's solid fill and label colour; nothing checkbox-related reads them.InstantPageListItemcheckboxes still derive their own colours. Rename or wire them up before relying on the names.- A button-label custom emoji uses a DIFFERENT run delegate from a body-text one, rewritten by
instantPageButtonLabelWithFittedEmojiinsideinstantPageInlineButtonAttachment— the single construction path — and therefore before truncation and before measurement, so the reserved advance and the drawn square are the same number by construction. Two of the body-text delegate's three numbers are wrong in a pill. Its width isA − D + 4·pointSize/17, which overflows the pill's ink box (≈21.3 vs ≈19.7pt at the 15pt inline label font) and gets shaved byclipsToBounds; a button-label emoji is sized toA − Dinstead, so it reads slightly smaller than the same emoji in the surrounding paragraph. Its descent isfont.descender, which is NEGATIVE — inert in body text, because the V2 line layout pinslineDescenttofontDescentBelowBaselineand never reads it, but a pill measures itself withCTLineGetTypographicBounds, so for a label that is only an emoji (no other run contributing a positive descent) the line's descent comes back negative and the pill collapses from ≈19.7pt to ≈12.9pt. The button-label delegate negates it. instantPageButtonLabelOriginis shared by the drawing and the emoji placement, deliberately. It is the math that used to be inlined inInstantPageV2ButtonPillContentView.draw(_:),- 0.33optical nudge included. Recomputing it at the emoji site instead is the same drift hazard the "single construction path" rule already exists for.- The placement functions must not read a pill's live
bounds.updateInlineEmoji()runs duringupdate(layout:theme:animation:), before a freshly createdInstantPageV2InlineButtonViewhas runlayoutSubviews— so its pill's frame is still zero. Both functions takepillSizeexplicitly, and the renderer sources it from the item (item.frame.size,item.buttons[i].frame.size). - Pill emoji live in the page's central
inlineStickerItemLayersregistry, not in the pill. That is what makes them inherit the reveal, energy-setting and visibility-rect gates rather than needing a parallel implementation of all three.InstantPageEmojiLayerData.textViewis therefore generalised tohostView: UIView?:updateEmojiRevealdowncasts it, so a pill host lands in the existingelse→revealed = true, which is correct because a pill already pops in atomically when its paragraph finishes revealing. The button views are deliberately NOT given arenderContext— the renderer creates the layers and already holds one. emojiContainerViewsits ABOVE the pill'scontentView, and that ordering is load-bearing twice over. The label is painted into the content view's layercontentsbitmap, so a sublayer must be above it to be seen at all; and the loading shimmer is deliberately inserted BELOWcontentView, so an emoji placed there would be washed by the sweep instead of riding on top of it.- An emoji layer's
dynamicColoris the pill'sresolvedLabelColor, not the label string's baked colour — the same reason the label itself is recoloured in the view rather than at construction. Sourcing it fromattachment.labelStringwould tint a template emoji in adangeror disabled pill with body-text colour. - Not covered: spoilers inside a button label. A pill renders no spoiler treatment at all, so an
emoji under a
.textSpoilerin a label shows unhidden — exactly as the label text around it already does. richButtonStyle'slink:flags.3is stored as its ownInstantPageButton.isLink, not as a fourthReplyMarkupButton.Style.Colorcase. That enum is shared with bot reply markups, whosekeyboardButtonStylehas no link bit, and it isInt32-raw-valued and persisted by both features. Keeping the facts independent also meanslink+bg_dangerround-trips losslessly even though the renderer makeslinkwin. No TL regeneration was needed:Api.RichButtonStyleis already a bareflags: Int32and its constructor id63312061is the same0x3C610BD.apiFlagsAndStyle()accumulates the flag word and THEN checks it. Its previous shape opened withguard let color = self.color else { return (0, nil) }, which would drop the style object entirely for a link-only button and loseflags.3on the way out.apiRichStyleFlagWordis public so that composition is testable.linkwins over the background bits, and the link uses the page's ordinary link colour. Same first-match precedence asbg_primary > bg_danger > bg_successandalign_left > center > right, so a malformed style is deterministic rather than evaluation-order dependent.- A link-styled button is only honoured on an inline
RichText.textButton. ApageBlockButtonRowentry stores and round-tripsisLinkbut never reads it — rows always draw pills. - The link route produces no attachment, so it is invisible to every piece of attachment
machinery: the line-breaker's re-break arm, the
lineAscentdiscount,additionalItems, and the pill view. Two side effects follow: a link button's label is selectable and copyable (a pill's is not, being outside the text), and it reveals character-by-character with its paragraph instead of popping in atomically. .link(false)is pushed for every non-.disabledaction, not only.url. The action decides which tap attribute is attached, not whether the label looks like a link — a link-styled.callbackis visually indistinguishable from a link-styled.url. Note also that the link route deliberately does NOT push.fontSize/.medium: a pill owns its typography, a link inherits the paragraph's.- An emoji-only link-button label gets NO underline. The underline on a link button is never
markup:
InstantPageTextStyleStack.textAttributes()adds it when the link colour equals the surrounding text colour, which is the normal state in the chat-bubble themes and in the caption/credit categories (seesetupStyleStack). Under a custom emoji it draws as a stray rule — wider than the glyph and detached from it — and there is no word for it to distinguish, soattributedStringForLinkStyleButtonstripsunderlineStylewhenrichTextIsOnlyCustomEmoji(button.text). That helper's switch is exhaustive on purpose (a newRichTextcase must decide) and answersfalsefor.underline, which is how an explicitly underlined label inside the button keeps its underline. Whitespace and non-underlining wrappers (.bold,.url,.textSpoiler, …) still count as emoji-only; one word anywhere in the label brings the underline back. - Only
.urlridesTelegramTextAttributes.URL. That is what buys the long-press menu, the concealed-URL confirmation, anchor scrolling and the link-progress shimmer for free..urlAuthand.openWebViewcarry URLs but take the button-dispatch route instead, because their dispatch differs from opening a plain URL..disabledrenders as ordinary text with no link styling and no attribute at all — a link-coloured span that does nothing is worse than plain text. InstantPageButtonActionAttributemust appear inlinkSelectionRects'sinteractiveKeysallow-list. Omitting it costs the tap highlight and the loading shimmer, silently, because the bubble'slinkProgressRectsis computed from exactly those rects..customtap actions do not receivetapAction.activate.ChatMessageBubbleItemNode's.customarm calls the closure and ignores the field, so the link-button arm mints its own promise by callingmakeActivate(...)()inside the closure — that call is what wires the shimmer. Any future.customarm wanting progress must do the same.- Interactive V2 items route taps through a pageView closure, NOT
tapActionAtPoint.buttonTappedonInstantPageV2Viewmirrors the pre-existingcheckboxTapped(InstantPageRenderer.swift:178)..custom+rectsonChatMessageBubbleContentTapActionis for text-attribute taps (entities, spoilers, "Show more"), not item views. - The
reusearm must re-wireonButtonTapped. A recycled view may have been created against a previousInstantPageV2View; without re-wiring, taps silently stop working after scrolling away and back. performMessageButtonActionlives onChatMessageItemView, so a content node cannot call it. The rich bubble synthesises aReplyMarkupButtonand callsChatMessageBubbleContentNode.performRichTextButtonAction, a closure wired byChatMessageBubbleItemNode(~:5060) alongsiderequestInlineUpdate/requestFullUpdate.InstantPageTheme.withUpdatedFontStyles(:173) reconstructs the struct field by field. Any field omitted there silently reverts to itsinitdefault — for the button danger/success colours that resets a bubble's theme-derived colours the moment the user changes Instant View font size. Nothing warns; it compiles.- Mid-paragraph buttons pop in when their whole paragraph finishes revealing, not when the cursor
reaches them, because an inline attachment lands in
additionalItemsafter the text item it sits inside and the cost map walks items in array order. Formulas already behave this way; fixing it means interleaving sub-item cost entries inside a text entry, i.e. changing the cost model. pageBlockDocumentis produced by the RichText article editor (attach a file →MediaKind.document→InstantPageBlock.document), so the renderer is exercised by real content. Download and cancel work through the fetch manager (messageMediaFileStatuskeys progress off itshasEntry, sofreeMediaFileInteractiveFetchedwould show no ring). Runtime-verified 2026-07-31 (thumbnails, download/cancel, tap-to-open) — this block had never been on screen before.- Tapping a downloaded file opens it through the stock pipeline. The row's
.Localtap travelsInstantPageV2DocumentContentNode.openDocument→InstantPageV2DocumentView.onDocumentTapped→InstantPageV2View.documentTapped→ChatMessageBubbleContentNode.openRichTextDocument→ChatMessageBubbleItemNode→controllerInteraction.openMessage(…, mediaSubject: .richTextMedia(file.fileId)), which reachesBrowserScreenfor pdf/markdown,presentDocumentPreviewControllerotherwise, the SVG warning andcanShare. Naming the medium is load-bearing: a rich message's files live in theRichTextMessageAttribute'sInstantPage, notmessage.media, somediaForMessage's default first-match resolution overeffectiveMediacould open a DIFFERENT attachment — which is exactly why this was previously left inert.mediaForMessagereturns[]on a named-but-absent medium: opening nothing beats opening the wrong file. documentTappedmust be wired in BOTH renderer arms (create and reuse), likebuttonTapped. A recycled view may have been created against a previousInstantPageV2View; without re-wiring, taps silently stop working after scrolling away and back.- The row has two modes.
isAuthoring(the editor, viaStandaloneInstantPageDocumentView) shows the thumbnail (or a static file glyph when there is none), never fetches, and its tap is inert — a just-picked file is already local and an edit-loaded cloud file is re-sent by reference. Message mode keeps download / progress / cancel plus the open affordance. Built withmessage: niland NO authoring flag,fetchStatusstays nil andupdateFetchStatemaps that to.download— a download arrow over a file the user just chose. - LOAD-BEARING —
tapped()andupdateFetchState()must partitionfetchStatusIDENTICALLY, or the control lies about what tapping it does. In particular.nonemeans "status not known YET" (themessageMediaFileStatussubscription is async, so this is the window right after a bubble appears), not "downloaded": it renders as a download arrow, so it must FETCH. Mapping.nonealongside.Localto open — the shape the original inertbreakinvited — opened undownloaded files instead of fetching them. Partition:.Local→ open;.Fetching→ cancel;.Remote/.Paused/.none→ fetch. - The thumbnail is a sibling node, which changes which foreground colour the control uses. A file with a
preview (
previewRepresentations— the picker populates them forimage/*andapplication/pdfinPollAttachmentScreen;immediateThumbnailDataor animage/*mime also qualify) renders aTransformImageNodefed bychatMessageImageFile(…, thumbnail: true), in the SAME Ø40 slot as the status disc, with the disc scrimmed over it (mediaOverlayControlColors) and hidden entirely when idle (authoring, or.Local). Do NOT passforegroundNodeColor: .clearfor the overlay look:SemanticStatusNodeAppearanceContext.effectiveForegroundColorprefersoverlayForegroundNodeColoronly when the status node owns abackgroundImage, and ours is nil — so a clear foreground renders the download arrow and the progress ring invisible over artwork. Pass the overlay colour as the foreground. - The thumbnail deliberately does NOT change the row height. Telegram's own file bubbles grow to 59–74pt
for artwork, but
MediaBlockBoxsizes the editor's row fromkindalone — it is account-free and cannot resolve the file — so a thumbnail-dependent height would desync the editor preview from the V2 renderer. Any height change must be uniform across all document rows and applied todocumentRowHeightandInstantPageV2Layout'sdocumentFrametogether. InstantPageDocumentColorOverrideis required outside a bubble. The row's title/description colours come fromtheme.chat.message.incoming/outgoing; without the override an editor-hosted row renders in outgoing-bubble colours. Twin ofInstantPageAudioColorOverride.- KNOWN:
fetch/cancelFetchand the status subscription all guard onmessage?.id, so on a message with no id (a pending/unsent one) the row shows a download arrow whose tap quietly does nothing. Routing through a.standalone(media:)reference when there is no message id is the fix if it matters. .buttonRowstill sits inInstantPageAnchorPath'sdefault:— Stage 1's reason ("V2 does not lay it out") has expired, but the outcome is unchanged for a new one: a button's label renders inside its own view, so it is not a page-text anchor scroll target.- The loading shimmer needs the label in a child view. A
UIView's owndraw(_:)output lands in its layer'scontents, and sublayers always composite above that — so aTextLoadingEffectViewadded to a pill that draws its own label would wash over the text. HenceInstantPageV2ButtonPillContentView: the pill keeps the fill and the touch handling, the child draws, and the shimmer is inserted between them.ChatMessageActionButtonsNodegets this for free by inserting below its title node. - The shimmer is tinted with the pill's label colour, not the reference's white. A chat action
button sits on a translucent dark blur; a neutral V2 pill's fill is
0xf3f4f5in the light theme, where a white sweep is invisible.instantPageButtonColorsalready resolves a label colour that contrasts with the fill in all four IV themes. InstantPageV2ButtonRowView.rebuild()must reuse pills positionally. A pill now holds an in-flight loading effect, and a.callbacktap updates the message — which relayouts the bubble and callsrebuild(). Recreating the pills there wipes the shimmer the tap just started. Same reuse policy, and same swap-on-edit consequence, asChatMessageActionButtonsNode.asyncLayout.- Only
.url,.openWebApp,.openWebViewand.callbackever shimmer. Those are the arms ofperformMessageButtonActionthat take theprogresspromise;.switchInline,.copyText,.openUserProfile,.paymentand.urlAuthleave it unfulfilled, so nothing appears. This needs no special-casing — reply-markup buttons behave identically. - A supplied promise suppresses the
.requestInProgresstitle panel. Both web-app entry points used to raise a panel across the top of the chat:openWebAppImpl(ChatControllerOpenWebApp.swift) for.openWebView, andrequestMessageActionCallback(ChatController.swift) for.openWebAppand.callback. Each now raises it only whenprogress == nil, so a surface that shows the loading state on the button itself does not also get a panel. The surfaces that pass nil — the reply keyboard (ChatButtonKeyboardInputNode), the game bubble, and the menu / inline-bot panels — keep the panel as their only indicator. Threading a promise into a new caller therefore silently removes its panel; that is the intent, but only if that caller actually renders the promise. - A button inside
<details>or a table cell is inert, and so never shimmers. The nestedInstantPageV2Views built inInstantPageRenderer.swiftnever getbuttonTappedpropagated — onlyrootMediaRegistryHostis pushed down, bypropagateRegistryHost. Pre-existing; unrelated to the loading effect, but it is why a nested button does nothing at all.
Unsupported blocks (InstantPageBlock.unsupported)
Every block this build cannot decode arrives as InstantPageBlock.unsupported — it is the
default: arm of the API-block conversion in SyncCore_InstantPage.swift, so a page authored
against a newer server is a page full of them. V2 renders each as the shared "please update" pill;
V1 Instant View still skips the block.
The pill is the same component the chat's standalone unsupported-media bubble draws:
submodules/TelegramUI/Components/UnsupportedContentPill. Its constants are a literal port of that
bubble's geometry and are load-bearing for its appearance — changing one changes every unsupported
message in the app.
Where things live
| File | Responsibility |
|---|---|
submodules/TelegramUI/Components/UnsupportedContentPill/Sources/UnsupportedContentPill.swift |
UnsupportedContentPillStrings / Colors / Layout, and the single measureUnsupportedContentPill(...) both the pure layout pass and the view run. |
.../UnsupportedContentPillView.swift |
The view: badge, text column, action button, and either a .free wallpaper bubble background or the static colors.fill. |
submodules/InstantPageUI/Sources/InstantPageV2UnsupportedItem.swift |
InstantPageV2UnsupportedItem, redundantUnsupportedBlockIndices(_:), layoutUnsupportedBlock(...), unsupportedContentTearZones(in:), unsupportedActionFrame(in:containing:) (tap arbitration). |
submodules/InstantPageUI/Sources/InstantPageV2UnsupportedView.swift |
InstantPageV2UnsupportedView — the V2 item view wrapping the pill. |
submodules/InstantPageUI/Sources/InstantPageTheme.swift |
unsupportedPillFillColor / unsupportedPillPrimaryColor and the derived unsupportedPillColors. |
Non-obvious invariants
- A
.collagecarrying an.unsupporteditem is unsupported as a whole (2026-08-18). The mosaic reserves a slot for every inner block and emits a cell only for the ones it can resolve, so an undecodable item leaves a hole and pushes the surrounding tiles into the wrong geometry — visibly wrong rather than visibly missing. One pill replaces the entire block, caption included. The rule lives inblockRendersAsUnsupported(_:), which is what every reader asking "is this block unsupported" must call instead of matchingcase .unsupported: the.collagearm oflayoutBlock, the run collapsing below, andInstantPageBlock.spacing(metrics:)(where the guard sits ahead of the switch, or the media arm would hand the pill a full-bleed block's flush-both-sides rhythm). Deliberately not extended to.slideshow, whose failure is different — it drops an undecodable item silently, one page fewer, and still renders the rest — and only one level deep, since a collage's items are flat media blocks. V1 is unchanged. - A maximal run of adjacent
.unsupportedblocks renders as ONE pill.layoutBlockSequenceconsultsredundantUnsupportedBlockIndicesin every sequence, nested ones included — and a collage-turned-pill counts as part of a run, so it never stacks a second pill on a neighbour's. It reports indices to skip rather than returning a filtered array, because the loop index becomespathPrefix + [i]— the structural path checkbox toggling and anchors address blocks by. Filtering would renumber every block after a collapsed run and silently toggle the wrong checkbox. - The layout value carries geometry only.
TextNode.asyncLayout(nil)'s apply creates a new node per call and the chat bubble re-lays out on every apply, so the view instead owns its three text nodes and re-derives them through the same measure function.constrainedWidthtravels on the layout so the view can reproduce the host's measure pass exactly. - Colours travel on
InstantPageThemeand MUST be listed inwithUpdatedFontStyles. That method reconstructs the theme field by field; an omitted field silently reverts to its init default the first time the reader changes Instant View font size. Nothing warns; it compiles. - The wallpaper travels as
InstantPageV2RenderContext.wallpaperBackgroundNode, a closure. A closure so a message-scoped context does not retain the chat's background node. No absolute-rect plumbing exists or is needed:WallpaperBubbleBackgroundNodeis a portal view that mirrors its source, so setting the frame is the whole contract. - The Update button is always rendered.
InstantPageV2View.unsupportedActionTappedis nil in the send preview, the text-processing screen and the formula editor, where the tap is inert — the alternative (hiding it) would change the pill's width between preview and sent message. - The Update button only works if the host steps out of the way — twice. The button is a real
UIButtoninside the page view, but a chat bubble arbitrates every touch over its content: unlesstapActionAtPointreturns.ignorefor the button's rect, the bubble'sTapLongTapOrDoubleTapGestureRecognizerclaims the touch and cancels the button's tracking, so it highlights and then does nothing.ChatMessageRichDataBubbleContentNoderesolves that rect first, before the collapsible-quote toggle, throughInstantPageV2View.unsupportedActionFrame(at:)→unsupportedActionFrame(in:containing:), which works off the layout (mid-touch there is no useful way to ask a pill view) and, unlikeunsupportedContentTearZones, does recurse intodetailsbodies and table cells.ChatMessageUnsupportedBubbleContentNodeanswers the same question for the standalone bubble via the pill view'sactionContains. The recognizer has its own escape hatch — it fails when the hit-test result is aUIButton— and the second half is what lets that fire: the button's label is aTextNode, whose view is interactive by default and would otherwise be the deepest hit, soUnsupportedContentPillViewsetsisUserInteractionEnabled = falseon it. Only the button's rect is claimed; the rest of the card stays ordinary bubble content, so the message's own tap and long-press survive. (Fixed 2026-08-18; the rich bubble had neither half and its button was dead.) UnsupportedContentPillLayout.actionFrame(in:)is the one source for where the button lands. The view positions its button there and layout-only hosts test against it;in size:rather than the layout's ownsizebecause a host may stretch the pill wider and the button is pinned to the trailing edge.- A pill nested in a
<details>body or a table cell still has an inert button. Tap arbitration finds it, but nestedInstantPageV2Views only getdetailsTappedforwarded —unsupportedActionTapped(likecheckboxTapped,buttonTappedanddocumentTapped) is not propagated into sub-layout views, so the closure is nil there. Pre-existing, and not specific to the pill. - Reveal cost is
.nonText. The pill pops in atomically with its position in the stream, like a button row.
Tearing the bubble across a pill
In a chat bubble the pill does not sit on the bubble — the bubble background is torn across it: a full-width band is cut out, so the pill's content floats over the chat wallpaper.
The band travels as geometry, not as a flag. unsupportedContentTearZones(in:) reads the
top-level .unsupportedContent items of a laid-out page and pads each by
instantPageUnsupportedTearPadding (6pt). ChatMessageRichDataBubbleContentNode maps them into its
own space through ChatMessageBubbleContentNode.unsupportedContentAreas(), and
ChatMessageBubbleItemNode collects them during its content-node layout loop, resolves them once
with resolveBubbleTearZones, and hands the same bands to both ChatMessageBackground and
ChatMessageBubbleBackdrop.
Load-bearing details:
- Top level only, and the flag is what enforces it — not the non-recursive walk. A blockquote
(and a list) lays its children out with
layoutBlockand appends the resulting items straight into its PARENT's array, offset, so a quoted pill sits inlayout.itemslooking exactly like a top-level one.InstantPageV2UnsupportedItem.isTopLevelis recorded during layout — the last point that still knows the difference — aspathPrefix.count == 1. Depth, notkind: a blockquote lays its children out with the enclosing sequence's kind, sokindcannot tell. Not recursing merely keeps the walk cheap; it excludes only the containers that build a sub-layout of their own (details, table cells). - The mask is subtractive via
CALayer.luminanceToAlpha(): a white surface with black bands. The backdrop's existing mask is the black-filledbubbleMaskForTypeimage, which works only because aCALayermask reads alpha and ignores colour — so it cannot simply be filtered, which would map it to alpha 0 and erase the whole backdrop.BubbleBackdropMaskViewre-renders it as a white.alwaysTemplateimage when torn (alpha and stretch caps both survive template rendering) and drops back to the plain image when not, so the filter is never installed on the bubbles that are never torn. luminanceToAlphais a privateCAFilterand can be nil. Then the bubble renders untorn — never masked by a surface that would hide it.- A residual run of bubble ≤ 8pt at the top or bottom is absorbed into the band, so a message
whose only content is an unsupported block has no bubble at all. The band merge runs twice in
resolveBubbleTearZonesbecause absorption can pull two bands to the same edge and make them touch when they did not before. ChatMessageBackground.updateTearMasktakes the size as a parameter rather than readingself.bounds: the layout passes set the node's own frame after calling it.ChatMessageShadowNodeandbackgroundHighlightNodeare deliberately NOT torn. The shadow is only drawn in the context-menu preview; the highlight is the ~0.3s jump-to-message flash and does paint over the gaps briefly.ChatMessageBubbleBackdrop.maskViewstays public and is now aBubbleBackdropMaskView.ChatMessageInstantVideoBubbleContentNodesetsoverrideMaskand hangs its own roundBubbleMaskLayeron that view's layer; that still works, because the extra layer lands above the now-empty shape image and an instant-video bubble is never torn.- Moving the silhouette into a child made the backdrop stop animating, and nothing said so.
BubbleBackdropMaskViewholds the stretchablebubbleMaskForTypeimage in ashapeViewchild, where the backdrop used to be that image view. Animating aUIImageView's bounds stretches a 9-slice image on the curve; animating a container that re-seats its child inlayoutSubviewsdoes not —layoutSubviewsruns at the next commit with the model (destination) bounds, so the silhouette jumped while the mask's own layer animated underneath it. The bubble body kept animating (ChatMessageBackgroundstill holds its image directly), so only the wallpaper backdrop looked wrong: it snapped outright when shrinking, and grew with square-cut edges and a popping tail when growing.BubbleBackdropMaskView.updateFrame(_:animator:)and its two transition overloads move both boxes together, and all threeChatMessageBubbleBackdrop.updateFrameoverloads go through them.ChatMessageBackdropFrameTestsis the guard.layoutSubviewsstays as the backstop for the unanimated paths (mask creation insetType, the node's ownframedidSet). - Never ask
CALayer.framewhether a frame changed. It is DERIVED —origin.y == position.y - bounds.height * anchor.y— and that round-trip is lossy for an origin that is not representable. The chat's bubble inset is 7/3:2.3333333333333335goes in,2.333333333333332comes back out, so an exactequalToanswers "changed" forever (ChatMessageBackdropFrameRoundTripTestspins the numbers, measured on device 2026-09-15).ChatMessageBubbleItemNode's.Systembranch was gated on!backgroundNode.frame.equalTo(backgroundFrame)and therefore re-ran on every pass, including the many that re-apply an unchanged layout — and becauseContainedViewLayoutTransition.updateFrame(layer:)carries the same derived-frame guard, the background and backdrop layers re-targeted each time, restarting a fresh full-duration animation from the layer's PRESENTATION value. The mask, its silhouette and the wallpaper portal are framed at(-1,-1,w+2,h+2)/(0,0,w,h), integral origins that round-trip exactly, so their guard did fire and they stayed on the first timeline: two halves of one bubble on two clocks, which reads as the backdrop lagging its own outline. The gate now comparespreviousBackgroundFrame, the value the previous pass computed, which never goes through a layer. This was invisible until the silhouette animated — before that it snapped, so there was no second clock to disagree with. The hazard is general: anyupdateFrame(layer:)caller whose rect has a non-representable origin re-targets on every repeat call. - With a patterned or gradient wallpaper the pill's own portal background and the torn gap are the same pixels, so the pill's card vanishes and only its badge and text read. That is the intended effect and is why the band is full-width. With a plain-colour theme the pill keeps its faint service fill and still reads as a card.
InstantPage thinking blocks (InstantPageBlock.thinking)
InstantPageBlock.thinking(RichText) renders server-sent reasoning as dimmed, continuously-shimmering text inside rich-data bubbles. V2 renderer only; V1 ignores the block (returns []). The shimmer and fade-in mechanics are deliberately separate from the char-reveal cursor so thinking blocks do not affect the reveal pacing of the answer content that follows them.
Where things live
| File | Responsibility |
|---|---|
submodules/InstantPageUI/Sources/InstantPageV2Layout.swift |
InstantPageV2ThinkingItem layout item + layoutThinking(...) (paragraph color × 0.55 alpha for the dimmed style) + layoutBlock .thinking arm. |
submodules/InstantPageUI/Sources/InstantPageRenderer.swift |
InstantPageV2ThinkingView — a ShimmeringMaskView wrapping a private inner InstantPageV2TextView; InstantPageV2StableItemId.thinking(Int) stable-id namespace; makeItemView/reuse/stableId arms for the .thinking item kind; the two-counter (content + thinking) stable-id loop in InstantPageV2View.update. |
submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift |
.thinking(start:) cost entry: contributes zero cursor cost; triggers whole-block alpha fade-in when revealedCount >= start. |
submodules/InstantPageUI/Sources/InstantPageLayout.swift |
V1 has no explicit .thinking case — it falls through layoutInstantPageBlock's default: to an empty layout (no-op). |
Non-obvious invariants
- Zero reveal cost is the linchpin. Thinking blocks do not advance the width-based cursor, so the answer's reveal position is identical whether or not thinking blocks are present — and is unaffected as they appear and disappear across streaming chunks. The answer text always reveals at the same rate regardless of how much thinking precedes it.
- Whole-block fade, not char reveal. The inner text is drawn fully under the shimmer mask at all times; the reveal mechanism is a simple alpha visibility keyed to the block's
startindex. A top-of-page thinking block (start == 0) is visible from the very first frame. - Shimmer runs continuously while the view is displayed via
ShimmeringMaskView'sHierarchyTrackingLayerself-animation. It does not stop when streaming ends. - Top-level only; separate stable-id namespace. Thinking blocks appear only at the top level of the page. They use the
InstantPageV2StableItemId.thinking(Int)namespace, numbered by a counter independent of content blocks. This means adding or removing a thinking block never renumbers the stable ids of content blocks — which, combined with pageView reuse, ensures content views and reveal state persist as thinking blocks come and go across chunks. - V1 is a no-op.
InstantPageLayout.swifthas no.thinkingcase; the block falls throughlayoutInstantPageBlock'sdefault:to an empty layout, so V1 rendering silently skips it.
Anchor navigation in rich bubbles (intra-message #anchor links)
Tapping a fragment-only link ([Jump](#section)) inside a rich-data bubble scrolls the chat so the matching in-message anchor lands ~8pt below the content-area top, expanding any enclosing collapsed <details> first. Anchors come from server/AI-sent InstantPages only — block-level InstantPageBlock.anchor(name) or inline RichText.anchor over a heading/paragraph; the markdown compose path deliberately skips generating heading-slug anchors for chat (markdownBlocksWithGeneratedAnchors runs only for documents), so user-typed messages have no anchors. The whole downstream scroll chain (ChatControllerInteraction.scrollToMessageIdWithAnchor → ChatMessageBubbleItemNode.getAnchorRect → historyNode.scrollToMessage(.bottom(anchorY))) pre-existed; this feature fills the two bubble-side seams that were stubbed.
Where things live
| File | Responsibility |
|---|---|
submodules/InstantPageUI/Sources/InstantPageRenderer.swift |
InstantPageV2View.anchorFrame(name:) (live-layout frame walk, mirrors findTextItem; handles .text/.codeBlock/.thinking/.details/.table) + firstCollapsedDetails(forOrdinalPath:) (maps an ordinal path to the first not-yet-expanded <details>'s live index). |
submodules/InstantPageUI/Sources/InstantPageAnchorPath.swift |
NEW. Pure instantPageAnchorPath(in:name:) model walk → the <details>-sibling-ordinal path to an anchor (nil = absent, [] = outside any details, [2,0] = inside the 3rd top-level details then its 1st nested details) + richTextContainsAnchor. |
…/Chat/ChatMessageRichDataBubbleContentNode/… |
getAnchorRect (delegates to anchorFrame, +8pt top margin); the tapActionAtPoint fragment route + streaming gate; the scrollToAnchor resolve→expand→scroll state machine (pendingScrollAnchor + progress guard); the post-relayout hook. |
Non-obvious invariants
- The ordinal path is mapped to live indices, never reproduced. The layout's
detailsIndexCounter(InstantPageV2Layout.swift) is expansion-dependent — a<details>nested inside a collapsed parent has no index until the parent expands and re-lays-out (a collapsed details hasinnerLayout == nil; its children aren't laid out). SoinstantPageAnchorPathreturns ordinals, andfirstCollapsedDetailsreads the real index from the live laid-out.detailsitem. Expansion is iterative: expand one collapsed level →requestMessageUpdate→ the post-relayout hook re-runsscrollToAnchor→ repeat until the anchor resolves viaanchorFrame. - The model walk's recursion set MUST equal the containers the V2 layout recurses through
layoutBlock(and thus counts<details>in viadetailsIndexCounter): exactly.blockQuote,.cover, and.list's.blocksitems — all of which the layout flattens into the parentitemsarray (onlylayoutDetailsnests a separateinnerLayout, which is the level boundary).instantPageAnchorPathrecurses those three sharing theinout detailsOrdinal, and treats.detailsas a new level. It deliberately does NOT recurse.postEmbed/.collage/.slideshow— the V2 layout lays out only their media/caption (never their child blocks), so it never counts a<details>inside them; recursing them would desync the model walk's ordinals from the layout. An anchor inside such a non-laid-out child is unresolvable byanchorFrameanyway, so skipping it is a no-op either way. anchorFrameand the model walk are only ever both consulted whenanchorFramefails.scrollToAnchorfirst triesanchorFrame(covers everything currently laid out — top level, expanded details, tables, thinking blocks); only on a miss does it consultinstantPageAnchorPath. So the only consequential model-walk output is a non-empty path (anchor buried in a collapsed details);nil/[]both no-op.getAnchorRectstays a pure synchronous query. ChatController calls it insideforEachVisibleItemNode; all expansion is orchestrated byscrollToAnchor/pendingScrollAnchorbefore the scroll fires. The chat scroll consumes only the returned rect'sminY.- Anchor taps are rejected while the message streams (
TypingDraftMessageAttribute) →.none. SopendingScrollAnchoris only ever set post-stream, and the reveal cursor never interacts with anchor scrolling. - A fragment-only URL (
#…, empty base) is always intercepted — never opened as an external URL. If it resolves → scroll; if not (missing or empty anchor) → no-op (press-highlight only). A real URL carrying a fragment (https://x.com/p#s, non-empty base) keeps the unchanged external-URL handling. - The expansion loop terminates via a progress guard (
lastExpandedPendingDetailsIndex == collapsedIndex→ give up): each relayout pass either resolves+scrolls (clearing pending) or advances to a strictly deeper collapsed<details>. - No
activate:on the anchor tap action (unlike external-URL taps): anchor scrolling is local and instant, so the link-loading shimmer (makeActivate) would falsely imply network activity. The press-highlightrectsare still passed.
"Show more" for partial rich messages (on-demand full page)
A server-sent rich message can arrive partial when the content is long: the RichMessage isPartial flag maps to instantPage.isComplete == false. The bubble then renders the partial page plus an inline "Show more" link; tapping it fetches the full page (once) and expands the bubble in place.
Data model
RichTextMessageAttribute(SyncCore_RichTextMessageAttribute.swift) carries the partialinstantPageand an optionalfullInstantPage: InstantPage?(nil until fetched). The partial page is never replaced — the full page is stored alongside it (encoded/decoded; both in==).engine.messages.requestFullRichText(id:)(TelegramEngineMessages.swift) requestsmessages.getRichMessage, thentransaction.updateMessage(id,…)sets the existing attribute'sfullInstantPageto the fetched complete page (keepinginstantPage), and returns the updated attribute. It yields.single(nil)for non-Cloud ids and on network failure (no postbox change).- The seed-config merge (
SyncCore_StandaloneAccountTransaction.swift) preserves a previously-fetchedfullInstantPageif a later server update for the same message arrives without one (same partialinstantPage).
Where things live
| File | Responsibility |
|---|---|
…/TelegramCore/Sources/SyncCore/SyncCore_RichTextMessageAttribute.swift |
The fullInstantPage field (init / encode / decode / ==). |
…/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift |
requestFullRichText(id:) — fetch + updateMessage to fill fullInstantPage. |
…/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift |
Seed-config merge preserving a fetched fullInstantPage across later updates. |
…/Chat/ChatMessageRichDataBubbleContentNode/… |
The "Show more" link (layout, tap via tapActionAtPoint .custom + updateTouchesAtPoint highlight, TextLoadingEffectView shimmer), the node-local expand state, the effective-page selection, and the downward-expand. |
Telegram/Telegram-iOS/en.lproj/Localizable.strings |
Chat.RichText.ShowMore = "Show more" (→ strings.Chat_RichText_ShowMore). |
Non-obvious invariants
- Expand state is node-local and per-message, NOT derived from the attribute.
showMoreExpanded: (messageId, value)?is snapshotted at layout time and resolved against the currentitem.message.id, so every fresh display of a message starts collapsed (partial) even when its attribute already carries a cachedfullInstantPage; only an in-place tap expands, and that expansion survives same-message relayouts. Resolving against the message id makes any other message collapse automatically (no stale-snapshot bug, no manual reset). - The bubble renders
(showMoreExpanded ? attribute.fullInstantPage : nil) ?? attribute.instantPage— the full page only while expanded — in both the webpage build andlayoutInstantPageV2.scrollToAnchorresolves anchors against the same effective page. - The link shows only when
!showMoreExpandedAND!attribute.instantPage.isComplete(plus the original gates: not streaming viaTypingDraftMessageAttribute,id.namespace == .CloudsincerequestFullRichTextis a no-op otherwise, and not a preview /.messageOptionscontext). The date/status trails the link's line by substituting the link frame for the last-text-line frame (see the status-node section). showMoreExpandedis part of BOTH layout caches. It is in thecurrentPageLayoutcache key and thepageViewcontent key (pageViewMessageKey). This is required because the cached-expand path (full page already on the attribute) performs no postbox write, sostableVersiondoes not bump — without the key, the cached partial layout/content would shadow the expand.- Tap (
activateShowMore): iffullInstantPageis already cached → set expanded +requestMessageUpdateimmediately (no network, no shimmer); otherwise shimmer the link and fetch, expanding only once the full page lands. Guards against a second in-flight request and against re-expanding. - Expand grows the bubble downward in screen space (top fixed) via
info?.setInvertOffsetDirection()on theListViewItemApplyin the apply closure, fired only on theappliedShowMoreExpanded → showMoreExpandedtransition (never on first apply). Same mechanism asChatMessageInteractiveFileNode's audio-transcription expand and the text/fact-check bubbles; the ListView clamps it to what fits.
Rich-message media in the gallery / shared-media / preview pipelines (Message.effectiveMedia)
A rich message's media (images / videos / audio / documents) lives in attribute.instantPage.media, not in message.media (which is empty — rich messages are sent with text: "" and no media reference). To make that media participate in the same shared-media-index, gallery, file-list, playback, download, and save/copy pipelines that normal message.media flows through, there is one shared accessor and a set of opt-in call-site swaps.
The accessor
Message.effectiveMedia: [Media] (+ a delegating EngineMessage.effectiveMedia) in submodules/TelegramCore/Sources/Utils/MessageUtils.swift:
var effectiveMedia: [Media] {
if !self.media.isEmpty { return self.media } // normal message: identical to message.media
if let richText = self.richText { return richText.instantPage.allMedia() } // rich: the instant-page media
return self.media
}
Message.richText (same file) is already a typed RichTextMessageAttribute?; InstantPage.allMedia() (SyncCore_InstantPage.swift) recursively gathers media from the page's blocks (audio/collage/cover/details/image/list/slideshow/video) via its [MediaId: Media] dict. For a normal message effectiveMedia == message.media, so swapping a message.media read for message.effectiveMedia is behavior-preserving for non-rich content and only adds the rich media where the site should consider it. Scope is first-media for now (call sites keep their .first / iterate-and-break logic; the helper returns all media but callers stop at the first match — the //TODO:rewrite to take all media markers remain).
Where things live
| Layer | What |
|---|---|
| Discovery / index | tagsForStoreMessage (StoreMessage_Telegram.swift) indexes rich media into MessageTags (photo/video/gif/voice/file). This is the linchpin: it makes rich messages appear in every tag-queried surface (shared-media tabs, search, downloads) — which is exactly why each rendering-side site below then needs effectiveMedia, or it renders the surfaced message blank. |
| Extraction helper | Message.effectiveMedia (above). |
| Shared-media grids / rows | PeerInfoVisualMediaPaneNode, PeerInfoGifPaneNode, ListMessageItem (row-type selection) + ListMessageFileItemNode (file/music/voice row), ChatListSearchMediaNode (search media grid). |
| Gallery open + items | GalleryController (tagsForMessage + mediaForMessage — the duplicated message.media/message.richText blocks were collapsed into one effectiveMedia loop), GalleryData.chatMessageGalleryControllerData, SecretMediaPreviewController (its own local mediaForMessage), and the gallery item nodes ChatDocumentGalleryItem / ChatExternalFileGalleryItem / ChatAnimationGalleryItem (these re-derive from message.media in node(), so a rich doc/animation rendered blank without the swap) + UniversalVideoGalleryItem secondary affordances + ChatItemGalleryFooterContentNode. |
| Playback | PeerMessagesMediaPlaylist.extractFileMedia (the peer music/voice playlist), OverlayAudioPlayerControllerNode (audio context menu). |
| Resolution / downloads / cleanup | FetchedMediaResource.findMediaResourceById(message:), SyncCore_RecentDownloadItem, StoreDownloadedMedia, DeleteMessages.addMessageMediaResourceIdsToRemove(message:) (rich media was leaking on delete), CollectCacheUsageStats, ChatHistoryListNode (download manager), ChatListSearch{ListPaneNode,ContainerNode}. |
| Actions | ChatInterfaceStateContextMenus (Save-to-Camera-Roll, copy-image, save-audio/music-to-files, debug/premium), ChatControllerNode (post-suggestion media ref), ChatControllerLoadDisplayNode (edit send-validation), ShareController.saveToCameraRoll. |
Non-obvious invariants
- The tag-index change is what creates the work.
tagsForStoreMessagesurfacing rich messages into tag-queried lists, without the rendering-sideeffectiveMediaswaps, produces visible blank cells / blank rows / wrong row types. Index and render must move together. - The rich message's own in-chat bubble + in-bubble gallery do NOT read
message.media— a rich message renders viaChatMessageRichDataBubbleContentNode(InstantPage V2), in-bubble image/video tap opensInstantPageGalleryController(reads the instant page directly), and in-bubble audio usesInstantPageV2AudioContentNode. So the text-bubble / interactive-file / interactive-media nodes'message.mediareads are never reached by a rich message and are deliberately left alone. - Rich audio plays out of an
InstantPageMediaPlaylist, so the mini player bar needs its own tap arm (fixed 2026-08-17). The file lives in theRichTextMessageAttribute, so playback is keyed byInstantPageMediaPlaylistId.richMessage(messageId:)with anInstantPagePlaylistLocation— NEITHER of which is aPeerMessages*type.MediaPlaybackHeaderPanelComponent'stapActionmatched onlyPeerMessagesMediaPlaylistItemId+PeerMessagesPlaylistLocation, so tapping the bar for rich audio did nothing at all.InstantPagePlaylistLocationnow lives inAccountContext/Sources/MediaManager.swift(public, besidePeerMessagesPlaylistLocation) and carries the originatingmessageId—AccountContextis the one module both the panel andInstantPageUIalready depend on; routing the panel throughInstantPageUIinstead would drag the whole reader into the chat-list header. The panel's new arm opens the samemakeOverlayAudioPlayerControllerat that message, which works because a rich message IS.music-tagged (see thetagsForStoreMessagerow above) so the player's own music-tagged history list finds it.messageId == nil(the Instant View reader) stays inert, exactly as before. - Do NOT route the FORWARD path through
effectiveMedia(ChatControllerNodeforwardedMessages~556/560/568). TheRichTextMessageAttributealready travels with a forward, so the forwarded copy reconstructs from the attribute; injecting the instant-page media as top-levelmessage.mediathere would double-render (rich bubble + a separate media attachment). Thatmessage.mediaprocessing is caption-hiding / poll-stripping only, both irrelevant to rich — left asmessage.media. - Rich messages are edited as reconstructed MARKDOWN, not via the media-caption edit path. So
ChatControllerLoadDisplayNode's edit caption-max-length / original-media-reference reads (~1241/1775/4463) stay onmessage.media— they belong to the.mediaedit state a rich message never enters. (The send-validation.containsat ~2273 IS swapped, so an edit that leaves only media isn't wrongly rejected.) RichTextMessageAttribute.associatedMediaIdsstays[]— intentionally.MessageHistoryTableresolvesassociatedMediaIdsviagetMedia(id)in the postbox media table, but rich-message media is embedded inside the attribute blob, not the table — so returning the keys would be a no-op without also inserting the media into the table. The embedded-blob approach is self-contained.fullInstantPageis not indexed (the server doesn't index it either, and it's fetched on demand after store-time). The first media lives in the partialinstantPageanyway.- Only switch the loop SOURCE, never the per-type branches. Many swapped loops still contain
TelegramMediaPoll/TelegramMediaPaidContent/TelegramMediaWebpagebranches that rich messages never match — that's fine and intentional; only thefor … in <msg>.mediasource changes. - Build-only completeness gate. Every swap is type-identical (
[Media]→[Media]), so the only compile risk is a receiver that is neitherMessagenorEngineMessage; the full Bazel build is the gate (no per-module build / unit tests). Deferred, NOT done: chat-list/reply/pinned/notification/forward thumbnail previews and the "Photo"/"Video" media-kind labels (messageContentKind/ChatListItemStrings) — those are preview surfaces, not blank-cell breakage — and multi-media (first-media-only is the current scope).
The chat-message text categories, and editor layout parity (2026-08-14)
layoutInstantPageV2 takes its fonts from the caller, so there is no single V2 look — and the three
chat-side callers had each hand-copied their own InstantPageTextCategories table, which drifted: the
bubble carried heading lineSpacingFactor 1.0 / body 0.9 while the long-press send preview and the
TextProcessing screen carried 0.685 / 1.0, so the send preview did not match the bubble it was
previewing. All three now share InstantPageTextCategories.chatMessage(primaryText:secondaryText:)
(InstantPageChatMessageTheme.swift) with the bubble's values. That deliberately changed the preview and
TextProcessing; the bubble's values won because it is the surface the recipient sees.
The same table is what the rich-text editor lays text out with, so an author composing a rich message
sees the message. InstantPageTheme.richTextRenderMetrics(edgeSpacingReduction:)
(InstantPageRichTextMetricsAdapter.swift) projects any theme into the editor's RichTextRenderMetrics
contract, and chatMessageRenderMetrics() is the convenience both editor hosts call. The heading ladder
comes from headingTextAttributes(level:link:) rather than being restated, so H1–H6's derivation from the
subheader's authored 22 (and its response to the reader's font-size slider and the chat's Text Size) stays
shared. That ladder is 22 / 20 / 18 / 17 / 16 / 15 serif medium (retuned 2026-08-17); H1 and H2 carry
their own base sizes there rather than returning the header / subheader categories, which stay at 24 / 22
for the page title/subtitle and the pageBlockHeader / pageBlockSubheader blocks. The ladder scales by the
theme's stored fontSizeMultiplier (the product of every withUpdatedFontStyles applied), not by a ratio
recovered from the live subheader size: that size is already floored, and at the chat .large step the
recovered 24/22 put H2 on 21 where floor(20 × 19/17) is 22. codeBlock reports the metrics'
codeBlockFontSize (15 at page scale), the size layoutCodeBlock actually draws; the chat table now says 15
too, while the Instant View themes keep their 14.
InstantPageUI gained a direct dep on RichTextEditorUIKit, which it already had transitively via
ChatRichTextEditorComposer, so there is no cycle. The adapter has to live on this side: the composer
module cannot import InstantPageUI (that direction is the cycle), which is why the composer passes
RichTextRenderMetrics.default and a test pins that default equal to the adapted theme.
Two parity test suites live in //submodules/InstantPageUI:InstantPageUITests, both calling the
renderer's own functions so a change on either side that breaks parity fails here:
RichTextV2MetricsParityTests pins the editor's line formulas against layoutTextItem, its resolved font
faces against InstantPageTextStyleStack's family scheme, and the whole pairwise gap table against
spacingBetweenBlocks; RichTextV2FrameParityTests pins that the editor composes those primitives into
the same running origin. Do not change a value in chatMessage without expecting the editor to move
with it — that coupling is the point. Editor-side detail, and what is deferred, is in
submodules/TelegramUI/Components/RichTextEditor/CLAUDE.md.
Compact InstantPage previews
TelegramStringFormatting/InstantPagePreviewBuilder.swift owns the shared compact
preview. Block traversal, caption handling, collection summaries, whitespace
normalization, and inline semantics produce one attributed string. The chat list
uses its .string as the backing text and consumes its attributes directly; never
independently fold the plain and attributed forms or reconstruct rich content from
message entities.
Collages/slideshows prefer their own caption and credit. Without a collection caption, caption-bearing children retain their order and captionless siblings keep media labels. Pure image/video collections use localized counts (including nested covers/collections). Other media remain ordinary child previews. Empty containers are skipped. Thinking text is a fallback when no answer content contributes.
Preview bodies are limited to 200 grapheme clusters, with an ellipsis on truncation.
Traversal also limits depth (64), nodes (4,096), and inspected UTF-16 units (16,384).
Spoilers, custom emoji, dates, italic, underline, and strikethrough survive nesting.
Dates use supplied presentation settings; nil-format dates retain their literal text.
Unresolved custom emoji keeps its identity in preview metadata and displays alt
text (or � if empty). Only a resolved file receives the native custom-emoji
attribute: entity text nodes otherwise replace alt text by a blank attachment.
The modern composer accessory uses the existing entity-aware text component.
styleInstantPagePreview applies a consumer's fonts/colors without deleting semantic
attributes. Formula/table runs use U+FFFC plus icon and fallback metadata, never
spaces. renderInstantPagePreviewIcons consumes icon markers, retaining semantic
attributes; materialize on the body before adding author prefixes. Its subsequent
calls are idempotent. instantPagePreviewPlainText provides spoken/textual icon
labels; it must not replace the visual backing string used by attribute ranges.
Regression targets (run through Make.py test --target as documented in CLAUDE.md):
//submodules/TelegramStringFormatting:TelegramStringFormattingTests (resource-free
collector fixtures) and //submodules/ChatListUI:ChatListUITests (unhosted
selection/composition fixtures). The latter bundles real generated strings data
and English localization, with test-scoped resource lookup redirection for AppBundle.
Both production and minimal UIKit test hosts deadlocked during pre-main Texture/UIKit
initialization on iOS 26.5; an iOS 27.0 hosted retry also failed to start tests.
Thumbnail selection and dedicated relative-date refresh timers are outside this preview change.
Validation on 2026-09-29: 23 collector tests and 8 selection/composition tests passed; the full simulator app build passed after the review fixes. Live screen appearance, VoiceOver playback, and attribute-only message-edit invalidation still need manual verification; helper tests do not establish those UI behaviors.