diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js
deleted file mode 100644
index ec24aae34..000000000
--- a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js
+++ /dev/null
@@ -1,45 +0,0 @@
-/* global axios */
-import ApiClient from '../../ApiClient';
-
-class WhatsappCallsAPI extends ApiClient {
- constructor() {
- super('whatsapp_calls', { accountScoped: true });
- }
-
- show(callId) {
- return axios.get(`${this.url}/${callId}`).then(r => r.data);
- }
-
- initiate(conversationId, sdpOffer) {
- return axios
- .post(`${this.url}/initiate`, {
- conversation_id: conversationId,
- sdp_offer: sdpOffer,
- })
- .then(r => r.data);
- }
-
- accept(callId, sdpAnswer) {
- return axios
- .post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer })
- .then(r => r.data);
- }
-
- reject(callId) {
- return axios.post(`${this.url}/${callId}/reject`).then(r => r.data);
- }
-
- terminate(callId) {
- return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data);
- }
-
- uploadRecording(callId, blob, filename = 'call-recording.webm') {
- const formData = new FormData();
- formData.append('recording', blob, filename);
- return axios
- .post(`${this.url}/${callId}/upload_recording`, formData)
- .then(r => r.data);
- }
-}
-
-export default new WhatsappCallsAPI();
diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue
index 65d31baeb..b258dc763 100644
--- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue
+++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue
@@ -3,16 +3,10 @@ import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
-import {
- isVoiceCallEnabled,
- getVoiceCallProvider,
- VOICE_CALL_PROVIDERS,
-} from 'dashboard/helper/inbox';
+import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
-import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
-import ContactAPI from 'dashboard/api/contacts';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -64,63 +58,9 @@ const navigateToConversation = conversationId => {
}
};
-const whatsappCallSession = useWhatsappCallSession();
-
-// Find the most recent open conversation for this contact in the picked inbox.
-// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
-const findWhatsappConversationId = async inboxId => {
- const { data } = await ContactAPI.getConversations(props.contactId);
- const conversations = data?.payload || [];
- const match = conversations
- .filter(c => c.inbox_id === inboxId)
- .sort((a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0))[0];
- return match?.id || null;
-};
-
-const startWhatsappCall = async inboxId => {
- const conversationId = await findWhatsappConversationId(inboxId);
- if (!conversationId) {
- useAlert(t('CONTACT_PANEL.CALL_FAILED'));
- return;
- }
-
- const response =
- await whatsappCallSession.initiateOutboundCall(conversationId);
- if (!response?.id) {
- // Permission flow returns no id — banner already handled server-side; surface to user.
- useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
- navigateToConversation(conversationId);
- return;
- }
-
- const callsStore = useCallsStore();
- callsStore.addCall({
- callSid: response.call_id,
- callId: response.id,
- conversationId,
- inboxId,
- callDirection: 'outbound',
- provider: 'whatsapp',
- });
- callsStore.setCallActive(response.call_id);
-
- useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
- navigateToConversation(conversationId);
-};
-
const startCall = async inboxId => {
if (isInitiatingCall.value) return;
- const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
- if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
- try {
- await startWhatsappCall(inboxId);
- } catch (error) {
- useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
- }
- return;
- }
-
try {
const response = await store.dispatch('contacts/initiateCall', {
contactId: props.contactId,
diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
index bcf97b707..f2383551c 100644
--- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
+++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
@@ -1,22 +1,18 @@
@@ -199,18 +152,6 @@ const startWhatsappCall = async () => {
:parent-width="width"
class="hidden md:flex"
/>
-
diff --git a/app/javascript/dashboard/composables/useCallSession.js b/app/javascript/dashboard/composables/useCallSession.js
index 179d5818b..32a3bd370 100644
--- a/app/javascript/dashboard/composables/useCallSession.js
+++ b/app/javascript/dashboard/composables/useCallSession.js
@@ -1,19 +1,14 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls';
-import {
- useWhatsappCallSession,
- sendWhatsappTerminateBeacon,
- cleanupWhatsappSession,
-} from 'dashboard/composables/useWhatsappCallSession';
+import { useAlert } from 'dashboard/composables';
import Timer from 'dashboard/helper/Timer';
-const isWhatsappCall = call => call?.provider === 'whatsapp';
-
export function useCallSession() {
const callsStore = useCallsStore();
- const whatsappSession = useWhatsappCallSession();
+ const { t } = useI18n();
const isJoining = ref(false);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
@@ -37,52 +32,20 @@ export function useCallSession() {
{ immediate: true }
);
- // Browser-native confirm prompt when reload/close happens mid-call. Reload
- // tears down the WebRTC session permanently for WhatsApp (no rejoin) and
- // drops the agent leg for Twilio, so warn either way.
- const handleBeforeUnload = event => {
- if (!hasActiveCall.value) return;
- event.preventDefault();
- event.returnValue = '';
- };
-
- // pagehide fires after the user confirms the prompt. Let the WhatsApp session
- // best-effort sendBeacon a terminate so the server doesn't keep the call open.
- const handlePageHide = () => {
- sendWhatsappTerminateBeacon();
- };
-
- const handleTwilioDisconnected = () => callsStore.clearActiveCall();
-
onMounted(() => {
- TwilioVoiceClient.addEventListener(
- 'call:disconnected',
- handleTwilioDisconnected
+ TwilioVoiceClient.addEventListener('call:disconnected', () =>
+ callsStore.clearActiveCall()
);
- window.addEventListener('beforeunload', handleBeforeUnload);
- window.addEventListener('pagehide', handlePageHide);
});
onUnmounted(() => {
durationTimer.stop();
- TwilioVoiceClient.removeEventListener(
- 'call:disconnected',
- handleTwilioDisconnected
+ TwilioVoiceClient.removeEventListener('call:disconnected', () =>
+ callsStore.clearActiveCall()
);
- window.removeEventListener('beforeunload', handleBeforeUnload);
- window.removeEventListener('pagehide', handlePageHide);
});
- const findCall = callSid => callsStore.calls.find(c => c.callSid === callSid);
-
const endCall = async ({ conversationId, inboxId, callSid }) => {
- if (isWhatsappCall(findCall(callSid))) {
- await whatsappSession.endActiveCall();
- durationTimer.stop();
- callsStore.clearActiveCall();
- return;
- }
-
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
TwilioVoiceClient.endClientCall();
durationTimer.stop();
@@ -94,18 +57,6 @@ export function useCallSession() {
isJoining.value = true;
try {
- const call = findCall(callSid);
- if (isWhatsappCall(call)) {
- await whatsappSession.acceptIncomingCall({
- callId: call.callId,
- sdpOffer: call.sdpOffer,
- iceServers: call.iceServers,
- });
- callsStore.setCallActive(callSid);
- durationTimer.start();
- return { callId: call.callId };
- }
-
const device = await TwilioVoiceClient.initializeDevice(inboxId);
if (!device) return null;
@@ -126,12 +77,13 @@ export function useCallSession() {
return { conferenceSid: joinResponse?.conference_sid };
} catch (error) {
+ useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
+ if (error?.response?.status === 409) {
+ TwilioVoiceClient.endClientCall();
+ callsStore.dismissCall(callSid);
+ }
// eslint-disable-next-line no-console
console.error('Failed to join call:', error);
- // Tear down any half-built WebRTC state so the user's next click starts
- // fresh; otherwise the leftover pc + mic stream survives and confuses
- // the second-attempt SDP exchange.
- cleanupWhatsappSession();
return null;
} finally {
isJoining.value = false;
@@ -139,12 +91,7 @@ export function useCallSession() {
};
const rejectIncomingCall = callSid => {
- const call = findCall(callSid);
- if (isWhatsappCall(call) && call?.callId) {
- whatsappSession.rejectIncomingCall(call.callId);
- } else {
- TwilioVoiceClient.endClientCall();
- }
+ TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
};
diff --git a/app/javascript/dashboard/composables/useWhatsappCallSession.js b/app/javascript/dashboard/composables/useWhatsappCallSession.js
deleted file mode 100644
index dab4cbc0f..000000000
--- a/app/javascript/dashboard/composables/useWhatsappCallSession.js
+++ /dev/null
@@ -1,343 +0,0 @@
-import { ref } from 'vue';
-import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI';
-
-// Browser ↔ Meta WebRTC is a singleton — only one PeerConnection at a time can
-// hold the user's mic. Module-level state lets cable handlers and the pagehide
-// listener reach the live session without prop-drilling refs through composables.
-let pc = null;
-let localStream = null;
-let remoteStream = null;
-let remoteAudioEl = null;
-let mediaRecorder = null;
-let recorderChunks = [];
-let audioContext = null;
-let activeCallId = null;
-let intentionallyClosing = false;
-
-// Lazily attach a hidden
diff --git a/app/javascript/dashboard/stores/calls.js b/app/javascript/dashboard/stores/calls.js
index 6c3bdf7f3..4b58b8bb8 100644
--- a/app/javascript/dashboard/stores/calls.js
+++ b/app/javascript/dashboard/stores/calls.js
@@ -1,16 +1,7 @@
import { defineStore } from 'pinia';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
-import { cleanupWhatsappSession } from 'dashboard/composables/useWhatsappCallSession';
import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
-const teardownByProvider = call => {
- if (call?.provider === 'whatsapp') {
- cleanupWhatsappSession();
- } else {
- TwilioVoiceClient.endClientCall();
- }
-};
-
export const useCallsStore = defineStore('calls', {
state: () => ({
calls: [],
@@ -25,32 +16,15 @@ export const useCallsStore = defineStore('calls', {
actions: {
handleCallStatusChanged({ callSid, status }) {
- if (!TERMINAL_STATUSES.includes(status)) return;
-
- const call = this.calls.find(c => c.callSid === callSid);
- // For WhatsApp, the upload-and-cleanup must happen before the recorder
- // state is wiped — that runs from the voice_call.ended cable handler.
- // If we tear down here (race-winning the cable end-event), the recorder
- // chunks are gone before they get uploaded, so the recording is lost.
- // Just drop the call from the store; voice_call.ended will idempotently
- // finish cleanup once it arrives.
- if (call?.provider === 'whatsapp') {
- this.calls = this.calls.filter(c => c.callSid !== callSid);
- return;
+ if (TERMINAL_STATUSES.includes(status)) {
+ this.removeCall(callSid);
}
-
- this.removeCall(callSid);
},
addCall(callData) {
if (!callData?.callSid) return;
- const existing = this.calls.find(c => c.callSid === callData.callSid);
- if (existing) {
- // Merge so a later cable event with sdp_offer/provider/caller fills in
- // gaps left by the earlier message.created path (and vice versa).
- Object.assign(existing, callData, { isActive: existing.isActive });
- return;
- }
+ const exists = this.calls.some(call => call.callSid === callData.callSid);
+ if (exists) return;
this.calls.push({
...callData,
@@ -61,7 +35,7 @@ export const useCallsStore = defineStore('calls', {
removeCall(callSid) {
const callToRemove = this.calls.find(c => c.callSid === callSid);
if (callToRemove?.isActive) {
- teardownByProvider(callToRemove);
+ TwilioVoiceClient.endClientCall();
}
this.calls = this.calls.filter(c => c.callSid !== callSid);
},
@@ -74,13 +48,26 @@ export const useCallsStore = defineStore('calls', {
},
clearActiveCall() {
- const active = this.calls.find(c => c.isActive);
- teardownByProvider(active);
+ TwilioVoiceClient.endClientCall();
this.calls = this.calls.filter(call => !call.isActive);
},
dismissCall(callSid) {
this.calls = this.calls.filter(call => call.callSid !== callSid);
},
+
+ removeCallsForConversation(conversationId) {
+ const callsToRemove = this.calls.filter(
+ call => call.conversationId === conversationId
+ );
+
+ if (callsToRemove.some(call => call.isActive)) {
+ TwilioVoiceClient.endClientCall();
+ }
+
+ this.calls = this.calls.filter(
+ call => call.conversationId !== conversationId
+ );
+ },
},
});