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

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

834 lines
54 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import Display
import AppBundle
import Postbox
import SwiftSignalKit
import TextFormat
import TelegramCore
import RichTextEditorCore
import RichTextEditorUIKit
import RichTextButtonIcons
import ChatInputTextNode
import CheckNode
import TelegramPresentationData
/// `RichTextChecklistMarkerView` host wrapper backing a checklist item's checkbox with a `CheckNode`
/// (an `ASDisplayNode`, so we host its `.view` — this is a `UIView`, not a node). The editor frames this
/// view in the marker gutter and calls `setChecked(_:animated:)` when the item toggles. A private copy
/// lives in each editor host (cross-module; duplication is expected).
private final class HostChecklistCheckboxView: UIView, RichTextChecklistMarkerView {
private let checkNode: CheckNode
init(theme: CheckNodeTheme, checked: Bool) {
self.checkNode = CheckNode(theme: theme, content: .check(isRectangle: true))
super.init(frame: .zero)
self.checkNode.isUserInteractionEnabled = false
self.addSubview(self.checkNode.view)
self.checkNode.setSelected(checked, animated: false)
}
required init?(coder: NSCoder) { fatalError() }
override func layoutSubviews() {
super.layoutSubviews()
self.checkNode.frame = self.bounds
}
func setChecked(_ checked: Bool, animated: Bool) {
self.checkNode.setSelected(checked, animated: animated)
}
}
/// `ChatRichTextInputNode` backend composing the TextKit-2 `RichTextEditorView`.
@available(iOS 13.0, *)
public final class RichTextEditorChatInputNode: ASDisplayNode, ChatRichTextInputNode {
private let strings: PresentationStrings
private let editorView = RichTextEditorView()
private let baseFontSize: CGFloat = 17.0
private var trackedInsets: UIEdgeInsets = .zero
private var trackedContentMargins: UIEdgeInsets = .zero
private var trackedRightInset: CGFloat = 0.0
// The host's constant scroll-indicator inset (the panel sets it once during loadTextInputNode, before the
// first layout). Threaded into every editor.update(...) so it survives — nil means "track the content insets".
private var trackedScrollIndicatorInsets: UIEdgeInsets?
private weak var storedDelegate: ChatInputTextNodeDelegate?
public var emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView?)?
/// Factory the panel supplies (it owns `AccountContext`) to turn a `Media` + natural size into a hosted
/// media view. The editor's media-view provider (registered in `didLoad`) resolves its opaque `mediaID` →
/// `mediaByID` → this factory. Read lazily, so the panel may set it after `didLoad`. Mirrors `emojiViewProvider`.
public var mediaItemViewFactory: ((_ items: [(media: EngineMedia, naturalSize: CGSize, isSpoiler: Bool, kind: MediaKind)], _ existing: (UIView & RichTextMediaItemView)?) -> (UIView & RichTextMediaItemView)?)?
public var formulaRenderer: ((RichTextFormulaRenderContext) -> RichTextFormulaRenderResult?)? {
didSet {
if self.isNodeLoaded {
self.updateFormulaRenderer()
}
}
}
/// Panel-set "user is typing" hook (wired to `ChatTextInputPanelNode.updateActivity`). Fired from `onChange`
/// ONLY on a genuine text edit — see the gating in `didLoad`. The legacy backend reports this via the
/// `chatInputTextNode(shouldChangeTextIn:)` delegate, which this engine never calls; this is its replacement.
public var onTypingActivity: (() -> Void)?
/// fileId → the file-bearing custom-emoji attribute, harvested from each `attributedText` /
/// `setInputContent` push. The editor hosts inline emoji by their `EmojiRef` fileId STRING only
/// (`document(from:)` / `document(fromChatInputContent:)` drop the `TelegramMediaFile`), but the host
/// renderer (`EmojiTextAttachmentView`) needs the file — so we cache the full attribute here and rebuild it
/// for the editor's view provider. Mirrors `RichTextEmojiKeyboardController.emojiFiles`. Also the source the
/// direct-bridge `resolveEmoji`/`registerEmoji` closures read/write (see `currentInputContent`/`setInputContent`).
private var customEmojiAttributes: [Int64: ChatTextInputTextCustomEmojiAttribute] = [:]
/// `MediaBlock.mediaID` → the concrete `Media` it stands for. The editor stores only the opaque host
/// `mediaID` string (it never holds a `Media`), so the node owns the mapping — exactly as
/// `RichTextAttachmentScreen.attachedMedia` does. Populated by the direct-bridge `registerMedia` closure
/// from each `setInputContent` push and read back by `resolveMedia` in `currentInputContent`, so a medium
/// the chat layer sets round-trips through the structural model. A medium the editor never received via this
/// path (no entry) resolves to nil and its block is dropped — see `resolveMedia` below.
private var mediaByID: [String: Media] = [:]
/// Typing-activity bookkeeping. The editor's `onChange` funnels THREE kinds of event into one payload-free
/// call — a genuine text edit, a caret/selection move, and a programmatic `document` swap (draft restore /
/// send-clear / state echo). Only the first is "typing". `lastTypingActivityText` is the plain text at the
/// previous `onChange`, so a selection-only move (text unchanged) doesn't fire `onTypingActivity`; the flag
/// suppresses the synchronous `onChange` a programmatic `document =` triggers (via `reload` → `setBlocks` →
/// `notifyContentSizeChanged`) so a draft restore / state re-apply never reports typing.
private var lastTypingActivityText: String = ""
private var isApplyingProgrammaticContent = false
/// Checklist checkbox palette, threaded across the `ChatRichTextThemeColors` seam (the node holds no
/// `PresentationTheme`). Set on each `applyRichTextTheme`; the marker provider reads them lazily so a
/// theme change takes effect on the next-built checkbox. Seeded with sane defaults so an early checkbox
/// (before the first `applyRichTextTheme`) still renders — overwritten on the first theme apply.
private var checkboxFill: UIColor = .systemBlue
private var checkboxForeground: UIColor = .white
private var checkboxBorder: UIColor = .systemGray
public var asNode: ASDisplayNode { self }
public init(strings: PresentationStrings) {
#if DEBUG && false
RichTextEditorView.debugShowLayoutOverlay = true
#endif
self.strings = strings
super.init()
}
/// The compact-composer layout knobs that affect measured text height. Applied to the live editor in
/// `didLoad` AND to the throwaway probe in `measuredTextFieldHeight`, so the probe's height matches the
/// live field's. (Height-irrelevant knobs — placeholders, theme, quote style — are NOT included.)
/// The composer lays text out with V2's chat-message metrics, so what you type reads like the
/// message you are about to send. It uses `RichTextRenderMetrics.default` rather than
/// `InstantPageTheme.chatMessageRenderMetrics()` because this module cannot import `InstantPageUI` —
/// that edge is a cycle (`InstantPageUI` depends on this module). The two are pinned equal by
/// `RichTextV2MetricsParityTests.test_editorDefaultMetrics_equalTheAdaptedChatMessageTheme`.
///
/// **`edgeSpacingReduction` trims the field's outer padding to nothing.** V2 gives a top-level
/// paragraph `blockVerticalPadding + 2` = 6pt at each SEQUENCE EDGE (not to be confused with the
/// inter-paragraph gap, which is 1pt), so an untrimmed one-line field measures 33pt against the
/// text's own 21pt. In a page that padding is the page's margin; in the composer the input panel
/// already owns the padding around the field, which is why the pre-parity config zeroed the block
/// inset for the same reason. Trimming here is V2's own mechanism for a host that insets the whole
/// page — the chat bubble passes 1.0 for it — and it affects ONLY the edges, so the interior rhythm
/// stays V2-exact (guarded by `BlockSpacingTests.test_edgeSpacingReduction_trimsEdgesOnlyAndClampsAtZero`).
/// Measured: 33pt → 21pt for one line; it clamps at 0, so 6 is "remove it entirely".
private static let composerEdgeSpacingReduction: CGFloat = 6.0
private static func applyComposerLayoutMetrics(to editor: RichTextEditorView) {
editor.contentPageMargin = 0.0
editor.minimumContentHeight = 0.0
var metrics = RichTextRenderMetrics.default
metrics.edgeSpacingReduction = composerEdgeSpacingReduction
editor.renderMetrics = metrics
}
private func updateFormulaRenderer() {
self.editorView.registerFormulaRenderer { [weak self] context in
return self?.formulaRenderer?(context)
}
}
/// A button pill's type icon. Registered unconditionally rather than proxied through a host hook
/// like the formula renderer: the mapping is a pure function of the action, and it is GEOMETRY —
/// an inline pill grows by `inlineIconReserve` to hold its icon, so a composer that skipped it
/// would wrap a paragraph differently from the message it sends.
private func updateButtonIconProvider() {
self.editorView.registerButtonIconProvider(richTextEditorButtonIcon)
}
public override func didLoad() {
super.didLoad()
// Model A: this node is the wrapper (the panel frames `asNode` to fill the clipping container);
// `editorView` is the inset child, positioned by the panel via `textFieldFrame`. They are distinct
// views — exactly as the legacy impl keeps `textInputNodeImpl` a subnode of the wrapper.
self.view.addSubview(self.editorView)
// Compact-composer layout knobs — shared with the 3-line measurement probe so the two cannot drift.
RichTextEditorChatInputNode.applyComposerLayoutMetrics(to: self.editorView)
// Suppress the editor's built-in placeholders ("Type something…" / list hints): the chat input panel
// draws its own placeholder ("Message", etc.), so the editor's would double up.
self.editorView.placeholders = RichTextEditorPlaceholders(body: "", listEnd: "", listOutdent: "", pullQuote: self.strings.RichText_PlaceholderQuote, blockQuote: self.strings.RichText_PlaceholderQuote, codeBlock: self.strings.RichText_PlaceholderCode, codeLanguage: self.strings.RichText_PlaceholderCodeLanguage, detailsTitle: self.strings.RichText_PlaceholderDetailTitle, quoteAuthor: self.strings.RichText_PlaceholderQuoteAuthor, caption: self.strings.RichText_PlaceholderCaption)
self.editorView.editMenuStrings = RichTextEditorMenuStrings(
format: self.strings.TextFormat_Format,
bold: self.strings.TextFormat_Bold,
italic: self.strings.TextFormat_Italic,
underline: self.strings.TextFormat_Underline,
lookUp: self.strings.Conversation_ContextMenuLookUp,
translate: self.strings.Conversation_ContextMenuTranslate,
share: self.strings.Conversation_ContextMenuShare
)
// The composer sits over the input panel's own background — clear the editor's document "page"
// background (`.systemBackground`, opaque white in light mode) so the panel shows through. `nil`
// (no background) rather than `.clear`: same transparency, but signals "unset" and avoids an
// explicit clear-color fill.
self.editorView.canvasBackgroundColor = nil
// Disable the "tap below the content adds a trailing paragraph" affordance: that's a full-page
// document-editor behavior (the article editor keeps it). In the compact composer there is no empty
// area below the content to grow into, so a tap there just places the caret in the trailing paragraph.
self.editorView.tapBelowAddsTrailingParagraph = false
// Quote geometry for the compact composer. Defaults == the editor's built-in look; tune here to
// diverge from the article editor (e.g. tighter insets in the narrow input field).
self.editorView.quoteStyle = QuoteStyle(
leadingInset: 9.0,
trailingInset: 22.0,
spacingBefore: 8.0,
spacingAfter: 8.0,
barWidth: 3.0,
cornerRadius: 2.5,
fillAlpha: 0.1,
topInset: 3.0,
bottomInset: 3.0
)
// Code-block geometry for the compact composer. The band does NOT bleed at all here: the
// editor sits inside the input field's rounded background (inset by the panel's
// `textInputViewInternalInsets`, 12 left / 11 right) and its right content margin also
// reserves room for the accessory + send buttons, so ANY outward bleed reads as spilling past
// what the field shows. The band therefore spans exactly the text column, and the code is
// indented within it instead — the inward counterpart of the renderer's outward bleed. A
// small radius keeps the band from fighting the field's own rounding.
//
// Deliberately NOT WYSIWYG against the sent bubble, which is full-bleed and square: a compact
// field is a different container shape from a message bubble.
self.editorView.codeStyle = CodeStyle(horizontalBleed: 0.0, horizontalInset: 8.0, cornerRadius: 4.0)
// Media (image/video/location/audio) insets like the text paragraphs in the compact composer
// (the document/article editor keeps the default edge-to-edge bleed).
self.editorView.mediaBlockStyle = MediaBlockStyle(horizontalBleed: 0.0)
// Quote collapse/expand affordance icons — the same bundle assets the legacy ChatInputTextNode uses.
if let collapse = UIImage(bundleImageName: "Media Gallery/Minimize")?.precomposed().withRenderingMode(.alwaysTemplate),
let expand = UIImage(bundleImageName: "Media Gallery/Fullscreen")?.precomposed().withRenderingMode(.alwaysTemplate) {
self.editorView.quoteCollapseIcons = RichTextEditorQuoteCollapseIcons(collapse: collapse, expand: expand)
}
// Detail-block fold chevron — the same vertical arrow the InstantPage V2 renderer uses.
self.editorView.detailsChevronImage = UIImage(bundleImageName: "Item List/ExpandingItemVerticalRegularArrow")?.withRenderingMode(.alwaysTemplate)
// A selection-handle ("knob") drag must NOT be hijacked by the interactive keyboard-/modal-dismiss
// gestures. Those Display flags can only be set host-side (the editor package can't import Display) and
// are applied to the hit-testable handle views, so the effect is scoped to knob interaction — not the
// whole editor surface.
self.editorView.configureSelectionHandleView = { handle in
handle.disablesInteractiveTransitionGestureRecognizer = true // navigation back-swipe (the one a horizontal knob drag triggers)
handle.disablesInteractiveModalDismiss = true
handle.disablesInteractiveKeyboardGestureRecognizer = true
}
// Seed an editable document. RichTextEditorView starts with ZERO blocks — its canvas is only
// populated by the `document` setter (which normalizes an empty Document to a single empty body
// paragraph). Every proven host (RichTextAttachmentScreen, the Demo) seeds a document at setup. We
// MUST do it here too: our `attributedText` setter's composerContentEqual guard skips the panel's
// initial empty push (""=="" → no-op), so without this the canvas would have no paragraph box to
// insert into and typing would do nothing.
self.editorView.document = Document()
// Phase 1 delegate subset: the editor exposes onChange + the two focus transitions. The panel's
// height/state refresh keys off chatInputTextNodeDidUpdateText (it then reads `attributedText`).
// The remaining ChatInputTextNodeDelegate methods (chatInputTextNodeDidChangeSelection,
// chatInputTextNodeBackspaceWhileEmpty, chatInputTextNodeMenu, chatInputTextNode(shouldChangeTextIn:),
// chatInputTextNodeShouldCopy/Paste, chatInputTextNodeShouldRespondToAction/TargetForAction) require
// new RichTextEditorView callbacks and are deferred to Phase 2 — the editor handles selection/menu/
// backspace internally, so omitting them only means the panel doesn't receive those hooks.
// Hardware Return → the panel's send-on-Enter / send-on-⌘-Enter decision (chatInputTextNodeShouldReturn
// returns true to insert a newline, false when it sent the message). Without this a hardware Return
// just inserted a paragraph break and never sent — the legacy backend wired this via
// ChatInputTextViewImpl's own \r keyCommand; the native editor surfaces it as onHardwareReturn.
self.editorView.onHardwareReturn = { [weak self] modifierFlags in
return self?.storedDelegate?.chatInputTextNodeShouldReturn(modifierFlags: modifierFlags) ?? true
}
self.editorView.onChange = { [weak self] in
guard let self else { return }
// RichTextEditorView is parent-driven: it does NOT self-layout on a content change (unlike the
// legacy UITextView backend, which renders its own edits). The host must call `update(...)` in
// response to `onChange`, or inserted text is never laid out — it appears that "typing does
// nothing". Re-lay it out here at the current committed size (mirrors RichTextAttachmentScreen's
// onChange → re-run-layout). `update`/`performLayout` never fire `onChange` synchronously
// (the editor's contract, covered by a regression test), so this cannot recurse. The panel's
// delegate-driven layout pass still handles field-height growth separately.
if self.editorView.bounds.width > 0.0 {
_ = self.editorView.update(size: self.editorView.bounds.size, insets: self.trackedInsets, contentMargins: self.trackedContentMargins, scrollIndicatorInsets: self.trackedScrollIndicatorInsets)
}
// Report "typing…" chat activity ONLY on a genuine user text edit. `onChange` also fires on a
// caret/selection move (text unchanged ⇒ no report) and on a programmatic content set (suppressed
// by `isApplyingProgrammaticContent`). This matches the legacy backend, which reports activity from
// `chatInputTextNode(shouldChangeTextIn:)` — a delegate the native editor never invokes. Reading the
// plain text is the existing hot-path send-counter read; the diff keeps cursor taps off the wire.
let currentText = self.text
if !self.isApplyingProgrammaticContent && currentText != self.lastTypingActivityText {
self.onTypingActivity?()
}
self.lastTypingActivityText = currentText
self.storedDelegate?.chatInputTextNodeDidUpdateText()
}
self.editorView.onBecameFirstResponder = { [weak self] in
self?.storedDelegate?.chatInputTextNodeDidBeginEditing()
}
self.editorView.onResignedFirstResponder = { [weak self] in
self?.storedDelegate?.chatInputTextNodeDidFinishEditing()
}
// Custom-emoji rendering. The editor hosts each inline emoji via this provider, asking by the
// `EmojiRef` fileId string. Forward to the chat host's `emojiViewProvider` (set by the panel),
// passing a `ChatTextInputTextCustomEmojiAttribute`. Prefer the file-bearing attribute cached from a
// file-carrying push/type (`customEmojiAttributes`, harvested so the file is available immediately);
// otherwise build a FILE-LESS attribute so the renderer (`InlineStickerItemLayer`) resolves the file
// by fileId via `resolveInlineStickers`. The file-less fallback is REQUIRED for a draft-restored /
// entity-only emoji: a draft persists only the fileId, so its `file` is nil and it was never cached —
// without the fallback the guard returned nil and the restored emoji rendered blank ("lost"). This
// mirrors the legacy node, which passes its attribute straight to the provider and relies on the same
// renderer fallback. The closure reads `self.emojiViewProvider` lazily, so the panel may set it after
// this registration. `size` is ignored: the host renderer picks its own point size and the editor
// frames the returned view to the glyph rect.
// The host owns "(language, text) -> colours": `asyncStanaloneSyntaxHighlight` runs libprisma off
// the main queue and returns the same cache model the message path stores, baking the LIGHT
// palette — so what the editor shows is what the sent message will show. The editor cannot do
// this itself; it cannot see TextFormat or libprisma.
self.editorView.registerSyntaxHighlighter { language, text, completion in
let spec = CachedMessageSyntaxHighlight.Spec(language: language, text: text)
let _ = (asyncStanaloneSyntaxHighlight(current: nil, specs: [spec])
|> deliverOnMainQueue).start(next: { result in
let entities = result.values[spec]?.entities ?? []
completion(entities.map { entity in
RichTextSyntaxToken(
range: NSRange(location: entity.range.lowerBound,
length: entity.range.upperBound - entity.range.lowerBound),
color: UIColor(rgb: UInt32(bitPattern: entity.color)))
})
})
}
self.editorView.registerEmojiViewProvider { [weak self] id, _ in
guard let self, let fileId = Int64(id), let provider = self.emojiViewProvider else { return nil }
let attribute = self.customEmojiAttributes[fileId]
?? ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: nil)
guard let view = provider(attribute) else { return nil }
// The panel-supplied provider is typed `-> UIView?` (the `ChatRichTextInputNode` protocol seam),
// but it hands back an `EmojiTextAttachmentView` — a `RichTextEmojiView` (conformance declared in
// `ChatTextInputPanelNode`) — so the editor can keep its `dynamicColor` synced to the text color.
// A non-conforming fallback view resolves to nil (the editor retries on the next layout pass).
return view as? (UIView & RichTextEmojiView)
}
// Formula rendering is owned by the chat host; math-rendering dependencies live above this module.
// Reinstalling when the provider arrives after `didLoad` reloads already-present formula atoms.
self.updateFormulaRenderer()
self.updateButtonIconProvider()
// Media rendering. The editor hosts each `.media` block via this provider, asking by the opaque host
// `mediaID` (the node's own key, recorded in `mediaByID` by `registerMediaValue`). Resolve it back to
// the concrete `Media` and hand it + the natural size to the panel-supplied factory, which builds the
// hosted view (it owns `AccountContext`). WITHOUT this, a `.media` block set via the model round-trips
// but never renders. Both `mediaItemViewFactory` and `mediaByID` are read LAZILY, so the panel may set
// the factory after this registration. The returned `(UIView & RichTextMediaItemView)?` is assignable
// to the provider's `RichTextMediaItemView?` return type.
self.editorView.registerMediaViewProvider { [weak self] items, _, _, existing in
guard let self, let factory = self.mediaItemViewFactory else { return nil }
let resolved: [(media: EngineMedia, naturalSize: CGSize, isSpoiler: Bool, kind: MediaKind)] = items.compactMap { item in
guard let media = self.mediaByID[item.mediaID] else { return nil }
return (EngineMedia(media), item.naturalSize, item.isSpoiler, item.kind)
}
guard !resolved.isEmpty else { return nil }
return factory(resolved, existing)
}
// Bridge the editor's account-free media-control request to the owner-facing context: resolve the
// concrete media from the opaque mediaID (may repeat across blocks — the request's `delete` is
// already bound to the exact occurrence) and hand the panel a MediaControlContext.
self.editorView.onRequestMediaControl = { [weak self] request in
guard let self, let media = self.mediaByID[request.mediaID] else { return }
self.onRequestMediaControl?(MediaControlContext(
media: EngineMedia(media),
control: request.control,
sourceView: request.view,
sourceRect: request.sourceRect,
delete: request.delete,
isSpoiler: request.isSpoiler,
toggleSpoiler: request.toggleSpoiler,
replace: request.replace,
addMore: request.addMore
))
}
// Checklist checkbox rendering. The editor hosts each checklist item's checkbox via this provider,
// asking for the current `checked` state. Build a `CheckNode`-backed view themed from the checkbox
// colors threaded across the `ChatRichTextThemeColors` seam (the node holds no `PresentationTheme`).
// The colors are read LAZILY, so a theme change applied after this registration takes effect on the
// next-built checkbox. When this provider is unset the editor falls back to its glyph marker.
self.editorView.registerChecklistMarkerViewProvider { [weak self] checked, _ in
guard let self else { return nil }
let nodeTheme = CheckNodeTheme(backgroundColor: self.checkboxFill, strokeColor: self.checkboxForeground, borderColor: self.checkboxBorder, overlayBorder: false, hasInset: false, hasShadow: false)
return HostChecklistCheckboxView(theme: nodeTheme, checked: checked)
}
}
// MARK: Display (Task 3)
public var attributedText: NSAttributedString? {
get { ComposerDocumentBridge.attributedString(from: self.editorView.document, baseFontSize: self.baseFontSize) }
set {
let incoming = newValue ?? NSAttributedString(string: "")
// Harvest file-bearing custom-emoji attributes so the editor's fileId-only emoji-view provider
// can rebuild them (`document(from:)` keeps only the fileId in the `EmojiRef`, dropping the
// `TelegramMediaFile` the renderer needs). Done BEFORE the equality guard so the cache stays
// current even when the document is not rebuilt. Only cache a file-BEARING attribute, so a later
// file-less round-trip (the getter emits `file: nil`) never clobbers a resolved one.
incoming.enumerateAttribute(ChatTextInputAttributes.customEmoji, in: NSRange(location: 0, length: incoming.length), options: []) { value, _, _ in
if let attribute = value as? ChatTextInputTextCustomEmojiAttribute, attribute.file != nil {
self.customEmojiAttributes[attribute.fileId] = attribute
}
}
let current = ComposerDocumentBridge.attributedString(from: self.editorView.document, baseFontSize: self.baseFontSize)
if ComposerDocumentBridge.composerContentEqual(incoming, current) {
return
}
// Programmatic set: the assignment fires `onChange` synchronously (reload → setBlocks →
// notifyContentSizeChanged); flag it so that isn't mistaken for the user typing.
self.isApplyingProgrammaticContent = true
self.editorView.document = ComposerDocumentBridge.document(from: incoming)
self.isApplyingProgrammaticContent = false
}
}
public var text: String {
// Read plain text straight from the document model rather than round-tripping through the full
// `Document → NSAttributedString` conversion — `text` is a hot path (the send-button counter
// re-reads it on every keystroke). Mirrors ChatInputContent.plainText recursion: paragraphs +
// block-quote children joined by "\n" (non-paragraph atoms and table blocks contribute nothing).
func blockText(_ block: Block) -> String? {
switch block {
case .paragraph(let p): return p.text
case .blockQuote(let bq): return bq.children.compactMap(blockText).joined(separator: "\n")
default: return nil
}
}
return self.editorView.document.blocks.compactMap(blockText).joined(separator: "\n")
}
// Content-aware emptiness read STRAIGHT from the live document (like `text` — no ChatInputContent build,
// no emoji/media resolvers). A direct walk is REQUIRED: `chatInputContent(fromDocument:)` with absent
// resolvers DROPS an unresolvable media block, which would reintroduce the "media reads as empty" bug.
public var inputContentIsEmpty: Bool {
let blocks = self.editorView.document.blocks
guard blocks.count <= 1 else { return false }
guard let block = blocks.first else { return true }
switch block {
case let .paragraph(p): return p.text.isEmpty
case let .code(c): return c.text.isEmpty
case let .pullQuote(pq): return pq.text.isEmpty
case .media, .table, .blockQuote, .details, .buttonRow: return false
}
}
public var inputContentIsEmptyWhitespaceTrimmed: Bool {
return self.editorView.document.blocks.allSatisfy { block in
switch block {
case let .paragraph(p): return p.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
case let .code(c): return c.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
case let .pullQuote(pq): return pq.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
case .media, .table, .blockQuote, .details, .buttonRow: return false
}
}
}
// MARK: ChatInputContent model — the STRUCTURAL draft currency (direct bridge)
/// The node's content as the canonical `ChatInputContent` value model. This is the chat layer's draft/state
/// read-back (the panel's `inputTextState` reads it, NOT `attributedText`), so it MUST carry the structural
/// blocks the `NSAttributedString` vocabulary can't express — media / tables / heading & list information.
/// We convert the editor's live `Document` STRAIGHT to `ChatInputContent` via the direct bridge
/// (`chatInputContent(fromDocument:)`), bypassing the lossy `Document → NSAttributedString` hop the default
/// protocol implementation (and the legacy `attributedText` getter) take. The resolvers map the editor's
/// opaque host keys to concrete chat-layer values: an unresolvable emoji degrades to plain text and an
/// unresolvable medium is dropped (see `resolveEmojiRef`/`resolveMediaID`). The selection rides the editor's
/// flat composer coordinate space (`selectedRange`), mapped into the content's structural selection — the
/// same mapping the default implementation uses.
public func currentInputContent() -> (content: ChatInputContent, selection: ChatInputSelection) {
let content = chatInputContent(
fromDocument: self.editorView.document,
resolveEmoji: { [weak self] emojiRef in self?.resolveEmojiRef(emojiRef) ?? nil },
resolveMedia: { [weak self] mediaID in self?.resolveMediaID(mediaID) ?? nil }
)
return (content, ChatInputSelection(nsRange: self.selectedRange, in: content))
}
/// Replace the editor's content from a `ChatInputContent`. This is the chat layer's structural SET path
/// (draft restore, send-clear, spoiler re-decorate): it MUST land the structural blocks back into the editor
/// `Document`, so we convert STRAIGHT via the direct bridge (`document(fromChatInputContent:)`) rather than
/// through the flattening `attributedText` setter. The registrars hand the chat-layer identity to the node's
/// caches and return the editor-side ref/key: `registerEmoji` records the `TelegramMediaFile` so the editor's
/// emoji-view provider can render it, and `registerMedia` records the `Media` so a later `currentInputContent`
/// can resolve it back. Sets the editor's live document directly (NOT via `attributedText`, which would
/// flatten structure beyond the `ChatTextInputAttributes` vocabulary, e.g. tables) and runs the same refresh
/// `onChange` performs (so the panel re-lays-out and re-reads content), preserving the refresh/`didUpdateText`
/// ordering, then restores the flat composer selection.
public func setInputContent(_ content: ChatInputContent, selection: ChatInputSelection) {
// CONTENT-EQUALITY GUARD (the load-bearing fix for "selection/scroll reset at random times").
//
// The chat composer re-applies the whole `effectiveInputState` through this method on EVERY chat
// state update: `ChatControllerNode.updateLayout` calls `updateInputTextState` whenever
// `effectiveInputState` changes — which covers not just genuine external sets (draft restore,
// send-clear) but also the editor's OWN edits/caret-moves echoed back through the interface state,
// and any unrelated state update that recomputes the compose/edit input state.
//
// Rebuilding the document on each of those is destructive: `self.editorView.document =` runs
// `canvas.reload` (tears down + rebuilds every block box, resetting the canvas selection) + a full
// `performLayout` (resets the scroll offset to the top). So an UNCHANGED round-trip would reset the
// scroll position (visible once the field has grown tall enough to scroll) and churn the live
// selection through the flat↔structural mapping — surfacing as the caret/selection jumping at
// seemingly random times.
//
// Skip the rebuild when the incoming content already matches what the editor shows, mirroring the
// legacy backend (`ChatInputTextNodeImpl.attributedText`'s `if self.attributedText != value`) and
// the native `attributedText` setter's `composerContentEqual` short-circuit. When content is equal we
// only move the caret if the requested selection actually differs from the live one — so the common
// no-op echo (the editor's own edit pushed up and applied back) leaves scroll AND selection
// untouched, while a deliberate programmatic caret move still takes effect.
let currentContent = self.currentInputContent().content
if currentContent == content {
let targetRange = selection.nsRange(in: content)
let liveRange = self.editorView.composerSelectedRange
// STALE-SELECTION GUARD (the double-tap "flash then deselect" fix).
//
// The editor reports a selection change to the host ASYNCHRONOUSLY (coalesced to the next runloop
// turn, so a backspace's transient delete-range isn't observed). So right after a gesture RANGE
// selection (double-tap word / triple-tap paragraph / Select-All), the editor holds the range but
// the chat interface state STILL carries the pre-selection caret. The `presentEditMenu()` that
// fires immediately after the selection drives a synchronous chat layout pass → this method with
// that LAGGING caret as `selection`, which would collapse the just-made range back to a caret
// (device-log-verified). The editor's own async notification settles the interface state to the
// range a turn later, so this is purely a transient echo.
//
// A content-equal set that would COLLAPSE the editor's live RANGE to a caret is always that lagging
// echo — a real edit changes content (→ the rebuild branch below), and a deliberate caret move goes
// through the editor first (so `liveRange` is already that caret). Skip it and keep the live range;
// every other move (including the unfocused/programmatic caret set) still applies.
let wouldCollapseLiveRange = liveRange.length > 0 && targetRange.length == 0
if liveRange != targetRange, !wouldCollapseLiveRange {
self.selectedRange = targetRange
}
return
}
let newDocument = document(
fromChatInputContent: content,
registerEmoji: { [weak self] fileId, file in self?.registerEmojiRef(fileId: fileId, file: file) ?? EmojiRef(id: String(fileId), instanceID: BlockID.generate().rawValue, altText: nil) },
registerMedia: { [weak self] media in self?.registerMediaValue(media) ?? "" }
)
// Programmatic set (draft restore / send-clear / state echo): the assignment fires `onChange`
// synchronously (reload → setBlocks → notifyContentSizeChanged); flag it so the typing-activity
// path in `onChange` doesn't treat this structural re-apply as the user typing.
self.isApplyingProgrammaticContent = true
self.editorView.document = newDocument
self.isApplyingProgrammaticContent = false
if self.editorView.bounds.width > 0.0 {
_ = self.editorView.update(size: self.editorView.bounds.size, insets: self.trackedInsets, contentMargins: self.trackedContentMargins, scrollIndicatorInsets: self.trackedScrollIndicatorInsets)
}
self.storedDelegate?.chatInputTextNodeDidUpdateText()
self.selectedRange = selection.nsRange(in: content)
}
/// Direct-bridge `resolveEmoji`: map an editor `EmojiRef` (whose `id` is the fileId STRING, by the node's
/// convention) to the chat layer's `(fileId, file)`. The `file` comes from the node's `customEmojiAttributes`
/// cache (the editor drops the `TelegramMediaFile`), or nil if this emoji wasn't seen via a file-bearing push.
/// A non-numeric `id` (should not occur from this path) returns nil ⇒ the run degrades to plain text, matching
/// `ComposerDocumentBridge`'s fallback.
private func resolveEmojiRef(_ emojiRef: EmojiRef) -> (fileId: Int64, file: TelegramMediaFile?)? {
guard let fileId = Int64(emojiRef.id) else {
return nil
}
return (fileId, self.customEmojiAttributes[fileId]?.file)
}
/// Direct-bridge `registerEmoji`: cache the `TelegramMediaFile` (mirroring the `attributedText`-setter
/// harvest — only a file-BEARING attribute is recorded, so a file-less round-trip never clobbers a resolved
/// one) and mint the editor `EmojiRef` keyed by the fileId string (the node's convention; `instanceID` via the
/// editor's `BlockID.generate()`, as `ComposerDocumentBridge` does).
private func registerEmojiRef(fileId: Int64, file: TelegramMediaFile?) -> EmojiRef {
if let file {
self.customEmojiAttributes[fileId] = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: file)
}
return EmojiRef(id: String(fileId), instanceID: BlockID.generate().rawValue, altText: nil)
}
/// Direct-bridge `resolveMedia`: map the editor's opaque `mediaID` back to the concrete `Media` the node
/// cached when it last received it (`registerMediaValue`). Nil ⇒ the media block is dropped on read-back
/// (an unresolved medium has no `ChatInputMedia` representation).
private func resolveMediaID(_ mediaID: String) -> Media? {
return self.mediaByID[mediaID]
}
/// Direct-bridge `registerMedia`: cache the `Media` under a stable opaque key (`composerMediaKey`, the
/// `"namespace:id"` convention shared with `RichTextAttachmentScreen.attachedMedia`) and hand that key to
/// the editor. The editor stores only this opaque key (it never holds a `Media`); the node's media-view
/// provider (registered in `didLoad`) resolves the key back through `mediaByID` to render the block. The
/// key also round-trips `currentInputContent → setInputContent` consistently, which an id-derived key does.
private func registerMediaValue(_ media: Media) -> String {
let mediaID = composerMediaKey(media)
self.mediaByID[mediaID] = media
return mediaID
}
/// Map the host's theme colors 1:1 into the editor's `RichTextEditorTheme`. Assigning `editorView.theme`
/// re-applies colors and redraws (see `RichTextEditorView.theme`).
public func applyRichTextTheme(_ colors: ChatRichTextThemeColors) {
// Store the checklist checkbox colors for the marker provider (registered in `didLoad`), which reads
// them lazily — so this theme change takes effect on the next-built checkbox.
self.checkboxFill = colors.listCheckFillColor
self.checkboxForeground = colors.listCheckForegroundColor
self.checkboxBorder = colors.listCheckBorderColor
self.editorView.theme = RichTextEditorTheme(
primaryText: colors.primaryText,
secondaryText: colors.secondaryText,
placeholder: colors.placeholder,
accent: colors.accent,
tableBorder: colors.tableBorder,
tableHeaderBackground: colors.tableHeaderBackground,
codeBackground: colors.tableHeaderBackground, // a code band reads as a highlighted table row — the same fill, deliberately
containerPlaceholder: colors.placeholder.mixedWith(colors.accent, alpha: 0.15).withMultipliedBrightnessBy(colors.primaryText.brightness >= 0.4 ? 1.1 : 0.9).withMultipliedAlpha(0.8),
shadowCursor: colors.shadowCursor,
quoteAuthorText: colors.quoteAuthorText,
quoteAuthorPlaceholder: colors.quoteAuthorPlaceholder
)
}
// MARK: Layout (Task 4)
/// The panel sets `textFieldFrame` (the child's frame) separately; this lays the editor's content out at
/// `size` using the tracked scroll insets + content margins. Trailing scroll inset stays `.zero` in
/// Phase 1 (the composer field is short and grows; the keyboard inset is the panel's concern).
public func updateLayout(size: CGSize) {
_ = self.editorView.update(size: size, insets: self.trackedInsets, contentMargins: self.trackedContentMargins, scrollIndicatorInsets: self.trackedScrollIndicatorInsets)
}
/// Measures the editor's content height at `width` with a stateless, side-effect-free measure
/// (`RichTextEditorView.height(forWidth:)`) — it does NOT reflow the live editor or touch its
/// frames/insets. `rightInset` is recorded for the next real `update(...)`.
///
/// Passes `trackedContentMargins` (the margins the next `update(...)` will apply) so the measure reserves
/// the right inset even when the panel sizes its field BEFORE the editor has been laid out — a draft
/// applied before the first layout. Without this the live canvas margins are still zero, the text is
/// measured at the full width, and the field is sized one wrap too short, then visibly grows on the next
/// pass (the pre-set-text "jump").
public func textHeightForWidth(_ width: CGFloat, rightInset: CGFloat) -> CGFloat {
self.trackedRightInset = rightInset
return self.editorView.height(forWidth: width, contentMargins: self.trackedContentMargins)
}
public func measuredTextFieldHeight(forWidth width: CGFloat, lineCount: Int) -> CGFloat {
// Measure the probe with THIS composer's live content margins (the same value the live `textHeightForWidth`
// passes), so the probe's vertical inset matches the real field and the 3-line size is exact.
return RichTextEditorView.measuredContentHeight(forWidth: width, lineCount: lineCount, contentMargins: self.trackedContentMargins, configure: { editor in
RichTextEditorChatInputNode.applyComposerLayoutMetrics(to: editor)
})
}
public func layoutInputField() {
_ = self.editorView.update(size: self.editorView.bounds.size, insets: self.trackedInsets, contentMargins: self.trackedContentMargins, scrollIndicatorInsets: self.trackedScrollIndicatorInsets)
}
public var textFieldFrame: CGRect {
get { self.editorView.frame }
set { self.editorView.frame = newValue }
}
public var inputView: UIView { self.editorView }
public var textContainerInset: UIEdgeInsets {
get {
var updated = self.trackedContentMargins
updated.top += 0.0
updated.bottom += 1.0
updated.right -= 14.0
return updated
}
set {
var updated = newValue
updated.top -= 0.0
updated.bottom -= 1.0
updated.right += 14.0
self.trackedContentMargins = updated
}
}
public var currentRightInset: CGFloat { self.trackedRightInset }
// MARK: Editing & focus (Task 5)
public func deleteBackward() { self.editorView.deleteBackward() }
public var isInputFirstResponder: Bool { self.editorView.isEditorFirstResponder }
@discardableResult public func makeInputFirstResponder() -> Bool { self.editorView.becomeFirstResponder() }
@discardableResult public func resignInputFirstResponder() -> Bool { self.editorView.resignEditorFirstResponder() }
public func applyAutocorrection() { self.editorView.finalizeComposerMarkedText() }
// MARK: Context menu + native format routing
public var usesNativeRichTextEngine: Bool { true }
public var contextMenuItemsProvider: ((_ defaultElements: [UIMenuElement]) -> [UIMenuElement])? {
get { self.editorView.contextMenuItemsProvider }
set { self.editorView.contextMenuItemsProvider = newValue }
}
public var onRequestTableStructuralMenu: ((TableStructuralMenuRequest) -> Void)? {
get { self.editorView.onRequestTableStructuralMenu }
set { self.editorView.onRequestTableStructuralMenu = newValue }
}
/// Set by the panel; invoked (with a resolved `EngineMedia`) when the editor reports a media control tap.
public var onRequestMediaControl: ((MediaControlContext) -> Void)?
public var canPasteMedia: (() -> Bool)? { didSet { self.editorView.canPasteMedia = canPasteMedia } }
public var onPasteMedia: (() -> Bool)? { didSet { self.editorView.onPasteMedia = onPasteMedia } }
public var pastedMarkdownFragmentParser: ((String) -> Document?)? { didSet { self.editorView.plainTextFragmentTransformer = self.pastedMarkdownFragmentParser } }
public func performFormatAction(_ action: ChatRichTextFormatAction) {
switch action {
case .bold: self.editorView.toggleBold()
case .italic: self.editorView.toggleItalic()
case .strikethrough: self.editorView.toggleStrikethrough()
case .underline: self.editorView.toggleUnderline()
case .monospace: self.editorView.toggleInlineCode()
case .spoiler: self.editorView.toggleSpoiler()
case .quote: self.editorView.wrapInBlockQuote()
case .pullQuote: self.editorView.makePullQuote()
case .code: self.editorView.makeCodeBlock()
case .date:
// TODO: no timestamp-entity model in the editor (deferred). No-op for now.
break
}
}
public func performRichPaste() {
self.editorView.pasteFromPasteboard()
}
public func currentRichTextLinkURL() -> String? { self.editorView.currentLink() }
public func selectedRichText() -> String { self.editorView.selectedText() }
public func applyRichTextLink(_ url: String?) {
if let url, !url.isEmpty {
self.editorView.setLink(url)
} else {
self.editorView.removeLink()
}
}
public var keyboardInputView: UIView? {
get { self.editorView.customInputView }
set { self.editorView.customInputView = newValue }
}
public func reloadInputViews() { self.editorView.reloadComposerInputViews() }
public var inputDelegate: ChatInputTextNodeDelegate? {
get { self.storedDelegate }
set { self.storedDelegate = newValue }
}
// MARK: Simple view adapters (Task 6)
public var inputIsUserInteractionEnabled: Bool {
get { self.editorView.isUserInteractionEnabled }
set { self.editorView.isUserInteractionEnabled = newValue }
}
public var inputClipsToBounds: Bool {
get { self.editorView.clipsToBounds }
set { self.editorView.clipsToBounds = newValue }
}
public var inputAccessibilityHint: String? {
get { self.editorView.accessibilityHint }
set { self.editorView.accessibilityHint = newValue }
}
/// Negative insets (the panel sets -5pt all sides) that ENLARGE the editor's tap target: a touch landing in
/// the thin margin just outside `editorView` (but inside this wrapper) still focuses/positions the editor,
/// instead of falling through to the wrapper. Applied by the `hitTest(_:with:)` override below — mirrors what
/// the legacy backend gets for free by forwarding to its inner text node's `ASDisplayNode.hitTestSlop`.
public var inputHitTestSlop: UIEdgeInsets = .zero
/// `ASDisplayNode.hitTest` is forwarded to by the backing `_ASDisplayView` when overridden (with a
/// re-entrancy guard, so `super.hitTest` below is the standard UIView pass). We special-case ONLY the slop
/// ring around `editorView`; taps inside the field, and everywhere else, take the normal path unchanged.
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let slop = self.inputHitTestSlop
if slop != .zero, self.editorView.isUserInteractionEnabled, !self.editorView.isHidden, self.editorView.alpha > 0.01 {
let frame = self.editorView.frame // in this wrapper's (self.view) coordinate space, as is `point`
// Negative insets expand the rect. Only the ring (expanded AND outside the real frame) is remapped.
if frame.inset(by: slop).contains(point) && !frame.contains(point) {
// Clamp the probe into the field so `editorView.hitTest` resolves to its canvas (the real
// UITextInput). UIKit still delivers the ACTUAL touch, which the canvas maps to the nearest caret.
var probe = self.view.convert(point, to: self.editorView)
probe.x = min(max(probe.x, self.editorView.bounds.minX), self.editorView.bounds.maxX - 0.5)
probe.y = min(max(probe.y, self.editorView.bounds.minY), self.editorView.bounds.maxY - 0.5)
if let hit = self.editorView.hitTest(probe, with: event) {
return hit
}
}
}
return super.hitTest(point, with: event)
}
public var inputContentOffset: CGPoint { self.editorView.composerContentOffset }
public func setInputScrollIndicatorInsets(_ insets: UIEdgeInsets) { self.trackedScrollIndicatorInsets = insets }
/// The caret/selection in the composer's flat UTF-16 coordinate space (paragraphs joined by "\n",
/// matching `ComposerDocumentBridge`). The panel reads this to know where to insert/replace and writes
/// it to move the caret after an edit, so it MUST reflect the editor's real selection — a stub (e.g.
/// always end-of-text + no-op setter) makes the panel selection-blind: the caret never advances after a
/// programmatic insert and a surrogate-pair emoji gets split on edit, leaving a stray "service character".
public var selectedRange: NSRange {
get { self.editorView.composerSelectedRange }
set { self.editorView.composerSelectedRange = newValue }
}
// MARK: Selection geometry
/// The current selection's first rect in the editor's CONTENT space (un-scrolled), matching the legacy
/// `firstRect(for:)` contract — `_showTextStyleOptions` subtracts `inputContentOffset.y` itself. That
/// legacy format-menu path is dead on iOS 16+ (it returns a nil target), so this is contract-honest but
/// not on a live path for this engine.
public var selectionRect: CGRect { self.editorView.composerSelectionBoundingRect }
/// First selection rect for a flat composer range, in this node's `self.view` space (the panel anchors
/// the emoji-suggestion popover here, then converts node-view → panel). The editor returns the rect in
/// `editorView` space; we finish with the trivial `editorView → self.view` parent conversion.
public func firstSelectionRect(forCharacterRange characterRange: NSRange) -> CGRect? {
self.editorView.composerFirstSelectionRect(forFlatRange: characterRange).map { self.editorView.convert($0, to: self.view) }
}
/// The caret rect in this node's `self.view` space (the panel anchors the emoji context panel's cursor
/// here via `asNode.view.convert(...)`). nil when there is no caret.
public func currentCaretRect() -> CGRect? {
self.editorView.composerCaretRect().map { self.editorView.convert($0, to: self.view) }
}
// MARK: Stubs — Phase 2+ (spoilers, typing attrs, theme fidelity)
public var spoilersRevealed: Bool = false
public func setSpoilersRevealed(_ revealed: Bool, animated: Bool) { }
public func prepareForSpoilerReveal() { }
public func updateRichRendering(textColor: UIColor, fullTranslucency: Bool) { }
public func refreshTextInputAttributes(context: AnyObject, primaryTextColor: UIColor, accentTextColor: UIColor, baseFontSize: CGFloat, spoilersRevealed: Bool, availableEmojis: Set<String>, emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?) { }
public func refreshTextInputTypingAttributes(textColor: UIColor, baseFontSize: CGFloat) { }
public var inputTypingAttributes: [NSAttributedString.Key: Any] {
get { [:] }
set { }
}
public var toggleQuoteCollapse: ((NSRange) -> Void)?
public func isCurrentlyEmoji() -> Bool { false }
// Input language: read the live keyboard language back, and pre-select the draft's saved language
// on the next focus, via the editor's `textInputMode` override (see RichTextEditorView+ComposerHost).
public var primaryLanguage: String? { self.editorView.inputPrimaryLanguage }
public var initialPrimaryLanguage: String? {
get { self.editorView.initialInputPrimaryLanguage }
set { self.editorView.initialInputPrimaryLanguage = newValue }
}
public func resetInitialPrimaryLanguage() { self.editorView.resetInputPrimaryLanguage() }
public var keyboardAppearance: UIKeyboardAppearance = .default
public var autocorrectionType: UITextAutocorrectionType = .default
public var inputTintColor: UIColor?
public func didChangeInputTintColor() { }
public var inputCaretColor: UIColor = .clear
public var inputTheme: ChatInputTextView.Theme?
}