From 6835f9c85bf44fc9a508d65e519c1149126c1752 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Sat, 2 May 2026 14:40:40 +0700 Subject: [PATCH] =?UTF-8?q?feat(voice):=20WhatsApp=20Cloud=20Calling=20?= =?UTF-8?q?=E2=80=94=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/channel/whatsapp/whatsappCallsAPI.js | 45 +++ .../Contacts/VoiceCallButton.vue | 62 +++- .../message/bubbles/VoiceCall.vue | 38 +- .../components-next/message/chips/Audio.vue | 30 +- .../components/widgets/FloatingCallWidget.vue | 55 ++- .../conversation/ConversationHeader.vue | 59 +++ .../dashboard/composables/useCallSession.js | 71 +++- .../composables/useWhatsappCallSession.js | 343 ++++++++++++++++++ .../dashboard/helper/actionCable.js | 48 +++ app/javascript/dashboard/helper/inbox.js | 9 +- app/javascript/dashboard/helper/voice.js | 6 +- .../i18n/locale/en/conversation.json | 8 +- .../dashboard/i18n/locale/en/inboxMgmt.json | 4 + .../inbox/settingsPage/ConfigurationPage.vue | 35 ++ app/javascript/dashboard/stores/calls.js | 39 +- 15 files changed, 828 insertions(+), 24 deletions(-) create mode 100644 app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js create 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 new file mode 100644 index 000000000..ec24aae34 --- /dev/null +++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js @@ -0,0 +1,45 @@ +/* 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 b258dc763..65d31baeb 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -3,10 +3,16 @@ 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 } from 'dashboard/helper/inbox'; +import { + isVoiceCallEnabled, + getVoiceCallProvider, + VOICE_CALL_PROVIDERS, +} 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'; @@ -58,9 +64,63 @@ 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 a270882ac..bcf97b707 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -5,6 +5,7 @@ import { VOICE_CALL_STATUS } from '../constants'; import Icon from 'dashboard/components-next/icon/Icon.vue'; import BaseBubble from 'next/message/bubbles/Base.vue'; +import AudioChip from 'dashboard/components-next/message/chips/Audio.vue'; const LABEL_MAP = { [VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS', @@ -30,7 +31,7 @@ const BG_COLOR_MAP = { [VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9', }; -const { call } = useMessageContext(); +const { call, attachments, contentAttributes } = useMessageContext(); const status = computed(() => call.value?.status); const isOutbound = computed(() => call.value?.direction === 'outgoing'); @@ -38,6 +39,30 @@ const isFailed = computed(() => [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value) ); +const audioAttachment = computed(() => + (attachments?.value || []).find(a => a.fileType === 'audio') +); + +// Duration lives in two places depending on which payload the FE got: +// - call.duration_seconds / call.durationSeconds (push_event_data shape) +// - content_attributes.data.duration_seconds (message-side mirror) +// Both can be camelCased by useTransformKeys upstream — check every variant. +const durationSeconds = computed(() => { + const fromCall = call.value?.durationSeconds || call.value?.duration_seconds; + if (fromCall != null) return fromCall; + + const data = contentAttributes?.value?.data || contentAttributes?.value?.data; + return data?.durationSeconds || data?.duration_seconds; +}); + +const formattedDuration = computed(() => { + const s = Number(durationSeconds.value); + if (!s || Number.isNaN(s)) return ''; + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`; +}); + const labelKey = computed(() => { if (LABEL_MAP[status.value]) return LABEL_MAP[status.value]; if (status.value === VOICE_CALL_STATUS.RINGING) { @@ -93,10 +118,19 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9'); {{ $t(labelKey) }} - {{ $t(subtextKey) }} + + {{ + audioAttachment + ? $t(subtextKey) + : formattedDuration || $t(subtextKey) + }} +
+ +
diff --git a/app/javascript/dashboard/components-next/message/chips/Audio.vue b/app/javascript/dashboard/components-next/message/chips/Audio.vue index 9c7a44b23..ec50d4a62 100644 --- a/app/javascript/dashboard/components-next/message/chips/Audio.vue +++ b/app/javascript/dashboard/components-next/message/chips/Audio.vue @@ -41,8 +41,33 @@ const playbackSpeed = ref(1); const { uid } = getCurrentInstance(); +// MediaRecorder-produced WebM/Opus blobs lack a Duration header →