diff --git a/docs/instantpage-richtext.md b/docs/instantpage-richtext.md index 75fbe45d99..c3e1f9fed9 100644 --- a/docs/instantpage-richtext.md +++ b/docs/instantpage-richtext.md @@ -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 `
` 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 `
`'s live index). | +| `submodules/InstantPageUI/Sources/InstantPageAnchorPath.swift` | **NEW.** Pure `instantPageAnchorPath(in:name:)` model walk → the `
`-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 `
` 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 `
` 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 `
` 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 `
`. +- **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. diff --git a/submodules/InstantPageUI/Sources/InstantPageAnchorPath.swift b/submodules/InstantPageUI/Sources/InstantPageAnchorPath.swift new file mode 100644 index 0000000000..25ee9c2b24 --- /dev/null +++ b/submodules/InstantPageUI/Sources/InstantPageAnchorPath.swift @@ -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 `
` blocks (outermost first). +/// +/// - `nil` — the anchor exists nowhere. +/// - `[]` — the anchor exists outside any `
` (top level, a table cell, a quote, etc.). +/// - `[2,0]`— inside the 3rd top-level `
`, then that details' 1st nested `
`. +/// +/// 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 +/// `
` blocks already passed at this level; container blocks that flatten in the layout +/// (`.blockQuote`/`.list`/`.cover`/`.postEmbed`) recurse sharing this counter, while a `
` +/// 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
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
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) + } +} diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index c389e0d3cf..243a3630f5 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -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 `
` + /// (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 `
` 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, diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index 38c668570c..0a13e2fede 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -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
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
+ // 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
. 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 } }