InstantPage blockQuote: nested-blocks payload

Upgrade InstantPageBlock.blockQuote from (text, caption) to
(blocks: [InstantPageBlock], caption). Legacy inbound shapes (API
pageBlockBlockquote, Postbox "t", FlatBuffers text) lift into
[.paragraph(text)]; outbound stays on the legacy wire constructor for
empty/single-paragraph quotes and uses pageBlockBlockquoteBlocks
otherwise. V1/V2 renderers recurse into child blocks with a fixed 10pt
inter-child gap; markdown forward/reverse, entity-expressibility, and
preview text updated. pullQuote is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isaac 2026-05-29 00:57:22 +02:00
parent ef7d582849
commit 28763bb92d
10 changed files with 233 additions and 62 deletions

View file

@ -1116,8 +1116,14 @@ private func blockIsEntityExpressible(_ block: InstantPageBlock) -> Bool {
return richTextIsEntityExpressible(text)
case .preformatted(let text, _):
return richTextIsEntityExpressible(text)
case .blockQuote(let text, let caption):
return isEmptyRichText(caption) && richTextIsEntityExpressible(text)
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
}
case .anchor, .unsupported:
return true
case .divider:
@ -1392,24 +1398,17 @@ private func markdownBlocks(from node: MarkdownIntentNode, context: MarkdownConv
case .thematicBreak:
return [.divider]
case .blockQuote:
var result: [InstantPageBlock] = []
var childBlocks: [InstantPageBlock] = []
for child in node.children {
guard let childBlocks = markdownBlocks(from: child, context: context, depth: depth + 1) else {
guard let parsed = markdownBlocks(from: child, context: context, depth: depth + 1) else {
return nil
}
for childBlock in childBlocks {
switch childBlock {
case let .paragraph(text):
result.append(.blockQuote(text: text, caption: .empty))
default:
let plainText = markdownPlainText(from: childBlock, depth: depth + 1)
if !plainText.isEmpty {
result.append(.blockQuote(text: .plain(plainText), caption: .empty))
}
}
}
childBlocks.append(contentsOf: parsed)
}
return result
guard !childBlocks.isEmpty else {
return []
}
return [.blockQuote(blocks: childBlocks, caption: .empty)]
case .orderedList:
guard let items = markdownListItems(from: node.children, ordered: true, context: context, depth: depth + 1) else {
return nil
@ -2213,8 +2212,9 @@ private func markdownPlainText(from block: InstantPageBlock, depth: Int = 0) ->
return text.plainText
case let .footer(text):
return text.plainText
case let .blockQuote(text, caption):
return text.plainText.isEmpty ? caption.plainText : text.plainText
case let .blockQuote(blocks, caption):
let blocksText = blocks.map { markdownPlainText(from: $0, depth: depth + 1) }.joined(separator: "\n")
return blocksText.isEmpty ? caption.plainText : blocksText
case let .pullQuote(text, caption):
return text.plainText.isEmpty ? caption.plainText : text.plainText
case let .kicker(text):

View file

@ -781,7 +781,7 @@ private func parsePageBlocks(_ input: [Any], _ url: String, _ media: inout [Engi
case "pre":
result.append(.preformatted(text: .fixed(trim(parseRichText(item, &media))), language: nil))
case "blockquote":
result.append(.blockQuote(text: .italic(trim(parseRichText(item, &media))), caption: .empty))
result.append(.blockQuote(blocks: [.paragraph(.italic(trim(parseRichText(item, &media))))], caption: .empty))
case "img":
if let image = parseImage(item, &media) {
result.append(image)

View file

@ -39,8 +39,8 @@ private func markdownString(from block: InstantPageBlock) -> String? {
case let .preformatted(text, language):
let language = language ?? ""
return "```\(language)\n\(markdownInline(from: text))\n```"
case let .blockQuote(text, _):
return markdownBlockQuote(text)
case let .blockQuote(blocks, _):
return markdownBlockQuoteBlocks(blocks)
case let .pullQuote(text, _):
return markdownBlockQuote(text)
case let .list(items, ordered):
@ -69,6 +69,19 @@ private func markdownBlockQuote(_ text: RichText) -> String {
return lines.map { "> \($0)" }.joined(separator: "\n")
}
private func markdownBlockQuoteBlocks(_ blocks: [InstantPageBlock]) -> String {
var lines: [String] = []
for block in blocks {
guard let body = markdownString(from: block) else {
continue
}
for line in body.split(separator: "\n", omittingEmptySubsequences: false) {
lines.append("> \(String(line))")
}
}
return lines.joined(separator: "\n")
}
private func markdownInline(from richText: RichText) -> String {
switch richText {
case .empty:

View file

@ -501,40 +501,65 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
}
}
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: listItems)
case let .blockQuote(text, caption):
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 styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false)
styleStack.push(.italic)
let (_, textItems, textContentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset, offset: CGPoint(x: horizontalInset + lineInset, y: contentSize.height), media: media, webpage: webpage, fitToWidth: fitToWidth)
contentSize.height += textContentSize.height
items.append(contentsOf: textItems)
let innerBoundingWidth = boundingWidth - horizontalInset * 2.0 - lineInset
let innerHorizontalInset = horizontalInset + lineInset
if blocks.count == 1, case let .paragraph(text) = blocks[0] {
// Legacy single-paragraph fast path: italicized body (unchanged from prior behavior).
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false)
styleStack.push(.italic)
let (_, textItems, textContentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: innerBoundingWidth, offset: CGPoint(x: innerHorizontalInset, y: contentSize.height), media: media, webpage: webpage, fitToWidth: fitToWidth)
contentSize.height += textContentSize.height
items.append(contentsOf: textItems)
} else {
var previousChildItems: [InstantPageItem] = []
// Fixed, compact gap between a quote's child blocks (the full page-flow
// spacing around quotes is too airy when nested); the first child hugs
// the top (only verticalInset above it).
let childSpacing: CGFloat = 10.0
for (i, child) in blocks.enumerated() {
let childLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: child, boundingWidth: innerBoundingWidth, horizontalInset: innerHorizontalInset, safeInset: safeInset, isCover: false, previousItems: previousChildItems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: excludeCaptions, isLast: i == blocks.count - 1 && isLast, fitToWidth: fitToWidth)
let spacing: CGFloat = i == 0 ? 0.0 : childSpacing
contentSize.height += spacing
var childItems: [InstantPageItem] = []
for var item in childLayout.items {
item.frame = item.frame.offsetBy(dx: 0.0, dy: contentSize.height)
childItems.append(item)
}
items.append(contentsOf: childItems)
previousChildItems = childItems
contentSize.height += childLayout.contentSize.height
}
}
if case .empty = caption {
} else {
contentSize.height += 14.0
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: theme, category: .caption, link: false)
let (_, captionItems, captionContentSize) = layoutTextItemWithString(attributedStringForRichText(caption, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset, offset: CGPoint(x: horizontalInset + lineInset, y: contentSize.height), media: media, webpage: webpage)
let (_, captionItems, captionContentSize) = layoutTextItemWithString(attributedStringForRichText(caption, styleStack: styleStack), boundingWidth: innerBoundingWidth, offset: CGPoint(x: innerHorizontalInset, y: contentSize.height), media: media, webpage: webpage)
contentSize.height += captionContentSize.height
items.append(contentsOf: captionItems)
}
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: CGPoint(x: 0.0, y: 0.0), 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)
case let .pullQuote(text, caption):
let verticalInset: CGFloat = 4.0

View file

@ -594,14 +594,14 @@ private func layoutBlock(
return layoutCodeBlock(text, language: language, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
case let .blockQuote(text, caption):
return layoutBlockQuote(text: text, caption: caption, isPull: false,
case let .blockQuote(blocks, caption):
return layoutBlockQuote(blocks: blocks, caption: caption,
boundingWidth: boundingWidth, horizontalInset: horizontalInset,
context: &context)
isLast: isLast, context: &context)
case let .pullQuote(text, caption):
return layoutBlockQuote(text: text, caption: caption, isPull: true,
boundingWidth: boundingWidth, horizontalInset: horizontalInset,
context: &context)
return layoutQuoteText(text: text, caption: caption, isPull: true,
boundingWidth: boundingWidth, horizontalInset: horizontalInset,
context: &context)
case let .image(id, caption, url, webpageId):
if case let .image(image) = context.media[id], let largest = largestImageRepresentation(image.representations) {
@ -1901,6 +1901,86 @@ private func layoutCodeBlock(
// MARK: - Block quote / pull quote layout (ported from V1 InstantPageLayout.swift lines 517586)
private func layoutBlockQuote(
blocks: [InstantPageBlock],
caption: RichText,
boundingWidth: CGFloat,
horizontalInset: CGFloat,
isLast: Bool,
context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] {
// Legacy single-paragraph fast path: preserve today's italicized body styling.
if blocks.count == 1, case let .paragraph(text) = blocks[0] {
return layoutQuoteText(text: text, caption: caption, isPull: false,
boundingWidth: boundingWidth, horizontalInset: horizontalInset,
context: &context)
}
let verticalInset: CGFloat = 4.0
let lineInset: CGFloat = 20.0
let barWidth: CGFloat = 3.0
let innerBoundingWidth = boundingWidth - horizontalInset * 2.0 - lineInset
let innerHorizontalInset = horizontalInset + lineInset
var result: [InstantPageV2LaidOutItem] = []
var contentHeight: CGFloat = verticalInset
// Fixed, compact gap between a quote's child blocks. The full page-flow spacing
// (spacingBetweenBlocks ~27pt around quotes) is too airy when nested; the first
// child hugs the top (only verticalInset above it).
let childSpacing: CGFloat = 10.0
for (i, child) in blocks.enumerated() {
let spacing: CGFloat = i == 0 ? 0.0 : childSpacing
let childItems = layoutBlock(
child,
boundingWidth: innerBoundingWidth,
horizontalInset: innerHorizontalInset,
isCover: false,
previousItems: result,
isLast: i == blocks.count - 1 && isLast,
context: &context
)
let dy = contentHeight + spacing
let offsetItems = childItems.map { $0.offsetBy(CGPoint(x: 0.0, y: dy)) }
let childMaxY = offsetItems.map { $0.frame.maxY }.max() ?? dy
contentHeight = max(contentHeight, childMaxY)
result.append(contentsOf: offsetItems)
}
// Optional caption (mirrors layoutQuoteText's caption branch).
if case .empty = caption {
// no caption
} else {
contentHeight += 14.0
let captionStyleStack = InstantPageTextStyleStack()
setupStyleStack(captionStyleStack, theme: context.theme, category: .caption, link: false)
let attributedCaption = attributedStringForRichText(caption, styleStack: captionStyleStack)
let (_, captionItems, captionSize) = layoutTextItem(
attributedCaption,
boundingWidth: innerBoundingWidth,
alignment: .natural,
offset: CGPoint(x: innerHorizontalInset, y: contentHeight),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
result.append(contentsOf: captionItems)
contentHeight += captionSize.height
}
contentHeight += verticalInset
// Vertical bar on the leading edge (matches the blockQuote branch of layoutQuoteText).
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
}
private func layoutQuoteText(
text: RichText,
caption: RichText,
isPull: Bool,

View file

@ -91,8 +91,9 @@ table InstantPageBlock_List {
}
table InstantPageBlock_BlockQuote {
text:RichText (id: 0, required);
text:RichText (id: 0);
caption:RichText (id: 1, required);
blocks:[InstantPageBlock] (id: 2);
}
table InstantPageBlock_PullQuote {

View file

@ -246,7 +246,9 @@ extension InstantPageBlock {
self = .anchor(name)
case let .pageBlockBlockquote(pageBlockBlockquoteData):
let (text, caption) = (pageBlockBlockquoteData.text, pageBlockBlockquoteData.caption)
self = .blockQuote(text: RichText(apiText: text), caption: RichText(apiText: caption))
self = .blockQuote(blocks: [.paragraph(RichText(apiText: text))], caption: RichText(apiText: caption))
case let .pageBlockBlockquoteBlocks(pageBlockBlockquoteBlocksData):
self = .blockQuote(blocks: pageBlockBlockquoteBlocksData.blocks.map { InstantPageBlock(apiBlock: $0) }, caption: RichText(apiText: pageBlockBlockquoteBlocksData.caption))
case let .pageBlockPullquote(pageBlockPullquoteData):
let (text, caption) = (pageBlockPullquoteData.text, pageBlockPullquoteData.caption)
self = .pullQuote(text: RichText(apiText: text), caption: RichText(apiText: caption))
@ -370,8 +372,14 @@ extension InstantPageBlock {
} else {
return .pageBlockList(Api.PageBlock.Cons_pageBlockList(items: items.map { $0.apiInputPageListItem() }))
}
case let .blockQuote(text, caption):
return .pageBlockBlockquote(Api.PageBlock.Cons_pageBlockBlockquote(text: text.apiRichText(), caption: caption.apiRichText()))
case let .blockQuote(blocks, caption):
if blocks.isEmpty {
return .pageBlockBlockquote(Api.PageBlock.Cons_pageBlockBlockquote(text: RichText.empty.apiRichText(), caption: caption.apiRichText()))
}
if blocks.count == 1, case let .paragraph(text) = blocks[0] {
return .pageBlockBlockquote(Api.PageBlock.Cons_pageBlockBlockquote(text: text.apiRichText(), caption: caption.apiRichText()))
}
return .pageBlockBlockquoteBlocks(Api.PageBlock.Cons_pageBlockBlockquoteBlocks(blocks: blocks.compactMap { $0.apiInputBlock() }, caption: caption.apiRichText()))
case let .pullQuote(text, caption):
return .pageBlockPullquote(Api.PageBlock.Cons_pageBlockPullquote(text: text.apiRichText(), caption: caption.apiRichText()))
case let .image(id, caption, url, webpageId):

View file

@ -70,7 +70,7 @@ public indirect enum InstantPageBlock: PostboxCoding, Equatable {
case divider
case anchor(String)
case list(items: [InstantPageListItem], ordered: Bool)
case blockQuote(text: RichText, caption: RichText)
case blockQuote(blocks: [InstantPageBlock], caption: RichText)
case pullQuote(text: RichText, caption: RichText)
case image(id: MediaId, caption: InstantPageCaption, url: String?, webpageId: MediaId?)
case video(id: MediaId, caption: InstantPageCaption, autoplay: Bool, loop: Bool)
@ -121,7 +121,13 @@ public indirect enum InstantPageBlock: PostboxCoding, Equatable {
case InstantPageBlockType.list.rawValue:
self = .list(items: decodeListItems(decoder), ordered: decoder.decodeOptionalInt32ForKey("o") != 0)
case InstantPageBlockType.blockQuote.rawValue:
self = .blockQuote(text: decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText, caption: decoder.decodeObjectForKey("c", decoder: { RichText(decoder: $0) }) as! RichText)
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)
}
case InstantPageBlockType.pullQuote.rawValue:
self = .pullQuote(text: decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText, caption: decoder.decodeObjectForKey("c", decoder: { RichText(decoder: $0) }) as! RichText)
case InstantPageBlockType.image.rawValue:
@ -225,9 +231,9 @@ public indirect enum InstantPageBlock: PostboxCoding, Equatable {
encoder.encodeInt32(InstantPageBlockType.list.rawValue, forKey: "r")
encoder.encodeObjectArray(items, forKey: "ml")
encoder.encodeInt32(ordered ? 1 : 0, forKey: "o")
case let .blockQuote(text, caption):
case let .blockQuote(blocks, caption):
encoder.encodeInt32(InstantPageBlockType.blockQuote.rawValue, forKey: "r")
encoder.encodeObject(text, forKey: "t")
encoder.encodeObjectArray(blocks, forKey: "b")
encoder.encodeObject(caption, forKey: "c")
case let .pullQuote(text, caption):
encoder.encodeInt32(InstantPageBlockType.pullQuote.rawValue, forKey: "r")
@ -445,8 +451,8 @@ public indirect enum InstantPageBlock: PostboxCoding, Equatable {
} else {
return false
}
case let .blockQuote(text, caption):
if case .blockQuote(text, caption) = rhs {
case let .blockQuote(lhsBlocks, lhsCaption):
if case let .blockQuote(rhsBlocks, rhsCaption) = rhs, lhsBlocks == rhsBlocks, lhsCaption == rhsCaption {
return true
} else {
return false
@ -621,7 +627,15 @@ public indirect enum InstantPageBlock: PostboxCoding, Equatable {
guard let value = flatBuffersObject.value(type: TelegramCore_InstantPageBlock_BlockQuote.self) else {
throw FlatBuffersError.missingRequiredField()
}
self = .blockQuote(text: try RichText(flatBuffersObject: value.text), caption: try RichText(flatBuffersObject: value.caption))
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)
}
case .instantpageblockPullquote:
guard let value = flatBuffersObject.value(type: TelegramCore_InstantPageBlock_PullQuote.self) else {
throw FlatBuffersError.missingRequiredField()
@ -796,12 +810,13 @@ public indirect enum InstantPageBlock: PostboxCoding, Equatable {
TelegramCore_InstantPageBlock_List.addVectorOf(items: itemsOffset, &builder)
TelegramCore_InstantPageBlock_List.add(ordered: ordered, &builder)
offset = TelegramCore_InstantPageBlock_List.endInstantPageBlock_List(&builder, start: start)
case let .blockQuote(text, caption):
case let .blockQuote(blocks, caption):
valueType = .instantpageblockBlockquote
let textOffset = text.encodeToFlatBuffers(builder: &builder)
let blocksOffsets = blocks.map { $0.encodeToFlatBuffers(builder: &builder) }
let blocksOffset = builder.createVector(ofOffsets: blocksOffsets, len: blocksOffsets.count)
let captionOffset = caption.encodeToFlatBuffers(builder: &builder)
let start = TelegramCore_InstantPageBlock_BlockQuote.startInstantPageBlock_BlockQuote(&builder)
TelegramCore_InstantPageBlock_BlockQuote.add(text: textOffset, &builder)
TelegramCore_InstantPageBlock_BlockQuote.addVectorOf(blocks: blocksOffset, &builder)
TelegramCore_InstantPageBlock_BlockQuote.add(caption: captionOffset, &builder)
offset = TelegramCore_InstantPageBlock_BlockQuote.endInstantPageBlock_BlockQuote(&builder, start: start)
case let .pullQuote(text, caption):

View file

@ -123,8 +123,9 @@ extension InstantPageBlock {
result.append(item.previewText())
}
return result
case let .blockQuote(text, caption):
return text.previewText() + caption.previewText()
case let .blockQuote(blocks, caption):
let body = blocks.map { $0.previewText() }.joined(separator: " ")
return body + caption.previewText()
case let .pullQuote(text, caption):
return text.previewText() + caption.previewText()
case .image(_, _, _, _):