Add Instagram-style pinch-to-zoom for media in conversations#693
Add Instagram-style pinch-to-zoom for media in conversations#693
Conversation
Profile info (avatar + name) is now always top-left, reactions are inset bottom-left inside the bubble, and a new top-right info badge shows "Tap to reveal" and/or video duration. Layout is consistent regardless of sent vs received. - Replace PhotoSenderLabel with MediaContainerID (always shows name) - Add MediaContainerInfo (top-right badge) and MediaContainerReax (frosted glass inset reaction pill) - Add MediaTopGradient (always top-aligned) - Update video play button to frosted glass circle design - Skip external reactionRow for full-bleed attachments - Delete PhotoBlurOverlayView.swift (unused) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The interactive containers (ID, Info, Reax) were inside AttachmentPlaceholder which is wrapped by .messageGesture(), causing the UIKit gesture recognizer to swallow all taps. Move them to VideoTapAttachmentView after .messageGesture() so SwiftUI Buttons receive taps first. - Reaction pill tap now opens reactions sheet - Avatar/name tap now opens profile view - Expose isPlaying as @binding for overlay visibility - Thread onTapAvatar through to MediaContainerID - Remove debug red backgrounds Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Media container overlays (identity, info, reactions) now follow the swipe-to-reply offset. Context menu photo preview no longer shows gradient, sender label, or info badge overlays. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Corners animate from sharp to 20pt as the user swipes, applied directly to the image clip shape for a natural rounding effect. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sync external swipe offset inside the animation block so container overlays animate back with the same spring as the image. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Play button now appears on blurred video previews. Tapping a blurred video reveals and plays in one action. Video duration is extracted from the AVAsset for received videos where it isn't stored in the model. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Video thumbnails loaded from attachment data were only stored in local state, so the context menu preview couldn't find them. Now cached in ImageCache so they're available for the long-press preview. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reaction pill uses .clear glass when not reacted and .regular glass with white tint (0.6 opacity) when the current user has reacted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Consecutive full-bleed attachment groups (images/videos) now render with a single-pixel gap instead of the standard 16pt spacing. Adjacency flags are computed in MessagesListProcessor and read directly by MessagesGroupView. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fixes SwiftLint explicit_type_interface violation in MessagesGroupView. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When tapping a blurred video, onPhotoRevealed runs async so shouldBlur is still true when handleVideoPlayTap fires, silently dropping the play intent. Add a pendingPlayAfterReveal flag that defers playback until the onChange(of: shouldBlur) observer sees the blur clear. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract loadVideoAttachment, loadPhotoAttachment, and resolveImageData from loadAttachment to reduce cyclomatic complexity from 16 to under 14. Remove explicit nil initialization on resolvedDuration optional. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Set pendingPlayAfterReveal directly in singleTapAction instead of toggling videoPlayTrigger, which would check stale blur state. Also move isPlaying assignment inside the seek completion handler so UI state stays consistent if the seek fails. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements lift-and-overlay pinch-to-zoom on photos and videos in message bubbles. The media lifts into a window-level overlay with a proportional scrim, scales up to 3.5x with rubber-band resistance below 1x, supports simultaneous pan while zoomed, and spring-animates back on release. Video playback continues during zoom via AVPlayer reparenting. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The overlay now maintains its own @State copies of scale, translation, and scrim alpha, enabling a smooth spring animation back to the original position on gesture release. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Claude finished @onefovrth's task —— View job PR Review CompleteI've reviewed the pinch-to-zoom implementation across all changed files. Here's my comprehensive feedback: 🔴 Critical Issues1. Race Condition in MediaZoomOverlay (High Priority)File: The dismiss animation completion handler unconditionally calls Problem: .onChange(of: state.isActive) { _, active in
if !active {
withAnimation(.spring(duration: 0.35, bounce: 0.2)) {
// ... animation
} completion: {
state.reset() // ❌ Unconditional - can clear new zoom's state
}
}
}Fix: } completion: {
guard !state.isActive else { return } // ✅ Guard against new zoom
state.reset()
}This matches Macroscope's finding. Without this fix, rapid pinch gestures could corrupt state. 2. @unchecked Sendable Without Justification (High Priority)File: @Observable
final class MediaZoomState: @unchecked Sendable {
Issues:
Recommendation:
3. Asymmetric Gesture Recognizer Simultaneity Check (Medium Priority)File: The simultaneity delegate method only checks func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
if gestureRecognizer is UIPinchGestureRecognizer || otherGestureRecognizer is UIPinchGestureRecognizer {
if otherGestureRecognizer is UILongPressGestureRecognizer // ❌ Only checks other
&& (otherGestureRecognizer as? UILongPressGestureRecognizer)?.minimumPressDuration == 0 {
return true
}
return false
}
// ...
}Problem: When UIKit calls this method with arguments swapped (press tracker as Fix: if gestureRecognizer is UIPinchGestureRecognizer || otherGestureRecognizer is UIPinchGestureRecognizer {
// Check both arguments for press tracker
if let lp = otherGestureRecognizer as? UILongPressGestureRecognizer, lp.minimumPressDuration == 0 {
return true
}
if let lp = gestureRecognizer as? UILongPressGestureRecognizer, lp.minimumPressDuration == 0 {
return true
}
return false
}
|
| } else { | ||
| withAnimation(.spring(duration: 0.35, bounce: 0.2)) { | ||
| displayScale = 1.0 | ||
| displayTranslation = .zero | ||
| displayScrimAlpha = 0 | ||
| } completion: { | ||
| state.reset() | ||
| } |
There was a problem hiding this comment.
🟠 High MediaZoom/MediaZoomOverlay.swift:46
If a user begins a new zoom while the dismiss animation is still running, the completion handler unconditionally calls state.reset(), which clears sourceImage, sourcePlayer, and attachmentKey for the newly-started zoom. The guard should check !state.isActive before resetting to avoid clobbering the new zoom's state.
} completion: {
- state.reset()
+ guard !state.isActive else { return }
+ state.reset()
}🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file Convos/Conversation Detail/Messages/MessagesListView/MediaZoom/MediaZoomOverlay.swift around lines 46-53:
If a user begins a new zoom while the dismiss animation is still running, the completion handler unconditionally calls `state.reset()`, which clears `sourceImage`, `sourcePlayer`, and `attachmentKey` for the newly-started zoom. The guard should check `!state.isActive` before resetting to avoid clobbering the new zoom's state.
Evidence trail:
MediaZoomOverlay.swift lines 43-52 (REVIEWED_COMMIT): `.onChange(of: state.isActive)` handler shows unconditional `state.reset()` call in animation completion at lines 49-51.
MediaZoomState.swift lines 56-61 (REVIEWED_COMMIT): `reset()` function clears `isActive`, `sourceImage`, `sourcePlayer`, `attachmentKey`.
MediaZoomState.swift lines 22-40 (REVIEWED_COMMIT): `beginZoom()` sets all state properties including `isActive = true` at line 39.
| _ gestureRecognizer: UIGestureRecognizer, | ||
| shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer | ||
| ) -> Bool { | ||
| if gestureRecognizer is UIPinchGestureRecognizer || otherGestureRecognizer is UIPinchGestureRecognizer { |
There was a problem hiding this comment.
🟢 Low MessagesListView/MessageContextMenuWrapper.swift:570
In gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) at lines 570-576, the pinch-to-press-tracker simultaneity check is asymmetric: it only returns true when otherGestureRecognizer is the press tracker (UILongPress with minimumPressDuration == 0). When UIKit calls the method with the arguments swapped—press tracker as gestureRecognizer and pinch as otherGestureRecognizer—the check fails and the method returns false, blocking pinch gestures during press tracking. The logic should be symmetric to handle both argument orderings.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file Convos/Conversation Detail/Messages/MessagesListView/MessageContextMenuWrapper.swift around line 570:
In `gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:)` at lines 570-576, the pinch-to-press-tracker simultaneity check is asymmetric: it only returns `true` when `otherGestureRecognizer` is the press tracker (UILongPress with `minimumPressDuration == 0`). When UIKit calls the method with the arguments swapped—press tracker as `gestureRecognizer` and pinch as `otherGestureRecognizer`—the check fails and the method returns `false`, blocking pinch gestures during press tracking. The logic should be symmetric to handle both argument orderings.
Evidence trail:
Convos/Conversation Detail/Messages/MessagesListView/MessageContextMenuWrapper.swift lines 565-586 at REVIEWED_COMMIT. The `gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:)` method checks `otherGestureRecognizer is UILongPressGestureRecognizer` (line 571) but never checks `gestureRecognizer is UILongPressGestureRecognizer`, making the simultaneity check asymmetric.
ApprovabilityVerdict: Needs human review 1 blocking correctness issue found. This PR introduces a new user-facing feature (pinch-to-zoom for media) with new state management, gesture recognition, and overlay components. Additionally, an unresolved high-severity comment identifies a potential race condition in the zoom state reset logic that could cause visual glitches. You can customize Macroscope's approvability policy. Learn more. |
…ontextMenuWrapper Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Test plan
🤖 Generated with Claude Code
Note
Add Instagram-style pinch-to-zoom for media in conversations
MediaZoomState, a shared observable class that tracks zoom scale, translation, source frame, and active media reference, passed via SwiftUI environment through the cell hierarchy.GestureOverlayViewthat initiates, updates, and ends a zoom session, temporarily disabling the scroll view's pan gesture during zoom.MediaZoomOverlayinMessagesViewthat animates media from its source frame and shows a scrim based on zoom level.VideoTapAttachmentViewandAttachmentPlaceholderto expose image/player references upward for zooming, add avatar/info/reaction overlays on full-bleed attachment cells, and defer blurred video playback until after reveal.adjacentToFullBleedAbove/adjacentToFullBleedBelowflags toMessagesGroupso consecutive full-bleed attachment cells use hairline spacing instead of standard padding.Macroscope summarized 9569dad.