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

266 lines
13 KiB
Swift

import Foundation
import Postbox
import TelegramCore
import RichTextEditorCore
/// Build a chat rich-message `InstantPage` from already-normalized blocks (media blocks resolved via the supplied map).
func buildInstantPage(from blocks: [Block], media: [String: Media]) -> InstantPage {
var pageBlocks: [InstantPageBlock] = []
var pageMedia: [MediaId: Media] = [:]
var index = 0
while index < blocks.count {
let block = blocks[index]
switch block {
case let .paragraph(paragraph):
if let latex = paragraph.standaloneFormulaLatex {
pageBlocks.append(.formula(latex: latex))
index += 1
} else if paragraph.list != nil {
var run: [ParagraphBlock] = []
while index < blocks.count, case let .paragraph(next) = blocks[index], next.list != nil {
run.append(next)
index += 1
}
pageBlocks.append(contentsOf: buildListBlocks(from: run[...]))
continue
} else {
pageBlocks.append(headingOrParagraphBlock(paragraph))
index += 1
}
case let .table(table):
pageBlocks.append(tableBlock(table))
index += 1
case let .code(code):
// Code blocks are entity-expressible (.Pre); in the rich (InstantPage) path they render as a
// preformatted block. Reached only when OTHER content (heading/list/table/media) forced rich layout.
pageBlocks.append(.preformatted(text: richText(from: code.runs), language: code.language))
index += 1
case let .pullQuote(pq):
pageBlocks.append(.pullQuote(text: richText(from: pq.runs), caption: authorCaption(pq.author, italic: true)))
index += 1
case let .media(mediaBlock):
let caption = InstantPageCaption(text: richText(from: mediaBlock.caption), credit: .empty)
if mediaBlock.items.count >= 2 {
// A multi-item media container → one `.collage` block wrapping an `.image`/`.video` per item.
// The container's own caption becomes the collage's caption; inner items carry no caption
// (mirrors `ChatInputContentInstantPage`, the composer's already-fixed forward converter).
var innerBlocks: [InstantPageBlock] = []
for item in mediaBlock.items {
guard let resolved = media[item.mediaID], let mediaId = resolved.id else { continue }
pageMedia[mediaId] = resolved // idempotent if the same mediaID appears in multiple blocks
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))
case .audio, .location, .document:
// Audio/location/document are permanently single-item; never grouped into a collage.
continue
}
}
switch mediaBlock.displayMode {
case .mosaic: pageBlocks.append(.collage(items: innerBlocks, caption: caption))
case .slideshow: pageBlocks.append(.slideshow(items: innerBlocks, caption: caption))
}
} else if let resolved = media[mediaBlock.mediaID] {
switch mediaBlock.kind {
case .image, .video, .audio, .document:
// Media.id is optional on the protocol; real TelegramMedia* image/video/audio/document
// always return non-nil.
if let mediaId = resolved.id {
pageMedia[mediaId] = resolved // idempotent if the same mediaID appears in multiple blocks
// The single-item convenience accessors (`mediaBlock.mediaID`/`.kind`) resolve to `items.first`;
// read the spoiler flag from the same source item (mirrors ChatInputContentInstantPage's single path).
let spoiler = mediaBlock.items.first?.isSpoiler ?? false
switch mediaBlock.kind {
case .image:
pageBlocks.append(.image(id: mediaId, caption: caption, url: nil, webpageId: nil, spoiler: spoiler))
case .audio:
// music & voice both → `.audio`; the file's `.Audio(isVoice:)` attribute drives the render.
pageBlocks.append(.audio(id: mediaId, caption: caption))
case .document:
// A caption-less block, so `caption` is always .empty here — the field is
// carried anyway because the wire block has one.
pageBlocks.append(.document(id: mediaId, caption: caption))
default:
pageBlocks.append(.video(id: mediaId, caption: caption, autoplay: false, loop: false, spoiler: spoiler))
}
}
case .location:
// A location resolves to a `TelegramMediaMap` (id-less); emit a `.map` block with the coordinates
// inline. No `pageMedia` entry (the block carries no MediaId). See ChatInputContentInstantPage.
if let map = resolved as? TelegramMediaMap {
let dimensions: PixelDimensions
if mediaBlock.naturalSize.width > 0, mediaBlock.naturalSize.height > 0 {
dimensions = PixelDimensions(width: Int32(mediaBlock.naturalSize.width), height: Int32(mediaBlock.naturalSize.height))
} else {
dimensions = PixelDimensions(width: 600, height: 300)
}
pageBlocks.append(.map(latitude: map.latitude, longitude: map.longitude, zoom: 15, dimensions: dimensions, caption: caption))
}
}
}
index += 1
case let .blockQuote(bq):
// A block quote → a structured InstantPage blockQuote. Recurse the children through the same
// builder; merge child media into the page-level dict so media inside a quote renders correctly.
let innerPage = buildInstantPage(from: bq.children, media: media)
for (id, m) in innerPage.media { pageMedia[id] = m }
pageBlocks.append(.blockQuote(blocks: innerPage.blocks, caption: authorCaption(bq.author), collapsed: bq.collapsed))
index += 1
case let .details(d):
// A detail (folding) block → an InstantPage `.details`. Recurse children through the same builder;
// merge child media into the page dict. The title becomes the details title RichText; `expanded`
// maps straight across (it is the inverse of a block-quote's `collapsed`).
let innerPage = buildInstantPage(from: d.children, media: media)
for (id, m) in innerPage.media { pageMedia[id] = m }
pageBlocks.append(.details(title: richText(from: d.title), blocks: innerPage.blocks, expanded: d.expanded))
index += 1
case let .buttonRow(row):
pageBlocks.append(.buttonRow(
alignment: instantPageRowAlignment(from: row.alignment),
buttons: row.buttons.map {
InstantPageButton(
text: richText(from: $0.label),
action: replyMarkupButtonAction(from: $0.action),
color: replyMarkupColor(from: $0.color),
isLink: $0.isLink
)
}
))
index += 1
}
}
return InstantPage(blocks: pageBlocks, media: pageMedia, isComplete: true, rtl: false, url: "", views: nil)
}
/// Emit a quote author as a bold (and, when `italic` is set — pull quotes only — ALSO italic) InstantPage
/// caption (both are ambient — not in the model — but must render that way on the recipient). Empty author
/// -> `.empty` (byte-identical to the prior output). Mirrors `authorCaptionRichText` in
/// `TelegramCore/Sources/ChatInputContent/ChatInputContentInstantPage.swift` (the composer's already-fixed
/// forward converter) using THIS module's `richText(from:)`.
private func authorCaption(_ author: [TextRun], italic: Bool = false) -> RichText {
richText(from: author.map { var r = $0; r.attributes.bold = true; if italic { r.attributes.italic = true }; return r })
}
/// A non-list, non-quote paragraph → a heading or a paragraph block.
private func headingOrParagraphBlock(_ paragraph: ParagraphBlock) -> InstantPageBlock {
if let latex = paragraph.standaloneFormulaLatex {
return .formula(latex: latex)
}
let text = richText(from: paragraph.runs)
switch paragraph.style {
case .heading1:
return .heading(text: text, level: 1)
case .heading2:
return .heading(text: text, level: 2)
case .heading3:
return .heading(text: text, level: 3)
case .heading4:
return .heading(text: text, level: 4)
case .heading5:
return .heading(text: text, level: 5)
case .heading6:
return .heading(text: text, level: 6)
case .body, .caption:
return .paragraph(text)
case .pullQuote:
// A `Block.paragraph` with `.pullQuote` style is render-only and unreachable here
// (pull quotes arrive as `Block.pullQuote`, handled by the `buildInstantPage` switch).
// Map defensively to an InstantPage pull-quote block.
return .pullQuote(text: text, caption: .empty)
}
}
/// Recursively build `.list` blocks from a consecutive run of list paragraphs.
/// Items at the same level + marker are siblings; deeper-level runs nest under the preceding item.
private func buildListBlocks(from paragraphs: ArraySlice<ParagraphBlock>) -> [InstantPageBlock] {
var result: [InstantPageBlock] = []
var index = paragraphs.startIndex
while index < paragraphs.endIndex {
let baseLevel = paragraphs[index].list?.level ?? 0
let baseMarker = paragraphs[index].list?.marker ?? .bullet
var items: [InstantPageListItem] = []
while index < paragraphs.endIndex,
(paragraphs[index].list?.level ?? 0) == baseLevel,
(paragraphs[index].list?.marker ?? .bullet) == baseMarker {
let text = richText(from: paragraphs[index].runs)
let checked = paragraphs[index].list?.checked
index += 1
let childStart = index
while index < paragraphs.endIndex, (paragraphs[index].list?.level ?? 0) > baseLevel {
index += 1
}
if index > childStart {
let nested = buildListBlocks(from: paragraphs[childStart ..< index])
items.append(.blocks([.paragraph(text)] + nested, nil, checked))
} else {
items.append(.text(text, nil, checked))
}
}
result.append(.list(items: items, ordered: baseMarker == .ordered))
}
return result
}
/// A table block → `.table`, mapping each cell's own per-cell H+V alignment, per-cell header flag, and
/// per-cell colspan/rowspan (editor `Int`, default 1, forwarded as InstantPage `Int32`).
private func tableBlock(_ table: TableBlock) -> InstantPageBlock {
let rows = table.rows.map { row -> InstantPageTableRow in
let cells = row.cells.map { cell -> InstantPageTableCell in
let alignment: TableHorizontalAlignment
switch cell.horizontalAlignment {
case .left, .justified, .natural:
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: cellRichText(cell),
header: cell.isHeader,
alignment: alignment,
verticalAlignment: vAlignment,
colspan: Int32(cell.colspan),
rowspan: Int32(cell.rowspan)
)
}
return InstantPageTableRow(cells: cells)
}
return .table(title: .empty, rows: rows, bordered: table.bordered, striped: false, compact: table.compact)
}
/// Concatenate a cell's paragraph blocks into one `RichText` (newline-joined). Images in cells dropped.
private func cellRichText(_ cell: Cell) -> RichText {
let paragraphs = cell.blocks.compactMap { block -> RichText? in
if case let .paragraph(paragraph) = block {
return richText(from: paragraph.runs)
}
return nil
}
if paragraphs.isEmpty {
return .empty
}
if paragraphs.count == 1 {
return paragraphs[0]
}
var joined: [RichText] = []
for (offset, text) in paragraphs.enumerated() {
if offset > 0 {
joined.append(.plain("\n"))
}
joined.append(text)
}
return .concat(joined)
}