InstantPage V2: render server-sent thinking blocks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-05-30 18:47:03 +02:00
parent 484b66f28c
commit e8de87dc7b
15 changed files with 329 additions and 1135 deletions

View file

@ -35,6 +35,7 @@ swift_library(
"//submodules/Tuples:Tuples",
"//third-party/SwiftMath:SwiftMath",
"//submodules/ComponentFlow:ComponentFlow",
"//submodules/TelegramUI/Components/ShimmeringMask:ShimmeringMask",
],
visibility = [
"//visibility:public",

View file

@ -461,7 +461,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
let subBlock = blocks[i]
let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: listItems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1, fitToWidth: fitToWidth)
let spacing: CGFloat = previousBlock != nil && subLayout.contentSize.height > 0.0 ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: fitToWidth) : 0.0
let spacing: CGFloat = previousBlock != nil && subLayout.contentSize.height > 0.0 ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: fitToWidth, kind: .topLevel) : 0.0
let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: horizontalInset + indexSpacing + maxIndexWidth, y: contentSize.height + spacing))
if previousBlock == nil {
originY += spacing
@ -778,7 +778,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
let subBlock = blocks[i]
let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1, fitToWidth: fitToWidth)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: false)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: false, kind: .topLevel)
let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: horizontalInset + lineInset, y: contentSize.height + spacing))
items.append(contentsOf: blockItems)
contentSize.height += subLayout.contentSize.height + spacing
@ -962,7 +962,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
let subBlock = blocks[i]
let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth, horizontalInset: horizontalInset, safeInset: safeInset, isCover: false, previousItems: subitems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &subDetailsIndex, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1, fitToWidth: fitToWidth)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: false)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: false, kind: .topLevel)
let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: 0.0, y: contentSize.height + spacing))
subitems.append(contentsOf: blockItems)
contentSize.height += subLayout.contentSize.height + spacing
@ -970,7 +970,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
}
if !blocks.isEmpty {
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: false)
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: false, kind: .topLevel)
contentSize.height += closingSpacing
}
@ -1102,7 +1102,7 @@ public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instant
for i in 0 ..< pageBlocks.count {
let block = pageBlocks[i]
let blockLayout = layoutInstantPageBlock(webpage: webPage, userLocation: userLocation, rtl: rtl, block: block, boundingWidth: boundingWidth, horizontalInset: sideInset + safeInset, safeInset: safeInset, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == pageBlocks.count - 1, fitToWidth: fitToWidth)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block, fitToWidth: fitToWidth)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block, fitToWidth: fitToWidth, kind: .topLevel)
let blockItems = blockLayout.flattenedItemsWithOrigin(CGPoint(x: 0.0, y: contentSize.height + spacing))
items.append(contentsOf: blockItems)
if CGFloat(0.0).isLess(than: blockLayout.contentSize.height) {
@ -1111,7 +1111,7 @@ public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instant
}
}
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: fitToWidth)
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: fitToWidth, kind: .topLevel)
contentSize.height += closingSpacing
if webPage.webpageId.id != 0 && addFeedback {

View file

@ -2,19 +2,30 @@ import Foundation
import UIKit
import TelegramCore
func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fitToWidth: Bool) -> CGFloat {
enum BlockSequenceKind {
case topLevel
case detail
case cell
case list
}
func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fitToWidth: Bool, kind: BlockSequenceKind) -> CGFloat {
if let upper, let lower {
switch (upper, lower) {
case (_, .cover), (_, .channelBanner), (.details, .details), (.relatedArticles, _), (_, .anchor):
return 0.0
case (.divider, _), (_, .divider):
if fitToWidth {
return 10.0
return 20.0
} else {
return 25.0
}
case (_, .blockQuote), (.blockQuote, _), (_, .pullQuote), (.pullQuote, _):
return 27.0
if fitToWidth {
return 11.0
} else {
return 27.0
}
case (.kicker, .title), (.cover, .title):
return 16.0
case (_, .title):
@ -27,12 +38,22 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
return 34.0
case (.header, .paragraph), (.subheader, .paragraph), (.heading, .paragraph):
if fitToWidth {
return 10.0
return 14.0
} else {
return 25.0
}
case (.list, .paragraph):
return 31.0
if fitToWidth {
return 14.0
} else {
return 31.0
}
case (.paragraph, .list):
if fitToWidth {
return 14.0
} else {
return 31.0
}
case (.preformatted, .paragraph):
return 19.0
case (.formula, .paragraph):
@ -96,20 +117,37 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
return 0.0
default:
if fitToWidth {
return 10.0
switch kind {
case .topLevel:
switch lower {
case .heading:
return 13.0
default:
return 10.0
}
case .detail, .cell, .list:
return 4.0
}
} else {
return 25.0
}
}
} else if let upper {
switch kind {
case .topLevel:
if case .relatedArticles = upper {
return 0.0
} else {
if fitToWidth {
return 5.0
} else {
return 25.0
}
}
case .detail, .cell, .list:
return 16.0
}
} else {
if let upper, case .relatedArticles = upper {
return 0.0
} else {
if fitToWidth {
return 5.0
} else {
return 25.0
}
}
return 0.0
}
}

View file

@ -14,6 +14,7 @@ import EmojiTextAttachmentView
import AnimationCache
import MultiAnimationRenderer
import InvisibleInkDustNode
import ShimmeringMask
// MARK: - Stable item identity (for view reuse on re-layouts)
@ -29,6 +30,7 @@ import InvisibleInkDustNode
public enum InstantPageV2StableItemId: Hashable {
case media(Int) // media.index (4 media cases share this namespace)
case details(Int) // details.index
case thinking(Int) // thinking-block sequence index (own namespace)
case positional(InstantPageV2ItemKind, Int) // (caseTag, items-array position)
}
@ -48,7 +50,7 @@ public enum InstantPageV2ItemKind: Hashable {
/// `InstantPageV2View()` constructor usable.
public final class InstantPageV2RenderContext {
public let context: AccountContext
public let webpage: TelegramMediaWebpage
public private(set) var webpage: TelegramMediaWebpage
public let sourceLocation: InstantPageSourceLocation
public let imageReference: (TelegramMediaImage) -> ImageMediaReference
public let fileReference: (TelegramMediaFile) -> FileMediaReference
@ -78,6 +80,15 @@ public final class InstantPageV2RenderContext {
self.openUrl = openUrl
self.baseNavigationController = baseNavigationController
}
/// Update the content-bearing fields for a later chunk of the SAME message. Enables the
/// streaming bubble to reuse one V2View across `stableVersion` bumps instead of rebuilding.
/// Only `webpage` changes across chunks; the `imageReference`/`fileReference` closures keep
/// their construction-time `MessageReference` snapshot, which is acceptable because the message
/// id is stable across chunks (media resolves by id) and streamed AI content carries no media.
public func updateContent(webpage: TelegramMediaWebpage) {
self.webpage = webpage
}
}
// MARK: - Inline image view data
@ -212,8 +223,21 @@ public final class InstantPageV2View: UIView {
var newStableIds: [InstantPageV2StableItemId] = []
var reusedIds: Set<InstantPageV2StableItemId> = []
for (position, item) in layout.items.enumerated() {
let id = InstantPageV2View.stableId(for: item, atPosition: position)
// Two independent position counters so thinking-block churn never renumbers content
// blocks' stable ids (requirement: adding/removing a thinking block must not affect other
// blocks). Content items are numbered ignoring thinking items; thinking items get their
// own .thinking(index) namespace.
var contentPosition = 0
var thinkingPosition = 0
for item in layout.items {
let id: InstantPageV2StableItemId
if case .thinking = item {
id = InstantPageV2View.stableId(for: item, atPosition: thinkingPosition)
thinkingPosition += 1
} else {
id = InstantPageV2View.stableId(for: item, atPosition: contentPosition)
contentPosition += 1
}
if let existing = oldViewsById[id], let reusedView = self.reuse(existingView: existing, for: item, theme: theme, animation: animation) {
let newFrame = InstantPageV2View.actualFrame(forItem: item) // parent positions child
@ -630,6 +654,10 @@ public final class InstantPageV2View: UIView {
guard let v = existingView as? InstantPageV2MediaCoverImageView, let rc = self.renderContext else { return nil }
v.update(item: media, theme: theme, renderContext: rc)
return v
case let .thinking(thinking):
guard let v = existingView as? InstantPageV2ThinkingView else { return nil }
v.update(item: thinking, theme: theme)
return v
}
}
@ -650,6 +678,7 @@ public final class InstantPageV2View: UIView {
case .table: return .positional(.table, position)
case .anchor: return .positional(.anchor, position)
case .formula: return .positional(.formula, position)
case .thinking: return .thinking(position)
}
}
@ -748,6 +777,8 @@ public final class InstantPageV2View: UIView {
}
case let .formula(formula):
return InstantPageV2FormulaView(item: formula)
case let .thinking(thinking):
return InstantPageV2ThinkingView(item: thinking)
}
}
@ -1652,11 +1683,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
self.titleTextView.isUserInteractionEnabled = false
self.chevronView = UIImageView()
// Single downward chevron; the expanded state is a 180° rotation (animatable) rather than
// an instant chevron.up/chevron.down image swap. A template image + tintColor renders the
// SF Symbol in the message's primary text color baking the color into a CALayer's cgImage
// contents drops the tint and renders black. (SF Symbol is iOS 13+.)
self.chevronView.image = UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate)
self.chevronView.image = UIImage(bundleImageName: "Item List/ExpandingItemVerticalRegularArrow")?.withRenderingMode(.alwaysTemplate)
self.chevronView.tintColor = theme.textCategories.paragraph.color
self.chevronView.contentMode = .scaleAspectFit
// Decorative: let taps fall through to titleHitView (which carries the toggle gesture).
@ -1677,24 +1704,6 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
self.addSubview(self.chevronView)
self.addSubview(self.separator)
let chevronSize = CGSize(width: 18.0, height: 18.0)
// bounds + center (not frame) so the rotation transform pivots around the center and the
// frame stays well-defined while a non-identity transform is applied.
self.chevronView.bounds = CGRect(origin: .zero, size: chevronSize)
self.chevronView.center = CGPoint(
x: item.titleFrame.maxX - chevronSize.width / 2.0 - 12.0,
y: item.titleFrame.midY
)
self.chevronView.layer.transform = item.isExpanded ? InstantPageV2DetailsView.expandedChevronTransform : CATransform3DIdentity
// V1 (InstantPageDetailsNode.swift:138): separator sits at titleHeight - UIScreenPixel.
self.separator.frame = CGRect(
x: 0.0,
y: item.titleFrame.maxY - 0.5,
width: item.frame.width,
height: 0.5
)
if item.isExpanded, let innerLayout = item.innerLayout {
let body = InstantPageV2View(renderContext: renderContext)
body.update(layout: innerLayout, theme: theme, animation: .None)
@ -1727,20 +1736,12 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
)
self.titleTextView.update(item: titleV2Item, theme: theme)
self.chevronView.tintColor = theme.textCategories.paragraph.color
self.chevronView.tintColor = theme.secondaryControlColor
let chevronSize = CGSize(width: 18.0, height: 18.0)
self.chevronView.bounds = CGRect(origin: .zero, size: chevronSize)
self.chevronView.center = CGPoint(
x: item.titleFrame.maxX - chevronSize.width / 2.0 - 12.0,
y: item.titleFrame.midY
)
self.separator.backgroundColor = item.separatorColor
self.separator.frame = CGRect(
x: 0.0,
y: item.titleFrame.maxY - 0.5,
width: item.frame.width,
height: 0.5
x: item.sideInset + chevronSize.width / 2.0,
y: item.titleFrame.midY + 1.0
)
self.titleHitView.frame = item.titleFrame
@ -1749,6 +1750,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
// view's own frame height (clipsToBounds = true), not by the body itself see
// InstantPageV2View.update. The body's internal layout is forwarded `animation` so a
// *nested* details block inside the body can also animate its own toggle.
let blockHeight: CGFloat
if item.isExpanded {
if let innerLayout = item.innerLayout {
let body: InstantPageV2View
@ -1767,6 +1769,9 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
origin: CGPoint(x: 0.0, y: item.titleFrame.maxY),
size: innerLayout.contentSize
)
blockHeight = body.frame.maxY
} else {
blockHeight = item.titleFrame.maxY
}
} else {
if let existingBody = self.bodyView {
@ -1780,7 +1785,16 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
self.bodyView = nil
}
}
blockHeight = item.titleFrame.maxY
}
self.separator.backgroundColor = item.separatorColor
animation.animator.updateFrame(layer: self.separator.layer, frame: CGRect(
x: 8.0,
y: blockHeight - UIScreenPixel,
width: item.frame.width - 8.0 * 2.0,
height: UIScreenPixel
), completion: nil)
// Chevron rotation. The body teardown on collapse is NOT tied to this completion see
// finalizePendingCollapse(), which the parent calls from the frame-shrink (clip) animation.
@ -1848,6 +1862,58 @@ final class InstantPageV2CodeBlockView: UIView, InstantPageItemView {
}
}
// MARK: - Thinking view (dimmed shimmering reasoning block)
/// A top-level thinking block: dimmed text drawn fully, masked by a continuously-running
/// `ShimmeringMaskView`. Reveal is whole-block alpha (driven from the cost map), NOT char-by-char,
/// and the block contributes zero reveal cost. Structure mirrors `InstantPageV2CodeBlockView`
/// (container hosting an inner `InstantPageV2TextView`).
final class InstantPageV2ThinkingView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2ThinkingItem
var itemFrame: CGRect { return self.item.frame }
private let shimmerView: ShimmeringMaskView
private let textView: InstantPageV2TextView
init(item: InstantPageV2ThinkingItem) {
self.item = item
self.shimmerView = ShimmeringMaskView(peakAlpha: 0.3, duration: 1.0)
let innerV2TextItem = InstantPageV2TextItem(frame: item.textItem.frame, textItem: item.textItem)
self.textView = InstantPageV2TextView(item: innerV2TextItem)
super.init(frame: item.frame)
self.backgroundColor = .clear
self.addSubview(self.shimmerView)
self.shimmerView.contentView.addSubview(self.textView)
self.layoutContents()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
/// Parent positions children (see CLAUDE.md "View frame ownership"): the shimmer covers the
/// whole block; the inner text view sits at its block-local typographic frame (expanded by the
/// text view's clipping inset, matching `InstantPageV2TextView.init`).
private func layoutContents() {
self.shimmerView.frame = CGRect(origin: .zero, size: self.item.frame.size)
self.textView.frame = self.item.textItem.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset)
self.shimmerView.update(
size: self.item.frame.size,
containerWidth: self.item.frame.size.width,
offsetX: 0.0,
gradientWidth: 200.0,
transition: .immediate
)
}
func update(item: InstantPageV2ThinkingItem, theme: InstantPageTheme) {
self.item = item
let innerV2TextItem = InstantPageV2TextItem(frame: item.textItem.frame, textItem: item.textItem)
self.textView.update(item: innerV2TextItem, theme: theme)
self.layoutContents()
}
}
// MARK: - Table view
final class InstantPageV2TableView: UIView, InstantPageItemView {

View file

@ -131,12 +131,12 @@ public final class InstantPageTheme {
public let tableBorderColor: UIColor
public let tableHeaderColor: UIColor
public let controlColor: UIColor
public let imageTintColor: UIColor?
public let overlayPanelColor: UIColor
public let separatorColor: UIColor
public let secondaryControlColor: UIColor
public init(type: InstantPageThemeType, pageBackgroundColor: UIColor, textCategories: InstantPageTextCategories, serif: Bool, codeBlockBackgroundColor: UIColor, linkColor: UIColor, textHighlightColor: UIColor, linkHighlightColor: UIColor, markerColor: UIColor, panelBackgroundColor: UIColor, panelHighlightedBackgroundColor: UIColor, panelPrimaryColor: UIColor, panelSecondaryColor: UIColor, panelAccentColor: UIColor, tableBorderColor: UIColor, tableHeaderColor: UIColor, controlColor: UIColor, imageTintColor: UIColor?, overlayPanelColor: UIColor) {
public init(type: InstantPageThemeType, pageBackgroundColor: UIColor, textCategories: InstantPageTextCategories, serif: Bool, codeBlockBackgroundColor: UIColor, linkColor: UIColor, textHighlightColor: UIColor, linkHighlightColor: UIColor, markerColor: UIColor, panelBackgroundColor: UIColor, panelHighlightedBackgroundColor: UIColor, panelPrimaryColor: UIColor, panelSecondaryColor: UIColor, panelAccentColor: UIColor, tableBorderColor: UIColor, tableHeaderColor: UIColor, controlColor: UIColor, imageTintColor: UIColor?, overlayPanelColor: UIColor, separatorColor: UIColor, secondaryControlColor: UIColor) {
self.type = type
self.pageBackgroundColor = pageBackgroundColor
self.textCategories = textCategories
@ -156,10 +156,12 @@ public final class InstantPageTheme {
self.controlColor = controlColor
self.imageTintColor = imageTintColor
self.overlayPanelColor = overlayPanelColor
self.separatorColor = separatorColor
self.secondaryControlColor = secondaryControlColor
}
public func withUpdatedFontStyles(sizeMultiplier: CGFloat, lineSpacingFactor: CGFloat, forceSerif: Bool) -> InstantPageTheme {
return InstantPageTheme(type: type, pageBackgroundColor: pageBackgroundColor, textCategories: self.textCategories.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), serif: forceSerif, codeBlockBackgroundColor: codeBlockBackgroundColor, linkColor: linkColor, textHighlightColor: textHighlightColor, linkHighlightColor: linkHighlightColor, markerColor: markerColor, panelBackgroundColor: panelBackgroundColor, panelHighlightedBackgroundColor: panelHighlightedBackgroundColor, panelPrimaryColor: panelPrimaryColor, panelSecondaryColor: panelSecondaryColor, panelAccentColor: panelAccentColor, tableBorderColor: tableBorderColor, tableHeaderColor: tableHeaderColor, controlColor: controlColor, imageTintColor: imageTintColor, overlayPanelColor: overlayPanelColor)
return InstantPageTheme(type: type, pageBackgroundColor: pageBackgroundColor, textCategories: self.textCategories.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), serif: forceSerif, codeBlockBackgroundColor: codeBlockBackgroundColor, linkColor: linkColor, textHighlightColor: textHighlightColor, linkHighlightColor: linkHighlightColor, markerColor: markerColor, panelBackgroundColor: panelBackgroundColor, panelHighlightedBackgroundColor: panelHighlightedBackgroundColor, panelPrimaryColor: panelPrimaryColor, panelSecondaryColor: panelSecondaryColor, panelAccentColor: panelAccentColor, tableBorderColor: tableBorderColor, tableHeaderColor: tableHeaderColor, controlColor: controlColor, imageTintColor: imageTintColor, overlayPanelColor: overlayPanelColor, separatorColor: separatorColor, secondaryControlColor: secondaryControlColor)
}
func headingTextAttributes(level: Int32, link: Bool) -> InstantPageTextAttributes {
@ -191,7 +193,7 @@ public final class InstantPageTheme {
baseSize = 13.0
}
let sizeMultiplier = subheaderAttributes.font.size / 19.0
let sizeMultiplier = subheaderAttributes.font.size / 18.0
let attributes = InstantPageTextAttributes(
font: InstantPageFont(style: .serif, size: floor(baseSize * sizeMultiplier), lineSpacingFactor: subheaderAttributes.font.lineSpacingFactor),
color: subheaderAttributes.color,
@ -229,7 +231,9 @@ private let lightTheme = InstantPageTheme(
tableHeaderColor: UIColor(rgb: 0xf4f4f4),
controlColor: UIColor(rgb: 0xc7c7cd),
imageTintColor: nil,
overlayPanelColor: .white
overlayPanelColor: .white,
separatorColor: UIColor(rgb: 0xe2e2e2),
secondaryControlColor: .black
)
private let sepiaTheme = InstantPageTheme(
@ -260,7 +264,9 @@ private let sepiaTheme = InstantPageTheme(
tableHeaderColor: UIColor(rgb: 0xf0e7d4),
controlColor: UIColor(rgb: 0xddd1b8),
imageTintColor: nil,
overlayPanelColor: UIColor(rgb: 0xf8f1e2)
overlayPanelColor: UIColor(rgb: 0xf8f1e2),
separatorColor: UIColor(rgb: 0xe2e2e2),
secondaryControlColor: .black
)
private let grayTheme = InstantPageTheme(
@ -291,7 +297,9 @@ private let grayTheme = InstantPageTheme(
tableHeaderColor: UIColor(rgb: 0x555556),
controlColor: UIColor(rgb: 0x484848),
imageTintColor: UIColor(rgb: 0xcecece),
overlayPanelColor: UIColor(rgb: 0x5a5a5c)
overlayPanelColor: UIColor(rgb: 0x5a5a5c),
separatorColor: UIColor(rgb: 0x484848),
secondaryControlColor: .black
)
private let darkTheme = InstantPageTheme(
@ -322,7 +330,9 @@ private let darkTheme = InstantPageTheme(
tableHeaderColor: UIColor(rgb: 0x131313),
controlColor: UIColor(rgb: 0x303030),
imageTintColor: UIColor(rgb: 0xb0b0b0),
overlayPanelColor: UIColor(rgb: 0x232323)
overlayPanelColor: UIColor(rgb: 0x232323),
separatorColor: UIColor(rgb: 0x303030),
secondaryControlColor: UIColor(rgb: 0xb0b0b0)
)
private func fontSizeMultiplierForVariant(_ variant: InstantPagePresentationFontSize) -> CGFloat {

View file

@ -85,6 +85,7 @@ public enum InstantPageV2LaidOutItem {
case mediaMap(InstantPageV2MediaMapItem)
case mediaCoverImage(InstantPageV2MediaCoverImageItem)
case formula(InstantPageV2FormulaItem)
case thinking(InstantPageV2ThinkingItem)
public var frame: CGRect {
switch self {
@ -103,6 +104,7 @@ public enum InstantPageV2LaidOutItem {
case let .mediaMap(item): return item.frame
case let .mediaCoverImage(item): return item.frame
case let .formula(item): return item.frame
case let .thinking(item): return item.frame
}
}
@ -126,6 +128,7 @@ public enum InstantPageV2LaidOutItem {
case var .mediaMap(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaMap(item)
case var .mediaCoverImage(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaCoverImage(item)
case var .formula(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .formula(item)
case var .thinking(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .thinking(item)
}
}
}
@ -143,6 +146,13 @@ public struct InstantPageV2CodeBlockItem {
public let inset: UIEdgeInsets
}
public struct InstantPageV2ThinkingItem {
public var frame: CGRect
/// The dimmed thinking text, laid out in block-local coordinates. Drawn fully (never
/// char-reveal-masked); the shimmer + whole-block fade are the only animations.
public let textItem: InstantPageTextItem
}
public struct InstantPageV2DividerItem {
public var frame: CGRect
public let color: UIColor
@ -283,6 +293,7 @@ public struct InstantPageV2MediaPlaceholderItem {
public struct InstantPageV2DetailsItem {
public var frame: CGRect
public let index: Int
public let sideInset: CGFloat
public let titleTextItem: InstantPageTextItem
public let titleFrame: CGRect // local to this item's frame
public let separatorColor: UIColor
@ -370,6 +381,7 @@ public func layoutInstantPageV2(
instantPage.blocks,
boundingWidth: boundingWidth,
horizontalInset: horizontalInset,
kind: .topLevel,
context: &context
)
}
@ -481,6 +493,7 @@ private func layoutBlockSequence(
_ blocks: [InstantPageBlock],
boundingWidth: CGFloat,
horizontalInset: CGFloat,
kind: BlockSequenceKind,
context: inout LayoutContext
) -> InstantPageV2Layout {
var items: [InstantPageV2LaidOutItem] = []
@ -489,7 +502,7 @@ private func layoutBlockSequence(
var previousBlock: InstantPageBlock?
for (i, block) in blocks.enumerated() {
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block, fitToWidth: context.fitToWidth)
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block, fitToWidth: context.fitToWidth, kind: kind)
let localItems = layoutBlock(
block,
boundingWidth: boundingWidth,
@ -521,7 +534,7 @@ private func layoutBlockSequence(
}
}
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: context.fitToWidth)
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: context.fitToWidth, kind: kind)
contentHeight += closingSpacing
var contentSize = CGSize(width: boundingWidth, height: contentHeight)
@ -888,9 +901,9 @@ private func layoutBlock(
boundingWidth: boundingWidth, horizontalInset: horizontalInset,
context: &context)
// Block kinds filled in by later tasks:
case .thinking:
return []
case let .thinking(text):
return layoutThinking(text, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
case .unsupported:
return []
}
@ -983,16 +996,19 @@ private func layoutDetails(
let (titleTextItem, _, _) = layoutTextItem(
attributedStringForRichText(title, styleStack: titleStyleStack),
boundingWidth: boundingWidth - horizontalInset * 2.0 - 32.0, // reserve right edge for chevron
offset: CGPoint(x: horizontalInset, y: 12.0),
offset: CGPoint(x: 0.0, y: 0.0),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
guard let titleTextItem = titleTextItem else { return [] }
let titleHeight = max(44.0, titleTextItem.frame.height + 26.0)
titleTextItem.frame.origin.x = horizontalInset + 32.0
titleTextItem.frame.origin.y = floorToScreenPixels((titleHeight - titleTextItem.frame.height) * 0.5)
let isExpanded = context.expandedDetails[index] ?? defaultExpanded
// V1 uses max(44.0, titleSize.height + 26.0); matched here.
let titleHeight = max(44.0, titleTextItem.frame.height + 26.0)
let titleFrame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: titleHeight)
var innerLayout: InstantPageV2Layout?
@ -1002,6 +1018,7 @@ private func layoutDetails(
blocks,
boundingWidth: boundingWidth,
horizontalInset: horizontalInset,
kind: .detail,
context: &context
)
innerLayout = layout
@ -1011,9 +1028,10 @@ private func layoutDetails(
let item = InstantPageV2DetailsItem(
frame: CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: totalHeight),
index: index,
sideInset: horizontalInset,
titleTextItem: titleTextItem,
titleFrame: titleFrame,
separatorColor: context.theme.controlColor.withMultipliedAlpha(0.25),
separatorColor: context.theme.separatorColor,
isExpanded: isExpanded,
innerLayout: innerLayout,
defaultExpanded: defaultExpanded
@ -1279,6 +1297,7 @@ private func layoutTable(
[.paragraph(cellText)],
boundingWidth: cellContentWidth,
horizontalInset: 0.0,
kind: .cell,
context: &context
)
stampMarkdownContext(cellLayout.items, kind: .tableCell(row: i, column: k, isHeader: cell.header))
@ -1503,6 +1522,7 @@ private func layoutTable(
[.paragraph(title)],
boundingWidth: totalWidth - v2TableCellInsets.left * 2.0,
horizontalInset: 0.0,
kind: .cell,
context: &context
)
titleSubLayout = titleLayout
@ -1944,6 +1964,38 @@ private func layoutCodeBlock(
))]
}
private func layoutThinking(
_ text: RichText,
boundingWidth: CGFloat,
horizontalInset: CGFloat,
context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] {
// Dimmed/secondary base color: the paragraph body color at reduced alpha. RichText keeps
// its own bold/italic/link/inline-emoji formatting on top of this base (mirrors the old
// hardcoded "Thinking" header, which used the message theme's dimmed description color).
let base = context.theme.textCategories.paragraph
let dimmedAttributes = InstantPageTextAttributes(
font: base.font,
color: base.color.withAlphaComponent(0.55),
underline: false
)
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: context.theme, attributes: dimmedAttributes)
let attributedString = attributedStringForRichText(text, styleStack: styleStack)
let (textItem, _, textSize) = layoutTextItem(
attributedString,
boundingWidth: boundingWidth - horizontalInset * 2.0,
offset: CGPoint(x: horizontalInset, y: 0.0),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
guard let textItem = textItem else { return [] }
let blockFrame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: textSize.height)
return [.thinking(InstantPageV2ThinkingItem(frame: blockFrame, textItem: textItem))]
}
// MARK: - Block quote / pull quote layout (ported from V1 InstantPageLayout.swift lines 517586)
private func layoutBlockQuote(
@ -1962,7 +2014,7 @@ private func layoutBlockQuote(
}
let verticalInset: CGFloat = 4.0
let lineInset: CGFloat = 20.0
let lineInset: CGFloat = context.fitToWidth ? 12.0 : 20.0
let barWidth: CGFloat = 3.0
let innerBoundingWidth = boundingWidth - horizontalInset * 2.0 - lineInset
@ -2041,7 +2093,7 @@ private func layoutQuoteText(
// V1 line 518/553: verticalInset = 4.0 for both variants.
let verticalInset: CGFloat = 4.0
// V1 line 518: lineInset = 20.0 (blockQuote only; pullQuote uses full width).
let lineInset: CGFloat = isPull ? 0.0 : 20.0
let lineInset: CGFloat = isPull ? 0.0 : (context.fitToWidth ? 12.0 : 20.0)
var result: [InstantPageV2LaidOutItem] = []
var contentHeight: CGFloat = verticalInset // V1 line 520/554: starts at verticalInset
@ -2329,7 +2381,7 @@ private func layoutList(
context: &context
)
let subLocalMaxY: CGFloat = subItems.map { $0.frame.maxY }.max() ?? 0.0
let spacing: CGFloat = (previousBlock != nil && subLocalMaxY > 0.0) ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: context.fitToWidth) : 0.0
let spacing: CGFloat = (previousBlock != nil && subLocalMaxY > 0.0) ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: context.fitToWidth, kind: .list) : 0.0
let offsetX = horizontalInset + indexSpacing + maxIndexWidth
let offsetY = contentHeight + spacing
let translatedItems = subItems.map { $0.offsetBy(CGPoint(x: offsetX, y: offsetY)) }

View file

@ -14,6 +14,7 @@ extension InstantPageV2RevealCostMap {
fileprivate enum Entry {
case text(start: Int, end: Int)
case nonText(start: Int, end: Int)
case thinking(start: Int)
case details(start: Int, end: Int, body: InstantPageV2RevealCostMap?)
case codeBlock(start: Int, end: Int)
case table(start: Int, end: Int, rows: [TableRow], title: InstantPageV2RevealCostMap?)
@ -168,6 +169,11 @@ private func revealedExtent(entry: InstantPageV2RevealCostMap.Entry, item: Insta
let _ = start
if revealedCount < end { return nil }
return item.frame
case let .thinking(start):
// Revealed (and contributes its full height) once the cursor reaches its index position.
// A top thinking block (start == 0) is revealed from the first frame.
if revealedCount < start { return nil }
return item.frame
case let .codeBlock(start, _):
if revealedCount <= start { return nil }
// Block backdrop appears atomically once revealing reaches the block; inner text
@ -323,6 +329,11 @@ private func computeEntries(items: [InstantPageV2LaidOutItem], cursor: inout Int
rows.append(InstantPageV2RevealCostMap.TableRow(startCount: rowStart, cells: cellMaps))
}
entries.append(.table(start: start, end: cursor, rows: rows, title: titleMap))
case .thinking:
// Zero cost: do NOT advance the cursor. This is the linchpin answer-content cursor
// positions are identical whether or not thinking blocks are present, so adding/
// removing a thinking block never jumps the answer's reveal position.
entries.append(.thinking(start: cursor))
case .formula, .mediaImage, .mediaVideo, .mediaMap, .mediaCoverImage, .mediaPlaceholder,
.divider, .listMarker, .blockQuoteBar, .shape, .anchor:
let start = cursor
@ -419,6 +430,11 @@ private func applyRevealEntry(view: InstantPageItemView, entry: InstantPageV2Rev
let visible = revealedCount >= end
applyVisibility(view: view, visible: visible, animated: animated)
let _ = start
case let .thinking(start):
// Whole-block 0.12s alpha fade-in at the index position; inner text is drawn fully
// (never char-reveal-masked) the shimmer is the only ongoing animation.
let visible = revealedCount >= start
applyVisibility(view: view, visible: visible, animated: animated)
}
}

View file

@ -1654,14 +1654,14 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
var allowFullWidth = false
let chatLocationPeerId: PeerId = item.chatLocation.peerId ?? item.content.firstMessage.id.peerId
var isInlinePage = false
/*let isInlinePage = false
for attribute in item.message.attributes {
if attribute is RichTextMessageAttribute {
allowFullWidth = true
isInlinePage = true
break
}
}
}*/
do {
let peerId = chatLocationPeerId
@ -1931,9 +1931,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
needsShareButton = false
}
if isInlinePage {
/*if isInlinePage {
needsShareButton = false
}
}*/
var tmpWidth: CGFloat
if allowFullWidth {

View file

@ -24,10 +24,7 @@ swift_library(
"//submodules/TelegramUI/Components/TextLoadingEffect",
"//submodules/TelegramUIPreferences",
"//submodules/TextSelectionNode",
"//submodules/TelegramUI/Components/ShimmeringMask:ShimmeringMask",
"//submodules/TelegramUI/Components/InteractiveTextComponent:InteractiveTextComponent",
"//submodules/TelegramUI/Components/StreamingTextReveal:StreamingTextReveal",
"//submodules/TelegramUI/Components/TextNodeWithEntities:TextNodeWithEntities",
],
visibility = [
"//visibility:public",

View file

@ -15,9 +15,6 @@ import TelegramUIPreferences
import TextLoadingEffect
import TextSelectionNode
import StreamingTextReveal
import ShimmeringMask
import InteractiveTextComponent
import TextNodeWithEntities
public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode {
public final class ContainerNode: ASDisplayNode {
@ -49,9 +46,6 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
private var textSelectionAdapter: InstantPageMultiTextAdapter?
private var textSelectionNode: TextSelectionNode?
private var streamingStatusTextNode: InteractiveTextNodeWithEntities?
private var streamingStatusShimmerView: ShimmeringMaskView?
private var textRevealController: TextRevealController?
private var textRevealLink: SharedDisplayLinkDriver.Link?
private var currentRevealCostMap: InstantPageV2RevealCostMap?
@ -70,10 +64,10 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
}
// Pushes the current `visibility` sub-rect into `pageView.visibilityRect`, translated into the
// page view's coordinate space (the page view sits at `streamingHeaderOffset` inside the bubble).
// Re-invoked from the apply closure after `pageView.frame` is set, because that offset shifts
// across streamed chunks without a `visibility` change, which would otherwise leave the
// animation-gating rect stale.
// page view's coordinate space (the page view sits at the top of the bubble; no header offset).
// Re-invoked from the apply closure after `pageView.frame` is set, because the pageView's
// y-origin and size can change across streamed chunks (content growth) without a `visibility`
// change, which would otherwise leave the animation-gating rect stale.
private func updatePageViewVisibilityRect() {
guard let pageView = self.pageView else {
return
@ -99,14 +93,22 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
self.addSubnode(self.containerNode)
}
/// Builds (or reuses) the V2View. The render context is constructor-fixed on V2View, so
/// when the bubble is recycled with a different webpage we must rebuild the V2View.
/// Builds (or reuses) the V2View. Same-message stableVersion bumps (streamed AI chunks) reuse
/// the existing view, updating only the webpage content in place. The view is rebuilt only when
/// the bubble is recycled with a different message/webpage (different message id).
private func ensurePageView(item: ChatMessageBubbleContentItem, webpage: TelegramMediaWebpage) -> InstantPageV2View {
let key = (id: item.message.id, stableVersion: item.message.stableVersion)
if let existing = self.pageView,
let current = self.pageViewMessageKey,
current.id == key.id,
current.stableVersion == key.stableVersion {
if let existing = self.pageView, let current = self.pageViewMessageKey, current.id == key.id {
if current.stableVersion == key.stableVersion {
return existing
}
// Same message, new chunk: reuse the view. Update only the content-bearing webpage on
// the existing render context; the subsequent pageView.update(layout:) call diffs item
// views by stable id (content blocks keep their ids, so their views and in-flight
// reveal state persist; only added/removed blocks change). This replaces the old
// wholesale rebuild and eliminates the per-chunk full-text-then-mask flash.
existing.renderContext?.updateContent(webpage: webpage)
self.pageViewMessageKey = key
return existing
}
self.pageView?.removeFromSuperview()
@ -174,7 +176,6 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) {
let previousItem = self.item
let streamingStatusTextLayout = InteractiveTextNodeWithEntities.asyncLayout(self.streamingStatusTextNode)
let currentPageLayout = self.currentPageLayout
let currentExpandedDetails = self.currentExpandedDetails
let statusLayout = ChatMessageDateAndStatusNode.asyncLayout(self.statusNode)
@ -188,25 +189,6 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: 0.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none)
return (contentProperties, nil, CGFloat.greatestFiniteMagnitude, { constrainedSize, position in
// topInset matches TextBubble's logic at lines 234-249 gives the "Thinking"
// header the same vertical alignment as TextBubble's status header does inside
// its bubble.
var topInset: CGFloat = 0.0
if case let .linear(top, _) = position {
switch top {
case .None:
topInset = layoutConstants.text.bubbleInsets.top
case let .Neighbour(_, topType, _):
switch topType {
case .text:
topInset = layoutConstants.text.bubbleInsets.top - 2.0
case .header, .footer, .media, .reactions:
topInset = layoutConstants.text.bubbleInsets.top
}
default:
topInset = layoutConstants.text.bubbleInsets.top
}
}
let suggestedBoundingWidth: CGFloat = constrainedSize.width
var boundingSize = CGSize(width: suggestedBoundingWidth, height: 0.0)
@ -219,7 +201,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
// self-x 0 (containerNode at 1, pageView at -1 inside it), so the page's text
// left edge in the status node's coordinate space is exactly this value. Used as
// the status node's left edge + side inset, mirroring TextBubble's bubbleInsets.
let pageHorizontalInset: CGFloat = 10.0
let pageHorizontalInset: CGFloat = 11.0
let isDark = item.presentationData.theme.theme.overallDarkAppearance
let isIncoming = item.message.effectivelyIncoming(item.context.account.peerId)
@ -296,8 +278,8 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
let textCategories = InstantPageTextCategories(
kicker: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 24.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 19.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 19.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
paragraph: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 17.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor),
caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor),
credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor),
@ -323,7 +305,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
tableHeaderColor: isDark || !isIncoming ? messageTheme.accentControlColor.withMultipliedAlpha(0.1) : UIColor(white: 0.0, alpha: 0.05),
controlColor: messageTheme.accentControlColor,
imageTintColor: nil,
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13)
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13),
separatorColor: isIncoming ? UIColor(white: 0.0, alpha: 0.25): messageTheme.accentControlColor.withMultipliedAlpha(0.25),
secondaryControlColor: messageTheme.secondaryTextColor
)
var hasDraft = false
@ -401,70 +385,10 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
boundingSize.height = effectiveSize.height + 2.0
}
let textFont = item.presentationData.messageFont
let textInsets = UIEdgeInsets(top: 2.0, left: 2.0, bottom: 5.0, right: 2.0)
let streamingTextSpacing: CGFloat = 1.0
let textConstrainedSize = CGSize(width: suggestedBoundingWidth - 4.0, height: .greatestFiniteMagnitude)
var streamingTextLayoutAndApply: (layout: InteractiveTextNodeLayout, apply: (InteractiveTextNodeWithEntities.Arguments) -> InteractiveTextNodeWithEntities)?
if hasDraft || hadDraft {
//TODO:localize
streamingTextLayoutAndApply = streamingStatusTextLayout(InteractiveTextNodeLayoutArguments(
attributedString: NSAttributedString(string: "Thinking...", font: textFont, textColor: messageTheme.fileDescriptionColor),
backgroundColor: nil,
maximumNumberOfLines: 1,
truncationType: .end,
constrainedSize: textConstrainedSize,
alignment: .natural,
cutout: nil,
insets: textInsets,
lineColor: messageTheme.accentControlColor,
customTruncationToken: nil,
computeCharacterRects: true
))
}
// Origin mirrors TextBubble:783 (bubbleInsets.left - textInsets.left,
// topInset - textInsets.top). The negative textInset offsets cancel the
// inset that's baked into the InteractiveTextNode layout, so the visible
// glyph origin aligns with (bubbleInsets.left, topInset).
var streamingTextFrame: CGRect?
if let streamingTextLayoutAndApply {
streamingTextFrame = CGRect(
origin: CGPoint(
x: layoutConstants.text.bubbleInsets.left - textInsets.left,
y: topInset - textInsets.top
),
size: streamingTextLayoutAndApply.layout.size
)
}
// Offset for the pageView (and status node y-shift) places the pageView
// right below the streaming header's *visible* bottom (= origin.y + height
// - inset.bottom, since the layout-baked inset.bottom isn't visible content)
// plus a 1pt spacing.
let streamingHeaderOffset: CGFloat
if let streamingTextFrame {
streamingHeaderOffset = streamingTextFrame.origin.y + streamingTextFrame.height - textInsets.bottom + streamingTextSpacing
} else {
streamingHeaderOffset = 0.0
}
if let streamingTextFrame {
// Mirrors TextBubble's suggestedBoundingWidth contribution at lines 886-893:
// visible_thinking_width + bubbleInsets.left + bubbleInsets.right
// where visible_thinking_width = streamingTextFrame.width - textInsets.left
// - textInsets.right. Adds 2pt for RichData's 1pt-per-side containerNode
// border that TextBubble doesn't have. Without this, an empty-pageLayout
// bubble was sized too narrow to fit the "Thinking" label.
let visibleThinkingWidth = streamingTextFrame.width - textInsets.left - textInsets.right
let thinkingMinBubbleWidth = visibleThinkingWidth + layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right + 2.0
boundingSize.width = max(boundingSize.width, thinkingMinBubbleWidth)
// Adds exactly the vertical space the streaming header consumes before the
// pageView starts (= where pageView's frame.origin.y will be set). Keeps
// the bubble's total height consistent with `containerHeight + closingPad + 2`
// computed in the apply closure.
boundingSize.height += streamingHeaderOffset
}
// The hardcoded "Thinking" header was removed in favor of server-sent
// InstantPageBlock.thinking blocks (rendered inside the pageView). There is no
// header strip anymore, so the page content starts at the top of the bubble.
let streamingHeaderOffset: CGFloat = 0.0
if hasDraft {
// The bubble's bottom inset is supplied by the `statusBottomEdge + 6.0`
@ -762,64 +686,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
self.currentRevealCostMap = nil
}
// 2. Update the "Thinking" header.
if let streamingTextFrame, let streamingTextLayoutAndApply {
var statusAnimation = animation
if self.streamingStatusTextNode == nil {
statusAnimation = .None
}
let streamingStatusTextNode = streamingTextLayoutAndApply.apply(InteractiveTextNodeWithEntities.Arguments(
context: item.context,
cache: item.controllerInteraction.presentationContext.animationCache,
renderer: item.controllerInteraction.presentationContext.animationRenderer,
placeholderColor: messageTheme.mediaPlaceholderColor,
attemptSynchronous: false,
textColor: messageTheme.primaryTextColor,
spoilerEffectColor: messageTheme.secondaryTextColor,
applyArguments: InteractiveTextNode.ApplyArguments(
animation: statusAnimation,
spoilerTextColor: messageTheme.primaryTextColor,
spoilerEffectColor: messageTheme.secondaryTextColor,
areContentAnimationsEnabled: item.context.sharedContext.energyUsageSettings.loopEmoji,
spoilerExpandRect: nil,
crossfadeContents: nil
)
))
let streamingStatusShimmerView: ShimmeringMaskView
if let current = self.streamingStatusShimmerView {
streamingStatusShimmerView = current
} else {
streamingStatusShimmerView = ShimmeringMaskView(peakAlpha: 0.3, duration: 1.0)
self.streamingStatusShimmerView = streamingStatusShimmerView
self.containerNode.view.addSubview(streamingStatusShimmerView)
}
if streamingStatusTextNode !== self.streamingStatusTextNode {
self.streamingStatusTextNode?.textNode.view.removeFromSuperview()
self.streamingStatusTextNode = streamingStatusTextNode
streamingStatusShimmerView.contentView.addSubview(streamingStatusTextNode.textNode.view)
}
statusAnimation.animator.updatePosition(layer: streamingStatusShimmerView.layer, position: streamingTextFrame.center, completion: nil)
statusAnimation.animator.updateBounds(layer: streamingStatusShimmerView.layer, bounds: CGRect(origin: .zero, size: streamingTextFrame.size), completion: nil)
statusAnimation.animator.updatePosition(layer: streamingStatusTextNode.textNode.layer, position: CGPoint(x: streamingTextFrame.size.width * 0.5, y: streamingTextFrame.size.height * 0.5), completion: nil)
statusAnimation.animator.updateBounds(layer: streamingStatusTextNode.textNode.layer, bounds: CGRect(origin: .zero, size: streamingTextFrame.size), completion: nil)
streamingStatusShimmerView.update(
size: streamingTextFrame.size,
containerWidth: streamingTextFrame.size.width,
offsetX: 0.0,
gradientWidth: 200.0,
transition: .immediate
)
} else if let streamingStatusShimmerView = self.streamingStatusShimmerView {
self.streamingStatusTextNode = nil
self.streamingStatusShimmerView = nil
animation.animator.updateAlpha(layer: streamingStatusShimmerView.layer, alpha: 0.0, completion: { [weak streamingStatusShimmerView] _ in
streamingStatusShimmerView?.removeFromSuperview()
})
}
// 3. Drive the reveal controller.
// 2. Drive the reveal controller.
let previousAnimateGlyphCount: Int? = (hasDraft || hadDraft) ? (self.textRevealController?.currentGlyphCount ?? 0) : nil
if previousAnimateGlyphCount != nil || self.textRevealController != nil || hasDraft || hadDraft {
if hasDraft {

View file

@ -698,7 +698,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
))
var streamingTextLayoutAndApply: (layout: InteractiveTextNodeLayout, apply: (InteractiveTextNodeWithEntities.Arguments) -> InteractiveTextNodeWithEntities)?
if hasDraft || hadDraft {
if !"".isEmpty && (hasDraft || hadDraft) {
//TODO:localize
streamingTextLayoutAndApply = streamingStatusTextLayout(InteractiveTextNodeLayoutArguments(
attributedString: NSAttributedString(string: "Thinking...", font: textFont, textColor: messageTheme.fileDescriptionColor),

View file

@ -119,7 +119,9 @@ final class ChatSendMessageRichTextPreview: ChatSendMessageContextScreenRichText
tableHeaderColor: messageTheme.accentControlColor.withMultipliedAlpha(0.1),
controlColor: messageTheme.accentControlColor,
imageTintColor: nil,
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13)
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13),
separatorColor: messageTheme.accentControlColor.withMultipliedAlpha(0.25),
secondaryControlColor: messageTheme.secondaryTextColor
)
let layout = layoutInstantPageV2(