Rich bubble: text selection in context-preview mode

Adds drag-handle text selection driven by TextSelectionNode. Exposes
attributedString and selection helpers on InstantPage text items, and
introduces a multi-text adapter aggregating items as a TextNodeProtocol.
Gates selection actions on reply-options and fixes highlight z-order.
This commit is contained in:
isaac 2026-05-01 21:06:25 +02:00
parent fdb2f369ec
commit 972cdf0658
6 changed files with 1020 additions and 13 deletions

View file

@ -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 <specific-path>` — 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 46 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 34 (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.

View file

@ -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`).

View file

@ -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)
}
}

View file

@ -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<Int>
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] = []

View file

@ -22,6 +22,7 @@ swift_library(
"//submodules/TelegramUI/Components/ChatControllerInteraction",
"//submodules/TelegramUI/Components/TextLoadingEffect",
"//submodules/TelegramUIPreferences",
"//submodules/TextSelectionNode",
],
visibility = [
"//visibility:public",

View file

@ -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?))? {