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