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 →