Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios
This commit is contained in:
commit
248ee44e30
14 changed files with 291 additions and 105 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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.
|
||||
|
|
@ -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 32–88)
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue