fix(voice): align outbound WhatsApp call lifecycle with real pickup

A pile of related fixes around the dashboard's WhatsApp call flow:

- ConversationHeader / Contacts/VoiceCallButton: drop the immediate
  setCallActive at initiate time. The call sits in incomingCalls
  (callDirection: outbound) until the backend signals real pickup, so
  the duration timer never starts pre-pickup. Phone button is disabled
  whenever there is an active or incoming call.

- FloatingCallWidget:
  * Loop a ringtone (bell.mp3) for inbound ringing only.
  * Hide the green Join button for outbound — the agent has nothing to
    "join", and clicking it routed through acceptIncomingCall →
    prepareInboundAnswer → cleanup() and tore down the live outbound
    session before the API 409 ("already accepted by another agent").
  * Auto-join watcher skips whatsapp outbound (Twilio's joinConference
    flow only).

- useCallSession:
  * joinCall short-circuits for outbound calls — defense-in-depth so
    no future surface can re-trigger the destroyed-session bug.
  * endCall + outbound rejectIncomingCall pass call.callId to
    endActiveCall, so terminate fires even if module state was wiped.

- useWhatsappCallSession:
  * New recorderArmed flag, reset by cleanup. ontrack only calls
    setupRecorder when armed.
  * Inbound's acceptIncomingCall arms the recorder before the API
    round-trip (agent click = pickup).
  * armOutboundRecorder exported for the cable handler when ACCEPTED
    arrives.
  * endActiveCall accepts a callIdOverride to fall back when the
    module's activeCallId was nulled by an earlier cleanup.

- actionCable:
  * Split the cable contract: outbound_connected only applies the SDP
    answer (tunnel-up signal); outbound_accepted (new) is the real
    pickup signal — flips active and arms the recorder.
This commit is contained in:
Tanmay Deep Sharma
2026-05-05 14:15:40 +07:00
parent 4a79abb367
commit e6a35193f3
6 changed files with 156 additions and 44 deletions
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
@@ -7,6 +7,8 @@ import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSessi
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
const router = useRouter();
const store = useStore();
@@ -97,12 +99,15 @@ const handleJoinCall = async call => {
}
};
// Auto-join outbound calls when window is visible
// Auto-join outbound calls when window is visible. WhatsApp outbound has no
// separate join step (the offer was sent at initiate time and the answer is
// applied directly by the cable handler), so this only covers Twilio.
watch(
() => incomingCalls.value[0],
call => {
if (
call?.callDirection === 'outbound' &&
call?.provider !== 'whatsapp' &&
!hasActiveCall.value &&
WindowVisibilityHelper.isWindowVisible()
) {
@@ -111,6 +116,37 @@ watch(
},
{ immediate: true }
);
// Loop the ringtone while an inbound call is unanswered. Stop the moment any
// call is active (we joined), every inbound call cleared, or the widget tears
// down. Browser autoplay may reject the first play() if the tab has no prior
// user gesture; that's fine — the visual widget still surfaces the call.
const ringtone = new Audio(RINGTONE_URL);
ringtone.loop = true;
ringtone.volume = 1;
const stopRingtone = () => {
ringtone.pause();
ringtone.currentTime = 0;
};
const ringingInbound = computed(() =>
incomingCalls.value.some(call => call.callDirection !== 'outbound')
);
watch(
() => ringingInbound.value && !hasActiveCall.value,
shouldRing => {
if (shouldRing) {
ringtone.play().catch(() => {});
} else {
stopRingtone();
}
},
{ immediate: true }
);
onBeforeUnmount(stopRingtone);
</script>
<template>
@@ -223,7 +259,9 @@ watch(
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
v-if="!hasActiveCall"
v-if="
!hasActiveCall && incomingCalls[0]?.callDirection !== 'outbound'
"
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(incomingCalls[0])"
>
@@ -107,6 +107,13 @@ const isWhatsappVoiceInbox = computed(
() => getVoiceCallProvider(inbox.value) === VOICE_CALL_PROVIDERS.WHATSAPP
);
const isWhatsappCallButtonDisabled = computed(
() =>
whatsappCallSession.isInitiating.value ||
callsStore.hasActiveCall ||
callsStore.hasIncomingCall
);
const startWhatsappCall = async () => {
if (whatsappCallSession.isInitiating.value) return;
try {
@@ -125,6 +132,8 @@ const startWhatsappCall = async () => {
return;
}
// Stay non-active until Meta delivers the connect webhook (sdp_answer);
// flipping to active here would start the duration timer before pickup.
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
@@ -133,7 +142,6 @@ const startWhatsappCall = async () => {
callDirection: 'outbound',
provider: 'whatsapp',
});
callsStore.setCallActive(response.call_id);
} catch (error) {
useAlert(error?.message || t('CONVERSATION.HEADER.WHATSAPP_CALL_FAILED'));
}
@@ -207,7 +215,7 @@ const startWhatsappCall = async () => {
color="slate"
icon="i-lucide-phone"
:is-loading="whatsappCallSession.isInitiating.value"
:disabled="whatsappCallSession.isInitiating.value"
:disabled="isWhatsappCallButtonDisabled"
class="rounded-md hover:bg-n-alpha-2"
@click="startWhatsappCall"
/>