Upgrades the InstantPageBlock.blockQuote case from a text-only payload to a nested-blocks payload, covering API parse, Postbox + FlatBuffers serialization, API encode, V1/V2 layout, markdown forward/reverse, entity-expressibility, and preview text. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20 KiB
InstantPage BlockQuote — nested blocks payload
Context
Telegram's instant-view API has two parallel constructors for the block-quote page block:
pageBlockBlockquote(text: RichText, caption: RichText)— legacy text-only form.pageBlockBlockquoteBlocks(blocks: [PageBlock], caption: RichText)— new form whose body is a sequence of nested page blocks (paragraphs, lists, headings, code, even nested quotes).
The Swift model currently represents only the legacy form
(InstantPageBlock.blockQuote(text: RichText, caption: RichText)) and the API
parser has a //TODO placeholder for the new constructor
(submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift:251). This spec
upgrades parsing, serialization (Postbox + FlatBuffers), encoding back to the
wire, and rendering (V1 + V2) to support nested blocks throughout.
Decisions
- Single enum case, blocks-only payload. Replace
(text: RichText, caption: RichText)with(blocks: [InstantPageBlock], caption: RichText). The legacy text-only inbound shape is lifted into the new shape at parse time by wrapping the RichText in a synthetic.paragraph(text). Downstream code branches on zero shapes. - Full block-level layout in both renderers. V2
(
InstantPageV2Layout.swift) and V1 (InstantPageLayout.swift) recurse into the child blocks. No surface where nested-block quotes render degraded. - Outbound: legacy when shape allows.
apiInputBlock()emits the legacypageBlockBlockquoteconstructor when blocks is[.paragraph(text)](or empty), and the newpageBlockBlockquoteBlocksconstructor otherwise. Keeps the common chat case on the wire constructor older client recipients already understand. .pullQuoteis unchanged. The TL API has nopullQuoteBlocksconstructor; the.pullQuote(text: RichText, caption: RichText)case keeps its existing shape, parser, serializer, and renderer.
Architecture
Enum shape
SyncCore_InstantPage.swift:73:
case blockQuote(blocks: [InstantPageBlock], caption: RichText)
The enum is already declared indirect, so a [InstantPageBlock] payload
needs no further annotation.
API parsing (InstantPage.swift:247–252)
case let .pageBlockBlockquote(data):
self = .blockQuote(
blocks: [.paragraph(RichText(apiText: data.text))],
caption: RichText(apiText: data.caption)
)
case let .pageBlockBlockquoteBlocks(data):
self = .blockQuote(
blocks: data.blocks.map { InstantPageBlock(apiBlock: $0) },
caption: RichText(apiText: data.caption)
)
API encoding (InstantPage.swift:376)
case let .blockQuote(blocks, caption):
if blocks.count == 1, case let .paragraph(text) = blocks[0] {
return .pageBlockBlockquote(.init(
text: text.apiRichText(), caption: caption.apiRichText()
))
}
if blocks.isEmpty {
return .pageBlockBlockquote(.init(
text: RichText.empty.apiRichText(),
caption: caption.apiRichText()
))
}
return .pageBlockBlockquoteBlocks(.init(
blocks: blocks.compactMap { $0.apiInputBlock() },
caption: caption.apiRichText()
))
Postbox coding
SyncCore_InstantPage.swift:228 (encoder): write "b" (object array of
blocks) and "c" (caption). Stop writing "t".
SyncCore_InstantPage.swift:123 (decoder): mirror the decodeListItems
pattern at line 39 — if the legacy "t" key is present, lift it into a
single-paragraph blocks array; otherwise decode the new "b" array.
case InstantPageBlockType.blockQuote.rawValue:
let caption = decoder.decodeObjectForKey("c", decoder: {
RichText(decoder: $0)
}) as! RichText
if let legacyText = decoder.decodeObjectForKey("t", decoder: {
RichText(decoder: $0)
}) as? RichText {
self = .blockQuote(blocks: [.paragraph(legacyText)], caption: caption)
} else {
let blocks: [InstantPageBlock] =
decoder.decodeObjectArrayWithDecoderForKey("b")
self = .blockQuote(blocks: blocks, caption: caption)
}
Old stored cached pages (with "t" set) decode unchanged; new writes only use
"b".
FlatBuffers
InstantPageBlock.fbs:93:
table InstantPageBlock_BlockQuote {
text:RichText (id: 0); // (required) dropped — legacy only
caption:RichText (id: 1, required);
blocks:[InstantPageBlock] (id: 2); // new
}
Dropping (required) from an existing field and appending a new field at a
higher id are both schema-evolution-safe per FlatBuffers rules. Per the
flatbuffers-codegen memory the .fbs is the source of truth and Bazel
regenerates *_generated.swift; the checked-in copies in Sources/ are
stale and should NOT be hand-edited.
Codec decoder (SyncCore_InstantPage.swift:620):
case .instantpageblockBlockquote:
guard let value = flatBuffersObject.value(
type: TelegramCore_InstantPageBlock_BlockQuote.self
) else {
throw FlatBuffersError.missingRequiredField()
}
let caption = try RichText(flatBuffersObject: value.caption)
if value.blocksCount > 0 {
let blocks = try (0 ..< value.blocksCount).map {
try InstantPageBlock(flatBuffersObject: value.blocks(at: $0)!)
}
self = .blockQuote(blocks: blocks, caption: caption)
} else if let legacyText = value.text {
self = .blockQuote(
blocks: [.paragraph(try RichText(flatBuffersObject: legacyText))],
caption: caption
)
} else {
self = .blockQuote(blocks: [], caption: caption)
}
Codec encoder (SyncCore_InstantPage.swift:799): write blocks + caption;
omit text.
Equality (SyncCore_InstantPage.swift:448)
case let .blockQuote(lhsBlocks, lhsCaption):
if case let .blockQuote(rhsBlocks, rhsCaption) = rhs,
lhsBlocks == rhsBlocks, lhsCaption == rhsCaption {
return true
} else {
return false
}
Mirrors the .collage/.slideshow pattern (which already do
[InstantPageBlock] equality).
V2 renderer
InstantPageV2Layout.swift:597 keeps dispatching to layoutBlockQuote for
the blockquote arm. Split the existing function:
layoutBlockQuote(blocks:caption:...)— new, for.blockQuoteonly.layoutPullQuote(text:caption:...)— for.pullQuoteonly; same behavior as today'sisPull = truebranch.
layoutBlockQuote strategy:
private func layoutBlockQuote(
blocks: [InstantPageBlock],
caption: RichText,
boundingWidth: CGFloat,
horizontalInset: CGFloat,
context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] {
let verticalInset: CGFloat = 4.0
let lineInset: CGFloat = 20.0
let barWidth: CGFloat = 3.0
var result: [InstantPageV2LaidOutItem] = []
var contentHeight: CGFloat = verticalInset
let innerBoundingWidth = boundingWidth - horizontalInset * 2.0 - lineInset
let innerHorizontalInset = horizontalInset + lineInset
if blocks.count == 1, case let .paragraph(text) = blocks[0] {
// Fast path: preserve today's italicized body styling for the
// legacy single-paragraph shape (unchanged from current code).
} else {
var previousItems: [InstantPageV2LaidOutItem] = []
for (i, child) in blocks.enumerated() {
var childItems = layoutBlock(
child,
boundingWidth: innerBoundingWidth,
horizontalInset: innerHorizontalInset,
isCover: false,
previousItems: previousItems,
isLast: i == blocks.count - 1,
context: &context
)
// Stack vertically: offset Y by current contentHeight.
// X is already correct (children laid out at innerHorizontalInset).
for j in childItems.indices {
childItems[j] = childItems[j].translatedY(by: contentHeight)
}
let childMaxY = childItems.map { $0.frame.maxY }.max() ?? contentHeight
contentHeight = max(contentHeight, childMaxY)
previousItems.append(contentsOf: childItems)
result.append(contentsOf: childItems)
}
}
// Caption (existing branch, unchanged).
if case .empty = caption { /* nothing */ } else {
contentHeight += 14.0
// ...existing caption layout, using innerHorizontalInset...
}
contentHeight += verticalInset
// Vertical bar on the leading edge (existing behavior).
let bar = InstantPageV2BarItem(
frame: CGRect(x: horizontalInset, y: 0.0,
width: barWidth, height: contentHeight),
color: context.theme.textCategories.paragraph.color,
cornerRadius: barWidth / 2.0
)
result.append(.blockQuoteBar(bar))
return result
}
Notes:
- Translation helper.
InstantPageV2LaidOutItemcovers many variants (text/shape/list-marker/bar/checklist/media/...). Add a small extension methodtranslatedY(by:)that returns a copy with the item's frame'sorigin.yoffset. Implemented as a switch over the case set, mirroring any existing per-variant frame-edit helper in the file. previousItemsfor spacing. V2's per-block layout functions consumepreviousItemsto compute inter-block gaps. Passing a fresh array per recursion gives correct in-quote spacing without affecting the outer page'spreviousItems.- Styling of nested children. Direct children render with their normal category styling (heading stays a heading, list stays a list). Italics are applied only by the single-paragraph fast path — consistent with the visual fidelity goal for legacy quotes without complicating the recursive case.
V1 renderer
InstantPageLayout.swift:504 is the .blockQuote arm of the giant top-level
switch self inside the extension InstantPageBlock { func layout(...) -> InstantPageLayout }.
Because the enum is already indirect, the arm can simply call
child.layout(...) recursively for each block in blocks — no signature
refactor needed.
BlockQuote arm:
case let .blockQuote(blocks, caption):
let lineInset: CGFloat = 20.0
let verticalInset: CGFloat = 4.0
var contentSize = CGSize(width: boundingWidth, height: verticalInset)
var items: [InstantPageItem] = []
let innerBoundingWidth = boundingWidth - horizontalInset * 2.0 - lineInset
let innerHorizontalInset = horizontalInset + lineInset
if blocks.count == 1, case let .paragraph(text) = blocks[0] {
// Fast path: existing italicized body layout (unchanged).
} else {
var previousChildItems: [InstantPageItem] = []
for child in blocks {
let childLayout = child.layout(
boundingWidth: innerBoundingWidth,
horizontalInset: innerHorizontalInset,
safeInset: safeInset,
isCover: false,
previousItems: previousChildItems,
fillToSize: nil,
media: media,
mediaIndexCounter: &mediaIndexCounter,
embedIndexCounter: &embedIndexCounter,
detailsIndexCounter: &detailsIndexCounter,
theme: theme,
strings: strings,
/* ... whichever other params the current signature takes ... */
fitToWidth: fitToWidth,
webpage: webpage
)
for var item in childLayout.items {
item.frame = item.frame.offsetBy(dx: 0.0, dy: contentSize.height)
items.append(item)
previousChildItems.append(item)
}
contentSize.height += childLayout.contentSize.height
}
}
// Caption (existing branch, using innerHorizontalInset).
if case .empty = caption { /* nothing */ } else {
contentSize.height += 14.0
// ...existing caption layout, parameterized on innerHorizontalInset...
}
contentSize.height += verticalInset
let shapeItem = InstantPageShapeItem(
frame: CGRect(origin: CGPoint(x: horizontalInset, y: 0.0),
size: CGSize(width: 3.0, height: contentSize.height)),
shapeFrame: CGRect(origin: .zero,
size: CGSize(width: 3.0, height: contentSize.height)),
shape: .roundLine,
color: theme.textCategories.paragraph.color
)
items.append(shapeItem)
return InstantPageLayout(origin: CGPoint(),
contentSize: contentSize, items: items)
The exact parameter list of the recursive child.layout(...) call mirrors
the current method's parameter list as it exists in the codebase; the planning
step will reconcile against the actual method signature (which may differ
from the snippet above in nominal arity).
Markdown forward parser
BrowserMarkdown.swift:1394. Replace the current per-child-paragraph
fragmentation with a single quote that carries all child blocks:
case .blockQuote:
var childBlocks: [InstantPageBlock] = []
for child in node.children {
guard let parsed = markdownBlocks(
from: child, context: context, depth: depth + 1
) else {
return nil
}
childBlocks.append(contentsOf: parsed)
}
guard !childBlocks.isEmpty else {
return []
}
return [.blockQuote(blocks: childBlocks, caption: .empty)]
Behavior change worth noting. Today a markdown
> p1\n>\n> p2 produces TWO separate top-level quotes because the current
code emits one quote per child paragraph (a workaround for the text-only
model). Under the new model that becomes one quote with two paragraphs —
which is the correct semantics. Both forms continue to trigger the rich-send
gate (under the new entity-expressibility rule below, a multi-paragraph
quote is no longer entity-expressible — see "Risks" below).
Entity-expressibility (BrowserMarkdown.swift:1119)
Telegram message-entity blockquotes are flat (single span of inline text). The new gate:
case .blockQuote(let blocks, let caption):
guard isEmptyRichText(caption) else { return false }
return blocks.allSatisfy { child in
if case let .paragraph(text) = child {
return richTextIsEntityExpressible(text)
}
return false
}
- A single
> quotestays entity-expressible ([.paragraph(t)], entity- expressible text) → sends via the regular entity path. - A nested-structure quote (
> # heading,> - list) is not entity- expressible → sends via the rich path. - See "Risks" for the multi-paragraph case.
Markdown reverse converter
InstantPageToMarkdown.swift:42. Recurse into each child block, dispatching
through the file's existing markdownString(from:) (which already knows
how to emit headings, lists, code, etc.), and prepend > to every line:
case let .blockQuote(blocks, _):
return markdownBlockQuote(blocks: blocks)
private func markdownBlockQuote(blocks: [InstantPageBlock]) -> String {
var lines: [String] = []
for block in blocks {
guard let body = markdownString(from: block, /* args */) else {
continue
}
for line in body.split(separator: "\n",
omittingEmptySubsequences: false) {
lines.append("> \(String(line))")
}
}
return lines.joined(separator: "\n")
}
.pullQuote(text, _) continues to call the existing
markdownBlockQuote(_:RichText).
Preview text (InstantPagePreviewText.swift:126)
case let .blockQuote(blocks, caption):
let body = blocks.map { $0.previewText() }.joined(separator: " ")
return body + caption.previewText()
Uses the existing per-block previewText() extension at the top of the
file so nested previews work transparently.
FAQ matcher (CachedFaqInstantPage.swift:23)
The match is case .blockQuote: with no payload destructure — no change
needed.
Risks
-
Behavior change for multi-paragraph quotes in chat send. Under today's text-only model,
> p1\n>\n> p2fragments into two top-level.blockQuote(text: p_i, caption: .empty)blocks, each of which is individually entity-expressible — so the message sends via the regular entity path (two consecutive blockquote entities). Under the new model the markdown parser emits one.blockQuote(blocks: [.paragraph(p1), .paragraph(p2)], caption: .empty), which is no longer entity-expressible under the proposed gate (multi-paragraph blockquote can't be a single flat entity). So the same message starts going via the rich path.This is correct semantically — the structure IS preserved end to end — but it changes the wire format for an existing user-visible flow. A minor compromise is available: in
blockIsEntityExpressible, treat a multi-paragraph quote as entity-expressible by serializing each paragraph through a separate entity at the message-build step; this is more involved and out of scope for the first cut. The risk is small — recipients of the rich message render it correctly; the only user- visible difference is that the message lands as a rich block on recipients who would otherwise have seen the consecutive-entities flattening. -
Old recipients receiving
pageBlockBlockquoteBlocksover the wire. Older clients that haven't been updated to parse the new constructor will route it to.unsupportedand skip it. The outbound choice ("legacy when shape allows") keeps the common single-paragraph case on the legacy constructor, minimizing this risk to actual nested-block quotes where there's no legacy equivalent anyway. -
FlatBuffers schema evolution. Dropping
(required)and appending a new field at a higher id are documented as safe under FBS rules. The same iOS app and a Telegram-Mac peer share the schema definition (per the project's TelegramCore conventions), so both ends must move together or accept that one side will seetextpopulated while the other writes onlyblocks— which the decoder handles correctly (legacy path). -
V1 recursive layout call signature drift. The exact parameter list of
child.layout(...)is reconciled in the implementation plan, not in the spec — the V1 layout method's signature is long and any plumbing detail is best captured at edit time rather than guessed here.
Out of scope
.pullQuoteenum shape, parsing, encoding, and renderer.- Streaming/reveal animation in
ChatMessageRichDataBubbleContentNode. Nested-block quotes emit ordinaryInstantPageV2LaidOutItems consumed by the existing width-based reveal cost map; no special handling. - Inline animated emoji owned by
InstantPageV2View. Quotes carrying paragraphs with custom emoji "just work" because each child paragraph's text items route throughupdateInlineEmojinormally.
Files affected
| File | Change |
|---|---|
submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift |
Enum case shape; Postbox encoder/decoder; equality; FlatBuffers encoder/decoder. |
submodules/TelegramCore/FlatSerialization/Models/InstantPageBlock.fbs |
Drop (required) from text; add blocks:[InstantPageBlock] (id: 2). |
submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift |
API parse for both inbound constructors; API encode with legacy-when-possible. |
submodules/InstantPageUI/Sources/InstantPageV2Layout.swift |
Split layoutBlockQuote into block- and pull- variants; recurse into child blocks. Add translatedY(by:) helper on InstantPageV2LaidOutItem. |
submodules/InstantPageUI/Sources/InstantPageLayout.swift |
Recurse into child blocks in the .blockQuote arm. |
submodules/BrowserUI/Sources/BrowserMarkdown.swift |
Single quote with all child blocks (forward); entity-expressibility gate. |
submodules/BrowserUI/Sources/InstantPageToMarkdown.swift |
Recursive markdownBlockQuote(blocks:). |
submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift |
Concatenate child previews. |
submodules/SettingsUI/Sources/CachedFaqInstantPage.swift (line 23) is a
payload-less match and needs no edit, but should be re-verified during the
implementation build (full build is the completeness gate per the
project's "no per-module build" rule).