Auto-detect rich vs. entity markdown on send

Classify typed markdown on send: structure the message-entity set can't
represent (headings, lists, tables) is sent as a rich message
(RichTextMessageAttribute carrying an InstantPage); everything else takes
the existing entity path. Adds the parse-then-inspect classifier in
BrowserMarkdown, gates it in ChatControllerNode.sendCurrentMessage
(replacing the debugRichText flag), drops formulas/dividers as triggers,
and cleans up heading blocks for the chat path. Documented in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-05-26 23:10:15 +02:00
parent 38464954b0
commit e1b48665a8
3 changed files with 162 additions and 5 deletions

View file

@ -128,6 +128,27 @@ The `ChatMessageDateAndStatusNode` mirrors TextBubble's placement, adapted to th
- **`linkSelectionRects` and the bubble tap path check all six interactive keys** (URL + the five entity keys), not just URL, so press-highlight and the link-loading shimmer cover entities too.
- **Rich-data text selection must reach a line's trailing edge.** This is general to rich-data selection, not just entities: `InstantPageTextItem.attributesAtPoint(_:orNearest:)`'s `orNearest: true` (selection-drag) path returns `line.range.upperBound` (via `CTLineGetStringRange`) when the point is at/past `lineFrame.maxX`. `TextSelectionNode` uses that index as the **exclusive** upper bound, so clamping to the last character's index — as the `orNearest: false` hit-testing path correctly does — would leave the last character/item of every line unselectable. Mirrors `Display.TextNode`. Do not collapse the two `orNearest` paths back together.
## Markdown send: entity vs. rich detection
On message send, the app auto-decides: if the typed markdown maps onto the regular message-entity set (bold/italic/code/strikethrough/spoiler/links/blockquote/fenced-code) it sends a **normal message** via the existing entity path; if it contains structure the entity set can't represent it sends a **rich message** (`RichTextMessageAttribute` carrying an `InstantPage`, rendered by `ChatMessageRichDataBubbleContentNode`). Always-on (no flag). **Effective rich triggers are headings, lists, and tables only.**
### Where things live
| File | Responsibility |
|---|---|
| `submodules/BrowserUI/Sources/BrowserMarkdown.swift` | The classifier `richMarkdownAttributeIfNeeded(context:text:)` (pre-filter `markdownMightNeedRichLayout` → parse via existing `inputRichTextAttributeFromText` → block inspection `instantPageNeedsRichLayout`/`blockIsEntityExpressible`/`richTextIsEntityExpressible`), plus the markdown→InstantPage conversion (`markdownWebpage`, `markdownBlocks(from:)`, `markdownBlocksWithGeneratedAnchors`). |
| `submodules/TelegramUI/Sources/ChatControllerNode.swift` (`sendCurrentMessage`, ~line 4860) | The gate: `if !isSpecialChatContents, let attribute = richMarkdownAttributeIfNeeded(context:, text: effectiveInputText.string)` routes to the rich branch; the unchanged `else` is the entity path. |
### Non-obvious invariants
- **Boundary rule:** send rich iff the parse yields an `InstantPageBlock` with no entity equivalent. Entity-expressible whitelist (→ normal): `.paragraph`, `.preformatted`, `.blockQuote` (empty caption), `.anchor`, `.unsupported`, **and `.divider`/`.formula`** — the latter two are *deliberately excluded as triggers* (`---` and `$…$` are too common in casual text, e.g. `$5-$10`/`$FOO=$BAR`). Inline `.formula` is likewise treated as expressible. So effective triggers = headings, lists, tables.
- **Approach A (parse-then-inspect):** the classifier reuses the real parser, so "what triggers rich" can't drift from "what the rich renderer shows." `markdownMightNeedRichLayout` is a cheap necessary-condition over-approximation — it may over-trigger a parse but must **never** false-negative. It detects `#`, list markers, dash-lines (`-{1,}`, which also catches setext-H2 underlines → heading blocks), `\n=` (setext H1), `|`, `![`. It deliberately does **not** detect `$`/`\(` (formulas don't trigger rich).
- **Chat vs. document path = `file == nil` / `context.documentURL == nil`.** `inputRichTextAttributeFromText` passes `file: nil`; the document-attachment path passes a real file. Two chat-only behaviors key off this: (a) generated heading anchors are **skipped** (`markdownBlocksWithGeneratedAnchors` runs only for documents — anchors exist for intra-document `#slug` links and otherwise prepend a spurious invisible `.anchor` block per heading); (b) a level-1 `#` heading maps to `.heading(text:, level: 1)`, not `.title` (the document/article-title treatment). H2H6 → `.heading(level: 2…6)` for both paths. This converter only ever emits `.title` (H1-doc) or `.heading` — never `.header`/`.subheader`.
- **The classifier is fed the RAW `effectiveInputText.string`**, not the post-`convertMarkdownToAttributes` `inputText`, so inline `**bold**` survives into the rich render. The entity branch still uses the converted `inputText`.
- **Bypassed for `.customChatContents`** (business links / quick replies) via `isSpecialChatContents`. **Editing is out of scope** — the gate is only on the compose/send path; edits route earlier to `editMessage`.
- **Transmission:** `RichTextMessageAttribute``Api.InputRichMessage` via `messages.sendMessage(richMessage:)` (flag bit 23, `StandaloneSendMessage.swift`); recipients reconstruct it from the incoming `richMessage` field (`StoreMessage_Telegram.swift`). The rich branch sends `text: ""` + the attribute, nils `mediaReference` (no separate webpage preview), and bypasses 4096-char chunking. iOS < 15 / oversize markdown `inputRichTextAttributeFromText` returns nil entity path (which chunks).
- **`debugRichText` experimental flag is now orphaned** — it previously gated this path and is no longer read anywhere, though `DebugController`/`ExperimentalUISettings` still define/persist it. Optional cleanup.
## Postbox → TelegramEngine refactor (in progress)
A gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`.

View file

@ -996,6 +996,126 @@ public func inputRichTextAttributeFromText(context: AccountContext, text: String
return RichTextMessageAttribute(instantPage: instantPage._parse())
}
// MARK: - Markdown classification (entity-expressible vs. rich layout)
private let markdownBlockHeuristicRegex = try? NSRegularExpression(
pattern: "(^|\\n)[ \\t]*(#{1,6}[ \\t]|[-*+][ \\t]|\\d{1,9}[.)][ \\t]|-{1,}[ \\t]*(\\n|$))",
options: []
)
// Cheap necessary-condition pre-filter. A safe over-approximation: if it
// returns false, the text cannot contain a rich-only construct, so the
// expensive markdown parse is skipped and the entity path is used.
private func markdownMightNeedRichLayout(_ text: String) -> Bool {
// Tables ('|') and images ('![') can appear anywhere on a line. This over-approximates
// (prose containing '|' also triggers a parse); the parse + block inspection resolves it.
if text.contains("|") || text.contains("![") {
return true
}
// Setext H1 heading: a line of '=' underlining the previous line.
// (Setext H2 dash-underlines are caught by the dash-line branch in the regex.)
if text.contains("\n=") {
return true
}
// Line-anchored block markers: headings, list items, setext-H2 dash underlines.
if let regex = markdownBlockHeuristicRegex {
let range = NSRange(text.startIndex..., in: text)
if regex.firstMatch(in: text, options: [], range: range) != nil {
return true
}
}
return false
}
// True when this inline RichText maps onto Telegram message entities.
// Returns false for inline content that forces the rich path (inline image,
// sub/superscript, highlight). Inline formulas are intentionally treated as
// expressible: '$'-delimited spans are too common in casual text ('$5-$10').
private func richTextIsEntityExpressible(_ text: RichText) -> Bool {
switch text {
case .empty, .plain:
return true
case .bold(let inner), .italic(let inner), .underline(let inner), .strikethrough(let inner), .fixed(let inner):
return richTextIsEntityExpressible(inner)
case .superscript, .marked, .`subscript`:
return false
case .url(let inner, _, _):
return richTextIsEntityExpressible(inner)
case .email(let inner, _):
return richTextIsEntityExpressible(inner)
case .concat(let items):
return items.allSatisfy(richTextIsEntityExpressible)
case .phone(let inner, _):
return richTextIsEntityExpressible(inner)
case .image:
return false
case .anchor(let inner, _):
return richTextIsEntityExpressible(inner)
case .formula:
return true
case .textCustomEmoji:
return true
case .textAutoEmail(let inner), .textAutoPhone(let inner), .textAutoUrl(let inner), .textBankCard(let inner), .textBotCommand(let inner), .textCashtag(let inner), .textHashtag(let inner), .textMention(let inner), .textSpoiler(let inner):
return richTextIsEntityExpressible(inner)
case .textMentionName(let inner, _):
return richTextIsEntityExpressible(inner)
}
}
private func isEmptyRichText(_ text: RichText) -> Bool {
switch text {
case .empty:
return true
case .plain(let value):
return value.isEmpty
default:
return false
}
}
// Block types that do NOT trigger a rich-layout message. Besides the genuinely
// entity-expressible blocks (paragraph/preformatted/blockQuote/anchor), dividers
// and formulas are intentionally excluded as triggers too: '---' and '$'-delimited
// spans are too common in casual text to justify a rich message. Effective rich
// triggers are therefore headings, lists, and tables.
private func blockIsEntityExpressible(_ block: InstantPageBlock) -> Bool {
switch block {
case .paragraph(let text):
return richTextIsEntityExpressible(text)
case .preformatted(let text, _):
return richTextIsEntityExpressible(text)
case .blockQuote(let text, let caption):
return isEmptyRichText(caption) && richTextIsEntityExpressible(text)
case .anchor, .unsupported:
return true
case .divider, .formula:
return true
default:
return false
}
}
private func instantPageNeedsRichLayout(_ blocks: [InstantPageBlock]) -> Bool {
return blocks.contains { !blockIsEntityExpressible($0) }
}
// Returns a RichTextMessageAttribute IFF the markdown in `text` produces an
// InstantPage block with no entity equivalent. Returns nil (-> send via the
// regular entity path) for plain text, pre-iOS-15, oversize markdown, or
// markdown that maps cleanly onto entities.
public func richMarkdownAttributeIfNeeded(context: AccountContext, text: String) -> RichTextMessageAttribute? {
guard markdownMightNeedRichLayout(text) else {
return nil
}
guard let attribute = inputRichTextAttributeFromText(context: context, text: text) else {
return nil
}
guard instantPageNeedsRichLayout(attribute.instantPage.blocks) else {
return nil
}
return attribute
}
@available(iOS 15.0, *)
private func markdownWebpage(context: AccountContext, file: (file: FileMediaReference, url: URL)?, data: Data) -> TelegramMediaWebpage? {
let limits = markdownSafetyLimits
@ -1029,7 +1149,15 @@ private func markdownWebpage(context: AccountContext, file: (file: FileMediaRefe
guard let pageResult = markdownPageResult(from: attributedString, context: conversionContext) else {
return nil
}
let blocks = markdownBlocksWithGeneratedAnchors(pageResult.blocks)
// Heading anchors exist for intra-document navigation ([link](#slug)); they are
// noise in a chat message, where they prepend an invisible block per heading.
// Only generate them for the document path (file != nil).
let blocks: [InstantPageBlock]
if file != nil {
blocks = markdownBlocksWithGeneratedAnchors(pageResult.blocks)
} else {
blocks = pageResult.blocks
}
guard !blocks.isEmpty, budget.validateFinalBlocks(blocks) else {
return nil
}
@ -1190,7 +1318,12 @@ private func markdownBlocks(from node: MarkdownIntentNode, context: MarkdownConv
}
switch level {
case Int.min ... 1:
return [.title(text)]
if context.documentURL == nil {
// Chat message: a single '#' is a normal heading, not a document title.
return [.heading(text: text, level: 1)]
} else {
return [.title(text)]
}
default:
return [.heading(text: text, level: Int32(max(2, min(level, 6))))]
}

View file

@ -4857,9 +4857,12 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate {
}
}
if self.context.sharedContext.immediateExperimentalUISettings.debugRichText, let attribute = inputRichTextAttributeFromText(context: self.context, text: inputText.string) {
var attributes: [MessageAttribute] = []
attributes.append(attribute)
var isSpecialChatContents = false
if case .customChatContents = self.chatPresentationInterfaceState.subject {
isSpecialChatContents = true
}
if !isSpecialChatContents, let attribute = richMarkdownAttributeIfNeeded(context: self.context, text: effectiveInputText.string) {
let attributes: [MessageAttribute] = [attribute]
messages.append(.message(text: "", attributes: attributes, inlineStickers: [:], mediaReference: nil, threadId: self.chatLocation.threadId, replyToMessageId: self.chatPresentationInterfaceState.interfaceState.replyMessageSubject?.subjectModel, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []))
mediaReference = nil
} else {