{channelName}
diff --git a/packages/shared/src/contexts/VoiceContext.jsx b/packages/shared/src/contexts/VoiceContext.jsx
index 818d381..52f1fd0 100644
--- a/packages/shared/src/contexts/VoiceContext.jsx
+++ b/packages/shared/src/contexts/VoiceContext.jsx
@@ -82,6 +82,11 @@ export const VoiceProvider = ({ children }) => {
);
const isMovingRef = useRef(false);
const isDMCallRef = useRef(false);
+ // Flipped to true by `disconnectVoice` when the user presses
+ // disconnect *during* the 'connecting' phase. Each major await in
+ // `connectToVoice` checks this ref and bails out cleanly so we
+ // don't end up connected to a channel the user tried to leave.
+ const cancelConnectRef = useRef(false);
const lastSpokeRef = useRef(Date.now());
const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false);
const [isReconnecting, setIsReconnecting] = useState(false);
@@ -288,6 +293,20 @@ export const VoiceProvider = ({ children }) => {
const connectToVoice = async (channelId, channelName, userId, isDMCall = false) => {
if (activeChannelId === channelId) return;
isDMCallRef.current = isDMCall;
+ cancelConnectRef.current = false;
+
+ // Channel hop — we're already in a voice room. Flip the
+ // move-in-progress flag BEFORE disconnecting so the old
+ // room's RoomEvent.Disconnected handler takes the short
+ // "moving" branch: it clears room/token without touching
+ // activeChannelId, skips the voiceState.leave mutation, and
+ // skips the leave sound. Without this the handler races
+ // against the rest of this function — nulling the channel
+ // id we're about to set and calling leave() between our
+ // disconnect and the new voiceState.join, which looks like
+ // "joins, plays sound, drops out" from the user's side.
+ const isMove = !!room;
+ if (isMove) isMovingRef.current = true;
if (room) await room.disconnect();
@@ -296,6 +315,38 @@ export const VoiceProvider = ({ children }) => {
setConnectionState('connecting');
window.__inVoiceCall = true;
+ // Cancellation check — called after every major async step.
+ // If the user pressed disconnect while we were awaiting, roll
+ // back: close the partially-built room (if any; its
+ // Disconnected handler cleans up state), otherwise reset the
+ // pre-room UI state manually. Returns true when the caller
+ // should bail.
+ const abortIfCancelled = (builtRoom) => {
+ if (!cancelConnectRef.current) return false;
+ if (builtRoom) {
+ builtRoom.disconnect().catch(() => {});
+ }
+ // Always reset UI state on cancel, even when a room is
+ // being torn down. The Disconnected handler's "moving"
+ // branch preserves activeChannelId (needed for legit
+ // moves), so we explicitly clear it here.
+ setActiveChannelId(null);
+ setActiveChannelName(null);
+ setConnectionState('disconnected');
+ setToken(null);
+ window.__inVoiceCall = false;
+ voiceService?.stopService();
+ // If this was a channel hop we flipped isMovingRef on;
+ // reset it so a later disconnect doesn't wrongly skip
+ // voiceState.leave.
+ isMovingRef.current = false;
+ // Clean up any stale server-side voice state (the old
+ // channel's row survives a cancelled move because the
+ // "moving" branch skips voiceState.leave).
+ convex.mutation(api.voiceState.leave, { userId }).catch(() => {});
+ return true;
+ };
+
try {
// Request microphone permission (triggers Android runtime prompt)
try {
@@ -308,6 +359,8 @@ export const VoiceProvider = ({ children }) => {
return;
}
+ if (abortIfCancelled(null)) return;
+
// Prove we control `userId` by signing the (userId, channelId,
// timestamp) tuple with the Ed25519 key decrypted at login.
// The server verifies with our public signing key before minting
@@ -323,6 +376,8 @@ export const VoiceProvider = ({ children }) => {
const message = `voice-token:${userId}:${channelId}:${timestamp}`;
const signature = await platform.crypto.signMessage(signingKey, message);
+ if (abortIfCancelled(null)) return;
+
const tokenResult = await convex.action(api.voice.getToken, {
channelId,
userId,
@@ -337,6 +392,8 @@ export const VoiceProvider = ({ children }) => {
return;
}
+ if (abortIfCancelled(null)) return;
+
const lkToken = tokenResult.token;
if (!lkToken) throw new Error('Failed to get token');
@@ -411,6 +468,10 @@ export const VoiceProvider = ({ children }) => {
});
await newRoom.connect(import.meta.env.VITE_LIVEKIT_URL, lkToken);
+ // Cancelled during the LiveKit handshake — tear the room
+ // back down before we wire any more state around it.
+ if (abortIfCancelled(newRoom)) return;
+
if (storedOutputDevice && storedOutputDevice !== 'default') {
await newRoom.switchActiveDevice('audiooutput', storedOutputDevice).catch(e =>
console.warn('Failed to switch output device on connect:', e)
@@ -419,6 +480,8 @@ export const VoiceProvider = ({ children }) => {
await newRoom.localParticipant.setMicrophoneEnabled(!isMuted && !isDeafened);
+ if (abortIfCancelled(newRoom)) return;
+
setRoom(newRoom);
setConnectionState('connected');
// Start native foreground service for background voice on Android
@@ -430,22 +493,12 @@ export const VoiceProvider = ({ children }) => {
playSound('join');
}
- await convex.mutation(api.voiceState.join, {
- channelId,
- userId,
- username: localStorage.getItem('username') || 'Unknown',
- isMuted,
- isDeafened,
- });
-
- // Auto-mute when joining AFK channel
- if (serverSettings?.afkChannelId === channelId) {
- setIsMuted(true);
- await newRoom.localParticipant.setMicrophoneEnabled(false);
- await convex.mutation(api.voiceState.updateState, { userId, isMuted: true });
- voiceService?.updateNotification({ isMuted: true });
- }
-
+ // Register the Disconnected / Reconnecting / etc. handlers
+ // BEFORE `voiceState.join`. If the user taps disconnect in
+ // the narrow window between `setRoom` and this mutation
+ // returning, `disconnectVoice` calls `room.disconnect()` —
+ // without the handler bound there's nothing to tear down
+ // state. Keeping registration first closes that gap.
newRoom.on(RoomEvent.Disconnected, async (reason) => {
console.warn('Voice Room Disconnected. Reason:', reason);
// If we're being moved, skip leave mutation — we'll reconnect shortly
@@ -556,11 +609,46 @@ export const VoiceProvider = ({ children }) => {
}));
});
+ // Commit the server-side voice state LAST. Until this
+ // resolves the reactive `voiceStates` query still reports
+ // us in the old channel, which is why we keep
+ // `isMovingRef.current === true` across the mutation —
+ // otherwise the reconcile effect below sees "server says
+ // A, client says B" and kicks off a spurious reconnect
+ // back to A (the bug that caused double-join / double-
+ // leave sounds when switching channels).
+ await convex.mutation(api.voiceState.join, {
+ channelId,
+ userId,
+ username: localStorage.getItem('username') || 'Unknown',
+ isMuted,
+ isDeafened,
+ });
+
+ // Auto-mute when joining AFK channel
+ if (serverSettings?.afkChannelId === channelId) {
+ setIsMuted(true);
+ await newRoom.localParticipant.setMicrophoneEnabled(false);
+ await convex.mutation(api.voiceState.updateState, { userId, isMuted: true });
+ voiceService?.updateNotification({ isMuted: true });
+ }
+
+ // Now that the server state has been committed and the
+ // reactive query will (imminently) show us in `channelId`,
+ // it's safe to re-enable the reconcile effect. A subtle
+ // point: the query push can land either before or after
+ // the mutation promise resolves; both orderings are fine
+ // because in either case by the time the effect next
+ // reads `voiceStates` it'll either agree with
+ // activeChannelId or this flag will still be true.
+ isMovingRef.current = false;
+
} catch (err) {
console.error('Voice Connection Failed:', err);
setConnectionState('error');
setActiveChannelId(null);
window.__inVoiceCall = false;
+ isMovingRef.current = false;
}
};
@@ -975,6 +1063,38 @@ export const VoiceProvider = ({ children }) => {
const disconnectVoice = () => {
console.log('User manually disconnected voice');
isDMCallRef.current = false;
+
+ // Mid-connect cancel: `connectToVoice` is awaiting somewhere
+ // (mic permission, Convex token action, LiveKit handshake).
+ // Flip the cancel ref so the next `abortIfCancelled` check
+ // tears things down, and reset visible UI state immediately
+ // so the button reflects the user's intent even before the
+ // in-flight awaits unwind.
+ if (connectionState === 'connecting') {
+ cancelConnectRef.current = true;
+ setConnectionState('disconnected');
+ setActiveChannelId(null);
+ setActiveChannelName(null);
+ setToken(null);
+ window.__inVoiceCall = false;
+ voiceService?.stopService();
+ // If the LiveKit connect already completed before the
+ // cancel fired, the room exists in state and we still
+ // need to drop it. The RoomEvent.Disconnected handler
+ // will run voiceState.leave and finish cleanup.
+ if (room) room.disconnect().catch(() => {});
+ return;
+ }
+
+ // A user-initiated disconnect always wants the "leave for
+ // real" path, even if we're still inside the
+ // `connectToVoice(new)` move window (connectionState ===
+ // 'connected' but `isMovingRef` hasn't cleared yet because
+ // `voiceState.join` is in flight). Clearing the move flag
+ // here makes the Disconnected handler take the normal
+ // branch: play leave, clear activeChannelId, run
+ // voiceState.leave.
+ isMovingRef.current = false;
window.__inVoiceCall = false;
voiceService?.stopService();
if (room) room.disconnect();
diff --git a/packages/ui/src/BottomSheet.module.css b/packages/ui/src/BottomSheet.module.css
index 3f0dc8a..3e02d0a 100644
--- a/packages/ui/src/BottomSheet.module.css
+++ b/packages/ui/src/BottomSheet.module.css
@@ -37,7 +37,19 @@
overflow: hidden;
pointer-events: auto;
padding-bottom: env(safe-area-inset-bottom, 0);
- touch-action: pan-y;
+ will-change: transform;
+}
+
+.dragZone {
+ touch-action: none;
+ user-select: none;
+ -webkit-user-select: none;
+ cursor: grab;
+ flex-shrink: 0;
+}
+
+.dragZone:active {
+ cursor: grabbing;
}
.handleRow {
@@ -46,7 +58,6 @@
justify-content: center;
padding: 8px 0 4px;
flex-shrink: 0;
- cursor: grab;
}
.handle {
@@ -106,4 +117,5 @@
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
+ overscroll-behavior: contain;
}
diff --git a/packages/ui/src/BottomSheet.tsx b/packages/ui/src/BottomSheet.tsx
index 7048eb4..68ce55a 100644
--- a/packages/ui/src/BottomSheet.tsx
+++ b/packages/ui/src/BottomSheet.tsx
@@ -1,6 +1,6 @@
import { useEffect, useCallback, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
-import { AnimatePresence, motion } from 'framer-motion';
+import { AnimatePresence, motion, useDragControls } from 'framer-motion';
import { X } from '@phosphor-icons/react';
import clsx from 'clsx';
import styles from './BottomSheet.module.css';
@@ -164,6 +164,22 @@ export function BottomSheet({
const targetY = snap === 'full' ? snapFullPx : snapInitialPx;
+ // Framer Motion drag controls — we attach drag listening only to a
+ // dedicated "drag zone" (handle + header) rather than the whole
+ // sheet. This keeps the body's native scroll from competing with
+ // the drag gesture, which was the main source of the sluggish/
+ // inconsistent feel on Android: when the finger landed on body
+ // content the browser arbitrated scroll-vs-drag every frame.
+ const dragControls = useDragControls();
+ const dragEnabled = dismissible && draggable;
+ const hasDragZone =
+ dragEnabled && (showHandle || (!disableDefaultHeader && !!title));
+
+ const handleDragZonePointerDown = (e: React.PointerEvent) => {
+ if (!dragEnabled) return;
+ dragControls.start(e);
+ };
+
return createPortal(
{isOpen && (
@@ -187,9 +203,15 @@ export function BottomSheet({
initial={{ y: '100%' }}
animate={{ y: targetY }}
exit={{ y: '100%' }}
- transition={{ type: 'spring', stiffness: 400, damping: 36 }}
+ transition={{ type: 'spring', stiffness: 500, damping: 42 }}
onClick={(e) => e.stopPropagation()}
- drag={dismissible && draggable ? 'y' : false}
+ drag={dragEnabled ? 'y' : false}
+ // When a drag zone exists, only it can initiate drag.
+ // Otherwise (rare: no handle + no header) fall back
+ // to dragging anywhere on the sheet.
+ dragListener={hasDragZone ? false : dragEnabled}
+ dragControls={dragEnabled ? dragControls : undefined}
+ dragMomentum={false}
dragConstraints={{
// Allow dragging UP to the fully-open snap when
// the sheet is partial and expandable; otherwise
@@ -197,39 +219,33 @@ export function BottomSheet({
top: partial && expandable ? snapFullPx - targetY : 0,
bottom: snapClosedPx - targetY,
}}
- dragElastic={{ top: partial && expandable ? 0.05 : 0, bottom: 0.2 }}
+ dragElastic={{
+ top: partial && expandable ? 0.12 : 0,
+ bottom: 0.12,
+ }}
onDragEnd={(_, info) => {
const finalY = targetY + info.offset.y;
// Velocity-driven flicks first — they win over
// position-based heuristics so a fast swipe
- // always does the obvious thing.
- if (info.velocity.y > 600) {
- // Strong downward flick → close.
+ // always does the obvious thing. Threshold
+ // lowered from 600 px/s so deliberate (non-
+ // flick) drags also register.
+ if (info.velocity.y > 400) {
onClose();
return;
}
- if (partial && expandable && info.velocity.y < -600) {
- // Strong upward flick → expand to full.
+ if (partial && expandable && info.velocity.y < -400) {
setSnap('full');
return;
}
// Position-based snap fallback.
if (!partial) {
- // Original behavior — close if dragged
- // down past the threshold, otherwise
- // spring back to fully open.
if (info.offset.y > 120) onClose();
return;
}
- // Partial sheet position-based snap:
- // - past the initial position by half its
- // distance to closed → close
- // - between snaps → snap to nearest
- // - above the initial position by 1/3 the
- // distance to full → expand
const closeThreshold = snapInitialPx + (snapClosedPx - snapInitialPx) * 0.4;
const expandThreshold = snapInitialPx - snapInitialPx * 0.33;
@@ -244,24 +260,53 @@ export function BottomSheet({
setSnap('initial');
}}
>
- {showHandle && (
-
- )}
+ {hasDragZone ? (
+
+ {showHandle && (
+
+ )}
- {!disableDefaultHeader && title && (
-
-
{title}
-
+ {!disableDefaultHeader && title && (
+
+
{title}
+
+
+ )}
+ ) : (
+ <>
+ {showHandle && (
+
+ )}
+ {!disableDefaultHeader && title && (
+
+
{title}
+
+
+ )}
+ >
)}
{children}