fix: refactor whatsapp calling for lint compliance, outbound call UI, and SDP handling
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user