diff --git a/CLAUDE.md b/CLAUDE.md index f8891292bb..cf02049054 100644 --- a/CLAUDE.md +++ b/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`. - **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 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) A gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`. diff --git a/submodules/BrowserUI/Sources/BrowserMarkdown.swift b/submodules/BrowserUI/Sources/BrowserMarkdown.swift index 9a6782ed24..493ab58877 100644 --- a/submodules/BrowserUI/Sources/BrowserMarkdown.swift +++ b/submodules/BrowserUI/Sources/BrowserMarkdown.swift @@ -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 } } diff --git a/submodules/BrowserUI/Sources/BrowserReadability.swift b/submodules/BrowserUI/Sources/BrowserReadability.swift index dcde773aeb..c0e55704b0 100644 --- a/submodules/BrowserUI/Sources/BrowserReadability.swift +++ b/submodules/BrowserUI/Sources/BrowserReadability.swift @@ -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 } diff --git a/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift b/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift index db05141e77..373836fdd7 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift @@ -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() } } } diff --git a/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift b/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift index aba31661e7..1ee070140c 100644 --- a/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift +++ b/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift @@ -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 } diff --git a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift index ae5b47daaf..f20e6a806f 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift @@ -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 diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index 1f1527be2c..8e0a8d5374 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -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 { diff --git a/submodules/TelegramCore/FlatSerialization/Models/RichText.fbs b/submodules/TelegramCore/FlatSerialization/Models/RichText.fbs index 974b82c844..5fc74ebc0e 100644 --- a/submodules/TelegramCore/FlatSerialization/Models/RichText.fbs +++ b/submodules/TelegramCore/FlatSerialization/Models/RichText.fbs @@ -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); +} diff --git a/submodules/TelegramCore/Sources/ApiUtils/RichText.swift b/submodules/TelegramCore/Sources/ApiUtils/RichText.swift index 44d90fee32..6f75fb3064 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/RichText.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/RichText.swift @@ -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)) } } } diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift index 4ced721aaa..826cb04431 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift @@ -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) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 953e6e5010..876d1866f3 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -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 { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD index bcc4287fb3..91734ff0f6 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD @@ -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", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index 5ee4ccee7f..afad7c32cd 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -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 {