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:
parent
99e4c41b24
commit
8b9f498d53
11 changed files with 266 additions and 31 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue