Files
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

617 lines
28 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Postbox
import TelegramCore
import RichTextEditorCore
import TextFormat
import RichTextEditorMessageConversion
/// A DIRECT, structure-preserving bridge between the RichTextEditor `Document` and TelegramCore's
/// `ChatInputContent` value model — the draft currency for the native composer node.
///
/// This bypasses the `NSAttributedString` hop (`ComposerDocumentBridge`), which silently drops the
/// structural blocks the `NSAttributedString` vocabulary can't carry (`.media` / `.table`, plus
/// heading/list information). The mapping is a 1:1 block tree + inline-run transcode that reuses the
/// existing inline rules (link classification via `TextFormat`'s `classifyChatLink` / `mentionMarkdownURL`
/// / `dateMarkdownURL`; custom emoji ↔ `EmojiRef`; spoiler/bold/italic/mono/strike/underline) so the two
/// converters can't drift. The non-chat-representable character attributes (`fontFamily` / `fontSize` /
/// `foreground` / `highlight` / `baselineOffset`) are dropped, matching what the chat layer can express.
/// An editor `EmojiRef`'s `altText` / `instanceID` are also dropped — `ChatInputContent`'s
/// `customEmoji(fileId:file:enableAnimation:)` has no slot for them, so the emoji placeholder text degrades to a
/// bare `U+FFFC` on a `Document → ChatInputContent → Document` round-trip (an inherent model limitation; `altText`
/// is otherwise load-bearing on the send path). `enableAnimation` is canonicalized to `true` (no editor field).
///
/// The two media/emoji indirections are threaded as closures so the node (Task #44) supplies its own
/// host resolvers:
/// - `resolveEmoji` maps an editor `EmojiRef` to the chat layer's `(fileId, file)`; nil ⇒ the run degrades
/// to plain text (no representable emoji), matching `ComposerDocumentBridge`'s non-numeric-id fallback.
/// - `resolveMedia` maps the editor's opaque `MediaBlock.mediaID` to a concrete `Media`; nil ⇒ the media
/// block is dropped (an unresolved medium has no `ChatInputMedia` representation).
/// - `registerEmoji` / `registerMedia` are the reverse: they hand the chat-layer identity back to the host
/// and return the editor-side ref/key for it.
// MARK: - Document → ChatInputContent
/// Convert the editor's live `Document` to the chat-layer `ChatInputContent` value model.
/// `resolveEmoji` and `resolveMedia` map the editor's opaque host keys to concrete chat-layer values; an
/// unresolvable emoji degrades to plain text and an unresolvable medium is dropped (see the type doc).
public func chatInputContent(
fromDocument document: Document,
resolveEmoji: (EmojiRef) -> (fileId: Int64, file: TelegramMediaFile?)?,
resolveMedia: (String) -> Media?
) -> ChatInputContent {
var blocks: [ChatInputBlock] = []
for block in document.blocks {
switch block {
case let .paragraph(paragraph):
blocks.append(.paragraph(ChatInputParagraph(
style: chatInputStyle(fromParagraphStyle: paragraph.style),
list: paragraph.list.map(chatInputList(fromList:)),
runs: chatInputRuns(fromRuns: paragraph.runs, resolveEmoji: resolveEmoji)
)))
case let .code(code):
blocks.append(.code(ChatInputCode(
language: code.language,
runs: chatInputRuns(fromRuns: code.runs, resolveEmoji: resolveEmoji)
)))
case let .pullQuote(pq):
blocks.append(.pullQuote(ChatInputPullQuote(
runs: chatInputRuns(fromRuns: pq.runs, resolveEmoji: resolveEmoji),
author: chatInputRuns(fromRuns: pq.author, resolveEmoji: resolveEmoji))))
case let .media(media):
// Resolve each item's Media; drop unresolvable items. A container with no resolvable items is dropped.
let resolvedItems: [ChatInputMediaItem] = media.items.compactMap { item in
guard let resolved = resolveMedia(item.mediaID) else { return nil }
return ChatInputMediaItem(
media: resolved,
kind: chatInputMediaKind(fromKind: item.kind),
naturalSize: ChatInputSize(width: item.naturalSize.width, height: item.naturalSize.height),
isSpoiler: item.isSpoiler)
}
guard !resolvedItems.isEmpty else { continue }
blocks.append(.media(ChatInputMedia(
items: resolvedItems,
displayWidth: media.displayWidth,
alignment: chatInputMediaAlignment(fromAlignment: media.alignment),
displayMode: media.displayMode == .slideshow ? .slideshow : .mosaic,
caption: chatInputRuns(fromRuns: media.caption, resolveEmoji: resolveEmoji)
)))
case let .table(table):
blocks.append(.table(chatInputTable(fromTable: table, resolveEmoji: resolveEmoji)))
case let .blockQuote(bq):
// Editor block-quote container → currency .blockQuote, recursing its children as a sub-document.
let inner = chatInputContent(fromDocument: Document(blocks: bq.children),
resolveEmoji: resolveEmoji, resolveMedia: resolveMedia)
blocks.append(.blockQuote(ChatInputBlockQuote(
content: inner, collapsed: bq.collapsed,
author: chatInputRuns(fromRuns: bq.author, resolveEmoji: resolveEmoji))))
case let .buttonRow(row):
blocks.append(.buttonRow(ChatInputButtonRow(
buttons: row.buttons.map(chatInputButton(fromButtonRef:)),
alignment: instantPageRowAlignment(from: row.alignment)
)))
case let .details(d):
// Editor detail (folding) container → currency .details, recursing its children as a sub-document.
let inner = chatInputContent(fromDocument: Document(blocks: d.children),
resolveEmoji: resolveEmoji, resolveMedia: resolveMedia)
blocks.append(.details(ChatInputDetails(
content: inner,
title: chatInputRuns(fromRuns: d.title, resolveEmoji: resolveEmoji),
expanded: d.expanded)))
}
}
return ChatInputContent(blocks: blocks)
}
private func chatInputStyle(fromParagraphStyle style: ParagraphStyleName) -> ChatInputParagraphStyle {
switch style {
case .body:
return .body
case .heading1:
return .heading1
case .heading2:
return .heading2
case .heading3:
return .heading3
case .heading4, .heading5, .heading6:
// ChatInputContent (the composer model) only models H1–H3; fold deeper editor headings to H3.
return .heading3
case .caption:
// `caption` is a render-only style (only ever appears in a media block's caption runs, handled
// there). A `caption`-styled top-level paragraph should not exist, but map it to `.body` defensively.
return .body
case .pullQuote:
// `pullQuote` is a render-only style (only ever appears as a Block.pullQuote; never persists as
// a top-level paragraph style). Map defensively to `.body`.
return .body
}
}
private func chatInputList(fromList list: ListMembership) -> ChatInputListMembership {
let marker: ChatInputListMarker
switch list.marker {
case .bullet:
marker = .bullet
case .ordered:
marker = .ordered
case .checklist:
marker = .checklist
}
return ChatInputListMembership(marker: marker, level: Int32(list.level), checked: list.checked)
}
private func chatInputMediaKind(fromKind kind: MediaKind) -> ChatInputMediaKind {
switch kind {
case .image:
return .image
case .video:
return .video
case .location:
return .location
case .audio:
return .audio
case .document:
return .document
}
}
private func chatInputMediaAlignment(fromAlignment alignment: MediaAlignment) -> ChatInputMediaAlignment {
switch alignment {
case .left:
return .left
case .center:
return .center
case .right:
return .right
}
}
private func chatInputTextAlignment(fromAlignment alignment: TextAlignment) -> ChatInputTextAlignment {
switch alignment {
case .left, .natural:
// `.natural` is leading (left in LTR); the chat input content has no natural case, and its
// default is `.left`, so natural maps to left. Chat-side text rendering re-detects RTL anyway.
return .left
case .center:
return .center
case .right:
return .right
case .justified:
return .justified
}
}
private func chatInputColor(fromColor color: RGBAColor) -> ChatInputColor {
return ChatInputColor(red: color.red, green: color.green, blue: color.blue, alpha: color.alpha)
}
private func chatInputTableVerticalAlignment(fromCore alignment: VerticalAlignment) -> ChatInputTableVerticalAlignment {
switch alignment {
case .top:
return .top
case .middle:
return .middle
case .bottom:
return .bottom
}
}
private func chatInputTable(
fromTable table: TableBlock,
resolveEmoji: (EmojiRef) -> (fileId: Int64, file: TelegramMediaFile?)?
) -> ChatInputTable {
let columns = table.columns.map { column in
ChatInputColumnSpec(width: column.width)
}
let rows = table.rows.map { row -> ChatInputTableRow in
let cells = row.cells.map { cell -> ChatInputTableCell in
// A `ChatInputTableCell` is inline-only (a flat run list). A well-formed editor cell holds a
// single `.paragraph`; defensively flatten ANY cell content to runs (a non-paragraph block —
// media/table/code — contributes its text only, ignoring structure the cell can't carry).
ChatInputTableCell(
runs: chatInputRuns(fromRuns: cellRuns(fromBlocks: cell.blocks), resolveEmoji: resolveEmoji),
background: cell.background.map(chatInputColor(fromColor:)),
horizontalAlignment: chatInputTextAlignment(fromAlignment: cell.horizontalAlignment),
verticalAlignment: chatInputTableVerticalAlignment(fromCore: cell.verticalAlignment),
isHeader: cell.isHeader,
colspan: cell.colspan,
rowspan: cell.rowspan
)
}
return ChatInputTableRow(height: row.height, cells: cells)
}
return ChatInputTable(columns: columns, rows: rows, compact: table.compact, bordered: table.bordered)
}
/// Flatten a table cell's blocks to a single run list. A paragraph contributes its runs verbatim; any other
/// block type contributes a plain-text run of its text (its structure is not representable inline). Blocks
/// are concatenated with no separator (cells are conceptually single-paragraph).
private func cellRuns(fromBlocks blocks: [Block]) -> [TextRun] {
var runs: [TextRun] = []
for block in blocks {
switch block {
case let .paragraph(paragraph):
runs.append(contentsOf: paragraph.runs)
case let .code(code):
runs.append(contentsOf: code.runs)
case let .pullQuote(pq):
runs.append(contentsOf: pq.runs)
runs.append(contentsOf: pq.author)
case let .media(media):
// No inline text for the medium itself; keep only its caption text.
runs.append(contentsOf: media.caption)
case .table:
// A nested table has no flat-run form; contribute nothing.
break
case .buttonRow:
// A table cell is a flat run list and a button row has no inline form; contribute nothing.
// Its label is button chrome, not document text, so leaking it into the cell would be wrong.
break
case let .blockQuote(bq):
// A table cell is inline-only; block-quote structure is not representable inside a cell.
// Flatten the quote's children's runs inline.
for child in bq.children {
runs.append(contentsOf: cellRuns(fromBlocks: [child]))
}
runs.append(contentsOf: bq.author)
case let .details(d):
// A table cell is inline-only; detail-block structure is not representable inside a cell.
// Flatten the title then the children's runs inline.
runs.append(contentsOf: d.title)
for child in d.children {
runs.append(contentsOf: cellRuns(fromBlocks: [child]))
}
}
}
return runs
}
private func chatInputRuns(
fromRuns runs: [TextRun],
resolveEmoji: (EmojiRef) -> (fileId: Int64, file: TelegramMediaFile?)?
) -> [ChatInputRun] {
return runs.map { run in
let attributes = chatInputInlineAttributes(fromCharacterAttributes: run.attributes, resolveEmoji: resolveEmoji)
// A custom emoji is a single `U+FFFC` in the editor `Document`, with its placeholder/alt text on the
// `EmojiRef`. The chat currency instead carries that alt text AS the run text under the custom-emoji
// entity (matching `chatInputContent(from:)` / `ComposerDocumentBridge`), so the SENT message's text +
// entity length are the emoji, not a bare `U+FFFC`. Substitute it when this resolved to a custom emoji.
// A button run's text stays the bare `U+FFFC` — unlike a custom emoji, whose alt text is
// substituted, a pill's label lives inside the button and is never document text.
if run.attributes.button != nil {
return ChatInputRun(text: "\u{FFFC}", attributes: attributes)
}
if case .customEmoji? = attributes.entity, let altText = run.attributes.emoji?.altText, !altText.isEmpty {
return ChatInputRun(text: altText, attributes: attributes)
}
if let formula = run.attributes.formula {
return ChatInputRun(text: formula, attributes: attributes)
}
return ChatInputRun(text: run.text, attributes: attributes)
}
}
private func chatInputInlineAttributes(
fromCharacterAttributes attributes: CharacterAttributes,
resolveEmoji: (EmojiRef) -> (fileId: Int64, file: TelegramMediaFile?)?
) -> ChatInputInlineAttributes {
// The entity slot is mutually exclusive (one of mention/url/date/customEmoji). Prefer the emoji over a
// link when a run improbably carries both — matching `ComposerDocumentBridge`, which short-circuits on
// a custom-emoji run before reading any other attribute.
var entity: ChatInputInlineEntity?
if let button = attributes.button {
entity = .button(chatInputButton(fromButtonRef: button))
} else if let emoji = attributes.emoji, let resolved = resolveEmoji(emoji) {
entity = .customEmoji(fileId: resolved.fileId, file: resolved.file, enableAnimation: true)
} else if let link = attributes.link {
switch classifyChatLink(link) {
case let .mention(peerId):
entity = .mention(peerId)
case let .date(timestamp):
entity = .date(timestamp)
case let .url(url):
entity = .url(url)
}
}
return ChatInputInlineAttributes(
bold: attributes.bold,
italic: attributes.italic,
monospace: attributes.inlineCode,
strikethrough: attributes.strikethrough,
underline: attributes.underline,
spoiler: attributes.spoiler,
formula: attributes.formula,
entity: entity
)
}
// MARK: - ChatInputContent → Document
/// Convert the chat-layer `ChatInputContent` value model back to an editor `Document`. `registerEmoji` and
/// `registerMedia` hand the chat-layer identity back to the host and return the editor-side ref/key for it.
/// `.blockQuote` is the sole quote path (Task 16b).
public func document(
fromChatInputContent content: ChatInputContent,
registerEmoji: (Int64, TelegramMediaFile?) -> EmojiRef,
registerMedia: (Media) -> String
) -> Document {
var blocks: [Block] = []
for block in content.blocks {
blocks.append(contentsOf: documentBlocks(fromChatInputBlock: block, registerEmoji: registerEmoji, registerMedia: registerMedia))
}
return Document(blocks: blocks)
}
/// One chat-input block maps to one or more editor blocks.
private func documentBlocks(
fromChatInputBlock block: ChatInputBlock,
registerEmoji: (Int64, TelegramMediaFile?) -> EmojiRef,
registerMedia: (Media) -> String
) -> [Block] {
switch block {
case let .paragraph(paragraph):
return [.paragraph(ParagraphBlock(
id: BlockID.generate(),
style: paragraphStyle(fromChatInputStyle: paragraph.style),
list: paragraph.list.map(list(fromChatInputList:)),
runs: runs(fromChatInputRuns: paragraph.runs, registerEmoji: registerEmoji)
))]
case let .code(code):
return [.code(CodeBlock(
id: BlockID.generate(),
language: code.language,
runs: runs(fromChatInputRuns: code.runs, registerEmoji: registerEmoji)
))]
case let .pullQuote(pq):
return [.pullQuote(PullQuote(
id: BlockID.generate(),
runs: runs(fromChatInputRuns: pq.runs, registerEmoji: registerEmoji),
author: runs(fromChatInputRuns: pq.author, registerEmoji: registerEmoji)))]
case let .media(media):
return [.media(MediaBlock(
id: BlockID.generate(),
items: media.items.map { item in
MediaItem(
mediaID: registerMedia(item.media),
kind: mediaKind(fromChatInputKind: item.kind),
naturalSize: Size2D(width: item.naturalSize.width, height: item.naturalSize.height),
isSpoiler: item.isSpoiler)
},
displayWidth: media.displayWidth,
alignment: mediaAlignment(fromChatInputAlignment: media.alignment),
displayMode: media.displayMode == .slideshow ? .slideshow : .mosaic,
caption: runs(fromChatInputRuns: media.caption, registerEmoji: registerEmoji)
))]
case let .table(table):
return [.table(tableBlock(fromChatInputTable: table, registerEmoji: registerEmoji))]
case let .blockQuote(bq):
// Currency .blockQuote → a real editor Block.blockQuote, recursing the inner ChatInputContent
// back to editor blocks.
let children = bq.content.blocks.flatMap {
documentBlocks(fromChatInputBlock: $0, registerEmoji: registerEmoji, registerMedia: registerMedia)
}
return [.blockQuote(BlockQuote(id: BlockID.generate(), children: children, collapsed: bq.collapsed,
author: runs(fromChatInputRuns: bq.author, registerEmoji: registerEmoji)))]
case let .buttonRow(row):
return [.buttonRow(ButtonRowBlock(
id: BlockID.generate(),
buttons: row.buttons.map(buttonRef(fromChatInputButton:)),
alignment: buttonRowAlignment(from: row.alignment)
))]
case let .details(d):
// Currency .details → a real editor Block.details, recursing the inner ChatInputContent back to
// editor blocks. `expanded` maps 1:1.
let children = d.content.blocks.flatMap {
documentBlocks(fromChatInputBlock: $0, registerEmoji: registerEmoji, registerMedia: registerMedia)
}
return [.details(DetailsBlock(id: BlockID.generate(),
title: runs(fromChatInputRuns: d.title, registerEmoji: registerEmoji),
children: children, expanded: d.expanded))]
}
}
private func paragraphStyle(fromChatInputStyle style: ChatInputParagraphStyle) -> ParagraphStyleName {
switch style {
case .body:
return .body
case .heading1:
return .heading1
case .heading2:
return .heading2
case .heading3:
return .heading3
}
}
private func list(fromChatInputList list: ChatInputListMembership) -> ListMembership {
let marker: ListMarker
switch list.marker {
case .bullet:
marker = .bullet
case .ordered:
marker = .ordered
case .checklist:
marker = .checklist
}
return ListMembership(marker: marker, level: Int(list.level), checked: list.checked)
}
private func mediaKind(fromChatInputKind kind: ChatInputMediaKind) -> MediaKind {
switch kind {
case .image:
return .image
case .video:
return .video
case .location:
return .location
case .audio:
return .audio
case .document:
return .document
}
}
private func mediaAlignment(fromChatInputAlignment alignment: ChatInputMediaAlignment) -> MediaAlignment {
switch alignment {
case .left:
return .left
case .center:
return .center
case .right:
return .right
}
}
private func textAlignment(fromChatInputAlignment alignment: ChatInputTextAlignment) -> TextAlignment {
switch alignment {
case .left:
return .left
case .center:
return .center
case .right:
return .right
case .justified:
return .justified
}
}
private func color(fromChatInputColor color: ChatInputColor) -> RGBAColor {
return RGBAColor(red: color.red, green: color.green, blue: color.blue, alpha: color.alpha)
}
private func verticalAlignment(fromChatInput alignment: ChatInputTableVerticalAlignment) -> VerticalAlignment {
switch alignment {
case .top:
return .top
case .middle:
return .middle
case .bottom:
return .bottom
}
}
private func tableBlock(
fromChatInputTable table: ChatInputTable,
registerEmoji: (Int64, TelegramMediaFile?) -> EmojiRef
) -> TableBlock {
let columns = table.columns.map { column in
ColumnSpec(width: column.width)
}
let rows = table.rows.map { row -> Row in
let cells = row.cells.map { cell -> Cell in
// A `ChatInputTableCell` is a flat run list; wrap it back into the editor's expected single
// body paragraph per cell.
Cell(
id: BlockID.generate(),
blocks: [.paragraph(ParagraphBlock(
id: BlockID.generate(),
style: .body,
runs: runs(fromChatInputRuns: cell.runs, registerEmoji: registerEmoji)
))],
background: cell.background.map(color(fromChatInputColor:)),
horizontalAlignment: textAlignment(fromChatInputAlignment: cell.horizontalAlignment),
verticalAlignment: verticalAlignment(fromChatInput: cell.verticalAlignment),
isHeader: cell.isHeader,
colspan: cell.colspan,
rowspan: cell.rowspan
)
}
return Row(id: BlockID.generate(), height: row.height, cells: cells)
}
return TableBlock(id: BlockID.generate(), columns: columns, rows: rows, compact: table.compact, bordered: table.bordered)
}
private func runs(
fromChatInputRuns runs: [ChatInputRun],
registerEmoji: (Int64, TelegramMediaFile?) -> EmojiRef
) -> [TextRun] {
return runs.map { run in
var attributes = characterAttributes(fromChatInputInlineAttributes: run.attributes, registerEmoji: registerEmoji)
// The reverse of the forward `chatInputRuns` substitution: a custom emoji must be a single `U+FFFC`
// in the editor `Document` (the editor's emoji invariant), with the chat-currency run text preserved
// as the `EmojiRef.altText` so a later forward pass re-emits it verbatim (and the editor's
// `insertEmoji` altText round-trips). `registerEmoji` mints the ref with a nil altText; fill it here.
if case .customEmoji? = run.attributes.entity, var emoji = attributes.emoji {
emoji.altText = run.text
attributes.emoji = emoji
return TextRun(text: "\u{FFFC}", attributes: attributes)
}
if attributes.formula != nil || attributes.button != nil {
return TextRun(text: "\u{FFFC}", attributes: attributes)
}
return TextRun(text: run.text, attributes: attributes)
}
}
private func characterAttributes(
fromChatInputInlineAttributes attributes: ChatInputInlineAttributes,
registerEmoji: (Int64, TelegramMediaFile?) -> EmojiRef
) -> CharacterAttributes {
var result = CharacterAttributes.plain
result.bold = attributes.bold
result.italic = attributes.italic
result.inlineCode = attributes.monospace
result.strikethrough = attributes.strikethrough
result.underline = attributes.underline
result.spoiler = attributes.spoiler
result.formula = attributes.formula
// The entity slot is mutually exclusive — emoji lands in `emoji`, mention/date/url in the shared `link`
// field as a `tg://` marker (mention/date) or the raw URL, symmetric with the forward classifier.
if let entity = attributes.entity {
switch entity {
case let .customEmoji(fileId, file, _):
result.emoji = registerEmoji(fileId, file)
case let .mention(peerId):
result.link = mentionMarkdownURL(peerId: peerId)
case let .date(timestamp):
result.link = dateMarkdownURL(timestamp: timestamp)
case let .url(url):
result.link = url
case let .button(button):
result.button = buttonRef(fromChatInputButton: button)
}
}
return result
}
/// True when pasted RTF carries a block the legacy input can't render even as text — a TABLE or MEDIA — and
/// should therefore latch the composer to the native editor. Lists, headings, code and quotes are deliberately
/// EXCLUDED: they have a linear text form, so they stay in the legacy field (a list renders its markers as text
/// via `legacyChatInputAttributedString`). Returns false when the data isn't parseable as RTF.
public func rtfRequiresNativeRichInput(_ data: Data) -> Bool {
guard let document = RTFImport.document(fromRTF: data) else { return false }
return documentRequiresNativeRichInput(document)
}
/// The block predicate behind `rtfRequiresNativeRichInput`, split out so it can be tested without
/// round-tripping through RTF bytes.
func documentRequiresNativeRichInput(_ document: Document) -> Bool {
return document.blocks.contains { block in
switch block {
case .table, .media, .details, .buttonRow:
// A button row has no linear text form at all — the legacy field cannot render it even
// as text, so its presence must latch the composer to the native editor.
return true
case .paragraph, .code, .pullQuote, .blockQuote:
return false
}
}
}
/// Legacy-input paste of external RTF that carries a LIST. iOS's own `NSAttributedString(rtf:)` flattens block
/// structure (no `NSTextList`), so `chatInputStateStringFromRTF` drops list markers entirely. This path instead
/// parses the RTF with the structure-preserving `RTFImport`, then renders each list item's marker (•, 1., …) as
/// LITERAL TEXT via `attributedString(from:renderListMarkers:)`, preserving inline formatting. Returns nil when
/// the RTF has no list (or doesn't parse) so the caller keeps its existing inline-preserving conversion.
public func legacyChatInputAttributedString(fromRTF data: Data) -> NSAttributedString? {
guard let document = RTFImport.document(fromRTF: data) else { return nil }
let hasList = document.blocks.contains { block in
if case .paragraph(let paragraph) = block, paragraph.list != nil { return true }
return false
}
guard hasList else { return nil }
let content = chatInputContent(fromDocument: document, resolveEmoji: { _ in nil }, resolveMedia: { _ in nil })
// Paste direction: an imported link whose label IS its own URL pastes as plain text (see
// `chatInputContentStrippingSelfReferentialLinks`), matching the `chatInputStateStringFromRTF` path.
return attributedString(from: chatInputContentStrippingSelfReferentialLinks(content), renderListMarkers: true)
}