Files
ghostgram/docs/richtext-composer.md
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

56 KiB
Raw Permalink Blame History

Rich-text chat composer & ChatInputContent

The app-side rich-text input story: the WYSIWYG editor, the ChatInputContent value model that replaced NSAttributedString as the composer currency, and the round-trips through send / edit / drafts / cross-device sync.

Authority boundaries — this file is the app-integration reference. For:

  • editor internals (TextKit seam, layout, boxes) → submodules/TelegramUI/Components/RichTextEditor/CLAUDE.md.
  • message rendering (InstantPage V2, rich bubbles) → docs/instantpage-richtext.md.

The editor is gated behind the debugRichText flag; the native engine is the rich path, the legacy UITextView node is the production fallback (a lossy filter — see below).


1. The editor

A from-scratch WYSIWYG rich-text editor (submodules/TelegramUI/Components/RichTextEditor), developed as an in-tree SwiftPM package and wired into the app: ChatTextInputPanelNode depends on :RichTextEditorUIKit, so it builds under Bazel at the repo's iOS-13 floor. Core (:RichTextEditorCore) is pure-Foundation.

  • TextKit is quarantined behind protocol BlockLayoutEngine: BlockLayout (TextKit 2, @available(iOS 16)) on iOS 16+, BlockLayoutTK1 (TextKit 1) on iOS 13–15, chosen by makeBlockLayout(...) / BlockLayoutBackend.forceTextKit1. Higher-OS APIs kept at their genuine floor: TK2 + UIEditMenuInteraction at 16, loupe (UITextLoupeSession) + inline predictions at 17, Translate at 17.4, isEditable at 18. TK1 trade-offs (iOS 13–15, deliberate): no spoiler text-hiding, loupe, or inline predictions.
  • The system edit menu falls back UIEditMenuInteraction (16+) → UIMenuController (13–15). iOS 13–15 keeps the editor's built-in menu (UIMenuController can't carry closure-backed items).
  • Bundled resource (spoiler texture) is build-system-split behind #if SWIFT_PACKAGE: SwiftPM uses .module; Bazel uses the app's AppBundle (UIImage(bundleImageName: "Components/TextSpeckle"), //submodules/AppBundle dep).
  • Parent-driven API: RichTextEditorView.update(size:insets:) -> CGFloat (parent supplies scroll insets, returns measured height), payload-free onChange, currentState() -> EditorState, deleteTable(), makeCodeBlock(), registerMediaViewProvider. The host RichTextAttachmentScreen (separate module) drives layout from onChange and owns a bottom action bar (RichTextActionBarComponent).

Invariant — a view does not own its frame. Editor/component update(...) reads self.bounds and lays out subviews; it never writes self.frame (the parent chose it). See the "View frame ownership" note in the root CLAUDE.md.


2. ChatInputContent — the composer currency

ChatInputContent (submodules/TelegramCore/Sources/ChatInputContent/ChatInputContentModel.swift) is a purpose-built, TelegramCore-native value model that replaced NSAttributedString as the chat composer's currency. It is a block/run tree mirroring the editor Document 1:1:

  • Blocks: .paragraph (body / heading1-3 / quote, + optional list membership), .code, .collapsedQuote(ChatInputContent), .media(ChatInputMedia), .table(ChatInputTable).
  • Inline runs: bold / italic / mono / strike / underline / spoiler + entity mention / url / date / customEmoji(fileId:file:enableAnimation:).
  • Selection is a recursive tree-path (ChatInputPathStep{blockIndex,slot} / ChatInputPosition{path,offset} / ChatInputSelection{start,end}) — chosen for nested quotes + collapsible blocks — with a content-aware flat↔structural bridge (position(forFlatOffset:) / flatOffset(for:) / nsRange(in:) / init(nsRange:in:)).

Conversions:

  • Display-neutral TextFormat utility (Sources/ChatInputContentConversion.swift): chatInputContent(from:) / attributedString(from:), round-trip-identity tested in //submodules/TextFormat:TextFormatTests. Contiguity is load-bearing for quotes: chatInputContent(from:) carves each maximal contiguous .block/.quote run (via enumerateAttribute, like codeBlockRanges for code) into ONE multi-paragraph .blockQuote — it must NOT split per-\n. A multi-line quote is one .block object spanning its interior newlines; splitting it produced one .blockQuote per line, which attributedString(from:) then re-emitted as non-contiguous runs separated by plain \ns → multiple on-screen quote boxes (seen both on format-apply and, since this feeds the persisted "cm" model, after re-opening a chat). Two genuinely separate quotes are divided by a non-block \n, so they remain two runs → two blocks (no coalescing across a non-quote gap).
  • Direct editor bridge (ChatRichTextEditorComposer/Sources/DocumentChatInputContentBridge.swift): Document ↔ ChatInputContent, used by the native node — it bypasses the NSAttributedString hop where structural blocks (media/table/heading/list) would be flattened.

Load-bearing invariants

  • ChatTextInputState.== is value-based (submodules/AccountContext/Sources/ChatController.swift): fast-path on selectionRange + inputText.isEqual(to:), else compare chatInputContent(from:) value models. This is strictly coarser-or-equal to the old reference equality (can only remove churn). Scoped to the state type (Design A) — not a global value-based ChatTextInputTextCustomEmojiAttribute.isEqual, which lives in ~90 files incl. message rendering.
  • .media / .table are off the flat axis (blockIsFlatParticipating) across plainText / blockFlatLength / flatOffset / position(forFlatOffset:), so the invariant plainText == attributedString(from:).string holds. Violating it drifts the native caret past non-text blocks. .collapsedQuote legitimately is one flat placeholder char.
  • Custom emoji occupies its alt-string's UTF-16 length in the composer flat space (the editor's composerSelectedRange / composerParagraphs), matching the content and the legacy seam — else the caret drifts past custom emoji.
  • Codable (drafts) goes through AdaptedPostbox{En,De}coder, which supports NO singleValueContainer and NO bare Int. So enum discriminators are explicit Int32 rawValues (never encode(SomeEnum.case)), and all numbers are explicit Int32/Int64. Test the model Codable via AdaptedPostboxEncoder (a JSON round-trip masks this). The polymorphic Media persists via a concrete-type discriminator + TelegramMediaImage/File(decoder:), not decodeRootObjectWithHash (that needs the app-startup declareEncodable registry, empty in tests). This applies to every raw enum, String-raw included: a RawRepresentable enum's synthesized Codable uses a singleValueContainer, so each raw enum in ChatInputContentModel.swift carries a CUSTOM keyed Codable encoding its .rawValue under a "raw" key. ChatInputMediaDisplayMode (a String-raw enum) was missed and crashed at AdaptedPostboxEncoder.swift:94 when a draft containing a .media block was persisted (it is encoded by ChatInputMedia.encode(to:)) — fixed by giving it the same custom keyed Codable.

isEntityExpressible(options:) is the routing switch: text / quote / collapsed-quote / code / mention / date / custom-emoji-in-body are entity-expressible (normal text+entities path); heading / list / table / media are not (→ structured .instantPage path). The EntityExpressibleOptions bag narrows this: with .quotesRequireRichContent, a .quote paragraph and a .collapsedQuote are treated as NOT entity-expressible, so quote-bearing content routes onto the rich path even though entities could represent it (opted into by the send-options preview and the attachment-menu rich-editor send — see §5).


3. Composer ↔ editor wiring

  • Node selection (ChatTextInputPanelNode): richTextInputNode is the native node (RichTextEditorChatInputNode, usesNativeRichTextEngine == true) under debugRichText, else the legacy UITextView node (ChatRichTextInputNodeImpl).
  • GET (inputTextState): returns ChatTextInputState(content: richTextInputNode.currentInputContent().content, …) directly — no NSAttributedString round-trip, so native structural blocks survive. For the legacy node the content is always flat, so it's identical to the old path.
  • SET (setInputContent): the chat layer routes content-set sites through the node. The native node lands structural blocks straight into the editor Document (registering media/emoji so a later GET resolves them); the legacy node is a render-only lossy filter — attributedString(from:) drops media/table and renders heading/list as plain text.
  • Legacy node owns its decoration: applyRenderingConfig(...) + setInputContent + currentInputContent, per-keystroke decorateAfterTextChange, spoiler-reveal (updateSpoilersRevealed, 1.5 s hide + dust cross-fade), paste/plain-fragment (decorateReplacementFragment). The panel no longer calls refreshTextInputAttributes / prepareForSpoilerReveal etc. Theme-change re-color routes through decorateAfterTextChange (correct per-attribute colors + overlay rebuild — a naive full-range foregroundColor rewrite reveals custom-emoji / unrevealed-spoiler base glyphs).
  • Expand / collapse is routed through ChatInterfaceState, not the node (ChatControllerNode.openExpandedInput): OUT converts the live inputTextState.content → (Document, media, emojiFiles) to seed RichTextAttachmentScreen; IN converts the editor's (document, media, emojiFiles) → ChatTextInputState applied via updateChatPresentationInterfaceState + withUpdatedEffectiveInputState (the openAICompose precedent — one value seen by undo/drafts/send/observers). Thin converters: ChatRichTextEditorComposer/Sources/ComposerExpandedEditorBridge.swift. Custom-emoji files ([Int64: TelegramMediaFile]) and media are threaded both ways (a fileId-only emoji ref renders blank).
  • OUT back-fills emoji files the composer content only holds by fileId. A ChatInputContent custom-emoji run may carry the fileId without the TelegramMediaFile attached; the plain documentMediaAndEmoji(...) then returns an emojiFiles map missing those ids, seeding the editor's emoji store (and, on the initial .edit/.standalone seed, the keyboard's file set) empty — so the emoji renders blank and its file is dropped on collapse-back / send. The OUT path therefore uses the async documentMediaAndEmojiAsync(engine:…) (awaited inside openExpandedInput's Task { @MainActor }), which resolves any fileId with no inline file from the local sticker cache (engine.stickers.resolveInlineStickersLocal, a postbox getMedia over Namespaces.Media.CloudFile). Local-only is deliberate — composer emoji are always already cached, so no network round-trip stalls the expand; a fileId absent even locally simply stays unresolved (renders blank, as before). Persisted drafts need no resolution (their emojiFiles are stored/decoded whole in RichTextDraft); the in-editor AI-insert paths still use the sync documentMediaAndEmoji (their completion closures are synchronous, and AI-generated content rarely carries custom emoji).

Invariant — the cycle constraint. InstantPageUI transitively deps ChatTextInputPanelNode, so the panel/node cannot dep InstantPageUI / RichTextEditorMediaView. TelegramUI (above the cycle) builds the inline-media view (RichTextEditorMediaView.MediaItemNodeView) and injects a mediaItemViewFactory closure into the panel at its construction sites; media crosses the protocol as EngineMedia (keeping the node Postbox-free).

Invariant — the Display.Window1.hitTest "EditMenu" match is load-bearing for the iOS-16 edit menu: without it the menu's taps fall through to content app-wide.

Keyboard input language (interfaceState.inputLanguage)

The composer reports the active keyboard's primary language back into ChatInterfaceState.inputLanguage (feeding emoji-keyword search at ChatInterfaceInputContexts.swift and draft persistence), and pre-selects the keyboard language when a draft is reopened. This rides the editor's first responder, DocumentCanvasView, which carries a one-time textInputMode override (a verbatim port of the legacy ChatInputTextView mechanism): RichTextEditorChatInputNode.primaryLanguage → RichTextEditorView.inputPrimaryLanguage → canvas.textInputMode?.primaryLanguage, and initialPrimaryLanguage is seeded by ChatTextInputPanelNode → RichTextEditorView.initialInputPrimaryLanguage → canvas.initialPrimaryLanguage. The override is single-shot (UIKit's becomeFirstResponder query consumes the pre-selection before any read-back), so the read path must not run before focus — the panel already guards on isInputFirstResponder, falling back to storedInputLanguage otherwise.

Writing direction (RTL)

The editor auto-detects each paragraph's direction (RTL for Arabic/Hebrew/…) and lays it out accordingly, so RTL typing "just works" in the composer; an empty message's caret follows the keyboard's input language and re-flips live on a globe-key switch. The whole-document override (RichTextEditorView.layoutDirectionOverride) exists on the façade (and has a UI control in RichTextAttachmentScreen) but is not surfaced in the chat composer this round — auto-detect covers it. Editor-side architecture lives in the editor's own CLAUDE.md ("RTL / writing direction").

Empty-input right inset. ChatTextInputPanelNode.calculateTextFieldRealInsets reserves the action-control slot on the field's right (actionControlsWidth - 10). The effective per-layout call (applied to the rich node) passes that width only when the send button is shown (inputHasText || hasMediaDraft || hasForward || isEditingMedia); when empty the send button is hidden (scaled to ~0), so the field insets only for the in-field accessory buttons (a further 10pt is trimmed). Without this an empty field over-insets on the right.


4. Feature round-trips

All inline / structural features round-trip losslessly through the native composer; the markers live in shared TextFormat codecs so live-edit, send, copy, and paste agree.

Document (file) blocks (added 2026-07-31). A generic file attached in the article editor is a MediaKind.document block — a CAPTION-LESS atom (the audio shape: nodeSize 3, empty leafRegions(), gap-caret), rendered as a fixed 52pt row that must stay in lockstep with InstantPageV2Layout's documentFrame. It threads editor Document ↔ ChatInputContent (ChatInputMediaKind.document, raw 4) ↔ InstantPage (.document(id:caption:)). Like .audio, the chat currency CARRIES a caption while the editor renders none — MediaBlockBox drops it at the boundary. Routing: everything picked that is not video/music/voice becomes a document, including an image-mime file from the Files tab (that tab's "send as file" meaning). Documents are permanently single-item and never grouped into a collage or slideshow. The host renderer dispatches on the editor's MediaKind (threaded through the media-view seam), NOT by sniffing the resolved Media — sniffing sent an image-mime file to the photo pool. A file carrying a preview renders its thumbnail in the row (editor and bubble share the node, so both get it at once). Recipient-side render, the thumbnail/status-colour traps, and tap-to-open live in instantpage-richtext.md. Runtime-verified 2026-07-31.

  • Formatting menu (iOS 16+): the composer's Format submenu (Bold/Italic/Monospace/Link/Strikethrough/ Underline/Quote/Spoiler/Date/Code, secret-chat gated) is spliced into the editor's edit menu via RichTextEditorView.contextMenuItemsProvider. Actions route to the native engine; Link through the host openLinkEditing; Code creates a first-class code block; Date is a deferred no-op (the editor lacks only a timestamp-creation UI).
  • Custom emoji / mention / date: carried by ComposerDocumentBridge (live) + the direct bridge. Custom emoji = one U+FFFC with the alt-string on EmojiRef.altText (emitted as the run text under the entity on send — a bare U+FFFC otherwise reaches the wire). Mentions / dates encode into the shared link field via tg://user?id= / tg://timestamp?t= markers (TextFormat/MentionDateMarkers.swift: mentionMarkdownURL / dateMarkdownURL / classifyChatLink / chatInputLinkAttribute). Accepted limitation: a textUrl whose string equals a tg:// marker (via markdown/paste/edit) is reinterpreted as a mention/date on round-trip — low-probability, documented in MentionDateMarkers.swift.
  • Code blocks: first-class multi-line Block.code (Core CodeBlock + CodeBlockBox), reusing the newline-agnostic position model. Markers in TextFormat/CodeBlockMarkers.swift; entity-expressible (.Pre). Enter inserts an interior newline (exits on an empty trailing line), backspace in an empty block un-codes it. Multi-line quotes also emit one contiguous blockquote on send. Deferred: language picker (creation sets language = nil; incoming language round-trips), code in table cells, inline formatting inside code.
  • Inline media: an attached image/video renders inline in the native composer and survives expand↔collapse
    • drafts, carried by ChatInputContent.media. The concrete view is the shared RichTextEditorMediaView.MediaItemNodeView (also used by the article editor), resolved via the injected factory (§3). Multi-media containers (added 2026-07-08). A media block now holds items: [ChatInputMediaItem] (one or more photos/videos) with a single shared caption — the editor's MediaBlock gained the matching items: [MediaItem]. The change is additive/back-compat: a convenience single-media initializer + get-only accessors reproduce the old API, and both MediaBlock/ChatInputMedia Codable decode a legacy flat single-media payload into items: [one], so existing single-media messages/drafts/ tests are unchanged. Grouping is photo/video only — .audio/.location stay permanently single-item. A container renders in-editor as a mosaic via the shared MosaicLayout engine (the same one grouped album messages use), with per-cell view reuse keyed by media identity so surviving cells aren't rebuilt/re-fetched on an add/remove. A container of items.count >= 2 sends as an InstantPage .collage rich message (see instantpage-richtext.md); count == 1 still sends the byte-identical .image/.video block. Layout mode (added 2026-07-17). A MediaBlock/ChatInputMedia now carries a displayMode (.mosaic default / .slideshow, Codable back-compat → .mosaic); a count >= 2 container in .slideshow mode renders in-editor as a swipeable carousel + paging dots and sends as InstantPage .slideshow instead of .collage (both forward converters branch on it; the reverse recovers it on edit). The toggle button that flips the mode is article-editor only (left of "+"); the composer stays mosaic-only. Design + plan: docs/superpowers/{specs,plans}/2026-07-17-richtext-media-layout-toggle*. Authoring is split: per-cell delete-one is wired in both hosts; "Add another photo/video" and the layout toggle are wired in the article editor's chrome only — the composer's in-place Add is a deferred follow-up (the composer already renders/edits multi-media from sent albums and drafts). Design + plan: docs/superpowers/{specs,plans}/2026-07-08-richtext-multi-media-container*. Media spoilers (added 2026-07-08). A photo/video can be marked a Telegram-style spoiler (dust-covered until tapped), per item: MediaItem.isSpoiler (editor Core) ↔ ChatInputMediaItem.isSpoiler (both additive, optional-Codable, absent ⇒ false, so all existing docs/drafts/messages decode unchanged) ↔ a spoiler: Bool associated value on InstantPageBlock.image/.video. Authoring: tap-select a single medium → the edit menu's "Spoiler" item (imageSelectionMenu, toggles via toggleSelectedMediaSpoiler); for an album, each cell's "•••" menu carries a per-cell "Spoiler" (threaded through MediaControlRequest.isSpoiler/toggleSpoiler → MediaControlContext → the panel's ContextUI menu). Both route to RichTextEditorView.toggleMediaSpoiler(itemIndex:) → DocumentCanvasView.toggleMediaSpoiler(blockID:itemIndex:) (one undo step, in-place MediaBlockBox rebuild like deleteMediaItem). In-editor render is a NON-revealable authoring cover — MediaItemNodeView hosts a MediaDustNode (via InvisibleInkDustNode) per spoiler cell, revealOnTap = false, non-interactive (taps fall through to selection); the flag reaches it through MediaProviderItem.isSpoiler and is folded into the syncMediaItemViews items-signature so a toggle re-provides the cell. On the wire, no Api regeneration: the server schema already defines pageBlockPhoto#1759c560 spoiler:flags.1 / pageBlockVideo#7c8fe7b6 spoiler:flags.2, so the bit is read/OR'd purely in ApiUtils/InstantPage.swift (like autoplay/loop). The flag round-trips through Postbox Codable ("sp" key), the flatBuffers InstantPageBlock path (Models/InstantPageBlock.fbs + the hand-written encode/decode — so it survives every InstantPage persistence path, not just Postbox), and BOTH send converters (ChatInputContentInstantPage composer + InstantPageBuilder article). Sent/received message render (revealable dust with first-tap reveal → then gallery) is in instantpage-richtext.md. Design + plan: docs/superpowers/{specs,plans}/2026-07-08-richtext-media-spoiler*. All sim tests run on the iPhone 17 Pro K3 sim.
  • Location maps: a picked location is a Block.media with MediaKind.location whose mediaID resolves to a TelegramMediaMap. A map is an id-less Media, so the host mints a deterministic "map:lat:long" key (not the usual namespace:id). It renders inline as a map snapshot through the same MediaItemNodeView seam — which, for a .geo EngineMedia, threads an InstantPageMapAttribute so InstantPageImageNode draws the snapshot + pin — and survives expand↔collapse + drafts via ChatInputContent.media. It sends as an InstantPage .map(lat, long, zoom:15, dimensions:600×300, caption) block (NO pageMedia entry — coordinates are inline). BOTH converters emit it — InstantPageBuilder (article editor) and ChatInputContentInstantPage (composer) — each restructured so the id-less map isn't dropped by the media.id guard; the reverse rebuilds a TelegramMediaMap (venue/zoom/dimensions aren't represented and canonicalize to defaults, like image size/alignment). Authoring is the existing attachment menu, not a dedicated button: the editor's single attach action opens presentRichTextAttachmentMenu (Gallery / Audio / Location); the .location result returns as RichTextAttachment.location(TelegramMediaMap) and is inserted with the venue title as its caption (a raw dropped pin → empty caption). The rendered map shows the theme list.mediaPlaceholderColor while the async MKMapSnapshotter fetch completes (see instantpage-richtext.md). Deferred: live location / heading / proximity, editing a placed location's coordinates, an interactive in-editor map.
  • Audio: an attached music/voice file is a Block.media with MediaKind.audio (a single kind for both — the file's .Audio(isVoice:) attribute drives the music-vs-voice render, so no separate voice kind). It renders inline as a fixed-height 44pt row (matching the V2 audioFrame height, not aspect-scaled — the first non-aspect media kind, so MediaBlockBox.imageAreaHeight/measuredHeight/mediaRect all branch on kind == .audio), via a new playable standalone view StandaloneInstantPageAudioView (InstantPageUI, sibling to StandaloneInstantPageImageView) resolved through the same MediaItemNodeView seam — it hosts the module-internal InstantPageV2AudioContentNode driven by a self-contained one-item InstantPageMediaPlaylist (no enclosing V2 tree; freeMediaFileInteractiveFetched(.standalone) so an edit-loaded cloud audio plays with no message reference). In the editor the row is themed to the editor's accent/text scheme (not the outgoing-bubble palette the V2 audio node uses for sent messages): the node gained an additive, nil-default InstantPageAudioColorOverride (play button + progress ring → editor accent, title/duration → editor primary/ secondary text) that each host fills from the same source the table reads — chat.inputPanel.* in the composer, list.item* on the attachment screen; nil leaves real message rendering byte-unchanged. Audio is a caption-less atom — unlike image/video/location it has no caption (not rendered, editable, or present in the position model): DocumentTree emits mediaBlock([mediaAtom]) (nodeSize 3, no caption paragraph) and MediaBlockBox is dual-moded on kind == .audio (textLength 0, leafRegions [], no "Add caption" row); inserting audio lands the caret in a following body paragraph, and the select-all/covered-delete checks use an audio-aware coverableContentEnd (nodeStart + 1). It sends as an InstantPage .audio(id, caption) block with an always-empty caption (file registered in page.media), drawn by the existing V2 audio node, and survives expand↔collapse + drafts via ChatInputContent.media (concrete TelegramMediaFile). BOTH converters emit/parse it — InstantPageBuilder (article) and ChatInputContentInstantPage (composer, both directions, paralleling .image/.video). Authoring is the attachment menu's existing Audio button (a music-file picker → RichTextAttachment.file; the .file route now branches isVideo → video, isMusic || isVoice → audio, else drop). Voice is round-trip-only (no picker / no in-editor recording): it enters on edit of a rich message that already contains a voice note, and renders through the same node (the V2 audio node is music-styled — waveform rendering deferred, voice plays as a music-style row via the .voice player type). Accepted limitation: an incoming audio caption is dropped when the message is opened for edit (audio is caption-less).

5. Send / edit / pending display of rich messages

Rich content that the entity set can't express (heading/list/table/media, and structured combinations) is sent as a RichTextMessageAttribute carrying an InstantPage, rendered by the V2 path (see instantpage-richtext.md). The ChatInputContent ↔ InstantPage pair lives in TelegramCore/Sources/ChatInputContent/ChatInputContentInstantPage.swift.

Interactive checklists round-trip (added 2026-06-26). ChatInputListMarker gained .checklist and ChatInputListMembership a checked: Bool? field (optional Codable → old drafts decode unchanged; this is the draft currency, so checked state persists in drafts for free). It threads: editor Document ↔ ChatInputContent (DocumentChatInputContentBridge, both directions) ↔ InstantPage (ChatInputContentInstantPage: forward emits per-item checked; reverse maps an item with checked != nil back to .checklist — so checked:false survives, not collapsing to bullet) → InstantPageListItem.checked. A checklist is a list ⇒ already non-entity-expressible ⇒ routes to the rich .instantPage send path with no change. Recipient checkboxes are display-only; the sender re-edits via the reverse bridge. The editor side (tappable checkbox, creation, geometry) is in RichTextEditor/CLAUDE.md.

  • Send (ChatControllerNode.sendCurrentMessage): reads the structural composeInputState.content. When editMessage == nil && !content.isEntityExpressible() && !content.isEmpty, enqueues one .message(text: "", attributes: [RichTextMessageAttribute(instantPage: instantPage(from: content), …)], …). Entity-expressible content keeps the existing breakChatInputText text+entities loop byte-identical. The custom-emoji premium-lock harvest + early-return stays before the branch.
  • Edit (ChatControllerLoadDisplayNode): LOAD seeds ChatTextInputState(content: chatInputContent(fromInstantPage: richTextAttribute.instantPage)) (structural, media preserved — not a markdown flatten); DONE routes by isEntityExpressible() — non-entity → rebuild RichTextMessageAttribute + pass richText: (so the backend uploads media), else demote to plain text+entities. Native path only (the legacy composer flattens structure on the first keystroke).
  • Attachment-menu rich editor (ChatControllerOpenAttachmentMenu's .richText send → composeRichMessage(from:media:forSendPreview:) in RichTextEditorMessageConversion): passes forSendPreview: true, so a blockquote forces the rich (InstantPage) path here even though a quote is entity-expressible (documentNeedsRichLayout honors the same flag at the editor-Document level). The composer Send / Edit gates above pass default options, so a quote sent from the composer is still plain text + a blockquote entity — a deliberate, localized divergence (this rich-editor send's long-press now opens send-options with no preview — see "Expanded-editor send-options" below; the composer's preview opts into the same quote-as-rich rule, below).
  • Pending edits display optimistically: ChatUpdatingMessageMedia carries an optional richText; the bubble prefers itemAttributes.updatingMedia.map(\.richText) ?? item.message.richText (display :360, anchor :1449, "Show more" gate :567 in ChatMessageRichDataBubbleContentNode). The render-cache key (ensurePageView / currentPageLayout) includes a pending-edit discriminator ((updatingMedia?.richText).map { ObjectIdentifier($0) }) because stableVersion doesn't bump during a pending edit; and PendingUpdateMessageManager.add publishes immediately (a media-bearing rich edit swallows upload progress, so the optimistic value must be visible before the upload finishes).

Rich-message media on the wire

The media-less serializer RichTextMessageAttribute.apiInputRichMessage() (photos:/documents: nil) is only the fallback (nil / secret-chat / empty-media / upload-failure). The real paths upload + assemble:

  • richMessageContentToUpload → assembleInputRichMessage (PendingMessageUploadedContent.swift) fill photos/documents (already-cloud media short-circuits to cloud IDs); uploadedRichMessage(...) is the thin single-emission (take(1)) resolver.
  • Send routes through messageContentToUpload → uploads. Edit sequences uploadedRichMessage before its media chain. Incoming parse (SyncCore_RichTextMessageAttribute.swift) reconstructs media from photos/documents.

Send-options preview (long-press Send)

Long-pressing Send opens the send-options context screen (ChatSendMessageContextScreen), whose preview bubble renders the message as it will be sent. For rich content the bubble shows the actual InstantPage via ChatSendMessageRichTextPreview (wrapping an InstantPageV2View in the outgoing message theme), injected through the ChatSendMessageContextScreenRichTextPreview protocol (mirroring the existing media-preview seam, since ChatSendMessageActionUI cannot dep InstantPageUI).

  • Gating mirrors the real send paths, built in Chat/ChatMessageDisplaySendMessageOptions.swift via the file-private makeRichTextSendPreview(context:content:mediaPreview:) (predicate: mediaPreview == nil && !content.isEmpty && !content.isEntityExpressible(options: [.quotesRequireRichContent])). New-message branch feeds composeInputState.content (matching ChatControllerNode's send gate; additionally skipped for .customChatContents); edit branch feeds editMessage.inputState.content (matching ChatControllerLoadDisplayNode's edit gate). Plain / quote-free entity-expressible content keeps the flat-text morph. With the legacy composer, content is always flat → entity-expressible → no preview (so no debugRichText check is needed).
  • A blockquote previews as a rich bubble (the .quotesRequireRichContent opt-in), even though the composer Send / Edit gates send a quote as plain text + a blockquote entity (they pass default options — see the divergence note above). So a quote-only message's preview bubble (InstantPage) renders through a different path than the eventually-sent message; align the two by passing .quotesRequireRichContent at those gates too if a pixel-faithful preview is wanted.
  • Morph (MessageItemView): the plain-text path morphs a flat-text copy of the live field into the bubble. That copy can't represent rich structure (headings/lists/tables), so the rich path instead captures a pixel snapshot of the live input field on the source-state layout (before the screen hard-hides the field), positions it to overlay the field exactly (top-left at (textInsets.left, 2.0), matching the screen's sourceMessageItemFrame math), and crossfades that snapshot into the InstantPageV2View as the bubble settles. Falls back to the flat-text crossfade if snapshotView returns nil.
  • Flat-copy text color is set per morph state, keyed off explicitBackgroundSize == nil (isSettled): the extracted textString carries no base foreground color that renders correctly here (the preview node defaults it to black), so MessageItemView applies one explicitly and re-applies it whenever the state flips (tracked by textNodeUsesOutgoingColor). Settled (inside the outgoing bubble) → chat.message.outgoing.primaryTextColor; source / animate-out (the copy overlaying the live field) → chat.inputPanel.inputTextColor, so the copy matches the still-visible field. This is why a colored outgoing bubble shows the right color (e.g. white) instead of black, and why the dark-theme animate-out doesn't fall back to black. Link entities stay outgoing.linkTextColor in both states.
  • Clipping: the page content is clipped to the bubble's inner corner radius (15pt, matching the real rich bubble's image.defaultCornerRadius) within the tail-excluded content rect [1, width − 7] (same as the text path), so images/tables round to the bubble and stay clear of the outgoing tail.

Expanded-editor send-options (RichTextAttachmentScreen, no preview)

The expanded rich-text editor's own Send button (RichTextAttachmentScreen, the full-screen article editor — not the chat composer's Send above) gained an optional long-press → send-options action (Send / Send silently / Schedule / Send when online) via makeChatSendMessageActionSheetController(params: .sendMessage(… mediaPreview: nil …)) — no message preview (distinct from the composer's preview subsection above). It is caller-supplied: the screen takes an optional RichTextAttachmentScreenSendContextActions (a module-local type — peerId + send/schedule closures over the screen's native (Document, [String: Media], [Int64: TelegramMediaFile], withoutFormatting: Bool, SendMode, SendParameters?) tuple; kept out of AccountContext because that tuple's RichTextEditorCoreDocument must not pull RichTextEditorCore into AccountContext). When it is nil the button is tap-only (feature off). The long-press is offered only when sendContextActions != nil && isSendEnabled && !isSendRichFormattingLocked — suppressed while a non-premium formatting lock is active, so the tap path's remove-formatting alert can't be side-stepped (⇒ withoutFormatting is always false on this path). The send button is wrapped in a ContextExtractedContentContainingView for the extract-out animation (mirrors TextProcessingScreen's ActionButtonsComponent). Screen side: displayLongPressSendMenu in RichTextAttachmentScreen.swift.

Two callers supply the actions, gated identically on editMessage == nil && chatLocation.peerId != nil:

  • Composer handoff (ChatControllerNode.openExpandedInput): send/schedule set the chat's effective input state from the editor content, then route through the same self.sendCurrentMessage(...) the tap path uses (.generic/.silently, .whenOnline → scheduleTime: scheduleWhenOnlineTimestamp) and controllerInteraction?.scheduleCurrentMessage — so tap-Send and long-press-Send stay identical.
  • Article route (ChatControllerOpenAttachmentMenu's .richText case, which sends by building an EnqueueMessage directly, not via sendCurrentMessage): shared buildRichTextContent + performRichTextSend locals feed transformEnqueueMessages(silentPosting:scheduleTime:) per mode (.whenOnline → scheduleWhenOnlineTimestamp) with presentScheduleTimePicker for Schedule, then presentPaidMessageAlertIfNeeded → sendMessages. Here message effects are ignored and when-online just sends (no scheduled-view navigation).

6. Draft persistence

Two layers (see SyncCore_SynchronizeableChatInputState.swift, ChatInterfaceState.swift):

  • Local — ChatTextInputState persists ChatInputContent under the "cm" Codable key (back-compat-decoding the legacy "at" ChatTextInputStateText). So a rich draft (incl. media, via the concrete Media) survives an app restart on-device.
  • Cloud / cross-device — ChatInterfaceState.synchronizeableInputState produces a SynchronizeableChatInputState.Content: .textEntities(text,entities) for entity-expressible content, .instantPage(InstantPage) otherwise. ManagedSynchronizeChatInputStateOperations builds messages.saveDraft.

Invariant — ChatInterfaceState.parse always overrides composeInputState from the flat synchronizeable form, but does NOT override editMessage.inputState. So the "cm" local Codable is redundant-but-harmless for the composer yet load-bearing for the edit-message draft (preserves structure the fragmenting "at" round-trip would lose).

Invariant — saveDraft sends message: "" / no entities (clears the 1<<3 flag) when richMessage is set. The InstantPage already carries the text; the receiver builds the draft purely from richMessage when present (AccountStateManagementUtils ignores message/entities), so sending the flat text duplicates it on the wire.

Cross-device media sync

The cloud draft uploads its inline media (the last media-less path, now closed). Driven by the account's MessageMediaPreuploadManager, made lifecycle-aware: add(...) returns a Disposable (a ref-counted "need" reusing the existing subscribers: Bag); the last released need starts a 1 s grace timer that cancels + evicts the upload unless re-added; a live/in-grace context is reused, never restarted (this also fixes a context leak — LegacyLiveUploadInterface holds its token until deinit). synchronizeChatInputState resolves the rich message through uploadedRichMessage, so the bytes upload once and are shared with the eventual send.

Since media pre-upload (below) the per-peer need updatePeerMediaNeeds holds is media-level and covers every uploadable medium, not byte-level over TelegramMediaFiles with a localIdForResource. That older form silently excluded images entirely, and excluded videos and iCloud files (whose resources have no local id) — those were re-uploaded on every draft save and again at send.

6a. Media pre-upload

Media attached in the editor starts uploading immediately, and the client takes over the result at send, at draft save, and across the editor being closed and reopened. Architecture and the full behavioural matrix are in the design doc (kept on branch feature/richtext-media-preupload, not on master).

One registry, two layers, on the existing MessageMediaPreuploadManager. Its byte layer (uploadContexts: [Int64: …], keyed by localIdForResource) is unchanged. A new media layer (mediaContexts, keyed by MediaId) parks a cloud EngineMedia and runs bytes → messages.uploadMedia → cloud media via the shared uploadMediaToCloud. The media layer's byte step goes through the manager's own upload(...), so the two share one transfer wherever localIdForResource resolves; where it does not — video, iCloud files — the media layer is the only dedup there is, which is why it must key on MediaId. The ref-counting itself lives in a standalone, unit-tested module, submodules/MediaPreuploadRegistry (21 tests).

Two holders, both reconciling through the same MediaPreuploadNeeds:

DURABLE    ManagedSynchronizeChatInputStateOperations.updatePeerMediaNeeds
           reconciles from the PERSISTED draft — survives closing the editor AND leaving the chat
TRANSIENT  RichTextAttachmentScreen  — from its live document, covering attach-before-first-save
           ChatControllerNode        — from composeInputState.content, covering the editor handoff

Load-bearing invariants

  • Needs and subscribers are counted separately. A need keeps an upload alive; an observer only watches. Grace fires when needs hit zero regardless of observers, so a passive UI subscriber can never pin an orphaned upload. join (the send path) holds a need for its subscription, which is what stops a send in flight being evicted when the editor that started it closes.
  • A context must never leave a subscriber waiting. Every eviction path — failure, cancellation, grace expiry — notifies subscribers with a terminal state before removing the context. A subscriber that only ever sees .progress is a hang on Send, not a slow upload.
  • The promotion write-back is a map-value swap under the UNCHANGED key. The editor addresses media by an opaque mediaID string (resolveMedia: { media[mediaID] }), so replacing the value promotes the medium everywhere downstream with no document mutation, no undo entry and no relayout. Re-deriving the key from the cloud media is a bug: promotion changes the MediaId, so the medium would silently vanish on read-back. moveResourceData points the local bytes at the cloud resource so nothing flashes a placeholder.
  • isPreuploadableMedia answers "is this worth pre-uploading?", NOT "can the send path use this?" It returns false for already-cloud media. Gating the send on it fails every promoted medium outright — which is exactly what broke sending after a successful pre-upload. The send path asks uploadedRichMedia(from:) instead, which answers the second question, and handles image and file symmetrically.
  • forceReupload must bypass the whole registry, evicting first. It is set by PendingMessageManager's FILEREF_INVALID / FILE_REFERENCE_*_EXPIRED retry, so a parked result — or the already-cloud fast path — would resend the very reference the server just rejected, and only one retry is permitted. Residual gap: if the bytes are no longer on disk (fresh device, cache eviction), the forced re-upload still fails; the correct fix is reference revalidation.
  • Progress is owned by the media view, not pushed to it. syncMediaItemViews re-invokes the media-view provider only when a block's itemsSignature changes, and upload progress is not — and cannot be — part of that signature. RichTextMediaContentComponent.View therefore subscribes to mediaPreuploadState(id:) itself and updates its status node directly, never via state.updated() (which re-enters update, and update re-issues the image fetch unconditionally).

Re-login restore

On a fresh login the server delivers each chat's draft in the dialog draft field (getDialogs), not as an updateDraftMessage. Drafts (rich included) restore via the shared parser _internal_synchronizeableChatInputState(accountPeerId:peerId:apiDraft:) (Sources/State/RestoreFetchedDrafts.swift, also called by the incremental updateDraftMessage path) + _internal_applyFetchedChatInputStates, wired into ResetState (login) and fetchChatListHole (live session) after updatePeers.

Invariant — newer-wins, never-clear. A fetched draft is applied only if there's no local draft OR its date is strictly newer than the local synchronizeableInputState.timestamp (so a live edit is never clobbered). Only non-empty .draftMessage drafts are collected (a fetch never clears; clearing stays with the real-time path). A pinned chat's draft appears in both the remote and pinned responses — the duplicate is deduped by the idempotent newer-wins guard, not explicitly, so don't drop the strict-> guard.


7. Markdown on paste (plain text → rich)

Pasting plain text that parses as CommonMark markdown inserts it as rich content instead of a literal string — **bold** becomes bold, # Heading / - list / a |-table become real blocks. Clipboard paste only (no drag-&-drop, programmatic insert, or share-extension prefill). Always-on; undo is the escape hatch.

Pipeline (reuses the send-path parser). chatInputContentFromPastedMarkdown(context:plainText:) (TelegramUI/Sources/PastedMarkdownConversion.swift) runs the same CommonMark parser the rich-message send path uses — inputRichTextAttributeFromText (BrowserUI/BrowserMarkdown.swift, Apple NSAttributedString(markdown:) with default options) → InstantPage → chatInputContent(fromInstantPage:) → ChatInputContent. A pure gate pastedMarkdownContentIsRicherThanPlain(_:) (TextFormat/PastedMarkdownGate.swift) returns nil when the parse is nothing but unformatted .body paragraphs, so ordinary text (incl. multi-line) falls through to the default plain paste; CommonMark's paired-delimiter rules mean a stray */- never triggers.

Self-referential links are stripped BEFORE that gate (load-bearing — a pasted URL must stay plain). Apple's NSAttributedString(markdown:) applies the GFM autolink extension, so a bare https://… — and also a bare www.… host or an email address, each of which the parser rewrites into http://www.… / mailto:… — comes back as a LINK run whose label is the URL itself. That entity alone made the gate answer "richer than plain", so pasting a plain URL latched the field to the native editor and landed the URL as a text link (textUrl), pinning a destination the plain URL already carries. chatInputContentStrippingSelfReferentialLinks(_:) (TextFormat/SelfReferentialLinks.swift) clears every .url entity whose covered text IS its own URL, so a URL-only paste now falls through to plain paste and the recipient's client detects the URL itself. A genuine text link ([label](url), a label that differs from its target) is untouched and still classifies as rich, as is anything pasted from Telegram's own private.telegramtext / editor-fragment representations — those never go through an importer. The predicate chatInputLinkIsSelfReferential(text:url:) tolerates exactly the normalizations a producer applies (added mailto: / http(s):// scheme — the latter only when the text carries no scheme of its own — percent-encoded path, trailing /); a tg:// mention/date marker never matches its own label, so those entities are unaffected. The same rule is applied on the other import paths, not just markdown: chatInputStateStringFromRTF (legacy field, RTF/HTML paste — via the NSAttributedString form chatInputTextStrippingSelfReferentialLinks), legacyChatInputAttributedString(fromRTF:), and the editor's own RTFConversion.fragment(fromRTF:) (which uses a duplicate of the predicate, linkIsSelfReferential, in RichTextEditorCore — Core cannot import TextFormat; keep the two in step). The copy direction (storeAttributedTextInPasteboard) is deliberately NOT touched: it keeps whatever entities the source message carried.

Why the monolith owns the parse. BrowserUI already depends on ChatRichTextEditorComposer, so neither the panel, the attachment screen, nor the RichTextEditor package may import it (cycle). The parse therefore lives in the TelegramUI monolith (the one layer that can import BrowserUI + AccountContext) and is injected downward as a closure. The RichTextEditor package stays markdown-free: it exposes a neutral plainTextFragmentTransformer: ((String) -> Document?)? it calls without knowing what markdown is.

Wiring.

  • Chat composer: ChatTextInputPanelNode.pastedMarkdownParser (set at panel construction — ChatControllerNode / ChatInterfaceStateInputPanels). It sets ChatRichTextInputNode.pastedMarkdownFragmentParser on the native node (→ RichTextEditorView.plainTextFragmentTransformer), and in chatInputTextNodeShouldPaste() (legacy field), any markdown latches to the native editor via pasteRichFragmentFromPasteboard(). So all markdown paste — inline or structural, legacy-surface or native — flows through the native editor uniformly.
  • Article editor: RichTextAttachmentScreen(pastedMarkdownParser:) threads the closure to its RichTextEditorView.

Two-step undo (load-bearing). The native paste (DocumentCanvasView.pasteMarkdownTwoStep) inserts the raw markdown text as step 1, then replaces that range with the rich content as step 2 — so one Cmd+Z / shake reverts rich → plain and a second removes it. Step 2 is deferred to the next run-loop cycle on purpose: the editor's private UndoManager uses the default groupsByEvent, which coalesces every registration made in one run-loop event into a single undo group, so a synchronous step 2 would collapse both into one undo. To avoid a visible flash of the raw markdown, step 1 sets DocumentCanvasView.suppressHostChangeNotification, which makes notifyContentSizeChanged() (gated at its definition — setBlocks calls it too, not just editing {}) and the editing {} trailing refreshSelectionUI()/onSelectionChange() no-op; the canvas has no draw(_:) and is parent-driven, so with the host un-notified the intermediate state neither lays out nor moves the caret. Step 2 runs the normal, host-notifying edit, so only the rich result is drawn and the caret moves once (to its end).

Nested lists. chatInputBlocks(fromInstantPageBlocks:) recurses into InstantPageListItem.blocks items — a list item that carries continuation paragraphs and/or a nested sub-list, which the markdown parser emits for an indented sub-list — preserving indent via ChatInputListMembership.level (mapped on to the editor's ListMembership.level by the bridge). Skipping .blocks used to silently drop a whole nested sub-list on paste.

Accepted limitation. The ChatInputContent → InstantPage forward (send) still coalesces list items to a flat level (canonicalizes indent), so a pasted nested list displays nested in the composer/editor but may flatten when the message is sent — a separate send-path change.

8. Accepted limitations & deferred work

  • Cross-device collapsed-quote fidelity (multi-block only): drafts sync as Api.InputRichMessage.inputRichMessage(blocks: [Api.PageBlock]) (ManagedSynchronizeChatInputStateOperations.swift), so a single-paragraph collapsed quote now keeps its collapsed state across devices — pageBlockBlockquote carries collapsed:flags.0?true. A quote with two or more blocks still serializes as pageBlockBlockquoteBlocks, which has no such flag, and arrives expanded. Closing that needs a server-side pageBlockBlockquoteBlocks flags:# collapsed:flags.0?true.
  • Custom-emoji enableAnimation has no RichText carrier, so it canonicalizes to true on the reverse (re-derived at decoration; pinned by test_customEmoji_enableAnimationFalse).
  • Forum/monoforum topic drafts and folder/archived dialog drafts are not restored on the fetchChatList path (separate paths; archived drafts restore when that folder is fetched).
  • Date creation in the composer is a no-op (preservation only — an incoming date round-trips).
  • Code blocks: no language picker; no code inside table cells; no inline formatting inside code.
  • The SynchronizeableChatInputState.Content.instantPage cloud branch is exercised today only by structural blocks; everything entity-expressible takes .textEntities.
  • Writing-direction override in the composer: auto-detect handles RTL while typing, but a manual whole-document LTR/RTL toggle is not surfaced in the chat composer (it exists on the façade + the attachment screen). Gutter ornaments (list markers / quote bar / indents) and table columns are not yet mirrored for RTL.
  • Document blocks have no composer authoring affordance — they enter the composer only via the edit round-trip; the article editor is the only place to attach one. There is likewise no editor-side open/preview of an attached document.
  • Markdown copy drops a document block (InstantPageToMarkdown has no spelling for it), the same class of loss already accepted for inline buttons. Editing is unaffected — that path is structural.
  • ChatInputMediaKind raw 4 fails to decode on an older build reading a cross-device-synced draft (its init(from:) rejects unknown raw values). Consistent with the ChatInputListMarker.checklist precedent.
  • File-reference refresh for an EXPIRED rich-message document is unverified. The open path builds FileMediaReference.message(…), but the file lives in the attribute rather than message.media. Freshly-received messages are unaffected; the fallback if it fails is a .standalone(media:) reference.

Key files

Concern Path
value model + Codable TelegramCore/Sources/ChatInputContent/ChatInputContentModel.swift
display-neutral conversion TextFormat/.../ChatInputContentConversion.swift
ChatInputContent ↔ InstantPage TelegramCore/Sources/ChatInputContent/ChatInputContentInstantPage.swift
direct editor bridge Chat/ChatRichTextEditorComposer/Sources/DocumentChatInputContentBridge.swift
live / send / expand bridges ChatRichTextEditorComposer/Sources/{ComposerDocumentBridge,ComposerExpandedEditorBridge}.swift
markers (mention/date, code) TextFormat/.../MentionDateMarkers.swift, CodeBlockMarkers.swift
native node Chat/ChatRichTextEditorComposer/Sources/RichTextEditorChatInputNode.swift
panel (GET/SET, node select) Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift
markdown-on-paste parse (monolith) TelegramUI/Sources/PastedMarkdownConversion.swift
markdown-on-paste gate TextFormat/Sources/PastedMarkdownGate.swift (+ Tests/PastedMarkdownGateTests.swift)
self-referential-link strip (paste) TextFormat/Sources/SelfReferentialLinks.swift (+ Tests/SelfReferentialLinkTests.swift); editor-side duplicate RichTextEditorCore/Model/SelfReferentialLinks.swift
CommonMark → InstantPage (send + paste) BrowserUI/Sources/BrowserMarkdown.swift (inputRichTextAttributeFromText)
two-step paste + neutral transformer hook RichTextEditor/.../Canvas/DocumentCanvasView+Clipboard.swift (pasteMarkdownTwoStep), DocumentCanvasView.swift (plainTextFragmentTransformer, suppressHostChangeNotification)
state value-equality AccountContext/Sources/ChatController.swift
send / edit TelegramUI/Sources/ChatControllerNode.swift, Chat/ChatControllerLoadDisplayNode.swift
expanded-editor send-options RichTextAttachmentScreen/Sources/RichTextAttachmentScreen.swift; callers ChatControllerNode.swift (openExpandedInput), ChatControllerOpenAttachmentMenu.swift (.richText)
rich attribute + wire TelegramCore/Sources/SyncCore/SyncCore_RichTextMessageAttribute.swift
upload + assemble TelegramCore/Sources/PendingMessages/PendingMessageUploadedContent.swift
draft persistence TelegramCore/Sources/SyncCore/SyncCore_SynchronizeableChatInputState.swift, ChatInterfaceState/Sources/ChatInterfaceState.swift
cloud draft sync + media TelegramCore/Sources/State/ManagedSynchronizeChatInputStateOperations.swift, State/MessageMediaPreuploadManager.swift
re-login restore TelegramCore/Sources/State/RestoreFetchedDrafts.swift