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📅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) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-06-04 13:45:48 +02:00
parent 0b912f26de
commit a731f8bdc0
10 changed files with 172 additions and 59 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 335338: 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(

View file

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

View file

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

View file

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

View file

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

View file

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