mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-06 03:33:41 +02:00
RichText: entity cases (mention / hashtag / cashtag / bot command / bank card / auto link)
Implement the nine message-entity RichText cases (textMention, textMentionName, textHashtag, textCashtag, textBotCommand, textBankCard, textAutoUrl, textAutoEmail, textAutoPhone): model + Postbox coding, FlatBuffers schema/codec, Api.RichText parsing/serialization (lossless), InstantPage display attaching the matching TelegramTextAttributes keys, and tap routing in the rich-data bubble mirroring ChatMessageTextBubbleContentNode. mentionName display resolves via EnginePeer.Id (PeerId not in scope). Also lets rich-data text selection reach a line's trailing edge and fixes the date/status node positioning to match TextBubble. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
b48b96ecd9
commit
99e4c41b24
13 changed files with 558 additions and 78 deletions
29
CLAUDE.md
29
CLAUDE.md
|
|
@ -74,6 +74,15 @@ Spec: [`docs/superpowers/specs/2026-05-19-richdata-streaming-animation-design.md
|
||||||
- **`Thinking…` header positioning matches TextBubble.** `streamingTextFrame.origin = (bubbleInsets.left - textInsets.left, topInset - textInsets.top)`. `streamingHeaderOffset` = visible bottom + 1pt spacing = where pageView's `frame.origin.y` and statusFrame y-shift attach. Bubble minimum width includes `visible_thinking_width + bubbleInsets.left + bubbleInsets.right + 2`.
|
- **`Thinking…` header positioning matches TextBubble.** `streamingTextFrame.origin = (bubbleInsets.left - textInsets.left, topInset - textInsets.top)`. `streamingHeaderOffset` = visible bottom + 1pt spacing = where pageView's `frame.origin.y` and statusFrame y-shift attach. Bubble minimum width includes `visible_thinking_width + bubbleInsets.left + bubbleInsets.right + 2`.
|
||||||
- **Display-link tick re-layouts on extent change.** Tick reads `revealedContentSize` at the new cursor; if the height differs from the previous cursor, calls `requestFullUpdate`. So the bubble grows in flight when the cursor crosses a line/item boundary, not just between chunks. Tick passes `animated: true` to `applyReveal` to fire the snippet pop-in.
|
- **Display-link tick re-layouts on extent change.** Tick reads `revealedContentSize` at the new cursor; if the height differs from the previous cursor, calls `requestFullUpdate`. So the bubble grows in flight when the cursor crosses a line/item boundary, not just between chunks. Tick passes `animated: true` to `applyReveal` to fire the snippet pop-in.
|
||||||
|
|
||||||
|
### Status node (date/time/checks) positioning
|
||||||
|
|
||||||
|
The `ChatMessageDateAndStatusNode` mirrors TextBubble's placement, adapted to the heterogeneous V2 layout. The node is a child of `self` (the content node), **not** of the clipping `containerNode`, so it is never clipped — the bubble height must be grown to contain it.
|
||||||
|
|
||||||
|
- **X is a fixed left edge, not the last line's `minX`.** Anchor x = `pageHorizontalInset` (10pt, the page layout's text inset; pageView sits at self-x 0). The status layout is measured with `boundingWidth - 2·pageHorizontalInset` (mirrors TextBubble's `boundingWidth - sideInsets`) so the right-aligned date lands at the right inset instead of off the bubble. Using `lastTextLineFrame.minX` (which is large for nested/indented last lines) shoved the date off to the right.
|
||||||
|
- **Trail the last line only when the bottom-most item is text.** `lastTextLineFrameIfLastItemIsText(in:)` (in `InstantPageV2Layout.swift`) returns the last line frame *only* when the bottom-most top-level item (max `maxY`) is a `.text`; otherwise nil, so the date wraps below all content (anchored at `contentSize.height`). For tables/images/etc. the date must not trail text buried above the final item.
|
||||||
|
- **InstantPage draws the baseline at the line frame's `maxY`** (`InstantPageRenderer` draws each line at `lineOrigin.y + lineFrame.height`), so the visible text of a plain line sits ~5pt below `maxY`. A date that **trails** on the line (`statusHeight == 0`) adds `trailingBottomPadding` (5pt) to align with the text; a date that **wraps** onto its own line below (`statusHeight > 0`) sits at the bare `maxY`. The pad is 0 for lines taller than their font line height (an inline animated emoji, ~`pointSize·24/17`, already pushes `maxY` down). `lastTextLineFrameIfLastItemIsText` returns `(frame, trailingBottomPadding)`; the bubble applies the pad only in the trailing case.
|
||||||
|
- **Bubble height leaves ~6pt below the date.** One unified formula for all cases: `boundingSize.height = max(boundingSize.height, statusBottomEdge + 6.0)`, where `statusBottomEdge = statusAnchorY + max(1, statusHeight)`. The `statusAnchorY` in the measure (`continue`) closure must mirror the `statusFrameY (+ streamingHeaderOffset)` in the apply closure exactly, or the date will be clipped/misplaced. 6pt matches TextBubble's bottom bubble inset.
|
||||||
|
|
||||||
## Inline custom emoji (RichText.textCustomEmoji)
|
## Inline custom emoji (RichText.textCustomEmoji)
|
||||||
|
|
||||||
`RichText.textCustomEmoji(fileId:alt:)` renders an inline **animated** custom emoji inside rich-data bubbles. Covers API parsing, Postbox + FlatBuffers serialization, and display in the InstantPage V2 renderer; the emoji participates in the streaming reveal above.
|
`RichText.textCustomEmoji(fileId:alt:)` renders an inline **animated** custom emoji inside rich-data bubbles. Covers API parsing, Postbox + FlatBuffers serialization, and display in the InstantPage V2 renderer; the emoji participates in the streaming reveal above.
|
||||||
|
|
@ -99,6 +108,26 @@ Spec: [`docs/superpowers/specs/2026-05-19-richdata-streaming-animation-design.md
|
||||||
- **`visibilityRect` gates looping; `nil` means "not visible".** The bubble's `visibility` override pushes a full-width sub-rect to the root `pageView.visibilityRect`, re-pushed in the apply closure after `pageView.frame` is set (because `streamingHeaderOffset` shifts across chunks without a `visibility` change). `propagateVisibilityRect` converts the rect into each nested V2View's coordinate space (`self.convert(_:to:)`) for details bodies / table cells+title, fanning out via each child's `didSet`.
|
- **`visibilityRect` gates looping; `nil` means "not visible".** The bubble's `visibility` override pushes a full-width sub-rect to the root `pageView.visibilityRect`, re-pushed in the apply closure after `pageView.frame` is set (because `streamingHeaderOffset` shifts across chunks without a `visibility` change). `propagateVisibilityRect` converts the rect into each nested V2View's coordinate space (`self.convert(_:to:)`) for details bodies / table cells+title, fanning out via each child's `didSet`.
|
||||||
- **CTRunDelegate extent buffers must be freed.** Every inline-attachment arm (`.image`/`.formula`/`.textCustomEmoji`) in `attributedStringForRichText` allocates an `extentBuffer`; the `dealloc` callback must `deallocate()` it (it re-runs per layout pass).
|
- **CTRunDelegate extent buffers must be freed.** Every inline-attachment arm (`.image`/`.formula`/`.textCustomEmoji`) in `attributedStringForRichText` allocates an `extentBuffer`; the `dealloc` callback must `deallocate()` it (it re-runs per layout pass).
|
||||||
|
|
||||||
|
## RichText entity cases (mention / hashtag / bot command / bank card / auto link)
|
||||||
|
|
||||||
|
`RichText.textMention`, `.textMentionName(text:peerId:)`, `.textHashtag`, `.textCashtag`, `.textBotCommand`, `.textBankCard`, `.textAutoUrl`, `.textAutoEmail`, `.textAutoPhone` render the message-entity flavors of rich text inside rich-data bubbles with full tap interaction mirroring `ChatMessageTextBubbleContentNode`. Covers API parsing, Postbox + FlatBuffers serialization, display, and tap routing. (`textDate`/`textSpoiler` remain unimplemented — `.plain("")`.)
|
||||||
|
|
||||||
|
### Where things live
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift` | The 9 enum cases (each wraps `text: RichText`; `textMentionName` adds raw `peerId: Int64`) + Postbox coding (discriminators 18–26, wrapped text under key `"t"`, mention-name peerId under `"mn.p"`), `==`, `plainText`, FlatBuffers codec. |
|
||||||
|
| `submodules/TelegramCore/FlatSerialization/Models/RichText.fbs` | Union members + tables (`RichText_MentionName` adds `peerId:long`). Source of truth — same flatc gotchas as the custom-emoji section above. |
|
||||||
|
| `submodules/TelegramCore/Sources/ApiUtils/RichText.swift` | `Api.RichText` ⇄ Swift, lossless. `textMentionName` carries `userId` ⇄ `peerId`. |
|
||||||
|
| `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` (`attributedStringForRichText`) | Display: auto url/email/phone reuse the `InstantPageUrlItem` (`url:`) path; the six entity cases push `.link(false)`, recurse, then attach the matching `TelegramTextAttributes.*` key over the produced range. |
|
||||||
|
| `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/...` | Tap routing: `entityForTapLocation` reads the attribute dict at the tapped point; `entityTapContent` maps keys → `ChatMessageBubbleContentTapAction.Content`. |
|
||||||
|
|
||||||
|
### Non-obvious invariants
|
||||||
|
|
||||||
|
- **Display attaches the same `TelegramTextAttributes.*` keys the chat text bubble uses; the bubble reads them back.** Contract: `textMention`→`PeerTextMention` (String); `textMentionName`→`PeerMention` (`TelegramPeerMention`, peerId built as `EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, …)` — `InstantPageTextItem` imports TelegramCore but NOT Postbox, so bare `PeerId` is out of scope); `textHashtag` AND `textCashtag`→`Hashtag` (`TelegramHashtag`; no dedicated cashtag key/tap-action — the leading `$` distinguishes them); `textBotCommand`→`BotCommand`; `textBankCard`→`BankCard`. Auto url/email/phone go through the URL path (`mailto:`/`tel:`/raw), NOT an entity key.
|
||||||
|
- **`linkSelectionRects` and the bubble tap path check all six interactive keys** (URL + the five entity keys), not just URL, so press-highlight and the link-loading shimmer cover entities too.
|
||||||
|
- **Rich-data text selection must reach a line's trailing edge.** This is general to rich-data selection, not just entities: `InstantPageTextItem.attributesAtPoint(_:orNearest:)`'s `orNearest: true` (selection-drag) path returns `line.range.upperBound` (via `CTLineGetStringRange`) when the point is at/past `lineFrame.maxX`. `TextSelectionNode` uses that index as the **exclusive** upper bound, so clamping to the last character's index — as the `orNearest: false` hit-testing path correctly does — would leave the last character/item of every line unselectable. Mirrors `Display.TextNode`. Do not collapse the two `orNearest` paths back together.
|
||||||
|
|
||||||
## Postbox → TelegramEngine refactor (in progress)
|
## Postbox → TelegramEngine refactor (in progress)
|
||||||
|
|
||||||
A gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`.
|
A gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`.
|
||||||
|
|
|
||||||
|
|
@ -1958,6 +1958,8 @@ private func markdownDroppingPrefixLength(_ length: Int, from text: RichText) ->
|
||||||
return dropped == .empty ? .empty : .anchor(text: dropped, name: name)
|
return dropped == .empty ? .empty : .anchor(text: dropped, name: name)
|
||||||
case .textCustomEmoji:
|
case .textCustomEmoji:
|
||||||
return text
|
return text
|
||||||
|
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName:
|
||||||
|
return text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1989,6 +1991,8 @@ private func markdownHasDisplayableContent(_ richText: RichText) -> Bool {
|
||||||
return !latex.isEmpty
|
return !latex.isEmpty
|
||||||
case .textCustomEmoji:
|
case .textCustomEmoji:
|
||||||
return true
|
return true
|
||||||
|
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName:
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2020,6 +2024,8 @@ private func markdownIsWhitespaceOnly(_ richText: RichText) -> Bool {
|
||||||
return latex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
return latex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
case .textCustomEmoji:
|
case .textCustomEmoji:
|
||||||
return false
|
return false
|
||||||
|
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName:
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,8 @@ private func trimStart(_ input: RichText) -> RichText {
|
||||||
break
|
break
|
||||||
case .textCustomEmoji:
|
case .textCustomEmoji:
|
||||||
break
|
break
|
||||||
|
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
@ -392,6 +394,8 @@ private func trimEnd(_ input: RichText) -> RichText {
|
||||||
break
|
break
|
||||||
case .textCustomEmoji:
|
case .textCustomEmoji:
|
||||||
break
|
break
|
||||||
|
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
@ -439,6 +443,8 @@ private func trim(_ input: RichText) -> RichText {
|
||||||
break
|
break
|
||||||
case .textCustomEmoji:
|
case .textCustomEmoji:
|
||||||
break
|
break
|
||||||
|
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
@ -485,6 +491,8 @@ private func addNewLine(_ input: RichText) -> RichText {
|
||||||
text = .concat([.formula(latex: latex), .plain("\n")])
|
text = .concat([.formula(latex: latex), .plain("\n")])
|
||||||
case .textCustomEmoji:
|
case .textCustomEmoji:
|
||||||
break
|
break
|
||||||
|
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,8 @@ extension RichText {
|
||||||
return "Fx"
|
return "Fx"
|
||||||
case let .textCustomEmoji(_, alt):
|
case let .textCustomEmoji(_, alt):
|
||||||
return alt
|
return alt
|
||||||
|
case let .textAutoEmail(value), let .textAutoPhone(value), let .textAutoUrl(value), let .textBankCard(value), let .textBotCommand(value), let .textCashtag(value), let .textHashtag(value), let .textMention(value), let .textMentionName(value, _):
|
||||||
|
return value.previewText()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,11 +102,11 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if let upper = upper, case .relatedArticles = upper {
|
if let upper, case .relatedArticles = upper {
|
||||||
return 0.0
|
return 0.0
|
||||||
} else {
|
} else {
|
||||||
if fitToWidth {
|
if fitToWidth {
|
||||||
return 10.0
|
return 5.0
|
||||||
} else {
|
} else {
|
||||||
return 25.0
|
return 25.0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -333,13 +333,16 @@ public final class InstantPageTextItem: InstantPageItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? {
|
public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? {
|
||||||
if let direct = self.attributesAtPoint(point) {
|
// Hit-testing (taps on links/entities) wants the character under the finger — keep the
|
||||||
return direct
|
// strict, clamping behavior.
|
||||||
|
if !orNearest {
|
||||||
|
return self.attributesAtPoint(point)
|
||||||
}
|
}
|
||||||
guard orNearest, !self.lines.isEmpty else {
|
guard !self.lines.isEmpty else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Selection drags can travel outside the text bounds, so pick the vertically-closest line.
|
||||||
let boundsWidth = self.frame.width
|
let boundsWidth = self.frame.width
|
||||||
var nearestLineIndex = 0
|
var nearestLineIndex = 0
|
||||||
var nearestDistance = CGFloat.greatestFiniteMagnitude
|
var nearestDistance = CGFloat.greatestFiniteMagnitude
|
||||||
|
|
@ -361,21 +364,31 @@ public final class InstantPageTextItem: InstantPageItem {
|
||||||
|
|
||||||
let line = self.lines[nearestLineIndex]
|
let line = self.lines[nearestLineIndex]
|
||||||
let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment)
|
let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment)
|
||||||
let clampedX = max(lineFrame.minX, min(lineFrame.maxX, point.x))
|
let lineRange = CTLineGetStringRange(line.line)
|
||||||
var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: clampedX - lineFrame.minX, y: 0.0))
|
var index: Int
|
||||||
if index == self.attributedString.length {
|
if point.x <= lineFrame.minX {
|
||||||
index -= 1
|
index = lineRange.location
|
||||||
} else if index != 0 {
|
} else if point.x >= lineFrame.maxX {
|
||||||
var glyphStart: CGFloat = 0.0
|
// Trailing edge: return the line's upper bound (one past its last character) so a
|
||||||
CTLineGetOffsetForStringIndex(line.line, index, &glyphStart)
|
// right-handle drag can include the last character/item of the line. The selection
|
||||||
if clampedX - lineFrame.minX < glyphStart {
|
// upper bound is exclusive, so clamping to the last character's index — as the strict
|
||||||
index -= 1
|
// path does — would always leave it unselected. Mirrors Display.TextNode.
|
||||||
|
index = lineRange.location + lineRange.length
|
||||||
|
} else {
|
||||||
|
index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: point.x - lineFrame.minX, y: 0.0))
|
||||||
|
if index != 0 {
|
||||||
|
var glyphStart: CGFloat = 0.0
|
||||||
|
CTLineGetOffsetForStringIndex(line.line, index, &glyphStart)
|
||||||
|
if point.x - lineFrame.minX < glyphStart {
|
||||||
|
index -= 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard index >= 0, index < self.attributedString.length else {
|
guard index >= 0, index <= self.attributedString.length else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return (index, self.attributedString.attributes(at: index, effectiveRange: nil))
|
let attributes = index < self.attributedString.length ? self.attributedString.attributes(at: index, effectiveRange: nil) : [:]
|
||||||
|
return (index, attributes)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func attributeRects(name: NSAttributedString.Key, at index: Int) -> [CGRect]? {
|
private func attributeRects(name: NSAttributedString.Key, at index: Int) -> [CGRect]? {
|
||||||
|
|
@ -424,8 +437,17 @@ public final class InstantPageTextItem: InstantPageItem {
|
||||||
|
|
||||||
public func linkSelectionRects(at point: CGPoint) -> [CGRect] {
|
public func linkSelectionRects(at point: CGPoint) -> [CGRect] {
|
||||||
if let (index, dict) = self.attributesAtPoint(point) {
|
if let (index, dict) = self.attributesAtPoint(point) {
|
||||||
if let _ = dict[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] {
|
let interactiveKeys = [
|
||||||
if let rects = self.attributeRects(name: NSAttributedString.Key(rawValue: TelegramTextAttributes.URL), at: index) {
|
TelegramTextAttributes.URL,
|
||||||
|
TelegramTextAttributes.PeerMention,
|
||||||
|
TelegramTextAttributes.PeerTextMention,
|
||||||
|
TelegramTextAttributes.BotCommand,
|
||||||
|
TelegramTextAttributes.Hashtag,
|
||||||
|
TelegramTextAttributes.BankCard
|
||||||
|
]
|
||||||
|
for key in interactiveKeys {
|
||||||
|
let attrKey = NSAttributedString.Key(rawValue: key)
|
||||||
|
if dict[attrKey] != nil, let rects = self.attributeRects(name: attrKey, at: index) {
|
||||||
return rects.compactMap { rect in
|
return rects.compactMap { rect in
|
||||||
if rect.width > 5.0 {
|
if rect.width > 5.0 {
|
||||||
return rect.insetBy(dx: 0.0, dy: -3.0)
|
return rect.insetBy(dx: 0.0, dy: -3.0)
|
||||||
|
|
@ -829,6 +851,76 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt
|
||||||
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
|
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
|
||||||
styleStack.pop()
|
styleStack.pop()
|
||||||
return result
|
return result
|
||||||
|
case let .textAutoUrl(text):
|
||||||
|
styleStack.push(.link(false))
|
||||||
|
let result = attributedStringForRichText(text, styleStack: styleStack, url: InstantPageUrlItem(url: text.plainText, webpageId: nil))
|
||||||
|
styleStack.pop()
|
||||||
|
return result
|
||||||
|
case let .textAutoEmail(text):
|
||||||
|
styleStack.push(.link(false))
|
||||||
|
let result = attributedStringForRichText(text, styleStack: styleStack, url: InstantPageUrlItem(url: "mailto:\(text.plainText)", webpageId: nil))
|
||||||
|
styleStack.pop()
|
||||||
|
return result
|
||||||
|
case let .textAutoPhone(text):
|
||||||
|
styleStack.push(.link(false))
|
||||||
|
let result = attributedStringForRichText(text, styleStack: styleStack, url: InstantPageUrlItem(url: "tel:\(text.plainText)", webpageId: nil))
|
||||||
|
styleStack.pop()
|
||||||
|
return result
|
||||||
|
case let .textMention(text):
|
||||||
|
styleStack.push(.link(false))
|
||||||
|
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
|
||||||
|
styleStack.pop()
|
||||||
|
let mutable = result.mutableCopy() as! NSMutableAttributedString
|
||||||
|
if mutable.length != 0 {
|
||||||
|
mutable.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention), value: mutable.string, range: NSRange(location: 0, length: mutable.length))
|
||||||
|
}
|
||||||
|
return mutable
|
||||||
|
case let .textMentionName(text, peerId):
|
||||||
|
styleStack.push(.link(false))
|
||||||
|
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
|
||||||
|
styleStack.pop()
|
||||||
|
let mutable = result.mutableCopy() as! NSMutableAttributedString
|
||||||
|
if mutable.length != 0 {
|
||||||
|
let mention = TelegramPeerMention(peerId: EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(peerId)), mention: mutable.string)
|
||||||
|
mutable.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention), value: mention, range: NSRange(location: 0, length: mutable.length))
|
||||||
|
}
|
||||||
|
return mutable
|
||||||
|
case let .textHashtag(text):
|
||||||
|
styleStack.push(.link(false))
|
||||||
|
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
|
||||||
|
styleStack.pop()
|
||||||
|
let mutable = result.mutableCopy() as! NSMutableAttributedString
|
||||||
|
if mutable.length != 0 {
|
||||||
|
mutable.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag), value: TelegramHashtag(peerName: nil, hashtag: mutable.string), range: NSRange(location: 0, length: mutable.length))
|
||||||
|
}
|
||||||
|
return mutable
|
||||||
|
case let .textCashtag(text):
|
||||||
|
styleStack.push(.link(false))
|
||||||
|
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
|
||||||
|
styleStack.pop()
|
||||||
|
let mutable = result.mutableCopy() as! NSMutableAttributedString
|
||||||
|
if mutable.length != 0 {
|
||||||
|
mutable.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag), value: TelegramHashtag(peerName: nil, hashtag: mutable.string), range: NSRange(location: 0, length: mutable.length))
|
||||||
|
}
|
||||||
|
return mutable
|
||||||
|
case let .textBotCommand(text):
|
||||||
|
styleStack.push(.link(false))
|
||||||
|
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
|
||||||
|
styleStack.pop()
|
||||||
|
let mutable = result.mutableCopy() as! NSMutableAttributedString
|
||||||
|
if mutable.length != 0 {
|
||||||
|
mutable.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand), value: mutable.string, range: NSRange(location: 0, length: mutable.length))
|
||||||
|
}
|
||||||
|
return mutable
|
||||||
|
case let .textBankCard(text):
|
||||||
|
styleStack.push(.link(false))
|
||||||
|
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
|
||||||
|
styleStack.pop()
|
||||||
|
let mutable = result.mutableCopy() as! NSMutableAttributedString
|
||||||
|
if mutable.length != 0 {
|
||||||
|
mutable.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.BankCard), value: mutable.string, range: NSRange(location: 0, length: mutable.length))
|
||||||
|
}
|
||||||
|
return mutable
|
||||||
case let .textCustomEmoji(fileId, _):
|
case let .textCustomEmoji(fileId, _):
|
||||||
struct RunStruct {
|
struct RunStruct {
|
||||||
let ascent: CGFloat
|
let ascent: CGFloat
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,34 @@ public func lastTextLineFrame(in layout: InstantPageV2Layout) -> CGRect? {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Variant of `lastTextLineFrame(in:)` that returns the last text line frame only when the
|
||||||
|
/// bottom-most top-level item in the layout is itself a text item. When the layout ends in a
|
||||||
|
/// non-text item (table, image, divider, list marker, details, …) it returns nil, so callers
|
||||||
|
/// place the trailing status below all content rather than beside text buried above the final
|
||||||
|
/// item.
|
||||||
|
///
|
||||||
|
/// Also returns `trailingBottomPadding`: the renderer draws the baseline at the line frame's maxY,
|
||||||
|
/// so the visible text of a plain line sits ~5pt below it. A status that *trails on the line* should
|
||||||
|
/// anchor at `maxY + trailingBottomPadding` to align with where the text actually renders. The pad
|
||||||
|
/// is 0 when the line is taller than its font line height (an inline animated emoji, ~pointSize·24/17,
|
||||||
|
/// already pushes maxY down to the right spot). Callers should NOT apply the pad when the status
|
||||||
|
/// wraps onto its own line below the text — there it should sit at the bare maxY.
|
||||||
|
public func lastTextLineFrameIfLastItemIsText(in layout: InstantPageV2Layout) -> (frame: CGRect, trailingBottomPadding: CGFloat)? {
|
||||||
|
guard let bottomItem = layout.items.max(by: { $0.frame.maxY < $1.frame.maxY }),
|
||||||
|
case let .text(text) = bottomItem,
|
||||||
|
let last = text.textItem.lines.last
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let lineFrame = last.frame.offsetBy(dx: text.frame.minX, dy: text.frame.minY)
|
||||||
|
var ascent: CGFloat = 0.0
|
||||||
|
var descent: CGFloat = 0.0
|
||||||
|
var leading: CGFloat = 0.0
|
||||||
|
_ = CTLineGetTypographicBounds(last.line, &ascent, &descent, &leading)
|
||||||
|
let isInflatedByAttachment = lineFrame.height > ascent + descent + 1.0
|
||||||
|
return (lineFrame, isInflatedByAttachment ? 0.0 : 5.0)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Layout context
|
// MARK: - Layout context
|
||||||
|
|
||||||
private struct LayoutContext {
|
private struct LayoutContext {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,16 @@ union RichText_Value {
|
||||||
RichText_Image,
|
RichText_Image,
|
||||||
RichText_Anchor,
|
RichText_Anchor,
|
||||||
RichText_Formula,
|
RichText_Formula,
|
||||||
RichText_CustomEmoji
|
RichText_CustomEmoji,
|
||||||
|
RichText_AutoEmail,
|
||||||
|
RichText_AutoPhone,
|
||||||
|
RichText_AutoUrl,
|
||||||
|
RichText_BankCard,
|
||||||
|
RichText_BotCommand,
|
||||||
|
RichText_Cashtag,
|
||||||
|
RichText_Hashtag,
|
||||||
|
RichText_Mention,
|
||||||
|
RichText_MentionName
|
||||||
}
|
}
|
||||||
|
|
||||||
table RichText {
|
table RichText {
|
||||||
|
|
@ -104,3 +113,40 @@ table RichText_CustomEmoji {
|
||||||
fileId:long (id: 0);
|
fileId:long (id: 0);
|
||||||
alt:string (id: 1, required);
|
alt:string (id: 1, required);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table RichText_AutoEmail {
|
||||||
|
text:RichText (id: 0, required);
|
||||||
|
}
|
||||||
|
|
||||||
|
table RichText_AutoPhone {
|
||||||
|
text:RichText (id: 0, required);
|
||||||
|
}
|
||||||
|
|
||||||
|
table RichText_AutoUrl {
|
||||||
|
text:RichText (id: 0, required);
|
||||||
|
}
|
||||||
|
|
||||||
|
table RichText_BankCard {
|
||||||
|
text:RichText (id: 0, required);
|
||||||
|
}
|
||||||
|
|
||||||
|
table RichText_BotCommand {
|
||||||
|
text:RichText (id: 0, required);
|
||||||
|
}
|
||||||
|
|
||||||
|
table RichText_Cashtag {
|
||||||
|
text:RichText (id: 0, required);
|
||||||
|
}
|
||||||
|
|
||||||
|
table RichText_Hashtag {
|
||||||
|
text:RichText (id: 0, required);
|
||||||
|
}
|
||||||
|
|
||||||
|
table RichText_Mention {
|
||||||
|
text:RichText (id: 0, required);
|
||||||
|
}
|
||||||
|
|
||||||
|
table RichText_MentionName {
|
||||||
|
text:RichText (id: 0, required);
|
||||||
|
peerId:long (id: 1);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,46 +57,28 @@ extension RichText {
|
||||||
self = .textCustomEmoji(fileId: data.documentId, alt: data.alt)
|
self = .textCustomEmoji(fileId: data.documentId, alt: data.alt)
|
||||||
case let .textMath(textMath):
|
case let .textMath(textMath):
|
||||||
self = .formula(latex: textMath.source)
|
self = .formula(latex: textMath.source)
|
||||||
case let .textAutoEmail(email):
|
case let .textAutoEmail(textAutoEmailData):
|
||||||
let _ = email
|
self = .textAutoEmail(text: RichText(apiText: textAutoEmailData.text))
|
||||||
//TODO:localize
|
case let .textAutoPhone(textAutoPhoneData):
|
||||||
self = .plain("")
|
self = .textAutoPhone(text: RichText(apiText: textAutoPhoneData.text))
|
||||||
case let .textAutoPhone(phone):
|
case let .textAutoUrl(textAutoUrlData):
|
||||||
let _ = phone
|
self = .textAutoUrl(text: RichText(apiText: textAutoUrlData.text))
|
||||||
//TODO:localize
|
case let .textBankCard(textBankCardData):
|
||||||
self = .plain("")
|
self = .textBankCard(text: RichText(apiText: textBankCardData.text))
|
||||||
case let .textAutoUrl(url):
|
case let .textBotCommand(textBotCommandData):
|
||||||
let _ = url
|
self = .textBotCommand(text: RichText(apiText: textBotCommandData.text))
|
||||||
//TODO:localize
|
case let .textCashtag(textCashtagData):
|
||||||
self = .plain("")
|
self = .textCashtag(text: RichText(apiText: textCashtagData.text))
|
||||||
case let .textBankCard(value):
|
|
||||||
let _ = value
|
|
||||||
//TODO:localize
|
|
||||||
self = .plain("")
|
|
||||||
case let .textBotCommand(value):
|
|
||||||
let _ = value
|
|
||||||
//TODO:localize
|
|
||||||
self = .plain("")
|
|
||||||
case let .textCashtag(value):
|
|
||||||
let _ = value
|
|
||||||
//TODO:localize
|
|
||||||
self = .plain("")
|
|
||||||
case let .textDate(value):
|
case let .textDate(value):
|
||||||
let _ = value
|
let _ = value
|
||||||
//TODO:localize
|
//TODO:localize
|
||||||
self = .plain("")
|
self = .plain("")
|
||||||
case let .textHashtag(value):
|
case let .textHashtag(textHashtagData):
|
||||||
let _ = value
|
self = .textHashtag(text: RichText(apiText: textHashtagData.text))
|
||||||
//TODO:localize
|
case let .textMention(textMentionData):
|
||||||
self = .plain("")
|
self = .textMention(text: RichText(apiText: textMentionData.text))
|
||||||
case let .textMention(value):
|
case let .textMentionName(textMentionNameData):
|
||||||
let _ = value
|
self = .textMentionName(text: RichText(apiText: textMentionNameData.text), peerId: textMentionNameData.userId)
|
||||||
//TODO:localize
|
|
||||||
self = .plain("")
|
|
||||||
case let .textMentionName(value):
|
|
||||||
let _ = value
|
|
||||||
//TODO:localize
|
|
||||||
self = .plain("")
|
|
||||||
case let .textSpoiler(value):
|
case let .textSpoiler(value):
|
||||||
let _ = value
|
let _ = value
|
||||||
//TODO:localize
|
//TODO:localize
|
||||||
|
|
@ -142,6 +124,24 @@ extension RichText {
|
||||||
return .textMath(Api.RichText.Cons_textMath(source: latex))
|
return .textMath(Api.RichText.Cons_textMath(source: latex))
|
||||||
case let .textCustomEmoji(fileId, alt):
|
case let .textCustomEmoji(fileId, alt):
|
||||||
return .textCustomEmoji(Api.RichText.Cons_textCustomEmoji(documentId: fileId, alt: alt))
|
return .textCustomEmoji(Api.RichText.Cons_textCustomEmoji(documentId: fileId, alt: alt))
|
||||||
|
case let .textAutoEmail(text):
|
||||||
|
return .textAutoEmail(Api.RichText.Cons_textAutoEmail(text: text.apiRichText()))
|
||||||
|
case let .textAutoPhone(text):
|
||||||
|
return .textAutoPhone(Api.RichText.Cons_textAutoPhone(text: text.apiRichText()))
|
||||||
|
case let .textAutoUrl(text):
|
||||||
|
return .textAutoUrl(Api.RichText.Cons_textAutoUrl(text: text.apiRichText()))
|
||||||
|
case let .textBankCard(text):
|
||||||
|
return .textBankCard(Api.RichText.Cons_textBankCard(text: text.apiRichText()))
|
||||||
|
case let .textBotCommand(text):
|
||||||
|
return .textBotCommand(Api.RichText.Cons_textBotCommand(text: text.apiRichText()))
|
||||||
|
case let .textCashtag(text):
|
||||||
|
return .textCashtag(Api.RichText.Cons_textCashtag(text: text.apiRichText()))
|
||||||
|
case let .textHashtag(text):
|
||||||
|
return .textHashtag(Api.RichText.Cons_textHashtag(text: text.apiRichText()))
|
||||||
|
case let .textMention(text):
|
||||||
|
return .textMention(Api.RichText.Cons_textMention(text: text.apiRichText()))
|
||||||
|
case let .textMentionName(text, peerId):
|
||||||
|
return .textMentionName(Api.RichText.Cons_textMentionName(text: text.apiRichText(), userId: peerId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,15 @@ private enum RichTextTypes: Int32 {
|
||||||
case anchor = 15
|
case anchor = 15
|
||||||
case formula = 16
|
case formula = 16
|
||||||
case textCustomEmoji = 17
|
case textCustomEmoji = 17
|
||||||
|
case textAutoEmail = 18
|
||||||
|
case textAutoPhone = 19
|
||||||
|
case textAutoUrl = 20
|
||||||
|
case textBankCard = 21
|
||||||
|
case textBotCommand = 22
|
||||||
|
case textCashtag = 23
|
||||||
|
case textHashtag = 24
|
||||||
|
case textMention = 25
|
||||||
|
case textMentionName = 26
|
||||||
}
|
}
|
||||||
|
|
||||||
public indirect enum RichText: PostboxCoding, Equatable {
|
public indirect enum RichText: PostboxCoding, Equatable {
|
||||||
|
|
@ -42,6 +51,15 @@ public indirect enum RichText: PostboxCoding, Equatable {
|
||||||
case anchor(text: RichText, name: String)
|
case anchor(text: RichText, name: String)
|
||||||
case formula(latex: String)
|
case formula(latex: String)
|
||||||
case textCustomEmoji(fileId: Int64, alt: String)
|
case textCustomEmoji(fileId: Int64, alt: String)
|
||||||
|
case textAutoEmail(text: RichText)
|
||||||
|
case textAutoPhone(text: RichText)
|
||||||
|
case textAutoUrl(text: RichText)
|
||||||
|
case textBankCard(text: RichText)
|
||||||
|
case textBotCommand(text: RichText)
|
||||||
|
case textCashtag(text: RichText)
|
||||||
|
case textHashtag(text: RichText)
|
||||||
|
case textMention(text: RichText)
|
||||||
|
case textMentionName(text: RichText, peerId: Int64)
|
||||||
|
|
||||||
public init(decoder: PostboxDecoder) {
|
public init(decoder: PostboxDecoder) {
|
||||||
switch decoder.decodeInt32ForKey("r", orElse: 0) {
|
switch decoder.decodeInt32ForKey("r", orElse: 0) {
|
||||||
|
|
@ -87,6 +105,24 @@ public indirect enum RichText: PostboxCoding, Equatable {
|
||||||
self = .formula(latex: decoder.decodeStringForKey("l", orElse: ""))
|
self = .formula(latex: decoder.decodeStringForKey("l", orElse: ""))
|
||||||
case RichTextTypes.textCustomEmoji.rawValue:
|
case RichTextTypes.textCustomEmoji.rawValue:
|
||||||
self = .textCustomEmoji(fileId: decoder.decodeInt64ForKey("ce.f", orElse: 0), alt: decoder.decodeStringForKey("ce.a", orElse: ""))
|
self = .textCustomEmoji(fileId: decoder.decodeInt64ForKey("ce.f", orElse: 0), alt: decoder.decodeStringForKey("ce.a", orElse: ""))
|
||||||
|
case RichTextTypes.textAutoEmail.rawValue:
|
||||||
|
self = .textAutoEmail(text: decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText)
|
||||||
|
case RichTextTypes.textAutoPhone.rawValue:
|
||||||
|
self = .textAutoPhone(text: decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText)
|
||||||
|
case RichTextTypes.textAutoUrl.rawValue:
|
||||||
|
self = .textAutoUrl(text: decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText)
|
||||||
|
case RichTextTypes.textBankCard.rawValue:
|
||||||
|
self = .textBankCard(text: decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText)
|
||||||
|
case RichTextTypes.textBotCommand.rawValue:
|
||||||
|
self = .textBotCommand(text: decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText)
|
||||||
|
case RichTextTypes.textCashtag.rawValue:
|
||||||
|
self = .textCashtag(text: decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText)
|
||||||
|
case RichTextTypes.textHashtag.rawValue:
|
||||||
|
self = .textHashtag(text: decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText)
|
||||||
|
case RichTextTypes.textMention.rawValue:
|
||||||
|
self = .textMention(text: decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText)
|
||||||
|
case RichTextTypes.textMentionName.rawValue:
|
||||||
|
self = .textMentionName(text: decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText, peerId: decoder.decodeInt64ForKey("mn.p", orElse: 0))
|
||||||
default:
|
default:
|
||||||
self = .empty
|
self = .empty
|
||||||
}
|
}
|
||||||
|
|
@ -162,6 +198,34 @@ public indirect enum RichText: PostboxCoding, Equatable {
|
||||||
encoder.encodeInt32(RichTextTypes.textCustomEmoji.rawValue, forKey: "r")
|
encoder.encodeInt32(RichTextTypes.textCustomEmoji.rawValue, forKey: "r")
|
||||||
encoder.encodeInt64(fileId, forKey: "ce.f")
|
encoder.encodeInt64(fileId, forKey: "ce.f")
|
||||||
encoder.encodeString(alt, forKey: "ce.a")
|
encoder.encodeString(alt, forKey: "ce.a")
|
||||||
|
case let .textAutoEmail(text):
|
||||||
|
encoder.encodeInt32(RichTextTypes.textAutoEmail.rawValue, forKey: "r")
|
||||||
|
encoder.encodeObject(text, forKey: "t")
|
||||||
|
case let .textAutoPhone(text):
|
||||||
|
encoder.encodeInt32(RichTextTypes.textAutoPhone.rawValue, forKey: "r")
|
||||||
|
encoder.encodeObject(text, forKey: "t")
|
||||||
|
case let .textAutoUrl(text):
|
||||||
|
encoder.encodeInt32(RichTextTypes.textAutoUrl.rawValue, forKey: "r")
|
||||||
|
encoder.encodeObject(text, forKey: "t")
|
||||||
|
case let .textBankCard(text):
|
||||||
|
encoder.encodeInt32(RichTextTypes.textBankCard.rawValue, forKey: "r")
|
||||||
|
encoder.encodeObject(text, forKey: "t")
|
||||||
|
case let .textBotCommand(text):
|
||||||
|
encoder.encodeInt32(RichTextTypes.textBotCommand.rawValue, forKey: "r")
|
||||||
|
encoder.encodeObject(text, forKey: "t")
|
||||||
|
case let .textCashtag(text):
|
||||||
|
encoder.encodeInt32(RichTextTypes.textCashtag.rawValue, forKey: "r")
|
||||||
|
encoder.encodeObject(text, forKey: "t")
|
||||||
|
case let .textHashtag(text):
|
||||||
|
encoder.encodeInt32(RichTextTypes.textHashtag.rawValue, forKey: "r")
|
||||||
|
encoder.encodeObject(text, forKey: "t")
|
||||||
|
case let .textMention(text):
|
||||||
|
encoder.encodeInt32(RichTextTypes.textMention.rawValue, forKey: "r")
|
||||||
|
encoder.encodeObject(text, forKey: "t")
|
||||||
|
case let .textMentionName(text, peerId):
|
||||||
|
encoder.encodeInt32(RichTextTypes.textMentionName.rawValue, forKey: "r")
|
||||||
|
encoder.encodeObject(text, forKey: "t")
|
||||||
|
encoder.encodeInt64(peerId, forKey: "mn.p")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,6 +339,24 @@ public indirect enum RichText: PostboxCoding, Equatable {
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
case let .textAutoEmail(text):
|
||||||
|
if case .textAutoEmail(text) = rhs { return true } else { return false }
|
||||||
|
case let .textAutoPhone(text):
|
||||||
|
if case .textAutoPhone(text) = rhs { return true } else { return false }
|
||||||
|
case let .textAutoUrl(text):
|
||||||
|
if case .textAutoUrl(text) = rhs { return true } else { return false }
|
||||||
|
case let .textBankCard(text):
|
||||||
|
if case .textBankCard(text) = rhs { return true } else { return false }
|
||||||
|
case let .textBotCommand(text):
|
||||||
|
if case .textBotCommand(text) = rhs { return true } else { return false }
|
||||||
|
case let .textCashtag(text):
|
||||||
|
if case .textCashtag(text) = rhs { return true } else { return false }
|
||||||
|
case let .textHashtag(text):
|
||||||
|
if case .textHashtag(text) = rhs { return true } else { return false }
|
||||||
|
case let .textMention(text):
|
||||||
|
if case .textMention(text) = rhs { return true } else { return false }
|
||||||
|
case let .textMentionName(lhsText, lhsPeerId):
|
||||||
|
if case let .textMentionName(rhsText, rhsPeerId) = rhs, lhsText == rhsText, lhsPeerId == rhsPeerId { return true } else { return false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -322,6 +404,24 @@ public extension RichText {
|
||||||
return latex
|
return latex
|
||||||
case let .textCustomEmoji(_, alt):
|
case let .textCustomEmoji(_, alt):
|
||||||
return alt
|
return alt
|
||||||
|
case let .textAutoEmail(text):
|
||||||
|
return text.plainText
|
||||||
|
case let .textAutoPhone(text):
|
||||||
|
return text.plainText
|
||||||
|
case let .textAutoUrl(text):
|
||||||
|
return text.plainText
|
||||||
|
case let .textBankCard(text):
|
||||||
|
return text.plainText
|
||||||
|
case let .textBotCommand(text):
|
||||||
|
return text.plainText
|
||||||
|
case let .textCashtag(text):
|
||||||
|
return text.plainText
|
||||||
|
case let .textHashtag(text):
|
||||||
|
return text.plainText
|
||||||
|
case let .textMention(text):
|
||||||
|
return text.plainText
|
||||||
|
case let .textMentionName(text, _):
|
||||||
|
return text.plainText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -419,6 +519,51 @@ extension RichText {
|
||||||
throw FlatBuffersError.missingRequiredField()
|
throw FlatBuffersError.missingRequiredField()
|
||||||
}
|
}
|
||||||
self = .textCustomEmoji(fileId: value.fileId, alt: value.alt)
|
self = .textCustomEmoji(fileId: value.fileId, alt: value.alt)
|
||||||
|
case .richtextAutoemail:
|
||||||
|
guard let value = flatBuffersObject.value(type: TelegramCore_RichText_AutoEmail.self) else {
|
||||||
|
throw FlatBuffersError.missingRequiredField()
|
||||||
|
}
|
||||||
|
self = .textAutoEmail(text: try RichText(flatBuffersObject: value.text))
|
||||||
|
case .richtextAutophone:
|
||||||
|
guard let value = flatBuffersObject.value(type: TelegramCore_RichText_AutoPhone.self) else {
|
||||||
|
throw FlatBuffersError.missingRequiredField()
|
||||||
|
}
|
||||||
|
self = .textAutoPhone(text: try RichText(flatBuffersObject: value.text))
|
||||||
|
case .richtextAutourl:
|
||||||
|
guard let value = flatBuffersObject.value(type: TelegramCore_RichText_AutoUrl.self) else {
|
||||||
|
throw FlatBuffersError.missingRequiredField()
|
||||||
|
}
|
||||||
|
self = .textAutoUrl(text: try RichText(flatBuffersObject: value.text))
|
||||||
|
case .richtextBankcard:
|
||||||
|
guard let value = flatBuffersObject.value(type: TelegramCore_RichText_BankCard.self) else {
|
||||||
|
throw FlatBuffersError.missingRequiredField()
|
||||||
|
}
|
||||||
|
self = .textBankCard(text: try RichText(flatBuffersObject: value.text))
|
||||||
|
case .richtextBotcommand:
|
||||||
|
guard let value = flatBuffersObject.value(type: TelegramCore_RichText_BotCommand.self) else {
|
||||||
|
throw FlatBuffersError.missingRequiredField()
|
||||||
|
}
|
||||||
|
self = .textBotCommand(text: try RichText(flatBuffersObject: value.text))
|
||||||
|
case .richtextCashtag:
|
||||||
|
guard let value = flatBuffersObject.value(type: TelegramCore_RichText_Cashtag.self) else {
|
||||||
|
throw FlatBuffersError.missingRequiredField()
|
||||||
|
}
|
||||||
|
self = .textCashtag(text: try RichText(flatBuffersObject: value.text))
|
||||||
|
case .richtextHashtag:
|
||||||
|
guard let value = flatBuffersObject.value(type: TelegramCore_RichText_Hashtag.self) else {
|
||||||
|
throw FlatBuffersError.missingRequiredField()
|
||||||
|
}
|
||||||
|
self = .textHashtag(text: try RichText(flatBuffersObject: value.text))
|
||||||
|
case .richtextMention:
|
||||||
|
guard let value = flatBuffersObject.value(type: TelegramCore_RichText_Mention.self) else {
|
||||||
|
throw FlatBuffersError.missingRequiredField()
|
||||||
|
}
|
||||||
|
self = .textMention(text: try RichText(flatBuffersObject: value.text))
|
||||||
|
case .richtextMentionname:
|
||||||
|
guard let value = flatBuffersObject.value(type: TelegramCore_RichText_MentionName.self) else {
|
||||||
|
throw FlatBuffersError.missingRequiredField()
|
||||||
|
}
|
||||||
|
self = .textMentionName(text: try RichText(flatBuffersObject: value.text), peerId: value.peerId)
|
||||||
case .none_:
|
case .none_:
|
||||||
self = .empty
|
self = .empty
|
||||||
}
|
}
|
||||||
|
|
@ -548,6 +693,61 @@ extension RichText {
|
||||||
TelegramCore_RichText_CustomEmoji.add(fileId: fileId, &builder)
|
TelegramCore_RichText_CustomEmoji.add(fileId: fileId, &builder)
|
||||||
TelegramCore_RichText_CustomEmoji.add(alt: altOffset, &builder)
|
TelegramCore_RichText_CustomEmoji.add(alt: altOffset, &builder)
|
||||||
offset = TelegramCore_RichText_CustomEmoji.endRichText_CustomEmoji(&builder, start: start)
|
offset = TelegramCore_RichText_CustomEmoji.endRichText_CustomEmoji(&builder, start: start)
|
||||||
|
case let .textAutoEmail(text):
|
||||||
|
valueType = .richtextAutoemail
|
||||||
|
let textOffset = text.encodeToFlatBuffers(builder: &builder)
|
||||||
|
let start = TelegramCore_RichText_AutoEmail.startRichText_AutoEmail(&builder)
|
||||||
|
TelegramCore_RichText_AutoEmail.add(text: textOffset, &builder)
|
||||||
|
offset = TelegramCore_RichText_AutoEmail.endRichText_AutoEmail(&builder, start: start)
|
||||||
|
case let .textAutoPhone(text):
|
||||||
|
valueType = .richtextAutophone
|
||||||
|
let textOffset = text.encodeToFlatBuffers(builder: &builder)
|
||||||
|
let start = TelegramCore_RichText_AutoPhone.startRichText_AutoPhone(&builder)
|
||||||
|
TelegramCore_RichText_AutoPhone.add(text: textOffset, &builder)
|
||||||
|
offset = TelegramCore_RichText_AutoPhone.endRichText_AutoPhone(&builder, start: start)
|
||||||
|
case let .textAutoUrl(text):
|
||||||
|
valueType = .richtextAutourl
|
||||||
|
let textOffset = text.encodeToFlatBuffers(builder: &builder)
|
||||||
|
let start = TelegramCore_RichText_AutoUrl.startRichText_AutoUrl(&builder)
|
||||||
|
TelegramCore_RichText_AutoUrl.add(text: textOffset, &builder)
|
||||||
|
offset = TelegramCore_RichText_AutoUrl.endRichText_AutoUrl(&builder, start: start)
|
||||||
|
case let .textBankCard(text):
|
||||||
|
valueType = .richtextBankcard
|
||||||
|
let textOffset = text.encodeToFlatBuffers(builder: &builder)
|
||||||
|
let start = TelegramCore_RichText_BankCard.startRichText_BankCard(&builder)
|
||||||
|
TelegramCore_RichText_BankCard.add(text: textOffset, &builder)
|
||||||
|
offset = TelegramCore_RichText_BankCard.endRichText_BankCard(&builder, start: start)
|
||||||
|
case let .textBotCommand(text):
|
||||||
|
valueType = .richtextBotcommand
|
||||||
|
let textOffset = text.encodeToFlatBuffers(builder: &builder)
|
||||||
|
let start = TelegramCore_RichText_BotCommand.startRichText_BotCommand(&builder)
|
||||||
|
TelegramCore_RichText_BotCommand.add(text: textOffset, &builder)
|
||||||
|
offset = TelegramCore_RichText_BotCommand.endRichText_BotCommand(&builder, start: start)
|
||||||
|
case let .textCashtag(text):
|
||||||
|
valueType = .richtextCashtag
|
||||||
|
let textOffset = text.encodeToFlatBuffers(builder: &builder)
|
||||||
|
let start = TelegramCore_RichText_Cashtag.startRichText_Cashtag(&builder)
|
||||||
|
TelegramCore_RichText_Cashtag.add(text: textOffset, &builder)
|
||||||
|
offset = TelegramCore_RichText_Cashtag.endRichText_Cashtag(&builder, start: start)
|
||||||
|
case let .textHashtag(text):
|
||||||
|
valueType = .richtextHashtag
|
||||||
|
let textOffset = text.encodeToFlatBuffers(builder: &builder)
|
||||||
|
let start = TelegramCore_RichText_Hashtag.startRichText_Hashtag(&builder)
|
||||||
|
TelegramCore_RichText_Hashtag.add(text: textOffset, &builder)
|
||||||
|
offset = TelegramCore_RichText_Hashtag.endRichText_Hashtag(&builder, start: start)
|
||||||
|
case let .textMention(text):
|
||||||
|
valueType = .richtextMention
|
||||||
|
let textOffset = text.encodeToFlatBuffers(builder: &builder)
|
||||||
|
let start = TelegramCore_RichText_Mention.startRichText_Mention(&builder)
|
||||||
|
TelegramCore_RichText_Mention.add(text: textOffset, &builder)
|
||||||
|
offset = TelegramCore_RichText_Mention.endRichText_Mention(&builder, start: start)
|
||||||
|
case let .textMentionName(text, peerId):
|
||||||
|
valueType = .richtextMentionname
|
||||||
|
let textOffset = text.encodeToFlatBuffers(builder: &builder)
|
||||||
|
let start = TelegramCore_RichText_MentionName.startRichText_MentionName(&builder)
|
||||||
|
TelegramCore_RichText_MentionName.add(text: textOffset, &builder)
|
||||||
|
TelegramCore_RichText_MentionName.add(peerId: peerId, &builder)
|
||||||
|
offset = TelegramCore_RichText_MentionName.endRichText_MentionName(&builder, start: start)
|
||||||
}
|
}
|
||||||
|
|
||||||
return TelegramCore_RichText.createRichText(&builder, valueType: valueType, valueOffset: offset)
|
return TelegramCore_RichText.createRichText(&builder, valueType: valueType, valueOffset: offset)
|
||||||
|
|
|
||||||
|
|
@ -401,6 +401,7 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([
|
||||||
|
|
||||||
if richText != nil && !skipText {
|
if richText != nil && !skipText {
|
||||||
result.append((message, ChatMessageRichDataBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default)))
|
result.append((message, ChatMessageRichDataBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default)))
|
||||||
|
needReactions = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if message.adAttribute != nil {
|
if message.adAttribute != nil {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ swift_library(
|
||||||
"//submodules/SSignalKit/SwiftSignalKit",
|
"//submodules/SSignalKit/SwiftSignalKit",
|
||||||
"//submodules/AccountContext",
|
"//submodules/AccountContext",
|
||||||
"//submodules/InstantPageUI",
|
"//submodules/InstantPageUI",
|
||||||
|
"//submodules/TextFormat:TextFormat",
|
||||||
"//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode",
|
"//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode",
|
||||||
"//submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode",
|
"//submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode",
|
||||||
"//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon",
|
"//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import ChatMessageDateAndStatusNode
|
||||||
import ChatMessageItemCommon
|
import ChatMessageItemCommon
|
||||||
import ChatControllerInteraction
|
import ChatControllerInteraction
|
||||||
import InstantPageUI
|
import InstantPageUI
|
||||||
|
import TextFormat
|
||||||
import TelegramUIPreferences
|
import TelegramUIPreferences
|
||||||
import TextLoadingEffect
|
import TextLoadingEffect
|
||||||
import TextSelectionNode
|
import TextSelectionNode
|
||||||
|
|
@ -213,6 +214,12 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
// Built alongside pageLayout so the apply closure can hand it to ensurePageView.
|
// Built alongside pageLayout so the apply closure can hand it to ensurePageView.
|
||||||
var pageWebpage: TelegramMediaWebpage?
|
var pageWebpage: TelegramMediaWebpage?
|
||||||
|
|
||||||
|
// Horizontal text inset baked into the InstantPage layout. The pageView sits at
|
||||||
|
// self-x 0 (containerNode at 1, pageView at -1 inside it), so the page's text
|
||||||
|
// left edge in the status node's coordinate space is exactly this value. Used as
|
||||||
|
// the status node's left edge + side inset, mirroring TextBubble's bubbleInsets.
|
||||||
|
let pageHorizontalInset: CGFloat = 10.0
|
||||||
|
|
||||||
let isDark = item.presentationData.theme.theme.overallDarkAppearance
|
let isDark = item.presentationData.theme.theme.overallDarkAppearance
|
||||||
let isIncoming = item.message.effectivelyIncoming(item.context.account.peerId)
|
let isIncoming = item.message.effectivelyIncoming(item.context.account.peerId)
|
||||||
let messageTheme = isIncoming ? item.presentationData.theme.theme.chat.message.incoming : item.presentationData.theme.theme.chat.message.outgoing
|
let messageTheme = isIncoming ? item.presentationData.theme.theme.chat.message.incoming : item.presentationData.theme.theme.chat.message.outgoing
|
||||||
|
|
@ -365,7 +372,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
instantPage: attribute.instantPage,
|
instantPage: attribute.instantPage,
|
||||||
userLocation: .other,
|
userLocation: .other,
|
||||||
boundingWidth: suggestedBoundingWidth - 2.0,
|
boundingWidth: suggestedBoundingWidth - 2.0,
|
||||||
horizontalInset: 10.0,
|
horizontalInset: pageHorizontalInset,
|
||||||
theme: pageTheme,
|
theme: pageTheme,
|
||||||
strings: item.presentationData.strings,
|
strings: item.presentationData.strings,
|
||||||
dateTimeFormat: item.presentationData.dateTimeFormat,
|
dateTimeFormat: item.presentationData.dateTimeFormat,
|
||||||
|
|
@ -521,10 +528,6 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
displayStatus = false
|
displayStatus = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if "".isEmpty {
|
|
||||||
displayStatus = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if displayStatus {
|
if displayStatus {
|
||||||
if incoming {
|
if incoming {
|
||||||
statusType = .BubbleIncoming
|
statusType = .BubbleIncoming
|
||||||
|
|
@ -541,7 +544,13 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
statusType = nil
|
statusType = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastTextLineFrame: CGRect? = pageLayout.flatMap(InstantPageUI.lastTextLineFrame(in:))
|
// Only trail the status inline with the last text line when the bottom-most page
|
||||||
|
// item is itself a text item; otherwise (table/image/etc. last) the status falls
|
||||||
|
// through to the contentSize.height anchor and sits below all content.
|
||||||
|
let lastTextLine = pageLayout.flatMap(InstantPageUI.lastTextLineFrameIfLastItemIsText(in:))
|
||||||
|
let lastTextLineFrame: CGRect? = lastTextLine?.frame
|
||||||
|
// Applied only when the date trails on the line (not when it wraps below it).
|
||||||
|
let lastTextLineTrailingPadding: CGFloat = lastTextLine?.trailingBottomPadding ?? 0.0
|
||||||
|
|
||||||
var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode))?
|
var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode))?
|
||||||
if let statusType = statusType {
|
if let statusType = statusType {
|
||||||
|
|
@ -582,19 +591,35 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
}
|
}
|
||||||
|
|
||||||
if let statusSuggestedWidthAndContinue, !hasDraft {
|
if let statusSuggestedWidthAndContinue, !hasDraft {
|
||||||
let statusLeftEdgeInBubble: CGFloat
|
// Mirrors TextBubble: max(contentWidth, statusWidth + sideInsets), where
|
||||||
if let lastTextLineFrame {
|
// sideInsets = left + right text inset (= pageHorizontalInset on each side).
|
||||||
statusLeftEdgeInBubble = 1.0 + lastTextLineFrame.minX
|
boundingSize.width = max(boundingSize.width, statusSuggestedWidthAndContinue.0 + pageHorizontalInset * 2.0)
|
||||||
} else {
|
|
||||||
statusLeftEdgeInBubble = 1.0
|
|
||||||
}
|
|
||||||
boundingSize.width = max(boundingSize.width, statusLeftEdgeInBubble * 2.0 + statusSuggestedWidthAndContinue.0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (boundingSize.width, { boundingWidth in
|
return (boundingSize.width, { boundingWidth in
|
||||||
let statusSizeAndApply = statusSuggestedWidthAndContinue?.1(boundingWidth)
|
// Mirrors TextBubble's `boundingWidth - sideInsets` so the right-aligned date
|
||||||
|
// lands at the right text inset rather than past the bubble's right edge.
|
||||||
|
let statusSizeAndApply = statusSuggestedWidthAndContinue?.1(boundingWidth - pageHorizontalInset * 2.0)
|
||||||
if let statusSizeAndApply, !hasDraft {
|
if let statusSizeAndApply, !hasDraft {
|
||||||
boundingSize.height += statusSizeAndApply.0.height
|
// Status node anchor Y in the content node's space — mirrors the apply
|
||||||
|
// closure below.
|
||||||
|
let statusAnchorY: CGFloat
|
||||||
|
if let lastTextLineFrame {
|
||||||
|
// Trailing date (statusHeight == 0) gets the 5pt text-rect pad so it
|
||||||
|
// aligns with the visible text; a date that wraps onto its own line
|
||||||
|
// below (statusHeight > 0) sits at the bare maxY.
|
||||||
|
let trailingPadding = statusSizeAndApply.0.height.isZero ? lastTextLineTrailingPadding : 0.0
|
||||||
|
statusAnchorY = 1.0 + lastTextLineFrame.maxY + trailingPadding + streamingHeaderOffset
|
||||||
|
} else if let pageLayout {
|
||||||
|
statusAnchorY = 1.0 + pageLayout.contentSize.height + streamingHeaderOffset
|
||||||
|
} else {
|
||||||
|
statusAnchorY = 1.0 + streamingHeaderOffset
|
||||||
|
}
|
||||||
|
// Date's bottom edge: a trailing date sits ~1pt below the anchor; a wrapped
|
||||||
|
// date extends `statusHeight` below it. Leave ~6pt to the bubble's bottom
|
||||||
|
// edge, matching TextBubble's bottom inset.
|
||||||
|
let statusBottomEdge = statusAnchorY + max(1.0, statusSizeAndApply.0.height)
|
||||||
|
boundingSize.height = max(boundingSize.height, statusBottomEdge + 6.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (boundingSize, { animation, _, _ in
|
return (boundingSize, { animation, _, _ in
|
||||||
|
|
@ -606,19 +631,23 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
animation.animator.updateFrame(layer: self.containerNode.layer, frame: CGRect(origin: CGPoint(x: 1.0, y: 1.0), size: CGSize(width: boundingWidth - 2.0, height: boundingSize.height)), completion: nil)
|
animation.animator.updateFrame(layer: self.containerNode.layer, frame: CGRect(origin: CGPoint(x: 1.0, y: 1.0), size: CGSize(width: boundingWidth - 2.0, height: boundingSize.height)), completion: nil)
|
||||||
|
|
||||||
if let statusSizeAndApply {
|
if let statusSizeAndApply {
|
||||||
let statusFrameX: CGFloat
|
// Match TextBubble: anchor the status node's x at the fixed text-block
|
||||||
|
// left edge (not the last line's minX, which is large for nested
|
||||||
|
// content and shoves the right-aligned date off the bubble). The status
|
||||||
|
// node positions the date trailing/below relative to this origin.
|
||||||
let statusFrameY: CGFloat
|
let statusFrameY: CGFloat
|
||||||
if let lastTextLineFrame {
|
if let lastTextLineFrame {
|
||||||
statusFrameX = 1.0 + lastTextLineFrame.minX
|
// Add the 5pt text-rect pad only when the date trails on the line
|
||||||
statusFrameY = 1.0 + lastTextLineFrame.maxY
|
// (statusHeight == 0); a date wrapped onto its own line below sits
|
||||||
|
// at the bare maxY so it isn't pushed too far down.
|
||||||
|
let trailingPadding = statusSizeAndApply.0.height.isZero ? lastTextLineTrailingPadding : 0.0
|
||||||
|
statusFrameY = 1.0 + lastTextLineFrame.maxY + trailingPadding
|
||||||
} else if let pageLayout {
|
} else if let pageLayout {
|
||||||
statusFrameX = 1.0
|
|
||||||
statusFrameY = 1.0 + pageLayout.contentSize.height
|
statusFrameY = 1.0 + pageLayout.contentSize.height
|
||||||
} else {
|
} else {
|
||||||
statusFrameX = 1.0
|
|
||||||
statusFrameY = 1.0
|
statusFrameY = 1.0
|
||||||
}
|
}
|
||||||
let statusFrame = CGRect(origin: CGPoint(x: statusFrameX, y: statusFrameY + streamingHeaderOffset), size: statusSizeAndApply.0)
|
let statusFrame = CGRect(origin: CGPoint(x: pageHorizontalInset, y: statusFrameY + streamingHeaderOffset), size: statusSizeAndApply.0)
|
||||||
let statusNode = statusSizeAndApply.1(self.statusNode == nil ? .None : animation)
|
let statusNode = statusSizeAndApply.1(self.statusNode == nil ? .None : animation)
|
||||||
|
|
||||||
if self.statusNode !== statusNode {
|
if self.statusNode !== statusNode {
|
||||||
|
|
@ -878,6 +907,14 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let urlHit = self.urlForTapLocation(point) else {
|
guard let urlHit = self.urlForTapLocation(point) else {
|
||||||
|
if let entityHit = self.entityForTapLocation(point), let content = self.entityTapContent(entityHit.attributes) {
|
||||||
|
let rects = self.computeHighlightRects(item: entityHit.item, parentOffset: entityHit.parentOffset, localPoint: entityHit.localPoint)
|
||||||
|
return ChatMessageBubbleContentTapAction(
|
||||||
|
content: content,
|
||||||
|
rects: rects,
|
||||||
|
activate: self.makeActivate(item: entityHit.item, parentOffset: entityHit.parentOffset, localPoint: entityHit.localPoint)
|
||||||
|
)
|
||||||
|
}
|
||||||
return ChatMessageBubbleContentTapAction(content: .none)
|
return ChatMessageBubbleContentTapAction(content: .none)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -927,6 +964,32 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func entityForTapLocation(_ point: CGPoint) -> (item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint, attributes: [NSAttributedString.Key: Any])? {
|
||||||
|
guard let pageView = self.pageView else { return nil }
|
||||||
|
let local = self.view.convert(point, to: pageView)
|
||||||
|
guard let hit = pageView.textItemAt(point: local) else { return nil }
|
||||||
|
let localPoint = CGPoint(x: local.x - hit.parentOffset.x, y: local.y - hit.parentOffset.y)
|
||||||
|
guard let (_, attributes) = hit.item.attributesAtPoint(localPoint, orNearest: false) else { return nil }
|
||||||
|
return (item: hit.item, parentOffset: hit.parentOffset, localPoint: localPoint, attributes: attributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func entityTapContent(_ attributes: [NSAttributedString.Key: Any]) -> ChatMessageBubbleContentTapAction.Content? {
|
||||||
|
if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention {
|
||||||
|
return .peerMention(peerId: mention.peerId, mention: mention.mention, openProfile: false)
|
||||||
|
} else if let peerName = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String {
|
||||||
|
return .textMention(peerName)
|
||||||
|
} else if let botCommand = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String {
|
||||||
|
return .botCommand(botCommand)
|
||||||
|
} else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag {
|
||||||
|
// Cashtags are carried as a Hashtag attribute (no dedicated cashtag key/tap-action exists);
|
||||||
|
// the leading "$" in the string distinguishes them, and the chat hashtag handler searches both.
|
||||||
|
return .hashtag(hashtag.peerName, hashtag.hashtag)
|
||||||
|
} else if let bankCard = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BankCard)] as? String {
|
||||||
|
return .bankCard(bankCard)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
/// Bridges an InstantPageUrlItem (used by the gallery's caption URL handler) to the
|
/// Bridges an InstantPageUrlItem (used by the gallery's caption URL handler) to the
|
||||||
/// chat layer's URL handler. `concealed: true` matches `tapActionAtPoint` for the same
|
/// chat layer's URL handler. `concealed: true` matches `tapActionAtPoint` for the same
|
||||||
/// reason: V2 cannot reliably compare displayed link text to the resolved URL.
|
/// reason: V2 cannot reliably compare displayed link text to the resolved URL.
|
||||||
|
|
@ -1027,8 +1090,12 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
||||||
}
|
}
|
||||||
|
|
||||||
var rects: [CGRect]?
|
var rects: [CGRect]?
|
||||||
if let point, let urlHit = self.urlForTapLocation(point) {
|
if let point {
|
||||||
rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
|
if let urlHit = self.urlForTapLocation(point) {
|
||||||
|
rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
|
||||||
|
} else if let entityHit = self.entityForTapLocation(point), self.entityTapContent(entityHit.attributes) != nil {
|
||||||
|
rects = self.computeHighlightRects(item: entityHit.item, parentOffset: entityHit.parentOffset, localPoint: entityHit.localPoint)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let rects, !rects.isEmpty {
|
if let rects, !rects.isEmpty {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue