From a731f8bdc082b5464fc3b362fe389366fb27e105 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 4 Jun 2026 13:45:48 +0200 Subject: [PATCH 1/5] 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 } From 9dada484aadacd22ceb5841b1a2a81d2fd4e637e Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 4 Jun 2026 15:01:59 +0200 Subject: [PATCH 2/5] InstantPage V2: proper RTL layout for rich messages When a rich message's page-level rtl flag is set, mirror every V2 block ornament and content column onto the trailing (right) edge, gated solely on the explicit flag (no content auto-detection, no send/wire change, V2 only): - Two centralized leading/trailing geometry helpers (instantPageV2ContentColumnX / instantPageV2LeadingEdgeX) as the single source of truth for gutter side. - Lists: content column mirrors to match the marker (8pt trailing gap). - Blockquotes: bar + text gutter on the trailing edge, single- and multi-block (multi-block child band rigid-translated; caption uses its own single-inset delta). -
: chevron + title indented from the right (rtl flag on the item). - Uniform trailing alignment for paragraph/heading/simple-text. - Keep the full bounding width for non-leading alignment so display-time alignment lands at the true edge: .right (RTL text) was collapsing short content to the leading edge under fitToWidth (the list-gap), and .center (pull quotes) was failing to center body + author. Only .natural shrinks. LTR output is unchanged (.right/.center are only produced under RTL contexts / pull quotes). Builds clean; runtime-verified on an rtl message with lists, blockquotes, pull quotes, and inline formatting. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/InstantPageRenderer.swift | 2 +- .../Sources/InstantPageV2Layout.swift | 65 +++++++++++++++---- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index 243a3630f5..b0400327d5 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -1779,7 +1779,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView { let chevronSize = CGSize(width: 18.0, height: 18.0) self.chevronView.bounds = CGRect(origin: .zero, size: chevronSize) self.chevronView.center = CGPoint( - x: item.sideInset + chevronSize.width / 2.0, + x: item.rtl ? (item.frame.width - item.sideInset - chevronSize.width / 2.0) : (item.sideInset + chevronSize.width / 2.0), y: item.titleFrame.midY + 1.0 ) diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index e57bd10e90..ed1062510c 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -338,6 +338,7 @@ public struct InstantPageV2DetailsItem { public let isExpanded: Bool public let innerLayout: InstantPageV2Layout? public let defaultExpanded: Bool // from the InstantPageBlock model + public let rtl: Bool // mirror chevron + title onto the trailing edge } public enum InstantPageV2TableVerticalAlignment { @@ -1103,7 +1104,9 @@ private func layoutDetails( guard let titleTextItem = titleTextItem else { return [] } let titleHeight = max(44.0, titleTextItem.frame.height + 26.0) - titleTextItem.frame.origin.x = horizontalInset + 23.0 + titleTextItem.frame.origin.x = context.rtl + ? (boundingWidth - horizontalInset - 23.0 - titleTextItem.frame.width) + : (horizontalInset + 23.0) titleTextItem.frame.origin.y = floorToScreenPixels((titleHeight - titleTextItem.frame.height) * 0.5) let isExpanded = context.expandedDetails[index] ?? defaultExpanded @@ -1134,7 +1137,8 @@ private func layoutDetails( separatorColor: context.theme.separatorColor, isExpanded: isExpanded, innerLayout: innerLayout, - defaultExpanded: defaultExpanded + defaultExpanded: defaultExpanded, + rtl: context.rtl ) return [.details(item)] } @@ -2038,6 +2042,7 @@ private func layoutSimpleText( let (_, items, _) = layoutTextItem( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, + alignment: context.rtl ? .right : .natural, offset: CGPoint(x: horizontalInset, y: 0.0), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects @@ -2058,6 +2063,7 @@ private func layoutHeading( let (_, items, _) = layoutTextItem( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, + alignment: context.rtl ? .right : .natural, offset: CGPoint(x: horizontalInset, y: 0.0), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects @@ -2083,6 +2089,7 @@ private func layoutParagraph( let (_, items, _) = layoutTextItem( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, + alignment: context.rtl ? .right : .natural, offset: CGPoint(x: horizontalInset, y: 0.0), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects @@ -2306,6 +2313,10 @@ private func layoutBlockQuote( let innerBoundingWidth = boundingWidth - horizontalInset * 2.0 - lineInset let innerHorizontalInset = horizontalInset + lineInset + // RTL: rigid-translate the child band so its gutter (lineInset) lands on the trailing edge, + // faithfully mirroring the existing (intentionally preserved) LTR band. Width is preserved, + // so a single x-delta moves the whole band correctly. + let bandOffsetX: CGFloat = context.rtl ? (2.0 * horizontalInset + lineInset) : 0.0 var result: [InstantPageV2LaidOutItem] = [] var contentHeight: CGFloat = verticalInset @@ -2327,7 +2338,7 @@ private func layoutBlockQuote( context: &context ) let dy = contentHeight + spacing - let offsetItems = childItems.map { $0.offsetBy(CGPoint(x: 0.0, y: dy)) } + let offsetItems = childItems.map { $0.offsetBy(CGPoint(x: bandOffsetX, y: dy)) } let childMaxY = offsetItems.map { $0.frame.maxY }.max() ?? dy contentHeight = max(contentHeight, childMaxY) result.append(contentsOf: offsetItems) @@ -2344,8 +2355,11 @@ private func layoutBlockQuote( let (_, captionItems, captionSize) = layoutTextItem( attributedCaption, boundingWidth: innerBoundingWidth, - alignment: .natural, - offset: CGPoint(x: innerHorizontalInset, y: contentHeight), + alignment: context.rtl ? .right : .natural, + // The caption is single-inset (band [H+lineInset, B-H]), unlike the double-inset + // child band, so it needs its own RTL mirror delta of -lineInset (→ [H, B-H-lineInset], + // tucked under the trailing bar) — NOT the children's bandOffsetX. + offset: CGPoint(x: innerHorizontalInset + (context.rtl ? -lineInset : 0.0), y: contentHeight), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) @@ -2357,7 +2371,7 @@ private func layoutBlockQuote( // Vertical bar on the leading edge (matches the blockQuote branch of layoutQuoteText). let bar = InstantPageV2BarItem( - frame: CGRect(x: horizontalInset, y: 0.0, width: barWidth, height: contentHeight), + frame: CGRect(x: instantPageV2LeadingEdgeX(boundingWidth: boundingWidth, horizontalInset: horizontalInset, elementWidth: barWidth, rtl: context.rtl), y: 0.0, width: barWidth, height: contentHeight), color: context.theme.textCategories.paragraph.color, cornerRadius: barWidth / 2.0 ) @@ -2408,8 +2422,8 @@ private func layoutQuoteText( // Body text (V1 line 528 / 562). let textBoundingWidth = boundingWidth - horizontalInset * 2.0 - lineInset - let textX: CGFloat = horizontalInset + lineInset - let textAlignment: NSTextAlignment = isPull ? .center : .natural + let textX: CGFloat = instantPageV2ContentColumnX(horizontalInset: horizontalInset, gutter: lineInset, rtl: context.rtl) + let textAlignment: NSTextAlignment = isPull ? .center : (context.rtl ? .right : .natural) let attributedBody = attributedStringForRichText(text, styleStack: styleStack, formatDate: context.formatDate) let (_, bodyItems, bodySize) = layoutTextItem( @@ -2463,7 +2477,7 @@ private func layoutQuoteText( // V1 shape: .roundLine (rounded caps) → cornerRadius = barWidth / 2 = 1.5. let barWidth: CGFloat = 3.0 // V1 line 547 let bar = InstantPageV2BarItem( - frame: CGRect(x: horizontalInset, y: 0.0, width: barWidth, height: contentHeight), + frame: CGRect(x: instantPageV2LeadingEdgeX(boundingWidth: boundingWidth, horizontalInset: horizontalInset, elementWidth: barWidth, rtl: context.rtl), y: 0.0, width: barWidth, height: contentHeight), color: context.theme.textCategories.paragraph.color, // V1 line 547 cornerRadius: barWidth / 2.0 // V1 .roundLine ≈ half-width rounded caps ) @@ -2612,11 +2626,12 @@ private func layoutList( let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false) let attrStr = attributedStringForRichText(text, styleStack: styleStack, formatDate: context.formatDate) - let textX = horizontalInset + indexSpacing + maxIndexWidth + let textX = instantPageV2ContentColumnX(horizontalInset: horizontalInset, gutter: indexSpacing + maxIndexWidth, rtl: context.rtl) let textWidth = boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth let (textItem, textLaidOutItems, textSize) = layoutTextItem( attrStr, boundingWidth: textWidth, + alignment: context.rtl ? .right : .natural, offset: CGPoint(x: textX, y: contentHeight), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects @@ -2672,7 +2687,7 @@ private func layoutList( ) let subLocalMaxY: CGFloat = subItems.map { $0.frame.maxY }.max() ?? 0.0 let spacing: CGFloat = (previousBlock != nil && subLocalMaxY > 0.0) ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: context.fitToWidth, kind: .list) : 0.0 - let offsetX = horizontalInset + indexSpacing + maxIndexWidth + let offsetX = instantPageV2ContentColumnX(horizontalInset: horizontalInset, gutter: indexSpacing + maxIndexWidth, rtl: context.rtl) let offsetY = contentHeight + spacing let translatedItems = subItems.map { $0.offsetBy(CGPoint(x: offsetX, y: offsetY)) } @@ -2779,6 +2794,26 @@ private func markerFrameFor( return CGRect(x: x, y: floorToScreenPixels(lineMidY - size.height / 2.0), width: size.width, height: size.height) } +/// Leading/trailing geometry helpers — the single source of truth for "which side is the +/// block gutter on", gated on the page's explicit `rtl` flag. The `rtl == false` branch returns +/// the pre-existing literal so non-RTL pages are byte-identical. + +/// X origin of a block's content column, given a leading gutter of width `gutter` +/// (the marker column, or the quote bar+inset band). Column width is unchanged either way. +/// LTR: content sits after the gutter → horizontalInset + gutter +/// RTL: content sits at the inset; the gutter is mirrored onto the trailing edge → horizontalInset +func instantPageV2ContentColumnX(horizontalInset: CGFloat, gutter: CGFloat, rtl: Bool) -> CGFloat { + return rtl ? horizontalInset : horizontalInset + gutter +} + +/// X origin of a leading-edge element of width `elementWidth` (e.g. the quote bar), hugging the +/// trailing edge of the gutter band in RTL. +/// LTR: horizontalInset +/// RTL: boundingWidth - horizontalInset - elementWidth +func instantPageV2LeadingEdgeX(boundingWidth: CGFloat, horizontalInset: CGFloat, elementWidth: CGFloat, rtl: Bool) -> CGFloat { + return rtl ? (boundingWidth - horizontalInset - elementWidth) : horizontalInset +} + // MARK: - Style helpers (ported from V1 InstantPageLayout.swift lines 32–88) private func setupStyleStack(_ stack: InstantPageTextStyleStack, theme: InstantPageTheme, attributes: InstantPageTextAttributes) { @@ -3378,7 +3413,13 @@ func layoutTextItem( } var textWidth = boundingWidth - if fitToWidth { + // Shrinking the box to content width anchors it at the leading `offset.x`, which makes any + // non-leading display-time alignment a no-op (the block stays pinned to the leading edge and + // only redistributes internally). Only `.natural` is leading-anchored; `.right` (RTL text) + // and `.center` (pull quotes) must keep the full bounding width so `v2FrameForLine` lands + // each line at the true trailing / centered position. `.right`/`.center` reach here only via + // RTL text and pull quotes respectively, so plain LTR `.natural` body text is unaffected. + if fitToWidth && alignment == .natural { textWidth = maxLineWidth } if (!imageItems.isEmpty || hasFormulaItems) && maxLineWidth > boundingWidth + 10.0 { From a4cddb74d6ffa2d16fafbebbdd76057686722351 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 4 Jun 2026 15:19:59 +0200 Subject: [PATCH 3/5] InstantPage V2: inset + round code blocks nested in blockquotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A code block inside a blockquote was pinned to local x=0 / full width, so its background bled out under the quote bar instead of insetting to the quote's content gutter like the quote's text. Detect quote nesting via the raised child inset (threaded as LayoutContext.pageHorizontalInset) and, when nested, inset the background to honor horizontalInset and give it an 8pt rounded corner. Top-level and
code blocks stay flush, full-width, and square (matching V1) — the bubble's own rounded clip handles their edges. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/InstantPageV2Layout.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index ed1062510c..66b4ae7331 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -425,6 +425,7 @@ public func layoutInstantPageV2( rtl: instantPage.rtl, fitToWidth: fitToWidth, computeRevealCharacterRects: computeRevealCharacterRects, + pageHorizontalInset: horizontalInset, mediaIndexCounter: 0, detailsIndexCounter: 0, expandedDetails: expandedDetails @@ -540,6 +541,7 @@ private struct LayoutContext { let rtl: Bool let fitToWidth: Bool let computeRevealCharacterRects: Bool + let pageHorizontalInset: CGFloat var mediaIndexCounter: Int = 0 var detailsIndexCounter: Int = 0 @@ -2192,7 +2194,6 @@ private func layoutCodeBlock( ) -> [InstantPageV2LaidOutItem] { let backgroundInset: CGFloat = 15.0 let textXOffset: CGFloat = 11.0 - let cornerRadius: CGFloat = 0.0 let attributedString: NSAttributedString if let language, !language.isEmpty { @@ -2233,12 +2234,19 @@ private func layoutCodeBlock( height: textItem.frame.height ) - // V1 line 348: block spans full boundingWidth (x=0), height = contentSize.height + backgroundInset*2. + // Top-level (and
) code blocks span the full boundingWidth flush (x=0), matching V1 + // (line 348). Inside a blockquote the child inset is raised above the page inset (by + // lineInset), so honor it here — otherwise the full-width background bleeds out under the + // quote bar instead of insetting to the quote's content gutter like the quote's text does. let blockHeight = textSize.height + backgroundInset * 2.0 + let isNestedInQuote = horizontalInset > context.pageHorizontalInset + // Inset (quote-nested) code blocks get an 8pt rounded background; flush (top-level / details) + // ones stay square — the bubble's own rounded clip handles their edges. + let cornerRadius: CGFloat = isNestedInQuote ? 8.0 : 0.0 let blockFrame = CGRect( - x: 0.0, + x: isNestedInQuote ? horizontalInset : 0.0, y: 0.0, - width: boundingWidth, + width: isNestedInQuote ? (boundingWidth - horizontalInset * 2.0) : boundingWidth, height: blockHeight ) From 0d5b051820fc7de5e7f2a6bc967b5d932d8f0e63 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 4 Jun 2026 15:30:28 +0200 Subject: [PATCH 4/5] InstantPage V2: support nested
blocks A details block nested inside another rendered (title + chevron) but could not expand: its body is hosted by a fresh InstantPageV2View whose detailsTapped closure was never wired (only the top-level view's is, by the bubble), so the nested title's tap hit a nil closure and was dropped. Wire the body view's detailsTapped to chain through this view's onTitleTapped, which makeItemView already routes to the owning view's detailsTapped -> the bubble's toggle handler; this chains up through arbitrary nesting depth. Also make the bubble's defaultExpanded(forDetailsIndex:) recurse into expanded bodies so a nested details whose model default is expanded toggles from the correct state (the flat top-level scan missed nested indices). Known pre-existing limitation (unchanged): V2 keys expansion state by a flat DFS index, so expanding a nested details can shift sibling indices. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/InstantPageRenderer.swift | 7 +++++++ ...ChatMessageRichDataBubbleContentNode.swift | 19 +++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index b0400327d5..23d8d00a07 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -1803,6 +1803,13 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView { self.addSubview(body) self.bodyView = body } + // Forward taps on details NESTED inside this body up to the same toggle handler this + // view uses: makeItemView wired our onTitleTapped to the owning InstantPageV2View's + // detailsTapped, so chaining through onTitleTapped reaches the bubble's toggle handler. + // Without this, a nested details' tap hits the body view's nil detailsTapped and is dropped. + body.detailsTapped = { [weak self] index in + self?.onTitleTapped?(index) + } body.update(layout: innerLayout, theme: theme, animation: animation) body.frame = CGRect( origin: CGPoint(x: 0.0, y: item.titleFrame.maxY), diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index 468f35c942..4a562f581e 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -164,12 +164,23 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode private func defaultExpanded(forDetailsIndex index: Int) -> Bool { guard let layout = self.currentPageLayout?.layout else { return false } - for item in layout.items { - if case let .details(d) = item, d.index == index { - return d.defaultExpanded + func search(_ items: [InstantPageV2LaidOutItem]) -> Bool? { + for item in items { + if case let .details(d) = item { + if d.index == index { + return d.defaultExpanded + } + // Recurse into an expanded parent's body so NESTED details indices resolve too; + // the flat top-level scan missed them, leaving the toggle's "current state" + // computation wrong for a nested details whose model default is expanded. + if let inner = d.innerLayout, let found = search(inner.items) { + return found + } + } } + return nil } - return false + return search(layout.items) ?? false } required public init?(coder aDecoder: NSCoder) { From a5825d5a67692f0c5c4631cf9023b418928f9f98 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 4 Jun 2026 15:54:27 +0200 Subject: [PATCH 5/5] Rich text wip --- .../Sources/InstantPageV2SlideshowView.swift | 2 ++ .../SyncCore_RichTextMessageAttribute.swift | 24 --------------- ...yncCore_StandaloneAccountTransaction.swift | 30 ++++++++++++++++++- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/submodules/InstantPageUI/Sources/InstantPageV2SlideshowView.swift b/submodules/InstantPageUI/Sources/InstantPageV2SlideshowView.swift index 9e0dafa35f..25b963c0f4 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2SlideshowView.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2SlideshowView.swift @@ -39,6 +39,8 @@ final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollVie self.backgroundColor = theme.panelSecondaryColor // structural self.clipsToBounds = true // structural + self.scrollView.disablesInteractiveTransitionGestureRecognizer = true + self.scrollView.isPagingEnabled = true self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.showsVerticalScrollIndicator = false diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichTextMessageAttribute.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichTextMessageAttribute.swift index 6989687dfe..0bcf72afdf 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichTextMessageAttribute.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichTextMessageAttribute.swift @@ -6,34 +6,10 @@ public class RichTextMessageAttribute: MessageAttribute, Equatable { public let instantPage: InstantPage public var associatedPeerIds: [PeerId] { - /*var result: [PeerId] = [] - for entity in entities { - switch entity.type { - case let .TextMention(peerId): - result.append(peerId) - default: - break - } - } - return result*/ return [] } public var associatedMediaIds: [MediaId] { - /*var result: [MediaId] = [] - for entity in self.entities { - switch entity.type { - case let .CustomEmoji(_, fileId): - result.append(MediaId(namespace: Namespaces.Media.CloudFile, id: fileId)) - default: - break - } - } - if result.isEmpty { - return result - } else { - return Array(Set(result)) - }*/ return [] } diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift index cbb1035e4c..ea3445d826 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift @@ -129,8 +129,15 @@ public let telegramPostboxSeedConfiguration: SeedConfiguration = { break } } + var previousRichText: RichTextMessageAttribute? + for attribute in previous { + if let attribute = attribute as? RichTextMessageAttribute { + previousRichText = attribute + break + } + } - if let audioTranscription = audioTranscription { + if let audioTranscription { var found = false for i in 0 ..< updated.count { if let attribute = updated[i] as? AudioTranscriptionMessageAttribute { @@ -155,6 +162,27 @@ public let telegramPostboxSeedConfiguration: SeedConfiguration = { updated.append(previousDerivedData) } } + if let previousRichText, previousRichText.instantPage.isComplete { + for i in 0 ..< updated.count { + if let attribute = updated[i] as? RichTextMessageAttribute { + if !attribute.instantPage.isComplete { + var prefixEquals = true + if attribute.instantPage.blocks.count <= previousRichText.instantPage.blocks.count { + inner: for j in 0 ..< attribute.instantPage.blocks.count { + if attribute.instantPage.blocks[j] != attribute.instantPage.blocks[j] { + prefixEquals = false + break inner + } + } + } + if prefixEquals { + updated[i] = previousRichText + } + } + break + } + } + } }, decodeMessageThreadInfo: { entry in guard let data = entry.get(MessageHistoryThreadData.self) else {