Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios

This commit is contained in:
Ilya Laktyushin 2026-06-04 19:49:10 +02:00
commit 248ee44e30
14 changed files with 291 additions and 105 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

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

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
@ -333,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 {
@ -395,10 +401,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,
@ -406,18 +425,21 @@ public func layoutInstantPageV2(
rtl: instantPage.rtl,
fitToWidth: fitToWidth,
computeRevealCharacterRects: computeRevealCharacterRects,
pageHorizontalInset: horizontalInset,
mediaIndexCounter: 0,
detailsIndexCounter: 0,
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 +525,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]
@ -514,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
@ -1069,7 +1097,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,
@ -1078,7 +1106,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
@ -1109,7 +1139,8 @@ private func layoutDetails(
separatorColor: context.theme.separatorColor,
isExpanded: isExpanded,
innerLayout: innerLayout,
defaultExpanded: defaultExpanded
defaultExpanded: defaultExpanded,
rtl: context.rtl
)
return [.details(item)]
}
@ -1186,7 +1217,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 +1703,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 +1729,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,10 +2040,11 @@ 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,
alignment: context.rtl ? .right : .natural,
offset: CGPoint(x: horizontalInset, y: 0.0),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
@ -2029,10 +2061,11 @@ 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,
alignment: context.rtl ? .right : .natural,
offset: CGPoint(x: horizontalInset, y: 0.0),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
@ -2053,11 +2086,12 @@ 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,
boundingWidth: boundingWidth - horizontalInset * 2.0,
alignment: context.rtl ? .right : .natural,
offset: CGPoint(x: horizontalInset, y: 0.0),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
@ -2125,7 +2159,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),
@ -2160,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 {
@ -2175,7 +2208,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.
@ -2201,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 <details>) 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
)
@ -2236,7 +2276,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
@ -2281,6 +2321,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
@ -2302,7 +2346,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)
@ -2315,12 +2359,15 @@ 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,
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
)
@ -2332,7 +2379,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
)
@ -2383,10 +2430,10 @@ 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)
let attributedBody = attributedStringForRichText(text, styleStack: styleStack, formatDate: context.formatDate)
let (_, bodyItems, bodySize) = layoutTextItem(
attributedBody,
boundingWidth: textBoundingWidth,
@ -2407,7 +2454,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,
@ -2438,7 +2485,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
)
@ -2522,7 +2569,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,12 +2633,13 @@ 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 textX = horizontalInset + indexSpacing + maxIndexWidth
let attrStr = attributedStringForRichText(text, styleStack: styleStack, formatDate: context.formatDate)
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
@ -2647,7 +2695,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)) }
@ -2754,6 +2802,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 3288)
private func setupStyleStack(_ stack: InstantPageTextStyleStack, theme: InstantPageTheme, attributes: InstantPageTextAttributes) {
@ -3353,7 +3421,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 {

View file

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

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

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

View file

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

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 {
@ -163,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) {
@ -177,6 +189,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 +373,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 +725,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 +1005,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
}