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