diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index a270882ac..f9801ac8f 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 } = useMessageContext(); const status = computed(() => call.value?.status); const isOutbound = computed(() => call.value?.direction === 'outgoing'); @@ -38,6 +39,22 @@ 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') +); + +const durationSeconds = computed( + () => call.value?.durationSeconds || call.value?.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 +110,13 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9'); {{ $t(labelKey) }} - {{ $t(subtextKey) }} + {{ formattedDuration || $t(subtextKey) }} +
+ +
diff --git a/app/javascript/dashboard/composables/useWhatsappCallSession.js b/app/javascript/dashboard/composables/useWhatsappCallSession.js index 34f981ba0..cd4282b41 100644 --- a/app/javascript/dashboard/composables/useWhatsappCallSession.js +++ b/app/javascript/dashboard/composables/useWhatsappCallSession.js @@ -262,10 +262,26 @@ export const applyOutboundAnswer = async (callId, sdpAnswer) => { export const hasActiveWhatsappCall = () => Boolean(activeCallId); -// Used by the calls store to tear down the WebRTC session when a WhatsApp call -// is removed by a cable end-event (the other side hung up). +// Used by the calls store as a sync teardown safety net. export const cleanupWhatsappSession = () => cleanup(); +// Cable-driven end (contact hung up / call timed out). Flush any in-memory +// recorder chunks and upload them so the resulting message bubble shows the +// audio + transcript — without this, only agent-initiated hangups upload. +export const handleWhatsappRemoteEnd = async callId => { + // Snapshot before cleanup nulls activeCallId. + const id = callId || activeCallId; + if (!id) { + cleanup(); + return; + } + try { + await stopRecorderAndUpload(id); + } finally { + cleanup(); + } +}; + // Mute helpers — toggle the mic track's enabled flag (instantaneous, no renegotiation). export const setWhatsappCallMuted = muted => { if (!localStream) return false; diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index 2b2dd70cb..32cd7ce26 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -5,7 +5,10 @@ import { BUS_EVENTS } from 'shared/constants/busEvents'; import { emitter } from 'shared/helpers/mitt'; import { useImpersonation } from 'dashboard/composables/useImpersonation'; import { useCallsStore } from 'dashboard/stores/calls'; -import { applyOutboundAnswer } from 'dashboard/composables/useWhatsappCallSession'; +import { + applyOutboundAnswer, + handleWhatsappRemoteEnd, +} from 'dashboard/composables/useWhatsappCallSession'; const { isImpersonating } = useImpersonation(); @@ -240,6 +243,9 @@ class ActionCableConnector extends BaseActionCableConnector { // eslint-disable-next-line class-methods-use-this onVoiceCallEnded = data => { if (data?.provider !== 'whatsapp') return; + // Upload any in-memory recording chunks before tearing down the session, + // so contact-initiated hangups still produce an audio attachment. + handleWhatsappRemoteEnd(data.id).catch(() => {}); useCallsStore().removeCall(data.call_id); }; }