feat(whatsapp-call): direct browser-Meta WebRTC, no media server
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class VoiceCallsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('voice_calls', { accountScoped: true });
|
||||
}
|
||||
|
||||
show(callId) {
|
||||
return axios.get(`${this.url}/${callId}`);
|
||||
}
|
||||
|
||||
// The browser does WebRTC locally and ships the SDP answer up. Rails forwards
|
||||
// it to Meta via pre_accept_call+accept_call.
|
||||
accept(callId, { sdpAnswer } = {}) {
|
||||
return axios.post(`${this.url}/${callId}/accept`, {
|
||||
sdp_answer: sdpAnswer,
|
||||
});
|
||||
}
|
||||
|
||||
reject(callId) {
|
||||
return axios.post(`${this.url}/${callId}/reject`);
|
||||
}
|
||||
|
||||
terminate(callId) {
|
||||
return axios.post(`${this.url}/${callId}/terminate`);
|
||||
}
|
||||
|
||||
// Outbound: browser builds the offer first; Rails ships it to Meta and
|
||||
// creates the Call record. Meta delivers its SDP answer later via the
|
||||
// connect webhook (broadcast over ActionCable as voice_call.outbound_connected).
|
||||
initiate(conversationId, provider, { sdpOffer } = {}) {
|
||||
return axios.post(`${this.url}/initiate`, {
|
||||
conversation_id: conversationId,
|
||||
provider,
|
||||
sdp_offer: sdpOffer,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the current agent's active call (if any). Used on page load to detect
|
||||
// a stale active session so we can terminate it cleanly.
|
||||
active() {
|
||||
return axios.get(`${this.url}/active`);
|
||||
}
|
||||
|
||||
// Multipart upload of the in-browser MediaRecorder Blob to Rails. The
|
||||
// controller attaches it to the call's voice_call message; the after-create
|
||||
// hook on Attachment fires Messages::AudioTranscriptionJob automatically.
|
||||
uploadRecording(callId, blob, filename) {
|
||||
const fd = new FormData();
|
||||
fd.append('recording', blob, filename);
|
||||
return axios.post(`${this.url}/${callId}/upload_recording`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
// URL helpers for navigator.sendBeacon (which can't carry custom headers and
|
||||
// needs the absolute account-scoped path).
|
||||
uploadRecordingUrl(callId) {
|
||||
return `${this.url}/${callId}/upload_recording`;
|
||||
}
|
||||
|
||||
terminateUrl(callId) {
|
||||
return `${this.url}/${callId}/terminate`;
|
||||
}
|
||||
}
|
||||
|
||||
export default new VoiceCallsAPI();
|
||||
@@ -3,10 +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 } from 'dashboard/helper/inbox';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
@@ -34,11 +34,12 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const voiceInboxes = computed(() =>
|
||||
(inboxesList.value || []).filter(isVoiceCallEnabled)
|
||||
(inboxesList.value || []).filter(
|
||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||
)
|
||||
);
|
||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
|
||||
// Unified behavior: hide when no phone
|
||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||
|
||||
const isInitiatingCall = computed(() => {
|
||||
@@ -66,15 +67,16 @@ const startCall = async inboxId => {
|
||||
contactId: props.contactId,
|
||||
inboxId,
|
||||
});
|
||||
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||
|
||||
// Add call to store immediately so widget shows
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
inboxId,
|
||||
callDirection: 'outbound',
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
voiceCallsStore.setActiveCall({
|
||||
id: response.call_id,
|
||||
callId: response.call_sid,
|
||||
provider: response.provider || 'twilio',
|
||||
direction: 'outbound',
|
||||
status: 'ringing',
|
||||
conversationId: response.conversation_id,
|
||||
caller: null,
|
||||
});
|
||||
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { VOICE_CALL_STATUS } from '../constants';
|
||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||
import { acceptVoiceCallById } from 'dashboard/composables/useVoiceCallSession';
|
||||
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
@@ -30,14 +33,65 @@ const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const { call } = useMessageContext();
|
||||
const router = useRouter();
|
||||
const { contentAttributes, messageType, attachments } = useMessageContext();
|
||||
|
||||
const status = computed(() => call.value?.status);
|
||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||
// NOTE: contentAttributes.data keys are camelCase because MessageList.vue
|
||||
// applies useCamelCase(messages, { deep: true }) before rendering.
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
const status = computed(() => data.value?.status?.toString());
|
||||
|
||||
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
|
||||
// Call source and metadata — all camelCase due to deep transform
|
||||
const callSource = computed(() => data.value?.callSource);
|
||||
const isVoiceCall = computed(() =>
|
||||
['whatsapp', 'twilio'].includes(callSource.value)
|
||||
);
|
||||
const callId = computed(() => data.value?.callId);
|
||||
const acceptedBy = computed(() => data.value?.acceptedBy);
|
||||
const durationSeconds = computed(() => data.value?.durationSeconds);
|
||||
|
||||
// Recording and transcript live on the first audio attachment. After the
|
||||
// call ends, CallRecordingFetchJob creates an Attachment on this message;
|
||||
// Messages::AudioTranscriptionService fills in `transcribedText` when ready.
|
||||
const audioAttachment = computed(
|
||||
() => attachments.value?.find(a => a.fileType === 'audio') || null
|
||||
);
|
||||
const recordingUrl = computed(() => audioAttachment.value?.dataUrl);
|
||||
const transcript = computed(() => audioAttachment.value?.transcribedText);
|
||||
const isJoining = ref(false);
|
||||
const isRejecting = ref(false);
|
||||
const showTranscript = ref(false);
|
||||
|
||||
// Reject is shown only for ringing inbound calls — outbound calls and
|
||||
// in-progress calls don't need a reject button (initiator hangs up via the
|
||||
// floating widget).
|
||||
const showRejectButton = computed(
|
||||
() =>
|
||||
isVoiceCall.value &&
|
||||
!isOutbound.value &&
|
||||
status.value === VOICE_CALL_STATUS.RINGING
|
||||
);
|
||||
|
||||
const formattedDuration = computed(() => {
|
||||
const seconds = durationSeconds.value;
|
||||
if (!seconds || seconds <= 0) return '';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
||||
});
|
||||
|
||||
// Show join/accept button logic
|
||||
// Direct browser↔Meta WebRTC has no rejoin path (Meta caches the prior
|
||||
// DTLS fingerprint), so only ringing calls show the Accept button.
|
||||
const showJoinButton = computed(
|
||||
() => isVoiceCall.value && status.value === VOICE_CALL_STATUS.RINGING
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
@@ -51,6 +105,19 @@ const labelKey = computed(() => {
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
// acceptedBy on outbound calls is the initiator, not the contact — so keep
|
||||
// "They answered" instead of "Answered by <agent>". Only inbound bubbles
|
||||
// should suppress the subtext in favor of the acceptedBy line.
|
||||
if (
|
||||
!isOutbound.value &&
|
||||
acceptedBy.value?.name &&
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS, VOICE_CALL_STATUS.COMPLETED].includes(
|
||||
status.value
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
@@ -62,12 +129,50 @@ const subtextKey = computed(() => {
|
||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
});
|
||||
|
||||
const answeredByText = computed(() => {
|
||||
if (isOutbound.value) return '';
|
||||
if (!acceptedBy.value?.name) return '';
|
||||
return acceptedBy.value.name;
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
|
||||
const handleJoinCall = async () => {
|
||||
if (isJoining.value || !isVoiceCall.value) return;
|
||||
isJoining.value = true;
|
||||
try {
|
||||
const result = await acceptVoiceCallById(callId.value);
|
||||
if (result?.success && result.call) {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: result.call.conversationId },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Voice Call] Accept from bubble failed:', err);
|
||||
} finally {
|
||||
isJoining.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectCall = async () => {
|
||||
if (isRejecting.value || !callId.value) return;
|
||||
isRejecting.value = true;
|
||||
try {
|
||||
await VoiceCallsAPI.reject(callId.value);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Voice Call] Reject from bubble failed:', err);
|
||||
} finally {
|
||||
isRejecting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -88,14 +193,90 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex overflow-hidden flex-col flex-grow">
|
||||
<div class="flex overflow-hidden flex-col flex-grow gap-0.5">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
<span v-if="answeredByText" class="text-xs text-n-slate-11">
|
||||
{{
|
||||
$t('CONVERSATION.VOICE_CALL.ANSWERED_BY', {
|
||||
name: answeredByText,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-else-if="subtextKey" class="text-xs text-n-slate-11">
|
||||
{{ $t(subtextKey) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="formattedDuration && status === VOICE_CALL_STATUS.COMPLETED"
|
||||
class="text-xs text-n-slate-10"
|
||||
>
|
||||
{{ formattedDuration }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="showRejectButton"
|
||||
:disabled="isRejecting"
|
||||
:title="$t('WHATSAPP_CALL.REJECT')"
|
||||
class="flex justify-center items-center w-8 h-8 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors shrink-0"
|
||||
:class="{ 'opacity-75 cursor-wait': isRejecting }"
|
||||
@click="handleRejectCall"
|
||||
>
|
||||
<i
|
||||
v-if="isRejecting"
|
||||
class="i-ph-circle-notch-bold text-sm text-white animate-spin"
|
||||
/>
|
||||
<i v-else class="i-ph-phone-x-bold text-sm text-white" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showJoinButton"
|
||||
:disabled="isJoining"
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-n-teal-9 hover:bg-n-teal-10 rounded-lg transition-colors shrink-0"
|
||||
:class="{ 'opacity-75 cursor-wait': isJoining }"
|
||||
@click="handleJoinCall"
|
||||
>
|
||||
<i
|
||||
v-if="isJoining"
|
||||
class="i-ph-circle-notch-bold text-sm animate-spin"
|
||||
/>
|
||||
<i v-else class="i-ph-phone-bold text-sm" />
|
||||
{{ $t('CONVERSATION.VOICE_CALL.ACCEPT_CALL') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="recordingUrl && status === VOICE_CALL_STATUS.COMPLETED"
|
||||
class="px-3 pb-2"
|
||||
>
|
||||
<audio controls class="w-full h-8" :src="recordingUrl">
|
||||
{{ $t('CONVERSATION.VOICE_CALL.AUDIO_NOT_SUPPORTED') }}
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="transcript && status === VOICE_CALL_STATUS.COMPLETED"
|
||||
class="px-3 pb-3"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1 text-xs text-n-slate-11 hover:text-n-slate-12 transition-colors"
|
||||
@click="showTranscript = !showTranscript"
|
||||
>
|
||||
<i
|
||||
class="text-sm"
|
||||
:class="
|
||||
showTranscript ? 'i-ph-caret-up-bold' : 'i-ph-caret-down-bold'
|
||||
"
|
||||
/>
|
||||
{{ $t('CONVERSATION.VOICE_CALL.TRANSCRIPT') }}
|
||||
</button>
|
||||
<p
|
||||
v-if="showTranscript"
|
||||
class="mt-1 text-xs leading-relaxed text-n-slate-11 whitespace-pre-wrap"
|
||||
>
|
||||
{{ transcript }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<script setup>
|
||||
import { watch, onUnmounted, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useVoiceCallSession } from 'dashboard/composables/useVoiceCallSession';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
activeCall,
|
||||
incomingCalls,
|
||||
hasActiveCall,
|
||||
hasIncomingCall,
|
||||
isAccepting,
|
||||
isMuted,
|
||||
isOutboundRinging,
|
||||
callError,
|
||||
formattedCallDuration,
|
||||
acceptCall,
|
||||
rejectCall,
|
||||
endActiveCall,
|
||||
toggleMute,
|
||||
dismissIncomingCall,
|
||||
startDurationTimer,
|
||||
} = useVoiceCallSession();
|
||||
|
||||
// In server-relay mode, the timer starts when the Peer B WebRTC handshake
|
||||
// completes (not when the agent clicks accept). Listen for this event.
|
||||
const onAgentWebRTCConnected = () => {
|
||||
startDurationTimer();
|
||||
};
|
||||
|
||||
const onPermissionGranted = ({ contactName }) => {
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: t('WHATSAPP_CALL.PERMISSION_GRANTED', { contactName }),
|
||||
type: 'success',
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on('voice_call:agent_webrtc_connected', onAgentWebRTCConnected);
|
||||
emitter.on('voice_call:permission_granted', onPermissionGranted);
|
||||
});
|
||||
|
||||
// Auto-dismiss ringing calls after 30 seconds
|
||||
const autoRejectTimers = new Map();
|
||||
|
||||
const startAutoRejectTimer = call => {
|
||||
if (autoRejectTimers.has(call.callId)) return;
|
||||
const timer = setTimeout(() => {
|
||||
dismissIncomingCall(call);
|
||||
autoRejectTimers.delete(call.callId);
|
||||
}, 30000);
|
||||
autoRejectTimers.set(call.callId, timer);
|
||||
};
|
||||
|
||||
const clearAutoRejectTimer = callId => {
|
||||
const timer = autoRejectTimers.get(callId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
autoRejectTimers.delete(callId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccept = async call => {
|
||||
clearAutoRejectTimer(call.callId);
|
||||
await acceptCall(call);
|
||||
if (activeCall.value) {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: call.conversationId },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async call => {
|
||||
clearAutoRejectTimer(call.callId);
|
||||
await rejectCall(call);
|
||||
};
|
||||
|
||||
const handleEndCall = async () => {
|
||||
await endActiveCall();
|
||||
};
|
||||
|
||||
// Start auto-reject timers for each newly added incoming call
|
||||
watch(
|
||||
incomingCalls,
|
||||
calls => {
|
||||
calls.forEach(call => startAutoRejectTimer(call));
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
autoRejectTimers.forEach(timer => clearTimeout(timer));
|
||||
autoRejectTimers.clear();
|
||||
emitter.off('voice_call:agent_webrtc_connected', onAgentWebRTCConnected);
|
||||
emitter.off('voice_call:permission_granted', onPermissionGranted);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="hasIncomingCall || hasActiveCall"
|
||||
class="fixed ltr:right-4 rtl:left-4 bottom-20 z-50 flex flex-col gap-2 w-72"
|
||||
>
|
||||
<!-- Error banner -->
|
||||
<div
|
||||
v-if="callError"
|
||||
class="px-3 py-2 bg-n-ruby-3 border border-n-ruby-6 rounded-lg text-xs text-n-ruby-11"
|
||||
>
|
||||
{{ callError }}
|
||||
</div>
|
||||
|
||||
<!-- Incoming calls (shown when there's no active call yet) -->
|
||||
<template v-if="!hasActiveCall">
|
||||
<div
|
||||
v-for="call in incomingCalls"
|
||||
:key="call.callId"
|
||||
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||
>
|
||||
<div
|
||||
class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex"
|
||||
>
|
||||
<Avatar
|
||||
:src="call.caller?.avatar"
|
||||
:name="call.caller?.name || call.caller?.phone"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||
{{
|
||||
call.caller?.name ||
|
||||
call.caller?.phone ||
|
||||
t('WHATSAPP_CALL.UNKNOWN_CALLER')
|
||||
}}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11 truncate">
|
||||
{{
|
||||
call.provider === 'twilio'
|
||||
? 'Incoming voice call'
|
||||
: t('WHATSAPP_CALL.INCOMING_WHATSAPP_CALL')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||
:title="t('WHATSAPP_CALL.REJECT')"
|
||||
@click="handleReject(call)"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||
</button>
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||
:disabled="isAccepting"
|
||||
:title="t('WHATSAPP_CALL.ACCEPT')"
|
||||
@click="handleAccept(call)"
|
||||
>
|
||||
<i
|
||||
v-if="isAccepting"
|
||||
class="text-lg text-white i-ph-circle-notch animate-spin"
|
||||
/>
|
||||
<i v-else class="text-lg text-white i-ph-phone-bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Active call widget -->
|
||||
<div
|
||||
v-if="hasActiveCall"
|
||||
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||
>
|
||||
<div
|
||||
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
|
||||
:class="{ 'animate-pulse': isOutboundRinging }"
|
||||
>
|
||||
<Avatar
|
||||
:src="activeCall.caller?.avatar"
|
||||
:name="activeCall.caller?.name || activeCall.caller?.phone"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||
{{
|
||||
activeCall.caller?.name ||
|
||||
activeCall.caller?.phone ||
|
||||
t('WHATSAPP_CALL.UNKNOWN_CALLER')
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
class="text-sm"
|
||||
:class="
|
||||
isOutboundRinging ? 'text-n-slate-11' : 'font-mono text-n-teal-9'
|
||||
"
|
||||
>
|
||||
<template v-if="isOutboundRinging">
|
||||
{{ t('WHATSAPP_CALL.RINGING') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formattedCallDuration }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<!-- Mute toggle -->
|
||||
<button
|
||||
class="flex justify-center items-center w-9 h-9 rounded-full transition-colors"
|
||||
:class="
|
||||
isMuted
|
||||
? 'bg-n-amber-9 hover:bg-n-amber-10'
|
||||
: 'bg-n-slate-4 hover:bg-n-slate-5'
|
||||
"
|
||||
:title="isMuted ? t('WHATSAPP_CALL.UNMUTE') : t('WHATSAPP_CALL.MUTE')"
|
||||
@click="toggleMute"
|
||||
>
|
||||
<i
|
||||
class="text-base text-white"
|
||||
:class="
|
||||
isMuted ? 'i-ph-microphone-slash-bold' : 'i-ph-microphone-bold'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
<!-- Hang up -->
|
||||
<button
|
||||
class="flex justify-center items-center w-9 h-9 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||
:title="t('WHATSAPP_CALL.HANG_UP')"
|
||||
@click="handleEndCall"
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-x-bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,6 +13,14 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
import {
|
||||
prepareOutboundOffer,
|
||||
cleanupInboundWebRTC,
|
||||
} from 'dashboard/composables/useVoiceCallSession';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
@@ -30,7 +38,10 @@ const store = useStore();
|
||||
const route = useRoute();
|
||||
const conversationHeader = ref(null);
|
||||
const { width } = useElementSize(conversationHeader);
|
||||
const { isAWebWidgetInbox } = useInbox();
|
||||
const { isAWebWidgetInbox, isAWhatsAppCloudChannel, voiceCallEnabled } =
|
||||
useInbox();
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
const isInitiatingCall = ref(false);
|
||||
|
||||
const currentChat = computed(() => store.getters.getSelectedChat);
|
||||
const accountId = computed(() => store.getters.getCurrentAccountId);
|
||||
@@ -91,6 +102,99 @@ const hasMultipleInboxes = computed(
|
||||
);
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
// Returns the provider to use for an outbound call, based on the inbox
|
||||
// channel type. WhatsApp Cloud inboxes use :whatsapp; Twilio Voice inboxes
|
||||
// use :twilio. Returns null if outbound calling isn't supported on this
|
||||
// channel.
|
||||
const callProvider = computed(() => {
|
||||
if (isAWhatsAppCloudChannel.value && inbox.value?.calling_enabled) {
|
||||
return 'whatsapp';
|
||||
}
|
||||
if (voiceCallEnabled.value) {
|
||||
return 'twilio';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const canInitiateVoiceCall = computed(() => {
|
||||
if (!callProvider.value) return false;
|
||||
if (voiceCallsStore.hasVoiceCall) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Outbound call initiation. WhatsApp now runs direct browser ↔ Meta WebRTC:
|
||||
// the browser builds the SDP offer locally before any network call, so Meta
|
||||
// gets the agent's real fingerprint and the eventual answer applies cleanly
|
||||
// in the cable handler. Twilio still POSTs first and waits for the
|
||||
// synchronous agent_offer in the response (its OutboundCallBuilder produces
|
||||
// the SDP server-side). On 138006 (no call permission) we tear down the local
|
||||
// PC immediately so the mic light goes off while the contact's phone shows
|
||||
// the consent prompt.
|
||||
const initiateWhatsappCall = async () => {
|
||||
if (isInitiatingCall.value || !currentChat.value?.id || !callProvider.value)
|
||||
return;
|
||||
isInitiatingCall.value = true;
|
||||
const isWhatsApp = callProvider.value === 'whatsapp';
|
||||
|
||||
try {
|
||||
let sdpOffer = null;
|
||||
if (isWhatsApp) {
|
||||
sdpOffer = await prepareOutboundOffer({});
|
||||
}
|
||||
|
||||
const response = await VoiceCallsAPI.initiate(
|
||||
currentChat.value.id,
|
||||
callProvider.value,
|
||||
isWhatsApp ? { sdpOffer } : {}
|
||||
);
|
||||
|
||||
const callStatus = response.data?.status;
|
||||
if (
|
||||
callStatus === 'permission_requested' ||
|
||||
callStatus === 'permission_pending'
|
||||
) {
|
||||
// Tear down the local PC; we'll rebuild from scratch on retry.
|
||||
cleanupInboundWebRTC();
|
||||
const message =
|
||||
callStatus === 'permission_requested'
|
||||
? t('WHATSAPP_CALL.PERMISSION_REQUESTED')
|
||||
: t('WHATSAPP_CALL.PERMISSION_PENDING');
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, { message, type: 'info' });
|
||||
return;
|
||||
}
|
||||
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: t('WHATSAPP_CALL.CALLING'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
const callData = response.data || {};
|
||||
voiceCallsStore.setActiveCall({
|
||||
id: callData.id,
|
||||
callId: callData.call_id,
|
||||
provider: callData.provider || callProvider.value,
|
||||
direction: 'outbound',
|
||||
status: 'ringing',
|
||||
conversationId: currentChat.value.id,
|
||||
caller: {
|
||||
name: currentContact.value?.name,
|
||||
phone: currentContact.value?.phone_number,
|
||||
avatar: currentContact.value?.thumbnail,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
cleanupInboundWebRTC();
|
||||
const errorMessage =
|
||||
err.response?.data?.error || t('WHATSAPP_CALL.CALL_FAILED');
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: errorMessage,
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
isInitiatingCall.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -152,6 +256,19 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
:parent-width="width"
|
||||
class="hidden md:flex"
|
||||
/>
|
||||
<button
|
||||
v-if="canInitiateVoiceCall"
|
||||
v-tooltip="$t('WHATSAPP_CALL.INITIATE_CALL')"
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg text-n-slate-11 hover:text-n-slate-12 hover:bg-n-slate-3 transition-colors"
|
||||
:disabled="isInitiatingCall"
|
||||
@click="initiateWhatsappCall"
|
||||
>
|
||||
<i
|
||||
v-if="isInitiatingCall"
|
||||
class="text-base i-ph-circle-notch animate-spin"
|
||||
/>
|
||||
<i v-else class="text-base i-ph-phone-bold" />
|
||||
</button>
|
||||
<MoreActions :conversation-id="currentChat.id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
/**
|
||||
* Detects a stale active call on page load and cleans it up. Direct
|
||||
* browser ↔ Meta WebRTC has no rejoin path — Meta caches the prior DTLS
|
||||
* fingerprint, so a fresh PC can never re-bind to the existing call leg.
|
||||
* Best we can do is terminate the stranded Call so the agent isn't shown
|
||||
* "in a call" forever.
|
||||
*/
|
||||
export function useCallReconnection() {
|
||||
const callsStore = useVoiceCallsStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
onMounted(async () => {
|
||||
if (callsStore.hasActiveCall || callsStore.hasIncomingCall) return;
|
||||
|
||||
try {
|
||||
const { data } = await VoiceCallsAPI.active();
|
||||
if (!data?.call && !data?.id) return;
|
||||
|
||||
const activeCallData = data.call || data;
|
||||
try {
|
||||
await VoiceCallsAPI.terminate(activeCallData.id);
|
||||
} catch {
|
||||
// best-effort — Rails-side cleanup
|
||||
}
|
||||
useAlert(t('WHATSAPP_CALL.RELOAD_ENDED_CALL'));
|
||||
} catch {
|
||||
callsStore.clearActiveCall();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Module-level WebRTC + recorder state. One active peer per browser tab.
|
||||
// `endActiveCall` and `pagehide` race for finalize duty; `intentionallyClosing`
|
||||
// disambiguates so beforeunload doesn't warn during a clean hangup.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
let inboundPc = null;
|
||||
let inboundStream = null;
|
||||
let inboundAudio = null;
|
||||
|
||||
let audioContext = null;
|
||||
let mediaRecorder = null;
|
||||
let recordingMime = null;
|
||||
let recordingChunks = [];
|
||||
let recordingFilename = null;
|
||||
let intentionallyClosing = false;
|
||||
|
||||
const RECORDER_MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg;codecs=opus',
|
||||
];
|
||||
|
||||
const DEFAULT_ICE = [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
|
||||
function pickRecorderMime() {
|
||||
if (typeof MediaRecorder === 'undefined') return null;
|
||||
return (
|
||||
RECORDER_MIME_CANDIDATES.find(t => MediaRecorder.isTypeSupported(t)) || null
|
||||
);
|
||||
}
|
||||
|
||||
function teardownRecorder() {
|
||||
mediaRecorder = null;
|
||||
recordingChunks = [];
|
||||
recordingMime = null;
|
||||
recordingFilename = null;
|
||||
if (audioContext) {
|
||||
audioContext.close().catch(() => {});
|
||||
audioContext = null;
|
||||
}
|
||||
}
|
||||
|
||||
function extensionFromMime(mime) {
|
||||
if (!mime) return 'webm';
|
||||
if (mime.startsWith('audio/webm')) return 'webm';
|
||||
if (mime.startsWith('audio/ogg')) return 'ogg';
|
||||
return 'webm';
|
||||
}
|
||||
|
||||
// Mix the agent's mic and Meta's remote audio into a single stream so the
|
||||
// resulting Blob is a real conversation rather than two interleaved channels.
|
||||
// Web Audio handles the mix; we never apply gain (raw levels matter for
|
||||
// Whisper's speech-detection floor).
|
||||
function setupRecorder(localStream, remoteStream, callId, providerCallId) {
|
||||
if (mediaRecorder || !localStream || !remoteStream) return;
|
||||
const mime = pickRecorderMime();
|
||||
if (!mime) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[Voice Call] No supported MediaRecorder MIME — skipping recording'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||
sampleRate: 48000,
|
||||
});
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume().catch(() => {});
|
||||
}
|
||||
const localSource = audioContext.createMediaStreamSource(localStream);
|
||||
const remoteSource = audioContext.createMediaStreamSource(remoteStream);
|
||||
const mixDest = audioContext.createMediaStreamDestination();
|
||||
localSource.connect(mixDest);
|
||||
remoteSource.connect(mixDest);
|
||||
|
||||
recordingMime = mime;
|
||||
recordingChunks = [];
|
||||
recordingFilename = `call_${callId}_${providerCallId || callId}.${extensionFromMime(mime)}`;
|
||||
|
||||
mediaRecorder = new MediaRecorder(mixDest.stream, { mimeType: mime });
|
||||
mediaRecorder.ondataavailable = event => {
|
||||
if (event.data && event.data.size) recordingChunks.push(event.data);
|
||||
};
|
||||
// 5s timeslice keeps the in-memory blob fresh enough for the pagehide
|
||||
// beacon to capture most of the call if the user closes the tab.
|
||||
mediaRecorder.start(5000);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[Voice Call] Recorder setup failed:', err);
|
||||
teardownRecorder();
|
||||
}
|
||||
}
|
||||
|
||||
// stop() returns nothing useful; the final dataavailable fires on the next
|
||||
// task and onstop fires after that. Wait for onstop, then assemble the blob.
|
||||
async function stopRecorderAndGetBlob() {
|
||||
if (!mediaRecorder) return null;
|
||||
const mr = mediaRecorder;
|
||||
if (mr.state === 'inactive') {
|
||||
const blob = new Blob(recordingChunks, {
|
||||
type: recordingMime || 'audio/webm',
|
||||
});
|
||||
teardownRecorder();
|
||||
return { blob, filename: recordingFilename };
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
const cleanup = () => {
|
||||
const blob = new Blob(recordingChunks, {
|
||||
type: recordingMime || 'audio/webm',
|
||||
});
|
||||
const filename = recordingFilename;
|
||||
teardownRecorder();
|
||||
resolve({ blob, filename });
|
||||
};
|
||||
mr.onstop = cleanup;
|
||||
try {
|
||||
mr.stop();
|
||||
} catch {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupInboundWebRTC() {
|
||||
// Stop recorder first so the source nodes still see live tracks during
|
||||
// flush. Any awaited blob is the caller's problem (endActiveCall does it).
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
try {
|
||||
mediaRecorder.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
teardownRecorder();
|
||||
|
||||
if (inboundStream) {
|
||||
inboundStream.getTracks().forEach(track => track.stop());
|
||||
inboundStream = null;
|
||||
}
|
||||
if (inboundPc) {
|
||||
inboundPc.close();
|
||||
inboundPc = null;
|
||||
}
|
||||
if (inboundAudio) {
|
||||
inboundAudio.srcObject = null;
|
||||
if (inboundAudio.parentNode) {
|
||||
inboundAudio.parentNode.removeChild(inboundAudio);
|
||||
}
|
||||
inboundAudio = null;
|
||||
}
|
||||
}
|
||||
|
||||
function waitForIceGatheringComplete(pc) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let timeout = null;
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
pc.onicegatheringstatechange = null;
|
||||
pc.oniceconnectionstatechange = null;
|
||||
};
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[Voice Call] ICE gathering timed out, sending partial SDP');
|
||||
resolve();
|
||||
}, 10000);
|
||||
|
||||
pc.onicegatheringstatechange = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
if (pc.iceConnectionState === 'failed') {
|
||||
cleanup();
|
||||
reject(new Error('ICE connection failed'));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function attachRemoteTrackHandler(pc, callId, providerCallId) {
|
||||
pc.ontrack = event => {
|
||||
let remoteStream = event.streams && event.streams[0];
|
||||
if (!remoteStream) {
|
||||
if (!event.track) return;
|
||||
remoteStream = new MediaStream([event.track]);
|
||||
}
|
||||
if (!inboundAudio) {
|
||||
const audio = document.createElement('audio');
|
||||
audio.autoplay = true;
|
||||
document.body.appendChild(audio);
|
||||
inboundAudio = audio;
|
||||
}
|
||||
inboundAudio.srcObject = remoteStream;
|
||||
inboundAudio.play().catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[Voice Call] audio.play() rejected:', err);
|
||||
});
|
||||
if (inboundStream) {
|
||||
setupRecorder(inboundStream, remoteStream, callId, providerCallId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Inbound: browser receives Meta's SDP offer via the voice_call.incoming
|
||||
// cable event, opens its mic, builds the answer, and ships it back so Rails
|
||||
// can hand it to Meta. After this returns, the agent and Meta are mid-DTLS.
|
||||
async function prepareInboundAnswer({
|
||||
callId,
|
||||
providerCallId,
|
||||
sdpOffer,
|
||||
iceServers,
|
||||
}) {
|
||||
cleanupInboundWebRTC();
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
inboundStream = stream;
|
||||
|
||||
const servers = iceServers?.length ? iceServers : DEFAULT_ICE;
|
||||
const pc = new RTCPeerConnection({ iceServers: servers });
|
||||
inboundPc = pc;
|
||||
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||
attachRemoteTrackHandler(pc, callId, providerCallId);
|
||||
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
|
||||
return pc.localDescription.sdp;
|
||||
}
|
||||
|
||||
// Outbound: browser builds the offer locally, before any network call. Rails
|
||||
// hands the offer to Meta via initiate_call; we receive Meta's SDP answer
|
||||
// later via the voice_call.outbound_connected cable event.
|
||||
async function prepareOutboundOffer({
|
||||
callId,
|
||||
providerCallId,
|
||||
iceServers,
|
||||
} = {}) {
|
||||
cleanupInboundWebRTC();
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
inboundStream = stream;
|
||||
|
||||
const servers = iceServers?.length ? iceServers : DEFAULT_ICE;
|
||||
const pc = new RTCPeerConnection({ iceServers: servers });
|
||||
inboundPc = pc;
|
||||
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||
attachRemoteTrackHandler(pc, callId, providerCallId);
|
||||
|
||||
const offer = await pc.createOffer({ offerToReceiveAudio: true });
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
|
||||
return pc.localDescription.sdp;
|
||||
}
|
||||
|
||||
// Apply Meta's SDP answer to the existing outbound RTCPeerConnection. Called
|
||||
// from the actionCable handler when voice_call.outbound_connected lands.
|
||||
async function applyOutboundAnswer(sdpAnswer) {
|
||||
if (!inboundPc) throw new Error('No active outbound RTCPeerConnection');
|
||||
if (inboundPc.signalingState !== 'have-local-offer') {
|
||||
// Already applied — duplicate cable delivery. No-op rather than detonate.
|
||||
return;
|
||||
}
|
||||
await inboundPc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
|
||||
// Some browsers fire ontrack synchronously inside setRemoteDescription;
|
||||
// others delay it. If we already have a remote stream, kick the recorder.
|
||||
if (inboundAudio?.srcObject && inboundStream) {
|
||||
setupRecorder(inboundStream, inboundAudio.srcObject, undefined, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export { prepareOutboundOffer, applyOutboundAnswer, cleanupInboundWebRTC };
|
||||
|
||||
// Standalone for the message bubble's Accept button. Branches on whether the
|
||||
// store has an SDP offer (WhatsApp direct mode = always yes).
|
||||
export async function acceptVoiceCallById(callId) {
|
||||
const callsStore = useVoiceCallsStore();
|
||||
|
||||
if (callsStore.hasActiveCall) {
|
||||
return { success: false, error: 'active_call_exists' };
|
||||
}
|
||||
|
||||
let call = callsStore.incomingCalls.find(
|
||||
c => c.id === callId || c.callId === String(callId)
|
||||
);
|
||||
|
||||
let sdpOffer = call?.sdpOffer;
|
||||
let iceServers = call?.iceServers;
|
||||
let providerCallId = call?.callId;
|
||||
|
||||
if (!sdpOffer) {
|
||||
const { data } = await VoiceCallsAPI.show(callId);
|
||||
if (data.status !== 'ringing') {
|
||||
return { success: false, error: 'not_ringing' };
|
||||
}
|
||||
sdpOffer = data.sdp_offer;
|
||||
iceServers = data.ice_servers;
|
||||
providerCallId = data.call_id;
|
||||
call = {
|
||||
id: data.id,
|
||||
callId: data.call_id,
|
||||
provider: data.provider,
|
||||
direction: data.direction,
|
||||
inboxId: data.inbox_id,
|
||||
conversationId: data.conversation_id,
|
||||
caller: data.caller,
|
||||
sdpOffer,
|
||||
iceServers,
|
||||
};
|
||||
callsStore.addIncomingCall(call);
|
||||
}
|
||||
|
||||
if (!sdpOffer) return { success: false, error: 'missing_sdp_offer' };
|
||||
|
||||
// ORDER-SENSITIVE: setActiveCall must happen synchronously before the
|
||||
// network call so the cable handler doesn't drop a frame on a null guard.
|
||||
const activeCallData = { ...call };
|
||||
callsStore.setActiveCall(activeCallData);
|
||||
|
||||
try {
|
||||
const sdpAnswer = await prepareInboundAnswer({
|
||||
callId: call.id,
|
||||
providerCallId,
|
||||
sdpOffer,
|
||||
iceServers,
|
||||
});
|
||||
await VoiceCallsAPI.accept(call.id, { sdpAnswer });
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.markActiveCallConnected();
|
||||
emitter.emit('voice_call:agent_webrtc_connected');
|
||||
return { success: true, call: activeCallData };
|
||||
} catch (err) {
|
||||
cleanupInboundWebRTC();
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.clearActiveCall?.();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort upload. Errors are swallowed because the call has already
|
||||
// completed by the time we get here; surfacing failures isn't actionable.
|
||||
async function uploadRecordingBlob(callId) {
|
||||
if (!mediaRecorder && recordingChunks.length === 0) return;
|
||||
try {
|
||||
const result = await stopRecorderAndGetBlob();
|
||||
if (!result?.blob || result.blob.size === 0) return;
|
||||
await VoiceCallsAPI.uploadRecording(callId, result.blob, result.filename);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[Voice Call] Recording upload failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Composable (used by VoiceCallWidget for floating UI + timer) ──
|
||||
export function useVoiceCallSession() {
|
||||
const { t } = useI18n();
|
||||
const callsStore = useVoiceCallsStore();
|
||||
const getters = useStoreGetters();
|
||||
const accountId = computed(() => getters.getCurrentAccountId.value);
|
||||
|
||||
const isAccepting = ref(false);
|
||||
const isMuted = ref(false);
|
||||
const callError = ref(null);
|
||||
const callDuration = ref(0);
|
||||
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
callDuration.value = elapsed;
|
||||
});
|
||||
|
||||
const activeCall = computed(() => callsStore.activeCall);
|
||||
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
|
||||
const firstIncomingCall = computed(() => callsStore.firstIncomingCall);
|
||||
|
||||
const isOutboundRinging = computed(
|
||||
() =>
|
||||
activeCall.value?.direction === 'outbound' &&
|
||||
activeCall.value?.status === 'ringing'
|
||||
);
|
||||
|
||||
const formattedCallDuration = computed(() => {
|
||||
const minutes = Math.floor(callDuration.value / 60);
|
||||
const seconds = callDuration.value % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
// Triggered when handleCallEnded fires — i.e. the OTHER side hung up
|
||||
// (Meta terminate webhook → voice_call.ended cable). Upload whatever we
|
||||
// recorded BEFORE tearing down the PC, otherwise the agent-hangup path is
|
||||
// the only one that ships audio to the server. uploadRecordingBlob
|
||||
// gathers the blob via the recorder's onstop listener and then frees the
|
||||
// recorder — only AFTER that completes do we close the PC and stop tracks.
|
||||
callsStore.registerCleanupCallback(async callId => {
|
||||
const idForUpload = callId || activeCall.value?.id;
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
try {
|
||||
if (idForUpload) await uploadRecordingBlob(idForUpload);
|
||||
} finally {
|
||||
cleanupInboundWebRTC();
|
||||
}
|
||||
});
|
||||
|
||||
// Warn the user if they try to navigate away mid-call. The actual cleanup
|
||||
// happens in the pagehide handler — beforeunload is purely the warning.
|
||||
const handleBeforeUnload = e => {
|
||||
if (!callsStore.activeCall || intentionallyClosing) return undefined;
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
// Some browsers (older Safari/Firefox) only honour a returned string.
|
||||
// eslint-disable-next-line consistent-return
|
||||
return '';
|
||||
};
|
||||
|
||||
// Fires after the user confirms close (or on tab switch). We use fetch
|
||||
// with `keepalive: true` rather than navigator.sendBeacon because Chatwoot
|
||||
// authenticates via devise-token-auth headers (`access-token`, `client`,
|
||||
// `uid`) which sendBeacon cannot attach — beacon would 401 silently.
|
||||
// fetch+keepalive sends both cookies AND custom headers and the request
|
||||
// survives page unload (capped at ~64KB body, fine for terminate).
|
||||
const handlePageHide = () => {
|
||||
const call = callsStore.activeCall;
|
||||
if (!call || intentionallyClosing) return;
|
||||
|
||||
// Stop the recorder synchronously; final dataavailable already fired via
|
||||
// the 5s timeslice, so chunks[] is approximately current.
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
try {
|
||||
mediaRecorder.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const sessionHeaders = (() => {
|
||||
try {
|
||||
return JSON.parse(
|
||||
decodeURIComponent(
|
||||
(document.cookie.match(/(^|;\s*)cw_d_session_info=([^;]+)/) ||
|
||||
[])[2] || '%7B%7D'
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
const authHeaders = {
|
||||
'access-token': sessionHeaders['access-token'] || '',
|
||||
client: sessionHeaders.client || '',
|
||||
uid: sessionHeaders.uid || '',
|
||||
expiry: sessionHeaders.expiry || '',
|
||||
'token-type': sessionHeaders['token-type'] || 'Bearer',
|
||||
};
|
||||
|
||||
if (recordingChunks.length > 0 && accountId.value) {
|
||||
try {
|
||||
const blob = new Blob(recordingChunks, {
|
||||
type: recordingMime || 'audio/webm',
|
||||
});
|
||||
const fd = new FormData();
|
||||
fd.append(
|
||||
'recording',
|
||||
blob,
|
||||
recordingFilename || `call_${call.id}.webm`
|
||||
);
|
||||
fetch(
|
||||
`/api/v1/accounts/${accountId.value}/voice_calls/${call.id}/upload_recording`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
headers: authHeaders,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (accountId.value) {
|
||||
try {
|
||||
fetch(
|
||||
`/api/v1/accounts/${accountId.value}/voice_calls/${call.id}/terminate`,
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
headers: authHeaders,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
cleanupInboundWebRTC();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
window.addEventListener('pagehide', handlePageHide);
|
||||
|
||||
// Start timer when an outbound call transitions to connected.
|
||||
watch(activeCall, call => {
|
||||
if (
|
||||
call?.direction === 'outbound' &&
|
||||
call?.status === 'connected' &&
|
||||
!durationTimer.intervalId
|
||||
) {
|
||||
durationTimer.start();
|
||||
}
|
||||
});
|
||||
|
||||
// Floating-widget Accept button. Same WhatsApp-direct flow as
|
||||
// acceptVoiceCallById — see comments there.
|
||||
const acceptCall = async call => {
|
||||
if (isAccepting.value) return;
|
||||
isAccepting.value = true;
|
||||
callError.value = null;
|
||||
|
||||
const sdpOffer = call.sdpOffer;
|
||||
const iceServers = call.iceServers;
|
||||
if (!sdpOffer) {
|
||||
callError.value = t('WHATSAPP_CALL.CALL_FAILED');
|
||||
isAccepting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const activeCallData = { ...call };
|
||||
callsStore.setActiveCall(activeCallData);
|
||||
|
||||
try {
|
||||
const sdpAnswer = await prepareInboundAnswer({
|
||||
callId: call.id,
|
||||
providerCallId: call.callId,
|
||||
sdpOffer,
|
||||
iceServers,
|
||||
});
|
||||
await VoiceCallsAPI.accept(call.id, { sdpAnswer });
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.markActiveCallConnected();
|
||||
durationTimer.start();
|
||||
emitter.emit('voice_call:agent_webrtc_connected');
|
||||
} catch (err) {
|
||||
callError.value =
|
||||
err?.name === 'NotAllowedError'
|
||||
? t('WHATSAPP_CALL.MIC_DENIED')
|
||||
: t('WHATSAPP_CALL.CALL_FAILED');
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Voice Call] acceptCall error:', err);
|
||||
cleanupInboundWebRTC();
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.clearActiveCall();
|
||||
} finally {
|
||||
isAccepting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const rejectCall = async call => {
|
||||
try {
|
||||
await VoiceCallsAPI.reject(call.id);
|
||||
} catch {
|
||||
// Best effort
|
||||
} finally {
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
}
|
||||
};
|
||||
|
||||
const endActiveCall = async () => {
|
||||
const call = activeCall.value;
|
||||
if (!call) return;
|
||||
intentionallyClosing = true;
|
||||
|
||||
try {
|
||||
await uploadRecordingBlob(call.id);
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
|
||||
try {
|
||||
await VoiceCallsAPI.terminate(call.id);
|
||||
} catch {
|
||||
// Best effort
|
||||
} finally {
|
||||
cleanupInboundWebRTC();
|
||||
callsStore.clearActiveCall();
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
intentionallyClosing = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
if (!inboundStream) return;
|
||||
const audioTrack = inboundStream.getAudioTracks()[0];
|
||||
if (!audioTrack) return;
|
||||
audioTrack.enabled = !audioTrack.enabled;
|
||||
isMuted.value = !audioTrack.enabled;
|
||||
};
|
||||
|
||||
const dismissIncomingCall = call => {
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
};
|
||||
|
||||
const startDurationTimer = () => {
|
||||
durationTimer.start();
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
window.removeEventListener('pagehide', handlePageHide);
|
||||
durationTimer.stop();
|
||||
});
|
||||
|
||||
return {
|
||||
activeCall,
|
||||
incomingCalls,
|
||||
hasActiveCall,
|
||||
hasIncomingCall,
|
||||
firstIncomingCall,
|
||||
isAccepting,
|
||||
isMuted,
|
||||
isOutboundRinging,
|
||||
callError,
|
||||
formattedCallDuration,
|
||||
acceptCall,
|
||||
rejectCall,
|
||||
endActiveCall,
|
||||
toggleMute,
|
||||
dismissIncomingCall,
|
||||
startDurationTimer,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@ 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 { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
import { applyOutboundAnswer } from 'dashboard/composables/useVoiceCallSession';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
|
||||
@@ -33,8 +35,12 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
'voice_call.incoming': this.onVoiceCallIncoming,
|
||||
'voice_call.accepted': this.onVoiceCallAccepted,
|
||||
'voice_call.ended': this.onVoiceCallEnded,
|
||||
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
|
||||
'voice_call.permission_granted': this.onVoiceCallPermissionGranted,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,16 +201,75 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('copilotMessages/upsert', data);
|
||||
};
|
||||
|
||||
onEnrichmentCompleted = () => {
|
||||
this.app.$store.dispatch('accounts/get', { silent: true });
|
||||
};
|
||||
|
||||
onCacheInvalidate = data => {
|
||||
const keys = data.cache_keys;
|
||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||
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 => {
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
voiceCallsStore.addIncomingCall({
|
||||
id: data.id,
|
||||
callId: data.call_id,
|
||||
provider: data.provider,
|
||||
direction: data.direction,
|
||||
inboxId: data.inbox_id,
|
||||
conversationId: data.conversation_id,
|
||||
caller: data.caller,
|
||||
// Stash Meta's SDP offer + ICE servers on the incoming-call entry so
|
||||
// the bubble's Accept button can build the WebRTC answer locally
|
||||
// without re-fetching from /show.
|
||||
sdpOffer: data.sdp_offer,
|
||||
iceServers: data.ice_servers,
|
||||
});
|
||||
};
|
||||
|
||||
onVoiceCallAccepted = data => {
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
const currentUserId = this.app.$store.getters.getCurrentUserID;
|
||||
if (data.accepted_by_agent_id !== currentUserId) {
|
||||
voiceCallsStore.handleCallAcceptedByOther(data.call_id);
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallEnded = data => {
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
voiceCallsStore.handleCallEnded(data.call_id);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallOutboundConnected = data => {
|
||||
// Outbound WhatsApp direct mode: Meta delivers its SDP answer when the
|
||||
// contact picks up. Apply it to the existing local RTCPeerConnection so
|
||||
// DTLS can complete browser ↔ Meta directly. The PC was created earlier
|
||||
// by prepareOutboundOffer when the agent clicked the call button.
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
const activeCall = voiceCallsStore.activeCall;
|
||||
if (!activeCall || activeCall.callId !== data.call_id) return;
|
||||
|
||||
if (!data.sdp_answer) return;
|
||||
|
||||
applyOutboundAnswer(data.sdp_answer)
|
||||
.then(() => {
|
||||
voiceCallsStore.markActiveCallConnected();
|
||||
emitter.emit('voice_call:agent_webrtc_connected');
|
||||
})
|
||||
.catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Voice Call] Failed to apply outbound SDP answer:', err);
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallPermissionGranted = data => {
|
||||
emitter.emit('voice_call:permission_granted', {
|
||||
contactName: data.contact_name,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -83,7 +83,11 @@
|
||||
"CALL_ENDED": "Call ended",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered"
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"ANSWERED_BY": "Answered by {name}",
|
||||
"ACCEPT_CALL": "Accept",
|
||||
"AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback.",
|
||||
"TRANSCRIPT": "Transcript"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
|
||||
@@ -36,6 +36,7 @@ import signup from './signup.json';
|
||||
import sla from './sla.json';
|
||||
import snooze from './snooze.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappCall from './whatsappCall.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
import mfa from './mfa.json';
|
||||
@@ -81,6 +82,7 @@ export default {
|
||||
...sla,
|
||||
...snooze,
|
||||
...teamsSettings,
|
||||
...whatsappCall,
|
||||
...whatsappTemplates,
|
||||
...contentTemplates,
|
||||
...mfa,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"WHATSAPP_CALL": {
|
||||
"INCOMING_WHATSAPP_CALL": "Incoming WhatsApp Call",
|
||||
"ACCEPT": "Accept",
|
||||
"REJECT": "Reject",
|
||||
"HANG_UP": "Hang Up",
|
||||
"MUTE": "Mute",
|
||||
"UNMUTE": "Unmute",
|
||||
"INITIATE_CALL": "Call via WhatsApp",
|
||||
"CALLING": "Calling…",
|
||||
"RINGING": "Ringing…",
|
||||
"CALL_FAILED": "Call failed. Please try again.",
|
||||
"PERMISSION_REQUESTED": "Call permission request sent to the contact. You can call once they approve.",
|
||||
"PERMISSION_PENDING": "Waiting for the contact to approve the call permission request. Please try again shortly.",
|
||||
"UNKNOWN_CALLER": "Unknown caller",
|
||||
"MIC_DENIED": "Microphone access denied. Please allow mic access and try again.",
|
||||
"PERMISSION_GRANTED": "{contactName} approved the call permission request. You can now call them.",
|
||||
"RELOAD_ENDED_CALL": "The call ended because the page was refreshed."
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,17 @@ const FloatingCallWidget = defineAsyncComponent(
|
||||
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
|
||||
);
|
||||
|
||||
const WhatsappCallWidget = defineAsyncComponent(
|
||||
() => import('dashboard/components/widgets/WhatsappCallWidget.vue')
|
||||
);
|
||||
|
||||
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
|
||||
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
|
||||
|
||||
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
import { useCallReconnection } from 'dashboard/composables/useCallReconnection';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -36,6 +42,7 @@ export default {
|
||||
CopilotLauncher,
|
||||
CopilotContainer,
|
||||
FloatingCallWidget,
|
||||
WhatsappCallWidget,
|
||||
MobileSidebarLauncher,
|
||||
},
|
||||
setup() {
|
||||
@@ -44,6 +51,13 @@ export default {
|
||||
const { accountId } = useAccount();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const callsStore = useCallsStore();
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
|
||||
// Detect a stale active WhatsApp call left over from a previous tab /
|
||||
// refresh and terminate it. Direct browser↔Meta WebRTC has no rejoin
|
||||
// path because Meta caches our DTLS fingerprint; the only safe action
|
||||
// is to clean up server-side state and tell the agent the call dropped.
|
||||
useCallReconnection();
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
@@ -51,8 +65,15 @@ export default {
|
||||
accountId,
|
||||
upgradePageRef,
|
||||
windowWidth,
|
||||
// Twilio voice still flows through useCallSession + the calls store.
|
||||
hasActiveCall: computed(() => callsStore.hasActiveCall),
|
||||
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
|
||||
// WhatsApp direct-browser calls flow through useVoiceCallSession + the
|
||||
// voiceCalls store. Both widgets can mount simultaneously; only one
|
||||
// ever has state at a time because they listen to disjoint cable
|
||||
// events.
|
||||
hasActiveVoiceCall: computed(() => voiceCallsStore.hasActiveCall),
|
||||
hasIncomingVoiceCall: computed(() => voiceCallsStore.hasIncomingCall),
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -163,6 +184,7 @@ export default {
|
||||
/>
|
||||
<CopilotContainer />
|
||||
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
|
||||
<WhatsappCallWidget v-if="hasActiveVoiceCall || hasIncomingVoiceCall" />
|
||||
</template>
|
||||
<AddAccountModal
|
||||
:show="showCreateAccountModal"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const useVoiceCallsStore = defineStore('voiceCalls', {
|
||||
state: () => ({
|
||||
// Incoming ringing calls waiting for agent action (per account)
|
||||
incomingCalls: [],
|
||||
// The single active call (accepted + audio connected)
|
||||
activeCall: null,
|
||||
// Cleanup callback registered by the composable — called when a call ends externally
|
||||
cleanupCallback: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
hasIncomingCall: state => state.incomingCalls.length > 0,
|
||||
hasActiveCall: state => state.activeCall !== null,
|
||||
hasVoiceCall: state =>
|
||||
state.incomingCalls.length > 0 || state.activeCall !== null,
|
||||
firstIncomingCall: state => state.incomingCalls[0] || null,
|
||||
},
|
||||
|
||||
actions: {
|
||||
addIncomingCall(callData) {
|
||||
const exists = this.incomingCalls.some(c => c.callId === callData.callId);
|
||||
if (exists) return;
|
||||
this.incomingCalls.push(callData);
|
||||
},
|
||||
|
||||
removeIncomingCall(callId) {
|
||||
this.incomingCalls = this.incomingCalls.filter(c => c.callId !== callId);
|
||||
},
|
||||
|
||||
setActiveCall(callData) {
|
||||
this.activeCall = callData;
|
||||
},
|
||||
|
||||
clearActiveCall() {
|
||||
this.activeCall = null;
|
||||
},
|
||||
|
||||
markActiveCallConnected() {
|
||||
if (this.activeCall) {
|
||||
this.activeCall = { ...this.activeCall, status: 'connected' };
|
||||
}
|
||||
},
|
||||
|
||||
registerCleanupCallback(callback) {
|
||||
this.cleanupCallback = callback;
|
||||
},
|
||||
|
||||
handleCallAcceptedByOther(callId) {
|
||||
this.removeIncomingCall(callId);
|
||||
},
|
||||
|
||||
handleCallEnded(callId) {
|
||||
this.removeIncomingCall(callId);
|
||||
if (this.activeCall?.callId === callId) {
|
||||
// Snapshot the DB id before nulling activeCall so the composable can
|
||||
// POST upload_recording with the right path even after we clear state.
|
||||
const dbCallId = this.activeCall?.id;
|
||||
if (this.cleanupCallback) this.cleanupCallback(dbCallId);
|
||||
this.activeCall = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user