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

135 lines
5.3 KiB
Swift

import Foundation
import TelegramCore
import Postbox
import RichTextEditorCore
/// Serialize a RichTextEditor `Document` into a `ComposedRichMessage`.
///
/// `forSendPreview`: when set, a blockquote forces the rich (InstantPage) path even though it is otherwise
/// entity-expressible. The attachment-menu rich editor opts in so a quote is sent as a rich message and renders
/// through InstantPage; the default keeps quotes on the entity path (the composer / edit / send-options gates).
public func composeRichMessage(from document: Document, media: [String: Media] = [:], forSendPreview: Bool = false) -> ComposedRichMessage {
let blocks = trimmedTrailingEmpty(normalizedBlocks(document.blocks, media: media))
if blocks.isEmpty {
return .empty
}
if documentNeedsRichLayout(blocks, forSendPreview: forSendPreview) {
return .rich(instantPage: buildInstantPage(from: blocks, media: media))
} else {
let message = buildEntityMessage(from: blocks)
if message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return .empty
}
return .plain(text: message.text, entities: message.entities)
}
}
/// Pass paragraphs/tables through. Keep a media block whose `mediaID` resolves (it becomes an image/video
/// block downstream); for an UNresolvable media block, fall back to its caption as a body paragraph.
func normalizedBlocks(_ blocks: [Block], media: [String: Media]) -> [Block] {
var out: [Block] = []
for block in blocks {
switch block {
case .paragraph, .table, .code, .pullQuote, .buttonRow:
out.append(block)
case let .media(mediaBlock):
if media[mediaBlock.mediaID] != nil {
out.append(block)
} else if !mediaBlock.caption.isEmpty {
out.append(.paragraph(ParagraphBlock(id: mediaBlock.id, style: .body, runs: mediaBlock.caption)))
}
case .blockQuote:
// Pass through; children are resolved in downstream builders.
out.append(block)
case .details:
// Pass through; children are resolved in downstream builders (buildInstantPage recurses).
out.append(block)
}
}
return out
}
/// Trim trailing empty body paragraphs (e.g. the editor's blank end paragraph).
func trimmedTrailingEmpty(_ blocks: [Block]) -> [Block] {
var result = blocks
while let last = result.last,
case let .paragraph(paragraph) = last,
paragraph.list == nil,
paragraph.style == .body,
paragraph.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
result.removeLast()
}
return result
}
/// True when the content needs the rich (InstantPage) path: any table, heading/title, list item, or resolvable
/// media block. With `forSendPreview`, a blockquote also forces the rich path (see `composeRichMessage`).
func documentNeedsRichLayout(_ blocks: [Block], forSendPreview: Bool = false) -> Bool {
for block in blocks {
switch block {
case .table:
return true
case let .paragraph(paragraph):
if runsContainFormula(paragraph.runs) || runsContainButton(paragraph.runs) {
return true
}
if paragraph.list != nil {
return true
}
switch paragraph.style {
case .heading1, .heading2, .heading3, .heading4, .heading5, .heading6:
return true
case .body, .caption:
break
case .pullQuote:
// `pullQuote` is a render-only paragraph style; a `.paragraph` block with this style
// should not exist, but treat it as non-rich defensively (it is unreachable in practice).
break
}
case .media:
// normalizedBlocks already dropped unresolvable media, so any surviving .media block forces rich layout.
return true
case let .code(code):
// A code block is entity-expressible (.Pre) and does NOT force the rich path — it round-trips
// through the normal text+entities builder (buildEntityMessage).
if runsContainFormula(code.runs) || runsContainButton(code.runs) {
return true
}
break
case .pullQuote:
// A pull quote has no entity equivalent → always forces the rich path.
return true
case .blockQuote:
// A block quote has no entity form → always forces the rich path.
return true
case .details:
// A detail (folding) block has no entity form → always forces the rich path.
return true
case .buttonRow:
// A button has no entity form at all → always forces the rich path.
return true
}
}
return false
}
private func runsContainFormula(_ runs: [TextRun]) -> Bool {
for run in runs {
if run.attributes.formula != nil {
return true
}
}
return false
}
/// True when any run carries an inline button. A button has no message-entity form, so its presence
/// forces the rich (InstantPage) path exactly as a formula does.
private func runsContainButton(_ runs: [TextRun]) -> Bool {
for run in runs {
if run.attributes.button != nil {
return true
}
}
return false
}