Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
56 KiB
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 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.
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, + 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. Contiguity is load-bearing for quotes:chatInputContent(from:)carves each maximal contiguous.block/.quote run (viaenumerateAttribute, likecodeBlockRangesfor code) into ONE multi-paragraph.blockQuote— it must NOT split per-\n. A multi-line quote is one.blockobject spanning its interior newlines; splitting it produced one.blockQuoteper line, whichattributedString(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 theNSAttributedStringhop where structural blocks (media/table/heading/list) would be flattened.
Load-bearing invariants
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). This applies to every raw enum,String-raw included: aRawRepresentableenum's synthesized Codable uses asingleValueContainer, so each raw enum inChatInputContentModel.swiftcarries a CUSTOM keyed Codable encoding its.rawValueunder a"raw"key.ChatInputMediaDisplayMode(aString-raw enum) was missed and crashed atAdaptedPostboxEncoder.swift:94when a draft containing a.mediablock was persisted (it is encoded byChatInputMedia.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):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.
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.documentblock — a CAPTION-LESS atom (the audio shape:nodeSize3, emptyleafRegions(), gap-caret), rendered as a fixed 52pt row that must stay in lockstep withInstantPageV2Layout'sdocumentFrame. It threads editorDocument↔ChatInputContent(ChatInputMediaKind.document, raw 4) ↔InstantPage(.document(id:caption:)). Like.audio, the chat currency CARRIES a caption while the editor renders none —MediaBlockBoxdrops 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'sMediaKind(threaded through the media-view seam), NOT by sniffing the resolvedMedia— 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 ininstantpage-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 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).
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).
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'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:
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).
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) 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.
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.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.
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/scheduleset the chat's effective input state from the editor content, then route through the sameself.sendCurrentMessage(...)the tap path uses (.generic/.silently,.whenOnline→scheduleTime: scheduleWhenOnlineTimestamp) andcontrollerInteraction?.scheduleCurrentMessage— so tap-Send and long-press-Send stay identical. - Article route (
ChatControllerOpenAttachmentMenu's.richTextcase, which sends by building anEnqueueMessagedirectly, not viasendCurrentMessage): sharedbuildRichTextContent+performRichTextSendlocals feedtransformEnqueueMessages(silentPosting:scheduleTime:)per mode (.whenOnline→scheduleWhenOnlineTimestamp) withpresentScheduleTimePickerfor Schedule, thenpresentPaidMessageAlertIfNeeded→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 —
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.
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
updatePeerMediaNeedsholds is media-level and covers every uploadable medium, not byte-level overTelegramMediaFiles with alocalIdForResource. 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
.progressis 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
mediaIDstring (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 theMediaId, so the medium would silently vanish on read-back.moveResourceDatapoints the local bytes at the cloud resource so nothing flashes a placeholder. isPreuploadableMediaanswers "is this worth pre-uploading?", NOT "can the send path use this?" It returnsfalsefor 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 asksuploadedRichMedia(from:)instead, which answers the second question, and handles image and file symmetrically.forceReuploadmust bypass the whole registry, evicting first. It is set byPendingMessageManager'sFILEREF_INVALID/FILE_REFERENCE_*_EXPIREDretry, 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.
syncMediaItemViewsre-invokes the media-view provider only when a block'sitemsSignaturechanges, and upload progress is not — and cannot be — part of that signature.RichTextMediaContentComponent.Viewtherefore subscribes tomediaPreuploadState(id:)itself and updates its status node directly, never viastate.updated()(which re-entersupdate, andupdatere-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
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.
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 setsChatRichTextInputNode.pastedMarkdownFragmentParseron the native node (→RichTextEditorView.plainTextFragmentTransformer), and inchatInputTextNodeShouldPaste()(legacy field), any markdown latches to the native editor viapasteRichFragmentFromPasteboard(). 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 itsRichTextEditorView.
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 —pageBlockBlockquotecarriescollapsed:flags.0?true. A quote with two or more blocks still serializes aspageBlockBlockquoteBlocks, which has no such flag, and arrives expanded. Closing that needs a server-sidepageBlockBlockquoteBlocks flags:# collapsed:flags.0?true. - 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.
- 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 (
InstantPageToMarkdownhas no spelling for it), the same class of loss already accepted for inline buttons. Editing is unaffected — that path is structural. ChatInputMediaKindraw4fails to decode on an older build reading a cross-device-synced draft (itsinit(from:)rejects unknown raw values). Consistent with theChatInputListMarker.checklistprecedent.- 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 thanmessage.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 |