Rich bubble: scrollToAnchor + getAnchorRect

Adds a base getAnchorRect on ChatMessageBubbleContentNode, the rich-bubble
override (including titleHeight in details recursion), bubble-item
forwarding to content nodes, ChatControllerInteraction.scrollToMessageIdWithAnchor,
and a scrollToAnchor that lands the anchor at the top of the content area /
its line. Threads anchor/scroll params through ChatController and related
call sites.
This commit is contained in:
isaac 2026-05-01 17:55:19 +02:00
parent f29af03cd7
commit fdb2f369ec
26 changed files with 1148 additions and 100 deletions

View file

@ -0,0 +1,504 @@
# Rich-Data Bubble scrollToAnchor Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `ChatMessageRichDataBubbleContentNode.scrollToAnchor` actually scroll the chat history so that an in-page anchor's line lands at the top of the visible content area.
**Architecture:** Mirror the existing `getQuoteRect` mechanism. The rich-data bubble exposes `getAnchorRect(anchor:)`, the bubble item node forwards to it. A new `ChatControllerInteraction.scrollToMessageIdWithAnchor` closure walks visible items via `forEachVisibleItemNode` (the bubble is necessarily at least partially visible because the user tapped a link in it), reads the anchor's item-local y, and routes through `ChatHistoryListNode.scrollToMessage(... scrollPosition: .bottom(anchorY))`. `.bottom` places the item so the anchor lands at the visual top of the content area; it works uniformly for short and tall items, where `.center(.custom)` is bypassed for items that fit in the content area.
**Tech Stack:** Swift, Bazel build, AsyncDisplayKit. No unit tests in this project — verification is a full Bazel build.
**Spec:** [docs/superpowers/specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md](../specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md)
**Build command (run after each task):**
```sh
source ~/.zshrc 2>/dev/null; \
python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build \
--configurationPath build-system/appstore-configuration.json \
--gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \
--gitCodesigningType development --gitCodesigningUseCurrent \
--buildNumber=1 --configuration=debug_sim_arm64
```
After Tasks 13 the build must be green; the feature is not yet live (no callers use the new methods). After Task 4 the new closure exists and is implemented but the bubble still routes through the old stub. After Task 5 the feature is live.
---
## Task 1: Add base `getAnchorRect` to `ChatMessageBubbleContentNode`
This makes `getAnchorRect(anchor:)` callable on every content node (returns `nil` by default) so the iteration in `ChatMessageBubbleItemNode` doesn't need a type-test.
**Files:**
- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift`
- [ ] **Step 1: Add the base method**
Open the file. Find the existing `open func transitionNode(messageId:media:adjustRect:) -> (...)` definition (around line 261). Add a new method directly after its closing brace:
```swift
open func getAnchorRect(anchor: String) -> CGRect? {
return nil
}
```
The result is that the file should contain, contiguously:
```swift
open func transitionNode(messageId: MessageId, media: Media, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return nil
}
open func getAnchorRect(anchor: String) -> CGRect? {
return nil
}
open func updateHiddenMedia(_ media: [Media]?) -> Bool {
return false
}
```
- [ ] **Step 2: Build**
Run the build command at the top of this plan.
Expected: build succeeds.
- [ ] **Step 3: Commit**
```sh
git add submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift
git commit -m "$(cat <<'EOF'
ChatMessageBubbleContentNode: add base getAnchorRect
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: Override `getAnchorRect` in `ChatMessageRichDataBubbleContentNode`
Walks the cached instant-page layout and returns the rect of an anchor (in the bubble content node's coordinate space) without triggering any side-effects.
**Files:**
- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift`
- [ ] **Step 1: Add the override and recursive helper**
Open the file. Find the existing `private func splitAnchor(_ url: String)` (around line 774). Insert two new methods directly above it (so the new public override comes before the private string helpers but after `findInstantPageMedia`):
```swift
override public func getAnchorRect(anchor: String) -> CGRect? {
guard let layout = self.currentPageLayout?.layout else {
return nil
}
if let rect = self.anchorRect(in: layout.items, anchor: anchor, baseY: 0.0) {
// Translate from layout/containerNode coords to bubble-content-node coords.
// containerNode is offset by (1, 1) from the bubble content node.
return rect.offsetBy(dx: 1.0, dy: 1.0)
}
return nil
}
private func anchorRect(in items: [InstantPageItem], anchor: String, baseY: CGFloat) -> CGRect? {
for item in items {
if let item = item as? InstantPageAnchorItem, item.anchor == anchor {
return CGRect(x: item.frame.minX, y: baseY + item.frame.minY, width: 1.0, height: 1.0)
} else if let item = item as? InstantPageTextItem {
if let (lineIndex, _) = item.anchors[anchor] {
let lineFrame = item.lines[lineIndex].frame
return CGRect(x: item.frame.minX + lineFrame.minX, y: baseY + item.frame.minY + lineFrame.minY, width: lineFrame.width, height: lineFrame.height)
}
} else if let item = item as? InstantPageTableItem {
if let (offset, _) = item.anchors[anchor] {
return CGRect(x: item.frame.minX, y: baseY + item.frame.minY + offset, width: item.frame.width, height: 1.0)
}
} else if let item = item as? InstantPageDetailsItem {
// Inner items are laid out below the title bar, so the recursive base
// must include titleHeight (mirrors InstantPageDetailsNode.linkSelectionRects).
if let rect = self.anchorRect(in: item.items, anchor: anchor, baseY: baseY + item.frame.minY + item.titleHeight) {
return rect
}
}
}
return nil
}
```
Note: the existing file already imports `InstantPageUI`, so `InstantPageItem`, `InstantPageAnchorItem`, `InstantPageTextItem`, `InstantPageTableItem`, and `InstantPageDetailsItem` resolve. `InstantPageTextItem.anchors` is typed `[String: (Int, Bool)]`, `InstantPageTableItem.anchors` is `[String: (CGFloat, Bool)]` — destructure accordingly.
- [ ] **Step 2: Build**
Run the build command.
Expected: build succeeds.
- [ ] **Step 3: Commit**
```sh
git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift
git commit -m "$(cat <<'EOF'
Rich bubble: add getAnchorRect override
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: Forward `getAnchorRect` from `ChatMessageBubbleItemNode`
Iterates content nodes and converts the rect to the bubble item node's coordinate space. Mirrors the existing `getQuoteRect` shape exactly.
**Files:**
- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift`
- [ ] **Step 1: Add the public forwarder**
Open the file. Find the existing `public func getQuoteRect(quote: String, offset: Int?) -> CGRect?` (around line 7237). Insert a new method directly after its closing brace, before `public func getInnerReplySubjectRect(...)`:
```swift
public func getAnchorRect(anchor: String) -> CGRect? {
for contentNode in self.contentNodes {
if let result = contentNode.getAnchorRect(anchor: anchor) {
return contentNode.view.convert(result, to: self.view)
}
}
return nil
}
```
- [ ] **Step 2: Build**
Run the build command.
Expected: build succeeds.
- [ ] **Step 3: Commit**
```sh
git add submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift
git commit -m "$(cat <<'EOF'
Bubble item: forward getAnchorRect to content nodes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Add `scrollToMessageIdWithAnchor` closure (declaration + 7 sites)
Adds the new `(MessageIndex, String) -> Void` closure to `ChatControllerInteraction`, the real implementation in `ChatController.swift`, and no-op stubs at the six other call sites. After this task the build is green and the closure works end-to-end on the chat-controller side; the rich-data bubble still routes through the old stub so the feature is not yet live.
**Files (8 edits):**
- Modify: `submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift` (3 edits: field, init param, assignment)
- Modify: `submodules/TelegramUI/Sources/ChatController.swift` (real implementation)
- Modify: `submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift` (no-op stub)
- Modify: `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift` (no-op stub)
- Modify: `submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift` (no-op stub)
- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift` (no-op stub)
- Modify: `submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift` (no-op stub)
- Modify: `submodules/TelegramUI/Sources/SharedAccountContext.swift` (no-op stub)
- [ ] **Step 1: Add the field on `ChatControllerInteraction`**
In `submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift`, find:
```swift
public let scrollToMessageId: (MessageIndex, CGFloat) -> Void
```
(around line 311). Insert directly after it:
```swift
public let scrollToMessageIdWithAnchor: (MessageIndex, String) -> Void
```
- [ ] **Step 2: Add the init parameter**
In the same file, find the init parameter list (around line 490):
```swift
scrollToMessageId: @escaping (MessageIndex, CGFloat) -> Void,
```
Insert directly after it:
```swift
scrollToMessageIdWithAnchor: @escaping (MessageIndex, String) -> Void,
```
- [ ] **Step 3: Add the init assignment**
In the same file, find (around line 622):
```swift
self.scrollToMessageId = scrollToMessageId
```
Insert directly after it:
```swift
self.scrollToMessageIdWithAnchor = scrollToMessageIdWithAnchor
```
- [ ] **Step 4: Add real implementation in `ChatController.swift`**
In `submodules/TelegramUI/Sources/ChatController.swift`, find (around line 5397):
```swift
}, scrollToMessageId: { [weak self] index, offset in
self?.chatDisplayNode.historyNode.scrollToMessage(index: index, offset: offset)
}, navigateToStory: { [weak self] message, storyId in
```
Insert a new closure between `scrollToMessageId` and `navigateToStory`:
```swift
}, scrollToMessageId: { [weak self] index, offset in
self?.chatDisplayNode.historyNode.scrollToMessage(index: index, offset: offset)
}, scrollToMessageIdWithAnchor: { [weak self] index, anchor in
guard let self else {
return
}
var anchorY: CGFloat?
self.chatDisplayNode.historyNode.forEachVisibleItemNode { itemNode in
guard anchorY == nil else {
return
}
if let itemNode = itemNode as? ChatMessageBubbleItemNode,
itemNode.item?.message.id == index.id,
let rect = itemNode.getAnchorRect(anchor: anchor) {
anchorY = rect.minY
}
}
if let anchorY {
self.chatDisplayNode.historyNode.scrollToMessage(
from: index, to: index,
animated: true, highlight: false,
scrollPosition: .bottom(anchorY)
)
} else {
self.chatDisplayNode.historyNode.scrollToMessage(index: index)
}
}, navigateToStory: { [weak self] message, storyId in
```
`scrollToMessage(from:to:animated:highlight:scrollPosition:)` is the existing public method on `ChatHistoryListNode` (declared at line 3585 in `ChatHistoryListNode.swift`); `quote`, `subject`, and `setupReply` use their default values. `ChatMessageBubbleItemNode` is already imported at the top of `ChatController.swift`. The `forEachVisibleItemNode` walk is sound because tapping the in-page anchor link requires the bubble to be at least partially visible.
- [ ] **Step 5: Add no-op stub in `BrowserBookmarksScreen.swift`**
In `submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift`, find (around line 183):
```swift
}, scrollToMessageId: { _, _ in
```
Replace with:
```swift
}, scrollToMessageId: { _, _ in
}, scrollToMessageIdWithAnchor: { _, _ in
```
- [ ] **Step 6: Add no-op stub in `ChatRecentActionsControllerNode.swift`**
In `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift`, find (around line 660):
```swift
}, scrollToMessageId: { _, _ in
```
Replace with:
```swift
}, scrollToMessageId: { _, _ in
}, scrollToMessageIdWithAnchor: { _, _ in
```
- [ ] **Step 7: Add no-op stub in `ChatSendAudioMessageContextPreview.swift`**
In `submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift`, find (around line 507):
```swift
}, scrollToMessageId: { _, _ in
```
Replace with:
```swift
}, scrollToMessageId: { _, _ in
}, scrollToMessageIdWithAnchor: { _, _ in
```
- [ ] **Step 8: Add no-op stub in `PeerInfoScreen.swift`**
In `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift`, find (around line 1278):
```swift
}, scrollToMessageId: { _, _ in
```
Replace with:
```swift
}, scrollToMessageId: { _, _ in
}, scrollToMessageIdWithAnchor: { _, _ in
```
- [ ] **Step 9: Add no-op stub in `OverlayAudioPlayerControllerNode.swift`**
In `submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift`, find (around line 252):
```swift
}, scrollToMessageId: { _, _ in
```
Replace with:
```swift
}, scrollToMessageId: { _, _ in
}, scrollToMessageIdWithAnchor: { _, _ in
```
- [ ] **Step 10: Add no-op stub in `SharedAccountContext.swift`**
In `submodules/TelegramUI/Sources/SharedAccountContext.swift`, find (around line 2565):
```swift
scrollToMessageId: { _, _ in
```
(Note: this site has no leading `}, ` because it is the first argument on its line — verify with `grep -n "scrollToMessageId:" submodules/TelegramUI/Sources/SharedAccountContext.swift`.)
Insert a new closure directly after the closing `}` of the `scrollToMessageId` stub. If the original looks like:
```swift
scrollToMessageId: { _, _ in
},
navigateToStory: { _, _ in
```
it should become:
```swift
scrollToMessageId: { _, _ in
},
scrollToMessageIdWithAnchor: { _, _ in
},
navigateToStory: { _, _ in
```
Match the surrounding indentation and trailing-comma style of the file.
- [ ] **Step 11: Build**
Run the build command.
Expected: build succeeds. Any compile error in this task means a stub site was missed or the closure type was mismatched — search for `scrollToMessageId:` again and confirm every site has a corresponding `scrollToMessageIdWithAnchor:`.
- [ ] **Step 12: Commit**
```sh
git add \
submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift \
submodules/TelegramUI/Sources/ChatController.swift \
submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift \
submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift \
submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift \
submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift \
submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift \
submodules/TelegramUI/Sources/SharedAccountContext.swift
git commit -m "$(cat <<'EOF'
ChatControllerInteraction: add scrollToMessageIdWithAnchor closure
Routes through ChatHistoryListNode.scrollToMessage with a custom
.center(.custom) callback that asks the bubble item for the anchor
rect's midY. Six existing no-op interaction sites get matching
no-op stubs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: Wire up `scrollToAnchor` in the rich-data bubble
Replace the stub body so that taps on in-page anchor links actually scroll. After this task the feature is live end-to-end.
**Files:**
- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift`
- [ ] **Step 1: Replace `scrollToAnchor` body**
In `ChatMessageRichDataBubbleContentNode.swift`, find (around line 796):
```swift
private func scrollToAnchor(_ anchor: String) {
guard let item = self.item else {
return
}
// 0.0 is offset
item.controllerInteraction.scrollToMessageId(item.message.index, 0.0)
}
```
Replace with:
```swift
private func scrollToAnchor(_ anchor: String) {
guard let item = self.item else {
return
}
if anchor.isEmpty {
item.controllerInteraction.scrollToMessageId(item.message.index, 0.0)
} else {
item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor)
}
}
```
The empty-anchor branch keeps the existing "scroll to message top" behavior for `#` URLs with no fragment.
- [ ] **Step 2: Build**
Run the build command.
Expected: build succeeds.
- [ ] **Step 3: Manual smoke test**
This project has no unit tests. Smoke-test in the simulator:
1. Launch the app on the iOS simulator.
2. Open a chat that contains a webpage message rendered as a rich-data bubble. Good source: a Wikipedia article URL whose Telegram instant-page render contains in-page section/footnote links (e.g., the "Contents" section or the `[1]`-style citation links).
3. Tap a section/footnote link inside the bubble.
4. Expected: the chat scrolls so that the target line of the bubble is centered in the visible area. If the bubble is partially off-screen, the chat scrolls to bring the line into view.
5. Tap a `#`-only link (no fragment) if you can find one. Expected: chat scrolls to the message top (existing pre-task behavior preserved).
If the scroll doesn't land where expected, double-check the coord conversions in Task 2 (`+1, +1` for `containerNode` inset) and Task 3 (`contentNode.view.convert(rect, to: self.view)`).
- [ ] **Step 4: Commit**
```sh
git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift
git commit -m "$(cat <<'EOF'
Rich bubble: scrollToAnchor scrolls to anchor's line
Empty anchor keeps the previous scroll-to-message-top behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Done
All five tasks complete leaves:
- Each commit independently builds.
- The feature is live: tapping an in-page anchor link inside a rich-data bubble scrolls the chat to center the target line.
- Reference popups and details expansion are deferred (per spec).

View file

@ -0,0 +1,149 @@
# ChatMessageRichDataBubbleContentNode.scrollToAnchor
## Background
`ChatMessageRichDataBubbleContentNode` renders a webpage's `instantPage` inline inside a chat message bubble (the same layout/tile machinery as `InstantPageControllerNode`, but embedded as a content node of `ChatMessageBubbleItemNode`).
The bubble already detects in-page anchor links (URL with a `#fragment`) when its base URL matches the current loaded webpage and routes them to a private `scrollToAnchor(_ anchor: String)`. That method is a stub today:
```swift
private func scrollToAnchor(_ anchor: String) {
guard let item = self.item else { return }
item.controllerInteraction.scrollToMessageId(item.message.index, 0.0)
}
```
`ChatHistoryListNode.scrollToMessage(index:offset:)` ignores the offset, so the anchor name is dropped and the bubble simply scrolls to the message top. Tapping a footnote / section link inside a long instant-page bubble does nothing useful when the target is below the fold.
## Goal
When an in-page anchor inside a rich-data bubble is tapped, scroll the chat history so the anchor's line lands at the top of the visible content area.
## Non-goals (explicitly deferred)
- **Reference popup**: `InstantPageControllerNode.scrollToAnchor` shows `InstantPageReferenceController` as an overlay when the anchor is a footnote-style "reference" (text item, non-empty anchor text). We will simply scroll to the line containing the reference instead. No popup.
- **Collapsed details expansion**: The bubble already no-ops `updateDetailsExpanded`, so the runtime never toggles `InstantPageDetailsItem` state. We compute the rect for anchors inside details items as if they were expanded; no expansion side-effect is performed. Worst case for a layout-collapsed details anchor is a slightly-off scroll target — acceptable for v1.
## Approach
Add a `getAnchorRect(anchor:)` resolver on the bubble (mirrors `getQuoteRect`'s shape: base no-op, rich-data override walks the layout, bubble item forwards to content nodes). The chat controller then uses `forEachVisibleItemNode` to find the bubble being scrolled to (it is by definition partially visible — the user tapped a link in it), reads the anchor's item-local y, and dispatches `historyNode.scrollToMessage(... scrollPosition: .bottom(anchorY))`. `.bottom(additionalOffset)` places the item so its frame.maxY lands at `(visibleSize.height - insets.bottom) + additionalOffset`; with `additionalOffset = anchorY` (item-local-y of the anchor's top edge), the anchor renders at the visual top of the chat's content area regardless of whether the item is short or tall. (`.center(.custom)` was the original pick but is bypassed for items that fit in the content area, and the rotation maps "list-coord low" to "visual bottom" in chat lists, so `.bottom` is the more uniform primitive here.)
### Components
#### 1. `ChatMessageBubbleContentNode.getAnchorRect(anchor:)` — base, default `nil`
Add an `open func getAnchorRect(anchor: String) -> CGRect? { return nil }` to the base class so callers don't need to type-test every content node.
#### 2. `ChatMessageRichDataBubbleContentNode.getAnchorRect(anchor:)` — override
Walk `self.currentPageLayout?.layout.items`, mirroring the cases in `InstantPageControllerNode.findAnchorItem`:
- `InstantPageAnchorItem` with matching `anchor` → return a 1pt rect at the item's `frame.origin`.
- `InstantPageTextItem`, `item.anchors[anchor] == (lineIndex, _)` → return the rect of `item.lines[lineIndex].frame`, offset by `item.frame.origin`.
- `InstantPageTableItem`, `item.anchors[anchor] == (offset, _)` → return a 1pt-tall row-width rect at `item.frame.origin + (0, offset)`.
- `InstantPageDetailsItem` → recurse into `item.items` with `baseY` increased by `item.frame.minY + item.titleHeight` (inner items live below the title bar; mirrors `InstantPageDetailsNode.linkSelectionRects`). Per non-goal #2, no expand side-effect.
The walk returns coordinates in *layout space* (= `containerNode`-local). The bubble's `containerNode` is offset `(1, 1)` from the bubble content node, so add `(1, 1)` before returning. The returned rect is in `ChatMessageRichDataBubbleContentNode`'s own coordinate space (its `view`).
If no anchor matches anywhere in the tree, return `nil`.
#### 3. `ChatMessageBubbleItemNode.getAnchorRect(anchor:)` — public
Add next to the existing `getQuoteRect(quote:offset:)`. Iterate `self.contentNodes`; for each, call `contentNode.getAnchorRect(anchor:)` and, if non-nil, return `contentNode.view.convert(rect, to: self.view)`. Return `nil` if no content node knows the anchor.
#### 4. `ChatControllerInteraction.scrollToMessageIdWithAnchor` — new closure
Add a new public closure on `ChatControllerInteraction`:
```swift
public let scrollToMessageIdWithAnchor: (MessageIndex, String) -> Void
```
Wire through the initializer (parameter, assignment) alongside the existing `scrollToMessageId`. The existing `scrollToMessageId(MessageIndex, CGFloat)` closure stays untouched — its 7 callers (incl. 6 no-op stubs) need no signature change.
Add no-op stubs `scrollToMessageIdWithAnchor: { _, _ in }` at the six existing no-op sites:
- `BrowserUI/Sources/BrowserBookmarksScreen.swift`
- `Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift`
- `Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift`
- `Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift`
- `TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift`
- `TelegramUI/Sources/SharedAccountContext.swift`
#### 5. Real implementation in `ChatController.swift`
Next to the existing `scrollToMessageId:` argument in the `ChatControllerInteraction(...)` construction, add:
```swift
scrollToMessageIdWithAnchor: { [weak self] index, anchor in
guard let self else { return }
var anchorY: CGFloat?
self.chatDisplayNode.historyNode.forEachVisibleItemNode { itemNode in
guard anchorY == nil else { return }
if let itemNode = itemNode as? ChatMessageBubbleItemNode,
itemNode.item?.message.id == index.id,
let rect = itemNode.getAnchorRect(anchor: anchor) {
anchorY = rect.minY
}
}
if let anchorY {
self.chatDisplayNode.historyNode.scrollToMessage(
from: index, to: index,
animated: true, highlight: false,
scrollPosition: .bottom(anchorY)
)
} else {
self.chatDisplayNode.historyNode.scrollToMessage(index: index)
}
}
```
`ChatHistoryListNode.scrollToMessage(from:to:animated:highlight:quote:subject:scrollPosition:setupReply:)` already accepts `scrollPosition` and routes it through `MessageHistoryScrollToSubject``ListViewScrollToItem.position`. The `.bottom(additionalOffset)` formula sets `frame.maxY' = (visibleSize.height - insets.bottom) + additionalOffset`; with `additionalOffset = anchorY` (the anchor's item-local y in pre-transform coords), the chat list — rotated 180° at the layer — renders the anchor at the visual top of the content area. The `forEachVisibleItemNode` walk is safe because tapping the in-page anchor link requires the bubble to be at least partially visible.
#### 6. Replace the `scrollToAnchor` stub
In `ChatMessageRichDataBubbleContentNode.swift`:
```swift
private func scrollToAnchor(_ anchor: String) {
guard let item = self.item else { return }
if anchor.isEmpty {
item.controllerInteraction.scrollToMessageId(item.message.index, 0.0)
} else {
item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor)
}
}
```
Empty anchor (the `#` with no fragment case) keeps the existing "scroll to message top" behavior.
## Files touched
| File | Change |
|---|---|
| `submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift` | Add `open func getAnchorRect(anchor:) -> CGRect?` returning `nil`. |
| `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` | Override `getAnchorRect`; rewrite `scrollToAnchor` body. |
| `submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift` | Add public `getAnchorRect(anchor:)`. |
| `submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift` | New `scrollToMessageIdWithAnchor` field + init param + assignment. |
| `submodules/TelegramUI/Sources/ChatController.swift` | Real implementation of the closure. |
| 6 no-op stub sites | Add `scrollToMessageIdWithAnchor: { _, _ in }` next to existing stub. |
## What is *not* changed
- No new types in `Display/`, `AccountContext/`, or `TelegramCore/`.
- No changes to `MessageHistoryScrollToSubject` or `ChatHistoryLocation`.
- No changes to `InstantPageUI/` (the layout-walking logic is replicated in the rich-data bubble file rather than exported, since it's both small and specialized for the embedded layout).
- No changes to the existing `scrollToMessageId(_, CGFloat)` closure or its 7 call sites' signatures.
## Verification
There are no unit tests in this project. Verification is a full Bazel build:
```sh
source ~/.zshrc 2>/dev/null; \
python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build \
--configurationPath build-system/appstore-configuration.json \
--gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \
--gitCodesigningType development --gitCodesigningUseCurrent \
--buildNumber=1 --configuration=debug_sim_arm64
```
Manual smoke test in the simulator: open a chat that contains a webpage message rendered as a rich-data bubble with an instant page that has internal anchors (e.g., a Wikipedia article with section links or footnote references). Tap a section link or footnote link; the chat should scroll so that the target line lands at the top of the visible content area.