From a731f8bdc082b5464fc3b362fe389366fb27e105 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 4 Jun 2026 13:45:48 +0200 Subject: [PATCH] Implement textDate autoformatter for InstantPage V2 rich bubbles Render RichText.textDate (API constructor textDate(flags, text, date)) as a locale/timezone-correct, relative-aware, tappable date inside V2 rich-message bubbles, reusing the existing stringForEntityFormattedDate autoformatter. - Model: add RichText.textDate(text:date:format:) with Postbox coding, FlatBuffers schema (RichText.fbs; flatc regenerates), Equatable and plainText; parse flags->DateTimeFormat? at the API boundary (flags==0 => nil, matching the messageEntityFormattedDate convention) with a symmetric apiRichText() round-trip. The new case is handled in every exhaustive RichText switch (SyncCore, ApiUtils, InstantPageTextItem, InstantPageAnchorPath, InstantPagePreviewText, BrowserReadability, BrowserMarkdown). - Render: attributedStringForRichText gains an optional formatDate closure; the V2 layout builds it from the message's strings/dateTimeFormat and threads it to every text-building call site, formatting the timestamp and tagging the run with TelegramTextAttributes.Date. - Tappable: the Date attribute is added to linkSelectionRects hit-testing and mapped in the rich bubble's entityTapContent to the existing .date tap action (opens the date context menu). - Live update: the V2 layout accumulates a page-wide formattedDateUpdatePeriod (>=10s) for relative dates; the rich bubble schedules a refresh timer on it, recreating it only when the period changes. The currentPageLayout cache is bypassed for relative-date pages so the timer-driven relayout actually re-formats the date. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../BrowserUI/Sources/BrowserMarkdown.swift | 8 ++- .../Sources/BrowserReadability.swift | 8 +-- .../Sources/InstantPageAnchorPath.swift | 2 + .../Sources/InstantPageTextItem.swift | 71 +++++++++++-------- .../Sources/InstantPageV2Layout.swift | 57 ++++++++++----- .../FlatSerialization/Models/RichText.fbs | 9 ++- .../Sources/ApiUtils/RichText.swift | 7 +- .../Sources/SyncCore/SyncCore_RichText.swift | 33 ++++++++- .../Sources/InstantPagePreviewText.swift | 2 +- ...ChatMessageRichDataBubbleContentNode.swift | 34 ++++++++- 10 files changed, 172 insertions(+), 59 deletions(-) diff --git a/submodules/BrowserUI/Sources/BrowserMarkdown.swift b/submodules/BrowserUI/Sources/BrowserMarkdown.swift index d956ac5395..80fb481859 100644 --- a/submodules/BrowserUI/Sources/BrowserMarkdown.swift +++ b/submodules/BrowserUI/Sources/BrowserMarkdown.swift @@ -1091,6 +1091,8 @@ private func richTextIsEntityExpressible(_ text: RichText) -> Bool { return richTextIsEntityExpressible(inner) case .textMentionName(let inner, _): return richTextIsEntityExpressible(inner) + case .textDate: + return false } } @@ -2153,7 +2155,7 @@ 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, .textSpoiler: + case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler, .textDate: return text } } @@ -2186,7 +2188,7 @@ private func markdownHasDisplayableContent(_ richText: RichText) -> Bool { return !latex.isEmpty case .textCustomEmoji: return true - case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler: + case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler, .textDate: return true } } @@ -2219,7 +2221,7 @@ 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, .textSpoiler: + case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler, .textDate: return false } } diff --git a/submodules/BrowserUI/Sources/BrowserReadability.swift b/submodules/BrowserUI/Sources/BrowserReadability.swift index 8875493226..c3710f64c3 100644 --- a/submodules/BrowserUI/Sources/BrowserReadability.swift +++ b/submodules/BrowserUI/Sources/BrowserReadability.swift @@ -406,7 +406,7 @@ private func trimStart(_ input: RichText) -> RichText { break case .textCustomEmoji: break - case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler: + case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler, .textDate: break } return text @@ -454,7 +454,7 @@ private func trimEnd(_ input: RichText) -> RichText { break case .textCustomEmoji: break - case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler: + case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler, .textDate: break } return text @@ -503,7 +503,7 @@ private func trim(_ input: RichText) -> RichText { break case .textCustomEmoji: break - case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler: + case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler, .textDate: break } return text @@ -551,7 +551,7 @@ 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, .textSpoiler: + case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler, .textDate: break } return text diff --git a/submodules/InstantPageUI/Sources/InstantPageAnchorPath.swift b/submodules/InstantPageUI/Sources/InstantPageAnchorPath.swift index 25ee9c2b24..ccb453ae50 100644 --- a/submodules/InstantPageUI/Sources/InstantPageAnchorPath.swift +++ b/submodules/InstantPageUI/Sources/InstantPageAnchorPath.swift @@ -149,5 +149,7 @@ private func richTextContainsAnchor(_ text: RichText, name: String) -> Bool { return richTextContainsAnchor(inner, name: name) case let .textMentionName(inner, _): return richTextContainsAnchor(inner, name: name) + case let .textDate(inner, _, _): + return richTextContainsAnchor(inner, name: name) } } diff --git a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift index b7c1a0c4cc..b11e0b988a 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift @@ -474,7 +474,8 @@ public final class InstantPageTextItem: InstantPageItem { TelegramTextAttributes.PeerTextMention, TelegramTextAttributes.BotCommand, TelegramTextAttributes.Hashtag, - TelegramTextAttributes.BankCard + TelegramTextAttributes.BankCard, + TelegramTextAttributes.Date ] for key in interactiveKeys { let attrKey = NSAttributedString.Key(rawValue: key) @@ -727,7 +728,7 @@ final class InstantPageScrollableTextItem: InstantPageScrollableItem { } } -func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextStyleStack, url: InstantPageUrlItem? = nil, boundingWidth: CGFloat? = nil) -> NSAttributedString { +func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextStyleStack, url: InstantPageUrlItem? = nil, boundingWidth: CGFloat? = nil, formatDate: ((Int32, MessageTextEntityType.DateTimeFormat) -> String)? = nil) -> NSAttributedString { switch text { case .empty: return NSAttributedString(string: "", attributes: styleStack.textAttributes()) @@ -739,67 +740,67 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt return NSAttributedString(string: string, attributes: attributes) case let .bold(text): styleStack.push(.bold) - let result = attributedStringForRichText(text, styleStack: styleStack, url: url) + let result = attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) styleStack.pop() return result case let .italic(text): styleStack.push(.italic) - let result = attributedStringForRichText(text, styleStack: styleStack, url: url) + let result = attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) styleStack.pop() return result case let .underline(text): styleStack.push(.underline) - let result = attributedStringForRichText(text, styleStack: styleStack, url: url) + let result = attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) styleStack.pop() return result case let .strikethrough(text): styleStack.push(.strikethrough) - let result = attributedStringForRichText(text, styleStack: styleStack, url: url) + let result = attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) styleStack.pop() return result case let .fixed(text): styleStack.push(.fontFixed(true)) - let result = attributedStringForRichText(text, styleStack: styleStack, url: url) + let result = attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) styleStack.pop() return result case let .url(text, url, webpageId): styleStack.push(.link(webpageId != nil)) - let result = attributedStringForRichText(text, styleStack: styleStack, url: InstantPageUrlItem(url: url, webpageId: webpageId)) + let result = attributedStringForRichText(text, styleStack: styleStack, url: InstantPageUrlItem(url: url, webpageId: webpageId), formatDate: formatDate) styleStack.pop() return result case let .email(text, email): styleStack.push(.bold) styleStack.push(.underline) - let result = attributedStringForRichText(text, styleStack: styleStack, url: InstantPageUrlItem(url: "mailto:\(email)", webpageId: nil)) + let result = attributedStringForRichText(text, styleStack: styleStack, url: InstantPageUrlItem(url: "mailto:\(email)", webpageId: nil), formatDate: formatDate) styleStack.pop() styleStack.pop() return result case let .concat(texts): let string = NSMutableAttributedString() for text in texts { - let substring = attributedStringForRichText(text, styleStack: styleStack, url: url, boundingWidth: boundingWidth) + let substring = attributedStringForRichText(text, styleStack: styleStack, url: url, boundingWidth: boundingWidth, formatDate: formatDate) string.append(substring) } return string case let .subscript(text): styleStack.push(.subscript) - let result = attributedStringForRichText(text, styleStack: styleStack, url: url) + let result = attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) styleStack.pop() return result case let .superscript(text): styleStack.push(.superscript) - let result = attributedStringForRichText(text, styleStack: styleStack, url: url) + let result = attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) styleStack.pop() return result case let .marked(text): styleStack.push(.marker) - let result = attributedStringForRichText(text, styleStack: styleStack, url: url) + let result = attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) styleStack.pop() return result case let .phone(text, phone): styleStack.push(.bold) styleStack.push(.underline) - let result = attributedStringForRichText(text, styleStack: styleStack, url: InstantPageUrlItem(url: "tel:\(phone)", webpageId: nil)) + let result = attributedStringForRichText(text, styleStack: styleStack, url: InstantPageUrlItem(url: "tel:\(phone)", webpageId: nil), formatDate: formatDate) styleStack.pop() styleStack.pop() return result @@ -829,7 +830,7 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt }) let delegate = CTRunDelegateCreate(&callbacks, extentBuffer) let attrDictionaryDelegate = [(kCTRunDelegateAttributeName as NSAttributedString.Key): (delegate as Any), NSAttributedString.Key(rawValue: InstantPageMediaIdAttribute): id.id, NSAttributedString.Key(rawValue: InstantPageMediaDimensionsAttribute): dimensions] - let mutableAttributedString = attributedStringForRichText(.plain(" "), styleStack: styleStack, url: url).mutableCopy() as! NSMutableAttributedString + let mutableAttributedString = attributedStringForRichText(.plain(" "), styleStack: styleStack, url: url, formatDate: formatDate).mutableCopy() as! NSMutableAttributedString mutableAttributedString.addAttributes(attrDictionaryDelegate, range: NSMakeRange(0, mutableAttributedString.length)) return mutableAttributedString case let .formula(latex): @@ -864,7 +865,7 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt return data.pointee.width }) let delegate = CTRunDelegateCreate(&callbacks, extentBuffer) - let mutableAttributedString = attributedStringForRichText(.plain(" "), styleStack: styleStack, url: url).mutableCopy() as! NSMutableAttributedString + let mutableAttributedString = attributedStringForRichText(.plain(" "), styleStack: styleStack, url: url, formatDate: formatDate).mutableCopy() as! NSMutableAttributedString mutableAttributedString.addAttributes([ kCTRunDelegateAttributeName as NSAttributedString.Key: delegate as Any, NSAttributedString.Key(rawValue: InstantPageFormulaAttribute): attachment @@ -877,29 +878,29 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt empty = true text = .plain("\u{200b}") } - let anchorText = !empty ? attributedStringForRichText(text, styleStack: styleStack, url: url) : nil + let anchorText = !empty ? attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) : nil styleStack.push(.anchor(name, anchorText, empty)) - let result = attributedStringForRichText(text, styleStack: styleStack, url: url) + let result = attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) 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)) + let result = attributedStringForRichText(text, styleStack: styleStack, url: InstantPageUrlItem(url: text.plainText, webpageId: nil), formatDate: formatDate) 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)) + let result = attributedStringForRichText(text, styleStack: styleStack, url: InstantPageUrlItem(url: "mailto:\(text.plainText)", webpageId: nil), formatDate: formatDate) 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)) + let result = attributedStringForRichText(text, styleStack: styleStack, url: InstantPageUrlItem(url: "tel:\(text.plainText)", webpageId: nil), formatDate: formatDate) styleStack.pop() return result case let .textMention(text): styleStack.push(.link(false)) - let result = attributedStringForRichText(text, styleStack: styleStack, url: url) + let result = attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) styleStack.pop() let mutable = result.mutableCopy() as! NSMutableAttributedString if mutable.length != 0 { @@ -908,7 +909,7 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt return mutable case let .textMentionName(text, peerId): styleStack.push(.link(false)) - let result = attributedStringForRichText(text, styleStack: styleStack, url: url) + let result = attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) styleStack.pop() let mutable = result.mutableCopy() as! NSMutableAttributedString if mutable.length != 0 { @@ -918,7 +919,7 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt return mutable case let .textHashtag(text): styleStack.push(.link(false)) - let result = attributedStringForRichText(text, styleStack: styleStack, url: url) + let result = attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) styleStack.pop() let mutable = result.mutableCopy() as! NSMutableAttributedString if mutable.length != 0 { @@ -927,7 +928,7 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt return mutable case let .textCashtag(text): styleStack.push(.link(false)) - let result = attributedStringForRichText(text, styleStack: styleStack, url: url) + let result = attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) styleStack.pop() let mutable = result.mutableCopy() as! NSMutableAttributedString if mutable.length != 0 { @@ -936,7 +937,7 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt return mutable case let .textBotCommand(text): styleStack.push(.link(false)) - let result = attributedStringForRichText(text, styleStack: styleStack, url: url) + let result = attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) styleStack.pop() let mutable = result.mutableCopy() as! NSMutableAttributedString if mutable.length != 0 { @@ -945,7 +946,7 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt return mutable case let .textBankCard(text): styleStack.push(.link(false)) - let result = attributedStringForRichText(text, styleStack: styleStack, url: url) + let result = attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) styleStack.pop() let mutable = result.mutableCopy() as! NSMutableAttributedString if mutable.length != 0 { @@ -981,19 +982,31 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt }) let delegate = CTRunDelegateCreate(&callbacks, extentBuffer) let emojiAttribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: nil) - let mutableAttributedString = attributedStringForRichText(.plain(" "), styleStack: styleStack, url: url).mutableCopy() as! NSMutableAttributedString + let mutableAttributedString = attributedStringForRichText(.plain(" "), styleStack: styleStack, url: url, formatDate: formatDate).mutableCopy() as! NSMutableAttributedString mutableAttributedString.addAttributes([ kCTRunDelegateAttributeName as NSAttributedString.Key: delegate as Any, ChatTextInputAttributes.customEmoji: emojiAttribute ], range: NSMakeRange(0, mutableAttributedString.length)) return mutableAttributedString case let .textSpoiler(text): - let result = attributedStringForRichText(text, styleStack: styleStack, url: url) + let result = attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) let mutable = result.mutableCopy() as! NSMutableAttributedString if mutable.length != 0 { mutable.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler), value: true, range: NSRange(location: 0, length: mutable.length)) } return mutable + case let .textDate(text, date, format): + if let format, let formatDate { + let formatted = formatDate(date, format) + let result = attributedStringForRichText(.plain(formatted), styleStack: styleStack, url: url, formatDate: formatDate) + let mutable = result.mutableCopy() as! NSMutableAttributedString + if mutable.length != 0 { + mutable.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.Date), value: date, range: NSRange(location: 0, length: mutable.length)) + } + return mutable + } else { + return attributedStringForRichText(text, styleStack: styleStack, url: url, formatDate: formatDate) + } } } diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index c9987acb3d..e57bd10e90 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -26,6 +26,11 @@ public struct InstantPageV2Layout { /// but no fetch signal can be bound (image view simply isn't created). public let webpage: TelegramMediaWebpage? + /// Set by `layoutInstantPageV2` when the page contains at least one `.relative` `textDate`. + /// The minimum refresh period (seconds, >=10) across all relative dates; the rich-data bubble + /// schedules a timer on it to keep "N minutes ago" fresh. nil => no relative date => no timer. + public var formattedDateUpdatePeriod: Int32? = nil + public init(contentSize: CGSize, items: [InstantPageV2LaidOutItem], detailsIndices: [Int], media: [EngineMedia.Id: EngineMedia] = [:], webpage: TelegramMediaWebpage? = nil) { self.contentSize = contentSize self.items = items @@ -395,10 +400,23 @@ public func layoutInstantPageV2( media[id] = .file(video) } + let dateAccumulator = DateUpdateAccumulator() + let formatDate: (Int32, MessageTextEntityType.DateTimeFormat) -> String = { timestamp, format in + if case .relative = format { + let now = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) + let age = abs(now - timestamp) + // Cap the fastest bucket at 10s (the message-entity reference uses 1s for <120s). + let period: Int32 = age < 120 ? 10 : (age <= 60 * 60 ? 60 : 30 * 60) + dateAccumulator.period = dateAccumulator.period.map { min($0, period) } ?? period + } + return stringForEntityFormattedDate(timestamp: timestamp, format: format, strings: strings, dateTimeFormat: dateTimeFormat) + } + var context = LayoutContext( theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, + formatDate: formatDate, userLocation: userLocation, webpage: webpage, media: media, @@ -411,13 +429,15 @@ public func layoutInstantPageV2( expandedDetails: expandedDetails ) - return layoutBlockSequence( + var result = layoutBlockSequence( instantPage.blocks, boundingWidth: boundingWidth, horizontalInset: horizontalInset, kind: .topLevel, context: &context ) + result.formattedDateUpdatePeriod = dateAccumulator.period + return result } /// Used by `ChatMessageRichDataBubbleContentNode` to anchor the date/checks status node at the @@ -503,10 +523,15 @@ public func lastTextLineFrameIfLastItemIsText(in layout: InstantPageV2Layout) -> // MARK: - Layout context +private final class DateUpdateAccumulator { + var period: Int32? +} + private struct LayoutContext { let theme: InstantPageTheme let strings: PresentationStrings let dateTimeFormat: PresentationDateTimeFormat + let formatDate: (Int32, MessageTextEntityType.DateTimeFormat) -> String let userLocation: MediaResourceUserLocation let webpage: TelegramMediaWebpage let media: [EngineMedia.Id: EngineMedia] @@ -1069,7 +1094,7 @@ private func layoutDetails( let titleStyleStack = InstantPageTextStyleStack() setupStyleStack(titleStyleStack, theme: context.theme, category: .paragraph, link: false) let (titleTextItem, _, _) = layoutTextItem( - attributedStringForRichText(title, styleStack: titleStyleStack), + attributedStringForRichText(title, styleStack: titleStyleStack, formatDate: context.formatDate), boundingWidth: boundingWidth - horizontalInset * 2.0 - 32.0, // reserve right edge for chevron offset: CGPoint(x: 0.0, y: 0.0), fitToWidth: context.fitToWidth, @@ -1186,7 +1211,7 @@ private func layoutTable( // boundingWidth sizes inline attachments to `cellWidthLimit - totalCellPadding`, while // the line-break budget passed to `layoutTextItem` is the full `cellWidthLimit`. (V1 // subtracts `totalCellPadding` only on the attribute-string arg, not the layout arg.) - let attrStr = attributedStringForRichText(text, styleStack: styleStack, boundingWidth: cellWidthLimit - totalCellPadding) + let attrStr = attributedStringForRichText(text, styleStack: styleStack, boundingWidth: cellWidthLimit - totalCellPadding, formatDate: context.formatDate) if let shortestItem = layoutTextItem( attrStr, boundingWidth: cellWidthLimit, @@ -1672,7 +1697,7 @@ private func layoutCaptionAndCredit( y += 14.0 let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: .caption, link: false) - let attributedString = attributedStringForRichText(caption.text, styleStack: styleStack) + let attributedString = attributedStringForRichText(caption.text, styleStack: styleStack, formatDate: context.formatDate) let (textItem, captionItems, captionSize) = layoutTextItem( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, @@ -1698,7 +1723,7 @@ private func layoutCaptionAndCredit( } let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: .credit, link: false) - let attributedString = attributedStringForRichText(caption.credit, styleStack: styleStack) + let attributedString = attributedStringForRichText(caption.credit, styleStack: styleStack, formatDate: context.formatDate) let (_, creditItems, creditSize) = layoutTextItem( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, @@ -2009,7 +2034,7 @@ private func layoutSimpleText( ) -> [InstantPageV2LaidOutItem] { let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: category, link: false) - let attributedString = attributedStringForRichText(text, styleStack: styleStack) + let attributedString = attributedStringForRichText(text, styleStack: styleStack, formatDate: context.formatDate) let (_, items, _) = layoutTextItem( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, @@ -2029,7 +2054,7 @@ private func layoutHeading( ) -> [InstantPageV2LaidOutItem] { let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, attributes: context.theme.headingTextAttributes(level: level, link: false)) - let attributedString = attributedStringForRichText(text, styleStack: styleStack) + let attributedString = attributedStringForRichText(text, styleStack: styleStack, formatDate: context.formatDate) let (_, items, _) = layoutTextItem( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, @@ -2053,7 +2078,7 @@ private func layoutParagraph( let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: kind == .cell ? .table : .paragraph, link: false) - let attributedString = attributedStringForRichText(text, styleStack: styleStack) + let attributedString = attributedStringForRichText(text, styleStack: styleStack, formatDate: context.formatDate) let (_, items, _) = layoutTextItem( attributedString, @@ -2125,7 +2150,7 @@ private func layoutAuthorDate( let alignment: NSTextAlignment = (context.rtl || previousItemHasRTL) ? .right : .natural let (_, items, _) = layoutTextItem( - attributedStringForRichText(resolvedText, styleStack: styleStack), + attributedStringForRichText(resolvedText, styleStack: styleStack, formatDate: context.formatDate), boundingWidth: boundingWidth - horizontalInset * 2.0, alignment: alignment, offset: CGPoint(x: horizontalInset, y: 0.0), @@ -2175,7 +2200,7 @@ private func layoutCodeBlock( // V1 lines 335–338: fall back to plain paragraph style when no language. let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: .codeBlock, link: false) - attributedString = attributedStringForRichText(text, styleStack: styleStack) + attributedString = attributedStringForRichText(text, styleStack: styleStack, formatDate: context.formatDate) } // V1 line 341: text bounding width excludes horizontalInset×2 and backgroundInset×2. @@ -2236,7 +2261,7 @@ private func layoutThinking( ) let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, attributes: dimmedAttributes) - let attributedString = attributedStringForRichText(text, styleStack: styleStack) + let attributedString = attributedStringForRichText(text, styleStack: styleStack, formatDate: context.formatDate) // Mirror a normal `.text` item's sizing: lay the text out flush (offset .zero) and put the page // inset onto the BLOCK frame, so the `.thinking` item's frame == a `.text` item's frame @@ -2315,7 +2340,7 @@ private func layoutBlockQuote( contentHeight += 14.0 let captionStyleStack = InstantPageTextStyleStack() setupStyleStack(captionStyleStack, theme: context.theme, category: .caption, link: false) - let attributedCaption = attributedStringForRichText(caption, styleStack: captionStyleStack) + let attributedCaption = attributedStringForRichText(caption, styleStack: captionStyleStack, formatDate: context.formatDate) let (_, captionItems, captionSize) = layoutTextItem( attributedCaption, boundingWidth: innerBoundingWidth, @@ -2386,7 +2411,7 @@ private func layoutQuoteText( let textX: CGFloat = horizontalInset + lineInset let textAlignment: NSTextAlignment = isPull ? .center : .natural - let attributedBody = attributedStringForRichText(text, styleStack: styleStack) + let attributedBody = attributedStringForRichText(text, styleStack: styleStack, formatDate: context.formatDate) let (_, bodyItems, bodySize) = layoutTextItem( attributedBody, boundingWidth: textBoundingWidth, @@ -2407,7 +2432,7 @@ private func layoutQuoteText( let captionStyleStack = InstantPageTextStyleStack() setupStyleStack(captionStyleStack, theme: context.theme, category: .caption, link: false) - let attributedCaption = attributedStringForRichText(caption, styleStack: captionStyleStack) + let attributedCaption = attributedStringForRichText(caption, styleStack: captionStyleStack, formatDate: context.formatDate) let (_, captionItems, captionSize) = layoutTextItem( attributedCaption, boundingWidth: textBoundingWidth, @@ -2522,7 +2547,7 @@ private func layoutList( // Measure using a UILabel to get the expected label width. let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false) - let attrStr = attributedStringForRichText(.plain(value), styleStack: styleStack) + let attrStr = attributedStringForRichText(.plain(value), styleStack: styleStack, formatDate: context.formatDate) let (textItem, _, _) = layoutTextItem( attrStr, boundingWidth: boundingWidth - horizontalInset * 2.0, @@ -2586,7 +2611,7 @@ private func layoutList( // Layout text content. let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false) - let attrStr = attributedStringForRichText(text, styleStack: styleStack) + let attrStr = attributedStringForRichText(text, styleStack: styleStack, formatDate: context.formatDate) let textX = horizontalInset + indexSpacing + maxIndexWidth let textWidth = boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth let (textItem, textLaidOutItems, textSize) = layoutTextItem( diff --git a/submodules/TelegramCore/FlatSerialization/Models/RichText.fbs b/submodules/TelegramCore/FlatSerialization/Models/RichText.fbs index a6154e51a7..3925effab6 100644 --- a/submodules/TelegramCore/FlatSerialization/Models/RichText.fbs +++ b/submodules/TelegramCore/FlatSerialization/Models/RichText.fbs @@ -31,7 +31,8 @@ union RichText_Value { RichText_Hashtag, RichText_Mention, RichText_MentionName, - RichText_Spoiler + RichText_Spoiler, + RichText_Date } table RichText { @@ -155,3 +156,9 @@ table RichText_MentionName { table RichText_Spoiler { text:RichText (id: 0, required); } + +table RichText_Date { + text:RichText (id: 0, required); + date:int (id: 1); + format:int = -1 (id: 2); +} diff --git a/submodules/TelegramCore/Sources/ApiUtils/RichText.swift b/submodules/TelegramCore/Sources/ApiUtils/RichText.swift index 373149628e..0507feaceb 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/RichText.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/RichText.swift @@ -70,9 +70,8 @@ extension RichText { case let .textCashtag(textCashtagData): self = .textCashtag(text: RichText(apiText: textCashtagData.text)) case let .textDate(value): - let _ = value - //TODO:localize - self = .plain("") + let format: MessageTextEntityType.DateTimeFormat? = value.flags == 0 ? nil : MessageTextEntityType.DateTimeFormat(rawValue: value.flags) + self = .textDate(text: RichText(apiText: value.text), date: value.date, format: format) case let .textHashtag(textHashtagData): self = .textHashtag(text: RichText(apiText: textHashtagData.text)) case let .textMention(textMentionData): @@ -142,6 +141,8 @@ extension RichText { return .textMentionName(Api.RichText.Cons_textMentionName(text: text.apiRichText(), userId: peerId)) case let .textSpoiler(text): return .textSpoiler(Api.RichText.Cons_textSpoiler(text: text.apiRichText())) + case let .textDate(text, date, format): + return .textDate(Api.RichText.Cons_textDate(flags: format?.rawValue ?? 0, text: text.apiRichText(), date: date)) } } } diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift index 7d39e4388a..d55bba650a 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift @@ -31,6 +31,7 @@ private enum RichTextTypes: Int32 { case textMention = 25 case textMentionName = 26 case textSpoiler = 27 + case textDate = 28 } public indirect enum RichText: PostboxCoding, Equatable { @@ -62,6 +63,7 @@ public indirect enum RichText: PostboxCoding, Equatable { case textMention(text: RichText) case textMentionName(text: RichText, peerId: Int64) case textSpoiler(text: RichText) + case textDate(text: RichText, date: Int32, format: MessageTextEntityType.DateTimeFormat?) public init(decoder: PostboxDecoder) { switch decoder.decodeInt32ForKey("r", orElse: 0) { @@ -127,6 +129,8 @@ public indirect enum RichText: PostboxCoding, Equatable { self = .textMentionName(text: decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText, peerId: decoder.decodeInt64ForKey("mn.p", orElse: 0)) case RichTextTypes.textSpoiler.rawValue: self = .textSpoiler(text: decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText) + case RichTextTypes.textDate.rawValue: + self = .textDate(text: decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText, date: decoder.decodeInt32ForKey("dt", orElse: 0), format: decoder.decodeOptionalInt32ForKey("df").flatMap { MessageTextEntityType.DateTimeFormat(rawValue: $0) }) default: self = .empty } @@ -233,9 +237,18 @@ public indirect enum RichText: PostboxCoding, Equatable { case let .textSpoiler(text): encoder.encodeInt32(RichTextTypes.textSpoiler.rawValue, forKey: "r") encoder.encodeObject(text, forKey: "t") + case let .textDate(text, date, format): + encoder.encodeInt32(RichTextTypes.textDate.rawValue, forKey: "r") + encoder.encodeObject(text, forKey: "t") + encoder.encodeInt32(date, forKey: "dt") + if let format { + encoder.encodeInt32(format.rawValue, forKey: "df") + } else { + encoder.encodeNil(forKey: "df") + } } } - + public static func ==(lhs: RichText, rhs: RichText) -> Bool { switch lhs { case .empty: @@ -366,6 +379,8 @@ public indirect enum RichText: PostboxCoding, Equatable { if case let .textMentionName(rhsText, rhsPeerId) = rhs, lhsText == rhsText, lhsPeerId == rhsPeerId { return true } else { return false } case let .textSpoiler(text): if case .textSpoiler(text) = rhs { return true } else { return false } + case let .textDate(lhsText, lhsDate, lhsFormat): + if case let .textDate(rhsText, rhsDate, rhsFormat) = rhs, lhsText == rhsText, lhsDate == rhsDate, lhsFormat == rhsFormat { return true } else { return false } } } } @@ -433,6 +448,8 @@ public extension RichText { return text.plainText case let .textSpoiler(text): return text.plainText + case let .textDate(text, _, _): + return text.plainText } } } @@ -580,6 +597,12 @@ extension RichText { throw FlatBuffersError.missingRequiredField() } self = .textSpoiler(text: try RichText(flatBuffersObject: value.text)) + case .richtextDate: + guard let value = flatBuffersObject.value(type: TelegramCore_RichText_Date.self) else { + throw FlatBuffersError.missingRequiredField() + } + let formatValue = value.format + self = .textDate(text: try RichText(flatBuffersObject: value.text), date: value.date, format: formatValue == -1 ? nil : MessageTextEntityType.DateTimeFormat(rawValue: formatValue)) case .none_: self = .empty } @@ -770,6 +793,14 @@ extension RichText { let start = TelegramCore_RichText_Spoiler.startRichText_Spoiler(&builder) TelegramCore_RichText_Spoiler.add(text: textOffset, &builder) offset = TelegramCore_RichText_Spoiler.endRichText_Spoiler(&builder, start: start) + case let .textDate(text, date, format): + valueType = .richtextDate + let textOffset = text.encodeToFlatBuffers(builder: &builder) + let start = TelegramCore_RichText_Date.startRichText_Date(&builder) + TelegramCore_RichText_Date.add(text: textOffset, &builder) + TelegramCore_RichText_Date.add(date: date, &builder) + TelegramCore_RichText_Date.add(format: format?.rawValue ?? -1, &builder) + offset = TelegramCore_RichText_Date.endRichText_Date(&builder, start: start) } return TelegramCore_RichText.createRichText(&builder, valueType: valueType, valueOffset: offset) diff --git a/submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift b/submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift index 6b8d179aef..47cf199bf6 100644 --- a/submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift +++ b/submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift @@ -46,7 +46,7 @@ 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, _), let .textSpoiler(value): + 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, _), let .textSpoiler(value), let .textDate(value, _, _): return value.previewText() } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index 0a13e2fede..468f35c942 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -58,6 +58,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode // to request a full bubble re-layout (so the bubble grows with the reveal). private var lastAppliedRevealedCount: Int = 0 private var displayContentsUnderSpoilers: Bool = false + private var relativeDateTimer: (timer: SwiftSignalKit.Timer, period: Int32)? override public var visibility: ListViewItemNodeVisibility { didSet { @@ -177,6 +178,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode deinit { self.linkProgressDisposable?.dispose() + self.relativeDateTimer?.timer.invalidate() } override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { @@ -360,7 +362,14 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode current.boundingWidth == suggestedBoundingWidth, current.presentationThemeIdentity == presentationThemeIdentity, current.expandedDetails == currentExpandedDetails, - current.messageStableVersion == currentMessageStableVersion { + current.messageStableVersion == currentMessageStableVersion, + current.layout.formattedDateUpdatePeriod == nil { + // Reuse the cached layout only when it has no relative `textDate`. A relative + // date's formatted string ("N minutes ago") is baked into the laid-out text at + // layout time, and none of the cache-key inputs change as wall-clock advances — + // so reusing it would freeze the date and defeat the refresh timer (which fires + // `requestFullUpdate` precisely to re-run `layoutInstantPageV2` → `formatDate`). + // Forcing a recompute for relative-date pages keeps the timer's tick visible. pageLayout = current.layout } else { #if DEBUG && false @@ -705,6 +714,26 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode self.pageViewMessageKey = nil } + if let formattedDateUpdatePeriod = pageLayout?.formattedDateUpdatePeriod { + // Recreate the timer only when the period changes — unlike the TextBubble + // reference (ChatMessageTextBubbleContentNode), which rebuilds it every apply. + // The timer fires `requestFullUpdate`, which relays out and re-enters here; at + // a steady period this guard is false, so the running timer keeps its schedule + // instead of being reallocated (no per-apply churn, no firing-phase reset, no + // self-trigger loop). Do not "simplify" this to match the reference. + if self.relativeDateTimer?.period != formattedDateUpdatePeriod { + self.relativeDateTimer?.timer.invalidate() + let timer = SwiftSignalKit.Timer(timeout: Double(formattedDateUpdatePeriod), repeat: true, completion: { [weak self] in + self?.requestFullUpdate?(ControlledTransition(duration: 0.15, curve: .easeInOut, interactive: false)) + }, queue: Queue.mainQueue()) + self.relativeDateTimer = (timer, formattedDateUpdatePeriod) + timer.start() + } + } else if let (timer, _) = self.relativeDateTimer { + self.relativeDateTimer = nil + timer.invalidate() + } + // === Streaming state apply === // 1. Compute / cache the cost map. @@ -965,6 +994,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode return .hashtag(hashtag.peerName, hashtag.hashtag) } else if let bankCard = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BankCard)] as? String { return .bankCard(bankCard) + } else if let date = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Date)] as? Int32 { + // The displayed string is unused downstream (ChatMessageBubbleItemNode matches `.date(date, _)`). + return .date(date, "") } return nil }