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>
1266 lines
57 KiB
Swift
1266 lines
57 KiB
Swift
import Foundation
|
|
import Postbox
|
|
|
|
public struct ChatInputRun: Equatable, Codable {
|
|
public var text: String
|
|
public var attributes: ChatInputInlineAttributes
|
|
public init(text: String, attributes: ChatInputInlineAttributes = ChatInputInlineAttributes()) {
|
|
self.text = text
|
|
self.attributes = attributes
|
|
}
|
|
}
|
|
|
|
public struct ChatInputInlineAttributes: Equatable, Codable {
|
|
public var bold: Bool
|
|
public var italic: Bool
|
|
public var monospace: Bool
|
|
public var strikethrough: Bool
|
|
public var underline: Bool
|
|
public var spoiler: Bool
|
|
public var formula: String?
|
|
public var entity: ChatInputInlineEntity?
|
|
public init(
|
|
bold: Bool = false,
|
|
italic: Bool = false,
|
|
monospace: Bool = false,
|
|
strikethrough: Bool = false,
|
|
underline: Bool = false,
|
|
spoiler: Bool = false,
|
|
formula: String? = nil,
|
|
entity: ChatInputInlineEntity? = nil
|
|
) {
|
|
self.bold = bold
|
|
self.italic = italic
|
|
self.monospace = monospace
|
|
self.strikethrough = strikethrough
|
|
self.underline = underline
|
|
self.spoiler = spoiler
|
|
self.formula = formula
|
|
self.entity = entity
|
|
}
|
|
}
|
|
|
|
public enum ChatInputInlineEntity: Equatable, Codable {
|
|
case mention(EnginePeer.Id)
|
|
case url(String)
|
|
case date(Int32)
|
|
case customEmoji(fileId: Int64, file: TelegramMediaFile?, enableAnimation: Bool)
|
|
/// An inline `RichText.textButton`. The carrying run's text MUST be exactly one `U+FFFC`.
|
|
case button(ChatInputButton)
|
|
|
|
public static func == (lhs: ChatInputInlineEntity, rhs: ChatInputInlineEntity) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case let (.mention(a), .mention(b)): return a == b
|
|
case let (.url(a), .url(b)): return a == b
|
|
case let (.date(a), .date(b)): return a == b
|
|
// `file` is a heavy media reference that is reconstructed from a side cache (and dropped by
|
|
// the canonical ChatTextInputStateText form); identity is the fileId + animation flag.
|
|
case let (.customEmoji(aId, _, aAnim), .customEmoji(bId, _, bAnim)): return aId == bId && aAnim == bAnim
|
|
case let (.button(a), .button(b)): return a == b
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case kind
|
|
case peerId
|
|
case url
|
|
case date
|
|
case fileId
|
|
case enableAnimation
|
|
case button
|
|
}
|
|
|
|
private enum Kind: Int32, Codable {
|
|
case mention
|
|
case url
|
|
case date
|
|
case customEmoji
|
|
case button
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
// Decode the discriminator as the raw `Int32` (NOT `Kind.self`): the Postbox `AdaptedPostbox*coder` does
|
|
// not support the `singleValueContainer` that a `RawRepresentable` enum's synthesized Codable uses.
|
|
let kindValue = try container.decode(Int32.self, forKey: .kind)
|
|
guard let kind = Kind(rawValue: kindValue) else {
|
|
throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown discriminator \(kindValue)")
|
|
}
|
|
switch kind {
|
|
case .mention:
|
|
let peerId = try container.decode(Int64.self, forKey: .peerId)
|
|
self = .mention(EnginePeer.Id(peerId))
|
|
case .url:
|
|
self = .url(try container.decode(String.self, forKey: .url))
|
|
case .date:
|
|
self = .date(try container.decode(Int32.self, forKey: .date))
|
|
case .customEmoji:
|
|
let fileId = try container.decode(Int64.self, forKey: .fileId)
|
|
let enableAnimation = try container.decode(Bool.self, forKey: .enableAnimation)
|
|
// `file` is not persisted (reconstructed from a side cache); decode as nil.
|
|
self = .customEmoji(fileId: fileId, file: nil, enableAnimation: enableAnimation)
|
|
case .button:
|
|
self = .button(try container.decode(ChatInputButton.self, forKey: .button))
|
|
}
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
switch self {
|
|
case let .mention(peerId):
|
|
try container.encode(Kind.mention.rawValue, forKey: .kind)
|
|
// Deliberately a flat Int64 (not `encode(peerId)`): `EnginePeer.Id`/`PeerId` is itself Codable but wraps
|
|
// the value in a nested `{internalValue:}` container — the flat form is the leaner persisted shape and
|
|
// round-trips exactly (`PeerId(toInt64()) == self`). Keep flat; do not "simplify" to `encode(peerId)`.
|
|
try container.encode(peerId.toInt64(), forKey: .peerId)
|
|
case let .url(value):
|
|
try container.encode(Kind.url.rawValue, forKey: .kind)
|
|
try container.encode(value, forKey: .url)
|
|
case let .date(value):
|
|
try container.encode(Kind.date.rawValue, forKey: .kind)
|
|
try container.encode(value, forKey: .date)
|
|
case let .customEmoji(fileId, _, enableAnimation):
|
|
try container.encode(Kind.customEmoji.rawValue, forKey: .kind)
|
|
// `file` is intentionally not persisted; only fileId + enableAnimation are encoded.
|
|
try container.encode(fileId, forKey: .fileId)
|
|
try container.encode(enableAnimation, forKey: .enableAnimation)
|
|
case let .button(button):
|
|
try container.encode(Kind.button.rawValue, forKey: .kind)
|
|
try container.encode(button, forKey: .button)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Used by ChatInputContent's lenient [ChatInputBlock] decode: each element in the objectArray is
|
|
// decoded by a fresh AdaptedPostboxDecoder, so catching the error per-element is safe and does not
|
|
// corrupt the array cursor. Unknown / removed block kinds (e.g. old "collapsedQuote" rawValue 2)
|
|
// produce block = nil, which the outer init filters out with compactMap.
|
|
private struct _LenientChatInputBlock: Decodable {
|
|
let block: ChatInputBlock?
|
|
init(from decoder: Decoder) throws {
|
|
block = try? ChatInputBlock(from: decoder)
|
|
}
|
|
}
|
|
|
|
public struct ChatInputContent: Equatable {
|
|
public var schemaVersion: Int32
|
|
public var blocks: [ChatInputBlock]
|
|
public init(schemaVersion: Int32 = 1, blocks: [ChatInputBlock] = []) {
|
|
self.schemaVersion = schemaVersion
|
|
self.blocks = blocks
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case schemaVersion
|
|
case blocks
|
|
}
|
|
|
|
/// The flat plain text, identical to `attributedString(from: self).string`: the flat-axis blocks joined by a
|
|
/// single "\n", a paragraph/code block contributing its runs' text and a `.blockQuote(collapsed: true)`
|
|
/// contributing one " " placeholder. A `.media`/`.table` block is OFF the flat axis — it contributes
|
|
/// no character AND no separator (matching `attributedString(from:)`, which drops them, and the editor's
|
|
/// `composerParagraphs()`, which skips them); a non-text block has no flat-text/caret representation. Lets
|
|
/// `inputText`-string readers move to the model — a round-trip-parity test guards the equivalence.
|
|
public var plainText: String {
|
|
var result = ""
|
|
var hasEmitted = false
|
|
for block in self.blocks {
|
|
switch block {
|
|
case let .paragraph(paragraph):
|
|
if hasEmitted { result.append("\n") }
|
|
hasEmitted = true
|
|
result.append(paragraph.text)
|
|
case let .code(code):
|
|
if hasEmitted { result.append("\n") }
|
|
hasEmitted = true
|
|
result.append(code.text)
|
|
case let .pullQuote(pq):
|
|
if hasEmitted { result.append("\n") }
|
|
hasEmitted = true
|
|
result.append(pq.text)
|
|
case let .blockQuote(bq):
|
|
if hasEmitted { result.append("\n") }
|
|
hasEmitted = true
|
|
if bq.collapsed {
|
|
result.append(" ")
|
|
} else {
|
|
result.append(bq.content.plainText)
|
|
}
|
|
case .media, .table, .details, .buttonRow:
|
|
// Off the flat axis: no character AND no separator (see `blockFlatLength`). Matches
|
|
// `attributedString(from:)` (drops them) and the editor's `composerParagraphs()` (skips them).
|
|
// A detail block round-trips via the structured Codable + InstantPage path, not flat text.
|
|
break
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// The UTF-16 length, identical to `attributedString(from: self).length` (a custom emoji is one U+FFFC unit).
|
|
public var length: Int {
|
|
return (self.plainText as NSString).length
|
|
}
|
|
|
|
/// Whether the composer has NO content — cheaper than `plainText.isEmpty` (it never builds the string).
|
|
/// Matches `plainText.isEmpty` / `length == 0` for text content, but is **content-aware, not flat-text-aware**:
|
|
/// a single `.media`/`.table` block has empty flat text yet is NOT empty (a medium/table IS content), so
|
|
/// `isEmpty == false` while `plainText.isEmpty == true` there. Empty iff there are no blocks, or exactly one
|
|
/// block whose text is empty (2+ blocks always carry an inter-block "\n"; a `.media`/`.table`/`.blockQuote`
|
|
/// is non-empty).
|
|
public var isEmpty: Bool {
|
|
guard self.blocks.count <= 1 else {
|
|
return false
|
|
}
|
|
guard let block = self.blocks.first else {
|
|
return true
|
|
}
|
|
switch block {
|
|
case let .paragraph(paragraph):
|
|
return paragraph.text.isEmpty
|
|
case let .code(code):
|
|
return code.text.isEmpty
|
|
case .media:
|
|
return false
|
|
case .table:
|
|
return false
|
|
case let .pullQuote(pq):
|
|
return pq.text.isEmpty
|
|
case .blockQuote:
|
|
return false
|
|
case .details:
|
|
return false
|
|
case .buttonRow:
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Like `isEmpty`, but a block whose text is only whitespace/newlines counts as empty. Unlike `isEmpty`
|
|
/// this is NOT short-circuited on block COUNT: several blank paragraphs are still "empty" here (their only
|
|
/// content is inter-block newlines), whereas `isEmpty` treats 2+ blocks as non-empty. A `.media`/`.table`/
|
|
/// `.blockQuote` block is still content (never whitespace-empty). Used by the composer's expand-button
|
|
/// affordance, which must stay hidden for a field that is tall only because of blank lines.
|
|
public var isEmptyWhitespaceTrimmed: Bool {
|
|
return self.blocks.allSatisfy { block in
|
|
switch block {
|
|
case let .paragraph(paragraph):
|
|
return paragraph.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
case let .code(code):
|
|
return code.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
case let .pullQuote(pq):
|
|
return pq.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
case .media, .table, .blockQuote, .details, .buttonRow:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Options that tune which blocks `isEntityExpressible(options:)` accepts on the plain text + entities path.
|
|
public struct EntityExpressibleOptions: OptionSet {
|
|
public let rawValue: Int32
|
|
public init(rawValue: Int32) {
|
|
self.rawValue = rawValue
|
|
}
|
|
|
|
/// Treat a blockquote as NOT entity-expressible, so quote-bearing content is routed onto the rich
|
|
/// (InstantPage) path even though message entities could represent it.
|
|
/// The default (no options) keeps a flat non-collapsed single-paragraph blockquote entity-expressible.
|
|
public static let quotesRequireRichContent = EntityExpressibleOptions(rawValue: 1 << 0)
|
|
}
|
|
|
|
/// Whether this content can be represented as plain text + message entities (the `.textEntities`
|
|
/// branch) rather than requiring a structured `InstantPage` (the `.instantPage` branch). True for
|
|
/// every block today; the deliberate switch with no `default` forces a compile decision the day a
|
|
/// non-entity-expressible block type is introduced.
|
|
///
|
|
/// `options` narrows what counts as entity-expressible — see `EntityExpressibleOptions`.
|
|
public func isEntityExpressible(options: EntityExpressibleOptions = []) -> Bool {
|
|
return self.blocks.allSatisfy { block in
|
|
switch block {
|
|
case let .paragraph(paragraph):
|
|
if chatInputRunsContainFormula(paragraph.runs) {
|
|
return false
|
|
}
|
|
if chatInputRunsContainButton(paragraph.runs) {
|
|
return false
|
|
}
|
|
// A heading or a list paragraph carries structure the message-entity set can't express.
|
|
if paragraph.list != nil {
|
|
return false
|
|
}
|
|
switch paragraph.style {
|
|
case .body:
|
|
return true
|
|
case .heading1, .heading2, .heading3:
|
|
return false
|
|
}
|
|
case let .code(code):
|
|
return !chatInputRunsContainFormula(code.runs) && !chatInputRunsContainButton(code.runs)
|
|
case .media: return false
|
|
case .table: return false
|
|
case .pullQuote:
|
|
return false
|
|
case let .blockQuote(bq):
|
|
if !bq.author.isEmpty { return false }
|
|
// FLAT-ONLY rule: expressible only when the quote is non-collapsed AND every child block is
|
|
// a plain body paragraph with no list membership (the message-entity blockquote can represent
|
|
// exactly one level of flat-text quote).
|
|
if options.contains(.quotesRequireRichContent) { return false }
|
|
guard !bq.collapsed else { return false }
|
|
if !bq.content.isEntityExpressible() {
|
|
return false
|
|
}
|
|
return bq.content.blocks.allSatisfy {
|
|
if case let .paragraph(p) = $0, case .body = p.style, p.list == nil { return true }
|
|
return false
|
|
}
|
|
case .details:
|
|
// A detail (folding) block carries structure the message-entity set can't express → rich path.
|
|
return false
|
|
case .buttonRow:
|
|
// A button has no message-entity form at all → rich path. LOAD-BEARING: returning true
|
|
// here would send the message as plain text + entities and destroy the buttons.
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func chatInputRunsContainFormula(_ runs: [ChatInputRun]) -> Bool {
|
|
for run in runs {
|
|
if run.attributes.formula != nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// True when any run carries an inline button entity. A button has no message-entity form, so its
|
|
/// presence forces the rich `.instantPage` send path exactly as a formula does.
|
|
private func chatInputRunsContainButton(_ runs: [ChatInputRun]) -> Bool {
|
|
for run in runs {
|
|
if case .button = run.attributes.entity {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
extension ChatInputContent: Codable {
|
|
public init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
schemaVersion = (try? c.decode(Int32.self, forKey: .schemaVersion)) ?? 1
|
|
// Lenient [ChatInputBlock] decode: the AdaptedPostbox objectArray decodes each element via a fresh
|
|
// child decoder, so wrapping in _LenientChatInputBlock catches per-element failures without
|
|
// corrupting the array cursor. Unknown/removed kinds (e.g. old "collapsedQuote" rawValue 2)
|
|
// decode to block == nil and are filtered out. Mirrors Document's lenient block decode.
|
|
let lenient = try c.decode([_LenientChatInputBlock].self, forKey: .blocks)
|
|
blocks = lenient.compactMap(\.block)
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
|
try c.encode(schemaVersion, forKey: .schemaVersion)
|
|
try c.encode(blocks, forKey: .blocks)
|
|
}
|
|
}
|
|
|
|
/// A structured blockquote block carrying arbitrary nested `ChatInputContent`.
|
|
/// Mirrors `InstantPage.blockQuote`; the `collapsed` flag maps 1:1 to the wire field.
|
|
/// `collapsed == true` is the folded (hidden) state (one " " placeholder on the flat axis);
|
|
/// `collapsed == false` is the visible expanded state (inner content on the flat axis).
|
|
public struct ChatInputBlockQuote: Equatable {
|
|
public var content: ChatInputContent
|
|
public var collapsed: Bool
|
|
/// Optional attribution ("author") line. `[]` = empty. Off the flat axis (never in plainText). Per-node.
|
|
public var author: [ChatInputRun]
|
|
public init(content: ChatInputContent, collapsed: Bool, author: [ChatInputRun] = []) {
|
|
self.content = content
|
|
self.collapsed = collapsed
|
|
self.author = author
|
|
}
|
|
}
|
|
|
|
extension ChatInputBlockQuote: Codable {
|
|
private enum CodingKeys: String, CodingKey { case content, collapsed, author }
|
|
public init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.content = try c.decode(ChatInputContent.self, forKey: .content)
|
|
self.collapsed = try c.decode(Bool.self, forKey: .collapsed)
|
|
self.author = try c.decodeIfPresent([ChatInputRun].self, forKey: .author) ?? []
|
|
}
|
|
public func encode(to encoder: Encoder) throws {
|
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
|
try c.encode(content, forKey: .content)
|
|
try c.encode(collapsed, forKey: .collapsed)
|
|
try c.encode(author, forKey: .author)
|
|
}
|
|
}
|
|
|
|
/// A structured detail (folding) block carrying an editable title (summary) + arbitrary nested content.
|
|
/// Mirrors `InstantPage.details`; `expanded` maps 1:1 to the wire field (the inverse of a blockQuote's
|
|
/// `collapsed`). Off the flat caret axis (like `.media`/`.table`) — it round-trips via this structured
|
|
/// Codable + the InstantPage path, not via flat text.
|
|
public struct ChatInputDetails: Equatable {
|
|
public var content: ChatInputContent
|
|
public var title: [ChatInputRun]
|
|
public var expanded: Bool
|
|
public init(content: ChatInputContent, title: [ChatInputRun] = [], expanded: Bool = true) {
|
|
self.content = content
|
|
self.title = title
|
|
self.expanded = expanded
|
|
}
|
|
}
|
|
|
|
extension ChatInputDetails: Codable {
|
|
private enum CodingKeys: String, CodingKey { case content, title, expanded }
|
|
public init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.content = try c.decode(ChatInputContent.self, forKey: .content)
|
|
self.title = try c.decodeIfPresent([ChatInputRun].self, forKey: .title) ?? []
|
|
self.expanded = try c.decodeIfPresent(Bool.self, forKey: .expanded) ?? true
|
|
}
|
|
public func encode(to encoder: Encoder) throws {
|
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
|
try c.encode(content, forKey: .content)
|
|
try c.encode(title, forKey: .title)
|
|
try c.encode(expanded, forKey: .expanded)
|
|
}
|
|
}
|
|
|
|
public enum ChatInputBlock: Equatable, Codable {
|
|
case paragraph(ChatInputParagraph)
|
|
case code(ChatInputCode)
|
|
// rawValue 2 (old "collapsedQuote") is permanently retired. Old drafts containing kind=2 are
|
|
// lenient-skipped by ChatInputContent.init(from:). Do NOT reuse rawValue 2.
|
|
/// An attached medium (image or video). Mirrors the editor `Block.media`; one placeholder in the flat text.
|
|
case media(ChatInputMedia)
|
|
/// A table. Mirrors the editor `Block.table`; one placeholder in the flat text (cell content is off-string).
|
|
case table(ChatInputTable)
|
|
/// A pull-quote block. Mirrors the editor `Block.pullQuote`. Always non-entity-expressible (forces the
|
|
/// rich InstantPage path). On the flat axis — contributes its runs' text and an inter-block "\n" separator.
|
|
case pullQuote(ChatInputPullQuote)
|
|
/// A structured blockquote block carrying arbitrary nested content. Mirrors `InstantPage.blockQuote`.
|
|
/// Collapsed → 1 " " placeholder on the flat axis; expanded → inner content on the flat axis.
|
|
case blockQuote(ChatInputBlockQuote)
|
|
case details(ChatInputDetails)
|
|
case buttonRow(ChatInputButtonRow)
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case kind
|
|
case paragraph
|
|
case code
|
|
case media
|
|
case table
|
|
case pullQuote
|
|
case blockQuote
|
|
case details
|
|
case buttonRow
|
|
}
|
|
|
|
private enum Kind: Int32, Codable {
|
|
case paragraph = 0 // NEVER renumber: these are the persisted-draft discriminators.
|
|
case code = 1
|
|
// 2 was collapsedQuote — retired. Kept as a gap so downstream rawValues are stable.
|
|
case media = 3
|
|
case table = 4
|
|
case pullQuote = 5
|
|
case blockQuote = 6
|
|
case details = 7
|
|
// NEVER renumber. 8 is the next free value: 2 is a deliberate gap (retired collapsedQuote).
|
|
case buttonRow = 8
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
// Decode the discriminator as the raw `Int32` (NOT `Kind.self`): the Postbox `AdaptedPostbox*coder` does
|
|
// not support the `singleValueContainer` that a `RawRepresentable` enum's synthesized Codable uses.
|
|
let kindValue = try container.decode(Int32.self, forKey: .kind)
|
|
guard let kind = Kind(rawValue: kindValue) else {
|
|
// Unknown / removed kind (e.g. old "collapsedQuote" rawValue 2). Throw so that
|
|
// ChatInputContent's lenient unkeyed-container loop can skip this block cleanly.
|
|
throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown discriminator \(kindValue)")
|
|
}
|
|
switch kind {
|
|
case .paragraph:
|
|
self = .paragraph(try container.decode(ChatInputParagraph.self, forKey: .paragraph))
|
|
case .code:
|
|
self = .code(try container.decode(ChatInputCode.self, forKey: .code))
|
|
case .media:
|
|
self = .media(try container.decode(ChatInputMedia.self, forKey: .media))
|
|
case .table:
|
|
self = .table(try container.decode(ChatInputTable.self, forKey: .table))
|
|
case .pullQuote:
|
|
self = .pullQuote(try container.decode(ChatInputPullQuote.self, forKey: .pullQuote))
|
|
case .blockQuote:
|
|
self = .blockQuote(try container.decode(ChatInputBlockQuote.self, forKey: .blockQuote))
|
|
case .details:
|
|
self = .details(try container.decode(ChatInputDetails.self, forKey: .details))
|
|
case .buttonRow:
|
|
self = .buttonRow(try container.decode(ChatInputButtonRow.self, forKey: .buttonRow))
|
|
}
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
switch self {
|
|
case let .paragraph(paragraph):
|
|
try container.encode(Kind.paragraph.rawValue, forKey: .kind)
|
|
try container.encode(paragraph, forKey: .paragraph)
|
|
case let .code(code):
|
|
try container.encode(Kind.code.rawValue, forKey: .kind)
|
|
try container.encode(code, forKey: .code)
|
|
case let .media(media):
|
|
try container.encode(Kind.media.rawValue, forKey: .kind)
|
|
try container.encode(media, forKey: .media)
|
|
case let .table(table):
|
|
try container.encode(Kind.table.rawValue, forKey: .kind)
|
|
try container.encode(table, forKey: .table)
|
|
case let .pullQuote(pq):
|
|
try container.encode(Kind.pullQuote.rawValue, forKey: .kind)
|
|
try container.encode(pq, forKey: .pullQuote)
|
|
case let .blockQuote(bq):
|
|
try container.encode(Kind.blockQuote.rawValue, forKey: .kind)
|
|
try container.encode(bq, forKey: .blockQuote)
|
|
case let .details(d):
|
|
try container.encode(Kind.details.rawValue, forKey: .kind)
|
|
try container.encode(d, forKey: .details)
|
|
case let .buttonRow(row):
|
|
try container.encode(Kind.buttonRow.rawValue, forKey: .kind)
|
|
try container.encode(row, forKey: .buttonRow)
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct ChatInputParagraph: Equatable, Codable {
|
|
public var style: ChatInputParagraphStyle
|
|
/// List membership (bullet/ordered + indent level), nil for a non-list paragraph. Mirrors the editor
|
|
/// `ParagraphBlock.list`.
|
|
public var list: ChatInputListMembership?
|
|
public var runs: [ChatInputRun]
|
|
public init(style: ChatInputParagraphStyle = .body, list: ChatInputListMembership? = nil, runs: [ChatInputRun] = []) {
|
|
self.style = style
|
|
self.list = list
|
|
self.runs = runs
|
|
}
|
|
public var text: String { runs.map(\.text).joined() }
|
|
}
|
|
|
|
public enum ChatInputParagraphStyle: Equatable, Codable {
|
|
case body
|
|
// rawValue 1 (old "quote") is permanently retired. Old drafts decode this as .body (lenient).
|
|
case heading1
|
|
case heading2
|
|
case heading3
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case kind
|
|
}
|
|
|
|
private enum Kind: Int32, Codable {
|
|
case body = 0
|
|
// 1 was quote — retired; lenient decode falls back to .body for any unknown kind.
|
|
case heading1 = 2
|
|
case heading2 = 3
|
|
case heading3 = 4
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
// Decode the discriminator as the raw `Int32` (NOT `Kind.self`): the Postbox `AdaptedPostbox*coder` does
|
|
// not support the `singleValueContainer` that a `RawRepresentable` enum's synthesized Codable uses.
|
|
let kindValue = try container.decode(Int32.self, forKey: .kind)
|
|
guard let kind = Kind(rawValue: kindValue) else {
|
|
// Unknown / removed kind (e.g. old "quote" rawValue 1) — degrade to .body.
|
|
self = .body
|
|
return
|
|
}
|
|
switch kind {
|
|
case .body:
|
|
self = .body
|
|
case .heading1:
|
|
self = .heading1
|
|
case .heading2:
|
|
self = .heading2
|
|
case .heading3:
|
|
self = .heading3
|
|
}
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
switch self {
|
|
case .body:
|
|
try container.encode(Kind.body.rawValue, forKey: .kind)
|
|
case .heading1:
|
|
try container.encode(Kind.heading1.rawValue, forKey: .kind)
|
|
case .heading2:
|
|
try container.encode(Kind.heading2.rawValue, forKey: .kind)
|
|
case .heading3:
|
|
try container.encode(Kind.heading3.rawValue, forKey: .kind)
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct ChatInputCode: Equatable, Codable {
|
|
public var language: String?
|
|
public var runs: [ChatInputRun]
|
|
public init(language: String? = nil, runs: [ChatInputRun] = []) {
|
|
self.language = language
|
|
self.runs = runs
|
|
}
|
|
public var text: String { runs.map(\.text).joined() }
|
|
}
|
|
|
|
public struct ChatInputPullQuote: Equatable {
|
|
public var runs: [ChatInputRun]
|
|
/// Optional attribution ("author") line. `[]` = empty. Off the flat axis (never in plainText).
|
|
public var author: [ChatInputRun]
|
|
public init(runs: [ChatInputRun] = [], author: [ChatInputRun] = []) { self.runs = runs; self.author = author }
|
|
public var text: String { runs.map(\.text).joined() }
|
|
}
|
|
|
|
extension ChatInputPullQuote: Codable {
|
|
private enum CodingKeys: String, CodingKey { case runs, author }
|
|
public init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.runs = try c.decode([ChatInputRun].self, forKey: .runs)
|
|
self.author = try c.decodeIfPresent([ChatInputRun].self, forKey: .author) ?? []
|
|
}
|
|
public func encode(to encoder: Encoder) throws {
|
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
|
try c.encode(runs, forKey: .runs)
|
|
try c.encode(author, forKey: .author)
|
|
}
|
|
}
|
|
|
|
// MARK: - Structural value types (Document-parity)
|
|
//
|
|
// Each raw enum below carries a CUSTOM keyed `Codable` that encodes/decodes its `.rawValue` as an `Int32`
|
|
// via a keyed container — never the synthesized `RawRepresentable` Codable, which uses a `singleValueContainer`
|
|
// the Postbox `AdaptedPostbox*coder` does not support (it crashes at runtime). The dependent structs below can
|
|
// then synthesize `Codable`, because every member is itself Postbox-safe.
|
|
|
|
/// A list marker style. Mirrors the editor list marker.
|
|
public enum ChatInputListMarker: Int32, Equatable, Codable {
|
|
case bullet = 0
|
|
case ordered = 1
|
|
case checklist = 2
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case raw
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
let value = try container.decode(Int32.self, forKey: .raw)
|
|
guard let v = ChatInputListMarker(rawValue: value) else {
|
|
throw DecodingError.dataCorruptedError(forKey: .raw, in: container, debugDescription: "Unknown ChatInputListMarker \(value)")
|
|
}
|
|
self = v
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(self.rawValue, forKey: .raw)
|
|
}
|
|
}
|
|
|
|
/// List membership for a paragraph: marker style + 0-based indent level + optional checked state (checklist
|
|
/// items only). Mirrors the editor `ListMembership`.
|
|
public struct ChatInputListMembership: Equatable, Codable {
|
|
public var marker: ChatInputListMarker
|
|
public var level: Int32
|
|
/// Whether this checklist item is checked. `nil` for bullet/ordered list items (marker ≠ `.checklist`).
|
|
/// Synthesized `Codable` uses `encodeIfPresent`/`decodeIfPresent` for optional fields: a `nil` value is
|
|
/// omitted on encode and an absent key decodes to `nil` — old payloads (no `checked` key) are back-compat.
|
|
public var checked: Bool?
|
|
public init(marker: ChatInputListMarker, level: Int32, checked: Bool? = nil) {
|
|
self.marker = marker
|
|
self.level = level
|
|
self.checked = checked
|
|
}
|
|
}
|
|
|
|
/// The medium's kind (image, video, location, audio, or document). Mirrors the editor `MediaKind`.
|
|
///
|
|
/// NOTE: `init(from:)` below throws on an unknown raw value, so an OLDER build decoding a
|
|
/// cross-device-synced draft containing `.document` (raw 4) fails that draft's decode. Accepted, and
|
|
/// consistent with the precedent set when `ChatInputListMarker.checklist` was added.
|
|
public enum ChatInputMediaKind: Int32, Equatable, Codable {
|
|
case image = 0
|
|
case video = 1
|
|
case location = 2
|
|
case audio = 3
|
|
case document = 4
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case raw
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
let value = try container.decode(Int32.self, forKey: .raw)
|
|
guard let v = ChatInputMediaKind(rawValue: value) else {
|
|
throw DecodingError.dataCorruptedError(forKey: .raw, in: container, debugDescription: "Unknown ChatInputMediaKind \(value)")
|
|
}
|
|
self = v
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(self.rawValue, forKey: .raw)
|
|
}
|
|
}
|
|
|
|
/// Horizontal alignment of a medium. Mirrors the editor `MediaAlignment`.
|
|
public enum ChatInputMediaAlignment: Int32, Equatable, Codable {
|
|
case left = 0
|
|
case center = 1
|
|
case right = 2
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case raw
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
let value = try container.decode(Int32.self, forKey: .raw)
|
|
guard let v = ChatInputMediaAlignment(rawValue: value) else {
|
|
throw DecodingError.dataCorruptedError(forKey: .raw, in: container, debugDescription: "Unknown ChatInputMediaAlignment \(value)")
|
|
}
|
|
self = v
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(self.rawValue, forKey: .raw)
|
|
}
|
|
}
|
|
|
|
/// Per-cell text alignment for a table cell. Mirrors the editor `TextAlignment` (markdown delimiter-row colons).
|
|
public enum ChatInputTextAlignment: Int32, Equatable, Codable {
|
|
case left = 0
|
|
case center = 1
|
|
case right = 2
|
|
case justified = 3
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case raw
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
let value = try container.decode(Int32.self, forKey: .raw)
|
|
guard let v = ChatInputTextAlignment(rawValue: value) else {
|
|
throw DecodingError.dataCorruptedError(forKey: .raw, in: container, debugDescription: "Unknown ChatInputTextAlignment \(value)")
|
|
}
|
|
self = v
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(self.rawValue, forKey: .raw)
|
|
}
|
|
}
|
|
|
|
/// Per-cell vertical alignment for a table. Mirrors the editor's cell vertical-alignment setting.
|
|
public enum ChatInputTableVerticalAlignment: Int32, Equatable, Codable {
|
|
case top = 0
|
|
case middle = 1
|
|
case bottom = 2
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case raw
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
let value = try container.decode(Int32.self, forKey: .raw)
|
|
guard let v = ChatInputTableVerticalAlignment(rawValue: value) else {
|
|
throw DecodingError.dataCorruptedError(forKey: .raw, in: container, debugDescription: "Unknown ChatInputTableVerticalAlignment \(value)")
|
|
}
|
|
self = v
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(self.rawValue, forKey: .raw)
|
|
}
|
|
}
|
|
|
|
/// A 2D size in points. Synthesized `Codable` (both members are `Double`).
|
|
public struct ChatInputSize: Equatable, Codable {
|
|
public var width: Double
|
|
public var height: Double
|
|
public init(width: Double, height: Double) {
|
|
self.width = width
|
|
self.height = height
|
|
}
|
|
}
|
|
|
|
/// An RGBA color (0...1 channels). Synthesized `Codable` (all members are `Double`).
|
|
public struct ChatInputColor: Equatable, Codable {
|
|
public var red: Double
|
|
public var green: Double
|
|
public var blue: Double
|
|
public var alpha: Double
|
|
public init(red: Double, green: Double, blue: Double, alpha: Double) {
|
|
self.red = red
|
|
self.green = green
|
|
self.blue = blue
|
|
self.alpha = alpha
|
|
}
|
|
}
|
|
|
|
/// One medium (image or video) inside a `ChatInputMedia` container. Carries a concrete `Media` (resolved from
|
|
/// the editor's opaque `mediaID` at conversion time). Mirrors the editor `MediaItem`.
|
|
public struct ChatInputMediaItem: Equatable {
|
|
public var media: Media
|
|
public var kind: ChatInputMediaKind
|
|
public var naturalSize: ChatInputSize
|
|
/// Telegram-style spoiler: the medium renders behind an animated "dust" overlay until tapped.
|
|
/// Optional Codable (absent key → `false`), so pre-spoiler drafts/messages decode unchanged.
|
|
public var isSpoiler: Bool
|
|
public init(media: Media, kind: ChatInputMediaKind, naturalSize: ChatInputSize, isSpoiler: Bool = false) {
|
|
self.media = media
|
|
self.kind = kind
|
|
self.naturalSize = naturalSize
|
|
self.isSpoiler = isSpoiler
|
|
}
|
|
public static func == (lhs: ChatInputMediaItem, rhs: ChatInputMediaItem) -> Bool {
|
|
return lhs.media.isEqual(to: rhs.media) && lhs.kind == rhs.kind && lhs.naturalSize == rhs.naturalSize && lhs.isSpoiler == rhs.isSpoiler
|
|
}
|
|
}
|
|
|
|
extension ChatInputMediaItem: Codable {
|
|
private enum CodingKeys: String, CodingKey { case media, mediaType, kind, naturalSize, isSpoiler }
|
|
// Concrete-`Media`-type discriminator (see ChatInputMedia notes — the same scheme, per item).
|
|
private enum MediaType: Int32 { case image = 0; case file = 1; case map = 2 }
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
let raw = try container.decode(AdaptedPostboxDecoder.RawObjectData.self, forKey: .media)
|
|
let mediaDecoder = PostboxDecoder(buffer: MemoryBuffer(data: raw.data))
|
|
let mediaTypeValue = try container.decode(Int32.self, forKey: .mediaType)
|
|
guard let mediaType = MediaType(rawValue: mediaTypeValue) else {
|
|
throw DecodingError.dataCorruptedError(forKey: .mediaType, in: container, debugDescription: "Unsupported media type \(mediaTypeValue)")
|
|
}
|
|
switch mediaType {
|
|
case .image: self.media = TelegramMediaImage(decoder: mediaDecoder)
|
|
case .file: self.media = TelegramMediaFile(decoder: mediaDecoder)
|
|
case .map: self.media = TelegramMediaMap(decoder: mediaDecoder)
|
|
}
|
|
self.kind = try container.decode(ChatInputMediaKind.self, forKey: .kind)
|
|
self.naturalSize = try container.decode(ChatInputSize.self, forKey: .naturalSize)
|
|
self.isSpoiler = try container.decodeIfPresent(Bool.self, forKey: .isSpoiler) ?? false
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
let mediaType: MediaType
|
|
if self.media is TelegramMediaImage { mediaType = .image }
|
|
else if self.media is TelegramMediaMap { mediaType = .map }
|
|
else if self.media is TelegramMediaFile { mediaType = .file }
|
|
else { mediaType = .file }
|
|
try container.encode(mediaType.rawValue, forKey: .mediaType)
|
|
try container.encode(PostboxEncoder().encodeObjectToRawData(self.media), forKey: .media)
|
|
try container.encode(self.kind, forKey: .kind)
|
|
try container.encode(self.naturalSize, forKey: .naturalSize)
|
|
try container.encode(self.isSpoiler, forKey: .isSpoiler)
|
|
}
|
|
}
|
|
|
|
/// Mirrors the editor's `MediaDisplayMode` — how a multi-item container lays out (mosaic → `.collage`,
|
|
/// slideshow → `.slideshow`). Meaningful only for `items.count >= 2`; default `.mosaic`.
|
|
///
|
|
/// Like the `Int32` raw enums above, this carries a CUSTOM keyed `Codable` that encodes its `String` `.rawValue`
|
|
/// under a keyed container — never the synthesized `RawRepresentable` Codable, which uses a `singleValueContainer`
|
|
/// the Postbox `AdaptedPostbox*coder` does not support (it crashes at runtime, e.g. when a draft's
|
|
/// `ChatInputMedia` block is persisted).
|
|
public enum ChatInputMediaDisplayMode: String, Equatable, Codable {
|
|
case mosaic
|
|
case slideshow
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case raw
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
let value = try container.decode(String.self, forKey: .raw)
|
|
guard let v = ChatInputMediaDisplayMode(rawValue: value) else {
|
|
throw DecodingError.dataCorruptedError(forKey: .raw, in: container, debugDescription: "Unknown ChatInputMediaDisplayMode \(value)")
|
|
}
|
|
self = v
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(self.rawValue, forKey: .raw)
|
|
}
|
|
}
|
|
|
|
/// An attached media container (one or more images/videos) with a single inline caption. Mirrors the editor
|
|
/// `MediaBlock` — items carry concrete `Media` (resolved from the editor's opaque `mediaID` at conversion time).
|
|
public struct ChatInputMedia: Equatable {
|
|
public var items: [ChatInputMediaItem]
|
|
/// Display width in points; nil = natural width. Honored only when `items.count == 1`.
|
|
public var displayWidth: Double?
|
|
/// Honored only when `items.count == 1`.
|
|
public var alignment: ChatInputMediaAlignment
|
|
/// Layout mode for a multi-item container; honored only when `items.count >= 2`. Default `.mosaic`.
|
|
public var displayMode: ChatInputMediaDisplayMode
|
|
public var caption: [ChatInputRun]
|
|
|
|
public init(items: [ChatInputMediaItem], displayWidth: Double? = nil,
|
|
alignment: ChatInputMediaAlignment = .center, displayMode: ChatInputMediaDisplayMode = .mosaic,
|
|
caption: [ChatInputRun] = []) {
|
|
self.items = items
|
|
self.displayWidth = displayWidth
|
|
self.alignment = alignment
|
|
self.displayMode = displayMode
|
|
self.caption = caption
|
|
}
|
|
|
|
/// Convenience single-media initializer — reproduces the pre-container API.
|
|
public init(media: Media, kind: ChatInputMediaKind, naturalSize: ChatInputSize,
|
|
displayWidth: Double? = nil, alignment: ChatInputMediaAlignment = .center,
|
|
displayMode: ChatInputMediaDisplayMode = .mosaic,
|
|
caption: [ChatInputRun] = []) {
|
|
self.init(items: [ChatInputMediaItem(media: media, kind: kind, naturalSize: naturalSize)],
|
|
displayWidth: displayWidth, alignment: alignment, displayMode: displayMode, caption: caption)
|
|
}
|
|
|
|
// Single-media convenience accessors (FIRST item).
|
|
public var media: Media { items[0].media }
|
|
public var kind: ChatInputMediaKind { items[0].kind }
|
|
public var naturalSize: ChatInputSize { items[0].naturalSize }
|
|
|
|
public static func == (lhs: ChatInputMedia, rhs: ChatInputMedia) -> Bool {
|
|
return lhs.items == rhs.items
|
|
&& lhs.displayWidth == rhs.displayWidth
|
|
&& lhs.alignment == rhs.alignment
|
|
&& lhs.displayMode == rhs.displayMode
|
|
&& lhs.caption == rhs.caption
|
|
}
|
|
}
|
|
|
|
extension ChatInputMedia: Codable {
|
|
private enum CodingKeys: String, CodingKey {
|
|
case items, displayWidth, alignment, displayMode, caption
|
|
// Legacy (pre-container) single-media keys:
|
|
case media, mediaType, kind, naturalSize
|
|
}
|
|
private enum MediaType: Int32 { case image = 0; case file = 1; case map = 2 }
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.displayWidth = try container.decodeIfPresent(Double.self, forKey: .displayWidth)
|
|
self.alignment = try container.decode(ChatInputMediaAlignment.self, forKey: .alignment)
|
|
self.displayMode = try container.decodeIfPresent(ChatInputMediaDisplayMode.self, forKey: .displayMode) ?? .mosaic
|
|
self.caption = try container.decode([ChatInputRun].self, forKey: .caption)
|
|
if let items = try container.decodeIfPresent([ChatInputMediaItem].self, forKey: .items) {
|
|
self.items = items
|
|
} else {
|
|
// Legacy single-media payload → one item.
|
|
let raw = try container.decode(AdaptedPostboxDecoder.RawObjectData.self, forKey: .media)
|
|
let mediaDecoder = PostboxDecoder(buffer: MemoryBuffer(data: raw.data))
|
|
let mediaTypeValue = try container.decode(Int32.self, forKey: .mediaType)
|
|
guard let mediaType = MediaType(rawValue: mediaTypeValue) else {
|
|
throw DecodingError.dataCorruptedError(forKey: .mediaType, in: container, debugDescription: "Unsupported media type \(mediaTypeValue)")
|
|
}
|
|
let media: Media
|
|
switch mediaType {
|
|
case .image: media = TelegramMediaImage(decoder: mediaDecoder)
|
|
case .file: media = TelegramMediaFile(decoder: mediaDecoder)
|
|
case .map: media = TelegramMediaMap(decoder: mediaDecoder)
|
|
}
|
|
let kind = try container.decode(ChatInputMediaKind.self, forKey: .kind)
|
|
let naturalSize = try container.decode(ChatInputSize.self, forKey: .naturalSize)
|
|
self.items = [ChatInputMediaItem(media: media, kind: kind, naturalSize: naturalSize)]
|
|
}
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(self.items, forKey: .items)
|
|
try container.encodeIfPresent(self.displayWidth, forKey: .displayWidth)
|
|
try container.encode(self.alignment, forKey: .alignment)
|
|
try container.encode(self.displayMode, forKey: .displayMode)
|
|
try container.encode(self.caption, forKey: .caption)
|
|
}
|
|
}
|
|
|
|
/// A table column: width only (alignment is per-cell — see `ChatInputTableCell`).
|
|
public struct ChatInputColumnSpec: Equatable, Codable {
|
|
public var width: Double
|
|
public init(width: Double) {
|
|
self.width = width
|
|
}
|
|
}
|
|
|
|
/// A table cell — INLINE-ONLY (a flat run list; no nested blocks). Per-cell H+V alignment.
|
|
public struct ChatInputTableCell: Equatable, Codable {
|
|
public var runs: [ChatInputRun]
|
|
public var background: ChatInputColor?
|
|
public var horizontalAlignment: ChatInputTextAlignment
|
|
public var verticalAlignment: ChatInputTableVerticalAlignment
|
|
/// Per-cell header/highlight flag (replaces the old whole-row header).
|
|
public var isHeader: Bool
|
|
/// Number of grid columns this cell spans (default 1). Mirrors the editor `Cell.colspan`.
|
|
public var colspan: Int
|
|
/// Number of grid rows this cell spans (default 1). Mirrors the editor `Cell.rowspan`.
|
|
public var rowspan: Int
|
|
public init(runs: [ChatInputRun] = [], background: ChatInputColor? = nil,
|
|
horizontalAlignment: ChatInputTextAlignment = .center, verticalAlignment: ChatInputTableVerticalAlignment = .top,
|
|
isHeader: Bool = false, colspan: Int = 1, rowspan: Int = 1) {
|
|
self.runs = runs
|
|
self.background = background
|
|
self.horizontalAlignment = horizontalAlignment
|
|
self.verticalAlignment = verticalAlignment
|
|
self.isHeader = isHeader
|
|
self.colspan = colspan
|
|
self.rowspan = rowspan
|
|
}
|
|
private enum CodingKeys: String, CodingKey { case runs, background, horizontalAlignment, verticalAlignment, isHeader, colspan, rowspan }
|
|
public init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
runs = try c.decodeIfPresent([ChatInputRun].self, forKey: .runs) ?? []
|
|
background = try c.decodeIfPresent(ChatInputColor.self, forKey: .background)
|
|
horizontalAlignment = try c.decodeIfPresent(ChatInputTextAlignment.self, forKey: .horizontalAlignment) ?? .center
|
|
verticalAlignment = try c.decodeIfPresent(ChatInputTableVerticalAlignment.self, forKey: .verticalAlignment) ?? .top
|
|
isHeader = try c.decodeIfPresent(Bool.self, forKey: .isHeader) ?? false
|
|
// Decoded (and encoded, below) as `Int32`, NOT `Int`: the Postbox `AdaptedPostbox*coder` has no
|
|
// `Int`-typed encode/decode overload (only Int32/Int64) — its keyed containers either assert (encode)
|
|
// or silently mismatch (decode) on a bare `Int`. `colspan`/`rowspan` stay `Int` in the public model
|
|
// (mirroring the editor `Cell.colspan`/`rowspan`); only the wire representation is `Int32`.
|
|
colspan = try c.decodeIfPresent(Int32.self, forKey: .colspan).map(Int.init) ?? 1
|
|
rowspan = try c.decodeIfPresent(Int32.self, forKey: .rowspan).map(Int.init) ?? 1
|
|
}
|
|
public func encode(to encoder: Encoder) throws {
|
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
|
try c.encode(runs, forKey: .runs)
|
|
try c.encodeIfPresent(background, forKey: .background)
|
|
try c.encode(horizontalAlignment, forKey: .horizontalAlignment)
|
|
try c.encode(verticalAlignment, forKey: .verticalAlignment)
|
|
try c.encode(isHeader, forKey: .isHeader)
|
|
try c.encode(Int32(colspan), forKey: .colspan)
|
|
try c.encode(Int32(rowspan), forKey: .rowspan)
|
|
}
|
|
}
|
|
|
|
/// A table row. `isHeader` is derived from the cells (per-cell is the source of truth).
|
|
public struct ChatInputTableRow: Equatable {
|
|
public var height: Double?
|
|
public var cells: [ChatInputTableCell]
|
|
public var isHeader: Bool { !cells.isEmpty && cells.allSatisfy { $0.isHeader } }
|
|
/// `isHeader: true` seeds every cell as a header cell; `false` leaves each cell's own flag untouched.
|
|
public init(height: Double? = nil, isHeader: Bool = false, cells: [ChatInputTableCell] = []) {
|
|
self.height = height
|
|
self.cells = isHeader ? cells.map { var c = $0; c.isHeader = true; return c } : cells
|
|
}
|
|
}
|
|
|
|
extension ChatInputTableRow: Codable {
|
|
private enum CodingKeys: String, CodingKey { case height, cells; case legacyIsHeader = "isHeader" }
|
|
public init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
height = try c.decodeIfPresent(Double.self, forKey: .height)
|
|
var decodedCells = try c.decodeIfPresent([ChatInputTableCell].self, forKey: .cells) ?? []
|
|
if (try c.decodeIfPresent(Bool.self, forKey: .legacyIsHeader)) == true {
|
|
decodedCells = decodedCells.map { var cell = $0; cell.isHeader = true; return cell }
|
|
}
|
|
cells = decodedCells
|
|
}
|
|
public func encode(to encoder: Encoder) throws {
|
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
|
try c.encodeIfPresent(height, forKey: .height)
|
|
try c.encode(cells, forKey: .cells)
|
|
// Deliberately omits `isHeader` — derived from cells.
|
|
}
|
|
}
|
|
|
|
/// A table. Synthesized `Codable`.
|
|
public struct ChatInputTable: Equatable, Codable {
|
|
public var columns: [ChatInputColumnSpec]
|
|
public var rows: [ChatInputTableRow]
|
|
/// Render-only: halve every cell's interior padding. Mirrors `pageBlockTable`'s `compact` flag.
|
|
public var compact: Bool
|
|
/// `pageBlockTable`'s `bordered` flag. Defaults TRUE — a table persisted before this field existed
|
|
/// was always bordered, and the forward converter hard-coded `bordered: true`.
|
|
public var bordered: Bool
|
|
public init(columns: [ChatInputColumnSpec] = [], rows: [ChatInputTableRow] = [], compact: Bool = false,
|
|
bordered: Bool = true) {
|
|
self.columns = columns
|
|
self.rows = rows
|
|
self.compact = compact
|
|
self.bordered = bordered
|
|
}
|
|
private enum CodingKeys: String, CodingKey { case columns, rows, compact, bordered }
|
|
// Custom decode so drafts persisted before `compact` / `bordered` existed still load. Encoding stays
|
|
// synthesized.
|
|
public init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
columns = try c.decodeIfPresent([ChatInputColumnSpec].self, forKey: .columns) ?? []
|
|
rows = try c.decodeIfPresent([ChatInputTableRow].self, forKey: .rows) ?? []
|
|
compact = try c.decodeIfPresent(Bool.self, forKey: .compact) ?? false
|
|
// Absent => true: an older draft's table was always bordered.
|
|
bordered = try c.decodeIfPresent(Bool.self, forKey: .bordered) ?? true
|
|
}
|
|
}
|
|
|
|
/// One step descending the block tree: pick a block at this level, then which of its nested-content slots to
|
|
/// descend into. `slot` is unused at the LEAF step (a text block has no nested content) — 0 by convention.
|
|
public struct ChatInputPathStep: Equatable {
|
|
public var blockIndex: Int
|
|
public var slot: Int
|
|
public init(blockIndex: Int, slot: Int = 0) {
|
|
self.blockIndex = blockIndex
|
|
self.slot = slot
|
|
}
|
|
}
|
|
|
|
/// A caret position: a non-empty path of `(blockIndex, slot)` steps through the (possibly nested) block tree to
|
|
/// a UTF-16 `offset` within the addressed text-leaf block. Today the editor produces only depth-1 paths (the
|
|
/// flat string contains only top-level, unfolded content); deeper paths are reserved for future nested editing.
|
|
public struct ChatInputPosition: Equatable {
|
|
public var path: [ChatInputPathStep]
|
|
public var offset: Int
|
|
public init(path: [ChatInputPathStep], offset: Int) {
|
|
self.path = path
|
|
self.offset = offset
|
|
}
|
|
}
|
|
|
|
public struct ChatInputSelection: Equatable {
|
|
public var start: ChatInputPosition
|
|
public var end: ChatInputPosition
|
|
public init(start: ChatInputPosition, end: ChatInputPosition) {
|
|
self.start = start
|
|
self.end = end
|
|
}
|
|
public var isCollapsed: Bool { start == end }
|
|
}
|
|
|
|
public extension ChatInputContent {
|
|
/// Whether a block lives on the flat caret axis (`plainText`): paragraphs, code, pullQuote, and blockQuote
|
|
/// do (they contribute text / a " " placeholder + an inter-block "\n"); `.media`/`.table` do NOT (off-axis
|
|
/// — no character, no separator), so a non-text block has no flat-text/caret position. Mirrors which blocks
|
|
/// `attributedString(from:)` emits and the editor's `composerParagraphs()` keeps.
|
|
func blockIsFlatParticipating(_ block: ChatInputBlock) -> Bool {
|
|
switch block {
|
|
case .paragraph, .code, .pullQuote, .blockQuote:
|
|
return true
|
|
case .media, .table, .details, .buttonRow:
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Flat (caret) extent of a top-level block in `plainText`: a paragraph/code block contributes its text
|
|
/// length; a `.blockQuote(collapsed: true)` contributes 1 (its " " placeholder); a collapsed blockQuote's
|
|
/// nested content is off-string; a `.media`/`.table` block contributes 0 (off-axis — see
|
|
/// `blockIsFlatParticipating`).
|
|
private func blockFlatLength(_ block: ChatInputBlock) -> Int {
|
|
switch block {
|
|
case let .paragraph(paragraph):
|
|
return (paragraph.text as NSString).length
|
|
case let .code(code):
|
|
return (code.text as NSString).length
|
|
case let .pullQuote(pq):
|
|
return (pq.text as NSString).length
|
|
case let .blockQuote(bq):
|
|
// Collapsed → 1 (the " " placeholder); expanded → the recursed interior flat length
|
|
// (the inner ChatInputContent's plainText already applies the same inter-block accounting).
|
|
return bq.collapsed ? 1 : (bq.content.plainText as NSString).length
|
|
case .media, .table, .details, .buttonRow:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
/// Map a structural position to a flat UTF-16 offset into `plainText`. Only depth-1 positions (the editing
|
|
/// surface today) round-trip exactly; a deeper path (into not-yet-unfolded nested content, which has no flat
|
|
/// representation) clamps to the addressed top-level block's start. Off-axis (`.media`/`.table`) blocks
|
|
/// contribute neither length nor a separator, so the flat space matches `plainText` exactly; a position that
|
|
/// addresses an off-axis block (defensive — the editor never selects into one) clamps to that boundary.
|
|
func flatOffset(for position: ChatInputPosition) -> Int {
|
|
guard let first = position.path.first else {
|
|
return 0
|
|
}
|
|
let count = self.blocks.count
|
|
if count == 0 {
|
|
return 0
|
|
}
|
|
let blockIndex = min(max(first.blockIndex, 0), count - 1)
|
|
var flat = 0
|
|
var hasEmitted = false
|
|
for i in 0 ... blockIndex {
|
|
let block = self.blocks[i]
|
|
guard blockIsFlatParticipating(block) else {
|
|
// Off-axis block: no flat position. If it is the addressed block, clamp to the current boundary
|
|
// (== just after the previous participating block; it adds no separator).
|
|
if i == blockIndex { return flat }
|
|
continue
|
|
}
|
|
if hasEmitted { flat += 1 } // the inter-block "\n" preceding this participating block
|
|
if i == blockIndex {
|
|
if position.path.count == 1 {
|
|
let leafLength = blockFlatLength(block)
|
|
return flat + min(max(position.offset, 0), leafLength)
|
|
}
|
|
return flat
|
|
}
|
|
hasEmitted = true
|
|
flat += blockFlatLength(block)
|
|
}
|
|
return flat
|
|
}
|
|
|
|
/// Map a flat UTF-16 offset (into `plainText`) to a structural position. Always a depth-1 path — the flat
|
|
/// string only contains top-level, unfolded content. Off-axis (`.media`/`.table`) blocks are skipped (they
|
|
/// hold no flat position), so an offset never resolves INTO one; offsets straddling an inter-block "\n"
|
|
/// resolve cleanly (the "\n" is its own index: `endOfBlockI` and `startOfBlockI+1` are distinct).
|
|
func position(forFlatOffset rawOffset: Int) -> ChatInputPosition {
|
|
let total = (self.plainText as NSString).length
|
|
let offset = min(max(rawOffset, 0), total)
|
|
var cursor = 0
|
|
var hasEmitted = false
|
|
var lastParticipatingIndex = 0
|
|
for (i, block) in self.blocks.enumerated() {
|
|
guard blockIsFlatParticipating(block) else { continue }
|
|
if hasEmitted { cursor += 1 } // step past the inter-block "\n" to this block's start
|
|
hasEmitted = true
|
|
let length = blockFlatLength(block)
|
|
if offset <= cursor + length {
|
|
return ChatInputPosition(path: [ChatInputPathStep(blockIndex: i)], offset: max(0, offset - cursor))
|
|
}
|
|
cursor += length
|
|
lastParticipatingIndex = i
|
|
}
|
|
// Past the end, or no participating blocks at all: clamp to the last participating block's end (else the
|
|
// last block / block 0 at offset 0).
|
|
let clampIndex = hasEmitted ? lastParticipatingIndex : max(self.blocks.count - 1, 0)
|
|
let clampLength = self.blocks.indices.contains(clampIndex) ? blockFlatLength(self.blocks[clampIndex]) : 0
|
|
return ChatInputPosition(path: [ChatInputPathStep(blockIndex: clampIndex)], offset: clampLength)
|
|
}
|
|
}
|
|
|
|
public extension ChatInputSelection {
|
|
/// The ordered flat `NSRange` (into `content.plainText`) for this selection.
|
|
func nsRange(in content: ChatInputContent) -> NSRange {
|
|
let a = content.flatOffset(for: self.start)
|
|
let b = content.flatOffset(for: self.end)
|
|
let lo = min(a, b)
|
|
let hi = max(a, b)
|
|
return NSRange(location: lo, length: hi - lo)
|
|
}
|
|
|
|
/// Build a selection from a flat `NSRange` (into `content.plainText`).
|
|
init(nsRange: NSRange, in content: ChatInputContent) {
|
|
self.init(
|
|
start: content.position(forFlatOffset: nsRange.location),
|
|
end: content.position(forFlatOffset: nsRange.location + nsRange.length)
|
|
)
|
|
}
|
|
}
|