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:
isaac 2026-05-23 22:12:47 +04:00
parent b48b96ecd9
commit 99e4c41b24
13 changed files with 558 additions and 78 deletions

View file

@ -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`.
- **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)
`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`.
- **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 1826, 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)
A gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`.

View file

@ -1958,6 +1958,8 @@ private func markdownDroppingPrefixLength(_ length: Int, from text: RichText) ->
return dropped == .empty ? .empty : .anchor(text: dropped, name: name)
case .textCustomEmoji:
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
case .textCustomEmoji:
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
case .textCustomEmoji:
return false
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName:
return false
}
}

View file

@ -346,6 +346,8 @@ private func trimStart(_ input: RichText) -> RichText {
break
case .textCustomEmoji:
break
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName:
break
}
return text
}
@ -392,6 +394,8 @@ private func trimEnd(_ input: RichText) -> RichText {
break
case .textCustomEmoji:
break
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName:
break
}
return text
}
@ -439,6 +443,8 @@ private func trim(_ input: RichText) -> RichText {
break
case .textCustomEmoji:
break
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName:
break
}
return text
}
@ -485,6 +491,8 @@ private func addNewLine(_ input: RichText) -> RichText {
text = .concat([.formula(latex: latex), .plain("\n")])
case .textCustomEmoji:
break
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName:
break
}
return text
}

View file

@ -122,6 +122,8 @@ extension RichText {
return "Fx"
case let .textCustomEmoji(_, 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()
}
}
}

View file

@ -102,11 +102,11 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
}
}
} else {
if let upper = upper, case .relatedArticles = upper {
if let upper, case .relatedArticles = upper {
return 0.0
} else {
if fitToWidth {
return 10.0
return 5.0
} else {
return 25.0
}

View file

@ -333,13 +333,16 @@ public final class InstantPageTextItem: InstantPageItem {
}
public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? {
if let direct = self.attributesAtPoint(point) {
return direct
// Hit-testing (taps on links/entities) wants the character under the finger keep the
// strict, clamping behavior.
if !orNearest {
return self.attributesAtPoint(point)
}
guard orNearest, !self.lines.isEmpty else {
guard !self.lines.isEmpty else {
return nil
}
// Selection drags can travel outside the text bounds, so pick the vertically-closest line.
let boundsWidth = self.frame.width
var nearestLineIndex = 0
var nearestDistance = CGFloat.greatestFiniteMagnitude
@ -361,21 +364,31 @@ public final class InstantPageTextItem: InstantPageItem {
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
let lineRange = CTLineGetStringRange(line.line)
var index: Int
if point.x <= lineFrame.minX {
index = lineRange.location
} else if point.x >= lineFrame.maxX {
// Trailing edge: return the line's upper bound (one past its last character) so a
// right-handle drag can include the last character/item of the line. The selection
// upper bound is exclusive, so clamping to the last character's index as the strict
// 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 (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]? {
@ -424,8 +437,17 @@ public final class InstantPageTextItem: InstantPageItem {
public func linkSelectionRects(at point: CGPoint) -> [CGRect] {
if let (index, dict) = self.attributesAtPoint(point) {
if let _ = dict[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] {
if let rects = self.attributeRects(name: NSAttributedString.Key(rawValue: TelegramTextAttributes.URL), at: index) {
let interactiveKeys = [
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
if rect.width > 5.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)
styleStack.pop()
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, _):
struct RunStruct {
let ascent: CGFloat

View file

@ -394,6 +394,34 @@ public func lastTextLineFrame(in layout: InstantPageV2Layout) -> CGRect? {
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
private struct LayoutContext {

View file

@ -21,7 +21,16 @@ union RichText_Value {
RichText_Image,
RichText_Anchor,
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 {
@ -104,3 +113,40 @@ table RichText_CustomEmoji {
fileId:long (id: 0);
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);
}

View file

@ -57,46 +57,28 @@ extension RichText {
self = .textCustomEmoji(fileId: data.documentId, alt: data.alt)
case let .textMath(textMath):
self = .formula(latex: textMath.source)
case let .textAutoEmail(email):
let _ = email
//TODO:localize
self = .plain("")
case let .textAutoPhone(phone):
let _ = phone
//TODO:localize
self = .plain("")
case let .textAutoUrl(url):
let _ = url
//TODO:localize
self = .plain("")
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 .textAutoEmail(textAutoEmailData):
self = .textAutoEmail(text: RichText(apiText: textAutoEmailData.text))
case let .textAutoPhone(textAutoPhoneData):
self = .textAutoPhone(text: RichText(apiText: textAutoPhoneData.text))
case let .textAutoUrl(textAutoUrlData):
self = .textAutoUrl(text: RichText(apiText: textAutoUrlData.text))
case let .textBankCard(textBankCardData):
self = .textBankCard(text: RichText(apiText: textBankCardData.text))
case let .textBotCommand(textBotCommandData):
self = .textBotCommand(text: RichText(apiText: textBotCommandData.text))
case let .textCashtag(textCashtagData):
self = .textCashtag(text: RichText(apiText: textCashtagData.text))
case let .textDate(value):
let _ = value
//TODO:localize
self = .plain("")
case let .textHashtag(value):
let _ = value
//TODO:localize
self = .plain("")
case let .textMention(value):
let _ = value
//TODO:localize
self = .plain("")
case let .textMentionName(value):
let _ = value
//TODO:localize
self = .plain("")
case let .textHashtag(textHashtagData):
self = .textHashtag(text: RichText(apiText: textHashtagData.text))
case let .textMention(textMentionData):
self = .textMention(text: RichText(apiText: textMentionData.text))
case let .textMentionName(textMentionNameData):
self = .textMentionName(text: RichText(apiText: textMentionNameData.text), peerId: textMentionNameData.userId)
case let .textSpoiler(value):
let _ = value
//TODO:localize
@ -142,6 +124,24 @@ extension RichText {
return .textMath(Api.RichText.Cons_textMath(source: latex))
case let .textCustomEmoji(fileId, 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))
}
}
}

View file

@ -21,6 +21,15 @@ private enum RichTextTypes: Int32 {
case anchor = 15
case formula = 16
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 {
@ -42,6 +51,15 @@ public indirect enum RichText: PostboxCoding, Equatable {
case anchor(text: RichText, name: String)
case formula(latex: 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) {
switch decoder.decodeInt32ForKey("r", orElse: 0) {
@ -87,6 +105,24 @@ public indirect enum RichText: PostboxCoding, Equatable {
self = .formula(latex: decoder.decodeStringForKey("l", orElse: ""))
case RichTextTypes.textCustomEmoji.rawValue:
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:
self = .empty
}
@ -162,6 +198,34 @@ public indirect enum RichText: PostboxCoding, Equatable {
encoder.encodeInt32(RichTextTypes.textCustomEmoji.rawValue, forKey: "r")
encoder.encodeInt64(fileId, forKey: "ce.f")
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 {
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
case let .textCustomEmoji(_, 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()
}
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_:
self = .empty
}
@ -548,6 +693,61 @@ extension RichText {
TelegramCore_RichText_CustomEmoji.add(fileId: fileId, &builder)
TelegramCore_RichText_CustomEmoji.add(alt: altOffset, &builder)
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)

View file

@ -401,6 +401,7 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([
if richText != nil && !skipText {
result.append((message, ChatMessageRichDataBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default)))
needReactions = false
}
if message.adAttribute != nil {

View file

@ -16,6 +16,7 @@ swift_library(
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/AccountContext",
"//submodules/InstantPageUI",
"//submodules/TextFormat:TextFormat",
"//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode",
"//submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode",
"//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon",

View file

@ -10,6 +10,7 @@ import ChatMessageDateAndStatusNode
import ChatMessageItemCommon
import ChatControllerInteraction
import InstantPageUI
import TextFormat
import TelegramUIPreferences
import TextLoadingEffect
import TextSelectionNode
@ -213,6 +214,12 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
// Built alongside pageLayout so the apply closure can hand it to ensurePageView.
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 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
@ -365,7 +372,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
instantPage: attribute.instantPage,
userLocation: .other,
boundingWidth: suggestedBoundingWidth - 2.0,
horizontalInset: 10.0,
horizontalInset: pageHorizontalInset,
theme: pageTheme,
strings: item.presentationData.strings,
dateTimeFormat: item.presentationData.dateTimeFormat,
@ -521,10 +528,6 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
displayStatus = false
}
if "".isEmpty {
displayStatus = false
}
if displayStatus {
if incoming {
statusType = .BubbleIncoming
@ -541,7 +544,13 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
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))?
if let statusType = statusType {
@ -582,19 +591,35 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
}
if let statusSuggestedWidthAndContinue, !hasDraft {
let statusLeftEdgeInBubble: CGFloat
if let lastTextLineFrame {
statusLeftEdgeInBubble = 1.0 + lastTextLineFrame.minX
} else {
statusLeftEdgeInBubble = 1.0
}
boundingSize.width = max(boundingSize.width, statusLeftEdgeInBubble * 2.0 + statusSuggestedWidthAndContinue.0)
// Mirrors TextBubble: max(contentWidth, statusWidth + sideInsets), where
// sideInsets = left + right text inset (= pageHorizontalInset on each side).
boundingSize.width = max(boundingSize.width, statusSuggestedWidthAndContinue.0 + pageHorizontalInset * 2.0)
}
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 {
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
@ -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)
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
if let lastTextLineFrame {
statusFrameX = 1.0 + lastTextLineFrame.minX
statusFrameY = 1.0 + lastTextLineFrame.maxY
// Add the 5pt text-rect pad only when the date trails on the line
// (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 {
statusFrameX = 1.0
statusFrameY = 1.0 + pageLayout.contentSize.height
} else {
statusFrameX = 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)
if self.statusNode !== statusNode {
@ -878,6 +907,14 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
}
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)
}
@ -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
/// chat layer's URL handler. `concealed: true` matches `tapActionAtPoint` for the same
/// reason: V2 cannot reliably compare displayed link text to the resolved URL.
@ -1027,8 +1090,12 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
}
var rects: [CGRect]?
if let point, let urlHit = self.urlForTapLocation(point) {
rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
if let point {
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 {