Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios

This commit is contained in:
Ilya Laktyushin 2026-05-31 17:35:39 +02:00
commit e2de53ea17
21 changed files with 738 additions and 746 deletions

View file

@ -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,9 +93,22 @@ 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.
## 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. 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 * 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.
## 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.
@ -119,8 +130,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 +260,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 +268,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 +292,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`.

View file

@ -1746,11 +1746,11 @@ xcode_provisioning_profile(
apple_prebuilt_watchos_application(
name = "TelegramWatchApp",
# The watch app's bundle id must be `<host>.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.
# Watch bundle id tracks the host config ("<host>.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,
),

View file

@ -38,7 +38,7 @@
<key>WKApplication</key>
<true/>
<key>WKCompanionAppBundleIdentifier</key>
<string>ph.telegra.Telegraph</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER:base)</string>
<key>WKRunsIndependentlyOfCompanionApp</key>
<true/>
</dict>

View file

@ -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 `<host>.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
# `<host>.`, 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,18 @@ def _apple_prebuilt_watchos_application_impl(ctx):
infoplist.path,
ctx.file.versions_json.path,
build_number,
host_bundle_id,
watch_bundle_id,
# Watch app bundle id ("<host>.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 = [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 +136,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,
),
},

View file

@ -0,0 +1,137 @@
#!/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
# $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,
# "<host>.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}"; 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
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" \
${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)"
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=<abs path>)" >&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"

View file

@ -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"

View file

@ -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 "<host_bundle_id>.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=<abs path>)" >&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"

View file

@ -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,

View file

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

View file

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

View file

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

View file

@ -14,6 +14,7 @@ import EmojiTextAttachmentView
import AnimationCache
import MultiAnimationRenderer
import InvisibleInkDustNode
import ShimmeringMask
// MARK: - Stable item identity (for view reuse on re-layouts)
@ -29,6 +30,7 @@ import InvisibleInkDustNode
public enum InstantPageV2StableItemId: Hashable {
case media(Int) // media.index (4 media cases share this namespace)
case details(Int) // details.index
case thinking(Int) // thinking-block sequence index (own namespace)
case positional(InstantPageV2ItemKind, Int) // (caseTag, items-array position)
}
@ -48,7 +50,7 @@ public enum InstantPageV2ItemKind: Hashable {
/// `InstantPageV2View()` constructor usable.
public final class InstantPageV2RenderContext {
public let context: AccountContext
public let webpage: TelegramMediaWebpage
public private(set) var webpage: TelegramMediaWebpage
public let sourceLocation: InstantPageSourceLocation
public let imageReference: (TelegramMediaImage) -> ImageMediaReference
public let fileReference: (TelegramMediaFile) -> FileMediaReference
@ -78,6 +80,15 @@ public final class InstantPageV2RenderContext {
self.openUrl = openUrl
self.baseNavigationController = baseNavigationController
}
/// Update the content-bearing fields for a later chunk of the SAME message. Enables the
/// streaming bubble to reuse one V2View across `stableVersion` bumps instead of rebuilding.
/// Only `webpage` changes across chunks; the `imageReference`/`fileReference` closures keep
/// their construction-time `MessageReference` snapshot, which is acceptable because the message
/// id is stable across chunks (media resolves by id) and streamed AI content carries no media.
public func updateContent(webpage: TelegramMediaWebpage) {
self.webpage = webpage
}
}
// MARK: - Inline image view data
@ -212,8 +223,21 @@ public final class InstantPageV2View: UIView {
var newStableIds: [InstantPageV2StableItemId] = []
var reusedIds: Set<InstantPageV2StableItemId> = []
for (position, item) in layout.items.enumerated() {
let id = InstantPageV2View.stableId(for: item, atPosition: position)
// Two independent position counters so thinking-block churn never renumbers content
// blocks' stable ids (requirement: adding/removing a thinking block must not affect other
// blocks). Content items are numbered ignoring thinking items; thinking items get their
// own .thinking(index) namespace.
var contentPosition = 0
var thinkingPosition = 0
for item in layout.items {
let id: InstantPageV2StableItemId
if case .thinking = item {
id = InstantPageV2View.stableId(for: item, atPosition: thinkingPosition)
thinkingPosition += 1
} else {
id = InstantPageV2View.stableId(for: item, atPosition: contentPosition)
contentPosition += 1
}
if let existing = oldViewsById[id], let reusedView = self.reuse(existingView: existing, for: item, theme: theme, animation: animation) {
let newFrame = InstantPageV2View.actualFrame(forItem: item) // parent positions child
@ -261,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 initupdateupdateSpoiler 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
@ -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)
}
}
@ -699,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):
@ -747,7 +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, theme: theme)
}
}
@ -849,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)
@ -886,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)
}
@ -1421,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)
@ -1443,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)
@ -1464,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)
@ -1538,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)
@ -1562,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)
@ -1598,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)
@ -1648,67 +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()
// 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.tintColor = theme.textCategories.paragraph.color
self.chevronView.image = UIImage(bundleImageName: "Item List/ExpandingItemVerticalRegularArrow")?.withRenderingMode(.alwaysTemplate)
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)
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)
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)
@ -1727,20 +1724,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 +1738,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 +1757,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 +1773,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.
@ -1810,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)
@ -1848,8 +1848,76 @@ 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, 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, theme: theme)
super.init(frame: item.frame)
self.backgroundColor = .clear // structural
self.addSubview(self.shimmerView) // structural
self.shimmerView.contentView.addSubview(self.textView) // structural
self.update(item: item, theme: theme)
}
@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
/// 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 }
@ -1875,81 +1943,51 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
super.init(frame: item.frame)
self.backgroundColor = .clear
self.scrollView.frame = self.bounds
self.scrollView.contentSize = item.contentSize
// 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 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.alwaysBounceHorizontal = false
self.scrollView.alwaysBounceVertical = false
self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width > item.frame.width
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.disablesInteractiveTransitionGestureRecognizer = true
self.addSubview(self.scrollView)
self.contentView.frame = CGRect(origin: .zero, size: item.contentSize)
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
// 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)
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)
}
}
// Border lines.
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)
}
// 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)
}
}
self.update(item: item, theme: theme)
}
@available(*, unavailable)
@ -1959,9 +1997,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 * 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
// propagation. Cell-count or title-presence changes fall back to rebuild via the
@ -1987,21 +2025,42 @@ 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.
for line in self.lineLayers {
// 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.)
// 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).
self.contentView.layer.cornerRadius = v2TableCornerRadius
self.contentView.layer.borderColor = item.borderColor.cgColor
self.contentView.layer.borderWidth = item.bordered ? v2TableBorderWidth : 0.0
}
}
@ -2079,7 +2138,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,
@ -2089,7 +2148,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)) {
@ -2142,7 +2201,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)
@ -2150,7 +2209,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)
@ -2175,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)
@ -2190,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() }

View file

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

View file

@ -85,6 +85,7 @@ public enum InstantPageV2LaidOutItem {
case mediaMap(InstantPageV2MediaMapItem)
case mediaCoverImage(InstantPageV2MediaCoverImageItem)
case formula(InstantPageV2FormulaItem)
case thinking(InstantPageV2ThinkingItem)
public var frame: CGRect {
switch self {
@ -103,6 +104,7 @@ public enum InstantPageV2LaidOutItem {
case let .mediaMap(item): return item.frame
case let .mediaCoverImage(item): return item.frame
case let .formula(item): return item.frame
case let .thinking(item): return item.frame
}
}
@ -126,6 +128,7 @@ public enum InstantPageV2LaidOutItem {
case var .mediaMap(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaMap(item)
case var .mediaCoverImage(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaCoverImage(item)
case var .formula(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .formula(item)
case var .thinking(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .thinking(item)
}
}
}
@ -143,6 +146,13 @@ public struct InstantPageV2CodeBlockItem {
public let inset: UIEdgeInsets
}
public struct InstantPageV2ThinkingItem {
public var frame: CGRect
/// The dimmed thinking text, laid out in block-local coordinates. Drawn fully (never
/// char-reveal-masked); the shimmer + whole-block fade are the only animations.
public let textItem: InstantPageTextItem
}
public struct InstantPageV2DividerItem {
public var frame: CGRect
public let color: UIColor
@ -283,6 +293,7 @@ public struct InstantPageV2MediaPlaceholderItem {
public struct InstantPageV2DetailsItem {
public var frame: CGRect
public let index: Int
public let sideInset: CGFloat
public let titleTextItem: InstantPageTextItem
public let titleFrame: CGRect // local to this item's frame
public let separatorColor: UIColor
@ -309,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 // 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]
@ -370,6 +382,7 @@ public func layoutInstantPageV2(
instantPage.blocks,
boundingWidth: boundingWidth,
horizontalInset: horizontalInset,
kind: .topLevel,
context: &context
)
}
@ -481,6 +494,7 @@ private func layoutBlockSequence(
_ blocks: [InstantPageBlock],
boundingWidth: CGFloat,
horizontalInset: CGFloat,
kind: BlockSequenceKind,
context: inout LayoutContext
) -> InstantPageV2Layout {
var items: [InstantPageV2LaidOutItem] = []
@ -489,11 +503,12 @@ 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,
horizontalInset: horizontalInset,
kind: kind,
isCover: false,
previousItems: items,
isLast: i == blocks.count - 1,
@ -521,7 +536,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)
@ -583,6 +598,7 @@ private func layoutBlock(
_ block: InstantPageBlock,
boundingWidth: CGFloat,
horizontalInset: CGFloat,
kind: BlockSequenceKind,
isCover: Bool,
previousItems: [InstantPageV2LaidOutItem],
isLast: Bool,
@ -591,7 +607,7 @@ private func layoutBlock(
let _ = isLast // reserved for Tasks 79
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,
@ -617,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,
@ -629,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,
@ -637,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,
@ -875,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):
@ -888,9 +904,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 []
}
@ -907,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).
@ -926,6 +943,7 @@ private func layoutFormulaBlock(
return layoutParagraph(.plain(latex),
boundingWidth: boundingWidth,
horizontalInset: horizontalInset,
kind: kind,
previousItems: [],
context: &context)
}
@ -983,16 +1001,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 + 23.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 +1023,7 @@ private func layoutDetails(
blocks,
boundingWidth: boundingWidth,
horizontalInset: horizontalInset,
kind: .detail,
context: &context
)
innerLayout = layout
@ -1011,9 +1033,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
@ -1028,8 +1051,13 @@ 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(
title: RichText,
@ -1046,11 +1074,15 @@ 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`),
// 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
@ -1080,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,
@ -1093,7 +1129,7 @@ private func layoutTable(
}
if let longestItem = layoutTextItem(
attrStr,
boundingWidth: cellWidthLimit - totalCellPadding,
boundingWidth: cellWidthLimit,
offset: CGPoint(),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
@ -1135,7 +1171,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] = [:]
@ -1206,7 +1242,7 @@ private func layoutTable(
distributedWidth -= growth
finalColumnWidths[i] = width
}
totalWidth = boundingWidth
totalWidth = contentBoundingWidth
} else {
totalWidth += borderWidth
}
@ -1279,6 +1315,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 +1540,7 @@ private func layoutTable(
[.paragraph(title)],
boundingWidth: totalWidth - v2TableCellInsets.left * 2.0,
horizontalInset: 0.0,
kind: .cell,
context: &context
)
titleSubLayout = titleLayout
@ -1510,10 +1548,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 inset on both sides.
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,
@ -1525,6 +1564,7 @@ private func layoutTable(
titleSubLayout: titleSubLayout,
titleFrame: titleFrame,
contentSize: contentSize,
contentInset: horizontalInset,
cells: finalizedCells,
horizontalLines: horizontalLines,
verticalLines: verticalLines,
@ -1765,13 +1805,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(
@ -1944,6 +1985,38 @@ private func layoutCodeBlock(
))]
}
private func layoutThinking(
_ text: RichText,
boundingWidth: CGFloat,
horizontalInset: CGFloat,
context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] {
// Dimmed/secondary base color: the paragraph body color at reduced alpha. RichText keeps
// its own bold/italic/link/inline-emoji formatting on top of this base (mirrors the old
// hardcoded "Thinking" header, which used the message theme's dimmed description color).
let base = context.theme.textCategories.paragraph
let dimmedAttributes = InstantPageTextAttributes(
font: base.font,
color: base.color.withAlphaComponent(0.55),
underline: false
)
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: context.theme, attributes: dimmedAttributes)
let attributedString = attributedStringForRichText(text, styleStack: styleStack)
let (textItem, _, textSize) = layoutTextItem(
attributedString,
boundingWidth: boundingWidth - horizontalInset * 2.0,
offset: CGPoint(x: horizontalInset, y: 0.0),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
guard let textItem = textItem else { return [] }
let blockFrame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: textSize.height)
return [.thinking(InstantPageV2ThinkingItem(frame: blockFrame, textItem: textItem))]
}
// MARK: - Block quote / pull quote layout (ported from V1 InstantPageLayout.swift lines 517586)
private func layoutBlockQuote(
@ -1951,6 +2024,7 @@ private func layoutBlockQuote(
caption: RichText,
boundingWidth: CGFloat,
horizontalInset: CGFloat,
kind: BlockSequenceKind,
isLast: Bool,
context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] {
@ -1962,7 +2036,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
@ -1981,6 +2055,7 @@ private func layoutBlockQuote(
child,
boundingWidth: innerBoundingWidth,
horizontalInset: innerHorizontalInset,
kind: kind,
isCover: false,
previousItems: result,
isLast: i == blocks.count - 1 && isLast,
@ -2041,7 +2116,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
@ -2147,6 +2222,7 @@ private func layoutList(
ordered: Bool,
boundingWidth: CGFloat,
horizontalInset: CGFloat,
kind: BlockSequenceKind,
context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] {
// Determine marker characteristics.
@ -2323,13 +2399,14 @@ 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,
context: &context
)
let subLocalMaxY: CGFloat = subItems.map { $0.frame.maxY }.max() ?? 0.0
let spacing: CGFloat = (previousBlock != nil && subLocalMaxY > 0.0) ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: context.fitToWidth) : 0.0
let spacing: CGFloat = (previousBlock != nil && subLocalMaxY > 0.0) ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: context.fitToWidth, kind: .list) : 0.0
let offsetX = horizontalInset + indexSpacing + maxIndexWidth
let offsetY = contentHeight + spacing
let translatedItems = subItems.map { $0.offsetBy(CGPoint(x: offsetX, y: offsetY)) }

View file

@ -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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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