RichText.textSpoiler: dust overlay + tap-to-reveal in rich-data bubbles

Add the RichText.textSpoiler case end-to-end: model + Postbox coding,
FlatBuffers schema/codec, and Api.RichText parsing/serialization (lossless).
Display attaches the spoiler attribute, collects per-line spoiler rects in
the V2 layout, clips obscured glyphs and draws an energy-gated dust overlay
in the V2 text view, propagates the reveal flag across the nested V2 tree
(building dust for freshly-created text views), and wires tap-to-reveal
routing in the rich-data bubble.

Also seeds the streaming reveal cursor across chunk rebuilds and refines
status date positioning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
isaac 2026-05-23 22:12:47 +04:00
parent 99e4c41b24
commit 8b9f498d53
11 changed files with 266 additions and 31 deletions

View file

@ -1958,7 +1958,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:
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler:
return text
}
}
@ -1991,7 +1991,7 @@ private func markdownHasDisplayableContent(_ richText: RichText) -> Bool {
return !latex.isEmpty
case .textCustomEmoji:
return true
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName:
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler:
return true
}
}
@ -2024,7 +2024,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:
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler:
return false
}
}

View file

@ -346,7 +346,7 @@ private func trimStart(_ input: RichText) -> RichText {
break
case .textCustomEmoji:
break
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName:
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler:
break
}
return text
@ -394,7 +394,7 @@ private func trimEnd(_ input: RichText) -> RichText {
break
case .textCustomEmoji:
break
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName:
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler:
break
}
return text
@ -443,7 +443,7 @@ private func trim(_ input: RichText) -> RichText {
break
case .textCustomEmoji:
break
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName:
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler:
break
}
return text
@ -491,7 +491,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:
case .textAutoEmail, .textAutoPhone, .textAutoUrl, .textBankCard, .textBotCommand, .textCashtag, .textHashtag, .textMention, .textMentionName, .textSpoiler:
break
}
return text

View file

@ -122,7 +122,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, _):
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):
return value.previewText()
}
}

View file

@ -19,6 +19,7 @@ swift_library(
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/TelegramUIPreferences:TelegramUIPreferences",
"//submodules/TextFormat:TextFormat",
"//submodules/InvisibleInkDustNode:InvisibleInkDustNode",
"//submodules/TelegramUI/Components/EmojiTextAttachmentView:EmojiTextAttachmentView",
"//submodules/TelegramUI/Components/AnimationCache:AnimationCache",
"//submodules/TelegramUI/Components/MultiAnimationRenderer:MultiAnimationRenderer",

View file

@ -12,6 +12,7 @@ import TextFormat
import EmojiTextAttachmentView
import AnimationCache
import MultiAnimationRenderer
import InvisibleInkDustNode
// MARK: - Stable item identity (for view reuse on re-layouts)
@ -118,6 +119,7 @@ public final class InstantPageV2View: UIView {
}
}
}
public private(set) var displayContentsUnderSpoilers: Bool = false
// Weak references to every media wrapper in the tree, keyed by `InstantPageMedia.index`.
// Used by `transitionArgsFor` and `applyHiddenMedia` so the gallery transition + hidden-source
@ -223,6 +225,18 @@ public final class InstantPageV2View: UIView {
self.currentLayout = layout
self.currentTheme = theme
self.updateInlineEmoji()
let enableSpoilerAnimations = self.renderContext.map { $0.context.sharedContext.energyUsageSettings.fullTranslucency } ?? true
for view in self.itemViews {
if let textView = view as? InstantPageV2TextView {
textView.enableSpoilerAnimations = enableSpoilerAnimations
// makeItemView builds fresh text views via init only (no update(item:theme:)), so
// build their dust here; updateSpoiler is idempotent (no-op when there are no spoilers).
textView.updateSpoiler(animated: false)
}
}
// Force the current reveal state (true OR false) onto every text view every layout, so a
// positionally-reused text view cannot retain a stale reveal flag from prior content.
self.setDisplayContentsUnderSpoilers(self.displayContentsUnderSpoilers, atLocation: nil, animated: false)
}
func updateInlineEmoji() {
@ -376,6 +390,23 @@ public final class InstantPageV2View: UIView {
}
}
/// Reveals (or re-hides) every spoiler in this view and nested details/table V2 views.
/// `location` (this view's coords) drives the dust explosion origin for whichever text view
/// contains it; the rest just toggle. Single-flag behavior mirrors ChatMessageTextBubbleContentNode.
public func setDisplayContentsUnderSpoilers(_ value: Bool, atLocation location: CGPoint?, animated: Bool) {
self.displayContentsUnderSpoilers = value
for view in self.itemViews {
if let textView = view as? InstantPageV2TextView {
let childLocation = location.map { self.convert($0, to: textView) }
textView.setDisplayContentsUnderSpoilers(value, atLocation: childLocation, animated: animated)
}
for nested in InstantPageV2View.nestedV2Views(of: view) {
let childLocation = location.map { self.convert($0, to: nested) }
nested.setDisplayContentsUnderSpoilers(value, atLocation: childLocation, animated: animated)
}
}
}
private static func nestedV2Views(of view: InstantPageItemView) -> [InstantPageV2View] {
if let detailsView = view as? InstantPageV2DetailsView {
return detailsView.bodyView.map { [$0] } ?? []
@ -649,6 +680,18 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
private let renderContainer: UIView
private let renderView: TextRenderView
let emojiContainerView: UIView = UIView()
let spoilerContainerView: UIView = UIView()
private var dustNode: InvisibleInkDustNode?
private var displayContentsUnderSpoilers: Bool = false
var enableSpoilerAnimations: Bool = true {
didSet {
if oldValue != self.enableSpoilerAnimations, let dustNode = self.dustNode {
self.dustNode = nil
dustNode.view.removeFromSuperview()
self.updateSpoiler(animated: false)
}
}
}
// Reveal mask state populated in Task 5.
private var maxCharacterDrawCount: Int?
@ -676,6 +719,10 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
self.emojiContainerView.frame = self.bounds
self.emojiContainerView.isUserInteractionEnabled = false
self.addSubview(self.emojiContainerView)
self.spoilerContainerView.frame = self.bounds
self.spoilerContainerView.isUserInteractionEnabled = false
self.addSubview(self.spoilerContainerView)
}
@available(*, unavailable)
@ -689,6 +736,75 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
self.renderView.item = item
self.renderView.setNeedsDisplay()
self.emojiContainerView.frame = self.bounds
self.spoilerContainerView.frame = self.bounds
self.renderView.displayContentsUnderSpoilers = self.displayContentsUnderSpoilers
self.updateSpoiler(animated: false)
}
private func spoilerRectsInContainer() -> [CGRect] {
let textItem = self.item.textItem
let boundsWidth = textItem.frame.size.width
var rects: [CGRect] = []
for line in textItem.lines {
if line.spoilerItems.isEmpty { continue }
let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment)
for spoiler in line.spoilerItems {
let localX = lineFrame.minX + (spoiler.frame.minX - line.frame.minX)
rects.append(CGRect(x: localX + v2TextViewClippingInset, y: spoiler.frame.minY + v2TextViewClippingInset, width: spoiler.frame.width, height: spoiler.frame.height))
}
}
return rects
}
func updateSpoiler(animated: Bool) {
let rects = self.spoilerRectsInContainer()
if rects.isEmpty || self.displayContentsUnderSpoilers {
if let dustNode = self.dustNode {
self.dustNode = nil
dustNode.view.removeFromSuperview()
}
return
}
let dustNode: InvisibleInkDustNode
if let current = self.dustNode {
dustNode = current
} else {
dustNode = InvisibleInkDustNode(textNode: nil, enableAnimations: self.enableSpoilerAnimations)
self.dustNode = dustNode
self.spoilerContainerView.addSubview(dustNode.view)
}
var color: UIColor = UIColor(white: 0.0, alpha: 1.0)
let textItem = self.item.textItem
if let firstRange = textItem.lines.first(where: { !$0.spoilerItems.isEmpty })?.spoilerItems.first?.range, firstRange.location < textItem.attributedString.length {
if let fg = textItem.attributedString.attribute(.foregroundColor, at: firstRange.location, effectiveRange: nil) as? UIColor {
color = fg
}
}
dustNode.view.frame = self.spoilerContainerView.bounds
dustNode.update(size: self.spoilerContainerView.bounds.size, color: color, textColor: color, rects: rects, wordRects: rects)
}
func setDisplayContentsUnderSpoilers(_ value: Bool, atLocation location: CGPoint?, animated: Bool) {
if self.displayContentsUnderSpoilers == value {
return
}
self.displayContentsUnderSpoilers = value
self.renderView.displayContentsUnderSpoilers = value
self.renderView.setNeedsDisplay()
if value {
if let dustNode = self.dustNode {
if let location, animated {
dustNode.revealAtLocation(self.convert(location, to: self.spoilerContainerView))
} else {
dustNode.update(revealed: true, animated: animated)
}
}
} else {
self.updateSpoiler(animated: animated)
}
}
func updateRevealCharacterCount(value: Int?, animated: Bool) {
@ -997,6 +1113,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
private final class TextRenderView: UIView {
var item: InstantPageV2TextItem
var displayContentsUnderSpoilers: Bool = false
init(item: InstantPageV2TextItem) {
self.item = item
@ -1018,6 +1135,31 @@ private final class TextRenderView: UIView {
context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0)
context.translateBy(x: v2TextViewClippingInset, y: v2TextViewClippingInset)
if !self.displayContentsUnderSpoilers {
let textItemForClip = self.item.textItem
let clipBoundsWidth = textItemForClip.frame.size.width
var spoilerRects: [CGRect] = []
for line in textItemForClip.lines {
if line.spoilerItems.isEmpty { continue }
let lineFrame = v2FrameForLine(line, boundingWidth: clipBoundsWidth, alignment: textItemForClip.alignment)
for spoiler in line.spoilerItems {
spoilerRects.append(spoiler.frame.offsetBy(dx: lineFrame.minX - line.frame.minX, dy: 0.0))
}
}
if !spoilerRects.isEmpty {
// Even-odd subtracts each spoiler rect from the full-bounds rect. Spoiler rects never
// overlap (same-line ranges are horizontally monotonic; cross-line rects differ in y),
// so no hole is accidentally re-included by an odd crossing count.
let path = CGMutablePath()
path.addRect(CGRect(origin: .zero, size: textItemForClip.frame.size).insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset))
for rect in spoilerRects {
path.addRect(rect)
}
context.addPath(path)
context.clip(using: .evenOdd)
}
}
let textItem = self.item.textItem
let boundsWidth = textItem.frame.size.width
let intersectRect = rect.offsetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset)

View file

@ -29,6 +29,11 @@ struct InstantPageTextMarkedItem {
let range: NSRange
}
struct InstantPageTextSpoilerItem {
let frame: CGRect
let range: NSRange
}
struct InstantPageTextStrikethroughItem {
let frame: CGRect
}
@ -82,6 +87,7 @@ public final class InstantPageTextLine {
let strikethroughItems: [InstantPageTextStrikethroughItem]
let underlineItems: [InstantPageTextUnderlineItem]
let markedItems: [InstantPageTextMarkedItem]
let spoilerItems: [InstantPageTextSpoilerItem]
let imageItems: [InstantPageTextImageItem]
let formulaItems: [InstantPageTextFormulaRun]
let emojiItems: [InstantPageTextEmojiItem]
@ -89,13 +95,14 @@ public final class InstantPageTextLine {
let isRTL: Bool
public let characterRects: [CGRect]? // line-local, one rect per character in `range`; nil = not computed
init(line: CTLine, range: NSRange, frame: CGRect, strikethroughItems: [InstantPageTextStrikethroughItem], underlineItems: [InstantPageTextUnderlineItem], markedItems: [InstantPageTextMarkedItem], imageItems: [InstantPageTextImageItem], formulaItems: [InstantPageTextFormulaRun], emojiItems: [InstantPageTextEmojiItem] = [], anchorItems: [InstantPageTextAnchorItem], isRTL: Bool, characterRects: [CGRect]? = nil) {
init(line: CTLine, range: NSRange, frame: CGRect, strikethroughItems: [InstantPageTextStrikethroughItem], underlineItems: [InstantPageTextUnderlineItem], markedItems: [InstantPageTextMarkedItem], spoilerItems: [InstantPageTextSpoilerItem] = [], imageItems: [InstantPageTextImageItem], formulaItems: [InstantPageTextFormulaRun], emojiItems: [InstantPageTextEmojiItem] = [], anchorItems: [InstantPageTextAnchorItem], isRTL: Bool, characterRects: [CGRect]? = nil) {
self.line = line
self.range = range
self.frame = frame
self.strikethroughItems = strikethroughItems
self.underlineItems = underlineItems
self.markedItems = markedItems
self.spoilerItems = spoilerItems
self.imageItems = imageItems
self.formulaItems = formulaItems
self.emojiItems = emojiItems
@ -952,6 +959,13 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt
ChatTextInputAttributes.customEmoji: emojiAttribute
], range: NSMakeRange(0, mutableAttributedString.length))
return mutableAttributedString
case let .textSpoiler(text):
let result = attributedStringForRichText(text, styleStack: styleStack, url: url)
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
}
}

View file

@ -2688,6 +2688,7 @@ func layoutTextItem(
var strikethroughItems: [InstantPageTextStrikethroughItem] = []
var underlineItems: [InstantPageTextUnderlineItem] = []
var markedItems: [InstantPageTextMarkedItem] = []
var spoilerItems: [InstantPageTextSpoilerItem] = []
var anchorItems: [InstantPageTextAnchorItem] = []
string.enumerateAttributes(in: lineRange, options: []) { attributes, range, _ in
@ -2720,6 +2721,12 @@ func layoutTextItem(
let x = lowerX < upperX ? lowerX : upperX
markedItems.append(InstantPageTextMarkedItem(frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y + (lineAscent - fontLineHeight) + delta, width: abs(upperX - lowerX), height: lineHeight), color: color, range: range))
}
if attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)] != nil {
let lowerX = floor(CTLineGetOffsetForStringIndex(line, range.location, nil))
let upperX = ceil(CTLineGetOffsetForStringIndex(line, range.location + range.length, nil))
let x = lowerX < upperX ? lowerX : upperX
spoilerItems.append(InstantPageTextSpoilerItem(frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y + (lineAscent - fontLineHeight), width: abs(upperX - lowerX), height: fontLineHeight), range: range))
}
if let item = attributes[NSAttributedString.Key.init(rawValue: InstantPageAnchorAttribute)] as? Dictionary<String, Any>, let name = item["name"] as? String, let empty = item["empty"] as? Bool {
anchorItems.append(InstantPageTextAnchorItem(name: name, anchorText: item["text"] as? NSAttributedString, empty: empty))
}
@ -2810,7 +2817,7 @@ func layoutTextItem(
} else {
lineCharacterRects = nil
}
let textLine = InstantPageTextLine(line: line, range: lineRange, frame: CGRect(x: workingLineOrigin.x, y: workingLineOrigin.y, width: lineWidth, height: height), strikethroughItems: strikethroughItems, underlineItems: underlineItems, markedItems: markedItems, imageItems: lineImageItems, formulaItems: lineFormulaItems, emojiItems: lineEmojiItems, anchorItems: anchorItems, isRTL: isRTL, characterRects: lineCharacterRects)
let textLine = InstantPageTextLine(line: line, range: lineRange, frame: CGRect(x: workingLineOrigin.x, y: workingLineOrigin.y, width: lineWidth, height: height), strikethroughItems: strikethroughItems, underlineItems: underlineItems, markedItems: markedItems, spoilerItems: spoilerItems, imageItems: lineImageItems, formulaItems: lineFormulaItems, emojiItems: lineEmojiItems, anchorItems: anchorItems, isRTL: isRTL, characterRects: lineCharacterRects)
lines.append(textLine)
imageItems.append(contentsOf: lineImageItems)

View file

@ -30,7 +30,8 @@ union RichText_Value {
RichText_Cashtag,
RichText_Hashtag,
RichText_Mention,
RichText_MentionName
RichText_MentionName,
RichText_Spoiler
}
table RichText {
@ -150,3 +151,7 @@ table RichText_MentionName {
text:RichText (id: 0, required);
peerId:long (id: 1);
}
table RichText_Spoiler {
text:RichText (id: 0, required);
}

View file

@ -79,10 +79,8 @@ extension RichText {
self = .textMention(text: RichText(apiText: textMentionData.text))
case let .textMentionName(textMentionNameData):
self = .textMentionName(text: RichText(apiText: textMentionNameData.text), peerId: textMentionNameData.userId)
case let .textSpoiler(value):
let _ = value
//TODO:localize
self = .plain("")
case let .textSpoiler(textSpoilerData):
self = .textSpoiler(text: RichText(apiText: textSpoilerData.text))
}
}
@ -142,6 +140,8 @@ extension RichText {
return .textMention(Api.RichText.Cons_textMention(text: text.apiRichText()))
case let .textMentionName(text, peerId):
return .textMentionName(Api.RichText.Cons_textMentionName(text: text.apiRichText(), userId: peerId))
case let .textSpoiler(text):
return .textSpoiler(Api.RichText.Cons_textSpoiler(text: text.apiRichText()))
}
}
}

View file

@ -30,6 +30,7 @@ private enum RichTextTypes: Int32 {
case textHashtag = 24
case textMention = 25
case textMentionName = 26
case textSpoiler = 27
}
public indirect enum RichText: PostboxCoding, Equatable {
@ -60,6 +61,7 @@ public indirect enum RichText: PostboxCoding, Equatable {
case textHashtag(text: RichText)
case textMention(text: RichText)
case textMentionName(text: RichText, peerId: Int64)
case textSpoiler(text: RichText)
public init(decoder: PostboxDecoder) {
switch decoder.decodeInt32ForKey("r", orElse: 0) {
@ -123,6 +125,8 @@ public indirect enum RichText: PostboxCoding, Equatable {
self = .textMention(text: decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText)
case RichTextTypes.textMentionName.rawValue:
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)
default:
self = .empty
}
@ -226,6 +230,9 @@ public indirect enum RichText: PostboxCoding, Equatable {
encoder.encodeInt32(RichTextTypes.textMentionName.rawValue, forKey: "r")
encoder.encodeObject(text, forKey: "t")
encoder.encodeInt64(peerId, forKey: "mn.p")
case let .textSpoiler(text):
encoder.encodeInt32(RichTextTypes.textSpoiler.rawValue, forKey: "r")
encoder.encodeObject(text, forKey: "t")
}
}
@ -357,6 +364,8 @@ public indirect enum RichText: PostboxCoding, Equatable {
if case .textMention(text) = rhs { return true } else { return false }
case let .textMentionName(lhsText, lhsPeerId):
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 }
}
}
}
@ -422,6 +431,8 @@ public extension RichText {
return text.plainText
case let .textMentionName(text, _):
return text.plainText
case let .textSpoiler(text):
return text.plainText
}
}
}
@ -564,6 +575,11 @@ extension RichText {
throw FlatBuffersError.missingRequiredField()
}
self = .textMentionName(text: try RichText(flatBuffersObject: value.text), peerId: value.peerId)
case .richtextSpoiler:
guard let value = flatBuffersObject.value(type: TelegramCore_RichText_Spoiler.self) else {
throw FlatBuffersError.missingRequiredField()
}
self = .textSpoiler(text: try RichText(flatBuffersObject: value.text))
case .none_:
self = .empty
}
@ -748,6 +764,12 @@ extension RichText {
TelegramCore_RichText_MentionName.add(text: textOffset, &builder)
TelegramCore_RichText_MentionName.add(peerId: peerId, &builder)
offset = TelegramCore_RichText_MentionName.endRichText_MentionName(&builder, start: start)
case let .textSpoiler(text):
valueType = .richtextSpoiler
let textOffset = text.encodeToFlatBuffers(builder: &builder)
let start = TelegramCore_RichText_Spoiler.startRichText_Spoiler(&builder)
TelegramCore_RichText_Spoiler.add(text: textOffset, &builder)
offset = TelegramCore_RichText_Spoiler.endRichText_Spoiler(&builder, start: start)
}
return TelegramCore_RichText.createRichText(&builder, valueType: valueType, valueOffset: offset)

View file

@ -59,6 +59,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
// compares the revealed prefix's height at this cursor vs the new cursor to decide when
// to request a full bubble re-layout (so the bubble grows with the reveal).
private var lastAppliedRevealedCount: Int = 0
private var displayContentsUnderSpoilers: Bool = false
override public var visibility: ListViewItemNodeVisibility {
didSet {
@ -549,7 +550,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
// through to the contentSize.height anchor and sits below all content.
let lastTextLine = pageLayout.flatMap(InstantPageUI.lastTextLineFrameIfLastItemIsText(in:))
let lastTextLineFrame: CGRect? = lastTextLine?.frame
// Applied only when the date trails on the line (not when it wraps below it).
// Baseline visible-text-bottom compensation. Applied whether the date trails on
// the last line or wraps onto its own line below it (0 for attachment-inflated lines,
// whose maxY already sits at the visible bottom).
let lastTextLineTrailingPadding: CGFloat = lastTextLine?.trailingBottomPadding ?? 0.0
var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode))?
@ -605,11 +608,13 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
// closure below.
let statusAnchorY: CGFloat
if let lastTextLineFrame {
// Trailing date (statusHeight == 0) gets the 5pt text-rect pad so it
// aligns with the visible text; a date that wraps onto its own line
// below (statusHeight > 0) sits at the bare maxY.
let trailingPadding = statusSizeAndApply.0.height.isZero ? lastTextLineTrailingPadding : 0.0
statusAnchorY = 1.0 + lastTextLineFrame.maxY + trailingPadding + streamingHeaderOffset
// The renderer draws the baseline at the line frame's maxY, so the
// visible text sits `trailingBottomPadding` below it. Apply that pad
// whether the date trails on the line OR wraps onto its own line below:
// in both cases the date should reference the visible text bottom, not
// the baseline (mirrors TextBubble, whose status anchors at the text
// frame's maxY). Without it the wrapped date crowded the last line.
statusAnchorY = 1.0 + lastTextLineFrame.maxY + lastTextLineTrailingPadding + streamingHeaderOffset
} else if let pageLayout {
statusAnchorY = 1.0 + pageLayout.contentSize.height + streamingHeaderOffset
} else {
@ -637,11 +642,11 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
// node positions the date trailing/below relative to this origin.
let statusFrameY: CGFloat
if let lastTextLineFrame {
// Add the 5pt text-rect pad only when the date trails on the line
// (statusHeight == 0); a date wrapped onto its own line below sits
// at the bare maxY so it isn't pushed too far down.
let trailingPadding = statusSizeAndApply.0.height.isZero ? lastTextLineTrailingPadding : 0.0
statusFrameY = 1.0 + lastTextLineFrame.maxY + trailingPadding
// Apply the text-rect pad (baseline visible text bottom) for both
// the trailing and wrapped cases, so the date references the visible
// text bottom rather than the baseline. Mirrors the measure closure
// and TextBubble. Without it the wrapped date crowded the last line.
statusFrameY = 1.0 + lastTextLineFrame.maxY + lastTextLineTrailingPadding
} else if let pageLayout {
statusFrameY = 1.0 + pageLayout.contentSize.height
} else {
@ -705,6 +710,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
size: pageLayout.contentSize
)
self.updatePageViewVisibilityRect()
if self.displayContentsUnderSpoilers {
pageView.setDisplayContentsUnderSpoilers(true, atLocation: nil, animated: false)
}
} else {
self.currentPageLayout = nil
self.pageView?.update(
@ -719,8 +727,17 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
// 1. Compute / cache the cost map.
// Reuse the cost map computed in the layout pass (the bubble's
// size depended on it) don't recompute.
self.currentRevealCostMap = revealCostMap
// size depended on it) don't recompute. Keep the previous map
// alive while a reveal/finalize is still in flight: on a post-
// streaming pass (hasDraft && hadDraft both false) revealCostMap is
// nil, and clobbering it would strand the display-link tick (whose
// guard requires a cost map), aborting the finalize before it can
// clear the mask and restore the status alpha.
if let revealCostMap {
self.currentRevealCostMap = revealCostMap
} else if self.textRevealController == nil {
self.currentRevealCostMap = nil
}
// 2. Update the "Thinking" header.
if let streamingTextFrame, let streamingTextLayoutAndApply {
@ -785,11 +802,16 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
if hasDraft {
self.statusNode?.alpha = 0.0
}
// Seed the V2 view to the previous count so we don't flash full text at the start.
self.pageView?.applyReveal(revealedCount: previousAnimateGlyphCount ?? 0,
// Seed the (possibly freshly rebuilt) V2 view to the reveal cursor's
// current position so we don't flash full text. Use the live controller
// count rather than `previousAnimateGlyphCount`, which is nil and would
// reset the reveal to 0 on post-streaming finalize passes where the
// controller is still animating.
let seedCount = self.textRevealController?.currentGlyphCount ?? previousAnimateGlyphCount ?? 0
self.pageView?.applyReveal(revealedCount: seedCount,
costMap: self.currentRevealCostMap,
animated: false)
self.lastAppliedRevealedCount = previousAnimateGlyphCount ?? 0
self.lastAppliedRevealedCount = seedCount
self.updateTextRevealAnimation(previousGlyphCount: previousAnimateGlyphCount ?? 0,
hasDraft: hasDraft,
hadDraft: hadDraft)
@ -826,6 +848,13 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
self.textRevealLink = nil
self.pageView?.applyReveal(revealedCount: nil, costMap: nil, animated: false)
self.lastAppliedRevealedCount = 0
// The cursor already caught up at finalize time, so the display-link `isComplete`
// branch (which normally restores the status alpha) will never run. Restore it
// here too, mirroring that branch.
if let item = self.item, let statusNode = self.statusNode,
!item.message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) {
ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut).updateAlpha(node: statusNode, alpha: 1.0)
}
return
}
@ -906,6 +935,12 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
}
}
if case .tap = gesture, !self.displayContentsUnderSpoilers, let entityHit = self.entityForTapLocation(point), entityHit.attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)] != nil {
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
self?.revealSpoilers(atContentPoint: point)
}))
}
guard let urlHit = self.urlForTapLocation(point) else {
if let entityHit = self.entityForTapLocation(point), let content = self.entityTapContent(entityHit.attributes) {
let rects = self.computeHighlightRects(item: entityHit.item, parentOffset: entityHit.parentOffset, localPoint: entityHit.localPoint)
@ -973,6 +1008,15 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
return (item: hit.item, parentOffset: hit.parentOffset, localPoint: localPoint, attributes: attributes)
}
private func revealSpoilers(atContentPoint point: CGPoint) {
guard !self.displayContentsUnderSpoilers, let pageView = self.pageView else {
return
}
self.displayContentsUnderSpoilers = true
let local = self.view.convert(point, to: pageView)
pageView.setDisplayContentsUnderSpoilers(true, atLocation: local, animated: true)
}
private func entityTapContent(_ attributes: [NSAttributedString.Key: Any]) -> ChatMessageBubbleContentTapAction.Content? {
if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention {
return .peerMention(peerId: mention.peerId, mention: mention.mention, openProfile: false)