All block-media kinds except .audio now lay out edge-to-edge (0 inset) with cornerRadius 0; the bubble's existing rounded containerNode clip rounds media at the bubble edge. Small images keep natural size (not upscaled); captions stay inset. Shared instantPageV2MediaFrame helper + flush flag on the two media layout helpers. V1 unchanged. Invariants documented in docs/instantpage-richtext.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
60 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.)
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.
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.
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. - 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 block media — flush (edge-to-edge), un-rounded
Every V2 block-media kind except .audio 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 keeps the legacy inset + 8pt rounding.)
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 (audio/webEmbed-placeholder/postEmbed/collage/slideshow/channelBanner/relatedArticles). |
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. All media call sites passflush: trueexcept.audio(flush: false). 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 on the flush path — 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 the lone exception and routes through the non-flush branch of
instantPageV2MediaFrame(inset byhorizontalInset, caller's cornerRadius), reproducing the legacy behavior exactly.
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. - 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).
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/textSpoiler remain unimplemented — .plain("").)
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 all six interactive keys (URL + the five entity keys), not just URL, so press-highlight and the link-loading shimmer cover entities too.- 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. Computes richMessageMarkdown from the message's RichTextMessageAttribute.instantPage (after let message = messages[0]), opens the Copy gate with richMessageMarkdown != nil, and short-circuits copyTextWithEntities to storeMessageTextInPasteboard(markdown, entities: nil). |
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).
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.
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. - Nested children use a FIXED 10pt inter-child gap, not
spacingBetweenBlocks. The full page-flow spacing (~27pt 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.
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.