fix: refactor whatsapp calling for lint compliance, outbound call UI, and SDP handling

This commit is contained in:
Tanmay Deep Sharma
2026-03-18 14:59:56 +05:30
parent c813505e7d
commit 1e3c6ad620
13 changed files with 339 additions and 261 deletions
+8 -20
View File
@@ -1,39 +1,27 @@
/* global axios */
class WhatsappCallsAPI {
import ApiClient from './ApiClient';
class WhatsappCallsAPI extends ApiClient {
constructor() {
this.apiVersion = '/api/v1';
}
// eslint-disable-next-line class-methods-use-this
get accountIdFromRoute() {
const isInsideAccountScopedURLs =
window.location.pathname.includes('/app/accounts');
if (isInsideAccountScopedURLs) {
return window.location.pathname.split('/')[3];
}
return '';
}
get baseUrl() {
return `${this.apiVersion}/accounts/${this.accountIdFromRoute}/whatsapp_calls`;
super('whatsapp_calls', { accountScoped: true });
}
accept(callId, sdpAnswer) {
return axios.post(`${this.baseUrl}/${callId}/accept`, {
return axios.post(`${this.url}/${callId}/accept`, {
sdp_answer: sdpAnswer,
});
}
reject(callId) {
return axios.post(`${this.baseUrl}/${callId}/reject`);
return axios.post(`${this.url}/${callId}/reject`);
}
terminate(callId) {
return axios.post(`${this.baseUrl}/${callId}/terminate`);
return axios.post(`${this.url}/${callId}/terminate`);
}
initiate(conversationId, sdpOffer) {
return axios.post(`${this.baseUrl}/initiate`, {
return axios.post(`${this.url}/initiate`, {
conversation_id: conversationId,
sdp_offer: sdpOffer,
});
@@ -15,6 +15,7 @@ const {
hasIncomingCall,
isAccepting,
isMuted,
isOutboundRinging,
callError,
formattedCallDuration,
acceptCall,
@@ -150,7 +151,10 @@ onUnmounted(() => {
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">
<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"
@@ -166,8 +170,17 @@ onUnmounted(() => {
t('WHATSAPP_CALL.UNKNOWN_CALLER')
}}
</p>
<p class="font-mono text-sm text-n-teal-9">
{{ formattedCallDuration }}
<p
class="text-sm"
:class="
isOutboundRinging ? 'text-n-slate-11' : 'font-mono text-n-teal-9'
"
>
{{
isOutboundRinging
? t('WHATSAPP_CALL.RINGING')
: formattedCallDuration
}}
</p>
</div>
<div class="flex shrink-0 gap-2">
@@ -16,6 +16,10 @@ import { useI18n } from 'vue-i18n';
import WhatsappCallsAPI from 'dashboard/api/whatsappCalls';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
useWhatsappCallsStore,
setOutboundCallProperty,
} from 'dashboard/stores/whatsappCalls';
const props = defineProps({
chat: {
@@ -34,6 +38,7 @@ const route = useRoute();
const conversationHeader = ref(null);
const { width } = useElementSize(conversationHeader);
const { isAWebWidgetInbox, isAWhatsAppCloudChannel } = useInbox();
const whatsappCallsStore = useWhatsappCallsStore();
const isInitiatingCall = ref(false);
const currentChat = computed(() => store.getters.getSelectedChat);
@@ -127,7 +132,7 @@ const initiateWhatsappCall = async () => {
});
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
// Handle remote audio from Meta
// Handle remote audio from Meta — ontrack fires when the callee picks up
pc.ontrack = event => {
const [stream] = event.streams;
if (!stream) return;
@@ -135,12 +140,15 @@ const initiateWhatsappCall = async () => {
audio.srcObject = stream;
audio.autoplay = true;
document.body.appendChild(audio);
window.__outboundCallAudio = audio;
setOutboundCallProperty('audio', audio);
// Remote audio arrived — callee picked up, transition from ringing to connected
whatsappCallsStore.markActiveCallConnected();
};
// eslint-disable-next-line no-console
pc.oniceconnectionstatechange = () =>
pc.oniceconnectionstatechange = () => {
// eslint-disable-next-line no-console
console.log('[WhatsApp Call] Outbound ICE state:', pc.iceConnectionState);
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
@@ -155,16 +163,17 @@ const initiateWhatsappCall = async () => {
);
const callStatus = response.data?.status;
if (callStatus === 'permission_requested' || callStatus === 'permission_pending') {
if (
callStatus === 'permission_requested' ||
callStatus === 'permission_pending'
) {
pc.close();
localStream.getTracks().forEach(track => track.stop());
const messageKey = callStatus === 'permission_requested'
? 'WHATSAPP_CALL.PERMISSION_REQUESTED'
: 'WHATSAPP_CALL.PERMISSION_PENDING';
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: t(messageKey),
type: 'info',
});
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;
}
@@ -173,9 +182,25 @@ const initiateWhatsappCall = async () => {
type: 'success',
});
window.__outboundCallPC = pc;
window.__outboundCallStream = localStream;
window.__outboundCallId = response.data?.call_id;
const outboundCallId = response.data?.call_id;
setOutboundCallProperty('pc', pc);
setOutboundCallProperty('stream', localStream);
setOutboundCallProperty('callId', outboundCallId);
// Set active call in store so the WhatsappCallWidget renders
// Status starts as 'ringing' — updated to 'connected' when SDP answer arrives
whatsappCallsStore.setActiveCall({
id: response.data?.id,
callId: outboundCallId,
direction: 'outbound',
status: 'ringing',
conversationId: currentChat.value.id,
caller: {
name: currentContact.value?.name,
phone: currentContact.value?.phone_number,
avatar: currentContact.value?.thumbnail,
},
});
} catch (err) {
if (pc) pc.close();
if (localStream) localStream.getTracks().forEach(track => track.stop());
@@ -1,9 +1,14 @@
import { ref, computed, onUnmounted } from 'vue';
import { useWhatsappCallsStore } from 'dashboard/stores/whatsappCalls';
import { ref, computed, watch, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import {
useWhatsappCallsStore,
getOutboundCallState,
} from 'dashboard/stores/whatsappCalls';
import WhatsappCallsAPI from 'dashboard/api/whatsappCalls';
import Timer from 'dashboard/helper/Timer';
export function useWhatsappCallSession() {
const { t } = useI18n();
const callsStore = useWhatsappCallsStore();
// WebRTC internals
@@ -27,6 +32,12 @@ export function useWhatsappCallSession() {
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;
@@ -35,7 +46,7 @@ export function useWhatsappCallSession() {
const cleanupWebRTC = () => {
if (localStream) {
localStream.getTracks().forEach(t => t.stop());
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
if (peerConnection) {
@@ -59,6 +70,17 @@ export function useWhatsappCallSession() {
callDuration.value = 0;
});
// Start timer when an outbound call becomes connected (SDP answer received)
watch(activeCall, call => {
if (
call?.direction === 'outbound' &&
call?.status === 'connected' &&
!durationTimer.intervalId
) {
durationTimer.start();
}
});
/**
* Waits for ICE candidate gathering to complete so the SDP contains all candidates.
* Meta's REST API doesn't support trickle ICE — the full SDP must be sent at once.
@@ -182,8 +204,8 @@ export function useWhatsappCallSession() {
} catch (err) {
callError.value =
err.name === 'NotAllowedError'
? 'Microphone access denied. Please allow mic access and try again.'
: 'Failed to accept call. Please try again.';
? t('WHATSAPP_CALL.MIC_DENIED')
: t('WHATSAPP_CALL.CALL_FAILED');
// eslint-disable-next-line no-console
console.error('[WhatsApp Call] acceptCall error:', err);
cleanupWebRTC();
@@ -211,7 +233,10 @@ export function useWhatsappCallSession() {
} catch {
// Best effort — always cleanup locally
} finally {
// For inbound calls, cleanup composable-managed WebRTC
cleanupWebRTC();
// For outbound calls, cleanup module-scoped WebRTC via store
callsStore.handleCallEnded(call.callId);
callsStore.clearActiveCall();
durationTimer.stop();
callDuration.value = 0;
@@ -219,8 +244,11 @@ export function useWhatsappCallSession() {
};
const toggleMute = () => {
if (!localStream) return;
const audioTrack = localStream.getAudioTracks()[0];
// For inbound calls, localStream is managed by this composable.
// For outbound calls, the stream is in module-scoped outbound state.
const stream = localStream || getOutboundCallState().stream;
if (!stream) return;
const audioTrack = stream.getAudioTracks()[0];
if (!audioTrack) return;
audioTrack.enabled = !audioTrack.enabled;
isMuted.value = !audioTrack.enabled;
@@ -242,6 +270,7 @@ export function useWhatsappCallSession() {
firstIncomingCall,
isAccepting,
isMuted,
isOutboundRinging,
callError,
formattedCallDuration,
acceptCall,
+10 -6
View File
@@ -4,7 +4,10 @@ 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 { useWhatsappCallsStore } from 'dashboard/stores/whatsappCalls';
import {
useWhatsappCallsStore,
getOutboundCallState,
} from 'dashboard/stores/whatsappCalls';
const { isImpersonating } = useImpersonation();
@@ -222,7 +225,6 @@ class ActionCableConnector extends BaseActionCableConnector {
});
};
// eslint-disable-next-line class-methods-use-this
onWhatsappCallAccepted = data => {
const whatsappCallsStore = useWhatsappCallsStore();
const currentUserId = this.app.$store.getters.getCurrentUserID;
@@ -240,13 +242,15 @@ class ActionCableConnector extends BaseActionCableConnector {
// eslint-disable-next-line class-methods-use-this
onWhatsappCallOutboundConnected = data => {
// When Meta sends the SDP answer for an outbound call, set it on the peer connection
const pc = window.__outboundCallPC;
if (pc && window.__outboundCallId === data.call_id && data.sdp_answer) {
const { pc, callId } = getOutboundCallState();
if (pc && callId === data.call_id && data.sdp_answer) {
pc.setRemoteDescription({ type: 'answer', sdp: data.sdp_answer }).catch(
err => {
// eslint-disable-next-line no-console
console.error('[WhatsApp Call] Failed to set remote SDP answer:', err);
console.error(
'[WhatsApp Call] Failed to set remote SDP answer:',
err
);
}
);
}
@@ -9,6 +9,7 @@
"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.",
@@ -1,5 +1,32 @@
import { defineStore } from 'pinia';
// Module-scoped (non-reactive) state for outbound call WebRTC objects.
// These cannot be in Pinia state because RTCPeerConnection/MediaStream are not serializable.
const outboundCall = { pc: null, stream: null, audio: null, callId: null };
export function getOutboundCallState() {
return outboundCall;
}
export function setOutboundCallProperty(key, value) {
outboundCall[key] = value;
}
function cleanupOutboundCall() {
if (outboundCall.pc) outboundCall.pc.close();
if (outboundCall.stream) {
outboundCall.stream.getTracks().forEach(t => t.stop());
}
if (outboundCall.audio) {
outboundCall.audio.srcObject = null;
outboundCall.audio.remove();
}
outboundCall.pc = null;
outboundCall.stream = null;
outboundCall.audio = null;
outboundCall.callId = null;
}
export const useWhatsappCallsStore = defineStore('whatsappCalls', {
state: () => ({
// Incoming ringing calls waiting for agent action
@@ -7,7 +34,7 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', {
// The single active call (accepted + audio connected)
activeCall: null,
// Cleanup callback registered by the composable — called when a call ends externally
_cleanupCallback: null,
cleanupCallback: null,
}),
getters: {
@@ -37,8 +64,14 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', {
this.activeCall = null;
},
markActiveCallConnected() {
if (this.activeCall) {
this.activeCall = { ...this.activeCall, status: 'connected' };
}
},
registerCleanupCallback(callback) {
this._cleanupCallback = callback;
this.cleanupCallback = callback;
},
handleCallAcceptedByOther(callId) {
@@ -49,24 +82,12 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', {
this.removeIncomingCall(callId);
if (this.activeCall?.callId === callId) {
this.activeCall = null;
// Trigger WebRTC cleanup via the registered callback
if (this._cleanupCallback) {
this._cleanupCallback();
if (this.cleanupCallback) {
this.cleanupCallback();
}
}
// Also clean up outbound call globals if they match
if (window.__outboundCallId === callId) {
if (window.__outboundCallPC) window.__outboundCallPC.close();
if (window.__outboundCallStream)
window.__outboundCallStream.getTracks().forEach(t => t.stop());
if (window.__outboundCallAudio) {
window.__outboundCallAudio.srcObject = null;
window.__outboundCallAudio.remove();
}
window.__outboundCallPC = null;
window.__outboundCallStream = null;
window.__outboundCallAudio = null;
window.__outboundCallId = null;
if (outboundCall.callId === callId) {
cleanupOutboundCall();
}
},
},