Skip to content

Unify photo/video media bubble layout#692

Merged
yewreeka merged 24 commits intodevfrom
onefovrth/media-bubble-unify
Apr 15, 2026
Merged

Unify photo/video media bubble layout#692
yewreeka merged 24 commits intodevfrom
onefovrth/media-bubble-unify

Conversation

@onefovrth
Copy link
Copy Markdown
Contributor

@onefovrth onefovrth commented Apr 12, 2026

Summary

  • Unified photo and video media bubbles with consistent container-based overlays: sender identity (top-left), info badge (top-right), reactions (bottom-left), and top gradient
  • Media overlays follow swipe-to-reply gesture with animated spring return, and corners animate from sharp to 20pt during swipe
  • Play button now appears on blurred videos; tapping a blurred video reveals and plays in one action
  • Video duration is resolved from AVAsset for received videos and displayed in the info badge
  • Reaction pill uses liquid glass with reacted/not-reacted visual distinction (.clear vs .regular.tint(.white.opacity(0.6)))
  • Video thumbnails cached in ImageCache so context menu preview works for received videos
  • Context menu photo preview cleaned up to show plain image without overlays
  • Text message reaction pill background changed from colorFillMinimal to colorFillSubtle

Test plan

  • Verify sender identity shows top-left on both sent and received photos/videos
  • Verify "Tap to reveal" and duration show in top-right info badge
  • Verify reactions pill appears bottom-left with glass effect
  • Verify tapping reactions opens reaction sheet, tapping identity opens profile
  • Verify swipe-to-reply moves all overlays with the image and animates back
  • Verify corner radius animates from 0 to 20pt during swipe
  • Verify play button appears on blurred videos
  • Verify tapping blurred video reveals and plays
  • Verify video duration appears for received videos
  • Verify long-press context menu shows photo/video preview correctly
  • Verify reaction pill visual difference between reacted and not-reacted states

🤖 Generated with Claude Code

Note

Unify photo and video media bubble layout with shared overlays and reactions support

  • Replaces per-media-type overlay components with unified overlays: MediaTopGradient, MediaContainerID (avatar/name), MediaContainerInfo (tap-to-reveal/duration), and MediaContainerReax (reaction chips) in MessagesGroupItemView.swift
  • Video attachments now support double-tap to react, a reactions peek overlay during playback, and auto-play after blur reveal
  • Removes PhotoBlurOverlayView, PhotoEdgeGradient, and PhotoSenderLabel in favor of the new shared components
  • Adds onReaction callback wired from MessagesGroupView through the data source up to MessagesViewController, enabling reaction toggle events to propagate out of the collection view
  • MessagesGroup gains adjacentToFullBleedAbove/Below flags computed in MessagesListProcessor, used to tighten spacing and suppress inline reaction indicators between consecutive full-bleed attachment groups
  • Context menu photo preview (MessageContextMenuOverlay) no longer shows a sender label and now overlays a play icon for videos

Macroscope summarized 023261b.

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

Claude finished @onefovrth's task —— View job


PR Review: Unify photo/video media bubble layout

I've completed a thorough code review. Here are my findings:

Tasks:

  • Read and understand the core changes in MessagesGroupItemView.swift
  • Review MessagesListView.swift changes
  • Review data source and view controller changes
  • Analyze architecture and state management
  • Check for potential bugs and edge cases
  • Evaluate performance considerations
  • Assess security concerns
  • Review test coverage needs
  • Provide comprehensive feedback

Code Quality & Architecture

Strengths:

  • Clean overlay system with dedicated components (MediaContainerID, MediaContainerInfo, MediaContainerReax) follows good separation of concerns
  • Full-bleed attachment adjacency logic in MessagesListProcessor is well-designed with proper isolation of concerns
  • Gesture exclusion frames properly prevent overlay tap conflicts with swipe gestures
  • Video playback coordination using NotificationCenter prevents multiple videos playing simultaneously
  • Proper use of @State and SwiftUI lifecycle management

Areas for Improvement:

1. Potential State Race Condition (MessagesGroupItemView.swift:615-618)

.onChange(of: shouldBlur) {
    if shouldBlur, isPlaying {
        inlinePlayer?.pause()
        isPlaying = false
    } else if !shouldBlur, pendingPlayAfterReveal {
        pendingPlayAfterReveal = false
        handleVideoPlayTap()
    }
}

Issue: When a blurred video is tapped, pendingPlayAfterReveal is set to true, then onPhotoRevealed is called. However, the reveal is asynchronous (persisted to database), so shouldBlur may not update immediately. If the user rapidly taps multiple times, the pending flag could be cleared before the blur state updates, losing the play intent.

Recommendation: Add a generation counter or timestamp to the pending play request to ensure the most recent intent is preserved:

@State private var pendingPlayGeneration: Int = 0

// In singleTapAction:
pendingPlayGeneration += 1
pendingPlayAfterReveal = true

// In onChange(of: shouldBlur):
else if !shouldBlur, pendingPlayAfterReveal {
    let generation = pendingPlayGeneration
    pendingPlayAfterReveal = false
    DispatchQueue.main.async {
        if generation == pendingPlayGeneration {
            handleVideoPlayTap()
        }
    }
}

2. Memory Leak Risk in VideoURLCache (MessagesGroupItemView.swift:491-502)

private actor VideoURLCache {
    static let shared: VideoURLCache = VideoURLCache()
    private var cache: [String: URL] = [:]
    
    func url(for key: String) -> URL? {
        cache[key]
    }
    
    func set(_ url: URL, for key: String) {
        cache[key] = url
    }
}

Issue: The cache grows unbounded and stores temporary file URLs that are never cleaned up. Temporary files may accumulate in the file system, consuming disk space.

Recommendation:

  • Add cache size limits and LRU eviction
  • Clean up temporary files when they're evicted
  • Consider using NSCache for automatic memory pressure handling:
private actor VideoURLCache {
    static let shared = VideoURLCache()
    private let cache = NSCache<NSString, NSURL>()
    private let fileManager = FileManager.default
    
    func url(for key: String) -> URL? {
        cache.object(forKey: key as NSString) as URL?
    }
    
    func set(_ url: URL, for key: String) {
        cache.setObject(url as NSURL, forKey: key as NSString)
    }
    
    func cleanup() async {
        // Periodically clean temp directory
    }
}

3. Inconsistent Error Handling (MessagesGroupItemView.swift:772-778)

} catch {
    loadError = error
    isLoading = false
    isLoadingVideo = false
    videoLoadFailed = true
    Log.error("Failed to load video: \(error)")
}

Issue: Video load failures are logged but not surfaced to the user in a clear way. The videoLoadFailed state prevents showing the play button, but there's no user-facing error message explaining why the video won't play.

Recommendation: Add an error overlay with retry option:

.overlay {
    if videoLoadFailed {
        VStack(spacing: 8) {
            Image(systemName: "exclamationmark.triangle")
            Text("Video failed to load")
                .font(.caption)
            Button("Retry") { /* retry logic */ }
        }
        .padding()
        .background(.ultraThinMaterial)
        .cornerRadius(12)
    }
}

4. Gesture Exclusion Frame Complexity (MessageContextMenuWrapper.swift:501-504)

func isExcluded(point: CGPoint, in overlay: UIView) -> Bool {
    let pointInGlobal = overlay.convert(point, to: nil)
    return excludedFrames.contains { $0.contains(pointInGlobal) }
}

Observation: The gesture exclusion system works correctly but is complex. Each overlay (avatar, info badge, reactions) reports its global frame via PreferenceKey, and the gesture overlay checks if taps fall within those frames.

Recommendation: Consider adding documentation explaining this system, as it's not immediately obvious how the parts connect. Also verify that rapid layout changes (e.g., during rotation) don't cause frame mismatches.


5. SwiftLint Violations

Based on CLAUDE.md rules, potential violations:

Line Length: Several lines exceed 200 characters (e.g., MessagesGroupItemView.swift:148):

private func messagesGroupView(for group: MessagesGroup) -> MessagesGroupView {

Function Body Length: AttachmentPlaceholder.body (lines 559-637, ~78 lines) approaches the 125-line limit. Consider extracting video overlay logic.

Type Body Length: MessagesGroupItemView + nested views total ~1100 lines, well within the 625-line limit for the main type.


Performance Considerations

Good:

  • Lazy image loading with ImageCache integration
  • Thumbnail extraction from video assets happens asynchronously
  • Proper use of .id() modifiers to prevent unnecessary redraws
  • Corner radius animation only applied during swipe (not constantly)

Concerns:

  1. Video Asset Loading (line 744-756): Creating AVURLAsset and loading duration happens on every view appearance. Consider caching resolved durations in the database or a separate cache layer.

  2. Reaction Peek Animations (line 458-488): Multiple Task creations and cancellations on every double-tap could be optimized. The generation-based approach is good, but consider debouncing rapid double-taps.

  3. Full-Bleed Adjacency Calculation (MessagesListProcessor.swift:278-292): O(n) iteration over all items to set adjacency flags. This is fine for typical message lists but could be optimized if lists grow very large (thousands of items).


Security Concerns

No critical security issues identified.

Minor observations:

  • Video URLs from temp directory are properly scoped per attachment
  • No sensitive data logged in error messages
  • Blur state properly respects attachment.isRevealed and shouldBlurPhotos flags

Test Coverage

Missing Test Coverage:

  1. Video Playback State Machine: No tests for the complex state transitions between blurred, pending play, loading, playing, and paused states
  2. Gesture Exclusion Logic: No tests verifying that taps on overlays don't trigger swipe-to-reply
  3. Reaction Peek Timing: No tests for the double-tap → reaction → peek → auto-hide flow
  4. Full-Bleed Adjacency: No tests in MessagesListProcessor verifying consecutive full-bleed media get collapsed spacing

Recommended Test Cases:

// Test: Tapping blurred video reveals and plays
func testTapBlurredVideoRevealsAndPlays() async {
    // Given: blurred video
    // When: single tap
    // Then: onPhotoRevealed called, pendingPlayAfterReveal set
    // When: shouldBlur updates to false
    // Then: video starts playing
}

// Test: Double-tapping playing video shows reaction peek
func testDoubleTapPlayingVideoShowsReactionPeek() async {
    // Given: playing video
    // When: double-tap
    // Then: reactionsPeekVisible = true
    // Then: after 1100ms, peek auto-hides
}

// Test: Gesture exclusion prevents overlay taps from swiping
func testOverlayTapsDoNotTriggerSwipe() {
    // Given: media bubble with avatar overlay
    // When: tap on avatar frame
    // Then: onTapAvatar called, swipe gesture ignored
}

Bugs & Edge Cases

Potential Bug: Seek Completion Handler Dispatch

MessagesGroupItemView.swift:675-686:

player.seek(to: .zero) { [weak player] finished in
    guard finished else {
        DispatchQueue.main.async {
            isPlaying = false
        }
        return
    }
    DispatchQueue.main.async {
        player?.play()
        isPlaying = true
        NotificationCenter.default.post(name: Self.videoPlaybackStarted, object: id)
    }
}

Issue: The finished check prevents playback if the seek was cancelled, but this could leave the UI in an inconsistent state if the user expects the video to replay from the start.

Recommendation: Add user-facing feedback if seek fails (e.g., show an alert or reset the play button state).


Best Practices Alignment

Adheres to CLAUDE.md guidelines:

  • ✅ Uses @Observable (not present here, but existing code uses @State correctly)
  • ✅ Extracts button actions to avoid closure compilation errors (line 915: let tapAction = { onTap?() ?? () })
  • ✅ No force unwrapping or implicitly unwrapped optionals
  • ✅ Uses guard let for safe unwrapping
  • ✅ No trailing whitespace
  • ✅ Proper spacing and formatting

Minor Deviations:

  • Some lines slightly exceed 200 characters (acceptable if SwiftLint doesn't flag them)
  • No inline documentation for complex gesture exclusion system

Summary

This is a well-architected PR that successfully unifies the media bubble layout with a clean overlay system. The code quality is high, with proper state management and gesture handling.

Critical issues: None
High-priority improvements: Video URL cache cleanup (memory leak risk), state race condition for pending play after reveal
Medium-priority improvements: Better error UX for failed video loads, test coverage for complex state machines
Low-priority improvements: Documentation for gesture exclusion system, minor performance optimizations

Recommendation: This PR is ready to merge after addressing the memory leak in VideoURLCache and considering the pending play race condition fix. The other issues can be addressed in follow-up PRs.


Comment thread Convos/Conversation Detail/Messages/MessagesListView/MessagesGroupItemView.swift Outdated
@yewreeka yewreeka marked this pull request as ready for review April 15, 2026 20:33
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp bot commented Apr 15, 2026

Approvability

Verdict: Needs human review

This PR unifies photo/video media bubble layouts but introduces significant user-facing behavior changes including new reaction handling on media, modified video playback interaction (double-tap and reveal-to-play flow), gesture exclusion zones, and layout adjustments between adjacent full-bleed items. The scope goes beyond cosmetic unification into new feature territory.

You can customize Macroscope's approvability policy. Learn more.

onefovrth and others added 20 commits April 15, 2026 16:02
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>
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>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Received videos lack thumbnailData because the XMTP protocol does not
carry sender-side custom metadata (thumbnail, dimensions, duration).
The AVPlayer displayed the first frame directly but no UIImage was ever
cached, so reply bar previews and inline reply references showed a
placeholder. Generate a thumbnail from the video asset after loading
and cache it in ImageCache, matching the behavior photos already have.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Media container overlays (avatar, duration, reactions) were layered
above the GestureOverlayView, intercepting touches before the UIKit
double-tap recognizer could fire. Move messageGesture to apply after
the overlays and mark overlays as non-hit-testable so the gesture
overlay sits on top and handles all interactions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@yewreeka yewreeka force-pushed the onefovrth/media-bubble-unify branch from a8119de to 71b5698 Compare April 15, 2026 23:06
@yewreeka yewreeka merged commit 0498a20 into dev Apr 15, 2026
7 checks passed
@yewreeka yewreeka deleted the onefovrth/media-bubble-unify branch April 15, 2026 23:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants