diff --git a/bitchat/Features/voice/VoiceNotePlaybackController.swift b/bitchat/Features/voice/VoiceNotePlaybackController.swift index 3818128cfe..f3c14ec7df 100644 --- a/bitchat/Features/voice/VoiceNotePlaybackController.swift +++ b/bitchat/Features/voice/VoiceNotePlaybackController.swift @@ -113,6 +113,11 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay updateProgress() isPlaying = false releaseSession() + // The slot means "audio is audible", not "a row is selected": a paused + // note has nothing to yield to the next playback, and readers of + // `hasActivePlayback` would otherwise see playback that stopped being + // heard when the user paused it. + exclusivity.deactivate(self) } func stop() { @@ -296,7 +301,7 @@ extension VoiceNotePlaybackController: ExclusivePlayback { } /// Ensures only one voice playback (note or live burst) runs at a time. -final class VoiceNotePlaybackCoordinator { +final class VoiceNotePlaybackCoordinator: ObservableObject { static let shared = VoiceNotePlaybackCoordinator() struct Reservation: Equatable { @@ -339,9 +344,21 @@ final class VoiceNotePlaybackCoordinator { } activeController?.pauseForExclusivity() activeController = controller + hasActivePlayback = true return true } + /// True while some controller holds the playback slot. + /// + /// Read by the private-chat swipe-to-leave gesture, which stands down while + /// a voice note is audible so a waveform seek is not starved by the + /// high-priority ancestor drag. See `PrivateChatSwipeToLeavePolicy`. + /// + /// Published rather than computed so a view can drop the gesture for the + /// duration: a gesture that is armed at all starves its descendants, so + /// reading this only once the drag ends comes too late for the seek. + @Published private(set) var hasActivePlayback: Bool = false + func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool { latestReservation == reservation && latestReservedController === controller } @@ -349,6 +366,7 @@ final class VoiceNotePlaybackCoordinator { func deactivate(_ controller: any ExclusivePlayback) { if activeController === controller { activeController = nil + hasActivePlayback = false } if latestReservedController === controller { latestReservedController = nil diff --git a/bitchat/ViewModels/PrivateChatSwipeToLeavePolicy.swift b/bitchat/ViewModels/PrivateChatSwipeToLeavePolicy.swift new file mode 100644 index 0000000000..160f67346a --- /dev/null +++ b/bitchat/ViewModels/PrivateChatSwipeToLeavePolicy.swift @@ -0,0 +1,27 @@ +import CoreGraphics + +/// Decides whether a drag on the private-chat message list should end the +/// conversation. +/// +/// The swipe-right-to-leave gesture is attached to the message list with +/// `highPriorityGesture`, which starves gestures on descendant views. A playing +/// voice note overlays its waveform with a seek `DragGesture(minimumDistance: 0)`, +/// so a drag meant to scrub playback never reaches the waveform, and one that +/// happens to travel far enough to the right leaves the conversation instead. +/// +/// While audio is playing the seek is the intent the reader is far more likely +/// to have, so the leave gesture stands down for the duration of playback. +/// Leaving remains available through the sidebar. +enum PrivateChatSwipeToLeavePolicy { + /// Rightward travel a drag must exceed before it counts as a leave. + static let minimumHorizontalTranslation: CGFloat = 80 + + /// Vertical travel above which a drag reads as a scroll, not a leave. + static let maximumVerticalTranslation: CGFloat = 60 + + static func shouldLeave(translation: CGSize, isVoiceNotePlaying: Bool) -> Bool { + guard !isVoiceNotePlaying else { return false } + return translation.width > minimumHorizontalTranslation + && abs(translation.height) < maximumVerticalTranslation + } +} diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 88be040fec..5741975e9b 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -462,6 +462,9 @@ private extension ContentPeopleListView { private struct ContentPrivateChatSheetView: View { @EnvironmentObject private var privateConversationModel: PrivateConversationModel + /// Observed so the swipe gesture can be dropped for the duration of + /// playback rather than merely declining to act when it ends. + @ObservedObject private var playbackCoordinator = VoiceNotePlaybackCoordinator.shared @Binding var showSidebar: Bool @Binding var messageText: String @@ -572,7 +575,14 @@ private struct ContentPrivateChatSheetView: View { // whole sheet it preempted the composer's press-and-hold mic // gesture (a high-priority ancestor drag cancels child gestures // within milliseconds — same starvation as the image-reveal bug). - .highPriorityGesture(swipeToLeaveGesture) + // `.subviews` while a note is audible: recognition then belongs to + // the waveform's seek drag. Guarding in `onEnded` is not enough -- + // an armed high-priority ancestor drag starves its descendants + // before either gesture ends, so the scrub would still be lost. + .highPriorityGesture( + swipeToLeaveGesture, + including: playbackCoordinator.hasActivePlayback ? .subviews : .all + ) if !theme.usesGlassChrome { Divider() @@ -608,9 +618,14 @@ private struct ContentPrivateChatSheetView: View { private var swipeToLeaveGesture: some Gesture { DragGesture(minimumDistance: 25, coordinateSpace: .local) .onEnded { value in - let horizontal = value.translation.width - let vertical = abs(value.translation.height) - guard horizontal > 80, vertical < 60 else { return } + // Stands down while a voice note is audible: this gesture is + // attached with `highPriorityGesture` and would otherwise starve + // the waveform's seek drag, and a scrub that drifts right would + // end the conversation mid-playback. + guard PrivateChatSwipeToLeavePolicy.shouldLeave( + translation: value.translation, + isVoiceNotePlaying: VoiceNotePlaybackCoordinator.shared.hasActivePlayback + ) else { return } withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { showSidebar = true privateConversationModel.endConversation() diff --git a/bitchat/Views/Media/WaveformView.swift b/bitchat/Views/Media/WaveformView.swift index b12ec6ee47..077bad9e84 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 gesture 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,19 @@ struct WaveformView: View { if isInteractive, let onSeek = onSeek { Color.clear .contentShape(Rectangle()) + // The private conversation drops its high-priority + // swipe-to-leave gesture while a note is playing, so + // drag-seeking remains available without allowing a + // scrub to close the conversation. Keep the original + // zero-distance drag: users can press, slide to a + // target, and release to seek. .gesture( - DragGesture(minimumDistance: 0) + DragGesture(minimumDistance: 0, 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) } ) diff --git a/bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift b/bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift new file mode 100644 index 0000000000..f56423e856 --- /dev/null +++ b/bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift @@ -0,0 +1,186 @@ +// +// PrivateChatSwipeToLeavePolicyTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import CoreGraphics +import Foundation +@testable import bitchat + +/// A stand-in for the playback slot holder. The coordinator only needs identity +/// and the ability to be told to yield. +private final class StubPlayback: ExclusivePlayback { + private(set) var pauseCount = 0 + func pauseForExclusivity() { pauseCount += 1 } +} + +@Suite("PrivateChatSwipeToLeavePolicy") +struct PrivateChatSwipeToLeavePolicyTests { + + // MARK: - Thresholds preserved + + @Test("a decisive rightward drag leaves when nothing is playing") + func decisiveDragLeaves() { + #expect(PrivateChatSwipeToLeavePolicy.shouldLeave( + translation: CGSize(width: 120, height: 10), + isVoiceNotePlaying: false + )) + } + + @Test("a drag that does not clear the horizontal threshold does not leave") + func shortDragStays() { + #expect(!PrivateChatSwipeToLeavePolicy.shouldLeave( + translation: CGSize(width: 80, height: 0), + isVoiceNotePlaying: false + )) + } + + @Test("a leftward drag never leaves") + func leftwardDragStays() { + #expect(!PrivateChatSwipeToLeavePolicy.shouldLeave( + translation: CGSize(width: -120, height: 0), + isVoiceNotePlaying: false + )) + } + + @Test("a steep drag reads as a scroll, not a leave", arguments: [60.0, 61.0, -60.0, -90.0]) + func steepDragStays(vertical: Double) { + #expect(!PrivateChatSwipeToLeavePolicy.shouldLeave( + translation: CGSize(width: 200, height: vertical), + isVoiceNotePlaying: false + )) + } + + @Test("vertical travel is judged on magnitude, so a shallow upward drag still leaves") + func shallowUpwardDragLeaves() { + #expect(PrivateChatSwipeToLeavePolicy.shouldLeave( + translation: CGSize(width: 200, height: -59), + isVoiceNotePlaying: false + )) + } + + // MARK: - The regression this policy exists for + + @Test("a drag that would otherwise leave is suppressed while a voice note plays") + func playbackSuppressesLeave() { + // The reported bug: scrubbing a playing waveform drifts right, the + // high-priority ancestor claims the drag, and the conversation ends. + #expect(!PrivateChatSwipeToLeavePolicy.shouldLeave( + translation: CGSize(width: 200, height: 5), + isVoiceNotePlaying: true + )) + } + + @Test("suppression lifts once playback stops") + func leaveResumesAfterPlayback() { + let translation = CGSize(width: 200, height: 5) + #expect(!PrivateChatSwipeToLeavePolicy.shouldLeave( + translation: translation, isVoiceNotePlaying: true)) + #expect(PrivateChatSwipeToLeavePolicy.shouldLeave( + translation: translation, isVoiceNotePlaying: false)) + } + + @Test("playback does not rescue a drag that never qualified") + func playbackDoesNotInventALeave() { + #expect(!PrivateChatSwipeToLeavePolicy.shouldLeave( + translation: CGSize(width: 10, height: 200), + isVoiceNotePlaying: true + )) + } +} + +@Suite("VoiceNotePlaybackCoordinator.hasActivePlayback") +struct VoiceNotePlaybackCoordinatorActivityTests { + + @Test("an idle coordinator reports no active playback") + func idleCoordinator() { + #expect(!VoiceNotePlaybackCoordinator().hasActivePlayback) + } + + @Test("activating a controller marks playback active") + func activationMarksActive() { + let coordinator = VoiceNotePlaybackCoordinator() + let playback = StubPlayback() + coordinator.activate(playback) + #expect(coordinator.hasActivePlayback) + } + + @Test("deactivating the active controller clears it") + func deactivationClears() { + let coordinator = VoiceNotePlaybackCoordinator() + let playback = StubPlayback() + coordinator.activate(playback) + coordinator.deactivate(playback) + #expect(!coordinator.hasActivePlayback) + } + + @Test("a reservation alone does not count as playing") + func reservationIsNotPlayback() { + // `reserve` records intent before an async starter has audio ready; + // treating that as playing would suppress the leave gesture on a note + // that never becomes audible. + let coordinator = VoiceNotePlaybackCoordinator() + _ = coordinator.reserve(StubPlayback()) + #expect(!coordinator.hasActivePlayback) + } + + @Test("deactivating a controller that does not hold the slot leaves it active") + func foreignDeactivateIsIgnored() { + let coordinator = VoiceNotePlaybackCoordinator() + let holder = StubPlayback() + coordinator.activate(holder) + coordinator.deactivate(StubPlayback()) + #expect(coordinator.hasActivePlayback) + } + + @Test("a controller that pauses releases the slot") + func pauseReleasesSlot() throws { + // The slot means audible playback. A paused note holds nothing, and + // leaving it in place kept the leave gesture suppressed for the rest + // of the row's life. + let coordinator = VoiceNotePlaybackCoordinator() + let url = try Self.makeSilentVoiceNote() + defer { try? FileManager.default.removeItem(at: url) } + let controller = VoiceNotePlaybackController(url: url, exclusivity: coordinator) + + controller.play() + #expect(coordinator.hasActivePlayback) + + controller.pause() + #expect(!coordinator.hasActivePlayback) + } + + /// A one-frame WAV: enough for AVAudioPlayer to prepare and start. + private static func makeSilentVoiceNote() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("swipe-policy-\(UUID().uuidString).wav") + var data = Data() + func append(_ string: String) { data.append(contentsOf: Array(string.utf8)) } + func append(_ value: UInt32) { withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) } } + func append(_ value: UInt16) { withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) } } + let frames: UInt32 = 8_000 // one second at 8 kHz, 16-bit mono + let dataBytes = frames * 2 + append("RIFF"); append(36 + dataBytes); append("WAVE") + append("fmt "); append(UInt32(16)); append(UInt16(1)); append(UInt16(1)) + append(UInt32(8_000)); append(UInt32(16_000)); append(UInt16(2)); append(UInt16(16)) + append("data"); append(dataBytes) + data.append(Data(repeating: 0, count: Int(dataBytes))) + try data.write(to: url) + return url + } + + @Test("taking over the slot pauses the previous holder and stays active") + func takeoverKeepsPlaybackActive() { + let coordinator = VoiceNotePlaybackCoordinator() + let first = StubPlayback() + let second = StubPlayback() + coordinator.activate(first) + coordinator.activate(second) + #expect(first.pauseCount == 1) + #expect(coordinator.hasActivePlayback) + } +}