A code block inside a blockquote was pinned to local x=0 / full width, so its
background bled out under the quote bar instead of insetting to the quote's
content gutter like the quote's text. Detect quote nesting via the raised
child inset (threaded as LayoutContext.pageHorizontalInset) and, when nested,
inset the background to honor horizontalInset and give it an 8pt rounded
corner. Top-level and <details> code blocks stay flush, full-width, and square
(matching V1) — the bubble's own rounded clip handles their edges.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a rich message's page-level rtl flag is set, mirror every V2 block
ornament and content column onto the trailing (right) edge, gated solely on
the explicit flag (no content auto-detection, no send/wire change, V2 only):
- Two centralized leading/trailing geometry helpers (instantPageV2ContentColumnX
/ instantPageV2LeadingEdgeX) as the single source of truth for gutter side.
- Lists: content column mirrors to match the marker (8pt trailing gap).
- Blockquotes: bar + text gutter on the trailing edge, single- and multi-block
(multi-block child band rigid-translated; caption uses its own single-inset
delta).
- <details>: chevron + title indented from the right (rtl flag on the item).
- Uniform trailing alignment for paragraph/heading/simple-text.
- Keep the full bounding width for non-leading alignment so display-time
alignment lands at the true edge: .right (RTL text) was collapsing short
content to the leading edge under fitToWidth (the list-gap), and .center
(pull quotes) was failing to center body + author. Only .natural shrinks.
LTR output is unchanged (.right/.center are only produced under RTL contexts /
pull quotes). Builds clean; runtime-verified on an rtl message with lists,
blockquotes, pull quotes, and inline formatting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render RichText.textDate (API constructor textDate(flags, text, date)) as a
locale/timezone-correct, relative-aware, tappable date inside V2 rich-message
bubbles, reusing the existing stringForEntityFormattedDate autoformatter.
- Model: add RichText.textDate(text📅format:) with Postbox coding,
FlatBuffers schema (RichText.fbs; flatc regenerates), Equatable and plainText;
parse flags->DateTimeFormat? at the API boundary (flags==0 => nil, matching
the messageEntityFormattedDate convention) with a symmetric apiRichText()
round-trip. The new case is handled in every exhaustive RichText switch
(SyncCore, ApiUtils, InstantPageTextItem, InstantPageAnchorPath,
InstantPagePreviewText, BrowserReadability, BrowserMarkdown).
- Render: attributedStringForRichText gains an optional formatDate closure;
the V2 layout builds it from the message's strings/dateTimeFormat and threads
it to every text-building call site, formatting the timestamp and tagging the
run with TelegramTextAttributes.Date.
- Tappable: the Date attribute is added to linkSelectionRects hit-testing and
mapped in the rich bubble's entityTapContent to the existing .date tap action
(opens the date context menu).
- Live update: the V2 layout accumulates a page-wide formattedDateUpdatePeriod
(>=10s) for relative dates; the rich bubble schedules a refresh timer on it,
recreating it only when the period changes. The currentPageLayout cache is
bypassed for relative-date pages so the timer-driven relayout actually
re-formats the date.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render and play InstantPageBlock.audio in the InstantPage V2 renderer
(rich-data message bubbles + the rich send preview), where audio was
previously an inert grey placeholder.
- New InstantPageV2AudioContentNode replicates the standard music message
bubble (ChatMessageInteractiveFileNode's music layout): a SemanticStatusNode
control (album art via playerAlbumArt + play/pause) with a download/progress
overlay, title + "duration · performer" lines, exact fonts/colors from
theme.chat.message.{incoming|outgoing}. Tap is driven by a
UITapGestureRecognizer (ASControl .touchUpInside is cancelled by the chat
ListView's gesture system). V1's InstantPageAudioNode is unchanged.
- Playback runs on InstantPageMediaPlaylist with a discriminated, message-scoped
InstantPageMediaPlaylistId (.instantPage / .richMessage) so concurrent
rich-message audio bubbles don't collide; the big control's play/pause comes
from filteredPlaylistState, the overlay's download/progress from
messageMediaFileStatus, and fetch goes through the fetch manager.
- Rich-message audio fetches via a MessageReference (threaded through the V2
render context) instead of the synthesized webpage; FetchedMediaResource's
.message revalidation arm now also searches RichTextMessageAttribute instant
pages, so a stale instant-page audio/image reference can recover. Corrected a
dormant inverted InstantPagePlaylistLocation.isEqual.
- New .mediaAudio laid-out item + layout/reveal-cost arms; the audio block lays
out full-width at the file node's music normHeight.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port V1's collage and slideshow InstantPage blocks into the V2 renderer
(previously grey-box placeholders).
- Collage: layoutCollage computes the mosaic (MosaicLayout, the grouped-message
engine) over inner image/video sizes and flattens it into existing top-level
.mediaImage/.mediaVideo items + a caption, so gallery / reveal-cost / registry
/ hidden-media all handle the cells with no collage-specific code. Right-edge
cells bleed 4pt for the bubble's rounded clip.
- Slideshow: a new .slideshow laid-out item + InstantPageV2SlideshowView, an
eager paged carousel (UIScrollView + PageControlNode) of InstantPageImageNode
pages, wired through frame/offsetBy/collectMedias/stableId/reuse/makeItemView
and the reveal-cost non-text list.
- Gallery transitions generalized onto InstantPageItemView via
instantPageTransitionNode(for:)/instantPageUpdateHiddenMedia(_:) (default
nil/no-op; explicit per-class witnesses on the 4 static media views, the
slideshow forwards to its live pages) so the multi-media slideshow can
participate alongside single-media views.
Docs: document both in docs/instantpage-richtext.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AI/server-sent .map blocks can arrive with dimensions == 0x0 (the wire
w/h are required Int32, but the sender may put 0; our parse and both
serializers preserve whatever arrives). A zero naturalSize.height hit
instantPageV2MediaFrame's else branch and returned a height-0 frame:
the map collapsed to no space, the caption slid up into it, and the V1
node's pin floated over the caption. A zero-sized MapSnapshotMediaResource
would also make MKMapSnapshotter render nothing.
Substitute PixelDimensions(600, 300) (2:1) whenever width <= 0 ||
height <= 0, feeding effectiveDimensions to BOTH the layout naturalSize
AND the InstantPageMapAttribute so the snapshot resource is non-zero and
actually renders. Scoped to the V2 .map arm; V1 (real web articles)
always carries real dimensions and never trips this.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All block-media kinds except .audio now lay out edge-to-edge (0 inset) with
cornerRadius 0; the bubble's existing rounded containerNode clip rounds media at
the bubble edge. Small images keep natural size (not upscaled); captions stay
inset. Shared instantPageV2MediaFrame helper + flush flag on the two media
layout helpers. V1 unchanged. Invariants documented in docs/instantpage-richtext.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- layoutTextItem now sizes a text item to the true font line height (A + D)
instead of the cap box: the line stack starts at lineBoxTopInset (the
ascender headroom, max(0, fontAscent - fontLineHeight)) and the returned
height is padded by the last line's descender. Inter-line advance is
unchanged and per-line frames stay the cap box, so the baseline draw,
decorations, reveal mask, and inline attachments translate consistently;
the page grows.
- Size inline custom emoji to ~the font line height
(font.ascender - font.descender + 4*pointSize/17) so they fit the taller
line box instead of overflowing it; the line is not inflated.
- Add a dedicated code-block text style for rich messages (monospace font +
codeBlock theme attribute) threaded through the rich-data bubble and send
preview; unify the bubble code-block/separator colors.
- Adjust inter-block spacings and assorted V2 layout/divider details.
- Document the true-font-height box and emoji sizing in CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The V2 line-breaker used `lineAscent` as both the line height and the
baseline offset, then inflated it to each inline emoji/image's full visual
size and bottom-aligned the attachment on that inflated baseline. A 24pt
emoji on a ~17pt line therefore doubled the line height and shoved the
text baseline (and all text on the line) down.
Stop inflating the line for emoji/images (only formulas, which carry their
own metrics, still grow it) and center each attachment on the font line
box at `baselineY - fontLineHeight/2 - size/2`, matching V1
`layoutTextItemWithString` and the chat `InteractiveTextComponent`. The
attachment now bleeds symmetrically instead of moving the baseline.
`extraDescent` absorbs tall-attachment bottom overflow so the next line is
not overlapped, and the streaming-reveal `characterRect` is centered in
lockstep so the reveal mask tracks the cell (reveal cost stays width-only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the thinking block render consistently with a normal text item:
- Size the `.thinking` item frame text-width at the page inset
(`(horizontalInset, 0, textWidth, height)`), identical to a `.text`
item, instead of a full-bleed `(0, 0, boundingWidth, height)` box, by
laying the text out flush and moving the inset onto the block frame.
The shimmer (sized to `item.frame.size`) now hugs the text.
- Size the shimmer + its gradient mask to the clipping-inset-expanded
frame (shifted `-inset`) so the mask no longer crops glyph ascenders,
descenders and the last line's underline, mirroring how a `.text`
view's frame is inset-expanded.
- Visit the thinking block's shimmer-wrapped inner text view in
updateInlineEmoji / updateInlineImages so its inline custom emoji and
images get layers created, positioned and revealed. They were skipped
because the page only iterated top-level InstantPageV2TextView items.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a first-class InstantPageBlock.thinking(RichText) case mirroring .kicker:
- enum case + InstantPageBlockType.thinking = 30; Postbox coding & equality
- FlatBuffers InstantPageBlock_Thinking schema (appended last) + Swift codecs
- API parse at InstantPage.swift; output-only on send (apiInputBlock returns nil)
- no-op stubs in the two exhaustive switches (preview text, V2 layout)
Rendering is out of scope. Verified by a full Bazel build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rich messages (RichTextMessageAttribute, text == "") are copyable as
markdown two ways: the context-menu Copy action copies the whole
message, and a text selection inside the rich-data bubble copies just
the selected range. Both reconstruct markdown mirroring the edit
round-trip (markdownStringFromInstantPage).
Implements the full RichText entity case set
(mention/hashtag/cashtag/bot-command/bank-card/auto url/email/phone) with
tap interaction, the InstantPage -> markdown inverse converter and edit
round-trip, markdown-context stamping during V2 layout
(InstantPageMarkdownBlockContext: heading level, list/code/table/quote
depth), partial-selection markdown emission
(InstantPageMultiTextAdapter.markdownForRange), and numerous converter
edge-case fixes (tables, links, fenced code, blockquote line coalescing,
compact nested >> markers).
CLAUDE.md documents the feature; the spec/plan scratch docs generated
during development are not committed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Upgrade InstantPageBlock.blockQuote from (text, caption) to
(blocks: [InstantPageBlock], caption). Legacy inbound shapes (API
pageBlockBlockquote, Postbox "t", FlatBuffers text) lift into
[.paragraph(text)]; outbound stays on the legacy wire constructor for
empty/single-paragraph quotes and uses pageBlockBlockquoteBlocks
otherwise. V1/V2 renderers recurse into child blocks with a fixed 10pt
inter-child gap; markdown forward/reverse, entity-expressibility, and
preview text updated. pullQuote is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Renders `RichText.image(id:, dimensions:)` runs as real fetched images
inside `ChatMessageRichDataBubbleContentNode` / `InstantPageV2View`,
replacing the gray `InstantPageV2MediaPlaceholderView`. Source is
whatever populates `instantPage.media[id]` (server-pushed webpage
InstantPages); the markdown converter is unchanged
(`markdownImageParsingEnabled` stays false).
Architecture mirrors inline custom emoji: inline images are NOT
top-level `InstantPageV2LaidOutItem`s. `InstantPageV2View` walks each
text view's `line.imageItems`, resolves each MediaId against
`layout.media`, and creates `InstantPageV2InlineImageView` children
inside a new `imageContainerView` on each `InstantPageV2TextView`
(sibling of `renderContainer`, above the reveal mask, below
`emojiContainerView`).
Streaming reveal participation: image cells contribute their full
width to the per-line character rects (mirrors emoji), so the cost map
charges the cursor by the image's width when crossing it. When the
cursor crosses the cell, `updateImageReveal` pops the view in
(opacity 0→1 + scale 0.1→1.0 over 0.2s ease-out), matching
`updateEmojiReveal` exactly. `applyReveal` calls both alongside each
other.
Non-interactive (V1 parity): `isUserInteractionEnabled = false` lets
URL-wrapping `RichText.url(text: .image(...))` route taps through to
the underlying text view's URL handler.
Key files:
- New `InstantPageV2InlineImageView.swift`: lightweight
`TransformImageNode`-backed view. Picks the fetch signal by
EngineMedia kind (.image → chatMessagePhoto; image-mime .file →
instantPageImageFile; video .file → chatMessageVideo single frame).
`MetaDisposable` cancels fetches on dealloc.
- `InstantPageV2Layout.swift`: `media`/`webpage` fields on the layout
struct; image cells contribute to char rects; the gray-placeholder
branch in `layoutTextItem` is removed; the dead `media`/`webpage`
params on `layoutTextItem` are dropped along with their 13 call
sites.
- `InstantPageRenderer.swift`: `imageContainerView` on text view
(sibling of `renderContainer`, between it and `emojiContainerView`);
`InlineImageKey` + `InstantPageInlineImageData` types;
`updateInlineImages()` + `updateImageReveal()` on
`InstantPageV2View`; wired into `update(layout:theme:animation:)`.
- `InstantPageV2RevealCost.swift`: `applyReveal` calls
`updateImageReveal` next to `updateEmojiReveal` in both the
clear-path branch and the entry-walking branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a first-class `checked: Bool?` to InstantPageListItem (orthogonal to the
ordered-list `num`), replacing the prior sentinel-string-in-num prototype.
- Data model: enum third associated value on .text/.blocks; Postbox + a new
FlatBuffers `checkState` tri-state field (0=nil,1=unchecked,2=checked,
backward-compatible); Equatable.
- Transmission: read/write the native API `checkbox`=flags.0 / `checked`=flags.1
bits on all four PageListItem / PageListOrderedItem constructors, so checkbox
state survives the server round-trip for sender and recipients.
- Markdown: forward parser routes detected `[ ]`/`[x]` into `checked` (keeping
the real number for ordered items); reverse converter emits `- [ ]`/`- [x]`
so editing a rich message round-trips.
- Display: V1 + V2 layout detect via `item.checked`; the V2 renderer draws real
CheckNode artwork (was a placeholder); preview text shows a checkbox glyph
instead of leaking the old sentinel.
Spec: docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md
Plan: docs/superpowers/plans/2026-05-27-instantpage-list-checkbox.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the RichText.textSpoiler case end-to-end: model + Postbox coding,
FlatBuffers schema/codec, and Api.RichText parsing/serialization (lossless).
Display attaches the spoiler attribute, collects per-line spoiler rects in
the V2 layout, clips obscured glyphs and draws an energy-gated dust overlay
in the V2 text view, propagates the reveal flag across the nested V2 tree
(building dust for freshly-created text views), and wires tap-to-reveal
routing in the rich-data bubble.
Also seeds the streaming reveal cursor across chunk rebuilds and refines
status date positioning.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implement the nine message-entity RichText cases (textMention,
textMentionName, textHashtag, textCashtag, textBotCommand, textBankCard,
textAutoUrl, textAutoEmail, textAutoPhone): model + Postbox coding,
FlatBuffers schema/codec, Api.RichText parsing/serialization (lossless),
InstantPage display attaching the matching TelegramTextAttributes keys, and
tap routing in the rich-data bubble mirroring ChatMessageTextBubbleContentNode.
mentionName display resolves via EnginePeer.Id (PeerId not in scope).
Also lets rich-data text selection reach a line's trailing edge and fixes
the date/status node positioning to match TextBubble.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add the RichText.textCustomEmoji(fileId:alt:) case end-to-end: model +
Postbox coding, FlatBuffers schema/codec, and Api.RichText
parsing/serialization (lossless), plus display in the InstantPage V2
renderer. The emoji renders as an InlineStickerItemLayer that participates
in the streaming reveal (pops in as the reveal cursor crosses it) and is
gated by the bubble's visibility rect, propagated recursively through the
nested V2 view tree. Also frees the CTRunDelegate extent buffers for the
image/formula/custom-emoji attachment arms and documents the feature in
CLAUDE.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Spec, plan, and full implementation of the AI-message streaming
animation in ChatMessageRichDataBubbleContentNode. Extracts
TextRevealController into a shared StreamingTextReveal submodule;
precomputes per-character rects in V2 layout (RTL-safe, glyph-ink
bounds); splits InstantPageV2TextView into render container + render
view with a reveal mask layer; implements mask + snippet pop-in
reveal; adds a per-text-view reveal cost map + applyReveal extension;
switches reveal cost to a width-based unit; sizes / clips the bubble
to the revealed prefix during streaming; aligns the Thinking… header
with TextBubble; floors table cell reveal cost at cell frame width;
includes layout closing pad in revealedContentSize; documents the
non-obvious invariants in CLAUDE.md.
Snapshot list marker subviews/sublayers before iteration, anchor
strike/underline drawing to item frame height, baseline-align inline
text, images, and formulas. Also: implementation plan and two spec
refreshes (clarify extraDescent nudge removal; refresh stale line
numbers).
Design and plan, then the implementation: extract a shared
openInstantPageMedia helper for V1+V2 reuse, add typed media item
cases + allMedias() helper, introduce InstantPageV2RenderContext and
thread it through the view tree, add four media wrapper views, route
layout dispatch through typed media items, let the chat bubble build
the render context so real media renders, wire gallery tap routing,
cache V2View by message id + stableVersion, add stable-id identity +
per-class update(item:theme:), reuse item views on re-layouts via
stable-id harvest, preserve TextView clipping inset on reuse, and let
the use site control media-reference construction.
Design, plan, and implementation. Adds .formula item case +
InstantPageV2FormulaView, bumps InstantPageMathAttachment fields to
public, routes block and inline formulas through .formula items,
aligns the scroll view with V1 (alwaysBounceHorizontal = false), and
reports narrow block formula natural width so the fitToWidth bubble
shrinks.
Initial design, plan, scaffold, and the full incremental implementation
of InstantPage V2 used by ChatMessageRichDataBubbleContentNode: layout
data types and driver, shared text helpers, text view / divider /
anchor, lists, code blocks, block/pull quotes, media placeholders,
details (with recursion), tables, hit-test / selection / last-line
helpers, V1→V2 swap in the rich-data bubble, and post-swap fix-ups
(cache key, RTL paragraphs, list geometry, lazy pageView construction,
fitToWidth horizontal inset, V2View.update() no longer writes
self.frame).