Files
moreminimore-chat/app/javascript/dashboard/helper/voice.js
Tanmay Deep Sharma cefb3fea54 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>
2026-08-06 16:17:30 +05:30

241 lines
6.9 KiB
JavaScript

import {
CONTENT_TYPES,
VOICE_CALL_STATUS,
} from 'dashboard/components-next/message/constants';
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { useCallsStore } from 'dashboard/stores/calls';
import types from 'dashboard/store/mutation-types';
export const TERMINAL_STATUSES = [
'completed',
'busy',
'failed',
'rejected',
'no-answer',
'canceled',
'missed',
'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 => {
return CONTENT_TYPES.VOICE_CALL === message?.content_type;
};
const shouldSkipCall = (callDirection, senderId, currentUserId) => {
return callDirection === 'outbound' && senderId !== currentUserId;
};
const extractAssigneeId = conversation => {
return conversation?.assignee_id || conversation?.meta?.assignee?.id || null;
};
const isAssignedToAnotherAgent = (assigneeId, currentUserId) => {
if (currentUserId == null) return false;
return !!assigneeId && assigneeId !== currentUserId;
};
const shouldShowCall = ({
callDirection,
senderId,
assigneeId,
currentUserId,
}) => {
if (shouldSkipCall(callDirection, senderId, currentUserId)) return false;
// Outbound calls are scoped to the initiator via shouldSkipCall; the
// conversation may be auto-assigned to a different agent on creation, so
// skip the assignee filter for outbound to avoid hiding the caller's own widget.
if (callDirection === 'outbound') return true;
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
};
// Offline/busy agents shouldn't get a ringing popup for inbound calls, but
// outbound calls always belong to the initiator regardless of their status,
// and existing (already-surfaced) calls keep going so a status change
// mid-call doesn't yank away an active widget.
const shouldRingInbound = (callDirection, currentUserAvailability) => {
if (callDirection === 'outbound') return true;
return currentUserAvailability === 'online';
};
function extractCallerSnapshot(message) {
// Snapshot caller info from the message at add-time so the widget can keep
// rendering it after the user navigates away from a conversation list that
// had the conversation hydrated (and Vuex evicts it from the store).
// Only incoming messages carry the contact as the sender; on outbound calls
// the sender is the initiating agent, so skip the snapshot and let the widget
// fall back to the conversation's contact (conversation.meta.sender).
if (message?.message_type !== MESSAGE_TYPE.INCOMING) return null;
const sender = message?.sender;
if (!sender) return null;
return {
name: sender.name,
phone: sender.phone_number,
avatar: sender.avatar || sender.thumbnail,
additionalAttributes: sender.additional_attributes || {},
};
}
function extractCallData(message) {
const call = message?.call || {};
return {
callSid: call.provider_call_id,
callId: call.id,
provider: call.provider,
status: call.status,
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
conversationId: message?.conversation_id,
inboxId: message?.inbox_id ?? message?.conversation?.inbox_id,
assigneeId: extractAssigneeId(message?.conversation),
senderId: message?.sender?.id,
caller: extractCallerSnapshot(message),
};
}
export function handleVoiceCallCreated(
message,
currentUserId,
currentUserAvailability
) {
if (!isVoiceCallMessage(message)) return;
const {
callSid,
callId,
provider,
status,
callDirection,
conversationId,
inboxId,
assigneeId,
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.
if (status !== VOICE_CALL_STATUS.RINGING) return;
if (
!shouldShowCall({
callDirection,
senderId,
assigneeId,
currentUserId,
})
) {
return;
}
if (!shouldRingInbound(callDirection, currentUserAvailability)) return;
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
callId,
provider,
conversationId,
inboxId,
callDirection,
senderId,
caller: extractCallerSnapshot(message),
});
}
export function handleVoiceCallUpdated(
commit,
message,
currentUserId,
currentUserAvailability
) {
if (!isVoiceCallMessage(message)) return;
const {
callSid,
callId,
provider,
status,
callDirection,
conversationId,
inboxId,
assigneeId,
senderId,
} = extractCallData(message);
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, {
conversationId,
callStatus: status,
callSid,
});
if (
!shouldShowCall({
callDirection,
senderId,
assigneeId,
currentUserId,
})
) {
callsStore.removeCall(callSid);
return;
}
if (status === 'ringing') {
if (!shouldRingInbound(callDirection, currentUserAvailability)) return;
callsStore.addCall({
callSid,
callId,
provider,
conversationId,
inboxId,
callDirection,
senderId,
caller: extractCallerSnapshot(message),
});
}
}
export function syncConversationCallVisibility(conversation, currentUserId) {
const assigneeId = extractAssigneeId(conversation);
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
// Outbound calls belong to the initiator regardless of who the conversation
// is currently assigned to (auto-assignment may flip mid-call). Mirror
// shouldShowCall's outbound exception so an in-progress outbound call isn't
// ripped out from under the caller when the conversation reassigns.
const callsStore = useCallsStore();
const callsToRemove = callsStore.calls.filter(
call =>
call.conversationId === conversation.id &&
!shouldShowCall({
callDirection: call.callDirection,
senderId: call.senderId,
assigneeId,
currentUserId,
})
);
callsToRemove.forEach(call => callsStore.removeCall(call.callSid));
}