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).
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 bymakeBlockLayout(...)/BlockLayoutBackend.forceTextKit1. Higher-OS APIs kept at their genuine floor: TK2 +UIEditMenuInteractionat 16, loupe (UITextLoupeSession) + inline predictions at 17, Translate at 17.4,isEditableat 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 (UIMenuControllercan't carry closure-backed items). - Bundled resource (spoiler texture) is build-system-split behind
#if SWIFT_PACKAGE: SwiftPM uses.module; Bazel uses the app'sAppBundle(UIImage(bundleImageName: "Components/TextSpeckle"),//submodules/AppBundledep). - Parent-driven API:
RichTextEditorView.update(size:insets:) -> CGFloat(parent supplies scroll insets, returns measured height), payload-freeonChange,currentState() -> EditorState,deleteTable(),makeCodeBlock(),registerMediaViewProvider. The hostRichTextAttachmentScreen(separate module) drives layout fromonChangeand owns a bottom action bar (RichTextActionBarComponent).
Invariant — a view does not own its
frame. Editor/componentupdate(...)readsself.boundsand lays out subviews; it never writesself.frame(the parent chose it). See the "View frame ownership" note in the rootCLAUDE.md.
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, + optionallistmembership),.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. - Direct editor bridge (
ChatRichTextEditorComposer/Sources/DocumentChatInputContentBridge.swift):Document ↔ ChatInputContent, used by the native node — it bypasses theNSAttributedStringhop where structural blocks (media/table/heading/list) would be flattened.
ChatTextInputState.==is value-based (submodules/AccountContext/Sources/ChatController.swift): fast-path onselectionRange+inputText.isEqual(to:), else comparechatInputContent(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-basedChatTextInputTextCustomEmojiAttribute.isEqual, which lives in ~90 files incl. message rendering..media/.tableare off the flat axis (blockIsFlatParticipating) acrossplainText/blockFlatLength/flatOffset/position(forFlatOffset:), so the invariantplainText == attributedString(from:).stringholds. Violating it drifts the native caret past non-text blocks..collapsedQuotelegitimately 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 NOsingleValueContainerand NO bareInt. So enum discriminators are explicitInt32rawValues (neverencode(SomeEnum.case)), and all numbers are explicitInt32/Int64. Test the model Codable viaAdaptedPostboxEncoder(a JSON round-trip masks this). The polymorphicMediapersists via a concrete-type discriminator +TelegramMediaImage/File(decoder:), notdecodeRootObjectWithHash(that needs the app-startupdeclareEncodableregistry, empty in tests).
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).
- Node selection (
ChatTextInputPanelNode):richTextInputNodeis the native node (RichTextEditorChatInputNode,usesNativeRichTextEngine == true) underdebugRichText, else the legacyUITextViewnode (ChatRichTextInputNodeImpl). - GET (
inputTextState): returnsChatTextInputState(content: richTextInputNode.currentInputContent().content, …)directly — noNSAttributedStringround-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 editorDocument(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-keystrokedecorateAfterTextChange, spoiler-reveal (updateSpoilersRevealed, 1.5 s hide + dust cross-fade), paste/plain-fragment (decorateReplacementFragment). The panel no longer callsrefreshTextInputAttributes/prepareForSpoilerRevealetc. Theme-change re-color routes throughdecorateAfterTextChange(correct per-attribute colors + overlay rebuild — a naive full-rangeforegroundColorrewrite reveals custom-emoji / unrevealed-spoiler base glyphs). - Expand / collapse is routed through
ChatInterfaceState, not the node (ChatControllerNode.openExpandedInput): OUT converts the liveinputTextState.content→(Document, media, emojiFiles)to seedRichTextAttachmentScreen; IN converts the editor's(document, media, emojiFiles)→ChatTextInputStateapplied viaupdateChatPresentationInterfaceState+withUpdatedEffectiveInputState(theopenAIComposeprecedent — 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
ChatInputContentcustom-emoji run may carry the fileId without theTelegramMediaFileattached; the plaindocumentMediaAndEmoji(...)then returns anemojiFilesmap missing those ids, seeding the editor's emoji store (and, on the initial.edit/.standaloneseed, 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 asyncdocumentMediaAndEmojiAsync(engine:…)(awaited insideopenExpandedInput'sTask { @MainActor }), which resolves any fileId with no inline file from the local sticker cache (engine.stickers.resolveInlineStickersLocal, a postboxgetMediaoverNamespaces.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 (theiremojiFilesare stored/decoded whole inRichTextDraft); the in-editor AI-insert paths still use the syncdocumentMediaAndEmoji(their completion closures are synchronous, and AI-generated content rarely carries custom emoji).
Invariant — the cycle constraint.
InstantPageUItransitively depsChatTextInputPanelNode, so the panel/node cannot depInstantPageUI/RichTextEditorMediaView.TelegramUI(above the cycle) builds the inline-media view (RichTextEditorMediaView.MediaItemNodeView) and injects amediaItemViewFactoryclosure into the panel at its construction sites; media crosses the protocol asEngineMedia(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.
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.
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.
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.
- 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 hostopenLinkEditing; 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 = oneU+FFFCwith the alt-string onEmojiRef.altText(emitted as the run text under the entity on send — a bareU+FFFCotherwise reaches the wire). Mentions / dates encode into the sharedlinkfield viatg://user?id=/tg://timestamp?t=markers (TextFormat/MentionDateMarkers.swift:mentionMarkdownURL/dateMarkdownURL/classifyChatLink/chatInputLinkAttribute). Accepted limitation: atextUrlwhose string equals atg://marker (via markdown/paste/edit) is reinterpreted as a mention/date on round-trip — low-probability, documented inMentionDateMarkers.swift. - Code blocks: first-class multi-line
Block.code(CoreCodeBlock+CodeBlockBox), reusing the newline-agnostic position model. Markers inTextFormat/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 setslanguage = 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 sharedRichTextEditorMediaView.MediaItemNodeView(also used by the article editor), resolved via the injected factory (§3). Multi-media containers (added 2026-07-08). A media block now holdsitems: [ChatInputMediaItem](one or more photos/videos) with a single shared caption — the editor'sMediaBlockgained the matchingitems: [MediaItem]. The change is additive/back-compat: a convenience single-media initializer + get-only accessors reproduce the old API, and bothMediaBlock/ChatInputMediaCodable decode a legacy flat single-media payload intoitems: [one], so existing single-media messages/drafts/ tests are unchanged. Grouping is photo/video only —.audio/.locationstay permanently single-item. A container renders in-editor as a mosaic via the sharedMosaicLayoutengine (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 ofitems.count >= 2sends as an InstantPage.collagerich message (seeinstantpage-richtext.md);count == 1still sends the byte-identical.image/.videoblock. Layout mode (added 2026-07-17). AMediaBlock/ChatInputMedianow carries adisplayMode(.mosaicdefault /.slideshow, Codable back-compat →.mosaic); acount >= 2container in.slideshowmode renders in-editor as a swipeable carousel + paging dots and sends as InstantPage.slideshowinstead 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) ↔ aspoiler: Boolassociated value onInstantPageBlock.image/.video. Authoring: tap-select a single medium → the edit menu's "Spoiler" item (imageSelectionMenu, toggles viatoggleSelectedMediaSpoiler); for an album, each cell's "•••" menu carries a per-cell "Spoiler" (threaded throughMediaControlRequest.isSpoiler/toggleSpoiler→MediaControlContext→ the panel's ContextUI menu). Both route toRichTextEditorView.toggleMediaSpoiler(itemIndex:)→DocumentCanvasView.toggleMediaSpoiler(blockID:itemIndex:)(one undo step, in-placeMediaBlockBoxrebuild likedeleteMediaItem). In-editor render is a NON-revealable authoring cover —MediaItemNodeViewhosts aMediaDustNode(viaInvisibleInkDustNode) per spoiler cell,revealOnTap = false, non-interactive (taps fall through to selection); the flag reaches it throughMediaProviderItem.isSpoilerand is folded into thesyncMediaItemViewsitems-signature so a toggle re-provides the cell. On the wire, no Api regeneration: the server schema already definespageBlockPhoto#1759c560 spoiler:flags.1/pageBlockVideo#7c8fe7b6 spoiler:flags.2, so the bit is read/OR'd purely inApiUtils/InstantPage.swift(likeautoplay/loop). The flag round-trips through Postbox Codable ("sp"key), the flatBuffersInstantPageBlockpath (Models/InstantPageBlock.fbs+ the hand-written encode/decode — so it survives every InstantPage persistence path, not just Postbox), and BOTH send converters (ChatInputContentInstantPagecomposer +InstantPageBuilderarticle). Sent/received message render (revealable dust with first-tap reveal → then gallery) is ininstantpage-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.
- drafts, carried by
- Location maps: a picked location is a
Block.mediawithMediaKind.locationwhosemediaIDresolves to aTelegramMediaMap. A map is an id-lessMedia, so the host mints a deterministic"map:lat:long"key (not the usualnamespace:id). It renders inline as a map snapshot through the sameMediaItemNodeViewseam — which, for a.geoEngineMedia, threads anInstantPageMapAttributesoInstantPageImageNodedraws the snapshot + pin — and survives expand↔collapse + drafts viaChatInputContent.media. It sends as an InstantPage.map(lat, long, zoom:15, dimensions:600×300, caption)block (NOpageMediaentry — coordinates are inline). BOTH converters emit it —InstantPageBuilder(article editor) andChatInputContentInstantPage(composer) — each restructured so the id-less map isn't dropped by themedia.idguard; the reverse rebuilds aTelegramMediaMap(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 openspresentRichTextAttachmentMenu(Gallery / Audio / Location); the.locationresult returns asRichTextAttachment.location(TelegramMediaMap)and is inserted with the venue title as its caption (a raw dropped pin → empty caption). The rendered map shows the themelist.mediaPlaceholderColorwhile the asyncMKMapSnapshotterfetch completes (seeinstantpage-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.mediawithMediaKind.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 V2audioFrameheight, not aspect-scaled — the first non-aspect media kind, soMediaBlockBox.imageAreaHeight/measuredHeight/mediaRectall branch onkind == .audio), via a new playable standalone viewStandaloneInstantPageAudioView(InstantPageUI, sibling toStandaloneInstantPageImageView) resolved through the sameMediaItemNodeViewseam — it hosts the module-internalInstantPageV2AudioContentNodedriven by a self-contained one-itemInstantPageMediaPlaylist(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-defaultInstantPageAudioColorOverride(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):DocumentTreeemitsmediaBlock([mediaAtom])(nodeSize 3, no caption paragraph) andMediaBlockBoxis dual-moded onkind == .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-awarecoverableContentEnd(nodeStart + 1). It sends as an InstantPage.audio(id, caption)block with an always-empty caption (file registered inpage.media), drawn by the existing V2 audio node, and survives expand↔collapse + drafts viaChatInputContent.media(concreteTelegramMediaFile). BOTH converters emit/parse it —InstantPageBuilder(article) andChatInputContentInstantPage(composer, both directions, paralleling.image/.video). Authoring is the attachment menu's existing Audio button (a music-file picker →RichTextAttachment.file; the.fileroute now branchesisVideo→ 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.voiceplayer type). Accepted limitation: an incoming audio caption is dropped when the message is opened for edit (audio is caption-less).
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).
ChatInputListMarkergained.checklistandChatInputListMembershipachecked: Bool?field (optional Codable → old drafts decode unchanged; this is the draft currency, so checked state persists in drafts for free). It threads: editorDocument↔ChatInputContent(DocumentChatInputContentBridge, both directions) ↔InstantPage(ChatInputContentInstantPage: forward emits per-itemchecked; reverse maps an item withchecked != nilback to.checklist— sochecked:falsesurvives, not collapsing to bullet) →InstantPageListItem.checked. A checklist is a list ⇒ already non-entity-expressible ⇒ routes to the rich.instantPagesend 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 inRichTextEditor/CLAUDE.md.
- Send (
ChatControllerNode.sendCurrentMessage): reads the structuralcomposeInputState.content. WheneditMessage == nil && !content.isEntityExpressible() && !content.isEmpty, enqueues one.message(text: "", attributes: [RichTextMessageAttribute(instantPage: instantPage(from: content), …)], …). Entity-expressible content keeps the existingbreakChatInputTexttext+entities loop byte-identical. The custom-emoji premium-lock harvest + early-return stays before the branch. - Edit (
ChatControllerLoadDisplayNode): LOAD seedsChatTextInputState(content: chatInputContent(fromInstantPage: richTextAttribute.instantPage))(structural, media preserved — not a markdown flatten); DONE routes byisEntityExpressible()— non-entity → rebuildRichTextMessageAttribute+ passrichText:(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.richTextsend →composeRichMessage(from:media:forSendPreview:)inRichTextEditorMessageConversion): passesforSendPreview: true, so a blockquote forces the rich (InstantPage) path here even though a quote is entity-expressible (documentNeedsRichLayouthonors the same flag at the editor-Documentlevel). 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 has no long-press preview; the composer's preview opts into the same quote-as-rich rule, below). - Pending edits display optimistically:
ChatUpdatingMessageMediacarries an optionalrichText; the bubble prefersitemAttributes.updatingMedia.map(\.richText) ?? item.message.richText(display:360, anchor:1449, "Show more" gate:567inChatMessageRichDataBubbleContentNode). The render-cache key (ensurePageView/currentPageLayout) includes a pending-edit discriminator ((updatingMedia?.richText).map { ObjectIdentifier($0) }) becausestableVersiondoesn't bump during a pending edit; andPendingUpdateMessageManager.addpublishes immediately (a media-bearing rich edit swallows upload progress, so the optimistic value must be visible before the upload finishes).
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) fillphotos/documents(already-cloud media short-circuits to cloud IDs);uploadedRichMessage(...)is the thin single-emission (take(1)) resolver.- Send routes through
messageContentToUpload→ uploads. Edit sequencesuploadedRichMessagebefore its media chain. Incoming parse (SyncCore_RichTextMessageAttribute.swift) reconstructsmediafromphotos/documents.
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.swiftvia the file-privatemakeRichTextSendPreview(context:content:mediaPreview:)(predicate:mediaPreview == nil && !content.isEmpty && !content.isEntityExpressible(options: [.quotesRequireRichContent])). New-message branch feedscomposeInputState.content(matchingChatControllerNode's send gate; additionally skipped for.customChatContents); edit branch feedseditMessage.inputState.content(matchingChatControllerLoadDisplayNode'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 nodebugRichTextcheck is needed). - A blockquote previews as a rich bubble (the
.quotesRequireRichContentopt-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.quotesRequireRichContentat 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'ssourceMessageItemFramemath), and crossfades that snapshot into theInstantPageV2Viewas the bubble settles. Falls back to the flat-text crossfade ifsnapshotViewreturns nil. - Flat-copy text color is set per morph state, keyed off
explicitBackgroundSize == nil(isSettled): the extractedtextStringcarries no base foreground color that renders correctly here (the preview node defaults it to black), soMessageItemViewapplies one explicitly and re-applies it whenever the state flips (tracked bytextNodeUsesOutgoingColor). 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 stayoutgoing.linkTextColorin 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.
Two layers (see SyncCore_SynchronizeableChatInputState.swift, ChatInterfaceState.swift):
- Local —
ChatTextInputStatepersistsChatInputContentunder the"cm"Codable key (back-compat-decoding the legacy"at"ChatTextInputStateText). So a rich draft (incl. media, via the concreteMedia) survives an app restart on-device. - Cloud / cross-device —
ChatInterfaceState.synchronizeableInputStateproduces aSynchronizeableChatInputState.Content:.textEntities(text,entities)for entity-expressible content,.instantPage(InstantPage)otherwise.ManagedSynchronizeChatInputStateOperationsbuildsmessages.saveDraft.
Invariant —
ChatInterfaceState.parsealways overridescomposeInputStatefrom the flat synchronizeable form, but does NOT overrideeditMessage.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 —
saveDraftsendsmessage: ""/ no entities (clears the1<<3flag) whenrichMessageis set. The InstantPage already carries the text; the receiver builds the draft purely fromrichMessagewhen present (AccountStateManagementUtilsignoresmessage/entities), so sending the flat text duplicates it on the wire.
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 and holds a per-peer need on each local draft file
resource (reconciled per save, add-before-dispose so a surviving resource never drops to 0 holders), so the
bytes upload once and are shared with the eventual send. Images de-dup via the content-hash
cachedSentMediaReference cache (not registered).
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
dateis strictly newer than the localsynchronizeableInputState.timestamp(so a live edit is never clobbered). Only non-empty.draftMessagedrafts 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.
- Cross-device collapsed-quote fidelity: the MTProto
Api.RichMessage/InputRichMessagehas nocollapsedflag, so the three model quote states collapse to one on the wire (.quote(isCollapsed:false)/.collapsedQuoteare round-trip identity;.quote(isCollapsed:true)normalizes to.collapsedQuote;nil/false→ visible quote — required, else every synced quote would fold). - Custom-emoji
enableAnimationhas noRichTextcarrier, so it canonicalizes totrueon the reverse (re-derived at decoration; pinned bytest_customEmoji_enableAnimationFalse). - Forum/monoforum topic drafts and folder/archived dialog drafts are not restored on the
fetchChatListpath (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.instantPagecloud 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.
| 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 |
| state value-equality | AccountContext/Sources/ChatController.swift |
| send / edit | TelegramUI/Sources/ChatControllerNode.swift, Chat/ChatControllerLoadDisplayNode.swift |
| 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 |