Files
ghostgram/submodules/InstantPageUI/Sources/InstantPageV2DocumentContentNode.swift
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

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

443 lines
25 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import AccountContext
import SemanticStatusNode
import AppBundle
import PhotoResources
/// Editor-side colour override for a document row. nil → the chat-bubble palette (real messages);
/// non-nil → the host's accent/text scheme, so the row reads correctly inside the article editor or
/// the composer rather than in outgoing-bubble colours. Twin of `InstantPageAudioColorOverride`.
public struct InstantPageDocumentColorOverride: Equatable {
public let control: UIColor // status-control fill
public let controlForeground: UIColor // the glyph drawn on the control fill
public let title: UIColor // line 1 (file name)
public let description: UIColor // line 2 (size · extension)
public init(control: UIColor, controlForeground: UIColor, title: UIColor, description: UIColor) {
self.control = control
self.controlForeground = controlForeground
self.title = title
self.description = description
}
}
/// Renders `InstantPageBlock.document` — a generic file row inside a rich message.
///
/// Mirrors `InstantPageV2AudioContentNode` (which itself deliberately re-implements
/// `ChatMessageInteractiveFileNode`'s music branch rather than reusing it), minus everything
/// audio-specific: no playback, no album art, no playlist state. Two lines — filename and
/// "<size> · <extension>" — beside a status control that downloads on tap.
///
/// Two modes. **Message** (the default): download / progress / cancel, and a tap on an already-local
/// file reports `openDocument`. **Authoring** (`isAuthoring`, the editor via
/// `StandaloneInstantPageDocumentView`): a static file glyph, no fetch subscription, inert tap.
final class InstantPageV2DocumentContentNode: ASDisplayNode {
private let file: TelegramMediaFile
// nil → chat-bubble palette (real messages); non-nil → host accent/text override (see the struct).
private let colorOverride: InstantPageDocumentColorOverride?
/// Authoring (editor) mode: the row shows a static file glyph, never fetches, and its tap is inert.
/// Correct by construction — a freshly-picked file is already local, and an edit-loaded cloud file is
/// re-sent BY REFERENCE through `richMessageContentToUpload`'s already-cloud fast path, so there is
/// never anything to download while authoring.
private let isAuthoring: Bool
private let statusNode: SemanticStatusNode
/// The file's own preview thumbnail, when it has one (PDFs and images picked from the Files tab carry a
/// `previewRepresentation` — see `PollAttachmentScreen`'s picker). Sits UNDER `statusNode`, which stays
/// as the download/progress overlay in message mode. nil when the file has no preview.
private let iconNode: TransformImageNode?
private let titleNode: TextNode
private let descriptionNode: TextNode
private let tapView: UIView
private var titleAttributedString: NSAttributedString?
private var descriptionAttributedString: NSAttributedString?
/// Invoked when the row is tapped while the file is not yet local.
var fetch: () -> Void = {}
/// Invoked when the row is tapped while a fetch is in flight.
var cancelFetch: () -> Void = {}
/// Invoked when the row is tapped while the file is already local. Wired only in message mode.
var openDocument: () -> Void = {}
/// Excludes the row's thumbnail from screenshots — see `InstantPageV2RenderContext.captureProtected`.
/// Only the artwork is protected: the file name/size text is metadata, and the regular chat file
/// bubble does not protect its label either.
var captureProtected: Bool = false {
didSet {
if self.captureProtected != oldValue {
self.iconNode?.captureProtected = self.captureProtected
}
}
}
private var resourceStatusDisposable: Disposable?
private var fetchStatus: EngineMediaResourceStatus?
/// The message this row's status is currently bound to, so `bindStatus` can no-op on an unchanged
/// rebind (the reuse path calls it on every layout).
private var boundMessageId: EngineMessage.Id??
private static let progressDiameter: CGFloat = 40.0
// Ø40 control vertically centred in the 52pt row: y = (52 − 40) / 2 = 6.
private static let progressOrigin = CGPoint(x: 12.0, y: 6.0)
private static let controlAreaWidth: CGFloat = 12.0 + 40.0 + 8.0
private static let normHeight: CGFloat = 52.0
init(context: AccountContext, message: MessageReference?, file: TelegramMediaFile, incoming: Bool,
presentationData: PresentationData,
colorOverride: InstantPageDocumentColorOverride? = nil,
isAuthoring: Bool = false) {
self.file = file
self.colorOverride = colorOverride
self.isAuthoring = isAuthoring
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
let backgroundNodeColor = colorOverride?.control ?? messageTheme.mediaActiveControlColor
let foregroundNodeColor: UIColor
if let colorOverride {
foregroundNodeColor = colorOverride.controlForeground
} else {
foregroundNodeColor = (incoming && messageTheme.mediaActiveControlColor.rgb != 0xffffff) ? .white : .clear
}
// A file with its own preview shows that thumbnail instead of a flat accent disc. The picker
// populates `previewRepresentations` for image/* and application/pdf; `image/*` is admitted even
// without one because the file itself is renderable.
let hasThumbnail = !file.previewRepresentations.isEmpty || file.immediateThumbnailData != nil || file.mimeType.hasPrefix("image/")
self.iconNode = hasThumbnail ? TransformImageNode() : nil
self.statusNode = SemanticStatusNode(
// Over a thumbnail the control is a scrim, not the accent fill, so the artwork stays readable.
backgroundNodeColor: hasThumbnail ? presentationData.theme.chat.message.mediaOverlayControlColors.fillColor : backgroundNodeColor,
// …and the arrow/ring must be drawn ON that scrim. NOT `.clear`: `effectiveForegroundColor`
// only falls back to `overlayForegroundNodeColor` when the status node owns a `backgroundImage`,
// and ours is nil (the thumbnail is a separate sibling node) — so a clear foreground renders the
// download arrow and the progress ring invisible over artwork.
foregroundNodeColor: hasThumbnail ? presentationData.theme.chat.message.mediaOverlayControlColors.foregroundColor : foregroundNodeColor,
image: nil,
overlayForegroundNodeColor: presentationData.theme.chat.message.mediaOverlayControlColors.foregroundColor
)
self.titleNode = TextNode()
self.titleNode.displaysAsynchronously = false
self.titleNode.isUserInteractionEnabled = false
self.descriptionNode = TextNode()
self.descriptionNode.displaysAsynchronously = false
self.descriptionNode.isUserInteractionEnabled = false
self.tapView = UIView()
super.init()
self.titleAttributedString = InstantPageV2DocumentContentNode.titleString(file: file, incoming: incoming, presentationData: presentationData, colorOverride: colorOverride)
self.descriptionAttributedString = InstantPageV2DocumentContentNode.descriptionString(file: file, incoming: incoming, presentationData: presentationData, colorOverride: colorOverride)
// The thumbnail goes in FIRST so the status control overlays it.
if let iconNode = self.iconNode {
self.addSubnode(iconNode)
// A message-mode file resolves through its message (file-reference refresh); an authoring row
// has no message, so it reads the local/cloud resource standalone.
let fileReference: FileMediaReference = message.flatMap { .message(message: $0, media: file) } ?? .standalone(media: file)
iconNode.setSignal(chatMessageImageFile(account: context.account, userLocation: .other, fileReference: fileReference, thumbnail: true))
}
self.addSubnode(self.statusNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.descriptionNode)
// Derives the initial state from the mode, so an authoring row never flashes a download arrow.
self.updateFetchState()
if !isAuthoring {
self.bindStatus(context: context, messageId: message?.id)
}
}
/// (Re)subscribes the row's fetch status. Called at init AND from the item view's reuse path, because a
/// message that was PENDING when this node was built (`MessageReference` is `.none` for a
/// `Namespaces.Message.Local` id, so `message?.id` is nil) acquires a cloud id later — and without a
/// rebind the row stays permanently statusless, which reads as a completely dead tap until the chat is
/// reopened and the view rebuilt.
func bindStatus(context: AccountContext, messageId: EngineMessage.Id?) {
guard !self.isAuthoring else {
return
}
if let boundMessageId = self.boundMessageId, boundMessageId == messageId {
return // already bound to this message; the reuse path calls us every layout
}
self.boundMessageId = .some(messageId)
self.resourceStatusDisposable?.dispose()
let statusSignal: Signal<EngineMediaResourceStatus, NoError>
if let messageId {
// Fetch-manager-backed: the only route that surfaces `.Fetching` and drives the progress ring.
statusSignal = messageMediaFileStatus(context: context, messageId: messageId, file: self.file)
} else {
// No message id yet (a pending/local message). Fall back to the raw resource status so the row
// still knows Local vs Remote — the sender's own just-attached file IS local, so its tap must
// open rather than dead-end. Downloading is inherently unavailable here: without a message
// there is no fetch-manager entry to key progress off.
// `engine.resources.status` yields the `EngineMediaResource.FetchStatus` WRAPPER, not the
// `EngineMediaResourceStatus` (= `MediaResourceStatus`) typealias this node stores — hence
// `_asStatus()`, mirroring `FetchMediaUtils`.
statusSignal = context.engine.resources.status(resource: EngineMediaResource(self.file.resource))
|> map { $0._asStatus() }
}
self.resourceStatusDisposable = (statusSignal
|> deliverOnMainQueue).startStrict(next: { [weak self] status in
self?.fetchStatus = status
self?.updateFetchState()
})
}
deinit {
self.resourceStatusDisposable?.dispose()
}
override func didLoad() {
super.didLoad()
// Plain view + UITapGestureRecognizer, NOT an ASControl: ASControl's .touchUpInside is
// cancelled by the chat ListView's gesture system (same reason as the audio node).
self.view.addSubview(self.tapView)
self.tapView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapped)))
}
@objc private func tapped() {
if self.isAuthoring {
return // the editor handles taps itself (caret placement / tap-select)
}
// INVARIANT: the tap must do whatever the disc DEPICTS — this switch and `updateFetchState`'s
// must partition `fetchStatus` identically, or the control lies about what tapping it will do.
switch self.fetchStatus {
case .Local:
self.openDocument()
case .Fetching:
self.cancelFetch()
case .Remote, .Paused, .none:
// `.none` is "status not known YET" (the subscription is async, so this is the window right
// after a bubble appears) — NOT "downloaded". `updateFetchState` draws a download arrow for it,
// so the tap fetches. Treating `.none` as local opened an undownloaded file instead of
// fetching it.
self.fetch()
}
}
/// The static glyph an authoring row shows in place of a fetch control.
private static func fileGlyph(color: UIColor) -> UIImage? {
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/File"), color: color)
}
private func updateFetchState() {
if self.isAuthoring {
// No fetch affordance while authoring — see `isAuthoring`. With a thumbnail the artwork IS the
// affordance, so the control is hidden entirely rather than scrimming it.
if self.iconNode != nil {
self.statusNode.isHidden = true
return
}
let glyphColor = self.colorOverride?.controlForeground ?? .white
if let glyph = InstantPageV2DocumentContentNode.fileGlyph(color: glyphColor) {
self.statusNode.transitionToState(.customIcon(glyph))
} else {
self.statusNode.transitionToState(.none)
}
return
}
if self.iconNode != nil, case .Local = self.fetchStatus {
// Downloaded and thumbnailed: show the artwork clean; the whole row is still tappable to open.
self.statusNode.isHidden = true
return
}
self.statusNode.isHidden = false
let state: SemanticStatusNodeState
switch self.fetchStatus {
case .none:
state = .download
case .Local:
// Downloaded: a static file glyph, tappable to open.
if let glyph = InstantPageV2DocumentContentNode.fileGlyph(color: .white) {
state = .customIcon(glyph)
} else {
state = .none
}
case let .Fetching(_, progress):
state = .progress(value: CGFloat(max(progress, 0.027)), cancelEnabled: true, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 1.0, lineWidth: 2.0), animateRotation: true)
case .Remote, .Paused:
state = .download
}
self.statusNode.transitionToState(state)
}
// Line 1: filename at 17pt (= baseDisplaySize at the default font setting; scales with it).
private static func titleString(file: TelegramMediaFile, incoming: Bool, presentationData: PresentationData,
colorOverride: InstantPageDocumentColorOverride?) -> NSAttributedString {
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
let titleFont = Font.regular(floor(presentationData.chatFontSize.baseDisplaySize))
let title = file.fileName ?? "File"
return NSAttributedString(string: title, font: titleFont,
textColor: colorOverride?.title ?? messageTheme.fileTitleColor)
}
// Line 2: "<size> · <EXT>", omitting either part when unavailable.
private static func descriptionString(file: TelegramMediaFile, incoming: Bool, presentationData: PresentationData,
colorOverride: InstantPageDocumentColorOverride?) -> NSAttributedString {
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
let descriptionFont = Font.with(size: floor(presentationData.chatFontSize.baseDisplaySize * 15.0 / 17.0), design: .regular, weight: .regular, traits: [.monospacedNumbers])
var text = ""
if let size = file.size, size > 0 {
text = dataSizeString(Int(size), formatting: DataSizeStringFormatting(presentationData: presentationData))
}
if let fileName = file.fileName, let dotIndex = fileName.lastIndex(of: "."), dotIndex < fileName.endIndex {
let ext = String(fileName[fileName.index(after: dotIndex)...]).uppercased()
if !ext.isEmpty {
text += text.isEmpty ? ext : " · \(ext)"
}
}
return NSAttributedString(string: text, font: descriptionFont,
textColor: colorOverride?.description ?? messageTheme.fileDescriptionColor)
}
func updateLayout(width: CGFloat) {
let progressFrame = CGRect(origin: InstantPageV2DocumentContentNode.progressOrigin, size: CGSize(width: InstantPageV2DocumentContentNode.progressDiameter, height: InstantPageV2DocumentContentNode.progressDiameter))
self.statusNode.frame = progressFrame
if let iconNode = self.iconNode {
// The thumbnail fills the same Ø40 slot as the status disc, aspect-FILLED into a rounded square.
// Deliberately the same slot (and so the same 52pt row) as a control-only document: `MediaBlockBox`
// sizes the editor's row from `kind` alone — it cannot resolve the file — so a thumbnail-dependent
// height would desync the editor preview from the V2 renderer.
iconNode.frame = progressFrame
let representationSize = largestImageRepresentation(self.file.previewRepresentations)?.dimensions.cgSize
?? CGSize(width: 320.0, height: 320.0)
let apply = iconNode.asyncLayout()(TransformImageArguments(
corners: ImageCorners(radius: 8.0),
imageSize: representationSize.aspectFilled(progressFrame.size),
boundingSize: progressFrame.size,
intrinsicInsets: UIEdgeInsets()
))
apply()
}
let controlAreaWidth = InstantPageV2DocumentContentNode.controlAreaWidth
let textWidth = max(1.0, width - controlAreaWidth - 8.0)
let (titleLayout, titleApply) = TextNode.asyncLayout(self.titleNode)(TextNodeLayoutArguments(attributedString: self.titleAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .middle, constrainedSize: CGSize(width: textWidth, height: 100.0), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let (descLayout, descApply) = TextNode.asyncLayout(self.descriptionNode)(TextNodeLayoutArguments(attributedString: self.descriptionAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: textWidth, height: 100.0), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let _ = titleApply()
let _ = descApply()
let titleAndDescriptionHeight = titleLayout.size.height - 1.0 + descLayout.size.height
let normHeight = InstantPageV2DocumentContentNode.normHeight
let titleFrame = CGRect(origin: CGPoint(x: controlAreaWidth, y: floor((normHeight - titleAndDescriptionHeight) / 2.0)), size: titleLayout.size)
self.titleNode.frame = titleFrame
self.descriptionNode.frame = CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY - 1.0), size: descLayout.size)
self.tapView.frame = CGRect(origin: .zero, size: CGSize(width: width, height: normHeight))
}
}
/// Item view for `InstantPageBlock.document`. Mirrors `InstantPageV2MediaAudioView`'s shape: wraps a
/// content node, routes fetch through the fetch manager, and re-lays out on bounds change.
final class InstantPageV2DocumentView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2DocumentItem
var itemFrame: CGRect { return self.item.frame }
private let documentNode: InstantPageV2DocumentContentNode
/// Set by the renderer; fires with this row's file when an already-downloaded file is tapped.
/// LOAD-BEARING: must be re-wired in the renderer's REUSE arm too — a recycled view may have been
/// created against a previous `InstantPageV2View`, and without re-wiring taps silently stop working
/// after scrolling away and back (the same trap as `onButtonTapped`).
var onDocumentTapped: ((TelegramMediaFile) -> Void)?
/// The message the fetch/cancel closures and the status subscription are currently bound to, so the
/// reuse path can detect a pending → confirmed transition and rebind.
private var boundMessage: MessageReference?
init(item: InstantPageV2DocumentItem, renderContext: InstantPageV2RenderContext, theme: InstantPageTheme) {
self.item = item
let presentationData = renderContext.context.sharedContext.currentPresentationData.with { $0 }
let incoming = renderContext.message?.isIncoming == true
let documentFile: TelegramMediaFile
if case let .file(f) = item.media.media {
documentFile = f
} else {
documentFile = TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: EmptyMediaResource(), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/octet-stream", size: nil, attributes: [], alternativeRepresentations: [])
}
self.documentNode = InstantPageV2DocumentContentNode(context: renderContext.context, message: renderContext.message, file: documentFile, incoming: incoming, presentationData: presentationData)
super.init(frame: item.frame)
self.backgroundColor = .clear
self.addSubview(self.documentNode.view)
self.documentNode.captureProtected = renderContext.captureProtected
self.bindToMessage(renderContext: renderContext)
}
/// Binds every piece of message-dependent wiring: the fetch/cancel closures and the node's status
/// subscription. **Must be re-applied from the reuse path**, not just at init: a message that was
/// PENDING when this view was built carries a `MessageReference` of `.none` (see
/// `MessageReference.init` — a `Namespaces.Message.Local` id is dropped), so its closures early-return
/// and no status arrives. When the send confirms, the item view is REUSED rather than rebuilt, so
/// without this the row stays wired to the pending message and its tap does nothing until the chat is
/// reopened.
private func bindToMessage(renderContext: InstantPageV2RenderContext) {
let fetchContext = renderContext.context
let fetchMessage = renderContext.message
let fetchMedia = self.item.media
self.boundMessage = fetchMessage
self.documentNode.fetch = {
guard case let .file(file) = fetchMedia.media, let message = fetchMessage, let messageId = message.id else {
return
}
// Through the fetch manager, not freeMediaFileInteractiveFetched: messageMediaFileStatus
// keys progress off the fetch manager's `hasEntry`, so only this route surfaces
// .Fetching and drives the progress ring.
let _ = messageMediaFileInteractiveFetched(fetchManager: fetchContext.fetchManager, messageId: messageId, messageReference: message, file: file, userInitiated: true, priority: .userInitiated).startStandalone()
}
self.documentNode.cancelFetch = {
guard case let .file(file) = fetchMedia.media, let messageId = fetchMessage?.id else {
return
}
messageMediaFileCancelInteractiveFetch(context: fetchContext, messageId: messageId, file: file)
}
self.documentNode.openDocument = { [weak self] in
guard let self, case let .file(file) = fetchMedia.media else {
return
}
self.onDocumentTapped?(file)
}
self.documentNode.bindStatus(context: fetchContext, messageId: fetchMessage?.id)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
self.documentNode.frame = self.bounds
self.documentNode.updateLayout(width: self.bounds.width)
}
func update(item: InstantPageV2DocumentItem, theme: InstantPageTheme, renderContext: InstantPageV2RenderContext) {
self.item = item
// Rebind when the message identity changed — the pending → confirmed transition arrives HERE (the
// view is reused, not rebuilt), and it is what turns a dead tap into a working one.
if self.boundMessage != renderContext.message {
self.bindToMessage(renderContext: renderContext)
}
self.documentNode.captureProtected = renderContext.captureProtected
self.documentNode.updateLayout(width: self.bounds.width)
}
// Not a gallery item: explicit no-op witnesses, matching the audio view's pattern.
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return nil
}
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
}
}