From f61aa98c4a0e026e4b819d325c8e65a08a1d7385 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 14 Aug 2026 10:18:30 +0530 Subject: [PATCH] fix: stand down swipe-to-leave while a voice note is playing A playing voice note overlays its waveform with a seek DragGesture(minimumDistance: 0). The DM sheet attaches swipe-right-to-leave to the same message list with .highPriorityGesture, and a high-priority ancestor drag starves descendant gestures -- so a scrub never reaches the seek, and one that drifts past horizontal > 80 / vertical < 60 ends the conversation mid-playback instead. While audio is audible the scrub is the likelier intent, so the leave gesture stands down for the duration. Leaving stays available from the sidebar. The thresholds move into PrivateChatSwipeToLeavePolicy so the decision is testable without a gesture recogniser, and the coordinator that already enforces single-playback exposes hasActivePlayback as the signal. Refs #1658 --- .../voice/VoiceNotePlaybackController.swift | 9 ++ .../PrivateChatSwipeToLeavePolicy.swift | 27 ++++ bitchat/Views/ContentSheetViews.swift | 11 +- .../PrivateChatSwipeToLeavePolicyTests.swift | 149 ++++++++++++++++++ 4 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 bitchat/ViewModels/PrivateChatSwipeToLeavePolicy.swift create mode 100644 bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift diff --git a/bitchat/Features/voice/VoiceNotePlaybackController.swift b/bitchat/Features/voice/VoiceNotePlaybackController.swift index 3818128c..0019719f 100644 --- a/bitchat/Features/voice/VoiceNotePlaybackController.swift +++ b/bitchat/Features/voice/VoiceNotePlaybackController.swift @@ -342,6 +342,15 @@ final class VoiceNotePlaybackCoordinator { 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`. + var hasActivePlayback: Bool { + activeController != nil + } + func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool { latestReservation == reservation && latestReservedController === controller } diff --git a/bitchat/ViewModels/PrivateChatSwipeToLeavePolicy.swift b/bitchat/ViewModels/PrivateChatSwipeToLeavePolicy.swift new file mode 100644 index 00000000..160f6734 --- /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 88be040f..9cd76170 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -608,9 +608,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/bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift b/bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift new file mode 100644 index 00000000..ece0d61e --- /dev/null +++ b/bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift @@ -0,0 +1,149 @@ +// +// PrivateChatSwipeToLeavePolicyTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import CoreGraphics +@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("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) + } +}