InstantPage V2: render server-sent thinking blocks
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
484b66f28c
commit
e8de87dc7b
15 changed files with 329 additions and 1135 deletions
43
CLAUDE.md
43
CLAUDE.md
|
|
@ -62,9 +62,7 @@ Rare exceptions: top-level view-controller views integrating with the system's f
|
|||
|
||||
## AI streaming animation (rich-text bubbles)
|
||||
|
||||
`ChatMessageRichDataBubbleContentNode` shows a "Thinking…" shimmer header and progressively reveals InstantPage V2 content while `TypingDraftMessageAttribute` is on the message. Mirrors the older animation in `ChatMessageTextBubbleContentNode`, adapted to the heterogeneous V2 layout.
|
||||
|
||||
Spec: [`docs/superpowers/specs/2026-05-19-richdata-streaming-animation-design.md`](docs/superpowers/specs/2026-05-19-richdata-streaming-animation-design.md). Plan: [`docs/superpowers/plans/2026-05-19-richdata-streaming-animation.md`](docs/superpowers/plans/2026-05-19-richdata-streaming-animation.md).
|
||||
`ChatMessageRichDataBubbleContentNode` progressively reveals InstantPage V2 content while `TypingDraftMessageAttribute` is on the message. Mirrors the older animation in `ChatMessageTextBubbleContentNode`, adapted to the heterogeneous V2 layout. The "Thinking…" indicator is now server-sent as `InstantPageBlock.thinking` rendered inside the pageView (see "InstantPage thinking blocks" section).
|
||||
|
||||
### Where things live
|
||||
|
||||
|
|
@ -74,18 +72,18 @@ Spec: [`docs/superpowers/specs/2026-05-19-richdata-streaming-animation-design.md
|
|||
| `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` (`InstantPageV2TextView`) | Drawing split: private `TextRenderView` does `draw(_)` inside a `renderContainer` whose layer carries a `revealMaskLayer`; new chars spawn cropped `SnippetLayer` siblings of the render container that animate in (blur + alpha + scale + position) and are absorbed into the mask on completion. Ported from `InteractiveTextComponent`. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift` | `InstantPageV2RevealCostMap` + `InstantPageV2View.applyReveal(revealedCount:costMap:animated:)`. Bridges the global width-based cursor to per-text-view char counts (via `charCountForWidthBudget`) and per-item visibility / table-row pop-in. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `InstantPageTextLine.characterRects` (line-local CT coords, baseline-relative positive-up) populated when `computeRevealCharacterRects: true` is passed to `layoutInstantPageV2(...)`. Uses `CTFontGetBoundingRectsForGlyphs` for actual glyph ink, not advance widths. |
|
||||
| `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/...` | Streaming detection (`TypingDraftMessageAttribute`), "Thinking…" header layout, display-link wiring, container sizing. |
|
||||
| `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/...` | Streaming detection (`TypingDraftMessageAttribute`), display-link wiring, container sizing. The hardcoded "Thinking…" header was removed; thinking is now rendered by the pageView via `InstantPageBlock.thinking`. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **Cost unit is points of width, not characters.** Each item's cost = its width in points along the reading direction. Text contributes sum of glyph ink widths; non-text items contribute `frame.width`. Table cells are floored at `cell.frame.width` so narrow- or empty-cell tables don't race through the cursor. Reveal pace becomes "points per second" — uniform across content types.
|
||||
- **Mask uses per-glyph ink bounds, unioned per line.** Each revealed glyph's mask rect comes from `CTFontGetBoundingRectsForGlyphs` (not advance widths) so italics, accents, descenders are covered exactly. Per line, glyphs are unioned into one mask rect; consecutive fully-revealed lines union further — fully-revealed prefix is always one `CALayer`.
|
||||
- **`containerNode` does ALL the clipping.** During streaming, containerNode is sized to `streamingHeaderOffset + revealedItemsMaxY` (no closing pad). The bubble itself is taller (`revealedContentSize.height + 2`) — the strip below containerNode is empty bubble background. pageView keeps its full `pageLayout.contentSize`; anything past containerNode's bottom is clipped at containerNode (`clipsToBounds = true` set in init). Do NOT shorten the pageView or set `pageView.clipsToBounds`.
|
||||
- **The pageView is rebuilt on every `stableVersion` bump.** V2View's render context is constructor-fixed, so each AI chunk creates a brand-new `InstantPageV2View`. The reveal cursor survives on `TextRevealController` (owned by the bubble). The seed call `applyReveal(revealedCount: previousAnimateGlyphCount, …, animated: false)` after `ensurePageView` re-applies state to the fresh V2View so there's no flash of full-text-then-mask.
|
||||
- **`containerNode` does ALL the clipping.** During streaming, containerNode is sized to `revealedItemsMaxY` (no header offset, no closing pad; `streamingHeaderOffset` is `0.0`). The bubble itself is taller (`revealedContentSize.height + 2`) — the strip below containerNode is empty bubble background. pageView keeps its full `pageLayout.contentSize`; anything past containerNode's bottom is clipped at containerNode (`clipsToBounds = true` set in init). Do NOT shorten the pageView or set `pageView.clipsToBounds`.
|
||||
- **The pageView is REUSED across `stableVersion` bumps for the same message id.** `ensurePageView` calls `existing.renderContext?.updateContent(webpage:)` (where `webpage` is now a `public private(set) var` with an `updateContent` mutator) and returns the existing view; `update(layout:)` then diffs item views by stable id, tearing down only views whose block was removed. The pageView is rebuilt only when the bubble is recycled with a different message or webpage. The reveal cursor on `TextRevealController` persists across chunks; the seed re-apply (`applyReveal(revealedCount: previousAnimateGlyphCount, …, animated: false)`) is now a continuation from the reused views' state, eliminating the per-chunk flash-of-full-text-then-mask that required the earlier from-scratch re-seed.
|
||||
- **Layout cache key includes `message.stableVersion`.** Each AI chunk bumps stableVersion; without this the cached layout would shadow newly-arrived content.
|
||||
- **`TypingDraftMessageAttribute` is the streaming gate.** Same trigger TextBubble uses. The InstantPage's `isComplete` flag is informational only.
|
||||
- **Width-based cost → char count bridge.** Mask APIs (`updateRevealCharacterCount`) still take character counts. `applyRevealEntry` calls `charCountForWidthBudget(textItem:widthBudget:)` to translate the width-based local cursor into the per-text-view character count.
|
||||
- **`Thinking…` header positioning matches TextBubble.** `streamingTextFrame.origin = (bubbleInsets.left - textInsets.left, topInset - textInsets.top)`. `streamingHeaderOffset` = visible bottom + 1pt spacing = where pageView's `frame.origin.y` and statusFrame y-shift attach. Bubble minimum width includes `visible_thinking_width + bubbleInsets.left + bubbleInsets.right + 2`.
|
||||
- **The hardcoded "Thinking…" header was removed.** `streamingStatusTextNode`, `streamingStatusShimmerView`, and the header-layout machinery no longer exist. `streamingHeaderOffset` is now a constant `0.0` — the pageView starts at the top of the bubble. The "Thinking…" indicator is now server-sent as `InstantPageBlock.thinking` and rendered inside the pageView (see "InstantPage thinking blocks" section below).
|
||||
- **Display-link tick re-layouts on extent change.** Tick reads `revealedContentSize` at the new cursor; if the height differs from the previous cursor, calls `requestFullUpdate`. So the bubble grows in flight when the cursor crosses a line/item boundary, not just between chunks. Tick passes `animated: true` to `applyReveal` to fire the snippet pop-in.
|
||||
|
||||
### Status node (date/time/checks) positioning
|
||||
|
|
@ -95,7 +93,7 @@ The `ChatMessageDateAndStatusNode` mirrors TextBubble's placement, adapted to th
|
|||
- **X is a fixed left edge, not the last line's `minX`.** Anchor x = `pageHorizontalInset` (10pt, the page layout's text inset; pageView sits at self-x 0). The status layout is measured with `boundingWidth - 2·pageHorizontalInset` (mirrors TextBubble's `boundingWidth - sideInsets`) so the right-aligned date lands at the right inset instead of off the bubble. Using `lastTextLineFrame.minX` (which is large for nested/indented last lines) shoved the date off to the right.
|
||||
- **Trail the last line only when the bottom-most item is text.** `lastTextLineFrameIfLastItemIsText(in:)` (in `InstantPageV2Layout.swift`) returns the last line frame *only* when the bottom-most top-level item (max `maxY`) is a `.text`; otherwise nil, so the date wraps below all content (anchored at `contentSize.height`). For tables/images/etc. the date must not trail text buried above the final item.
|
||||
- **InstantPage draws the baseline at the line frame's `maxY`** (`InstantPageRenderer` draws each line at `lineOrigin.y + lineFrame.height`), so the visible text of a plain line sits ~5pt below `maxY`. A date that **trails** on the line (`statusHeight == 0`) adds `trailingBottomPadding` (5pt) to align with the text; a date that **wraps** onto its own line below (`statusHeight > 0`) sits at the bare `maxY`. The pad is 0 for lines taller than their font line height (an inline animated emoji, ~`pointSize·24/17`, already pushes `maxY` down). `lastTextLineFrameIfLastItemIsText` returns `(frame, trailingBottomPadding)`; the bubble applies the pad only in the trailing case.
|
||||
- **Bubble height leaves ~6pt below the date.** One unified formula for all cases: `boundingSize.height = max(boundingSize.height, statusBottomEdge + 6.0)`, where `statusBottomEdge = statusAnchorY + max(1, statusHeight)`. The `statusAnchorY` in the measure (`continue`) closure must mirror the `statusFrameY (+ streamingHeaderOffset)` in the apply closure exactly, or the date will be clipped/misplaced. 6pt matches TextBubble's bottom bubble inset.
|
||||
- **Bubble height leaves ~6pt below the date.** One unified formula for all cases: `boundingSize.height = max(boundingSize.height, statusBottomEdge + 6.0)`, where `statusBottomEdge = statusAnchorY + max(1, statusHeight)`. The `statusAnchorY` in the measure (`continue`) closure must mirror the `statusFrameY` in the apply closure exactly, or the date will be clipped/misplaced. (`streamingHeaderOffset` is `0.0` — there is no header offset to add.) 6pt matches TextBubble's bottom bubble inset.
|
||||
- **`hasDraft` adds the same 6pt at the streaming site.** The status max() above is gated by `!hasDraft`, so during streaming (status hidden, alpha=0) it can't supply the bubble's bottom inset. A separate `boundingSize.height += 6.0` inside `if hasDraft` in the SizeBlock closure does it instead — same 6pt, so the streaming bubble's bottom breathing room matches its post-stream height and there's no 6pt grow-pop when the status node fades in at finalize. The `hadDraft && !hasDraft` finalize pass doesn't need it because `!hasDraft` re-enables the status max(). If you ever refactor the `+6.0` constant out of the status max() into a `bottomInset` (TextBubble's pattern), kill this separate term at the same time — they're two ends of the same invariant.
|
||||
|
||||
## Inline custom emoji (RichText.textCustomEmoji)
|
||||
|
|
@ -119,8 +117,8 @@ The `ChatMessageDateAndStatusNode` mirrors TextBubble's placement, adapted to th
|
|||
- **`ChatTextInputTextCustomEmojiAttribute` is reused end-to-end** (display layer ⇄ layout model). The attribute is written to the placeholder in `attributedStringForRichText` and read back by the V2 line-breaker under the SAME key (`ChatTextInputAttributes.customEmoji`); `InlineStickerItemLayer.init` consumes it directly and resolves the file lazily from `fileId`.
|
||||
- **Emoji participates in the streaming reveal.** Its placeholder char's `characterRect` is overwritten to a full cell (width = `itemSize`, baseline-relative bottom at `y=0`), so the width-based cost map charges it like other content. `updateEmojiReveal` pops the layer in (alpha 0→1 + scale) when `charIndexInItem < currentRevealCharacterCount`; unrevealed → opacity 0.
|
||||
- **Layers sit ABOVE the reveal mask.** They attach to `InstantPageV2TextView.emojiContainerView` (a sibling above `renderContainer`), NOT inside it — so the reveal mask wipes glyphs while emoji pop in independently. Adding a CTRunDelegate-glyph to the mask would clip-wipe them instead.
|
||||
- **Layers are owned by `InstantPageV2View`, not the text view.** Keyed by `InlineStickerItemLayer.Key(id: fileId, index: occurrence)`. The pageView is rebuilt per `stableVersion` bump (see streaming section), so the dict starts fresh each chunk — no orphan/leak across rebuilds; within one view, stale keys are pruned.
|
||||
- **`visibilityRect` gates looping; `nil` means "not visible".** The bubble's `visibility` override pushes a full-width sub-rect to the root `pageView.visibilityRect`, re-pushed in the apply closure after `pageView.frame` is set (because `streamingHeaderOffset` shifts across chunks without a `visibility` change). `propagateVisibilityRect` converts the rect into each nested V2View's coordinate space (`self.convert(_:to:)`) for details bodies / table cells+title, fanning out via each child's `didSet`.
|
||||
- **Layers are owned by `InstantPageV2View`, not the text view.** Keyed by `InlineStickerItemLayer.Key(id: fileId, index: occurrence)`. The pageView is now REUSED across `stableVersion` bumps (see streaming section), so the inline-emoji dict PERSISTS across chunks; `updateInlineEmoji` prunes stale keys (emoji whose blocks have been removed) and creates/repositions layers for new or unchanged emoji each update pass.
|
||||
- **`visibilityRect` gates looping; `nil` means "not visible".** The bubble's `visibility` override pushes a full-width sub-rect to the root `pageView.visibilityRect`, re-pushed in the apply closure after `pageView.frame` is set. `propagateVisibilityRect` converts the rect into each nested V2View's coordinate space (`self.convert(_:to:)`) for details bodies / table cells+title, fanning out via each child's `didSet`.
|
||||
- **CTRunDelegate extent buffers must be freed.** Every inline-attachment arm (`.image`/`.formula`/`.textCustomEmoji`) in `attributedStringForRichText` allocates an `extentBuffer`; the `dealloc` callback must `deallocate()` it (it re-runs per layout pass).
|
||||
|
||||
## RichText entity cases (mention / hashtag / bot command / bank card / auto link)
|
||||
|
|
@ -249,7 +247,7 @@ Spec: [`docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md`](
|
|||
- **API bits are `checkbox`=flags.0, `checked`=flags.1 on ALL FOUR list-item constructors** (`pageListItemText`/`Blocks` and `pageListOrderedItemText`/`Blocks`, in and out — `pageListItemText#2f58683c`, `pageListOrderedItemText#cd3ea036`, etc.). The iOS `Api.*` layer exposes only `flags: Int32`; mask the bits (`apiFlags(fromChecked:)` / `checkedFromApiFlags`). Because state rides the flags (not the text), it survives the server round-trip for sender + recipients — **including the sender's own send-confirmation echo** (`applyUpdateMessage` replaces local attributes with the server's reconstruction, `ApplyUpdateMessage.swift`).
|
||||
- **Tri-state persistence `0=nil, 1=unchecked, 2=checked`** in BOTH Postbox (key `"ck"`, decoded with `decodeInt32ForKey(orElse: 0)`) and FlatBuffers (`checkState:int32`, default 0). Absent/0 → `nil`, so pre-existing stored pages decode unchanged.
|
||||
- **Detection reads `item.checked != nil`** in both layout engines (was `instantPageTaskListMarkerState(item.num)`); the V2 marker kind is `.checklist(checked: item.checked == true, colors:)`. The empty-blocks `.blocks → .text(.plain(" "), num, checked)` promotion must carry `checked` through, not drop it.
|
||||
- **V2 `CheckNode` is hosted directly in a plain `UIView`**, not an ASDisplayNode tree, so `checkNode.displaysAsynchronously = false` is set to avoid a first-draw blank flash (the V2 pageView is rebuilt per streaming chunk — see the AI streaming section). `InstantPageV2CheckboxColors` (background←`panelAccentColor`, stroke←`pageBackgroundColor`, border←`controlColor`) is carried on the `.checklist` payload and mirrors the V1 `instantPageChecklistMarkerTheme`.
|
||||
- **V2 `CheckNode` is hosted directly in a plain `UIView`**, not an ASDisplayNode tree, so `checkNode.displaysAsynchronously = false` is set to avoid a first-draw blank flash. (The V2 pageView is now REUSED across streaming chunks via stable-id diffing — see the AI streaming section; `CheckNode` views survive across chunks as long as their list item is present.) `InstantPageV2CheckboxColors` (background←`panelAccentColor`, stroke←`pageBackgroundColor`, border←`controlColor`) is carried on the `.checklist` payload and mirrors the V1 `instantPageChecklistMarkerTheme`.
|
||||
- **Forward parser keeps `[ ]` detection but routes to `checked`.** `markdownApplyTaskListMarker`/`markdownStrippingTaskListMarker`/`markdownTaskListMarker` still strip the marker from the item text; the state flows into `checked` while ordered items keep their real `"\(ordinal)"` number. The reverse converter emits lowercase `[x]` / `[ ]`, which the forward `hasPrefix` guards re-parse — that is the round-trip contract.
|
||||
- **The enum-arity change is compile-enforced.** Adding the third associated value broke every `.text`/`.blocks` construction/destructure; the full build is the completeness gate. Read-only consumers outside the core set exist (`BrowserInstantPageContent.swift`, `CachedFaqInstantPage.swift`) — grep `\.(text|blocks)\(` repo-wide when touching the enum again.
|
||||
|
||||
|
|
@ -257,7 +255,7 @@ Spec: [`docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md`](
|
|||
|
||||
`InstantPageBlock.blockQuote` carries `(blocks: [InstantPageBlock], caption: RichText)` — a sequence of nested page blocks (paragraphs, headings, lists, code, even nested quotes), not the legacy text-only payload. `.pullQuote` is unchanged (still `(text: RichText, caption: RichText)`; the TL API has no `pullQuoteBlocks` constructor).
|
||||
|
||||
Spec: [`docs/superpowers/specs/2026-05-29-instantpage-blockquote-blocks-design.md`](docs/superpowers/specs/2026-05-29-instantpage-blockquote-blocks-design.md). Plan: [`docs/superpowers/plans/2026-05-29-instantpage-blockquote-blocks.md`](docs/superpowers/plans/2026-05-29-instantpage-blockquote-blocks.md).
|
||||
Spec: [`docs/superpowers/specs/2026-05-29-instantpage-blockquote-blocks-design.md`](docs/superpowers/specs/2026-05-29-instantpage-blockquote-blocks-design.md).
|
||||
|
||||
### Where things live
|
||||
|
||||
|
|
@ -281,6 +279,27 @@ Spec: [`docs/superpowers/specs/2026-05-29-instantpage-blockquote-blocks-design.m
|
|||
- **Entity-expressibility:** a quote is entity-expressible (→ regular message path) only if its caption is empty AND every child is an entity-expressible `.paragraph`. A nested-structure or multi-paragraph quote is not, so it sends via the rich path. **Behavior change:** markdown `> p1\n>\n> p2` is now ONE quote with two paragraphs (rich) rather than two consecutive entity quotes — correct semantics.
|
||||
- **The enum-arity change is compile-enforced** across all modules; the full Bazel build is the completeness gate (no per-module build). `CachedFaqInstantPage.swift` matches `case .blockQuote:` payload-less and needs no edit. `BrowserReadability.swift` constructs `.blockQuote(blocks: [.paragraph(.italic(...))], …)` and is easy to miss in the spec's file list — grep `\.blockQuote(` repo-wide when touching the case again.
|
||||
|
||||
## InstantPage thinking blocks (InstantPageBlock.thinking)
|
||||
|
||||
`InstantPageBlock.thinking(RichText)` renders server-sent reasoning as dimmed, continuously-shimmering text inside rich-data bubbles. V2 renderer only; V1 ignores the block (returns `[]`). The shimmer and fade-in mechanics are deliberately separate from the char-reveal cursor so thinking blocks do not affect the reveal pacing of the answer content that follows them.
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `InstantPageV2ThinkingItem` layout item + `layoutThinking(...)` (paragraph color × 0.55 alpha for the dimmed style) + `layoutBlock` `.thinking` arm. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` | `InstantPageV2ThinkingView` — a `ShimmeringMaskView` wrapping a private inner `InstantPageV2TextView`; `InstantPageV2StableItemId.thinking(Int)` stable-id namespace; `makeItemView`/`reuse`/`stableId` arms for the `.thinking` item kind; the two-counter (content + thinking) stable-id loop in `InstantPageV2View.update`. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift` | `.thinking(start:)` cost entry: contributes **zero** cursor cost; triggers whole-block alpha fade-in when `revealedCount >= start`. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageLayout.swift` | V1 has no explicit `.thinking` case — it falls through `layoutInstantPageBlock`'s `default:` to an empty layout (no-op). |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **Zero reveal cost is the linchpin.** Thinking blocks do not advance the width-based cursor, so the answer's reveal position is identical whether or not thinking blocks are present — and is unaffected as they appear and disappear across streaming chunks. The answer text always reveals at the same rate regardless of how much thinking precedes it.
|
||||
- **Whole-block fade, not char reveal.** The inner text is drawn fully under the shimmer mask at all times; the reveal mechanism is a simple alpha visibility keyed to the block's `start` index. A top-of-page thinking block (`start == 0`) is visible from the very first frame.
|
||||
- **Shimmer runs continuously while the view is displayed** via `ShimmeringMaskView`'s `HierarchyTrackingLayer` self-animation. It does not stop when streaming ends.
|
||||
- **Top-level only; separate stable-id namespace.** Thinking blocks appear only at the top level of the page. They use the `InstantPageV2StableItemId.thinking(Int)` namespace, numbered by a counter independent of content blocks. This means adding or removing a thinking block never renumbers the stable ids of content blocks — which, combined with pageView reuse, ensures content views and reveal state persist as thinking blocks come and go across chunks.
|
||||
- **V1 is a no-op.** `InstantPageLayout.swift` has no `.thinking` case; the block falls through `layoutInstantPageBlock`'s `default:` to an empty layout, so V1 rendering silently skips it.
|
||||
|
||||
## Postbox → TelegramEngine refactor (in progress)
|
||||
|
||||
A gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`.
|
||||
|
|
|
|||
|
|
@ -1,642 +0,0 @@
|
|||
# InstantPage V2 thinking-block rendering + cross-update view reuse — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Render top-level `InstantPageBlock.thinking(RichText)` as a dimmed, always-shimmering, reusable block in the InstantPage V2 renderer (zero reveal-cost, whole-block fade-in at its index); remove the hardcoded "Thinking…" header from the rich-data bubble; and make the streaming pageView reuse views across chunks instead of rebuilding wholesale.
|
||||
|
||||
**Architecture:** A new `InstantPageV2LaidOutItem.thinking` case carries a single dimmed `InstantPageTextItem`; a new `InstantPageV2ThinkingView` hosts that text inside a `ShimmeringMaskView`; a new zero-cost `.thinking` reveal-cost entry fades the whole view in when the cursor reaches its index. The pageView stops rebuilding per `stableVersion` by making `InstantPageV2RenderContext.webpage` mutable and switching `ensurePageView` to update-in-place, leaning on the renderer's existing stable-id diffing — with thinking blocks placed in their own stable-id namespace so their churn never renumbers content blocks.
|
||||
|
||||
**Tech Stack:** Swift, UIKit, AsyncDisplayKit, CoreText; Bazel via `build-system/Make/Make.py`. **This repo has NO unit tests and NO per-module build** (CLAUDE.md) — the only build is the full `Telegram/Telegram` target, which is also the enum-arity completeness gate. Verification is therefore: (a) the full Bazel build at the integration checkpoints below, and (b) manual streaming inspection in Task 11.
|
||||
|
||||
---
|
||||
|
||||
## Spec
|
||||
|
||||
`docs/superpowers/specs/2026-05-29-instantpage-thinking-block-design.md`
|
||||
|
||||
## Build command (the integration gate)
|
||||
|
||||
```sh
|
||||
source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \
|
||||
--cacheDir ~/telegram-bazel-cache \
|
||||
build \
|
||||
--configurationPath build-system/appstore-configuration.json \
|
||||
--gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \
|
||||
--gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 \
|
||||
--continueOnError
|
||||
```
|
||||
|
||||
`--continueOnError` surfaces every error in one pass (the enum-arity change touches many switches). The controller must drive this build directly (run_in_background + capture real exit code), not a subagent — see the `feedback_subagent_build_execution` memory.
|
||||
|
||||
## Completeness-gate switch inventory (every exhaustive switch over `InstantPageV2LaidOutItem` / cost-map `Entry`)
|
||||
|
||||
Adding `InstantPageV2LaidOutItem.thinking` breaks these and they MUST each gain a `.thinking` arm (those with a `default:` need no edit — listed for completeness):
|
||||
|
||||
| File | Symbol | Line (approx) | Has `default:`? |
|
||||
|---|---|---|---|
|
||||
| `InstantPageV2Layout.swift` | `collectMedias` | 47 | **yes — no edit** |
|
||||
| `InstantPageV2Layout.swift` | `frame` computed prop | 90 | no — **edit** |
|
||||
| `InstantPageV2Layout.swift` | `offsetBy` | 113 | no — **edit** |
|
||||
| `InstantPageV2Layout.swift` | `layoutBlock` (switches `InstantPageBlock`, not the laid-out item) | 892 (`case .thinking`) | n/a — **replace stub** |
|
||||
| `InstantPageRenderer.swift` | `reuse` | 572 | no — **edit** |
|
||||
| `InstantPageRenderer.swift` | `stableId(for:atPosition:)` | 636 | no — **edit** |
|
||||
| `InstantPageRenderer.swift` | `makeItemView` | 700 | no — **edit** |
|
||||
| `InstantPageV2RevealCost.swift` | `computeEntries` | 258 | no — **edit** |
|
||||
| `InstantPageV2RevealCost.swift` | `revealedExtent` (switches cost `Entry`) | 159 | no — **edit (new Entry case)** |
|
||||
| `InstantPageV2RevealCost.swift` | `applyRevealEntry` (switches cost `Entry`) | 380 | no — **edit (new Entry case)** |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add the `.thinking` laid-out item model
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` (enum + 2 switches)
|
||||
|
||||
- [ ] **Step 1: Add the item struct.** After the `InstantPageV2CodeBlockItem` struct (ends at `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift:144`), insert:
|
||||
|
||||
```swift
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the enum case.** In `InstantPageV2LaidOutItem` (the `case formula(...)` line is `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift:87`), add after it:
|
||||
|
||||
```swift
|
||||
case thinking(InstantPageV2ThinkingItem)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Handle it in the `frame` computed property.** In the switch starting at `InstantPageV2Layout.swift:90`, after `case let .formula(item): return item.frame` add:
|
||||
|
||||
```swift
|
||||
case let .thinking(item): return item.frame
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Handle it in `offsetBy`.** In the switch starting at `InstantPageV2Layout.swift:113`, after the `.formula` line add:
|
||||
|
||||
```swift
|
||||
case var .thinking(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .thinking(item)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit** (will not build standalone — no per-module build exists; full build runs at the Task 6 checkpoint).
|
||||
|
||||
```bash
|
||||
git add submodules/InstantPageUI/Sources/InstantPageV2Layout.swift
|
||||
git commit -m "InstantPage V2: add .thinking laid-out item model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Lay out the thinking block
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` (new `layoutThinking` + `layoutBlock` arm)
|
||||
|
||||
- [ ] **Step 1: Add `layoutThinking`.** Insert immediately after `layoutCodeBlock` (which ends at `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift:1945`):
|
||||
|
||||
```swift
|
||||
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))]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the `layoutBlock` stub.** At `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift:892`, replace:
|
||||
|
||||
```swift
|
||||
case .thinking:
|
||||
return []
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
case let .thinking(text):
|
||||
return layoutThinking(text, boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset, context: &context)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit.**
|
||||
|
||||
```bash
|
||||
git add submodules/InstantPageUI/Sources/InstantPageV2Layout.swift
|
||||
git commit -m "InstantPage V2: lay out .thinking block as dimmed text"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add the `ShimmeringMask` dependency to InstantPageUI
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/InstantPageUI/BUILD`
|
||||
|
||||
- [ ] **Step 1: Inspect current deps.** Run:
|
||||
|
||||
```bash
|
||||
grep -n "ComponentFlow\|HierarchyTracking\|ShimmeringMask\|deps = \[" submodules/InstantPageUI/BUILD
|
||||
```
|
||||
|
||||
Expected: `//submodules/ComponentFlow:ComponentFlow` present; no `ShimmeringMask`.
|
||||
|
||||
- [ ] **Step 2: Add the dep.** In the `deps = [ ... ]` list of the `swift_library` in `submodules/InstantPageUI/BUILD`, add (next to the existing `ComponentFlow` line, keeping the list's existing ordering/indentation):
|
||||
|
||||
```python
|
||||
"//submodules/TelegramUI/Components/ShimmeringMask:ShimmeringMask",
|
||||
```
|
||||
|
||||
`ShimmeringMask` itself depends on `ComponentFlow`, `Display`, and `HierarchyTrackingLayer`; those resolve transitively, so no other dep line is required.
|
||||
|
||||
- [ ] **Step 3: Commit.**
|
||||
|
||||
```bash
|
||||
git add submodules/InstantPageUI/BUILD
|
||||
git commit -m "InstantPageUI: depend on ShimmeringMask for thinking-block shimmer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add `InstantPageV2ThinkingView` + renderer wiring
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` (import, stable-id enum, new view class, `makeItemView`, `reuse`, `stableId`)
|
||||
|
||||
- [ ] **Step 1: Import ShimmeringMask.** At the top of `submodules/InstantPageUI/Sources/InstantPageRenderer.swift`, with the other imports, add:
|
||||
|
||||
```swift
|
||||
import ShimmeringMask
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the stable-id case.** In `InstantPageV2StableItemId` (`submodules/InstantPageUI/Sources/InstantPageRenderer.swift:29`), after `case details(Int)` add:
|
||||
|
||||
```swift
|
||||
case thinking(Int) // thinking-block sequence index (own namespace)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the view class.** Immediately after `InstantPageV2CodeBlockView` (ends at `submodules/InstantPageUI/Sources/InstantPageRenderer.swift:1849`), insert:
|
||||
|
||||
```swift
|
||||
// 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
|
||||
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()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire `makeItemView`.** In the switch at `submodules/InstantPageUI/Sources/InstantPageRenderer.swift:700`, after the `case let .formula(formula):` arm (the last one), add:
|
||||
|
||||
```swift
|
||||
case let .thinking(thinking):
|
||||
return InstantPageV2ThinkingView(item: thinking)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Wire `reuse`.** In the switch at `submodules/InstantPageUI/Sources/InstantPageRenderer.swift:572`, after the last media arm (before the closing `}` of the switch), add:
|
||||
|
||||
```swift
|
||||
case let .thinking(thinking):
|
||||
guard let v = existingView as? InstantPageV2ThinkingView else { return nil }
|
||||
v.update(item: thinking, theme: theme)
|
||||
return v
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Wire `stableId`.** In the switch at `submodules/InstantPageUI/Sources/InstantPageRenderer.swift:636`, after `case let .formula(...)` add:
|
||||
|
||||
```swift
|
||||
case .thinking: return .thinking(position)
|
||||
```
|
||||
|
||||
(The `update()` loop passes a thinking-specific index here — see Task 5.)
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
|
||||
```bash
|
||||
git add submodules/InstantPageUI/Sources/InstantPageRenderer.swift
|
||||
git commit -m "InstantPage V2: add InstantPageV2ThinkingView + renderer wiring"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Separate stable-id namespaces in the update loop
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` (the `update()` loop at lines 215-242)
|
||||
|
||||
- [ ] **Step 1: Replace the enumerate loop header.** At `submodules/InstantPageUI/Sources/InstantPageRenderer.swift:215`, replace:
|
||||
|
||||
```swift
|
||||
for (position, item) in layout.items.enumerated() {
|
||||
let id = InstantPageV2View.stableId(for: item, atPosition: position)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
// 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
|
||||
}
|
||||
```
|
||||
|
||||
(The rest of the loop body — the `if let existing … else …` block, lines 218-241 — is unchanged. Confirm the loop's closing brace still matches.)
|
||||
|
||||
- [ ] **Step 2: Verify no other use of the old `position` variable.** Run:
|
||||
|
||||
```bash
|
||||
sed -n '215,243p' submodules/InstantPageUI/Sources/InstantPageRenderer.swift
|
||||
```
|
||||
|
||||
Expected: the only references to a position value inside the loop are the two `stableId(... atPosition:)` calls just edited; `actualFrame(forItem:)` and the rest use `item`/`reusedView`, not `position`.
|
||||
|
||||
- [ ] **Step 3: Commit.**
|
||||
|
||||
```bash
|
||||
git add submodules/InstantPageUI/Sources/InstantPageRenderer.swift
|
||||
git commit -m "InstantPage V2: namespace thinking vs content stable ids"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Zero-cost reveal entry for thinking + first build checkpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift` (Entry enum + 3 switches + clear path)
|
||||
|
||||
- [ ] **Step 1: Add the Entry case.** In the `fileprivate enum Entry` (`submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift:14`), after `case nonText(start: Int, end: Int)` add:
|
||||
|
||||
```swift
|
||||
case thinking(start: Int)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Emit a zero-cost entry in `computeEntries`.** In the switch at `submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift:258`, add a dedicated arm BEFORE the grouped `case .formula, .mediaImage, …` arm (line 326):
|
||||
|
||||
```swift
|
||||
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))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Contribute height when revealed in `revealedExtent`.** In the switch at `submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift:159`, after the `case let .nonText(start, end):` arm add:
|
||||
|
||||
```swift
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Fade the whole view in `applyRevealEntry`.** In the switch at `submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift:380`, after the `case let .nonText(start, end):` arm add:
|
||||
|
||||
```swift
|
||||
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)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Handle the clear path.** In `clearRevealOn` (`submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift:533`), after the `InstantPageV2TableView` block (ends ~line 551, before the final `applyVisibility(view: view, visible: true, …)` line) add:
|
||||
|
||||
```swift
|
||||
if let thinkingView = view as? InstantPageV2ThinkingView {
|
||||
// Defensive: the inner text is never char-masked, but ensure it's full on clear.
|
||||
thinkingView.textView.updateRevealCharacterCount(value: nil, animated: animated)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Build checkpoint (full Bazel build).** This is the first point where all InstantPageUI enum-arity edits are complete. Run the build command from the top of this plan (controller-driven, run_in_background, capture exit).
|
||||
- Expected: **BUILD SUCCESSFUL**, or only errors in files this plan has not yet touched.
|
||||
- If errors: every error should name one of the inventory switches above or a typo in the new code — fix inline before committing.
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
|
||||
```bash
|
||||
git add submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift
|
||||
git commit -m "InstantPage V2: zero-cost reveal entry + whole-block fade for thinking"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Make the render context updatable
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` (`InstantPageV2RenderContext`)
|
||||
|
||||
- [ ] **Step 1: Make `webpage` mutable + add an update method.** In `InstantPageV2RenderContext` (`submodules/InstantPageUI/Sources/InstantPageRenderer.swift:49`), change:
|
||||
|
||||
```swift
|
||||
public let webpage: TelegramMediaWebpage
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```swift
|
||||
public private(set) var webpage: TelegramMediaWebpage
|
||||
```
|
||||
|
||||
Then, immediately after the `init(...)` closing brace (currently `submodules/InstantPageUI/Sources/InstantPageRenderer.swift:80`), add:
|
||||
|
||||
```swift
|
||||
/// 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
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit.**
|
||||
|
||||
```bash
|
||||
git add submodules/InstantPageUI/Sources/InstantPageRenderer.swift
|
||||
git commit -m "InstantPage V2: make render context webpage updatable"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Reuse the pageView across chunks (`ensurePageView`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` (`ensurePageView`, lines 104-145)
|
||||
|
||||
- [ ] **Step 1: Update-in-place on a stableVersion-only change.** In `ensurePageView` (`…/ChatMessageRichDataBubbleContentNode.swift:104`), replace the early-return + teardown block (lines 105-113):
|
||||
|
||||
```swift
|
||||
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 {
|
||||
return existing
|
||||
}
|
||||
self.pageView?.removeFromSuperview()
|
||||
self.pageView = nil
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
let key = (id: item.message.id, stableVersion: item.message.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 — see Task 5 — 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()
|
||||
self.pageView = nil
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit.**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift
|
||||
git commit -m "RichData bubble: reuse pageView across streaming chunks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Remove the hardcoded "Thinking…" header
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift`
|
||||
|
||||
This removes the built-in header and collapses `streamingHeaderOffset` to a constant `0`. Do the edits in order; several reference each other.
|
||||
|
||||
- [ ] **Step 1: Remove the stored properties.** Delete lines `…:52-53`:
|
||||
|
||||
```swift
|
||||
private var streamingStatusTextNode: InteractiveTextNodeWithEntities?
|
||||
private var streamingStatusShimmerView: ShimmeringMaskView?
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove the async-layout closure capture.** Delete line `…:177`:
|
||||
|
||||
```swift
|
||||
let streamingStatusTextLayout = InteractiveTextNodeWithEntities.asyncLayout(self.streamingStatusTextNode)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove the header layout block.** Delete the whole `streamingTextLayoutAndApply` block (`…:409-425`):
|
||||
|
||||
```swift
|
||||
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
|
||||
))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace the streaming-frame / offset block.** Replace `…:431-467` (the `var streamingTextFrame …` through the `boundingSize.height += streamingHeaderOffset` block) with a constant-zero offset:
|
||||
|
||||
```swift
|
||||
// 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
|
||||
```
|
||||
|
||||
(This deletes `streamingTextFrame`, the `thinkingMinBubbleWidth` width bump, the `boundingSize.height += streamingHeaderOffset` line, and the `streamingTextSpacing` use. Keep `streamingHeaderOffset` as a `let 0.0` so the downstream references in Step 6 still compile; a follow-up could inline it, but leaving it avoids a wide diff.)
|
||||
|
||||
- [ ] **Step 5: Remove the now-unused `streamingTextSpacing`.** Delete line `…:406`:
|
||||
|
||||
```swift
|
||||
let streamingTextSpacing: CGFloat = 1.0
|
||||
```
|
||||
|
||||
Then run `grep -n "streamingTextSpacing\|textConstrainedSize" submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` — if `textConstrainedSize` (line ~408) is now unused (it fed only the deleted header layout), delete its declaration too. The build is warnings-as-error (`project_swift_warnings_as_errors` memory), so unused `let`s fail.
|
||||
|
||||
- [ ] **Step 6: Remove the apply-time header wiring.** Delete the entire "2. Update the "Thinking…" header." block — `…:765-820` — from the comment `// 2. Update the "Thinking…" header.` through the closing `}` of the `else if let streamingStatusShimmerView` branch (the block that ends by removing `streamingStatusShimmerView` from its superview). After deletion, the surrounding numbered comments ("1. Compute / cache the cost map." above, "3. Drive the reveal controller." below) should be adjacent.
|
||||
|
||||
- [ ] **Step 7: Remove the import.** Delete line `…:18`:
|
||||
|
||||
```swift
|
||||
import ShimmeringMask
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Grep for stragglers.** Run:
|
||||
|
||||
```bash
|
||||
grep -n "streamingStatus\|streamingTextFrame\|streamingTextLayoutAndApply\|thinkingMinBubbleWidth\|ShimmeringMask\|Thinking" submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift
|
||||
```
|
||||
|
||||
Expected: **no matches** (the only remaining `streamingHeaderOffset` references are the `let = 0.0` and the status-node `statusAnchorY`/`statusFrameY (+ streamingHeaderOffset)` arithmetic, which now add 0 and are harmless). If any `streamingStatus*` / `streamingTextFrame` / `Thinking` match remains, remove it.
|
||||
|
||||
- [ ] **Step 9: Commit.**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift
|
||||
git commit -m "RichData bubble: remove hardcoded Thinking header (server-controlled now)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Second build checkpoint (bubble changes)
|
||||
|
||||
**Files:** none (build + fix)
|
||||
|
||||
- [ ] **Step 1: Full Bazel build.** Run the build command from the top of this plan (controller-driven).
|
||||
- Expected: **BUILD SUCCESSFUL**.
|
||||
- Common failures to watch for: leftover reference to a deleted `streamingStatus*` symbol (Task 9), an unused-`let` warnings-as-error (`textConstrainedSize`/`streamingTextSpacing`), or `ShimmeringMask` import still present in the bubble. Fix inline.
|
||||
|
||||
- [ ] **Step 2: If fixes were needed, commit them.**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "Fix build after thinking-block integration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Manual verification
|
||||
|
||||
**Files:** none
|
||||
|
||||
- [ ] **Step 1: Run the app on the simulator** (use the project's run tooling / XcodeBuildMCP, simulator `debug_sim_arm64`).
|
||||
|
||||
- [ ] **Step 2: Stream an AI rich message whose server payload includes one or more top-level `.thinking` blocks interleaved with answer content.** Confirm each, against the spec's Verification section:
|
||||
- thinking blocks render in dimmed/secondary color and **shimmer continuously** (streaming and after completion);
|
||||
- a top thinking block is visible from the start; a thinking block placed after content **fades in** (0.12s) as the reveal cursor passes its position;
|
||||
- the answer's reveal pace does **not** jump when a thinking block appears or disappears across chunks (zero-cost);
|
||||
- across chunks, answer-content blocks are **reused** — no per-chunk full-text flash, no reveal reset;
|
||||
- the bubble grows to contain revealed thinking blocks; the date/status node sits correctly with the header gone;
|
||||
- a finalized message that still carries a thinking block renders it (shimmering).
|
||||
|
||||
- [ ] **Step 3: Capture a screen recording / screenshots** for the PR and note any visual deltas from the spec.
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Update CLAUDE.md invariants
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Rewrite the flipped invariants in the "AI streaming animation" section.** Update:
|
||||
- The bullet "**The pageView is rebuilt on every `stableVersion` bump.**" → describe the new behavior: the pageView is now **reused** across `stableVersion` bumps (same message id); `ensurePageView` updates the render context's webpage and lets `update(layout:)` diff by stable id. Views are torn down only when their block is removed.
|
||||
- The inline-emoji note "**the dict starts fresh each chunk — no orphan/leak across rebuilds**" → the dict now **persists** across chunks; stale keys are pruned within the view by `updateInlineEmoji`/`updateInlineImages`.
|
||||
- The "Status node positioning" / `streamingHeaderOffset` notes → the hardcoded "Thinking…" header is gone; `streamingHeaderOffset` is a constant `0`; thinking content is server-sent `InstantPageBlock.thinking` rendered inside the pageView.
|
||||
|
||||
- [ ] **Step 2: Add a new subsection documenting thinking blocks** (under the InstantPage sections), covering: top-level only; `InstantPageV2LaidOutItem.thinking` + `InstantPageV2ThinkingView` (shimmer over dimmed text); zero reveal-cost + whole-block fade at index; separate `.thinking(Int)` stable-id namespace; V1 no-op. Link the spec + this plan.
|
||||
|
||||
- [ ] **Step 3: Commit.**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "Docs: thinking blocks + pageView cross-chunk reuse invariants"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (for the implementer)
|
||||
|
||||
- **Spec coverage:** Part B.2 → Task 2; B.3 → Tasks 3-4; B.4 → Task 6; A.1 → Task 7; A.2 → Task 8; A.3 → Task 5; A.4 → Task 9; CLAUDE.md risk → Task 12; V1 no-op → unchanged (the spec requires no V1 edit; `InstantPageLayout.swift` has no `InstantPageV2LaidOutItem` switch). Visual style / shimmer-always / dimmed color → Tasks 2 + 4.
|
||||
- **Type consistency:** `InstantPageV2ThinkingItem` (frame + textItem) is constructed in Task 2 and consumed in Tasks 4 (view) and 6 (cost). `InstantPageV2ThinkingView.textView` (exposed `let`) is read by `clearRevealOn` in Task 6. `InstantPageV2StableItemId.thinking(Int)` defined in Task 4, produced in Tasks 4 (`stableId`) + 5 (loop). Cost `Entry.thinking(start:)` defined in Task 6 and handled in all three Entry switches in the same task.
|
||||
- **No per-module build:** intermediate tasks are not independently buildable; the two full-build checkpoints (Tasks 6 and 10) are the real gates.
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
# Server-controlled thinking blocks + cross-update view reuse (InstantPage V2)
|
||||
|
||||
**Date:** 2026-05-29
|
||||
**Status:** Approved (design)
|
||||
|
||||
## Summary
|
||||
|
||||
Render the top-level `InstantPageBlock.thinking(RichText)` block in the InstantPage **V2**
|
||||
renderer as a dimmed, always-shimmering, reusable block view. The visual treatment matches the
|
||||
existing `streamingStatusTextNode` + `streamingStatusShimmerView` "Thinking…" header — but driven
|
||||
by **server-sent** content instead of a hardcoded string, and packaged as a first-class V2 block
|
||||
item view. The hardcoded header in `ChatMessageRichDataBubbleContentNode` is removed.
|
||||
|
||||
This is a **two-part, coupled** design:
|
||||
|
||||
- **Part A — Cross-update view reuse.** Stop rebuilding the entire `InstantPageV2View` on every
|
||||
streaming chunk (`stableVersion` bump). Reuse item views across page updates; tear a view down
|
||||
only when its block is actually removed. This is what lets thinking blocks "come and go" across
|
||||
chunks without disturbing the answer content's views or reveal animation.
|
||||
- **Part B — Thinking block rendering.** Lay out, display, and reveal-animate the thinking block;
|
||||
remove the hardcoded header. V1 ignores thinking blocks (no-op).
|
||||
|
||||
The two parts are coupled: thinking blocks are explicitly allowed to appear and disappear across
|
||||
chunks, and the requirement that this "should not affect other blocks" is only meaningful once
|
||||
views survive across chunks (Part A) and thinking carries a separate id namespace + zero reveal
|
||||
cost (Part B).
|
||||
|
||||
## Motivation / requirements (from the requester)
|
||||
|
||||
1. Add layout display of `InstantPageBlock.thinking` in the InstantPage **V2** renderer. V1 may
|
||||
ignore them.
|
||||
2. The built-in hardcoded "Thinking…" implementation at
|
||||
`ChatMessageRichDataBubbleContentNode.swift:412` must be **removed** in favor of
|
||||
server-controlled thinking blocks.
|
||||
3. Take care of the message reveal animation.
|
||||
4. Thinking blocks **may come and go** across chunks, and they must **not** take part in reveal
|
||||
cost estimation. Yet they must follow the reveal fade-in *as if* they had a cost position:
|
||||
reveal (fade in) when `revealedCount >= the thinking block's index position`.
|
||||
5. Thinking blocks may appear **only at the top level** of the instant page. There may be **any
|
||||
number** of them.
|
||||
6. Adding or removing thinking blocks must **not affect other blocks**. This implies a **separate
|
||||
id assignment** for thinking blocks vs. all other blocks.
|
||||
7. **Views should not be torn down under any page update unless they were removed. Views should be
|
||||
reused whenever possible.**
|
||||
|
||||
## Design decisions (confirmed)
|
||||
|
||||
- **Visual style:** identical to the existing `streamingStatusTextNode` rendered inside a
|
||||
`ShimmeringMaskView` mask — a shimmering text region — refactored into a reusable block item view.
|
||||
- **Shimmer lifecycle:** the block shimmers **continuously while displayed**, streaming or not.
|
||||
The `ShimmeringMaskView` self-animates via its `HierarchyTrackingLayer` whenever in-hierarchy, so
|
||||
the view owns its own animation regardless of message state.
|
||||
- **Base text color:** **dimmed / secondary** (matching the old header's
|
||||
`messageTheme.fileDescriptionColor`). RichText keeps its own bold/italic/link/inline-emoji
|
||||
formatting on top of this base color.
|
||||
- **Reveal mechanic:** **zero reveal-cost**; the whole block **alpha-fades in (0.12s)** once the
|
||||
reveal cursor reaches its index position; the inner text is drawn **fully** (not char-by-char)
|
||||
under the shimmer.
|
||||
- **Header removal:** **fully remove** the hardcoded header and its `streamingHeaderOffset`
|
||||
machinery. **No empty-state fallback** — the bubble is empty until the server sends its first
|
||||
block (thinking or answer).
|
||||
- **Placement:** thinking blocks appear only at the top level; any count; rendered in normal
|
||||
block flow exactly where they sit in the sequence.
|
||||
- **View reuse:** Part A is **in scope** for this task. `InstantPageV2RenderContext` becomes
|
||||
updatable to enable it.
|
||||
|
||||
---
|
||||
|
||||
## Part B — Thinking block rendering
|
||||
|
||||
### B.1 Core model (`TelegramCore`)
|
||||
|
||||
`InstantPageBlock.thinking(RichText)` already exists (parsing, Postbox + FlatBuffers
|
||||
serialization, API parse, `apiInputBlock` returns nil = output-only). **No core changes.** The
|
||||
layout layer is the only thing that needs to learn to render it.
|
||||
|
||||
### B.2 Layout (`submodules/InstantPageUI/Sources/InstantPageV2Layout.swift`)
|
||||
|
||||
- Add `InstantPageV2LaidOutItem.thinking(InstantPageV2ThinkingItem)`. `InstantPageV2ThinkingItem`
|
||||
holds:
|
||||
- the inner laid-out text (an `InstantPageTextItem` / sub-text-layout produced like a paragraph
|
||||
but using a **secondary/dimmed base color** for the default run color), and
|
||||
- its own `frame`.
|
||||
- Replace the `case .thinking: return []` stub (currently `InstantPageV2Layout.swift:892`) with an
|
||||
arm that lays out the RichText (reusing the existing simple-text layout helper) and wraps it in
|
||||
the thinking item. Normal inter-block spacing in the top-level flow.
|
||||
- The dimmed base color is sourced from the existing theme. Reuse the caption/secondary text
|
||||
category or pass the dimmed color explicitly; the exact category is an implementation detail to
|
||||
match the old header color.
|
||||
|
||||
### B.3 View (`submodules/InstantPageUI/Sources/InstantPageRenderer.swift`)
|
||||
|
||||
- Add `InstantPageV2ThinkingView: InstantPageItemView`. Structure mirrors
|
||||
`InstantPageV2CodeBlockView` (a container hosting an inner text view):
|
||||
- hosts a `ShimmeringMaskView(peakAlpha: 0.3, duration: 1.0)`;
|
||||
- the shimmer's `contentView` holds the inner `InstantPageV2TextView` (so inline emoji, links,
|
||||
bold/italic etc. all work via the standard text view);
|
||||
- the shimmer self-animates whenever in-hierarchy → **always shimmering while displayed**;
|
||||
- `update(item:theme:...)` refreshes the inner text view and re-runs
|
||||
`ShimmeringMaskView.update(size:containerWidth:offsetX:gradientWidth:)`.
|
||||
- `makeItemView` gains a `.thinking` arm constructing the view.
|
||||
- `reuse` gains a `.thinking` arm calling the view's `update`.
|
||||
- `stableId(for:atPosition:)` gains a `.thinking` arm → `.thinking(thinkingIndex)` (see A.3).
|
||||
- **BUILD:** add `//submodules/TelegramUI/Components/ShimmeringMask:ShimmeringMask` (and its
|
||||
transitive `HierarchyTrackingLayer`, `ComponentFlow` is already present) to `InstantPageUI/BUILD`.
|
||||
|
||||
### B.4 Reveal cost (`submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift`)
|
||||
|
||||
- Add cost-map entry `.thinking(start: Int)`.
|
||||
- `computeEntries`: append `.thinking(start: cursor)` and **do not advance `cursor`** (zero cost).
|
||||
Consequence: the answer content's cursor positions are identical whether or not thinking blocks
|
||||
are present → reveal cursor is stable through thinking churn (**the linchpin** of requirement 4).
|
||||
- `revealedExtent(entry:item:revealedCount:)`: for `.thinking(start)`, return `item.frame` when
|
||||
`revealedCount >= start`, else `nil`. So the block contributes height (grows the bubble) exactly
|
||||
when it is revealed. A top thinking block (`start == 0`) contributes height from the first frame.
|
||||
- `applyRevealEntry(view:entry:revealedCount:animated:)`: for `.thinking(start)`, set the inner
|
||||
text view to its **full** character count (fully drawn, never reveal-masked), then
|
||||
`applyVisibility(view: thinkingView, visible: revealedCount >= start, animated:)` (the existing
|
||||
0.12s alpha cross-fade used for non-text items).
|
||||
- A top thinking block (`start == 0`) is visible from the start of the reveal; one placed after
|
||||
content fades in as the cursor passes its position.
|
||||
|
||||
### B.5 V1 (`submodules/InstantPageUI/Sources/InstantPageLayout.swift`)
|
||||
|
||||
`.thinking` stays a no-op (`[]`). V1 is unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Part A — Cross-update view reuse
|
||||
|
||||
Today `ensurePageView` (`ChatMessageRichDataBubbleContentNode.swift:104`) rebuilds the
|
||||
`InstantPageV2View` from scratch whenever `stableVersion` changes, because
|
||||
`InstantPageV2RenderContext` is constructor-fixed (`let webpage`, closures capturing a
|
||||
`MessageReference`). Every other update path (theme, width, details expand/collapse) already flows
|
||||
through `InstantPageV2View.update(layout:)` and reuses item views via the renderer's stable-id
|
||||
diffing. The **only** thing forcing a wholesale teardown each chunk is the `stableVersion` key.
|
||||
|
||||
### A.1 Updatable render context (`InstantPageRenderer.swift`)
|
||||
|
||||
Make `InstantPageV2RenderContext` content updatable for same-message chunks: convert `webpage`
|
||||
(and the captured `MessageReference`) to mutable storage and add an
|
||||
`updateContent(webpage:messageReference:)` method (or equivalent). The closures
|
||||
(`imageReference`/`fileReference`) read the current stored `MessageReference` rather than capturing
|
||||
a fixed snapshot. `context`, `sourceLocation`, and the navigation closures are unchanged across
|
||||
chunks.
|
||||
|
||||
### A.2 `ensurePageView` update-in-place (`ChatMessageRichDataBubbleContentNode.swift`)
|
||||
|
||||
- **Same message id, new `stableVersion`** → keep the existing `pageView`, call
|
||||
`updateContent(...)` on its render context, and fall through to `pageView.update(layout:)`. No
|
||||
teardown.
|
||||
- **Different message id / recycled bubble with a different webpage** → rebuild as today.
|
||||
- The reveal seed (`applyReveal(revealedCount: seedCount, animated: false)`) becomes a continuation
|
||||
of the live views' existing reveal state instead of a from-scratch re-seed — eliminating the
|
||||
brief full-text-then-mask flash at each chunk boundary.
|
||||
|
||||
### A.3 Separate stable-id namespaces (`InstantPageRenderer.swift`)
|
||||
|
||||
`InstantPageV2StableItemId` gains a `.thinking(Int)` case. In the `update()` walk
|
||||
(`InstantPageRenderer.swift:215`), maintain **two independent counters**:
|
||||
|
||||
- a **content counter** that increments only for non-thinking items; non-thinking items are
|
||||
numbered by it (`.positional(kind, contentIndex)`);
|
||||
- a **thinking counter** that increments only for thinking items; thinking items get
|
||||
`.thinking(thinkingIndex)`.
|
||||
|
||||
Result: inserting or removing a thinking block no longer renumbers the content blocks' positional
|
||||
ids, so their views (and their reveal masks/state) are reused and animate smoothly across chunks
|
||||
(requirement 6).
|
||||
|
||||
### A.4 Bubble header removal (`ChatMessageRichDataBubbleContentNode.swift`)
|
||||
|
||||
Delete the hardcoded "Thinking…" header and its supporting machinery:
|
||||
|
||||
- `streamingStatusTextNode`, `streamingStatusShimmerView` properties and all their apply-time
|
||||
wiring (the block around lines 765–820);
|
||||
- `streamingStatusTextLayout`, `streamingTextLayoutAndApply`, `streamingTextFrame`;
|
||||
- the hardcoded `NSAttributedString(string: "Thinking...")` (line 413);
|
||||
- `streamingHeaderOffset` (becomes a constant `0`): remove it from the bubble-height math
|
||||
(`boundingSize.height += streamingHeaderOffset`), from the `thinkingMinBubbleWidth` width bump,
|
||||
from the status-node `statusAnchorY`/`statusFrameY` shifts, and from `pageView.frame.origin.y`
|
||||
(which becomes `0`, matching today's non-streaming position);
|
||||
- `import ShimmeringMask` from the bubble file (the dependency moves to `InstantPageUI`).
|
||||
|
||||
No empty-state placeholder: while `hasDraft` and the page has no blocks yet, the bubble is empty.
|
||||
|
||||
---
|
||||
|
||||
## Risks / invariants
|
||||
|
||||
- **CLAUDE.md updates required.** The "AI streaming animation" section states "The pageView is
|
||||
rebuilt on every `stableVersion` bump… each AI chunk creates a brand-new `InstantPageV2View`" and
|
||||
"the dict starts fresh each chunk — no orphan/leak across rebuilds." Both invariants flip with
|
||||
Part A: views and the inline-emoji/inline-image dicts now **persist across chunks**. Verify the
|
||||
existing within-view stale-key pruning in `updateInlineEmoji`/`updateInlineImages` correctly
|
||||
removes layers for blocks/emoji that disappear across a chunk (it prunes keys not present in the
|
||||
current layout, so cross-chunk removal should be handled — confirm during implementation). The
|
||||
header/`streamingHeaderOffset` notes in the "Status node positioning" subsection must be
|
||||
rewritten.
|
||||
- **Reveal cursor stability** depends entirely on thinking being **zero-cost**. If thinking ever
|
||||
contributed cost, adding/removing a thinking block mid-stream would jump the answer's reveal
|
||||
position. Keep cost at 0.
|
||||
- **Enum-arity changes are compile-enforced.** Adding `InstantPageV2LaidOutItem.thinking` and
|
||||
`InstantPageV2StableItemId.thinking` breaks every exhaustive switch over them (`computeEntries`,
|
||||
`revealedExtent`, `applyRevealEntry`, `stableId`, `makeItemView`, `reuse`, and any preview/extent
|
||||
walks). The **full Bazel build** is the completeness gate (no per-module build in this repo).
|
||||
- **Render-context mutability** must not break media reference resolution: media resolves by media
|
||||
id within the message, and the message id is stable across chunks, so updating the stored
|
||||
`MessageReference` to the latest chunk is safe (and more correct than a stale snapshot).
|
||||
- **Details/table reuse** already works via stable ids and `update(layout:)`; Part A must not
|
||||
regress the existing collapse/expand animation (`finalizePendingCollapse`) — the same-id reuse
|
||||
path is unchanged for those views.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Sending thinking blocks (the app is output-only for `.thinking`; `apiInputBlock` returns nil).
|
||||
- V1 rendering of thinking blocks.
|
||||
- Any change to how the server decides to include/exclude thinking blocks.
|
||||
- Tappable interaction / collapse-expand for thinking blocks (they are plain shimmering text).
|
||||
|
||||
## Verification
|
||||
|
||||
- Full Bazel build (`Make.py … --configuration=debug_sim_arm64`) — the enum-arity completeness gate.
|
||||
- Manual: stream an AI message whose server payload includes one or more top-level `.thinking`
|
||||
blocks interleaved with answer content; confirm:
|
||||
- thinking blocks shimmer continuously and render in dimmed/secondary color;
|
||||
- thinking blocks fade in at their index position and never advance the answer's reveal;
|
||||
- across chunks where thinking blocks appear/disappear, answer-content views are reused (no flash,
|
||||
no reveal-position jump);
|
||||
- the bubble grows to contain revealed thinking blocks and the status node sits correctly with the
|
||||
header removed;
|
||||
- a finalized message carrying a thinking block still renders it (shimmering).
|
||||
|
|
@ -35,6 +35,7 @@ swift_library(
|
|||
"//submodules/Tuples:Tuples",
|
||||
"//third-party/SwiftMath:SwiftMath",
|
||||
"//submodules/ComponentFlow:ComponentFlow",
|
||||
"//submodules/TelegramUI/Components/ShimmeringMask:ShimmeringMask",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 517–586)
|
||||
|
||||
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)) }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue