This commit is contained in:
isaac 2026-05-31 18:00:47 +02:00
commit 471ae08219
49 changed files with 530 additions and 1880 deletions

View file

@ -111,7 +111,7 @@ A V2 `.table` block's item frame is **full-width / flush** with the bubble inter
## Inline custom emoji (RichText.textCustomEmoji)
`RichText.textCustomEmoji(fileId:alt:)` renders an inline **animated** custom emoji inside rich-data bubbles. Covers API parsing, Postbox + FlatBuffers serialization, and display in the InstantPage V2 renderer; the emoji participates in the streaming reveal above.
`RichText.textCustomEmoji(fileId:alt:)` renders an inline **animated** custom emoji inside rich-data bubbles. Covers API parsing, Postbox + FlatBuffers serialization, and display in the InstantPage V2 renderer; the emoji participates in the streaming reveal above. (The **send / edit / copy / paste** round-trip that produces `.textCustomEmoji` from typed markdown is a separate section below: "Custom emoji in markdown messages".)
### Where things live
@ -191,7 +191,7 @@ Rich messages (`RichTextMessageAttribute`, `text == ""`) are made editable by re
### Non-obvious invariants
- **The converter emits CommonMark inline, NOT the entity-regex dialect.** `**bold**`, `*italic*`, `` `code` ``, `~~strike~~`, `[text](url)` — because re-send re-parses the text through the *rich* path (`richMarkdownAttributeIfNeeded` → `NSAttributedString(markdown:)`, Apple CommonMark), not `convertMarkdownToAttributes` (whose dialect is `__italic__`/`||spoiler||`). The two parsers disagree on `__`/`*`; the rich round-trip is the contract.
- **Re-classify every edit (edit ≡ send).** `editMessage` runs the same `richMarkdownAttributeIfNeeded` on the RAW input string. Rich → `pendingUpdateMessageManager.add(text: "", entities: nil, richText: attr, …)`; else the unchanged plain path. So normal→rich (add a table) and rich→plain (drop all triggers) both work. Bypassed for `.customChatContents`.
- **Re-classify every edit (edit ≡ send).** `editMessage` runs the same `richMarkdownAttributeIfNeeded` on the edit field's attributed text (so reattached custom emoji round-trip — see the custom-emoji section). Rich → `pendingUpdateMessageManager.add(text: "", entities: nil, richText: attr, …)`; else the unchanged plain path. So normal→rich (add a table) and rich→plain (drop all triggers) both work. Bypassed for `.customChatContents`.
- **Change-detection compares the rich attribute.** The save guard adds `currentRichText != richTextAttribute` (rich branch — skips no-op rich edits) and `currentRichText != nil` (plain branch — so rich→plain still saves even when `text.string` looks unchanged). `RichTextMessageAttribute` is `Equatable` on `instantPage`.
- **The `text.length == 0` early-return guard is safe for rich.** `convertMarkdownToAttributes` only rewrites inline tokens, never strips `#`/`-`/`|`, so a rich message's markdown source stays non-empty and passes; the rich branch then sends `text: ""`.
- **Known limitation:** a rich→plain edit that leaves only inline-formatted text loses `*italic*` (the entity path recognizes only `__…__`). Rare edge; the rich round-trip contract holds.
@ -218,9 +218,36 @@ Rich messages (`RichTextMessageAttribute`, `text == ""`) are copyable as markdow
- **`quoteDepth` is orthogonal to `kind`** so a heading/list/code line inside a blockquote round-trips (e.g. `> ## Title`). `bumpQuoteDepth` lifts a quote's children by 1; nested quotes accumulate. `layoutQuoteText` (single-paragraph blockquote fast path AND `.pullQuote`) bumps once — it is never reached by the multi-block recursion, so no double-count.
- **A blockquote is exploded into one text item per line.** `markdownForRange` must re-coalesce a run of consecutive `quoteDepth > 0` segments into ONE `\n`-joined block (each line prefixed at its own depth); otherwise every quote line becomes its own block separated by a blank line. Code/table/list runs are likewise coalesced (one fence; one pipe table; one tight list).
- **Both converters emit compact nested-quote markers (`>>`, not `> >`).** Selection: `String(repeating: ">", count: depth) + " "`. Whole-message: when wrapping a line that already starts with `>`, prepend a bare `>`. Keep the two in sync.
- **Inline markdown is read from display attributes, not the RichText tree.** `inlineMarkdown` inspects the slice's `UIFont` (bold/italic/mono — font-based, no symbolic-trait flag for named fonts), `.strikethroughStyle`, and `TelegramTextAttributes.URL` (→ `InstantPageUrlItem.url`, angle-bracketed if it contains `(`/`)`/space). Custom-emoji placeholders are dropped (no `alt` on the display attribute).
- **Inline markdown is read from display attributes, not the RichText tree.** `inlineMarkdown` inspects the slice's `UIFont` (bold/italic/mono — font-based, no symbolic-trait flag for named fonts), `.strikethroughStyle`, and `TelegramTextAttributes.URL` (→ `InstantPageUrlItem.url`, angle-bracketed if it contains `(`/`)`/space). Custom-emoji placeholders now emit the `[<alt>](tg://emoji?id=…)` marker from the display attribute's `fileId` (alt is best-effort — the display placeholder may be a bare space; see the custom-emoji round-trip section).
- **`.copy` stores plain text.** Passing `NSAttributedString(string: markdown)` through the existing `performTextSelectionAction(.copy)` path (`storeAttributedTextInPasteboard`) generates no entities, so the literal `**`/`#`/`>`/`|` survive. The whole-message Copy uses `storeMessageTextInPasteboard(_, entities: nil)` directly.
- **Fidelity caveats (intentional):** custom emoji omitted; ordered list + checkbox loses the ordinal (`-` wins); a partial table selection emits touched cells as rows (no forced header `---` separator); block prefixes apply to the whole touched line on a mid-line selection (correct markdown).
- **Fidelity caveats (intentional):** custom emoji are now preserved as `[<alt>](tg://emoji?id=…)` markers (selection copy uses a best-effort alt — see the custom-emoji round-trip section below); ordered list + checkbox loses the ordinal (`-` wins); a partial table selection emits touched cells as rows (no forced header `---` separator); block prefixes apply to the whole touched line on a mid-line selection (correct markdown).
## Custom emoji in markdown messages (send + edit/copy/paste round-trip)
Custom emoji typed into the compose field survive when a message is sent as a **rich** message (heading/list/table/formula), rendering as `RichText.textCustomEmoji` (the display side is the "Inline custom emoji" section above). The carrier across Apple's CommonMark parser is a shared markdown-link marker `[<alt>](tg://emoji?id=<fileId>)`, used identically by the forward (send) and reverse (edit/copy/paste) paths so encode and decode cannot drift. Always-on. **Scope: only rich messages — a custom emoji alone never forces a rich message** (it stays on the entity path as a `.CustomEmoji` entity, the pre-existing behavior).
### Where things live
| File | Responsibility |
|---|---|
| `submodules/TextFormat/Sources/CustomEmojiMarkdownMarker.swift` | The marker format — single source of truth: `customEmojiMarkdownURL(fileId:)`, `parseCustomEmojiFileId(fromMarkdownURL:)`, `escapeCustomEmojiMarkdownAlt(_:)`, and `chatInputTextWithReattachedCustomEmoji(_:)` (markers → live `customEmoji` attributes). In TextFormat so both BrowserUI and InstantPageUI can import it. |
| `submodules/BrowserUI/Sources/BrowserMarkdown.swift` | Forward: `markdownSourceInjectingCustomEmojiMarkers` rewrites each `customEmoji` run into the marker; `richMarkdownAttributeIfNeeded(context:attributedText:)` (signature changed from `text:`); the marker-URL intercept in `markdownInlineContent``.textCustomEmoji`. |
| `submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` | Reverse (whole-message copy + edit reconstruction): `.textCustomEmoji` → emit the marker. |
| `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` | Reverse (text-selection copy): emit the marker from the display attribute's `fileId` (alt best-effort). |
| `submodules/TelegramUI/Sources/ChatControllerNode.swift`, `…/Chat/ChatMessageDisplaySendMessageOptions.swift` | Send + send-options-preview call sites pass the `NSAttributedString` (`effectiveInputText` / `textInputView.attributedText`); the rich send now passes `inlineStickers`. |
| `submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift` | Edit-load (`setupEditMessage`) reattaches markers via `chatInputTextWithReattachedCustomEmoji`; edit-save (`editMessage`) re-classifies the attributed edit text. |
| `submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift` | Paste (`chatInputTextNodeShouldPaste`) reattaches plain-text markdown markers → live emoji. |
### Non-obvious invariants
- **One shared marker, one set of helpers.** All emit sites (forward normalize, reverse copy/edit, selection copy) use `customEmojiMarkdownURL` + `escapeCustomEmojiMarkdownAlt`; the forward intercept and both reattach sites use `parseCustomEmojiFileId`. The marker is internal/transient — it exists only in the rich-conversion source string and on the clipboard, never persisted as a URL entity.
- **CommonMark preserves the `tg://emoji?id=N` link URL verbatim** under the `NSLink` attribute (spike-verified). `markdownLink`'s `as? NSURL` branch returns `url.absoluteString`, which `parseCustomEmojiFileId` matches by strict prefix. Negative (signed Int64) file ids survive too (the reattach regex is `(-?\d+)`).
- **Scope guard is structural.** `markdownSourceInjectingCustomEmojiMarkers` works on a LOCAL copy — `effectiveInputText` is never mutated. A marker is an entity-expressible link, so an emoji-only message classifies not-rich (`markdownMightNeedRichLayout` finds no `#`/`|`/`![`/`$`/list tokens) and takes the entity path; the untouched `customEmoji` attribute becomes a `.CustomEmoji` entity.
- **`richMarkdownAttributeIfNeeded` now takes `attributedText: NSAttributedString`** (was `text: String`); it normalizes to the marker'd source internally, then calls the unchanged `inputRichTextAttributeFromText(text:)`. All three call sites (send, edit-save, send-options preview) pass the attributed string.
- **Edit-load AND paste reattach to live attributes; copy stays textual.** `setupEditMessage` and `chatInputTextNodeShouldPaste` run `chatInputTextWithReattachedCustomEmoji` so the field shows the animated emoji, not raw token text. The paste branch is guarded by `.contains("tg://emoji?id=")` AND `reattached.string != plainText`, and runs only after the rich pasteboard types miss — `private.telegramtext`/RTF already decode the indexed `tg://emoji?id=<id>&t=<n>` RTF-link form via `chatInputStateStringFromRTF`. `previewText()` is unchanged (keeps the alt glyph).
- **Empty alt → a space.** CommonMark drops `[](url)` (no run carries the link attribute), which would silently lose the emoji; every emit site and the reattach substitute a space when the alt is empty.
- **Rich send attaches `inlineStickers`** (was `[:]`) + bubble-up packs, so the local store has the files. **OPEN runtime risk:** the wire send uses `Api.InputRichMessage.documents: nil` (`apiInputRichMessage()` in `SyncCore_RichTextMessageAttribute.swift`), so recipient rendering depends on the server back-filling `documents` from the embedded `documentId` — UNVERIFIED. If recipients see only the fallback glyph, populate `documents:` there.
- **Accepted limitations:** edit-load reattaches with `file: nil` (renders via lazy fileId resolution, but the premium-emoji gate is bypassed on edit); an alt containing a literal `]` won't reattach on edit-load (cosmetic — re-save still parses it); `parseCustomEmojiFileId` (strict prefix) vs `Pasteboard.swift`'s `URLComponents` parse could drift if the marker format ever changes.
## Formulas trigger rich messages (strict math detection)