diff --git a/submodules/BrowserUI/Sources/BrowserMarkdown.swift b/submodules/BrowserUI/Sources/BrowserMarkdown.swift index 493ab58877..9a9154586b 100644 --- a/submodules/BrowserUI/Sources/BrowserMarkdown.swift +++ b/submodules/BrowserUI/Sources/BrowserMarkdown.swift @@ -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 } } diff --git a/submodules/BrowserUI/Sources/BrowserReadability.swift b/submodules/BrowserUI/Sources/BrowserReadability.swift index c0e55704b0..be9bfa240e 100644 --- a/submodules/BrowserUI/Sources/BrowserReadability.swift +++ b/submodules/BrowserUI/Sources/BrowserReadability.swift @@ -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 diff --git a/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift b/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift index 373836fdd7..049059d932 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift @@ -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() } } diff --git a/submodules/InstantPageUI/BUILD b/submodules/InstantPageUI/BUILD index 26ac164ecc..f94169828a 100644 --- a/submodules/InstantPageUI/BUILD +++ b/submodules/InstantPageUI/BUILD @@ -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", diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index c608993678..19de7e9208 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -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) diff --git a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift index f20e6a806f..bd0c974cdc 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift @@ -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 } } diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index 8e0a8d5374..54ebfb71fe 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -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, 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) diff --git a/submodules/TelegramCore/FlatSerialization/Models/RichText.fbs b/submodules/TelegramCore/FlatSerialization/Models/RichText.fbs index 5fc74ebc0e..a6154e51a7 100644 --- a/submodules/TelegramCore/FlatSerialization/Models/RichText.fbs +++ b/submodules/TelegramCore/FlatSerialization/Models/RichText.fbs @@ -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); +} diff --git a/submodules/TelegramCore/Sources/ApiUtils/RichText.swift b/submodules/TelegramCore/Sources/ApiUtils/RichText.swift index 6f75fb3064..373149628e 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/RichText.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/RichText.swift @@ -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())) } } } diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift index 826cb04431..7d39e4388a 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift @@ -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) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index afad7c32cd..14e3ed4501 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -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)