Skip to content

fix: stand down swipe-to-leave while a voice note is playing - #1668

Open
Chessing234 wants to merge 3 commits into
permissionlesstech:mainfrom
Chessing234:fix/voice-note-seek-vs-swipe-to-leave
Open

fix: stand down swipe-to-leave while a voice note is playing#1668
Chessing234 wants to merge 3 commits into
permissionlesstech:mainfrom
Chessing234:fix/voice-note-seek-vs-swipe-to-leave

Conversation

@Chessing234

Copy link
Copy Markdown
Contributor

Refs #1658.

What happens

WaveformView overlays the bars with a seek DragGesture(minimumDistance: 0)
(WaveformView.swift:55), live while the note plays — VoiceNoteView.swift:71
passes isInteractive: playback.isPlaying. In a private chat that waveform
renders inside the message list, and the sheet attaches swipe-right-to-leave to
that same list with .highPriorityGesture (ContentSheetViews.swift:575).

A high-priority ancestor drag starves descendant gestures, so two things follow:
a scrub across the waveform never reaches the seek, and a scrub that happens to
travel past horizontal > 80, vertical < 60 ends the conversation while the note
is still playing.

The issue notes this is the third appearance of the same starvation: #1405 fixed
it as a cause (the mic press-and-hold was cancelled 3-10 ms after touch-down, and
the swipe moved off the whole sheet onto the message list), and #1402 hardened
against it (the reveal moved onto a Button, because Button actions survive
starvation while descendant gestures do not). The comment at the attachment site
already records the mechanism.

Fix

While audio is audible the scrub is far likelier to be the reader's intent than
leaving, so the leave gesture stands down for the duration of playback.

Two supporting changes keep that decision honest rather than buried in a view:

  • PrivateChatSwipeToLeavePolicy holds the 80/60 thresholds and the playback
    guard, so the decision is testable without a gesture recogniser.
  • VoiceNotePlaybackCoordinator — which already exists to enforce
    single-playback and already exposes an internal init() "so tests can isolate
    their own exclusivity slot" — gains hasActivePlayback. No new state, no new
    ownership; it is the object that already knows.

Deliberate behaviour change, stated plainly: while a voice note plays, the
swipe gesture will not leave the conversation. Leaving stays available from the
sidebar. That is the trade this fix makes, and it is exactly the reported harm
("leaves the conversation while the note is playing") — but it is a change, not
a pure bug fix, so please push back if you'd rather the seek be rescued a
different way (e.g. moving the seek onto a control that survives starvation, the
#1402 route).

Test plan

  • swift build --build-tests — clean; no new warnings (the four
    no calls to throwing functions warnings in BLEAnnounceHandlerTests and
    the unhandled-resource warning are pre-existing on main).
  • swift test --skip-build --parallel2032 tests in 221 suites passed.
    Baseline on main before this change: 2018 tests in 219 suites, also green.
    The delta is exactly the 14 new tests in 2 new suites.
  • Verified the regression tests have teeth: deleting the
    guard !isVoiceNotePlaying line fails exactly the two suppression tests
    and nothing else.
  • Gesture composition itself is not covered. swift test cannot exercise
    SwiftUI gesture arbitration, so "the seek now actually receives the drag"
    is verified by reading .highPriorityGesture semantics and the two prior
    PRs, not by execution. A simulator UI test would be needed to prove it, and
    I have not written one.

New coverage, 14 tests:

  • thresholds preserved — decisive drag leaves; sub-threshold, leftward, and
    steep drags do not; vertical is judged on magnitude so a shallow upward drag
    still leaves
  • the regression — a qualifying drag is suppressed while playing, resumes once
    stopped, and playback never invents a leave from a drag that never qualified
  • hasActivePlayback — idle is false, activate/deactivate flip it, a bare
    reserve does not count as playing (an async starter that never becomes
    audible must not suppress the gesture), a foreign deactivate is ignored, and
    a takeover pauses the previous holder while staying active

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 permissionlesstech#1658

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f61aa98c4a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +615 to +617
guard PrivateChatSwipeToLeavePolicy.shouldLeave(
translation: value.translation,
isVoiceNotePlaying: VoiceNotePlaybackCoordinator.shared.hasActivePlayback

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disable the gesture before arbitration, not in onEnded

When the touch starts on an interactive waveform while playback is active, this still installs the same high-priority ancestor DragGesture; this guard only runs after that gesture has already won recognition over the waveform's DragGesture(minimumDistance: 0). In that scenario the scrub still never reaches WaveformView—the change only prevents the conversation from leaving—so the swipe gesture needs to be omitted or disabled while playback is active rather than returning here.

Useful? React with 👍 / 👎.

Comment on lines +350 to +352
var hasActivePlayback: Bool {
activeController != nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the active slot when playback is paused

A paused voice note still leaves activeController set: VoiceNotePlaybackController.pause() sets isPlaying = false and releases the audio session, but only stop()/finish deactivate the coordinator. Because this property equates holding the slot with audible playback, any qualifying private-chat swipe remains suppressed after the user pauses a note, until the row disappears or playback is stopped some other way.

Useful? React with 👍 / 👎.

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.
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.
@Chessing234

Copy link
Copy Markdown
Contributor Author

both findings are right, and the first one is the important half — the onEnded guard stopped the conversation from being left but did nothing for the scrub, because an armed highPriorityGesture starves its descendants at recognition time.

2da25a9: the gesture is attached with including: playbackCoordinator.hasActivePlayback ? .subviews : .all, so while a note is audible recognition belongs to the waveform's seek drag. the onEnded guard stays for a drag already in flight when playback starts.

ca15f66: pause() now releases the slot. it was leaving activeController set, so hasActivePlayback meant "a row was played at some point" and the gesture stayed suppressed after a pause until the row went away. a paused note has nothing to yield to the next playback, so the slot is the wrong thing for it to hold. hasActivePlayback is stored and published now, which is also what lets the view react rather than sample it once.

verification, plainly: xcode is not on this machine any more (command line tools only), so swift-testing does not build and i could not run the suite locally this time — the app target does type-check with both changes. the added coordinator test covers pause releasing the slot; CI runs it. gesture arbitration itself is still not unit-testable, as noted in the PR body.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant