diff --git a/docs/superpowers/plans/2026-05-05-shimmering-mask-view.md b/docs/superpowers/plans/2026-05-05-shimmering-mask-view.md deleted file mode 100644 index 7e748a2e0d..0000000000 --- a/docs/superpowers/plans/2026-05-05-shimmering-mask-view.md +++ /dev/null @@ -1,552 +0,0 @@ -# ShimmeringMaskView 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:** Build a reusable `ShimmeringMaskView` (alpha-mask "running shimmer" effect) and wire it as the host for `streamingStatusTextNode` in `ChatMessageTextBubbleContentNode` to give the streaming-status line a ChatGPT-style "thinking" effect. - -**Architecture:** Single `CAGradientLayer` set as `contentView.layer.mask`. Horizontal three-stop gradient `[white@1.0, white@peakAlpha, white@1.0]` with the dip parked at the layer's bounds center; layer is oversized (`size.width + 2 × travelDistance`) so its `alpha=1.0` edges keep `contentView` covered at every animation phase. A `position.x` `CABasicAnimation` (`additive: true`, `repeatCount: .infinity`, `easeOut`) shifts the dip across the wave path. `HierarchyTrackingLayer` re-arms the animation when the view re-enters the hierarchy. API mirrors `VideoChatVideoLoadingEffectView` (init takes appearance constants; `update` takes layout values + a `ComponentTransition`). - -**Tech Stack:** Swift, UIKit, `CAGradientLayer`, `CABasicAnimation`, `HierarchyTrackingLayer`, `ComponentFlow.ComponentTransition`, Bazel (`swift_library`). - -**Reference reading (no edits needed):** -- Spec: `docs/superpowers/specs/2026-05-05-shimmering-mask-view-design.md` -- Pattern reference: `submodules/TelegramCallsUI/Sources/VideoChatVideoLoadingEffectView.swift` -- Pattern reference: `submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/LoadingEffectView.swift` -- Pattern reference: `submodules/TelegramUI/Components/TextLoadingEffect/Sources/TextLoadingEffect.swift` - -**Important context — no unit tests in this project:** -This codebase has no unit-test harness (see `CLAUDE.md`: *"No tests are used at the moment"*). Verification is done by running the full Bazel build and visually inspecting the result. The "test" steps in this plan therefore replace per-task pytest-style verification with **build steps** that compile the affected modules, plus one explicit manual run-the-app step at the end. - -**Build invocation used throughout this plan:** - -```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 -``` - -The `source ~/.zshrc 2>/dev/null;` prefix is required to pick up `TELEGRAM_CODESIGNING_GIT_PASSWORD`. Bazel is the only supported build path; there is no per-module build target — the full `Telegram/Telegram` app is built. First build of a fresh worktree may take 10+ minutes; incremental builds during this plan are typically 30s–2min. - ---- - -## File Structure - -| Action | Path | Responsibility | -|---|---|---| -| **Modify** | `submodules/TelegramUI/Components/ShimmeringMask/Sources/ShimmeringMaskView.swift` | Replace stub with full implementation. | -| **Modify** | `submodules/TelegramUI/Components/ShimmeringMask/BUILD` | Trim deps to `ComponentFlow` + `Components/HierarchyTrackingLayer`. | -| **Modify** | `submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift` | Wrap `streamingStatusTextNode` in a `ShimmeringMaskView`; route position/bounds/alpha animation to the wrapper. | - -The `ShimmeringMask` module is already wired as a dep on the `ChatMessageTextBubbleContentNode` library (we confirmed `submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD` has `"//submodules/TelegramUI/Components/ShimmeringMask"`), and the consumer file already has `import ShimmeringMask`. No BUILD edits are needed for the consumer side. - ---- - -### Task 1: Trim BUILD deps for ShimmeringMask - -**Files:** -- Modify: `submodules/TelegramUI/Components/ShimmeringMask/BUILD` - -- [ ] **Step 1: Open the BUILD file** - -Read `submodules/TelegramUI/Components/ShimmeringMask/BUILD` to confirm current contents. - -- [ ] **Step 2: Replace deps with the trimmed list** - -Replace the existing `deps` block in `submodules/TelegramUI/Components/ShimmeringMask/BUILD` so the file matches: - -```python -load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") - -swift_library( - name = "ShimmeringMask", - module_name = "ShimmeringMask", - srcs = glob([ - "Sources/**/*.swift", - ]), - copts = [ - "-warnings-as-errors", - ], - deps = [ - "//submodules/ComponentFlow", - "//submodules/Components/HierarchyTrackingLayer", - ], - visibility = [ - "//visibility:public", - ], -) -``` - -The removed deps (`AsyncDisplayKit`, `Display`, `ShimmerEffect`) are not used by the new implementation. The added dep (`Components/HierarchyTrackingLayer`) is needed for the pause/resume-on-hierarchy pattern. - -- [ ] **Step 3: Build to confirm BUILD parses** - -The current stub `ShimmeringMaskView.swift` imports `Display`, `ShimmerEffect`, `AsyncDisplayKit`, and `ComponentFlow`. Trimming the BUILD deps without first updating the source would break the build. Skip building until Task 2's source replacement lands. (We'll build after Task 2.) - -- [ ] **Step 4: Stage but don't commit yet** - -```sh -git add submodules/TelegramUI/Components/ShimmeringMask/BUILD -``` - -The commit will happen at the end of Task 2 to keep the BUILD + source change atomic. - ---- - -### Task 2: Replace ShimmeringMaskView stub with full implementation - -**Files:** -- Modify: `submodules/TelegramUI/Components/ShimmeringMask/Sources/ShimmeringMaskView.swift` - -- [ ] **Step 1: Verify the stub matches what we expect** - -Read `submodules/TelegramUI/Components/ShimmeringMask/Sources/ShimmeringMaskView.swift`. Confirm it contains the stub (a `ShimmeringMaskView` class with `public let contentView: UIView`, `init(frame:)`, and an empty `update(size:transition:)`). If it has diverged, stop and ask for direction; otherwise proceed. - -- [ ] **Step 2: Rewrite the file** - -Replace the entire contents of `submodules/TelegramUI/Components/ShimmeringMask/Sources/ShimmeringMaskView.swift` with: - -```swift -import Foundation -import UIKit -import ComponentFlow -import HierarchyTrackingLayer - -public final class ShimmeringMaskView: UIView { - private struct Params: Equatable { - var size: CGSize - var containerWidth: CGFloat - var offsetX: CGFloat - var gradientWidth: CGFloat - } - - public let contentView: UIView - - private let peakAlpha: CGFloat - private let duration: Double - - private let hierarchyTrackingLayer: HierarchyTrackingLayer - private let maskLayer: CAGradientLayer - - private var params: Params? - - public init(peakAlpha: CGFloat, duration: Double) { - self.peakAlpha = peakAlpha - self.duration = duration - - self.contentView = UIView() - - self.hierarchyTrackingLayer = HierarchyTrackingLayer() - - self.maskLayer = CAGradientLayer() - self.maskLayer.startPoint = CGPoint(x: 0.0, y: 0.5) - self.maskLayer.endPoint = CGPoint(x: 1.0, y: 0.5) - self.maskLayer.colors = [ - UIColor(white: 1.0, alpha: 1.0).cgColor, - UIColor(white: 1.0, alpha: peakAlpha).cgColor, - UIColor(white: 1.0, alpha: 1.0).cgColor - ] - self.maskLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5) - - super.init(frame: CGRect()) - - self.addSubview(self.contentView) - self.contentView.layer.mask = self.maskLayer - - self.layer.addSublayer(self.hierarchyTrackingLayer) - self.hierarchyTrackingLayer.didEnterHierarchy = { [weak self] in - guard let self else { - return - } - self.updateAnimations() - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func updateAnimations() { - guard let params = self.params else { - return - } - if self.maskLayer.animation(forKey: "shimmer") != nil { - return - } - let travelDelta = params.containerWidth + params.gradientWidth - let animation = self.maskLayer.makeAnimation( - from: 0.0 as NSNumber, - to: travelDelta as NSNumber, - keyPath: "position.x", - timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, - duration: self.duration, - delay: 0.0, - mediaTimingFunction: nil, - removeOnCompletion: true, - additive: true - ) - animation.repeatCount = Float.infinity - self.maskLayer.add(animation, forKey: "shimmer") - } - - public func update( - size: CGSize, - containerWidth: CGFloat, - offsetX: CGFloat, - gradientWidth: CGFloat, - transition: ComponentTransition - ) { - let params = Params( - size: size, - containerWidth: containerWidth, - offsetX: offsetX, - gradientWidth: gradientWidth - ) - if self.params == params { - return - } - self.params = params - - transition.setFrame(view: self.contentView, frame: CGRect(origin: CGPoint(), size: size)) - - let travelDistance = containerWidth + gradientWidth - let maskWidth = size.width + 2.0 * travelDistance - - let dipHalfFraction: CGFloat - if maskWidth > 0.0 { - dipHalfFraction = (gradientWidth * 0.5) / maskWidth - } else { - dipHalfFraction = 0.0 - } - self.maskLayer.locations = [ - (0.5 - dipHalfFraction) as NSNumber, - 0.5 as NSNumber, - (0.5 + dipHalfFraction) as NSNumber - ] - - let maskBounds = CGRect(origin: CGPoint(), size: CGSize(width: maskWidth, height: size.height)) - let staticPositionX = -gradientWidth * 0.5 - offsetX - let maskPosition = CGPoint(x: staticPositionX, y: size.height * 0.5) - - transition.setBounds(layer: self.maskLayer, bounds: maskBounds) - transition.setPosition(layer: self.maskLayer, position: maskPosition) - - self.maskLayer.removeAnimation(forKey: "shimmer") - self.updateAnimations() - } -} -``` - -Notes on the code (do not alter): -- `super.init(frame: CGRect())` is intentional — callers always size via `update(...)`; init takes appearance constants only. -- `maskLayer.anchorPoint = (0.5, 0.5)` and the mask is parented in `contentView.layer`'s coord system (because `contentView.layer.mask = maskLayer`). So `maskLayer.position.x = -gradientWidth/2 - offsetX` puts the dip just off-left of the container in `contentView` coords. -- `dipHalfFraction` is `(gradientWidth/2) / maskWidth` because `CAGradientLayer.locations` are normalized to the *layer's* bounds. With locations `[0.5 − Δ, 0.5, 0.5 + Δ]` the dip occupies `gradientWidth` pixels centered in `maskWidth`. -- The `if maskWidth > 0.0` guard avoids divide-by-zero on a zero-sized first call. -- Animation re-arm is unconditional whenever params change (intentionally — tradeoff documented in the spec). -- `makeAnimation(...)` is a `CALayer` extension provided by `Display`/`ComponentFlow` (it's used identically in the reference files). It *is* available without importing `Display` because the helper is on `ComponentFlow`'s import surface that we already pull in. If the build complains that `makeAnimation` is unresolved, add `import Display` and `"//submodules/Display"` to the BUILD deps — but check first. - -- [ ] **Step 3: Verify the `makeAnimation` symbol resolves** - -Before a full build, run a quick grep to be sure of the source of `makeAnimation`: - -```sh -grep -rn "func makeAnimation" submodules/Display/ submodules/ComponentFlow/ submodules/Components/HierarchyTrackingLayer/ 2>/dev/null -``` - -Expected: at least one hit. If the only hit is in `submodules/Display/`, then `import Display` and the `Display` BUILD dep ARE required. Update Task 2's source file (add `import Display` after `import UIKit`) and Task 1's BUILD (add `"//submodules/Display",` to deps) before building. If hits exist in `ComponentFlow` or `HierarchyTrackingLayer` we're fine without `Display`. - -- [ ] **Step 4: Build the affected target** - -Run the full Bazel build (see "Build invocation" above). Bazel will compile the `ShimmeringMask` library as part of building `Telegram/Telegram`. - -Expected: build succeeds. Watch for `-warnings-as-errors` failures in `ShimmeringMaskView.swift` (e.g. unused-let, always-false casts) — fix them inline before re-running. - -- [ ] **Step 5: Commit Task 1 + Task 2 together** - -```sh -git add submodules/TelegramUI/Components/ShimmeringMask/BUILD \ - submodules/TelegramUI/Components/ShimmeringMask/Sources/ShimmeringMaskView.swift -git commit -m "$(cat <<'EOF' -ShimmeringMask: implement ShimmeringMaskView reveal-mask shimmer - -CAGradientLayer mask with horizontal [white@1.0, white@peakAlpha, -white@1.0] gradient; oversized so alpha=1.0 edges always cover -contentView. Animates position.x (additive, infinite, easeOut) so the -dip travels across containerWidth. HierarchyTrackingLayer pauses / -resumes the animation on hierarchy entry. API mirrors -VideoChatVideoLoadingEffectView. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 3: Wrap streamingStatusTextNode in ShimmeringMaskView - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift` - -This task touches three regions of the file. The line numbers below are accurate as of the time the spec was written; if the file has shifted by a handful of lines, locate by surrounding text (the code excerpts shown are the unique anchors). - -- [ ] **Step 1: Add a sibling field for the shimmer view** - -Find the existing field declaration: - -```swift - private var streamingStatusTextNode: InteractiveTextNodeWithEntities? -``` - -(approximately line 90). Insert a new line directly after it: - -```swift - private var streamingStatusTextNode: InteractiveTextNodeWithEntities? - private var streamingStatusShimmerView: ShimmeringMaskView? -``` - -- [ ] **Step 2: Wrap the streaming-text branch — locate** - -Find this block (approximately lines 959-1000): - -```swift - if let streamingTextFrame, let streamingTextLayoutAndApply { - var animation = animation - if strongSelf.streamingStatusTextNode == nil { - animation = .None - } - let streamingStatusTextNode = streamingTextLayoutAndApply.apply(InteractiveTextNodeWithEntities.Arguments( - context: item.context, - cache: item.controllerInteraction.presentationContext.animationCache, - renderer: item.controllerInteraction.presentationContext.animationRenderer, - placeholderColor: messageTheme.mediaPlaceholderColor, - attemptSynchronous: synchronousLoads, - textColor: messageTheme.primaryTextColor, - spoilerEffectColor: messageTheme.secondaryTextColor, - applyArguments: InteractiveTextNode.ApplyArguments( - animation: animation, - spoilerTextColor: messageTheme.primaryTextColor, - spoilerEffectColor: messageTheme.secondaryTextColor, - areContentAnimationsEnabled: item.context.sharedContext.energyUsageSettings.loopEmoji, - spoilerExpandRect: nil, - crossfadeContents: { [weak strongSelf] sourceView in - guard let strongSelf, let streamingStatusTextNode = strongSelf.streamingStatusTextNode else { - return - } - if let textNodeContainer = streamingStatusTextNode.textNode.view.superview { - sourceView.frame = CGRect(origin: streamingStatusTextNode.textNode.frame.origin, size: sourceView.bounds.size) - textNodeContainer.addSubview(sourceView) - - sourceView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.12, removeOnCompletion: false, completion: { [weak sourceView] _ in - sourceView?.removeFromSuperview() - }) - streamingStatusTextNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) - } - } - ) - )) - if streamingStatusTextNode !== strongSelf.streamingStatusTextNode { - strongSelf.streamingStatusTextNode?.textNode.removeFromSupernode() - strongSelf.streamingStatusTextNode = streamingStatusTextNode - strongSelf.containerNode.addSubnode(streamingStatusTextNode.textNode) - } - animation.animator.updatePosition(layer: streamingStatusTextNode.textNode.layer, position: streamingTextFrame.center, completion: nil) - animation.animator.updateBounds(layer: streamingStatusTextNode.textNode.layer, bounds: CGRect(origin: CGPoint(), size: streamingTextFrame.size), completion: nil) - } else if let streamingStatusTextNode = strongSelf.streamingStatusTextNode { - strongSelf.streamingStatusTextNode = nil - let streamingStatusTextNodeNode = streamingStatusTextNode.textNode - animation.animator.updateAlpha(layer: streamingStatusTextNodeNode.layer, alpha: 0.0, completion: { [weak streamingStatusTextNodeNode] _ in - streamingStatusTextNodeNode?.removeFromSupernode() - }) - } -``` - -This is the region we will replace. - -- [ ] **Step 3: Wrap the streaming-text branch — replace** - -Replace the entire block from Step 2 with: - -```swift - if let streamingTextFrame, let streamingTextLayoutAndApply { - var animation = animation - if strongSelf.streamingStatusTextNode == nil { - animation = .None - } - let streamingStatusTextNode = streamingTextLayoutAndApply.apply(InteractiveTextNodeWithEntities.Arguments( - context: item.context, - cache: item.controllerInteraction.presentationContext.animationCache, - renderer: item.controllerInteraction.presentationContext.animationRenderer, - placeholderColor: messageTheme.mediaPlaceholderColor, - attemptSynchronous: synchronousLoads, - textColor: messageTheme.primaryTextColor, - spoilerEffectColor: messageTheme.secondaryTextColor, - applyArguments: InteractiveTextNode.ApplyArguments( - animation: animation, - spoilerTextColor: messageTheme.primaryTextColor, - spoilerEffectColor: messageTheme.secondaryTextColor, - areContentAnimationsEnabled: item.context.sharedContext.energyUsageSettings.loopEmoji, - spoilerExpandRect: nil, - crossfadeContents: { [weak strongSelf] sourceView in - guard let strongSelf, let streamingStatusTextNode = strongSelf.streamingStatusTextNode else { - return - } - if let textNodeContainer = streamingStatusTextNode.textNode.view.superview { - sourceView.frame = CGRect(origin: streamingStatusTextNode.textNode.frame.origin, size: sourceView.bounds.size) - textNodeContainer.addSubview(sourceView) - - sourceView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.12, removeOnCompletion: false, completion: { [weak sourceView] _ in - sourceView?.removeFromSuperview() - }) - streamingStatusTextNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) - } - } - ) - )) - - let streamingStatusShimmerView: ShimmeringMaskView - if let current = strongSelf.streamingStatusShimmerView { - streamingStatusShimmerView = current - } else { - streamingStatusShimmerView = ShimmeringMaskView(peakAlpha: 0.3, duration: 1.0) - strongSelf.streamingStatusShimmerView = streamingStatusShimmerView - strongSelf.containerNode.view.addSubview(streamingStatusShimmerView) - } - - if streamingStatusTextNode !== strongSelf.streamingStatusTextNode { - strongSelf.streamingStatusTextNode?.textNode.view.removeFromSuperview() - strongSelf.streamingStatusTextNode = streamingStatusTextNode - streamingStatusShimmerView.contentView.addSubview(streamingStatusTextNode.textNode.view) - } - animation.animator.updatePosition(layer: streamingStatusShimmerView.layer, position: streamingTextFrame.center, completion: nil) - animation.animator.updateBounds(layer: streamingStatusShimmerView.layer, bounds: CGRect(origin: CGPoint(), size: streamingTextFrame.size), completion: nil) - animation.animator.updatePosition(layer: streamingStatusTextNode.textNode.layer, position: CGPoint(x: streamingTextFrame.size.width * 0.5, y: streamingTextFrame.size.height * 0.5), completion: nil) - animation.animator.updateBounds(layer: streamingStatusTextNode.textNode.layer, bounds: CGRect(origin: CGPoint(), size: streamingTextFrame.size), completion: nil) - streamingStatusShimmerView.update( - size: streamingTextFrame.size, - containerWidth: streamingTextFrame.size.width, - offsetX: 0.0, - gradientWidth: 200.0, - transition: ComponentTransition(animation.transition) - ) - } else if let streamingStatusTextNode = strongSelf.streamingStatusTextNode { - strongSelf.streamingStatusTextNode = nil - let streamingStatusShimmerView = strongSelf.streamingStatusShimmerView - strongSelf.streamingStatusShimmerView = nil - let streamingStatusTextNodeNode = streamingStatusTextNode.textNode - if let streamingStatusShimmerView { - animation.animator.updateAlpha(layer: streamingStatusShimmerView.layer, alpha: 0.0, completion: { [weak streamingStatusShimmerView] _ in - streamingStatusShimmerView?.removeFromSuperview() - }) - } else { - animation.animator.updateAlpha(layer: streamingStatusTextNodeNode.layer, alpha: 0.0, completion: { [weak streamingStatusTextNodeNode] _ in - streamingStatusTextNodeNode?.removeFromSupernode() - }) - } - } -``` - -What changed: -- Lazy-create `ShimmeringMaskView(peakAlpha: 0.3, duration: 1.0)` and add it to `containerNode.view`. -- When the streaming text node is created/replaced, add `streamingStatusTextNode.textNode.view` to `streamingStatusShimmerView.contentView` (UIView hierarchy) **instead of** `containerNode.addSubnode(streamingStatusTextNode.textNode)`. Mixing `view`/`Subview` and `Subnode`/`Supernode` is fine here: ASDisplayNode's `view` is a real UIView, and adding it via `addSubview` from another UIView reparents it. -- Animate the **shimmer view's** layer to `streamingTextFrame.center`/size — this is the position where the streaming-text strip lives in the bubble's container. -- Animate the inner textNode's layer to the shimmer view's local bounds (`origin = .zero`, same size). Without this, after we reparent the textNode under contentView, the textNode keeps its old `containerNode`-relative frame and ends up offset by `streamingTextFrame.origin`. We're explicitly placing it at `(0, 0, streamingTextFrame.size)` inside `contentView`. -- Call `streamingStatusShimmerView.update(...)` with `containerWidth = streamingTextFrame.size.width` and `offsetX = 0.0` (wave scoped to the streaming-text strip itself; broadenable later). -- The teardown branch animates alpha on the shimmer view (with the textNode inside it). When the shimmer view exists, we use it as the alpha-animation target and do `removeFromSuperview` in the completion; the textNode rides along because it's a subview of `contentView`. We keep a fallback `else` branch animating the textNode directly in case some path produces a streamingStatusTextNode without a shimmer view (defensive — should be unreachable today, but it's a one-line cost and matches the previous behavior exactly). -- The replacement step uses `view.removeFromSuperview()` (not `removeFromSupernode()`) because the inner textNode is now hosted inside `streamingStatusShimmerView.contentView` (a plain UIView) via `addSubview`. ASDisplayKit's `addSubview`/`removeFromSupernode` paths don't sync; using the UIView pair ensures replacements actually unhook the previous textNode's view from the shimmer view. - -Notes on the `transition: ComponentTransition(animation.transition)` — the existing call sites in this file use `animation.animator.update*` (where `animator` is a `Display`-flavored animator) but our `ShimmeringMaskView.update` takes a `ComponentFlow.ComponentTransition`. The `animation` value flowing through is a `ListViewItemUpdateAnimation`; it exposes `.transition` as a `ContainedViewLayoutTransition`. `ComponentTransition` has a public initializer accepting `ContainedViewLayoutTransition`. **Verify this initializer exists** before building (see Step 4); if not, fall back to `ComponentTransition.immediate` (the only consequence is that the mask layer's bounds/position aren't animated to their new values, which is rare and benign). - -- [ ] **Step 4: Verify the ComponentTransition initializer exists** - -```sh -grep -rn "init.*ContainedViewLayoutTransition" submodules/ComponentFlow/Source/ 2>/dev/null -grep -rn "extension ComponentTransition" submodules/ComponentFlow/Source/ 2>/dev/null -``` - -Expected: at least one hit indicating an initializer or static helper that converts a `ContainedViewLayoutTransition` to a `ComponentTransition`. If you find one named differently (e.g. `ComponentTransition(transition:)` or `ComponentTransition.init(legacyAnimation:)`), use that exact name in the call from Step 3. - -If neither exists, replace the `transition:` argument in Step 3's call with `.immediate`: - -```swift - streamingStatusShimmerView.update( - size: streamingTextFrame.size, - containerWidth: streamingTextFrame.size.width, - offsetX: 0.0, - gradientWidth: 200.0, - transition: .immediate - ) -``` - -- [ ] **Step 5: Build** - -Run the full Bazel build (see "Build invocation" above). Expected: build succeeds. - -If you get `-warnings-as-errors` failures specifically about an unused `streamingStatusShimmerView` variable in the teardown branch, that means a compiler-flagged path: re-check the diff against the Step 3 source. - -- [ ] **Step 6: Commit** - -```sh -git add submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift -git commit -m "$(cat <<'EOF' -ChatMessageTextBubbleContentNode: wrap streaming-status text in ShimmeringMaskView - -Hosts streamingStatusTextNode inside a ShimmeringMaskView so the -streaming line gets a "thinking"-style running shimmer (alpha-mask wave -with peakAlpha=0.3, duration=1.0, gradientWidth=200). Layout/teardown -animations target the shimmer view's layer; the text node lives inside -contentView at the wrapper's local bounds. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 4: Manual verification - -**Files:** none (build + run only) - -- [ ] **Step 1: Confirm clean build state** - -```sh -git status --short -``` - -Expected: empty (or only the unrelated `m`/`M` entries that were present before this work began — see `gitStatus` in the conversation context). - -- [ ] **Step 2: Build for the simulator** - -Run the full Bazel build (see "Build invocation" above). Expected: build succeeds with no warnings-as-errors. - -- [ ] **Step 3: Manual run** - -Launch the built app in the simulator. Open a chat with an in-progress AI streaming message (or trigger a streaming-status placeholder if one exists in the test environment). Confirm: - -- The streaming-status line shows a smooth horizontal wave that runs continuously while streaming. -- Outside the wave, the text is fully readable (alpha=1.0). -- At the wave's center, the text dims to ~30% (the `peakAlpha = 0.3` value). -- When the streaming status disappears (message finishes streaming), the shimmer view fades out cleanly with the text inside it. -- Scrolling the streaming message off-screen pauses the animation; scrolling it back on resumes (`HierarchyTrackingLayer` doing its job). - -If the wave is too fast / too slow / too subtle, adjust the constants `peakAlpha`, `duration`, `gradientWidth` at the call site in `ChatMessageTextBubbleContentNode.swift` (Task 3, Step 3, where `ShimmeringMaskView(peakAlpha: 0.3, duration: 1.0)` and `gradientWidth: 200.0` appear) and rebuild. Don't commit tuning changes as part of this plan — leave them for a follow-up. - -- [ ] **Step 4: No commit (verification only)** - -This task produces no code changes. - ---- - -## Notes for the implementer - -- **`-warnings-as-errors`** is enabled on both modules. Common gotchas: unused locals, always-false `is` checks, always-failing `as?` casts. If a build fails with these, fix them inline rather than adding `// swiftlint:disable` or `_ = unused`. -- **No unit tests, no UI snapshot tests** in this project. The full Bazel build is the only automated gate. Be diligent about the manual verification step. -- **Bazel cache:** the plan assumes `~/telegram-bazel-cache` is reusable across builds. If you're working in a fresh worktree (no shared cache), the first build will take meaningfully longer. -- **`HierarchyTrackingLayer` BUILD path** is `//submodules/Components/HierarchyTrackingLayer` (note the `Components/` prefix — there's no top-level `submodules/HierarchyTrackingLayer/`). -- If `streamingTextLayoutAndApply` ends up being non-nil in fast succession (streaming status flickers), the same `ShimmeringMaskView` instance is reused — `update(...)`'s `params != self.params` short-circuit avoids re-arming the animation when nothing changed. -- The wave's containerWidth is currently scoped to the streaming-text strip itself, not the bubble. If a future change wants the wave to traverse the full bubble width (or sync across multiple bubbles), pass a larger `containerWidth` and a non-zero `offsetX` (the streaming-text strip's `minX` within the chosen container) to `update(...)`. diff --git a/docs/superpowers/specs/2026-05-05-shimmering-mask-view-design.md b/docs/superpowers/specs/2026-05-05-shimmering-mask-view-design.md deleted file mode 100644 index 4ca1ae79d8..0000000000 --- a/docs/superpowers/specs/2026-05-05-shimmering-mask-view-design.md +++ /dev/null @@ -1,152 +0,0 @@ -# ShimmeringMaskView — design - -## Goal - -Build a reusable `ShimmeringMaskView` that applies a moving alpha-mask shimmer to its `contentView`, producing a "ChatGPT thinking"-style running effect. First consumer: the `streamingStatusTextNode` in `ChatMessageTextBubbleContentNode`. - -## Visual model - -Reveal mask with constant baseline: - -- Outside the wave, mask alpha = `1.0` (content fully visible). -- A horizontal wave travels across the content; at the wave's center, mask alpha dips to `peakAlpha` (a value < 1.0). -- The wave repeats infinitely while in the view hierarchy. - -Conceptually: the wave is a *low-opacity dimming dip* sliding through; rest state is fully visible. - -## Module location and BUILD - -- File: `submodules/TelegramUI/Components/ShimmeringMask/Sources/ShimmeringMaskView.swift` -- BUILD: `submodules/TelegramUI/Components/ShimmeringMask/BUILD` - -Final deps: - -```python -deps = [ - "//submodules/ComponentFlow", - "//submodules/Components/HierarchyTrackingLayer", -], -``` - -The currently-listed `AsyncDisplayKit`, `Display`, and `ShimmerEffect` deps are removed — none of their types are used. (Re-add `//submodules/Display` if a Display utility is needed during implementation.) - -## Public API - -```swift -public final class ShimmeringMaskView: UIView { - public let contentView: UIView - - public init(peakAlpha: CGFloat, duration: Double) - - public func update( - size: CGSize, - containerWidth: CGFloat, - offsetX: CGFloat, - gradientWidth: CGFloat, - transition: ComponentTransition - ) -} -``` - -Init params (chosen once): -- `peakAlpha` — alpha at the center of the wave (e.g. `0.3`). -- `duration` — seconds per cycle. - -Update params (per layout): -- `size` — `contentView.frame.size`. -- `containerWidth`, `offsetX` — coordinate space the wave traverses, allowing the wave to extend past `contentView`'s own bounds (matches the `VideoChatVideoLoadingEffectView` API). For an isolated use, pass `containerWidth = size.width, offsetX = 0`. -- `gradientWidth` — width of the dip in container coordinates. - -## Internal architecture - -``` -ShimmeringMaskView (UIView) -├── contentView (UIView, public) -│ └── layer.mask = maskLayer -└── HierarchyTrackingLayer (pause/resume on hierarchy entry) - -maskLayer: CAGradientLayer - startPoint = (0, 0.5), endPoint = (1, 0.5) // horizontal - colors = [white@1.0, white@peakAlpha, white@1.0] - locations = positions placing a gradientWidth-wide dip - centered in maskLayer.bounds - bounds.width = size.width + 2 × travelDistance - where travelDistance = containerWidth + gradientWidth - (guarantees alpha=1.0 edges always cover contentView) - bounds.height = size.height - anchorPoint = (0.5, 0.5) - static position.x = −gradientWidth/2 − offsetX - (dip parked just off-left of the container in contentView coords; - contentView's layer is the mask's reference coord system, so - position.x is in contentView coords directly) - - position.x animation (CABasicAnimation, additive, infinite): - keyPath = "position.x" - from = 0 - to = containerWidth + gradientWidth - duration = duration - timingFunction = .easeOut - repeatCount = .infinity - isRemovedOnCompletion = true (safety net; in practice never completes) -``` - -### Why a single oversized `CAGradientLayer` - -- For an additive overlay (the shape of `AnimatedGradientView` inside `VideoChatVideoLoadingEffectView`), the unit-scale + container-scale + offset-scale hierarchy lets you keep animation params constant while changing `containerWidth/gradientWidth` via static transforms. For a *mask*, the layer must always cover `contentView.bounds` at every animation phase — which forces oversize anyway. So the hierarchy stops paying for itself; we'd carry three intermediate layers and still need to oversize. -- Trade-off: when `containerWidth` or `gradientWidth` change, the animation is re-armed with new `to` values. For the streaming-status use case, layout changes are rare (only when the bubble re-lays out), and the re-arm is cheap. - -### Why animate `position.x` (not `locations`) - -Per direction in the design discussion: `position.x` is GPU-accelerated as a layer translation, matches the proven pattern in `AnimatedGradientView` / `LoadingEffectView`, and produces stable jank-free motion. `additive: true` lets the layer's static `position.x` carry the per-layout offset (offsetX baked in) while the animation contributes the fixed `[0, containerWidth + gradientWidth]` translation delta. - -## Lifecycle - -- `HierarchyTrackingLayer` is added as a sublayer of `self.layer`. Its `didEnterHierarchy` callback calls `updateAnimations()`. -- `updateAnimations()`: if `maskLayer.animation(forKey: "shimmer") == nil`, build the `CABasicAnimation` and add it to `maskLayer`. This restarts the animation when re-entering the hierarchy. -- `update(...)`: - 1. Build `Params(size, containerWidth, offsetX, gradientWidth)`. - 2. If `params == self.params`, return. - 3. Otherwise store new params; apply layout via the supplied `transition` (frame of `contentView`, bounds + position of `maskLayer`). - 4. Re-arm the animation (remove existing key + add a new `CABasicAnimation` reflecting the updated `to` value). -- Re-arm is unconditional when params change. Visible jump is acceptable since layout changes for the streaming-status use case are rare. - -## Integration: `ChatMessageTextBubbleContentNode` - -Location: `submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift` (currently around lines 90 and 961-1006). - -1. Add a field alongside `streamingStatusTextNode`: - ```swift - private var streamingStatusShimmerView: ShimmeringMaskView? - ``` -2. In the `if let streamingTextFrame, let streamingTextLayoutAndApply { ... }` branch (~line 959): - - Lazily create `ShimmeringMaskView(peakAlpha: 0.3, duration: 1.0)`; add to `containerNode.view`. - - Move the streaming text node's view into the shimmer view's `contentView` (instead of adding it directly to `containerNode`). - - Drive shimmer view position/size with `animation.animator` (currently used directly on `streamingStatusTextNode.textNode.layer`): - - `animation.animator.updatePosition(layer: shimmerView.layer, position: streamingTextFrame.center, ...)`. - - `animation.animator.updateBounds(layer: shimmerView.layer, bounds: CGRect(origin: .zero, size: streamingTextFrame.size), ...)`. - - The textNode inside `contentView` is laid out at `(0, 0, streamingTextFrame.size)`. - - Call `shimmerView.update(size: streamingTextFrame.size, containerWidth: streamingTextFrame.width, offsetX: 0, gradientWidth: 200, transition: ComponentTransition(animation))`. -3. In the "tear-down" branch (~line 1001) where `streamingStatusTextNode` is being dropped: - - Animate alpha to 0 on the shimmer view (not the textNode), and remove on completion. The textNode is inside the shimmer view, so removing the shimmer view removes both. -4. The crossfade flow at lines 982-989 continues to work — the textNode's superview is now `shimmerView.contentView` instead of `containerNode`; `sourceView` still gets added to `textNodeContainer` (= `shimmerView.contentView`) and crossfaded in place. - -### Constants used at the call site - -| Constant | Value | Notes | -|----------------|------:|-------| -| `peakAlpha` | `0.3` | Wave dip floor — comfortable contrast against fully visible rest state. | -| `duration` | `1.0` | Matches `LoadingEffectView` / `VideoChatVideoLoadingEffectView` cadence. | -| `gradientWidth`| `200` | Matches the gradient width used elsewhere in the family. | -| `containerWidth` | `streamingTextFrame.width` | Wave scoped to the streaming text strip; broadenable to bubble width if cross-element synchronization is later required. | -| `offsetX` | `0` | Streaming text strip is the container in this scoping. | - -## Out of scope - -- Synchronizing the wave across multiple separate views — the API supports it via `containerWidth + offsetX`, but no consumer needs it today. -- Border-shimmer companion (analogous to `LoadingEffectView.borderGradientView`) — not required for streaming-status text. -- Color tinting the wave — only alpha is modulated; `contentView` keeps its existing colors. - -## Verification - -- Build: `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` (with `source ~/.zshrc 2>/dev/null;` prefix to pick up `TELEGRAM_CODESIGNING_GIT_PASSWORD`). -- Manual: open a chat with a streaming AI message and observe the shimmer effect on the streaming-status line. Confirm the wave runs continuously, the text remains fully readable except when the dip passes, and the effect tears down cleanly when the streaming status disappears. diff --git a/submodules/ContextUI/Sources/ContextController.swift b/submodules/ContextUI/Sources/ContextController.swift index 9c6b20390a..435072a929 100644 --- a/submodules/ContextUI/Sources/ContextController.swift +++ b/submodules/ContextUI/Sources/ContextController.swift @@ -349,25 +349,29 @@ public final class ContextControllerTakeViewInfo { 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) { + 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 init(contentAreaInScreenSpace: CGRect, maskView: UIView? = nil) { + public let sourceTransitionSurface: UIView? + + public init(contentAreaInScreenSpace: CGRect, maskView: UIView? = nil, sourceTransitionSurface: UIView? = nil) { self.contentAreaInScreenSpace = contentAreaInScreenSpace self.maskView = maskView + self.sourceTransitionSurface = sourceTransitionSurface } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift index d6bada6e9b..f0ff8cfa22 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift @@ -88,7 +88,8 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { private let containerNode: ContainerNode private let textNode: InteractiveTextNodeWithEntities private var streamingStatusTextNode: InteractiveTextNodeWithEntities? - + private var streamingStatusShimmerView: ShimmeringMaskView? + private let textAccessibilityOverlayNode: TextAccessibilityOverlayNode public var statusNode: ChatMessageDateAndStatusNode? private var linkHighlightingNode: LinkHighlightingNode? @@ -979,10 +980,10 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { guard let strongSelf, let streamingStatusTextNode = strongSelf.streamingStatusTextNode else { return } - if let textNodeContainer = streamingStatusTextNode.textNode.view.superview { + if let shimmerView = strongSelf.streamingStatusShimmerView { sourceView.frame = CGRect(origin: streamingStatusTextNode.textNode.frame.origin, size: sourceView.bounds.size) - textNodeContainer.addSubview(sourceView) - + shimmerView.addSubview(sourceView) + sourceView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.12, removeOnCompletion: false, completion: { [weak sourceView] _ in sourceView?.removeFromSuperview() }) @@ -991,18 +992,37 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } ) )) - if streamingStatusTextNode !== strongSelf.streamingStatusTextNode { - strongSelf.streamingStatusTextNode?.textNode.removeFromSupernode() - strongSelf.streamingStatusTextNode = streamingStatusTextNode - strongSelf.containerNode.addSubnode(streamingStatusTextNode.textNode) + + let streamingStatusShimmerView: ShimmeringMaskView + if let current = strongSelf.streamingStatusShimmerView { + streamingStatusShimmerView = current + } else { + streamingStatusShimmerView = ShimmeringMaskView(peakAlpha: 0.3, duration: 1.0) + strongSelf.streamingStatusShimmerView = streamingStatusShimmerView + strongSelf.containerNode.view.addSubview(streamingStatusShimmerView) } - animation.animator.updatePosition(layer: streamingStatusTextNode.textNode.layer, position: streamingTextFrame.center, completion: nil) + + if streamingStatusTextNode !== strongSelf.streamingStatusTextNode { + strongSelf.streamingStatusTextNode?.textNode.view.removeFromSuperview() + strongSelf.streamingStatusTextNode = streamingStatusTextNode + streamingStatusShimmerView.contentView.addSubview(streamingStatusTextNode.textNode.view) + } + animation.animator.updatePosition(layer: streamingStatusShimmerView.layer, position: streamingTextFrame.center, completion: nil) + animation.animator.updateBounds(layer: streamingStatusShimmerView.layer, bounds: CGRect(origin: CGPoint(), size: streamingTextFrame.size), completion: nil) + animation.animator.updatePosition(layer: streamingStatusTextNode.textNode.layer, position: CGPoint(x: streamingTextFrame.size.width * 0.5, y: streamingTextFrame.size.height * 0.5), completion: nil) animation.animator.updateBounds(layer: streamingStatusTextNode.textNode.layer, bounds: CGRect(origin: CGPoint(), size: streamingTextFrame.size), completion: nil) - } else if let streamingStatusTextNode = strongSelf.streamingStatusTextNode { + streamingStatusShimmerView.update( + size: streamingTextFrame.size, + containerWidth: streamingTextFrame.size.width, + offsetX: 0.0, + gradientWidth: 200.0, + transition: .immediate + ) + } else if let streamingStatusShimmerView = strongSelf.streamingStatusShimmerView { strongSelf.streamingStatusTextNode = nil - let streamingStatusTextNodeNode = streamingStatusTextNode.textNode - animation.animator.updateAlpha(layer: streamingStatusTextNodeNode.layer, alpha: 0.0, completion: { [weak streamingStatusTextNodeNode] _ in - streamingStatusTextNodeNode?.removeFromSupernode() + strongSelf.streamingStatusShimmerView = nil + animation.animator.updateAlpha(layer: streamingStatusShimmerView.layer, alpha: 0.0, completion: { [weak streamingStatusShimmerView] _ in + streamingStatusShimmerView?.removeFromSuperview() }) } diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift index d65a39fad8..0f656f4597 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift @@ -123,6 +123,128 @@ private extension ContextControllerTakeViewInfo.ContainingItem { } } +private final class PortalTransitionStaging { + enum SettleDestination { + case offsetContainer(ASDisplayNode) + case original + } + + weak var surface: UIView? + var wrapper: PortalSourceView? + var clone: PortalView? + 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() + + // 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 + // + // Hidden assumption: contentNode.frame.origin == (0, 0) within containingNode. + // Holds for every current ContextExtractedContentContainingNode adopter; if a + // future adopter offsets contentNode within its containing parent, the bubble + // will be mispositioned in the portal target by that delta. + 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.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. + /// + /// `presentationScale` re-application is intentionally not handled here: the + /// portal path is gated on `contentNode.presentationScale == 1.0` in CCEPN's + /// animateIn/animateOut wiring (Tasks 3 and 4), so the scale compensation that + /// the design spec described at settle time is always identity in practice. + 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: + // Reparent to the source's containing node/view (where ContextExtracted- + // ContentContainingNode/View added contentNode at init). Capturing + // contentNode.supernode at staging-enter time was overengineered: during + // animateOut staging that's offsetContainerNode (CCEPN's transient host), + // which gets torn down with CCEPN — leaving the bubble parentless. + switch containingItem { + case let .node(containingNode): + 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.containingItem = nil + } +} + final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextControllerPresentationNode, ASScrollViewDelegate { enum ContentSource { case location(ContextLocationContentSource) @@ -139,6 +261,8 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo var storedGlobalFrame: CGRect? var storedGlobalBoundsFrame: CGRect? var presentationScale: CGFloat = 1.0 + var portalStaging: PortalTransitionStaging? + weak var sourceTransitionSurface: UIView? init(containingItem: ContextControllerTakeViewInfo.ContainingItem) { self.offsetContainerNode = ASDisplayNode() @@ -634,6 +758,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo } let contentNodeValue = ItemContentNode(containingItem: takeInfo.containingItem) contentNodeValue.animateClippingFromContentAreaInScreenSpace = takeInfo.contentAreaInScreenSpace + contentNodeValue.sourceTransitionSurface = takeInfo.sourceTransitionSurface // Mirror any ancestor scale on the source (e.g. a sheet's container transform) onto the offset // container so the extracted contents render at the same visual size as in-place — without this @@ -1204,6 +1329,18 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo } } contentTransition.updateFrame(node: contentNode, frame: contentFrame, beginWithCurrentState: true) + + // Keep the staging wrapper's frame in sync with ItemContentNode's frame so + // chat-side relayouts that fire mid-animation (e.g. via layoutUpdated → + // requestUpdate after isExtractedToContextPreview = true) shift the portal + // source the same way ItemContentNode shifts. The additive position springs + // on wrapper.layer ("position.x" / "position.y") and this layout-pass + // "position" animation compose: visual = (interpolated layout) + (additive offset). + if let staging = contentNode.portalStaging, let wrapper = staging.wrapper, let surface = staging.surface { + let wrapperFrameInWindow = self.scrollNode.view.convert(contentFrame, to: nil) + let wrapperFrameInSurface = surface.convert(wrapperFrameInWindow, from: nil) + contentTransition.updateFrame(view: wrapper, frame: wrapperFrameInSurface, beginWithCurrentState: true) + } } if let contentNode = controllerContentNode { //TODO: @@ -1267,9 +1404,13 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo let actionsSize = self.actionsContainerNode.bounds.size if let contentNode = itemContentNode { - contentNode.takeContainingNode() + if contentNode.sourceTransitionSurface != nil && contentNode.presentationScale == 1.0 { + // Portal path: defer reparenting to the staging.enter call below. + } else { + contentNode.takeContainingNode() + } } - + let duration: Double = 0.42 let springDamping: CGFloat = 104.0 @@ -1278,11 +1419,70 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo var animationInContentYDistance: CGFloat let currentContentScreenFrame: CGRect if let contentNode = itemContentNode { - if let animateClippingFromContentAreaInScreenSpace = contentNode.animateClippingFromContentAreaInScreenSpace { + let portalAnimationLayer: CALayer? + if let surface = contentNode.sourceTransitionSurface, contentNode.presentationScale == 1.0 { + let staging = PortalTransitionStaging() + // Use ItemContentNode.frame (already includes contentVerticalOffset and + // additionalVisibleOffsetY from the layout pass) plus + // containingItem.contentRect.origin to derive the bubble's actual rest + // window rect. Using bare `contentRect` skips those offsets — when reactions + // are visible (additionalVisibleOffsetY = reactionContextNode.visibleExtensionDistance) + // the wrapper would sit ~10pt above the bubble's true rest, visibly offsetting + // the portal mirror until staging.settle reparents into offsetContainerNode. + // Springs are additive: `from: -distance` at t=0 pulls the wrapper back to + // the chat-side source position; `to: 0` lands at rest. + let bubbleRestRectInScrollNode = CGRect( + x: contentNode.frame.minX + contentNode.containingItem.contentRect.minX, + y: contentNode.frame.minY + contentNode.containingItem.contentRect.minY, + width: contentNode.containingItem.contentRect.width, + height: contentNode.containingItem.contentRect.height + ) + let currentContentLocalFrameInWindow = self.scrollNode.view.convert(bubbleRestRectInScrollNode, to: nil) + if let layer = staging.enter( + for: contentNode.containingItem, + in: surface, + overlayHost: contentNode.offsetContainerNode.view, + targetScreenRect: currentContentLocalFrameInWindow + ) { + contentNode.portalStaging = staging + portalAnimationLayer = layer + + // Mid-animation crossfade: source (chat-tree wrapper, behind chrome) + // is visible at the chat-bubble slot; final (overlay clone, above + // chrome) is visible at the menu position. Final fade-in begins + // `crossfadeOverlap` seconds before source fade-out, so the final + // is already on screen before the source starts dropping out — no + // single-frame seam at the handoff. Each fade runs for a fixed 0.1s. + if let wrapper = staging.wrapper, let clone = staging.clone { + let sourceFadeDelay = 0.12 * duration + let crossfadeOverlap: Double = 0.3 + let crossfadeDuration: Double = 0.1 + let finalFadeDelay = max(0.0, sourceFadeDelay - crossfadeOverlap) + + // Final = clone (visible at end), fades in. + clone.view.alpha = 1.0 + clone.view.layer.allowsGroupOpacity = true + clone.view.layer.animateAlpha(from: 0.01, to: 1.0, duration: crossfadeDuration, delay: finalFadeDelay) + + // Source = wrapper (visible at start), fades out. + wrapper.alpha = 0.0 + wrapper.layer.allowsGroupOpacity = true + wrapper.layer.animateAlpha(from: 1.0, to: 0.0, duration: crossfadeDuration, delay: sourceFadeDelay) + } + } else { + // Staging refused (PortalView nil); fall back to clipping by reparenting now. + contentNode.takeContainingNode() + portalAnimationLayer = nil + } + } else { + portalAnimationLayer = nil + } + + 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) } - + currentContentScreenFrame = convertFrame(contentNode.containingItem.contentRect, from: contentNode.containingItem.view, to: self.view) let currentContentLocalFrame = convertFrame(contentRect, from: self.scrollNode.view, to: self.view) animationInContentYDistance = currentContentLocalFrame.maxY - currentContentScreenFrame.maxY @@ -1298,7 +1498,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo if actionsSize.height.isZero { var initialContentRect = contentRect initialContentRect.origin.y += extracted.initialAppearanceOffset.y - + let fixedContentY = floorToScreenPixels((layout.size.height - contentHeight) / 2.0) animationInContentYDistance = fixedContentY - initialContentRect.minY } else if contentX + contentWidth > layout.size.width / 2.0, actionsSize.height > 0.0 { @@ -1308,9 +1508,11 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo } else { animationInContentXDistance = contentParentGlobalFrameOffsetX } - + + let animateLayer: CALayer = portalAnimationLayer ?? contentNode.layer + if animationInContentXDistance != 0.0 { - contentNode.layer.animateSpring( + animateLayer.animateSpring( from: -animationInContentXDistance as NSNumber, to: 0.0 as NSNumber, keyPath: "position.x", duration: duration, @@ -1320,15 +1522,22 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo additive: true ) } - - contentNode.layer.animateSpring( + + 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 + 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 + } + } ) if let reactionPreviewView = self.reactionPreviewView { @@ -1504,8 +1713,10 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo } } + var portalAnimationLayer: CALayer? = nil + let currentContentScreenFrame: CGRect - + switch self.source { case let .location(location): if let putBackInfo = location.transitionInfo() { @@ -1527,12 +1738,55 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo } case let .extracted(source): let putBackInfo = source.putBack() - - if let putBackInfo = putBackInfo { + + 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 + + // Mid-dismiss crossfade: clone (overlay portal target, above chrome) + // is visible at the menu position; final (chat-tree wrapper, behind + // chrome) is visible once the bubble settles at the chat-bubble + // slot. Late handoff — clone carries the bubble through most of the + // dismiss; final picks up only as the bubble lands. Source fade-out + // is anchored at 80% of the spring duration; final fade-in starts + // `crossfadeOverlap` seconds earlier. Each fade runs for 0.1s. + if let wrapper = staging.wrapper, let clone = staging.clone { + let sourceFadeDelay = 0.5 * duration + let crossfadeOverlap: Double = 0.3 + let crossfadeDuration: Double = 0.12 + let finalFadeDelay = max(0.0, sourceFadeDelay - crossfadeOverlap) + + // Final = wrapper (visible at end), fades in. + wrapper.alpha = 1.0 + wrapper.layer.allowsGroupOpacity = true + wrapper.layer.animateAlpha(from: 0.01, to: 1.0, duration: crossfadeDuration, delay: finalFadeDelay) + + // Source = clone (visible at start), fades out. + clone.view.alpha = 0.0 + clone.view.layer.allowsGroupOpacity = true + clone.view.layer.animateAlpha(from: 1.0, to: 0.0, duration: crossfadeDuration, delay: sourceFadeDelay) + } + } + } + + 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 { @@ -1640,8 +1894,10 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo animationInContentXDistance = -contentParentGlobalFrameOffsetX } + let animateLayer: CALayer = portalAnimationLayer ?? contentNode.offsetContainerNode.layer + if animationInContentXDistance != 0.0 { - contentNode.offsetContainerNode.layer.animate( + animateLayer.animate( from: -animationInContentXDistance as NSNumber, to: 0.0 as NSNumber, keyPath: "position.x", @@ -1651,10 +1907,13 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo additive: true ) } - - contentNode.offsetContainerNode.position = contentNode.offsetContainerNode.position.offsetBy(dx: animationInContentXDistance, dy: -animationInContentYDistance) + + if portalAnimationLayer == nil { + contentNode.offsetContainerNode.position = contentNode.offsetContainerNode.position.offsetBy(dx: animationInContentXDistance, dy: -animationInContentYDistance) + } + let reactionContextNodeIsAnimatingOut = self.reactionContextNodeIsAnimatingOut - contentNode.offsetContainerNode.layer.animate( + animateLayer.animate( from: animationInContentYDistance as NSNumber, to: 0.0 as NSNumber, keyPath: "position.y", @@ -1665,18 +1924,25 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo 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) + 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) + } } } - - contentNode.containingItem.isExtractedToContextPreview = false - contentNode.containingItem.isExtractedToContextPreviewUpdated?(false) - contentNode.containingItem.onDismiss?() - + + if let strongSelf = self, let contentNode = strongSelf.itemContentNode { + contentNode.containingItem.isExtractedToContextPreview = false + contentNode.containingItem.isExtractedToContextPreviewUpdated?(false) + contentNode.containingItem.onDismiss?() + } + restoreOverlayViews.forEach({ $0() }) completion() }) diff --git a/submodules/TelegramUI/Components/ShimmeringMask/BUILD b/submodules/TelegramUI/Components/ShimmeringMask/BUILD index 5e0e9cd015..8aaeac03c3 100644 --- a/submodules/TelegramUI/Components/ShimmeringMask/BUILD +++ b/submodules/TelegramUI/Components/ShimmeringMask/BUILD @@ -10,10 +10,9 @@ swift_library( "-warnings-as-errors", ], deps = [ - "//submodules/AsyncDisplayKit", - "//submodules/Display", - "//submodules/ShimmerEffect", "//submodules/ComponentFlow", + "//submodules/Display", + "//submodules/Components/HierarchyTrackingLayer", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/ShimmeringMask/Sources/ShimmeringMaskView.swift b/submodules/TelegramUI/Components/ShimmeringMask/Sources/ShimmeringMaskView.swift index 65ccc99e76..7da9d6d331 100644 --- a/submodules/TelegramUI/Components/ShimmeringMask/Sources/ShimmeringMaskView.swift +++ b/submodules/TelegramUI/Components/ShimmeringMask/Sources/ShimmeringMaskView.swift @@ -1,26 +1,129 @@ import Foundation import UIKit -import AsyncDisplayKit -import Display -import ShimmerEffect import ComponentFlow +import Display +import HierarchyTrackingLayer public final class ShimmeringMaskView: UIView { + private struct Params: Equatable { + var size: CGSize + var containerWidth: CGFloat + var offsetX: CGFloat + var gradientWidth: CGFloat + } + public let contentView: UIView - override public init(frame: CGRect) { + private let peakAlpha: CGFloat + private let duration: Double + + private let hierarchyTrackingLayer: HierarchyTrackingLayer + private let maskLayer: CAGradientLayer + + private var params: Params? + + public init(peakAlpha: CGFloat, duration: Double) { + self.peakAlpha = peakAlpha + self.duration = duration + self.contentView = UIView() - super.init(frame: frame) + self.hierarchyTrackingLayer = HierarchyTrackingLayer() + + self.maskLayer = CAGradientLayer() + self.maskLayer.startPoint = CGPoint(x: 0.0, y: 0.5) + self.maskLayer.endPoint = CGPoint(x: 1.0, y: 0.5) + self.maskLayer.colors = [ + UIColor(white: 1.0, alpha: 1.0).cgColor, + UIColor(white: 1.0, alpha: self.peakAlpha).cgColor, + UIColor(white: 1.0, alpha: 1.0).cgColor + ] + self.maskLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5) + + super.init(frame: CGRect()) self.addSubview(self.contentView) - } - - required public init(coder: NSCoder) { - preconditionFailure() + self.contentView.layer.mask = self.maskLayer + + self.layer.addSublayer(self.hierarchyTrackingLayer) + self.hierarchyTrackingLayer.didEnterHierarchy = { [weak self] in + guard let self else { + return + } + self.updateAnimations() + } } - public func update(size: CGSize, transition: ComponentTransition) { + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + private func updateAnimations() { + guard let params = self.params else { + return + } + if self.maskLayer.animation(forKey: "shimmer") != nil { + return + } + let travelDelta = params.containerWidth + params.gradientWidth + let animation = self.maskLayer.makeAnimation( + from: 0.0 as NSNumber, + to: travelDelta as NSNumber, + keyPath: "position.x", + timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, + duration: self.duration, + delay: 0.0, + mediaTimingFunction: nil, + removeOnCompletion: true, + additive: true + ) + animation.repeatCount = Float.infinity + self.maskLayer.add(animation, forKey: "shimmer") + } + + public func update( + size: CGSize, + containerWidth: CGFloat, + offsetX: CGFloat, + gradientWidth: CGFloat, + transition: ComponentTransition + ) { + let params = Params( + size: size, + containerWidth: containerWidth, + offsetX: offsetX, + gradientWidth: gradientWidth + ) + if self.params == params { + return + } + self.params = params + + transition.setFrame(view: self.contentView, frame: CGRect(origin: CGPoint(), size: size)) + + let travelDistance = containerWidth + gradientWidth + let maskWidth = size.width + 2.0 * travelDistance + + let dipHalfFraction: CGFloat + if maskWidth > 0.0 { + dipHalfFraction = (gradientWidth * 0.5) / maskWidth + } else { + dipHalfFraction = 0.0 + } + self.maskLayer.locations = [ + (0.5 - dipHalfFraction) as NSNumber, + 0.5 as NSNumber, + (0.5 + dipHalfFraction) as NSNumber + ] + + let maskBounds = CGRect(origin: CGPoint(), size: CGSize(width: maskWidth, height: size.height)) + let staticPositionX = -gradientWidth * 0.5 - offsetX + let maskPosition = CGPoint(x: staticPositionX, y: size.height * 0.5) + + transition.setBounds(layer: self.maskLayer, bounds: maskBounds) + transition.setPosition(layer: self.maskLayer, position: maskPosition) + + self.maskLayer.removeAnimation(forKey: "shimmer") + self.updateAnimations() } } diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index 2ff4273233..d69d16cb1a 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -183,6 +183,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { private var scrollContainerNode: ScrollContainerNode? private var containerNode: ASDisplayNode? private var overlayNavigationBar: ChatOverlayNavigationBar? + private var contextTransitionContainer: UIView? var overlayTitle: String? { didSet { @@ -3528,7 +3529,11 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { } self.derivedLayoutState = ChatControllerNodeDerivedLayoutState(inputContextPanelsFrame: inputContextPanelsFrame, inputContextPanelsOverMainPanelFrame: inputContextPanelsOverMainPanelFrame, inputNodeHeight: inputNodeHeightAndOverflow?.0, inputNodeAdditionalHeight: inputNodeHeightAndOverflow?.1, upperInputPositionBound: inputNodeHeightAndOverflow?.0 != nil ? self.upperInputPositionBound : nil) - + + if let contextTransitionContainer = self.contextTransitionContainer { + contextTransitionContainer.frame = self.view.convert(self.frameForVisibleArea(), to: self.contentContainerNode.contentNode.view) + } + //self.notifyTransitionCompletionListeners(transition: transition) } @@ -4035,6 +4040,37 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { self.textInputPanelNode?.isMediaDeleted = isDeleted } + func ensureContextTransitionContainer() -> UIView? { + // Frames are expressed in self.view coords by frameForVisibleArea(), but the + // container lives inside self.contentContainerNode.contentNode.view (the + // direct parent of historyNodeContainer) so chat-side ancestor clipping + // applies and chrome above contentContainerNode (input panel, nav, etc.) + // renders over it. Convert at the boundary. + // + // In overlay chat mode (self.containerNode != nil) historyNodeContainer is + // reparented out of contentContainerNode (see line ~1299), making the + // `aboveSubview: historyNodeContainer.view` insertion invalid. Return nil + // so callers fall back to CCEPN's clipping path — portal-style transitions + // are not supported in overlay mode. + guard self.containerNode == nil else { return nil } + let parent = self.contentContainerNode.contentNode.view + let frame = self.view.convert(self.frameForVisibleArea(), to: parent) + if let existing = self.contextTransitionContainer { + existing.frame = frame + return existing + } + let container = UIView() + // No clipsToBounds: the source-side wrapper is faded out via alpha during the + // crossfade, so we don't rely on clipping to hide it. Clipping the wrapper + // would also clip the iOS portal mirror (which reflects ancestor clipping), + // producing visibly clipped pixels in the clone at intermediate positions. + container.isUserInteractionEnabled = false + container.frame = frame + parent.insertSubview(container, aboveSubview: self.historyNodeContainer.view) + self.contextTransitionContainer = container + return container + } + func frameForVisibleArea() -> CGRect { var rect = CGRect(origin: CGPoint(x: self.visibleAreaInset.left, y: self.visibleAreaInset.top), size: CGSize(width: self.bounds.size.width - self.visibleAreaInset.left - self.visibleAreaInset.right, height: self.bounds.size.height - self.visibleAreaInset.top - self.visibleAreaInset.bottom)) if let inputContextPanelNode = self.inputContextPanelNode, let topItemFrame = inputContextPanelNode.topItemFrame { diff --git a/submodules/TelegramUI/Sources/ChatMessageContextControllerContentSource.swift b/submodules/TelegramUI/Sources/ChatMessageContextControllerContentSource.swift index 78d6cb1f9d..560273d070 100644 --- a/submodules/TelegramUI/Sources/ChatMessageContextControllerContentSource.swift +++ b/submodules/TelegramUI/Sources/ChatMessageContextControllerContentSource.swift @@ -87,7 +87,7 @@ final class ChatMessageContextExtractedContentSource: ContextExtractedContentSou return } if item.content.contains(where: { $0.0.stableId == self.message.stableId }), let contentNode = itemNode.getMessageContextSourceNode(stableId: self.selectAll ? nil : self.message.stableId) { - result = ContextControllerTakeViewInfo(containingItem: .node(contentNode), contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil)) + result = ContextControllerTakeViewInfo(containingItem: .node(contentNode), contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil), sourceTransitionSurface: chatNode.ensureContextTransitionContainer()) if self.snapshot, let snapshotView = contentNode.contentNode.view.snapshotContentTree(unhide: false, keepPortals: true, keepTransform: true) { contentNode.view.superview?.addSubview(snapshotView) @@ -112,10 +112,10 @@ final class ChatMessageContextExtractedContentSource: ContextExtractedContentSou return } if item.content.contains(where: { $0.0.stableId == self.message.stableId }) { - result = ContextControllerPutBackViewInfo(contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil)) + result = ContextControllerPutBackViewInfo(contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil), sourceTransitionSurface: chatNode.ensureContextTransitionContainer()) } } - + return result } } @@ -463,17 +463,17 @@ final class ChatMessageReactionContextExtractedContentSource: ContextExtractedCo return } if item.content.contains(where: { $0.0.stableId == self.message.stableId }) { - result = ContextControllerTakeViewInfo(containingItem: .view(self.contentView), contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil)) + result = ContextControllerTakeViewInfo(containingItem: .view(self.contentView), contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil), sourceTransitionSurface: chatNode.ensureContextTransitionContainer()) } } return result } - + func putBack() -> ContextControllerPutBackViewInfo? { guard let chatNode = self.chatNode else { return nil } - + var result: ContextControllerPutBackViewInfo? chatNode.historyNode.forEachItemNode { itemNode in guard let itemNode = itemNode as? ChatMessageItemView else { @@ -483,7 +483,7 @@ final class ChatMessageReactionContextExtractedContentSource: ContextExtractedCo return } if item.content.contains(where: { $0.0.stableId == self.message.stableId }) { - result = ContextControllerPutBackViewInfo(contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil)) + result = ContextControllerPutBackViewInfo(contentAreaInScreenSpace: chatNode.convert(chatNode.frameForVisibleArea(), to: nil), sourceTransitionSurface: chatNode.ensureContextTransitionContainer()) } } return result