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

570 lines
36 KiB
Swift

import Foundation
import Postbox
// A lossless, bidirectional converter between the `ChatInputContent` value model and `InstantPage`.
//
// The invariant guaranteed (and unit-tested in `//submodules/TextFormat:TextFormatTests`):
//
// chatInputContent(fromInstantPage: instantPage(from: c)) == c for every `c`.
//
// This is a fresh, separate pair from the lossy `RichTextEditorMessageConversion` converter — do not
// conflate them. The round-trip is identity, so the reverse must invert the forward EXACTLY, in
// particular preserving run boundaries (one top-level `RichText` part ⇄ exactly one run; no merging).
// `.blockQuote` is the sole quote path — the old flat-`.quote` paragraph and `.collapsedQuote` block
// are retired; old persisted drafts degrade via the lenient `ChatInputContent.init(from:)` decoder.
// MARK: - Forward: ChatInputContent -> InstantPage
public func instantPage(from content: ChatInputContent) -> InstantPage {
// Collect the concrete media referenced by `.media` blocks: the InstantPage carries `Media` out-of-band in its
// `media: [MediaId: Media]` dict and the image/video block holds only the `MediaId`. The reverse re-resolves the
// `Media` from this dict, so the round-trip preserves the exact `Media` instance (identity), not a copy.
var media: [MediaId: Media] = [:]
let blocks = instantPageBlocks(from: content, collectingMediaInto: &media)
return InstantPage(blocks: blocks, media: media, isComplete: true, rtl: false, url: "", views: nil)
}
func instantPageBlocks(from content: ChatInputContent, collectingMediaInto media: inout [MediaId: Media]) -> [InstantPageBlock] {
var result: [InstantPageBlock] = []
var i = 0
while i < content.blocks.count {
switch content.blocks[i] {
case let .paragraph(p):
if let latex = standaloneFormulaLatex(from: p) {
result.append(.formula(latex: latex))
} else if let list = p.list, case .body = p.style {
// Coalesce a maximal run of consecutive BODY-styled list paragraphs with the SAME marker into one
// `.list`. (Adjacent paragraphs with different markers — a bullet list followed by an ordered list —
// stay separate `.list` blocks.) `InstantPageListItem` has no indent-level field, so the per-paragraph
// `level` is NOT representable: level 0 round-trips exactly, and level > 0 is CANONICALIZED to 0 (the
// InstantPage list projection is flat). This mirrors the collapsed-quote/customEmoji canonicalizations.
//
// The `case .body = p.style` guard is LOAD-BEARING: it must match the inner `while`'s `.body` check. A
// non-body paragraph that also carries a list (e.g. a degenerate heading+list — the editor's heading /
// quote styles and list membership are mutually exclusive in practice) would otherwise enter this
// branch, fail the inner `while` on its first iteration, never advance `i`, and `continue` past the
// `i += 1` → an infinite loop / main-thread hang on the draft-save path. With the guard it falls
// through to the style switch and canonicalizes to its style (the list is dropped) — consistent with
// quote-before-list precedence above.
let marker = list.marker
var items: [InstantPageListItem] = []
while i < content.blocks.count, case let .paragraph(q) = content.blocks[i], let qList = q.list, qList.marker == marker, case .body = q.style {
items.append(.text(richText(from: q.runs), nil, qList.checked))
i += 1
}
result.append(.list(items: items, ordered: marker == .ordered))
continue
} else {
switch p.style {
case .heading1:
result.append(.heading(text: richText(from: p.runs), level: 1))
case .heading2:
result.append(.heading(text: richText(from: p.runs), level: 2))
case .heading3:
result.append(.heading(text: richText(from: p.runs), level: 3))
case .body:
result.append(.paragraph(richText(from: p.runs)))
}
}
case let .code(c):
result.append(.preformatted(text: richText(from: c.runs), language: c.language))
case let .pullQuote(pq):
result.append(.pullQuote(text: richText(from: pq.runs), caption: authorCaptionRichText(pq.author, italic: true)))
case let .blockQuote(bq):
// Recursive structured blockquote: forward the inner content unchanged. `collapsed` maps 1:1.
result.append(.blockQuote(blocks: instantPageBlocks(from: bq.content, collectingMediaInto: &media),
caption: authorCaptionRichText(bq.author), collapsed: bq.collapsed))
case let .media(m):
let caption = InstantPageCaption(text: richText(from: m.caption), credit: .empty)
if m.items.count >= 2 {
// Multi-item photo/video container → one .collage block; each inner .image/.video references
// a MediaId stored in the page media dict. Inner captions are empty (the container caption is
// on the collage). Items with a nil id or a non-image/video kind are skipped defensively.
var innerBlocks: [InstantPageBlock] = []
for item in m.items {
guard let mediaId = item.media.id else { continue }
media[mediaId] = item.media
switch item.kind {
case .image:
innerBlocks.append(.image(id: mediaId, caption: InstantPageCaption(text: .empty, credit: .empty), url: nil, webpageId: nil, spoiler: item.isSpoiler))
case .video:
innerBlocks.append(.video(id: mediaId, caption: InstantPageCaption(text: .empty, credit: .empty), autoplay: false, loop: false, spoiler: item.isSpoiler))
default:
// audio / location are never grouped into a collage (single-item only); skip defensively.
continue
}
}
switch m.displayMode {
case .mosaic: result.append(.collage(items: innerBlocks, caption: caption))
case .slideshow: result.append(.slideshow(items: innerBlocks, caption: caption))
}
break
}
// Single item: byte-identical to the pre-container output.
let item = m.items[0]
switch item.kind {
case .image, .video, .audio, .document:
// Stash the Media in the page's `media` dict, keyed by its own MediaId; the block carries only the id.
// image/video/audio/document are always a concrete TelegramMediaImage/TelegramMediaFile with an id;
// a nil-id medium is dropped (it could not be resolved back from the dict anyway).
guard let mediaId = item.media.id else {
break
}
media[mediaId] = item.media
switch item.kind {
case .image:
result.append(.image(id: mediaId, caption: caption, url: nil, webpageId: nil, spoiler: item.isSpoiler))
case .audio:
// music & voice both serialize as `.audio`; the file's `.Audio(isVoice:)` attribute (carried on
// the stored Media) drives the music-vs-voice render. No size/alignment is representable.
result.append(.audio(id: mediaId, caption: caption))
case .document:
// A caption-less block, so `caption` is .empty for editor-authored content; the field is
// carried anyway because the wire block has one.
result.append(.document(id: mediaId, caption: caption))
default:
result.append(.video(id: mediaId, caption: caption, autoplay: false, loop: false, spoiler: item.isSpoiler))
}
case .location:
// A location is a `TelegramMediaMap` (id-less): the InstantPage `.map` block carries its coordinates
// inline, so nothing goes in the page `media` dict. zoom/dimensions are render hints (15 / 600x300
// fallback). venue is not representable here — the caption already carries the venue title.
if let map = item.media as? TelegramMediaMap {
let dimensions: PixelDimensions
if item.naturalSize.width > 0, item.naturalSize.height > 0 {
dimensions = PixelDimensions(width: Int32(item.naturalSize.width), height: Int32(item.naturalSize.height))
} else {
dimensions = PixelDimensions(width: 600, height: 300)
}
result.append(.map(latitude: map.latitude, longitude: map.longitude, zoom: 15, dimensions: dimensions, caption: caption))
}
}
case let .table(t):
// `naturalSize`/`displayWidth`/`alignment` of media and `width`/cell-background of a table are NOT
// representable in the InstantPage projection (see the reverse for the documented defaults the
// round-trip restores). Forward each cell's own per-cell H+V alignment (the editor's `justified` has
// no InstantPage equivalent → CANONICALIZED to `.left`), per-cell header flag, colspan/rowspan, and
// runs. `header` is carried PER CELL (not derived from the row), so a partial-header row (some
// cells header, some not) round-trips exactly. `colspan`/`rowspan` are `Int` (chat currency, default
// 1) forwarded as `Int32` (InstantPage's `0`/`1` = no span, `>1` = real span).
let rows = t.rows.map { row -> InstantPageTableRow in
let cells = row.cells.map { cell -> InstantPageTableCell in
let alignment: TableHorizontalAlignment
switch cell.horizontalAlignment {
case .left, .justified:
alignment = .left
case .center:
alignment = .center
case .right:
alignment = .right
}
let vAlignment: TableVerticalAlignment
switch cell.verticalAlignment {
case .top:
vAlignment = .top
case .middle:
vAlignment = .middle
case .bottom:
vAlignment = .bottom
}
return InstantPageTableCell(text: richText(from: cell.runs), header: cell.isHeader, alignment: alignment, verticalAlignment: vAlignment, colspan: Int32(cell.colspan), rowspan: Int32(cell.rowspan))
}
return InstantPageTableRow(cells: cells)
}
// TODO: `striped` (and `title`) are the last unmodelled `pageBlockTable` fields — a server
// table with `striped: true` keeps its borders through this round-trip but loses its stripes.
// Deliberate, not an oversight: modelling them needs an editor affordance too.
result.append(.table(title: .empty, rows: rows, bordered: t.bordered, striped: false, compact: t.compact))
case let .details(d):
// Recursive detail (folding) block → InstantPage `.details`. Forward the title as RichText and the
// inner content unchanged; `expanded` maps 1:1 (the inverse of a block-quote's `collapsed`).
result.append(.details(title: richText(from: d.title),
blocks: instantPageBlocks(from: d.content, collectingMediaInto: &media),
expanded: d.expanded))
case let .buttonRow(row):
result.append(.buttonRow(
alignment: row.alignment,
buttons: row.buttons.map {
InstantPageButton(text: richText(from: $0.label), action: $0.action, color: $0.color, isLink: $0.isLink)
}
))
}
i += 1
}
return result
}
func richText(from runs: [ChatInputRun]) -> RichText {
if runs.isEmpty {
return .empty // an empty paragraph round-trips to/from .empty
}
let parts = runs.map { run -> RichText in
var rt: RichText
// The entity becomes the leaf/innermost (customEmoji is itself the leaf carrying the run text as alt).
if let formula = run.attributes.formula {
rt = .formula(latex: formula)
} else {
switch run.attributes.entity {
case let .customEmoji(fileId, _, _):
rt = .textCustomEmoji(fileId: fileId, alt: run.text)
case let .mention(peerId):
rt = .textMentionName(text: .plain(run.text), peerId: peerId.toInt64())
case let .url(url):
rt = .url(text: .plain(run.text), url: url, webpageId: nil)
case let .date(date):
rt = .textDate(text: .plain(run.text), date: date, format: nil)
case let .button(button):
// The carrying run's text is a bare `U+FFFC` and is deliberately NOT emitted — a
// button's label lives inside the button. The reverse restores the `U+FFFC`.
rt = .textButton(InstantPageButton(text: richText(from: button.label), action: button.action, color: button.color, isLink: button.isLink))
case nil:
rt = .plain(run.text)
}
}
if run.attributes.bold { rt = .bold(rt) }
if run.attributes.italic { rt = .italic(rt) }
if run.attributes.monospace { rt = .fixed(rt) }
if run.attributes.strikethrough { rt = .strikethrough(rt) }
if run.attributes.underline { rt = .underline(rt) }
if run.attributes.spoiler { rt = .textSpoiler(text: rt) }
return rt
}
return parts.count == 1 ? parts[0] : .concat(parts)
}
/// Force bold (and, when `italic` is set — pull quotes only — italic too) on each author run for the
/// emitted InstantPage caption (both are ambient — not in the model — but must render that way on the
/// recipient). Empty stays empty (→ `.empty`, no bold/italic wrapper).
private func authorCaptionRichText(_ author: [ChatInputRun], italic: Bool = false) -> RichText {
richText(from: author.map { var r = $0; r.attributes.bold = true; if italic { r.attributes.italic = true }; return r })
}
/// Strip the ambient bold (and, when `italic` is set, the ambient italic) from a decoded quote caption so
/// `author` matches the model (bold/italic-free).
private func authorRuns(fromCaption rt: RichText, italic: Bool = false) -> [ChatInputRun] {
chatInputRuns(fromRichText: rt).map { var r = $0; r.attributes.bold = false; if italic { r.attributes.italic = false }; return r }
}
// MARK: - Reverse: InstantPage -> ChatInputContent
public func chatInputContent(fromInstantPage page: InstantPage) -> ChatInputContent {
return ChatInputContent(blocks: chatInputBlocks(fromInstantPageBlocks: page.blocks, media: page.media))
}
/// Recover a media block's natural size (aspect) from the resolved `Media`. The InstantPage image/video block
/// carries no size field, so the dimensions live only on the `Media` itself — an image's largest representation
/// (`.last`, the convention), or a video/image file's `.Video`/`.ImageSize` attribute (`TelegramMediaFile.dimensions`).
/// This mirrors the size the editor derives at insertion (`RichTextAttachmentScreen`), so a
/// `Document → InstantPage → Document` round-trip preserves the aspect instead of collapsing to the editor's 16:9
/// fallback (`MediaBlockBox.imageDisplaySize`). Falls back to `.zero` when the media carries no dimensions.
private func chatInputNaturalSize(fromMedia media: Media) -> ChatInputSize {
let dimensions: PixelDimensions?
if let image = media as? TelegramMediaImage {
dimensions = image.representations.last?.dimensions
} else if let file = media as? TelegramMediaFile {
dimensions = file.dimensions
} else {
dimensions = nil
}
guard let dimensions else {
return ChatInputSize(width: 0.0, height: 0.0)
}
return ChatInputSize(width: Double(dimensions.width), height: Double(dimensions.height))
}
/// Shared by the `.collage` (mosaic) and `.slideshow` reverse arms: resolve each inner `.image`/`.video` block's
/// `Media` from the page dict and recover its `naturalSize`/`isSpoiler`, exactly as the single-item `.image`/`.video`
/// arms do. The two container kinds differ ONLY in the resulting `ChatInputMedia.displayMode` (set by the caller),
/// not in how their inner items are resolved.
private func chatInputMediaItems(fromInnerBlocks innerBlocks: [InstantPageBlock], media: [MediaId: Media]) -> [ChatInputMediaItem] {
var items: [ChatInputMediaItem] = []
for inner in innerBlocks {
switch inner {
case let .image(id, _, _, _, spoiler):
if let m = media[id] {
items.append(ChatInputMediaItem(media: m, kind: .image, naturalSize: chatInputNaturalSize(fromMedia: m), isSpoiler: spoiler))
}
case let .video(id, _, _, _, spoiler):
if let m = media[id] {
items.append(ChatInputMediaItem(media: m, kind: .video, naturalSize: chatInputNaturalSize(fromMedia: m), isSpoiler: spoiler))
}
default:
break // collage/slideshow only ever carry image/video from the forward
}
}
return items
}
/// Flattens InstantPage list items into `ChatInputContent` list paragraphs, recursing into `.blocks` items (a
/// list item carrying continuation paragraphs and/or a nested sub-list) and preserving indent depth via the
/// paragraph's `ChatInputListMembership.level`. Without the recursion a nested sub-list (carried as an item's
/// `.blocks` payload — what the markdown parser emits) would be dropped entirely.
private func appendChatInputListParagraphs(_ items: [InstantPageListItem], ordered: Bool, level: Int32, media: [MediaId: Media], into result: inout [ChatInputBlock]) {
func marker(_ checked: Bool?) -> ChatInputListMarker {
return checked != nil ? .checklist : (ordered ? .ordered : .bullet)
}
for item in items {
switch item {
case let .text(rt, _, checked):
result.append(.paragraph(ChatInputParagraph(style: .body, list: ChatInputListMembership(marker: marker(checked), level: level, checked: checked), runs: chatInputRuns(fromRichText: rt))))
case let .blocks(blocks, _, checked):
var isFirstParagraph = true
for inner in blocks {
switch inner {
case let .paragraph(rt):
let paragraphChecked = isFirstParagraph ? checked : nil
result.append(.paragraph(ChatInputParagraph(style: .body, list: ChatInputListMembership(marker: marker(paragraphChecked), level: level, checked: paragraphChecked), runs: chatInputRuns(fromRichText: rt))))
isFirstParagraph = false
case let .list(subItems, subOrdered):
appendChatInputListParagraphs(subItems, ordered: subOrdered, level: level + 1, media: media, into: &result)
default:
// A non-paragraph, non-list block inside a list item (e.g. a nested quote/code — rare from
// markdown). Flatten via the general mapper so its content is not lost.
result.append(contentsOf: chatInputBlocks(fromInstantPageBlocks: [inner], media: media))
}
}
case .unknown:
break
}
}
}
func chatInputBlocks(fromInstantPageBlocks blocks: [InstantPageBlock], media: [MediaId: Media] = [:]) -> [ChatInputBlock] {
var result: [ChatInputBlock] = []
for block in blocks {
switch block {
case let .paragraph(rt):
result.append(.paragraph(ChatInputParagraph(style: .body, runs: chatInputRuns(fromRichText: rt))))
case let .heading(rt, level):
// Clamp an out-of-range heading level into the editor's 1...3 band; the forward only emits 1/2/3, but a
// cloud-received heading may carry any level (InstantPage's default is 3 — see the decoder's `orElse: 3`).
let style: ChatInputParagraphStyle
switch max(1, min(3, level)) {
case 1: style = .heading1
case 2: style = .heading2
default: style = .heading3
}
result.append(.paragraph(ChatInputParagraph(style: style, runs: chatInputRuns(fromRichText: rt))))
case let .list(items, ordered):
// One body list paragraph per item. `.text` items are a single paragraph; a `.blocks` item — which
// the markdown parser produces for a list item that carries continuation paragraphs and/or a NESTED
// sub-list — is flattened recursively (its own paragraph(s) at this level, a nested `.list` one level
// deeper), preserving indent via `ChatInputListMembership.level`. A non-nil `checked` selects the
// `.checklist` marker (the forward threads `checked` from `ChatInputListMembership`); otherwise
// `.ordered` / `.bullet` per the `ordered` flag. (Skipping `.blocks` used to silently drop a whole
// nested sub-list on paste.)
appendChatInputListParagraphs(items, ordered: ordered, level: 0, media: media, into: &result)
case let .preformatted(rt, language):
result.append(.code(ChatInputCode(language: language, runs: chatInputRuns(fromRichText: rt))))
case let .pullQuote(rt, caption):
result.append(.pullQuote(ChatInputPullQuote(runs: chatInputRuns(fromRichText: rt),
author: authorRuns(fromCaption: caption, italic: true))))
case let .blockQuote(innerBlocks, caption, collapsed):
// Recursive structured blockquote: map back to `ChatInputBlock.blockQuote` with the same `collapsed`
// flag. Nil (cloud-received wire, no collapsed field) is treated as `false` (visible, non-collapsed).
// This is the symmetric inverse of the forward `.blockQuote(bq)` arm. Old flat-`.quote` paragraphs
// and `.collapsedQuote` blocks are no longer produced by the forward (Task 16b removed them).
result.append(.blockQuote(ChatInputBlockQuote(
content: ChatInputContent(blocks: chatInputBlocks(fromInstantPageBlocks: innerBlocks, media: media)),
collapsed: collapsed == true,
author: authorRuns(fromCaption: caption))))
case let .details(title, innerBlocks, expanded):
// Recursive detail (folding) block → `ChatInputBlock.details`. Symmetric inverse of the forward
// `.details` arm; `expanded` maps 1:1 (the inverse of a block-quote's `collapsed`).
result.append(.details(ChatInputDetails(
content: ChatInputContent(blocks: chatInputBlocks(fromInstantPageBlocks: innerBlocks, media: media)),
title: chatInputRuns(fromRichText: title),
expanded: expanded)))
case let .formula(latex):
var attributes = ChatInputInlineAttributes()
attributes.formula = latex
result.append(.paragraph(ChatInputParagraph(style: .body, runs: [
ChatInputRun(text: latex, attributes: attributes)
])))
case let .image(id, caption, _, _, spoiler):
// Resolve the concrete `Media` from the page's `media` dict; a missing entry (the forward always stores it,
// so this is defensive against a malformed page) drops the block. The InstantPage image block carries no
// size, so `naturalSize` is recovered from the `Media` itself (`chatInputNaturalSize(fromMedia:)`) — the
// dimensions the editor also derives at insertion, so the aspect survives the round-trip. `displayWidth`
// (a user resize) and `alignment` are genuinely not representable, so they canonicalize to `nil` / `.center`
// (the editor's `ChatInputMedia` default; a documented limitation, pinned by the media tests). `spoiler`
// threads through onto the item's `isSpoiler`.
if let media = media[id] {
let item = ChatInputMediaItem(media: media, kind: .image, naturalSize: chatInputNaturalSize(fromMedia: media), isSpoiler: spoiler)
result.append(.media(ChatInputMedia(items: [item], displayWidth: nil, alignment: .center, caption: chatInputRuns(fromRichText: caption.text))))
}
case let .video(id, caption, _, _, spoiler):
// Same as `.image` (see above): `naturalSize` recovered from the file's `.Video`/`.ImageSize` dimensions,
// `displayWidth`/`alignment` canonicalized to the defaults, `spoiler` threads through onto `isSpoiler`.
if let media = media[id] {
let item = ChatInputMediaItem(media: media, kind: .video, naturalSize: chatInputNaturalSize(fromMedia: media), isSpoiler: spoiler)
result.append(.media(ChatInputMedia(items: [item], displayWidth: nil, alignment: .center, caption: chatInputRuns(fromRichText: caption.text))))
}
case let .collage(innerBlocks, caption):
// Rebuild a multi-item container. Each inner .image/.video resolves its Media from the page dict;
// naturalSize is recovered from the resolved Media (as for single .image/.video). displayWidth /
// alignment canonicalize to nil / .center (not representable — same as the single case). `.collage`
// is the mosaic-mode wire representation (see `.slideshow` below for the slideshow-mode twin).
let items = chatInputMediaItems(fromInnerBlocks: innerBlocks, media: media)
if !items.isEmpty {
result.append(.media(ChatInputMedia(items: items, displayWidth: nil, alignment: .center,
displayMode: .mosaic,
caption: chatInputRuns(fromRichText: caption.text))))
}
case let .slideshow(innerBlocks, caption):
// Mirror of `.collage` above — the ONLY difference is `displayMode: .slideshow`. Shares the inner
// .image/.video → [ChatInputMediaItem] resolution via `chatInputMediaItems(fromInnerBlocks:media:)`.
let items = chatInputMediaItems(fromInnerBlocks: innerBlocks, media: media)
if !items.isEmpty {
result.append(.media(ChatInputMedia(items: items, displayWidth: nil, alignment: .center,
displayMode: .slideshow,
caption: chatInputRuns(fromRichText: caption.text))))
}
case let .audio(id, caption):
// Resolve the concrete `TelegramMediaFile` from the page `media` dict (the forward always stores it).
// naturalSize/displayWidth/alignment restore the editor's media defaults, matching .image/.video.
// Music vs voice is intrinsic to the file (`.Audio(isVoice:)`), so a single `.audio` kind suffices.
if let media = media[id] {
result.append(.media(ChatInputMedia(media: media, kind: .audio, naturalSize: ChatInputSize(width: 0.0, height: 0.0), displayWidth: nil, alignment: .center, caption: chatInputRuns(fromRichText: caption.text))))
}
case let .document(id, caption):
// Mirror of `.audio`: resolve the concrete `TelegramMediaFile` from the page `media` dict (the
// forward always stores it). naturalSize/displayWidth/alignment restore the editor's media
// defaults; the caption rides the chat currency even though the editor renders none (a document
// is caption-less on screen — `MediaBlockBox` drops it at that boundary).
if let media = media[id] {
result.append(.media(ChatInputMedia(media: media, kind: .document, naturalSize: ChatInputSize(width: 0.0, height: 0.0), displayWidth: nil, alignment: .center, caption: chatInputRuns(fromRichText: caption.text))))
}
case let .map(latitude, longitude, _, _, caption):
// Reconstruct a `TelegramMediaMap` from the inline coordinates (no media-dict lookup — a `.map` block
// stores none). zoom/dimensions are render-only and dropped; venue is not in the block (the caption
// carried the title forward). naturalSize/displayWidth/alignment restore the editor's media defaults,
// matching the .image/.video canonicalization above.
let map = TelegramMediaMap(latitude: latitude, longitude: longitude, heading: nil, accuracyRadius: nil, venue: nil)
result.append(.media(ChatInputMedia(media: map, kind: .location, naturalSize: ChatInputSize(width: 0.0, height: 0.0), displayWidth: nil, alignment: .center, caption: chatInputRuns(fromRichText: caption.text))))
case let .table(_, rows, bordered, _, compact):
// Rebuild the `ChatInputTable`. Columns are inferred from the widest row's SPANNED cell count (each
// cell occupies `max(1, colspan)` grid columns, so a colspanning first cell no longer under-counts
// — a plain, no-span table still infers from the raw cell count, matching the prior behavior); column
// `width` is NOT representable in InstantPage cells → restored as the DEFAULT `0.0`. The table
// `title` and cell `background` are likewise not in the editor model → dropped/fixed (title unused;
// background `nil`). Per-cell H+V alignment, per-cell `header`, AND per-cell `colspan`/`rowspan` DO
// round-trip (restored onto each `ChatInputTableCell` below — no row-level derivation). `colspan`/
// `rowspan` are InstantPage `Int32` (`0`/`1` = no span) normalized back to the chat currency's `Int`
// (default 1): a span `> 1` is preserved, anything else canonicalizes to `1`. Identity therefore
// holds for a table whose columns use width `0.0` and whose cells use `background: nil` (the values
// the reverse yields).
let columnCount = rows.map { row in row.cells.reduce(0) { $0 + max(1, Int($1.colspan)) } }.max() ?? 0
let columns: [ChatInputColumnSpec] = Array(repeating: ChatInputColumnSpec(width: 0.0), count: columnCount)
let outRows = rows.map { row -> ChatInputTableRow in
let cells = row.cells.map { cell -> ChatInputTableCell in
let alignment: ChatInputTextAlignment
switch cell.alignment {
case .left: alignment = .left
case .center: alignment = .center
case .right: alignment = .right
}
let vAlignment: ChatInputTableVerticalAlignment
switch cell.verticalAlignment {
case .top: vAlignment = .top
case .middle: vAlignment = .middle
case .bottom: vAlignment = .bottom
}
return ChatInputTableCell(runs: chatInputRuns(fromRichText: cell.text ?? .empty), background: nil, horizontalAlignment: alignment, verticalAlignment: vAlignment, isHeader: cell.header, colspan: cell.colspan > 1 ? Int(cell.colspan) : 1, rowspan: cell.rowspan > 1 ? Int(cell.rowspan) : 1)
}
return ChatInputTableRow(height: nil, cells: cells)
}
result.append(.table(ChatInputTable(columns: columns, rows: outRows, compact: compact, bordered: bordered)))
case let .buttonRow(alignment, buttons):
result.append(.buttonRow(ChatInputButtonRow(
buttons: buttons.map {
ChatInputButton(label: chatInputRuns(fromRichText: $0.text), action: $0.action, color: $0.color, isLink: $0.isLink)
},
alignment: alignment
)))
default:
break // Non-text InstantPage blocks have no ChatInputContent representation (drafts never carry them).
}
}
return result
}
/// Top-level: split a paragraph's RichText into one run per concat part (no merging); `.empty` -> `[]` runs.
func chatInputRuns(fromRichText rt: RichText) -> [ChatInputRun] {
switch rt {
case .empty:
return []
case let .concat(parts):
return parts.flatMap { chatInputRuns(fromRichText: $0) } // parts are never .concat from forward; flatMap is defensive
default:
return [chatInputRun(fromSinglePart: rt, attributes: ChatInputInlineAttributes())]
}
}
/// Unwrap a wrapper chain into one run, accumulating attributes; leaves are `.plain` / `.textCustomEmoji`.
func chatInputRun(fromSinglePart rt: RichText, attributes: ChatInputInlineAttributes) -> ChatInputRun {
var attributes = attributes
switch rt {
case let .plain(s):
return ChatInputRun(text: s, attributes: attributes)
case let .formula(latex):
attributes.formula = latex
return ChatInputRun(text: latex, attributes: attributes)
case let .textCustomEmoji(fileId, alt):
// `enableAnimation` is canonicalized to `true` (and `file` to nil): `RichText.textCustomEmoji` carries
// neither. This MATCHES the production `.textEntities` draft path — `MessageTextEntityType.CustomEmoji` has
// no animation field either, so a draft already loses `enableAnimation` on save and re-derives it (default
// `true`) at decoration. So the round-trip is identity over the persisted fidelity, canonicalizing this
// render-time flag. (Pinned by `test_customEmoji_enableAnimationFalse`.)
attributes.entity = .customEmoji(fileId: fileId, file: nil, enableAnimation: true)
return ChatInputRun(text: alt, attributes: attributes)
case let .bold(inner):
attributes.bold = true
return chatInputRun(fromSinglePart: inner, attributes: attributes)
case let .italic(inner):
attributes.italic = true
return chatInputRun(fromSinglePart: inner, attributes: attributes)
case let .fixed(inner):
attributes.monospace = true
return chatInputRun(fromSinglePart: inner, attributes: attributes)
case let .strikethrough(inner):
attributes.strikethrough = true
return chatInputRun(fromSinglePart: inner, attributes: attributes)
case let .underline(inner):
attributes.underline = true
return chatInputRun(fromSinglePart: inner, attributes: attributes)
case let .textSpoiler(inner):
attributes.spoiler = true
return chatInputRun(fromSinglePart: inner, attributes: attributes)
case let .textMentionName(inner, peerId):
attributes.entity = .mention(EnginePeer.Id(peerId))
return chatInputRun(fromSinglePart: inner, attributes: attributes)
case let .url(inner, url, _):
attributes.entity = .url(url)
return chatInputRun(fromSinglePart: inner, attributes: attributes)
case let .textDate(inner, date, _):
attributes.entity = .date(date)
return chatInputRun(fromSinglePart: inner, attributes: attributes)
case .empty:
return ChatInputRun(text: "", attributes: attributes)
case let .textButton(button):
// A button is an atom: its run text is a bare `U+FFFC`, mirroring the custom-emoji and formula
// invariants. The label lives INSIDE the button, not in the run — falling through to the
// `default:` below would flatten the pill to its label text and destroy the button.
attributes.entity = .button(ChatInputButton(label: chatInputRuns(fromRichText: button.text), action: button.action, color: button.color, isLink: button.isLink))
return ChatInputRun(text: "\u{FFFC}", attributes: attributes)
default:
// Defensive for wire-only RichText cases that the forward never produces.
return ChatInputRun(text: rt.plainText, attributes: attributes)
}
}
private func standaloneFormulaLatex(from paragraph: ChatInputParagraph) -> String? {
guard paragraph.style == .body, paragraph.list == nil, paragraph.runs.count == 1 else {
return nil
}
guard let latex = paragraph.runs[0].attributes.formula, !latex.isEmpty else {
return nil
}
return latex
}