Cleanup
This commit is contained in:
parent
9a86e79b5c
commit
689f0408d2
6 changed files with 0 additions and 3542 deletions
|
|
@ -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).
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
# Gate Rich Text messages behind Premium — design
|
||||
|
||||
**Date:** 2026-06-02
|
||||
**Status:** Approved (design); ready for implementation plan.
|
||||
|
||||
## Problem
|
||||
|
||||
Rich Text messages (`RichTextMessageAttribute` carrying an `InstantPage` — headings, lists,
|
||||
tables, formulas) are auto-produced from typed markdown on send/edit (see
|
||||
[`docs/instantpage-richtext.md`](../../instantpage-richtext.md), "Markdown send: entity vs. rich
|
||||
detection"). We want sending/creating a Rich Text message to be a **Telegram Premium** feature:
|
||||
non-premium users are blocked at the point of sending or editing-into-rich and offered the Premium
|
||||
upsell, mirroring how the existing **todo/checklist** message type is gated.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope — gating message *creation* (the markdown → rich path):**
|
||||
|
||||
- Compose + send.
|
||||
- Editing a message whose new markdown would produce rich.
|
||||
- The long-press "Send options" sheet (its preview, and — transitively — its send).
|
||||
|
||||
**Out of scope (explicit non-goals):**
|
||||
|
||||
- **Rendering / receiving** rich messages. Non-premium users must still *see* formatted messages
|
||||
others send. Only the create/send side is gated.
|
||||
- No new `PremiumSource` enum case — the paywall reuses the generic `.settings` source.
|
||||
- No server promo-content / `premiumPromoConfiguration` changes.
|
||||
|
||||
## Behavior
|
||||
|
||||
When a non-premium user composes or edits markdown that the classifier
|
||||
(`richMarkdownAttributeIfNeeded`) would turn into a Rich Text message, the action is **blocked**
|
||||
and a `.premiumPaywall` toast is shown. Tapping the toast's info action opens the Premium intro
|
||||
screen. The user's typed text is **preserved** in the input/edit field (the send/edit simply does
|
||||
not proceed).
|
||||
|
||||
This matches the todo gate precedent at `ChatController.swift:5668` and
|
||||
`ChatControllerOpenTodoContextMenu.swift:71`.
|
||||
|
||||
### Gate conditions
|
||||
|
||||
The gate fires **iff all** of the following hold:
|
||||
|
||||
1. The text would produce a rich message (`richMarkdownAttributeIfNeeded(...) != nil`).
|
||||
2. The account is **not** premium.
|
||||
3. The chat is **not** the user's own Saved Messages (`peerId != context.account.peerId`).
|
||||
— "notes to self" carve-out, matching the premium-emoji gate
|
||||
(`ChatControllerNode.swift:4742`).
|
||||
4. Premium is **not** disabled in this region/build
|
||||
(`!PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }).isPremiumDisabled`).
|
||||
— so rich text isn't permanently blocked where Premium can't be purchased. (Note: the todo gate
|
||||
does **not** do this; we deliberately add it here.)
|
||||
|
||||
If premium is disabled, rich text behaves exactly as today (free for everyone).
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Gate-decision helper (shared free function)
|
||||
|
||||
New file `submodules/TelegramUI/Sources/Chat/ChatRichTextPremiumGate.swift`:
|
||||
|
||||
```swift
|
||||
func isRichTextMessageGated(context: AccountContext, peerId: EnginePeer.Id?, isPremium: Bool) -> Bool {
|
||||
if isPremium {
|
||||
return false
|
||||
}
|
||||
if let peerId, peerId == context.account.peerId {
|
||||
return false // Saved Messages carve-out
|
||||
}
|
||||
let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
|
||||
if premiumConfiguration.isPremiumDisabled {
|
||||
return false // premium-disabled regions
|
||||
}
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
Used by all three sites (Sites 1 & 2 to decide whether to block; Site 3 to decide whether to build
|
||||
the preview). It does **not** itself call the classifier — each site already has (or computes) the
|
||||
`richMarkdownAttributeIfNeeded` result and combines it with this decision.
|
||||
|
||||
### 2. Paywall-presentation helper (method on `ChatControllerImpl`)
|
||||
|
||||
The toast is presented from the controller, which already owns `present`/`push`/`presentationData`.
|
||||
A new method on `ChatControllerImpl` (the same type the todo gate at `ChatController.swift:5668`
|
||||
lives on):
|
||||
|
||||
```swift
|
||||
func presentRichTextPremiumPaywall() {
|
||||
let controller = UndoOverlayController(
|
||||
presentationData: self.presentationData,
|
||||
content: .premiumPaywall(title: nil, text: self.presentationData.strings.Chat_RichText_PremiumRequired, customUndoText: nil, timeout: nil, linkAction: nil),
|
||||
action: { [weak self] action in
|
||||
guard let self else {
|
||||
return false
|
||||
}
|
||||
if case .info = action {
|
||||
let controller = self.context.sharedContext.makePremiumIntroController(context: self.context, source: .settings, forceDark: false, dismissed: nil)
|
||||
self.push(controller)
|
||||
}
|
||||
return false
|
||||
}
|
||||
)
|
||||
self.present(controller, in: .current)
|
||||
}
|
||||
```
|
||||
|
||||
- New localized string `Chat_RichText_PremiumRequired`, e.g. *"Subscribe to Telegram Premium to
|
||||
send formatted messages with headings, lists and tables."* Added to the strings infra alongside
|
||||
the existing `Chat_Todo_PremiumRequired`.
|
||||
- `source: .settings` (generic) per the design decision — no dedicated enum case.
|
||||
|
||||
### 3. Wiring at the three entry points
|
||||
|
||||
**Site 1 — Send.** `ChatControllerNode.sendCurrentMessage` (~`ChatControllerNode.swift:4864`),
|
||||
the rich branch. After `richMarkdownAttributeIfNeeded` returns non-nil:
|
||||
|
||||
```swift
|
||||
if !isSpecialChatContents, let attribute = richMarkdownAttributeIfNeeded(context: self.context, attributedText: effectiveInputText) {
|
||||
if isRichTextMessageGated(context: self.context, peerId: self.chatPresentationInterfaceState.chatLocation.peerId, isPremium: self.chatPresentationInterfaceState.isPremium) {
|
||||
self.controller?.presentRichTextPremiumPaywall()
|
||||
return
|
||||
}
|
||||
// ... existing rich-send code unchanged ...
|
||||
}
|
||||
```
|
||||
|
||||
`ChatControllerNode` reaches the controller via `self.controller` (a `ChatControllerImpl`). The
|
||||
early `return` aborts the whole send. **This site also covers the long-press send-options sheet's
|
||||
*send***: `ChatMessageDisplaySendMessageOptions`'s `.generic`/`.silently` modes call
|
||||
`controllerInteraction?.sendCurrentMessage` → `ChatController.swift:2228` → `chatDisplayNode.sendCurrentMessage`,
|
||||
and `.whenOnline` calls `chatDisplayNode.sendCurrentMessage` directly — all land in Site 1.
|
||||
|
||||
**Site 2 — Edit save.** `ChatControllerLoadDisplayNode` editMessage (~`:2224`). After computing
|
||||
`richTextAttribute`:
|
||||
|
||||
```swift
|
||||
if let richTextAttribute, isRichTextMessageGated(context: strongSelf.context, peerId: strongSelf.chatLocation.peerId, isPremium: strongSelf.presentationInterfaceState.isPremium) {
|
||||
strongSelf.presentRichTextPremiumPaywall()
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
`ChatControllerLoadDisplayNode` is an extension of `ChatControllerImpl`, so `strongSelf` calls the
|
||||
helper directly. A **rich→plain** edit produces `richTextAttribute == nil`, so it is not gated and
|
||||
still saves normally.
|
||||
|
||||
**Site 3 — Send-options preview.** `ChatMessageDisplaySendMessageOptions` (~`:219`). Build the
|
||||
rich preview **only when not gated**:
|
||||
|
||||
```swift
|
||||
} else if mediaPreview == nil,
|
||||
let attributedText = textInputView.attributedText,
|
||||
let attribute = richMarkdownAttributeIfNeeded(context: selfController.context, attributedText: attributedText),
|
||||
!isRichTextMessageGated(context: selfController.context, peerId: selfController.presentationInterfaceState.chatLocation.peerId, isPremium: selfController.presentationInterfaceState.isPremium) {
|
||||
richTextPreview = ChatSendMessageRichTextPreview(context: selfController.context, instantPage: attribute.instantPage)
|
||||
}
|
||||
```
|
||||
|
||||
A gated user sees the **plain** preview in the options sheet (consistent with the block), and the
|
||||
actual send is stopped by Site 1's gate. No separate toast is presented here.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Editing an already-rich message as non-premium.** Only reachable if the user was formerly
|
||||
premium (or in Saved Messages, which is exempt). Site 2 blocks re-saving it as rich; a
|
||||
rich→plain edit still works.
|
||||
- **Premium disabled.** Gate 4 short-circuits everywhere → rich text is free, as today.
|
||||
- **`isSpecialChatContents` (business links / quick replies).** Already bypassed before the gate at
|
||||
Sites 1 & 3; unchanged.
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `submodules/TelegramUI/Sources/Chat/ChatRichTextPremiumGate.swift` (new) | `isRichTextMessageGated(...)` free function. |
|
||||
| `submodules/TelegramUI/Sources/ChatController.swift` | `presentRichTextPremiumPaywall()` method on `ChatControllerImpl`. |
|
||||
| `submodules/TelegramUI/Sources/ChatControllerNode.swift` (~4864) | Site 1 gate. |
|
||||
| `submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift` (~2224) | Site 2 gate. |
|
||||
| `submodules/TelegramUI/Sources/Chat/ChatMessageDisplaySendMessageOptions.swift` (~219) | Site 3 preview suppression. |
|
||||
| Localizable strings | New `Chat_RichText_PremiumRequired`. |
|
||||
|
||||
## Verification
|
||||
|
||||
No unit tests in this project. Verify via the full Bazel build, then manual two-account smoke test:
|
||||
|
||||
1. Non-premium account, regular chat: type `# Heading\n- a\n- b`, send → paywall toast; tapping it
|
||||
opens Premium intro; text remains in input; nothing sent.
|
||||
2. Same, edit an existing plain message into a table → paywall on save; original message unchanged.
|
||||
3. Same, long-press send button on rich markdown → options sheet shows **plain** preview; sending
|
||||
from it → paywall.
|
||||
4. Non-premium account, **Saved Messages**: same rich markdown sends normally (carve-out).
|
||||
5. Premium account: rich markdown sends/edits normally (no toast).
|
||||
6. Non-premium account, plain markdown (e.g. `**bold**`, `---`): sends normally (not a rich
|
||||
trigger).
|
||||
Loading…
Add table
Add a link
Reference in a new issue