Copy rich messages as markdown (whole message + partial selection)

Rich messages (RichTextMessageAttribute, text == "") are copyable as
markdown two ways: the context-menu Copy action copies the whole
message, and a text selection inside the rich-data bubble copies just
the selected range. Both reconstruct markdown mirroring the edit
round-trip (markdownStringFromInstantPage).

Implements the full RichText entity case set
(mention/hashtag/cashtag/bot-command/bank-card/auto url/email/phone) with
tap interaction, the InstantPage -> markdown inverse converter and edit
round-trip, markdown-context stamping during V2 layout
(InstantPageMarkdownBlockContext: heading level, list/code/table/quote
depth), partial-selection markdown emission
(InstantPageMultiTextAdapter.markdownForRange), and numerous converter
edge-case fixes (tables, links, fenced code, blockquote line coalescing,
compact nested >> markers).

CLAUDE.md documents the feature; the spec/plan scratch docs generated
during development are not committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-05-29 13:52:47 +02:00
parent 65eac2fa94
commit 471d11df16
7 changed files with 383 additions and 5 deletions

View file

@ -186,6 +186,31 @@ Rich messages (`RichTextMessageAttribute`, `text == ""`) are made editable by re
- **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.
- **`previewText()` lives in TelegramStringFormatting, not TextFormat/TelegramCore.** It will gain a `strings: PresentationStrings` param (to localize the `"Photo"`/`"Video"`/`"Table"` placeholders), so it must sit in a UI-string module — `messageContentKind`/`descriptionStringForMessage` (same module) already take `strings:`. Teaching `messageContentKind` about rich cascades the preview to the edit accessory panel, reply/pinned panels, and forward preview in one place (those surfaces need no individual change).
## Copying rich messages as markdown (whole message + partial selection)
Rich messages (`RichTextMessageAttribute`, `text == ""`) are copyable as markdown two ways: the context-menu **Copy** action copies the whole message; a **text selection** inside the rich-data bubble copies just the selected range. Both reconstruct markdown that mirrors the edit round-trip (`markdownStringFromInstantPage`). Always-on.
### Where things live
| File | Responsibility |
|---|---|
| `submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift` | Whole-message Copy. Computes `richMessageMarkdown` from the message's `RichTextMessageAttribute.instantPage` (after `let message = messages[0]`), opens the Copy gate with `richMessageMarkdown != nil`, and short-circuits `copyTextWithEntities` to `storeMessageTextInPasteboard(markdown, entities: nil)`. |
| `submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` | `markdownStringFromInstantPage` — the block-tree → markdown converter (also used by the edit round-trip). Blocks joined by `\n\n`; nested blockquotes via recursive `> ` wrapping. |
| `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` | `InstantPageMarkdownBlockContext` (`kind` + `quoteDepth`) and the `markdownContext: InstantPageMarkdownBlockContext?` field on `InstantPageTextItem`. |
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `stampMarkdownContext`/`bumpQuoteDepth`; stamps `markdownContext` during layout (heading/title/code/list/blockQuote/`layoutQuoteText`/table-cell). |
| `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` | `markdownForRange(_ range: NSRange)` + the private attributed-substring→inline-markdown converter `inlineMarkdown(from:)`. |
| `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/.../ChatMessageRichDataBubbleContentNode.swift` | Intercepts `.copy` in the `TextSelectionNode` `performAction` closure: `textSelectionNode.getSelection()``adapter.markdownForRange(range)` → stores as plain `NSAttributedString(string:)`. |
### Non-obvious invariants
- **The V2 layout discards block role.** A `.text` layout item from an `H2` heading is byte-identical to a body paragraph — heading level and the title category are dropped with no back-reference to the source `InstantPageBlock`. Precise structural markdown for a *selection* therefore requires stamping `markdownContext` at layout time (lists/code/tables/details are structurally recoverable; **heading level and `.title` are not**, so they MUST be stamped). Plain paragraphs stay `nil` (≡ plain).
- **`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).
- **`.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).
## Formulas trigger rich messages (strict math detection)
`$…$`/`$$…$$` (and `\(…\)`/`\[…\]`) math triggers a rich message, gated by a

View file

@ -76,7 +76,10 @@ private func markdownBlockQuoteBlocks(_ blocks: [InstantPageBlock]) -> String {
continue
}
for line in body.split(separator: "\n", omittingEmptySubsequences: false) {
lines.append("> \(String(line))")
// Stack nested-quote markers without internal spaces (`>>` not `> >`):
// a line already starting with `>` (a nested quote) gets a bare `>`.
let text = String(line)
lines.append(text.hasPrefix(">") ? ">\(text)" : "> \(text)")
}
}
return lines.joined(separator: "\n")

View file

@ -3,6 +3,7 @@ import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import TextFormat
public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol {
public struct Entry {
@ -110,4 +111,234 @@ public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol
}
return (allRects, start, end)
}
public func markdownForRange(_ range: NSRange) -> String {
struct Segment {
let context: InstantPageMarkdownBlockContext?
let inline: String
}
var segments: [Segment] = []
for entry in self.entries {
let entryRange = NSRange(location: entry.charOffset, length: entry.item.attributedString.length)
let intersection = NSIntersectionRange(range, entryRange)
if intersection.length == 0 {
continue
}
let localRange = NSRange(location: intersection.location - entry.charOffset, length: intersection.length)
let slice = entry.item.attributedString.attributedSubstring(from: localRange)
let inline = inlineMarkdown(from: slice)
if inline.isEmpty {
continue
}
segments.append(Segment(context: entry.item.markdownContext, inline: inline))
}
if segments.isEmpty {
return ""
}
func quotePrefixed(_ text: String, depth: Int) -> String {
guard depth > 0 else { return text }
let q = String(repeating: ">", count: depth) + " "
return text.split(separator: "\n", omittingEmptySubsequences: false).map { q + String($0) }.joined(separator: "\n")
}
func taskMarker(_ c: Bool?) -> String {
switch c {
case .some(false): return "[ ] "
case .some(true): return "[x] "
case .none: return ""
}
}
// Renders one segment's block content WITHOUT the quote prefix and without
// cross-segment coalescing. Used for segments inside a quoted run, where each
// line carries its own depth and the whole quote is one `\n`-joined block.
func renderContent(_ seg: Segment) -> String {
switch seg.context?.kind {
case let .code(language):
return "```\(language ?? "")\n\(seg.inline)\n```"
case .tableCell:
return "| " + escapeSelectionTableCell(seg.inline) + " |"
case let .listItem(_, marker, checked):
return "\(marker) \(taskMarker(checked))" + seg.inline.replacingOccurrences(of: "\n", with: " ")
case let .heading(level):
let hashes = String(repeating: "#", count: max(1, min(6, level)))
return "\(hashes) \(seg.inline)"
case .title:
return "# \(seg.inline)"
case .paragraph, .none:
return seg.inline
}
}
var groups: [String] = []
var index = 0
while index < segments.count {
let seg = segments[index]
let depth = seg.context?.quoteDepth ?? 0
// A blockquote is exploded by the layout into one text item per child line,
// each stamped with quoteDepth > 0. Re-coalesce a run of consecutive quoted
// segments into a single block whose lines are joined by `\n` (each carrying
// its own `> ` depth), matching the whole-message converter otherwise every
// quote line would become its own block and be separated by a blank line.
if depth > 0 {
var lines: [String] = []
var j = index
while j < segments.count, let d = segments[j].context?.quoteDepth, d > 0 {
lines.append(quotePrefixed(renderContent(segments[j]), depth: d))
j += 1
}
groups.append(lines.joined(separator: "\n"))
index = j
continue
}
switch seg.context?.kind {
case let .code(language):
var body = [seg.inline]
var j = index + 1
while j < segments.count,
case let .code(lang2)? = segments[j].context?.kind,
lang2 == language,
(segments[j].context?.quoteDepth ?? 0) == depth {
body.append(segments[j].inline)
j += 1
}
let fence = "```\(language ?? "")\n" + body.joined(separator: "\n") + "\n```"
groups.append(quotePrefixed(fence, depth: depth))
index = j
case let .tableCell(row, _, _):
var rows: [[String]] = [[escapeSelectionTableCell(seg.inline)]]
var currentRow = row
var j = index + 1
while j < segments.count,
case let .tableCell(row2, _, _)? = segments[j].context?.kind,
(segments[j].context?.quoteDepth ?? 0) == depth {
if row2 != currentRow {
rows.append([])
currentRow = row2
}
rows[rows.count - 1].append(escapeSelectionTableCell(segments[j].inline))
j += 1
}
let tableBlock = rows.map { "| " + $0.joined(separator: " | ") + " |" }.joined(separator: "\n")
groups.append(quotePrefixed(tableBlock, depth: depth))
index = j
case let .listItem(_, marker, checked):
let firstLine = "\(marker) \(taskMarker(checked))" + seg.inline.replacingOccurrences(of: "\n", with: " ")
var lines = [firstLine]
var j = index + 1
while j < segments.count, case let .listItem(_, marker2, checked2)? = segments[j].context?.kind, (segments[j].context?.quoteDepth ?? 0) == depth {
lines.append("\(marker2) \(taskMarker(checked2))" + segments[j].inline.replacingOccurrences(of: "\n", with: " "))
j += 1
}
groups.append(quotePrefixed(lines.joined(separator: "\n"), depth: depth))
index = j
case let .heading(level):
let hashes = String(repeating: "#", count: max(1, min(6, level)))
groups.append(quotePrefixed("\(hashes) \(seg.inline)", depth: depth))
index += 1
case .title:
groups.append(quotePrefixed("# \(seg.inline)", depth: depth))
index += 1
case .paragraph, .none:
groups.append(quotePrefixed(seg.inline, depth: depth))
index += 1
}
}
return groups.joined(separator: "\n\n")
}
}
private func escapeSelectionMarkdown(_ string: String) -> String {
var result = ""
result.reserveCapacity(string.count)
for character in string {
switch character {
case "\\", "*", "_", "`", "[", "]", "~", "|":
result.append("\\")
result.append(character)
default:
result.append(character)
}
}
return result
}
private func escapeSelectionTableCell(_ string: String) -> String {
return string.replacingOccurrences(of: "\n", with: " ")
}
/// Converts a styled slice of an InstantPage text item into inline markdown,
/// reading the same attributes the renderer wrote (font-based bold/italic/mono,
/// strikethrough style, the TelegramTextAttributes.URL link item, custom emoji).
private func inlineMarkdown(from slice: NSAttributedString) -> String {
let fullRange = NSRange(location: 0, length: slice.length)
var result = ""
slice.enumerateAttributes(in: fullRange, options: []) { attributes, range, _ in
let substring = (slice.string as NSString).substring(with: range)
// Custom emoji: single-space placeholder with no alt available drop it.
if attributes[ChatTextInputAttributes.customEmoji] != nil {
return
}
var bold = false
var italic = false
var mono = false
if let font = attributes[.font] as? UIFont {
let name = font.fontName.lowercased()
if name.hasPrefix(".sfui") || name.hasPrefix(".applesystemui") {
let traits = font.fontDescriptor.symbolicTraits
if traits.contains(.traitMonoSpace) {
mono = true
} else {
bold = traits.contains(.traitBold)
italic = traits.contains(.traitItalic)
}
} else if name.contains("menlo") || name.contains("courier") || name.contains("sfmono") {
mono = true
} else {
if name.contains("bolditalic") {
bold = true; italic = true
} else if name.contains("bold") {
bold = true
} else if name.contains("italic") {
italic = true
}
}
}
let strike = attributes[.strikethroughStyle] != nil
var inner: String
if mono {
// Inline code takes no nested emphasis; emit the raw text in backticks.
inner = "`\(substring)`"
} else {
inner = escapeSelectionMarkdown(substring)
if strike { inner = "~~\(inner)~~" }
if bold && italic {
inner = "***\(inner)***"
} else if bold {
inner = "**\(inner)**"
} else if italic {
inner = "*\(inner)*"
}
}
if let urlItem = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? InstantPageUrlItem {
let url = urlItem.url
let needsBrackets = url.contains("(") || url.contains(")") || url.contains(" ")
let destination = needsBrackets ? "<\(url)>" : url
inner = "[\(inner)](\(destination))"
}
result += inner
}
return result
}

View file

@ -191,6 +191,29 @@ private func attachmentBoundsForRange(_ range: NSRange, line: InstantPageTextLin
return alignedAttachmentFrame(localBounds, line: line, boundingWidth: boundingWidth, alignment: alignment)
}
/// Block-level role of a text item, used to reconstruct markdown from a
/// selection. `kind` is the primary role; `quoteDepth` (0 = not quoted) is
/// orthogonal so a heading/list/code line inside a blockquote can be emitted
/// as e.g. `> ## Title`.
public struct InstantPageMarkdownBlockContext: Equatable {
public enum Kind: Equatable {
case paragraph
case heading(level: Int) // 1...6
case title // InstantPageBlock.title "# "
case listItem(ordered: Bool, marker: String, checked: Bool?)
case code(language: String?)
case tableCell(row: Int, column: Int, isHeader: Bool)
}
public var kind: Kind
public var quoteDepth: Int
public init(kind: Kind, quoteDepth: Int = 0) {
self.kind = kind
self.quoteDepth = quoteDepth
}
}
public final class InstantPageTextItem: InstantPageItem {
public let attributedString: NSAttributedString
public let lines: [InstantPageTextLine]
@ -203,7 +226,8 @@ public final class InstantPageTextItem: InstantPageItem {
public let wantsNode: Bool = false
public let separatesTiles: Bool = false
public var selectable: Bool = true
public var markdownContext: InstantPageMarkdownBlockContext? = nil
var containsRTL: Bool {
return !self.rtlLineIndices.isEmpty
}

View file

@ -540,6 +540,45 @@ private func layoutBlockSequence(
return InstantPageV2Layout(contentSize: contentSize, items: items, detailsIndices: detailsIndices, media: context.media, webpage: context.webpage)
}
// MARK: - Markdown block context stamping helpers
private func stampMarkdownContext(_ items: [InstantPageV2LaidOutItem], kind: InstantPageMarkdownBlockContext.Kind) {
for item in items {
switch item {
case let .text(textItem):
if textItem.textItem.markdownContext == nil {
textItem.textItem.markdownContext = InstantPageMarkdownBlockContext(kind: kind)
}
case let .codeBlock(block):
if block.textItem.markdownContext == nil {
block.textItem.markdownContext = InstantPageMarkdownBlockContext(kind: kind)
}
default:
break
}
}
}
/// Marks every text item produced by a blockquote's children as quoted (depth + 1),
/// preserving each child's own kind (heading/list/paragraph/).
private func bumpQuoteDepth(_ items: [InstantPageV2LaidOutItem]) {
for item in items {
let target: InstantPageTextItem?
switch item {
case let .text(textItem): target = textItem.textItem
case let .codeBlock(block): target = block.textItem
default: target = nil
}
guard let target else { continue }
if var ctx = target.markdownContext {
ctx.quoteDepth += 1
target.markdownContext = ctx
} else {
target.markdownContext = InstantPageMarkdownBlockContext(kind: .paragraph, quoteDepth: 1)
}
}
}
private func layoutBlock(
_ block: InstantPageBlock,
boundingWidth: CGFloat,
@ -555,8 +594,10 @@ private func layoutBlock(
return layoutBlock(inner, boundingWidth: boundingWidth, horizontalInset: horizontalInset,
isCover: true, previousItems: previousItems, isLast: isLast, context: &context)
case let .title(text):
return layoutSimpleText(text, category: .header, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
let titleItems = layoutSimpleText(text, category: .header, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
stampMarkdownContext(titleItems, kind: .title)
return titleItems
case let .subtitle(text):
return layoutSimpleText(text, category: .subheader, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
@ -1238,6 +1279,7 @@ private func layoutTable(
horizontalInset: 0.0,
context: &context
)
stampMarkdownContext(cellLayout.items, kind: .tableCell(row: i, column: k, isHeader: cell.header))
subLayout = cellLayout
subLayoutHeight = cellLayout.contentSize.height
isEmptyRow = false
@ -1713,6 +1755,7 @@ private func layoutHeading(
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
stampMarkdownContext(items, kind: .heading(level: max(1, min(6, Int(level)))))
return items
}
@ -1870,6 +1913,7 @@ private func layoutCodeBlock(
computeRevealCharacterRects: context.computeRevealCharacterRects
)
guard let textItem = textItem else { return [] }
textItem.markdownContext = InstantPageMarkdownBlockContext(kind: .code(language: language))
// Position text within the block's content area.
// V1 line 342: x=17.0, y=backgroundInset (block-local).
@ -1977,6 +2021,10 @@ private func layoutBlockQuote(
)
result.append(.blockQuoteBar(bar))
// Caption items (appended above) are also bumped to quoteDepth 1 and will render with a
// `>` prefix. The whole-message markdown converter drops blockquote captions entirely, and
// markdown-sent quotes carry empty captions, so this is benign.
bumpQuoteDepth(result)
return result
}
@ -2080,6 +2128,13 @@ private func layoutQuoteText(
result.append(.blockQuoteBar(bar))
}
// Tag this quote's produced text items at quote depth 1 so the markdown
// converter renders them with a `> ` prefix. Applies to BOTH block quotes
// (single-paragraph fast path) and pull quotes the whole-message markdown
// converter renders both flavors as `> `. Nested quotes are lifted further
// by the outer multi-block path's own bumpQuoteDepth(result) call.
bumpQuoteDepth(result)
return result
}
@ -2200,6 +2255,14 @@ private func layoutList(
effectiveItem = .text(.plain(" "), num, checked)
}
// Derive the markdown marker string and checked state from the original item.
let markdownMarker: String
switch markerInfo.kind {
case let .number(value): markdownMarker = value
default: markdownMarker = "-"
}
let markdownChecked: Bool? = item.checked
switch effectiveItem {
case let .text(text, _, _):
// Layout text content.
@ -2243,6 +2306,7 @@ private func layoutList(
kind: markerInfo.kind,
color: context.theme.textCategories.paragraph.color
)))
stampMarkdownContext(textLaidOutItems, kind: .listItem(ordered: ordered, marker: markdownMarker, checked: markdownChecked))
result.append(contentsOf: textLaidOutItems)
contentHeight += textSize.height
@ -2289,6 +2353,11 @@ private func layoutList(
blockMaxY = max(blockMaxY, ti.frame.maxY)
}
// Nil-guard in stampMarkdownContext preserves any richer kind (e.g. .heading)
// already stamped by a child block's own layout. So a heading nested inside a
// .blocks list item keeps .heading, not .listItem multi-block list items are
// a documented best-effort case for markdown reconstruction.
stampMarkdownContext(translatedItems, kind: .listItem(ordered: ordered, marker: markdownMarker, checked: markdownChecked))
result.append(contentsOf: translatedItems)
contentHeight = blockMaxY
previousBlock = subBlock

View file

@ -1259,6 +1259,16 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
guard let self, let item = self.item else {
return
}
if case .copy = action,
let range = self.textSelectionNode?.getSelection(),
range.length > 0,
let adapter = self.textSelectionAdapter {
let markdown = adapter.markdownForRange(range)
if !markdown.isEmpty {
item.controllerInteraction.performTextSelectionAction(item.message, true, NSAttributedString(string: markdown), nil, .copy)
return
}
}
item.controllerInteraction.performTextSelectionAction(item.message, true, text, nil, action)
}
)

View file

@ -28,6 +28,7 @@ import TranslateUI
import DebugSettingsUI
import ChatPresentationInterfaceState
import Pasteboard
import BrowserUI
import SettingsUI
import TextNodeWithEntities
import ChatControllerInteraction
@ -1254,6 +1255,13 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
}
let message = messages[0]
var richMessageMarkdown: String?
if let richTextAttribute = message.attributes.first(where: { $0 is RichTextMessageAttribute }) as? RichTextMessageAttribute {
let markdown = markdownStringFromInstantPage(richTextAttribute.instantPage)
if !markdown.isEmpty {
richMessageMarkdown = markdown
}
}
var isExpired = false
var isImage = false
for media in message.media {
@ -1266,7 +1274,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
}
let isCopyProtected = chatPresentationInterfaceState.copyProtectionEnabled || message.isCopyProtected()
if !messageText.isEmpty || (resourceAvailable && isImage) || diceEmoji != nil {
if !messageText.isEmpty || richMessageMarkdown != nil || (resourceAvailable && isImage) || diceEmoji != nil {
if !isExpired {
if !isPoll {
if !isCopyProtected {
@ -1277,6 +1285,14 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
UIPasteboard.general.string = diceEmoji
} else {
let copyTextWithEntities = {
if let richMessageMarkdown {
storeMessageTextInPasteboard(richMessageMarkdown, entities: nil)
Queue.mainQueue().after(0.2, {
let content: UndoOverlayContent = .copy(text: chatPresentationInterfaceState.strings.Conversation_MessageCopied)
controllerInteraction.displayUndo(content)
})
return
}
var messageText = message.text
var messageEntities: [MessageTextEntity]?
var restrictedText: String?