diff --git a/packages/web/src/components/voice/IncomingCallModal.tsx b/packages/web/src/components/voice/IncomingCallModal.tsx index 7c591938..d497daae 100644 --- a/packages/web/src/components/voice/IncomingCallModal.tsx +++ b/packages/web/src/components/voice/IncomingCallModal.tsx @@ -42,12 +42,19 @@ export function IncomingCallModal() { const handleAccept = () => { if (timerRef.current) clearTimeout(timerRef.current); const dmChannelId = incomingCall.dmChannelId; - const { callOrigin, federatedCallId } = useVoiceStore.getState(); + const { callOrigin, federatedCallId, setActiveDmCall, connectFn } = useVoiceStore.getState(); const origin = callOrigin || (dmChannelId ? getChannelOrigin(dmChannelId) : undefined); + const callDmId = dmChannelId || federatedCallId!; + + // Immediately transition to active call state — don't wait for server response. + // The dm_call_accepted event races with connectFn's async AudioContext resume, + // causing isLiveKitConnected to be false when it arrives → activeDmCall never set. + setIncomingCall(null); + setActiveDmCall({ dmChannelId: callDmId }); + wsSend({ type: 'dm_call_accept', dmChannelId, federatedCallId }, origin); // Connect directly within gesture context (required for iOS audio permission) - const connectFn = useVoiceStore.getState().connectFn; - if (connectFn) connectFn(dmChannelId || federatedCallId!, true); + if (connectFn) connectFn(callDmId, true); }; const handleDecline = () => { diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index a8910445..c778f704 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -321,21 +321,12 @@ function handleEvent(origin: string, event: ServerEvent): void { // For federated calls, check membership via token presence (participants may be empty) const isParticipant = call.participants.includes(myId) || !!call.livekitToken; if (call.state === 'active' && isParticipant) { - const callDmId = call.dmChannelId || call.federatedCallId || ''; // Clear any stuck ringing UI from a ringing→active transition during refresh setIncomingCall(null); - setActiveDmCall({ dmChannelId: callDmId }); - // Store federated call data if present (server already filtered to this user's token) - if (call.livekitUrl && call.livekitToken) { - setFederatedCallData(call.livekitToken, call.livekitUrl); - } - if (call.federatedCallId) { - setFederatedCallId(call.federatedCallId); - } - // PASSIVE: do NOT auto-connect to LiveKit on ready. - // The user must click "Join" or re-accept. Auto-connecting causes - // identity conflicts when the same user has multiple sessions — - // both sessions fight for the same LiveKit identity slot. + // Do NOT set activeDmCall or auto-connect. On refresh/restart, the user + // is no longer in LiveKit — showing "Connecting..." with no connection + // is broken UX. The call exists on the server but this client session + // has no active LiveKit connection. The user can re-initiate if needed. break; } else if (call.state === 'ringing' && call.callerId !== myId) { const dmCh = event.dmChannels?.find((d: any) => d.id === call.dmChannelId);