This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user