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

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)
)
}
}