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) + } +}