fix(voice): sync inbound WhatsApp call accept state across tabs (#15326)

When an agent has multiple tabs or windows open on the same account, an
inbound WhatsApp call rings in all of them, as expected. But once the
call is answered in one tab, the others never found out — they kept
showing the incoming-call popup and playing the ringtone indefinitely,
as if the call were still waiting to be picked up.

## How to reproduce
1. Log into the same agent account in two browser tabs.
2. Receive an inbound WhatsApp call (rings in both tabs).
3. Accept the call in one tab.
4. The other tab keeps ringing and shows the call as still incoming.

## What changed
- The backend already broadcasts a `voice_call.accepted` event
account-wide when a call is answered, but the dashboard never had a
listener registered for it — the event was silently dropped. Added the
missing handler in `actionCable.js` so every tab except the one that
owns the now-active call clears its ringing state.
- Added `root: true` to `.eslintrc.js` so ESLint config resolution stops
at the project root instead of also picking up a parent directory's
config (this repo's git-worktree layout nests worktrees under the main
checkout, which was causing an ambiguous plugin-resolution error for
anyone linting from a worktree).

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
This commit is contained in:
Tanmay Deep Sharma
2026-08-06 16:17:30 +05:30
committed by GitHub
parent 240aca1d7a
commit cefb3fea54
5 changed files with 58 additions and 17 deletions

View File

@@ -10,7 +10,10 @@ import {
sendWhatsappTerminateBeacon,
cleanupWhatsappSession,
} from 'dashboard/composables/useWhatsappCallSession';
import { handleVoiceCallCreated } from 'dashboard/helper/voice';
import {
handleVoiceCallCreated,
markCallDismissed,
} from 'dashboard/helper/voice';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import {
CONTENT_TYPES,
@@ -21,13 +24,6 @@ import Timer from 'dashboard/helper/Timer';
const isWhatsappCall = call => call?.provider === VOICE_CALL_PROVIDERS.WHATSAPP;
// Dismissed call sids must not be re-seeded by the conversation-load watcher.
// Lives at module scope so all consumers share the same set.
const dismissedCallSids = new Set();
const markDismissed = callSid => {
if (callSid) dismissedCallSids.add(callSid);
};
// Globals attached once across all useCallSession() consumers — bubbles in a
// long thread call this composable many times, and a per-instance Timer +
// window listener stack would multiply work.
@@ -164,7 +160,7 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
// 409 = the call already ended before accept landed (e.g. caller hung up mid-ring).
if (error?.response?.status === 409) {
TwilioVoiceClient.endClientCall();
markDismissed(callSid);
markCallDismissed(callSid);
callsStore.dismissCall(callSid);
} else if (!isWhatsappCall(call)) {
// Tear down the Twilio Device on any other join error so a retry
@@ -209,13 +205,13 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
TwilioVoiceClient.endClientCall();
}
} finally {
markDismissed(callSid);
markCallDismissed(callSid);
callsStore.dismissCall(callSid);
}
};
const dismissCall = callSid => {
markDismissed(callSid);
markCallDismissed(callSid);
callsStore.dismissCall(callSid);
};
@@ -255,9 +251,9 @@ export function useCallSession() {
// Cable broadcasts (voice_call.incoming / message.created) are one-shot, so
// on a hard refresh they leave the calls store empty. Seed it from any
// ringing voice_call message in the conversation cache. Skip calls the
// agent has already dismissed locally so they don't re-pop on the next
// conversation update.
// ringing voice_call message in the conversation cache. handleVoiceCallCreated
// skips calls already dismissed (locally or via a real-time accepted/ended
// event) so they don't re-pop on the next conversation update.
const seedCallsFromHydratedMessages = () => {
const conversations = store.getters.getAllConversations || [];
const currentUserId = store.getters.getCurrentUserID;
@@ -266,8 +262,6 @@ export function useCallSession() {
(conv.messages || []).forEach(msg => {
if (msg.content_type !== CONTENT_TYPES.VOICE_CALL) return;
if (msg.call?.status !== VOICE_CALL_STATUS.RINGING) return;
const callSid = msg.call?.provider_call_id;
if (callSid && dismissedCallSids.has(callSid)) return;
handleVoiceCallCreated(msg, currentUserId, currentUserAvailability);
});
});

View File

@@ -12,6 +12,7 @@ import {
isLocalWhatsappCall,
} from 'dashboard/composables/useWhatsappCallSession';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { markCallDismissed } from 'dashboard/helper/voice';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -61,6 +62,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
'voice_call.incoming': this.onVoiceCallIncoming,
'voice_call.accepted': this.onVoiceCallAccepted,
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
'voice_call.outbound_accepted': this.onVoiceCallOutboundAccepted,
'voice_call.ended': this.onVoiceCallEnded,
@@ -374,6 +376,24 @@ class ActionCableConnector extends BaseActionCableConnector {
});
};
// Inbound call accepted (in this tab or a sibling tab/window on the same
// account). Broadcast is account-wide, so drop the ringing card everywhere
// except the tab that actually owns the now-active call — removing an
// active call here would tear down its live WebRTC session. Check
// isLocalWhatsappCall (set synchronously before the accept API call) rather
// than the store's isActive flag, which this tab may not have set yet.
// Mark dismissed regardless of locality: the ringing message.created for
// this call is queued through ActionCableBroadcastJob and can still be
// delivered after this (synchronous) broadcast, which would otherwise
// re-add the call as ringing once it finally arrives.
// eslint-disable-next-line class-methods-use-this
onVoiceCallAccepted = data => {
if (data?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP) return;
markCallDismissed(data.call_id);
if (isLocalWhatsappCall(data.id)) return;
useCallsStore().removeCall(data.call_id);
};
// `connect` is the WebRTC tunnel-ready signal (fires ~20s before pickup
// for outbound). Apply the SDP answer so the handshake completes during
// ringing, but stay non-active until `outbound_accepted` arrives.
@@ -405,6 +425,9 @@ class ActionCableConnector extends BaseActionCableConnector {
// eslint-disable-next-line class-methods-use-this
onVoiceCallEnded = async data => {
if (data?.provider !== VOICE_CALL_PROVIDERS.WHATSAPP) return;
// A still-queued ringing message.created (see onVoiceCallAccepted) must not
// resurrect a call that has already ended.
markCallDismissed(data.call_id);
// The store entry should always be removed for this account-wide broadcast,
// but the WebRTC/recorder teardown must only run for the call this tab owns
// — otherwise an unrelated agent's call ending would stop this tab's

View File

@@ -17,6 +17,16 @@ export const TERMINAL_STATUSES = [
'ended',
];
// A message.created for a ringing call is queued through ActionCableBroadcastJob and can
// be delivered after the call has already been accepted/ended via a synchronous broadcast.
// Track dismissed call sids at module scope so that late, stale "ringing" snapshot doesn't
// resurrect a card every caller of handleVoiceCallCreated (hydration and real-time alike)
// has already cleared.
const dismissedCallSids = new Set();
export const markCallDismissed = callSid => {
if (callSid) dismissedCallSids.add(callSid);
};
export const isInbound = direction => direction === 'inbound';
const isVoiceCallMessage = message => {
@@ -112,6 +122,8 @@ export function handleVoiceCallCreated(
senderId,
} = extractCallData(message);
if (callSid && dismissedCallSids.has(callSid)) return;
// A voice_call message can be created already terminal when the caller hangs
// up before connect. Only ring while the call is actually ringing; mirrors the
// guard in seedCallsFromHydratedMessages.
@@ -165,6 +177,10 @@ export function handleVoiceCallUpdated(
const callsStore = useCallsStore();
// Guard against a still-queued ringing message.created arriving after this
// terminal update, same as the accepted/ended broadcast handlers.
if (TERMINAL_STATUSES.includes(status)) markCallDismissed(callSid);
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
commit(types.UPDATE_MESSAGE_CALL_STATUS, {