diff --git a/docs/superpowers/plans/2026-05-01-rich-data-bubble-scroll-to-anchor.md b/docs/superpowers/plans/2026-05-01-rich-data-bubble-scroll-to-anchor.md new file mode 100644 index 0000000000..bcdfa83373 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-rich-data-bubble-scroll-to-anchor.md @@ -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 1–3 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) +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) +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) +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) +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) +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). diff --git a/docs/superpowers/specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md b/docs/superpowers/specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md new file mode 100644 index 0000000000..bdd859d8a0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md @@ -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. diff --git a/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift b/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift index bc84de9157..7b88fba51b 100644 --- a/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift +++ b/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift @@ -83,6 +83,7 @@ public final class BrowserBookmarksScreen: ViewController { controller.openUrl(url.url) controller.dismiss() } + }, openExternalInstantPage: { _ in }, shareCurrentLocation: { }, shareAccountContact: { }, sendBotCommand: { _, _ in @@ -179,7 +180,8 @@ public final class BrowserBookmarksScreen: ViewController { }, requestMessageUpdate: { _, _, _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { - }, scrollToMessageId: { _ in + }, scrollToMessageId: { _, _ in + }, scrollToMessageIdWithAnchor: { _, _ in }, navigateToStory: { _, _ in }, attemptedNavigationToPrivateQuote: { _ in }, forceUpdateWarpContents: { diff --git a/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift b/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift index 933b8af958..996b15b381 100644 --- a/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift +++ b/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift @@ -401,6 +401,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg self.settings = InstantPagePresentationSettings( themeType: self.presentationData.theme.overallDarkAppearance ? .dark : .light, fontSize: fontSize, + lineSpacingFactor: 1.0, forceSerif: state.isSerif, autoNightMode: false, ignoreAutoNightModeUntil: 0 @@ -498,7 +499,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg return } - let currentLayout = instantPageLayoutForWebPage(webPage, instantPage: instantPage, userLocation: self.sourceLocation.userLocation, boundingWidth: size.width, safeInset: insets.left, strings: self.presentationData.strings, theme: self.theme, dateTimeFormat: self.presentationData.dateTimeFormat, webEmbedHeights: self.currentWebEmbedHeights, cachedMessageSyntaxHighlight: self.codeHighlight) + let currentLayout = instantPageLayoutForWebPage(webPage, instantPage: instantPage, userLocation: self.sourceLocation.userLocation, boundingWidth: size.width, sideInset: 17.0, safeInset: insets.left, strings: self.presentationData.strings, theme: self.theme, dateTimeFormat: self.presentationData.dateTimeFormat, webEmbedHeights: self.currentWebEmbedHeights, cachedMessageSyntaxHighlight: self.codeHighlight) let currentLayoutTiles = instantPageTilesFromLayout(currentLayout, boundingWidth: size.width) diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index a00cc9c112..05cd1827a1 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -2687,6 +2687,9 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur return false } let pinToEdgeTopInset = self.calculatePinToEdgeTopInset() + if pinToEdgeTopInset <= 0.0 { + return false + } for itemNode in self.itemNodes { if itemNode.index == targetIndex { let extensionOffset = self.pinToEdgeBottomExtension(forPinnedHeight: itemNode.apparentBounds.height) diff --git a/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift b/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift index 8df30b2e86..95f7b281d0 100644 --- a/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift @@ -256,7 +256,7 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { updateLayout = true animated = true } - if previousSettings.fontSize != settings.fontSize || previousSettings.forceSerif != settings.forceSerif { + if previousSettings.fontSize != settings.fontSize || previousSettings.lineSpacingFactor != settings.lineSpacingFactor || previousSettings.forceSerif != settings.forceSerif { animated = false updateLayout = true } @@ -475,7 +475,7 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { return } - let currentLayout = instantPageLayoutForWebPage(webPage, instantPage: instantPage, userLocation: self.sourceLocation.userLocation, boundingWidth: containerLayout.size.width, safeInset: containerLayout.safeInsets.left, strings: self.strings, theme: theme, dateTimeFormat: self.dateTimeFormat, webEmbedHeights: self.currentWebEmbedHeights) + let currentLayout = instantPageLayoutForWebPage(webPage, instantPage: instantPage, userLocation: self.sourceLocation.userLocation, boundingWidth: containerLayout.size.width, sideInset: 17.0, safeInset: containerLayout.safeInsets.left, strings: self.strings, theme: theme, dateTimeFormat: self.dateTimeFormat, webEmbedHeights: self.currentWebEmbedHeights) let currentLayoutTiles = instantPageTilesFromLayout(currentLayout, boundingWidth: containerLayout.size.width) diff --git a/submodules/InstantPageUI/Sources/InstantPageLayout.swift b/submodules/InstantPageUI/Sources/InstantPageLayout.swift index 39baa17a74..d511a57a4a 100644 --- a/submodules/InstantPageUI/Sources/InstantPageLayout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageLayout.swift @@ -165,8 +165,7 @@ private func instantPageFirstTextLineMidY(in items: [InstantPageItem]) -> CGFloa return nil } -public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: MediaResourceUserLocation, rtl: Bool, block: InstantPageBlock, boundingWidth: CGFloat, horizontalInset: CGFloat, safeInset: CGFloat, isCover: Bool, previousItems: [InstantPageItem], fillToSize: CGSize?, media: [EngineMedia.Id: EngineMedia], mediaIndexCounter: inout Int, embedIndexCounter: inout Int, detailsIndexCounter: inout Int, theme: InstantPageTheme, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, webEmbedHeights: [Int : CGFloat] = [:], cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? = nil, excludeCaptions: Bool) -> InstantPageLayout { - +public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: MediaResourceUserLocation, rtl: Bool, block: InstantPageBlock, boundingWidth: CGFloat, horizontalInset: CGFloat, safeInset: CGFloat, isCover: Bool, previousItems: [InstantPageItem], fillToSize: CGSize?, media: [EngineMedia.Id: EngineMedia], mediaIndexCounter: inout Int, embedIndexCounter: inout Int, detailsIndexCounter: inout Int, theme: InstantPageTheme, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, webEmbedHeights: [Int : CGFloat] = [:], cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? = nil, excludeCaptions: Bool, isLast: Bool) -> InstantPageLayout { let layoutCaption: (InstantPageCaption, CGSize) -> ([InstantPageItem], CGSize) = { caption, contentSize in var items: [InstantPageItem] = [] var offset = contentSize.height @@ -218,7 +217,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: switch block { case let .cover(block): - return layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: block, boundingWidth: boundingWidth, horizontalInset: horizontalInset, safeInset: safeInset, isCover: true, previousItems:previousItems, fillToSize: fillToSize, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false) + return layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: block, boundingWidth: boundingWidth, horizontalInset: horizontalInset, safeInset: safeInset, isCover: true, previousItems:previousItems, fillToSize: fillToSize, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: true) case let .title(text): let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .header, link: false) @@ -355,9 +354,13 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage) return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case .divider: - let lineWidth = floor(boundingWidth / 2.0) - let shapeItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: floor((boundingWidth - lineWidth) / 2.0), y: 0.0), size: CGSize(width: lineWidth, height: 1.0)), shapeFrame: CGRect(origin: CGPoint(), size: CGSize(width: lineWidth, height: 1.0)), shape: .rect, color: theme.textCategories.caption.color) - return InstantPageLayout(origin: CGPoint(), contentSize: shapeItem.frame.size, items: [shapeItem]) + if isLast { + return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: []) + } else { + let lineWidth = floor(boundingWidth / 2.0) + let shapeItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: floor((boundingWidth - lineWidth) / 2.0), y: 0.0), size: CGSize(width: lineWidth, height: 1.0)), shapeFrame: CGRect(origin: CGPoint(), size: CGSize(width: lineWidth, height: 1.0)), shape: .rect, color: theme.textCategories.caption.color) + return InstantPageLayout(origin: CGPoint(), contentSize: shapeItem.frame.size, items: [shapeItem]) + } case let .list(contentItems, ordered): var contentSize = CGSize(width: boundingWidth, height: 0.0) var maxIndexWidth: CGFloat = 0.0 @@ -462,8 +465,9 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: var previousBlock: InstantPageBlock? var originY: CGFloat = contentSize.height var firstBlockLineMidY: CGFloat? - for subBlock in blocks { - let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: listItems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false) + for i in 0 ..< blocks.count { + let subBlock = blocks[i] + let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: listItems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1) let spacing: CGFloat = previousBlock != nil && subLayout.contentSize.height > 0.0 ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock) : 0.0 let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: horizontalInset + indexSpacing + maxIndexWidth, y: contentSize.height + spacing)) @@ -680,7 +684,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: var i = 0 for subItem in innerItems { let frame = mosaicLayout[i].0 - let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subItem, boundingWidth: frame.width, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: items, fillToSize: frame.size, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: true) + let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subItem, boundingWidth: frame.width, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: items, fillToSize: frame.size, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: true, isLast: false) items.append(contentsOf: subLayout.flattenedItemsWithOrigin(frame.origin)) i += 1 } @@ -753,8 +757,9 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: } var previousBlock: InstantPageBlock? - for subBlock in blocks { - let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false) + for i in 0 ..< blocks.count { + let subBlock = blocks[i] + let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1) let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock) let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: horizontalInset + lineInset, y: contentSize.height + spacing)) @@ -936,8 +941,9 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: var subDetailsIndex = 0 var previousBlock: InstantPageBlock? - for subBlock in blocks { - let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth, horizontalInset: horizontalInset, safeInset: safeInset, isCover: false, previousItems: subitems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &subDetailsIndex, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false) + for i in 0 ..< blocks.count { + let subBlock = blocks[i] + let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth, horizontalInset: horizontalInset, safeInset: safeInset, isCover: false, previousItems: subitems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &subDetailsIndex, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1) let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock) let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: 0.0, y: contentSize.height + spacing)) @@ -1007,10 +1013,12 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: contentSize.height += item.frame.height items.append(item) - let inset: CGFloat = i == articles.count - 1 ? 0.0 : 17.0 - let lineSize = CGSize(width: boundingWidth - inset, height: UIScreenPixel) - let shapeItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: rtl || item.rtl ? 0.0 : inset, y: contentSize.height - lineSize.height), size: lineSize), shapeFrame: CGRect(origin: CGPoint(), size: lineSize), shape: .rect, color: theme.controlColor) - items.append(shapeItem) + if !isLast { + let inset: CGFloat = i == articles.count - 1 ? 0.0 : 17.0 + let lineSize = CGSize(width: boundingWidth - inset, height: UIScreenPixel) + let shapeItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: rtl || item.rtl ? 0.0 : inset, y: contentSize.height - lineSize.height), size: lineSize), shapeFrame: CGRect(origin: CGPoint(), size: lineSize), shape: .rect, color: theme.controlColor) + items.append(shapeItem) + } } return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .map(latitude, longitude, zoom, dimensions, caption): @@ -1046,7 +1054,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: } } -public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instantPage: InstantPage?, userLocation: MediaResourceUserLocation, boundingWidth: CGFloat, safeInset: CGFloat, strings: PresentationStrings, theme: InstantPageTheme, dateTimeFormat: PresentationDateTimeFormat, webEmbedHeights: [Int : CGFloat] = [:], cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? = nil, addFeedback: Bool = true) -> InstantPageLayout { +public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instantPage: InstantPage?, userLocation: MediaResourceUserLocation, boundingWidth: CGFloat, sideInset: CGFloat, safeInset: CGFloat, strings: PresentationStrings, theme: InstantPageTheme, dateTimeFormat: PresentationDateTimeFormat, webEmbedHeights: [Int : CGFloat] = [:], cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? = nil, addFeedback: Bool = true) -> InstantPageLayout { var maybeLoadedContent: TelegramMediaWebpageLoadedContent? if case let .Loaded(content) = webPage.content { maybeLoadedContent = content @@ -1074,8 +1082,9 @@ public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instant var detailsIndexCounter: Int = 0 var previousBlock: InstantPageBlock? - for block in pageBlocks { - let blockLayout = layoutInstantPageBlock(webpage: webPage, userLocation: userLocation, rtl: rtl, block: block, boundingWidth: boundingWidth, horizontalInset: 17.0 + safeInset, safeInset: safeInset, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false) + for i in 0 ..< pageBlocks.count { + let block = pageBlocks[i] + let blockLayout = layoutInstantPageBlock(webpage: webPage, userLocation: userLocation, rtl: rtl, block: block, boundingWidth: boundingWidth, horizontalInset: sideInset + safeInset, safeInset: safeInset, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == pageBlocks.count - 1) let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block) let blockItems = blockLayout.flattenedItemsWithOrigin(CGPoint(x: 0.0, y: contentSize.height + spacing)) items.append(contentsOf: blockItems) diff --git a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift index f3ac903e2e..c90d09e5b3 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift @@ -287,7 +287,7 @@ public final class InstantPageTextItem: InstantPageItem { context.setFillColor(color.cgColor) } let itemFrame = item.frame.offsetBy(dx: lineFrame.minX, dy: 0.0) - context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + lineFrame.size.height + 1.0, width: itemFrame.size.width, height: 1.0)) + context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + lineFrame.size.height + 2.0, width: itemFrame.size.width, height: 1.0)) } } } diff --git a/submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift b/submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift index 2ab0a10df8..8b49bbfcdd 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift @@ -197,6 +197,9 @@ final class InstantPageTextStyleStack { if let link = link, let linkColor = linkColor { attributes[NSAttributedString.Key.foregroundColor] = linkColor + if linkColor == color { + attributes[NSAttributedString.Key.underlineStyle] = NSUnderlineStyle.single.rawValue as NSNumber + } if link, let linkMarkerColor = linkMarkerColor { attributes[NSAttributedString.Key(rawValue: InstantPageMarkerColorAttribute)] = linkMarkerColor } diff --git a/submodules/InstantPageUI/Sources/InstantPageTheme.swift b/submodules/InstantPageUI/Sources/InstantPageTheme.swift index dccc23d9fd..72a6008861 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTheme.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTheme.swift @@ -4,23 +4,29 @@ import Display import TelegramPresentationData import TelegramUIPreferences -enum InstantPageFontStyle { +public enum InstantPageFontStyle { case sans case serif } -struct InstantPageFont { +public struct InstantPageFont { let style: InstantPageFontStyle let size: CGFloat let lineSpacingFactor: CGFloat + + public init(style: InstantPageFontStyle, size: CGFloat, lineSpacingFactor: CGFloat) { + self.style = style + self.size = size + self.lineSpacingFactor = lineSpacingFactor + } } -struct InstantPageTextAttributes { +public struct InstantPageTextAttributes { let font: InstantPageFont let color: UIColor let underline: Bool - init(font: InstantPageFont, color: UIColor, underline: Bool = false) { + public init(font: InstantPageFont, color: UIColor, underline: Bool = false) { self.font = font self.color = color self.underline = underline @@ -30,8 +36,8 @@ struct InstantPageTextAttributes { return InstantPageTextAttributes(font: self.font, color: self.color, underline: underline) } - func withUpdatedFontStyles(sizeMultiplier: CGFloat, forceSerif: Bool) -> InstantPageTextAttributes { - return InstantPageTextAttributes(font: InstantPageFont(style: forceSerif ? .serif : self.font.style, size: floor(self.font.size * sizeMultiplier), lineSpacingFactor: self.font.lineSpacingFactor), color: self.color, underline: self.underline) + func withUpdatedFontStyles(sizeMultiplier: CGFloat, lineSpacingFactor: CGFloat, forceSerif: Bool) -> InstantPageTextAttributes { + return InstantPageTextAttributes(font: InstantPageFont(style: forceSerif ? .serif : self.font.style, size: floor(self.font.size * sizeMultiplier), lineSpacingFactor: self.font.lineSpacingFactor * lineSpacingFactor), color: self.color, underline: self.underline) } } @@ -56,6 +62,17 @@ public struct InstantPageTextCategories { let table: InstantPageTextAttributes let article: InstantPageTextAttributes + public init(kicker: InstantPageTextAttributes, header: InstantPageTextAttributes, subheader: InstantPageTextAttributes, paragraph: InstantPageTextAttributes, caption: InstantPageTextAttributes, credit: InstantPageTextAttributes, table: InstantPageTextAttributes, article: InstantPageTextAttributes) { + self.kicker = kicker + self.header = header + self.subheader = subheader + self.paragraph = paragraph + self.caption = caption + self.credit = credit + self.table = table + self.article = article + } + func attributes(type: InstantPageTextCategoryType, link: Bool) -> InstantPageTextAttributes { switch type { case .kicker: @@ -77,8 +94,17 @@ public struct InstantPageTextCategories { } } - func withUpdatedFontStyles(sizeMultiplier: CGFloat, forceSerif: Bool) -> InstantPageTextCategories { - return InstantPageTextCategories(kicker: self.kicker.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), header: self.header.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), subheader: self.subheader.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), paragraph: self.paragraph.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), caption: self.caption.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), credit: self.credit.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), table: self.table.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), article: self.article.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif)) + func withUpdatedFontStyles(sizeMultiplier: CGFloat, lineSpacingFactor: CGFloat, forceSerif: Bool) -> InstantPageTextCategories { + return InstantPageTextCategories( + kicker: self.kicker.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), + header: self.header.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), + subheader: self.subheader.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), + paragraph: self.paragraph.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), + caption: self.caption.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), + credit: self.credit.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), + table: self.table.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), + article: self.article.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif) + ) } } @@ -132,8 +158,8 @@ public final class InstantPageTheme { self.overlayPanelColor = overlayPanelColor } - public func withUpdatedFontStyles(sizeMultiplier: CGFloat, forceSerif: Bool) -> InstantPageTheme { - return InstantPageTheme(type: type, pageBackgroundColor: pageBackgroundColor, textCategories: self.textCategories.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), serif: forceSerif, codeBlockBackgroundColor: codeBlockBackgroundColor, linkColor: linkColor, textHighlightColor: textHighlightColor, linkHighlightColor: linkHighlightColor, markerColor: markerColor, panelBackgroundColor: panelBackgroundColor, panelHighlightedBackgroundColor: panelHighlightedBackgroundColor, panelPrimaryColor: panelPrimaryColor, panelSecondaryColor: panelSecondaryColor, panelAccentColor: panelAccentColor, tableBorderColor: tableBorderColor, tableHeaderColor: tableHeaderColor, controlColor: controlColor, imageTintColor: imageTintColor, overlayPanelColor: overlayPanelColor) + public func withUpdatedFontStyles(sizeMultiplier: CGFloat, lineSpacingFactor: CGFloat, forceSerif: Bool) -> InstantPageTheme { + return InstantPageTheme(type: type, pageBackgroundColor: pageBackgroundColor, textCategories: self.textCategories.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), serif: forceSerif, codeBlockBackgroundColor: codeBlockBackgroundColor, linkColor: linkColor, textHighlightColor: textHighlightColor, linkHighlightColor: linkHighlightColor, markerColor: markerColor, panelBackgroundColor: panelBackgroundColor, panelHighlightedBackgroundColor: panelHighlightedBackgroundColor, panelPrimaryColor: panelPrimaryColor, panelSecondaryColor: panelSecondaryColor, panelAccentColor: panelAccentColor, tableBorderColor: tableBorderColor, tableHeaderColor: tableHeaderColor, controlColor: controlColor, imageTintColor: imageTintColor, overlayPanelColor: overlayPanelColor) } func headingTextAttributes(level: Int32, link: Bool) -> InstantPageTextAttributes { @@ -339,14 +365,14 @@ func instantPageThemeTypeForSettingsAndTime(themeSettings: PresentationThemeSett public func instantPageThemeForType(_ type: InstantPageThemeType, settings: InstantPagePresentationSettings) -> InstantPageTheme { switch type { - case .light: - return lightTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), forceSerif: settings.forceSerif) - case .sepia: - return sepiaTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), forceSerif: settings.forceSerif) - case .gray: - return grayTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), forceSerif: settings.forceSerif) - case .dark: - return darkTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), forceSerif: settings.forceSerif) + case .light: + return lightTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), lineSpacingFactor: settings.lineSpacingFactor, forceSerif: settings.forceSerif) + case .sepia: + return sepiaTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), lineSpacingFactor: settings.lineSpacingFactor, forceSerif: settings.forceSerif) + case .gray: + return grayTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), lineSpacingFactor: settings.lineSpacingFactor, forceSerif: settings.forceSerif) + case .dark: + return darkTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), lineSpacingFactor: settings.lineSpacingFactor, forceSerif: settings.forceSerif) } } diff --git a/submodules/TelegramUI/Components/Chat/ChatBotInfoItem/Sources/ChatBotInfoItem.swift b/submodules/TelegramUI/Components/Chat/ChatBotInfoItem/Sources/ChatBotInfoItem.swift index 39aec40872..abb6a5e6db 100644 --- a/submodules/TelegramUI/Components/Chat/ChatBotInfoItem/Sources/ChatBotInfoItem.swift +++ b/submodules/TelegramUI/Components/Chat/ChatBotInfoItem/Sources/ChatBotInfoItem.swift @@ -185,7 +185,7 @@ public final class ChatBotInfoItemNode: ListViewItemNode { break case .ignore: return .fail - case .url, .phone, .peerMention, .textMention, .botCommand, .hashtag, .instantPage, .wallpaper, .theme, .call, .conferenceCall, .openMessage, .timecode, .bankCard, .tooltip, .openPollResults, .copy, .largeEmoji, .customEmoji, .date, .custom: + case .url, .phone, .peerMention, .textMention, .botCommand, .hashtag, .instantPage, .wallpaper, .theme, .call, .conferenceCall, .openMessage, .timecode, .bankCard, .tooltip, .openPollResults, .copy, .largeEmoji, .customEmoji, .date, .custom, .externalInstantPage: return .waitForSingleTap } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift index 0e849d382e..539164a44f 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift @@ -162,6 +162,7 @@ public struct ChatMessageBubbleContentTapAction { case date(Int32, String) case largeEmoji(String, String?, TelegramMediaFile) case customEmoji(TelegramMediaFile) + case externalInstantPage(url: Url, webpageId: MediaId, anchor: String?) case custom(() -> Void) } @@ -260,7 +261,11 @@ open class ChatMessageBubbleContentNode: ASDisplayNode { 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 } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 2195dfb30a..4a117bf8a0 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -1347,7 +1347,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI break case .ignore: return .fail - case .url, .phone, .peerMention, .textMention, .botCommand, .hashtag, .instantPage, .wallpaper, .theme, .call, .conferenceCall, .openMessage, .timecode, .bankCard, .tooltip, .openPollResults, .copy, .largeEmoji, .customEmoji, .date, .custom: + case .url, .phone, .peerMention, .textMention, .botCommand, .hashtag, .instantPage, .wallpaper, .theme, .call, .conferenceCall, .openMessage, .timecode, .bankCard, .tooltip, .openPollResults, .copy, .largeEmoji, .customEmoji, .date, .custom, .externalInstantPage: return .waitForSingleTap } } @@ -5791,6 +5791,27 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI item.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl(url: url.url, concealed: url.concealed, message: item.content.firstMessage, allowInlineWebpageResolution: url.allowInlineWebpageResolution, progress: tapAction.activate?())) }, contextMenuOnLongPress: !tapAction.hasLongTapAction)) } + case let .externalInstantPage(url, webpageId, anchor): + if case .longTap = gesture, !tapAction.hasLongTapAction, let item = self.item { + let tapMessage = item.content.firstMessage + var subFrame = self.backgroundNode.frame + if case .group = item.content { + for contentNode in self.contentNodes { + if contentNode.item?.message.stableId == tapMessage.stableId { + subFrame = contentNode.frame.insetBy(dx: 0.0, dy: -4.0) + break + } + } + } + return .openContextMenu(InternalBubbleTapAction.OpenContextMenu(tapMessage: tapMessage, selectAll: false, subFrame: subFrame, disableDefaultPressAnimation: true)) + } else { + return .action(InternalBubbleTapAction.Action({ [weak self] in + guard let self, let item = self.item else { + return + } + item.controllerInteraction.openExternalInstantPage(ChatControllerInteraction.OpenInstantPage(webpageId: webpageId, url: url.url, anchor: anchor, concealed: true, progress: tapAction.activate?())) + }, contextMenuOnLongPress: !tapAction.hasLongTapAction)) + } case let .phone(number): return .action(InternalBubbleTapAction.Action({ [weak self] in guard let self, let item = self.item, let contentNode = self.contextContentNodeForLink(number, rects: rects) else { @@ -5999,6 +6020,42 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { disableDefaultPressAnimation = true } + case let .externalInstantPage(url, webpageId, anchor): + if tapAction.hasLongTapAction { + return .action(InternalBubbleTapAction.Action({}, actionWithLongTapRecognizer: { [weak self] gesture in + guard let self, let item = self.item else { + return + } + let cleanUrl = url.url.replacingOccurrences(of: "mailto:", with: "") + guard let contentNode = self.contextContentNodeForLink(cleanUrl, rects: rects) else { + return + } + item.controllerInteraction.longTap(.url(url.url), ChatControllerInteraction.LongTapParams(message: item.content.firstMessage, contentNode: contentNode, messageNode: self, progress: tapAction.activate?(), gesture: gesture)) + }, contextMenuOnLongPress: false)) + } else { + disableDefaultPressAnimation = true + } + + if case .longTap = gesture, !tapAction.hasLongTapAction, let item = self.item { + let tapMessage = item.content.firstMessage + var subFrame = self.backgroundNode.frame + if case .group = item.content { + for contentNode in self.contentNodes { + if contentNode.item?.message.stableId == tapMessage.stableId { + subFrame = contentNode.frame.insetBy(dx: 0.0, dy: -4.0) + break + } + } + } + return .openContextMenu(InternalBubbleTapAction.OpenContextMenu(tapMessage: tapMessage, selectAll: false, subFrame: subFrame, disableDefaultPressAnimation: true)) + } else { + return .action(InternalBubbleTapAction.Action({ [weak self] in + guard let self, let item = self.item else { + return + } + item.controllerInteraction.openExternalInstantPage(ChatControllerInteraction.OpenInstantPage(webpageId: webpageId, url: url.url, anchor: anchor, concealed: true, progress: tapAction.activate?())) + }, contextMenuOnLongPress: !tapAction.hasLongTapAction)) + } case let .phone(number): if tapAction.hasLongTapAction { return .action(InternalBubbleTapAction.Action({}, actionWithLongTapRecognizer: { [weak self] gesture in @@ -7187,7 +7244,16 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } return nil } - + + 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 + } + public func getInnerReplySubjectRect(innerSubject: EngineMessageReplyInnerSubject) -> CGRect? { switch innerSubject { case let .todoItem(todoItemId): diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index dc033a5580..f09fc1549f 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -22,6 +22,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode private var visibleTiles: [Int: InstantPageTileNode] = [:] private var visibleItemsWithNodes: [Int: InstantPageNode] = [:] private var currentPageLayout: (boundingWidth: CGFloat, layout: InstantPageLayout)? + private var pageTheme: InstantPageTheme? private var distanceThresholdGroupCount: [Int: Int] = [:] private var currentLayoutItemsWithNodes: [InstantPageItem] = [] private var currentExpandedDetails: [Int : Bool]? @@ -56,6 +57,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode } override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { + let previousItem = self.item let currentPageLayout = self.currentPageLayout let previousCurrentLayoutTiles = self.currentLayoutTiles @@ -71,20 +73,117 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode var pageLayout: InstantPageLayout? var currentLayoutTiles: [InstantPageTile] = [] + let isDark = item.presentationData.theme.theme.overallDarkAppearance + let isIncoming = item.message.effectivelyIncoming(item.context.account.peerId) + let messageTheme = isIncoming ? item.presentationData.theme.theme.chat.message.incoming : item.presentationData.theme.theme.chat.message.outgoing + + var underlineLinks = true + if !messageTheme.primaryTextColor.isEqual(messageTheme.linkTextColor) { + underlineLinks = false + } + let _ = underlineLinks + + let author = item.message.author + let mainColor: UIColor + var secondaryColor: UIColor? = nil + var tertiaryColor: UIColor? = nil + + let nameColors: PeerNameColors.Colors? + switch author?.nameColor { + case let .preset(nameColor): + nameColors = item.context.peerNameColors.get(nameColor, dark: item.presentationData.theme.theme.overallDarkAppearance) + case let .collectible(collectibleColor): + nameColors = collectibleColor.peerNameColors(dark: item.presentationData.theme.theme.overallDarkAppearance) + default: + nameColors = nil + } + + let codeBlockTitleColor: UIColor + let codeBlockAccentColor: UIColor + let codeBlockBackgroundColor: UIColor + if !isIncoming { + mainColor = messageTheme.accentTextColor + if let _ = nameColors?.secondary { + secondaryColor = .clear + } + if let _ = nameColors?.tertiary { + tertiaryColor = .clear + } + + if item.presentationData.theme.theme.overallDarkAppearance { + codeBlockTitleColor = .white + codeBlockAccentColor = UIColor(white: 1.0, alpha: 0.5) + codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.25) + } else { + codeBlockTitleColor = mainColor + codeBlockAccentColor = mainColor + codeBlockBackgroundColor = mainColor.withMultipliedAlpha(0.1) + } + } else { + let authorNameColor = nameColors?.main + secondaryColor = nameColors?.secondary + tertiaryColor = nameColors?.tertiary + + if let authorNameColor { + mainColor = authorNameColor + } else { + mainColor = messageTheme.accentTextColor + } + + codeBlockTitleColor = mainColor + codeBlockAccentColor = mainColor + + if item.presentationData.theme.theme.overallDarkAppearance { + codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.65) + } else { + codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.05) + } + } + + let _ = secondaryColor + let _ = tertiaryColor + + let _ = codeBlockTitleColor + let _ = codeBlockAccentColor + + let textCategories = InstantPageTextCategories( + kicker: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor), + header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 24.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor), + subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 19.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor), + paragraph: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 17.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), + caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor), + credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor), + table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), + article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor) + ) + let pageTheme = InstantPageTheme( + type: isDark ? .dark : .light, + pageBackgroundColor: .clear, + textCategories: textCategories, + serif: false, + codeBlockBackgroundColor: codeBlockBackgroundColor, + linkColor: messageTheme.linkTextColor, + textHighlightColor: messageTheme.accentTextColor.withMultipliedAlpha(0.1), + linkHighlightColor: messageTheme.linkTextColor.withMultipliedAlpha(0.1), + markerColor: UIColor(rgb: 0xfef3bc), + panelBackgroundColor: messageTheme.accentControlColor.withMultipliedAlpha(0.1), + panelHighlightedBackgroundColor: messageTheme.accentControlColor.withMultipliedAlpha(0.25), + panelPrimaryColor: messageTheme.primaryTextColor, + panelSecondaryColor: messageTheme.secondaryTextColor, + panelAccentColor: messageTheme.accentTextColor, + tableBorderColor: isDark || !isIncoming ? messageTheme.accentControlColor.withMultipliedAlpha(0.25) : UIColor(white: 0.0, alpha: 0.1), + tableHeaderColor: isDark || !isIncoming ? messageTheme.accentControlColor.withMultipliedAlpha(0.1) : UIColor(white: 0.0, alpha: 0.05), + controlColor: messageTheme.accentControlColor, + imageTintColor: nil, + overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13) + ) + if let webpage = item.message.media.first(where: { $0 is TelegramMediaWebpage }) as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content, let instantPage = content.instantPage { - if let current = currentPageLayout, current.boundingWidth == boundingSize.width { + if let current = currentPageLayout, current.boundingWidth == boundingSize.width, previousItem?.presentationData.theme.theme === item.presentationData.theme.theme { pageLayout = current.layout currentLayoutTiles = previousCurrentLayoutTiles } else { - let pageTheme = instantPageThemeForType(item.presentationData.theme.theme.overallDarkAppearance ? .dark : .light, settings: InstantPagePresentationSettings( - themeType: item.presentationData.theme.theme.overallDarkAppearance ? .dark : .light, - fontSize: .standard, - lineSpacingFactor: 0.9, - forceSerif: false, - autoNightMode: false, - ignoreAutoNightModeUntil: 0 - )) - pageLayout = instantPageLayoutForWebPage(webpage, instantPage: instantPage._parse(), userLocation: .other, boundingWidth: boundingWidth - 2.0, safeInset: 0.0, strings: item.presentationData.strings, theme: pageTheme, dateTimeFormat: item.presentationData.dateTimeFormat, webEmbedHeights: [:], addFeedback: false) + pageLayout = instantPageLayoutForWebPage(webpage, instantPage: instantPage._parse(), userLocation: .other, boundingWidth: boundingWidth - 2.0, sideInset: 10.0, safeInset: 0.0, strings: item.presentationData.strings, theme: pageTheme, dateTimeFormat: item.presentationData.dateTimeFormat, webEmbedHeights: [:], addFeedback: false) if let pageLayout { currentLayoutTiles = instantPageTilesFromLayout(pageLayout, boundingWidth: boundingWidth) } @@ -100,6 +199,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode return } self.item = item + self.pageTheme = pageTheme self.containerNode.frame = CGRect(origin: CGPoint(x: 1.0, y: 1.0), size: CGSize(width: boundingSize.width - 2.0, height: boundingSize.height - 2.0)) @@ -158,17 +258,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode } private func updateVisibleItems(visibleBounds: CGRect, animated: Bool = false) { - guard let messageItem = self.item else { + guard let messageItem = self.item, let pageTheme = self.pageTheme else { return } - let pageTheme = instantPageThemeForType(messageItem.presentationData.theme.theme.overallDarkAppearance ? .dark : .light, settings: InstantPagePresentationSettings( - themeType: messageItem.presentationData.theme.theme.overallDarkAppearance ? .dark : .light, - fontSize: .standard, - lineSpacingFactor: 0.9, - forceSerif: false, - autoNightMode: false, - ignoreAutoNightModeUntil: 0 - )) let sourceLocation = InstantPageSourceLocation(userLocation: .other, peerType: .otherPrivate) var visibleTileIndices = Set() @@ -427,13 +519,23 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode // The chat URL handler will show a confirmation when concealed is true // and the visible text differs from the destination — safer default. let concealed = true - let url = ChatMessageBubbleContentTapAction.Url(url: urlHit.urlItem.url, concealed: concealed) + let url = ChatMessageBubbleContentTapAction.Url(url: urlHit.urlItem.url, concealed: concealed, allowInlineWebpageResolution: urlHit.urlItem.webpageId != nil) let rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint) - return ChatMessageBubbleContentTapAction( - content: .url(url), - rects: rects, - activate: self.makeActivate(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint) - ) + + if let webpageId = urlHit.urlItem.webpageId { + let split = self.splitAnchor(url.url) + return ChatMessageBubbleContentTapAction( + content: .externalInstantPage(url: url, webpageId: webpageId, anchor: split.anchor), + rects: rects, + activate: self.makeActivate(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint) + ) + } else { + return ChatMessageBubbleContentTapAction( + content: .url(url), + rects: rects, + activate: self.makeActivate(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint) + ) + } } private func textItemAtLocation(_ location: CGPoint) -> (item: InstantPageTextItem, parentOffset: CGPoint)? { @@ -650,6 +752,42 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode return nil } + 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 + } + override public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? { /*if let statusNode = self.statusNode, !statusNode.isHidden { return statusNode.reactionView(value: value) @@ -692,7 +830,13 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode } private func scrollToAnchor(_ anchor: String) { - // TODO: implement intra-page anchor scrolling - let _ = anchor + 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) + } } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift index a8797855fe..d11768efe9 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift @@ -832,8 +832,8 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } strongSelf.textNode.textNode.displaysAsynchronously = !item.presentationData.isPreview - animation.animator.updateFrame(layer: strongSelf.containerNode.layer, frame: CGRect(origin: CGPoint(), size: boundingSize), completion: nil) - + animation.animator.updatePosition(layer: strongSelf.containerNode.layer, position: CGRect(origin: CGPoint(), size: boundingSize).center, completion: nil) + animation.animator.updateBounds(layer: strongSelf.containerNode.layer, bounds: CGRect(origin: CGPoint(), size: boundingSize), completion: nil) if let formattedDateUpdatePeriod { if strongSelf.relativeDateTimer?.period != formattedDateUpdatePeriod { @@ -917,7 +917,8 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } ) )) - animation.animator.updateFrame(layer: strongSelf.textNode.textNode.layer, frame: realTextFrame, completion: nil) + animation.animator.updatePosition(layer: strongSelf.textNode.textNode.layer, position: realTextFrame.center, completion: nil) + animation.animator.updateBounds(layer: strongSelf.textNode.textNode.layer, bounds: CGRect(origin: CGPoint(), size: realTextFrame.size), completion: nil) switch strongSelf.visibility { case .none: @@ -971,7 +972,8 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } statusNode.frame = statusFrame } else { - animation.animator.updateFrame(layer: statusNode.layer, frame: statusFrame, completion: nil) + animation.animator.updatePosition(layer: statusNode.layer, position: statusFrame.center, completion: nil) + animation.animator.updateBounds(layer: statusNode.layer, bounds: CGRect(origin: CGPoint(), size: statusFrame.size), completion: nil) } } else if let statusNode = strongSelf.statusNode { strongSelf.statusNode = nil diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift index e1408ee47d..40d13fac3e 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift @@ -327,6 +327,7 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { } }, requestMessageActionUrlAuth: { _, _ in }, activateSwitchInline: { _, _, _ in }, openUrl: { [weak self] url in self?.openUrl(url.url, progress: url.progress) + }, openExternalInstantPage: { _ in }, shareCurrentLocation: {}, shareAccountContact: {}, sendBotCommand: { _, _ in }, openInstantPage: { [weak self] message, associatedData in if let strongSelf = self, let navigationController = strongSelf.getNavigationController() { if let controller = strongSelf.context.sharedContext.makeInstantPageController(context: strongSelf.context, message: message, sourcePeerType: associatedData?.automaticDownloadPeerType) { @@ -656,7 +657,8 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { }, requestMessageUpdate: { _, _, _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { - }, scrollToMessageId: { _ in + }, scrollToMessageId: { _, _ in + }, scrollToMessageIdWithAnchor: { _, _ in }, navigateToStory: { _, _ in }, attemptedNavigationToPrivateQuote: { _ in }, forceUpdateWarpContents: { diff --git a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift index f5069b7768..85ac8110b9 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift +++ b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift @@ -426,7 +426,8 @@ public final class ChatSendGroupMediaMessageContextPreview: UIView, ChatSendMess }, toggleMessagesSelection: { _, _ in }, sendCurrentMessage: { _, _ in }, sendMessage: { _ in }, sendSticker: { _, _, _, _, _, _, _, _, _ in return false }, sendEmoji: { _, _, _ in }, sendGif: { _, _, _, _, _ in return false }, sendBotContextResultAsGif: { _, _, _, _, _, _ in return false }, editGif: { _, _ in - }, requestMessageActionCallback: { _, _, _, _, _ in }, requestMessageActionUrlAuth: { _, _ in }, activateSwitchInline: { _, _, _ in }, openUrl: { _ in }, shareCurrentLocation: {}, shareAccountContact: {}, sendBotCommand: { _, _ in }, openInstantPage: { _, _ in }, openWallpaper: { _ in }, openTheme: { _ in }, openHashtag: { _, _ in }, updateInputState: { _ in }, updateInputMode: { _ in }, updatePresentationState: { _ in }, openMessageShareMenu: { _ in + }, requestMessageActionCallback: { _, _, _, _, _ in }, requestMessageActionUrlAuth: { _, _ in }, activateSwitchInline: { _, _, _ in }, openUrl: { _ in }, openExternalInstantPage: { _ in + }, shareCurrentLocation: {}, shareAccountContact: {}, sendBotCommand: { _, _ in }, openInstantPage: { _, _ in }, openWallpaper: { _ in }, openTheme: { _ in }, openHashtag: { _, _ in }, updateInputState: { _ in }, updateInputMode: { _ in }, updatePresentationState: { _ in }, openMessageShareMenu: { _ in }, presentController: { _, _ in }, presentControllerInCurrent: { _, _ in }, navigationController: { @@ -503,7 +504,8 @@ public final class ChatSendGroupMediaMessageContextPreview: UIView, ChatSendMess }, requestMessageUpdate: { _, _, _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { - }, scrollToMessageId: { _ in + }, scrollToMessageId: { _, _ in + }, scrollToMessageIdWithAnchor: { _, _ in }, navigateToStory: { _, _ in }, attemptedNavigationToPrivateQuote: { _ in }, forceUpdateWarpContents: { diff --git a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift index 168605725f..43ba16297c 100644 --- a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift @@ -158,6 +158,22 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol } } + public struct OpenInstantPage { + public var webpageId: MediaId + public var url: String + public var anchor: String? + public var concealed: Bool + public var progress: Promise? + + public init(webpageId: MediaId, url: String, anchor: String?, concealed: Bool, progress: Promise? = nil) { + self.webpageId = webpageId + self.url = url + self.anchor = anchor + self.concealed = concealed + self.progress = progress + } + } + public struct LongTapParams { public var message: Message? public var contentNode: ContextExtractedContentContainingNode? @@ -204,6 +220,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol public let requestMessageActionUrlAuth: (String, MessageActionUrlSubject) -> Void public let activateSwitchInline: (PeerId?, String, ReplyMarkupButtonAction.PeerTypes?) -> Void public let openUrl: (OpenUrl) -> Void + public let openExternalInstantPage: (OpenInstantPage) -> Void public let shareCurrentLocation: () -> Void public let shareAccountContact: () -> Void public let sendBotCommand: (MessageId?, String) -> Void @@ -291,7 +308,8 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol public let requestMessageUpdate: (MessageId, Bool, ControlledTransition?) -> Void public let cancelInteractiveKeyboardGestures: () -> Void public let dismissTextInput: () -> Void - public let scrollToMessageId: (MessageIndex) -> Void + public let scrollToMessageId: (MessageIndex, CGFloat) -> Void + public let scrollToMessageIdWithAnchor: (MessageIndex, String) -> Void public let navigateToStory: (Message, StoryId) -> Void public let attemptedNavigationToPrivateQuote: (Peer?) -> Void public let forceUpdateWarpContents: () -> Void @@ -382,6 +400,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol requestMessageActionUrlAuth: @escaping (String, MessageActionUrlSubject) -> Void, activateSwitchInline: @escaping (PeerId?, String, ReplyMarkupButtonAction.PeerTypes?) -> Void, openUrl: @escaping (OpenUrl) -> Void, + openExternalInstantPage: @escaping (OpenInstantPage) -> Void, shareCurrentLocation: @escaping () -> Void, shareAccountContact: @escaping () -> Void, sendBotCommand: @escaping (MessageId?, String) -> Void, @@ -469,7 +488,8 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol requestMessageUpdate: @escaping (MessageId, Bool, ControlledTransition?) -> Void, cancelInteractiveKeyboardGestures: @escaping () -> Void, dismissTextInput: @escaping () -> Void, - scrollToMessageId: @escaping (MessageIndex) -> Void, + scrollToMessageId: @escaping (MessageIndex, CGFloat) -> Void, + scrollToMessageIdWithAnchor: @escaping (MessageIndex, String) -> Void, navigateToStory: @escaping (Message, StoryId) -> Void, attemptedNavigationToPrivateQuote: @escaping (Peer?) -> Void, forceUpdateWarpContents: @escaping () -> Void, @@ -512,6 +532,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol self.requestMessageActionUrlAuth = requestMessageActionUrlAuth self.activateSwitchInline = activateSwitchInline self.openUrl = openUrl + self.openExternalInstantPage = openExternalInstantPage self.shareCurrentLocation = shareCurrentLocation self.shareAccountContact = shareAccountContact self.sendBotCommand = sendBotCommand @@ -601,6 +622,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol self.cancelInteractiveKeyboardGestures = cancelInteractiveKeyboardGestures self.dismissTextInput = dismissTextInput self.scrollToMessageId = scrollToMessageId + self.scrollToMessageIdWithAnchor = scrollToMessageIdWithAnchor self.navigateToStory = navigateToStory self.attemptedNavigationToPrivateQuote = attemptedNavigationToPrivateQuote self.forceUpdateWarpContents = forceUpdateWarpContents diff --git a/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift b/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift index 25153b3fe8..af0f34a9a1 100644 --- a/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift +++ b/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift @@ -3246,8 +3246,11 @@ final class TextContentItemLayer: SimpleLayer { } } - animation.animator.updateFrame(layer: self.renderNodeContainer, frame: effectiveContentFrame, completion: nil) - animation.animator.updateFrame(layer: self.renderNode.layer, frame: CGRect(origin: CGPoint(), size: effectiveContentFrame.size), completion: nil) + animation.animator.updatePosition(layer: self.renderNodeContainer, position: effectiveContentFrame.center, completion: nil) + animation.animator.updateBounds(layer: self.renderNodeContainer, bounds: CGRect(origin: CGPoint(), size: effectiveContentFrame.size), completion: nil) + + animation.animator.updatePosition(layer: self.renderNode.layer, position: effectiveContentFrame.center, completion: nil) + animation.animator.updateBounds(layer: self.renderNode.layer, bounds: CGRect(origin: CGRect(origin: CGPoint(), size: effectiveContentFrame.size).center, size: effectiveContentFrame.size), completion: nil) var staticContentMask = contentMask if let contentMask, self.isAnimating { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index 6c61401182..43b382ee37 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -1109,6 +1109,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro return } strongSelf.openUrl(url: url.url, concealed: url.concealed, external: url.external ?? false) + }, openExternalInstantPage: { _ in }, shareCurrentLocation: { }, shareAccountContact: { }, sendBotCommand: { _, _ in @@ -1274,7 +1275,8 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro }, requestMessageUpdate: { _, _, _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { - }, scrollToMessageId: { _ in + }, scrollToMessageId: { _, _ in + }, scrollToMessageIdWithAnchor: { _, _ in }, navigateToStory: { _, _ in }, attemptedNavigationToPrivateQuote: { _ in }, forceUpdateWarpContents: { diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 68457c5b26..fb0c1a7311 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -3221,6 +3221,66 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { strongSelf.openUrl(url, concealed: concealed, forceExternal: forceExternal, skipConcealedAlert: skipConcealedAlert, message: message, allowInlineWebpageResolution: urlData.allowInlineWebpageResolution, progress: progress) } + }, openExternalInstantPage: { [weak self] instantPage in + guard let self else { + return + } + let _ = (webpagePreviewWithProgress(account: self.context.account, urls: [instantPage.url], webpageId: instantPage.webpageId) + |> deliverOnMainQueue).startStandalone(next: { [weak self] result in + Task { @MainActor in + guard let self else { + return + } + switch result { + case let .result(webpageResult): + instantPage.progress?.set(.single(false)) + + guard let webpageResult else { + self.openUrl(instantPage.url, concealed: true) + return + } + + if case .Loaded = webpageResult.webpage.content { + let sourceLocation: InstantPageSourceLocation + + if let peerId = self.chatLocation.peerId { + let (peer, isContact) = await self.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: peerId), + TelegramEngine.EngineData.Item.Peer.IsContact(id: peerId) + ).get() + + if let peer { + let peerType: MediaAutoDownloadPeerType + switch peer { + case .user: + peerType = isContact ? .contact : .otherPrivate + case let .channel(channel): + if case .group = channel.info { + peerType = .group + } else { + peerType = .channel + } + case .legacyGroup: + peerType = .group + case .secretChat: + return + } + let peerLocation = MediaResourceUserLocation.peer(peer.id) + sourceLocation = InstantPageSourceLocation(userLocation: peerLocation, peerType: peerType) + } else { + sourceLocation = InstantPageSourceLocation(userLocation: .other, peerType: .channel) + } + } else { + sourceLocation = InstantPageSourceLocation(userLocation: .other, peerType: .channel) + } + + self.push(makeInstantPageControllerImpl(context: self.context, webPage: webpageResult.webpage, anchor: instantPage.anchor, sourceLocation: sourceLocation)) + } + case .progress: + instantPage.progress?.set(.single(true)) + } + } + }) }, shareCurrentLocation: { [weak self] in if let strongSelf = self { if case .pinnedMessages = strongSelf.presentationInterfaceState.subject { @@ -5334,8 +5394,38 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }, dismissTextInput: { [weak self] in self?.chatDisplayNode.dismissTextInput() - }, scrollToMessageId: { [weak self] index in - self?.chatDisplayNode.historyNode.scrollToMessage(index: index) + }, 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 + } + // Find the bubble for this message (it must be at least partially + // visible — we got here from a tap inside it) and compute the + // anchor's y in item-local coords. .bottom(anchorY) places the item + // so the anchor lands at the visual top of the rotated chat list's + // content area; .center(.custom) is bypassed for short items, so it + // can't position the anchor uniformly. + 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 guard let self else { return diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index 2ee2daf1e4..2302845a6f 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -5064,7 +5064,7 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH } } - func scrollToMessage(index: MessageIndex) { + func scrollToMessage(index: MessageIndex, offset: CGFloat = 0.0) { self.appliedScrollToMessageId = nil self.scrollToMessageIdPromise.set(.single(index)) } diff --git a/submodules/TelegramUI/Sources/OpenChatMessage.swift b/submodules/TelegramUI/Sources/OpenChatMessage.swift index 6d916852c5..d2b59d5a02 100644 --- a/submodules/TelegramUI/Sources/OpenChatMessage.swift +++ b/submodules/TelegramUI/Sources/OpenChatMessage.swift @@ -478,7 +478,7 @@ func openChatMessageImpl(_ params: OpenChatMessageParams) -> Bool { return false } -func makeInstantPageControllerImpl(context: AccountContext, message: Message, sourcePeerType: MediaAutoDownloadPeerType?) -> ViewController? { +public func makeInstantPageControllerImpl(context: AccountContext, message: Message, sourcePeerType: MediaAutoDownloadPeerType?) -> ViewController? { guard let (webpage, anchor) = instantPageAndAnchor(message: message) else { return nil } @@ -486,7 +486,7 @@ func makeInstantPageControllerImpl(context: AccountContext, message: Message, so return makeInstantPageControllerImpl(context: context, webPage: webpage, anchor: anchor, sourceLocation: sourceLocation) } -func makeInstantPageControllerImpl(context: AccountContext, webPage: TelegramMediaWebpage, anchor: String?, sourceLocation: InstantPageSourceLocation) -> ViewController { +public func makeInstantPageControllerImpl(context: AccountContext, webPage: TelegramMediaWebpage, anchor: String?, sourceLocation: InstantPageSourceLocation) -> ViewController { return BrowserScreen(context: context, subject: .instantPage(webPage: webPage, anchor: anchor, sourceLocation: sourceLocation, preloadedResources: nil)) } diff --git a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift index 9fa74bbce6..213aa143fe 100644 --- a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift +++ b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift @@ -155,6 +155,7 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, ASGestu }, requestMessageActionUrlAuth: { _, _ in }, activateSwitchInline: { _, _, _ in }, openUrl: { _ in + }, openExternalInstantPage: { _ in }, shareCurrentLocation: { }, shareAccountContact: { }, sendBotCommand: { _, _ in @@ -248,7 +249,8 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, ASGestu }, requestMessageUpdate: { _, _, _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { - }, scrollToMessageId: { _ in + }, scrollToMessageId: { _, _ in + }, scrollToMessageIdWithAnchor: { _, _ in }, navigateToStory: { _, _ in }, attemptedNavigationToPrivateQuote: { _ in }, forceUpdateWarpContents: { diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 223c0af0a8..3896988f1f 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -2392,6 +2392,8 @@ public final class SharedAccountContextImpl: SharedAccountContext { requestMessageActionUrlAuth: { _, _ in }, activateSwitchInline: { _, _, _ in }, openUrl: { _ in }, + openExternalInstantPage: { _ in + }, shareCurrentLocation: {}, shareAccountContact: {}, sendBotCommand: { _, _ in }, @@ -2560,7 +2562,9 @@ public final class SharedAccountContextImpl: SharedAccountContext { }, dismissTextInput: { }, - scrollToMessageId: { _ in + scrollToMessageId: { _, _ in + }, + scrollToMessageIdWithAnchor: { _, _ in }, navigateToStory: { _, _ in }, diff --git a/submodules/TelegramUIPreferences/Sources/InstantPagePresentationSettings.swift b/submodules/TelegramUIPreferences/Sources/InstantPagePresentationSettings.swift index 420dca7e11..8781c437ec 100644 --- a/submodules/TelegramUIPreferences/Sources/InstantPagePresentationSettings.swift +++ b/submodules/TelegramUIPreferences/Sources/InstantPagePresentationSettings.swift @@ -21,17 +21,19 @@ public enum InstantPagePresentationFontSize: Int32 { } public final class InstantPagePresentationSettings: Codable, Equatable { - public static var defaultSettings = InstantPagePresentationSettings(themeType: .light, fontSize: .standard, forceSerif: false, autoNightMode: true, ignoreAutoNightModeUntil: 0) + public static var defaultSettings = InstantPagePresentationSettings(themeType: .light, fontSize: .standard, lineSpacingFactor: 1.0, forceSerif: false, autoNightMode: true, ignoreAutoNightModeUntil: 0) public var themeType: InstantPageThemeType public var fontSize: InstantPagePresentationFontSize + public var lineSpacingFactor: CGFloat public var forceSerif: Bool public var autoNightMode: Bool public var ignoreAutoNightModeUntil: Int32 - public init(themeType: InstantPageThemeType, fontSize: InstantPagePresentationFontSize, forceSerif: Bool, autoNightMode: Bool, ignoreAutoNightModeUntil: Int32) { + public init(themeType: InstantPageThemeType, fontSize: InstantPagePresentationFontSize, lineSpacingFactor: CGFloat, forceSerif: Bool, autoNightMode: Bool, ignoreAutoNightModeUntil: Int32) { self.themeType = themeType self.fontSize = fontSize + self.lineSpacingFactor = lineSpacingFactor self.forceSerif = forceSerif self.autoNightMode = autoNightMode self.ignoreAutoNightModeUntil = ignoreAutoNightModeUntil @@ -42,6 +44,7 @@ public final class InstantPagePresentationSettings: Codable, Equatable { self.themeType = InstantPageThemeType(rawValue: try container.decode(Int32.self, forKey: "themeType"))! self.fontSize = InstantPagePresentationFontSize(rawValue: try container.decode(Int32.self, forKey: "fontSize"))! + self.lineSpacingFactor = try container.decodeIfPresent(Double.self, forKey: "lineSpacingFactor") ?? 1.0 self.forceSerif = try container.decode(Int32.self, forKey: "forceSerif") != 0 self.autoNightMode = try container.decode(Int32.self, forKey: "autoNightMode") != 0 self.ignoreAutoNightModeUntil = try container.decode(Int32.self, forKey: "ignoreAutoNightModeUntil") @@ -52,6 +55,7 @@ public final class InstantPagePresentationSettings: Codable, Equatable { try container.encode(self.themeType.rawValue, forKey: "themeType") try container.encode(self.fontSize.rawValue, forKey: "fontSize") + try container.encode(self.lineSpacingFactor, forKey: "lineSpacingFactor") try container.encode((self.forceSerif ? 1 : 0) as Int32, forKey: "forceSerif") try container.encode((self.autoNightMode ? 1 : 0) as Int32, forKey: "autoNightMode") try container.encode(self.ignoreAutoNightModeUntil, forKey: "ignoreAutoNightModeUntil") @@ -64,6 +68,9 @@ public final class InstantPagePresentationSettings: Codable, Equatable { if lhs.fontSize != rhs.fontSize { return false } + if lhs.lineSpacingFactor != rhs.lineSpacingFactor { + return false + } if lhs.forceSerif != rhs.forceSerif { return false } @@ -77,23 +84,23 @@ public final class InstantPagePresentationSettings: Codable, Equatable { } public func withUpdatedThemeType(_ themeType: InstantPageThemeType) -> InstantPagePresentationSettings { - return InstantPagePresentationSettings(themeType: themeType, fontSize: self.fontSize, forceSerif: self.forceSerif, autoNightMode: self.autoNightMode, ignoreAutoNightModeUntil: self.ignoreAutoNightModeUntil) + return InstantPagePresentationSettings(themeType: themeType, fontSize: self.fontSize, lineSpacingFactor: self.lineSpacingFactor, forceSerif: self.forceSerif, autoNightMode: self.autoNightMode, ignoreAutoNightModeUntil: self.ignoreAutoNightModeUntil) } public func withUpdatedFontSize(_ fontSize: InstantPagePresentationFontSize) -> InstantPagePresentationSettings { - return InstantPagePresentationSettings(themeType: self.themeType, fontSize: fontSize, forceSerif: self.forceSerif, autoNightMode: self.autoNightMode, ignoreAutoNightModeUntil: self.ignoreAutoNightModeUntil) + return InstantPagePresentationSettings(themeType: self.themeType, fontSize: fontSize, lineSpacingFactor: self.lineSpacingFactor, forceSerif: self.forceSerif, autoNightMode: self.autoNightMode, ignoreAutoNightModeUntil: self.ignoreAutoNightModeUntil) } public func withUpdatedForceSerif(_ forceSerif: Bool) -> InstantPagePresentationSettings { - return InstantPagePresentationSettings(themeType: self.themeType, fontSize: self.fontSize, forceSerif: forceSerif, autoNightMode: self.autoNightMode, ignoreAutoNightModeUntil: self.ignoreAutoNightModeUntil) + return InstantPagePresentationSettings(themeType: self.themeType, fontSize: self.fontSize, lineSpacingFactor: self.lineSpacingFactor, forceSerif: forceSerif, autoNightMode: self.autoNightMode, ignoreAutoNightModeUntil: self.ignoreAutoNightModeUntil) } public func withUpdatedAutoNightMode(_ autoNightMode: Bool) -> InstantPagePresentationSettings { - return InstantPagePresentationSettings(themeType: self.themeType, fontSize: self.fontSize, forceSerif: self.forceSerif, autoNightMode: autoNightMode, ignoreAutoNightModeUntil: self.ignoreAutoNightModeUntil) + return InstantPagePresentationSettings(themeType: self.themeType, fontSize: self.fontSize, lineSpacingFactor: self.lineSpacingFactor, forceSerif: self.forceSerif, autoNightMode: autoNightMode, ignoreAutoNightModeUntil: self.ignoreAutoNightModeUntil) } public func withUpdatedIgnoreAutoNightModeUntil(_ ignoreAutoNightModeUntil: Int32) -> InstantPagePresentationSettings { - return InstantPagePresentationSettings(themeType: self.themeType, fontSize: self.fontSize, forceSerif: self.forceSerif, autoNightMode: autoNightMode, ignoreAutoNightModeUntil: ignoreAutoNightModeUntil) + return InstantPagePresentationSettings(themeType: self.themeType, fontSize: self.fontSize, lineSpacingFactor: self.lineSpacingFactor, forceSerif: self.forceSerif, autoNightMode: autoNightMode, ignoreAutoNightModeUntil: ignoreAutoNightModeUntil) } }