Files
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

2531 lines
150 KiB
Swift

import Foundation
import LottieSettings
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import TelegramPresentationData
import SwiftSignalKit
import AccountContext
import ChatMessageBubbleContentNode
import ChatMessageDateAndStatusNode
import ChatMessageItemCommon
import ChatControllerInteraction
import InstantPageUI
import TextFormat
import TelegramUIPreferences
import TextLoadingEffect
import TextSelectionNode
import StreamingTextReveal
import ShimmeringLinkNode
import WalletContext
public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode {
public final class ContainerNode: ASDisplayNode {
}
private enum ResolvedRichDataPageKey: Equatable {
case pendingEdit(attribute: ObjectIdentifier, page: ObjectIdentifier)
case translated(language: String, attribute: ObjectIdentifier, page: ObjectIdentifier)
case original(attribute: ObjectIdentifier, page: ObjectIdentifier)
/// Which KIND of page this is, with the per-object identities stripped.
enum CaseTag: Equatable {
case pendingEdit
case translated(language: String)
case original
}
/// The full key must NOT be used to detect a content change: it carries `ObjectIdentifier`s
/// of the attribute and page objects, and a streamed AI chunk produces a fresh
/// `RichTextMessageAttribute` on every tick — so comparing keys would report a change on
/// every chunk. Only a move BETWEEN kinds (translate, enter or leave a pending edit) is a
/// whole-content transition. `language` is part of the tag so that switching between two
/// translation languages counts as one too.
var caseTag: CaseTag {
switch self {
case .pendingEdit:
return .pendingEdit
case let .translated(language, _, _):
return .translated(language: language)
case .original:
return .original
}
}
}
private struct ResolvedRichDataContent {
let instantPage: InstantPage
let originalAttribute: RichTextMessageAttribute?
let key: ResolvedRichDataPageKey
let isTranslated: Bool
let isTranslating: Bool
}
private let containerNode: ContainerNode
/// Clips `containerNode` to the bubble's four (possibly unequal, when merged) corner radii —
/// see `applyContainerCorners`. Created on first layout, since an unloaded node has no layer.
private var containerCornerMaskLayer: CAShapeLayer?
public var statusNode: ChatMessageDateAndStatusNode?
// `init()` may run off the main thread; UIView construction must happen on the main thread.
// The page view is built lazily inside the apply closure (always main-thread) via ensurePageView().
private var pageView: InstantPageV2View?
// The page view being crossfaded OUT by a whole-content update. Kept LIVE rather than replaced
// by a `snapshotView(afterScreenUpdates:)` replicant, so its inline video keeps playing and its
// custom-emoji layers keep looping through the fade.
//
// At most one is held: a second whole-content update landing mid-fade removes the in-flight one
// immediately rather than stacking dissolves.
//
// It does NOT compete with the incoming view for the media registry: `mediaRegistry` is
// per-root-view (`rootMediaRegistryHost = self` in InstantPageV2View.init) and every bubble-side
// lookup — transitionArgsFor, applyHiddenMedia — goes through `self.pageView`, which by then
// points at the new view. The outgoing view's registry is unreachable, not conflicting.
private var fadingOutPageView: InstantPageV2View?
// Tracks the message (id + stableVersion) baked into the current pageView's render context.
// The synthesized webpage uses a sentinel id (namespace 0, id 0) shared across all richText
// messages, so we key cache invalidation on the message itself. When the bubble is recycled
// with a different message we must discard pageView (render context is constructor-fixed).
// `stableId` (not `id`) is the reuse identity: it is preserved across the Local→Cloud send
// transition (whereas `id` flips namespace), so the pageView — and its media views' already-
// rendered pixels — survive send instead of being rebuilt (which caused the media blink). A
// genuinely recycled bubble carries a different stableId, so recycling still rebuilds.
// `messageId` is kept only to detect the Local→Cloud id flip, gating the reference refresh.
private var pageViewMessageKey: (stableId: UInt32, messageId: EngineMessage.Id, stableVersion: UInt32, pendingEditKey: ObjectIdentifier?, richPageKey: ResolvedRichDataPageKey, showMoreExpanded: Bool, structure: Int)?
// The `InstantPage` last handed to `pageView`, held so a whole-content candidate can be
// suppressed when the incoming page is structurally AND textually identical to what is already
// on screen. The case that reaches it in practice is `.pendingEdit -> .original` when the server
// confirms an edit whose result matches the optimistic local page — a dissolve there would be a
// flash for nothing. `InstantPage` is a class with a deep `==`, and
// `RichTextMessageAttribute.instantPage` is a plain stored `let` (no FlatBuffers
// materialization), so this is a straight structural compare. It runs only on the
// about-to-crossfade path, never on the hot same-content path.
private var appliedInstantPage: InstantPage?
// messageStableVersion is in the cache key because the synthesized instantPage content
// mutates between streamed AI message chunks (each chunk bumps stableVersion); without
// this, the cached layout would shadow newly-arrived content during streaming.
private var currentPageLayout: (boundingWidth: CGFloat,
presentationThemeIdentity: ObjectIdentifier,
// Text Size does not change the theme object, so it needs its own key or a
// size change keeps serving the old layout.
baseFontSize: CGFloat,
expandedDetails: [Int: Bool],
expandedQuotePaths: Set<[Int]>,
messageStableVersion: UInt32,
pendingEditKey: ObjectIdentifier?,
richPageKey: ResolvedRichDataPageKey,
showMoreExpanded: Bool,
codeHighlight: CachedMessageSyntaxHighlight?,
layout: InstantPageV2Layout)?
private var currentExpandedDetails: [Int: Bool] = [:]
/// Quotes the reader expanded, keyed by structural block path. Lives on the content node, so
/// scrolling away and back re-collapses — matching ChatMessageTextBubbleContentNode's
/// `expandedBlockIds`. Path-keyed rather than ordinal: AI streaming appends blocks, which would
/// shift ordinals under the state.
private var currentExpandedQuotePaths: Set<[Int]> = Set()
// Intra-message anchor scroll that is waiting on a collapsed <details> to expand + relayout.
private var pendingScrollAnchor: String?
// Progress guard: the details index expanded on the previous pending pass.
private var lastExpandedPendingDetailsIndex: Int?
private var linkProgressDisposable: Disposable?
private var linkProgressRects: [CGRect]?
private var linkHighlightingNode: LinkHighlightingNode?
private var linkProgressView: TextLoadingEffectView?
private var shimmeringNode: ShimmeringLinkNode?
private var shimmeringNodeIsSkeleton: Bool = false
private var textSelectionAdapter: InstantPageMultiTextAdapter?
private var textSelectionNode: TextSelectionNode?
private var textRevealController: TextRevealController?
private var textRevealLink: SharedDisplayLinkDriver.Link?
private var currentRevealCostMap: InstantPageV2RevealCostMap?
// Cursor value pushed into pageView.applyReveal on the prior tick. The display-link tick
// 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
private var relativeDateTimer: (timer: SwiftSignalKit.Timer, period: Int32)?
// "Show more" affordance for partial rich messages (instantPage.isComplete == false).
// Managed inline, mirroring the statusNode pattern: a bubble-owned TextNode below the page
// content, with a TextLoadingEffectView shimmer while the full-text request is in flight.
private var showMoreTextNode: TextNode?
private var showMoreLoadingView: TextLoadingEffectView?
private var requestFullRichTextDisposable: Disposable?
private var requestFullRichTextMessageId: EngineMessage.Id?
// Transient per-message expand state. The full page is shown only after the user taps "Show
// more"; tagging it with the message id means any other message starts collapsed (partial)
// every time, even if its attribute already carries a cached fullInstantPage.
private var showMoreExpanded: (messageId: EngineMessage.Id, value: Bool)?
// The expand state actually applied on the previous layout pass, used to detect the
// collapse→expand transition so the bubble can grow downward in screen space (see the
// setInvertOffsetDirection call in the apply closure). nil until the first apply.
private var appliedShowMoreExpanded: Bool?
override public var visibility: ListViewItemNodeVisibility {
didSet {
if oldValue != self.visibility {
self.updatePageViewVisibilityRect()
}
}
}
// Pushes the current `visibility` sub-rect into `pageView.visibilityRect`, translated into the
// page view's coordinate space (the page view sits at the top of the bubble; no header offset).
// Re-invoked from the apply closure after `pageView.frame` is set, because the pageView's
// y-origin and size can change across streamed chunks (content growth) without a `visibility`
// change, which would otherwise leave the animation-gating rect stale.
private func updatePageViewVisibilityRect() {
guard let pageView = self.pageView else {
return
}
switch self.visibility {
case .none:
pageView.visibilityRect = nil
case let .visible(_, subRect):
var rect = subRect
rect.origin.x = 0.0
rect.size.width = 10000.0
rect.origin.y -= pageView.frame.minY
pageView.visibilityRect = rect
}
}
/// Drops every piece of node state derived from the OUTGOING page's layout. Called when a
/// whole-content update rebuilds `pageView`: each of these holds page-space geometry or block
/// indices that no longer refer to anything in the new page.
///
/// `currentExpandedDetails` is deliberately NOT reset. It is keyed by details index and read by
/// the LAYOUT pass, which runs before apply — clearing it here would need an extra relayout
/// round-trip to take effect, and carrying an expand state onto a same-indexed details block
/// reads as reasonable rather than wrong.
private func resetPageDerivedState() {
// Built from the old view's `selectableTextItems()`; its rects are in the old page's
// coordinate space.
self.tearDownTextSelection(animated: false)
self.linkProgressDisposable?.dispose()
self.linkProgressDisposable = nil
if self.linkProgressRects != nil {
self.linkProgressRects = nil
self.updateLinkProgressState()
}
// Clears `linkHighlightingNode` through its own animated teardown.
self.updateTouchesAtPoint(nil)
// An in-flight anchor scroll targets blocks that no longer exist.
self.pendingScrollAnchor = nil
self.lastExpandedPendingDetailsIndex = nil
// Defensive: streaming is exempt from whole-content updates, so these are already inert.
self.currentRevealCostMap = nil
self.lastAppliedRevealedCount = 0
}
/// Fades `outgoing` out and hands it to `fadingOutPageView` for the duration. Durations match
/// `ChatMessageTextBubbleContentNode`'s plain-text content swap (0.12s out / 0.1s in, started
/// together), so a message that changes between rich and plain dissolves identically either way.
///
/// No clipping work is needed here: `containerNode.clipsToBounds` is already true and the corner
/// mask lives on `containerNode`, so a shrinking bubble clips the outgoing view for free while
/// its own resize animation runs underneath.
private func beginCrossfadeOut(_ outgoing: InstantPageV2View) {
// Only one dissolve at a time.
if let previous = self.fadingOutPageView {
self.fadingOutPageView = nil
previous.removeFromSuperview()
}
// Inert for input and for VoiceOver, but still animating visually.
outgoing.isUserInteractionEnabled = false
outgoing.accessibilityElementsHidden = true
self.fadingOutPageView = outgoing
// The incoming view is added via addSubview and therefore lands on top; bring the outgoing
// one forward so it is the layer fading out over the new content, matching TextBubble.
outgoing.superview?.bringSubviewToFront(outgoing)
outgoing.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.12, removeOnCompletion: false, completion: { [weak self, weak outgoing] _ in
guard let outgoing else {
return
}
if let self, self.fadingOutPageView === outgoing {
self.fadingOutPageView = nil
}
outgoing.removeFromSuperview()
})
}
required public init(lottieSettings: LottieRenderingSettings) {
self.containerNode = ContainerNode()
self.containerNode.clipsToBounds = true
super.init(lottieSettings: lottieSettings)
self.addSubnode(self.containerNode)
}
private static func resolvedRichDataContent(item: ChatMessageBubbleContentItem, showMoreExpanded: Bool) -> ResolvedRichDataContent? {
if let attribute = item.attributes.updatingMedia?.richText {
let instantPage = (showMoreExpanded ? attribute.fullInstantPage : nil) ?? attribute.instantPage
return ResolvedRichDataContent(
instantPage: instantPage,
originalAttribute: attribute,
key: .pendingEdit(attribute: ObjectIdentifier(attribute), page: ObjectIdentifier(instantPage)),
isTranslated: false,
isTranslating: false
)
}
guard let attribute = item.message.richText else {
return nil
}
let isIncoming = item.message.effectivelyIncoming(item.context.account.peerId)
var canDisplayTranslation = isIncoming
if let subject = item.associatedData.subject, case .messageOptions = subject {
canDisplayTranslation = false
}
if canDisplayTranslation, let translateToLanguage = item.associatedData.translateToLanguage {
if let translation = item.message.attributes.first(where: { ($0 as? TranslationMessageAttribute)?.toLang == translateToLanguage }) as? TranslationMessageAttribute, let instantPage = translation.instantPage {
return ResolvedRichDataContent(
instantPage: instantPage,
originalAttribute: attribute,
key: .translated(language: translateToLanguage, attribute: ObjectIdentifier(translation), page: ObjectIdentifier(instantPage)),
isTranslated: true,
isTranslating: false
)
} else {
return ResolvedRichDataContent(
instantPage: attribute.instantPage,
originalAttribute: attribute,
key: .original(attribute: ObjectIdentifier(attribute), page: ObjectIdentifier(attribute.instantPage)),
isTranslated: false,
isTranslating: true
)
}
}
let instantPage = (showMoreExpanded ? attribute.fullInstantPage : nil) ?? attribute.instantPage
return ResolvedRichDataContent(
instantPage: instantPage,
originalAttribute: attribute,
key: .original(attribute: ObjectIdentifier(attribute), page: ObjectIdentifier(instantPage)),
isTranslated: false,
isTranslating: false
)
}
/// The message-scoped media-reference closures for the render context. Extracted so the
/// initial build and the Local→Cloud reference refresh construct identical closures.
private static func mediaReferenceClosures(messageReference: MessageReference) -> (image: (TelegramMediaImage) -> ImageMediaReference, file: (TelegramMediaFile) -> FileMediaReference) {
return (
image: { image in ImageMediaReference.message(message: messageReference, media: image) },
file: { file in FileMediaReference.message(message: messageReference, media: file) }
)
}
/// Whether this message's media must be screenshot-protected, matching
/// `ChatMessageInteractiveMediaNode`'s rule for regular media messages (its third disjunct,
/// extended/paid media, cannot occur inside a rich message). Recomputed on every apply rather
/// than captured once: `isCopyProtectionEnabled` is a peer setting that can flip while the
/// bubble is on screen.
private static func isCaptureProtected(item: ChatMessageBubbleContentItem) -> Bool {
return item.associatedData.isCopyProtectionEnabled || item.message.isCopyProtected()
}
/// Builds (or reuses) the V2View. Same-message stableVersion bumps (streamed AI chunks) reuse
/// the existing view, updating only the webpage content in place. The view is rebuilt when the
/// bubble is recycled with a genuinely different message (different `stableId`), and — since
/// the whole-content split — when the SAME message's content is wholly replaced.
private func ensurePageView(
item: ChatMessageBubbleContentItem,
webpage: TelegramMediaWebpage,
page: InstantPage,
richPageKey: ResolvedRichDataPageKey,
showMoreExpanded: Bool,
structure: Int,
isStreaming: Bool,
animation: ListViewItemUpdateAnimation
) -> InstantPageV2View {
// Set only by the whole-content branch below; read by the rebuild tail. This cannot be
// inferred from `self.fadingOutPageView != nil` — a dissolve from a PREVIOUS update can
// still be in flight when a scroll recycle rebuilds for a different message, which would
// fade the recycled bubble in for no reason.
var crossfadeIn = false
// Copy protection is a peer setting that can flip without any of the keys below changing,
// so refresh it up front — ahead of every reuse branch, including the early returns. The
// rebuild path picks the same value up through the render context's initializer.
self.pageView?.renderContext?.updateCaptureProtected(ChatMessageRichDataBubbleContentNode.isCaptureProtected(item: item))
let key = (stableId: item.message.stableId, messageId: item.message.id, stableVersion: item.message.stableVersion, pendingEditKey: (item.attributes.updatingMedia?.richText).map({ ObjectIdentifier($0) }), richPageKey: richPageKey, showMoreExpanded: showMoreExpanded, structure: structure)
if let existing = self.pageView, let current = self.pageViewMessageKey, current.stableId == key.stableId {
if current.stableVersion == key.stableVersion && current.messageId == key.messageId && current.pendingEditKey == key.pendingEditKey && current.richPageKey == key.richPageKey && current.showMoreExpanded == key.showMoreExpanded {
// `structure` is not compared here: `richPageKey` carries the page object's
// ObjectIdentifier, so an equal key already implies the same page and therefore
// the same structure.
return existing
}
// A whole-content update replaces the document rather than editing it. Diffing into the
// existing view would reuse item views positionally — a paragraph view at position 3
// rendering whatever unrelated block now occupies position 3 — so the view is rebuilt.
let isLocalToCloudFlip = current.messageId != key.messageId
let isWholeContentUpdate =
animation.isAnimated // nothing to show otherwise
&& !isStreaming // streamed chunk: exempt
&& !isLocalToCloudFlip // send flip: exempt
&& ( current.richPageKey.caseTag != key.richPageKey.caseTag // translate / pending edit
|| current.showMoreExpanded != key.showMoreExpanded // "show more"
|| current.structure != key.structure) // block shape
// Suppress the no-op confirmation: `.pendingEdit -> .original` where the server's
// result matches the optimistic local page already on screen.
&& !(self.appliedInstantPage.flatMap({ $0 == page }) ?? false)
if !isWholeContentUpdate {
// Same logical message (stableId), same document. Two sub-cases:
// - messageId unchanged (streamed AI chunk / pending edit): swap only the webpage;
// the construction-time reference snapshot stays valid (media resolves by id). The
// subsequent pageView.update(layout:) diffs item views by stable id, so content
// blocks keep their views + in-flight reveal state (only added/removed blocks
// change) — eliminating the per-chunk full-text-then-mask flash.
// - messageId changed (Local→Cloud send flip): also refresh the render context's
// MessageReference + reference closures, so live consumers (inline video/audio/
// gallery) use the Cloud reference. The reused media VIEWS keep their init-time
// (local) reference — their bytes are already local, so the poster does not reload
// and there is no blink; a later scroll-recycle rebuilds them against the Cloud ref.
if isLocalToCloudFlip {
let messageReference = MessageReference(item.message)
let closures = ChatMessageRichDataBubbleContentNode.mediaReferenceClosures(messageReference: messageReference)
existing.renderContext?.updateContent(webpage: webpage, message: messageReference, imageReference: closures.image, fileReference: closures.file)
} else {
existing.renderContext?.updateContent(webpage: webpage)
}
self.pageViewMessageKey = key
self.appliedInstantPage = page
return existing
}
self.resetPageDerivedState()
self.beginCrossfadeOut(existing)
crossfadeIn = true
// `beginCrossfadeOut` took ownership of the outgoing view, so clear the slot before the
// rebuild below to keep its `removeFromSuperview()` from tearing down the fading view.
self.pageView = nil
// Falls through to the rebuild below, which fades the new view in.
}
self.pageView?.removeFromSuperview()
self.pageView = nil
// Abandon any dissolve still in flight when this rebuild is NOT a crossfade: the bubble is
// now showing a different message, so finishing the previous message's fade would leave
// content on screen that no longer belongs to it.
if !crossfadeIn, let previous = self.fadingOutPageView {
self.fadingOutPageView = nil
previous.removeFromSuperview()
}
// Capture only the MessageReference (value type) — the closures are retained on the
// render context which is owned by the V2View, so we must avoid making them retain
// the bubble (`self`) or the message indirectly via `item`.
let messageReference = MessageReference(item.message)
let closures = ChatMessageRichDataBubbleContentNode.mediaReferenceClosures(messageReference: messageReference)
let policyContext = item.context
let autoDownloadSettings = item.controllerInteraction.automaticMediaDownloadSettings
let autoDownloadPeerType = item.associatedData.automaticDownloadPeerType
let autoDownloadNetworkType = item.associatedData.automaticDownloadNetworkType
let autoDownloadContactsPeerIds = item.associatedData.contactsPeerIds
let messageAuthorPeerId = item.message.author?.id
let messagePeerId = item.message.id.peerId
let renderContext = InstantPageV2RenderContext(
context: item.context,
webpage: webpage,
sourceLocation: InstantPageSourceLocation(userLocation: .peer(messagePeerId), peerType: autoDownloadPeerType),
imageReference: closures.image,
fileReference: closures.file,
present: { [weak self] controller, args in
self?.item?.controllerInteraction.presentController(controller, args)
},
push: { [weak self] controller in
self?.item?.controllerInteraction.navigationController()?.pushViewController(controller)
},
openUrl: { [weak self] urlItem in
self?.openInstantPageUrl(urlItem)
},
baseNavigationController: { [weak self] in
self?.item?.controllerInteraction.navigationController()
},
shouldAutoDownloadImage: { image in
return shouldDownloadMediaAutomatically(settings: autoDownloadSettings, peerType: autoDownloadPeerType, networkType: autoDownloadNetworkType, authorPeerId: messageAuthorPeerId, contactsPeerIds: autoDownloadContactsPeerIds, media: image)
},
shouldAutoDownloadFile: { file in
return shouldDownloadMediaAutomatically(settings: autoDownloadSettings, peerType: autoDownloadPeerType, networkType: autoDownloadNetworkType, authorPeerId: messageAuthorPeerId, contactsPeerIds: autoDownloadContactsPeerIds, media: file)
},
shouldAutoplayVideo: { file in
let enabled = file.isAnimated ? policyContext.sharedContext.energyUsageSettings.autoplayGif : policyContext.sharedContext.energyUsageSettings.autoplayVideo
guard enabled else { return false }
return policyContext.engine.resources.completedResourcePath(id: EngineMediaResource.Id(file.resource.id)) != nil
},
wallpaperBackgroundNode: { [weak self] in
return self?.item?.controllerInteraction.presentationContext.backgroundNode
},
captureProtected: ChatMessageRichDataBubbleContentNode.isCaptureProtected(item: item),
message: messageReference
)
let view = InstantPageV2View(renderContext: renderContext)
self.pageView = view
self.pageViewMessageKey = key
self.appliedInstantPage = page
self.containerNode.view.addSubview(view)
if crossfadeIn {
view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1)
}
view.unsupportedActionTapped = { [weak self] in
guard let item = self?.item else {
return
}
item.controllerInteraction.openAppStorePage()
}
view.detailsTapped = { [weak self] index in
guard let self else { return }
let current = self.currentExpandedDetails[index] ?? self.defaultExpanded(forDetailsIndex: index)
self.currentExpandedDetails[index] = !current
if let item = self.item {
item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
}
}
return view
}
/// True when the rendered page is the message's primary (non-translated, non-full,
/// non-pending-edit) InstantPage — the only rendering whose checkbox paths are safe to
/// edit — AND the message is editable.
private func checkboxesInteractive(item: ChatMessageBubbleContentItem, resolved: ResolvedRichDataContent) -> Bool {
// `.original` (server state) and `.pendingEdit` (an in-flight edit) are both eligible —
// keeping checkboxes live during the pending round-trip lets the user toggle several boxes
// in a row. `.translated` is inert, as is the translation-pending fallback (which reports an
// `.original` key over the genuine original page but with `isTranslating == true`).
switch resolved.key {
case .original, .pendingEdit:
break
case .translated:
return false
}
if resolved.isTranslating {
return false
}
// The primary page is the resolved attribute's `instantPage` (class identity). The show-more
// (`fullInstantPage`) rendering carries the same key but a different page object, so an
// identity check excludes it. `originalAttribute` is Optional (it is the pending edit's
// attribute in the `.pendingEdit` case).
guard let attribute = resolved.originalAttribute, resolved.instantPage === attribute.instantPage else {
return false
}
return item.controllerInteraction.canEditMessageRichText(item.message)
}
private func defaultExpanded(forDetailsIndex index: Int) -> Bool {
guard let layout = self.currentPageLayout?.layout else { return false }
func search(_ items: [InstantPageV2LaidOutItem]) -> Bool? {
for item in items {
if case let .details(d) = item {
if d.index == index {
return d.defaultExpanded
}
// Recurse into an expanded parent's body so NESTED details indices resolve too;
// the flat top-level scan missed them, leaving the toggle's "current state"
// computation wrong for a nested details whose model default is expanded.
if let inner = d.innerLayout, let found = search(inner.items) {
return found
}
}
}
return nil
}
return search(layout.items) ?? false
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// The syntax-highlight job in flight for this node, so an identical spec set is not re-run on every
/// layout pass. Mirrors `ChatMessageTextBubbleContentNode.codeHighlightState`.
private var codeHighlightState: (id: EngineMessage.Id, specs: [CachedMessageSyntaxHighlight.Spec], disposable: MetaDisposable)?
deinit {
self.codeHighlightState?.disposable.dispose()
self.linkProgressDisposable?.dispose()
self.relativeDateTimer?.timer.invalidate()
self.requestFullRichTextDisposable?.dispose()
}
override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) {
let previousItem = self.item
let currentPageLayout = self.currentPageLayout
let currentExpandedDetails = self.currentExpandedDetails
let currentExpandedQuotePaths = self.currentExpandedQuotePaths
let showMoreExpandedState = self.showMoreExpanded
let statusLayout = ChatMessageDateAndStatusNode.asyncLayout(self.statusNode)
let showMoreTextLayout = TextNode.asyncLayout(self.showMoreTextNode)
// Captured at main-thread, top of asyncLayoutContent. Mirrors TextBubble's
// `currentMaxGlyphCount` (TextBubble:313). The bubble's bounding size is sized
// to this revealed prefix during streaming, so it grows with the reveal rather
// than being final-sized from the first chunk.
let currentMaxGlyphCount: Int? = self.textRevealController?.currentGlyphCount
return { [weak self] item, layoutConstants, _, _, _, _ in
// Structural detector (model-only): does the effective rich page end with full-width
// visual media? This is emitted BEFORE the page is laid out, so it inspects the block
// model rather than laid-out items. Its only job is to push non-inline reactions outside
// the bubble (the overlaid pill can't host multi-row reactions). The authoritative
// full-width placement decision happens later, in the layout phase.
var wantsReactionsOutside = false
if let attribute = (item.attributes.updatingMedia.map(\.richText) ?? item.message.richText) {
let showMoreExpanded = (showMoreExpandedState?.messageId == item.message.id) ? (showMoreExpandedState?.value ?? false) : false
let page = (showMoreExpanded ? attribute.fullInstantPage : nil) ?? attribute.instantPage
if let lastBlock = page.blocks.last, richDataBlockEndsWithVisualMedia(lastBlock) {
let reactions = mergedMessageReactions(attributes: item.message.attributes, isTags: item.message.areReactionsTags(accountPeerId: item.context.account.peerId))
let hasReactions = !(reactions?.reactions.isEmpty ?? true)
let inline = shouldDisplayInlineDateReactions(message: EngineMessage(item.message), isPremium: item.associatedData.isPremium, forceInline: item.associatedData.forceInlineReactions)
wantsReactionsOutside = hasReactions && !inline
}
}
// The bubble's header (author name, "Forwarded from", reply) ends in an overlap that
// assumes the content below brings its own top inset, as a text bubble does. A page that
// opens with text does (its leading padding); one that opens flush — a photo, a code
// band, a file row — brings none and would run into the header. Such a page takes the
// media bubble's spacing, so with both inset 2pt a photo here sits exactly where a photo
// message's does.
let headerSpacingShowMoreExpanded = (showMoreExpandedState?.messageId == item.message.id) ? (showMoreExpandedState?.value ?? false) : false
var headerSpacing: CGFloat = 0.0
if let resolvedContent = ChatMessageRichDataBubbleContentNode.resolvedRichDataContent(item: item, showMoreExpanded: headerSpacingShowMoreExpanded), instantPageV2ContentStartsFlushAtTop(resolvedContent.instantPage.blocks) {
headerSpacing = 7.0
}
let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: headerSpacing, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none, wantsReactionsOutside: wantsReactionsOutside)
return (contentProperties, nil, CGFloat.greatestFiniteMagnitude, { constrainedSize, position in
let suggestedBoundingWidth: CGFloat = constrainedSize.width
var boundingSize = CGSize(width: suggestedBoundingWidth, height: 0.0)
/// Syntax-highlight specs for this message's code blocks, and the answer already stored
/// on the message. Declared HERE, in the measure scope the apply closure captures, so the
/// apply step can drive the async job from them.
var codeHighlightSpecs: [CachedMessageSyntaxHighlight.Spec] = []
var cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight?
var pageLayout: InstantPageV2Layout?
// Built alongside pageLayout so the apply closure can hand it to ensurePageView.
var pageWebpage: TelegramMediaWebpage?
// Shape-only fingerprint of the resolved page, decided in the layout pass (pure,
// safe off-main, one tree walk against a pass already O(page)) and consumed by
// `ensurePageView` in apply.
var pageStructure: Int = 0
var pageResolvedInstantPage: InstantPage?
// The page's text left edge in THIS node's coordinate space — the status node's left
// edge + side inset, mirroring TextBubble's bubbleInsets. The page itself is inset by
// `pageContentInset` (below), so the value handed to the layout is smaller by that
// much; these two must not be conflated, or the status and date drift off the text.
let pageHorizontalInset: CGFloat = 11.0
// The whole page is inset by this much on all four sides, so full-width media sits
// INSIDE the bubble background (corners clipped by the rounded container) instead of
// running under it — matching how a regular media bubble insets its image. The page
// gives the same amount back out of its own insets (`horizontalInset` here,
// `edgeSpacingReduction` vertically), so every block keeps its absolute position and
// the bubble keeps its size.
//
// It is 2, not 1, because THIS NODE'S BOUNDS ARE 1pt LARGER THAN THE BACKGROUND on
// each side: the first point only reaches the background edge (which is why the old
// container sat at x = 1 and read as flush), and the second is the visible inset.
// Measure any change to this against the background, not against these bounds.
let pageContentInset: CGFloat = 2.0
// The text inset INSIDE the page — what the layout is given, and the origin that
// page-space frames are measured from. Smaller than `pageHorizontalInset` by the
// inset, so that text still lands at `pageHorizontalInset` in this node's space.
let pageLayoutHorizontalInset: CGFloat = pageHorizontalInset - pageContentInset
let isDark = item.presentationData.theme.theme.overallDarkAppearance
let isIncoming = item.message.effectivelyIncoming(item.context.account.peerId)
let messageTheme = isIncoming ? item.presentationData.theme.theme.chat.message.incoming : item.presentationData.theme.theme.chat.message.outgoing
// Service-message colours for the unsupported-content pill, so a pill inside this
// bubble matches the standalone unsupported bubble rather than the bubble's palette.
let serviceColor = serviceMessageColorComponents(theme: item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper)
var underlineLinks = true
if !messageTheme.primaryTextColor.isEqual(messageTheme.linkTextColor) {
underlineLinks = false
}
let _ = underlineLinks
let author = item.message.author
let mainColor: UIColor
var secondaryColor: UIColor? = nil
var tertiaryColor: UIColor? = nil
let nameColors: PeerNameColors.Colors?
switch author?.nameColor {
case let .preset(nameColor):
nameColors = item.context.peerNameColors.get(nameColor, dark: item.presentationData.theme.theme.overallDarkAppearance)
case let .collectible(collectibleColor):
nameColors = collectibleColor.peerNameColors(dark: item.presentationData.theme.theme.overallDarkAppearance)
default:
nameColors = nil
}
let codeBlockTitleColor: UIColor
let codeBlockAccentColor: UIColor
if !isIncoming {
mainColor = messageTheme.accentTextColor
if let _ = nameColors?.secondary {
secondaryColor = .clear
}
if let _ = nameColors?.tertiary {
tertiaryColor = .clear
}
if item.presentationData.theme.theme.overallDarkAppearance {
codeBlockTitleColor = .white
codeBlockAccentColor = UIColor(white: 1.0, alpha: 0.5)
} else {
codeBlockTitleColor = mainColor
codeBlockAccentColor = mainColor
}
} else {
let authorNameColor = nameColors?.main
secondaryColor = nameColors?.secondary
tertiaryColor = nameColors?.tertiary
if let authorNameColor {
mainColor = authorNameColor
} else {
mainColor = messageTheme.accentTextColor
}
codeBlockTitleColor = mainColor
codeBlockAccentColor = mainColor
}
let _ = secondaryColor
let _ = tertiaryColor
let _ = codeBlockTitleColor
let _ = codeBlockAccentColor
let textCategories = InstantPageTextCategories.chatMessage(
primaryText: messageTheme.primaryTextColor,
secondaryText: messageTheme.secondaryTextColor
)
let tableHeaderColor = isDark || !isIncoming ? messageTheme.accentControlColor.withMultipliedAlpha(0.1) : UIColor(white: 0.0, alpha: 0.05)
let checkboxFill = isIncoming ? item.presentationData.theme.theme.list.itemCheckColors.fillColor : messageTheme.accentControlColor
var checkboxForeground = isIncoming ? item.presentationData.theme.theme.list.itemCheckColors.foregroundColor : item.presentationData.theme.theme.list.itemCheckColors.foregroundColor
if isDark && checkboxForeground == checkboxFill {
checkboxForeground = messageTheme.mediaControlInnerBackgroundColor
}
// Incoming bubbles are neutral, so a 15% tint with a full-strength label reads fine.
// Outgoing bubbles can be saturated (Day Blue), where the tint mixes into the bubble
// colour and the label loses contrast, so there the fill is solid with a white label,
// as the message's inline keyboard buttons (ChatButtonKeyboardInputNode) draw them.
let buttonDangerColor = item.presentationData.theme.theme.contextMenu.destructiveColor
let buttonSuccessColor = item.presentationData.theme.theme.list.freeTextSuccessColor
let buttonDangerBackgroundColor = isIncoming ? buttonDangerColor.withMultipliedAlpha(0.15) : buttonDangerColor
let buttonDangerForegroundColor = isIncoming ? buttonDangerColor : UIColor.white
let buttonSuccessBackgroundColor = isIncoming ? buttonSuccessColor.withMultipliedAlpha(0.15) : buttonSuccessColor
let buttonSuccessForegroundColor = isIncoming ? buttonSuccessColor : UIColor.white
let pageTheme = InstantPageTheme(
type: isDark ? .dark : .light,
pageBackgroundColor: .clear,
textCategories: textCategories,
serif: false,
// A code block reads as a highlighted table row, not as an accent-tinted quote
// — the same fill a filled table cell gets. (V1 Instant View still reads this
// field for its own gray box; only the value THIS host passes changes.)
codeBlockBackgroundColor: tableHeaderColor,
linkColor: messageTheme.linkTextColor,
textHighlightColor: messageTheme.accentTextColor.withMultipliedAlpha(0.1),
linkHighlightColor: messageTheme.linkTextColor.withMultipliedAlpha(0.1),
markerColor: UIColor(rgb: 0xfef3bc),
panelBackgroundColor: messageTheme.accentControlColor.withMultipliedAlpha(0.1),
panelHighlightedBackgroundColor: messageTheme.accentControlColor.withMultipliedAlpha(0.8),
panelPrimaryColor: messageTheme.primaryTextColor,
panelSecondaryColor: messageTheme.secondaryTextColor,
panelAccentColor: messageTheme.accentTextColor,
tableBorderColor: isDark || !isIncoming ? messageTheme.accentControlColor.withMultipliedAlpha(0.25) : UIColor(white: 0.0, alpha: 0.1),
tableHeaderColor: tableHeaderColor,
controlColor: messageTheme.accentControlColor,
imageTintColor: nil,
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13),
separatorColor: messageTheme.secondaryTextColor.mixedWith(mainColor.withMultipliedAlpha(0.2), alpha: 0.3),
secondaryControlColor: messageTheme.secondaryTextColor.mixedWith(mainColor.withMultipliedAlpha(0.2), alpha: 0.3),
quoteAccentColor: mainColor,
buttonDangerBackgroundColor: buttonDangerBackgroundColor,
buttonDangerForegroundColor: buttonDangerForegroundColor,
buttonSuccessBackgroundColor: buttonSuccessBackgroundColor,
buttonSuccessForegroundColor: buttonSuccessForegroundColor,
checkboxFill: checkboxFill,
checkboxForeground: checkboxForeground,
neutralButtonBackgroundColor: tableHeaderColor,
neutralButtonForegroundColor: isIncoming ? messageTheme.primaryTextColor : messageTheme.accentControlColor,
unsupportedPillFillColor: selectDateFillStaticColor(theme: item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper),
unsupportedPillPrimaryColor: serviceColor.primaryText
)
var hasDraft = false
if item.message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) {
hasDraft = true
}
var hadDraft = false
if let previousItem, previousItem.message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) {
hadDraft = true
}
// Resolve the node-local expand state for THIS message (collapsed for any other).
let showMoreExpanded = (showMoreExpandedState?.messageId == item.message.id) ? (showMoreExpandedState?.value ?? false) : false
let resolvedContent = ChatMessageRichDataBubbleContentNode.resolvedRichDataContent(item: item, showMoreExpanded: showMoreExpanded)
if let resolvedContent {
#if DEBUG && false
let instantPage = InstantPage(blocks: [.thinking(.concat([
.textCustomEmoji(fileId: 5384559872899555845, alt: "a"),
.plain("Thinking...")
]))], media: [:], isComplete: true, rtl: false, url: "", views: nil)
#else
let instantPage = resolvedContent.instantPage
#endif
let webpage = TelegramMediaWebpage(webpageId: EngineMedia.Id(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent(
url: "",
displayUrl: "",
hash: 0,
type: nil,
websiteName: nil,
title: nil,
text: nil,
embedUrl: nil,
embedType: nil,
embedSize: nil,
duration: nil,
author: nil,
isMediaLargeByDefault: nil,
imageIsVideoCover: false,
image: nil,
file: nil,
story: nil,
attributes: [],
instantPage: instantPage
)))
pageWebpage = webpage
pageStructure = instantPageStructureFingerprint(instantPage)
pageResolvedInstantPage = instantPage
// The code in a RICH message lives in InstantPageBlock.preformatted, not in a `.Pre`
// entity, so the entity-based `extractMessageSyntaxHighlightSpecs` the text bubble
// uses cannot see it — this walks the page instead. Declared in the outer scope
// (beside `pageLayout`) because the APPLY closure drives the job from them.
codeHighlightSpecs = instantPageSyntaxHighlightSpecs(for: instantPage.blocks)
if !codeHighlightSpecs.isEmpty {
for attribute in item.message.attributes {
if let attribute = attribute as? DerivedDataMessageAttribute {
if let value = attribute.data["code"]?.get(CachedMessageSyntaxHighlight.self) {
cachedMessageSyntaxHighlight = value
}
}
}
}
let presentationThemeIdentity = ObjectIdentifier(item.presentationData.theme.theme)
// Settings ▸ Appearance ▸ Text Size (bugs.telegram.org/c/62776). A rich message is a
// bubble like any other, so the whole page — fonts and the geometry tuned against them —
// scales by the same step a plain bubble's `messageFont` takes. The theme above stays
// authored at 17pt; the renderer applies the scale once.
let baseFontSize = item.presentationData.fontSize.baseDisplaySize
let currentMessageStableVersion = item.message.stableVersion
let currentPendingEditKey = (item.attributes.updatingMedia?.richText).map({ ObjectIdentifier($0) })
if let current = currentPageLayout,
current.boundingWidth == suggestedBoundingWidth,
current.presentationThemeIdentity == presentationThemeIdentity,
current.baseFontSize == baseFontSize,
current.expandedDetails == currentExpandedDetails,
current.expandedQuotePaths == currentExpandedQuotePaths,
current.showMoreExpanded == showMoreExpanded,
current.messageStableVersion == currentMessageStableVersion,
current.pendingEditKey == currentPendingEditKey,
current.richPageKey == resolvedContent.key,
// LOAD-BEARING. The guard keys on `messageStableVersion`, and whether a
// `storeLocallyDerivedData` write bumps that is Postbox's business, not this
// node's. Without this clause a newly-arrived highlight could be computed,
// persisted, and never painted, because the node would keep serving the layout it
// cached before the job finished.
current.codeHighlight == cachedMessageSyntaxHighlight,
current.layout.formattedDateUpdatePeriod == nil {
// Reuse the cached layout only when it has no relative `textDate`. A relative
// date's formatted string ("N minutes ago") is baked into the laid-out text at
// layout time, and none of the cache-key inputs change as wall-clock advances —
// so reusing it would freeze the date and defeat the refresh timer (which fires
// `requestFullUpdate` precisely to re-run `layoutInstantPageV2` → `formatDate`).
// Forcing a recompute for relative-date pages keeps the timer's tick visible.
pageLayout = current.layout
} else {
pageLayout = layoutInstantPageV2(
webpage: webpage,
instantPage: instantPage,
userLocation: .other,
boundingWidth: suggestedBoundingWidth - 2.0,
horizontalInset: pageLayoutHorizontalInset,
theme: pageTheme,
strings: item.presentationData.strings,
dateTimeFormat: item.presentationData.dateTimeFormat,
cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight,
expandedDetails: currentExpandedDetails,
expandedQuotePaths: currentExpandedQuotePaths,
fitToWidth: true,
computeRevealCharacterRects: hasDraft || hadDraft,
edgeSpacingReduction: pageContentInset,
contentScale: instantPageChatMessageContentScale(baseFontSize: baseFontSize)
)
}
}
// Cost map computed here (not in apply) so we can size the bubble to the
// revealed prefix this layout pass. Mirrors TextBubble's clippedGlyphCountLayout.
let revealCostMap: InstantPageV2RevealCostMap? = (hasDraft || hadDraft) ? pageLayout?.computeRevealCostMap() : nil
let revealedGlyphCount: Int? = (hasDraft || hadDraft) ? (currentMaxGlyphCount ?? 0) : nil
if let pageLayout {
let effectiveSize: CGSize
if let costMap = revealCostMap, let glyphCount = revealedGlyphCount {
effectiveSize = costMap.revealedContentSize(revealedCount: glyphCount, layout: pageLayout)
} else {
effectiveSize = pageLayout.contentSize
}
// The page is inset on every side, so the bubble is its content plus the two
// rims. Both axes cancel the trims above (`horizontalInset` is 1pt smaller and
// `layoutTextItem` reserves the right margin as `maxX + horizontalInset`;
// `edgeSpacingReduction` takes 1pt off each vertical edge), so the bubble ends up
// exactly the size it was before the inset.
boundingSize.width = effectiveSize.width + pageContentInset * 2.0
boundingSize.height = effectiveSize.height + pageContentInset * 2.0
}
// Authoritative detector: the bottom-most laid-out item is full-width visual media,
// so the status becomes an image-style pill overlaid on it (no reserved strip).
// Captured by the nested measure/apply closures below.
let mediaStatusFrame: CGRect? = pageLayout.flatMap(lastFullWidthMediaFrame(in:))
// The hardcoded "Thinking…" header was removed in favor of server-sent
// InstantPageBlock.thinking blocks (rendered inside the pageView). There is no
// header strip anymore, so the page content starts at the top of the bubble.
let streamingHeaderOffset: CGFloat = 0.0
if hasDraft {
// The bubble's bottom inset is supplied by the `statusBottomEdge + 6.0`
// max() in the measure closure below — but that branch is gated by
// `!hasDraft`, so during streaming the bubble has only its 1pt bottom rim
// past `revealedContentSize.height` (= bounds.maxY + closingPad). Without
// this, descenders of the last revealed line sit cramped against the
// bubble's bottom edge and the bubble visibly grows by 6pt when streaming
// ends and the status node fades in. 6pt matches the constant inside the
// status max() (which itself tracks `TextBubble`'s `bubbleInsets.bottom`).
// `hadDraft && !hasDraft` (the finalize pass) doesn't need this because
// `!hasDraft` re-enables the status max(), which supplies the inset for it.
boundingSize.height += 6.0
}
let message = item.message
let incoming = isIncoming
var edited = false
if item.attributes.updatingMedia != nil {
edited = true
}
var viewCount: Int?
var dateReplies = 0
var starsCount: Int64?
var dateReactionsAndPeers = mergedMessageReactionsAndPeers(accountPeerId: item.context.account.peerId, accountPeer: item.associatedData.accountPeer, message: item.topMessage)
if item.message.isRestricted(platform: "ios", contentSettings: item.context.currentContentSettings.with { $0 }) {
dateReactionsAndPeers = ([], [])
}
for attribute in item.message.attributes {
if let attribute = attribute as? EditedMessageAttribute {
edited = !attribute.isHidden
} else if let attribute = attribute as? ViewCountMessageAttribute {
viewCount = attribute.count
} else if let attribute = attribute as? ReplyThreadMessageAttribute, case .peer = item.chatLocation {
if let channel = item.message.peers[item.message.id.peerId] as? TelegramChannel, case .group = channel.info {
dateReplies = Int(attribute.count)
}
} else if let attribute = attribute as? PaidStarsMessageAttribute, item.message.id.peerId.namespace == Namespaces.Peer.CloudChannel {
starsCount = attribute.stars.value
}
}
let dateFormat: MessageTimestampStatusFormat
if item.presentationData.isPreview {
dateFormat = .full
} else if let subject = item.associatedData.subject, case .messageOptions = subject {
dateFormat = .minimal
} else {
dateFormat = .regular
}
let dateText = stringForMessageTimestampStatus(context: item.context, message: EngineMessage(item.message), dateTimeFormat: item.presentationData.dateTimeFormat, nameDisplayOrder: item.presentationData.nameDisplayOrder, strings: item.presentationData.strings, format: dateFormat, associatedData: item.associatedData)
let statusType: ChatMessageDateAndStatusType?
var displayStatus = false
switch position {
case let .linear(_, neighbor):
if case .None = neighbor {
displayStatus = true
} else if case .Neighbour(true, _, _) = neighbor {
displayStatus = true
}
default:
break
}
if case let .customChatContents(contents) = item.associatedData.subject {
if case .hashTagSearch = contents.kind {
displayStatus = true
} else {
displayStatus = false
}
} else if !item.presentationData.chatBubbleCorners.hasTails {
displayStatus = false
} else if case let .messageOptions(_, _, info) = item.associatedData.subject, case let .link(link) = info, link.isCentered {
displayStatus = false
}
if displayStatus {
let outgoingStatus: ChatMessageDateAndStatusOutgoingType
if message.flags.contains(.Failed) {
outgoingStatus = .Failed
} else if (message.flags.isSending && !message.isSentOrAcknowledged) || item.attributes.updatingMedia != nil {
outgoingStatus = .Sending
} else {
outgoingStatus = .Sent(read: item.read)
}
if mediaStatusFrame != nil {
statusType = incoming ? .ImageIncoming : .ImageOutgoing(outgoingStatus)
} else {
statusType = incoming ? .BubbleIncoming : .BubbleOutgoing(outgoingStatus)
}
} else {
statusType = nil
}
// Only trail the status inline with the last text line when the bottom-most page
// item is itself a text item; otherwise (table/image/etc. last) the status falls
// through to the contentSize.height anchor and sits below all content.
let lastTextLine = pageLayout.flatMap(InstantPageUI.lastTextLineFrameIfLastItemIsText(in:))
var lastTextLineFrame: CGRect? = lastTextLine?.frame
// 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).
var lastTextLineTrailingPadding: CGFloat = lastTextLine?.trailingBottomPadding ?? 0.0
// "Show more" affordance for partial rich messages: laid out as a bubble-owned text
// node below the page content. Shown only when the page is incomplete AND the user
// has not expanded it yet (showMoreExpanded == false), the message is not streaming,
// it is a Cloud message (requestFullRichText is a no-op otherwise), and we are not in
// a preview / messageOptions context. When present, the date trails the link's line
// by substituting its frame for the last-text-line frame the status machinery consumes.
var showMore = false
if let attribute = resolvedContent?.originalAttribute,
resolvedContent?.isTranslated != true,
resolvedContent?.isTranslating != true,
!showMoreExpanded,
!attribute.instantPage.isComplete,
!hasDraft,
item.message.id.namespace == Namespaces.Message.Cloud,
!item.presentationData.isPreview {
if let subject = item.associatedData.subject, case .messageOptions = subject {
showMore = false
} else {
showMore = true
}
}
var showMoreLayoutResult: (TextNodeLayout, () -> TextNode)?
var showMoreFramePageLocal: CGRect?
if showMore, let pageLayout {
let title = item.presentationData.strings.Chat_RichText_ShowMore
// The link is body text, so it takes the body size the page was just laid out at.
let attributedTitle = NSAttributedString(string: title, font: Font.regular(item.presentationData.fontSize.baseDisplaySize), textColor: messageTheme.linkTextColor)
// The link only fits within the existing bubble width (it does not widen the
// bubble the way the status node does); the short fixed string never needs more,
// and `.end` truncation is a safe fallback for a pathologically narrow bubble.
let constrainedWidth = max(1.0, boundingSize.width - pageHorizontalInset * 2.0)
let layout = showMoreTextLayout(TextNodeLayoutArguments(attributedString: attributedTitle, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: constrainedWidth, height: 100.0)))
let showMoreTopSpacing: CGFloat = 2.0
// Page-space, matching the other frames assigned to `lastTextLineFrame` below —
// the node itself is still placed at `pageHorizontalInset` in self-space.
let frame = CGRect(origin: CGPoint(x: pageLayoutHorizontalInset, y: pageLayout.contentSize.height + showMoreTopSpacing), size: layout.0.size)
showMoreLayoutResult = layout
showMoreFramePageLocal = frame
// Date trails the link line (or wraps below it if it doesn't fit) — reuse the
// status machinery by substituting the link frame for the last-text-line frame.
lastTextLineFrame = frame
lastTextLineTrailingPadding = 0.0
// Ensure the bubble contains the link even when the status node is hidden. The 1.0
// is the content top rim; 6.0 the bottom breathing room used elsewhere in this file.
boundingSize.height = max(boundingSize.height, 1.0 + frame.maxY + 6.0)
}
var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode))?
if let statusType = statusType {
var isReplyThread = false
if case .replyThread = item.chatLocation {
isReplyThread = true
}
// Measure trailing extent from the line's actual visible RIGHT EDGE (after
// alignment, in page coords) — not just its intrinsic width. A right-aligned
// or RTL last line has `lineWidth` worth of glyphs but sits all the way at
// the right text inset (lineFrame.maxX == text.frame.minX + textItem.width).
// Feeding the status node just `lineWidth` would let the trail/wrap decision
// place the date inline with the line — on top of it. `pageHorizontalInset`
// is where the text lands in self-coords; `pageLayoutHorizontalInset` is the same
// edge in page-coords (the status node sits at x=pageHorizontalInset in self, and
// the pageView sits at self-x `pageContentInset` inside containerNode).
let dateLayoutInput: ChatMessageDateAndStatusNode.LayoutInput
if mediaStatusFrame != nil {
// Overlaid pill: reactions live outside the bubble. Inline reactions, if any,
// render inside the pill via reactionSettings — mirroring media messages.
let inlineReactionSettings = shouldDisplayInlineDateReactions(message: EngineMessage(item.message), isPremium: item.associatedData.isPremium, forceInline: item.associatedData.forceInlineReactions) ? ChatMessageDateAndStatusNode.StandaloneReactionSettings() : nil
dateLayoutInput = .standalone(reactionSettings: item.presentationData.isPreview ? nil : inlineReactionSettings)
} else {
let trailingWidthToMeasure: CGFloat = lastTextLineFrame.map { $0.maxX - pageLayoutHorizontalInset } ?? 10000.0
dateLayoutInput = .trailingContent(contentWidth: trailingWidthToMeasure, reactionSettings: ChatMessageDateAndStatusNode.TrailingReactionSettings(displayInline: shouldDisplayInlineDateReactions(message: EngineMessage(item.message), isPremium: item.associatedData.isPremium, forceInline: item.associatedData.forceInlineReactions), preferAdditionalInset: false))
}
statusSuggestedWidthAndContinue = statusLayout(ChatMessageDateAndStatusNode.Arguments(
context: item.context,
presentationData: item.presentationData,
edited: edited && !item.presentationData.isPreview,
impressionCount: !item.presentationData.isPreview ? viewCount : nil,
dateText: dateText,
type: statusType,
layoutInput: dateLayoutInput,
constrainedSize: CGSize(width: boundingSize.width, height: .greatestFiniteMagnitude),
availableReactions: item.associatedData.availableReactions,
savedMessageTags: item.associatedData.savedMessageTags,
// Empty the status node's own reactions exactly when they are externalized
// (wantsReactionsOutside), NOT when the pill is shown — otherwise a structural
// "externalize" that the layout declines to pillify (e.g. a narrow collage cell)
// would render reactions both inline here AND in the external buttons node. When
// the pill IS shown, its `.standalone` input renders no reaction list regardless.
reactions: (item.presentationData.isPreview || wantsReactionsOutside) ? [] : dateReactionsAndPeers.reactions,
reactionPeers: wantsReactionsOutside ? [] : dateReactionsAndPeers.peers,
displayAllReactionPeers: item.message.id.peerId.namespace == Namespaces.Peer.CloudUser,
areReactionsTags: item.topMessage.areReactionsTags(accountPeerId: item.context.account.peerId),
areStarReactionsEnabled: item.associatedData.areStarReactionsEnabled,
messageEffect: item.topMessage.messageEffect(availableMessageEffects: item.associatedData.availableMessageEffects),
replyCount: dateReplies,
starsCount: starsCount,
isPinned: item.message.tags.contains(.pinned) && (!item.associatedData.isInPinnedListMode || isReplyThread),
hasAutoremove: item.message.isSelfExpiring,
canViewReactionList: canViewMessageReactionList(message: EngineMessage(item.topMessage)),
animationCache: item.controllerInteraction.presentationContext.animationCache,
animationRenderer: item.controllerInteraction.presentationContext.animationRenderer
))
}
if let statusSuggestedWidthAndContinue, !hasDraft, mediaStatusFrame == nil {
// Mirrors TextBubble: max(contentWidth, statusWidth + sideInsets), where
// sideInsets = left + right text inset (= pageHorizontalInset on each side).
// Skipped for the overlaid pill — the media already defines the bubble width.
boundingSize.width = max(boundingSize.width, statusSuggestedWidthAndContinue.0 + pageHorizontalInset * 2.0)
}
return (boundingSize.width, { boundingWidth in
// Non-pill: pass `boundingWidth - sideInsets` (mirrors TextBubble) so the
// right-aligned date lands at the right text inset. For the overlaid pill,
// pass the status node's own suggested width so its internal `leftOffset`
// (= passedWidth - layoutSize.width) is 0 — otherwise the date is shoved right
// of its backdrop pill (the pill is anchored at the node's own bounds). This
// mirrors ChatMessageInteractiveMediaNode, which calls the continue closure with
// the suggested width for the standalone image status.
let statusContinueWidth: CGFloat = mediaStatusFrame != nil ? (statusSuggestedWidthAndContinue?.0 ?? 0.0) : (boundingWidth - pageHorizontalInset * 2.0)
let statusSizeAndApply = statusSuggestedWidthAndContinue?.1(statusContinueWidth)
if let statusSizeAndApply, !hasDraft, mediaStatusFrame == nil {
// Status node anchor Y in the content node's space — mirrors the apply
// closure below.
let statusAnchorY: CGFloat
if let lastTextLineFrame {
// 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 {
statusAnchorY = 1.0 + streamingHeaderOffset
}
// Date's bottom edge: a trailing date sits ~1pt below the anchor; a wrapped
// date extends `statusHeight` below it. Leave ~6pt to the bubble's bottom
// edge, matching TextBubble's bottom inset.
let statusBottomEdge = statusAnchorY + max(1.0, statusSizeAndApply.0.height)
boundingSize.height = max(boundingSize.height, statusBottomEdge + 6.0)
}
return (boundingSize, { animation, _, info in
guard let self else {
return
}
self.item = item
self.containerNode.layer.cornerCurve = .circular
// If the bubble was recycled onto a different message while a full-text
// request was in flight, cancel it so this message never shows another's
// shimmer.
if let pendingId = self.requestFullRichTextMessageId, pendingId != item.message.id {
self.requestFullRichTextDisposable?.dispose()
self.requestFullRichTextDisposable = nil
self.requestFullRichTextMessageId = nil
self.updateShowMoreLoading(false)
}
// On the collapse→expand transition (tapping "Show more"), grow the bubble
// downward in screen space (inverted list offset direction) instead of pushing
// earlier messages up — matching the audio-transcription expand. The ListView
// clamps this to what fits, so "if possible" is handled for us. Only fires on a
// change, and never on the first apply (appliedShowMoreExpanded is nil).
if let appliedShowMoreExpanded = self.appliedShowMoreExpanded, appliedShowMoreExpanded != showMoreExpanded {
info?.setInvertOffsetDirection()
}
self.appliedShowMoreExpanded = showMoreExpanded
// Inset on all four sides — `boundingWidth` is the FINAL bubble width handed
// back by the bubble layout (it can exceed the width this node proposed), so
// the width term is relative to the bubble, not to the page.
animation.animator.updateFrame(layer: self.containerNode.layer, frame: CGRect(origin: CGPoint(x: pageContentInset, y: pageContentInset), size: CGSize(width: boundingWidth - pageContentInset * 2.0, height: boundingSize.height - pageContentInset * 2.0)), completion: nil)
// Four independent radii, because a merged bubble does not have one: a message
// grouped with the one above gets small top corners and full-size bottom ones.
// `chatMessageBubbleImageContentCorners` is the same helper the media bubble
// uses, so rich bubbles round exactly like a photo in the same merge position.
// `position` is already handed to this layout closure — the merge geometry
// needed no new plumbing from the bubble.
//
// A single `cornerRadius` cannot express this, so the container is masked by a
// path instead. Each radius is reduced by the inset to stay concentric with the
// bubble's own curve.
let imageCorners = chatMessageBubbleImageContentCorners(
relativeContentPosition: position,
normalRadius: layoutConstants.image.defaultCornerRadius,
mergedRadius: layoutConstants.image.mergedCornerRadius,
mergedWithAnotherContentRadius: layoutConstants.image.contentMergedCornerRadius,
layoutConstants: layoutConstants,
chatPresentationData: item.presentationData
)
self.applyContainerCorners(imageCorners, inset: pageContentInset, animation: animation)
if let statusSizeAndApply {
// Match TextBubble: anchor the status node's x at the fixed text-block
// left edge (not the last line's minX, which is large for nested
// content and shoves the right-aligned date off the bubble). The status
// node positions the date trailing/below relative to this origin.
let statusFrame: CGRect
if let mediaStatusFrame {
// Overlaid pill: anchor to the media item's bottom-right corner,
// inset by the standard image status insets. page-coord (px,py)
// maps to self-coord (px, 1.0 + py).
let insets = layoutConstants.image.statusInsets
// Full-width flush media frames are widened by instantPageV2MediaEdgeBleed
// (4pt) past the visible/clipped right edge; clamp to the content width so
// the pill's trailing inset matches image messages (6pt, not 2pt).
let visibleMaxX = min(mediaStatusFrame.maxX, pageLayout?.contentSize.width ?? mediaStatusFrame.maxX)
let statusX = visibleMaxX - insets.right - statusSizeAndApply.0.width
let statusY = 1.0 + mediaStatusFrame.maxY - insets.bottom - statusSizeAndApply.0.height
statusFrame = CGRect(origin: CGPoint(x: statusX, y: statusY + streamingHeaderOffset), size: statusSizeAndApply.0)
} else {
let statusFrameY: CGFloat
if let lastTextLineFrame {
// 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 {
statusFrameY = 1.0
}
statusFrame = CGRect(origin: CGPoint(x: pageHorizontalInset, y: statusFrameY + streamingHeaderOffset), size: statusSizeAndApply.0)
}
let statusNode = statusSizeAndApply.1(self.statusNode == nil ? .None : animation)
if self.statusNode !== statusNode {
self.statusNode?.removeFromSupernode()
self.statusNode = statusNode
self.addSubnode(statusNode)
statusNode.reactionSelected = { [weak self] _, value, sourceView in
guard let self, let item = self.item else {
return
}
item.controllerInteraction.updateMessageReaction(item.topMessage, .reaction(value), false, sourceView)
}
statusNode.openReactionPreview = { [weak self] gesture, sourceNode, value in
guard let self, let item = self.item else {
gesture?.cancel()
return
}
item.controllerInteraction.openMessageReactionContextMenu(item.topMessage, sourceNode, gesture, value)
}
statusNode.frame = statusFrame
} else {
animation.animator.updatePosition(layer: statusNode.layer, position: statusFrame.center, completion: nil)
animation.animator.updateBounds(layer: statusNode.layer, bounds: CGRect(origin: .zero, size: statusFrame.size), completion: nil)
}
} else if let statusNode = self.statusNode {
self.statusNode = nil
statusNode.removeFromSupernode()
}
if let forwardInfo = item.message.forwardInfo, forwardInfo.flags.contains(.isImported), let statusNode = self.statusNode {
statusNode.pressed = { [weak self] in
guard let self, let statusNode = self.statusNode, let item = self.item else {
return
}
item.controllerInteraction.displayImportedMessageTooltip(statusNode)
}
} else {
self.statusNode?.pressed = nil
}
// Kick the highlight job for any spec set we do not already have an answer for.
// Persisting mutates the message, which re-lays-out this bubble into a cache hit.
// Mirrors ChatMessageTextBubbleContentNode's codeHighlightState loop.
if !codeHighlightSpecs.isEmpty {
if let current = self.codeHighlightState, current.id == item.message.id, current.specs == codeHighlightSpecs {
} else {
if let codeHighlightState = self.codeHighlightState {
self.codeHighlightState = nil
codeHighlightState.disposable.dispose()
}
let disposable = MetaDisposable()
self.codeHighlightState = (item.message.id, codeHighlightSpecs, disposable)
disposable.set(asyncUpdateMessageSyntaxHighlight(engine: item.context.engine, messageId: item.message.id, current: cachedMessageSyntaxHighlight, specs: codeHighlightSpecs).startStrict(completed: {
}))
}
} else if let codeHighlightState = self.codeHighlightState {
self.codeHighlightState = nil
codeHighlightState.disposable.dispose()
}
if let pageLayout, let pageWebpage, let resolvedContent {
self.currentPageLayout = (
suggestedBoundingWidth,
ObjectIdentifier(item.presentationData.theme.theme),
item.presentationData.fontSize.baseDisplaySize,
self.currentExpandedDetails,
self.currentExpandedQuotePaths,
item.message.stableVersion,
(item.attributes.updatingMedia?.richText).map({ ObjectIdentifier($0) }),
resolvedContent.key,
showMoreExpanded,
cachedMessageSyntaxHighlight,
pageLayout
)
let pageView = self.ensurePageView(
item: item,
webpage: pageWebpage,
page: pageResolvedInstantPage ?? resolvedContent.instantPage,
richPageKey: resolvedContent.key,
showMoreExpanded: showMoreExpanded,
structure: pageStructure,
isStreaming: hasDraft || hadDraft,
animation: animation
)
if self.checkboxesInteractive(item: item, resolved: resolvedContent) {
pageView.checkboxTapped = { [weak self] path, newValue in
guard let self, let item = self.item else {
return
}
item.controllerInteraction.toggleMessageRichTextCheckbox(item.message.id, path, newValue)
}
} else {
pageView.checkboxTapped = nil
}
pageView.buttonTapped = { [weak self] button, progress in
guard let self else {
return
}
// Reuse the whole bot-button dispatch by synthesising the
// ReplyMarkupButton it expects. Only InlineButtonType-derived actions
// can occur on a page button, so .text (which would sendMessage) is
// unreachable here.
self.performRichTextButtonAction?(ReplyMarkupButton(
title: button.text.plainText,
titleWhenForwarded: nil,
action: button.action,
style: nil
), progress)
}
pageView.documentTapped = { [weak self] file in
guard let self else {
return
}
self.openRichTextDocument?(file)
}
pageView.update(layout: pageLayout, theme: pageTheme, animation: animation)
// Flush inside `containerNode`, which supplies the inset on every side.
// This used to be -1, cancelling the container's horizontal inset so the
// page ran under the clip and lost its leading 1pt rather than sitting in.
pageView.frame = CGRect(
origin: CGPoint(x: 0.0, y: streamingHeaderOffset),
size: pageLayout.contentSize
)
self.updatePageViewVisibilityRect()
if self.displayContentsUnderSpoilers {
pageView.setDisplayContentsUnderSpoilers(true, atLocation: nil, animated: false)
}
let showTextAsPlaceholder = item.associatedData.showTextAsPlaceholder
var isTranslating = resolvedContent.isTranslating
if showTextAsPlaceholder {
isTranslating = true
}
self.updateIsTranslating(isTranslating, showTextAsPlaceholder: showTextAsPlaceholder)
// Continue an in-flight anchor scroll that is waiting on a <details>
// expansion to re-lay-out. This runs on EVERY apply pass (not only the
// expand-triggered one), but only does anything while a scroll is pending
// — and scrollToAnchor is idempotent: each invocation either resolves and
// scrolls (clearing pending) or expands the next collapsed level, and the
// progress guard guarantees termination. So an unrelated relayout (theme,
// width, reactions) that lands mid-expand simply advances/no-ops the loop.
// Deferred via justDispatch to avoid re-entering layout from this apply.
if let pendingAnchor = self.pendingScrollAnchor {
Queue.mainQueue().justDispatch { [weak self] in
guard let self, self.pendingScrollAnchor == pendingAnchor else {
return
}
self.scrollToAnchor(pendingAnchor)
}
}
} else {
self.currentPageLayout = nil
self.updateIsTranslating(false, showTextAsPlaceholder: false)
self.pageView?.update(
layout: InstantPageV2Layout(contentSize: .zero, items: [], detailsIndices: []),
theme: pageTheme,
animation: animation
)
self.pageViewMessageKey = nil
}
// "Show more" link node.
if let showMoreLayoutResult, let showMoreFramePageLocal {
let showMoreTextNode = showMoreLayoutResult.1()
if self.showMoreTextNode !== showMoreTextNode {
self.showMoreTextNode?.removeFromSupernode()
self.showMoreTextNode = showMoreTextNode
showMoreTextNode.isUserInteractionEnabled = false
self.addSubnode(showMoreTextNode)
}
// Self-coords: the 1.0 mirrors statusFrameY's container offset; the page
// content sits 1pt below the content-node top.
showMoreTextNode.frame = CGRect(origin: CGPoint(x: pageHorizontalInset, y: 1.0 + showMoreFramePageLocal.minY), size: showMoreFramePageLocal.size)
// Keep the shimmer alive across intervening relayouts while loading.
if self.requestFullRichTextDisposable != nil, self.requestFullRichTextMessageId == item.message.id {
self.updateShowMoreLoading(true)
}
} else {
if let showMoreTextNode = self.showMoreTextNode {
self.showMoreTextNode = nil
showMoreTextNode.removeFromSupernode()
}
self.updateShowMoreLoading(false)
}
if let formattedDateUpdatePeriod = pageLayout?.formattedDateUpdatePeriod {
// Recreate the timer only when the period changes — unlike the TextBubble
// reference (ChatMessageTextBubbleContentNode), which rebuilds it every apply.
// The timer fires `requestFullUpdate`, which relays out and re-enters here; at
// a steady period this guard is false, so the running timer keeps its schedule
// instead of being reallocated (no per-apply churn, no firing-phase reset, no
// self-trigger loop). Do not "simplify" this to match the reference.
if self.relativeDateTimer?.period != formattedDateUpdatePeriod {
self.relativeDateTimer?.timer.invalidate()
let timer = SwiftSignalKit.Timer(timeout: Double(formattedDateUpdatePeriod), repeat: true, completion: { [weak self] in
self?.requestFullUpdate?(ControlledTransition(duration: 0.15, curve: .easeInOut, interactive: false))
}, queue: Queue.mainQueue())
self.relativeDateTimer = (timer, formattedDateUpdatePeriod)
timer.start()
}
} else if let (timer, _) = self.relativeDateTimer {
self.relativeDateTimer = nil
timer.invalidate()
}
// === Streaming state apply ===
// 1. Compute / cache the cost map.
// Reuse the cost map computed in the layout pass (the bubble's
// 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. Drive the reveal controller.
let previousAnimateGlyphCount: Int? = (hasDraft || hadDraft) ? (self.textRevealController?.currentGlyphCount ?? 0) : nil
if previousAnimateGlyphCount != nil || self.textRevealController != nil || hasDraft || hadDraft {
if hasDraft {
self.statusNode?.alpha = 0.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 = seedCount
self.updateTextRevealAnimation(previousGlyphCount: previousAnimateGlyphCount ?? 0,
hasDraft: hasDraft,
hadDraft: hadDraft)
}
})
})
})
}
}
private func updateTextRevealAnimation(previousGlyphCount: Int, hasDraft: Bool, hadDraft: Bool) {
let toCount = self.currentRevealCostMap?.total ?? 0
let now = CACurrentMediaTime()
if hasDraft, let controller = self.textRevealController, controller.isFinalizing {
self.textRevealController = nil
self.textRevealLink = nil
}
if self.textRevealController == nil && (hasDraft || hadDraft) {
self.textRevealController = TextRevealController(initialRevealedCount: previousGlyphCount, initialLength: toCount, durationMultiplier: 10.0)
}
guard let controller = self.textRevealController else { return }
if hasDraft {
controller.observeUpdate(latestLength: toCount, at: now)
} else if hadDraft {
controller.finalize(finalLength: toCount)
}
if controller.isFinalizing && controller.revealedCount >= Double(controller.latestLength) {
self.textRevealController = nil
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
}
guard toCount > 0 else { return }
if self.textRevealLink == nil {
self.textRevealLink = SharedDisplayLinkDriver.shared.add { [weak self] _ in
guard let self else { return }
guard let item = self.item else {
self.textRevealController = nil
self.textRevealLink = nil
return
}
guard let controller = self.textRevealController, let costMap = self.currentRevealCostMap else {
self.textRevealLink = nil
return
}
let now = CACurrentMediaTime()
let (revealedGlyphCount, isComplete) = controller.tick(now: now)
if isComplete {
self.textRevealController = nil
self.textRevealLink = nil
self.pageView?.applyReveal(revealedCount: nil, costMap: nil, animated: false)
self.lastAppliedRevealedCount = 0
if 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)
}
self.requestFullUpdate?(ControlledTransition(duration: 0.15, curve: .easeInOut, interactive: false))
} else {
// If the revealed prefix's bottom y would change at the new cursor (i.e.
// crossing a line/item boundary), trigger a full bubble re-layout so the
// bubble grows with the reveal. Mirrors TextBubble's
// `cachedLayout.sizeForCharacterCount(...)` check at lines 1209-1216.
var requestUpdate = false
if let pageLayout = self.currentPageLayout?.layout, self.lastAppliedRevealedCount != revealedGlyphCount {
let prevHeight = costMap.revealedContentSize(revealedCount: self.lastAppliedRevealedCount, layout: pageLayout).height
let newHeight = costMap.revealedContentSize(revealedCount: revealedGlyphCount, layout: pageLayout).height
if prevHeight != newHeight {
requestUpdate = true
}
}
self.pageView?.applyReveal(revealedCount: revealedGlyphCount, costMap: costMap, animated: true)
self.lastAppliedRevealedCount = revealedGlyphCount
if requestUpdate {
self.requestFullUpdate?(ControlledTransition(duration: 0.15, curve: .easeInOut, interactive: false))
}
}
}
}
}
/// Tightens the rich content's corners beyond what the inset alone requires.
///
/// Distinct from the inset compensation it sits next to: subtracting the inset is what keeps the
/// curve CONCENTRIC with the bubble's, and is not a matter of taste — this is the visual tuning
/// on top. Keep them separate so changing the inset does not silently change the look, and
/// vice versa.
private static let richBubbleExtraCornerRadiusReduction: CGFloat = 2.0
/// Clips `containerNode` to the bubble's four corner radii.
///
/// `CALayer.cornerRadius` carries a single value, which is enough only for an unmerged bubble;
/// a merged one has different radii top and bottom, so the clip is a mask path instead. Each
/// radius is reduced by `inset` so the curve stays concentric with the bubble's own — an inset
/// box that keeps the outer radius reads as a differently-rounded rectangle sitting inside it.
///
/// Called from the apply closure right after the container frame is set, so it reads the frame
/// that was just applied rather than the presented one.
private func applyContainerCorners(_ corners: ImageCorners, inset: CGFloat, animation: ListViewItemUpdateAnimation) {
let size = self.containerNode.frame.size
guard size.width > 0.0, size.height > 0.0 else {
return
}
let cornersTransition: ContainedViewLayoutTransition
if case let .System(duration, _) = animation {
cornersTransition = .animated(duration: duration, curve: .easeInOut)
} else {
cornersTransition = .immediate
}
// A radius can never exceed half the box, or opposite corners' arcs cross and the path
// inverts — reachable for a short bubble whose height is under twice the corner radius.
let limit = min(size.width, size.height) / 2.0
let radius: (ImageCorner) -> CGFloat = { corner in
return max(0.0, min(limit, corner.radius - inset - ChatMessageRichDataBubbleContentNode.richBubbleExtraCornerRadiusReduction + 5.0))
}
let topLeft = radius(corners.topLeft)
let topRight = radius(corners.topRight)
let bottomLeft = radius(corners.bottomLeft)
let bottomRight = radius(corners.bottomRight)
let radii = CornerRadii(topLeft: topLeft, topRight: topRight, bottomLeft: bottomLeft, bottomRight: bottomRight)
// Preferred path: the layer's own per-corner radii. It composites with the layer, so there is
// no offscreen mask pass, and it animates as a layer property.
if CALayer.cornerRadiiSupported {
if let maskLayer = self.containerCornerMaskLayer {
// Left over from an earlier layout on a build without the property.
self.containerNode.layer.mask = nil
self.containerCornerMaskLayer = nil
maskLayer.removeAllAnimations()
}
cornersTransition.updateCornerRadii(layer: self.containerNode.layer, cornerRadii: radii)
return
}
let path = CGMutablePath()
path.move(to: CGPoint(x: topLeft, y: 0.0))
path.addLine(to: CGPoint(x: size.width - topRight, y: 0.0))
path.addArc(tangent1End: CGPoint(x: size.width, y: 0.0), tangent2End: CGPoint(x: size.width, y: topRight), radius: topRight)
path.addLine(to: CGPoint(x: size.width, y: size.height - bottomRight))
path.addArc(tangent1End: CGPoint(x: size.width, y: size.height), tangent2End: CGPoint(x: size.width - bottomRight, y: size.height), radius: bottomRight)
path.addLine(to: CGPoint(x: bottomLeft, y: size.height))
path.addArc(tangent1End: CGPoint(x: 0.0, y: size.height), tangent2End: CGPoint(x: 0.0, y: size.height - bottomLeft), radius: bottomLeft)
path.addLine(to: CGPoint(x: 0.0, y: topLeft))
path.addArc(tangent1End: CGPoint(x: 0.0, y: 0.0), tangent2End: CGPoint(x: topLeft, y: 0.0), radius: topLeft)
path.closeSubpath()
let maskLayer: CAShapeLayer
let isNewMask: Bool
if let existing = self.containerCornerMaskLayer {
maskLayer = existing
isNewMask = false
} else {
maskLayer = CAShapeLayer()
self.containerCornerMaskLayer = maskLayer
self.containerNode.layer.mask = maskLayer
isNewMask = true
}
let previousPath = maskLayer.path
maskLayer.frame = CGRect(origin: CGPoint(), size: size)
maskLayer.path = path
// The mask does not follow the layer's frame animation on its own, so a growing bubble would
// clip to its old shape for the whole animation and snap at the end. Animate the path
// alongside. Skipped on the first application, where there is no previous shape to grow from.
if case let .animated(duration, curve) = cornersTransition, !isNewMask, let previousPath, previousPath != path {
maskLayer.animate(from: previousPath, to: path, keyPath: "path", timingFunction: curve.timingFunction, duration: duration, mediaTimingFunction: curve.mediaTimingFunction)
}
}
private func translationShimmerRects(pageView: InstantPageV2View) -> [CGRect] {
let pageOrigin = pageView.frame.origin
let entries = pageView.selectableTextItems()
.filter { $0.item.selectable && !$0.item.attributedString.string.isEmpty }
.map { entry in
InstantPageMultiTextAdapter.Entry(
item: entry.item,
frameOrigin: CGPoint(
x: entry.parentOffset.x + pageOrigin.x,
y: entry.parentOffset.y + pageOrigin.y
)
)
}
guard !entries.isEmpty else {
return []
}
let adapter = InstantPageMultiTextAdapter(entries: entries)
guard let text = adapter.currentText, text.length > 0, let rects = adapter.textRangeRects(in: NSRange(location: 0, length: text.length))?.rects else {
return []
}
return rects
}
private func updateIsTranslating(_ isTranslating: Bool, showTextAsPlaceholder: Bool) {
guard let item = self.item, let pageView = self.pageView else {
if let shimmeringNode = self.shimmeringNode {
self.shimmeringNode = nil
self.shimmeringNodeIsSkeleton = false
shimmeringNode.removeFromSupernode()
}
return
}
var rects = self.translationShimmerRects(pageView: pageView)
if isTranslating, !rects.isEmpty {
pageView.isHidden = showTextAsPlaceholder
let isIncoming = item.message.effectivelyIncoming(item.context.account.peerId)
let messageTheme = item.presentationData.theme.theme.chat.message
let color: UIColor
if showTextAsPlaceholder {
rects = rects.map { $0.insetBy(dx: 0.0, dy: 6.0 + UIScreenPixel) }
if rects.count == 2 {
rects[0].origin.y += 1.0
rects[1].origin.y -= 1.0
}
color = isIncoming ? messageTheme.incoming.secondaryTextColor.withMultipliedAlpha(0.25) : messageTheme.outgoing.secondaryTextColor.withMultipliedAlpha(0.25)
} else if item.presentationData.theme.theme.overallDarkAppearance {
color = isIncoming ? messageTheme.incoming.primaryTextColor.withAlphaComponent(0.1) : messageTheme.outgoing.primaryTextColor.withAlphaComponent(0.1)
} else {
color = isIncoming ? messageTheme.incoming.accentTextColor.withAlphaComponent(0.1) : messageTheme.outgoing.secondaryTextColor.withAlphaComponent(0.1)
}
let shimmeringNode: ShimmeringLinkNode
let isNew: Bool
if let current = self.shimmeringNode, self.shimmeringNodeIsSkeleton == showTextAsPlaceholder {
shimmeringNode = current
isNew = false
} else {
self.shimmeringNode?.removeFromSupernode()
shimmeringNode = ShimmeringLinkNode(color: color, isSkeleton: showTextAsPlaceholder)
self.shimmeringNode = shimmeringNode
self.shimmeringNodeIsSkeleton = showTextAsPlaceholder
self.containerNode.insertSubnode(shimmeringNode, at: 0)
isNew = true
}
shimmeringNode.updateRects(rects, color: color)
shimmeringNode.frame = self.containerNode.bounds
shimmeringNode.updateLayout(self.containerNode.bounds.size)
if isNew {
shimmeringNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
} else {
pageView.isHidden = false
if let shimmeringNode = self.shimmeringNode {
self.shimmeringNode = nil
self.shimmeringNodeIsSkeleton = false
shimmeringNode.alpha = 0.0
shimmeringNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak shimmeringNode] _ in
shimmeringNode?.removeFromSupernode()
})
}
}
}
override public func animateInsertion(_ currentTimestamp: Double, duration: Double) {
if let statusNode = self.statusNode, statusNode.alpha != 0.0 {
statusNode.layer.animateAlpha(from: 0.0, to: statusNode.alpha, duration: 0.2)
}
}
override public func animateAdded(_ currentTimestamp: Double, duration: Double) {
if let statusNode = self.statusNode, statusNode.alpha != 0.0 {
statusNode.layer.animateAlpha(from: 0.0, to: statusNode.alpha, duration: 0.2)
}
}
override public func animateRemoved(_ currentTimestamp: Double, duration: Double) {
if let statusNode = self.statusNode, statusNode.alpha != 0.0 {
statusNode.layer.animateAlpha(from: statusNode.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false)
}
}
override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction {
if case .tap = gesture {
} else {
if let item = self.item, let subject = item.associatedData.subject, case .messageOptions = subject {
return ChatMessageBubbleContentTapAction(content: .none)
}
}
// Resolved FIRST, and for every gesture: an unsupported pill's Update button is a real
// `UIButton` inside the page view, and unless the bubble steps aside here its tap recognizer
// claims the touch and cancels the button's tracking, so `touchUpInside` never fires — the
// button highlights and then does nothing. `.ignore` is what makes the recognizer fail
// (ChatMessageBubbleItemNode:1355), which is also how the standalone
// `ChatMessageUnsupportedBubbleContentNode` keeps the same button alive.
//
// Before the collapsible-quote toggle in particular: a pill inside a collapsed quote must
// still hand its button the tap rather than expanding the quote under it.
if self.unsupportedActionContains(point) {
return ChatMessageBubbleContentTapAction(content: .ignore)
}
if case .tap = gesture, let showMoreTextNode = self.showMoreTextNode, showMoreTextNode.frame.contains(point) {
// Highlight rect in containerNode-local coords (the highlight overlay lives inside
// containerNode, which sits at self (1, 1); the text node is on self).
let rects = [showMoreTextNode.frame.offsetBy(dx: -1.0, dy: -1.0)]
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
self?.activateShowMore()
}), rects: rects)
}
let entityHit = self.entityForTapLocation(point)
if case .tap = gesture, !self.displayContentsUnderSpoilers, let entityHit, entityHit.attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)] != nil {
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
self?.revealSpoilers(atContentPoint: point)
}))
}
if let entityHit, entityHit.attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.TonAddress)] != nil {
guard let content = self.entityTapContent(entityHit.attributes) else {
return ChatMessageBubbleContentTapAction(content: .none)
}
return ChatMessageBubbleContentTapAction(
content: content,
rects: self.computeHighlightRects(item: entityHit.item, parentOffset: entityHit.parentOffset, localPoint: entityHit.localPoint),
activate: self.makeActivate(item: entityHit.item, parentOffset: entityHit.parentOffset, localPoint: entityHit.localPoint)
)
}
guard let urlHit = self.urlForTapLocation(point) else {
if let entityHit {
let rects = self.computeHighlightRects(item: entityHit.item, parentOffset: entityHit.parentOffset, localPoint: entityHit.localPoint)
// A link-styled page button (richButtonStyle link:flags.3) whose action is not a URL,
// so it carries the button rather than an InstantPageUrlItem and cannot use the url
// arm below.
//
// `.custom` is the only content case that fits, and ChatMessageBubbleItemNode's
// `.custom` arm (:6021) calls the closure but IGNORES `tapAction.activate` — so the
// closure mints the progress promise itself. `makeActivate` is the call that wires
// the shimmer over the tapped rects, which is how a link-styled `.callback` gets the
// same loading treatment a pill does.
if let actionItem = entityHit.attributes[NSAttributedString.Key(rawValue: InstantPageButtonActionAttribute)] as? InstantPageButtonActionItem {
let activate = self.makeActivate(item: entityHit.item, parentOffset: entityHit.parentOffset, localPoint: entityHit.localPoint)
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
guard let self else {
return
}
let progress = activate?() ?? Promise<Bool>()
// Mirrors the pill's dispatch (:1056): synthesise the ReplyMarkupButton the
// bot-button handler expects. Only InlineButtonType-derived actions can occur
// on a page button, so .text (which would sendMessage) is unreachable here.
self.performRichTextButtonAction?(ReplyMarkupButton(
title: actionItem.button.text.plainText,
titleWhenForwarded: nil,
action: actionItem.button.action,
style: nil
), progress)
}), rects: rects)
}
if let content = self.entityTapContent(entityHit.attributes) {
return ChatMessageBubbleContentTapAction(
content: content,
rects: rects,
activate: self.makeActivate(item: entityHit.item, parentOffset: entityHit.parentOffset, localPoint: entityHit.localPoint)
)
}
}
if let action = self.collapsibleQuoteTapAction(point) {
return action
}
return ChatMessageBubbleContentTapAction(content: .none)
}
let split = self.splitAnchor(urlHit.urlItem.url)
if split.base.isEmpty, let anchor = split.anchor {
// Don't accept intra-message anchor taps while the message is still streaming.
if let item = self.item, item.message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) {
return ChatMessageBubbleContentTapAction(content: .none)
}
let rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
self?.scrollToAnchor(anchor)
}), rects: rects)
}
if let webpage = self.currentLoadedWebpage(), webpage.content.url == split.base, let anchor = split.anchor {
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
self?.scrollToAnchor(anchor)
}))
}
// Default to concealed=true: InstantPageTextItem does not expose a clean
// "attribute substring with displayed range" API, so we cannot compare
// displayed text to the resolved URL the way the chat text bubble does.
// The chat URL handler will show a confirmation when concealed is true
// and the visible text differs from the destination — safer default.
let concealed = true
let url = ChatMessageBubbleContentTapAction.Url(url: urlHit.urlItem.url, concealed: concealed, allowInlineWebpageResolution: urlHit.urlItem.webpageId != nil)
let rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
if let webpageId = urlHit.urlItem.webpageId {
let split = self.splitAnchor(url.url)
return ChatMessageBubbleContentTapAction(
content: .externalInstantPage(url: url, webpageId: webpageId, anchor: split.anchor),
rects: rects,
activate: self.makeActivate(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
)
} else {
return ChatMessageBubbleContentTapAction(
content: .url(url),
rects: rects,
activate: self.makeActivate(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
)
}
}
/// True when `point` (this node's coords) is inside the Update button of an unsupported-content
/// pill in the rendered page. The page answers from its LAYOUT — during touch arbitration there
/// is no useful way to ask the pill view, and a nested pill (details body, table cell) must be
/// found too.
private func unsupportedActionContains(_ point: CGPoint) -> Bool {
guard let pageView = self.pageView else {
return false
}
return pageView.unsupportedActionFrame(at: self.view.convert(point, to: pageView)) != nil
}
/// Toggling a collapsed quote, resolved LAST in `tapActionAtPoint`: a URL, button or entity inside
/// the visible three lines wins over the expand toggle. `.custom` is the content case for an action
/// with no chat-level meaning of its own — the same one a link-styled page button uses.
private func collapsibleQuoteTapAction(_ point: CGPoint) -> ChatMessageBubbleContentTapAction? {
guard let pageView = self.pageView else {
return nil
}
let local = self.view.convert(point, to: pageView)
guard let path = pageView.collapsibleQuoteAt(point: local) else {
return nil
}
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
guard let self, let item = self.item else {
return
}
if self.currentExpandedQuotePaths.contains(path) {
self.currentExpandedQuotePaths.remove(path)
} else {
self.currentExpandedQuotePaths.insert(path)
}
item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
}))
}
private func textItemAtLocation(_ location: CGPoint) -> (item: InstantPageTextItem, parentOffset: CGPoint)? {
guard let pageView = self.pageView else { return nil }
let local = self.view.convert(location, to: pageView)
return pageView.textItemAt(point: local)
}
private func urlForTapLocation(_ point: CGPoint) -> (item: InstantPageTextItem, urlItem: InstantPageUrlItem, parentOffset: CGPoint, localPoint: CGPoint)? {
guard let pageView = self.pageView else { return nil }
let local = self.view.convert(point, to: pageView)
return pageView.urlItemAt(point: local).map {
(item: $0.item, urlItem: $0.urlItem, parentOffset: $0.parentOffset, localPoint: $0.localPoint)
}
}
private func entityForTapLocation(_ point: CGPoint) -> (item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint, attributes: [NSAttributedString.Key: Any])? {
guard let pageView = self.pageView else { return nil }
let local = self.view.convert(point, to: pageView)
guard let hit = pageView.textItemAt(point: local) else { return nil }
let localPoint = CGPoint(x: local.x - hit.parentOffset.x, y: local.y - hit.parentOffset.y)
guard let (_, attributes) = hit.item.attributesAtPoint(localPoint, orNearest: false) else { return nil }
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)
}
/// Whether a tap on these attributes does anything.
///
/// LOAD-BEARING that `tapActionAtPoint` and `updateTouchesAtPoint` agree on this set: the first
/// decides what a tap DOES, the second whether it lights up. A link-styled button is not an
/// `entityTapContent` case — `tapActionAtPoint` handles it separately, because it needs the hit
/// geometry that `entityTapContent` deliberately does not take — so gating the highlight on
/// `entityTapContent` alone gave a control that acted on tap but never highlighted.
private func entityIsTappable(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
if attributes[NSAttributedString.Key(rawValue: InstantPageButtonActionAttribute)] is InstantPageButtonActionItem {
return true
}
return self.entityTapContent(attributes) != nil
}
private func entityTapContent(_ attributes: [NSAttributedString.Key: Any]) -> ChatMessageBubbleContentTapAction.Content? {
if let tonAddress = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.TonAddress)] as? InstantPageTonAddressItem {
if attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)] != nil, !self.displayContentsUnderSpoilers {
return nil
}
if let item = self.item, item.associatedData.isSuspiciousPeer, item.message.effectivelyIncoming(item.context.account.peerId) {
return nil
}
guard tonAddress.address.utf8.count == 48, WalletContext.transferAddress(from: tonAddress.address) != nil else {
return nil
}
return .tonAddress(tonAddress.address)
} else if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention {
return .peerMention(peerId: mention.peerId, mention: mention.mention, openProfile: false)
} else if let peerName = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String {
return .textMention(peerName)
} else if let botCommand = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String {
return .botCommand(botCommand)
} else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag {
// Cashtags are carried as a Hashtag attribute (no dedicated cashtag key/tap-action exists);
// the leading "$" in the string distinguishes them, and the chat hashtag handler searches both.
return .hashtag(hashtag.peerName, hashtag.hashtag)
} else if let bankCard = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BankCard)] as? String {
return .bankCard(bankCard)
} else if let date = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Date)] as? Int32 {
// The displayed string is unused downstream (ChatMessageBubbleItemNode matches `.date(date, _)`).
return .date(date, "")
}
return nil
}
/// Bridges an InstantPageUrlItem (used by the gallery's caption URL handler) to the
/// chat layer's URL handler. `concealed: true` matches `tapActionAtPoint` for the same
/// reason: V2 cannot reliably compare displayed link text to the resolved URL.
private func openInstantPageUrl(_ url: InstantPageUrlItem) {
guard let item = self.item else { return }
item.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl(
url: url.url,
concealed: true,
allowInlineWebpageResolution: url.webpageId != nil
))
}
private func computeHighlightRects(item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint) -> [CGRect] {
// Text item returns rects in its local coords; translate back into containerNode-local coords.
// containerNode is offset by (1, 1) from the bubble-content-node, but the highlight overlay lives
// *inside* containerNode, so we use layout-coords (= containerNode-local) for the rects.
let originX = parentOffset.x
let originY = parentOffset.y
return item.linkSelectionRects(at: localPoint).map { rect in
rect.offsetBy(dx: originX, dy: originY)
}
}
private func makeActivate(item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint) -> (() -> Promise<Bool>?)? {
return { [weak self, weak item] in
guard let self else {
return nil
}
let promise = Promise<Bool>()
self.linkProgressDisposable?.dispose()
if self.linkProgressRects != nil {
self.linkProgressRects = nil
self.updateLinkProgressState()
}
self.linkProgressDisposable = (promise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in
guard let self else {
return
}
let updated: [CGRect]?
if value, let item {
updated = self.computeHighlightRects(item: item, parentOffset: parentOffset, localPoint: localPoint)
} else {
updated = nil
}
let changed: Bool
if let lhs = self.linkProgressRects, let rhs = updated {
changed = lhs != rhs
} else {
changed = (self.linkProgressRects == nil) != (updated == nil)
}
if changed {
self.linkProgressRects = updated
self.updateLinkProgressState()
}
})
return promise
}
}
private func updateLinkProgressState() {
guard let messageItem = self.item else {
return
}
if let rects = self.linkProgressRects, !rects.isEmpty {
let linkProgressView: TextLoadingEffectView
if let current = self.linkProgressView {
linkProgressView = current
} else {
linkProgressView = TextLoadingEffectView(frame: CGRect())
self.linkProgressView = linkProgressView
self.containerNode.view.addSubview(linkProgressView)
}
linkProgressView.frame = self.containerNode.bounds
let progressColor: UIColor = messageItem.message.effectivelyIncoming(messageItem.context.account.peerId)
? messageItem.presentationData.theme.theme.chat.message.incoming.linkHighlightColor
: messageItem.presentationData.theme.theme.chat.message.outgoing.linkHighlightColor
linkProgressView.update(color: progressColor, size: self.containerNode.bounds.size, rects: rects)
} else if let linkProgressView = self.linkProgressView {
self.linkProgressView = nil
linkProgressView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak linkProgressView] _ in
linkProgressView?.removeFromSuperview()
})
}
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let statusNode = self.statusNode, statusNode.supernode != nil, let result = statusNode.hitTest(self.view.convert(point, to: statusNode.view), with: event) {
return result
}
return super.hitTest(point, with: event)
}
override public func updateTouchesAtPoint(_ point: CGPoint?) {
guard let messageItem = self.item else {
return
}
var rects: [CGRect]?
if let point {
if let showMoreTextNode = self.showMoreTextNode, showMoreTextNode.frame.contains(point) {
rects = [showMoreTextNode.frame.offsetBy(dx: -1.0, dy: -1.0)]
} else if let entityHit = self.entityForTapLocation(point), entityHit.attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.TonAddress)] != nil {
if self.entityIsTappable(entityHit.attributes) {
rects = self.computeHighlightRects(item: entityHit.item, parentOffset: entityHit.parentOffset, localPoint: entityHit.localPoint)
}
} else if let urlHit = self.urlForTapLocation(point) {
rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
} else if let entityHit = self.entityForTapLocation(point), self.entityIsTappable(entityHit.attributes) {
rects = self.computeHighlightRects(item: entityHit.item, parentOffset: entityHit.parentOffset, localPoint: entityHit.localPoint)
}
}
if let rects, !rects.isEmpty {
let highlightingNode: LinkHighlightingNode
if let current = self.linkHighlightingNode {
highlightingNode = current
} else {
let color: UIColor = messageItem.message.effectivelyIncoming(messageItem.context.account.peerId)
? messageItem.presentationData.theme.theme.chat.message.incoming.linkHighlightColor
: messageItem.presentationData.theme.theme.chat.message.outgoing.linkHighlightColor
highlightingNode = LinkHighlightingNode(color: color)
highlightingNode.useModernPathCalculation = true
self.linkHighlightingNode = highlightingNode
self.containerNode.insertSubnode(highlightingNode, at: 0)
}
highlightingNode.frame = self.containerNode.bounds
highlightingNode.updateRects(rects)
} else if let highlightingNode = self.linkHighlightingNode {
self.linkHighlightingNode = nil
highlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak highlightingNode] _ in
highlightingNode?.removeFromSupernode()
})
}
}
override public func updateSearchTextHighlightState(text: String?, messages: [EngineMessage.Index]?) {
}
/// Removes the selection overlay built by `updateIsExtractedToContextPreview(true)`.
///
/// LOAD-BEARING that every un-extract path reaches this. `TextSelectionNode.hitTest` claims
/// EVERY point inside its bounds (it is sized to `containerNode.bounds`), and it is the topmost
/// subnode of `containerNode` — so while it is attached it becomes the hit view for the whole
/// bubble content. UIKit collects a touch's gesture recognizers from the hit view UPWARD, and
/// the page's media taps live on recognizers INSIDE `pageView` — a sibling branch, not an
/// ancestor — so a leftover selection node silently kills tap-to-open on every image and video
/// in the message. Bubble-level taps (links, buttons, quote toggles) keep working, because that
/// recognizer sits on the item node, an ancestor of the hit view: the failure presents as
/// "only media stopped reacting".
///
/// The adapter is removed from its supernode too. It is `isUserInteractionEnabled = false`, so
/// leaving it attached did not block touches — but it holds the whole page's attributed text.
private func tearDownTextSelection(animated: Bool) {
if let adapter = self.textSelectionAdapter {
self.textSelectionAdapter = nil
adapter.removeFromSupernode()
}
guard let textSelectionNode = self.textSelectionNode else {
return
}
self.textSelectionNode = nil
if animated {
textSelectionNode.highlightAreaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
textSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak textSelectionNode] _ in
textSelectionNode?.highlightAreaNode.removeFromSupernode()
textSelectionNode?.removeFromSupernode()
})
} else {
textSelectionNode.highlightAreaNode.removeFromSupernode()
textSelectionNode.removeFromSupernode()
}
}
override public func willUpdateIsExtractedToContextPreview(_ value: Bool) {
if !value {
self.tearDownTextSelection(animated: true)
}
}
override public func updateIsExtractedToContextPreview(_ value: Bool) {
// The un-extract must be handled HERE as well as in `willUpdate…`, because the two hooks are
// separate closures on the item node (`ChatMessageBubbleItemNode`'s
// `willUpdateIsExtractedToContextPreview` vs `isExtractedToContextPreviewUpdated`) and the
// SEND ANIMATION only fires the second one: `ChatMessageTransitionNode` sets
// `isExtractedToContextPreview` + calls `isExtractedToContextPreviewUpdated?(true/false)`
// and never touches `willUpdate…`. Handling `false` only in `willUpdate…` therefore left the
// selection overlay attached on every message sent from the composer. This mirrors
// `ChatMessageTextBubbleContentNode`, which tears down in both hooks for the same reason.
if !value {
self.tearDownTextSelection(animated: true)
return
}
guard self.textSelectionNode == nil, let messageItem = self.item, self.currentPageLayout?.layout != nil, let pageView = self.pageView, let rootNode = messageItem.controllerInteraction.chatControllerNode() else {
return
}
// pageView sits flush at (0, 0) inside containerNode; the adapter is placed at
// containerNode.bounds, so shift each item's page-space origin into
// containerNode-local coords for the adapter to operate in.
let pageOrigin = pageView.frame.origin
let entries = pageView.selectableTextItems()
.filter { $0.item.selectable && !$0.item.attributedString.string.isEmpty }
.map { entry in
InstantPageMultiTextAdapter.Entry(
item: entry.item,
frameOrigin: CGPoint(
x: entry.parentOffset.x + pageOrigin.x,
y: entry.parentOffset.y + pageOrigin.y
)
)
}
guard !entries.isEmpty else {
return
}
let adapter = InstantPageMultiTextAdapter(entries: entries)
adapter.frame = self.containerNode.bounds
self.textSelectionAdapter = adapter
self.containerNode.addSubnode(adapter)
let incoming = messageItem.message.effectivelyIncoming(messageItem.context.account.peerId)
let theme = messageItem.presentationData.theme.theme
let selectionColor = incoming ? theme.chat.message.incoming.textSelectionColor : theme.chat.message.outgoing.textSelectionColor
let knobColor = incoming ? theme.chat.message.incoming.textSelectionKnobColor : theme.chat.message.outgoing.textSelectionKnobColor
let textSelectionNode = TextSelectionNode(
theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: theme.overallDarkAppearance),
strings: messageItem.presentationData.strings,
textNodeOrView: .node(adapter),
updateIsActive: { _ in },
present: { [weak self] c, a in
guard let self, let item = self.item else {
return
}
if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info {
item.controllerInteraction.presentControllerInCurrent(c, a)
} else {
item.controllerInteraction.presentGlobalOverlayController(c, a)
}
},
rootView: { [weak rootNode] in
return rootNode?.view
},
performAction: { [weak self] text, action in
guard let self, let item = self.item else {
return
}
if case .copy = action,
let range = self.textSelectionNode?.getSelection(),
range.length > 0,
let adapter = self.textSelectionAdapter {
let markdown = adapter.markdownForRange(range)
if !markdown.isEmpty {
item.controllerInteraction.performTextSelectionAction(item.message, true, NSAttributedString(string: markdown), nil, .copy)
return
}
}
item.controllerInteraction.performTextSelectionAction(item.message, true, text, nil, action)
}
)
let enableCopy = (!messageItem.associatedData.isCopyProtectionEnabled && !messageItem.message.isCopyProtected()) || messageItem.message.id.peerId.isVerificationCodes
textSelectionNode.enableCopy = enableCopy
var enableOtherActions = true
if let subject = messageItem.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info {
enableOtherActions = false
}
textSelectionNode.enableQuote = false
textSelectionNode.enableTranslate = enableOtherActions
textSelectionNode.enableShare = enableOtherActions && enableCopy
textSelectionNode.enableLookup = true
textSelectionNode.menuSkipCoordnateConversion = !enableOtherActions
textSelectionNode.frame = self.containerNode.bounds
textSelectionNode.highlightAreaNode.frame = self.containerNode.bounds
self.containerNode.insertSubnode(textSelectionNode.highlightAreaNode, at: 0)
self.containerNode.addSubnode(textSelectionNode)
self.textSelectionNode = textSelectionNode
}
override public func transitionNode(messageId: EngineMessage.Id, media: EngineRawMedia, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
// V2 V0: media items render as gray placeholders; no transition node is exposed.
return nil
}
override public func updateHiddenMedia(_ media: [EngineRawMedia]?) -> Bool {
// V2 V0: media items render as gray placeholders; nothing to hide.
return false
}
override public func getAnchorRect(anchor: String) -> CGRect? {
guard let pageView = self.pageView, let rect = pageView.anchorFrame(name: anchor) else {
return nil
}
// Small top breathing room so the target isn't flush against the content-area top
// (cf. V1 InstantPageControllerNode's -10 offset). The chat scroll consumes only the
// returned rect's minY (ChatController's scrollToMessageIdWithAnchor → .bottom(anchorY)),
// so pulling minY up by the margin is what lands the anchor below the top edge; the rect
// is grown to keep maxY stable should a future caller use the full rect (e.g. a highlight).
let topMargin: CGFloat = 8.0
let adjusted = CGRect(x: rect.minX, y: max(0.0, rect.minY - topMargin), width: rect.width, height: rect.height + topMargin)
return self.view.convert(adjusted, from: pageView)
}
override public func unsupportedContentAreas() -> [CGRect] {
guard let layout = self.currentPageLayout?.layout, let pageView = self.pageView else {
return []
}
// Converting through the view hierarchy rather than re-deriving `pageContentInset` and the
// streaming-header offset by hand: those are the layout pass's business, and a second copy
// would drift. Safe mid-animation because every `ControlledTransitionAnimator.updateFrame`
// writes the model layer's position and bounds synchronously and animates *from* the old
// value, so the hierarchy already reports target geometry once apply returns.
return unsupportedContentTearZones(in: layout).map { self.view.convert($0, from: pageView) }
}
override public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? {
if let statusNode = self.statusNode, !statusNode.isHidden {
return statusNode.reactionView(value: value)
}
return nil
}
override public func messageEffectTargetView() -> UIView? {
if let statusNode = self.statusNode, !statusNode.isHidden {
return statusNode.messageEffectTargetView()
}
return nil
}
override public func getStatusNode() -> ASDisplayNode? {
return self.statusNode
}
private func splitAnchor(_ url: String) -> (base: String, anchor: String?) {
if let anchorRange = url.range(of: "#") {
let anchor = String(url[anchorRange.upperBound...]).removingPercentEncoding
let base = String(url[..<anchorRange.lowerBound])
return (base, anchor)
}
return (url, nil)
}
private func currentLoadedWebpage() -> TelegramMediaWebpage? {
return nil // V2 V0: media items are placeholders; no inline webpage resolution.
}
private func scrollToAnchor(_ anchor: String) {
guard let item = self.item else {
return
}
// Empty fragment ("#") is a no-op.
if anchor.isEmpty {
self.clearPendingScroll()
return
}
// 1. Anchor is in the currently laid-out content → scroll now.
if self.pageView?.anchorFrame(name: anchor) != nil {
self.clearPendingScroll()
item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor)
return
}
// 2. Not laid out — it may be buried in a collapsed <details>. Find the path and expand
// the first collapsed details on it, then retry after the relayout (post-relayout hook).
let anchorExpanded = (self.showMoreExpanded?.messageId == item.message.id) ? (self.showMoreExpanded?.value ?? false) : false
guard let resolvedContent = ChatMessageRichDataBubbleContentNode.resolvedRichDataContent(item: item, showMoreExpanded: anchorExpanded),
let path = instantPageAnchorPath(in: resolvedContent.instantPage, name: anchor),
!path.isEmpty,
let collapsedIndex = self.pageView?.firstCollapsedDetails(forOrdinalPath: path)
else {
self.clearPendingScroll()
return
}
// Progress guard: if expanding this same index last pass didn't move us forward, stop.
if self.lastExpandedPendingDetailsIndex == collapsedIndex {
self.clearPendingScroll()
return
}
self.currentExpandedDetails[collapsedIndex] = true
self.pendingScrollAnchor = anchor
self.lastExpandedPendingDetailsIndex = collapsedIndex
item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
}
private func clearPendingScroll() {
self.pendingScrollAnchor = nil
self.lastExpandedPendingDetailsIndex = nil
}
// Fired by the "Show more" tap action. Expands this bubble to the full page: if the attribute
// already carries a cached fullInstantPage, expands immediately; otherwise fetches it (which
// persists it onto the message) while shimmering the link, then expands. Guards against a
// second request while one is in flight, and against re-expanding an already-expanded bubble.
private func activateShowMore() {
guard let item = self.item, let attribute = item.message.richText else {
return
}
let messageId = item.message.id
if let state = self.showMoreExpanded, state.messageId == messageId, state.value {
return
}
// Full page already cached on the attribute — expand immediately, no network, no shimmer.
if attribute.fullInstantPage != nil {
self.showMoreExpanded = (messageId, true)
item.controllerInteraction.requestMessageUpdate(messageId, false, nil)
return
}
// Otherwise fetch it; keep the link visible and shimmering until it arrives.
if self.requestFullRichTextDisposable != nil {
return
}
self.requestFullRichTextMessageId = messageId
self.updateShowMoreLoading(true)
self.requestFullRichTextDisposable = (item.context.engine.messages.requestFullRichText(id: messageId)
|> deliverOnMainQueue).startStrict(next: { [weak self] result in
guard let self else {
return
}
if result?.fullInstantPage != nil {
self.showMoreExpanded = (messageId, true)
}
self.finishShowMore()
if let item = self.item, item.message.id == messageId {
item.controllerInteraction.requestMessageUpdate(messageId, false, nil)
}
}, completed: { [weak self] in
self?.finishShowMore()
})
}
// Clears the in-flight request state and stops the shimmer. Invoked from both the request's
// `next` and `completed` handlers (the signal emits one value then completes); idempotent.
private func finishShowMore() {
self.requestFullRichTextDisposable?.dispose()
self.requestFullRichTextDisposable = nil
self.requestFullRichTextMessageId = nil
self.updateShowMoreLoading(false)
}
// Shows/hides the shimmer over the "Show more" text node. The TextLoadingEffectView masks
// itself with the text node's own range rects, so it is placed at the text node's frame in
// self-coordinates (same parent). Removing the text node also removes the shimmer.
private func updateShowMoreLoading(_ loading: Bool) {
guard let item = self.item, let showMoreTextNode = self.showMoreTextNode else {
if let loadingView = self.showMoreLoadingView {
self.showMoreLoadingView = nil
loadingView.removeFromSuperview()
}
return
}
if loading {
let loadingView: TextLoadingEffectView
if let current = self.showMoreLoadingView {
loadingView = current
} else {
loadingView = TextLoadingEffectView(frame: CGRect())
self.showMoreLoadingView = loadingView
self.view.addSubview(loadingView)
}
loadingView.frame = showMoreTextNode.frame
let color = item.message.effectivelyIncoming(item.context.account.peerId)
? item.presentationData.theme.theme.chat.message.incoming.linkTextColor
: item.presentationData.theme.theme.chat.message.outgoing.linkTextColor
let title = item.presentationData.strings.Chat_RichText_ShowMore
loadingView.update(color: color, textNode: showMoreTextNode, range: NSRange(location: 0, length: (title as NSString).length))
} else if let loadingView = self.showMoreLoadingView {
self.showMoreLoadingView = nil
loadingView.removeFromSuperview()
}
}
}
private func richDataBlockEndsWithVisualMedia(_ block: InstantPageBlock) -> Bool {
func captionIsEmpty(_ caption: InstantPageCaption) -> Bool {
return caption.text == .empty && caption.credit == .empty
}
switch block {
case let .image(_, caption, _, _, _),
let .video(_, caption, _, _, _),
let .map(_, _, _, _, caption):
return captionIsEmpty(caption)
case let .collage(_, caption),
let .slideshow(_, caption):
return captionIsEmpty(caption)
case let .cover(inner):
return richDataBlockEndsWithVisualMedia(inner)
default:
return false
}
}