From f61aa98c4a0e026e4b819d325c8e65a08a1d7385 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 14 Aug 2026 10:18:30 +0530 Subject: [PATCH 1/3] 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) + } +} From ca15f66080cde9d933ba45caca51e03b7a5dfce2 Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 17 Aug 2026 10:48:29 +0530 Subject: [PATCH 2/3] fix: release the playback slot when a note is paused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pause() left activeController set, so the slot meant "a row was played at some point" rather than "audio is audible". Any reader of hasActivePlayback — the swipe-to-leave policy above all — kept seeing playback after the user paused, until the row disappeared or something called stop(). A paused note has nothing to yield to the next playback, so it releases the slot. hasActivePlayback becomes stored and published, so a view can react to it rather than sample it once. --- .../voice/VoiceNotePlaybackController.swift | 17 +++++++-- .../PrivateChatSwipeToLeavePolicyTests.swift | 37 +++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/bitchat/Features/voice/VoiceNotePlaybackController.swift b/bitchat/Features/voice/VoiceNotePlaybackController.swift index 0019719f..f3c14ec7 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,6 +344,7 @@ final class VoiceNotePlaybackCoordinator { } activeController?.pauseForExclusivity() activeController = controller + hasActivePlayback = true return true } @@ -347,9 +353,11 @@ final class VoiceNotePlaybackCoordinator { /// 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 - } + /// + /// 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 @@ -358,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/bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift b/bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift index ece0d61e..f56423e8 100644 --- a/bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift +++ b/bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift @@ -8,6 +8,7 @@ import Testing import CoreGraphics +import Foundation @testable import bitchat /// A stand-in for the playback slot holder. The coordinator only needs identity @@ -136,6 +137,42 @@ struct VoiceNotePlaybackCoordinatorActivityTests { #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() From 2da25a9d9e84e5addcd767f982eb3888cd56a3e5 Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 17 Aug 2026 10:48:29 +0530 Subject: [PATCH 3/3] fix: drop the swipe gesture during playback instead of ignoring it The guard in onEnded prevented the conversation from being left, but the scrub was still lost: an armed .highPriorityGesture starves its descendants at recognition time, long before either gesture ends, so the waveform's seek never saw the drag. The gesture is now attached with `including: .subviews` while a note is audible, which yields recognition to the waveform. The onEnded guard stays as the second line of defence for a drag already in flight when playback starts. --- bitchat/Views/ContentSheetViews.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 9cd76170..5741975e 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()