Non-premium users are blocked at send/edit when typed markdown would
produce a RichTextMessageAttribute, and shown a .premiumPaywall toast
(todo-gate pattern). Saved Messages and premium-disabled regions are
exempt. Receiving/rendering rich messages is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render and play InstantPageBlock.audio in the InstantPage V2 renderer
(rich-data message bubbles + the rich send preview), where audio was
previously an inert grey placeholder.
- New InstantPageV2AudioContentNode replicates the standard music message
bubble (ChatMessageInteractiveFileNode's music layout): a SemanticStatusNode
control (album art via playerAlbumArt + play/pause) with a download/progress
overlay, title + "duration · performer" lines, exact fonts/colors from
theme.chat.message.{incoming|outgoing}. Tap is driven by a
UITapGestureRecognizer (ASControl .touchUpInside is cancelled by the chat
ListView's gesture system). V1's InstantPageAudioNode is unchanged.
- Playback runs on InstantPageMediaPlaylist with a discriminated, message-scoped
InstantPageMediaPlaylistId (.instantPage / .richMessage) so concurrent
rich-message audio bubbles don't collide; the big control's play/pause comes
from filteredPlaylistState, the overlay's download/progress from
messageMediaFileStatus, and fetch goes through the fetch manager.
- Rich-message audio fetches via a MessageReference (threaded through the V2
render context) instead of the synthesized webpage; FetchedMediaResource's
.message revalidation arm now also searches RichTextMessageAttribute instant
pages, so a stale instant-page audio/image reference can recover. Corrected a
dormant inverted InstantPagePlaylistLocation.isEqual.
- New .mediaAudio laid-out item + layout/reveal-cost arms; the audio block lays
out full-width at the file node's music normHeight.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
InstantImageGalleryItem.animateIn animated the transition surface-copy view
to transformedCopyViewFinalFrame (a point in the gallery item node's own
coordinate space) instead of transformedSurfaceFinalFrame (that point expressed
in the transition surface's space). ChatImageGalleryItem and
UniversalVideoGalleryItem both use the surface-space frame here; only
InstantImageGalleryItem (the InstantView photo item) had the slip.
It was harmless in V1's full-page Instant View because the surface was
window-scale and roughly aligned with the gallery, but in the V2 rich-data
chat bubble the surface is the bubble's container inside the scrolled,
flipped chat list (observed window frame ~ y:-1097 h:2107), so the
gallery-space target landed ~1100pt off-screen and the photo snapshot flew
away on open. Video used UniversalVideoGalleryItem and was unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port V1's collage and slideshow InstantPage blocks into the V2 renderer
(previously grey-box placeholders).
- Collage: layoutCollage computes the mosaic (MosaicLayout, the grouped-message
engine) over inner image/video sizes and flattens it into existing top-level
.mediaImage/.mediaVideo items + a caption, so gallery / reveal-cost / registry
/ hidden-media all handle the cells with no collage-specific code. Right-edge
cells bleed 4pt for the bubble's rounded clip.
- Slideshow: a new .slideshow laid-out item + InstantPageV2SlideshowView, an
eager paged carousel (UIScrollView + PageControlNode) of InstantPageImageNode
pages, wired through frame/offsetBy/collectMedias/stableId/reuse/makeItemView
and the reveal-cost non-text list.
- Gallery transitions generalized onto InstantPageItemView via
instantPageTransitionNode(for:)/instantPageUpdateHiddenMedia(_:) (default
nil/no-op; explicit per-class witnesses on the 4 static media views, the
slideshow forwards to its live pages) so the multi-media slideshow can
participate alongside single-media views.
Docs: document both in docs/instantpage-richtext.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AI/server-sent .map blocks can arrive with dimensions == 0x0 (the wire
w/h are required Int32, but the sender may put 0; our parse and both
serializers preserve whatever arrives). A zero naturalSize.height hit
instantPageV2MediaFrame's else branch and returned a height-0 frame:
the map collapsed to no space, the caption slid up into it, and the V1
node's pin floated over the caption. A zero-sized MapSnapshotMediaResource
would also make MKMapSnapshotter render nothing.
Substitute PixelDimensions(600, 300) (2:1) whenever width <= 0 ||
height <= 0, feeding effectiveDimensions to BOTH the layout naturalSize
AND the InstantPageMapAttribute so the snapshot resource is non-zero and
actually renders. Scoped to the V2 .map arm; V1 (real web articles)
always carries real dimensions and never trips this.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All block-media kinds except .audio now lay out edge-to-edge (0 inset) with
cornerRadius 0; the bubble's existing rounded containerNode clip rounds media at
the bubble edge. Small images keep natural size (not upscaled); captions stay
inset. Shared instantPageV2MediaFrame helper + flush flag on the two media
layout helpers. V1 unchanged. Invariants documented in docs/instantpage-richtext.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove stale data from CLAUDE.md and split out the large InstantPage V2 /
rich-text feature documentation into its own file.
- Drop two dead spec links (table inset/corner-radius design docs no longer
exist), the orphaned debugRichText cleanup note (flag is read by nothing),
and the stale "238 waves as of 2026-05-04" count.
- Remove the duplicated Postbox "Wave-selection guidance" and "facade
inventory" subsections; both live in docs/superpowers/postbox-refactor-log.md
and TelegramEngineResources.swift (pointer folded into the Historical record).
- Move the 13 InstantPage V2 / rich-text sections to
docs/instantpage-richtext.md, leaving a brief pointer in CLAUDE.md.
CLAUDE.md: 507 -> 161 lines.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-applies commit 1120b9084e (reverted in ef2c1d47cc during install
debugging). The install failure was proven to be the oversized watch
binary (fixed by DEAD_CODE_STRIPPING), not the bundle-id mechanism — which
was verified to produce a byte-identical artifact for ph.telegra.Telegraph.
xcodebuild bakes PRODUCT_BUNDLE_IDENTIFIER=<host>.watchkitapp and the watch
Info.plist derives WKCompanionAppBundleIdentifier via
$(PRODUCT_BUNDLE_IDENTIFIER:base), so the embedded watch app is correct for
any host (ph.telegra.Telegraph and org.telegram.TelegramInternal/enterprise)
with no post-build plist patching.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- layoutTextItem now sizes a text item to the true font line height (A + D)
instead of the cap box: the line stack starts at lineBoxTopInset (the
ascender headroom, max(0, fontAscent - fontLineHeight)) and the returned
height is padded by the last line's descender. Inter-line advance is
unchanged and per-line frames stay the cap box, so the baseline draw,
decorations, reveal mask, and inline attachments translate consistently;
the page grows.
- Size inline custom emoji to ~the font line height
(font.ascender - font.descender + 4*pointSize/17) so they fit the taller
line box instead of overflowing it; the line is not inflated.
- Add a dedicated code-block text style for rich messages (monospace font +
codeBlock theme attribute) threaded through the rich-data bubble and send
preview; unify the bubble code-block/separator colors.
- Adjust inter-block spacings and assorted V2 layout/divider details.
- Document the true-font-height box and emoji sizing in CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shift the line stack down by the ascender headroom (A - L, as
`lineBoxTopInset` to avoid colliding with the existing formula-bleed
`topInset` local) and pad the returned height by the last line's
descender (D), so a single-line V2 text item is a true ascent/descent
line box measuring exactly A + D instead of the cap box A - L. Inter-line
advance unchanged; formulas still inflate via lineAscent/extraDescent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Local in-progress work (committed at the author's request as a checkpoint
before unrelated true-font-height work lands on top): introduces a
dedicated `codeBlock` InstantPage font/text style (.monospace / .codeBlock
font cases + monospace font resolution), adds a `codeBlock` field to the
InstantPage text-attributes theme and threads it through the rich-data
bubble and the send preview, and unifies the rich-bubble code-block
background/secondary-control colors.
Task 1: 3-line metric change in layoutTextItem (topInset shift + descender
pad). Task 2: re-tune the streaming chat-bubble bottom inset (the now-
contained descender makes the +6.0 hack partly redundant). Task 3: broad
visual verification of the remaining consumers (decorations, reveal mask,
lists, blockquotes, tables, title) with one gated table-inset fix. Task 4:
spec acceptance checklist. Verification model adapted to this repo: full
Bazel build + simulator visual checks (no unit tests exist).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Approach 2 (true proper line box): shift the line stack down by the
ascender headroom (exact A - L, not pixel-snapped) and pad the returned
height by the last line's descender, so a single-line V2 text item
measures exactly the true font height (A + D) instead of the cap box
(A - D). Inter-line advance unchanged; formulas still inflate.
Documents the affected height/frame consumers (chat-bubble height +
status node, streaming clip, table cells, title centering, reveal cost)
and the verification plan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The V2 line-breaker used `lineAscent` as both the line height and the
baseline offset, then inflated it to each inline emoji/image's full visual
size and bottom-aligned the attachment on that inflated baseline. A 24pt
emoji on a ~17pt line therefore doubled the line height and shoved the
text baseline (and all text on the line) down.
Stop inflating the line for emoji/images (only formulas, which carry their
own metrics, still grow it) and center each attachment on the font line
box at `baselineY - fontLineHeight/2 - size/2`, matching V1
`layoutTextItemWithString` and the chat `InteractiveTextComponent`. The
attachment now bleeds symmetrically instead of moving the baseline.
`extraDescent` absorbs tall-attachment bottom overflow so the next line is
not overlapped, and the streaming-reveal `characterRect` is centered in
lockstep so the reveal mask tracks the cell (reveal cost stays width-only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Revert the universal/dynamic watch bundle id change (commit 1120b9084e)
back to the static 0682 shape, for install testing.
This reverts the PRODUCT_BUNDLE_IDENTIFIER override + $(...:base) companion-id
derivation; the watch app again bakes the hardcoded ph.telegra.Telegraph.watchkitapp
and literal WKCompanionAppBundleIdentifier=ph.telegra.Telegraph.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Parse custom emoji into RichText.textCustomEmoji when sending markdown
rich messages, and round-trip them through edit, copy, and paste using a
shared tg://emoji?id=<fileId> markdown-link marker.
- Send: rewrite each customEmoji input attribute into a
[<alt>](tg://emoji?id=<fileId>) marker before the CommonMark parse, then
intercept the marker URL afterward to emit .textCustomEmoji. Only rich
messages are affected; a custom emoji alone stays on the entity path.
- Reverse: InstantPageToMarkdown (whole-message copy + edit reconstruction)
and InstantPageMultiTextAdapter (selection copy) emit the marker;
edit-load and chat paste reattach it as a live customEmoji attribute.
- Marker helpers shared in TextFormat/CustomEmojiMarkdownMarker.swift.
- Rich sends now pass inlineStickers so recipients can fetch the files.
Follow-up to verify at runtime: recipient rendering goes out with
Api.InputRichMessage.documents: nil; if recipients see only the fallback
glyph, populate documents: in apiInputRichMessage().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the thinking block render consistently with a normal text item:
- Size the `.thinking` item frame text-width at the page inset
(`(horizontalInset, 0, textWidth, height)`), identical to a `.text`
item, instead of a full-bleed `(0, 0, boundingWidth, height)` box, by
laying the text out flush and moving the inset onto the block frame.
The shimmer (sized to `item.frame.size`) now hugs the text.
- Size the shimmer + its gradient mask to the clipping-inset-expanded
frame (shifted `-inset`) so the mask no longer crops glyph ascenders,
descenders and the last line's underline, mirroring how a `.text`
view's frame is inset-expanded.
- Visit the thinking block's shimmer-wrapped inner text view in
updateInlineEmoji / updateInlineImages so its inline custom emoji and
images get layers created, positioned and revealed. They were skipped
because the page only iterated top-level InstantPageV2TextView items.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>