mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 22:27:13 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2531 lines
150 KiB
Swift
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
|
|
}
|
|
}
|