mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios
This commit is contained in:
commit
ec72568429
27 changed files with 1439 additions and 3819 deletions
353
CLAUDE.md
353
CLAUDE.md
|
|
@ -60,306 +60,17 @@ This matters in two places specifically:
|
|||
|
||||
Rare exceptions: top-level view-controller views integrating with the system's first-responder/inset model. If you find yourself wanting `self.frame = …` from inside a child view, refactor so the parent positions it instead.
|
||||
|
||||
## AI streaming animation (rich-text bubbles)
|
||||
## InstantPage V2 & rich-text messages
|
||||
|
||||
`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
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/TelegramUI/Components/StreamingTextReveal/Sources/TextRevealController.swift` | Pacing controller, shared by both bubbles. EWMA inter-arrival → velocity-smoothed cursor. |
|
||||
| `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`), 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 `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.
|
||||
- **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
|
||||
|
||||
The `ChatMessageDateAndStatusNode` mirrors TextBubble's placement, adapted to the heterogeneous V2 layout. The node is a child of `self` (the content node), **not** of the clipping `containerNode`, so it is never clipped — the bubble height must be grown to contain it.
|
||||
|
||||
- **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 (a tall inline attachment, e.g. a formula, 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` 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.
|
||||
|
||||
## InstantPage V2 text item height (true font line box)
|
||||
|
||||
`layoutTextItem` (`InstantPageV2Layout.swift`) sizes a `.text` item to the **true font line height**, not the cap box. A single-line item measures exactly `fontAscent + fontDescentBelowBaseline` (`A + D`); the old behavior was the cap box `fontLineHeight = floor(fontAscent + fontDescent)` (`A − D`).
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **Two edits in `layoutTextItem`:** the line stack starts at `lineBoxTopInset = max(0, fontAscent − fontLineHeight)` (was `0`), and the returned height is `lines.last.frame.maxY + extraDescent + fontDescentBelowBaseline` (the `+ fontDescentBelowBaseline` contains the last line's descender). Net: every text item grows ~`(A − L) + D` (~8pt @17pt) and its glyphs draw ~`lineBoxTopInset` (~4pt) lower within their box; the page grows.
|
||||
- **Per-line frames stay the cap box** (`height = lineAscent = fontLineHeight`). Only the stack's starting origin moves and the total is padded — so the baseline is still drawn at each line frame's `maxY`, inter-line advance (`lineAscent + fontLineSpacing + extraDescent`) is unchanged, and decorations / inline attachments / `characterRect` / the reveal mask (all line-frame-relative) translate consistently.
|
||||
- **`lineBoxTopInset` is exact, NOT pixel-snapped** — it is an intra-item line offset; crispness rides on the item's own pixel-snapped frame origin (intra-item line positions may already be fractional, e.g. after a non-integral `extraDescent`).
|
||||
- **Formulas / tall inline content still inflate** via `lineAscent`/`extraDescent`; the `"\u{200b}"`+anchors `height = 0` case is preserved.
|
||||
- **Inline custom emoji are sized to ≈ the line box** so they fit the taller box rather than overflowing it (see "Inline custom emoji").
|
||||
|
||||
## 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. (The **send / edit / copy / paste** round-trip that produces `.textCustomEmoji` from typed markdown is a separate section below: "Custom emoji in markdown messages".)
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift` | Enum case `textCustomEmoji(fileId: Int64, alt: String)` + Postbox coding (discriminator 17, keys `ce.f`/`ce.a`), `==`, `plainText` (returns `alt`), and FlatBuffers codec. |
|
||||
| `submodules/TelegramCore/FlatSerialization/Models/RichText.fbs` | FlatBuffers schema — `RichText_CustomEmoji` union member + table. **Source of truth**; the Bazel `flatc` genrule regenerates `*_generated.swift` at build time (the checked-in `Sources/*_generated.swift` is stale). |
|
||||
| `submodules/TelegramCore/Sources/ApiUtils/RichText.swift` | `Api.RichText.textCustomEmoji` ⇄ Swift, lossless both ways. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` (`attributedStringForRichText`) | Emits a single placeholder char carrying `ChatTextInputAttributes.customEmoji` (a `ChatTextInputTextCustomEmojiAttribute`) + a `CTRunDelegate` sized to the font line height (`font.ascender − font.descender + 4·pointSize/17` ≈ 24pt @17pt). |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` (line-breaker) | Collects per-line `InstantPageTextLine.emojiItems`; overwrites each placeholder char's `characterRect` with a full cell (`width = itemSize`) so it feeds the reveal cost map. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` (`InstantPageV2View`) | Owns the `InlineStickerItemLayer`s: `updateInlineEmoji` (create/reuse/remove/position), `updateEmojiReveal` (reveal-driven pop-in), `updateEmojiVisibility` + `propagateVisibilityRect`. Layers attach to each text view's `emojiContainerView`. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **flatc casing/`required` gotchas.** Edit `RichText.fbs`, not the generated Swift. Scalars (`long`) cannot be `(required)` — only strings/tables can. A union member `RichText_CustomEmoji` generates the Swift enum case `.richtextCustomemoji` (everything after the suffix's first letter is lowercased); the table type stays `TelegramCore_RichText_CustomEmoji` and field accessors keep `.fbs` casing (`value.fileId`). See the `flatbuffers-codegen` memory.
|
||||
- **`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`), 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.
|
||||
- **Inline emoji/images are CENTERED on the font line box, NOT baseline-aligned, and do NOT inflate the line.** The line-breaker keeps `lineAscent = fontLineHeight` (only formulas grow it) and places each attachment at `baselineY − fontLineHeight/2 − size/2`, so it bleeds symmetrically about the line box instead of doubling the line height and shoving the text baseline down (the prior `lineAscent = emoji.size` behavior was a regression from V1 `layoutTextItemWithString`, which centers via `(fontLineHeight − imageHeight)/2`). Custom emoji are sized to ≈ the line box (`size = font.ascender − font.descender + 4·pointSize/17`) so they fit the true-font-height item box (see "InstantPage V2 text item height") with minimal bleed. Mirrors the chat `InteractiveTextComponent`. The cell's `characterRect` is centered the same way (`y = fontLineHeight/2 − size/2`) so the reveal mask (`renderer: y = minY + lineAscent − rect.maxY`) tracks it; a tall attachment grows `extraDescent` so the next line isn't overlapped. Three things must stay in lockstep: the display frame, the `characterRect`, and `extraDescent`.
|
||||
- **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 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)
|
||||
|
||||
`RichText.textMention`, `.textMentionName(text:peerId:)`, `.textHashtag`, `.textCashtag`, `.textBotCommand`, `.textBankCard`, `.textAutoUrl`, `.textAutoEmail`, `.textAutoPhone` render the message-entity flavors of rich text inside rich-data bubbles with full tap interaction mirroring `ChatMessageTextBubbleContentNode`. Covers API parsing, Postbox + FlatBuffers serialization, display, and tap routing. (`textDate`/`textSpoiler` remain unimplemented — `.plain("")`.)
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift` | The 9 enum cases (each wraps `text: RichText`; `textMentionName` adds raw `peerId: Int64`) + Postbox coding (discriminators 18–26, wrapped text under key `"t"`, mention-name peerId under `"mn.p"`), `==`, `plainText`, FlatBuffers codec. |
|
||||
| `submodules/TelegramCore/FlatSerialization/Models/RichText.fbs` | Union members + tables (`RichText_MentionName` adds `peerId:long`). Source of truth — same flatc gotchas as the custom-emoji section above. |
|
||||
| `submodules/TelegramCore/Sources/ApiUtils/RichText.swift` | `Api.RichText` ⇄ Swift, lossless. `textMentionName` carries `userId` ⇄ `peerId`. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` (`attributedStringForRichText`) | Display: auto url/email/phone reuse the `InstantPageUrlItem` (`url:`) path; the six entity cases push `.link(false)`, recurse, then attach the matching `TelegramTextAttributes.*` key over the produced range. |
|
||||
| `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/...` | Tap routing: `entityForTapLocation` reads the attribute dict at the tapped point; `entityTapContent` maps keys → `ChatMessageBubbleContentTapAction.Content`. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **Display attaches the same `TelegramTextAttributes.*` keys the chat text bubble uses; the bubble reads them back.** Contract: `textMention`→`PeerTextMention` (String); `textMentionName`→`PeerMention` (`TelegramPeerMention`, peerId built as `EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, …)` — `InstantPageTextItem` imports TelegramCore but NOT Postbox, so bare `PeerId` is out of scope); `textHashtag` AND `textCashtag`→`Hashtag` (`TelegramHashtag`; no dedicated cashtag key/tap-action — the leading `$` distinguishes them); `textBotCommand`→`BotCommand`; `textBankCard`→`BankCard`. Auto url/email/phone go through the URL path (`mailto:`/`tel:`/raw), NOT an entity key.
|
||||
- **`linkSelectionRects` and the bubble tap path check all six interactive keys** (URL + the five entity keys), not just URL, so press-highlight and the link-loading shimmer cover entities too.
|
||||
- **Rich-data text selection must reach a line's trailing edge.** This is general to rich-data selection, not just entities: `InstantPageTextItem.attributesAtPoint(_:orNearest:)`'s `orNearest: true` (selection-drag) path returns `line.range.upperBound` (via `CTLineGetStringRange`) when the point is at/past `lineFrame.maxX`. `TextSelectionNode` uses that index as the **exclusive** upper bound, so clamping to the last character's index — as the `orNearest: false` hit-testing path correctly does — would leave the last character/item of every line unselectable. Mirrors `Display.TextNode`. Do not collapse the two `orNearest` paths back together.
|
||||
|
||||
## Markdown send: entity vs. rich detection
|
||||
|
||||
On message send, the app auto-decides: if the typed markdown maps onto the regular message-entity set (bold/italic/code/strikethrough/spoiler/links/blockquote/fenced-code) it sends a **normal message** via the existing entity path; if it contains structure the entity set can't represent it sends a **rich message** (`RichTextMessageAttribute` carrying an `InstantPage`, rendered by `ChatMessageRichDataBubbleContentNode`). Always-on (no flag). **Effective rich triggers are headings, lists, and tables only.**
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/BrowserUI/Sources/BrowserMarkdown.swift` | The classifier `richMarkdownAttributeIfNeeded(context:text:)` (pre-filter `markdownMightNeedRichLayout` → parse via existing `inputRichTextAttributeFromText` → block inspection `instantPageNeedsRichLayout`/`blockIsEntityExpressible`/`richTextIsEntityExpressible`), plus the markdown→InstantPage conversion (`markdownWebpage`, `markdownBlocks(from:)`, `markdownBlocksWithGeneratedAnchors`). |
|
||||
| `submodules/TelegramUI/Sources/ChatControllerNode.swift` (`sendCurrentMessage`, ~line 4860) | The gate: `if !isSpecialChatContents, let attribute = richMarkdownAttributeIfNeeded(context:, text: effectiveInputText.string)` routes to the rich branch; the unchanged `else` is the entity path. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **Boundary rule:** send rich iff the parse yields an `InstantPageBlock` with no entity equivalent. Entity-expressible whitelist (→ normal): `.paragraph`, `.preformatted`, `.blockQuote` (empty caption), `.anchor`, `.unsupported`, **and `.divider`** (`---` is too common in casual text to trigger rich). **`.formula` (block and inline) DOES trigger rich**, gated by strict math detection (see "Formulas trigger rich messages" below) so casual `$` usage (`$5-$10`, `$FOO=$BAR`) stays plain. So effective triggers = headings, lists, tables, formulas.
|
||||
- **Approach A (parse-then-inspect):** the classifier reuses the real parser, so "what triggers rich" can't drift from "what the rich renderer shows." `markdownMightNeedRichLayout` is a cheap necessary-condition over-approximation — it may over-trigger a parse but must **never** false-negative. It detects `#`, list markers, dash-lines (`-{1,}`, which also catches setext-H2 underlines → heading blocks), `\n=` (setext H1), `|`, `` — because re-send re-parses the text through the *rich* path (`richMarkdownAttributeIfNeeded` → `NSAttributedString(markdown:)`, Apple CommonMark), not `convertMarkdownToAttributes` (whose dialect is `__italic__`/`||spoiler||`). The two parsers disagree on `__`/`*`; the rich round-trip is the contract.
|
||||
- **Re-classify every edit (edit ≡ send).** `editMessage` runs the same `richMarkdownAttributeIfNeeded` on the edit field's attributed text (so reattached custom emoji round-trip — see the custom-emoji section). Rich → `pendingUpdateMessageManager.add(text: "", entities: nil, richText: attr, …)`; else the unchanged plain path. So normal→rich (add a table) and rich→plain (drop all triggers) both work. Bypassed for `.customChatContents`.
|
||||
- **Change-detection compares the rich attribute.** The save guard adds `currentRichText != richTextAttribute` (rich branch — skips no-op rich edits) and `currentRichText != nil` (plain branch — so rich→plain still saves even when `text.string` looks unchanged). `RichTextMessageAttribute` is `Equatable` on `instantPage`.
|
||||
- **The `text.length == 0` early-return guard is safe for rich.** `convertMarkdownToAttributes` only rewrites inline tokens, never strips `#`/`-`/`|`, so a rich message's markdown source stays non-empty and passes; the rich branch then sends `text: ""`.
|
||||
- **Known limitation:** a rich→plain edit that leaves only inline-formatted text loses `*italic*` (the entity path recognizes only `__…__`). Rare edge; the rich round-trip contract holds.
|
||||
- **`previewText()` lives in TelegramStringFormatting, not TextFormat/TelegramCore.** It will gain a `strings: PresentationStrings` param (to localize the `"Photo"`/`"Video"`/`"Table"` placeholders), so it must sit in a UI-string module — `messageContentKind`/`descriptionStringForMessage` (same module) already take `strings:`. Teaching `messageContentKind` about rich cascades the preview to the edit accessory panel, reply/pinned panels, and forward preview in one place (those surfaces need no individual change).
|
||||
|
||||
## Copying rich messages as markdown (whole message + partial selection)
|
||||
|
||||
Rich messages (`RichTextMessageAttribute`, `text == ""`) are copyable as markdown two ways: the context-menu **Copy** action copies the whole message; a **text selection** inside the rich-data bubble copies just the selected range. Both reconstruct markdown that mirrors the edit round-trip (`markdownStringFromInstantPage`). Always-on.
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift` | Whole-message Copy. Computes `richMessageMarkdown` from the message's `RichTextMessageAttribute.instantPage` (after `let message = messages[0]`), opens the Copy gate with `richMessageMarkdown != nil`, and short-circuits `copyTextWithEntities` to `storeMessageTextInPasteboard(markdown, entities: nil)`. |
|
||||
| `submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` | `markdownStringFromInstantPage` — the block-tree → markdown converter (also used by the edit round-trip). Blocks joined by `\n\n`; nested blockquotes via recursive `> ` wrapping. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` | `InstantPageMarkdownBlockContext` (`kind` + `quoteDepth`) and the `markdownContext: InstantPageMarkdownBlockContext?` field on `InstantPageTextItem`. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `stampMarkdownContext`/`bumpQuoteDepth`; stamps `markdownContext` during layout (heading/title/code/list/blockQuote/`layoutQuoteText`/table-cell). |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` | `markdownForRange(_ range: NSRange)` + the private attributed-substring→inline-markdown converter `inlineMarkdown(from:)`. |
|
||||
| `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/.../ChatMessageRichDataBubbleContentNode.swift` | Intercepts `.copy` in the `TextSelectionNode` `performAction` closure: `textSelectionNode.getSelection()` → `adapter.markdownForRange(range)` → stores as plain `NSAttributedString(string:)`. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **The V2 layout discards block role.** A `.text` layout item from an `H2` heading is byte-identical to a body paragraph — heading level and the title category are dropped with no back-reference to the source `InstantPageBlock`. Precise structural markdown for a *selection* therefore requires stamping `markdownContext` at layout time (lists/code/tables/details are structurally recoverable; **heading level and `.title` are not**, so they MUST be stamped). Plain paragraphs stay `nil` (≡ plain).
|
||||
- **`quoteDepth` is orthogonal to `kind`** so a heading/list/code line inside a blockquote round-trips (e.g. `> ## Title`). `bumpQuoteDepth` lifts a quote's children by 1; nested quotes accumulate. `layoutQuoteText` (single-paragraph blockquote fast path AND `.pullQuote`) bumps once — it is never reached by the multi-block recursion, so no double-count.
|
||||
- **A blockquote is exploded into one text item per line.** `markdownForRange` must re-coalesce a run of consecutive `quoteDepth > 0` segments into ONE `\n`-joined block (each line prefixed at its own depth); otherwise every quote line becomes its own block separated by a blank line. Code/table/list runs are likewise coalesced (one fence; one pipe table; one tight list).
|
||||
- **Both converters emit compact nested-quote markers (`>>`, not `> >`).** Selection: `String(repeating: ">", count: depth) + " "`. Whole-message: when wrapping a line that already starts with `>`, prepend a bare `>`. Keep the two in sync.
|
||||
- **Inline markdown is read from display attributes, not the RichText tree.** `inlineMarkdown` inspects the slice's `UIFont` (bold/italic/mono — font-based, no symbolic-trait flag for named fonts), `.strikethroughStyle`, and `TelegramTextAttributes.URL` (→ `InstantPageUrlItem.url`, angle-bracketed if it contains `(`/`)`/space). Custom-emoji placeholders now emit the `[<alt>](tg://emoji?id=…)` marker from the display attribute's `fileId` (alt is best-effort — the display placeholder may be a bare space; see the custom-emoji round-trip section).
|
||||
- **`.copy` stores plain text.** Passing `NSAttributedString(string: markdown)` through the existing `performTextSelectionAction(.copy)` path (`storeAttributedTextInPasteboard`) generates no entities, so the literal `**`/`#`/`>`/`|` survive. The whole-message Copy uses `storeMessageTextInPasteboard(_, entities: nil)` directly.
|
||||
- **Fidelity caveats (intentional):** custom emoji are now preserved as `[<alt>](tg://emoji?id=…)` markers (selection copy uses a best-effort alt — see the custom-emoji round-trip section below); ordered list + checkbox loses the ordinal (`-` wins); a partial table selection emits touched cells as rows (no forced header `---` separator); block prefixes apply to the whole touched line on a mid-line selection (correct markdown).
|
||||
|
||||
## Custom emoji in markdown messages (send + edit/copy/paste round-trip)
|
||||
|
||||
Custom emoji typed into the compose field survive when a message is sent as a **rich** message (heading/list/table/formula), rendering as `RichText.textCustomEmoji` (the display side is the "Inline custom emoji" section above). The carrier across Apple's CommonMark parser is a shared markdown-link marker `[<alt>](tg://emoji?id=<fileId>)`, used identically by the forward (send) and reverse (edit/copy/paste) paths so encode and decode cannot drift. Always-on. **Scope: only rich messages — a custom emoji alone never forces a rich message** (it stays on the entity path as a `.CustomEmoji` entity, the pre-existing behavior).
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/TextFormat/Sources/CustomEmojiMarkdownMarker.swift` | The marker format — single source of truth: `customEmojiMarkdownURL(fileId:)`, `parseCustomEmojiFileId(fromMarkdownURL:)`, `escapeCustomEmojiMarkdownAlt(_:)`, and `chatInputTextWithReattachedCustomEmoji(_:)` (markers → live `customEmoji` attributes). In TextFormat so both BrowserUI and InstantPageUI can import it. |
|
||||
| `submodules/BrowserUI/Sources/BrowserMarkdown.swift` | Forward: `markdownSourceInjectingCustomEmojiMarkers` rewrites each `customEmoji` run into the marker; `richMarkdownAttributeIfNeeded(context:attributedText:)` (signature changed from `text:`); the marker-URL intercept in `markdownInlineContent` → `.textCustomEmoji`. |
|
||||
| `submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` | Reverse (whole-message copy + edit reconstruction): `.textCustomEmoji` → emit the marker. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` | Reverse (text-selection copy): emit the marker from the display attribute's `fileId` (alt best-effort). |
|
||||
| `submodules/TelegramUI/Sources/ChatControllerNode.swift`, `…/Chat/ChatMessageDisplaySendMessageOptions.swift` | Send + send-options-preview call sites pass the `NSAttributedString` (`effectiveInputText` / `textInputView.attributedText`); the rich send now passes `inlineStickers`. |
|
||||
| `submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift` | Edit-load (`setupEditMessage`) reattaches markers via `chatInputTextWithReattachedCustomEmoji`; edit-save (`editMessage`) re-classifies the attributed edit text. |
|
||||
| `submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift` | Paste (`chatInputTextNodeShouldPaste`) reattaches plain-text markdown markers → live emoji. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **One shared marker, one set of helpers.** All emit sites (forward normalize, reverse copy/edit, selection copy) use `customEmojiMarkdownURL` + `escapeCustomEmojiMarkdownAlt`; the forward intercept and both reattach sites use `parseCustomEmojiFileId`. The marker is internal/transient — it exists only in the rich-conversion source string and on the clipboard, never persisted as a URL entity.
|
||||
- **CommonMark preserves the `tg://emoji?id=N` link URL verbatim** under the `NSLink` attribute (spike-verified). `markdownLink`'s `as? NSURL` branch returns `url.absoluteString`, which `parseCustomEmojiFileId` matches by strict prefix. Negative (signed Int64) file ids survive too (the reattach regex is `(-?\d+)`).
|
||||
- **Scope guard is structural.** `markdownSourceInjectingCustomEmojiMarkers` works on a LOCAL copy — `effectiveInputText` is never mutated. A marker is an entity-expressible link, so an emoji-only message classifies not-rich (`markdownMightNeedRichLayout` finds no `#`/`|`/`` (no run carries the link attribute), which would silently lose the emoji; every emit site and the reattach substitute a space when the alt is empty.
|
||||
- **Rich send attaches `inlineStickers`** (was `[:]`) + bubble-up packs, so the local store has the files. **OPEN runtime risk:** the wire send uses `Api.InputRichMessage.documents: nil` (`apiInputRichMessage()` in `SyncCore_RichTextMessageAttribute.swift`), so recipient rendering depends on the server back-filling `documents` from the embedded `documentId` — UNVERIFIED. If recipients see only the fallback glyph, populate `documents:` there.
|
||||
- **Accepted limitations:** edit-load reattaches with `file: nil` (renders via lazy fileId resolution, but the premium-emoji gate is bypassed on edit); an alt containing a literal `]` won't reattach on edit-load (cosmetic — re-save still parses it); `parseCustomEmojiFileId` (strict prefix) vs `Pasteboard.swift`'s `URLComponents` parse could drift if the marker format ever changes.
|
||||
|
||||
## Formulas trigger rich messages (strict math detection)
|
||||
|
||||
`$…$`/`$$…$$` (and `\(…\)`/`\[…\]`) math triggers a rich message, gated by a
|
||||
strict boundary rule so casual `$` stays plain. Inverse companion of the
|
||||
markdown-send gate above.
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **Inline `$…$`/`$$…$$` detection requires a 4-way boundary** (in `markdownReplacingInlineFormulas`, `BrowserMarkdown.swift`): outer side of each delimiter = line edge OR non-alphanumeric; inner side = non-whitespace; opener/closer `$`-counts must match (1 or 2). This is what rejects `$5-$10`/`$FOO=$BAR`/`cost$5$total` (alphanumeric outer) while keeping `$x$`, `($x$)`, `the answer is $x$.`. The outer check is the addition over a plain "no-space-inside" rule.
|
||||
- **Block `$$` detection** (`markdownBlockFormulaReplacement`): single-line `$$…$$` requires an exact `$$` opener (not `$$$`) and trailing whitespace only; multi-line requires a **bare** `$$` opener line. `$$x$$ trailing text` falls through to the inline rule. The `\[…\]` opener path is unchanged and exempt from these `$$`-only guards.
|
||||
- **Detection is shared with the document path; the gate is chat-only.** `markdownPreparedSource` (detection) runs for both chat and document attachments. The triggers (`richTextIsEntityExpressible`/`blockIsEntityExpressible` → `.formula` is non-expressible; `$`/`\(`/`\[` in `markdownMightNeedRichLayout`) are read only by the chat classifier `richMarkdownAttributeIfNeeded`.
|
||||
|
||||
## InstantPageListItem task-list checkboxes (`- [ ]` / `- [x]`)
|
||||
|
||||
`InstantPageListItem` carries a first-class `checked: Bool?` — the **third** associated value of `.text(RichText, String?, Bool?)` / `.blocks([InstantPageBlock], String?, Bool?)`, orthogonal to the ordered-list `num` — representing a GitHub-style task-list checkbox. `nil` = not a checkbox item, `false` = unchecked, `true` = checked. Covers markdown parse, Postbox + FlatBuffers serialization, Telegram API transmission, display (V1 + V2), the edit round-trip, and previews.
|
||||
|
||||
Spec: [`docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md`](docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md). Plan: [`docs/superpowers/plans/2026-05-27-instantpage-list-checkbox.md`](docs/superpowers/plans/2026-05-27-instantpage-list-checkbox.md).
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift` | The `checked: Bool?` enum payload; Postbox coding (key `"ck"`, tri-state Int32); `==`; FlatBuffers codec. Internal tri-state helpers `checkedFromTriState`/`triState(fromChecked:)`. |
|
||||
| `submodules/TelegramCore/FlatSerialization/Models/InstantPageBlock.fbs` | `checkState:int32 (id: 2)` on `InstantPageListItem_Text` + `_Blocks`. **Source of truth**; the Bazel `flatc` genrule regenerates the Swift (checked-in `*_generated.swift` is stale). |
|
||||
| `submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift` | `checked` / `num` accessors; reads & writes the API `checkbox`=flags.0 / `checked`=flags.1 bits via `checkedFromApiFlags` / `apiFlags(fromChecked:)` across all four list-item types. |
|
||||
| `submodules/BrowserUI/Sources/BrowserMarkdown.swift` | Forward parse: `markdownTaskListMarker` detects `[ ]`/`[x]`/`[X]`; the result routes into `checked` (NOT `num`). |
|
||||
| `submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` | Reverse: emits `- [ ] ` / `- [x] ` from `item.checked` for the edit round-trip. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | V2 detection via `item.checked`; `.checklist(checked:colors:)` marker carrying `InstantPageV2CheckboxColors`. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` | V2 marker view (`InstantPageV2ListMarkerView`) hosts a real `CheckNode`. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageLayout.swift` | V1 detection via `item.checked` (renders the existing `InstantPageChecklistMarkerItem`). |
|
||||
| `submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift` | `previewText()` renders a `☐`/`☑︎` glyph + body for checkbox items. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **`checked` is orthogonal to `num`.** The API keeps `checkbox`/`checked` as flags **separate from the list number**, so an ordered item can be both numbered AND a checkbox. This is exactly why the first-class field replaced an earlier sentinel-string-in-`num` prototype (which could not represent both). No `\u{001f}tg-md-task:*` sentinel remains anywhere.
|
||||
- **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 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.
|
||||
|
||||
## InstantPageBlock.blockQuote nested blocks
|
||||
|
||||
`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).
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift` | Enum case shape; Postbox coding (legacy `"t"` lift → new `"b"` object array); equality (array-aware, mirrors `.collage`); FlatBuffers codec. |
|
||||
| `submodules/TelegramCore/FlatSerialization/Models/InstantPageBlock.fbs` | `InstantPageBlock_BlockQuote`: `text` (now optional, legacy fallback) + `caption (required)` + new `blocks:[InstantPageBlock] (id: 2)`. **Source of truth**; Bazel regenerates the `*_generated.swift`. |
|
||||
| `submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift` | Parse both `pageBlockBlockquote` (lift text→`[.paragraph]`) and `pageBlockBlockquoteBlocks`; encode legacy-when-possible. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `layoutBlockQuote(blocks:…)` recurses into children; legacy single-paragraph fast path delegates to `layoutQuoteText` (the renamed shared text core, also used by `.pullQuote`). |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageLayout.swift` | V1 `.blockQuote` arm recurses via `layoutInstantPageBlock(...)`; same single-paragraph fast path. |
|
||||
| `submodules/BrowserUI/Sources/BrowserMarkdown.swift` | Forward: one quote carrying all child blocks. Entity-expressibility gate (below). |
|
||||
| `submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` | Reverse: `markdownBlockQuoteBlocks(_:)` recurses per child and prefixes `> ` per line. |
|
||||
| `submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift` | Concatenates child `previewText()`s + caption. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **Legacy shapes lift to `[.paragraph(text)]` at every decode boundary.** API `pageBlockBlockquote`, the Postbox `"t"` key (old cached pages), and the FlatBuffers `text` field (now optional) each lift into a single-paragraph blocks array. New writes emit only `blocks` (`"b"` / the FB vector). So pre-existing stored pages and older senders decode unchanged.
|
||||
- **Outbound stays on the legacy wire constructor when the shape allows.** `apiInputBlock()` emits `pageBlockBlockquote` for empty or single-`.paragraph` quotes (so older recipients understand the common chat case) and `pageBlockBlockquoteBlocks` only for genuinely nested quotes.
|
||||
- **Both renderers share one text core for the single-paragraph fast path.** `layoutQuoteText` (V2; the function formerly named `layoutBlockQuote`, `isPull:` distinguishes pull vs block) and the V1 fast-path branch keep the legacy italicized-body styling; nested children render with their own normal category styling.
|
||||
- **Nested children use a FIXED 10pt inter-child gap, not `spacingBetweenBlocks`.** The full page-flow spacing (~27pt around quotes) is too airy when nested, and 0 is too tight. `childSpacing = 10.0` lives in both layout files; the first child hugs the container's `verticalInset` (no leading gap). Combined with a nested quote's own 4pt top inset this gives ~14pt effective separation.
|
||||
- **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.
|
||||
Typed markdown with structure the regular message-entity set can't represent (headings, lists, tables, formulas, nested blockquotes) is sent as a **rich message** — a `RichTextMessageAttribute` carrying an `InstantPage`, drawn by `ChatMessageRichDataBubbleContentNode` via the **InstantPage V2** renderer (with AI-streaming progressive reveal, inline custom emoji, and entity cases). The detailed architecture and non-obvious invariants — streaming reveal, V2 table/text-box layout, custom-emoji & entity round-trips, task-list checkboxes, nested blockquotes, thinking blocks, and the markdown send / edit / copy / paste paths — live in [`docs/instantpage-richtext.md`](docs/instantpage-richtext.md).
|
||||
|
||||
## Postbox → TelegramEngine refactor (in progress)
|
||||
|
||||
A gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`.
|
||||
|
||||
**Historical record:** Wave-by-wave outcomes, the running tally of Postbox-free modules, and full verbose forms of the guidance subsections below live in [`docs/superpowers/postbox-refactor-log.md`](docs/superpowers/postbox-refactor-log.md). Read that file when you need wave-specific context, a full worked example of a pattern, or the history of a particular module's migration.
|
||||
**Historical record:** Wave-by-wave outcomes, the running tally of Postbox-free modules, the full wave-selection guidance, and the `TelegramEngine.Resources` facade inventory (also authoritatively defined in `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`) live in [`docs/superpowers/postbox-refactor-log.md`](docs/superpowers/postbox-refactor-log.md). Read that file when you need wave-specific context, a full worked example of a pattern, or the history of a particular module's migration.
|
||||
|
||||
Waves landed so far (as of 2026-05-04): 238 waves plus standalone cleanups. See the log file for per-wave detail; the list of still-open migration opportunities lives in the `project_postbox_refactor_next_wave.md` memory file.
|
||||
See the log for per-wave detail; the current wave count and the list of still-open migration opportunities live in the `project_postbox_refactor_next_wave.md` memory file.
|
||||
|
||||
### Rules that apply to every wave
|
||||
|
||||
|
|
@ -436,62 +147,6 @@ Do **not** add opt-in `EngineMediaResource` overloads alongside raw-`MediaResour
|
|||
|
||||
For consumer modules, prefer `EngineMediaResource` as the type in properties, locals, generic arguments and function parameters when the usage is a pure type reference. Do **not** try to use `EngineMediaResource` where a class must conform to `TelegramMediaResource` (Postbox protocol) or override `isEqual(to: MediaResource)` — those remain `import Postbox`.
|
||||
|
||||
### Wave-selection guidance
|
||||
|
||||
Distilled lessons from waves 1–26. Each bullet below has a full-form counterpart in `postbox-refactor-log.md` (same subsection heading) with backstory, example scripts, and per-wave numbers.
|
||||
|
||||
**Shape selection.** The "leaf module, drop Postbox in isolation" approach (wave 1) only works when the candidate's public API doesn't leak Postbox domain types. Most candidates DO leak (`postbox: Postbox` / `account: Account` in public inits, `Media`/`Message` as public parameter types). Grep each candidate for `:\s*Postbox\b`, `:\s*Account\b`, `:\s*MediaBox\b`, and `Media`/`Message` as public parameter types before committing to a wave; abandon candidates whose public API leaks.
|
||||
|
||||
**Inventory at execution time, not just planning time.** Planning-time grep often undercounts. Re-inventory at Task-1 time using the full token set `\b(postbox|mediaBox|transaction|PostboxView|combinedView|MediaResource|PostboxDecoder|PostboxEncoder|MemoryBuffer)\b|^import Postbox` over the module's sources. If the count exceeds the plan, abandon before editing code rather than substituting a different module.
|
||||
|
||||
**Two feasible wave shapes.** Shape 1 = "per-module Postbox drop" (fragile; wave 1 lost 6 of 10 candidates). Shape 2 = "per-engine-facade-API migrate in place, update all call sites in one commit" (validated from wave 2 onward). Prefer shape 2 when the target is an API surface that multiple consumer modules depend on.
|
||||
|
||||
**Enum-payload migrations need full case-site grep.** When changing the payload type of a public enum, grep `case \.` / `let \.` / `\.<caseName>\(` across the enum's defining module — not just call sites of the facade that returns it. Wave 4 undercounted by 6 sites (shortcut constructions and destructures inside the same file as the facade) because the inventory only grepped facade callers.
|
||||
|
||||
**Unused-import sweeps** (wave-shape applied in waves 6, 14). Speculatively drop `^import Postbox$` from every candidate file, build with `--continueOnError`, extract failing files and restore their imports, iterate. After a few iterations, do pattern-based preemptive restores for files naming Postbox-only symbols (`MediaBox`, `PostboxCoding`, `PostboxDecoder`, `PostboxEncoder`, `TempBoxFile`, `ValueBoxKey`, `Postbox\b`, `PeerId`, `MessageId`, `MediaId`, `MessageIndex`, `MessageAndThreadId`, `PeerNameIndex`). Scope never leaves the consumer-module candidate set — halt if errors surface in TelegramCore / Postbox / TelegramApi. Run a matching BUILD-dep sweep immediately after (near-zero execution risk). Full methodology, scripts, and iteration-count history in the log.
|
||||
|
||||
**Public-Postbox-type inventory** (wave-11-pattern planning). Grep candidate modules against the full Postbox public-types allowlist, not just the pattern's target tokens. Waves before 16 missed types like `EngineMessageHistoryThread.Info` (Postbox-defined despite its "Engine" prefix) and `PeerStoryStats`. "Engine"-prefixed types can still be Postbox-defined — grep for the defining module, don't trust naming. Build allowlist with `grep -rhE "^public\s+(class|struct|enum|protocol|typealias)\s+\w+" submodules/Postbox/Sources/ | awk '{print $3}' | sed 's/[(:<].*//' | sort -u`, then grep candidates against it. Full script in the log.
|
||||
|
||||
**Wave-shape G: facade addition + consumer sweep in one commit** (validated across waves 19–26). Recipe:
|
||||
1. Target a `MediaBox` method whose Postbox signature uses clean leaf types (`MediaResourceId`, `Data`, `String`, `Bool`) and whose return type is either non-Postbox or has an existing `Engine*` wrapper.
|
||||
2. Pre-flight inventory: classify each call site as Shape A (`context.account.postbox.mediaBox.X(...)`, migratable), Shape B (different overload via `AccountContext`, migratable), Shape C (raw `account: Account` local, skip — needs per-module rework), Shape D (`self.postbox` stored field, skip). Also check for `accountManager.mediaBox.X(...)` — a separate migration path.
|
||||
3. Design facade with `EngineMediaResource.Id` or `EngineMediaResource` parameters and engine-or-clean return types; preserve default argument values.
|
||||
4. WIP-interference check: `git status --short | grep -v "^??"` — if any Shape-A site is in a WIP file, either skip those sites or wait.
|
||||
5. Name-collision check: if the facade signature names a Swift stdlib type with availability restrictions (`RangeSet`, iOS 18+), verify the third-party module import is present in `TelegramEngineResources.swift`.
|
||||
6. Batch duplicate call expressions with `replace_all=true`.
|
||||
7. Cheapness: 5–50 sites per wave, single atomic commit, expected first-pass-clean build. If post-migration grep for the migrated expression returns empty (excluding Shape C/D) and build is green, commit.
|
||||
|
||||
Full per-shape recipe and wave-specific examples in the log.
|
||||
|
||||
### TelegramEngine.Resources facade inventory (as of wave 32)
|
||||
|
||||
All mediaBox methods with clean signatures (no Postbox-protocol leaks, no complex return-type migrations) have been migrated to `TelegramEngine.Resources`. Quick reference for consumers — all of these live in `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`:
|
||||
|
||||
| Facade | Wave | Wraps |
|
||||
|---|---|---|
|
||||
| `fetch(reference:userLocation:userContentType:)` | 3 | `fetchedMediaResource` |
|
||||
| `status(resource:)` | 3 | `MediaBox.resourceStatus` (resource-based) |
|
||||
| `status(id:, resourceSize:)` | 32 | `MediaBox.resourceStatus(_ id:, resourceSize:)` |
|
||||
| `data(resource:, pathExtension:, waitUntilFetchStatus:)` | 3 | `MediaBox.resourceData` (resource-based) |
|
||||
| `data(id:, attemptSynchronously:)` | 3 | `MediaBox.resourceData` (id-based, defaults to `.complete(waitUntilFetchStatus: false)`) |
|
||||
| `custom(id:, fetch:, cacheTimeout:, attemptSynchronously:)` | pre-wave-21 | `MediaBox.customResourceData` |
|
||||
| `httpData(url:, preserveExactUrl:)` | pre-wave-21 | `fetchHttpResource` |
|
||||
| `shortLivedResourceCachePathPrefix(id:)` | 19 | `MediaBox.shortLivedResourceCachePathPrefix` |
|
||||
| `completedResourcePath(id:, pathExtension:)` | 21 | `MediaBox.completedResourcePath(id:, pathExtension:)` |
|
||||
| `storeResourceData(id:, data:, synchronous:)` | 22 | `MediaBox.storeResourceData(_ id:, data:, synchronous:)` |
|
||||
| `cancelInteractiveResourceFetch(id:)` | 23 | `MediaBox.cancelInteractiveResourceFetch(resourceId:)` |
|
||||
| `moveResourceData(id:, toTempPath:)` | 24 | `MediaBox.moveResourceData(_ id:, toTempPath:)` |
|
||||
| `moveResourceData(from:, to:, synchronous:)` | 24 | `MediaBox.moveResourceData(from:, to:, synchronous:)` |
|
||||
| `copyResourceData(id:, fromTempPath:)` | 25 | `MediaBox.copyResourceData(_ id:, fromTempPath:)` |
|
||||
| `copyResourceData(from:, to:, synchronous:)` | 25 | `MediaBox.copyResourceData(from:, to:, synchronous:)` |
|
||||
| `resourceRangesStatus(resource:)` | 26 | `MediaBox.resourceRangesStatus(_ resource:)` |
|
||||
| `removeCachedResources(ids:, force:, notify:)` | 26 | `MediaBox.removeCachedResources(_ ids:, force:, notify:)` |
|
||||
| `clearCachedMediaResources(mediaResourceIds:)` | 223 | `_internal_clearCachedMediaResources` |
|
||||
|
||||
**Facade-shape convention:** all of these take `EngineMediaResource.Id` or `EngineMediaResource` (never raw `MediaResourceId`/`MediaResource`). Return types either don't leak Postbox (`Void`, `String`, `String?`, `Signal<RangeSet<Int64>, NoError>`, `Signal<Float, NoError>`) or wrap via TelegramCore type (`Signal<EngineMediaResource.ResourceData, NoError>`).
|
||||
|
||||
**Swift-stdlib-vs-third-party-module name collisions** (learned in wave 26): `RangeSet<Int64>` collides with Swift stdlib's `RangeSet` (iOS 18+ only). Fix: `import RangeSet` at the file top of any TelegramCore file that names `RangeSet` in a signature. `TelegramCore/BUILD` already depends on `//submodules/Utils/RangeSet:RangeSet`. Future facade additions in TelegramEngineResources.swift should re-check this if new signature types are introduced.
|
||||
|
||||
## tgcalls Testbench
|
||||
|
||||
This repo includes a tgcalls testbench (CLI tool, Go/Pion SFU, Docker build) layered on top of the iOS source. All testbench code, build instructions, and architecture docs live inside the tgcalls submodule:
|
||||
|
|
|
|||
375
docs/instantpage-richtext.md
Normal file
375
docs/instantpage-richtext.md
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
# InstantPage V2 & rich-text message rendering
|
||||
|
||||
This file documents the **rich-text message** pipeline and the **InstantPage V2** renderer that backs it.
|
||||
|
||||
A rich message is a `RichTextMessageAttribute` carrying an `InstantPage` (sent with `text: ""`), produced when typed markdown contains structure the regular message-entity set can't represent (headings, lists, tables, formulas, nested blockquotes) and drawn by `ChatMessageRichDataBubbleContentNode` via the InstantPage V2 layout/renderer — including AI-streaming progressive reveal, inline custom emoji, and entity (mention / hashtag / …) cases. It also covers the send / edit / copy / paste round-trips between markdown and `InstantPage`.
|
||||
|
||||
These are detailed, non-obvious invariants — read the relevant section before touching the corresponding code. (Moved out of `CLAUDE.md` to keep that file focused; `CLAUDE.md` retains a brief pointer back to here.)
|
||||
|
||||
## AI streaming animation (rich-text bubbles)
|
||||
|
||||
`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
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/TelegramUI/Components/StreamingTextReveal/Sources/TextRevealController.swift` | Pacing controller, shared by both bubbles. EWMA inter-arrival → velocity-smoothed cursor. |
|
||||
| `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`), 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 `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.
|
||||
- **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
|
||||
|
||||
The `ChatMessageDateAndStatusNode` mirrors TextBubble's placement, adapted to the heterogeneous V2 layout. The node is a child of `self` (the content node), **not** of the clipping `containerNode`, so it is never clipped — the bubble height must be grown to contain it.
|
||||
|
||||
- **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 (a tall inline attachment, e.g. a formula, 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` 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**.
|
||||
|
||||
### 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.
|
||||
|
||||
## InstantPage V2 block media — flush (edge-to-edge), un-rounded
|
||||
|
||||
Every V2 block-media kind lays out **flush** with the bubble interior (0 inset, full bounding width) and **un-rounded** (cornerRadius 0). The bubble's existing rounded clipping container rounds any media that meets the bubble's top/bottom edge. V1 (`InstantPageLayout.swift`) is unchanged. (Audio is **also** full-width / x = 0 as of the V2 audio port, but it does not use this helper — it has its own `layoutAudio` arm; the wrapped `InstantPageAudioNode` supplies its own 17pt internal content inset. See the "InstantPage V2 audio/music" section below.)
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `instantPageV2MediaFrame(naturalSize:flush:cornerRadius:boundingWidth:horizontalInset:)` — the shared frame helper; `instantPageV2MediaEdgeBleed` constant; the `flush: Bool` parameter on `layoutTypedMediaWithCaption` (image/video/webEmbed-cover/map) and `layoutMediaWithCaption` (webEmbed-placeholder/postEmbed/channelBanner/relatedArticles). (Collage/slideshow and **audio** no longer route through these — see their dedicated sections.) |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift`, `…/InstantPageRenderer.swift` (`InstantPageV2MediaPlaceholderView`) | Renderer — **no change needed**: every media view + the placeholder view already does `clipsToBounds = item.cornerRadius > 0.0`, so cornerRadius 0 means the view doesn't self-clip; the bubble's `containerNode` clips. |
|
||||
| `…/Chat/ChatMessageRichDataBubbleContentNode/…` | The clipping container: `containerNode` (`clipsToBounds = true`, `cornerRadius = layoutConstants.image.defaultCornerRadius` ≈ 15–16pt) is what rounds flush media at the bubble edge. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **`flush` is a parameter, not inferred from cornerRadius.** **Every** remaining media call site now passes `flush: true`. Audio — the former lone `flush: false` caller — was moved to its own `layoutAudio` arm in the V2 audio port, so `instantPageV2MediaFrame`'s `flush == false` branch is now **dead code** (a candidate for a follow-up cleanup: drop the `flush` parameter and the inset branch entirely). On the flush path the helper forces the returned corner radius to `0` regardless of the caller's `cornerRadius` argument (the legacy `8.0`/`0.0` args at the call sites are now inert — kept as-is, documented in the helper).
|
||||
- **Small images are NOT upscaled.** The `scale = min(availableWidth / naturalSize.width, 1.0)` cap is kept (now against `availableWidth = boundingWidth`). A small image stays at natural size, **flush-left at x = 0** (not stretched to full width). Large images (the common server/AI case) fill the width.
|
||||
- **Full-width media bleeds `instantPageV2MediaEdgeBleed` (4pt) past the trailing edge.** The pageView sits at `x: -1` inside `containerNode` (a border-hiding hairline), so a frame at `x: 0, width: boundingWidth` falls ~1px short of the container's right rounded-clip edge → a 1px corner notch. A small over-bleed on **full-width** items only (`fillsWidth = scaledSize.width >= availableWidth - 1.0`) closes it; a genuinely small image gets no bleed. **The bleed never widens the bubble** because `layoutInstantPageV2` clamps `contentSize.width = min(maxX, boundingWidth)` (gated by `context.fitToWidth`, which both callers — the rich bubble and the send preview — pass `true`).
|
||||
- **Captions stay inset.** `layoutCaptionAndCredit` is still called with the page `horizontalInset` and offset by the **un-bled** `scaledSize.height`; the caption/credit text is inset under a full-bleed image. The `isCover && captionHeight > 0` cover-padding block is unchanged.
|
||||
- **Audio is no longer routed through this helper.** As of the V2 audio port it has a dedicated `layoutAudio` arm emitting a typed `.mediaAudio` item at a full-width (x = 0), height-48 frame (matching V1 `InstantPageLayout.swift`); the wrapped `InstantPageAudioNode` self-insets its content by 17pt, and audio does **not** participate in `instantPageV2MediaEdgeBleed` (its node background is transparent). See the dedicated "InstantPage V2 audio/music" section below.
|
||||
- **`.map` blocks get a 600×300 (2:1) fallback when the sender omits dimensions.** AI/server-sent `.map` blocks can arrive with `dimensions == 0×0` (the wire `w`/`h` are *required* `Int32`, but the sender may put 0; our `pageBlockMap` parse and both serializers — Postbox `sw`/`sh`, FlatBuffers `required dimensions` — preserve whatever arrives, so the zero originates upstream). A zero `naturalSize.height` hits `instantPageV2MediaFrame`'s `else` branch and returns a **height-0** frame: the map collapses to no space, the caption slides up into it, and the V1 node's pin (positioned at `size.height*0.5 − 10 − pinSize/2`) floats over the caption. **The `.map` arm in `InstantPageV2Layout.swift` substitutes `PixelDimensions(600, 300)` whenever `width <= 0 || height <= 0`, and feeds that `effectiveDimensions` to BOTH the layout `naturalSize` AND the `InstantPageMapAttribute`** — the latter is essential because a `MapSnapshotMediaResource(width:0,height:0)` makes `MKMapSnapshotter` render nothing, so fixing only the frame would yield a correctly-sized *blank* box. Real web-article maps (the V1 renderer) always carry real dimensions, so V1 never trips this; the fallback is deliberately scoped to the V2 `.map` arm rather than V1 or the wire/parse layer.
|
||||
|
||||
## InstantPage V2 audio/music
|
||||
|
||||
`InstantPageBlock.audio` renders in V2 as a control **styled exactly like the standard music message bubble** (`ChatMessageInteractiveFileNode`'s music layout) — a dedicated `InstantPageV2AudioContentNode`, NOT the V1 `InstantPageAudioNode` (which V2 used in the first iteration and which still backs V1's full-page Instant View). It replaces the earlier inert grey `.mediaPlaceholder(kind: .audio)`. Playback stays on `InstantPageMediaPlaylist`, with two deliberate behavior changes for the rich-message context: the shared playlist identity is **message-scoped** so concurrent rich-message audio bubbles don't collide, and rich-message audio files are fetched via a **message reference** (not the synthesized webpage) so a stale file reference can revalidate.
|
||||
|
||||
Specs: [`2026-06-02-instantpage-v2-audio-design.md`](docs/superpowers/specs/2026-06-02-instantpage-v2-audio-design.md) (initial port) + [`2026-06-02-instantpage-v2-audio-file-style-design.md`](docs/superpowers/specs/2026-06-02-instantpage-v2-audio-file-style-design.md) (file-bubble styling). Plans: [`2026-06-02-instantpage-v2-audio.md`](docs/superpowers/plans/2026-06-02-instantpage-v2-audio.md) + [`2026-06-02-instantpage-v2-audio-file-style.md`](docs/superpowers/plans/2026-06-02-instantpage-v2-audio-file-style.md).
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/InstantPageUI/Sources/InstantPageMediaPlaylist.swift` | `InstantPageMediaPlaylistId` is a **public enum** — `.instantPage(webpageId:)` (V1 full-page IV) / `.richMessage(messageId:)` (V2 rich bubble). `InstantPageMediaPlaylist.init` takes an injected `playlistId:` (no longer derived from the webpage) and a `messageReference: MessageReference?` threaded into each `InstantPageMediaPlaylistItem`. The item's `fileReference(_:)` helper builds a `.message(message:media:)` file reference when a (resolvable-id) message reference is present, else the legacy `.webPage(...)`. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2AudioContentNode.swift` | **The V2 control** — replicates `ChatMessageInteractiveFileNode`'s music layout: a Ø44 `SemanticStatusNode` (album art via `playerAlbumArt` + play/pause) + a small bottom-right `streamingStatusNode` download/progress overlay + title/performer `TextNode`s + a line `MediaPlayerScrubbingNode`. Big control play/pause from **our** `filteredPlaylistState`; small overlay download/progress from `messageMediaFileStatus`; tap via a `UITapGestureRecognizer` (`controlTapped` routes fetch / `play` / `togglePlayPause`); fetch via `messageMediaFileInteractiveFetched(fetchManager:…)`. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageAudioNode.swift` | **V1 only** (full-page Instant View) — unchanged except `init` takes an injected `playlistId:`. No longer used by V2. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `InstantPageV2MediaAudioItem` (frame/media/webPage — no cornerRadius/attributes); the `.mediaAudio` `InstantPageV2LaidOutItem` case + its `frame`/`offsetBy`/`collectMedias` arms; the `.audio` block's `layoutAudio` arm (full-width x = 0, height 44 — the file node's music `normHeight`; the `InstantPageMedia` carries `caption: nil`/`credit: nil`, the visible caption is a separate item via `layoutCaptionAndCredit`). |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift` | `InstantPageV2MediaAudioView` (hosts `InstantPageV2AudioContentNode` via the shared `WrapperRef` weak-box pattern; wires its `play`/`togglePlayPause`/`seek`/`fetch` closures + the `filteredPlaylistState` playback signal) + `handleOpenAudioTap` (builds the playlist + `setPlaylist`, mirroring V1's `InstantPageControllerNode.openMedia`). |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` | `InstantPageV2RenderContext.message: MessageReference?` (carries both the playlist-key id via `.id` AND the file-fetch reference); the `.mediaAudio` arms in `stableId`/`reuse`/`makeItemView`. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift` | `.mediaAudio` is a non-text reveal entry charging `frame.width` (like other media). |
|
||||
| `submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift` | The `.message` media-reference revalidation arm also searches `RichTextMessageAttribute.instantPage.media` (not just `message.media`), so a stale instant-page file reference inside a rich message can recover. |
|
||||
| rich bubble + send preview | `ChatMessageRichDataBubbleContentNode` passes `message: MessageReference(item.message)`; `ChatSendMessageRichTextPreview` passes `message: nil`. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **The playlist key is message-scoped, NOT webpage-scoped, for rich bubbles.** Every rich message synthesizes its `TelegramMediaWebpage` with the SAME constant id `(namespace: 0, id: 0)` (`ChatMessageRichDataBubbleContentNode`), and `mediaIndex` restarts at 0 per page — so keying playback by `(webpageId, mediaIndex)` (V1's scheme) would make two audio bubbles on screen share/fight playback state (scrubber + play/pause icon). The discriminated `InstantPageMediaPlaylistId.richMessage(messageId)` isolates them. The audio view resolves `renderContext.message?.id` → `.richMessage(messageId)`, else `.instantPage(webpageId:)`; the send preview (no message) takes the webpage fallback — harmless since only one preview is ever on screen. The V1 full-page IV path is byte-identical (always `.instantPage(...)`).
|
||||
- **`InstantPageMediaPlaylistId` had to become `public`.** It is exposed through `InstantPageMediaPlaylist`'s `public init`, which BrowserUI constructs cross-module; an internal type in a public initializer is a hard Swift compile error (independent of `-warnings-as-errors`). This surfaced only at full-build time — the per-module reasoning didn't catch it.
|
||||
- **The big control's play/pause comes from OUR playlist, the small overlay's download/progress from the resource status — two separate signals.** The file node (`ChatMessageInteractiveFileNode`) for music keys its play/pause off the **peer-messages** playback model (`messageFileMediaPlaybackStatus` → `peerMessagesMediaPlaylistAndItemId`), which our attribute-embedded audio is NOT part of — so `InstantPageV2AudioContentNode` drives the big `statusNode` `.play`↔`.pause` from **our** `filteredPlaylistState` (keyed by the message-scoped `playlistId` + `InstantPageMediaPlaylistItemId(index:)`) and the small `streamingStatusNode` from `messageMediaFileStatus`. This split (rather than reusing the file node) is why the redesign is a replicated layout, not a hosted `ChatMessageInteractiveFileNode`.
|
||||
- **Fetch MUST go through the fetch manager, not `freeMediaFileInteractiveFetched`.** `messageMediaFileStatus`'s progress (`.Fetching`) is derived from the fetch manager's `hasEntry` flag; `freeMediaFileInteractiveFetched` bypasses the manager (`hasEntry` stays false), so the overlay would stick on the static download icon and never show the animated ring. The control fetches via `messageMediaFileInteractiveFetched(fetchManager:messageId:messageReference:file:…)`.
|
||||
- **Tap is a `UITapGestureRecognizer`, never an ASControl** (same invariant as the V1 `InstantPageAudioNode` play button): ASControl `.touchUpInside` is cancelled by the chat `ListView`'s gesture system. The plain `tapView` covers the whole control → `controlTapped` (fetch-when-remote / `togglePlayPause`-when-playing / `play`-else).
|
||||
- **`InstantPageV2AudioContentNode.updatePresentationData` must refresh EVERYTHING theme/incoming-dependent.** `TextNode` (unlike `ASTextNode`) has no stored `attributedText` — the strings live in `titleAttributedString`/`descriptionAttributedString` and are fed to `TextNode.asyncLayout`. On an in-place theme/direction change `updatePresentationData` rebuilds those strings AND `statusNode.backgroundNodeColor` + `foregroundNodeColor` + `overlayForegroundNodeColor` + `scrubbingNode.updateColors(…)`; missing any leaves a stale-colored control. Font size is `presentationData.chatFontSize.baseDisplaySize` (plain `PresentationData` has no `.fontSize`).
|
||||
- **Audio is NOT a gallery item.** `InstantPageV2MediaAudioView` does not register in the root media registry (no `didMoveToWindow`/`registerInRootRegistry`) and returns `nil` from `instantPageTransitionNode` / no-ops `instantPageUpdateHiddenMedia` — explicit per-class witnesses, not the protocol-extension default. Its media IS enrolled in `collectMedias`/`allMedias()` so `handleOpenAudioTap` can gather the page's sibling voice/music files for the playlist (matching V1's `mediasFromItems`). The `WrapperRef` weak box breaks the wrapper → node → closure → wrapper retain cycle (the `play` closure captures only the box + value locals, never `self`).
|
||||
- **Full-width item frame, file-node internal layout.** The `.audio` arm lays the item at `x = 0, width = boundingWidth, height = 44` (the file node's music `normHeight`), NOT inset by `horizontalInset`. The control's internal geometry is copied from the file node's non-thumbnail music branch (Ø44 control at x = 3, `controlAreaWidth = 55`, title at x = 55). Music-only: any voice file renders music-style (no waveform/transcription). No edge-bleed.
|
||||
- **Audio files fetch via a message reference (the former recipient-fetch risk is resolved).** `InstantPageMediaPlaylistItem.fileReference(_:)` builds `.message(message: messageReference, media: file)` when the playlist carries a **resolvable-id** `MessageReference` (rich bubbles), else the legacy `.webPage(...)` (V1 full-page IV, whose webpage is real). The fetch-reference fallback uses the same `message?.id != nil` test as the playlist-key fallback, so a `.none`-content reference degrades to the webpage path consistently. Because the rich-message file lives in `RichTextMessageAttribute.instantPage.media` (not `message.media`), `FetchedMediaResource.swift`'s `.message` revalidation arm was taught to search the attribute's instant page too — so a **stale** file reference can re-fetch the message and recover (a synthetic-`(0,0)`-webpage reference never could, because that webpage doesn't exist server-side). This also fixes a latent pre-existing bug: instant-page **image** references in rich messages couldn't revalidate either.
|
||||
- **Fixed a dormant inverted `InstantPagePlaylistLocation.isEqual`** (it returned `false` for equal locations and `true` for unequal — backwards). `areSharedMediaPlaylistsEqual` ANDs the playlist `id` and `location`; it gates only seek-forwarding inside `setPlaylist`, a path the instant-page audio scrubber doesn't take (it uses `playlistControl(.seek)`), so the bug was inert. The corrected equality is safe even though all rich-message locations share the synthetic `(0,0)` webpageId: the `.richMessage(messageId)` **id** (ANDed in) disambiguates different rich-message playlists.
|
||||
|
||||
## InstantPage V2 collage & slideshow blocks
|
||||
|
||||
`InstantPageBlock.collage` and `.slideshow` (grouped photos/videos with a caption — only ever produced by **real web Instant View articles**; nothing on the markdown/AI path emits them) render in V2 by porting V1. Collage flattens into the existing media-item machinery; slideshow is a dedicated interactive carousel.
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `layoutCollage(...)` — mosaic via `chatMessageBubbleMosaicLayout` (the `MosaicLayout` module, same engine grouped messages use), emitting one existing `.mediaImage`/`.mediaVideo` item per cell. `layoutSlideshow(...)` + the `InstantPageV2SlideshowItem` laid-out item (+ its `frame`/`offsetBy`/`collectMedias` arms). |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2SlideshowView.swift` | The carousel view: a paged `UIScrollView` of `InstantPageImageNode` pages + a `PageControlNode`, with all pages created **eagerly**. |
|
||||
| `…/InstantPageRenderer.swift` | `InstantPageItemView.instantPageTransitionNode(for:)` / `instantPageUpdateHiddenMedia(_:)` (gallery hooks, nil/no-op defaults); `transitionArgsFor`/`applyHiddenMedia` dispatch through them. The `.slideshow` arms in `InstantPageV2ItemKind`/`stableId`/`reuse`/`makeItemView`. |
|
||||
| `…/InstantPageV2RevealCost.swift` | `.slideshow` is a non-text reveal entry (collage cells already are, being top-level media items). |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **Collage is a flatten, not a container.** `layoutCollage` computes the mosaic, then emits each cell as an ordinary top-level `.mediaImage`/`.mediaVideo` item (cornerRadius 0) into the parent layout — exactly as V1 does (`flattenedItemsWithOrigin`). Consequence: gallery enumeration (`allMedias`), the media registry, hidden-media, the reveal-cost map, and view reuse all handle collage cells **for free**, with no collage-specific code in any of those subsystems. There is **no** `.collage` laid-out item or view.
|
||||
- **Right-edge collage cells bleed 4pt** (`instantPageV2MediaEdgeBleed`, applied only to `MosaicItemPosition.right` cells) for the same bubble-rounded-clip reason as full-width single media; interior gaps are the mosaic's 1pt spacing; outer corners are rounded by the bubble's `containerNode`.
|
||||
- **Slideshow IS a container** (it's swipeable), so it gets its own laid-out item + view, unlike collage. Adding the `.slideshow` case to `InstantPageV2LaidOutItem` forces a `.slideshow` arm in every no-`default` switch over it: `frame`, `offsetBy`, `stableId`, `reuse`, `makeItemView`, and the reveal-cost `computeEntries` (plus `collectMedias`, which has a `default` but needs the arm to enumerate slideshow medias for the gallery).
|
||||
- **Slideshow pages are created eagerly, deviating from V1's lazy central±1 paging.** In a chat bubble a slideshow is a handful of images, so eager creation avoids V1's index bookkeeping and makes the gallery transition source available for **every** page (even off-screen). Height = the tallest image `fitted(boundingWidth × 1200)`; only `.image` inner blocks render (matches V1 — videos become empty pages).
|
||||
- **The slideshow registers under EVERY contained media index, and re-registers on an in-window rebuild.** Its stableId is positional (`.positional(.slideshow, position)`, not `.media(index)` like the static media views), so it can be reused for a *different* slideshow at the same block position; `rebuildPages()` re-runs `registerMedias()` (guarded by `window != nil`) so the new indices land in the registry. The gallery hooks iterate the live page nodes and match by `InstantPageMedia` identity, so registering one view under N indices is idempotent.
|
||||
- **The 4 static media views answer the gallery hooks with explicit per-class witnesses, NOT a shared protocol-extension override** — an extension-only implementation is statically dispatched and would silently bind to the nil default when invoked through the `InstantPageItemView`-typed registry wrapper.
|
||||
|
||||
## InstantPage V2 text item height (true font line box)
|
||||
|
||||
`layoutTextItem` (`InstantPageV2Layout.swift`) sizes a `.text` item to the **true font line height**, not the cap box. A single-line item measures exactly `fontAscent + fontDescentBelowBaseline` (`A + D`); the old behavior was the cap box `fontLineHeight = floor(fontAscent + fontDescent)` (`A − D`).
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **Two edits in `layoutTextItem`:** the line stack starts at `lineBoxTopInset = max(0, fontAscent − fontLineHeight)` (was `0`), and the returned height is `lines.last.frame.maxY + extraDescent + fontDescentBelowBaseline` (the `+ fontDescentBelowBaseline` contains the last line's descender). Net: every text item grows ~`(A − L) + D` (~8pt @17pt) and its glyphs draw ~`lineBoxTopInset` (~4pt) lower within their box; the page grows.
|
||||
- **Per-line frames stay the cap box** (`height = lineAscent = fontLineHeight`). Only the stack's starting origin moves and the total is padded — so the baseline is still drawn at each line frame's `maxY`, inter-line advance (`lineAscent + fontLineSpacing + extraDescent`) is unchanged, and decorations / inline attachments / `characterRect` / the reveal mask (all line-frame-relative) translate consistently.
|
||||
- **`lineBoxTopInset` is exact, NOT pixel-snapped** — it is an intra-item line offset; crispness rides on the item's own pixel-snapped frame origin (intra-item line positions may already be fractional, e.g. after a non-integral `extraDescent`).
|
||||
- **Formulas / tall inline content still inflate** via `lineAscent`/`extraDescent`; the `"\u{200b}"`+anchors `height = 0` case is preserved.
|
||||
- **Inline custom emoji are sized to ≈ the line box** so they fit the taller box rather than overflowing it (see "Inline custom emoji").
|
||||
|
||||
## 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. (The **send / edit / copy / paste** round-trip that produces `.textCustomEmoji` from typed markdown is a separate section below: "Custom emoji in markdown messages".)
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift` | Enum case `textCustomEmoji(fileId: Int64, alt: String)` + Postbox coding (discriminator 17, keys `ce.f`/`ce.a`), `==`, `plainText` (returns `alt`), and FlatBuffers codec. |
|
||||
| `submodules/TelegramCore/FlatSerialization/Models/RichText.fbs` | FlatBuffers schema — `RichText_CustomEmoji` union member + table. **Source of truth**; the Bazel `flatc` genrule regenerates `*_generated.swift` at build time (the checked-in `Sources/*_generated.swift` is stale). |
|
||||
| `submodules/TelegramCore/Sources/ApiUtils/RichText.swift` | `Api.RichText.textCustomEmoji` ⇄ Swift, lossless both ways. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` (`attributedStringForRichText`) | Emits a single placeholder char carrying `ChatTextInputAttributes.customEmoji` (a `ChatTextInputTextCustomEmojiAttribute`) + a `CTRunDelegate` sized to the font line height (`font.ascender − font.descender + 4·pointSize/17` ≈ 24pt @17pt). |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` (line-breaker) | Collects per-line `InstantPageTextLine.emojiItems`; overwrites each placeholder char's `characterRect` with a full cell (`width = itemSize`) so it feeds the reveal cost map. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` (`InstantPageV2View`) | Owns the `InlineStickerItemLayer`s: `updateInlineEmoji` (create/reuse/remove/position), `updateEmojiReveal` (reveal-driven pop-in), `updateEmojiVisibility` + `propagateVisibilityRect`. Layers attach to each text view's `emojiContainerView`. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **flatc casing/`required` gotchas.** Edit `RichText.fbs`, not the generated Swift. Scalars (`long`) cannot be `(required)` — only strings/tables can. A union member `RichText_CustomEmoji` generates the Swift enum case `.richtextCustomemoji` (everything after the suffix's first letter is lowercased); the table type stays `TelegramCore_RichText_CustomEmoji` and field accessors keep `.fbs` casing (`value.fileId`). See the `flatbuffers-codegen` memory.
|
||||
- **`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`), 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.
|
||||
- **Inline emoji/images are CENTERED on the font line box, NOT baseline-aligned, and do NOT inflate the line.** The line-breaker keeps `lineAscent = fontLineHeight` (only formulas grow it) and places each attachment at `baselineY − fontLineHeight/2 − size/2`, so it bleeds symmetrically about the line box instead of doubling the line height and shoving the text baseline down (the prior `lineAscent = emoji.size` behavior was a regression from V1 `layoutTextItemWithString`, which centers via `(fontLineHeight − imageHeight)/2`). Custom emoji are sized to ≈ the line box (`size = font.ascender − font.descender + 4·pointSize/17`) so they fit the true-font-height item box (see "InstantPage V2 text item height") with minimal bleed. Mirrors the chat `InteractiveTextComponent`. The cell's `characterRect` is centered the same way (`y = fontLineHeight/2 − size/2`) so the reveal mask (`renderer: y = minY + lineAscent − rect.maxY`) tracks it; a tall attachment grows `extraDescent` so the next line isn't overlapped. Three things must stay in lockstep: the display frame, the `characterRect`, and `extraDescent`.
|
||||
- **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 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)
|
||||
|
||||
`RichText.textMention`, `.textMentionName(text:peerId:)`, `.textHashtag`, `.textCashtag`, `.textBotCommand`, `.textBankCard`, `.textAutoUrl`, `.textAutoEmail`, `.textAutoPhone` render the message-entity flavors of rich text inside rich-data bubbles with full tap interaction mirroring `ChatMessageTextBubbleContentNode`. Covers API parsing, Postbox + FlatBuffers serialization, display, and tap routing. (`textDate`/`textSpoiler` remain unimplemented — `.plain("")`.)
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift` | The 9 enum cases (each wraps `text: RichText`; `textMentionName` adds raw `peerId: Int64`) + Postbox coding (discriminators 18–26, wrapped text under key `"t"`, mention-name peerId under `"mn.p"`), `==`, `plainText`, FlatBuffers codec. |
|
||||
| `submodules/TelegramCore/FlatSerialization/Models/RichText.fbs` | Union members + tables (`RichText_MentionName` adds `peerId:long`). Source of truth — same flatc gotchas as the custom-emoji section above. |
|
||||
| `submodules/TelegramCore/Sources/ApiUtils/RichText.swift` | `Api.RichText` ⇄ Swift, lossless. `textMentionName` carries `userId` ⇄ `peerId`. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` (`attributedStringForRichText`) | Display: auto url/email/phone reuse the `InstantPageUrlItem` (`url:`) path; the six entity cases push `.link(false)`, recurse, then attach the matching `TelegramTextAttributes.*` key over the produced range. |
|
||||
| `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/...` | Tap routing: `entityForTapLocation` reads the attribute dict at the tapped point; `entityTapContent` maps keys → `ChatMessageBubbleContentTapAction.Content`. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **Display attaches the same `TelegramTextAttributes.*` keys the chat text bubble uses; the bubble reads them back.** Contract: `textMention`→`PeerTextMention` (String); `textMentionName`→`PeerMention` (`TelegramPeerMention`, peerId built as `EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, …)` — `InstantPageTextItem` imports TelegramCore but NOT Postbox, so bare `PeerId` is out of scope); `textHashtag` AND `textCashtag`→`Hashtag` (`TelegramHashtag`; no dedicated cashtag key/tap-action — the leading `$` distinguishes them); `textBotCommand`→`BotCommand`; `textBankCard`→`BankCard`. Auto url/email/phone go through the URL path (`mailto:`/`tel:`/raw), NOT an entity key.
|
||||
- **`linkSelectionRects` and the bubble tap path check all six interactive keys** (URL + the five entity keys), not just URL, so press-highlight and the link-loading shimmer cover entities too.
|
||||
- **Rich-data text selection must reach a line's trailing edge.** This is general to rich-data selection, not just entities: `InstantPageTextItem.attributesAtPoint(_:orNearest:)`'s `orNearest: true` (selection-drag) path returns `line.range.upperBound` (via `CTLineGetStringRange`) when the point is at/past `lineFrame.maxX`. `TextSelectionNode` uses that index as the **exclusive** upper bound, so clamping to the last character's index — as the `orNearest: false` hit-testing path correctly does — would leave the last character/item of every line unselectable. Mirrors `Display.TextNode`. Do not collapse the two `orNearest` paths back together.
|
||||
|
||||
## Markdown send: entity vs. rich detection
|
||||
|
||||
On message send, the app auto-decides: if the typed markdown maps onto the regular message-entity set (bold/italic/code/strikethrough/spoiler/links/blockquote/fenced-code) it sends a **normal message** via the existing entity path; if it contains structure the entity set can't represent it sends a **rich message** (`RichTextMessageAttribute` carrying an `InstantPage`, rendered by `ChatMessageRichDataBubbleContentNode`). Always-on (no flag). **Effective rich triggers are headings, lists, and tables only.**
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/BrowserUI/Sources/BrowserMarkdown.swift` | The classifier `richMarkdownAttributeIfNeeded(context:text:)` (pre-filter `markdownMightNeedRichLayout` → parse via existing `inputRichTextAttributeFromText` → block inspection `instantPageNeedsRichLayout`/`blockIsEntityExpressible`/`richTextIsEntityExpressible`), plus the markdown→InstantPage conversion (`markdownWebpage`, `markdownBlocks(from:)`, `markdownBlocksWithGeneratedAnchors`). |
|
||||
| `submodules/TelegramUI/Sources/ChatControllerNode.swift` (`sendCurrentMessage`, ~line 4860) | The gate: `if !isSpecialChatContents, let attribute = richMarkdownAttributeIfNeeded(context:, text: effectiveInputText.string)` routes to the rich branch; the unchanged `else` is the entity path. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **Boundary rule:** send rich iff the parse yields an `InstantPageBlock` with no entity equivalent. Entity-expressible whitelist (→ normal): `.paragraph`, `.preformatted`, `.blockQuote` (empty caption), `.anchor`, `.unsupported`, **and `.divider`** (`---` is too common in casual text to trigger rich). **`.formula` (block and inline) DOES trigger rich**, gated by strict math detection (see "Formulas trigger rich messages" below) so casual `$` usage (`$5-$10`, `$FOO=$BAR`) stays plain. So effective triggers = headings, lists, tables, formulas.
|
||||
- **Approach A (parse-then-inspect):** the classifier reuses the real parser, so "what triggers rich" can't drift from "what the rich renderer shows." `markdownMightNeedRichLayout` is a cheap necessary-condition over-approximation — it may over-trigger a parse but must **never** false-negative. It detects `#`, list markers, dash-lines (`-{1,}`, which also catches setext-H2 underlines → heading blocks), `\n=` (setext H1), `|`, `` — because re-send re-parses the text through the *rich* path (`richMarkdownAttributeIfNeeded` → `NSAttributedString(markdown:)`, Apple CommonMark), not `convertMarkdownToAttributes` (whose dialect is `__italic__`/`||spoiler||`). The two parsers disagree on `__`/`*`; the rich round-trip is the contract.
|
||||
- **Re-classify every edit (edit ≡ send).** `editMessage` runs the same `richMarkdownAttributeIfNeeded` on the edit field's attributed text (so reattached custom emoji round-trip — see the custom-emoji section). Rich → `pendingUpdateMessageManager.add(text: "", entities: nil, richText: attr, …)`; else the unchanged plain path. So normal→rich (add a table) and rich→plain (drop all triggers) both work. Bypassed for `.customChatContents`.
|
||||
- **Change-detection compares the rich attribute.** The save guard adds `currentRichText != richTextAttribute` (rich branch — skips no-op rich edits) and `currentRichText != nil` (plain branch — so rich→plain still saves even when `text.string` looks unchanged). `RichTextMessageAttribute` is `Equatable` on `instantPage`.
|
||||
- **The `text.length == 0` early-return guard is safe for rich.** `convertMarkdownToAttributes` only rewrites inline tokens, never strips `#`/`-`/`|`, so a rich message's markdown source stays non-empty and passes; the rich branch then sends `text: ""`.
|
||||
- **Known limitation:** a rich→plain edit that leaves only inline-formatted text loses `*italic*` (the entity path recognizes only `__…__`). Rare edge; the rich round-trip contract holds.
|
||||
- **`previewText()` lives in TelegramStringFormatting, not TextFormat/TelegramCore.** It will gain a `strings: PresentationStrings` param (to localize the `"Photo"`/`"Video"`/`"Table"` placeholders), so it must sit in a UI-string module — `messageContentKind`/`descriptionStringForMessage` (same module) already take `strings:`. Teaching `messageContentKind` about rich cascades the preview to the edit accessory panel, reply/pinned panels, and forward preview in one place (those surfaces need no individual change).
|
||||
|
||||
## Copying rich messages as markdown (whole message + partial selection)
|
||||
|
||||
Rich messages (`RichTextMessageAttribute`, `text == ""`) are copyable as markdown two ways: the context-menu **Copy** action copies the whole message; a **text selection** inside the rich-data bubble copies just the selected range. Both reconstruct markdown that mirrors the edit round-trip (`markdownStringFromInstantPage`). Always-on.
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift` | Whole-message Copy. Computes `richMessageMarkdown` from the message's `RichTextMessageAttribute.instantPage` (after `let message = messages[0]`), opens the Copy gate with `richMessageMarkdown != nil`, and short-circuits `copyTextWithEntities` to `storeMessageTextInPasteboard(markdown, entities: nil)`. |
|
||||
| `submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` | `markdownStringFromInstantPage` — the block-tree → markdown converter (also used by the edit round-trip). Blocks joined by `\n\n`; nested blockquotes via recursive `> ` wrapping. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` | `InstantPageMarkdownBlockContext` (`kind` + `quoteDepth`) and the `markdownContext: InstantPageMarkdownBlockContext?` field on `InstantPageTextItem`. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `stampMarkdownContext`/`bumpQuoteDepth`; stamps `markdownContext` during layout (heading/title/code/list/blockQuote/`layoutQuoteText`/table-cell). |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` | `markdownForRange(_ range: NSRange)` + the private attributed-substring→inline-markdown converter `inlineMarkdown(from:)`. |
|
||||
| `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/.../ChatMessageRichDataBubbleContentNode.swift` | Intercepts `.copy` in the `TextSelectionNode` `performAction` closure: `textSelectionNode.getSelection()` → `adapter.markdownForRange(range)` → stores as plain `NSAttributedString(string:)`. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **The V2 layout discards block role.** A `.text` layout item from an `H2` heading is byte-identical to a body paragraph — heading level and the title category are dropped with no back-reference to the source `InstantPageBlock`. Precise structural markdown for a *selection* therefore requires stamping `markdownContext` at layout time (lists/code/tables/details are structurally recoverable; **heading level and `.title` are not**, so they MUST be stamped). Plain paragraphs stay `nil` (≡ plain).
|
||||
- **`quoteDepth` is orthogonal to `kind`** so a heading/list/code line inside a blockquote round-trips (e.g. `> ## Title`). `bumpQuoteDepth` lifts a quote's children by 1; nested quotes accumulate. `layoutQuoteText` (single-paragraph blockquote fast path AND `.pullQuote`) bumps once — it is never reached by the multi-block recursion, so no double-count.
|
||||
- **A blockquote is exploded into one text item per line.** `markdownForRange` must re-coalesce a run of consecutive `quoteDepth > 0` segments into ONE `\n`-joined block (each line prefixed at its own depth); otherwise every quote line becomes its own block separated by a blank line. Code/table/list runs are likewise coalesced (one fence; one pipe table; one tight list).
|
||||
- **Both converters emit compact nested-quote markers (`>>`, not `> >`).** Selection: `String(repeating: ">", count: depth) + " "`. Whole-message: when wrapping a line that already starts with `>`, prepend a bare `>`. Keep the two in sync.
|
||||
- **Inline markdown is read from display attributes, not the RichText tree.** `inlineMarkdown` inspects the slice's `UIFont` (bold/italic/mono — font-based, no symbolic-trait flag for named fonts), `.strikethroughStyle`, and `TelegramTextAttributes.URL` (→ `InstantPageUrlItem.url`, angle-bracketed if it contains `(`/`)`/space). Custom-emoji placeholders now emit the `[<alt>](tg://emoji?id=…)` marker from the display attribute's `fileId` (alt is best-effort — the display placeholder may be a bare space; see the custom-emoji round-trip section).
|
||||
- **`.copy` stores plain text.** Passing `NSAttributedString(string: markdown)` through the existing `performTextSelectionAction(.copy)` path (`storeAttributedTextInPasteboard`) generates no entities, so the literal `**`/`#`/`>`/`|` survive. The whole-message Copy uses `storeMessageTextInPasteboard(_, entities: nil)` directly.
|
||||
- **Fidelity caveats (intentional):** custom emoji are now preserved as `[<alt>](tg://emoji?id=…)` markers (selection copy uses a best-effort alt — see the custom-emoji round-trip section below); ordered list + checkbox loses the ordinal (`-` wins); a partial table selection emits touched cells as rows (no forced header `---` separator); block prefixes apply to the whole touched line on a mid-line selection (correct markdown).
|
||||
|
||||
## Custom emoji in markdown messages (send + edit/copy/paste round-trip)
|
||||
|
||||
Custom emoji typed into the compose field survive when a message is sent as a **rich** message (heading/list/table/formula), rendering as `RichText.textCustomEmoji` (the display side is the "Inline custom emoji" section above). The carrier across Apple's CommonMark parser is a shared markdown-link marker `[<alt>](tg://emoji?id=<fileId>)`, used identically by the forward (send) and reverse (edit/copy/paste) paths so encode and decode cannot drift. Always-on. **Scope: only rich messages — a custom emoji alone never forces a rich message** (it stays on the entity path as a `.CustomEmoji` entity, the pre-existing behavior).
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/TextFormat/Sources/CustomEmojiMarkdownMarker.swift` | The marker format — single source of truth: `customEmojiMarkdownURL(fileId:)`, `parseCustomEmojiFileId(fromMarkdownURL:)`, `escapeCustomEmojiMarkdownAlt(_:)`, and `chatInputTextWithReattachedCustomEmoji(_:)` (markers → live `customEmoji` attributes). In TextFormat so both BrowserUI and InstantPageUI can import it. |
|
||||
| `submodules/BrowserUI/Sources/BrowserMarkdown.swift` | Forward: `markdownSourceInjectingCustomEmojiMarkers` rewrites each `customEmoji` run into the marker; `richMarkdownAttributeIfNeeded(context:attributedText:)` (signature changed from `text:`); the marker-URL intercept in `markdownInlineContent` → `.textCustomEmoji`. |
|
||||
| `submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` | Reverse (whole-message copy + edit reconstruction): `.textCustomEmoji` → emit the marker. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` | Reverse (text-selection copy): emit the marker from the display attribute's `fileId` (alt best-effort). |
|
||||
| `submodules/TelegramUI/Sources/ChatControllerNode.swift`, `…/Chat/ChatMessageDisplaySendMessageOptions.swift` | Send + send-options-preview call sites pass the `NSAttributedString` (`effectiveInputText` / `textInputView.attributedText`); the rich send now passes `inlineStickers`. |
|
||||
| `submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift` | Edit-load (`setupEditMessage`) reattaches markers via `chatInputTextWithReattachedCustomEmoji`; edit-save (`editMessage`) re-classifies the attributed edit text. |
|
||||
| `submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift` | Paste (`chatInputTextNodeShouldPaste`) reattaches plain-text markdown markers → live emoji. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **One shared marker, one set of helpers.** All emit sites (forward normalize, reverse copy/edit, selection copy) use `customEmojiMarkdownURL` + `escapeCustomEmojiMarkdownAlt`; the forward intercept and both reattach sites use `parseCustomEmojiFileId`. The marker is internal/transient — it exists only in the rich-conversion source string and on the clipboard, never persisted as a URL entity.
|
||||
- **CommonMark preserves the `tg://emoji?id=N` link URL verbatim** under the `NSLink` attribute (spike-verified). `markdownLink`'s `as? NSURL` branch returns `url.absoluteString`, which `parseCustomEmojiFileId` matches by strict prefix. Negative (signed Int64) file ids survive too (the reattach regex is `(-?\d+)`).
|
||||
- **Scope guard is structural.** `markdownSourceInjectingCustomEmojiMarkers` works on a LOCAL copy — `effectiveInputText` is never mutated. A marker is an entity-expressible link, so an emoji-only message classifies not-rich (`markdownMightNeedRichLayout` finds no `#`/`|`/`` (no run carries the link attribute), which would silently lose the emoji; every emit site and the reattach substitute a space when the alt is empty.
|
||||
- **Rich send attaches `inlineStickers`** (was `[:]`) + bubble-up packs, so the local store has the files. **OPEN runtime risk:** the wire send uses `Api.InputRichMessage.documents: nil` (`apiInputRichMessage()` in `SyncCore_RichTextMessageAttribute.swift`), so recipient rendering depends on the server back-filling `documents` from the embedded `documentId` — UNVERIFIED. If recipients see only the fallback glyph, populate `documents:` there.
|
||||
- **Accepted limitations:** edit-load reattaches with `file: nil` (renders via lazy fileId resolution, but the premium-emoji gate is bypassed on edit); an alt containing a literal `]` won't reattach on edit-load (cosmetic — re-save still parses it); `parseCustomEmojiFileId` (strict prefix) vs `Pasteboard.swift`'s `URLComponents` parse could drift if the marker format ever changes.
|
||||
|
||||
## Formulas trigger rich messages (strict math detection)
|
||||
|
||||
`$…$`/`$$…$$` (and `\(…\)`/`\[…\]`) math triggers a rich message, gated by a
|
||||
strict boundary rule so casual `$` stays plain. Inverse companion of the
|
||||
markdown-send gate above.
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **Inline `$…$`/`$$…$$` detection requires a 4-way boundary** (in `markdownReplacingInlineFormulas`, `BrowserMarkdown.swift`): outer side of each delimiter = line edge OR non-alphanumeric; inner side = non-whitespace; opener/closer `$`-counts must match (1 or 2). This is what rejects `$5-$10`/`$FOO=$BAR`/`cost$5$total` (alphanumeric outer) while keeping `$x$`, `($x$)`, `the answer is $x$.`. The outer check is the addition over a plain "no-space-inside" rule.
|
||||
- **Block `$$` detection** (`markdownBlockFormulaReplacement`): single-line `$$…$$` requires an exact `$$` opener (not `$$$`) and trailing whitespace only; multi-line requires a **bare** `$$` opener line. `$$x$$ trailing text` falls through to the inline rule. The `\[…\]` opener path is unchanged and exempt from these `$$`-only guards.
|
||||
- **Detection is shared with the document path; the gate is chat-only.** `markdownPreparedSource` (detection) runs for both chat and document attachments. The triggers (`richTextIsEntityExpressible`/`blockIsEntityExpressible` → `.formula` is non-expressible; `$`/`\(`/`\[` in `markdownMightNeedRichLayout`) are read only by the chat classifier `richMarkdownAttributeIfNeeded`.
|
||||
|
||||
## InstantPageListItem task-list checkboxes (`- [ ]` / `- [x]`)
|
||||
|
||||
`InstantPageListItem` carries a first-class `checked: Bool?` — the **third** associated value of `.text(RichText, String?, Bool?)` / `.blocks([InstantPageBlock], String?, Bool?)`, orthogonal to the ordered-list `num` — representing a GitHub-style task-list checkbox. `nil` = not a checkbox item, `false` = unchecked, `true` = checked. Covers markdown parse, Postbox + FlatBuffers serialization, Telegram API transmission, display (V1 + V2), the edit round-trip, and previews.
|
||||
|
||||
Spec: [`docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md`](docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md). Plan: [`docs/superpowers/plans/2026-05-27-instantpage-list-checkbox.md`](docs/superpowers/plans/2026-05-27-instantpage-list-checkbox.md).
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift` | The `checked: Bool?` enum payload; Postbox coding (key `"ck"`, tri-state Int32); `==`; FlatBuffers codec. Internal tri-state helpers `checkedFromTriState`/`triState(fromChecked:)`. |
|
||||
| `submodules/TelegramCore/FlatSerialization/Models/InstantPageBlock.fbs` | `checkState:int32 (id: 2)` on `InstantPageListItem_Text` + `_Blocks`. **Source of truth**; the Bazel `flatc` genrule regenerates the Swift (checked-in `*_generated.swift` is stale). |
|
||||
| `submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift` | `checked` / `num` accessors; reads & writes the API `checkbox`=flags.0 / `checked`=flags.1 bits via `checkedFromApiFlags` / `apiFlags(fromChecked:)` across all four list-item types. |
|
||||
| `submodules/BrowserUI/Sources/BrowserMarkdown.swift` | Forward parse: `markdownTaskListMarker` detects `[ ]`/`[x]`/`[X]`; the result routes into `checked` (NOT `num`). |
|
||||
| `submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` | Reverse: emits `- [ ] ` / `- [x] ` from `item.checked` for the edit round-trip. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | V2 detection via `item.checked`; `.checklist(checked:colors:)` marker carrying `InstantPageV2CheckboxColors`. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` | V2 marker view (`InstantPageV2ListMarkerView`) hosts a real `CheckNode`. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageLayout.swift` | V1 detection via `item.checked` (renders the existing `InstantPageChecklistMarkerItem`). |
|
||||
| `submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift` | `previewText()` renders a `☐`/`☑︎` glyph + body for checkbox items. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **`checked` is orthogonal to `num`.** The API keeps `checkbox`/`checked` as flags **separate from the list number**, so an ordered item can be both numbered AND a checkbox. This is exactly why the first-class field replaced an earlier sentinel-string-in-`num` prototype (which could not represent both). No `\u{001f}tg-md-task:*` sentinel remains anywhere.
|
||||
- **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 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.
|
||||
|
||||
## InstantPageBlock.blockQuote nested blocks
|
||||
|
||||
`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).
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift` | Enum case shape; Postbox coding (legacy `"t"` lift → new `"b"` object array); equality (array-aware, mirrors `.collage`); FlatBuffers codec. |
|
||||
| `submodules/TelegramCore/FlatSerialization/Models/InstantPageBlock.fbs` | `InstantPageBlock_BlockQuote`: `text` (now optional, legacy fallback) + `caption (required)` + new `blocks:[InstantPageBlock] (id: 2)`. **Source of truth**; Bazel regenerates the `*_generated.swift`. |
|
||||
| `submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift` | Parse both `pageBlockBlockquote` (lift text→`[.paragraph]`) and `pageBlockBlockquoteBlocks`; encode legacy-when-possible. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `layoutBlockQuote(blocks:…)` recurses into children; legacy single-paragraph fast path delegates to `layoutQuoteText` (the renamed shared text core, also used by `.pullQuote`). |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageLayout.swift` | V1 `.blockQuote` arm recurses via `layoutInstantPageBlock(...)`; same single-paragraph fast path. |
|
||||
| `submodules/BrowserUI/Sources/BrowserMarkdown.swift` | Forward: one quote carrying all child blocks. Entity-expressibility gate (below). |
|
||||
| `submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` | Reverse: `markdownBlockQuoteBlocks(_:)` recurses per child and prefixes `> ` per line. |
|
||||
| `submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift` | Concatenates child `previewText()`s + caption. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **Legacy shapes lift to `[.paragraph(text)]` at every decode boundary.** API `pageBlockBlockquote`, the Postbox `"t"` key (old cached pages), and the FlatBuffers `text` field (now optional) each lift into a single-paragraph blocks array. New writes emit only `blocks` (`"b"` / the FB vector). So pre-existing stored pages and older senders decode unchanged.
|
||||
- **Outbound stays on the legacy wire constructor when the shape allows.** `apiInputBlock()` emits `pageBlockBlockquote` for empty or single-`.paragraph` quotes (so older recipients understand the common chat case) and `pageBlockBlockquoteBlocks` only for genuinely nested quotes.
|
||||
- **Both renderers share one text core for the single-paragraph fast path.** `layoutQuoteText` (V2; the function formerly named `layoutBlockQuote`, `isPull:` distinguishes pull vs block) and the V1 fast-path branch keep the legacy italicized-body styling; nested children render with their own normal category styling.
|
||||
- **Nested children use a FIXED 10pt inter-child gap, not `spacingBetweenBlocks`.** The full page-flow spacing (~27pt around quotes) is too airy when nested, and 0 is too tight. `childSpacing = 10.0` lives in both layout files; the first child hugs the container's `verticalInset` (no leading gap). Combined with a nested quote's own 4pt top inset this gives ~14pt effective separation.
|
||||
- **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.
|
||||
|
|
@ -1,999 +0,0 @@
|
|||
# Context Controller Portal-View Transition Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace `ContextControllerExtractedPresentationNode`'s manual visible-area clipping animation with a portal-based transition so the chat's natural ancestor clipping (and live re-layout) drives the bubble's in/out edges, fixing two issues: shadow/rounded-corner cutoff at chat-content-area edges, and stale clipping when chat re-layouts mid-animation.
|
||||
|
||||
**Architecture:** Optional `sourceTransitionSurface: UIView?` on `ContextControllerTakeViewInfo` / `ContextControllerPutBackViewInfo`. When non-nil, CCEPN parks the source's `contentNode` inside that surface (via a `PortalSourceView` wrapper) for the duration of the in/out animation, mirrors it through a `PortalView(matchPosition: true)` clone in `ItemContentNode.offsetContainerNode`, and retargets the existing spring/position deltas onto the wrapper's layer instead of the overlay-side layer. The manual `clippingNode.layer.animateFrame(...)` calls are bypassed on this path. Resting state is unchanged from today (contentNode lives in `offsetContainerNode` while the menu is up). When the surface is nil, today's clipping path is preserved verbatim. First adopter: chat message bubbles (regular long-press + reaction context).
|
||||
|
||||
**Tech Stack:** Swift, AsyncDisplayKit, UIKit. `PortalSourceView` / `PortalView` from `submodules/Display/Source/`. Build via Bazel (`Make.py` wrapper).
|
||||
|
||||
**Reference spec:** `docs/superpowers/specs/2026-05-05-context-controller-portal-view-design.md`.
|
||||
|
||||
**Build verification command** (used at the end of each task):
|
||||
|
||||
```sh
|
||||
source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \
|
||||
--cacheDir ~/telegram-bazel-cache build \
|
||||
--configurationPath build-system/appstore-configuration.json \
|
||||
--gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \
|
||||
--gitCodesigningType development --gitCodesigningUseCurrent \
|
||||
--buildNumber=1 --configuration=debug_sim_arm64 --continueOnError
|
||||
```
|
||||
|
||||
Expected: build succeeds (no compile errors). The project has no unit tests; verification is the build plus manual checks in Task 8.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `sourceTransitionSurface` field to `ContextUI` structs
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/ContextUI/Sources/ContextController.swift:347-372`
|
||||
|
||||
The change is purely additive. Default values are `nil`, so every existing producer of `ContextControllerTakeViewInfo` / `ContextControllerPutBackViewInfo` keeps compiling unchanged and falls back to today's clipping path.
|
||||
|
||||
- [ ] **Step 1: Replace the two struct definitions**
|
||||
|
||||
Open `submodules/ContextUI/Sources/ContextController.swift`. Find lines 347–372 (the existing `ContextControllerTakeViewInfo` and `ContextControllerPutBackViewInfo` declarations). Replace exactly:
|
||||
|
||||
Find:
|
||||
```swift
|
||||
public final class ContextControllerTakeViewInfo {
|
||||
public enum ContainingItem {
|
||||
case node(ContextExtractedContentContainingNode)
|
||||
case view(ContextExtractedContentContainingView)
|
||||
}
|
||||
|
||||
public let containingItem: ContainingItem
|
||||
public let contentAreaInScreenSpace: CGRect
|
||||
public let maskView: UIView?
|
||||
|
||||
public init(containingItem: ContainingItem, contentAreaInScreenSpace: CGRect, maskView: UIView? = nil) {
|
||||
self.containingItem = containingItem
|
||||
self.contentAreaInScreenSpace = contentAreaInScreenSpace
|
||||
self.maskView = maskView
|
||||
}
|
||||
}
|
||||
|
||||
public final class ContextControllerPutBackViewInfo {
|
||||
public let contentAreaInScreenSpace: CGRect
|
||||
public let maskView: UIView?
|
||||
|
||||
public init(contentAreaInScreenSpace: CGRect, maskView: UIView? = nil) {
|
||||
self.contentAreaInScreenSpace = contentAreaInScreenSpace
|
||||
self.maskView = maskView
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```swift
|
||||
public final class ContextControllerTakeViewInfo {
|
||||
public enum ContainingItem {
|
||||
case node(ContextExtractedContentContainingNode)
|
||||
case view(ContextExtractedContentContainingView)
|
||||
}
|
||||
|
||||
public let containingItem: ContainingItem
|
||||
public let contentAreaInScreenSpace: CGRect
|
||||
public let maskView: UIView?
|
||||
public let sourceTransitionSurface: UIView?
|
||||
|
||||
public init(containingItem: ContainingItem, contentAreaInScreenSpace: CGRect, maskView: UIView? = nil, sourceTransitionSurface: UIView? = nil) {
|
||||
self.containingItem = containingItem
|
||||
self.contentAreaInScreenSpace = contentAreaInScreenSpace
|
||||
self.maskView = maskView
|
||||
self.sourceTransitionSurface = sourceTransitionSurface
|
||||
}
|
||||
}
|
||||
|
||||
public final class ContextControllerPutBackViewInfo {
|
||||
public let contentAreaInScreenSpace: CGRect
|
||||
public let maskView: UIView?
|
||||
public let sourceTransitionSurface: UIView?
|
||||
|
||||
public init(contentAreaInScreenSpace: CGRect, maskView: UIView? = nil, sourceTransitionSurface: UIView? = nil) {
|
||||
self.contentAreaInScreenSpace = contentAreaInScreenSpace
|
||||
self.maskView = maskView
|
||||
self.sourceTransitionSurface = sourceTransitionSurface
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to confirm no caller broke**
|
||||
|
||||
Run the full build verification command from the plan header. Expected: succeeds (existing init calls keep working because the new parameter is defaulted).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/ContextUI/Sources/ContextController.swift
|
||||
git commit -m "$(cat <<'EOF'
|
||||
ContextUI: add sourceTransitionSurface to TakeViewInfo / PutBackInfo
|
||||
|
||||
Optional UIView field provided by extracted-content sources to opt
|
||||
into the upcoming portal-based transition path in CCEPN. Defaults to
|
||||
nil, so existing callers are unchanged.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add `PortalTransitionStaging` helper + `portalStaging` field on `ItemContentNode`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift` (top of file, near other private types; and inside `ItemContentNode`)
|
||||
|
||||
The class is unused at this point — Task 3 / Task 4 wire it in. Splitting this out is intentional: a buildable commit that adds the new abstraction with no behavior change makes review easier.
|
||||
|
||||
- [ ] **Step 1: Insert the helper class definition**
|
||||
|
||||
Open `submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift`. Imports already include `Display`, `AsyncDisplayKit`, and `UIKit` (line 1–14), which are sufficient for `PortalSourceView` / `PortalView`.
|
||||
|
||||
After the closing `}` of the `private extension ContextControllerTakeViewInfo.ContainingItem { ... }` block (it ends at line 124, just before the start of `final class ContextControllerExtractedPresentationNode` at line 126), insert this new file-local helper:
|
||||
|
||||
```swift
|
||||
private final class PortalTransitionStaging {
|
||||
enum SettleDestination {
|
||||
case offsetContainer(ASDisplayNode)
|
||||
case original
|
||||
}
|
||||
|
||||
enum OriginalParent {
|
||||
case node(ASDisplayNode)
|
||||
case view(UIView)
|
||||
}
|
||||
|
||||
weak var surface: UIView?
|
||||
var wrapper: PortalSourceView?
|
||||
var clone: PortalView?
|
||||
var originalParent: OriginalParent?
|
||||
var containingItem: ContextControllerTakeViewInfo.ContainingItem?
|
||||
|
||||
/// Reparents the source's contentNode/contentView into a freshly-created
|
||||
/// `PortalSourceView` inside `surface`, attaches a `PortalView(matchPosition: true)`
|
||||
/// clone to `overlayHost`, sizes the wrapper so contentNode appears at
|
||||
/// `targetScreenRect` in window coords, and returns the wrapper's layer.
|
||||
///
|
||||
/// Returns nil if `PortalView(matchPosition:)` cannot be instantiated. In that
|
||||
/// case staging is left empty and the caller takes the clipping fallback path.
|
||||
func enter(
|
||||
for containingItem: ContextControllerTakeViewInfo.ContainingItem,
|
||||
in surface: UIView,
|
||||
overlayHost: UIView,
|
||||
targetScreenRect: CGRect
|
||||
) -> CALayer? {
|
||||
guard let clone = PortalView(matchPosition: true) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let wrapper = PortalSourceView()
|
||||
|
||||
let originalParent: OriginalParent
|
||||
switch containingItem {
|
||||
case let .node(containingNode):
|
||||
if let supernode = containingNode.contentNode.supernode {
|
||||
originalParent = .node(supernode)
|
||||
} else {
|
||||
originalParent = .node(containingNode)
|
||||
}
|
||||
case let .view(containingView):
|
||||
if let superview = containingView.contentView.superview {
|
||||
originalParent = .view(superview)
|
||||
} else {
|
||||
originalParent = .view(containingView)
|
||||
}
|
||||
}
|
||||
|
||||
// Place wrapper so that the bubble (= containingItem.contentRect, in
|
||||
// containingItem.view coords) lands at `targetScreenRect` on screen.
|
||||
//
|
||||
// After reparenting contentNode/contentView into wrapper (preserving its
|
||||
// frame value), the bubble's rect in wrapper-local coords numerically
|
||||
// equals containingItem.contentRect (since the bubble was at
|
||||
// contentNode.frame.origin + bubbleOffsetInContentNode == contentRect.origin
|
||||
// in the original parent). So:
|
||||
// bubble.screen.origin = wrapper.screen.origin + contentRect.origin
|
||||
// and we want bubble.screen.origin == targetScreenRect.origin, hence:
|
||||
// wrapper.screen.origin = targetScreenRect.origin - contentRect.origin
|
||||
let bubbleOffsetInContainer = containingItem.contentRect.origin
|
||||
let wrapperOriginInWindow = CGPoint(
|
||||
x: targetScreenRect.origin.x - bubbleOffsetInContainer.x,
|
||||
y: targetScreenRect.origin.y - bubbleOffsetInContainer.y
|
||||
)
|
||||
let wrapperFrameInWindow = CGRect(origin: wrapperOriginInWindow, size: containingItem.view.bounds.size)
|
||||
wrapper.frame = surface.convert(wrapperFrameInWindow, from: nil)
|
||||
surface.addSubview(wrapper)
|
||||
|
||||
switch containingItem {
|
||||
case let .node(containingNode):
|
||||
wrapper.addSubview(containingNode.contentNode.view)
|
||||
case let .view(containingView):
|
||||
wrapper.addSubview(containingView.contentView)
|
||||
}
|
||||
|
||||
wrapper.addPortal(view: clone)
|
||||
overlayHost.addSubview(clone.view)
|
||||
|
||||
self.surface = surface
|
||||
self.wrapper = wrapper
|
||||
self.clone = clone
|
||||
self.originalParent = originalParent
|
||||
self.containingItem = containingItem
|
||||
|
||||
return wrapper.layer
|
||||
}
|
||||
|
||||
/// Tears down staging. Reparents contentNode into the requested destination,
|
||||
/// removes clone from its overlay host, removes wrapper from surface.
|
||||
/// All operations are explicit; we rely on Telegram's manual-animation policy
|
||||
/// (no implicit CALayer actions) — no CATransaction wrapping needed.
|
||||
func settle(into destination: SettleDestination) {
|
||||
guard let wrapper = self.wrapper, let containingItem = self.containingItem else {
|
||||
return
|
||||
}
|
||||
|
||||
switch destination {
|
||||
case let .offsetContainer(offsetContainerNode):
|
||||
switch containingItem {
|
||||
case let .node(containingNode):
|
||||
offsetContainerNode.addSubnode(containingNode.contentNode)
|
||||
case let .view(containingView):
|
||||
offsetContainerNode.view.addSubview(containingView.contentView)
|
||||
}
|
||||
case .original:
|
||||
switch (containingItem, self.originalParent) {
|
||||
case let (.node(containingNode), .some(.node(parent))):
|
||||
parent.addSubnode(containingNode.contentNode)
|
||||
case let (.view(containingView), .some(.view(parent))):
|
||||
parent.addSubview(containingView.contentView)
|
||||
case let (.node(containingNode), _):
|
||||
// Surface lost; restore to the source's containing node as a fallback.
|
||||
containingNode.addSubnode(containingNode.contentNode)
|
||||
case let (.view(containingView), _):
|
||||
containingView.addSubview(containingView.contentView)
|
||||
}
|
||||
}
|
||||
|
||||
if let clone = self.clone {
|
||||
wrapper.removePortal(view: clone)
|
||||
clone.view.removeFromSuperview()
|
||||
}
|
||||
wrapper.removeFromSuperview()
|
||||
|
||||
self.surface = nil
|
||||
self.wrapper = nil
|
||||
self.clone = nil
|
||||
self.originalParent = nil
|
||||
self.containingItem = nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `portalStaging` field on `ItemContentNode`**
|
||||
|
||||
Find the `ItemContentNode` class declaration starting at line 134. The existing fields are:
|
||||
|
||||
```swift
|
||||
private final class ItemContentNode: ASDisplayNode {
|
||||
let offsetContainerNode: ASDisplayNode
|
||||
var containingItem: ContextControllerTakeViewInfo.ContainingItem
|
||||
|
||||
var animateClippingFromContentAreaInScreenSpace: CGRect?
|
||||
var storedGlobalFrame: CGRect?
|
||||
var storedGlobalBoundsFrame: CGRect?
|
||||
var presentationScale: CGFloat = 1.0
|
||||
```
|
||||
|
||||
Insert after the `presentationScale` line:
|
||||
|
||||
```swift
|
||||
var portalStaging: PortalTransitionStaging?
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to confirm helper compiles**
|
||||
|
||||
Run the build verification command from the plan header. Expected: succeeds. The new class and field are unreferenced; the build only validates syntax/types.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift
|
||||
git commit -m "$(cat <<'EOF'
|
||||
CCEPN: add PortalTransitionStaging helper
|
||||
|
||||
File-local class that owns the transient PortalSourceView wrapper
|
||||
+ PortalView clone lifecycle for the upcoming portal-based transition
|
||||
path. Adds an unused portalStaging field on ItemContentNode. Wired
|
||||
up in subsequent commits.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire portal path into `case .animateIn:`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift:1265-1474` (the `.animateIn` arm of the `stateTransition` switch).
|
||||
|
||||
The portal path is opt-in: only fires when `takeInfo.sourceTransitionSurface != nil` AND `presentationScale == 1.0` AND `staging.enter(...)` succeeds. Otherwise the today's-clipping path runs unchanged.
|
||||
|
||||
CCEPN's `update(state:transition:)` does not have direct access to the takeInfo at `case .animateIn` time — `takeInfo` is consumed earlier at the `case let .extracted(source):` block (line ~631) where `ItemContentNode` is constructed. We thread `sourceTransitionSurface` through the `ItemContentNode` so `.animateIn` can see it.
|
||||
|
||||
- [ ] **Step 1: Stash `sourceTransitionSurface` on `ItemContentNode` at construction time**
|
||||
|
||||
Find the construction site at line 631–656 (`case let .extracted(source):` block). Inside the `if-let` for `takeInfo` (around line 632–655), after the existing line `contentNodeValue.animateClippingFromContentAreaInScreenSpace = takeInfo.contentAreaInScreenSpace` (line 636), add:
|
||||
|
||||
```swift
|
||||
contentNodeValue.sourceTransitionSurface = takeInfo.sourceTransitionSurface
|
||||
```
|
||||
|
||||
Then add a matching field on `ItemContentNode` (next to `animateClippingFromContentAreaInScreenSpace`, line 138):
|
||||
|
||||
```swift
|
||||
weak var sourceTransitionSurface: UIView?
|
||||
```
|
||||
|
||||
The field is `weak` because the surface's lifetime is owned by the source side, not by CCEPN.
|
||||
|
||||
- [ ] **Step 2: Bypass `takeContainingNode()` when staging is active**
|
||||
|
||||
Find line 1269–1271:
|
||||
|
||||
```swift
|
||||
if let contentNode = itemContentNode {
|
||||
contentNode.takeContainingNode()
|
||||
}
|
||||
```
|
||||
|
||||
The portal path needs contentNode to NOT be reparented into `offsetContainerNode` here — it'll go into the wrapper instead (next step). Replace with:
|
||||
|
||||
```swift
|
||||
if let contentNode = itemContentNode {
|
||||
if let surface = contentNode.sourceTransitionSurface, contentNode.presentationScale == 1.0 {
|
||||
// Defer reparenting to staging.enter (Step 3 below).
|
||||
let _ = surface
|
||||
} else {
|
||||
contentNode.takeContainingNode()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the staging-enter / animation-target retargeting block**
|
||||
|
||||
Find line 1280: `if let contentNode = itemContentNode { ... }`. The block currently begins with the clipping animation guard (lines 1281–1284). Insert this BEFORE that guard, as the new very-first thing inside the block:
|
||||
|
||||
```swift
|
||||
let portalAnimationLayer: CALayer?
|
||||
if let surface = contentNode.sourceTransitionSurface, contentNode.presentationScale == 1.0 {
|
||||
let staging = PortalTransitionStaging()
|
||||
let currentContentLocalFrameInWindow = self.view.convert(
|
||||
convertFrame(contentRect, from: self.scrollNode.view, to: self.view),
|
||||
to: nil
|
||||
)
|
||||
if let layer = staging.enter(
|
||||
for: contentNode.containingItem,
|
||||
in: surface,
|
||||
overlayHost: contentNode.offsetContainerNode.view,
|
||||
targetScreenRect: currentContentLocalFrameInWindow
|
||||
) {
|
||||
contentNode.portalStaging = staging
|
||||
portalAnimationLayer = layer
|
||||
} else {
|
||||
// Staging refused (PortalView nil); fall back to clipping by reparenting now.
|
||||
contentNode.takeContainingNode()
|
||||
portalAnimationLayer = nil
|
||||
}
|
||||
} else {
|
||||
portalAnimationLayer = nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Gate the existing clipping animation on no-staging**
|
||||
|
||||
Find lines 1281–1284:
|
||||
|
||||
```swift
|
||||
if let animateClippingFromContentAreaInScreenSpace = contentNode.animateClippingFromContentAreaInScreenSpace {
|
||||
self.clippingNode.layer.animateFrame(from: CGRect(origin: CGPoint(x: 0.0, y: animateClippingFromContentAreaInScreenSpace.minY), size: CGSize(width: layout.size.width, height: animateClippingFromContentAreaInScreenSpace.height)), to: CGRect(origin: CGPoint(), size: layout.size), duration: 0.2)
|
||||
self.clippingNode.layer.animateBoundsOriginYAdditive(from: animateClippingFromContentAreaInScreenSpace.minY, to: 0.0, duration: 0.2)
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
if portalAnimationLayer == nil, let animateClippingFromContentAreaInScreenSpace = contentNode.animateClippingFromContentAreaInScreenSpace {
|
||||
self.clippingNode.layer.animateFrame(from: CGRect(origin: CGPoint(x: 0.0, y: animateClippingFromContentAreaInScreenSpace.minY), size: CGSize(width: layout.size.width, height: animateClippingFromContentAreaInScreenSpace.height)), to: CGRect(origin: CGPoint(), size: layout.size), duration: 0.2)
|
||||
self.clippingNode.layer.animateBoundsOriginYAdditive(from: animateClippingFromContentAreaInScreenSpace.minY, to: 0.0, duration: 0.2)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Retarget the position springs onto `portalAnimationLayer` when present**
|
||||
|
||||
Find the two `contentNode.layer.animateSpring(...)` calls at lines 1313–1322 and 1324–1332. They animate `contentNode.layer` (= the `ItemContentNode`'s layer). When `portalAnimationLayer` is non-nil, the springs need to target the wrapper's layer instead.
|
||||
|
||||
Replace lines 1312–1332 (the X-distance spring guard + X spring + Y spring) with:
|
||||
|
||||
```swift
|
||||
let animateLayer: CALayer = portalAnimationLayer ?? contentNode.layer
|
||||
|
||||
if animationInContentXDistance != 0.0 {
|
||||
animateLayer.animateSpring(
|
||||
from: -animationInContentXDistance as NSNumber, to: 0.0 as NSNumber,
|
||||
keyPath: "position.x",
|
||||
duration: duration,
|
||||
delay: 0.0,
|
||||
initialVelocity: 0.0,
|
||||
damping: springDamping,
|
||||
additive: true
|
||||
)
|
||||
}
|
||||
|
||||
animateLayer.animateSpring(
|
||||
from: -animationInContentYDistance as NSNumber, to: 0.0 as NSNumber,
|
||||
keyPath: "position.y",
|
||||
duration: duration,
|
||||
delay: 0.0,
|
||||
initialVelocity: 0.0,
|
||||
damping: springDamping,
|
||||
additive: true
|
||||
)
|
||||
```
|
||||
|
||||
(Net change: replace each `contentNode.layer.animateSpring(...)` with `animateLayer.animateSpring(...)`, keeping every other parameter identical.)
|
||||
|
||||
The `reactionPreviewView` springs at lines 1334–1355 stay unchanged — `reactionPreviewView` lives in CCEPN's own tree, never inside the source-side surface.
|
||||
|
||||
- [ ] **Step 6: Attach a settle completion to the Y-spring**
|
||||
|
||||
The Y-distance spring is the longest-lived in the animateIn animation set. Add a completion handler that calls `staging.settle(into: .offsetContainer(...))` when staging is active. Replace the just-edited Y-spring (the one at the bottom of the block from Step 5):
|
||||
|
||||
```swift
|
||||
animateLayer.animateSpring(
|
||||
from: -animationInContentYDistance as NSNumber, to: 0.0 as NSNumber,
|
||||
keyPath: "position.y",
|
||||
duration: duration,
|
||||
delay: 0.0,
|
||||
initialVelocity: 0.0,
|
||||
damping: springDamping,
|
||||
additive: true
|
||||
)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
animateLayer.animateSpring(
|
||||
from: -animationInContentYDistance as NSNumber, to: 0.0 as NSNumber,
|
||||
keyPath: "position.y",
|
||||
duration: duration,
|
||||
delay: 0.0,
|
||||
initialVelocity: 0.0,
|
||||
damping: springDamping,
|
||||
additive: true,
|
||||
completion: { [weak contentNode] _ in
|
||||
guard let contentNode else { return }
|
||||
if let staging = contentNode.portalStaging {
|
||||
staging.settle(into: .offsetContainer(contentNode.offsetContainerNode))
|
||||
contentNode.portalStaging = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
(The `Display` module's `animateSpring` already accepts a `completion:` parameter — the dismiss path uses it at line 1665.)
|
||||
|
||||
- [ ] **Step 7: Build to confirm everything compiles**
|
||||
|
||||
Run the build verification command from the plan header. Expected: succeeds. Existing source-callers don't pass `sourceTransitionSurface`, so portal path is dormant; visual behavior is unchanged.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift
|
||||
git commit -m "$(cat <<'EOF'
|
||||
CCEPN: portal-mode animateIn (gated on sourceTransitionSurface)
|
||||
|
||||
When a source provides sourceTransitionSurface (and presentationScale
|
||||
is 1.0), reparent the source's contentNode into a PortalSourceView
|
||||
inside the surface, mirror it via PortalView(matchPosition: true) into
|
||||
ItemContentNode.offsetContainerNode, and retarget the position springs
|
||||
onto the wrapper's layer. The clipping animation is bypassed in this
|
||||
mode. On animation completion, contentNode is reparented home into
|
||||
offsetContainerNode (today's resting state). Fallback path unchanged.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire portal path into `case .animateOut:`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift:1487-1684` (the `.animateOut` arm of the `stateTransition` switch).
|
||||
|
||||
Mirror of Task 3 but in reverse direction: contentNode goes from `offsetContainerNode` back into the source-side surface, animation runs in chat tree, and on completion contentNode is reparented to its original parent (chat-side).
|
||||
|
||||
- [ ] **Step 1: Declare `portalAnimationLayer` at the top of the animateOut block**
|
||||
|
||||
Find line 1507 (`let currentContentScreenFrame: CGRect`), inside `case .animateOut(...)`. Insert immediately before that line:
|
||||
|
||||
```swift
|
||||
var portalAnimationLayer: CALayer? = nil
|
||||
```
|
||||
|
||||
The local is `var` because the `.extracted` arm of the source switch (next step) populates it. It must be declared up here — outside the source switch — because the dismiss-spring code further down (the `if let contentNode = itemContentNode { ... }` block around line 1622) needs to read it.
|
||||
|
||||
- [ ] **Step 2: Replace the `.extracted` arm of the source switch**
|
||||
|
||||
Find lines 1528–1543 (the `.extracted` arm of the source switch inside `.animateOut`). The current code:
|
||||
|
||||
```swift
|
||||
case let .extracted(source):
|
||||
let putBackInfo = source.putBack()
|
||||
|
||||
if let putBackInfo = putBackInfo {
|
||||
self.clippingNode.layer.animateFrame(from: CGRect(origin: CGPoint(), size: layout.size), to: CGRect(origin: CGPoint(x: 0.0, y: putBackInfo.contentAreaInScreenSpace.minY), size: CGSize(width: layout.size.width, height: putBackInfo.contentAreaInScreenSpace.height)), duration: duration, timingFunction: timingFunction, removeOnCompletion: false)
|
||||
self.clippingNode.layer.animateBoundsOriginYAdditive(from: 0.0, to: putBackInfo.contentAreaInScreenSpace.minY, duration: duration, timingFunction: timingFunction, removeOnCompletion: false)
|
||||
}
|
||||
|
||||
if let contentNode = itemContentNode {
|
||||
currentContentScreenFrame = convertFrame(contentNode.containingItem.contentRect, from: contentNode.containingItem.view, to: self.view)
|
||||
if currentContentScreenFrame.origin.x < 0.0 {
|
||||
contentParentGlobalFrameOffsetX = layout.size.width
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
Replace with (note: assigns into the `portalAnimationLayer` declared in Step 1, no re-declaration here):
|
||||
|
||||
```swift
|
||||
case let .extracted(source):
|
||||
let putBackInfo = source.putBack()
|
||||
|
||||
if let putBackInfo = putBackInfo,
|
||||
let surface = putBackInfo.sourceTransitionSurface,
|
||||
let contentNode = itemContentNode,
|
||||
contentNode.presentationScale == 1.0
|
||||
{
|
||||
let preStagingScreenFrame = convertFrame(contentNode.containingItem.contentRect, from: contentNode.containingItem.view, to: self.view)
|
||||
let preStagingScreenFrameInWindow = self.view.convert(preStagingScreenFrame, to: nil)
|
||||
let staging = PortalTransitionStaging()
|
||||
if let layer = staging.enter(
|
||||
for: contentNode.containingItem,
|
||||
in: surface,
|
||||
overlayHost: contentNode.offsetContainerNode.view,
|
||||
targetScreenRect: preStagingScreenFrameInWindow
|
||||
) {
|
||||
contentNode.portalStaging = staging
|
||||
portalAnimationLayer = layer
|
||||
}
|
||||
}
|
||||
|
||||
if portalAnimationLayer == nil, let putBackInfo = putBackInfo {
|
||||
self.clippingNode.layer.animateFrame(from: CGRect(origin: CGPoint(), size: layout.size), to: CGRect(origin: CGPoint(x: 0.0, y: putBackInfo.contentAreaInScreenSpace.minY), size: CGSize(width: layout.size.width, height: putBackInfo.contentAreaInScreenSpace.height)), duration: duration, timingFunction: timingFunction, removeOnCompletion: false)
|
||||
self.clippingNode.layer.animateBoundsOriginYAdditive(from: 0.0, to: putBackInfo.contentAreaInScreenSpace.minY, duration: duration, timingFunction: timingFunction, removeOnCompletion: false)
|
||||
}
|
||||
|
||||
if let contentNode = itemContentNode {
|
||||
currentContentScreenFrame = convertFrame(contentNode.containingItem.contentRect, from: contentNode.containingItem.view, to: self.view)
|
||||
if currentContentScreenFrame.origin.x < 0.0 {
|
||||
contentParentGlobalFrameOffsetX = layout.size.width
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Retarget the dismiss springs onto `portalAnimationLayer` when present**
|
||||
|
||||
Find lines 1643–1684 (the X spring at 1644, the position adjustment at 1655, and the Y spring at 1657). Currently:
|
||||
|
||||
```swift
|
||||
if animationInContentXDistance != 0.0 {
|
||||
contentNode.offsetContainerNode.layer.animate(
|
||||
from: -animationInContentXDistance as NSNumber,
|
||||
to: 0.0 as NSNumber,
|
||||
keyPath: "position.x",
|
||||
timingFunction: timingFunction,
|
||||
duration: duration,
|
||||
delay: 0.0,
|
||||
additive: true
|
||||
)
|
||||
}
|
||||
|
||||
contentNode.offsetContainerNode.position = contentNode.offsetContainerNode.position.offsetBy(dx: animationInContentXDistance, dy: -animationInContentYDistance)
|
||||
let reactionContextNodeIsAnimatingOut = self.reactionContextNodeIsAnimatingOut
|
||||
contentNode.offsetContainerNode.layer.animate(
|
||||
from: animationInContentYDistance as NSNumber,
|
||||
to: 0.0 as NSNumber,
|
||||
keyPath: "position.y",
|
||||
timingFunction: timingFunction,
|
||||
duration: duration,
|
||||
delay: 0.0,
|
||||
additive: true,
|
||||
completion: { [weak self] _ in
|
||||
Queue.mainQueue().after(reactionContextNodeIsAnimatingOut ? 0.2 * UIView.animationDurationFactor() : 0.0, {
|
||||
if let strongSelf = self, let contentNode = strongSelf.itemContentNode {
|
||||
switch contentNode.containingItem {
|
||||
case let .node(containingNode):
|
||||
containingNode.addSubnode(containingNode.contentNode)
|
||||
case let .view(containingView):
|
||||
containingView.addSubview(containingView.contentView)
|
||||
}
|
||||
}
|
||||
|
||||
contentNode.containingItem.isExtractedToContextPreview = false
|
||||
contentNode.containingItem.isExtractedToContextPreviewUpdated?(false)
|
||||
contentNode.containingItem.onDismiss?()
|
||||
|
||||
restoreOverlayViews.forEach({ $0() })
|
||||
completion()
|
||||
})
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Replace with the staging-aware variant:
|
||||
|
||||
```swift
|
||||
let animateLayer: CALayer = portalAnimationLayer ?? contentNode.offsetContainerNode.layer
|
||||
|
||||
if animationInContentXDistance != 0.0 {
|
||||
animateLayer.animate(
|
||||
from: -animationInContentXDistance as NSNumber,
|
||||
to: 0.0 as NSNumber,
|
||||
keyPath: "position.x",
|
||||
timingFunction: timingFunction,
|
||||
duration: duration,
|
||||
delay: 0.0,
|
||||
additive: true
|
||||
)
|
||||
}
|
||||
|
||||
if portalAnimationLayer == nil {
|
||||
contentNode.offsetContainerNode.position = contentNode.offsetContainerNode.position.offsetBy(dx: animationInContentXDistance, dy: -animationInContentYDistance)
|
||||
}
|
||||
|
||||
let reactionContextNodeIsAnimatingOut = self.reactionContextNodeIsAnimatingOut
|
||||
animateLayer.animate(
|
||||
from: animationInContentYDistance as NSNumber,
|
||||
to: 0.0 as NSNumber,
|
||||
keyPath: "position.y",
|
||||
timingFunction: timingFunction,
|
||||
duration: duration,
|
||||
delay: 0.0,
|
||||
additive: true,
|
||||
completion: { [weak self] _ in
|
||||
Queue.mainQueue().after(reactionContextNodeIsAnimatingOut ? 0.2 * UIView.animationDurationFactor() : 0.0, {
|
||||
if let strongSelf = self, let contentNode = strongSelf.itemContentNode {
|
||||
if let staging = contentNode.portalStaging {
|
||||
staging.settle(into: .original)
|
||||
contentNode.portalStaging = nil
|
||||
} else {
|
||||
switch contentNode.containingItem {
|
||||
case let .node(containingNode):
|
||||
containingNode.addSubnode(containingNode.contentNode)
|
||||
case let .view(containingView):
|
||||
containingView.addSubview(containingView.contentView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let strongSelf = self, let contentNode = strongSelf.itemContentNode {
|
||||
contentNode.containingItem.isExtractedToContextPreview = false
|
||||
contentNode.containingItem.isExtractedToContextPreviewUpdated?(false)
|
||||
contentNode.containingItem.onDismiss?()
|
||||
}
|
||||
|
||||
restoreOverlayViews.forEach({ $0() })
|
||||
completion()
|
||||
})
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Three substantive changes:
|
||||
- The X / Y spring `animate(...)` calls target `animateLayer` instead of `contentNode.offsetContainerNode.layer`.
|
||||
- The `offsetContainerNode.position = ...` mutation is skipped on the portal path (offsetContainerNode is not the visible animation layer in that case; mutating it would be a no-op visually but is unnecessary).
|
||||
- The completion's reparent step branches on `contentNode.portalStaging` — staging owns the reparent on the portal path.
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run the build verification command. Expected: succeeds.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift
|
||||
git commit -m "$(cat <<'EOF'
|
||||
CCEPN: portal-mode animateOut (gated on sourceTransitionSurface)
|
||||
|
||||
Symmetric to portal-mode animateIn: when putBackInfo provides a
|
||||
surface (and presentationScale is 1.0), reparent contentNode out of
|
||||
offsetContainerNode into a PortalSourceView in the surface, mirror
|
||||
via PortalView clone in offsetContainerNode, retarget dismiss springs
|
||||
onto the wrapper's layer, skip the manual clip animation. On
|
||||
completion, staging reparents contentNode to its original chat-side
|
||||
parent. Fallback path unchanged.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add `contextTransitionContainer` to `ChatControllerNode`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Sources/ChatControllerNode.swift` (private storage near other view fields; `ensureContextTransitionContainer()` accessor; frame update inside `containerLayoutUpdated`).
|
||||
|
||||
`ChatControllerNode` is a 4400+ line file. The exact line numbers below are anchors — search for the surrounding code to locate the precise insertion point if your local copy has drifted.
|
||||
|
||||
- [ ] **Step 1: Add private storage**
|
||||
|
||||
Open `submodules/TelegramUI/Sources/ChatControllerNode.swift`. Find a section of private view fields near the top of the class (after `class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate {` at line 171). A natural location is just after other private optional view fields. Search for `private var` near the start of the class body and insert this declaration alongside them — for example, immediately before `weak var node: ChatControllerNode?` is *not* applicable (that's on a different type at line 80); look in the body of `ChatControllerNode` itself.
|
||||
|
||||
Concretely: after the existing private property block (anywhere in the first 250 lines after class start), add:
|
||||
|
||||
```swift
|
||||
private var contextTransitionContainer: UIView?
|
||||
```
|
||||
|
||||
If you cannot find an obvious spot, place it directly above the `historyNode` property — `grep -n "self.historyNode = historyNode"` (line 166) is one anchor; the property declaration itself is nearby.
|
||||
|
||||
- [ ] **Step 2: Add the accessor**
|
||||
|
||||
Find the `frameForVisibleArea()` function at line 4038. Immediately before its `func frameForVisibleArea() -> CGRect {` line, insert:
|
||||
|
||||
```swift
|
||||
func ensureContextTransitionContainer() -> UIView {
|
||||
if let existing = self.contextTransitionContainer {
|
||||
existing.frame = self.frameForVisibleArea()
|
||||
return existing
|
||||
}
|
||||
let v = UIView()
|
||||
v.clipsToBounds = true
|
||||
v.isUserInteractionEnabled = false
|
||||
v.frame = self.frameForVisibleArea()
|
||||
self.view.insertSubview(v, aboveSubview: self.historyNodeContainer.view)
|
||||
self.contextTransitionContainer = v
|
||||
return v
|
||||
}
|
||||
```
|
||||
|
||||
The choice `aboveSubview: self.historyNodeContainer.view` puts the container at the same Z position as the chat history. If a future visual check shows the input panel or navigation chrome rendering UNDER the staged bubble (when it should render OVER), change to `belowSubview:` against the appropriate subview. The first visual smoke test in Task 8 will surface this.
|
||||
|
||||
- [ ] **Step 3: Wire frame update into the layout pass**
|
||||
|
||||
Find the end of `containerLayoutUpdated(...)` at line 3530–3533:
|
||||
|
||||
```swift
|
||||
self.derivedLayoutState = ChatControllerNodeDerivedLayoutState(inputContextPanelsFrame: inputContextPanelsFrame, inputContextPanelsOverMainPanelFrame: inputContextPanelsOverMainPanelFrame, inputNodeHeight: inputNodeHeightAndOverflow?.0, inputNodeAdditionalHeight: inputNodeHeightAndOverflow?.1, upperInputPositionBound: inputNodeHeightAndOverflow?.0 != nil ? self.upperInputPositionBound : nil)
|
||||
|
||||
//self.notifyTransitionCompletionListeners(transition: transition)
|
||||
}
|
||||
```
|
||||
|
||||
Just before the closing `}` of `containerLayoutUpdated`, insert:
|
||||
|
||||
```swift
|
||||
if let contextTransitionContainer = self.contextTransitionContainer {
|
||||
contextTransitionContainer.frame = self.frameForVisibleArea()
|
||||
}
|
||||
```
|
||||
|
||||
This makes the surface track the chat's visible-area rect on every layout pass, which is what gives Issue B (live source-side dynamics) its win.
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run the build verification command. Expected: succeeds. Nothing references `ensureContextTransitionContainer()` yet, so this is a no-op behaviorally.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramUI/Sources/ChatControllerNode.swift
|
||||
git commit -m "$(cat <<'EOF'
|
||||
ChatControllerNode: add contextTransitionContainer
|
||||
|
||||
Lazy UIView sized to frameForVisibleArea(), updated in
|
||||
containerLayoutUpdated, used as the sourceTransitionSurface for
|
||||
extracted-content context menus in subsequent commits.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Adopt `sourceTransitionSurface` in `ChatMessageContextExtractedContentSource`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Sources/ChatMessageContextControllerContentSource.swift:76-120` (the `takeView` and `putBack` of `ChatMessageContextExtractedContentSource`).
|
||||
|
||||
This is the regular bubble long-press path. After this task, that path uses the portal transition.
|
||||
|
||||
- [ ] **Step 1: Update `takeView()`**
|
||||
|
||||
Find lines 76–99 (the `func takeView()` body of `ChatMessageContextExtractedContentSource`). The relevant single line is line 90:
|
||||
|
||||
```swift
|
||||
result = ContextControllerTakeViewInfo(containingItem: .node(contentNode), contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
result = ContextControllerTakeViewInfo(containingItem: .node(contentNode), contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil), sourceTransitionSurface: chatNode.ensureContextTransitionContainer())
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `putBack()`**
|
||||
|
||||
Find lines 101–120 (`func putBack()`). The relevant single line is line 115:
|
||||
|
||||
```swift
|
||||
result = ContextControllerPutBackViewInfo(contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
result = ContextControllerPutBackViewInfo(contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil), sourceTransitionSurface: chatNode.ensureContextTransitionContainer())
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run the build verification command. Expected: succeeds.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramUI/Sources/ChatMessageContextControllerContentSource.swift
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Adopt sourceTransitionSurface in ChatMessage extraction source
|
||||
|
||||
ChatMessageContextExtractedContentSource (regular long-press menu)
|
||||
now passes the chat's contextTransitionContainer as the surface for
|
||||
take/putBack, enabling CCEPN's portal transition path.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Adopt `sourceTransitionSurface` in `ChatMessageReactionContextExtractedContentSource`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramUI/Sources/ChatMessageContextControllerContentSource.swift:452-490` (the reaction-context source's `takeView` and `putBack`).
|
||||
|
||||
This is the reaction context menu path on a bubble. After this task, that path also uses the portal transition.
|
||||
|
||||
- [ ] **Step 1: Update `takeView()`**
|
||||
|
||||
Find lines 452–470 (the `func takeView()` body of `ChatMessageReactionContextExtractedContentSource`). Line 466:
|
||||
|
||||
```swift
|
||||
result = ContextControllerTakeViewInfo(containingItem: .view(self.contentView), contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
result = ContextControllerTakeViewInfo(containingItem: .view(self.contentView), contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil), sourceTransitionSurface: chatNode.ensureContextTransitionContainer())
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `putBack()`**
|
||||
|
||||
Find lines 472–490. Line 486:
|
||||
|
||||
```swift
|
||||
result = ContextControllerPutBackViewInfo(contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil))
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```swift
|
||||
result = ContextControllerPutBackViewInfo(contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil), sourceTransitionSurface: chatNode.ensureContextTransitionContainer())
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run the build verification command. Expected: succeeds.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramUI/Sources/ChatMessageContextControllerContentSource.swift
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Adopt sourceTransitionSurface in ChatMessage reaction source
|
||||
|
||||
ChatMessageReactionContextExtractedContentSource (reaction context
|
||||
menu on a bubble) now passes the chat's contextTransitionContainer
|
||||
as the surface for take/putBack, enabling CCEPN's portal transition
|
||||
path.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Manual visual verification
|
||||
|
||||
**Files:** None modified. This task is hands-on testing.
|
||||
|
||||
The project has no unit tests; the design's claims (Issue A, Issue B fixes, fallback unchanged) need to be verified by exercising the app in a simulator. The four checks below correspond to the four spec verification items.
|
||||
|
||||
- [ ] **Step 1: Final build (no `--continueOnError`)**
|
||||
|
||||
```sh
|
||||
source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \
|
||||
--cacheDir ~/telegram-bazel-cache build \
|
||||
--configurationPath build-system/appstore-configuration.json \
|
||||
--gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \
|
||||
--gitCodesigningType development --gitCodesigningUseCurrent \
|
||||
--buildNumber=1 --configuration=debug_sim_arm64
|
||||
```
|
||||
|
||||
Expected: clean build (zero errors).
|
||||
|
||||
- [ ] **Step 2: Issue A — boundary clipping check**
|
||||
|
||||
Run the app on the iOS simulator. Open a chat with messages. Long-press a message that is positioned near the top of the chat (so its bubble overlaps the navigation bar) and another that is positioned near the bottom (overlapping the input panel). Compare the in/out animation against `master`:
|
||||
|
||||
- On `master`: the bubble visibly cuts at the chat content-area edge during the in/out transition (a straight horizontal line where shadow/rounded corner is sliced).
|
||||
- On this branch: the cut should follow the chat's actual ancestor mask shape — no straight-line stutter at the manual-clip rect edge. The shadow / rounded corner should clip exactly as the in-place bubble does at rest.
|
||||
|
||||
If the bubble appears NOT to be clipped at all (i.e., it visibly extends OVER the navigation bar / input panel during the animation), check Z-order — see Task 5 Step 2 note about `aboveSubview` vs `belowSubview`.
|
||||
|
||||
- [ ] **Step 3: Issue B — live source dynamics check**
|
||||
|
||||
Long-press a message. Mid-animation (during the spring's ~0.42s duration), induce a chat layout change — easiest method: tap the input field to bring up the keyboard right at the moment of long-press. Visually watch the bubble. The clipping should track the chat's live state, not a frozen snapshot taken at animation start.
|
||||
|
||||
- [ ] **Step 4: Reaction context check**
|
||||
|
||||
On a message in the same chat, open the reaction context menu (long-press to bring up the menu, then tap an empty area or whatever invokes the reactions). Confirm the in/out animation looks correct (same as Step 2's criteria).
|
||||
|
||||
- [ ] **Step 5: Fallback path regression check**
|
||||
|
||||
Exercise three sources that pass `sourceTransitionSurface = nil` (i.e., still on the today's-clipping path):
|
||||
|
||||
- A play-once voice or video message context (`ChatViewOnceMessageContextExtractedContentSource`).
|
||||
- A chat-message-navigation-button context (`ChatMessageNavigationButtonContextExtractedContentSource`).
|
||||
- The overlay audio player context (`OverlayAudioPlayerControllerNode`).
|
||||
- The chat search title accessory context (`ChatSearchTitleAccessoryPanelNode`).
|
||||
|
||||
Confirm each looks identical to `master` — no visible regressions.
|
||||
|
||||
- [ ] **Step 6: Take notes on any visible regressions**
|
||||
|
||||
If any regression is observed in steps 2–5, capture (a) the source it occurred in, (b) the exact visual symptom, (c) whether it reproduces on `master`. File these against this plan; do not silently land regressions.
|
||||
|
||||
---
|
||||
|
||||
## Limitation noted in the design
|
||||
|
||||
The portal path is gated on `presentationScale == 1.0`. When CCEPN detects ancestor scale on the source view (e.g., a chat shown inside a sheet with a container transform), it falls back to the clipping path. Lifting this restriction is future work — the animation deltas would need to be divided by `presentationScale` before being applied to the wrapper's layer (since the wrapper sits in scaled chat-tree-local coords, while deltas are computed in screen coords). None of the first adopters in this plan trigger that case.
|
||||
|
||||
## Files touched (summary)
|
||||
|
||||
- `submodules/ContextUI/Sources/ContextController.swift` — Task 1.
|
||||
- `submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift` — Tasks 2, 3, 4.
|
||||
- `submodules/TelegramUI/Sources/ChatControllerNode.swift` — Task 5.
|
||||
- `submodules/TelegramUI/Sources/ChatMessageContextControllerContentSource.swift` — Tasks 6, 7.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,295 +0,0 @@
|
|||
# Context controller — portal-view transition (replaces visible-area clipping)
|
||||
|
||||
Date: 2026-05-05
|
||||
Status: Design approved; pending implementation plan.
|
||||
|
||||
## Problem
|
||||
|
||||
`ContextControllerExtractedPresentationNode` (CCEPN) animates an extracted bubble in/out of the context menu by reparenting the source's `contentNode`/`contentView` into its own `offsetContainerNode` and then animating a separate `clippingNode` frame to interpolate between the chat's content area and the full screen. The clipping animation is what keeps the bubble from visibly bleeding past chat boundaries (navigation bar above, input panel below) during the transition.
|
||||
|
||||
Two problems with that approach:
|
||||
|
||||
1. **Boundary artifacts.** Bubbles with shadows or rounded corners get visibly cut at the chat content-area edges during the in/out transition — the manual clip rectangle doesn't honor the actual ancestor masking shape.
|
||||
2. **No live source-side dynamics.** The clip animation is computed once at animation start using `contentAreaInScreenSpace`. If the chat scrolls, the navigation-bar height changes, the input panel's keyboard rises, etc. mid-animation, the manually animated `clippingNode` frame goes stale; the visible clip drifts away from where the chat would actually clip the bubble.
|
||||
|
||||
Both classes of issue go away if the bubble is rendered through a primitive that already exists in the codebase: a `PortalSourceView` whose layer tree is mirrored to a `PortalView` at a different Z position. `ChatMessageTransitionNode` already uses this pattern for message-send animations. CCEPN should use the same primitive for extraction transitions.
|
||||
|
||||
## Solution overview
|
||||
|
||||
During the in/out transition, the source's `contentNode` is reparented into a `sourceTransitionSurface: UIView` that the source provides — a view in its own hierarchy where ancestor clipping (chat content area) applies naturally. CCEPN wraps it with a `PortalSourceView` and adds a `PortalView(matchPosition: true)` clone into `ItemContentNode.offsetContainerNode` (in the overlay tree). The clone tracks the wrapper's screen-space frame automatically.
|
||||
|
||||
CCEPN keeps applying the same spring/position/transform animation values it computes today, but retargets them onto the wrapper's layer instead of `contentNode.layer` / `offsetContainerNode.layer`. Because the wrapper is in the chat tree, its frame changes (and the chat's real-time re-layout) flow through the portal mirror to the visible clone. The manual `clippingNode.layer.animateFrame(...)` calls become unnecessary on this path.
|
||||
|
||||
After the in-animation completes, the `contentNode` is reparented out of the wrapper into `offsetContainerNode` (today's resting state). Portal staging is torn down. On dismiss, the inverse: contentNode is reparented from `offsetContainerNode` into the (fresh) `putBackInfo.sourceTransitionSurface`, the wrapper + clone are reconstructed, the dismiss animation runs against the wrapper's layer, and on completion contentNode is reparented home (its original chat-side parent).
|
||||
|
||||
The new path is opt-in. When `sourceTransitionSurface == nil`, CCEPN falls back to today's `clippingNode.layer.animateFrame(...)` behavior. Existing `ContextExtractedContentSource` adopters that don't pass a surface keep working unchanged.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope.** The `.extracted` `ContentSource` case in CCEPN. Two struct fields. One file-local helper inside CCEPN. Adoption in two of the four `ContextExtractedContentSource` types in `submodules/TelegramUI/Sources/ChatMessageContextControllerContentSource.swift` — `ChatMessageContextExtractedContentSource` (regular bubble long-press) and `ChatMessageReactionContextExtractedContentSource` (reaction context).
|
||||
|
||||
**Out of scope.**
|
||||
- `.reference`, `.location`, `.controller` source cases. They never reparent `contentNode` and don't have analogous boundary issues. Their `clippingNode.animateFrame(...)` calls (where present) stay unchanged.
|
||||
- `ChatViewOnceMessageContextExtractedContentSource`. Has a private `messageNodeCopy` and a custom dust-effect dismiss path that's structurally different.
|
||||
- `ChatMessageNavigationButtonContextExtractedContentSource`. Stays on the fallback path; not part of this change.
|
||||
- The `ChatMessageTransitionNode` portal usage itself. We adopt the same primitive; we do not refactor CMTN.
|
||||
- `maskView` semantics on `TakeViewInfo`/`PutBackInfo`. Unchanged; the portal path doesn't use it.
|
||||
|
||||
## Public-API changes — `submodules/ContextUI/Sources/ContextController.swift`
|
||||
|
||||
```swift
|
||||
public final class ContextControllerTakeViewInfo {
|
||||
public enum ContainingItem { case node(...) ; case view(...) }
|
||||
|
||||
public let containingItem: ContainingItem
|
||||
public let contentAreaInScreenSpace: CGRect
|
||||
public let maskView: UIView?
|
||||
public let sourceTransitionSurface: UIView? // NEW
|
||||
|
||||
public init(
|
||||
containingItem: ContainingItem,
|
||||
contentAreaInScreenSpace: CGRect,
|
||||
maskView: UIView? = nil,
|
||||
sourceTransitionSurface: UIView? = nil // NEW, defaults nil
|
||||
)
|
||||
}
|
||||
|
||||
public final class ContextControllerPutBackViewInfo {
|
||||
public let contentAreaInScreenSpace: CGRect
|
||||
public let maskView: UIView?
|
||||
public let sourceTransitionSurface: UIView? // NEW
|
||||
|
||||
public init(
|
||||
contentAreaInScreenSpace: CGRect,
|
||||
maskView: UIView? = nil,
|
||||
sourceTransitionSurface: UIView? = nil // NEW, defaults nil
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Source-side contract** when `sourceTransitionSurface != nil`:
|
||||
|
||||
- The view MUST be attached to a window-bearing tree at the moment the source returns it.
|
||||
- Its on-screen frame MUST already reflect current chat layout (the chat's existing layout pass owns this).
|
||||
- The source MUST NOT remove or move it before the corresponding transition completes (the in-animation for `TakeViewInfo`; the dismiss animation for `PutBackInfo`). CCEPN owns reparenting cleanup.
|
||||
- A single surface may be reused across `takeView` and `putBack` (and across multiple presentations). The chat owns one shared `transitionContainer: UIView` per `ChatControllerNode`.
|
||||
|
||||
The default `nil` is what makes this a strictly additive change for every existing adopter.
|
||||
|
||||
## `PortalTransitionStaging` — file-local helper inside CCEPN
|
||||
|
||||
A new private class in `submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift`. Single instance per `ItemContentNode`, non-nil only while a transition is in flight. Encapsulates the wrapper + clone lifecycle so CCEPN's animation code never observes a half-staged state.
|
||||
|
||||
```swift
|
||||
private final class PortalTransitionStaging {
|
||||
enum OriginalParent {
|
||||
case node(ASDisplayNode) // contentNode.supernode at staging time
|
||||
case view(UIView) // contentView.superview at staging time
|
||||
}
|
||||
|
||||
weak var surface: UIView?
|
||||
var wrapper: PortalSourceView?
|
||||
var clone: PortalView?
|
||||
var originalParentSnapshot: OriginalParent?
|
||||
|
||||
/// Sets up staging and returns the layer the caller should animate. Returns nil
|
||||
/// (and leaves staging untouched) if `PortalView(matchPosition:)` cannot be
|
||||
/// instantiated — caller falls back to the clipping path.
|
||||
///
|
||||
/// Side effects on success:
|
||||
/// - records `originalParentSnapshot` from the containingItem's current parent
|
||||
/// - constructs `wrapper = PortalSourceView()` and adds it to `surface`
|
||||
/// - reparents the containingItem's contentNode/contentView into `wrapper`
|
||||
/// - constructs `clone = PortalView(matchPosition: true)` and adds its view into `overlayHost`
|
||||
/// - calls `wrapper.addPortal(view: clone)`
|
||||
/// - sets `wrapper.frame` so the contentNode's screen-space rect equals
|
||||
/// `targetScreenRect` (via `surface.convert(targetScreenRect, from: nil)`)
|
||||
func enter(
|
||||
for containingItem: ContextControllerTakeViewInfo.ContainingItem,
|
||||
in surface: UIView,
|
||||
overlayHost: UIView,
|
||||
targetScreenRect: CGRect
|
||||
) -> CALayer?
|
||||
|
||||
enum SettleDestination {
|
||||
case offsetContainer(ASDisplayNode) // resting parent for animateIn end
|
||||
case original // restore from originalParentSnapshot
|
||||
}
|
||||
|
||||
/// Tears down staging. Reparents contentNode into the requested destination,
|
||||
/// removes the clone from its host, removes the wrapper from `surface`.
|
||||
/// All reparenting is synchronous; no CATransaction is needed because all
|
||||
/// animations in this codebase are explicit `animate*` calls — there are no
|
||||
/// implicit CALayer actions to suppress.
|
||||
func settle(into: SettleDestination, presentationScale: CGFloat)
|
||||
}
|
||||
```
|
||||
|
||||
**Invariant** (asserted on `enter`): `contentNode.portalStaging != nil` ⇒ contentNode parent is `staging.wrapper`.
|
||||
|
||||
If CCEPN re-enters animateIn or animateOut while staging is non-nil (defensive case — shouldn't happen at runtime), the new branch tears down the existing staging via `settle(into: .offsetContainer, ...)` first, then proceeds with its own `enter(...)`.
|
||||
|
||||
## CCEPN integration
|
||||
|
||||
Three edit points in `ContextControllerExtractedPresentationNode.swift`. The portal-mode branch lives next to the existing clipping branch in each animateIn / animateOut path.
|
||||
|
||||
### `ItemContentNode` — one new property
|
||||
|
||||
```swift
|
||||
private final class ItemContentNode: ASDisplayNode {
|
||||
// ...existing fields unchanged...
|
||||
var portalStaging: PortalTransitionStaging? // NEW
|
||||
}
|
||||
```
|
||||
|
||||
### `case .animateIn:` (currently lines ~1265–1474)
|
||||
|
||||
At the top of `if let contentNode = itemContentNode { ... }`:
|
||||
|
||||
```swift
|
||||
if let surface = takeInfo.sourceTransitionSurface {
|
||||
let staging = PortalTransitionStaging()
|
||||
// `targetScreenRect`: the bubble's resting end-of-animateIn rect in window coords
|
||||
// (i.e. its menu-extracted position) — derived from today's `currentContentLocalFrame`
|
||||
// (already in self.view coords) via `self.view.convert(_, to: nil)`.
|
||||
let targetScreenRect = self.view.convert(currentContentLocalFrame, to: nil)
|
||||
if let _ = staging.enter(
|
||||
for: contentNode.containingItem,
|
||||
in: surface,
|
||||
overlayHost: contentNode.offsetContainerNode.view,
|
||||
targetScreenRect: targetScreenRect
|
||||
) {
|
||||
contentNode.portalStaging = staging
|
||||
}
|
||||
}
|
||||
|
||||
let animatedLayer: CALayer = contentNode.portalStaging?.wrapper?.layer ?? contentNode.layer
|
||||
```
|
||||
|
||||
The existing `if let animateClippingFromContentAreaInScreenSpace = contentNode.animateClippingFromContentAreaInScreenSpace { ... self.clippingNode.layer.animateFrame(...) ... self.clippingNode.layer.animateBoundsOriginYAdditive(...) }` block is wrapped in `if contentNode.portalStaging == nil { ... }` — clip animation only fires on the fallback path.
|
||||
|
||||
The four spring `animateSpring(... keyPath: "position.x" / "position.y" ...)` calls today applied to `contentNode.layer` (and the reactionPreview spring twin) target `animatedLayer` instead. Same delta values (`animationInContentXDistance`, `animationInContentYDistance`), same spring params (`damping: springDamping`, `duration: 0.42`, etc.).
|
||||
|
||||
A completion handler is attached to the longest-lived spring (`position.y` on the contentNode/wrapper):
|
||||
|
||||
```swift
|
||||
{ [weak self] _ in
|
||||
guard let strongSelf = self, let contentNode = strongSelf.itemContentNode else { return }
|
||||
contentNode.portalStaging?.settle(
|
||||
into: .offsetContainer(contentNode.offsetContainerNode),
|
||||
presentationScale: contentNode.presentationScale
|
||||
)
|
||||
contentNode.portalStaging = nil
|
||||
}
|
||||
```
|
||||
|
||||
`presentationScale` re-application: while contentNode was inside the wrapper (in chat tree), the chat ancestor's scale was already in effect — the existing `CATransform3DMakeScale(detectedScale, detectedScale, 1.0)` compensation must NOT be applied during staging. After reparenting back into the unscaled `offsetContainerNode`, `settle(...)` reapplies it on `offsetContainerNode.layer.transform`. (Today's code applies it once at construction in lines ~647–649; on the portal path, it shifts to staging-settle time.)
|
||||
|
||||
### `case .animateOut:` (currently lines ~1487–1684)
|
||||
|
||||
Symmetric. After computing `putBackInfo`:
|
||||
|
||||
```swift
|
||||
if let putBackInfo, let surface = putBackInfo.sourceTransitionSurface {
|
||||
let staging = PortalTransitionStaging()
|
||||
// `targetScreenRect`: the bubble's resting end-of-animateOut rect in window coords
|
||||
// (i.e. its source position back in chat) — derived from `currentContentScreenFrame`
|
||||
// (in self.view coords, despite the name) via `self.view.convert(_, to: nil)`.
|
||||
let targetScreenRect = self.view.convert(currentContentScreenFrame, to: nil)
|
||||
if let _ = staging.enter(
|
||||
for: contentNode.containingItem,
|
||||
in: surface,
|
||||
overlayHost: contentNode.offsetContainerNode.view,
|
||||
targetScreenRect: targetScreenRect
|
||||
) {
|
||||
contentNode.portalStaging = staging
|
||||
}
|
||||
}
|
||||
|
||||
let animatedLayer: CALayer = contentNode.portalStaging?.wrapper?.layer ?? contentNode.offsetContainerNode.layer
|
||||
```
|
||||
|
||||
The two `self.clippingNode.layer.animateFrame(...) / animateBoundsOriginYAdditive(...)` blocks (in the `.location`, `.reference`, `.extracted` arms of the source switch) are guarded by `contentNode.portalStaging == nil`. Note: only the `.extracted` arm can have staging; `.location` and `.reference` never set `sourceTransitionSurface`, so this guard simplifies cleanly.
|
||||
|
||||
The dismiss spring on `contentNode.offsetContainerNode.layer` (`position.x` and `position.y`, lines ~1644 and ~1657) targets `animatedLayer` instead. The completion handler at ~1665 currently does:
|
||||
|
||||
```swift
|
||||
switch contentNode.containingItem {
|
||||
case let .node(containingNode): containingNode.addSubnode(containingNode.contentNode)
|
||||
case let .view(containingView): containingView.addSubview(containingView.contentView)
|
||||
}
|
||||
```
|
||||
|
||||
When staging is active, the manual reparenting is replaced by `contentNode.portalStaging?.settle(into: .original, presentationScale: contentNode.presentationScale)` followed by `contentNode.portalStaging = nil`. The flag-clearing (`isExtractedToContextPreview = false` etc.) and `restoreOverlayViews.forEach { $0() }` cleanup stay where they are.
|
||||
|
||||
### Unchanged
|
||||
|
||||
- `willUpdateIsExtractedToContextPreview?(...)` callbacks at lines 1462 and 1623 fire at the same point, with the same arguments. The chat does not need to know we're using a portal.
|
||||
- The overlay-views snapshot logic at lines 1476–1486 (animateIn) and 1596–1618 (animateOut) references `itemContentNode.supernode` / `itemContentNode.view` — `itemContentNode` itself stays in the scrollNode regardless of staging, so these blocks need no edits.
|
||||
- All other animations on `actionsContainerNode`, `additionalActionsStackNode`, `reactionContextNode`, etc. are untouched.
|
||||
|
||||
## First adopter — chat message extraction
|
||||
|
||||
Two of the three sources in `submodules/TelegramUI/Sources/ChatMessageContextControllerContentSource.swift`:
|
||||
|
||||
- `ChatMessageContextExtractedContentSource` (regular bubble long-press menu)
|
||||
- `ChatMessageReactionContextExtractedContentSource` (reaction context)
|
||||
|
||||
Both already return `chatNode.convert(chatNode.frameForVisibleArea(), to: nil)` as their `contentAreaInScreenSpace`. Each gains one new field on the `TakeViewInfo`/`PutBackInfo` constructor: `sourceTransitionSurface: chatNode.ensureContextTransitionContainer()`.
|
||||
|
||||
`ChatMessageNavigationButtonContextExtractedContentSource` and `ChatViewOnceMessageContextExtractedContentSource` are NOT adopted in this change; they continue to pass `sourceTransitionSurface = nil` (i.e. the default).
|
||||
|
||||
### `ChatControllerNode.contextTransitionContainer`
|
||||
|
||||
A single lazy view per chat node, owned by `ChatControllerNode`:
|
||||
|
||||
```swift
|
||||
// in ChatControllerNode (private):
|
||||
private var contextTransitionContainer: UIView?
|
||||
|
||||
func ensureContextTransitionContainer() -> UIView {
|
||||
if let existing = self.contextTransitionContainer { return existing }
|
||||
let v = UIView()
|
||||
v.clipsToBounds = true
|
||||
v.isUserInteractionEnabled = false
|
||||
self.view.insertSubview(v, aboveSubview: self.historyNode.view)
|
||||
self.contextTransitionContainer = v
|
||||
return v
|
||||
}
|
||||
```
|
||||
|
||||
**Frame management.** Sized to `frameForVisibleArea()` and updated whenever the chat's layout changes. The chat's existing layout pass already updates that rect; the container's frame mirrors it. This is what gives us issue B's win: live re-layout flows through the surface, into the wrapped contentNode, into the portal mirror.
|
||||
|
||||
**Z-order check.** Inserting above `historyNode.view` matches the bubble's natural Z. When implementing, verify the input panel and navigation chrome render *over* the surface (so an extracted bubble visually tucks under them just as the in-place bubble does). If a chrome element lands below the surface in the live z-order, adjust `insertSubview(_, belowSubview:)` accordingly. This is a per-implementation check, not an open design question.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **`PortalView(matchPosition:)` returns nil.** `_UIPortalView` is private API; if it can't be instantiated, `staging.enter(...)` returns nil. CCEPN treats that exactly like `sourceTransitionSurface == nil` and takes the today's-clipping path. No half-staged state.
|
||||
- **Surface deallocates mid-transition.** `surface` is captured weakly. If it goes away while the spring is running, the portal mirror renders nothing (UIKit handles a missing portal source). The spring completion still fires from the wrapper's layer (still alive in the staging instance) and `settle(...)` walks defensive — if `wrapper.superview == nil` it reparents contentNode into the destination and bails on portal teardown. Visual is degraded for the remainder of the transition; no leaks or crashes.
|
||||
- **Animation interrupts itself.** If CCEPN is asked to `.animateOut` while `.animateIn` staging is still live (menu dismissed during open), the animateOut branch first calls `settle(into: .offsetContainer, ...)` on the existing staging, clears `portalStaging`, then performs its own `enter(...)`. The "staging non-nil ⇒ contentNode is in wrapper" invariant from above makes this safe.
|
||||
- **`presentationScale != 1.0`.** Discussed above. The existing scale compensation (lines 642–650) is applied at staging-settle time on the portal path, not at construction time.
|
||||
- **Chat scrolls during in-animation.** This is the issue-B win: while contentNode is in the wrapper inside `transitionContainer` (whose frame tracks `frameForVisibleArea()`), chat re-layout naturally re-clips the wrapper's content. The portal mirror reflects the live-clipped state.
|
||||
|
||||
## Verification
|
||||
|
||||
No unit tests exist in this project. Verification is manual.
|
||||
|
||||
1. **Build.** Full project build per CLAUDE.md, with `--continueOnError` to surface a multi-file failure set in one pass.
|
||||
2. **Issue A — boundary clipping.** Long-press a message at the top of the chat (bubble overlapping nav bar) and at the bottom (overlapping input panel). On `master` the bubble visibly cuts at chat content-area edges during the in/out transition. On this branch the cut should follow the chat's actual ancestor mask shape (no straight-line stutter at the manual-clip rect edge).
|
||||
3. **Issue B — live source dynamics.** Long-press a message and induce a chat layout change mid-animation (e.g. bring up the keyboard, scroll). The visible bubble's clipping should track the chat's live state, not a stale snapshot.
|
||||
4. **Reaction context.** Open the reaction context on a message; same in/out paths exercised.
|
||||
5. **Fallback path.** Long-press in a non-bubble extracted source (the search title accessory panel via `ChatSearchTitleAccessoryPanelNode`, the overlay audio player via `OverlayAudioPlayerControllerNode`, and the navigation button via `ChatMessageNavigationButtonContextExtractedContentSource`). All three pass `sourceTransitionSurface = nil` and should behave identically to today.
|
||||
6. **`ChatViewOnceMessageContextExtractedContentSource`.** Open a play-once voice/video message context. Stays on fallback; dust-effect dismiss path unchanged.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `submodules/ContextUI/Sources/ContextController.swift` — two struct field additions.
|
||||
- `submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift` — `PortalTransitionStaging` helper, three integration points, one new field on `ItemContentNode`.
|
||||
- `submodules/TelegramUI/Sources/ChatMessageContextControllerContentSource.swift` — `sourceTransitionSurface` argument added at four call sites (two `takeView`, two `putBack`, across two source classes).
|
||||
- `submodules/TelegramUI/Sources/ChatControllerNode.swift` — `contextTransitionContainer` storage, `ensureContextTransitionContainer()`, frame updates within the existing layout pass.
|
||||
|
||||
## Risks
|
||||
|
||||
- The `_UIPortalView` private-API path is already in production use via `ChatMessageTransitionNode`, so no net new private-API surface.
|
||||
- The biggest risk is a regression on the fallback path — i.e. accidentally changing behavior for sources that don't pass a `sourceTransitionSurface`. The design preserves today's clip-animation block verbatim, gated only on `portalStaging == nil`. Reviewers should confirm every existing animation call site still fires unchanged when staging is nil.
|
||||
- Z-order between `transitionContainer` and the chat's chrome elements (input panel, nav, overlay audio bar) needs a per-element visual check at implementation time. Listed under "First adopter" above.
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
# InstantPage list checkboxes (task-list style) — design
|
||||
|
||||
**Date:** 2026-05-27
|
||||
**Status:** Approved for planning
|
||||
|
||||
## Summary
|
||||
|
||||
`InstantPageListItem` gains first-class **task-list checkbox** support — the `- [ ]` / `- [x]`
|
||||
markdown construct — covering parsing, local serialization (Postbox + FlatBuffers),
|
||||
API transmission, display, and the edit round-trip in rich-data message bubbles.
|
||||
|
||||
A prior prototype already wired most of this through a **sentinel string stuffed into the
|
||||
ordered-list `num` field** (`"\u{001f}tg-md-task:checked"` / `:unchecked`). This design
|
||||
**replaces that sentinel with a first-class `checked: Bool?`** on the list item, orthogonal
|
||||
to `num`, because:
|
||||
|
||||
1. The Telegram API represents the checkbox as **flags independent of the list number** on
|
||||
all four list-item types (see "API facts" below). The sentinel-in-`num` cannot represent
|
||||
an item that is *both* numbered *and* a checkbox, which the API allows.
|
||||
2. The sentinel never survived the server round-trip — `apiInputBlock` hardcoded `flags: 0`
|
||||
and dropped it — so checkboxes silently reverted to bullets the moment a message was
|
||||
confirmed, even for the sender.
|
||||
|
||||
The first-class field fixes transmission (real API flags), makes the local model faithful to
|
||||
the API, and removes the sentinel hack from three files.
|
||||
|
||||
## API facts (verified against generated `Api.*` source, not inferred)
|
||||
|
||||
All four list-item constructors carry `checkbox` and `checked` as conditional-`true` flags;
|
||||
`checkbox` = bit 0, `checked` = bit 1. They are **independent** of the ordered-list number.
|
||||
|
||||
```
|
||||
pageListItemText#2f58683c flags:# checkbox:flags.0?true checked:flags.1?true text:RichText
|
||||
pageListItemBlocks#63ca67aa flags:# checkbox:flags.0?true checked:flags.1?true blocks:Vector<PageBlock>
|
||||
pageListOrderedItemText#cd3ea036 flags:# checkbox:flags.0?true checked:flags.1?true num:string text:RichText
|
||||
pageListOrderedItemBlocks#422931d4 flags:# checkbox:flags.0?true checked:flags.1?true num:string blocks:Vector<PageBlock>
|
||||
```
|
||||
|
||||
| iOS `Api.` type | Used for | checkbox | checked | other bits |
|
||||
|---|---|---|---|---|
|
||||
| `Api.PageListItem` (`.pageListItemText` / `.Blocks`) | send + receive (unordered) | flags.0 | flags.1 | — |
|
||||
| `Api.PageListOrderedItem` | receive (ordered) | flags.0 | flags.1 | `num` (unconditional string) |
|
||||
| `Api.InputPageListOrderedItem` | send (ordered) | flags.0 | flags.1 | value=flags.2, type=flags.3 |
|
||||
|
||||
The generated structs expose only `flags: Int32`; checkbox/checked are read/written by
|
||||
masking bits 0 and 1. The current conversion code ignores them.
|
||||
|
||||
## Data model
|
||||
|
||||
`InstantPageListItem` (`SyncCore_InstantPage.swift`) gains a third associated value:
|
||||
|
||||
```swift
|
||||
public indirect enum InstantPageListItem: PostboxCoding, Equatable {
|
||||
case unknown
|
||||
case text(RichText, String?, Bool?) // (text, num, checked)
|
||||
case blocks([InstantPageBlock], String?, Bool?) // (blocks, num, checked)
|
||||
}
|
||||
```
|
||||
|
||||
`checked` semantics (orthogonal to `num`):
|
||||
|
||||
- `nil` — not a checkbox item (ordinary bullet / numbered item)
|
||||
- `false` — checkbox, unchecked
|
||||
- `true` — checkbox, checked
|
||||
|
||||
A new accessor mirrors the existing `var num`:
|
||||
|
||||
```swift
|
||||
public extension InstantPageListItem {
|
||||
var checked: Bool? { … } // returns the third value for .text/.blocks, nil for .unknown
|
||||
}
|
||||
```
|
||||
|
||||
The `var num` accessor is unchanged.
|
||||
|
||||
### Why an associated value (not a new enum case)
|
||||
|
||||
A checkbox item is still a text/blocks item; the only new dimension is the checkbox state.
|
||||
An associated value keeps every existing `switch` at three cases (one extra bound variable
|
||||
each) rather than forcing a fourth case into every exhaustive switch. The change is
|
||||
source-breaking by design — the compiler enumerates every construction/destructure site, so
|
||||
a full build with `--continueOnError` is the completeness check.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Enum + local serialization — `SyncCore_InstantPage.swift`
|
||||
|
||||
- Add the `Bool?` associated value to `.text` / `.blocks`; add the `checked` accessor.
|
||||
- **Postbox**: encode the checked tri-state under a new key (`"ck"`) using the **same
|
||||
`0=nil, 1=unchecked, 2=checked`** mapping as FlatBuffers (below); encode only when
|
||||
`checked != nil`, and decode with `decodeInt32ForKey("ck", orElse: 0)` → `0→nil, 1→false,
|
||||
2→true`. Old stored data lacks the key → `orElse: 0` → `nil` (backward compatible).
|
||||
- **FlatBuffers**: add `checkState:int32 (id: 2)` to both `InstantPageListItem_Text` and
|
||||
`InstantPageListItem_Blocks` in `InstantPageBlock.fbs` (tri-state: `0=nil, 1=unchecked,
|
||||
2=checked`). int32 defaults to 0, so absent → `nil` (backward compatible) — this mirrors
|
||||
the existing `alignment:int32` / `level:int32` pattern and sidesteps optional-scalar-bool
|
||||
ambiguity. Edit the `.fbs` (source of truth; the Bazel `flatc` genrule regenerates the
|
||||
Swift); update the hand-written codec in `SyncCore_InstantPage.swift` to read/write
|
||||
`checkState`. Encode only when non-nil (keeps the wire compact).
|
||||
- **Equatable**: compare the new value in the `.text` / `.blocks` arms.
|
||||
|
||||
### 2. API transmission — `ApiUtils/InstantPage.swift`
|
||||
|
||||
- **Receive** `init(apiListItem:)` (unordered) and `init(apiListOrderedItem:)` (ordered):
|
||||
read `flags & (1<<0)` (checkbox present) and `flags & (1<<1)` (checked). Set
|
||||
`checked = (flags & 1<<0) != 0 ? ((flags & 1<<1) != 0) : nil`. Keep `num` as today
|
||||
(nil for unordered, the API `num` string for ordered).
|
||||
- **Send** `apiInputPageListItem()` (→ `Api.PageListItem`) and `apiInputPageOrderedListItem()`
|
||||
(→ `Api.InputPageListOrderedItem`): when `checked != nil`, set `flags |= (1<<0)` and add
|
||||
`(1<<1)` when `checked == true`. Preserve the existing `value`/`type` bit handling on the
|
||||
ordered input.
|
||||
- The `var num` accessor is unaffected; add `checked` reads where the items are built.
|
||||
- **Bonus:** incoming Telegra.ph / cross-client task lists now render as checkboxes.
|
||||
|
||||
### 3. Real V2 checkbox artwork — `InstantPageRenderer.swift` + `InstantPageV2Layout.swift`
|
||||
|
||||
The V2 renderer (the live path for rich-message bubbles) currently draws a **placeholder**
|
||||
square. Replace it with the real artwork:
|
||||
|
||||
- In `InstantPageV2ListMarkerView.rebuildContents()`'s `.checklist` case, host a
|
||||
`CheckNode(theme:, content: .check(isRectangle: true))` (add its `.view` as a subview),
|
||||
themed exactly like the V1 `InstantPageChecklistMarkerNode` (`CheckNodeTheme` from
|
||||
`panelAccentColor` / `pageBackgroundColor` / `controlColor`), and call
|
||||
`setSelected(checked, animated: false)`. `import CheckNode` (the module is already a
|
||||
`BUILD` dep of InstantPageUI).
|
||||
- The marker view's `init` does not receive the theme, only `update(item:theme:)` does. Carry
|
||||
the three required colors on the `.checklist` marker payload (the layout has
|
||||
`context.theme`) so the view stays theme-agnostic and the colors are present at init time.
|
||||
- Layout detection switches from `instantPageTaskListMarkerState(item.num)` to `item.checked`;
|
||||
the marker kind becomes `.checklist(checked: item.checked == true)` whenever
|
||||
`item.checked != nil`. Remove the V1/V2 sentinel constants and `instantPageTaskListMarkerState`.
|
||||
|
||||
### 4. Reverse markdown (edit path) — `InstantPageToMarkdown.swift`
|
||||
|
||||
`markdownList` currently ignores the marker, so editing a rich message downgrades checkboxes
|
||||
to bullets. Read `item.checked` and emit `- [ ] ` / `- [x] ` (task markers are an unordered
|
||||
construct). Re-classification on save re-parses it back to a checkbox → API flags.
|
||||
|
||||
### 5. preview-text — `InstantPagePreviewText.swift`
|
||||
|
||||
`previewText()` currently prepends `num` blindly, which under the old sentinel leaked
|
||||
`"\u{001f}tg-md-task:checked. text"` into notifications/reply panels. With the first-class
|
||||
field, `num` is once again only a real number, so the leak is gone; additionally render a
|
||||
checkbox glyph (`"☑︎ "` / `"☐ "`) before the text when `checked != nil`.
|
||||
|
||||
### 6. Markdown forward parser — `BrowserMarkdown.swift`
|
||||
|
||||
`markdownListItems` keeps the existing `[ ]`/`[x]`/`[X]` detection
|
||||
(`markdownTaskListMarker` / `markdownStrippingTaskListMarker` / `markdownApplyTaskListMarker`)
|
||||
but routes the result into the new `checked` field instead of the `num` sentinel:
|
||||
|
||||
- Unordered task item → `.text(text, nil, state)` (number stays nil).
|
||||
- Ordered task item (`1. [ ] x`) → `.text(text, "\(ordinal)", state)` — number **and** checkbox
|
||||
now coexist (previously the sentinel destroyed the number).
|
||||
|
||||
Remove the markdown sentinel constants (`markdownTaskListUncheckedNumber` /
|
||||
`CheckedNumber`) and `markdownTaskListNumber`. Update the `.blocks(...)` / `.text(...)`
|
||||
construction arms and the `validate(listItem:)` destructure to the 3-value shape.
|
||||
|
||||
### 7. Other construction/destructure sites (mechanical, compiler-enforced)
|
||||
|
||||
The enum change touches these files' `.text(...)` / `.blocks(...)` sites; all are in
|
||||
list-handling modules (no external consumer constructs `InstantPageListItem`):
|
||||
|
||||
- `SyncCore_InstantPage.swift` — decodeListItems, Postbox decode/encode, `==`, FlatBuffers codec
|
||||
- `ApiUtils/InstantPage.swift` — `num`/`checked` accessors, `init(apiListItem:)`,
|
||||
`init(apiListOrderedItem:)`, `apiInputPageListItem()`, `apiInputPageOrderedListItem()`
|
||||
- `BrowserReadability.swift` — `.blocks(blocks, nil)` / `.text(...)` builders → add `nil`
|
||||
- `InstantPageV2Layout.swift` / `InstantPageLayout.swift` — `layoutList` empty-blocks
|
||||
substitution (`.text(.plain(" "), num)` must carry `checked` through) and the
|
||||
`.text`/`.blocks` destructures
|
||||
- `InstantPagePreviewText.swift`, `InstantPageToMarkdown.swift` — destructures (see 4 / 5)
|
||||
|
||||
## Round-trip contract
|
||||
|
||||
```
|
||||
compose "- [ ] x"
|
||||
→ markdown parse → .text("x", nil, false)
|
||||
→ render checkbox (V2 CheckNode)
|
||||
→ send: Api.PageListItem.pageListItemText(flags: 1<<0, text:"x")
|
||||
→ server echo → receive: flags bit0 set, bit1 clear → .text("x", nil, false)
|
||||
→ render checkbox on SENDER and RECIPIENT (and native checkbox on other clients)
|
||||
edit
|
||||
→ reverse markdown reads checked=false → "- [ ] x"
|
||||
→ re-classify → .text("x", nil, false) (identical)
|
||||
```
|
||||
|
||||
Postbox/FlatBuffers carry `checked` locally across app restarts; the API flags carry it
|
||||
across the server.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Interactivity (tapping a rendered checkbox to toggle it). These are display-only, matching
|
||||
the rest of InstantPage rendering.
|
||||
- Inline images/videos inside list items (unchanged; pre-existing behavior).
|
||||
|
||||
## Testing / verification
|
||||
|
||||
No unit tests exist in this project. Verify manually after a full build:
|
||||
|
||||
1. **Build** the full `Telegram/Telegram` target (`--continueOnError`) — the compile-breaking
|
||||
enum change surfaces any missed `.text`/`.blocks` site.
|
||||
2. **Send round-trip:** send a rich message `- [ ] a` / `- [x] b` to Saved Messages; confirm
|
||||
the checkboxes persist **after the send confirms** (not just in the pre-send preview), and
|
||||
that checked/unchecked states are correct.
|
||||
3. **Edit round-trip:** edit that message; confirm the editor repopulates `- [ ] a` / `- [x] b`
|
||||
and saving preserves the states.
|
||||
4. **Preview surfaces:** confirm the chat list / notification / reply panel previews show a
|
||||
checkbox glyph, never the raw sentinel or `num`.
|
||||
5. **Regression:** ordinary ordered (`1.`) and unordered (`-`) lists still render with correct
|
||||
numbers/bullets.
|
||||
|
||||
## Risks
|
||||
|
||||
- **FlatBuffers regen:** the checked-in `*_generated.swift` is stale; the build regenerates
|
||||
from the `.fbs`. Follow the flatc casing rules already documented (edit `.fbs`, not the
|
||||
generated Swift).
|
||||
- **Source-breaking enum change:** mitigated by the compiler — every site must be updated to
|
||||
build. The full-build step is the completeness gate.
|
||||
- **Tri-state encoding:** both Postbox (`"ck"` int) and FlatBuffers (`checkState:int32`) treat
|
||||
absent/0 as `nil`, so pre-existing stored pages decode unchanged.
|
||||
|
|
@ -1,514 +0,0 @@
|
|||
# InstantPage BlockQuote — nested blocks payload
|
||||
|
||||
## Context
|
||||
|
||||
Telegram's instant-view API has two parallel constructors for the block-quote
|
||||
page block:
|
||||
|
||||
- `pageBlockBlockquote(text: RichText, caption: RichText)` — legacy
|
||||
text-only form.
|
||||
- `pageBlockBlockquoteBlocks(blocks: [PageBlock], caption: RichText)` — new
|
||||
form whose body is a sequence of nested page blocks (paragraphs, lists,
|
||||
headings, code, even nested quotes).
|
||||
|
||||
The Swift model currently represents only the legacy form
|
||||
(`InstantPageBlock.blockQuote(text: RichText, caption: RichText)`) and the API
|
||||
parser has a `//TODO` placeholder for the new constructor
|
||||
(`submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift:251`). This spec
|
||||
upgrades parsing, serialization (Postbox + FlatBuffers), encoding back to the
|
||||
wire, and rendering (V1 + V2) to support nested blocks throughout.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Single enum case, blocks-only payload.** Replace
|
||||
`(text: RichText, caption: RichText)` with
|
||||
`(blocks: [InstantPageBlock], caption: RichText)`. The legacy text-only
|
||||
inbound shape is lifted into the new shape at parse time by wrapping the
|
||||
RichText in a synthetic `.paragraph(text)`. Downstream code branches on
|
||||
zero shapes.
|
||||
- **Full block-level layout in both renderers.** V2
|
||||
(`InstantPageV2Layout.swift`) and V1 (`InstantPageLayout.swift`) recurse
|
||||
into the child blocks. No surface where nested-block quotes render
|
||||
degraded.
|
||||
- **Outbound: legacy when shape allows.** `apiInputBlock()` emits the legacy
|
||||
`pageBlockBlockquote` constructor when blocks is `[.paragraph(text)]` (or
|
||||
empty), and the new `pageBlockBlockquoteBlocks` constructor otherwise.
|
||||
Keeps the common chat case on the wire constructor older client recipients
|
||||
already understand.
|
||||
- **`.pullQuote` is unchanged.** The TL API has no `pullQuoteBlocks`
|
||||
constructor; the `.pullQuote(text: RichText, caption: RichText)` case keeps
|
||||
its existing shape, parser, serializer, and renderer.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Enum shape
|
||||
|
||||
`SyncCore_InstantPage.swift:73`:
|
||||
|
||||
```swift
|
||||
case blockQuote(blocks: [InstantPageBlock], caption: RichText)
|
||||
```
|
||||
|
||||
The enum is already declared `indirect`, so a `[InstantPageBlock]` payload
|
||||
needs no further annotation.
|
||||
|
||||
### API parsing (`InstantPage.swift:247–252`)
|
||||
|
||||
```swift
|
||||
case let .pageBlockBlockquote(data):
|
||||
self = .blockQuote(
|
||||
blocks: [.paragraph(RichText(apiText: data.text))],
|
||||
caption: RichText(apiText: data.caption)
|
||||
)
|
||||
case let .pageBlockBlockquoteBlocks(data):
|
||||
self = .blockQuote(
|
||||
blocks: data.blocks.map { InstantPageBlock(apiBlock: $0) },
|
||||
caption: RichText(apiText: data.caption)
|
||||
)
|
||||
```
|
||||
|
||||
### API encoding (`InstantPage.swift:376`)
|
||||
|
||||
```swift
|
||||
case let .blockQuote(blocks, caption):
|
||||
if blocks.count == 1, case let .paragraph(text) = blocks[0] {
|
||||
return .pageBlockBlockquote(.init(
|
||||
text: text.apiRichText(), caption: caption.apiRichText()
|
||||
))
|
||||
}
|
||||
if blocks.isEmpty {
|
||||
return .pageBlockBlockquote(.init(
|
||||
text: RichText.empty.apiRichText(),
|
||||
caption: caption.apiRichText()
|
||||
))
|
||||
}
|
||||
return .pageBlockBlockquoteBlocks(.init(
|
||||
blocks: blocks.compactMap { $0.apiInputBlock() },
|
||||
caption: caption.apiRichText()
|
||||
))
|
||||
```
|
||||
|
||||
### Postbox coding
|
||||
|
||||
`SyncCore_InstantPage.swift:228` (encoder): write `"b"` (object array of
|
||||
blocks) and `"c"` (caption). Stop writing `"t"`.
|
||||
|
||||
`SyncCore_InstantPage.swift:123` (decoder): mirror the `decodeListItems`
|
||||
pattern at line 39 — if the legacy `"t"` key is present, lift it into a
|
||||
single-paragraph blocks array; otherwise decode the new `"b"` array.
|
||||
|
||||
```swift
|
||||
case InstantPageBlockType.blockQuote.rawValue:
|
||||
let caption = decoder.decodeObjectForKey("c", decoder: {
|
||||
RichText(decoder: $0)
|
||||
}) as! RichText
|
||||
if let legacyText = decoder.decodeObjectForKey("t", decoder: {
|
||||
RichText(decoder: $0)
|
||||
}) as? RichText {
|
||||
self = .blockQuote(blocks: [.paragraph(legacyText)], caption: caption)
|
||||
} else {
|
||||
let blocks: [InstantPageBlock] =
|
||||
decoder.decodeObjectArrayWithDecoderForKey("b")
|
||||
self = .blockQuote(blocks: blocks, caption: caption)
|
||||
}
|
||||
```
|
||||
|
||||
Old stored cached pages (with `"t"` set) decode unchanged; new writes only use
|
||||
`"b"`.
|
||||
|
||||
### FlatBuffers
|
||||
|
||||
`InstantPageBlock.fbs:93`:
|
||||
|
||||
```fbs
|
||||
table InstantPageBlock_BlockQuote {
|
||||
text:RichText (id: 0); // (required) dropped — legacy only
|
||||
caption:RichText (id: 1, required);
|
||||
blocks:[InstantPageBlock] (id: 2); // new
|
||||
}
|
||||
```
|
||||
|
||||
Dropping `(required)` from an existing field and appending a new field at a
|
||||
higher id are both schema-evolution-safe per FlatBuffers rules. Per the
|
||||
`flatbuffers-codegen` memory the `.fbs` is the source of truth and Bazel
|
||||
regenerates `*_generated.swift`; the checked-in copies in `Sources/` are
|
||||
stale and should NOT be hand-edited.
|
||||
|
||||
Codec decoder (`SyncCore_InstantPage.swift:620`):
|
||||
|
||||
```swift
|
||||
case .instantpageblockBlockquote:
|
||||
guard let value = flatBuffersObject.value(
|
||||
type: TelegramCore_InstantPageBlock_BlockQuote.self
|
||||
) else {
|
||||
throw FlatBuffersError.missingRequiredField()
|
||||
}
|
||||
let caption = try RichText(flatBuffersObject: value.caption)
|
||||
if value.blocksCount > 0 {
|
||||
let blocks = try (0 ..< value.blocksCount).map {
|
||||
try InstantPageBlock(flatBuffersObject: value.blocks(at: $0)!)
|
||||
}
|
||||
self = .blockQuote(blocks: blocks, caption: caption)
|
||||
} else if let legacyText = value.text {
|
||||
self = .blockQuote(
|
||||
blocks: [.paragraph(try RichText(flatBuffersObject: legacyText))],
|
||||
caption: caption
|
||||
)
|
||||
} else {
|
||||
self = .blockQuote(blocks: [], caption: caption)
|
||||
}
|
||||
```
|
||||
|
||||
Codec encoder (`SyncCore_InstantPage.swift:799`): write `blocks` + `caption`;
|
||||
omit `text`.
|
||||
|
||||
### Equality (`SyncCore_InstantPage.swift:448`)
|
||||
|
||||
```swift
|
||||
case let .blockQuote(lhsBlocks, lhsCaption):
|
||||
if case let .blockQuote(rhsBlocks, rhsCaption) = rhs,
|
||||
lhsBlocks == rhsBlocks, lhsCaption == rhsCaption {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
Mirrors the `.collage`/`.slideshow` pattern (which already do
|
||||
`[InstantPageBlock]` equality).
|
||||
|
||||
### V2 renderer
|
||||
|
||||
`InstantPageV2Layout.swift:597` keeps dispatching to `layoutBlockQuote` for
|
||||
the blockquote arm. Split the existing function:
|
||||
|
||||
- `layoutBlockQuote(blocks:caption:...)` — new, for `.blockQuote` only.
|
||||
- `layoutPullQuote(text:caption:...)` — for `.pullQuote` only; same behavior
|
||||
as today's `isPull = true` branch.
|
||||
|
||||
`layoutBlockQuote` strategy:
|
||||
|
||||
```swift
|
||||
private func layoutBlockQuote(
|
||||
blocks: [InstantPageBlock],
|
||||
caption: RichText,
|
||||
boundingWidth: CGFloat,
|
||||
horizontalInset: CGFloat,
|
||||
context: inout LayoutContext
|
||||
) -> [InstantPageV2LaidOutItem] {
|
||||
let verticalInset: CGFloat = 4.0
|
||||
let lineInset: CGFloat = 20.0
|
||||
let barWidth: CGFloat = 3.0
|
||||
|
||||
var result: [InstantPageV2LaidOutItem] = []
|
||||
var contentHeight: CGFloat = verticalInset
|
||||
|
||||
let innerBoundingWidth = boundingWidth - horizontalInset * 2.0 - lineInset
|
||||
let innerHorizontalInset = horizontalInset + lineInset
|
||||
|
||||
if blocks.count == 1, case let .paragraph(text) = blocks[0] {
|
||||
// Fast path: preserve today's italicized body styling for the
|
||||
// legacy single-paragraph shape (unchanged from current code).
|
||||
} else {
|
||||
var previousItems: [InstantPageV2LaidOutItem] = []
|
||||
for (i, child) in blocks.enumerated() {
|
||||
var childItems = layoutBlock(
|
||||
child,
|
||||
boundingWidth: innerBoundingWidth,
|
||||
horizontalInset: innerHorizontalInset,
|
||||
isCover: false,
|
||||
previousItems: previousItems,
|
||||
isLast: i == blocks.count - 1,
|
||||
context: &context
|
||||
)
|
||||
// Stack vertically: offset Y by current contentHeight.
|
||||
// X is already correct (children laid out at innerHorizontalInset).
|
||||
for j in childItems.indices {
|
||||
childItems[j] = childItems[j].translatedY(by: contentHeight)
|
||||
}
|
||||
let childMaxY = childItems.map { $0.frame.maxY }.max() ?? contentHeight
|
||||
contentHeight = max(contentHeight, childMaxY)
|
||||
previousItems.append(contentsOf: childItems)
|
||||
result.append(contentsOf: childItems)
|
||||
}
|
||||
}
|
||||
|
||||
// Caption (existing branch, unchanged).
|
||||
if case .empty = caption { /* nothing */ } else {
|
||||
contentHeight += 14.0
|
||||
// ...existing caption layout, using innerHorizontalInset...
|
||||
}
|
||||
contentHeight += verticalInset
|
||||
|
||||
// Vertical bar on the leading edge (existing behavior).
|
||||
let bar = InstantPageV2BarItem(
|
||||
frame: CGRect(x: horizontalInset, y: 0.0,
|
||||
width: barWidth, height: contentHeight),
|
||||
color: context.theme.textCategories.paragraph.color,
|
||||
cornerRadius: barWidth / 2.0
|
||||
)
|
||||
result.append(.blockQuoteBar(bar))
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- **Translation helper.** `InstantPageV2LaidOutItem` covers many variants
|
||||
(text/shape/list-marker/bar/checklist/media/...). Add a small extension
|
||||
method `translatedY(by:)` that returns a copy with the item's frame's
|
||||
`origin.y` offset. Implemented as a switch over the case set, mirroring
|
||||
any existing per-variant frame-edit helper in the file.
|
||||
- **`previousItems` for spacing.** V2's per-block layout functions consume
|
||||
`previousItems` to compute inter-block gaps. Passing a fresh array per
|
||||
recursion gives correct in-quote spacing without affecting the outer
|
||||
page's `previousItems`.
|
||||
- **Styling of nested children.** Direct children render with their normal
|
||||
category styling (heading stays a heading, list stays a list). Italics are
|
||||
applied only by the single-paragraph fast path — consistent with the
|
||||
visual fidelity goal for legacy quotes without complicating the recursive
|
||||
case.
|
||||
|
||||
### V1 renderer
|
||||
|
||||
`InstantPageLayout.swift:504` is the `.blockQuote` arm of the giant top-level
|
||||
`switch self` inside the `extension InstantPageBlock { func layout(...) -> InstantPageLayout }`.
|
||||
Because the enum is already `indirect`, the arm can simply call
|
||||
`child.layout(...)` recursively for each block in `blocks` — no signature
|
||||
refactor needed.
|
||||
|
||||
BlockQuote arm:
|
||||
|
||||
```swift
|
||||
case let .blockQuote(blocks, caption):
|
||||
let lineInset: CGFloat = 20.0
|
||||
let verticalInset: CGFloat = 4.0
|
||||
var contentSize = CGSize(width: boundingWidth, height: verticalInset)
|
||||
var items: [InstantPageItem] = []
|
||||
|
||||
let innerBoundingWidth = boundingWidth - horizontalInset * 2.0 - lineInset
|
||||
let innerHorizontalInset = horizontalInset + lineInset
|
||||
|
||||
if blocks.count == 1, case let .paragraph(text) = blocks[0] {
|
||||
// Fast path: existing italicized body layout (unchanged).
|
||||
} else {
|
||||
var previousChildItems: [InstantPageItem] = []
|
||||
for child in blocks {
|
||||
let childLayout = child.layout(
|
||||
boundingWidth: innerBoundingWidth,
|
||||
horizontalInset: innerHorizontalInset,
|
||||
safeInset: safeInset,
|
||||
isCover: false,
|
||||
previousItems: previousChildItems,
|
||||
fillToSize: nil,
|
||||
media: media,
|
||||
mediaIndexCounter: &mediaIndexCounter,
|
||||
embedIndexCounter: &embedIndexCounter,
|
||||
detailsIndexCounter: &detailsIndexCounter,
|
||||
theme: theme,
|
||||
strings: strings,
|
||||
/* ... whichever other params the current signature takes ... */
|
||||
fitToWidth: fitToWidth,
|
||||
webpage: webpage
|
||||
)
|
||||
for var item in childLayout.items {
|
||||
item.frame = item.frame.offsetBy(dx: 0.0, dy: contentSize.height)
|
||||
items.append(item)
|
||||
previousChildItems.append(item)
|
||||
}
|
||||
contentSize.height += childLayout.contentSize.height
|
||||
}
|
||||
}
|
||||
|
||||
// Caption (existing branch, using innerHorizontalInset).
|
||||
if case .empty = caption { /* nothing */ } else {
|
||||
contentSize.height += 14.0
|
||||
// ...existing caption layout, parameterized on innerHorizontalInset...
|
||||
}
|
||||
contentSize.height += verticalInset
|
||||
|
||||
let shapeItem = InstantPageShapeItem(
|
||||
frame: CGRect(origin: CGPoint(x: horizontalInset, y: 0.0),
|
||||
size: CGSize(width: 3.0, height: contentSize.height)),
|
||||
shapeFrame: CGRect(origin: .zero,
|
||||
size: CGSize(width: 3.0, height: contentSize.height)),
|
||||
shape: .roundLine,
|
||||
color: theme.textCategories.paragraph.color
|
||||
)
|
||||
items.append(shapeItem)
|
||||
|
||||
return InstantPageLayout(origin: CGPoint(),
|
||||
contentSize: contentSize, items: items)
|
||||
```
|
||||
|
||||
The exact parameter list of the recursive `child.layout(...)` call mirrors
|
||||
the current method's parameter list as it exists in the codebase; the planning
|
||||
step will reconcile against the actual method signature (which may differ
|
||||
from the snippet above in nominal arity).
|
||||
|
||||
### Markdown forward parser
|
||||
|
||||
`BrowserMarkdown.swift:1394`. Replace the current per-child-paragraph
|
||||
fragmentation with a single quote that carries all child blocks:
|
||||
|
||||
```swift
|
||||
case .blockQuote:
|
||||
var childBlocks: [InstantPageBlock] = []
|
||||
for child in node.children {
|
||||
guard let parsed = markdownBlocks(
|
||||
from: child, context: context, depth: depth + 1
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
childBlocks.append(contentsOf: parsed)
|
||||
}
|
||||
guard !childBlocks.isEmpty else {
|
||||
return []
|
||||
}
|
||||
return [.blockQuote(blocks: childBlocks, caption: .empty)]
|
||||
```
|
||||
|
||||
**Behavior change worth noting.** Today a markdown
|
||||
`> p1\n>\n> p2` produces TWO separate top-level quotes because the current
|
||||
code emits one quote per child paragraph (a workaround for the text-only
|
||||
model). Under the new model that becomes one quote with two paragraphs —
|
||||
which is the correct semantics. Both forms continue to trigger the rich-send
|
||||
gate (under the new entity-expressibility rule below, a multi-paragraph
|
||||
quote is no longer entity-expressible — see "Risks" below).
|
||||
|
||||
### Entity-expressibility (`BrowserMarkdown.swift:1119`)
|
||||
|
||||
Telegram message-entity blockquotes are flat (single span of inline text).
|
||||
The new gate:
|
||||
|
||||
```swift
|
||||
case .blockQuote(let blocks, let caption):
|
||||
guard isEmptyRichText(caption) else { return false }
|
||||
return blocks.allSatisfy { child in
|
||||
if case let .paragraph(text) = child {
|
||||
return richTextIsEntityExpressible(text)
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
- A single `> quote` stays entity-expressible (`[.paragraph(t)]`, entity-
|
||||
expressible text) → sends via the regular entity path.
|
||||
- A nested-structure quote (`> # heading`, `> - list`) is not entity-
|
||||
expressible → sends via the rich path.
|
||||
- See "Risks" for the multi-paragraph case.
|
||||
|
||||
### Markdown reverse converter
|
||||
|
||||
`InstantPageToMarkdown.swift:42`. Recurse into each child block, dispatching
|
||||
through the file's existing `markdownString(from:)` (which already knows
|
||||
how to emit headings, lists, code, etc.), and prepend `> ` to every line:
|
||||
|
||||
```swift
|
||||
case let .blockQuote(blocks, _):
|
||||
return markdownBlockQuote(blocks: blocks)
|
||||
|
||||
private func markdownBlockQuote(blocks: [InstantPageBlock]) -> String {
|
||||
var lines: [String] = []
|
||||
for block in blocks {
|
||||
guard let body = markdownString(from: block, /* args */) else {
|
||||
continue
|
||||
}
|
||||
for line in body.split(separator: "\n",
|
||||
omittingEmptySubsequences: false) {
|
||||
lines.append("> \(String(line))")
|
||||
}
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
```
|
||||
|
||||
`.pullQuote(text, _)` continues to call the existing
|
||||
`markdownBlockQuote(_:RichText)`.
|
||||
|
||||
### Preview text (`InstantPagePreviewText.swift:126`)
|
||||
|
||||
```swift
|
||||
case let .blockQuote(blocks, caption):
|
||||
let body = blocks.map { $0.previewText() }.joined(separator: " ")
|
||||
return body + caption.previewText()
|
||||
```
|
||||
|
||||
Uses the existing per-block `previewText()` extension at the top of the
|
||||
file so nested previews work transparently.
|
||||
|
||||
### FAQ matcher (`CachedFaqInstantPage.swift:23`)
|
||||
|
||||
The match is `case .blockQuote:` with no payload destructure — no change
|
||||
needed.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Behavior change for multi-paragraph quotes in chat send.** Under today's
|
||||
text-only model, `> p1\n>\n> p2` fragments into two top-level
|
||||
`.blockQuote(text: p_i, caption: .empty)` blocks, each of which is
|
||||
individually entity-expressible — so the message sends via the regular
|
||||
entity path (two consecutive blockquote entities). Under the new model
|
||||
the markdown parser emits one `.blockQuote(blocks: [.paragraph(p1),
|
||||
.paragraph(p2)], caption: .empty)`, which is no longer entity-expressible
|
||||
under the proposed gate (multi-paragraph blockquote can't be a single
|
||||
flat entity). So the same message starts going via the rich path.
|
||||
|
||||
This is correct semantically — the structure IS preserved end to end —
|
||||
but it changes the wire format for an existing user-visible flow. A
|
||||
minor compromise is available: in `blockIsEntityExpressible`, treat a
|
||||
multi-paragraph quote as entity-expressible by serializing each
|
||||
paragraph through a separate entity at the message-build step; this is
|
||||
more involved and out of scope for the first cut. The risk is small —
|
||||
recipients of the rich message render it correctly; the only user-
|
||||
visible difference is that the message lands as a rich block on
|
||||
recipients who would otherwise have seen the consecutive-entities
|
||||
flattening.
|
||||
|
||||
- **Old recipients receiving `pageBlockBlockquoteBlocks` over the wire.**
|
||||
Older clients that haven't been updated to parse the new constructor
|
||||
will route it to `.unsupported` and skip it. The outbound choice
|
||||
("legacy when shape allows") keeps the common single-paragraph case
|
||||
on the legacy constructor, minimizing this risk to actual nested-block
|
||||
quotes where there's no legacy equivalent anyway.
|
||||
|
||||
- **FlatBuffers schema evolution.** Dropping `(required)` and appending a
|
||||
new field at a higher id are documented as safe under FBS rules. The
|
||||
same iOS app and a Telegram-Mac peer share the schema definition (per
|
||||
the project's TelegramCore conventions), so both ends must move together
|
||||
or accept that one side will see `text` populated while the other writes
|
||||
only `blocks` — which the decoder handles correctly (legacy path).
|
||||
|
||||
- **V1 recursive layout call signature drift.** The exact parameter list of
|
||||
`child.layout(...)` is reconciled in the implementation plan, not in the
|
||||
spec — the V1 layout method's signature is long and any plumbing detail
|
||||
is best captured at edit time rather than guessed here.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `.pullQuote` enum shape, parsing, encoding, and renderer.
|
||||
- Streaming/reveal animation in `ChatMessageRichDataBubbleContentNode`.
|
||||
Nested-block quotes emit ordinary `InstantPageV2LaidOutItem`s consumed by
|
||||
the existing width-based reveal cost map; no special handling.
|
||||
- Inline animated emoji owned by `InstantPageV2View`. Quotes carrying
|
||||
paragraphs with custom emoji "just work" because each child paragraph's
|
||||
text items route through `updateInlineEmoji` normally.
|
||||
|
||||
## Files affected
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift` | Enum case shape; Postbox encoder/decoder; equality; FlatBuffers encoder/decoder. |
|
||||
| `submodules/TelegramCore/FlatSerialization/Models/InstantPageBlock.fbs` | Drop `(required)` from `text`; add `blocks:[InstantPageBlock] (id: 2)`. |
|
||||
| `submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift` | API parse for both inbound constructors; API encode with legacy-when-possible. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | Split `layoutBlockQuote` into block- and pull- variants; recurse into child blocks. Add `translatedY(by:)` helper on `InstantPageV2LaidOutItem`. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageLayout.swift` | Recurse into child blocks in the `.blockQuote` arm. |
|
||||
| `submodules/BrowserUI/Sources/BrowserMarkdown.swift` | Single quote with all child blocks (forward); entity-expressibility gate. |
|
||||
| `submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` | Recursive `markdownBlockQuote(blocks:)`. |
|
||||
| `submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift` | Concatenate child previews. |
|
||||
|
||||
`submodules/SettingsUI/Sources/CachedFaqInstantPage.swift` (line 23) is a
|
||||
payload-less match and needs no edit, but should be re-verified during the
|
||||
implementation build (full build is the completeness gate per the
|
||||
project's "no per-module build" rule).
|
||||
|
|
@ -1590,7 +1590,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
}
|
||||
}
|
||||
}
|
||||
self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(webPage: webPage, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
|
||||
self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(playlistId: .instantPage(webpageId: webPage.webpageId), webPage: webPage, messageReference: nil, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ swift_library(
|
|||
"//submodules/TelegramUI/Components/MultiAnimationRenderer:MultiAnimationRenderer",
|
||||
"//submodules/GalleryUI:GalleryUI",
|
||||
"//submodules/MusicAlbumArtResources:MusicAlbumArtResources",
|
||||
"//submodules/SemanticStatusNode:SemanticStatusNode",
|
||||
"//submodules/LiveLocationPositionNode:LiveLocationPositionNode",
|
||||
"//submodules/MosaicLayout:MosaicLayout",
|
||||
"//submodules/LocationUI:LocationUI",
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ final class InstantImageGalleryItemNode: ZoomableContentGalleryItemNode {
|
|||
copyView.layer.animate(from: NSValue(caTransform3D: CATransform3DIdentity), to: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false)
|
||||
|
||||
if let transformedSurfaceFrame = transformedSurfaceFrame, let transformedSurfaceFinalFrame = transformedSurfaceFinalFrame {
|
||||
surfaceCopyView.layer.animatePosition(from: CGPoint(x: transformedSurfaceFrame.midX, y: transformedSurfaceFrame.midY), to: CGPoint(x: transformedCopyViewFinalFrame.midX, y: transformedCopyViewFinalFrame.midY), duration: positionDuration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak surfaceCopyView] _ in
|
||||
surfaceCopyView.layer.animatePosition(from: CGPoint(x: transformedSurfaceFrame.midX, y: transformedSurfaceFrame.midY), to: CGPoint(x: transformedSurfaceFinalFrame.midX, y: transformedSurfaceFinalFrame.midY), duration: positionDuration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak surfaceCopyView] _ in
|
||||
surfaceCopyView?.removeFromSuperview()
|
||||
})
|
||||
let scale = CGSize(width: transformedSurfaceFinalFrame.size.width / transformedSurfaceFrame.size.width, height: transformedSurfaceFinalFrame.size.height / transformedSurfaceFrame.size.height)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ public final class InstantPageAudioItem: InstantPageItem {
|
|||
}
|
||||
|
||||
public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
|
||||
return InstantPageAudioNode(context: context, strings: strings, theme: theme, webPage: self.webpage, media: self.media, openMedia: openMedia)
|
||||
return InstantPageAudioNode(context: context, strings: strings, theme: theme, webPage: self.webpage, media: self.media, playlistId: .instantPage(webpageId: self.webpage.webpageId), openMedia: openMedia)
|
||||
}
|
||||
|
||||
public func matchesAnchor(_ anchor: String) -> Bool {
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
|
|||
private var playImage: UIImage
|
||||
private var pauseImage: UIImage
|
||||
|
||||
private let buttonNode: HighlightableButtonNode
|
||||
private let buttonView: UIView
|
||||
private let statusNode: RadialStatusNode
|
||||
private let titleNode: ASTextNode
|
||||
private let scrubbingNode: MediaPlayerScrubbingNode
|
||||
|
|
@ -76,7 +76,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
|
|||
private var isPlaying: Bool = false
|
||||
private var playbackState: SharedMediaPlayerItemPlaybackState?
|
||||
|
||||
init(context: AccountContext, strings: PresentationStrings, theme: InstantPageTheme, webPage: TelegramMediaWebpage, media: InstantPageMedia, openMedia: @escaping (InstantPageMedia) -> Void) {
|
||||
init(context: AccountContext, strings: PresentationStrings, theme: InstantPageTheme, webPage: TelegramMediaWebpage, media: InstantPageMedia, playlistId: InstantPageMediaPlaylistId, openMedia: @escaping (InstantPageMedia) -> Void) {
|
||||
self.context = context
|
||||
self.strings = strings
|
||||
self.theme = theme
|
||||
|
|
@ -86,7 +86,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
|
|||
self.playImage = generatePlayButton(color: theme.textCategories.paragraph.color)!
|
||||
self.pauseImage = generatePauseButton(color: theme.textCategories.paragraph.color)!
|
||||
|
||||
self.buttonNode = HighlightableButtonNode()
|
||||
self.buttonView = UIView()
|
||||
self.statusNode = RadialStatusNode(backgroundNodeColor: .clear)
|
||||
self.titleNode = ASTextNode()
|
||||
self.titleNode.maximumNumberOfLines = 1
|
||||
|
|
@ -112,25 +112,11 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
|
|||
self.titleNode.attributedText = titleString(media: media, theme: theme, strings: strings)
|
||||
|
||||
self.addSubnode(self.statusNode)
|
||||
self.addSubnode(self.buttonNode)
|
||||
self.addSubnode(self.titleNode)
|
||||
self.addSubnode(self.scrubbingNode)
|
||||
|
||||
|
||||
self.statusNode.transitionToState(RadialStatusNodeState.customIcon(self.playImage), animated: false, completion: {})
|
||||
|
||||
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
|
||||
self.buttonNode.highligthedChanged = { [weak self] highlighted in
|
||||
if let strongSelf = self {
|
||||
if highlighted {
|
||||
strongSelf.statusNode.layer.removeAnimation(forKey: "opacity")
|
||||
strongSelf.statusNode.alpha = 0.4
|
||||
} else {
|
||||
strongSelf.statusNode.alpha = 1.0
|
||||
strongSelf.statusNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.scrubbingNode.seek = { [weak self] timestamp in
|
||||
if let strongSelf = self {
|
||||
if let _ = strongSelf.playbackState {
|
||||
|
|
@ -178,12 +164,12 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
|
|||
}
|
||||
})*/
|
||||
|
||||
self.scrubbingNode.status = context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: InstantPageMediaPlaylistId(webpageId: webPage.webpageId), itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: self.playlistType)
|
||||
self.scrubbingNode.status = context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: playlistId, itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: self.playlistType)
|
||||
|> map { playbackState -> MediaPlayerStatus in
|
||||
return playbackState?.status ?? MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true)
|
||||
}
|
||||
|
||||
self.playerStatusDisposable = (context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: InstantPageMediaPlaylistId(webpageId: webPage.webpageId), itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: playlistType)
|
||||
self.playerStatusDisposable = (context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: playlistId, itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: playlistType)
|
||||
|> deliverOnMainQueue).start(next: { [weak self] playbackState in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
|
|
@ -213,7 +199,21 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
|
|||
deinit {
|
||||
self.playerStatusDisposable?.dispose()
|
||||
}
|
||||
|
||||
|
||||
override func didLoad() {
|
||||
super.didLoad()
|
||||
// The play/pause tap target is a plain view + UITapGestureRecognizer, NOT an ASControl
|
||||
// button. An ASControl's `.touchUpInside` is cancelled by the chat ListView's gesture
|
||||
// system (the control highlights on touch-down, but the action never fires), so an
|
||||
// embedded audio control in a rich-message bubble could never start playback. A gesture
|
||||
// recognizer coordinates with the list's gestures and fires reliably — matching the V2
|
||||
// image node, the details-title hit view, and the regular file/music message. The plain
|
||||
// view sits above `statusNode` and is positioned over the icon in `layout()`. (Works in
|
||||
// V1's full-page Instant View too; gesture recognizers fire inside its scroll view.)
|
||||
self.view.addSubview(self.buttonView)
|
||||
self.buttonView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.buttonPressed)))
|
||||
}
|
||||
|
||||
func update(strings: PresentationStrings, theme: InstantPageTheme) {
|
||||
if self.strings !== strings || self.theme !== theme {
|
||||
let themeUpdated = self.theme !== theme
|
||||
|
|
@ -268,7 +268,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
|
|||
let titleSize = self.titleNode.measure(CGSize(width: maxTitleWidth, height: size.height))
|
||||
self.titleNode.frame = CGRect(origin: CGPoint(x: insets.left + leftInset, y: 2.0), size: titleSize)
|
||||
|
||||
self.buttonNode.frame = CGRect(origin: CGPoint(x: insets.left, y: 0.0), size: CGSize(width: 48.0, height: 48.0))
|
||||
self.buttonView.frame = CGRect(origin: CGPoint(x: insets.left, y: 0.0), size: CGSize(width: 48.0, height: 48.0))
|
||||
self.statusNode.frame = CGRect(origin: CGPoint(x: insets.left, y: 0.0), size: CGSize(width: 48.0, height: 48.0))
|
||||
|
||||
var topOffset: CGFloat = 0.0
|
||||
|
|
|
|||
|
|
@ -1839,7 +1839,7 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate {
|
|||
}
|
||||
}
|
||||
}
|
||||
self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(webPage: webPage, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
|
||||
self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(playlistId: .instantPage(webpageId: webPage.webpageId), webPage: webPage, messageReference: nil, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,12 +20,18 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
|
|||
} else {
|
||||
return 25.0
|
||||
}
|
||||
case (_, .blockQuote), (.blockQuote, _), (_, .pullQuote), (.pullQuote, _):
|
||||
case (_, .blockQuote), (.blockQuote, _):
|
||||
if fitToWidth {
|
||||
return 11.0
|
||||
} else {
|
||||
return 27.0
|
||||
}
|
||||
case (_, .pullQuote), (.pullQuote, _):
|
||||
if fitToWidth {
|
||||
return 14.0
|
||||
} else {
|
||||
return 27.0
|
||||
}
|
||||
case (.kicker, .title), (.cover, .title):
|
||||
return 16.0
|
||||
case (_, .title):
|
||||
|
|
|
|||
|
|
@ -30,15 +30,28 @@ private func extractFileMedia(_ item: InstantPageMedia) -> TelegramMediaFile? {
|
|||
|
||||
final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem {
|
||||
let webPage: TelegramMediaWebpage
|
||||
let messageReference: MessageReference?
|
||||
let id: SharedMediaPlaylistItemId
|
||||
let item: InstantPageMedia
|
||||
|
||||
init(webPage: TelegramMediaWebpage, item: InstantPageMedia) {
|
||||
|
||||
init(webPage: TelegramMediaWebpage, messageReference: MessageReference?, item: InstantPageMedia) {
|
||||
self.webPage = webPage
|
||||
self.messageReference = messageReference
|
||||
self.id = InstantPageMediaPlaylistItemId(index: item.index)
|
||||
self.item = item
|
||||
}
|
||||
|
||||
|
||||
private func fileReference(_ file: TelegramMediaFile) -> FileMediaReference {
|
||||
// Require a resolvable message id (mirrors the playlist-key fallback in
|
||||
// InstantPageV2MediaAudioView): a `.none`-content reference can't revalidate, so fall
|
||||
// back to the webpage reference in that case.
|
||||
if let messageReference = self.messageReference, messageReference.id != nil {
|
||||
return .message(message: messageReference, media: file)
|
||||
} else {
|
||||
return .webPage(webPage: WebpageReference(self.webPage), media: file)
|
||||
}
|
||||
}
|
||||
|
||||
var stableId: AnyHashable {
|
||||
return self.item.index
|
||||
}
|
||||
|
|
@ -49,13 +62,13 @@ final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem {
|
|||
switch attribute {
|
||||
case let .Audio(isVoice, _, _, _, _):
|
||||
if isVoice {
|
||||
return SharedMediaPlaybackData(type: .voice, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
|
||||
return SharedMediaPlaybackData(type: .voice, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
|
||||
} else {
|
||||
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
|
||||
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
|
||||
}
|
||||
case let .Video(_, _, flags, _, _, _):
|
||||
if flags.contains(.instantRoundVideo) {
|
||||
return SharedMediaPlaybackData(type: .instantVideo, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
|
||||
return SharedMediaPlaybackData(type: .instantVideo, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -64,12 +77,12 @@ final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem {
|
|||
}
|
||||
}
|
||||
if file.mimeType.hasPrefix("audio/") {
|
||||
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
|
||||
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
|
||||
}
|
||||
if let fileName = file.fileName {
|
||||
let ext = (fileName as NSString).pathExtension.lowercased()
|
||||
if ext == "wav" || ext == "opus" {
|
||||
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
|
||||
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -116,14 +129,15 @@ final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem {
|
|||
}
|
||||
}
|
||||
|
||||
struct InstantPageMediaPlaylistId: SharedMediaPlaylistId {
|
||||
let webpageId: EngineMedia.Id
|
||||
|
||||
func isEqual(to: SharedMediaPlaylistId) -> Bool {
|
||||
if let to = to as? InstantPageMediaPlaylistId {
|
||||
return self.webpageId == to.webpageId
|
||||
public enum InstantPageMediaPlaylistId: Equatable, SharedMediaPlaylistId {
|
||||
case instantPage(webpageId: EngineMedia.Id)
|
||||
case richMessage(messageId: EngineMessage.Id)
|
||||
|
||||
public func isEqual(to: SharedMediaPlaylistId) -> Bool {
|
||||
guard let to = to as? InstantPageMediaPlaylistId else {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
return self == to
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -134,15 +148,13 @@ struct InstantPagePlaylistLocation: Equatable, SharedMediaPlaylistLocation {
|
|||
guard let to = to as? InstantPagePlaylistLocation else {
|
||||
return false
|
||||
}
|
||||
if self.webpageId == to.webpageId {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return self.webpageId == to.webpageId
|
||||
}
|
||||
}
|
||||
|
||||
public final class InstantPageMediaPlaylist: SharedMediaPlaylist {
|
||||
private let webPage: TelegramMediaWebpage
|
||||
private let messageReference: MessageReference?
|
||||
private let items: [InstantPageMedia]
|
||||
private let initialItemIndex: Int
|
||||
|
||||
|
|
@ -164,15 +176,16 @@ public final class InstantPageMediaPlaylist: SharedMediaPlaylist {
|
|||
return self.stateValue.get()
|
||||
}
|
||||
|
||||
public init(webPage: TelegramMediaWebpage, items: [InstantPageMedia], initialItemIndex: Int) {
|
||||
public init(playlistId: InstantPageMediaPlaylistId, webPage: TelegramMediaWebpage, messageReference: MessageReference?, items: [InstantPageMedia], initialItemIndex: Int) {
|
||||
assert(Queue.mainQueue().isCurrent())
|
||||
|
||||
self.id = InstantPageMediaPlaylistId(webpageId: webPage.webpageId)
|
||||
|
||||
|
||||
self.id = playlistId
|
||||
|
||||
self.webPage = webPage
|
||||
self.messageReference = messageReference
|
||||
self.items = items
|
||||
self.initialItemIndex = initialItemIndex
|
||||
|
||||
|
||||
self.control(.next)
|
||||
}
|
||||
|
||||
|
|
@ -243,7 +256,7 @@ public final class InstantPageMediaPlaylist: SharedMediaPlaylist {
|
|||
}
|
||||
|
||||
private func updateState() {
|
||||
self.stateValue.set(.single(SharedMediaPlaylistState(loading: false, playedToEnd: self.playedToEnd, item: self.currentItem.flatMap({ InstantPageMediaPlaylistItem(webPage: self.webPage, item: $0) }), nextItem: nil, previousItem: nil, order: self.order, looping: self.looping)))
|
||||
self.stateValue.set(.single(SharedMediaPlaylistState(loading: false, playedToEnd: self.playedToEnd, item: self.currentItem.flatMap({ InstantPageMediaPlaylistItem(webPage: self.webPage, messageReference: self.messageReference, item: $0) }), nextItem: nil, previousItem: nil, order: self.order, looping: self.looping)))
|
||||
}
|
||||
|
||||
public func onItemPlaybackStarted(_ item: SharedMediaPlaylistItem) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Foundation
|
||||
import UIKit
|
||||
import AsyncDisplayKit
|
||||
import Display
|
||||
import CheckNode
|
||||
import SwiftSignalKit
|
||||
|
|
@ -35,7 +36,7 @@ public enum InstantPageV2StableItemId: Hashable {
|
|||
}
|
||||
|
||||
public enum InstantPageV2ItemKind: Hashable {
|
||||
case text, codeBlock, divider, listMarker, blockQuoteBar, shape, mediaPlaceholder, table, anchor, formula
|
||||
case text, codeBlock, divider, listMarker, blockQuoteBar, shape, mediaPlaceholder, table, anchor, formula, slideshow
|
||||
}
|
||||
|
||||
// MARK: - Render context
|
||||
|
|
@ -58,6 +59,11 @@ public final class InstantPageV2RenderContext {
|
|||
public let push: (ViewController) -> Void
|
||||
public let openUrl: (InstantPageUrlItem) -> Void
|
||||
public let baseNavigationController: () -> NavigationController?
|
||||
/// A reference to the message hosting this page, when rendered inside a chat bubble. Used to
|
||||
/// key audio playback per message (`.richMessage(message.id)`) AND to fetch audio files via a
|
||||
/// message reference (so a stale file reference can revalidate); `nil` in the send preview,
|
||||
/// which falls back to the webpage-keyed playlist id + webpage file reference.
|
||||
public let message: MessageReference?
|
||||
|
||||
public init(
|
||||
context: AccountContext,
|
||||
|
|
@ -68,7 +74,8 @@ public final class InstantPageV2RenderContext {
|
|||
present: @escaping (ViewController, Any?) -> Void,
|
||||
push: @escaping (ViewController) -> Void,
|
||||
openUrl: @escaping (InstantPageUrlItem) -> Void,
|
||||
baseNavigationController: @escaping () -> NavigationController?
|
||||
baseNavigationController: @escaping () -> NavigationController?,
|
||||
message: MessageReference?
|
||||
) {
|
||||
self.context = context
|
||||
self.webpage = webpage
|
||||
|
|
@ -79,6 +86,7 @@ public final class InstantPageV2RenderContext {
|
|||
self.push = push
|
||||
self.openUrl = openUrl
|
||||
self.baseNavigationController = baseNavigationController
|
||||
self.message = message
|
||||
}
|
||||
|
||||
/// Update the content-bearing fields for a later chunk of the SAME message. Enables the
|
||||
|
|
@ -675,10 +683,18 @@ 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 .mediaAudio(media):
|
||||
guard let v = existingView as? InstantPageV2MediaAudioView, 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
|
||||
case let .slideshow(slideshow):
|
||||
guard let v = existingView as? InstantPageV2SlideshowView, let rc = self.renderContext else { return nil }
|
||||
v.update(item: slideshow, theme: theme, renderContext: rc)
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -688,6 +704,7 @@ public final class InstantPageV2View: UIView {
|
|||
case let .mediaVideo(m): return .media(m.media.index)
|
||||
case let .mediaMap(m): return .media(m.media.index)
|
||||
case let .mediaCoverImage(m): return .media(m.media.index)
|
||||
case let .mediaAudio(m): return .media(m.media.index)
|
||||
case let .details(d): return .details(d.index)
|
||||
case .text: return .positional(.text, position)
|
||||
case .codeBlock: return .positional(.codeBlock, position)
|
||||
|
|
@ -700,6 +717,7 @@ public final class InstantPageV2View: UIView {
|
|||
case .anchor: return .positional(.anchor, position)
|
||||
case .formula: return .positional(.formula, position)
|
||||
case .thinking: return .thinking(position)
|
||||
case .slideshow: return .positional(.slideshow, position)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -724,13 +742,8 @@ public final class InstantPageV2View: UIView {
|
|||
guard let wrapperBox = self.trueRegistryRoot.mediaRegistry[media.index], let wrapper = wrapperBox.value else {
|
||||
return nil
|
||||
}
|
||||
let imageNode: InstantPageImageNode? =
|
||||
(wrapper as? InstantPageV2MediaImageView)?.wrappedNode
|
||||
?? (wrapper as? InstantPageV2MediaVideoView)?.wrappedNode
|
||||
?? (wrapper as? InstantPageV2MediaMapView)?.wrappedNode
|
||||
?? (wrapper as? InstantPageV2MediaCoverImageView)?.wrappedNode
|
||||
guard let imageNode else { return nil }
|
||||
guard let transitionNode = imageNode.transitionNode(media: media) else { return nil }
|
||||
guard let itemView = wrapper as? InstantPageItemView else { return nil }
|
||||
guard let transitionNode = itemView.instantPageTransitionNode(for: media) else { return nil }
|
||||
return GalleryTransitionArguments(transitionNode: transitionNode, addToTransitionSurface: addToTransitionSurface)
|
||||
}
|
||||
|
||||
|
|
@ -739,10 +752,7 @@ public final class InstantPageV2View: UIView {
|
|||
func applyHiddenMedia(_ hidden: InstantPageMedia?) {
|
||||
for (_, weakBox) in self.trueRegistryRoot.mediaRegistry {
|
||||
guard let wrapper = weakBox.value else { continue }
|
||||
if let v = wrapper as? InstantPageV2MediaImageView { v.wrappedNode.updateHiddenMedia(media: hidden) }
|
||||
if let v = wrapper as? InstantPageV2MediaVideoView { v.wrappedNode.updateHiddenMedia(media: hidden) }
|
||||
if let v = wrapper as? InstantPageV2MediaMapView { v.wrappedNode.updateHiddenMedia(media: hidden) }
|
||||
if let v = wrapper as? InstantPageV2MediaCoverImageView { v.wrappedNode.updateHiddenMedia(media: hidden) }
|
||||
(wrapper as? InstantPageItemView)?.instantPageUpdateHiddenMedia(hidden)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -796,10 +806,22 @@ public final class InstantPageV2View: UIView {
|
|||
} else {
|
||||
return InstantPageV2MediaPlaceholderView(item: placeholderFallback(for: media), theme: theme)
|
||||
}
|
||||
case let .mediaAudio(media):
|
||||
if let renderContext = self.renderContext {
|
||||
return InstantPageV2MediaAudioView(item: media, renderContext: renderContext, theme: theme)
|
||||
} else {
|
||||
return InstantPageV2MediaPlaceholderView(item: InstantPageV2MediaPlaceholderItem(frame: media.frame, kind: .audio, cornerRadius: 0.0), theme: theme)
|
||||
}
|
||||
case let .formula(formula):
|
||||
return InstantPageV2FormulaView(item: formula, theme: theme)
|
||||
case let .thinking(thinking):
|
||||
return InstantPageV2ThinkingView(item: thinking, theme: theme)
|
||||
case let .slideshow(slideshow):
|
||||
if let renderContext = self.renderContext {
|
||||
return InstantPageV2SlideshowView(item: slideshow, renderContext: renderContext, theme: theme)
|
||||
} else {
|
||||
return InstantPageV2MediaPlaceholderView(item: InstantPageV2MediaPlaceholderItem(frame: slideshow.frame, kind: .slideshow, cornerRadius: 0.0), theme: theme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -853,10 +875,18 @@ protocol InstantPageItemView: UIView {
|
|||
var itemFrame: CGRect { get }
|
||||
/// Recursion hook for nested layouts (details body, table cells, table title).
|
||||
var subLayoutView: InstantPageV2View? { get }
|
||||
/// Gallery open: the transition source for `media` if this view (or a descendant) shows it.
|
||||
/// Default nil (non-media views). Media views forward to their wrapped `InstantPageImageNode`;
|
||||
/// the slideshow forwards to its matching page.
|
||||
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))?
|
||||
/// Gallery hidden-media tick: hide/show the source for `media`. Default no-op.
|
||||
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?)
|
||||
}
|
||||
|
||||
extension InstantPageItemView {
|
||||
var subLayoutView: InstantPageV2View? { return nil }
|
||||
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { return nil }
|
||||
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) { }
|
||||
}
|
||||
|
||||
// MARK: - Text view (port of V1 InstantPageTextItem.drawInTile)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,278 @@
|
|||
import Foundation
|
||||
import UIKit
|
||||
import AsyncDisplayKit
|
||||
import Display
|
||||
import SwiftSignalKit
|
||||
import TelegramCore
|
||||
import TelegramPresentationData
|
||||
import AccountContext
|
||||
import UniversalMediaPlayer
|
||||
import SemanticStatusNode
|
||||
import MusicAlbumArtResources
|
||||
import PhotoResources
|
||||
|
||||
// Renders an InstantPage audio block to match the standard music message bubble
|
||||
// (ChatMessageInteractiveFileNode, non-thumbnail/non-voice music branch). Visual only differs
|
||||
// from the file node in that playback is driven by InstantPageMediaPlaylist (our `play` closure)
|
||||
// rather than the peer-messages model. V1's InstantPageAudioNode is unaffected.
|
||||
final class InstantPageV2AudioContentNode: ASDisplayNode {
|
||||
private let context: AccountContext
|
||||
private let message: MessageReference?
|
||||
private let file: TelegramMediaFile
|
||||
private let incoming: Bool
|
||||
|
||||
private let statusNode: SemanticStatusNode
|
||||
private let streamingStatusNode: SemanticStatusNode
|
||||
private let titleNode: TextNode
|
||||
private let descriptionNode: TextNode
|
||||
private let tapView: UIView
|
||||
|
||||
// TextNode (unlike ASTextNode) has no stored `attributedText`; the string is an argument to
|
||||
// `TextNode.asyncLayout`. Keep the current strings here and feed them in `updateLayout`.
|
||||
private var titleAttributedString: NSAttributedString?
|
||||
private var descriptionAttributedString: NSAttributedString?
|
||||
|
||||
var play: () -> Void = {}
|
||||
var togglePlayPause: () -> Void = {}
|
||||
var fetch: () -> Void = {}
|
||||
|
||||
private var resourceStatusDisposable: Disposable?
|
||||
// EngineMediaResourceStatus is the TelegramCore typealias for Postbox's MediaResourceStatus;
|
||||
// using it keeps this file off `import Postbox` (TelegramCore doesn't re-export Postbox).
|
||||
private var fetchStatus: EngineMediaResourceStatus?
|
||||
|
||||
private var playbackStatusDisposable: Disposable?
|
||||
private(set) var isPlaying: Bool = false
|
||||
|
||||
// Theme-refresh state. `incoming` is already a `let` stored above; `incomingValue` tracks the
|
||||
// last theme-update's incoming flag so `updatePresentationData` can guard on change.
|
||||
private var presentationData: PresentationData
|
||||
private var incomingValue: Bool
|
||||
|
||||
private static let progressDiameter: CGFloat = 40.0
|
||||
// Shifted +9pt right of the original x=3 (→ 12); the Ø40 control is vertically centered in the
|
||||
// 44pt row (y = (44 − 40)/2 = 2).
|
||||
private static let progressOrigin = CGPoint(x: 12.0, y: 2.0)
|
||||
private static let controlAreaWidth: CGFloat = 12.0 + 40.0 + 8.0
|
||||
private static let normHeight: CGFloat = 44.0
|
||||
|
||||
init(context: AccountContext, message: MessageReference?, file: TelegramMediaFile, incoming: Bool, presentationData: PresentationData) {
|
||||
self.context = context
|
||||
self.message = message
|
||||
self.file = file
|
||||
self.incoming = incoming
|
||||
self.presentationData = presentationData
|
||||
self.incomingValue = incoming
|
||||
|
||||
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
|
||||
|
||||
let backgroundNodeColor = messageTheme.mediaActiveControlColor
|
||||
let foregroundNodeColor: UIColor = (incoming && messageTheme.mediaActiveControlColor.rgb != 0xffffff) ? .white : .clear
|
||||
|
||||
var title: String?
|
||||
var performer: String?
|
||||
for attribute in file.attributes {
|
||||
if case let .Audio(_, _, t, p, _) = attribute { title = t; performer = p; break }
|
||||
}
|
||||
let albumArtImage: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?
|
||||
if file.isMusic, file.fileName?.lowercased().hasSuffix(".ogg") != true, let message = message {
|
||||
let fileRef: FileMediaReference = .message(message: message, media: file)
|
||||
albumArtImage = playerAlbumArt(
|
||||
engine: context.engine,
|
||||
fileReference: fileRef,
|
||||
albumArt: SharedMediaPlaybackAlbumArt(
|
||||
thumbnailResource: ExternalMusicAlbumArtResource(file: fileRef, title: title ?? "", performer: performer ?? "", isThumbnail: true),
|
||||
fullSizeResource: ExternalMusicAlbumArtResource(file: fileRef, title: title ?? "", performer: performer ?? "", isThumbnail: false)
|
||||
),
|
||||
thumbnail: true,
|
||||
overlayColor: UIColor(white: 0.0, alpha: 0.3),
|
||||
drawPlaceholderWhenEmpty: false,
|
||||
attemptSynchronously: false
|
||||
)
|
||||
} else {
|
||||
albumArtImage = nil
|
||||
}
|
||||
|
||||
self.statusNode = SemanticStatusNode(
|
||||
backgroundNodeColor: backgroundNodeColor,
|
||||
foregroundNodeColor: foregroundNodeColor,
|
||||
image: albumArtImage,
|
||||
overlayForegroundNodeColor: presentationData.theme.chat.message.mediaOverlayControlColors.foregroundColor
|
||||
)
|
||||
self.streamingStatusNode = SemanticStatusNode(backgroundNodeColor: .clear, foregroundNodeColor: messageTheme.mediaActiveControlColor)
|
||||
|
||||
self.titleNode = TextNode()
|
||||
self.titleNode.displaysAsynchronously = false
|
||||
self.titleNode.isUserInteractionEnabled = false
|
||||
self.descriptionNode = TextNode()
|
||||
self.descriptionNode.displaysAsynchronously = false
|
||||
self.descriptionNode.isUserInteractionEnabled = false
|
||||
|
||||
self.tapView = UIView()
|
||||
|
||||
super.init()
|
||||
|
||||
self.titleAttributedString = InstantPageV2AudioContentNode.titleString(file: file, incoming: incoming, presentationData: presentationData)
|
||||
self.descriptionAttributedString = InstantPageV2AudioContentNode.descriptionString(file: file, incoming: incoming, presentationData: presentationData)
|
||||
|
||||
self.addSubnode(self.statusNode)
|
||||
self.addSubnode(self.streamingStatusNode)
|
||||
self.addSubnode(self.titleNode)
|
||||
self.addSubnode(self.descriptionNode)
|
||||
|
||||
self.statusNode.transitionToState(.play, animated: false)
|
||||
self.streamingStatusNode.transitionToState(.none, animated: false)
|
||||
|
||||
if let messageId = self.message?.id {
|
||||
self.resourceStatusDisposable = (messageMediaFileStatus(context: context, messageId: messageId, file: file)
|
||||
|> deliverOnMainQueue).startStrict(next: { [weak self] status in
|
||||
self?.fetchStatus = status
|
||||
self?.updateStreamingState()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override func didLoad() {
|
||||
super.didLoad()
|
||||
// Tap target = plain view + UITapGestureRecognizer, NOT an ASControl: ASControl
|
||||
// .touchUpInside is cancelled by the chat ListView's gesture system (see InstantPageAudioNode
|
||||
// for the same reason).
|
||||
self.view.addSubview(self.tapView)
|
||||
self.tapView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapped)))
|
||||
}
|
||||
|
||||
@objc private func tapped() {
|
||||
self.controlTapped()
|
||||
}
|
||||
|
||||
func controlTapped() {
|
||||
switch self.fetchStatus {
|
||||
case .Remote, .Paused:
|
||||
self.fetch()
|
||||
case .none, .Local, .Fetching:
|
||||
if self.isPlaying {
|
||||
self.togglePlayPause()
|
||||
} else {
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.resourceStatusDisposable?.dispose()
|
||||
self.playbackStatusDisposable?.dispose()
|
||||
}
|
||||
|
||||
// Drives the big control's play/pause icon from the playlist state filtered to our
|
||||
// playlistId + itemId. Mirrors InstantPageAudioNode's subscription shape.
|
||||
func setPlaybackStatusSignal(_ signal: Signal<SharedMediaPlayerItemPlaybackState?, NoError>) {
|
||||
self.playbackStatusDisposable?.dispose() // defensive: a re-call must not leak the prior subscription
|
||||
self.playbackStatusDisposable = (signal |> deliverOnMainQueue).startStrict(next: { [weak self] state in
|
||||
guard let self else { return }
|
||||
let isPlaying: Bool
|
||||
if let status = state?.status {
|
||||
if case .playing = status.status {
|
||||
isPlaying = true
|
||||
} else {
|
||||
isPlaying = false
|
||||
}
|
||||
} else {
|
||||
isPlaying = false
|
||||
}
|
||||
self.isPlaying = isPlaying
|
||||
self.statusNode.transitionToState(isPlaying ? .pause : .play)
|
||||
})
|
||||
}
|
||||
|
||||
// Refreshes title/description attributed strings and the statusNode tint/foreground/overlay
|
||||
// colors when the theme or incoming direction changes. Called from the host view's
|
||||
// update(item:theme:renderContext:).
|
||||
func updatePresentationData(_ presentationData: PresentationData, incoming: Bool) {
|
||||
if self.presentationData.theme === presentationData.theme && self.incomingValue == incoming { return }
|
||||
self.presentationData = presentationData
|
||||
self.incomingValue = incoming
|
||||
self.titleAttributedString = InstantPageV2AudioContentNode.titleString(file: self.file, incoming: incoming, presentationData: presentationData)
|
||||
self.descriptionAttributedString = InstantPageV2AudioContentNode.descriptionString(file: self.file, incoming: incoming, presentationData: presentationData)
|
||||
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
|
||||
self.statusNode.backgroundNodeColor = messageTheme.mediaActiveControlColor
|
||||
// foreground/overlay also depend on incoming + theme (set at construction) — refresh them
|
||||
// too so the play glyph isn't miscolored after an in-place theme/direction change.
|
||||
self.statusNode.foregroundNodeColor = (incoming && messageTheme.mediaActiveControlColor.rgb != 0xffffff) ? .white : .clear
|
||||
self.statusNode.overlayForegroundNodeColor = presentationData.theme.chat.message.mediaOverlayControlColors.foregroundColor
|
||||
|
||||
// No setNeedsLayout(): this node doesn't override layout(); the host calls updateLayout(width:)
|
||||
// right after updatePresentationData, which re-runs the text layout with the rebuilt strings.
|
||||
}
|
||||
|
||||
private func updateStreamingState() {
|
||||
let state: SemanticStatusNodeState
|
||||
switch self.fetchStatus {
|
||||
case .none, .Local:
|
||||
state = .none
|
||||
case let .Fetching(_, progress):
|
||||
state = .progress(value: CGFloat(max(progress, 0.027)), cancelEnabled: true, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 1.0, lineWidth: 2.0), animateRotation: true)
|
||||
case .Remote, .Paused:
|
||||
state = .download
|
||||
}
|
||||
self.streamingStatusNode.transitionToState(state)
|
||||
}
|
||||
|
||||
// Line 1: track title at 17pt (= baseDisplaySize at the default font setting; scales with it).
|
||||
private static func titleString(file: TelegramMediaFile, incoming: Bool, presentationData: PresentationData) -> NSAttributedString {
|
||||
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
|
||||
let titleFont = Font.regular(floor(presentationData.chatFontSize.baseDisplaySize * 17.0 / 17.0))
|
||||
var title = file.fileName ?? "Unknown Track"
|
||||
for attribute in file.attributes {
|
||||
if case let .Audio(false, _, t, _, _) = attribute { title = t ?? title; break }
|
||||
}
|
||||
return NSAttributedString(string: title, font: titleFont, textColor: messageTheme.fileTitleColor)
|
||||
}
|
||||
|
||||
// Line 2: "<duration> · <performer>" at 15pt (omits the "· performer" tail when there's no
|
||||
// performer; omits the duration when it's absent).
|
||||
private static func descriptionString(file: TelegramMediaFile, incoming: Bool, presentationData: PresentationData) -> NSAttributedString {
|
||||
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
|
||||
let descriptionFont = Font.with(size: floor(presentationData.chatFontSize.baseDisplaySize * 15.0 / 17.0), design: .regular, weight: .regular, traits: [.monospacedNumbers])
|
||||
var performer = ""
|
||||
var durationSeconds: Int = 0
|
||||
for attribute in file.attributes {
|
||||
if case let .Audio(false, duration, _, p, _) = attribute {
|
||||
performer = (p ?? "").trimmingCharacters(in: .whitespaces)
|
||||
durationSeconds = duration
|
||||
break
|
||||
}
|
||||
}
|
||||
var text = ""
|
||||
if durationSeconds > 0 {
|
||||
text = String(format: "%d:%02d", Int32(durationSeconds / 60), Int32(durationSeconds % 60))
|
||||
}
|
||||
if !performer.isEmpty {
|
||||
text += text.isEmpty ? performer : " · \(performer)"
|
||||
}
|
||||
return NSAttributedString(string: text, font: descriptionFont, textColor: messageTheme.fileDescriptionColor)
|
||||
}
|
||||
|
||||
func updateLayout(width: CGFloat) {
|
||||
let progressFrame = CGRect(origin: InstantPageV2AudioContentNode.progressOrigin, size: CGSize(width: InstantPageV2AudioContentNode.progressDiameter, height: InstantPageV2AudioContentNode.progressDiameter))
|
||||
self.statusNode.frame = progressFrame
|
||||
let streamingDiameter: CGFloat = 24.0
|
||||
self.streamingStatusNode.frame = CGRect(origin: CGPoint(x: progressFrame.maxX - streamingDiameter + 2.0, y: progressFrame.maxY - streamingDiameter + 2.0), size: CGSize(width: streamingDiameter, height: streamingDiameter))
|
||||
|
||||
let controlAreaWidth = InstantPageV2AudioContentNode.controlAreaWidth
|
||||
let textWidth = max(1.0, width - controlAreaWidth - 8.0)
|
||||
let (titleLayout, titleApply) = TextNode.asyncLayout(self.titleNode)(TextNodeLayoutArguments(attributedString: self.titleAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .middle, constrainedSize: CGSize(width: textWidth, height: 100.0), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
|
||||
let (descLayout, descApply) = TextNode.asyncLayout(self.descriptionNode)(TextNodeLayoutArguments(attributedString: self.descriptionAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: textWidth, height: 100.0), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
|
||||
let _ = titleApply()
|
||||
let _ = descApply()
|
||||
|
||||
let titleAndDescriptionHeight = titleLayout.size.height - 1.0 + descLayout.size.height
|
||||
let normHeight = InstantPageV2AudioContentNode.normHeight
|
||||
let titleFrame = CGRect(origin: CGPoint(x: controlAreaWidth, y: floor((normHeight - titleAndDescriptionHeight) / 2.0)), size: titleLayout.size)
|
||||
self.titleNode.frame = titleFrame
|
||||
self.descriptionNode.frame = CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY - 1.0), size: descLayout.size)
|
||||
|
||||
// No scrubber. The tapView covers the full row so a tap anywhere toggles playback (there is
|
||||
// no scrubber pan to conflict with anymore).
|
||||
self.tapView.frame = CGRect(origin: .zero, size: CGSize(width: width, height: normHeight))
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import TelegramPresentationData
|
|||
import TelegramUIPreferences
|
||||
import TextFormat
|
||||
import TelegramStringFormatting
|
||||
import MosaicLayout
|
||||
|
||||
// MARK: - Public layout data types
|
||||
|
||||
|
|
@ -49,6 +50,8 @@ public struct InstantPageV2Layout {
|
|||
case let .mediaVideo(m): result.append(m.media)
|
||||
case let .mediaMap(m): result.append(m.media)
|
||||
case let .mediaCoverImage(m): result.append(m.media)
|
||||
case let .mediaAudio(m): result.append(m.media)
|
||||
case let .slideshow(s): result.append(contentsOf: s.medias)
|
||||
case let .details(d):
|
||||
if let inner = d.innerLayout {
|
||||
collectMedias(in: inner.items, into: &result)
|
||||
|
|
@ -84,8 +87,10 @@ public enum InstantPageV2LaidOutItem {
|
|||
case mediaVideo(InstantPageV2MediaVideoItem)
|
||||
case mediaMap(InstantPageV2MediaMapItem)
|
||||
case mediaCoverImage(InstantPageV2MediaCoverImageItem)
|
||||
case mediaAudio(InstantPageV2MediaAudioItem)
|
||||
case formula(InstantPageV2FormulaItem)
|
||||
case thinking(InstantPageV2ThinkingItem)
|
||||
case slideshow(InstantPageV2SlideshowItem)
|
||||
|
||||
public var frame: CGRect {
|
||||
switch self {
|
||||
|
|
@ -103,8 +108,10 @@ public enum InstantPageV2LaidOutItem {
|
|||
case let .mediaVideo(item): return item.frame
|
||||
case let .mediaMap(item): return item.frame
|
||||
case let .mediaCoverImage(item): return item.frame
|
||||
case let .mediaAudio(item): return item.frame
|
||||
case let .formula(item): return item.frame
|
||||
case let .thinking(item): return item.frame
|
||||
case let .slideshow(item): return item.frame
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -127,8 +134,10 @@ public enum InstantPageV2LaidOutItem {
|
|||
case var .mediaVideo(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaVideo(item)
|
||||
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 .mediaAudio(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaAudio(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)
|
||||
case var .slideshow(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .slideshow(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -236,6 +245,18 @@ public struct InstantPageV2MediaImageItem {
|
|||
}
|
||||
}
|
||||
|
||||
public struct InstantPageV2MediaAudioItem {
|
||||
public var frame: CGRect
|
||||
public let media: InstantPageMedia
|
||||
public let webPage: TelegramMediaWebpage
|
||||
|
||||
public init(frame: CGRect, media: InstantPageMedia, webPage: TelegramMediaWebpage) {
|
||||
self.frame = frame
|
||||
self.media = media
|
||||
self.webPage = webPage
|
||||
}
|
||||
}
|
||||
|
||||
public struct InstantPageV2MediaVideoItem {
|
||||
public var frame: CGRect
|
||||
public let cornerRadius: CGFloat
|
||||
|
|
@ -290,6 +311,18 @@ public struct InstantPageV2MediaPlaceholderItem {
|
|||
public let cornerRadius: CGFloat
|
||||
}
|
||||
|
||||
public struct InstantPageV2SlideshowItem {
|
||||
public var frame: CGRect
|
||||
public let medias: [InstantPageMedia]
|
||||
public let webPage: TelegramMediaWebpage
|
||||
|
||||
public init(frame: CGRect, medias: [InstantPageMedia], webPage: TelegramMediaWebpage) {
|
||||
self.frame = frame
|
||||
self.medias = medias
|
||||
self.webPage = webPage
|
||||
}
|
||||
}
|
||||
|
||||
public struct InstantPageV2DetailsItem {
|
||||
public var frame: CGRect
|
||||
public let index: Int
|
||||
|
|
@ -688,6 +721,7 @@ private func layoutBlock(
|
|||
caption: caption,
|
||||
isCover: isCover,
|
||||
cornerRadius: 8.0,
|
||||
flush: true,
|
||||
boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset,
|
||||
context: &context
|
||||
|
|
@ -727,6 +761,7 @@ private func layoutBlock(
|
|||
caption: caption,
|
||||
isCover: isCover,
|
||||
cornerRadius: 8.0,
|
||||
flush: true,
|
||||
boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset,
|
||||
context: &context
|
||||
|
|
@ -735,11 +770,34 @@ private func layoutBlock(
|
|||
return []
|
||||
}
|
||||
|
||||
case let .audio(_, caption):
|
||||
return layoutMediaWithCaption(kind: .audio,
|
||||
naturalSize: CGSize(width: boundingWidth, height: 56.0), caption: caption,
|
||||
isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset, context: &context)
|
||||
case let .audio(audioId, caption):
|
||||
guard case let .file(file) = context.media[audioId] else {
|
||||
return []
|
||||
}
|
||||
let mediaIndex = context.mediaIndexCounter
|
||||
context.mediaIndexCounter += 1
|
||||
let instantPageMedia = InstantPageMedia(
|
||||
index: mediaIndex,
|
||||
media: .file(file),
|
||||
url: nil,
|
||||
caption: nil,
|
||||
credit: nil
|
||||
)
|
||||
let audioFrame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: 44.0)
|
||||
var result: [InstantPageV2LaidOutItem] = [.mediaAudio(InstantPageV2MediaAudioItem(
|
||||
frame: audioFrame,
|
||||
media: instantPageMedia,
|
||||
webPage: context.webpage
|
||||
))]
|
||||
let (captionItems, _) = layoutCaptionAndCredit(
|
||||
caption,
|
||||
offset: audioFrame.height,
|
||||
boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset,
|
||||
context: &context
|
||||
)
|
||||
result.append(contentsOf: captionItems)
|
||||
return result
|
||||
|
||||
case let .webEmbed(url, _, dimensions, caption, _, _, coverId):
|
||||
// V1 (InstantPageLayout.swift:848): if the embed has a URL and a resolvable cover image,
|
||||
|
|
@ -800,6 +858,7 @@ private func layoutBlock(
|
|||
caption: caption,
|
||||
isCover: false,
|
||||
cornerRadius: 0.0,
|
||||
flush: true,
|
||||
boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset,
|
||||
context: &context
|
||||
|
|
@ -809,38 +868,48 @@ private func layoutBlock(
|
|||
let h: CGFloat = CGFloat(dimensions?.height ?? 240)
|
||||
return layoutMediaWithCaption(kind: .webEmbed,
|
||||
naturalSize: CGSize(width: boundingWidth, height: h), caption: caption,
|
||||
isCover: false, cornerRadius: 0.0, boundingWidth: boundingWidth,
|
||||
isCover: false, cornerRadius: 0.0, flush: true, boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset, context: &context)
|
||||
}
|
||||
|
||||
case let .postEmbed(_, _, _, _, _, _, caption):
|
||||
return layoutMediaWithCaption(kind: .postEmbed,
|
||||
naturalSize: CGSize(width: boundingWidth, height: 140.0), caption: caption,
|
||||
isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth,
|
||||
isCover: false, cornerRadius: 8.0, flush: true, boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset, context: &context)
|
||||
|
||||
case let .collage(_, caption):
|
||||
return layoutMediaWithCaption(kind: .collage,
|
||||
naturalSize: CGSize(width: boundingWidth, height: 240.0), caption: caption,
|
||||
isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset, context: &context)
|
||||
case let .collage(items, caption):
|
||||
return layoutCollage(items: items, caption: caption, isCover: isCover,
|
||||
boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context)
|
||||
|
||||
case let .slideshow(_, caption):
|
||||
return layoutMediaWithCaption(kind: .slideshow,
|
||||
naturalSize: CGSize(width: boundingWidth, height: 240.0), caption: caption,
|
||||
isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset, context: &context)
|
||||
case let .slideshow(items, caption):
|
||||
return layoutSlideshow(items: items, caption: caption,
|
||||
boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context)
|
||||
|
||||
case let .channelBanner(channel):
|
||||
if channel == nil { return [] }
|
||||
return layoutMediaWithCaption(kind: .channelBanner,
|
||||
naturalSize: CGSize(width: boundingWidth, height: 60.0),
|
||||
caption: InstantPageCaption(text: .empty, credit: .empty),
|
||||
isCover: false, cornerRadius: 0.0, boundingWidth: boundingWidth,
|
||||
isCover: false, cornerRadius: 0.0, flush: true, boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset, context: &context)
|
||||
|
||||
case let .map(latitude, longitude, zoom, dimensions, caption):
|
||||
let naturalSize = CGSize(width: CGFloat(dimensions.width), height: CGFloat(dimensions.height))
|
||||
// AI/server-sent `.map` blocks can arrive with zero `dimensions` (the wire `w`/`h` are
|
||||
// required, but the sender may put 0). A zero `naturalSize.height` collapses the media
|
||||
// frame to height 0 (`instantPageV2MediaFrame`'s else branch) — the map takes no space,
|
||||
// the caption slides up into it, and the pin floats over the caption — and a zero-sized
|
||||
// `MapSnapshotMediaResource` makes `MKMapSnapshotter` render nothing. Substitute a sensible
|
||||
// default (a 2:1 map strip) for BOTH the layout size and the snapshot resource. Real web
|
||||
// articles (the V1 renderer) always carry real dimensions, so only the rich-message path
|
||||
// hits this; the fallback is scoped here rather than in V1 or the wire/parse layer.
|
||||
let effectiveDimensions: PixelDimensions
|
||||
if dimensions.width > 0 && dimensions.height > 0 {
|
||||
effectiveDimensions = dimensions
|
||||
} else {
|
||||
effectiveDimensions = PixelDimensions(width: 600, height: 300)
|
||||
}
|
||||
let naturalSize = CGSize(width: CGFloat(effectiveDimensions.width), height: CGFloat(effectiveDimensions.height))
|
||||
let map = TelegramMediaMap(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
|
|
@ -850,7 +919,7 @@ private func layoutBlock(
|
|||
liveBroadcastingTimeout: nil,
|
||||
liveProximityNotificationRadius: nil
|
||||
)
|
||||
let mapAttributes: [InstantPageImageAttribute] = [InstantPageMapAttribute(zoom: zoom, dimensions: dimensions.cgSize)]
|
||||
let mapAttributes: [InstantPageImageAttribute] = [InstantPageMapAttribute(zoom: zoom, dimensions: effectiveDimensions.cgSize)]
|
||||
let mediaIndex = context.mediaIndexCounter
|
||||
context.mediaIndexCounter += 1
|
||||
let instantPageMedia = InstantPageMedia(
|
||||
|
|
@ -875,6 +944,7 @@ private func layoutBlock(
|
|||
caption: caption,
|
||||
isCover: false,
|
||||
cornerRadius: 8.0,
|
||||
flush: true,
|
||||
boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset,
|
||||
context: &context
|
||||
|
|
@ -885,7 +955,7 @@ private func layoutBlock(
|
|||
return layoutMediaWithCaption(kind: .relatedArticles,
|
||||
naturalSize: CGSize(width: boundingWidth, height: max(h, 80.0)),
|
||||
caption: InstantPageCaption(text: .empty, credit: .empty),
|
||||
isCover: false, cornerRadius: 0.0, boundingWidth: boundingWidth,
|
||||
isCover: false, cornerRadius: 0.0, flush: true, boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset, context: &context)
|
||||
|
||||
case let .formula(latex):
|
||||
|
|
@ -1644,6 +1714,60 @@ private func layoutCaptionAndCredit(
|
|||
return (items, totalHeight)
|
||||
}
|
||||
|
||||
// How many points a full-width flush media item bleeds past the bubble interior on the
|
||||
// trailing edge so the rounded `containerNode` clip (see ChatMessageRichDataBubbleContentNode) rounds
|
||||
// the trailing corners with no 1px background sliver. Harmless: the
|
||||
// `contentSize.width = min(maxX, boundingWidth)` clamp keeps it from widening the bubble.
|
||||
private let instantPageV2MediaEdgeBleed: CGFloat = 4.0
|
||||
|
||||
// Computes the laid-out frame for a block-media item.
|
||||
//
|
||||
// `flush == true` (every current caller): the media is edge-to-edge (x = 0, full
|
||||
// `boundingWidth`) with corner radius forced to 0, relying on the bubble's rounded clipping
|
||||
// container to round media that meets the bubble's top/bottom edge. A media item that fills the
|
||||
// full width is widened by `instantPageV2MediaEdgeBleed` on the trailing edge (see the constant).
|
||||
// A media item narrower than the full width (a small image — NOT upscaled, the `min(_, 1.0)`
|
||||
// scale cap is kept) stays at its natural size, flush-left at x = 0, with no bleed.
|
||||
// (The `cornerRadius` argument is ignored when `flush == true` — flush media is always
|
||||
// un-rounded; callers may still pass their legacy radius, it has no effect.)
|
||||
//
|
||||
// `flush == false`: DEAD as of the V2 audio port — audio was its last caller and now has its
|
||||
// own `layoutAudio` arm (in `layoutBlock`), so this branch is currently unreachable (follow-up:
|
||||
// drop the `flush` parameter and this branch). Legacy behavior was: inset by `horizontalInset`
|
||||
// on each side with the caller-supplied corner radius.
|
||||
//
|
||||
// Returns the frame, the un-bled scaled content size (the caption is offset by
|
||||
// `scaledSize.height`), and the effective corner radius to stamp on the item.
|
||||
private func instantPageV2MediaFrame(
|
||||
naturalSize: CGSize,
|
||||
flush: Bool,
|
||||
cornerRadius: CGFloat,
|
||||
boundingWidth: CGFloat,
|
||||
horizontalInset: CGFloat
|
||||
) -> (frame: CGRect, scaledSize: CGSize, cornerRadius: CGFloat) {
|
||||
let availableWidth = flush ? boundingWidth : (boundingWidth - horizontalInset * 2.0)
|
||||
let scaledSize: CGSize
|
||||
if naturalSize.width > 0.0 && naturalSize.height > 0.0 {
|
||||
let scale = min(availableWidth / naturalSize.width, 1.0)
|
||||
scaledSize = CGSize(width: floor(naturalSize.width * scale), height: floor(naturalSize.height * scale))
|
||||
} else {
|
||||
scaledSize = CGSize(width: availableWidth, height: naturalSize.height)
|
||||
}
|
||||
|
||||
if flush {
|
||||
// `floor(x) > x - 1` always, so a full-width item (scaledSize.width == floor(availableWidth))
|
||||
// always trips this; a genuinely smaller image does not. (availableWidth == boundingWidth
|
||||
// in the flush branch, so the bleed below extends past the full bounding width.)
|
||||
let fillsWidth = scaledSize.width >= availableWidth - 1.0
|
||||
let frameWidth = fillsWidth ? boundingWidth + instantPageV2MediaEdgeBleed : scaledSize.width
|
||||
let frame = CGRect(x: 0.0, y: 0.0, width: frameWidth, height: scaledSize.height)
|
||||
return (frame, scaledSize, 0.0)
|
||||
} else {
|
||||
let frame = CGRect(x: horizontalInset, y: 0.0, width: scaledSize.width, height: scaledSize.height)
|
||||
return (frame, scaledSize, cornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
/// Variant of `layoutMediaWithCaption` that emits a caller-produced typed media item
|
||||
/// instead of a `.mediaPlaceholder`. The frame-fitting logic + caption/credit text item
|
||||
/// layout is otherwise identical.
|
||||
|
|
@ -1653,21 +1777,19 @@ private func layoutTypedMediaWithCaption(
|
|||
caption: InstantPageCaption,
|
||||
isCover: Bool,
|
||||
cornerRadius: CGFloat,
|
||||
flush: Bool,
|
||||
boundingWidth: CGFloat,
|
||||
horizontalInset: CGFloat,
|
||||
context: inout LayoutContext
|
||||
) -> [InstantPageV2LaidOutItem] {
|
||||
let availableWidth = boundingWidth - horizontalInset * 2.0
|
||||
let scaledSize: CGSize
|
||||
if naturalSize.width > 0.0 && naturalSize.height > 0.0 {
|
||||
let scale = min(availableWidth / naturalSize.width, 1.0)
|
||||
scaledSize = CGSize(width: floor(naturalSize.width * scale), height: floor(naturalSize.height * scale))
|
||||
} else {
|
||||
scaledSize = CGSize(width: availableWidth, height: naturalSize.height)
|
||||
}
|
||||
|
||||
let mediaFrame = CGRect(x: horizontalInset, y: 0.0, width: scaledSize.width, height: scaledSize.height)
|
||||
var result: [InstantPageV2LaidOutItem] = [produceItem(mediaFrame, cornerRadius)]
|
||||
let (mediaFrame, scaledSize, effectiveCornerRadius) = instantPageV2MediaFrame(
|
||||
naturalSize: naturalSize,
|
||||
flush: flush,
|
||||
cornerRadius: cornerRadius,
|
||||
boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset
|
||||
)
|
||||
var result: [InstantPageV2LaidOutItem] = [produceItem(mediaFrame, effectiveCornerRadius)]
|
||||
|
||||
let (captionItems, captionHeight) = layoutCaptionAndCredit(
|
||||
caption,
|
||||
|
|
@ -1695,36 +1817,154 @@ private func layoutTypedMediaWithCaption(
|
|||
return result
|
||||
}
|
||||
|
||||
/// Lays out an `InstantPageBlock.collage(items:caption:)`. Mirrors V1
|
||||
/// (InstantPageLayout.swift:692-727): compute a mosaic over the inner image/video sizes, then emit
|
||||
/// one existing typed media item per cell at its mosaic frame, flush (cornerRadius 0) so the bubble's
|
||||
/// rounded clip handles the outer corners and the 1pt mosaic spacing handles the interior gaps. A
|
||||
/// single caption renders below the whole mosaic. Cells are top-level `.mediaImage`/`.mediaVideo`
|
||||
/// items, so gallery / reveal / registry / hidden-media all work with no extra code.
|
||||
private func layoutCollage(
|
||||
items innerBlocks: [InstantPageBlock],
|
||||
caption: InstantPageCaption,
|
||||
isCover: Bool,
|
||||
boundingWidth: CGFloat,
|
||||
horizontalInset: CGFloat,
|
||||
context: inout LayoutContext
|
||||
) -> [InstantPageV2LaidOutItem] {
|
||||
// 1. One size per inner block (zero for unresolved — V1 still reserves a mosaic slot).
|
||||
var itemSizes: [CGSize] = []
|
||||
for block in innerBlocks {
|
||||
switch block {
|
||||
case let .image(id, _, _, _):
|
||||
if case let .image(image) = context.media[id], let largest = largestImageRepresentation(image.representations) {
|
||||
itemSizes.append(CGSize(width: CGFloat(largest.dimensions.width), height: CGFloat(largest.dimensions.height)))
|
||||
} else {
|
||||
itemSizes.append(CGSize())
|
||||
}
|
||||
case let .video(id, _, _, _):
|
||||
if case let .file(file) = context.media[id], let dimensions = file.dimensions {
|
||||
itemSizes.append(CGSize(width: CGFloat(dimensions.width), height: CGFloat(dimensions.height)))
|
||||
} else {
|
||||
itemSizes.append(CGSize())
|
||||
}
|
||||
default:
|
||||
itemSizes.append(CGSize())
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Mosaic geometry — the same engine V1 uses.
|
||||
let (mosaic, mosaicSize) = chatMessageBubbleMosaicLayout(maxSize: CGSize(width: boundingWidth, height: boundingWidth), itemSizes: itemSizes)
|
||||
|
||||
// 3. One typed media item per resolvable cell, at its mosaic frame.
|
||||
var result: [InstantPageV2LaidOutItem] = []
|
||||
let webpage = context.webpage
|
||||
for (i, block) in innerBlocks.enumerated() {
|
||||
guard i < mosaic.count else { break }
|
||||
let (cellFrame, position) = mosaic[i]
|
||||
// Right-edge cells bleed 4pt so the bubble's rounded clip leaves no trailing sliver.
|
||||
var frame = cellFrame
|
||||
if position.contains(.right) {
|
||||
frame.size.width += instantPageV2MediaEdgeBleed
|
||||
}
|
||||
switch block {
|
||||
case let .image(id, blockCaption, url, webpageId):
|
||||
guard case let .image(image) = context.media[id] else { continue }
|
||||
let mediaIndex = context.mediaIndexCounter
|
||||
context.mediaIndexCounter += 1
|
||||
let mediaUrl: InstantPageUrlItem? = url.flatMap { InstantPageUrlItem(url: $0, webpageId: webpageId) }
|
||||
let media = InstantPageMedia(index: mediaIndex, media: .image(image), url: mediaUrl, caption: blockCaption.text, credit: blockCaption.credit)
|
||||
result.append(.mediaImage(InstantPageV2MediaImageItem(frame: frame, cornerRadius: 0.0, media: media, webPage: webpage, attributes: [])))
|
||||
case let .video(id, blockCaption, _, _):
|
||||
guard case let .file(file) = context.media[id] else { continue }
|
||||
let mediaIndex = context.mediaIndexCounter
|
||||
context.mediaIndexCounter += 1
|
||||
let media = InstantPageMedia(index: mediaIndex, media: .file(file), url: nil, caption: blockCaption.text, credit: blockCaption.credit)
|
||||
result.append(.mediaVideo(InstantPageV2MediaVideoItem(frame: frame, cornerRadius: 0.0, media: media, webPage: webpage, attributes: [])))
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Caption below the mosaic.
|
||||
let (captionItems, captionHeight) = layoutCaptionAndCredit(caption, offset: mosaicSize.height, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context)
|
||||
result.append(contentsOf: captionItems)
|
||||
|
||||
// Cover-caption padding parity with layoutTypedMediaWithCaption.
|
||||
if isCover && captionHeight > 0.0 {
|
||||
if let lastIndex = result.lastIndex(where: { if case .text = $0 { return true } else { return false } }) {
|
||||
if case var .text(lastText) = result[lastIndex] {
|
||||
lastText.frame = CGRect(origin: lastText.frame.origin, size: CGSize(width: lastText.frame.width, height: lastText.frame.height + 14.0))
|
||||
result[lastIndex] = .text(lastText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Lays out an `InstantPageBlock.slideshow(items:caption:)`. Mirrors V1
|
||||
/// (InstantPageLayout.swift:809-843): collect the inner image medias, size the block to the tallest
|
||||
/// image fitted into the bounding width (cap 1200), emit a single full-width slideshow carousel item,
|
||||
/// caption below. Only `.image` inner blocks contribute (matches V1).
|
||||
private func layoutSlideshow(
|
||||
items innerBlocks: [InstantPageBlock],
|
||||
caption: InstantPageCaption,
|
||||
boundingWidth: CGFloat,
|
||||
horizontalInset: CGFloat,
|
||||
context: inout LayoutContext
|
||||
) -> [InstantPageV2LaidOutItem] {
|
||||
var medias: [InstantPageMedia] = []
|
||||
var height: CGFloat = 0.0
|
||||
for block in innerBlocks {
|
||||
switch block {
|
||||
case let .image(id, blockCaption, url, webpageId):
|
||||
if case let .image(image) = context.media[id], let imageSize = largestImageRepresentation(image.representations)?.dimensions {
|
||||
let mediaIndex = context.mediaIndexCounter
|
||||
context.mediaIndexCounter += 1
|
||||
let filledSize = imageSize.cgSize.fitted(CGSize(width: boundingWidth, height: 1200.0))
|
||||
height = max(height, filledSize.height)
|
||||
let mediaUrl: InstantPageUrlItem? = url.flatMap { InstantPageUrlItem(url: $0, webpageId: webpageId) }
|
||||
medias.append(InstantPageMedia(index: mediaIndex, media: .image(image), url: mediaUrl, caption: blockCaption.text, credit: blockCaption.credit))
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var result: [InstantPageV2LaidOutItem] = []
|
||||
result.append(.slideshow(InstantPageV2SlideshowItem(
|
||||
frame: CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: height),
|
||||
medias: medias,
|
||||
webPage: context.webpage
|
||||
)))
|
||||
|
||||
let (captionItems, _) = layoutCaptionAndCredit(caption, offset: height, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context)
|
||||
result.append(contentsOf: captionItems)
|
||||
return result
|
||||
}
|
||||
|
||||
private func layoutMediaWithCaption(
|
||||
kind: InstantPageV2MediaPlaceholderKind,
|
||||
naturalSize: CGSize,
|
||||
caption: InstantPageCaption,
|
||||
isCover: Bool,
|
||||
cornerRadius: CGFloat,
|
||||
flush: Bool,
|
||||
boundingWidth: CGFloat,
|
||||
horizontalInset: CGFloat,
|
||||
context: inout LayoutContext
|
||||
) -> [InstantPageV2LaidOutItem] {
|
||||
// Scale naturalSize to fit within (boundingWidth - horizontalInset*2) × naturalSize.height.
|
||||
let availableWidth = boundingWidth - horizontalInset * 2.0
|
||||
let scaledSize: CGSize
|
||||
if naturalSize.width > 0.0 && naturalSize.height > 0.0 {
|
||||
let scale = min(availableWidth / naturalSize.width, 1.0)
|
||||
scaledSize = CGSize(width: floor(naturalSize.width * scale), height: floor(naturalSize.height * scale))
|
||||
} else {
|
||||
scaledSize = CGSize(width: availableWidth, height: naturalSize.height)
|
||||
}
|
||||
|
||||
let placeholderFrame = CGRect(
|
||||
x: horizontalInset,
|
||||
y: 0.0,
|
||||
width: scaledSize.width,
|
||||
height: scaledSize.height
|
||||
let (placeholderFrame, scaledSize, effectiveCornerRadius) = instantPageV2MediaFrame(
|
||||
naturalSize: naturalSize,
|
||||
flush: flush,
|
||||
cornerRadius: cornerRadius,
|
||||
boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset
|
||||
)
|
||||
let placeholderItem = InstantPageV2MediaPlaceholderItem(
|
||||
frame: placeholderFrame,
|
||||
kind: kind,
|
||||
cornerRadius: cornerRadius
|
||||
cornerRadius: effectiveCornerRadius
|
||||
)
|
||||
|
||||
var result: [InstantPageV2LaidOutItem] = [.mediaPlaceholder(placeholderItem)]
|
||||
|
|
@ -2134,8 +2374,8 @@ private func layoutQuoteText(
|
|||
let lineWidth = boundingWidth - horizontalInset * 2.0
|
||||
let topLine = InstantPageV2ShapeItem(
|
||||
frame: CGRect(x: horizontalInset, y: contentHeight, width: lineWidth, height: 1.0),
|
||||
kind: .line(thickness: 1.0),
|
||||
color: context.theme.textCategories.caption.color
|
||||
kind: .line(thickness: UIScreenPixel),
|
||||
color: context.theme.separatorColor
|
||||
)
|
||||
result.append(.shape(topLine))
|
||||
contentHeight += 1.0 + verticalInset // rule + small gap before body text
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import AccountContext
|
|||
import TelegramCore
|
||||
import TelegramPresentationData
|
||||
import GalleryUI
|
||||
import UniversalMediaPlayer
|
||||
|
||||
// Mutable weak box: lets a wrapper hand its `openMedia` closure a back-reference to itself,
|
||||
// filled in after `super.init` (when `self` becomes usable). SwiftSignalKit's `Weak<T>` requires
|
||||
|
|
@ -19,7 +20,7 @@ private final class WrapperRef {
|
|||
|
||||
// Hosts a V1 `InstantPageImageNode` inside a V2 UIView wrapper. The caller sizes its own
|
||||
// frame from `item.frame` and adds the returned node's view as a subview.
|
||||
private func makeMediaWrapper(
|
||||
func makeMediaWrapper(
|
||||
frame: CGRect,
|
||||
media: InstantPageMedia,
|
||||
webPage: TelegramMediaWebpage,
|
||||
|
|
@ -68,14 +69,14 @@ private func findEnclosingV2View(from start: UIView?) -> InstantPageV2View? {
|
|||
// its `rootMediaRegistryHost` chain transitively (nested details blocks can leave an inner
|
||||
// body's host pointing at an intermediate body — see `trueRegistryRoot`). No-op if the wrapper
|
||||
// isn't yet attached to a V2View ancestor.
|
||||
private func registerInRootRegistry(wrapper: UIView, mediaIndex: Int) {
|
||||
func registerInRootRegistry(wrapper: UIView, mediaIndex: Int) {
|
||||
guard let v2 = findEnclosingV2View(from: wrapper.superview) else { return }
|
||||
v2.trueRegistryRoot.mediaRegistry[mediaIndex] = Weak(wrapper)
|
||||
}
|
||||
|
||||
// Routes a tap on `tapped` through `openInstantPageMedia`, sourcing sibling medias from the
|
||||
// root V2View's `currentLayout`. No-op if the wrapper isn't currently in a V2View tree.
|
||||
private func handleOpenMediaTap(
|
||||
func handleOpenMediaTap(
|
||||
tapped: InstantPageMedia,
|
||||
wrapper: UIView,
|
||||
renderContext: InstantPageV2RenderContext
|
||||
|
|
@ -164,6 +165,14 @@ final class InstantPageV2MediaImageView: UIView, InstantPageItemView {
|
|||
let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings
|
||||
self.wrappedNode.update(strings: strings, theme: theme)
|
||||
}
|
||||
|
||||
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
|
||||
return self.wrappedNode.transitionNode(media: media)
|
||||
}
|
||||
|
||||
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
|
||||
self.wrappedNode.updateHiddenMedia(media: media)
|
||||
}
|
||||
}
|
||||
|
||||
final class InstantPageV2MediaVideoView: UIView, InstantPageItemView {
|
||||
|
|
@ -219,6 +228,14 @@ final class InstantPageV2MediaVideoView: UIView, InstantPageItemView {
|
|||
let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings
|
||||
self.wrappedNode.update(strings: strings, theme: theme)
|
||||
}
|
||||
|
||||
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
|
||||
return self.wrappedNode.transitionNode(media: media)
|
||||
}
|
||||
|
||||
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
|
||||
self.wrappedNode.updateHiddenMedia(media: media)
|
||||
}
|
||||
}
|
||||
|
||||
final class InstantPageV2MediaMapView: UIView, InstantPageItemView {
|
||||
|
|
@ -274,6 +291,14 @@ final class InstantPageV2MediaMapView: UIView, InstantPageItemView {
|
|||
let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings
|
||||
self.wrappedNode.update(strings: strings, theme: theme)
|
||||
}
|
||||
|
||||
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
|
||||
return self.wrappedNode.transitionNode(media: media)
|
||||
}
|
||||
|
||||
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
|
||||
self.wrappedNode.updateHiddenMedia(media: media)
|
||||
}
|
||||
}
|
||||
|
||||
final class InstantPageV2MediaCoverImageView: UIView, InstantPageItemView {
|
||||
|
|
@ -329,4 +354,142 @@ final class InstantPageV2MediaCoverImageView: UIView, InstantPageItemView {
|
|||
let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings
|
||||
self.wrappedNode.update(strings: strings, theme: theme)
|
||||
}
|
||||
|
||||
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
|
||||
return self.wrappedNode.transitionNode(media: media)
|
||||
}
|
||||
|
||||
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
|
||||
self.wrappedNode.updateHiddenMedia(media: media)
|
||||
}
|
||||
}
|
||||
|
||||
// Sets up shared-media playback for an audio tap. Mirrors V1's
|
||||
// `InstantPageControllerNode.openMedia(_:)` audio branch: collect the page's voice/music
|
||||
// medias from the root V2View's current layout, build an `InstantPageMediaPlaylist` keyed by
|
||||
// `playlistId`, and start playback. No-op if the wrapper isn't currently in a V2View tree.
|
||||
func handleOpenAudioTap(
|
||||
tapped: InstantPageMedia,
|
||||
wrapper: UIView,
|
||||
renderContext: InstantPageV2RenderContext,
|
||||
playlistId: InstantPageMediaPlaylistId
|
||||
) {
|
||||
guard case let .file(tappedFile) = tapped.media, tappedFile.isVoice || tappedFile.isMusic else { return }
|
||||
guard let v2 = findEnclosingV2View(from: wrapper.superview) else { return }
|
||||
let root = v2.trueRegistryRoot
|
||||
guard let layout = root.currentLayout else { return }
|
||||
|
||||
var audioMedias: [InstantPageMedia] = []
|
||||
var initialIndex = 0
|
||||
for media in layout.allMedias() {
|
||||
if case let .file(file) = media.media, (file.isVoice || file.isMusic) {
|
||||
if media.index == tapped.index {
|
||||
initialIndex = audioMedias.count
|
||||
}
|
||||
audioMedias.append(media)
|
||||
}
|
||||
}
|
||||
|
||||
let playlist = InstantPageMediaPlaylist(
|
||||
playlistId: playlistId,
|
||||
webPage: renderContext.webpage,
|
||||
messageReference: renderContext.message,
|
||||
items: audioMedias,
|
||||
initialItemIndex: initialIndex
|
||||
)
|
||||
renderContext.context.sharedContext.mediaManager.setPlaylist(
|
||||
(renderContext.context, playlist),
|
||||
type: tappedFile.isVoice ? .voice : .music,
|
||||
control: .playback(.play)
|
||||
)
|
||||
}
|
||||
|
||||
final class InstantPageV2MediaAudioView: UIView, InstantPageItemView {
|
||||
private(set) var item: InstantPageV2MediaAudioItem
|
||||
var itemFrame: CGRect { return self.item.frame }
|
||||
private let audioNode: InstantPageV2AudioContentNode
|
||||
|
||||
init(item: InstantPageV2MediaAudioItem, renderContext: InstantPageV2RenderContext, theme: InstantPageTheme) {
|
||||
self.item = item
|
||||
|
||||
// `.richMessage(messageId)` isolates playback state per chat message; the preview (no
|
||||
// message) falls back to the webpage-keyed id (only one preview is ever on screen).
|
||||
let playlistId: InstantPageMediaPlaylistId
|
||||
if let messageId = renderContext.message?.id {
|
||||
playlistId = .richMessage(messageId: messageId)
|
||||
} else {
|
||||
playlistId = .instantPage(webpageId: renderContext.webpage.webpageId)
|
||||
}
|
||||
|
||||
let wrapperRef = WrapperRef()
|
||||
let renderContextRef = renderContext
|
||||
let itemMedia = item.media
|
||||
|
||||
let presentationData = renderContext.context.sharedContext.currentPresentationData.with { $0 }
|
||||
let incoming = renderContext.message?.isIncoming == true
|
||||
let audioFile: TelegramMediaFile
|
||||
if case let .file(f) = item.media.media { audioFile = f } else { audioFile = TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: EmptyMediaResource(), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/mpeg", size: nil, attributes: [], alternativeRepresentations: []) }
|
||||
self.audioNode = InstantPageV2AudioContentNode(context: renderContext.context, message: renderContext.message, file: audioFile, incoming: incoming, presentationData: presentationData)
|
||||
|
||||
super.init(frame: item.frame)
|
||||
self.backgroundColor = .clear // structural
|
||||
self.addSubview(self.audioNode.view) // structural
|
||||
wrapperRef.view = self // structural: back-reference for the play closure
|
||||
|
||||
self.audioNode.play = {
|
||||
guard let wrapper = wrapperRef.view else { return }
|
||||
handleOpenAudioTap(tapped: itemMedia, wrapper: wrapper, renderContext: renderContextRef, playlistId: playlistId)
|
||||
}
|
||||
|
||||
let fetchContext = renderContext.context
|
||||
let fetchMessage = renderContext.message
|
||||
let fetchMedia = item.media
|
||||
self.audioNode.fetch = {
|
||||
guard case let .file(file) = fetchMedia.media, let message = fetchMessage, let messageId = message.id else { return }
|
||||
// Route through the fetch manager (not freeMediaFileInteractiveFetched) so the
|
||||
// messageMediaFileStatus signal — which keys progress off the fetch manager's
|
||||
// `hasEntry` — surfaces .Fetching, letting the overlay show the animated ring.
|
||||
let _ = messageMediaFileInteractiveFetched(fetchManager: fetchContext.fetchManager, messageId: messageId, messageReference: message, file: file, userInitiated: true, priority: .userInitiated).startStandalone()
|
||||
}
|
||||
|
||||
let mediaForPlayback = item.media
|
||||
let playlistTypeForPlayback: MediaManagerPlayerType
|
||||
if case let .file(f) = mediaForPlayback.media, f.isVoice { playlistTypeForPlayback = .voice } else { playlistTypeForPlayback = .music }
|
||||
let contextForPlayback = renderContext.context
|
||||
|
||||
self.audioNode.togglePlayPause = {
|
||||
contextForPlayback.sharedContext.mediaManager.playlistControl(.playback(.togglePlayPause), type: playlistTypeForPlayback)
|
||||
}
|
||||
|
||||
let stateSignal = contextForPlayback.sharedContext.mediaManager.filteredPlaylistState(accountId: contextForPlayback.account.id, playlistId: playlistId, itemId: InstantPageMediaPlaylistItemId(index: mediaForPlayback.index), type: playlistTypeForPlayback)
|
||||
self.audioNode.setPlaybackStatusSignal(stateSignal)
|
||||
|
||||
self.update(item: item, theme: theme, renderContext: renderContext)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
self.audioNode.frame = self.bounds
|
||||
self.audioNode.updateLayout(width: self.bounds.width)
|
||||
}
|
||||
|
||||
func update(item: InstantPageV2MediaAudioItem, theme: InstantPageTheme, renderContext: InstantPageV2RenderContext) {
|
||||
self.item = item
|
||||
let presentationData = renderContext.context.sharedContext.currentPresentationData.with { $0 }
|
||||
let incoming = renderContext.message?.isIncoming == true
|
||||
self.audioNode.updatePresentationData(presentationData, incoming: incoming)
|
||||
self.audioNode.updateLayout(width: self.bounds.width)
|
||||
}
|
||||
|
||||
// Audio is not a gallery item: explicit nil/no-op witnesses (per the existing pattern of
|
||||
// explicit per-class witnesses rather than a shared protocol-extension override).
|
||||
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -334,7 +334,7 @@ private func computeEntries(items: [InstantPageV2LaidOutItem], cursor: inout Int
|
|||
// 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,
|
||||
case .formula, .mediaImage, .mediaVideo, .mediaMap, .mediaCoverImage, .mediaAudio, .mediaPlaceholder, .slideshow,
|
||||
.divider, .listMarker, .blockQuoteBar, .shape, .anchor:
|
||||
let start = cursor
|
||||
cursor += itemWidthCost(item)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
import Foundation
|
||||
import UIKit
|
||||
import AsyncDisplayKit
|
||||
import Display
|
||||
import AccountContext
|
||||
import TelegramCore
|
||||
import TelegramPresentationData
|
||||
|
||||
// A paged carousel for an `InstantPageBlock.slideshow`. Ports V1's InstantPageSlideshowNode /
|
||||
// InstantPageSlideshowPagerNode (InstantPageSlideshowItemNode.swift), simplified to create all pages
|
||||
// eagerly (slideshows are short; this avoids V1's central±1 index bookkeeping and makes the gallery
|
||||
// transition source available for every page). Each image page hosts an `InstantPageImageNode` exactly
|
||||
// like the static media views; non-image medias render an empty page (matches V1).
|
||||
final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollViewDelegate {
|
||||
private(set) var item: InstantPageV2SlideshowItem
|
||||
var itemFrame: CGRect { return self.item.frame }
|
||||
|
||||
private let renderContext: InstantPageV2RenderContext
|
||||
private var theme: InstantPageTheme
|
||||
|
||||
private let scrollView: UIScrollView
|
||||
private let pageControlNode: PageControlNode
|
||||
|
||||
// One wrapper view per media (so page count stays aligned with the page control). `pageImageNodes`
|
||||
// holds only the real image nodes; it may be shorter than `pageViews` if a non-image media appears
|
||||
// (which `layoutSlideshow` currently filters out). Nothing relies on positional correspondence.
|
||||
private var pageViews: [UIView] = []
|
||||
private var pageImageNodes: [InstantPageImageNode] = []
|
||||
|
||||
init(item: InstantPageV2SlideshowItem, renderContext: InstantPageV2RenderContext, theme: InstantPageTheme) {
|
||||
self.item = item
|
||||
self.renderContext = renderContext
|
||||
self.theme = theme
|
||||
self.scrollView = UIScrollView()
|
||||
self.pageControlNode = PageControlNode(dotColor: .white, inactiveDotColor: UIColor(white: 1.0, alpha: 0.5))
|
||||
|
||||
super.init(frame: item.frame)
|
||||
|
||||
self.backgroundColor = theme.panelSecondaryColor // structural
|
||||
self.clipsToBounds = true // structural
|
||||
|
||||
self.scrollView.isPagingEnabled = true
|
||||
self.scrollView.showsHorizontalScrollIndicator = false
|
||||
self.scrollView.showsVerticalScrollIndicator = false
|
||||
self.scrollView.scrollsToTop = false
|
||||
if #available(iOS 11.0, *) {
|
||||
self.scrollView.contentInsetAdjustmentBehavior = .never
|
||||
}
|
||||
self.scrollView.delegate = self
|
||||
self.addSubview(self.scrollView) // structural
|
||||
|
||||
self.pageControlNode.isUserInteractionEnabled = false
|
||||
self.addSubview(self.pageControlNode.view) // structural
|
||||
|
||||
self.rebuildPages()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
private func rebuildPages() {
|
||||
for view in self.pageViews {
|
||||
view.removeFromSuperview()
|
||||
}
|
||||
self.pageViews = []
|
||||
self.pageImageNodes = []
|
||||
|
||||
let renderContext = self.renderContext
|
||||
// The image node owns this closure, and is owned (transitively) by self — capture weakly.
|
||||
let openMedia: (InstantPageMedia) -> Void = { [weak self] tapped in
|
||||
guard let self else { return }
|
||||
handleOpenMediaTap(tapped: tapped, wrapper: self, renderContext: renderContext)
|
||||
}
|
||||
|
||||
for media in self.item.medias {
|
||||
let pageView = UIView()
|
||||
pageView.clipsToBounds = true
|
||||
if case .image = media.media {
|
||||
let node = makeMediaWrapper(
|
||||
frame: CGRect(origin: .zero, size: self.item.frame.size),
|
||||
media: media,
|
||||
webPage: self.item.webPage,
|
||||
attributes: [],
|
||||
renderContext: self.renderContext,
|
||||
theme: self.theme,
|
||||
openMedia: openMedia,
|
||||
longPressMedia: { _ in }
|
||||
)
|
||||
pageView.addSubview(node.view)
|
||||
self.pageImageNodes.append(node)
|
||||
}
|
||||
// Non-image medias (none in practice — layoutSlideshow filters to images) get an empty page
|
||||
// to keep page indices aligned with the page control.
|
||||
self.scrollView.addSubview(pageView)
|
||||
self.pageViews.append(pageView)
|
||||
}
|
||||
|
||||
self.pageControlNode.pagesCount = self.item.medias.count
|
||||
self.pageControlNode.setPage(0.0)
|
||||
// Re-register media indices when rebuilding while already on-window (positional reuse with
|
||||
// changed content); no-ops before the view is attached, where didMoveToWindow handles it.
|
||||
self.registerMedias()
|
||||
self.setNeedsLayout()
|
||||
}
|
||||
|
||||
private func registerMedias() {
|
||||
guard self.window != nil else { return }
|
||||
// Register under every contained media index so transitionArgsFor(media) can find this view.
|
||||
for media in self.item.medias {
|
||||
registerInRootRegistry(wrapper: self, mediaIndex: media.index)
|
||||
}
|
||||
}
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
self.registerMedias()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let size = self.bounds.size
|
||||
guard size.width > 0.0, size.height > 0.0 else { return }
|
||||
|
||||
self.scrollView.frame = CGRect(origin: .zero, size: size)
|
||||
for (i, pageView) in self.pageViews.enumerated() {
|
||||
pageView.frame = CGRect(x: CGFloat(i) * size.width, y: 0.0, width: size.width, height: size.height)
|
||||
}
|
||||
for node in self.pageImageNodes {
|
||||
node.frame = CGRect(origin: .zero, size: size)
|
||||
}
|
||||
self.scrollView.contentSize = CGSize(width: CGFloat(self.pageViews.count) * size.width, height: size.height)
|
||||
|
||||
self.pageControlNode.layer.transform = CATransform3DIdentity
|
||||
self.pageControlNode.frame = CGRect(x: 0.0, y: size.height - 20.0, width: size.width, height: 20.0)
|
||||
let maxWidth = size.width - 36.0
|
||||
let pageControlSize = self.pageControlNode.calculateSizeThatFits(size)
|
||||
if pageControlSize.width > maxWidth, pageControlSize.width > 0.0 {
|
||||
let scale = maxWidth / pageControlSize.width
|
||||
self.pageControlNode.layer.transform = CATransform3DMakeScale(scale, scale, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
let width = self.bounds.size.width
|
||||
guard width > 0.0, !self.item.medias.isEmpty else { return }
|
||||
let page = Int((scrollView.contentOffset.x + width / 2.0) / width)
|
||||
let clamped = max(0, min(self.item.medias.count - 1, page))
|
||||
self.pageControlNode.setPage(CGFloat(clamped))
|
||||
}
|
||||
|
||||
func update(item: InstantPageV2SlideshowItem, theme: InstantPageTheme, renderContext: InstantPageV2RenderContext) {
|
||||
let mediasChanged = self.item.medias.map { $0.index } != item.medias.map { $0.index }
|
||||
self.item = item
|
||||
self.theme = theme
|
||||
self.backgroundColor = theme.panelSecondaryColor
|
||||
if mediasChanged {
|
||||
self.rebuildPages()
|
||||
} else {
|
||||
let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings
|
||||
for node in self.pageImageNodes {
|
||||
node.update(strings: strings, theme: theme)
|
||||
}
|
||||
}
|
||||
self.setNeedsLayout()
|
||||
}
|
||||
|
||||
// MARK: InstantPageItemView gallery hooks
|
||||
|
||||
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
|
||||
for node in self.pageImageNodes {
|
||||
if let transition = node.transitionNode(media: media) {
|
||||
return transition
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
|
||||
for node in self.pageImageNodes {
|
||||
node.updateHiddenMedia(media: media)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -901,6 +901,18 @@ func revalidateMediaResourceReference(accountPeerId: PeerId, postbox: Postbox, n
|
|||
return .single(RevalidatedMediaResource(updatedResource: updatedResource, updatedReference: nil))
|
||||
}
|
||||
}
|
||||
// Rich-text messages (`RichTextMessageAttribute`) embed their media in the
|
||||
// attribute's `InstantPage`, not in `message.media` — search there too so a
|
||||
// stale instant-page audio/image file reference can revalidate.
|
||||
for attribute in message.attributes {
|
||||
if let attribute = attribute as? RichTextMessageAttribute {
|
||||
for (_, pageMedia) in attribute.instantPage.media {
|
||||
if let updatedResource = findUpdatedMediaResource(media: pageMedia, previousMedia: previousMedia, resource: resource) {
|
||||
return .single(RevalidatedMediaResource(updatedResource: updatedResource, updatedReference: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return .fail(.generic)
|
||||
}
|
||||
case let .stickerPack(stickerPack, media):
|
||||
|
|
|
|||
|
|
@ -139,7 +139,8 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
|||
},
|
||||
baseNavigationController: { [weak self] in
|
||||
self?.item?.controllerInteraction.navigationController()
|
||||
}
|
||||
},
|
||||
message: messageReference
|
||||
)
|
||||
let view = InstantPageV2View(renderContext: renderContext)
|
||||
self.pageView = view
|
||||
|
|
|
|||
|
|
@ -453,6 +453,7 @@ final class PeerSelectionControllerNode: ASDisplayNode {
|
|||
|
||||
var hasOther = false
|
||||
var hasNotOwnMessages = false
|
||||
var hasRichMessages = false
|
||||
for message in messages {
|
||||
if let author = message.effectiveAuthor {
|
||||
if !uniquePeerIds.contains(author.id) {
|
||||
|
|
@ -479,9 +480,12 @@ final class PeerSelectionControllerNode: ASDisplayNode {
|
|||
if !isDice {
|
||||
hasOther = true
|
||||
}
|
||||
if message.richText != nil {
|
||||
hasRichMessages = true
|
||||
}
|
||||
}
|
||||
|
||||
let canHideNames = hasNotOwnMessages && hasOther
|
||||
let canHideNames = hasNotOwnMessages && hasOther && !hasRichMessages
|
||||
|
||||
let hideNames = forwardOptions.hideNames
|
||||
let hideCaptions = forwardOptions.hideCaptions
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ private func chatForwardOptions(selfController: ChatControllerImpl, sourceView:
|
|||
var hasOther = false
|
||||
var hasNotOwnMessages = false
|
||||
var hasPaid = false
|
||||
var hasRichMessages = false
|
||||
for message in messages {
|
||||
if let author = message.effectiveAuthor {
|
||||
if !uniquePeerIds.contains(author.id) {
|
||||
|
|
@ -175,9 +176,12 @@ private func chatForwardOptions(selfController: ChatControllerImpl, sourceView:
|
|||
if !isDice {
|
||||
hasOther = true
|
||||
}
|
||||
if message.richText != nil {
|
||||
hasRichMessages = true
|
||||
}
|
||||
}
|
||||
|
||||
var canHideNames = hasNotOwnMessages && hasOther
|
||||
var canHideNames = hasNotOwnMessages && hasOther && !hasRichMessages
|
||||
if case let .peer(peerId) = selfController.chatLocation, peerId.namespace == Namespaces.Peer.SecretChat {
|
||||
canHideNames = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ final class ChatSendMessageRichTextPreview: ChatSendMessageContextScreenRichText
|
|||
present: { _, _ in },
|
||||
push: { _ in },
|
||||
openUrl: { _ in },
|
||||
baseNavigationController: { return nil }
|
||||
baseNavigationController: { return nil },
|
||||
message: nil
|
||||
)
|
||||
self.pageView = InstantPageV2View(renderContext: renderContext)
|
||||
self.pageView.isUserInteractionEnabled = false
|
||||
|
|
|
|||
|
|
@ -4861,7 +4861,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate {
|
|||
if case .customChatContents = self.chatPresentationInterfaceState.subject {
|
||||
isSpecialChatContents = true
|
||||
}
|
||||
if !isSpecialChatContents, let attribute = richMarkdownAttributeIfNeeded(context: self.context, attributedText: effectiveInputText) {
|
||||
if !"".isEmpty, !isSpecialChatContents, let attribute = richMarkdownAttributeIfNeeded(context: self.context, attributedText: effectiveInputText) {
|
||||
let attributes: [MessageAttribute] = [attribute]
|
||||
var richBubbleUpEmojiOrStickersets: [ItemCollectionId] = []
|
||||
for (_, packId) in bubbleUpEmojiOrStickersetsById {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue