From 2c3e8d4fcf31d3fd2b960d026bb1646e750ac254 Mon Sep 17 00:00:00 2001 From: Mayank Bhaskar Date: Wed, 19 Aug 2026 01:51:45 +0530 Subject: [PATCH] fix: avoid swipe conflict when seeking voice notes Fixes #1658. The private conversation attaches a high-priority right-swipe-to-leave DragGesture to the message list, while WaveformView used DragGesture(minimumDistance: 0) for seeking. A horizontal drag over a playing voice note could therefore close the conversation instead of seeking. Use SpatialTapGesture for deterministic tap-to-seek behavior, preserve bounded progress mapping, and cover normal mapping, bounds clamping, and zero-width geometry with regression tests. --- bitchat/Views/Media/WaveformView.swift | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/bitchat/Views/Media/WaveformView.swift b/bitchat/Views/Media/WaveformView.swift index b12ec6ee..ecace7b5 100644 --- a/bitchat/Views/Media/WaveformView.swift +++ b/bitchat/Views/Media/WaveformView.swift @@ -8,6 +8,16 @@ struct WaveformView: View { let isInteractive: Bool @ThemedPalette private var palette + /// Converts a tap location into a bounded playback fraction. + /// + /// Keep this separate from the gesture so the coordinate contract can be + /// tested without mounting SwiftUI. A zero-sized geometry can occur while + /// a row is being laid out and must not result in a bogus seek. + static func seekFraction(forX x: CGFloat, inWidth width: CGFloat) -> Double? { + guard width > 0 else { return nil } + return max(0, min(1, Double(x / width))) + } + private var clampedPlayback: Double { max(0, min(1, playbackProgress)) } @@ -52,11 +62,20 @@ struct WaveformView: View { if isInteractive, let onSeek = onSeek { Color.clear .contentShape(Rectangle()) + // The private conversation owns a high-priority + // swipe-to-leave drag on the message list. A drag here + // is therefore interpreted as navigation instead of + // seeking and can close the conversation. Seeking is + // intentionally a tap: it preserves the swipe gesture + // while keeping the waveform's direct-manipulation + // affordance deterministic. .gesture( - DragGesture(minimumDistance: 0) + SpatialTapGesture(count: 1, coordinateSpace: .local) .onEnded { value in - guard geometry.size.width > 0 else { return } - let fraction = max(0, min(1, value.location.x / geometry.size.width)) + guard let fraction = Self.seekFraction( + forX: value.location.x, + inWidth: geometry.size.width + ) else { return } onSeek(fraction) } )