Anchor navigation in InstantPage V2 / rich-content bubbles

Tapping an intra-message #fragment link in a rich-data bubble now scrolls
the chat so the matching anchor lands just below the content-area top,
expanding any enclosing collapsed <details> first. Anchors come from
server/AI-sent InstantPages (block .anchor or inline RichText.anchor); the
compose path is unchanged.

- InstantPageV2View.anchorFrame(name:) resolves an anchor's frame in the
  live layout (text/codeBlock/thinking/details/table), mirroring findTextItem.
- instantPageAnchorPath(in:name:) is a pure model walk returning the
  <details>-sibling-ordinal path to an anchor; its recursion set matches
  exactly the containers the V2 layout flattens through layoutBlock
  (.blockQuote/.cover/.list .blocks), keeping ordinals consistent with the
  layout's detailsIndexCounter.
- InstantPageV2View.firstCollapsedDetails(forOrdinalPath:) maps that path to
  the first not-yet-expanded details' live index (read, never reproduced).
- The rich bubble fills the two stubbed seams: getAnchorRect, and a
  fragment-link route in tapActionAtPoint that drives a resolve -> expand ->
  scroll state machine (pendingScrollAnchor + progress guard + a
  post-relayout hook). Taps are gated off while the message streams.

Verified by the full Bazel build; runtime behavior not yet exercised.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-06-04 01:11:27 +02:00
parent c46249178d
commit a81d0ee944
4 changed files with 359 additions and 6 deletions

View file

@ -373,3 +373,26 @@ Spec: [`docs/superpowers/specs/2026-05-29-instantpage-blockquote-blocks-design.m
- **Shimmer runs continuously while the view is displayed** via `ShimmeringMaskView`'s `HierarchyTrackingLayer` self-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.swift` has no `.thinking` case; the block falls through `layoutInstantPageBlock`'s `default:` 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 has `innerLayout == nil`; its children aren't laid out). So `instantPageAnchorPath` returns ordinals, and `firstCollapsedDetails` reads the real index from the live laid-out `.details` item. Expansion is iterative: expand one collapsed level → `requestMessageUpdate` → the post-relayout hook re-runs `scrollToAnchor` → repeat until the anchor resolves via `anchorFrame`.
- **The model walk's recursion set MUST equal the containers the V2 layout recurses through `layoutBlock`** (and thus counts `<details>` in via `detailsIndexCounter`): exactly `.blockQuote`, `.cover`, and `.list`'s `.blocks` items — all of which the layout **flattens** into the parent `items` array (only `layoutDetails` nests a separate `innerLayout`, which is the level boundary). `instantPageAnchorPath` recurses those three sharing the `inout detailsOrdinal`, and treats `.details` as 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 by `anchorFrame` anyway, so skipping it is a no-op either way.
- **`anchorFrame` and the model walk are only ever both consulted when `anchorFrame` fails.** `scrollToAnchor` first tries `anchorFrame` (covers everything currently laid out — top level, expanded details, tables, thinking blocks); only on a miss does it consult `instantPageAnchorPath`. So the only consequential model-walk output is a **non-empty** path (anchor buried in a collapsed details); `nil`/`[]` both no-op.
- **`getAnchorRect` stays a pure synchronous query.** ChatController calls it inside `forEachVisibleItemNode`; all expansion is orchestrated by `scrollToAnchor`/`pendingScrollAnchor` **before** the scroll fires. The chat scroll consumes only the returned rect's `minY`.
- **Anchor taps are rejected while the message streams** (`TypingDraftMessageAttribute`) → `.none`. So `pendingScrollAnchor` is 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-highlight `rects` are still passed.

View file

@ -0,0 +1,153 @@
import Foundation
import TelegramCore
/// Recurses the `InstantPage` block tree to locate the anchor `name`, returning the
/// details-sibling-ordinal path of enclosing `<details>` blocks (outermost first).
///
/// - `nil` the anchor exists nowhere.
/// - `[]` the anchor exists outside any `<details>` (top level, a table cell, a quote, etc.).
/// - `[2,0]` inside the 3rd top-level `<details>`, then that details' 1st nested `<details>`.
///
/// Ordinals (not layout indices) because the layout's details index counter is
/// expansion-dependent. Consumers map ordinals to live indices via
/// `InstantPageV2View.firstCollapsedDetails(forOrdinalPath:)`.
public func instantPageAnchorPath(in instantPage: InstantPage, name: String) -> [Int]? {
var ordinal = 0
return instantPageAnchorPathSearch(instantPage.blocks, name: name, detailsOrdinal: &ordinal)
}
/// Searches `blocks` at a single details-nesting level. `detailsOrdinal` is the running count of
/// `<details>` blocks already passed at this level; container blocks that flatten in the layout
/// (`.blockQuote`/`.list`/`.cover`/`.postEmbed`) recurse sharing this counter, while a `<details>`
/// recurses with a fresh counter (a new level).
private func instantPageAnchorPathSearch(
_ blocks: [InstantPageBlock],
name: String,
detailsOrdinal: inout Int
) -> [Int]? {
for block in blocks {
switch block {
case let .anchor(anchorName):
if anchorName == name { return [] }
case let .title(text), let .subtitle(text), let .header(text), let .subheader(text),
let .paragraph(text), let .footer(text), let .kicker(text), let .thinking(text):
if richTextContainsAnchor(text, name: name) { return [] }
case let .heading(text, _):
if richTextContainsAnchor(text, name: name) { return [] }
case let .authorDate(author, _):
if richTextContainsAnchor(author, name: name) { return [] }
case let .preformatted(text, _):
if richTextContainsAnchor(text, name: name) { return [] }
case let .pullQuote(text, caption):
if richTextContainsAnchor(text, name: name) || richTextContainsAnchor(caption, name: name) { return [] }
case let .details(title, childBlocks, _):
if richTextContainsAnchor(title, name: name) { return [] } // title is always laid out
var childOrdinal = 0
if let sub = instantPageAnchorPathSearch(childBlocks, name: name, detailsOrdinal: &childOrdinal) {
return [detailsOrdinal] + sub
}
detailsOrdinal += 1
case let .blockQuote(quoteBlocks, caption):
if richTextContainsAnchor(caption, name: name) { return [] }
if let r = instantPageAnchorPathSearch(quoteBlocks, name: name, detailsOrdinal: &detailsOrdinal) { return r }
case let .list(items, _):
for listItem in items {
switch listItem {
case let .text(text, _, _):
if richTextContainsAnchor(text, name: name) { return [] }
case let .blocks(itemBlocks, _, _):
if let r = instantPageAnchorPathSearch(itemBlocks, name: name, detailsOrdinal: &detailsOrdinal) { return r }
case .unknown:
break
}
}
case let .cover(inner):
if let r = instantPageAnchorPathSearch([inner], name: name, detailsOrdinal: &detailsOrdinal) { return r }
case let .table(title, rows, _, _):
if richTextContainsAnchor(title, name: name) { return [] }
for row in rows {
for cell in row.cells {
if let cellText = cell.text, richTextContainsAnchor(cellText, name: name) { return [] }
}
}
default:
// .unsupported/.divider/.formula/.image/.video/.audio/.webEmbed/.channelBanner/.map
// leaf/media blocks with no anchor-bearing text. (.relatedArticles also lands here: the
// V2 layout discards its title and lays out only the article media, so its title text is
// never rendered.)
//
// CRITICAL the recursion set here must match the containers the V2 layout recurses
// through layoutBlock (and thus counts <details> in via detailsIndexCounter). Those are
// exactly .blockQuote, .cover, and .list's .blocks items all handled above, sharing
// detailsOrdinal. The following carry [InstantPageBlock] children but are deliberately
// NOT recursed because the V2 layout does NOT lay their children out as blocks, so it
// never counts a nested <details> in them recursing here while sharing detailsOrdinal
// would desync our ordinals from the layout:
// .collage/.slideshow layoutCollage/layoutSlideshow lay out only .image/.video children.
// .postEmbed layoutMediaWithCaption lays out only its caption (a real .text item,
// so a caption anchor is found by anchorFrame directly); its `blocks` are ignored.
// Any anchor inside a non-laid-out child is unresolvable by anchorFrame anyway, so
// skipping it here is a no-op either way.
break
}
}
return nil
}
/// True if the `RichText` tree contains an inline `.anchor` whose name equals `name`.
private func richTextContainsAnchor(_ text: RichText, name: String) -> Bool {
switch text {
case .empty, .plain, .image, .formula, .textCustomEmoji:
return false
case let .anchor(inner, anchorName):
if anchorName == name { return true }
return richTextContainsAnchor(inner, name: name)
case let .concat(parts):
for part in parts {
if richTextContainsAnchor(part, name: name) { return true }
}
return false
case let .bold(inner):
return richTextContainsAnchor(inner, name: name)
case let .italic(inner):
return richTextContainsAnchor(inner, name: name)
case let .underline(inner):
return richTextContainsAnchor(inner, name: name)
case let .strikethrough(inner):
return richTextContainsAnchor(inner, name: name)
case let .fixed(inner):
return richTextContainsAnchor(inner, name: name)
case let .marked(inner):
return richTextContainsAnchor(inner, name: name)
case let .`subscript`(inner):
return richTextContainsAnchor(inner, name: name)
case let .superscript(inner):
return richTextContainsAnchor(inner, name: name)
case let .textAutoEmail(inner):
return richTextContainsAnchor(inner, name: name)
case let .textAutoPhone(inner):
return richTextContainsAnchor(inner, name: name)
case let .textAutoUrl(inner):
return richTextContainsAnchor(inner, name: name)
case let .textBankCard(inner):
return richTextContainsAnchor(inner, name: name)
case let .textBotCommand(inner):
return richTextContainsAnchor(inner, name: name)
case let .textCashtag(inner):
return richTextContainsAnchor(inner, name: name)
case let .textHashtag(inner):
return richTextContainsAnchor(inner, name: name)
case let .textMention(inner):
return richTextContainsAnchor(inner, name: name)
case let .textSpoiler(inner):
return richTextContainsAnchor(inner, name: name)
case let .url(inner, _, _):
return richTextContainsAnchor(inner, name: name)
case let .email(inner, _):
return richTextContainsAnchor(inner, name: name)
case let .phone(inner, _):
return richTextContainsAnchor(inner, name: name)
case let .textMentionName(inner, _):
return richTextContainsAnchor(inner, name: name)
}
}

View file

@ -2159,6 +2159,43 @@ public extension InstantPageV2View {
}
return nil
}
/// The frame (pageView-space) of the anchor `name` in the *currently laid-out* layout.
/// Returns nil if the anchor isn't present e.g. it's inside a collapsed `<details>`
/// (whose inner blocks aren't laid out) or doesn't exist. Mirrors `findTextItem`.
func anchorFrame(name: String) -> CGRect? {
guard let layout = self.currentLayout else { return nil }
return findAnchorFrame(in: layout, name: name, accumulatedOffset: .zero)
}
/// Given a details-sibling-ordinal path (from `instantPageAnchorPath`), walk the live layout
/// and return the `currentExpandedDetails` index of the FIRST not-yet-expanded `<details>` on
/// the path. Returns nil if every details on the path is already expanded, or the path doesn't
/// match the live layout. Reads indices from the laid-out items never reproduces them.
func firstCollapsedDetails(forOrdinalPath path: [Int]) -> Int? {
guard let layout = self.currentLayout else { return nil }
var currentItems = layout.items
for ordinal in path {
var seen = 0
var found: InstantPageV2DetailsItem?
for item in currentItems {
if case let .details(details) = item {
if seen == ordinal {
found = details
break
}
seen += 1
}
}
guard let details = found else { return nil }
if !details.isExpanded {
return details.index
}
guard let inner = details.innerLayout else { return nil }
currentItems = inner.items
}
return nil
}
}
// MARK: - Private recursion helpers
@ -2221,6 +2258,78 @@ private func findTextItem(
return nil
}
private func findAnchorFrame(
in layout: InstantPageV2Layout,
name: String,
accumulatedOffset: CGPoint
) -> CGRect? {
for item in layout.items {
let f = item.frame.offsetBy(dx: accumulatedOffset.x, dy: accumulatedOffset.y)
switch item {
case let .anchor(anchor):
if anchor.name == name {
return CGRect(x: f.minX, y: f.minY, width: 0.0, height: 0.0)
}
case let .text(text):
if let (lineIndex, _) = text.textItem.anchors[name], lineIndex < text.textItem.lines.count {
let line = text.textItem.lines[lineIndex].frame
return CGRect(x: f.minX + line.minX, y: f.minY + line.minY, width: line.width, height: line.height)
}
case let .codeBlock(block):
if let (lineIndex, _) = block.textItem.anchors[name], lineIndex < block.textItem.lines.count {
let line = block.textItem.lines[lineIndex].frame
return CGRect(
x: f.minX + block.textItem.frame.minX + line.minX,
y: f.minY + block.textItem.frame.minY + line.minY,
width: line.width, height: line.height
)
}
case let .thinking(thinking):
if let (lineIndex, _) = thinking.textItem.anchors[name], lineIndex < thinking.textItem.lines.count {
let line = thinking.textItem.lines[lineIndex].frame
return CGRect(
x: f.minX + thinking.textItem.frame.minX + line.minX,
y: f.minY + thinking.textItem.frame.minY + line.minY,
width: line.width, height: line.height
)
}
case let .details(details):
if let (lineIndex, _) = details.titleTextItem.anchors[name], lineIndex < details.titleTextItem.lines.count {
let line = details.titleTextItem.lines[lineIndex].frame
return CGRect(
x: f.minX + details.titleTextItem.frame.minX + line.minX,
y: f.minY + details.titleTextItem.frame.minY + line.minY,
width: line.width, height: line.height
)
}
if let inner = details.innerLayout {
let innerOffset = CGPoint(x: f.minX, y: f.minY + details.titleFrame.maxY)
if let hit = findAnchorFrame(in: inner, name: name, accumulatedOffset: innerOffset) {
return hit
}
}
case let .table(table):
for cell in table.cells {
if let sub = cell.subLayout {
let cellOffset = CGPoint(x: f.minX + table.contentInset + cell.frame.minX, y: f.minY + cell.frame.minY)
if let hit = findAnchorFrame(in: sub, name: name, accumulatedOffset: cellOffset) {
return hit
}
}
}
if let titleLayout = table.titleSubLayout, let titleFrame = table.titleFrame {
let titleOffset = CGPoint(x: f.minX + table.contentInset + titleFrame.minX, y: f.minY + titleFrame.minY)
if let hit = findAnchorFrame(in: titleLayout, name: name, accumulatedOffset: titleOffset) {
return hit
}
}
default:
continue
}
}
return nil
}
private func collectSelectableTextItems(
in layout: InstantPageV2Layout,
accumulatedOffset: CGPoint,

View file

@ -39,6 +39,10 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
messageStableVersion: UInt32,
layout: InstantPageV2Layout)?
private var currentExpandedDetails: [Int: Bool] = [:]
// Intra-message anchor scroll that is waiting on a collapsed <details> to expand + relayout.
private var pendingScrollAnchor: String?
// Progress guard: the details index expanded on the previous pending pass.
private var lastExpandedPendingDetailsIndex: Int?
private var linkProgressDisposable: Disposable?
private var linkProgressRects: [CGRect]?
private var linkHighlightingNode: LinkHighlightingNode?
@ -675,6 +679,22 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
if self.displayContentsUnderSpoilers {
pageView.setDisplayContentsUnderSpoilers(true, atLocation: nil, animated: false)
}
// Continue an in-flight anchor scroll that is waiting on a <details>
// expansion to re-lay-out. This runs on EVERY apply pass (not only the
// expand-triggered one), but only does anything while a scroll is pending
// and scrollToAnchor is idempotent: each invocation either resolves and
// scrolls (clearing pending) or expands the next collapsed level, and the
// progress guard guarantees termination. So an unrelated relayout (theme,
// width, reactions) that lands mid-expand simply advances/no-ops the loop.
// Deferred via justDispatch to avoid re-entering layout from this apply.
if let pendingAnchor = self.pendingScrollAnchor {
Queue.mainQueue().justDispatch { [weak self] in
guard let self, self.pendingScrollAnchor == pendingAnchor else {
return
}
self.scrollToAnchor(pendingAnchor)
}
}
} else {
self.currentPageLayout = nil
self.pageView?.update(
@ -859,6 +879,16 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
}
let split = self.splitAnchor(urlHit.urlItem.url)
if split.base.isEmpty, let anchor = split.anchor {
// Don't accept intra-message anchor taps while the message is still streaming.
if let item = self.item, item.message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) {
return ChatMessageBubbleContentTapAction(content: .none)
}
let rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
self?.scrollToAnchor(anchor)
}), rects: rects)
}
if let webpage = self.currentLoadedWebpage(), webpage.content.url == split.base, let anchor = split.anchor {
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
self?.scrollToAnchor(anchor)
@ -1187,9 +1217,17 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
}
override public func getAnchorRect(anchor: String) -> CGRect? {
// V2 V0: anchor resolution lives in the V2 view (text-item anchors). Not yet wired through.
let _ = anchor
return nil
guard let pageView = self.pageView, let rect = pageView.anchorFrame(name: anchor) else {
return nil
}
// Small top breathing room so the target isn't flush against the content-area top
// (cf. V1 InstantPageControllerNode's -10 offset). The chat scroll consumes only the
// returned rect's minY (ChatController's scrollToMessageIdWithAnchor .bottom(anchorY)),
// so pulling minY up by the margin is what lands the anchor below the top edge; the rect
// is grown to keep maxY stable should a future caller use the full rect (e.g. a highlight).
let topMargin: CGFloat = 8.0
let adjusted = CGRect(x: rect.minX, y: max(0.0, rect.minY - topMargin), width: rect.width, height: rect.height + topMargin)
return self.view.convert(adjusted, from: pageView)
}
override public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? {
@ -1227,10 +1265,40 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
guard let item = self.item else {
return
}
// Empty fragment ("#") is a no-op.
if anchor.isEmpty {
item.controllerInteraction.scrollToMessageId(item.message.index, 0.0)
} else {
item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor)
self.clearPendingScroll()
return
}
// 1. Anchor is in the currently laid-out content scroll now.
if self.pageView?.anchorFrame(name: anchor) != nil {
self.clearPendingScroll()
item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor)
return
}
// 2. Not laid out it may be buried in a collapsed <details>. Find the path and expand
// the first collapsed details on it, then retry after the relayout (post-relayout hook).
guard let instantPage = item.message.richText?.instantPage,
let path = instantPageAnchorPath(in: instantPage, name: anchor),
!path.isEmpty,
let collapsedIndex = self.pageView?.firstCollapsedDetails(forOrdinalPath: path)
else {
self.clearPendingScroll()
return
}
// Progress guard: if expanding this same index last pass didn't move us forward, stop.
if self.lastExpandedPendingDetailsIndex == collapsedIndex {
self.clearPendingScroll()
return
}
self.currentExpandedDetails[collapsedIndex] = true
self.pendingScrollAnchor = anchor
self.lastExpandedPendingDetailsIndex = collapsedIndex
item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
}
private func clearPendingScroll() {
self.pendingScrollAnchor = nil
self.lastExpandedPendingDetailsIndex = nil
}
}