Files
chatwoot/app/javascript/dashboard/stores/calls.js
T
Tanmay Deep SharmaandGitHub 68e358d732 feat: voice-call UX fixes (#14579)
## Linear ticket
https://linear.app/chatwoot/issue/CW-7187/voice-calls-followup-tasks

## Description

Improvements to the WhatsApp voice-calling experience plus a cheaper,
more accurate audio-transcription model.
- First-time callers now get a real name. An inbound WhatsApp call
creates the contact from the caller's WhatsApp profile name instead of
the bare phone number.
- Clear, consistent call attribution. Call bubbles show a unified
"Handled by {agent}"
- Cleaner call widget. The dismiss (✕) button is shown only for incoming
calls
- WhatsApp calling for manual inboxes. voice_calling_supported? now
covers any whatsapp_cloud inbox
- Transcription: whisper-1 → gpt-4o-mini-transcribe.

## Type of change

- [ ] New feature (non-breaking change which adds functionality)


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-05-27 16:16:16 +05:30

106 lines
3.6 KiB
JavaScript

import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { cleanupWhatsappSession } from 'dashboard/composables/useWhatsappCallSession';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
import { defineStore } from 'pinia';
const teardownByProvider = call => {
if (call?.provider === VOICE_CALL_PROVIDERS.WHATSAPP) {
cleanupWhatsappSession();
} else {
TwilioVoiceClient.endClientCall();
}
};
export const useCallsStore = defineStore('calls', {
state: () => ({
calls: [],
}),
getters: {
activeCall: state => state.calls.find(call => call.isActive) || null,
hasActiveCall: state => state.calls.some(call => call.isActive),
incomingCalls: state => state.calls.filter(call => !call.isActive),
hasIncomingCall: state => state.calls.some(call => !call.isActive),
},
actions: {
handleCallStatusChanged({ callSid, status }) {
if (!TERMINAL_STATUSES.includes(status)) return;
const call = this.calls.find(c => c.callSid === callSid);
// WhatsApp recordings live in the in-memory recorder until voice_call.ended
// uploads them; tearing down here would race-wipe those chunks.
if (call?.provider === 'whatsapp') {
this.calls = this.calls.filter(c => c.callSid !== callSid);
return;
}
this.removeCall(callSid);
},
addCall(callData) {
if (!callData?.callSid) return;
const existing = this.calls.find(c => c.callSid === callData.callSid);
if (existing) {
// Merge so a later cable event with sdp_offer/provider/caller fills in
// gaps left by the earlier message.created path (and vice versa).
// Preserve a previously-captured caller snapshot when the incoming
// event has no sender info, otherwise the widget would flip to
// "Unknown caller" on the next status update.
const next = { ...callData };
if (existing.caller && !next.caller) delete next.caller;
Object.assign(existing, next, { isActive: existing.isActive });
return;
}
// Prepend so the newest call surfaces as the primary card (incomingCalls[0])
// and older calls drop into the stack below it.
this.calls.unshift({
...callData,
isActive: false,
});
},
removeCall(callSid) {
const callToRemove = this.calls.find(c => c.callSid === callSid);
if (callToRemove?.isActive) {
teardownByProvider(callToRemove);
}
this.calls = this.calls.filter(c => c.callSid !== callSid);
},
setCallActive(callSid) {
this.calls = this.calls.map(call => ({
...call,
isActive: call.callSid === callSid,
}));
},
clearActiveCall() {
const active = this.calls.find(c => c.isActive);
teardownByProvider(active);
this.calls = this.calls.filter(call => !call.isActive);
},
dismissCall(callSid) {
this.calls = this.calls.filter(call => call.callSid !== callSid);
},
removeCallsForConversation(conversationId) {
const callsToRemove = this.calls.filter(
call => call.conversationId === conversationId
);
// Tear down each active call via its own provider so a WhatsApp call
// gets cleanupWhatsappSession() (closes pc, stops recorder/mic) instead
// of the Twilio-only endClientCall() — otherwise mic stays open.
callsToRemove.filter(call => call.isActive).forEach(teardownByProvider);
this.calls = this.calls.filter(
call => call.conversationId !== conversationId
);
},
},
});