From 0e0e0868d7224fa5c6e967850c2635deece0e402 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Sat, 2 May 2026 14:31:13 +0700 Subject: [PATCH] chore(voice): split FE changes out to feat/whatsapp-call-ui (PR #14346) --- .../api/channel/whatsapp/whatsappCallsAPI.js | 45 --- .../Contacts/VoiceCallButton.vue | 62 +--- .../message/bubbles/VoiceCall.vue | 92 ++--- .../components-next/message/chips/Audio.vue | 30 +- .../components/widgets/FloatingCallWidget.vue | 55 +-- .../conversation/ConversationHeader.vue | 59 --- .../dashboard/composables/useCallSession.js | 79 +--- .../composables/useWhatsappCallSession.js | 343 ------------------ .../dashboard/helper/actionCable.js | 48 --- app/javascript/dashboard/helper/inbox.js | 9 +- app/javascript/dashboard/helper/voice.js | 75 +++- .../i18n/locale/en/conversation.json | 11 +- .../dashboard/i18n/locale/en/inboxMgmt.json | 4 - .../inbox/settingsPage/ConfigurationPage.vue | 35 -- app/javascript/dashboard/stores/calls.js | 53 +-- 15 files changed, 155 insertions(+), 845 deletions(-) delete mode 100644 app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js delete mode 100644 app/javascript/dashboard/composables/useWhatsappCallSession.js 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 @@ 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 + ); + }, }, });