mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-04 06:37:08 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
617 lines
28 KiB
Swift
617 lines
28 KiB
Swift
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)
|
||
}
|