diff --git a/docs/superpowers/plans/2026-05-01-rich-bubble-text-selection.md b/docs/superpowers/plans/2026-05-01-rich-bubble-text-selection.md new file mode 100644 index 0000000000..af23d2627f --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-rich-bubble-text-selection.md @@ -0,0 +1,627 @@ +# Rich-bubble text selection 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:** Wire drag-handle text selection inside `ChatMessageRichDataBubbleContentNode`, available only in context-preview mode, with cross-paragraph selection across all visible `InstantPageTextItem`s. + +**Architecture:** Extend `InstantPageTextItem` with a small public surface (`attributedString` + `attributesAtPoint(_:orNearest:)` + `textRangeRects(in:)`). Add a new `InstantPageMultiTextAdapter` that implements `TextNodeProtocol` by aggregating multiple items into one character-indexed view. In the rich-bubble, gate selection on `updateIsExtractedToContextPreview`, mirroring `ChatMessageTextBubbleContentNode`. + +**Tech Stack:** Swift, AsyncDisplayKit, Bazel (`build-system/Make/Make.py`); modules `Display` (TextNodeProtocol, TextRangeRectEdge), `InstantPageUI` (text item + new adapter), `TextSelectionNode`, `ChatControllerInteraction`. + +**Reference spec:** `docs/superpowers/specs/2026-05-01-rich-bubble-text-selection-design.md` + +**Project context:** No automated tests (per `CLAUDE.md`). Per-task verification is "Bazel build green" using: + +```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 \ + --continueOnError +``` + +The final task is a manual smoke test in the simulator. + +**Working-tree note:** The user has unrelated WIP modifications in the tree (notably `submodules/InstantPageUI/Sources/InstantPageLayout.swift`, `InstantPageControllerNode.swift`, `InstantPageTheme.swift`, plus a `lineSpacingFactor: 0.9` addition in the rich-bubble). DO NOT touch those files except for the specific edits this plan calls out. Use `git add ` — never `git add -A` / `git add .`. + +--- + +## File map + +- **Modify:** `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` — promote `attributedString` to public; add a new public `attributesAtPoint(_:orNearest:)` extending the existing internal one; add a new public `textRangeRects(in:)` returning `Display.TextRangeRectEdge`. +- **Create:** `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` — new file containing `InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol`. No BUILD changes needed; the file is picked up by `glob(["Sources/**/*.swift"])` and `Display` is already a dep of `InstantPageUI`. +- **Modify:** `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD` — add `//submodules/TextSelectionNode` to `deps`. +- **Modify:** `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` — add `import TextSelectionNode`, two new ivars, and the lifecycle hooks. + +No other files change. + +--- + +### Task 1: Public surface on `InstantPageTextItem` + +Three accessors become public so the adapter (defined in Task 2, in the same module) and external consumers can compose with the item. + +**Files:** +- Modify: `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` + +- [ ] **Step 1: Promote `attributedString` to public** + +Locate (around line 170): +```swift +public final class InstantPageTextItem: InstantPageItem { + let attributedString: NSAttributedString + public let lines: [InstantPageTextLine] +``` + +Replace with: +```swift +public final class InstantPageTextItem: InstantPageItem { + public let attributedString: NSAttributedString + public let lines: [InstantPageTextLine] +``` + +- [ ] **Step 2: Add public `attributesAtPoint(_:orNearest:)`** + +The existing internal method is at line 272 of `InstantPageTextItem.swift`: + +```swift + func attributesAtPoint(_ point: CGPoint) -> (Int, [NSAttributedString.Key: Any])? { + let transformedPoint = CGPoint(x: point.x, y: point.y) + let boundsWidth = self.frame.width + for i in 0 ..< self.lines.count { + let line = self.lines[i] + + let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment) + if lineFrame.insetBy(dx: -5.0, dy: -5.0).contains(transformedPoint) { + var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: transformedPoint.x - lineFrame.minX, y: transformedPoint.y - lineFrame.minY)) + if index == self.attributedString.length { + index -= 1 + } else if index != 0 { + var glyphStart: CGFloat = 0.0 + CTLineGetOffsetForStringIndex(line.line, index, &glyphStart) + if transformedPoint.x < glyphStart { + index -= 1 + } + } + if index >= 0 && index < self.attributedString.length { + return (index, self.attributedString.attributes(at: index, effectiveRange: nil)) + } + break + } + } + return nil + } +``` + +Leave that method untouched (it's still used by `urlAttribute(at:)` and `linkSelectionRects(at:)`). Immediately after it, add the new public method: + +```swift + public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? { + if let direct = self.attributesAtPoint(point) { + return direct + } + guard orNearest, !self.lines.isEmpty else { + return nil + } + + let boundsWidth = self.frame.width + var nearestLineIndex = 0 + var nearestDistance = CGFloat.greatestFiniteMagnitude + for i in 0 ..< self.lines.count { + let lineFrame = expandedFrameForLine(self.lines[i], boundingWidth: boundsWidth, alignment: self.alignment) + let distance: CGFloat + if point.y < lineFrame.minY { + distance = lineFrame.minY - point.y + } else if point.y > lineFrame.maxY { + distance = point.y - lineFrame.maxY + } else { + distance = 0.0 + } + if distance < nearestDistance { + nearestDistance = distance + nearestLineIndex = i + } + } + + let line = self.lines[nearestLineIndex] + let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment) + let clampedX = max(lineFrame.minX, min(lineFrame.maxX, point.x)) + var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: clampedX - lineFrame.minX, y: 0.0)) + if index == self.attributedString.length { + index -= 1 + } else if index != 0 { + var glyphStart: CGFloat = 0.0 + CTLineGetOffsetForStringIndex(line.line, index, &glyphStart) + if clampedX - lineFrame.minX < glyphStart { + index -= 1 + } + } + guard index >= 0, index < self.attributedString.length else { + return nil + } + return (index, self.attributedString.attributes(at: index, effectiveRange: nil)) + } +``` + +- [ ] **Step 3: Add public `textRangeRects(in:)`** + +The existing internal `rangeRects(in:)` is at line 369 of the file. Leave it untouched. Add a new public method that wraps it and converts the edge type. Place it directly after the existing `rangeRects(in:)` (so the implementations live together). + +```swift + public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { + guard let result = self.rangeRects(in: range), let start = result.start, let end = result.end, !result.rects.isEmpty else { + return nil + } + let startEdge = TextRangeRectEdge(x: start.x, y: start.y, height: start.height) + let endEdge = TextRangeRectEdge(x: end.x, y: end.y, height: end.height) + return (result.rects, startEdge, endEdge) + } +``` + +`TextRangeRectEdge` is in `Display`, which is already imported by `InstantPageTextItem.swift` (verify the imports near the top of the file include `import Display` — it should). + +- [ ] **Step 4: Build to verify** + +```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 --continueOnError +``` + +Expected: build succeeds. + +Note: the user has unrelated WIP that may be breaking the build (a `sideInset` migration in `InstantPageLayout.swift` / `InstantPageControllerNode.swift` / `InstantPageTheme.swift`). If the build fails, check whether the failures mention `sideInset` and are inside those three files. If so, the failure is pre-existing and not from this task — flag it in your report and proceed. If failures are inside `InstantPageTextItem.swift`, those ARE from this task and need fixing. + +- [ ] **Step 5: Commit** + +```sh +git add submodules/InstantPageUI/Sources/InstantPageTextItem.swift +git commit -m "InstantPage: expose text-item attributedString and selection helpers" +``` + +--- + +### Task 2: `InstantPageMultiTextAdapter` + +A `TextNodeProtocol`-conforming `ASDisplayNode` that aggregates multiple `InstantPageTextItem`s into a single character-indexed view, suitable for `TextSelectionNode`. + +**Files:** +- Create: `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` + +- [ ] **Step 1: Create the new file with the full adapter** + +Write `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` containing exactly: + +```swift +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import TelegramCore + +public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol { + private struct Entry { + let item: InstantPageTextItem + let charOffset: Int + let frameOrigin: CGPoint + } + + private let entries: [Entry] + private let combinedString: NSAttributedString + + public init(items: [InstantPageTextItem]) { + let separator = NSAttributedString(string: "\n\n") + let combined = NSMutableAttributedString() + var entries: [Entry] = [] + for (index, item) in items.enumerated() { + let charOffset = combined.length + entries.append(Entry(item: item, charOffset: charOffset, frameOrigin: item.frame.origin)) + combined.append(item.attributedString) + if index != items.count - 1 { + combined.append(separator) + } + } + self.entries = entries + self.combinedString = combined + super.init() + self.isUserInteractionEnabled = false + } + + public var currentText: NSAttributedString? { + return self.combinedString + } + + public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? { + for entry in self.entries { + let localPoint = CGPoint(x: point.x - entry.frameOrigin.x, y: point.y - entry.frameOrigin.y) + if let (localIndex, attrs) = entry.item.attributesAtPoint(localPoint, orNearest: false) { + return (entry.charOffset + localIndex, attrs) + } + } + guard orNearest, !self.entries.isEmpty else { + return nil + } + var nearestEntry = self.entries[0] + var nearestDistance = CGFloat.greatestFiniteMagnitude + for entry in self.entries { + let frame = CGRect(origin: entry.frameOrigin, size: entry.item.frame.size) + let distance: CGFloat + if point.y < frame.minY { + distance = frame.minY - point.y + } else if point.y > frame.maxY { + distance = point.y - frame.maxY + } else { + distance = 0.0 + } + if distance < nearestDistance { + nearestDistance = distance + nearestEntry = entry + } + } + let localPoint = CGPoint(x: point.x - nearestEntry.frameOrigin.x, y: point.y - nearestEntry.frameOrigin.y) + if let (localIndex, attrs) = nearestEntry.item.attributesAtPoint(localPoint, orNearest: true) { + return (nearestEntry.charOffset + localIndex, attrs) + } + return nil + } + + public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { + var allRects: [CGRect] = [] + var startEdge: TextRangeRectEdge? + var endEdge: TextRangeRectEdge? + for entry in self.entries { + let itemLength = entry.item.attributedString.length + let entryRange = NSRange(location: entry.charOffset, length: itemLength) + let intersection = NSIntersectionRange(range, entryRange) + if intersection.length == 0 { + continue + } + let localRange = NSRange(location: intersection.location - entry.charOffset, length: intersection.length) + guard let result = entry.item.textRangeRects(in: localRange) else { + continue + } + for rect in result.rects { + allRects.append(rect.offsetBy(dx: entry.frameOrigin.x, dy: entry.frameOrigin.y)) + } + let translatedStart = TextRangeRectEdge(x: result.start.x + entry.frameOrigin.x, y: result.start.y + entry.frameOrigin.y, height: result.start.height) + let translatedEnd = TextRangeRectEdge(x: result.end.x + entry.frameOrigin.x, y: result.end.y + entry.frameOrigin.y, height: result.end.height) + if startEdge == nil { + startEdge = translatedStart + } + endEdge = translatedEnd + } + guard !allRects.isEmpty, let start = startEdge, let end = endEdge else { + return nil + } + return (allRects, start, end) + } +} +``` + +- [ ] **Step 2: Build to verify** + +```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 --continueOnError +``` + +Expected: build succeeds. The new file is picked up automatically by `glob(["Sources/**/*.swift"])` in `submodules/InstantPageUI/BUILD` (already verified). `Display` is already a dep so `TextNodeProtocol` and `TextRangeRectEdge` are reachable. + +If the build fails inside the adapter file, fix and re-build. Pre-existing `sideInset` failures elsewhere are not from this task — see Task 1's note. + +- [ ] **Step 3: Commit** + +```sh +git add submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift +git commit -m "InstantPage: add multi-text adapter aggregating items as a TextNodeProtocol" +``` + +--- + +### Task 3: BUILD dep, import, and ivars + +Bring `TextSelectionNode` into the rich-bubble's BUILD graph and add the two new ivars. The lifecycle methods land in Task 4. + +**Files:** +- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD` +- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` + +- [ ] **Step 1: Add `TextSelectionNode` to BUILD deps** + +Locate the deps list. Currently it ends with these entries (order approximate; you'll see `GalleryUI` and `TextLoadingEffect` already present from prior work): + +``` + "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", + "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", + "//submodules/TelegramUI/Components/ChatControllerInteraction", + "//submodules/TelegramUI/Components/TextLoadingEffect", + "//submodules/TelegramUIPreferences", + "//submodules/GalleryUI", + ], +``` + +Append `"//submodules/TextSelectionNode",` immediately before the closing `],`: + +``` + "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", + "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", + "//submodules/TelegramUI/Components/ChatControllerInteraction", + "//submodules/TelegramUI/Components/TextLoadingEffect", + "//submodules/TelegramUIPreferences", + "//submodules/GalleryUI", + "//submodules/TextSelectionNode", + ], +``` + +- [ ] **Step 2: Add the import** + +In `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift`, the imports currently look like: + +```swift +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import TelegramCore +import Postbox +import SwiftSignalKit +import AccountContext +import ChatMessageBubbleContentNode +import ChatMessageItemCommon +import ChatControllerInteraction +import InstantPageUI +import TelegramUIPreferences +import TextLoadingEffect +``` + +Append `import TextSelectionNode` immediately after `import TextLoadingEffect`: + +```swift +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import TelegramCore +import Postbox +import SwiftSignalKit +import AccountContext +import ChatMessageBubbleContentNode +import ChatMessageItemCommon +import ChatControllerInteraction +import InstantPageUI +import TelegramUIPreferences +import TextLoadingEffect +import TextSelectionNode +``` + +(There may be additional imports below `TextLoadingEffect` from prior work, e.g. `GalleryUI`. If so, place `import TextSelectionNode` after them — order within the import block doesn't matter as long as you don't break alphabetization that already exists.) + +- [ ] **Step 3: Add the two new ivars** + +Locate the existing block of stored properties (currently around lines 27-32 — the `linkProgress*` and `linkHighlightingNode` group): + +```swift + private var linkProgressDisposable: Disposable? + private var linkProgressRects: [CGRect]? + private var linkHighlightingNode: LinkHighlightingNode? + private var linkProgressView: TextLoadingEffectView? +``` + +Append the two new ivars immediately after `linkProgressView`: + +```swift + private var linkProgressDisposable: Disposable? + private var linkProgressRects: [CGRect]? + private var linkHighlightingNode: LinkHighlightingNode? + private var linkProgressView: TextLoadingEffectView? + private var textSelectionAdapter: InstantPageMultiTextAdapter? + private var textSelectionNode: TextSelectionNode? +``` + +- [ ] **Step 4: Build to verify** + +```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 --continueOnError +``` + +Expected: build succeeds. Unused `import TextSelectionNode` and unused private ivars don't trigger Swift warnings, so `-warnings-as-errors` stays clean. + +- [ ] **Step 5: Commit** + +```sh +git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +git commit -m "Rich bubble: add TextSelectionNode dep and selection ivars" +``` + +--- + +### Task 4: Lifecycle hooks for entering / leaving context preview + +Override `updateIsExtractedToContextPreview(_:)` to set up the adapter + `TextSelectionNode` when the bubble is lifted into the context-menu preview, and `willUpdateIsExtractedToContextPreview(_:)` to tear them down when it leaves. + +**Files:** +- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` + +- [ ] **Step 1: Replace the empty preview-lifecycle stubs** + +The file currently contains two empty overrides (around the area near `updateSearchTextHighlightState`): + +```swift + override public func willUpdateIsExtractedToContextPreview(_ value: Bool) { + } + + override public func updateIsExtractedToContextPreview(_ value: Bool) { + } +``` + +Replace BOTH with: + +```swift + override public func willUpdateIsExtractedToContextPreview(_ value: Bool) { + if !value, let textSelectionNode = self.textSelectionNode { + self.textSelectionNode = nil + self.textSelectionAdapter = nil + textSelectionNode.highlightAreaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + textSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak textSelectionNode] _ in + textSelectionNode?.highlightAreaNode.removeFromSupernode() + textSelectionNode?.removeFromSupernode() + }) + } + } + + override public func updateIsExtractedToContextPreview(_ value: Bool) { + guard value, self.textSelectionNode == nil, let messageItem = self.item, let layout = self.currentPageLayout?.layout, let rootNode = messageItem.controllerInteraction.chatControllerNode() else { + return + } + + let items = layout.items.compactMap { $0 as? InstantPageTextItem }.filter { $0.selectable && !$0.attributedString.string.isEmpty } + guard !items.isEmpty else { + return + } + + let adapter = InstantPageMultiTextAdapter(items: items) + adapter.frame = self.containerNode.bounds + self.textSelectionAdapter = adapter + self.containerNode.addSubnode(adapter) + + let incoming = messageItem.message.effectivelyIncoming(messageItem.context.account.peerId) + let theme = messageItem.presentationData.theme.theme + let selectionColor = incoming ? theme.chat.message.incoming.textSelectionColor : theme.chat.message.outgoing.textSelectionColor + let knobColor = incoming ? theme.chat.message.incoming.textSelectionKnobColor : theme.chat.message.outgoing.textSelectionKnobColor + + let textSelectionNode = TextSelectionNode( + theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: theme.overallDarkAppearance), + strings: messageItem.presentationData.strings, + textNodeOrView: .node(adapter), + updateIsActive: { _ in }, + present: { [weak self] c, a in + guard let self, let item = self.item else { + return + } + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info { + item.controllerInteraction.presentControllerInCurrent(c, a) + } else { + item.controllerInteraction.presentGlobalOverlayController(c, a) + } + }, + rootView: { [weak rootNode] in + return rootNode?.view + }, + performAction: { [weak self] text, action in + guard let self, let item = self.item else { + return + } + item.controllerInteraction.performTextSelectionAction(item.message, true, text, nil, action) + } + ) + + let enableCopy = (!messageItem.associatedData.isCopyProtectionEnabled && !messageItem.message.isCopyProtected()) || messageItem.message.id.peerId.isVerificationCodes + textSelectionNode.enableCopy = enableCopy + textSelectionNode.enableQuote = false + textSelectionNode.enableTranslate = true + textSelectionNode.enableShare = true + textSelectionNode.enableLookup = true + + textSelectionNode.frame = self.containerNode.bounds + textSelectionNode.highlightAreaNode.frame = self.containerNode.bounds + self.containerNode.addSubnode(textSelectionNode.highlightAreaNode) + self.containerNode.addSubnode(textSelectionNode) + self.textSelectionNode = textSelectionNode + } +``` + +Notes on this block: +- `chatControllerNode()` returns `ASDisplayNode?` from `ChatControllerInteraction`. The `rootView` closure weakly captures it. +- `TextSelectionTheme(selection:knob:isDark:)` — verified against `submodules/TextSelectionNode/Sources/TextSelectionNode.swift:66`. +- `TextSelectionNode(theme:strings:textNodeOrView:updateIsActive:present:rootView:externalKnobSurface:performAction:)` — verified against the same file at line 296. We omit `externalKnobSurface` (defaulted to `nil`). +- `controllerInteraction.performTextSelectionAction(_:_:_:_:_:)` signature `(Message?, Bool, NSAttributedString, [MessageTextEntity]?, TextSelectionAction)` — verified against `ChatControllerInteraction.swift:263`. +- The `subject` pattern `case let .messageOptions(_, _, info) = subject, case .reply = info` mirrors `ChatMessageTextBubbleContentNode.swift:1651-1654`. +- `enableLookup` defaults to `true` on `TextSelectionNode`; we set it explicitly for clarity. +- The capture `[weak self]` in `present` and `performAction` is enough; we don't need to retain `self` from inside those closures because the bubble node stays alive while the selection node does. + +- [ ] **Step 2: Build to verify** + +```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 --continueOnError +``` + +Expected: build succeeds. + +Common likely errors and fixes: +- "value of type 'ChatMessageItemAssociatedData' has no member 'isCopyProtectionEnabled'" → property name has changed; grep `submodules/TelegramUI/Components/Chat/ChatMessageItemCommon/Sources/` for the canonical name and substitute. +- "value of type 'PeerId' has no member 'isVerificationCodes'" → grep `submodules/TelegramCore/Sources/` for `isVerificationCodes` and import the right module if missing. +- "instance method 'presentControllerInCurrent' requires…" / similar — both `presentControllerInCurrent` and `presentGlobalOverlayController` exist on `ChatControllerInteraction` (lines 219 and 222 of `ChatControllerInteraction.swift`); their signatures take `(ViewController, Any?)`. +- Missing pre-existing failures from the user's `sideInset` migration are NOT from this task — see Task 1 Step 4's note. + +- [ ] **Step 3: Commit** + +```sh +git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +git commit -m "Rich bubble: drag-handle text selection in context-preview mode" +``` + +--- + +### Task 5: Manual smoke verification + +There are no automated tests for chat UI. Final task is a hands-on smoke test in the simulator. + +**Files:** none modified. Verification only. + +- [ ] **Step 1: Launch the app in the simulator** + +The build command run in earlier tasks produces the app binary; launch via Xcode (open `Telegram-iOS.xcworkspace` if the workspace exists locally) or run the Bazel-produced target. Sign in to a real test account. + +- [ ] **Step 2: Find or send a message with a rich-data IV preview** + +Send a Telegraph or t.me URL that produces an instant-view preview. The `debugRichText` experimental setting must be enabled for the preview to render via `ChatMessageRichDataBubbleContentNode` instead of the standard webpage card. (`ChatMessageBubbleItemNode.swift:386-387` is the gate.) + +- [ ] **Step 3: Long-press the bubble** + +Long-press the rich-data bubble. The framework lifts it into the context-preview popover, the message context menu appears alongside. + +- [ ] **Step 4: Drag-select text** + +Tap and drag inside the lifted preview. Drag-handles (knobs) should appear; the selection should extend across paragraphs as you drag. The selection background uses the incoming/outgoing `textSelectionColor` from the current theme. + +- [ ] **Step 5: Verify the action menu** + +The action menu (Copy / Translate / Share / Speak / Look Up) should appear anchored on the selection. Verify: +- Copy: places the selected text on the pasteboard. Multi-paragraph selections include `\n\n` between paragraphs. +- Quote menu item is **not** present (we explicitly disabled it). +- Translate / Share / Speak / Look Up route through the standard chat flow and behave normally. + +- [ ] **Step 6: Dismiss the context preview** + +Tap outside the preview or scroll away. The selection overlay and knobs fade out cleanly (alpha 1→0 over 0.2s), and the bubble returns to its normal state. Re-opening the context preview should re-create the selection nodes from scratch. + +- [ ] **Step 7: Sign-off** + +If steps 4–6 behave as described, the change is complete. If any step fails, capture: which step, observed vs. expected, any console output. Common deviations: +- Knobs never appear → confirm `textSelectionNode.frame` and `textSelectionNode.highlightAreaNode.frame` are both `containerNode.bounds`. If `containerNode.bounds.size` is zero at the moment of preview entry, the selection node has nothing to draw on. +- Selection rects don't line up with text → confirm `adapter.frame.origin` is `.zero` and item frames in the layout are in `containerNode`-local coords (the layout origin is `(0, 0)` inside `containerNode`; the `(1, 1)` outer offset doesn't apply here because the selection nodes live INSIDE `containerNode`). +- Cross-paragraph drag stops at paragraph boundaries → confirm `attributesAtPoint` falls through to the nearest-entry branch when `orNearest == true`. +- Copy includes wrong text → confirm `combinedString` interleaves `"\n\n"` separators between items. + +--- + +## Self-review + +**Spec coverage:** +- Spec §"API exposure on `InstantPageTextItem`" → Task 1 (all three accessors). +- Spec §"`InstantPageMultiTextAdapter`" → Task 2 (full adapter). +- Spec §"Rich-bubble lifecycle wiring" — ivars/import/BUILD dep → Task 3; `updateIsExtractedToContextPreview` / `willUpdateIsExtractedToContextPreview` → Task 4. +- Spec §"Verification" → Task 5 (manual smoke). +- Spec §"Out of scope" stays out of scope; no tasks added. + +All spec sections covered. + +**Placeholder scan:** None of the "TBD" / "TODO" / "implement later" / "similar to Task N" patterns are present. Each step shows the actual code or command. + +**Type consistency:** +- `InstantPageMultiTextAdapter` is named the same in Task 2 (definition) and Tasks 3–4 (consumers). +- `Entry` struct is internal to the adapter and not referenced externally. +- `InstantPageTextItem.attributesAtPoint(_:orNearest:)` and `textRangeRects(in:)` are defined in Task 1 with the same signatures the adapter calls in Task 2. +- `TextSelectionNode` initializer parameters in Task 4 match the verified signature from `submodules/TextSelectionNode/Sources/TextSelectionNode.swift:296` (`theme`, `strings`, `textNodeOrView`, `updateIsActive`, `present`, `rootView`, `performAction` — no `externalKnobSurface`). +- `TextSelectionTheme(selection:knob:isDark:)` matches the verified init at line 66 of the same file. +- `controllerInteraction.performTextSelectionAction` invocation matches `(Message?, Bool, NSAttributedString, [MessageTextEntity]?, TextSelectionAction)` from line 263 of `ChatControllerInteraction.swift`. +- `subject` pattern `.messageOptions(_, _, info)` with `case .reply = info` mirrors text-bubble exactly. diff --git a/docs/superpowers/specs/2026-05-01-rich-bubble-text-selection-design.md b/docs/superpowers/specs/2026-05-01-rich-bubble-text-selection-design.md new file mode 100644 index 0000000000..0dbdf584e9 --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-rich-bubble-text-selection-design.md @@ -0,0 +1,145 @@ +# Text selection in `ChatMessageRichDataBubbleContentNode` + +## Context + +`ChatMessageRichDataBubbleContentNode` renders an instant-page preview inline inside a chat bubble using `InstantPageLayout`/`InstantPageTile`/`InstantPageNode`. Users can already tap URLs and tap media (gallery), but cannot select any of the article text inside the preview. + +Two reference implementations exist: + +- `ChatMessageTextBubbleContentNode` (`submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/...`) — uses `TextSelectionNode` (drag-handle / knob style, action menu via `controllerInteraction.performTextSelectionAction`). Selection is gated on the bubble entering context-preview mode (`updateIsExtractedToContextPreview(true)`). The text-bubble has a single `textNode` so `TextSelectionNode` wraps it directly. +- `InstantPageControllerNode` (`submodules/InstantPageUI/Sources/...`) — paragraph-granularity highlight + `ContextMenuController`. Long-tap on a paragraph selects the whole paragraph; no drag-handles. + +The user picked the chat text-bubble model, gated only on context-preview mode. The structural challenge: rich-bubble has **many** `InstantPageTextItem`s spread across tiles, with no per-item rendering node — text is drawn directly into tile contexts via `CTLine`. + +## Goal + +Wire drag-handle text selection inside `ChatMessageRichDataBubbleContentNode`, available only in context-preview mode, with cross-paragraph selection across all `InstantPageTextItem`s in the visible layout. Action menu integrates Copy / Translate / Share / Speak / Look Up via `controllerInteraction.performTextSelectionAction`. Quote is disabled (the IV preview text is not part of `item.message.text`, which the quote feature references). + +Out of scope: nested selection inside `InstantPageDetailsItem` / `InstantPageScrollableItem` (rich-bubble does not expand details or scroll inner content); selection during normal (non-preview) interaction; per-paragraph selection-only mode. + +## Design + +### Files touched + +- **Modify:** `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` — promote three accessors to public so a `TextNodeProtocol` adapter can build on top. +- **Create:** `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` — a `TextNodeProtocol`-conforming `ASDisplayNode` that aggregates multiple `InstantPageTextItem`s into a single character-indexed text view. +- **Modify:** `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD` — add `//submodules/TextSelectionNode`. +- **Modify:** `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` — add the `import`, two new ivars, and lifecycle hooks for entering / leaving context preview. + +### API exposure on `InstantPageTextItem` + +Three accessors become public: + +```swift +public final class InstantPageTextItem: InstantPageItem { + public let attributedString: NSAttributedString // was `let` (package-private) + // ... + + public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? + public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? +} +``` + +- The new `attributesAtPoint(_:orNearest:)` extends the existing internal `attributesAtPoint(_:)`. When `orNearest == true` and no line directly contains the point, it picks the line with the smallest vertical distance to the point and runs `CTLineGetStringIndexForPosition` with the X clamped to that line's horizontal range. Mirrors what `TextNode.attributesAtPoint(orNearest:)` does. The existing internal `attributesAtPoint(_:)` is preserved (still used by `urlAttribute(at:)` and `linkSelectionRects(at:)`). +- The new `textRangeRects(in:)` wraps the existing internal `rangeRects(in:)`. It returns `Display.TextRangeRectEdge` (same `(x, y, height: CGFloat)` shape as the IV's local `InstantPageTextRangeRectEdge`). When the inner result has no edges (range maps to no rects), the public version returns `nil`. + +The existing internal members are not renamed — only new public surface is added. + +### `InstantPageMultiTextAdapter` + +A `TextNodeProtocol`-conforming `ASDisplayNode` that aggregates multiple `InstantPageTextItem`s into a single character-indexed text view: + +```swift +public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol { + private struct Entry { + let item: InstantPageTextItem + let charOffset: Int // global char index where this item's text starts + let frameOrigin: CGPoint // item.frame.origin, in adapter-local coords + } + + private let entries: [Entry] + private let combinedString: NSAttributedString + + public init(items: [InstantPageTextItem]) + + // TextNodeProtocol + public var currentText: NSAttributedString? { combinedString } + public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? + public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? +} +``` + +**Construction.** `init(items:)` walks the list in document order. For each item: +1. Append `item.attributedString` to the running combined string. +2. Record `Entry(item, charOffset: combined.length-before-append, frameOrigin: item.frame.origin)`. +3. Append `"\n\n"` (plain) as a separator between entries (no separator after the last). + +The separator chars sit in the global string with no rects in `textRangeRects` (no entry contains them), so visual selection cleanly skips inter-paragraph gaps. They also keep paragraph breaks in the copied text. + +**`attributesAtPoint(_:orNearest:)`.** +1. Direct pass: for each entry, compute `localPoint = point - entry.frameOrigin`. If `entry.item.attributesAtPoint(localPoint, orNearest: false)` returns a hit, return `(entry.charOffset + localIndex, attrs)`. +2. Fallback (only when `orNearest == true`): pick the entry whose frame has the smallest vertical distance to `point.y` (zero if `point.y` is in the y-range, otherwise `min(|p.y - frame.minY|, |p.y - frame.maxY|)`), then call its `attributesAtPoint(localPoint, orNearest: true)`. Return `(entry.charOffset + localIndex, attrs)` or `nil` if even the nearest item returns nil. +3. Otherwise return `nil`. + +**`textRangeRects(in:)`.** Splits the global range across entries: +1. For each entry whose `[charOffset, charOffset + item.attributedString.length)` intersects the requested range: + - Compute the local sub-range within the entry. + - Call `entry.item.textRangeRects(in: localRange)`. + - Translate each rect by `entry.frameOrigin`. + - First contributing entry: take its `start` edge translated by `frameOrigin`. + - Each contributing entry updates `end` to its translated `end` edge. +2. If no entry contributed any rects, return `nil`. +3. Otherwise return `(allRects, start, end)`. + +The adapter is invisible — it has no contents and is purely a `TextNodeProtocol` provider. It exists as an `ASDisplayNode` only because the protocol requires it. + +### Rich-bubble lifecycle wiring + +**New ivars:** + +```swift +private var textSelectionAdapter: InstantPageMultiTextAdapter? +private var textSelectionNode: TextSelectionNode? +``` + +**Imports / BUILD:** add `import TextSelectionNode` and `//submodules/TextSelectionNode` to the rich-bubble's BUILD deps. `TextRangeRectEdge` lives in `Display`, already imported. + +**Entering preview** (`updateIsExtractedToContextPreview(true)`): +1. Bail out if `textSelectionNode != nil`, no `item`, no `currentPageLayout`, or no `chatControllerNode`. +2. Filter `currentPageLayout.layout.items` to selectable, non-empty `InstantPageTextItem`s. +3. Construct `InstantPageMultiTextAdapter(items:)`, set its frame to `containerNode.bounds`, add it to `containerNode`. +4. Pick incoming/outgoing `textSelectionColor` and `textSelectionKnobColor` from the presentation theme. +5. Construct `TextSelectionNode` with: + - `textNodeOrView: .node(adapter)` + - `present`: routes to `controllerInteraction.presentControllerInCurrent` when `item.associatedData.subject` matches `.messageOptions(_, _, info)` with `case .reply = info`, else `presentGlobalOverlayController` — same branch the text-bubble uses (see `ChatMessageTextBubbleContentNode.swift:1651-1654`). + - `rootView`: returns the `chatControllerNode` view. + - `performAction`: routes to `controllerInteraction.performTextSelectionAction(item.message, true, text, nil, action)`. +6. Set flags: + - `enableCopy = (!associatedData.isCopyProtectionEnabled && !message.isCopyProtected()) || message.id.peerId.isVerificationCodes` — same rule as text-bubble. + - `enableQuote = false` — quote-replies reference `item.message.text`; IV preview text isn't in there. + - `enableTranslate = true`, `enableShare = true`, `enableLookup = true`. +7. Set `textSelectionNode.frame` and `textSelectionNode.highlightAreaNode.frame` to `containerNode.bounds`. +8. Add `highlightAreaNode` then `textSelectionNode` to `containerNode`. + +**Leaving preview** (`willUpdateIsExtractedToContextPreview(false)`): mirror text-bubble's tear-down — animate alpha 1→0 over 0.2s on both `highlightAreaNode` and the `textSelectionNode` itself, remove from supernode in the completion, clear both ivars synchronously so a subsequent re-entry creates fresh nodes. + +**Subnode ordering** inside `containerNode` (bottom → top): tiles → `linkHighlightingNode` (touch state, from earlier task) → `linkProgressView` (in-flight URL shimmer) → adapter (invisible) → `textSelectionNode.highlightAreaNode` → `textSelectionNode`. Order preserved by insertion sequence. + +**Coordinate strategy.** Both adapter and `textSelectionNode` use `containerNode.bounds`. The IV layout origin is `(0, 0)` inside `containerNode`, and `InstantPageTextItem.frame` is in that space — so the adapter's local coords line up with item frames directly without a `(1, 1)` translation. (The `(1, 1)` translation only applies to points coming from the bubble-content-node coord space, e.g., in `tapActionAtPoint`.) + +## Verification + +- Build green: `python3 build-system/Make/Make.py … build … --configuration=debug_sim_arm64`. No automated tests in this project. +- Manual test in simulator: + 1. Find or send a message with a rich-data IV preview (`debugRichText` setting must be on). + 2. Long-press the bubble — it lifts into the context-preview popover, the message context menu appears alongside. + 3. In the lifted preview, select text by tapping and dragging knobs. Selection extends across paragraphs. + 4. Action menu (Copy / Translate / Share / Speak / Look Up) appears anchored on the selection. Confirm Copy puts the selected text on the pasteboard, with `\n\n` between paragraphs. + 5. Quote menu item is **not** present. + 6. Dismiss the context preview — selection overlay and knobs fade out cleanly. + +## Open follow-ups (not in this spec) + +- Cross-paragraph selection inside expanded `InstantPageDetailsItem` / scrollable `InstantPageScrollableItem` content (rich-bubble doesn't currently expand or scroll those). +- Spoiler awareness on selection (text-bubble has spoiler-aware selection that triggers reveal). IV text items currently don't carry spoiler attributes through `attributedString` in a way that's symmetric with chat text, so deferred. +- Search-text highlighting within the IV preview (`updateSearchTextHighlightState`). diff --git a/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift b/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift new file mode 100644 index 0000000000..6c3051114c --- /dev/null +++ b/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift @@ -0,0 +1,103 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import TelegramCore + +public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol { + private struct Entry { + let item: InstantPageTextItem + let charOffset: Int + let frameOrigin: CGPoint + } + + private let entries: [Entry] + private let combinedString: NSAttributedString + + public init(items: [InstantPageTextItem]) { + let separator = NSAttributedString(string: "\n\n") + let combined = NSMutableAttributedString() + var entries: [Entry] = [] + for (index, item) in items.enumerated() { + let charOffset = combined.length + entries.append(Entry(item: item, charOffset: charOffset, frameOrigin: item.frame.origin)) + combined.append(item.attributedString) + if index != items.count - 1 { + combined.append(separator) + } + } + self.entries = entries + self.combinedString = combined + super.init() + self.isUserInteractionEnabled = false + } + + public var currentText: NSAttributedString? { + return self.combinedString + } + + public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? { + for entry in self.entries { + let localPoint = CGPoint(x: point.x - entry.frameOrigin.x, y: point.y - entry.frameOrigin.y) + if let (localIndex, attrs) = entry.item.attributesAtPoint(localPoint, orNearest: false) { + return (entry.charOffset + localIndex, attrs) + } + } + guard orNearest, !self.entries.isEmpty else { + return nil + } + var nearestEntry = self.entries[0] + var nearestDistance = CGFloat.greatestFiniteMagnitude + for entry in self.entries { + let frame = CGRect(origin: entry.frameOrigin, size: entry.item.frame.size) + let distance: CGFloat + if point.y < frame.minY { + distance = frame.minY - point.y + } else if point.y > frame.maxY { + distance = point.y - frame.maxY + } else { + distance = 0.0 + } + if distance < nearestDistance { + nearestDistance = distance + nearestEntry = entry + } + } + let localPoint = CGPoint(x: point.x - nearestEntry.frameOrigin.x, y: point.y - nearestEntry.frameOrigin.y) + if let (localIndex, attrs) = nearestEntry.item.attributesAtPoint(localPoint, orNearest: true) { + return (nearestEntry.charOffset + localIndex, attrs) + } + return nil + } + + public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { + var allRects: [CGRect] = [] + var startEdge: TextRangeRectEdge? + var endEdge: TextRangeRectEdge? + for entry in self.entries { + let itemLength = entry.item.attributedString.length + let entryRange = NSRange(location: entry.charOffset, length: itemLength) + let intersection = NSIntersectionRange(range, entryRange) + if intersection.length == 0 { + continue + } + let localRange = NSRange(location: intersection.location - entry.charOffset, length: intersection.length) + guard let result = entry.item.textRangeRects(in: localRange) else { + continue + } + for rect in result.rects { + allRects.append(rect.offsetBy(dx: entry.frameOrigin.x, dy: entry.frameOrigin.y)) + } + let translatedStart = TextRangeRectEdge(x: result.start.x + entry.frameOrigin.x, y: result.start.y + entry.frameOrigin.y, height: result.start.height) + let translatedEnd = TextRangeRectEdge(x: result.end.x + entry.frameOrigin.x, y: result.end.y + entry.frameOrigin.y, height: result.end.height) + if startEdge == nil { + startEdge = translatedStart + } + endEdge = translatedEnd + } + guard !allRects.isEmpty, let start = startEdge, let end = endEdge else { + return nil + } + return (allRects, start, end) + } +} diff --git a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift index c90d09e5b3..10826a6c94 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift @@ -175,7 +175,7 @@ private func attachmentBoundsForRange(_ range: NSRange, line: InstantPageTextLin } public final class InstantPageTextItem: InstantPageItem { - let attributedString: NSAttributedString + public let attributedString: NSAttributedString public let lines: [InstantPageTextLine] let rtlLineIndices: Set public var frame: CGRect @@ -300,7 +300,7 @@ public final class InstantPageTextItem: InstantPageItem { let boundsWidth = self.frame.width for i in 0 ..< self.lines.count { let line = self.lines[i] - + let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment) if lineFrame.insetBy(dx: -5.0, dy: -5.0).contains(transformedPoint) { var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: transformedPoint.x - lineFrame.minX, y: transformedPoint.y - lineFrame.minY)) @@ -321,7 +321,53 @@ public final class InstantPageTextItem: InstantPageItem { } return nil } - + + public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? { + if let direct = self.attributesAtPoint(point) { + return direct + } + guard orNearest, !self.lines.isEmpty else { + return nil + } + + let boundsWidth = self.frame.width + var nearestLineIndex = 0 + var nearestDistance = CGFloat.greatestFiniteMagnitude + for i in 0 ..< self.lines.count { + let lineFrame = expandedFrameForLine(self.lines[i], boundingWidth: boundsWidth, alignment: self.alignment) + let distance: CGFloat + if point.y < lineFrame.minY { + distance = lineFrame.minY - point.y + } else if point.y > lineFrame.maxY { + distance = point.y - lineFrame.maxY + } else { + distance = 0.0 + } + if distance < nearestDistance { + nearestDistance = distance + nearestLineIndex = i + } + } + + let line = self.lines[nearestLineIndex] + let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment) + let clampedX = max(lineFrame.minX, min(lineFrame.maxX, point.x)) + var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: clampedX - lineFrame.minX, y: 0.0)) + if index == self.attributedString.length { + index -= 1 + } else if index != 0 { + var glyphStart: CGFloat = 0.0 + CTLineGetOffsetForStringIndex(line.line, index, &glyphStart) + if clampedX - lineFrame.minX < glyphStart { + index -= 1 + } + } + guard index >= 0, index < self.attributedString.length else { + return nil + } + return (index, self.attributedString.attributes(at: index, effectiveRange: nil)) + } + private func attributeRects(name: NSAttributedString.Key, at index: Int) -> [CGRect]? { var range = NSRange() let _ = self.attributedString.attribute(name, at: index, effectiveRange: &range) @@ -396,9 +442,9 @@ public final class InstantPageTextItem: InstantPageItem { guard range.length != 0 else { return nil } - + let boundsWidth = self.frame.width - + var rects: [(CGRect, CGRect)] = [] var startEdge: InstantPageTextRangeRectEdge? var endEdge: InstantPageTextRangeRectEdge? @@ -419,11 +465,11 @@ public final class InstantPageTextItem: InstantPageItem { rightOffset = ceil(secondaryOffset) } } - + let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment) - + let width = max(0.0, abs(rightOffset - leftOffset)) - + if line.range.contains(range.lowerBound) { let offsetX = floor(CTLineGetOffsetForStringIndex(line.line, range.lowerBound, nil)) startEdge = InstantPageTextRangeRectEdge(x: lineFrame.minX + offsetX, y: lineFrame.minY, height: lineFrame.height) @@ -437,7 +483,7 @@ public final class InstantPageTextItem: InstantPageItem { let primaryOffset = floor(CTLineGetOffsetForStringIndex(line.line, range.upperBound - 1, &secondaryOffset)) secondaryOffset = floor(secondaryOffset) let nextOffet = floor(CTLineGetOffsetForStringIndex(line.line, range.upperBound, &secondaryOffset)) - + if primaryOffset != secondaryOffset { offsetX = secondaryOffset } else { @@ -446,7 +492,7 @@ public final class InstantPageTextItem: InstantPageItem { } endEdge = InstantPageTextRangeRectEdge(x: lineFrame.minX + offsetX, y: lineFrame.minY, height: lineFrame.height) } - + rects.append((lineFrame, CGRect(origin: CGPoint(x: lineFrame.minX + min(leftOffset, rightOffset), y: lineFrame.minY), size: CGSize(width: width, height: lineFrame.size.height)))) } } @@ -455,7 +501,16 @@ public final class InstantPageTextItem: InstantPageItem { } return nil } - + + public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { + guard let result = self.rangeRects(in: range), let start = result.start, let end = result.end, !result.rects.isEmpty else { + return nil + } + let startEdge = TextRangeRectEdge(x: start.x, y: start.y, height: start.height) + let endEdge = TextRangeRectEdge(x: end.x, y: end.y, height: end.height) + return (result.rects, startEdge, endEdge) + } + public func lineRects() -> [CGRect] { let boundsWidth = self.frame.width var rects: [CGRect] = [] diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD index 28bdeb5f94..0615ab5b69 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD @@ -22,6 +22,7 @@ swift_library( "//submodules/TelegramUI/Components/ChatControllerInteraction", "//submodules/TelegramUI/Components/TextLoadingEffect", "//submodules/TelegramUIPreferences", + "//submodules/TextSelectionNode", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index f09fc1549f..a5e003fda8 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -12,6 +12,7 @@ import ChatControllerInteraction import InstantPageUI import TelegramUIPreferences import TextLoadingEffect +import TextSelectionNode public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode { public final class ContainerNode: ASDisplayNode { @@ -30,7 +31,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode private var linkProgressRects: [CGRect]? private var linkHighlightingNode: LinkHighlightingNode? private var linkProgressView: TextLoadingEffectView? - + private var textSelectionAdapter: InstantPageMultiTextAdapter? + private var textSelectionNode: TextSelectionNode? + override public var visibility: ListViewItemNodeVisibility { didSet { if oldValue != self.visibility { @@ -697,9 +700,82 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode } override public func willUpdateIsExtractedToContextPreview(_ value: Bool) { + if !value, let textSelectionNode = self.textSelectionNode { + self.textSelectionNode = nil + self.textSelectionAdapter = nil + textSelectionNode.highlightAreaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + textSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak textSelectionNode] _ in + textSelectionNode?.highlightAreaNode.removeFromSupernode() + textSelectionNode?.removeFromSupernode() + }) + } } - + override public func updateIsExtractedToContextPreview(_ value: Bool) { + guard value, self.textSelectionNode == nil, let messageItem = self.item, let layout = self.currentPageLayout?.layout, let rootNode = messageItem.controllerInteraction.chatControllerNode() else { + return + } + + let items = layout.items.compactMap { $0 as? InstantPageTextItem }.filter { $0.selectable && !$0.attributedString.string.isEmpty } + guard !items.isEmpty else { + return + } + + let adapter = InstantPageMultiTextAdapter(items: items) + adapter.frame = self.containerNode.bounds + self.textSelectionAdapter = adapter + self.containerNode.addSubnode(adapter) + + let incoming = messageItem.message.effectivelyIncoming(messageItem.context.account.peerId) + let theme = messageItem.presentationData.theme.theme + let selectionColor = incoming ? theme.chat.message.incoming.textSelectionColor : theme.chat.message.outgoing.textSelectionColor + let knobColor = incoming ? theme.chat.message.incoming.textSelectionKnobColor : theme.chat.message.outgoing.textSelectionKnobColor + + let textSelectionNode = TextSelectionNode( + theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: theme.overallDarkAppearance), + strings: messageItem.presentationData.strings, + textNodeOrView: .node(adapter), + updateIsActive: { _ in }, + present: { [weak self] c, a in + guard let self, let item = self.item else { + return + } + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info { + item.controllerInteraction.presentControllerInCurrent(c, a) + } else { + item.controllerInteraction.presentGlobalOverlayController(c, a) + } + }, + rootView: { [weak rootNode] in + return rootNode?.view + }, + performAction: { [weak self] text, action in + guard let self, let item = self.item else { + return + } + item.controllerInteraction.performTextSelectionAction(item.message, true, text, nil, action) + } + ) + + let enableCopy = (!messageItem.associatedData.isCopyProtectionEnabled && !messageItem.message.isCopyProtected()) || messageItem.message.id.peerId.isVerificationCodes + textSelectionNode.enableCopy = enableCopy + + var enableOtherActions = true + if let subject = messageItem.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info { + enableOtherActions = false + } + + textSelectionNode.enableQuote = false + textSelectionNode.enableTranslate = enableOtherActions + textSelectionNode.enableShare = enableOtherActions && enableCopy + textSelectionNode.enableLookup = true + textSelectionNode.menuSkipCoordnateConversion = !enableOtherActions + + textSelectionNode.frame = self.containerNode.bounds + textSelectionNode.highlightAreaNode.frame = self.containerNode.bounds + self.containerNode.insertSubnode(textSelectionNode.highlightAreaNode, at: 0) + self.containerNode.addSubnode(textSelectionNode) + self.textSelectionNode = textSelectionNode } override public func transitionNode(messageId: MessageId, media: Media, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {