diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index ae7c7fda..40f2cc1f 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -3614,12 +3614,18 @@ function processDmCallAcceptEvent( // We're a REMOTE instance receiving fan-out — transition local state const fedCall = connectionManager.getFederatedCall(event.federatedId); if (fedCall) { + // Only broadcast if transitioning from ringing → active. + // If already active (e.g., we initiated the accept and the host is fanning out back), + // skip the duplicate broadcast to avoid state conflicts on the client. + const wasRinging = fedCall.state === 'ringing'; connectionManager.activateFederatedCall(event.federatedId); - connectionManager.sendToFederatedCallUsers(event.federatedId, { - type: 'dm_call_accepted', - dmChannelId: fedCall.dmChannelId, - federatedCallId: event.federatedId, - } as ServerEvent); + if (wasRinging) { + connectionManager.sendToFederatedCallUsers(event.federatedId, { + type: 'dm_call_accepted', + dmChannelId: fedCall.dmChannelId, + federatedCallId: event.federatedId, + } as ServerEvent); + } } } diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index ed170b5e..de3d790d 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -322,6 +322,8 @@ function handleEvent(origin: string, event: ServerEvent): void { 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) { @@ -797,13 +799,17 @@ function handleEvent(origin: string, event: ServerEvent): void { } case 'dm_call_accepted': { - const { setIncomingCall, setOutgoingCall, setActiveDmCall, connectFn, isLiveKitConnected } = useVoiceStore.getState(); + const { setIncomingCall, setOutgoingCall, outgoingCall, setActiveDmCall, connectFn, isLiveKitConnected } = useVoiceStore.getState(); + // Track whether WE are the caller before clearing state + const wasOutgoingCall = !!outgoingCall; setIncomingCall(null); setOutgoingCall(null); const callDmId = event.dmChannelId || event.federatedCallId || ''; setActiveDmCall({ dmChannelId: callDmId }); - // Only connect if not already connected (federated acceptor connects immediately in handleAccept) - if (connectFn && !isLiveKitConnected && callDmId) { + // Only auto-connect if we're the CALLER waiting for acceptance. + // The callee who accepted connects in the click handler (handleAccept). + // Other instances of the same user should NOT auto-connect. + if (connectFn && !isLiveKitConnected && wasOutgoingCall && callDmId) { connectFn(callDmId, true).catch((err: unknown) => { console.error('[WS] DM call connect failed:', err); });