From a50f40476d1a860f8c8a2aecbc2647cd50817576 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Fri, 29 May 2026 16:28:37 +0200 Subject: [PATCH 01/10] Add design spec: InstantPage V2 thinking-block rendering + cross-update view reuse Co-Authored-By: Claude Opus 4.8 (1M context) --- ...05-29-instantpage-thinking-block-design.md | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-29-instantpage-thinking-block-design.md diff --git a/docs/superpowers/specs/2026-05-29-instantpage-thinking-block-design.md b/docs/superpowers/specs/2026-05-29-instantpage-thinking-block-design.md new file mode 100644 index 0000000000..e2a342950e --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-instantpage-thinking-block-design.md @@ -0,0 +1,232 @@ +# 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). From 484b66f28c9b87d98b52fb8eb8fae0f01523306e Mon Sep 17 00:00:00 2001 From: isaac <> Date: Fri, 29 May 2026 16:36:40 +0200 Subject: [PATCH 02/10] Add implementation plan: InstantPage V2 thinking-block + cross-update view reuse Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-29-instantpage-thinking-block.md | 642 ++++++++++++++++++ 1 file changed, 642 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-29-instantpage-thinking-block.md diff --git a/docs/superpowers/plans/2026-05-29-instantpage-thinking-block.md b/docs/superpowers/plans/2026-05-29-instantpage-thinking-block.md new file mode 100644 index 0000000000..21e4ec835a --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-instantpage-thinking-block.md @@ -0,0 +1,642 @@ +# 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. From e8de87dc7bcc180c24dbe8dd3ff054ddcf941a58 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Sat, 30 May 2026 18:47:03 +0200 Subject: [PATCH 03/10] InstantPage V2: render server-sent thinking blocks Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 43 +- .../2026-05-29-instantpage-thinking-block.md | 642 ------------------ ...05-29-instantpage-thinking-block-design.md | 232 ------- submodules/InstantPageUI/BUILD | 1 + .../Sources/InstantPageLayout.swift | 12 +- .../Sources/InstantPageLayoutSpacings.swift | 68 +- .../Sources/InstantPageRenderer.swift | 140 +++- .../Sources/InstantPageTheme.swift | 28 +- .../Sources/InstantPageV2Layout.swift | 74 +- .../Sources/InstantPageV2RevealCost.swift | 16 + .../Sources/ChatMessageBubbleItemNode.swift | 8 +- .../BUILD | 3 - ...ChatMessageRichDataBubbleContentNode.swift | 191 +----- .../ChatMessageTextBubbleContentNode.swift | 2 +- .../Chat/ChatSendMessageRichTextPreview.swift | 4 +- 15 files changed, 329 insertions(+), 1135 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-29-instantpage-thinking-block.md delete mode 100644 docs/superpowers/specs/2026-05-29-instantpage-thinking-block-design.md diff --git a/CLAUDE.md b/CLAUDE.md index 1813186004..872fbc9cee 100644 --- a/CLAUDE.md +++ b/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`. diff --git a/docs/superpowers/plans/2026-05-29-instantpage-thinking-block.md b/docs/superpowers/plans/2026-05-29-instantpage-thinking-block.md deleted file mode 100644 index 21e4ec835a..0000000000 --- a/docs/superpowers/plans/2026-05-29-instantpage-thinking-block.md +++ /dev/null @@ -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. diff --git a/docs/superpowers/specs/2026-05-29-instantpage-thinking-block-design.md b/docs/superpowers/specs/2026-05-29-instantpage-thinking-block-design.md deleted file mode 100644 index e2a342950e..0000000000 --- a/docs/superpowers/specs/2026-05-29-instantpage-thinking-block-design.md +++ /dev/null @@ -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). diff --git a/submodules/InstantPageUI/BUILD b/submodules/InstantPageUI/BUILD index f94169828a..0671d69c9a 100644 --- a/submodules/InstantPageUI/BUILD +++ b/submodules/InstantPageUI/BUILD @@ -35,6 +35,7 @@ swift_library( "//submodules/Tuples:Tuples", "//third-party/SwiftMath:SwiftMath", "//submodules/ComponentFlow:ComponentFlow", + "//submodules/TelegramUI/Components/ShimmeringMask:ShimmeringMask", ], visibility = [ "//visibility:public", diff --git a/submodules/InstantPageUI/Sources/InstantPageLayout.swift b/submodules/InstantPageUI/Sources/InstantPageLayout.swift index 0f227366c7..1a9ff938f4 100644 --- a/submodules/InstantPageUI/Sources/InstantPageLayout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageLayout.swift @@ -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 { diff --git a/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift b/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift index 6e869fa28b..f3b12a675b 100644 --- a/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift +++ b/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift @@ -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 } } diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index 26c85c8650..f869a8c3b6 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -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 = [] - 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 { diff --git a/submodules/InstantPageUI/Sources/InstantPageTheme.swift b/submodules/InstantPageUI/Sources/InstantPageTheme.swift index 5d820ae9a6..a1857d1ef5 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTheme.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTheme.swift @@ -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 { diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index 698029dd77..c07ac62819 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -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)) } diff --git a/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift b/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift index 6e255c8e32..dc6cb22efa 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift @@ -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) } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index eb34eb8b44..2079e6767c 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -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 { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD index 91734ff0f6..d9cab4e388 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD @@ -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", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index d0ad7cb69c..d643d7f132 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -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 { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift index dd78c19c41..f527da9128 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift @@ -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), diff --git a/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift b/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift index 8afffb287b..ae797425cf 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift @@ -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( From 79bcc7bc34c91ec078251e07b96056358d704667 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Sat, 30 May 2026 18:47:03 +0200 Subject: [PATCH 04/10] InstantPage V2 table: flush frame, inset borders Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 11 ++++++++ .../Sources/InstantPageRenderer.swift | 25 +++++++++++-------- .../Sources/InstantPageV2Layout.swift | 19 +++++++++----- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 872fbc9cee..427f6dd665 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,17 @@ The `ChatMessageDateAndStatusNode` mirrors TextBubble's placement, adapted to th - **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. +## InstantPage V2 table — flush frame, inset borders + +A V2 `.table` block's item frame is **full-width / flush** with the bubble interior (so a horizontally-scrollable wide table's scroll container bleeds edge-to-edge), but the actual grid **borders start at the body-text side inset** — matching the V1 renderer. Spec: [`docs/superpowers/specs/2026-05-30-instantpage-v2-table-inset-design.md`](docs/superpowers/specs/2026-05-30-instantpage-v2-table-inset-design.md). + +### Non-obvious invariants + +- **`InstantPageV2TableItem.contentInset` (= page `horizontalInset`) is the linchpin.** `layoutTable` (`InstantPageV2Layout.swift`) sizes columns against `contentBoundingWidth = boundingWidth − horizontalInset·2` (so a fitting table aligns with body text on both sides) and stores `contentInset` on the item; the item `frame.width` is the flush `boundingWidth`, and `contentSize.width` stays the **bare grid width** (`totalWidth`, no inset). +- **The renderer (`InstantPageV2TableView`) realizes the inset as a view shift, not baked coordinates.** In `init` AND `update` it shifts the grid `contentView` to `x: contentInset`, sets `scrollView.contentSize.width = contentSize.width + contentInset` (**left margin only**), and `scrollView.clipsToBounds = true`. Cells, border lines, the outer border, and the title stay x=0-relative inside `contentView`, so the single shift carries them all; the outer border keeps using the bare `contentSize.width`. +- **Scrollable tables clip to the full width with no inset on the clip.** The inset lives inside the scroll content as a left margin only: a fitting table (`grid + inset ≤ boundingWidth`) doesn't scroll and shows both-side inset; an overflowing table rests with its left border at the inset and scrolls its right border flush to the full-width edge (no trailing gap). +- **Manual cell-coordinate helpers MUST add `contentInset`.** Because the shift is a real `contentView` frame change, UIKit `hitTest` and `self.convert(_:to:)` paths (`propagateVisibilityRect`, the row-reveal mask) handle it automatically — but the *manual* coordinate helpers `findTextItem` / `collectSelectableTextItems` (the live tap / URL / text-selection path) compute cell/title positions arithmetically and must add `table.contentInset` to the x-offset, or in-cell hit-testing is off by the inset. (These helpers still do **not** account for the table's live horizontal `scrollView.contentOffset` — a pre-existing limitation, so in-cell hit-testing is only correct at scroll offset 0.) The dead-but-symmetric `lastTextLineFrame(in:)` table branch has the same omission but has no callers. + ## Inline custom emoji (RichText.textCustomEmoji) `RichText.textCustomEmoji(fileId:alt:)` renders an inline **animated** custom emoji inside rich-data bubbles. Covers API parsing, Postbox + FlatBuffers serialization, and display in the InstantPage V2 renderer; the emoji participates in the streaming reveal above. diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index f869a8c3b6..be65b8cb8b 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -1942,15 +1942,20 @@ final class InstantPageV2TableView: UIView, InstantPageItemView { self.backgroundColor = .clear self.scrollView.frame = self.bounds - self.scrollView.contentSize = item.contentSize + // Scrollable tables clip to the full width with no inset on the clip; the inset lives inside + // the scroll content width as a left margin only (see contentSize below). + self.scrollView.clipsToBounds = true + self.scrollView.contentSize = CGSize(width: item.contentSize.width + item.contentInset, height: item.contentSize.height) self.scrollView.alwaysBounceHorizontal = false self.scrollView.alwaysBounceVertical = false - self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width > item.frame.width + self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width + item.contentInset > item.frame.width self.scrollView.showsVerticalScrollIndicator = false self.scrollView.disablesInteractiveTransitionGestureRecognizer = true self.addSubview(self.scrollView) - self.contentView.frame = CGRect(origin: .zero, size: item.contentSize) + // Grid container shifted right by the inset so the borders start at the body-text inset. + // Its size stays the bare grid (item.contentSize); grid coords inside remain x=0-relative. + self.contentView.frame = CGRect(x: item.contentInset, y: 0.0, width: item.contentSize.width, height: item.contentSize.height) self.scrollView.addSubview(self.contentView) // Title sub-layout (above the grid, inside the scroll view's content). @@ -2025,9 +2030,9 @@ final class InstantPageV2TableView: UIView, InstantPageItemView { self.item = item self.scrollView.frame = CGRect(origin: .zero, size: item.frame.size) - self.scrollView.contentSize = item.contentSize - self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width > item.frame.width - self.contentView.frame = CGRect(origin: .zero, size: item.contentSize) + self.scrollView.contentSize = CGSize(width: item.contentSize.width + item.contentInset, height: item.contentSize.height) + self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width + item.contentInset > item.frame.width + self.contentView.frame = CGRect(x: item.contentInset, y: 0.0, width: item.contentSize.width, height: item.contentSize.height) // Forward updates to nested V2 sub-layouts (title + each cell). Recursive update // propagation. Cell-count or title-presence changes fall back to rebuild via the @@ -2145,7 +2150,7 @@ private func findTextItem( } case let .table(table): for cell in table.cells { - let cellAbs = cell.frame.offsetBy(dx: f.minX, dy: f.minY) + let cellAbs = cell.frame.offsetBy(dx: f.minX + table.contentInset, dy: f.minY) if !cellAbs.contains(point) { continue } if let sub = cell.subLayout { if let hit = findTextItem(in: sub, point: point, @@ -2155,7 +2160,7 @@ private func findTextItem( } } if let titleLayout = table.titleSubLayout, let titleFrame = table.titleFrame { - let titleAbs = titleFrame.offsetBy(dx: f.minX, dy: f.minY) + let titleAbs = titleFrame.offsetBy(dx: f.minX + table.contentInset, dy: f.minY) if titleAbs.contains(point) { if let hit = findTextItem(in: titleLayout, point: point, accumulatedOffset: CGPoint(x: titleAbs.minX, y: titleAbs.minY)) { @@ -2208,7 +2213,7 @@ private func collectSelectableTextItems( case let .table(table): if let titleLayout = table.titleSubLayout, let titleFrame = table.titleFrame { let titleOffset = CGPoint( - x: accumulatedOffset.x + table.frame.minX + titleFrame.minX, + x: accumulatedOffset.x + table.frame.minX + table.contentInset + titleFrame.minX, y: accumulatedOffset.y + table.frame.minY + titleFrame.minY ) collectSelectableTextItems(in: titleLayout, accumulatedOffset: titleOffset, into: &result) @@ -2216,7 +2221,7 @@ private func collectSelectableTextItems( for cell in table.cells { if let sub = cell.subLayout { let cellOffset = CGPoint( - x: accumulatedOffset.x + table.frame.minX + cell.frame.minX, + x: accumulatedOffset.x + table.frame.minX + table.contentInset + cell.frame.minX, y: accumulatedOffset.y + table.frame.minY + cell.frame.minY ) collectSelectableTextItems(in: sub, accumulatedOffset: cellOffset, into: &result) diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index c07ac62819..5b8c00a413 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -320,6 +320,7 @@ public struct InstantPageV2TableItem { public let titleSubLayout: InstantPageV2Layout? public let titleFrame: CGRect? public let contentSize: CGSize // grid intrinsic size; may exceed frame.width → scroll + public let contentInset: CGFloat // left inset (= page horizontalInset) baked into the scroll content width by the renderer public let cells: [InstantPageV2TableCell] public let horizontalLines: [CGRect] public let verticalLines: [CGRect] @@ -1067,8 +1068,12 @@ private func layoutTable( setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false) let borderWidth = bordered ? v2TableBorderWidth : 0.0 + // Size columns against the inset content width (mirrors V1's `boundingWidth - horizontalInset*2`), + // so a fitting table aligns with body text on both sides. The item frame stays full-width (flush) + // and the renderer bakes the inset back in as a left margin on the scroll content. + let contentBoundingWidth = boundingWidth - horizontalInset * 2.0 let totalCellPadding = v2TableCellInsets.left + v2TableCellInsets.right - let cellWidthLimit = boundingWidth - totalCellPadding + let cellWidthLimit = contentBoundingWidth - totalCellPadding var tableRows: [V2TableRow] = [] var columnCount: Int = 0 @@ -1153,7 +1158,7 @@ private func layoutTable( } // Aggregate column min/max across all rows. - let maxContentWidth = boundingWidth - borderWidth + let maxContentWidth = contentBoundingWidth - borderWidth var availableWidth = maxContentWidth var minColumnWidths: [Int: CGFloat] = [:] var maxColumnWidths: [Int: CGFloat] = [:] @@ -1224,7 +1229,7 @@ private func layoutTable( distributedWidth -= growth finalColumnWidths[i] = width } - totalWidth = boundingWidth + totalWidth = contentBoundingWidth } else { totalWidth += borderWidth } @@ -1530,10 +1535,11 @@ private func layoutTable( titleFrame = CGRect(x: 0.0, y: 0.0, width: totalWidth, height: titleHeight) } - // The table item frame spans the full boundingWidth slot in the bubble; - // contentSize.width is the intrinsic grid width (may exceed frame.width → horizontal scroll). + // The table item frame spans the full visible bubble interior (`boundingWidth`); the scroll + // viewport equals what is actually visible. contentSize.width is the intrinsic grid width + // (may exceed frame.width → horizontal scroll); the renderer adds the left inset on top. let tableFrame = CGRect(x: 0.0, y: 0.0, - width: boundingWidth + horizontalInset * 2.0, + width: boundingWidth, height: totalHeight + (titleFrame?.height ?? 0.0)) let contentSize = CGSize( width: totalWidth, @@ -1545,6 +1551,7 @@ private func layoutTable( titleSubLayout: titleSubLayout, titleFrame: titleFrame, contentSize: contentSize, + contentInset: horizontalInset, cells: finalizedCells, horizontalLines: horizontalLines, verticalLines: verticalLines, From 3d9fbdec3cada93f9a51e44f3b26ec77326e9d5c Mon Sep 17 00:00:00 2001 From: isaac <> Date: Sat, 30 May 2026 18:47:04 +0200 Subject: [PATCH 05/10] InstantPage V2 table: rounded corners and corner-cell fill rounding Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 8 +- .../Sources/InstantPageRenderer.swift | 78 +++++++++++++------ .../Sources/InstantPageV2Layout.swift | 1 + 3 files changed, 60 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 427f6dd665..3faefc99dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,16 +96,18 @@ The `ChatMessageDateAndStatusNode` mirrors TextBubble's placement, adapted to th - **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. -## InstantPage V2 table — flush frame, inset borders +## InstantPage V2 table — flush frame, inset borders, rounded corners -A V2 `.table` block's item frame is **full-width / flush** with the bubble interior (so a horizontally-scrollable wide table's scroll container bleeds edge-to-edge), but the actual grid **borders start at the body-text side inset** — matching the V1 renderer. Spec: [`docs/superpowers/specs/2026-05-30-instantpage-v2-table-inset-design.md`](docs/superpowers/specs/2026-05-30-instantpage-v2-table-inset-design.md). +A V2 `.table` block's item frame is **full-width / flush** with the bubble interior (so a horizontally-scrollable wide table's scroll container bleeds edge-to-edge), but the actual grid **borders start at the body-text side inset** — matching the V1 renderer. The grid card also has a **10pt rounded outer border**. Specs: [`…-table-inset-design.md`](docs/superpowers/specs/2026-05-30-instantpage-v2-table-inset-design.md), [`…-table-corner-radius-design.md`](docs/superpowers/specs/2026-05-30-instantpage-v2-table-corner-radius-design.md). ### Non-obvious invariants - **`InstantPageV2TableItem.contentInset` (= page `horizontalInset`) is the linchpin.** `layoutTable` (`InstantPageV2Layout.swift`) sizes columns against `contentBoundingWidth = boundingWidth − horizontalInset·2` (so a fitting table aligns with body text on both sides) and stores `contentInset` on the item; the item `frame.width` is the flush `boundingWidth`, and `contentSize.width` stays the **bare grid width** (`totalWidth`, no inset). -- **The renderer (`InstantPageV2TableView`) realizes the inset as a view shift, not baked coordinates.** In `init` AND `update` it shifts the grid `contentView` to `x: contentInset`, sets `scrollView.contentSize.width = contentSize.width + contentInset` (**left margin only**), and `scrollView.clipsToBounds = true`. Cells, border lines, the outer border, and the title stay x=0-relative inside `contentView`, so the single shift carries them all; the outer border keeps using the bare `contentSize.width`. +- **The renderer (`InstantPageV2TableView`) realizes the inset as a view shift, not baked coordinates.** In `init` AND `update` it shifts the grid `contentView` to `x: contentInset`, sets `scrollView.contentSize.width = contentSize.width + contentInset` (**left margin only**), and `scrollView.clipsToBounds = true`. Cells, inner border lines, and the title stay x=0-relative inside `contentView`, so the single shift carries them all; the rounded outer border is `contentView.layer`'s own border (see below), which wraps the shifted layer automatically. - **Scrollable tables clip to the full width with no inset on the clip.** The inset lives inside the scroll content as a left margin only: a fitting table (`grid + inset ≤ boundingWidth`) doesn't scroll and shows both-side inset; an overflowing table rests with its left border at the inset and scrolls its right border flush to the full-width edge (no trailing gap). - **Manual cell-coordinate helpers MUST add `contentInset`.** Because the shift is a real `contentView` frame change, UIKit `hitTest` and `self.convert(_:to:)` paths (`propagateVisibilityRect`, the row-reveal mask) handle it automatically — but the *manual* coordinate helpers `findTextItem` / `collectSelectableTextItems` (the live tap / URL / text-selection path) compute cell/title positions arithmetically and must add `table.contentInset` to the x-offset, or in-cell hit-testing is off by the inset. (These helpers still do **not** account for the table's live horizontal `scrollView.contentOffset` — a pre-existing limitation, so in-cell hit-testing is only correct at scroll offset 0.) The dead-but-symmetric `lastTextLineFrame(in:)` table branch has the same omission but has no callers. +- **The 10pt rounded outer border is `contentView.layer`'s own border, NOT sublayers.** `v2TableCornerRadius = 10.0` (`InstantPageV2Layout.swift`). The renderer sets `contentView.layer.cornerRadius`/`borderColor`/`borderWidth = bordered ? v2TableBorderWidth : 0.0` in BOTH `init` and `update` (the four straight outer-edge rect layers were removed; `lineLayers` now holds only inner grid lines). **Border-only — deliberately no `masksToBounds`:** `cornerRadius` rounds the layer's border without clipping contents (filled corner cells round their own fills separately — see next bullet), and there is **zero interaction with the streaming reveal mask** (`contentView.layer.mask`, set only during AI streaming) — the border reveals row-by-row with the rows and is part of the masked layer. The rounded card belongs to the grid (scrolls with it). For a non-empty-title table (never produced by markdown/AI), the border wraps title+grid since `contentView` includes the title region — an accepted, approved nuance. +- **Filled corner cells round their own fills to match the border.** A header/striped cell's background is a stripe `CALayer`; `tableStripeCornerMask(cellFrame:gridWidth:gridHeight:effectiveBorderWidth:)` detects which grid corners the cell's (grid-local) frame touches — `firstCol/firstRow` via `frame.min{X,Y} <= effectiveBorderWidth/2 + 0.5`, `lastCol/lastRow` via `frame.max{X,Y} >= grid{Width,Height} - …` (gridWidth = `item.contentSize.width`, gridHeight = `item.contentSize.height - gridOffsetY`) — and rounds only those corners: `stripe.cornerRadius = max(0, v2TableCornerRadius - effectiveBorderWidth)` (the `-borderWidth` leaves an even border ring; borderless → full radius) + `stripe.maskedCorners`, in BOTH `init` and `update`. A `CALayer`'s `backgroundColor` honors `cornerRadius`+`maskedCorners` with no `masksToBounds`. A full-width (colspan) header rounds both top corners; a one-row filled table rounds all four; bottom corners round only when the last row is filled. The empty-mask branch resets `cornerRadius = 0` **and** `maskedCorners = []` so reused stripes (persist across streaming chunks) don't keep stale rounding. Detection is grid-local, so it's independent of the `contentInset` shift / horizontal scroll. ## Inline custom emoji (RichText.textCustomEmoji) diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index be65b8cb8b..b5c6ae8b79 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -1916,6 +1916,22 @@ final class InstantPageV2ThinkingView: UIView, InstantPageItemView { // MARK: - Table view +/// The set of grid corners a cell occupies, so a filled corner cell's stripe can be rounded to +/// follow the table's rounded outer border. `cellFrame` is table-grid-local (pre-`gridOffsetY`). +private func tableStripeCornerMask(cellFrame: CGRect, gridWidth: CGFloat, gridHeight: CGFloat, effectiveBorderWidth: CGFloat) -> CACornerMask { + let edge = effectiveBorderWidth / 2.0 + 0.5 + let firstCol = cellFrame.minX <= edge + let firstRow = cellFrame.minY <= edge + let lastCol = cellFrame.maxX >= gridWidth - edge + let lastRow = cellFrame.maxY >= gridHeight - edge + var mask: CACornerMask = [] + if firstRow && firstCol { mask.insert(.layerMinXMinYCorner) } + if firstRow && lastCol { mask.insert(.layerMaxXMinYCorner) } + if lastRow && firstCol { mask.insert(.layerMinXMaxYCorner) } + if lastRow && lastCol { mask.insert(.layerMaxXMaxYCorner) } + return mask +} + final class InstantPageV2TableView: UIView, InstantPageItemView { private(set) var item: InstantPageV2TableItem var itemFrame: CGRect { return self.item.frame } @@ -1970,6 +1986,8 @@ final class InstantPageV2TableView: UIView, InstantPageItemView { // Grid origin: shifted down by title height when present. let gridOffsetY = item.titleFrame?.height ?? 0.0 + let effectiveBorderWidth = item.bordered ? v2TableBorderWidth : 0.0 + let gridHeight = item.contentSize.height - gridOffsetY // Cell backgrounds and sub-layouts. for cell in item.cells { @@ -1977,6 +1995,16 @@ final class InstantPageV2TableView: UIView, InstantPageItemView { let stripe = CALayer() stripe.backgroundColor = bg.cgColor stripe.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY) + // Round the stripe at any grid corner it touches, so a filled corner cell + // follows the rounded outer border instead of poking past it. + let cornerMask = tableStripeCornerMask(cellFrame: cell.frame, gridWidth: item.contentSize.width, gridHeight: gridHeight, effectiveBorderWidth: effectiveBorderWidth) + if cornerMask.isEmpty { + stripe.cornerRadius = 0.0 + stripe.maskedCorners = [] + } else { + stripe.cornerRadius = max(0.0, v2TableCornerRadius - effectiveBorderWidth) + stripe.maskedCorners = cornerMask + } self.contentView.layer.insertSublayer(stripe, at: 0) self.stripeLayers.append(stripe) } @@ -1990,7 +2018,14 @@ final class InstantPageV2TableView: UIView, InstantPageItemView { } } - // Border lines. + // Rounded outer border. `cornerRadius` rounds the layer's border WITHOUT `masksToBounds`, + // so table contents (cell fills, text) stay unclipped — only the border visual is rounded. + // Replaces the four straight outer-edge layers the old code appended to `lineLayers`. + self.contentView.layer.cornerRadius = v2TableCornerRadius + self.contentView.layer.borderColor = item.borderColor.cgColor + self.contentView.layer.borderWidth = item.bordered ? v2TableBorderWidth : 0.0 + + // Inner grid lines (between cells). if item.bordered { for r in item.horizontalLines + item.verticalLines { let line = CALayer() @@ -1999,27 +2034,6 @@ final class InstantPageV2TableView: UIView, InstantPageItemView { self.contentView.layer.addSublayer(line) self.lineLayers.append(line) } - // Outer border rect (four edges). - let outerW = v2TableBorderWidth - let outerRect = CGRect( - x: outerW / 2.0, - y: gridOffsetY + outerW / 2.0, - width: item.contentSize.width - outerW, - height: item.contentSize.height - outerW - ) - let outerEdges: [CGRect] = [ - CGRect(x: outerRect.minX, y: outerRect.minY, width: outerRect.width, height: outerW), - CGRect(x: outerRect.minX, y: outerRect.maxY - outerW, width: outerRect.width, height: outerW), - CGRect(x: outerRect.minX, y: outerRect.minY, width: outerW, height: outerRect.height), - CGRect(x: outerRect.maxX - outerW, y: outerRect.minY, width: outerW, height: outerRect.height) - ] - for edge in outerEdges { - let line = CALayer() - line.backgroundColor = item.borderColor.cgColor - line.frame = edge - self.contentView.layer.addSublayer(line) - self.lineLayers.append(line) - } } } @@ -2058,21 +2072,37 @@ final class InstantPageV2TableView: UIView, InstantPageItemView { } } - // Stripe layers (cell backgrounds) — update color + frame in original order. + // Stripe layers (cell backgrounds) — update color + frame + corner rounding in original order. + let effectiveBorderWidth = item.bordered ? v2TableBorderWidth : 0.0 + let gridHeight = item.contentSize.height - gridOffsetY var stripeIndex = 0 for cell in item.cells { if let bg = cell.backgroundColor, stripeIndex < self.stripeLayers.count { let stripe = self.stripeLayers[stripeIndex] stripe.backgroundColor = bg.cgColor stripe.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY) + let cornerMask = tableStripeCornerMask(cellFrame: cell.frame, gridWidth: item.contentSize.width, gridHeight: gridHeight, effectiveBorderWidth: effectiveBorderWidth) + if cornerMask.isEmpty { + stripe.cornerRadius = 0.0 + stripe.maskedCorners = [] + } else { + stripe.cornerRadius = max(0.0, v2TableCornerRadius - effectiveBorderWidth) + stripe.maskedCorners = cornerMask + } stripeIndex += 1 } } - // Line layers (borders) — update color in place; frames recomputed in original order. + // Inner line layers — refresh color in place. (`lineLayers` now holds only inner grid + // lines; the outer border is the contentView layer's own rounded border, refreshed below.) for line in self.lineLayers { line.backgroundColor = item.borderColor.cgColor } + + // Rounded outer border — refresh radius/color/width (theme or `bordered` flag may change). + self.contentView.layer.cornerRadius = v2TableCornerRadius + self.contentView.layer.borderColor = item.borderColor.cgColor + self.contentView.layer.borderWidth = item.bordered ? v2TableBorderWidth : 0.0 } } diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index 5b8c00a413..3127a665c9 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -1049,6 +1049,7 @@ private struct V2TableRow { let v2TableCellInsets = UIEdgeInsets(top: 14.0, left: 12.0, bottom: 14.0, right: 12.0) let v2TableBorderWidth: CGFloat = 1.0 +let v2TableCornerRadius: CGFloat = 10.0 private func layoutTable( title: RichText, From eb1b918a77d4b322702340e03772fad86d0961c3 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Sat, 30 May 2026 18:47:04 +0200 Subject: [PATCH 06/10] InstantPage V2 table: cell layout polish (insets, text category, border width) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/InstantPageLayoutSpacings.swift | 8 +++-- .../Sources/InstantPageV2Layout.swift | 33 +++++++++++++------ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift b/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift index f3b12a675b..240e8a4de4 100644 --- a/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift +++ b/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift @@ -125,7 +125,9 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi default: return 10.0 } - case .detail, .cell, .list: + case .cell: + return 0.0 + case .detail, .list: return 4.0 } } else { @@ -144,8 +146,10 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi return 25.0 } } - case .detail, .cell, .list: + case .detail, .list: return 16.0 + case .cell: + return 0.0 } } else { return 0.0 diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index 3127a665c9..4b594800b4 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -508,6 +508,7 @@ private func layoutBlockSequence( block, boundingWidth: boundingWidth, horizontalInset: horizontalInset, + kind: kind, isCover: false, previousItems: items, isLast: i == blocks.count - 1, @@ -597,6 +598,7 @@ private func layoutBlock( _ block: InstantPageBlock, boundingWidth: CGFloat, horizontalInset: CGFloat, + kind: BlockSequenceKind, isCover: Bool, previousItems: [InstantPageV2LaidOutItem], isLast: Bool, @@ -605,7 +607,7 @@ private func layoutBlock( let _ = isLast // reserved for Tasks 7–9 switch block { case let .cover(inner): - return layoutBlock(inner, boundingWidth: boundingWidth, horizontalInset: horizontalInset, + return layoutBlock(inner, boundingWidth: boundingWidth, horizontalInset: horizontalInset, kind: kind, isCover: true, previousItems: previousItems, isLast: isLast, context: &context) case let .title(text): let titleItems = layoutSimpleText(text, category: .header, boundingWidth: boundingWidth, @@ -631,7 +633,7 @@ private func layoutBlock( return layoutSimpleText(text, category: .caption, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .paragraph(text): - return layoutParagraph(text, boundingWidth: boundingWidth, horizontalInset: horizontalInset, + return layoutParagraph(text, boundingWidth: boundingWidth, horizontalInset: horizontalInset, kind: kind, previousItems: previousItems, context: &context) case let .authorDate(author, date): return layoutAuthorDate(author: author, date: date, boundingWidth: boundingWidth, @@ -643,7 +645,7 @@ private func layoutBlock( case let .list(items, ordered): return layoutList(items, ordered: ordered, boundingWidth: boundingWidth, - horizontalInset: horizontalInset, context: &context) + horizontalInset: horizontalInset, kind: kind, context: &context) case let .preformatted(text, language): return layoutCodeBlock(text, language: language, boundingWidth: boundingWidth, @@ -651,7 +653,7 @@ private func layoutBlock( case let .blockQuote(blocks, caption): return layoutBlockQuote(blocks: blocks, caption: caption, - boundingWidth: boundingWidth, horizontalInset: horizontalInset, + boundingWidth: boundingWidth, horizontalInset: horizontalInset, kind: kind, isLast: isLast, context: &context) case let .pullQuote(text, caption): return layoutQuoteText(text: text, caption: caption, isPull: true, @@ -889,7 +891,7 @@ private func layoutBlock( case let .formula(latex): return layoutFormulaBlock(latex: latex, boundingWidth: boundingWidth, - horizontalInset: horizontalInset, + horizontalInset: horizontalInset, kind: kind, context: &context) case let .details(title, blocks, expanded): @@ -921,6 +923,7 @@ private func layoutFormulaBlock( latex: String, boundingWidth: CGFloat, horizontalInset: CGFloat, + kind: BlockSequenceKind, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { // Style stack matches V1's per-block formula (paragraph category, not header). @@ -940,6 +943,7 @@ private func layoutFormulaBlock( return layoutParagraph(.plain(latex), boundingWidth: boundingWidth, horizontalInset: horizontalInset, + kind: kind, previousItems: [], context: &context) } @@ -1004,7 +1008,7 @@ private func layoutDetails( 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.x = horizontalInset + 23.0 titleTextItem.frame.origin.y = floorToScreenPixels((titleHeight - titleTextItem.frame.height) * 0.5) let isExpanded = context.expandedDetails[index] ?? defaultExpanded @@ -1047,8 +1051,12 @@ private struct V2TableRow { var maxColumnWidths: [Int: CGFloat] } -let v2TableCellInsets = UIEdgeInsets(top: 14.0, left: 12.0, bottom: 14.0, right: 12.0) -let v2TableBorderWidth: CGFloat = 1.0 +let v2TableCellInsets: UIEdgeInsets = { + return UIEdgeInsets(top: 15.0, left: 13.0, bottom: 15.0, right: 13.0) +}() +let v2TableBorderWidth: CGFloat = { + return UIScreenPixel * 2.0 +}() let v2TableCornerRadius: CGFloat = 10.0 private func layoutTable( @@ -1066,7 +1074,7 @@ private func layoutTable( // Style stack shared across all cell text measurements. let styleStack = InstantPageTextStyleStack() - setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false) + setupStyleStack(styleStack, theme: context.theme, category: .table, link: false) let borderWidth = bordered ? v2TableBorderWidth : 0.0 // Size columns against the inset content width (mirrors V1's `boundingWidth - horizontalInset*2`), @@ -1793,13 +1801,14 @@ private func layoutParagraph( _ text: RichText, boundingWidth: CGFloat, horizontalInset: CGFloat, + kind: BlockSequenceKind, previousItems: [InstantPageV2LaidOutItem], context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { let _ = previousItems let styleStack = InstantPageTextStyleStack() - setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false) + setupStyleStack(styleStack, theme: context.theme, category: kind == .cell ? .table : .paragraph, link: false) let attributedString = attributedStringForRichText(text, styleStack: styleStack) let (_, items, _) = layoutTextItem( @@ -2011,6 +2020,7 @@ private func layoutBlockQuote( caption: RichText, boundingWidth: CGFloat, horizontalInset: CGFloat, + kind: BlockSequenceKind, isLast: Bool, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { @@ -2041,6 +2051,7 @@ private func layoutBlockQuote( child, boundingWidth: innerBoundingWidth, horizontalInset: innerHorizontalInset, + kind: kind, isCover: false, previousItems: result, isLast: i == blocks.count - 1 && isLast, @@ -2207,6 +2218,7 @@ private func layoutList( ordered: Bool, boundingWidth: CGFloat, horizontalInset: CGFloat, + kind: BlockSequenceKind, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { // Determine marker characteristics. @@ -2383,6 +2395,7 @@ private func layoutList( subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth, horizontalInset: 0.0, + kind: kind, isCover: false, previousItems: result, isLast: j == blocks.count - 1, From 3525bcacb2e5474dac2990af37178ee4dd9ea274 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Sat, 30 May 2026 22:16:10 +0200 Subject: [PATCH 07/10] Add watch profile guard --- build-system/Make/Make.py | 62 ++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/build-system/Make/Make.py b/build-system/Make/Make.py index 5d4c7fd615..57416703f8 100644 --- a/build-system/Make/Make.py +++ b/build-system/Make/Make.py @@ -608,6 +608,53 @@ def generate_project(bazel, arguments): call_executable(['open', xcodeproj_path]) +def resolve_watch_provisioning_profile(arguments, base_path): + """Resolve the watchkitapp provisioning profile for an --embedWatchApp build. + + Returns the absolute path of the profile to sign the embedded watch app with, or None + to build the watch app UNSIGNED. None is only returned (with a warning) for + non-distribution codesigning — a distribution build (appstore/adhoc/enterprise) raises + instead, because the host ios_application does NOT re-sign the embedded watch app, so an + unsigned Watch/ payload ships as-is and is silently rejected at install time. Failing + here turns that silent "won't install" into a build error that names the missing profile. + """ + is_distribution_codesigning = arguments.gitCodesigningType in ('appstore', 'adhoc', 'enterprise') + + explicit_profile = arguments.watchProvisioningProfile + if explicit_profile is not None: + if not os.path.exists(explicit_profile): + raise Exception('--watchProvisioningProfile was set to a path that does not exist: {}'.format(explicit_profile)) + return explicit_profile + + # Default to the watchkitapp profile that resolve_configuration() just extracted from the + # codesigning material (renamed via the bundle-id mapping in BuildConfiguration.py). This + # matches the active codesigning type (e.g. appstore) and the host app's identity, so the + # embedded watch app is signed correctly without an explicit --watchProvisioningProfile. + # The worker derives the signing identity (cert) from this profile when + # --watchSigningIdentity is omitted. + resolved_watch_profile = os.path.join(base_path, 'build-input/configuration-repository/provisioning/WatchApp.mobileprovision') + if os.path.exists(resolved_watch_profile): + return resolved_watch_profile + + if is_distribution_codesigning: + raise Exception( + '--embedWatchApp is set for a distribution build (--gitCodesigningType={ct}), but no watchkitapp ' + 'provisioning profile resolved (looked for {p}).\n' + 'The {ct} codesigning material does not contain a `.watchkitapp` profile, so the embedded watch app ' + 'would be UNSIGNED — the host app is not re-signed over it, so it ships unsigned and is silently ' + 'rejected when installing on a watch.\n' + 'Fix: fetch the latest codesigning material so the watchkitapp profile is present — drop ' + '--gitCodesigningUseCurrent (it skips the fetch), or run ' + '`git -C build-input/configuration-repository-workdir/encrypted pull` — or create/register the {ct} ' + 'watchkitapp provisioning profile, or pass an explicit --watchProvisioningProfile.'.format( + ct=arguments.gitCodesigningType, p=resolved_watch_profile + ) + ) + + print('TelegramBuild: warning: --embedWatchApp is set but no watch provisioning profile was found (pass --watchProvisioningProfile, or use codesigning material that includes the watchkitapp profile; looked for {}). The embedded watch app will be UNSIGNED and rejected by the App Store.'.format(resolved_watch_profile)) + return None + + def build(bazel, arguments): bazel_command_line = BazelCommandLine( bazel=bazel, @@ -636,20 +683,7 @@ def build(bazel, arguments): if arguments.configuration in ('debug_arm64', 'release_arm64'): if arguments.watchApiId is None or arguments.watchApiHash is None: raise Exception('--embedWatchApp requires --watchApiId and --watchApiHash (the embedded watch app build needs API credentials).') - watch_provisioning_profile = arguments.watchProvisioningProfile - if watch_provisioning_profile is None: - # Default to the watchkitapp profile that resolve_configuration() just - # extracted from the codesigning material (renamed via the bundle-id - # mapping in BuildConfiguration.py). This matches the active codesigning - # type (e.g. appstore) and the host app's identity, so the embedded watch - # app is signed correctly without requiring an explicit - # --watchProvisioningProfile. The worker derives the signing identity - # (cert) from this profile when --watchSigningIdentity is omitted. - resolved_watch_profile = os.path.join(os.getcwd(), 'build-input/configuration-repository/provisioning/WatchApp.mobileprovision') - if os.path.exists(resolved_watch_profile): - watch_provisioning_profile = resolved_watch_profile - else: - print('TelegramBuild: warning: --embedWatchApp is set but no watch provisioning profile was found (pass --watchProvisioningProfile, or use codesigning material that includes the watchkitapp profile; looked for {}). The embedded watch app will be UNSIGNED and rejected by the App Store.'.format(resolved_watch_profile)) + watch_provisioning_profile = resolve_watch_provisioning_profile(arguments=arguments, base_path=os.getcwd()) bazel_command_line.set_watch_app( arguments.watchApiId, arguments.watchApiHash, From f6dc52c5331fbc7136d56e2b48c63e2f64d1d606 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Sat, 30 May 2026 23:27:26 +0200 Subject: [PATCH 08/10] Update layout --- CLAUDE.md | 4 +- .../Sources/InstantPageLayoutSpacings.swift | 2 +- .../Sources/InstantPageRenderer.swift | 221 +++++++----------- .../Sources/InstantPageV2Layout.swift | 14 +- .../Sources/InstantPageV2MediaViews.swift | 36 ++- 5 files changed, 118 insertions(+), 159 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3faefc99dc..d628a94182 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,8 +103,8 @@ A V2 `.table` block's item frame is **full-width / flush** with the bubble inter ### Non-obvious invariants - **`InstantPageV2TableItem.contentInset` (= page `horizontalInset`) is the linchpin.** `layoutTable` (`InstantPageV2Layout.swift`) sizes columns against `contentBoundingWidth = boundingWidth − horizontalInset·2` (so a fitting table aligns with body text on both sides) and stores `contentInset` on the item; the item `frame.width` is the flush `boundingWidth`, and `contentSize.width` stays the **bare grid width** (`totalWidth`, no inset). -- **The renderer (`InstantPageV2TableView`) realizes the inset as a view shift, not baked coordinates.** In `init` AND `update` it shifts the grid `contentView` to `x: contentInset`, sets `scrollView.contentSize.width = contentSize.width + contentInset` (**left margin only**), and `scrollView.clipsToBounds = true`. Cells, inner border lines, and the title stay x=0-relative inside `contentView`, so the single shift carries them all; the rounded outer border is `contentView.layer`'s own border (see below), which wraps the shifted layer automatically. -- **Scrollable tables clip to the full width with no inset on the clip.** The inset lives inside the scroll content as a left margin only: a fitting table (`grid + inset ≤ boundingWidth`) doesn't scroll and shows both-side inset; an overflowing table rests with its left border at the inset and scrolls its right border flush to the full-width edge (no trailing gap). +- **The renderer (`InstantPageV2TableView`) realizes the inset as a view shift, not baked coordinates.** In `init` AND `update` it shifts the grid `contentView` to `x: contentInset`, sets `scrollView.contentSize.width = contentSize.width + contentInset * 2.0` (**margin on both sides**, mirroring V1's `InstantPageScrollableNode`), and `scrollView.clipsToBounds = true`. Cells, inner border lines, and the title stay x=0-relative inside `contentView`, so the single shift carries them all; the rounded outer border is `contentView.layer`'s own border (see below), which wraps the shifted layer automatically. +- **Scrollable tables clip to the full width with no inset on the clip.** The inset lives inside the scroll content as a symmetric margin on both sides (`contentInset * 2.0`): a fitting table (`grid + 2·inset ≤ boundingWidth`) doesn't scroll and shows both-side inset; an overflowing table rests with its left border at the inset and scrolls until its right border reaches a matching trailing inset (it does **not** jam flush against the screen edge — matches V1). The scroll-indicator threshold and `contentSize.width` use the same `+ contentInset * 2.0`, so "does it scroll" is exactly `grid > boundingWidth − 2·inset`. - **Manual cell-coordinate helpers MUST add `contentInset`.** Because the shift is a real `contentView` frame change, UIKit `hitTest` and `self.convert(_:to:)` paths (`propagateVisibilityRect`, the row-reveal mask) handle it automatically — but the *manual* coordinate helpers `findTextItem` / `collectSelectableTextItems` (the live tap / URL / text-selection path) compute cell/title positions arithmetically and must add `table.contentInset` to the x-offset, or in-cell hit-testing is off by the inset. (These helpers still do **not** account for the table's live horizontal `scrollView.contentOffset` — a pre-existing limitation, so in-cell hit-testing is only correct at scroll offset 0.) The dead-but-symmetric `lastTextLineFrame(in:)` table branch has the same omission but has no callers. - **The 10pt rounded outer border is `contentView.layer`'s own border, NOT sublayers.** `v2TableCornerRadius = 10.0` (`InstantPageV2Layout.swift`). The renderer sets `contentView.layer.cornerRadius`/`borderColor`/`borderWidth = bordered ? v2TableBorderWidth : 0.0` in BOTH `init` and `update` (the four straight outer-edge rect layers were removed; `lineLayers` now holds only inner grid lines). **Border-only — deliberately no `masksToBounds`:** `cornerRadius` rounds the layer's border without clipping contents (filled corner cells round their own fills separately — see next bullet), and there is **zero interaction with the streaming reveal mask** (`contentView.layer.mask`, set only during AI streaming) — the border reveals row-by-row with the rows and is part of the masked layer. The rounded card belongs to the grid (scrolls with it). For a non-empty-title table (never produced by markdown/AI), the border wraps title+grid since `contentView` includes the title region — an accepted, approved nuance. - **Filled corner cells round their own fills to match the border.** A header/striped cell's background is a stripe `CALayer`; `tableStripeCornerMask(cellFrame:gridWidth:gridHeight:effectiveBorderWidth:)` detects which grid corners the cell's (grid-local) frame touches — `firstCol/firstRow` via `frame.min{X,Y} <= effectiveBorderWidth/2 + 0.5`, `lastCol/lastRow` via `frame.max{X,Y} >= grid{Width,Height} - …` (gridWidth = `item.contentSize.width`, gridHeight = `item.contentSize.height - gridOffsetY`) — and rounds only those corners: `stripe.cornerRadius = max(0, v2TableCornerRadius - effectiveBorderWidth)` (the `-borderWidth` leaves an even border ring; borderless → full radius) + `stripe.maskedCorners`, in BOTH `init` and `update`. A `CALayer`'s `backgroundColor` honors `cornerRadius`+`maskedCorners` with no `masksToBounds`. A full-width (colspan) header rounds both top corners; a one-row filled table rounds all four; bottom corners round only when the last row is filled. The empty-mask branch resets `cornerRadius = 0` **and** `maskedCorners = []` so reused stripes (persist across streaming chunks) don't keep stale rounding. Detection is grid-local, so it's independent of the `contentInset` shift / horizontal scroll. diff --git a/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift b/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift index 240e8a4de4..bd1e281844 100644 --- a/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift +++ b/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift @@ -113,7 +113,7 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi } } else if let lower { switch lower { - case .cover, .channelBanner, .details, .anchor, .table: + case .cover, .channelBanner, .details, .anchor: return 0.0 default: if fitToWidth { diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index b5c6ae8b79..bd661f6110 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -285,10 +285,10 @@ public final class InstantPageV2View: UIView { let enableSpoilerAnimations = self.renderContext.map { $0.context.sharedContext.energyUsageSettings.fullTranslucency } ?? true for view in self.itemViews { if let textView = view as? InstantPageV2TextView { + // Both fresh (makeItemView) and reused text views now build their dust through the + // single init→update→updateSpoiler path, so we only push the external animation + // setting here; its didSet rebuilds the dust if the value actually changed. textView.enableSpoilerAnimations = enableSpoilerAnimations - // makeItemView builds fresh text views via init only (no update(item:theme:)), so - // build their dust here; updateSpoiler is idempotent (no-op when there are no spoilers). - textView.updateSpoiler(animated: false) } } // Force the current reveal state (true OR false) onto every text view every layout, so a @@ -728,19 +728,19 @@ public final class InstantPageV2View: UIView { private func makeItemView(for item: InstantPageV2LaidOutItem, theme: InstantPageTheme) -> InstantPageItemView? { switch item { case let .text(text): - return InstantPageV2TextView(item: text) + return InstantPageV2TextView(item: text, theme: theme) case let .divider(divider): - return InstantPageV2DividerView(item: divider) + return InstantPageV2DividerView(item: divider, theme: theme) case let .anchor(anchor): - return InstantPageV2AnchorView(item: anchor) + return InstantPageV2AnchorView(item: anchor, theme: theme) case let .listMarker(marker): - return InstantPageV2ListMarkerView(item: marker) + return InstantPageV2ListMarkerView(item: marker, theme: theme) case let .codeBlock(block): - return InstantPageV2CodeBlockView(item: block) + return InstantPageV2CodeBlockView(item: block, theme: theme) case let .blockQuoteBar(bar): - return InstantPageV2BlockQuoteBarView(item: bar) + return InstantPageV2BlockQuoteBarView(item: bar, theme: theme) case let .shape(shape): - return InstantPageV2ShapeView(item: shape) + return InstantPageV2ShapeView(item: shape, theme: theme) case let .mediaPlaceholder(media): return InstantPageV2MediaPlaceholderView(item: media, theme: theme) case let .details(details): @@ -776,9 +776,9 @@ public final class InstantPageV2View: UIView { return InstantPageV2MediaPlaceholderView(item: placeholderFallback(for: media), theme: theme) } case let .formula(formula): - return InstantPageV2FormulaView(item: formula) + return InstantPageV2FormulaView(item: formula, theme: theme) case let .thinking(thinking): - return InstantPageV2ThinkingView(item: thinking) + return InstantPageV2ThinkingView(item: thinking, theme: theme) } } @@ -880,33 +880,25 @@ final class InstantPageV2TextView: UIView, InstantPageItemView { private var revealLineMaskLayers: [SimpleLayer] = [] private var animatingSnippetLayers: [SnippetLayer] = [] - init(item: InstantPageV2TextItem) { + init(item: InstantPageV2TextItem, theme: InstantPageTheme) { self.item = item self.renderContainer = UIView() self.renderView = TextRenderView(item: item) super.init(frame: item.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset)) + // Structural wiring only (one-time); all frames/content live in update(item:theme:). self.backgroundColor = .clear self.isOpaque = false - - self.renderContainer.frame = self.bounds self.renderContainer.backgroundColor = .clear self.renderContainer.isOpaque = false self.addSubview(self.renderContainer) - - self.renderView.frame = self.bounds self.renderContainer.addSubview(self.renderView) - - self.imageContainerView.frame = self.bounds self.imageContainerView.isUserInteractionEnabled = false self.addSubview(self.imageContainerView) - - self.emojiContainerView.frame = self.bounds self.emojiContainerView.isUserInteractionEnabled = false self.addSubview(self.emojiContainerView) - - self.spoilerContainerView.frame = self.bounds self.spoilerContainerView.isUserInteractionEnabled = false self.addSubview(self.spoilerContainerView) + self.update(item: item, theme: theme) } @available(*, unavailable) @@ -917,11 +909,18 @@ final class InstantPageV2TextView: UIView, InstantPageItemView { func update(item: InstantPageV2TextItem, theme: InstantPageTheme) { let _ = theme self.item = item + // Lay every container out from the item's own (clipping-inset-expanded) frame rather than + // self.bounds, so the single path is correct regardless of when the parent assigns our + // frame — and so a reused text view that changed size (e.g. AI streaming) re-frames its + // renderContainer/renderView too, which the old update path skipped. + let containerBounds = CGRect(origin: .zero, size: item.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset).size) + self.renderContainer.frame = containerBounds + self.renderView.frame = containerBounds self.renderView.item = item self.renderView.setNeedsDisplay() - self.imageContainerView.frame = self.bounds - self.emojiContainerView.frame = self.bounds - self.spoilerContainerView.frame = self.bounds + self.imageContainerView.frame = containerBounds + self.emojiContainerView.frame = containerBounds + self.spoilerContainerView.frame = containerBounds self.renderView.displayContentsUnderSpoilers = self.displayContentsUnderSpoilers self.updateSpoiler(animated: false) } @@ -1452,10 +1451,10 @@ final class InstantPageV2DividerView: UIView, InstantPageItemView { private(set) var item: InstantPageV2DividerItem var itemFrame: CGRect { return self.item.frame } - init(item: InstantPageV2DividerItem) { + init(item: InstantPageV2DividerItem, theme: InstantPageTheme) { self.item = item super.init(frame: item.frame) - self.backgroundColor = item.color + self.update(item: item, theme: theme) } @available(*, unavailable) @@ -1474,10 +1473,11 @@ final class InstantPageV2AnchorView: UIView, InstantPageItemView { private(set) var item: InstantPageV2AnchorItem var itemFrame: CGRect { return self.item.frame } - init(item: InstantPageV2AnchorItem) { + init(item: InstantPageV2AnchorItem, theme: InstantPageTheme) { self.item = item super.init(frame: item.frame) - self.isHidden = true + self.isHidden = true // structural: zero-height, never renders + self.update(item: item, theme: theme) } @available(*, unavailable) @@ -1495,12 +1495,12 @@ final class InstantPageV2ListMarkerView: UIView, InstantPageItemView { private(set) var item: InstantPageV2ListMarkerItem var itemFrame: CGRect { return self.item.frame } - init(item: InstantPageV2ListMarkerItem) { + init(item: InstantPageV2ListMarkerItem, theme: InstantPageTheme) { self.item = item super.init(frame: item.frame) - self.backgroundColor = .clear - self.isOpaque = false - self.rebuildContents() + self.backgroundColor = .clear // structural + self.isOpaque = false // structural + self.update(item: item, theme: theme) } @available(*, unavailable) @@ -1569,11 +1569,10 @@ final class InstantPageV2BlockQuoteBarView: UIView, InstantPageItemView { private(set) var item: InstantPageV2BarItem var itemFrame: CGRect { return self.item.frame } - init(item: InstantPageV2BarItem) { + init(item: InstantPageV2BarItem, theme: InstantPageTheme) { self.item = item super.init(frame: item.frame) - self.backgroundColor = item.color - self.layer.cornerRadius = item.cornerRadius + self.update(item: item, theme: theme) } @available(*, unavailable) @@ -1593,10 +1592,10 @@ final class InstantPageV2ShapeView: UIView, InstantPageItemView { private(set) var item: InstantPageV2ShapeItem var itemFrame: CGRect { return self.item.frame } - init(item: InstantPageV2ShapeItem) { + init(item: InstantPageV2ShapeItem, theme: InstantPageTheme) { self.item = item super.init(frame: item.frame) - self.applyKind() + self.update(item: item, theme: theme) } @available(*, unavailable) @@ -1629,9 +1628,7 @@ final class InstantPageV2MediaPlaceholderView: UIView, InstantPageItemView { init(item: InstantPageV2MediaPlaceholderItem, theme: InstantPageTheme) { self.item = item super.init(frame: item.frame) - self.backgroundColor = theme.imageTintColor?.withAlphaComponent(0.2) ?? UIColor(white: 0.85, alpha: 1.0) - self.layer.cornerRadius = item.cornerRadius - self.clipsToBounds = item.cornerRadius > 0.0 + self.update(item: item, theme: theme) } @available(*, unavailable) @@ -1679,45 +1676,36 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView { frame: item.titleTextItem.frame, textItem: item.titleTextItem ) - self.titleTextView = InstantPageV2TextView(item: titleV2Item) + self.titleTextView = InstantPageV2TextView(item: titleV2Item, theme: theme) self.titleTextView.isUserInteractionEnabled = false self.chevronView = UIImageView() 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). self.chevronView.isUserInteractionEnabled = false self.separator = UIView() - self.separator.backgroundColor = item.separatorColor self.separator.isUserInteractionEnabled = false - self.titleHitView = UIView(frame: item.titleFrame) + self.titleHitView = UIView() self.titleHitView.backgroundColor = .clear super.init(frame: item.frame) - self.backgroundColor = .clear - self.clipsToBounds = true + self.backgroundColor = .clear // structural + self.clipsToBounds = true // structural — the parent's frame-height animation clips the body self.addSubview(self.titleTextView) self.addSubview(self.chevronView) self.addSubview(self.separator) - if item.isExpanded, let innerLayout = item.innerLayout { - let body = InstantPageV2View(renderContext: renderContext) - body.update(layout: innerLayout, theme: theme, animation: .None) - body.frame = CGRect( - origin: CGPoint(x: 0.0, y: item.titleFrame.maxY), - size: innerLayout.contentSize - ) - self.addSubview(body) - self.bodyView = body - } - let tap = UITapGestureRecognizer(target: self, action: #selector(self.titleTapped)) self.insertSubview(self.titleHitView, at: 0) self.titleHitView.addGestureRecognizer(tap) + + // All content (title, chevron tint/position, separator, titleHit frame, body) flows through + // update — its expanded branch lazily creates the body, so init no longer builds it itself. + self.update(item: item, theme: theme, renderContext: renderContext, animation: .None) } @available(*, unavailable) @@ -1824,25 +1812,23 @@ final class InstantPageV2CodeBlockView: UIView, InstantPageItemView { private let backgroundLayer: CALayer let textView: InstantPageV2TextView - init(item: InstantPageV2CodeBlockItem) { + init(item: InstantPageV2CodeBlockItem, theme: InstantPageTheme) { self.item = item self.backgroundLayer = CALayer() - self.backgroundLayer.backgroundColor = item.backgroundColor.cgColor - self.backgroundLayer.cornerRadius = item.cornerRadius - self.backgroundLayer.frame = CGRect(origin: .zero, size: item.frame.size) // item.textItem.frame is already in code-block content-area coords (x=17, y=backgroundInset). let innerV2TextItem = InstantPageV2TextItem( frame: item.textItem.frame, textItem: item.textItem ) - self.textView = InstantPageV2TextView(item: innerV2TextItem) + self.textView = InstantPageV2TextView(item: innerV2TextItem, theme: theme) super.init(frame: item.frame) - self.backgroundColor = .clear - self.layer.addSublayer(self.backgroundLayer) - self.addSubview(self.textView) + self.backgroundColor = .clear // structural + self.layer.addSublayer(self.backgroundLayer) // structural + self.addSubview(self.textView) // structural + self.update(item: item, theme: theme) } @available(*, unavailable) @@ -1875,17 +1861,17 @@ final class InstantPageV2ThinkingView: UIView, InstantPageItemView { private let shimmerView: ShimmeringMaskView private let textView: InstantPageV2TextView - init(item: InstantPageV2ThinkingItem) { + init(item: InstantPageV2ThinkingItem, theme: InstantPageTheme) { 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) + self.textView = InstantPageV2TextView(item: innerV2TextItem, theme: theme) super.init(frame: item.frame) - self.backgroundColor = .clear - self.addSubview(self.shimmerView) - self.shimmerView.contentView.addSubview(self.textView) - self.layoutContents() + self.backgroundColor = .clear // structural + self.addSubview(self.shimmerView) // structural + self.shimmerView.contentView.addSubview(self.textView) // structural + self.update(item: item, theme: theme) } @available(*, unavailable) @@ -1957,84 +1943,51 @@ final class InstantPageV2TableView: UIView, InstantPageItemView { super.init(frame: item.frame) self.backgroundColor = .clear - self.scrollView.frame = self.bounds + // Structural, one-time scroll-view configuration. Frames / contentSize / indicator + // visibility all depend on the item and are (re)applied by update(item:theme:). // Scrollable tables clip to the full width with no inset on the clip; the inset lives inside - // the scroll content width as a left margin only (see contentSize below). + // the scroll content width as a margin on BOTH sides (`contentInset * 2.0`, mirroring V1's + // `InstantPageScrollableNode`), so a scrolled-to-the-end table keeps a symmetric trailing + // inset instead of jamming its right border flush against the screen edge. self.scrollView.clipsToBounds = true - self.scrollView.contentSize = CGSize(width: item.contentSize.width + item.contentInset, height: item.contentSize.height) self.scrollView.alwaysBounceHorizontal = false self.scrollView.alwaysBounceVertical = false - self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width + item.contentInset > item.frame.width self.scrollView.showsVerticalScrollIndicator = false self.scrollView.disablesInteractiveTransitionGestureRecognizer = true self.addSubview(self.scrollView) - - // Grid container shifted right by the inset so the borders start at the body-text inset. - // Its size stays the bare grid (item.contentSize); grid coords inside remain x=0-relative. - self.contentView.frame = CGRect(x: item.contentInset, y: 0.0, width: item.contentSize.width, height: item.contentSize.height) self.scrollView.addSubview(self.contentView) - // Title sub-layout (above the grid, inside the scroll view's content). - if let titleLayout = item.titleSubLayout, let titleFrame = item.titleFrame { + // Build the (content-less) child structure sized to the construction-time item; update fills + // every frame / colour / sub-layout below. Insertion order matches the original interleaved + // build so the layer/subview z-order is unchanged (stripes at the bottom, then the title and + // cell sub-views, then the inner grid lines). Cell-count changes on later reuse are not + // reconciled here (pre-existing limitation) — update's index-guarded loops refresh in place. + if item.titleSubLayout != nil { let v = InstantPageV2View(renderContext: renderContext) - v.update(layout: titleLayout, theme: theme, animation: .None) - v.frame = CGRect(x: v2TableCellInsets.left, y: titleFrame.minY + v2TableCellInsets.top, - width: titleLayout.contentSize.width, height: titleLayout.contentSize.height) self.contentView.addSubview(v) self.titleSubView = v } - - // Grid origin: shifted down by title height when present. - let gridOffsetY = item.titleFrame?.height ?? 0.0 - let effectiveBorderWidth = item.bordered ? v2TableBorderWidth : 0.0 - let gridHeight = item.contentSize.height - gridOffsetY - - // Cell backgrounds and sub-layouts. for cell in item.cells { - if let bg = cell.backgroundColor { + if cell.backgroundColor != nil { let stripe = CALayer() - stripe.backgroundColor = bg.cgColor - stripe.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY) - // Round the stripe at any grid corner it touches, so a filled corner cell - // follows the rounded outer border instead of poking past it. - let cornerMask = tableStripeCornerMask(cellFrame: cell.frame, gridWidth: item.contentSize.width, gridHeight: gridHeight, effectiveBorderWidth: effectiveBorderWidth) - if cornerMask.isEmpty { - stripe.cornerRadius = 0.0 - stripe.maskedCorners = [] - } else { - stripe.cornerRadius = max(0.0, v2TableCornerRadius - effectiveBorderWidth) - stripe.maskedCorners = cornerMask - } self.contentView.layer.insertSublayer(stripe, at: 0) self.stripeLayers.append(stripe) } - if let subLayout = cell.subLayout { + if cell.subLayout != nil { let v = InstantPageV2View(renderContext: renderContext) - v.update(layout: subLayout, theme: theme, animation: .None) - // The sub-layout items are already offset by cell insets inside the cell frame. - v.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY) self.contentView.addSubview(v) self.cellSubViews.append(v) } } - - // Rounded outer border. `cornerRadius` rounds the layer's border WITHOUT `masksToBounds`, - // so table contents (cell fills, text) stay unclipped — only the border visual is rounded. - // Replaces the four straight outer-edge layers the old code appended to `lineLayers`. - self.contentView.layer.cornerRadius = v2TableCornerRadius - self.contentView.layer.borderColor = item.borderColor.cgColor - self.contentView.layer.borderWidth = item.bordered ? v2TableBorderWidth : 0.0 - - // Inner grid lines (between cells). if item.bordered { - for r in item.horizontalLines + item.verticalLines { + for _ in item.horizontalLines + item.verticalLines { let line = CALayer() - line.backgroundColor = item.borderColor.cgColor - line.frame = r.offsetBy(dx: 0.0, dy: gridOffsetY) self.contentView.layer.addSublayer(line) self.lineLayers.append(line) } } + + self.update(item: item, theme: theme) } @available(*, unavailable) @@ -2044,8 +1997,8 @@ final class InstantPageV2TableView: UIView, InstantPageItemView { self.item = item self.scrollView.frame = CGRect(origin: .zero, size: item.frame.size) - self.scrollView.contentSize = CGSize(width: item.contentSize.width + item.contentInset, height: item.contentSize.height) - self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width + item.contentInset > item.frame.width + self.scrollView.contentSize = CGSize(width: item.contentSize.width + item.contentInset * 2.0, height: item.contentSize.height) + self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width + item.contentInset * 2.0 > item.frame.width self.contentView.frame = CGRect(x: item.contentInset, y: 0.0, width: item.contentSize.width, height: item.contentSize.height) // Forward updates to nested V2 sub-layouts (title + each cell). Recursive update @@ -2093,10 +2046,15 @@ final class InstantPageV2TableView: UIView, InstantPageItemView { } } - // Inner line layers — refresh color in place. (`lineLayers` now holds only inner grid + // Inner line layers — refresh colour AND frame in place. (`lineLayers` holds only inner grid // lines; the outer border is the contentView layer's own rounded border, refreshed below.) - for line in self.lineLayers { + // Frames are set here (not in init) so reuse with a different grid re-positions the lines. + let lineRects = item.horizontalLines + item.verticalLines + for (i, line) in self.lineLayers.enumerated() { line.backgroundColor = item.borderColor.cgColor + if i < lineRects.count { + line.frame = lineRects[i].offsetBy(dx: 0.0, dy: gridOffsetY) + } } // Rounded outer border — refresh radius/color/width (theme or `bordered` flag may change). @@ -2276,12 +2234,12 @@ final class InstantPageV2FormulaView: UIView, InstantPageItemView { private(set) var item: InstantPageV2FormulaItem var itemFrame: CGRect { return self.item.frame } - init(item: InstantPageV2FormulaItem) { + init(item: InstantPageV2FormulaItem, theme: InstantPageTheme) { self.item = item super.init(frame: item.frame) - self.backgroundColor = .clear - self.isOpaque = false - self.buildContents() + self.backgroundColor = .clear // structural + self.isOpaque = false // structural + self.update(item: item, theme: theme) } @available(*, unavailable) @@ -2291,7 +2249,8 @@ final class InstantPageV2FormulaView: UIView, InstantPageItemView { let _ = theme self.item = item - // Image content and scroll/non-scroll shape may change with width; rebuild. + // Image content and scroll/non-scroll shape may change with width; rebuild. On the first + // call (from init) there is nothing to tear down, so this collapses to a plain build. for sub in self.subviews { sub.removeFromSuperview() } if let sublayers = self.layer.sublayers { for layer in sublayers { layer.removeFromSuperlayer() } diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index 4b594800b4..8ec8c483cb 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -320,7 +320,7 @@ public struct InstantPageV2TableItem { public let titleSubLayout: InstantPageV2Layout? public let titleFrame: CGRect? public let contentSize: CGSize // grid intrinsic size; may exceed frame.width → scroll - public let contentInset: CGFloat // left inset (= page horizontalInset) baked into the scroll content width by the renderer + public let contentInset: CGFloat // page horizontalInset; the renderer shifts the grid right by it and pads the scroll content by it on BOTH sides public let cells: [InstantPageV2TableCell] public let horizontalLines: [CGRect] public let verticalLines: [CGRect] @@ -1112,10 +1112,14 @@ private func layoutTable( var minCellWidth: CGFloat = 1.0 var maxCellWidth: CGFloat = 1.0 if let text = cell.text { - let attrStr = attributedStringForRichText(text, styleStack: styleStack) + // Mirror V1 (`InstantPageTableItem.layoutTableItem`): `attributedStringForRichText`'s + // boundingWidth sizes inline attachments to `cellWidthLimit - totalCellPadding`, while + // the line-break budget passed to `layoutTextItem` is the full `cellWidthLimit`. (V1 + // subtracts `totalCellPadding` only on the attribute-string arg, not the layout arg.) + let attrStr = attributedStringForRichText(text, styleStack: styleStack, boundingWidth: cellWidthLimit - totalCellPadding) if let shortestItem = layoutTextItem( attrStr, - boundingWidth: cellWidthLimit - totalCellPadding, + boundingWidth: cellWidthLimit, offset: CGPoint(), minimizeWidth: true, fitToWidth: context.fitToWidth, @@ -1125,7 +1129,7 @@ private func layoutTable( } if let longestItem = layoutTextItem( attrStr, - boundingWidth: cellWidthLimit - totalCellPadding, + boundingWidth: cellWidthLimit, offset: CGPoint(), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects @@ -1546,7 +1550,7 @@ private func layoutTable( // The table item frame spans the full visible bubble interior (`boundingWidth`); the scroll // viewport equals what is actually visible. contentSize.width is the intrinsic grid width - // (may exceed frame.width → horizontal scroll); the renderer adds the left inset on top. + // (may exceed frame.width → horizontal scroll); the renderer adds the inset on both sides. let tableFrame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: totalHeight + (titleFrame?.height ?? 0.0)) diff --git a/submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift b/submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift index 50b824f2fa..bb00d9aad5 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift @@ -137,11 +137,10 @@ final class InstantPageV2MediaImageView: UIView, InstantPageItemView { ) super.init(frame: item.frame) - self.backgroundColor = .clear - self.layer.cornerRadius = item.cornerRadius - self.clipsToBounds = item.cornerRadius > 0.0 - self.addSubview(self.wrappedNode.view) - wrapperRef.view = self + self.backgroundColor = .clear // structural + self.addSubview(self.wrappedNode.view) // structural + wrapperRef.view = self // structural: back-reference for the openMedia closure + self.update(item: item, theme: theme, renderContext: renderContext) } @available(*, unavailable) @@ -193,11 +192,10 @@ final class InstantPageV2MediaVideoView: UIView, InstantPageItemView { ) super.init(frame: item.frame) - self.backgroundColor = .clear - self.layer.cornerRadius = item.cornerRadius - self.clipsToBounds = item.cornerRadius > 0.0 - self.addSubview(self.wrappedNode.view) - wrapperRef.view = self + self.backgroundColor = .clear // structural + self.addSubview(self.wrappedNode.view) // structural + wrapperRef.view = self // structural: back-reference for the openMedia closure + self.update(item: item, theme: theme, renderContext: renderContext) } @available(*, unavailable) @@ -249,11 +247,10 @@ final class InstantPageV2MediaMapView: UIView, InstantPageItemView { ) super.init(frame: item.frame) - self.backgroundColor = .clear - self.layer.cornerRadius = item.cornerRadius - self.clipsToBounds = item.cornerRadius > 0.0 - self.addSubview(self.wrappedNode.view) - wrapperRef.view = self + self.backgroundColor = .clear // structural + self.addSubview(self.wrappedNode.view) // structural + wrapperRef.view = self // structural: back-reference for the openMedia closure + self.update(item: item, theme: theme, renderContext: renderContext) } @available(*, unavailable) @@ -305,11 +302,10 @@ final class InstantPageV2MediaCoverImageView: UIView, InstantPageItemView { ) super.init(frame: item.frame) - self.backgroundColor = .clear - self.layer.cornerRadius = item.cornerRadius - self.clipsToBounds = item.cornerRadius > 0.0 - self.addSubview(self.wrappedNode.view) - wrapperRef.view = self + self.backgroundColor = .clear // structural + self.addSubview(self.wrappedNode.view) // structural + wrapperRef.view = self // structural: back-reference for the openMedia closure + self.update(item: item, theme: theme, renderContext: renderContext) } @available(*, unavailable) From d4736fe59f96e47e681b9fac704030a014e49f52 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Sun, 31 May 2026 15:04:50 +0200 Subject: [PATCH 09/10] Revert tgwatch signing --- Telegram/BUILD | 8 -- Telegram/prebuilt_watchos.bzl | 103 +++---------------- Telegram/prebuilt_watchos_build.sh | 128 +++++++++++++++++++++++ Telegram/prebuilt_watchos_compile.sh | 60 ----------- Telegram/prebuilt_watchos_patch.sh | 146 --------------------------- 5 files changed, 140 insertions(+), 305 deletions(-) create mode 100755 Telegram/prebuilt_watchos_build.sh delete mode 100755 Telegram/prebuilt_watchos_compile.sh delete mode 100755 Telegram/prebuilt_watchos_patch.sh diff --git a/Telegram/BUILD b/Telegram/BUILD index 59cc632763..0ed745668f 100644 --- a/Telegram/BUILD +++ b/Telegram/BUILD @@ -1746,14 +1746,6 @@ xcode_provisioning_profile( apple_prebuilt_watchos_application( name = "TelegramWatchApp", - # The watch app's bundle id must be `.watchkitapp` for the host - # ios_application's child-bundle-id prefix check; the rule derives the - # companion (host) bundle id from this by stripping `.watchkitapp` and the - # patch worker writes both CFBundleIdentifier and WKCompanionAppBundleIdentifier - # into the embedded watch app's Info.plist. - bundle_id = "{telegram_bundle_id}.watchkitapp".format( - telegram_bundle_id = telegram_bundle_id, - ), tags = ["manual"], ) diff --git a/Telegram/prebuilt_watchos.bzl b/Telegram/prebuilt_watchos.bzl index 69f25f04f1..f4ce9d66ad 100644 --- a/Telegram/prebuilt_watchos.bzl +++ b/Telegram/prebuilt_watchos.bzl @@ -1,25 +1,9 @@ """Embeds the standalone, xcodebuild-built tgwatch watch app into the Bazel iOS build. -`apple_prebuilt_watchos_application` builds the watch app in two actions and exposes +`apple_prebuilt_watchos_application` runs `xcodebuild` (via prebuilt_watchos_build.sh) +against an exported tgwatch source tree, optionally codesigns the result, and exposes it through the providers that `ios_application(watch_application = ...)` consumes: - 1. PrebuiltWatchosCompile (prebuilt_watchos_compile.sh) — runs `xcodebuild` against - the exported tgwatch source tree with PLACEHOLDER version/api values, emitting an - unsigned .app archive. Depends only on the source snapshot. - 2. PrebuiltWatchosPatchSign (prebuilt_watchos_patch.sh) — rewrites the six per-build - Info.plist keys (version, build number, api id/hash, watch CFBundleIdentifier, - WKCompanionAppBundleIdentifier) on the compiled app, then optionally codesigns - it. The watch + companion bundle ids must track the host's `telegram_bundle_id` - (rules_apple validates both via the parent ios_application's - bundle_verification_targets), so they live in the patch action — not in the - xcodebuild snapshot — to keep the compile cached across host-bundle-id changes. - -Splitting them lets Bazel cache the (expensive, ~4-min) compile whenever only the -version/build number/api/identity/bundle-id change — those values never reach the -compiled binary, only the Info.plist. - -The providers exposed: - * AppleBundleInfo — bundle metadata (the host reads only `.product_type`). * AppleEmbeddableInfo — `watch_bundles` (the zipped .app placed under Watch/). @@ -48,19 +32,6 @@ def _apple_prebuilt_watchos_application_impl(ctx): api_hash = ctx.var.get("watchApiHash", "placeholder") identity = ctx.var.get("watchSigningIdentity", "") - # The watch bundle id is `.watchkitapp`; strip the suffix to recover the - # host bundle id, which the patch worker writes to WKCompanionAppBundleIdentifier - # (and the watch's own CFBundleIdentifier needs to be the watch bundle id, not the - # hardcoded one baked in by xcodebuild from the snapshot's pbxproj). The host - # ios_application validates both: child CFBundleIdentifier must start with - # `.`, and child WKCompanionAppBundleIdentifier must equal the host's - # bundle id (see rules_apple's bundle_verification_targets in ios_rules.bzl). - watch_bundle_id = ctx.attr.bundle_id - _watchkitapp_suffix = ".watchkitapp" - if not watch_bundle_id.endswith(_watchkitapp_suffix): - fail("apple_prebuilt_watchos_application bundle_id must end with '.watchkitapp' (got %r)" % watch_bundle_id) - host_bundle_id = watch_bundle_id[:-len(_watchkitapp_suffix)] - # The provisioning profile is an external, machine-specific absolute path passed via # --define rather than a Bazel label, so the gitignored profile need not be exposed as # a target. The local action reads it directly. Empty => unsigned build; when set but @@ -72,69 +43,25 @@ def _apple_prebuilt_watchos_application_impl(ctx): # build version from buildNumber (Make.py always emits --define=buildNumber). build_number = ctx.var.get("buildNumber", "1") archive = ctx.actions.declare_file(ctx.label.name + ".zip") - # Intermediate output of the compile action: the unsigned, placeholder-version .app. - # Keyed only on the source snapshot, so version/build/api/identity changes reuse it. - compiled_archive = ctx.actions.declare_file(ctx.label.name + "_compiled.zip") # The host ios_application reads the watch app's Info.plist (via AppleBundleInfo.infoplist) # to verify WKCompanionAppBundleIdentifier against the host bundle id, so expose it as a # separate output (resources.bzl bundle_verification crashes on a None infoplist). infoplist = ctx.actions.declare_file(ctx.label.name + "_Info.plist") - # The compile action runs xcodebuild locally (needs the host's Xcode + SwiftPM - # network access), but its output — an unsigned, placeholder-version .app — is - # portable across machines that share the same Xcode SDK, so it IS shared via the - # remote cache: `no-remote-exec` (not `no-remote`) lets Bazel read/write the - # `--remote_cache` while still pinning execution local on cache miss. This is the - # big win when CI builders share a remote cache — a peer's xcodebuild result is - # reused instead of every fresh worker paying the ~4-min build. Caveat: if the - # build fleet runs different Xcode major versions, mismatched artifacts could be - # served (the action key does not include the Xcode version); align Xcode across - # builders, or downgrade to `no-remote-cache` to be safe. - compile_exec_requirements = { - "no-sandbox": "1", - "no-remote-exec": "1", - "local": "1", - "requires-network": "1", - } - - # The patch+sign action cannot share results across machines: its inputs include - # the absolute `--watchProvisioningProfile` path and the codesigning identity is - # resolved from the local keychain, both machine-specific. Remote-cache lookups - # would essentially never hit and uploads would just waste bandwidth, so keep the - # umbrella `no-remote` here. - patch_exec_requirements = { + # Track the in-repo snapshot so the watch build re-runs only when it changes. + inputs = [ctx.file._worker, ctx.file.versions_json] + ctx.files.srcs + exec_requirements = { "no-sandbox": "1", "no-remote": "1", "local": "1", "requires-network": "1", } - # Action 1 — compile. Inputs are ONLY the in-repo snapshot (+ the worker), so this - # (expensive) xcodebuild re-runs only when the watch sources change, not when the - # version, build number, api id/hash or signing identity change. ctx.actions.run( executable = "/bin/bash", arguments = [ - ctx.file._compile_worker.path, + ctx.file._worker.path, source_path, - compiled_archive.path, - ], - inputs = [ctx.file._compile_worker] + ctx.files.srcs, - outputs = [compiled_archive], - mnemonic = "PrebuiltWatchosCompile", - progress_message = "Compiling watch app via xcodebuild", - execution_requirements = compile_exec_requirements, - use_default_shell_env = True, - ) - - # Action 2 — patch Info.plist (version/build/api) + optionally sign. Cheap; re-runs - # on any of those changes without re-running the compile above. versions.json (the - # marketing version) is an input here only, so a version bump skips the compile. - ctx.actions.run( - executable = "/bin/bash", - arguments = [ - ctx.file._patch_worker.path, - compiled_archive.path, archive.path, api_id, api_hash, @@ -143,14 +70,12 @@ def _apple_prebuilt_watchos_application_impl(ctx): infoplist.path, ctx.file.versions_json.path, build_number, - host_bundle_id, - watch_bundle_id, ], - inputs = [ctx.file._patch_worker, compiled_archive, ctx.file.versions_json], + inputs = inputs, outputs = [archive, infoplist], - mnemonic = "PrebuiltWatchosPatchSign", - progress_message = "Patching%s watch app Info.plist" % (" + signing" if profile else ""), - execution_requirements = patch_exec_requirements, + mnemonic = "PrebuiltWatchosBuild", + progress_message = "Building%s watch app via xcodebuild" % (" + signing" if profile else ""), + execution_requirements = exec_requirements, use_default_shell_env = True, ) @@ -205,12 +130,8 @@ apple_prebuilt_watchos_application = rule( default = "//:versions.json", doc = "Source of the marketing version (key 'app'), kept in sync with the host app.", ), - "_compile_worker": attr.label( - default = "//Telegram:prebuilt_watchos_compile.sh", - allow_single_file = True, - ), - "_patch_worker": attr.label( - default = "//Telegram:prebuilt_watchos_patch.sh", + "_worker": attr.label( + default = "//Telegram:prebuilt_watchos_build.sh", allow_single_file = True, ), }, diff --git a/Telegram/prebuilt_watchos_build.sh b/Telegram/prebuilt_watchos_build.sh new file mode 100755 index 0000000000..1e1a4d537b --- /dev/null +++ b/Telegram/prebuilt_watchos_build.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Worker for the apple_prebuilt_watchos_application Bazel rule. +# +# Builds the tgwatch watch app via xcodebuild (device, Release, UNSIGNED), then +# — if a provisioning profile is supplied — codesigns the app and its nested +# frameworks with the watchkitapp provisioning profile and a matching identity, +# and finally zips the .app into the rule's output archive. +# +# The host ios_application embeds this archive under Watch/ and re-seals the host; +# it does NOT re-sign the watch app, so the watch signing must happen here. +# +# Args: +# $1 source_path Execroot-relative path to the committed in-repo snapshot +# (Telegram/WatchApp), which contains tgwatch.xcodeproj. +# $2 output_zip Path (declared by Bazel) to write the .app archive to +# $3 api_id TG_API_ID build setting +# $4 api_hash TG_API_HASH build setting +# $5 identity Codesigning identity (SHA1 hash); empty => derived from $6's cert +# $6 profile Path to the watchkitapp .mobileprovision; empty => unsigned build +set -euo pipefail + +SRC="$1"; OUT_ZIP="$2"; API_ID="$3"; API_HASH="$4"; IDENTITY="${5:-}"; PROFILE="${6:-}"; INFOPLIST_OUT="${7:-}"; VERSIONS_JSON="${8:-}"; BUILD_NUMBER="${9:-1}" + +if [ ! -e "$SRC/tgwatch.xcodeproj" ]; then + echo "error: no tgwatch.xcodeproj at $SRC (re-sync the Telegram/WatchApp snapshot via tgwatch/tools/export-sources.sh)" >&2 + exit 1 +fi + +# Match the host app's version (rules_apple requires the embedded watch app's +# CFBundleShortVersionString/CFBundleVersion to equal the parent's). +MARKETING_VERSION="0.1" +if [ -n "$VERSIONS_JSON" ]; then + MARKETING_VERSION="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['app'])" "$VERSIONS_JSON")" +fi + +DD="$(mktemp -d)" +trap 'rm -rf "$DD"' EXIT + +# Build from a writable copy so xcodebuild/SwiftPM never write into the (possibly +# in-repo, read-only) source tree — e.g. SwiftPM's Package.resolved or the workspace. +# The tree is small (~12M); a plain cp on each (uncached) build is acceptable. +WORKSRC="$DD/src" +mkdir -p "$WORKSRC" +cp -R "$SRC/." "$WORKSRC/" + +xcodebuild \ + -project "$WORKSRC/tgwatch.xcodeproj" \ + -scheme "tgwatch Watch App" \ + -configuration Release \ + -destination 'generic/platform=watchOS' \ + -derivedDataPath "$DD" \ + -quiet \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" \ + TG_API_ID="$API_ID" TG_API_HASH="$API_HASH" \ + MARKETING_VERSION="$MARKETING_VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ + build 1>&2 + +APP="$(find "$DD/Build/Products" -maxdepth 2 -name 'tgwatch Watch App.app' -type d | head -1)" +if [ -z "$APP" ]; then + echo "error: built watch .app not found under $DD/Build/Products" >&2 + exit 1 +fi + +# Expose the watch app's Info.plist (the host reads it to verify the companion +# bundle-id linkage). Codesigning does not alter Info.plist content, so capture it now. +if [ -n "$INFOPLIST_OUT" ]; then + cp "$APP/Info.plist" "$INFOPLIST_OUT" +fi + +if [ -n "$IDENTITY" ] && [ -z "$PROFILE" ]; then + echo "error: a signing identity was given but no provisioning profile (set --watchProvisioningProfile=)" >&2 + exit 1 +fi + +# Sign the watch app whenever a provisioning profile is available. When no explicit +# identity is supplied, derive it from the certificate embedded in that profile, so +# the watch app is signed with the same distribution/development identity as the host +# app (resolved from the shared codesigning material) — required for App Store, where +# every nested bundle must carry the Apple submission certificate. Without a profile +# the app is left unsigned (the host does not re-sign it). +if [ -n "$PROFILE" ]; then + cp "$PROFILE" "$APP/embedded.mobileprovision" + ENT="$(mktemp)" + trap 'rm -rf "$DD" "$ENT" "$ENT.plist"' EXIT + security cms -D -i "$APP/embedded.mobileprovision" > "$ENT.plist" + if ! /usr/libexec/PlistBuddy -x -c 'Print :Entitlements' "$ENT.plist" > "$ENT" 2>/dev/null; then + echo "error: provisioning profile has no Entitlements key: $PROFILE" >&2 + exit 1 + fi + + if [ -z "$IDENTITY" ]; then + # The identity is the SHA-1 of the profile's first embedded certificate, which is + # exactly how codesign / the keychain reference it. The matching private key must + # be in the keychain (it is: the same cert signs the host app). + IDENTITY="$(python3 -c "import sys,plistlib,subprocess,hashlib; d=plistlib.loads(subprocess.run(['security','cms','-D','-i',sys.argv[1]],capture_output=True).stdout); print(hashlib.sha1(d['DeveloperCertificates'][0]).hexdigest().upper())" "$APP/embedded.mobileprovision")" + if [ -z "$IDENTITY" ]; then + echo "error: could not derive a signing identity from the provisioning profile (no DeveloperCertificates): $PROFILE" >&2 + exit 1 + fi + echo "note: signing watch app with identity $IDENTITY derived from $(basename "$PROFILE")" >&2 + fi + + # Distribution profiles (App Store / Ad Hoc) set get-task-allow=false and require a + # secure timestamp; development builds set it true and can skip the timestamp (faster, + # no round-trip to Apple's timestamp service). + TS_FLAG="--timestamp" + if /usr/libexec/PlistBuddy -c 'Print :get-task-allow' "$ENT" 2>/dev/null | grep -qi '^true$'; then + TS_FLAG="--timestamp=none" + fi + + # Sign inside-out: nested frameworks first, then the app bundle. + if [ -d "$APP/Frameworks" ]; then + for fw in "$APP/Frameworks/"*; do + [ -e "$fw" ] || continue + codesign --force "$TS_FLAG" --sign "$IDENTITY" "$fw" 1>&2 + done + fi + codesign --force "$TS_FLAG" --sign "$IDENTITY" --entitlements "$ENT" "$APP" 1>&2 + codesign --verify --deep --strict "$APP" 1>&2 +else + echo "warning: no watch provisioning profile supplied; the watch app will be UNSIGNED and will be rejected by the App Store. Pass --watchProvisioningProfile, or build with codesigning material that includes the watchkitapp profile." >&2 +fi + +# $OUT_ZIP is execroot-relative; the action's cwd is the execroot, so do NOT cd +# (that would resolve $OUT_ZIP against the DerivedData dir). --keepParent makes the +# archive root the .app itself even when $APP is an absolute path. +rm -f "$OUT_ZIP" +/usr/bin/ditto -c -k --keepParent "$APP" "$OUT_ZIP" diff --git a/Telegram/prebuilt_watchos_compile.sh b/Telegram/prebuilt_watchos_compile.sh deleted file mode 100755 index d85a089ade..0000000000 --- a/Telegram/prebuilt_watchos_compile.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# Compile worker for the apple_prebuilt_watchos_application Bazel rule (action 1 of 2). -# -# Builds the tgwatch watch app via xcodebuild (device, Release, UNSIGNED) with -# PLACEHOLDER version/api values, then zips the .app into the rule's intermediate -# archive. It deliberately depends only on the watch source snapshot — version, -# build number, api id/hash and signing are all applied later by -# prebuilt_watchos_patch.sh, so this (expensive, ~4-min) action stays cached across -# version/build/identity changes. -# -# Args: -# $1 source_path Execroot-relative path to the committed in-repo snapshot -# (Telegram/WatchApp), which contains tgwatch.xcodeproj. -# $2 output_zip Path (declared by Bazel) to write the unsigned .app archive to. -set -euo pipefail - -SRC="$1"; OUT_ZIP="$2" - -if [ ! -e "$SRC/tgwatch.xcodeproj" ]; then - echo "error: no tgwatch.xcodeproj at $SRC (re-sync the Telegram/WatchApp snapshot via tgwatch/tools/export-sources.sh)" >&2 - exit 1 -fi - -DD="$(mktemp -d)" -trap 'rm -rf "$DD"' EXIT - -# Build from a writable copy so xcodebuild/SwiftPM never write into the (possibly -# in-repo, read-only) source tree — e.g. SwiftPM's Package.resolved or the workspace. -# The tree is small (~12M); a plain cp on each (uncached) build is acceptable. -WORKSRC="$DD/src" -mkdir -p "$WORKSRC" -cp -R "$SRC/." "$WORKSRC/" - -# Version/api are placeholders here; prebuilt_watchos_patch.sh overwrites the four -# Info.plist keys afterward. They only ever land in the Info.plist (via $(...) -# substitution and a runtime Bundle.main lookup), never in the compiled binary, so -# the build output is independent of them. -xcodebuild \ - -project "$WORKSRC/tgwatch.xcodeproj" \ - -scheme "tgwatch Watch App" \ - -configuration Release \ - -destination 'generic/platform=watchOS' \ - -derivedDataPath "$DD" \ - -quiet \ - CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" \ - TG_API_ID=0 TG_API_HASH=placeholder \ - MARKETING_VERSION=0.0 CURRENT_PROJECT_VERSION=0 \ - build 1>&2 - -APP="$(find "$DD/Build/Products" -maxdepth 2 -name 'tgwatch Watch App.app' -type d | head -1)" -if [ -z "$APP" ]; then - echo "error: built watch .app not found under $DD/Build/Products" >&2 - exit 1 -fi - -# $OUT_ZIP is execroot-relative; the action's cwd is the execroot, so do NOT cd -# (that would resolve $OUT_ZIP against the DerivedData dir). --keepParent makes the -# archive root the .app itself even when $APP is an absolute path. -rm -f "$OUT_ZIP" -/usr/bin/ditto -c -k --keepParent "$APP" "$OUT_ZIP" diff --git a/Telegram/prebuilt_watchos_patch.sh b/Telegram/prebuilt_watchos_patch.sh deleted file mode 100755 index 4c32f4982d..0000000000 --- a/Telegram/prebuilt_watchos_patch.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env bash -# Patch + sign worker for the apple_prebuilt_watchos_application Bazel rule (action 2 of 2). -# -# Takes the unsigned, placeholder-version watch .app archive produced by -# prebuilt_watchos_compile.sh, rewrites the six per-build Info.plist values -# (CFBundleShortVersionString, CFBundleVersion, TG_API_ID, TG_API_HASH, -# CFBundleIdentifier, WKCompanionAppBundleIdentifier) — none of which affect the -# compiled binary — then — if a provisioning profile is supplied — codesigns the -# app and its nested frameworks with the watchkitapp provisioning profile and a -# matching identity, and finally zips the .app into the rule's output archive. -# -# Bundle-id rewriting is needed because xcodebuild bakes the snapshot's pbxproj -# PRODUCT_BUNDLE_IDENTIFIER (ph.telegra.Telegraph.watchkitapp) into the compiled -# Info.plist, and WKCompanionAppBundleIdentifier is hardcoded in the snapshot's -# source Info.plist — but the host ios_application's bundle id varies by codesigning -# configuration (e.g. org.telegram.TelegramInternal for development) and rules_apple -# requires the child CFBundleIdentifier to start with the host bundle id and -# WKCompanionAppBundleIdentifier to equal it (bundle_verification_targets in -# ios_rules.bzl). Doing the rewrite here keeps the expensive xcodebuild action -# cached across host-bundle-id changes. -# -# Splitting this from the compile step lets Bazel cache the (expensive) xcodebuild -# whenever only the version/build number/api/identity/bundle-id change. -# -# The host ios_application embeds this archive under Watch/ and re-seals the host; -# it does NOT re-sign the watch app, so the watch signing must happen here. -# -# Args: -# $1 input_zip Compiled (unsigned, placeholder-version) .app archive from action 1 -# $2 output_zip Path (declared by Bazel) to write the final .app archive to -# $3 api_id TG_API_ID Info.plist value -# $4 api_hash TG_API_HASH Info.plist value -# $5 identity Codesigning identity (SHA1 hash); empty => derived from $6's cert -# $6 profile Path to the watchkitapp .mobileprovision; empty => unsigned build -# $7 infoplist_out Path (declared by Bazel) to copy the patched Info.plist to -# $8 versions_json versions.json (key 'app' => CFBundleShortVersionString) -# $9 build_number CFBundleVersion -# $10 host_bundle_id WKCompanionAppBundleIdentifier value (host app bundle id) -# $11 watch_bundle_id CFBundleIdentifier value (must be ".watchkitapp") -set -euo pipefail - -IN_ZIP="$1"; OUT_ZIP="$2"; API_ID="$3"; API_HASH="$4"; IDENTITY="${5:-}"; PROFILE="${6:-}"; INFOPLIST_OUT="${7:-}"; VERSIONS_JSON="${8:-}"; BUILD_NUMBER="${9:-1}"; HOST_BUNDLE_ID="${10:-}"; WATCH_BUNDLE_ID="${11:-}" - -if [ -z "$HOST_BUNDLE_ID" ] || [ -z "$WATCH_BUNDLE_ID" ]; then - echo "error: host_bundle_id and watch_bundle_id must both be supplied (got host=$HOST_BUNDLE_ID watch=$WATCH_BUNDLE_ID)" >&2 - exit 1 -fi - -# Match the host app's version (rules_apple requires the embedded watch app's -# CFBundleShortVersionString/CFBundleVersion to equal the parent's). -MARKETING_VERSION="0.1" -if [ -n "$VERSIONS_JSON" ]; then - MARKETING_VERSION="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['app'])" "$VERSIONS_JSON")" -fi - -DD="$(mktemp -d)" -trap 'rm -rf "$DD"' EXIT - -/usr/bin/ditto -x -k "$IN_ZIP" "$DD" -APP="$(find "$DD" -maxdepth 2 -name 'tgwatch Watch App.app' -type d | head -1)" -if [ -z "$APP" ]; then - echo "error: compiled watch .app not found inside $IN_ZIP" >&2 - exit 1 -fi - -# Overwrite the placeholder values baked in at compile time. All six keys already -# exist in the compiled (binary-format) Info.plist, so PlistBuddy Set preserves their -# (string) type — matching what $(...) substitution produced and what Secrets.swift -# expects from Bundle.main.object(forInfoDictionaryKey:). The bundle-id keys track -# the host's `telegram_bundle_id` (see the file header for why); xcodebuild bakes -# `ph.telegra.Telegraph.watchkitapp` / `ph.telegra.Telegraph` here from the -# snapshot's pbxproj/Info.plist regardless of what host the rest of the build uses. -PLIST="$APP/Info.plist" -/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $MARKETING_VERSION" "$PLIST" -/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST" -/usr/libexec/PlistBuddy -c "Set :TG_API_ID $API_ID" "$PLIST" -/usr/libexec/PlistBuddy -c "Set :TG_API_HASH $API_HASH" "$PLIST" -/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $WATCH_BUNDLE_ID" "$PLIST" -/usr/libexec/PlistBuddy -c "Set :WKCompanionAppBundleIdentifier $HOST_BUNDLE_ID" "$PLIST" - -# Expose the patched watch Info.plist (the host reads it to verify the companion -# bundle-id linkage and the child version). Codesigning does not alter Info.plist -# content, so capture it now. -if [ -n "$INFOPLIST_OUT" ]; then - cp "$PLIST" "$INFOPLIST_OUT" -fi - -if [ -n "$IDENTITY" ] && [ -z "$PROFILE" ]; then - echo "error: a signing identity was given but no provisioning profile (set --watchProvisioningProfile=)" >&2 - exit 1 -fi - -# Sign the watch app whenever a provisioning profile is available. When no explicit -# identity is supplied, derive it from the certificate embedded in that profile, so -# the watch app is signed with the same distribution/development identity as the host -# app (resolved from the shared codesigning material) — required for App Store, where -# every nested bundle must carry the Apple submission certificate. Without a profile -# the app is left unsigned (the host does not re-sign it). -if [ -n "$PROFILE" ]; then - cp "$PROFILE" "$APP/embedded.mobileprovision" - ENT="$(mktemp)" - trap 'rm -rf "$DD" "$ENT" "$ENT.plist"' EXIT - security cms -D -i "$APP/embedded.mobileprovision" > "$ENT.plist" - if ! /usr/libexec/PlistBuddy -x -c 'Print :Entitlements' "$ENT.plist" > "$ENT" 2>/dev/null; then - echo "error: provisioning profile has no Entitlements key: $PROFILE" >&2 - exit 1 - fi - - if [ -z "$IDENTITY" ]; then - # The identity is the SHA-1 of the profile's first embedded certificate, which is - # exactly how codesign / the keychain reference it. The matching private key must - # be in the keychain (it is: the same cert signs the host app). - IDENTITY="$(python3 -c "import sys,plistlib,subprocess,hashlib; d=plistlib.loads(subprocess.run(['security','cms','-D','-i',sys.argv[1]],capture_output=True).stdout); print(hashlib.sha1(d['DeveloperCertificates'][0]).hexdigest().upper())" "$APP/embedded.mobileprovision")" - if [ -z "$IDENTITY" ]; then - echo "error: could not derive a signing identity from the provisioning profile (no DeveloperCertificates): $PROFILE" >&2 - exit 1 - fi - echo "note: signing watch app with identity $IDENTITY derived from $(basename "$PROFILE")" >&2 - fi - - # Distribution profiles (App Store / Ad Hoc) set get-task-allow=false and require a - # secure timestamp; development builds set it true and can skip the timestamp (faster, - # no round-trip to Apple's timestamp service). - TS_FLAG="--timestamp" - if /usr/libexec/PlistBuddy -c 'Print :get-task-allow' "$ENT" 2>/dev/null | grep -qi '^true$'; then - TS_FLAG="--timestamp=none" - fi - - # Sign inside-out: nested frameworks first, then the app bundle. - if [ -d "$APP/Frameworks" ]; then - for fw in "$APP/Frameworks/"*; do - [ -e "$fw" ] || continue - codesign --force "$TS_FLAG" --sign "$IDENTITY" "$fw" 1>&2 - done - fi - codesign --force "$TS_FLAG" --sign "$IDENTITY" --entitlements "$ENT" "$APP" 1>&2 - codesign --verify --deep --strict "$APP" 1>&2 -else - echo "warning: no watch provisioning profile supplied; the watch app will be UNSIGNED and will be rejected by the App Store. Pass --watchProvisioningProfile, or build with codesigning material that includes the watchkitapp profile." >&2 -fi - -# $OUT_ZIP is execroot-relative; the action's cwd is the execroot, so do NOT cd -# (that would resolve $OUT_ZIP against the temp dir). --keepParent makes the archive -# root the .app itself even when $APP is an absolute path. -rm -f "$OUT_ZIP" -/usr/bin/ditto -c -k --keepParent "$APP" "$OUT_ZIP" From 1120b9084ebafee9b20b201faac89ffe0ba21028 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Sun, 31 May 2026 15:34:43 +0200 Subject: [PATCH 10/10] Update tgwatch signing --- Telegram/BUILD | 8 ++++++++ Telegram/WatchApp/tgwatch Watch App/Info.plist | 2 +- Telegram/prebuilt_watchos.bzl | 6 ++++++ Telegram/prebuilt_watchos_build.sh | 11 ++++++++++- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/Telegram/BUILD b/Telegram/BUILD index 0ed745668f..ef5fa5dbdf 100644 --- a/Telegram/BUILD +++ b/Telegram/BUILD @@ -1746,6 +1746,14 @@ xcode_provisioning_profile( apple_prebuilt_watchos_application( name = "TelegramWatchApp", + # Watch bundle id tracks the host config (".watchkitapp"). The rule passes it + # to xcodebuild as PRODUCT_BUNDLE_IDENTIFIER (baked + signed); the watch Info.plist + # derives WKCompanionAppBundleIdentifier from it via $(PRODUCT_BUNDLE_IDENTIFIER:base), + # so the embedded watch app is correct for any host (ph.telegra.Telegraph, + # org.telegram.TelegramInternal, ...) with no post-build plist patching. + bundle_id = "{telegram_bundle_id}.watchkitapp".format( + telegram_bundle_id = telegram_bundle_id, + ), tags = ["manual"], ) diff --git a/Telegram/WatchApp/tgwatch Watch App/Info.plist b/Telegram/WatchApp/tgwatch Watch App/Info.plist index 1d426f5632..ca62526111 100644 --- a/Telegram/WatchApp/tgwatch Watch App/Info.plist +++ b/Telegram/WatchApp/tgwatch Watch App/Info.plist @@ -38,7 +38,7 @@ WKApplication WKCompanionAppBundleIdentifier - ph.telegra.Telegraph + $(PRODUCT_BUNDLE_IDENTIFIER:base) WKRunsIndependentlyOfCompanionApp diff --git a/Telegram/prebuilt_watchos.bzl b/Telegram/prebuilt_watchos.bzl index f4ce9d66ad..3002c45892 100644 --- a/Telegram/prebuilt_watchos.bzl +++ b/Telegram/prebuilt_watchos.bzl @@ -70,6 +70,12 @@ def _apple_prebuilt_watchos_application_impl(ctx): infoplist.path, ctx.file.versions_json.path, build_number, + # Watch app bundle id (".watchkitapp"). xcodebuild bakes it as + # PRODUCT_BUNDLE_IDENTIFIER so the signed CFBundleIdentifier matches the host + # config; the Info.plist derives WKCompanionAppBundleIdentifier from it via + # $(PRODUCT_BUNDLE_IDENTIFIER:base). Keeps the build dynamic across hosts with + # no post-build plist mutation (xcodebuild bakes, the worker signs once). + ctx.attr.bundle_id, ], inputs = inputs, outputs = [archive, infoplist], diff --git a/Telegram/prebuilt_watchos_build.sh b/Telegram/prebuilt_watchos_build.sh index 1e1a4d537b..9acc2fd93e 100755 --- a/Telegram/prebuilt_watchos_build.sh +++ b/Telegram/prebuilt_watchos_build.sh @@ -17,9 +17,17 @@ # $4 api_hash TG_API_HASH build setting # $5 identity Codesigning identity (SHA1 hash); empty => derived from $6's cert # $6 profile Path to the watchkitapp .mobileprovision; empty => unsigned build +# $7 infoplist Path (declared by Bazel) to copy the built Info.plist to +# $8 versions_json versions.json (key 'app' => CFBundleShortVersionString) +# $9 build_number CFBundleVersion +# $10 watch_bundle_id PRODUCT_BUNDLE_IDENTIFIER for xcodebuild (the watch app id, +# ".watchkitapp"); empty => keep the project default. xcodebuild +# bakes it into CFBundleIdentifier (and signs with it); the Info.plist +# derives WKCompanionAppBundleIdentifier from it via +# $(PRODUCT_BUNDLE_IDENTIFIER:base), so no post-build plist patching. set -euo pipefail -SRC="$1"; OUT_ZIP="$2"; API_ID="$3"; API_HASH="$4"; IDENTITY="${5:-}"; PROFILE="${6:-}"; INFOPLIST_OUT="${7:-}"; VERSIONS_JSON="${8:-}"; BUILD_NUMBER="${9:-1}" +SRC="$1"; OUT_ZIP="$2"; API_ID="$3"; API_HASH="$4"; IDENTITY="${5:-}"; PROFILE="${6:-}"; INFOPLIST_OUT="${7:-}"; VERSIONS_JSON="${8:-}"; BUILD_NUMBER="${9:-1}"; WATCH_BUNDLE_ID="${10:-}" if [ ! -e "$SRC/tgwatch.xcodeproj" ]; then echo "error: no tgwatch.xcodeproj at $SRC (re-sync the Telegram/WatchApp snapshot via tgwatch/tools/export-sources.sh)" >&2 @@ -53,6 +61,7 @@ xcodebuild \ CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" \ TG_API_ID="$API_ID" TG_API_HASH="$API_HASH" \ MARKETING_VERSION="$MARKETING_VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ + ${WATCH_BUNDLE_ID:+PRODUCT_BUNDLE_IDENTIFIER="$WATCH_BUNDLE_ID"} \ build 1>&2 APP="$(find "$DD/Build/Products" -maxdepth 2 -name 'tgwatch Watch App.app' -type d | head -1)"