Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion bitchat/Features/voice/VoiceNotePlaybackController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -339,16 +344,29 @@ 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
}

func deactivate(_ controller: any ExclusivePlayback) {
if activeController === controller {
activeController = nil
hasActivePlayback = false
}
if latestReservedController === controller {
latestReservedController = nil
Expand Down
27 changes: 27 additions & 0 deletions bitchat/ViewModels/PrivateChatSwipeToLeavePolicy.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
23 changes: 19 additions & 4 deletions bitchat/Views/ContentSheetViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
24 changes: 21 additions & 3 deletions bitchat/Views/Media/WaveformView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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)
}
)
Expand Down
186 changes: 186 additions & 0 deletions bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
//
// 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
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)
}
}