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
This commit is contained in:
Taksh 2026-08-14 10:18:30 +05:30
parent 9b84b36122
commit f61aa98c4a
4 changed files with 193 additions and 3 deletions

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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()

View File

@ -0,0 +1,149 @@
//
// PrivateChatSwipeToLeavePolicyTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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)
}
}