chore(voice): split FE changes out to feat/whatsapp-call-ui (PR #14346)

This commit is contained in:
Tanmay Deep Sharma
2026-05-02 14:31:13 +07:00
parent d9077c64d3
commit 0e0e0868d7
15 changed files with 155 additions and 845 deletions
@@ -1,45 +0,0 @@
/* global axios */
import ApiClient from '../../ApiClient';
class WhatsappCallsAPI extends ApiClient {
constructor() {
super('whatsapp_calls', { accountScoped: true });
}
show(callId) {
return axios.get(`${this.url}/${callId}`).then(r => r.data);
}
initiate(conversationId, sdpOffer) {
return axios
.post(`${this.url}/initiate`, {
conversation_id: conversationId,
sdp_offer: sdpOffer,
})
.then(r => r.data);
}
accept(callId, sdpAnswer) {
return axios
.post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer })
.then(r => r.data);
}
reject(callId) {
return axios.post(`${this.url}/${callId}/reject`).then(r => r.data);
}
terminate(callId) {
return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data);
}
uploadRecording(callId, blob, filename = 'call-recording.webm') {
const formData = new FormData();
formData.append('recording', blob, filename);
return axios
.post(`${this.url}/${callId}/upload_recording`, formData)
.then(r => r.data);
}
}
export default new WhatsappCallsAPI();
@@ -3,16 +3,10 @@ import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import {
isVoiceCallEnabled,
getVoiceCallProvider,
VOICE_CALL_PROVIDERS,
} from 'dashboard/helper/inbox';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import ContactAPI from 'dashboard/api/contacts';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -64,63 +58,9 @@ const navigateToConversation = conversationId => {
}
};
const whatsappCallSession = useWhatsappCallSession();
// Find the most recent open conversation for this contact in the picked inbox.
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
const findWhatsappConversationId = async inboxId => {
const { data } = await ContactAPI.getConversations(props.contactId);
const conversations = data?.payload || [];
const match = conversations
.filter(c => c.inbox_id === inboxId)
.sort((a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0))[0];
return match?.id || null;
};
const startWhatsappCall = async inboxId => {
const conversationId = await findWhatsappConversationId(inboxId);
if (!conversationId) {
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
return;
}
const response =
await whatsappCallSession.initiateOutboundCall(conversationId);
if (!response?.id) {
// Permission flow returns no id — banner already handled server-side; surface to user.
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(conversationId);
return;
}
const callsStore = useCallsStore();
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
conversationId,
inboxId,
callDirection: 'outbound',
provider: 'whatsapp',
});
callsStore.setCallActive(response.call_id);
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(conversationId);
};
const startCall = async inboxId => {
if (isInitiatingCall.value) return;
const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
try {
await startWhatsappCall(inboxId);
} catch (error) {
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
}
return;
}
try {
const response = await store.dispatch('contacts/initiateCall', {
contactId: props.contactId,
@@ -1,22 +1,18 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useMessageContext } from '../provider.js';
import { VOICE_CALL_STATUS } from '../constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import AudioChip from 'dashboard/components-next/message/chips/Audio.vue';
const LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const SUBTEXT_MAP = {
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
@@ -31,36 +27,39 @@ const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const { call, attachments, contentAttributes } = useMessageContext();
const { t } = useI18n();
const store = useStore();
const { call, conversationId, currentUserId } = useMessageContext();
const status = computed(() => call.value?.status);
const isOutbound = computed(() => call.value?.direction === 'outgoing');
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const audioAttachment = computed(() =>
(attachments?.value || []).find(a => a.fileType === 'audio')
const acceptedByAgentId = computed(() => call.value?.accepted_by_agent_id);
const didCurrentUserAnswer = computed(
() =>
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
);
// Duration lives in two places depending on which payload the FE got:
// - call.duration_seconds / call.durationSeconds (push_event_data shape)
// - content_attributes.data.duration_seconds (message-side mirror)
// Both can be camelCased by useTransformKeys upstream — check every variant.
const durationSeconds = computed(() => {
const fromCall = call.value?.durationSeconds || call.value?.duration_seconds;
if (fromCall != null) return fromCall;
const data = contentAttributes?.value?.data || contentAttributes?.value?.data;
return data?.durationSeconds || data?.duration_seconds;
// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
// Twilio's call-status webhook flipped the call to in-progress before the
// participant-join webhook claimed it).
const conversationAssignee = computed(() => {
const conversation = store.getters.getConversationById?.(
conversationId?.value
);
return conversation?.meta?.assignee || null;
});
const formattedDuration = computed(() => {
const s = Number(durationSeconds.value);
if (!s || Number.isNaN(s)) return '';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
const displayAgentName = computed(() => {
if (call.value?.accepted_by_agent_name)
return call.value.accepted_by_agent_name;
if (acceptedByAgentId.value) {
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
if (agent?.available_name) return agent.available_name;
if (agent?.name) return agent.name;
}
return conversationAssignee.value?.name || null;
});
const labelKey = computed(() => {
@@ -75,16 +74,28 @@ const labelKey = computed(() => {
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const subtextKey = computed(() => {
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
const subtext = computed(() => {
if (status.value === VOICE_CALL_STATUS.RINGING) {
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
if (didCurrentUserAnswer.value) {
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
if (displayAgentName.value) {
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
agentName: displayAgentName.value,
});
}
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
});
const iconName = computed(() => {
@@ -118,19 +129,10 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
{{ $t(labelKey) }}
</span>
<span class="text-xs text-n-slate-11">
<!-- When the audio chip is rendered it already shows duration in
its own player; suppress here to avoid two competing numbers. -->
{{
audioAttachment
? $t(subtextKey)
: formattedDuration || $t(subtextKey)
}}
{{ subtext }}
</span>
</div>
</div>
<div v-if="audioAttachment" class="px-3 pb-3 w-full">
<AudioChip :attachment="audioAttachment" class="text-n-slate-12" />
</div>
</div>
</BaseBubble>
</template>
@@ -41,33 +41,8 @@ const playbackSpeed = ref(1);
const { uid } = getCurrentInstance();
// MediaRecorder-produced WebM/Opus blobs lack a Duration header → <audio>.duration
// resolves to Infinity until we seek past the end, which forces the engine to
// scan the file and compute the real length. Safe no-op for files with a real
// duration already (mp3/m4a/etc).
const resolveStreamingDuration = () => {
const el = audioPlayer.value;
if (!el) return;
const onTimeUpdate = () => {
el.removeEventListener('timeupdate', onTimeUpdate);
el.currentTime = 0;
duration.value = el.duration;
};
el.addEventListener('timeupdate', onTimeUpdate);
try {
el.currentTime = Number.MAX_SAFE_INTEGER;
} catch {
el.removeEventListener('timeupdate', onTimeUpdate);
}
};
const onLoadedMetadata = () => {
const d = audioPlayer.value?.duration;
if (!Number.isFinite(d)) {
resolveStreamingDuration();
return;
}
duration.value = d;
duration.value = audioPlayer.value?.duration;
};
const playbackSpeedLabel = computed(() => {
@@ -78,8 +53,7 @@ const playbackSpeedLabel = computed(() => {
// When the onLoadMetadata is called, so we need to set the duration
// value when the component is mounted
onMounted(() => {
const d = audioPlayer.value?.duration;
if (Number.isFinite(d)) duration.value = d;
duration.value = audioPlayer.value?.duration;
audioPlayer.value.playbackRate = playbackSpeed.value;
});
@@ -1,9 +1,8 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
@@ -22,40 +21,16 @@ const {
formattedCallDuration,
} = useCallSession();
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
// don't expose a mic track on the browser side.
const isMuted = ref(false);
const isWhatsappActive = computed(
() => activeCall.value?.provider === 'whatsapp'
);
const toggleMute = () => {
isMuted.value = !isMuted.value;
setWhatsappCallMuted(isMuted.value);
};
watch(hasActiveCall, active => {
if (!active) isMuted.value = false;
});
const getCallInfo = call => {
const conversation = store.getters.getConversationById(call?.conversationId);
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
const sender = conversation?.meta?.sender;
// Inbound WhatsApp calls stash caller info on the call record (from the cable
// payload) so the widget has something to show before the conversation lands.
const caller = call?.caller;
return {
conversation,
inbox,
contactName:
sender?.name ||
sender?.phone_number ||
caller?.name ||
caller?.phone ||
'Unknown caller',
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
inboxName: inbox?.name || 'Customer support',
avatar: sender?.avatar || sender?.thumbnail || caller?.avatar,
avatar: sender?.avatar || sender?.thumbnail,
};
};
@@ -187,30 +162,6 @@ watch(
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
v-if="hasActiveCall && isWhatsappActive"
v-tooltip.top="
isMuted
? $t('CONVERSATION.VOICE_WIDGET.UNMUTE')
: $t('CONVERSATION.VOICE_WIDGET.MUTE')
"
class="flex justify-center items-center w-10 h-10 rounded-full transition-colors"
:class="
isMuted
? 'bg-n-amber-9 hover:bg-n-amber-10'
: 'bg-n-slate-3 hover:bg-n-slate-4'
"
@click="toggleMute"
>
<i
class="text-lg"
:class="
isMuted
? 'text-white i-ph-microphone-slash-bold'
: 'text-n-slate-12 i-ph-microphone-bold'
"
/>
</button>
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="
@@ -4,7 +4,6 @@ import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Avatar from 'next/avatar/Avatar.vue';
@@ -13,13 +12,6 @@ import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import {
getVoiceCallProvider,
VOICE_CALL_PROVIDERS,
} from 'dashboard/helper/inbox';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import { useCallsStore } from 'dashboard/stores/calls';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
const props = defineProps({
@@ -99,45 +91,6 @@ const hasMultipleInboxes = computed(
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const callsStore = useCallsStore();
const whatsappCallSession = useWhatsappCallSession();
const isWhatsappVoiceInbox = computed(
() => getVoiceCallProvider(inbox.value) === VOICE_CALL_PROVIDERS.WHATSAPP
);
const startWhatsappCall = async () => {
if (whatsappCallSession.isInitiating.value) return;
try {
const response = await whatsappCallSession.initiateOutboundCall(
currentChat.value.id
);
// Permission template path returns no call id — show banner, no widget yet.
if (!response?.id) {
const status = response?.status;
const messageKey =
status === 'permission_pending'
? 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING'
: 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED';
useAlert(t(messageKey));
return;
}
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
conversationId: currentChat.value.id,
inboxId: inbox.value?.id,
callDirection: 'outbound',
provider: 'whatsapp',
});
callsStore.setCallActive(response.call_id);
} catch (error) {
useAlert(error?.message || t('CONVERSATION.HEADER.WHATSAPP_CALL_FAILED'));
}
};
</script>
<template>
@@ -199,18 +152,6 @@ const startWhatsappCall = async () => {
:parent-width="width"
class="hidden md:flex"
/>
<ButtonV4
v-if="isWhatsappVoiceInbox"
v-tooltip.bottom="$t('CONVERSATION.HEADER.WHATSAPP_CALL')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-phone"
:is-loading="whatsappCallSession.isInitiating.value"
:disabled="whatsappCallSession.isInitiating.value"
class="rounded-md hover:bg-n-alpha-2"
@click="startWhatsappCall"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
@@ -1,19 +1,14 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls';
import {
useWhatsappCallSession,
sendWhatsappTerminateBeacon,
cleanupWhatsappSession,
} from 'dashboard/composables/useWhatsappCallSession';
import { useAlert } from 'dashboard/composables';
import Timer from 'dashboard/helper/Timer';
const isWhatsappCall = call => call?.provider === 'whatsapp';
export function useCallSession() {
const callsStore = useCallsStore();
const whatsappSession = useWhatsappCallSession();
const { t } = useI18n();
const isJoining = ref(false);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
@@ -37,52 +32,20 @@ export function useCallSession() {
{ immediate: true }
);
// Browser-native confirm prompt when reload/close happens mid-call. Reload
// tears down the WebRTC session permanently for WhatsApp (no rejoin) and
// drops the agent leg for Twilio, so warn either way.
const handleBeforeUnload = event => {
if (!hasActiveCall.value) return;
event.preventDefault();
event.returnValue = '';
};
// pagehide fires after the user confirms the prompt. Let the WhatsApp session
// best-effort sendBeacon a terminate so the server doesn't keep the call open.
const handlePageHide = () => {
sendWhatsappTerminateBeacon();
};
const handleTwilioDisconnected = () => callsStore.clearActiveCall();
onMounted(() => {
TwilioVoiceClient.addEventListener(
'call:disconnected',
handleTwilioDisconnected
TwilioVoiceClient.addEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('pagehide', handlePageHide);
});
onUnmounted(() => {
durationTimer.stop();
TwilioVoiceClient.removeEventListener(
'call:disconnected',
handleTwilioDisconnected
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('pagehide', handlePageHide);
});
const findCall = callSid => callsStore.calls.find(c => c.callSid === callSid);
const endCall = async ({ conversationId, inboxId, callSid }) => {
if (isWhatsappCall(findCall(callSid))) {
await whatsappSession.endActiveCall();
durationTimer.stop();
callsStore.clearActiveCall();
return;
}
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
TwilioVoiceClient.endClientCall();
durationTimer.stop();
@@ -94,18 +57,6 @@ export function useCallSession() {
isJoining.value = true;
try {
const call = findCall(callSid);
if (isWhatsappCall(call)) {
await whatsappSession.acceptIncomingCall({
callId: call.callId,
sdpOffer: call.sdpOffer,
iceServers: call.iceServers,
});
callsStore.setCallActive(callSid);
durationTimer.start();
return { callId: call.callId };
}
const device = await TwilioVoiceClient.initializeDevice(inboxId);
if (!device) return null;
@@ -126,12 +77,13 @@ export function useCallSession() {
return { conferenceSid: joinResponse?.conference_sid };
} catch (error) {
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
if (error?.response?.status === 409) {
TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
}
// eslint-disable-next-line no-console
console.error('Failed to join call:', error);
// Tear down any half-built WebRTC state so the user's next click starts
// fresh; otherwise the leftover pc + mic stream survives and confuses
// the second-attempt SDP exchange.
cleanupWhatsappSession();
return null;
} finally {
isJoining.value = false;
@@ -139,12 +91,7 @@ export function useCallSession() {
};
const rejectIncomingCall = callSid => {
const call = findCall(callSid);
if (isWhatsappCall(call) && call?.callId) {
whatsappSession.rejectIncomingCall(call.callId);
} else {
TwilioVoiceClient.endClientCall();
}
TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
};
@@ -1,343 +0,0 @@
import { ref } from 'vue';
import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI';
// Browser ↔ Meta WebRTC is a singleton — only one PeerConnection at a time can
// hold the user's mic. Module-level state lets cable handlers and the pagehide
// listener reach the live session without prop-drilling refs through composables.
let pc = null;
let localStream = null;
let remoteStream = null;
let remoteAudioEl = null;
let mediaRecorder = null;
let recorderChunks = [];
let audioContext = null;
let activeCallId = null;
let intentionallyClosing = false;
// Lazily attach a hidden <audio autoplay> to the document so Meta's track
// actually plays through the speakers — without this, mic flows to Meta but
// the user hears nothing back.
const ensureRemoteAudioElement = () => {
if (remoteAudioEl) return remoteAudioEl;
remoteAudioEl = document.createElement('audio');
remoteAudioEl.id = 'whatsapp-call-remote-audio';
remoteAudioEl.autoplay = true;
remoteAudioEl.playsInline = true;
remoteAudioEl.style.display = 'none';
document.body.appendChild(remoteAudioEl);
return remoteAudioEl;
};
const playRemoteStream = stream => {
const el = ensureRemoteAudioElement();
el.srcObject = stream;
// play() may reject under autoplay policies; surface to console but don't crash the call.
el.play().catch(err => {
// eslint-disable-next-line no-console
console.warn('[WhatsApp Call] remote audio play() failed:', err);
});
};
// Smaller timeslice → chunks flush to memory every second so a remote hangup
// that races cleanup still leaves data behind to upload.
const RECORDING_TIMESLICE_MS = 1000;
const ICE_GATHER_TIMEOUT_MS = 10000;
const RECORDER_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
];
// Outbound calls don't get ice_servers from the backend (call doesn't exist yet
// at offer time). Without STUN the browser only has host candidates which can't
// reach Meta through NAT, so the browser→Meta direction silently drops media.
const DEFAULT_OUTBOUND_ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }];
const waitForIceGatheringComplete = peer =>
new Promise(resolve => {
if (peer.iceGatheringState === 'complete') {
resolve();
return;
}
const timer = setTimeout(resolve, ICE_GATHER_TIMEOUT_MS);
peer.addEventListener('icegatheringstatechange', () => {
if (peer.iceGatheringState === 'complete') {
clearTimeout(timer);
resolve();
}
});
});
const cleanup = () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch (_) {
/* noop */
}
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close().catch(() => {});
}
if (localStream) localStream.getTracks().forEach(t => t.stop());
if (remoteStream) remoteStream.getTracks().forEach(t => t.stop());
if (pc) pc.close();
if (remoteAudioEl) remoteAudioEl.srcObject = null;
pc = null;
localStream = null;
remoteStream = null;
mediaRecorder = null;
recorderChunks = [];
audioContext = null;
activeCallId = null;
intentionallyClosing = false;
};
// Mix local mic + remote audio via Web Audio so the recording captures both legs.
const setupRecorder = () => {
if (!localStream || !remoteStream || mediaRecorder) return;
// Without at least one remote track, createMediaStreamSource on remoteStream
// wires up to nothing — the recorded mix is effectively just silence.
if (remoteStream.getAudioTracks().length === 0) return;
audioContext = new AudioContext({ sampleRate: 48000 });
// AudioContext starts suspended under most autoplay policies. Resume so the
// graph actually runs; otherwise the destination stream produces silence.
audioContext.resume().catch(() => {});
const destination = audioContext.createMediaStreamDestination();
audioContext.createMediaStreamSource(localStream).connect(destination);
audioContext.createMediaStreamSource(remoteStream).connect(destination);
const mimeType = RECORDER_MIME_CANDIDATES.find(t =>
MediaRecorder.isTypeSupported(t)
);
if (!mimeType) return;
recorderChunks = [];
mediaRecorder = new MediaRecorder(destination.stream, { mimeType });
mediaRecorder.ondataavailable = event => {
if (event.data && event.data.size > 0) recorderChunks.push(event.data);
};
mediaRecorder.start(RECORDING_TIMESLICE_MS);
};
const buildPeerConnection = iceServers => {
const config = iceServers && iceServers.length ? { iceServers } : {};
pc = new RTCPeerConnection(config);
remoteStream = new MediaStream();
pc.ontrack = event => {
// Add to the stable placeholder stream so any sources/recorders referencing
// it stay connected — never reassign the variable, since the recorder's
// audioContext source taps the original MediaStream object.
const tracks =
event.streams && event.streams[0]
? event.streams[0].getTracks()
: [event.track];
tracks.forEach(track => {
if (!remoteStream.getTracks().includes(track))
remoteStream.addTrack(track);
});
playRemoteStream(remoteStream);
// Defer recorder setup until we actually have remote tracks; createMediaStreamSource
// on an empty MediaStream is unreliable across browsers.
setupRecorder();
};
return pc;
};
const stopRecorderAndUpload = async callId => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
await new Promise(resolve => {
mediaRecorder.addEventListener('stop', resolve, { once: true });
try {
mediaRecorder.stop();
} catch (_) {
resolve();
}
});
}
if (!recorderChunks.length || !callId) return;
const blob = new Blob(recorderChunks, { type: recorderChunks[0].type });
try {
await WhatsappCallsAPI.uploadRecording(callId, blob);
} catch (_) {
/* best-effort — server-side idempotency guard handles a retry */
}
};
export function useWhatsappCallSession() {
const isInitiating = ref(false);
const error = ref(null);
const prepareInboundAnswer = async (sdpOffer, iceServers) => {
cleanup();
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
buildPeerConnection(iceServers);
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await waitForIceGatheringComplete(pc);
// Recorder fires from ontrack once remote tracks arrive.
return pc.localDescription.sdp;
};
const prepareOutboundOffer = async () => {
cleanup();
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
buildPeerConnection(DEFAULT_OUTBOUND_ICE_SERVERS);
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
};
const acceptIncomingCall = async ({ callId, sdpOffer, iceServers }) => {
// The store may not have sdpOffer yet (cable's voice_call.incoming raced
// the click), so fall back to GET /whatsapp_calls/:id which exposes the
// SDP offer + ICE servers from the show jbuilder.
let offer = sdpOffer;
let ice = iceServers;
if (!offer && callId) {
try {
const fresh = await WhatsappCallsAPI.show(callId);
offer = fresh?.sdp_offer || fresh?.sdpOffer;
ice = ice || fresh?.ice_servers || fresh?.iceServers;
} catch (e) {
// eslint-disable-next-line no-console
console.error(
'[WhatsApp Call] failed to fetch call data for accept:',
e
);
}
}
if (!offer) {
throw new Error('Missing sdp_offer for accept — call may have ended.');
}
const sdpAnswer = await prepareInboundAnswer(offer, ice);
activeCallId = callId;
await WhatsappCallsAPI.accept(callId, sdpAnswer);
};
const rejectIncomingCall = async callId => {
intentionallyClosing = true;
try {
await WhatsappCallsAPI.reject(callId);
} finally {
cleanup();
}
};
const initiateOutboundCall = async conversationId => {
if (isInitiating.value) return null;
isInitiating.value = true;
error.value = null;
try {
const sdpOffer = await prepareOutboundOffer();
const response = await WhatsappCallsAPI.initiate(
conversationId,
sdpOffer
);
// Permission flow returns no call id — let the caller render the banner.
activeCallId = response?.id || null;
return response;
} catch (e) {
cleanup();
error.value = e;
throw e;
} finally {
isInitiating.value = false;
}
};
const endActiveCall = async () => {
if (!activeCallId) {
cleanup();
return;
}
intentionallyClosing = true;
const callIdSnapshot = activeCallId;
try {
await stopRecorderAndUpload(callIdSnapshot);
await WhatsappCallsAPI.terminate(callIdSnapshot).catch(() => {});
} finally {
cleanup();
}
};
return {
isInitiating,
error,
prepareInboundAnswer,
prepareOutboundOffer,
acceptIncomingCall,
rejectIncomingCall,
initiateOutboundCall,
endActiveCall,
};
}
// Cable handlers fire outside any composable instance; expose the shared session
// surface so they can apply the outbound answer onto the live PeerConnection.
export const applyOutboundAnswer = async (callId, sdpAnswer) => {
if (!pc) return;
activeCallId = callId;
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
// Recorder + audio playback fire from ontrack as soon as Meta's tracks arrive.
};
export const hasActiveWhatsappCall = () => Boolean(activeCallId);
// Used by the calls store as a sync teardown safety net.
export const cleanupWhatsappSession = () => cleanup();
// Cable-driven end (contact hung up / call timed out). Flush any in-memory
// recorder chunks and upload them so the resulting message bubble shows the
// audio + transcript — without this, only agent-initiated hangups upload.
export const handleWhatsappRemoteEnd = async callId => {
// Snapshot before cleanup nulls activeCallId.
const id = callId || activeCallId;
if (!id) {
cleanup();
return;
}
try {
await stopRecorderAndUpload(id);
} finally {
cleanup();
}
};
// Mute helpers — toggle the mic track's enabled flag (instantaneous, no renegotiation).
export const setWhatsappCallMuted = muted => {
if (!localStream) return false;
localStream.getAudioTracks().forEach(track => {
track.enabled = !muted;
});
return muted;
};
export const isWhatsappCallMuted = () => {
if (!localStream) return false;
const tracks = localStream.getAudioTracks();
if (!tracks.length) return false;
return !tracks[0].enabled;
};
// Best-effort terminate when the tab actually closes after the beforeunload prompt.
export const sendWhatsappTerminateBeacon = () => {
if (!activeCallId || intentionallyClosing) return;
const accountId = window.location.pathname.split('/')[3];
if (!accountId) return;
const url = `/api/v1/accounts/${accountId}/whatsapp_calls/${activeCallId}/terminate`;
try {
navigator.sendBeacon(url);
} catch (_) {
/* noop */
}
};
@@ -4,11 +4,6 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import { useCallsStore } from 'dashboard/stores/calls';
import {
applyOutboundAnswer,
handleWhatsappRemoteEnd,
} from 'dashboard/composables/useWhatsappCallSession';
const { isImpersonating } = useImpersonation();
@@ -40,11 +35,6 @@ class ActionCableConnector extends BaseActionCableConnector {
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
// WhatsApp call SDP exchange happens via these events; Twilio-shaped voice_call.*
// events also flow through here but are ignored when provider !== 'whatsapp'.
'voice_call.incoming': this.onVoiceCallIncoming,
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
'voice_call.ended': this.onVoiceCallEnded,
};
}
@@ -215,44 +205,6 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallIncoming = data => {
if (data?.provider !== 'whatsapp') return;
useCallsStore().addCall({
callSid: data.call_id,
callId: data.id,
conversationId: data.conversation_id,
inboxId: data.inbox_id,
callDirection: 'inbound',
provider: 'whatsapp',
sdpOffer: data.sdp_offer,
iceServers: data.ice_servers,
// Caller info for the FloatingCallWidget so it doesn't show "Unknown caller"
// before the conversation/contact has loaded into the store.
caller: data.caller,
});
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallOutboundConnected = data => {
if (data?.provider !== 'whatsapp' || !data.sdp_answer) return;
applyOutboundAnswer(data.id, data.sdp_answer).catch(() => {});
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallEnded = async data => {
if (data?.provider !== 'whatsapp') return;
// Must await the upload-and-cleanup BEFORE removeCall, because the store's
// sync teardownByProvider -> cleanupWhatsappSession would otherwise wipe
// mediaRecorder + recorderChunks before any upload microtask gets to run.
try {
await handleWhatsappRemoteEnd(data.id);
} catch (_) {
/* noop — upload is best-effort */
}
useCallsStore().removeCall(data.call_id);
};
}
export default {
+3 -6
View File
@@ -16,7 +16,6 @@ export const INBOX_TYPES = {
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
WHATSAPP: 'whatsapp',
};
export const getVoiceCallProvider = inbox => {
@@ -26,11 +25,9 @@ export const getVoiceCallProvider = inbox => {
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (!voiceEnabled) return null;
if (channelType === INBOX_TYPES.TWILIO) return VOICE_CALL_PROVIDERS.TWILIO;
if (channelType === INBOX_TYPES.WHATSAPP)
return VOICE_CALL_PROVIDERS.WHATSAPP;
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
}
return null;
};
+63 -12
View File
@@ -22,15 +22,37 @@ 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);
};
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,
assigneeId: extractAssigneeId(message?.conversation),
senderId: message?.sender?.id,
};
}
@@ -38,16 +60,23 @@ function extractCallData(message) {
export function handleVoiceCallCreated(message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, callId, provider, callDirection, conversationId, senderId } =
const { callSid, callDirection, conversationId, assigneeId, senderId } =
extractCallData(message);
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
if (
!shouldShowCall({
callDirection,
senderId,
assigneeId,
currentUserId,
})
) {
return;
}
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
callId,
provider,
conversationId,
callDirection,
senderId,
@@ -57,8 +86,14 @@ export function handleVoiceCallCreated(message, currentUserId) {
export function handleVoiceCallUpdated(commit, message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, status, callDirection, conversationId, senderId } =
extractCallData(message);
const {
callSid,
status,
callDirection,
conversationId,
assigneeId,
senderId,
} = extractCallData(message);
const callsStore = useCallsStore();
@@ -70,11 +105,19 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
callSid,
});
const isNewCall =
status === 'ringing' &&
!shouldSkipCall(callDirection, senderId, currentUserId);
if (
!shouldShowCall({
callDirection,
senderId,
assigneeId,
currentUserId,
})
) {
callsStore.removeCall(callSid);
return;
}
if (isNewCall) {
if (status === 'ringing') {
callsStore.addCall({
callSid,
conversationId,
@@ -83,3 +126,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
});
}
}
export function syncConversationCallVisibility(conversation, currentUserId) {
const assigneeId = extractAssigneeId(conversation);
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
const callsStore = useCallsStore();
callsStore.removeCallsForConversation(conversation.id);
}
@@ -83,7 +83,8 @@
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered"
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
@@ -97,10 +98,6 @@
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": {
"FRT": "FRT {status}",
"NRT": "NRT {status}",
@@ -301,9 +298,7 @@
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call",
"MUTE": "Mute mic",
"UNMUTE": "Unmute mic"
"END_CALL": "End call"
}
},
"EMAIL_TRANSCRIPT": {
@@ -800,10 +800,6 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
"WHATSAPP_CALLING_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
},
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
},
"HELP_CENTER": {
@@ -44,7 +44,6 @@ export default {
allowedDomains: '',
isUpdatingAllowedDomains: false,
isSettingDefaults: false,
whatsAppCallingEnabled: false,
};
},
validations: {
@@ -72,10 +71,6 @@ export default {
if (!this.isSettingDefaults && this.isAWebWidgetInbox)
this.handleHmacFlag();
},
whatsAppCallingEnabled() {
if (!this.isSettingDefaults && this.isEmbeddedSignupWhatsApp)
this.updateWhatsAppCallingEnabled();
},
},
mounted() {
this.setDefaults();
@@ -88,9 +83,6 @@ export default {
this.inbox.selected_feature_flags || []
).includes('allow_mobile_webview');
this.allowedDomains = this.inbox.allowed_domains || '';
this.whatsAppCallingEnabled = Boolean(
this.inbox.provider_config?.calling_enabled
);
this.$nextTick(() => {
this.isSettingDefaults = false;
});
@@ -155,24 +147,6 @@ export default {
this.isUpdatingAllowedDomains = false;
}
},
async updateWhatsAppCallingEnabled() {
try {
const payload = {
id: this.inbox.id,
formData: false,
channel: {
provider_config: {
...this.inbox.provider_config,
calling_enabled: this.whatsAppCallingEnabled,
},
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async updateWhatsAppInboxAPIKey() {
try {
const payload = {
@@ -400,15 +374,6 @@ export default {
</NextButton>
</div>
</SettingsFieldSection>
<SettingsToggleSection
v-model="whatsAppCallingEnabled"
:header="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CALLING_ENABLED.LABEL')
"
:description="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CALLING_ENABLED.DESCRIPTION')
"
/>
</template>
<!-- Manual Setup Section -->
+20 -33
View File
@@ -1,16 +1,7 @@
import { defineStore } from 'pinia';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { cleanupWhatsappSession } from 'dashboard/composables/useWhatsappCallSession';
import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
const teardownByProvider = call => {
if (call?.provider === 'whatsapp') {
cleanupWhatsappSession();
} else {
TwilioVoiceClient.endClientCall();
}
};
export const useCallsStore = defineStore('calls', {
state: () => ({
calls: [],
@@ -25,32 +16,15 @@ export const useCallsStore = defineStore('calls', {
actions: {
handleCallStatusChanged({ callSid, status }) {
if (!TERMINAL_STATUSES.includes(status)) return;
const call = this.calls.find(c => c.callSid === callSid);
// For WhatsApp, the upload-and-cleanup must happen before the recorder
// state is wiped — that runs from the voice_call.ended cable handler.
// If we tear down here (race-winning the cable end-event), the recorder
// chunks are gone before they get uploaded, so the recording is lost.
// Just drop the call from the store; voice_call.ended will idempotently
// finish cleanup once it arrives.
if (call?.provider === 'whatsapp') {
this.calls = this.calls.filter(c => c.callSid !== callSid);
return;
if (TERMINAL_STATUSES.includes(status)) {
this.removeCall(callSid);
}
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).
Object.assign(existing, callData, { isActive: existing.isActive });
return;
}
const exists = this.calls.some(call => call.callSid === callData.callSid);
if (exists) return;
this.calls.push({
...callData,
@@ -61,7 +35,7 @@ export const useCallsStore = defineStore('calls', {
removeCall(callSid) {
const callToRemove = this.calls.find(c => c.callSid === callSid);
if (callToRemove?.isActive) {
teardownByProvider(callToRemove);
TwilioVoiceClient.endClientCall();
}
this.calls = this.calls.filter(c => c.callSid !== callSid);
},
@@ -74,13 +48,26 @@ export const useCallsStore = defineStore('calls', {
},
clearActiveCall() {
const active = this.calls.find(c => c.isActive);
teardownByProvider(active);
TwilioVoiceClient.endClientCall();
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
);
if (callsToRemove.some(call => call.isActive)) {
TwilioVoiceClient.endClientCall();
}
this.calls = this.calls.filter(
call => call.conversationId !== conversationId
);
},
},
});