feat(whatsapp-call): direct browser-Meta WebRTC, no media server
This commit is contained in:
@@ -3,10 +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 } from 'dashboard/helper/inbox';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
@@ -34,11 +34,12 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const voiceInboxes = computed(() =>
|
||||
(inboxesList.value || []).filter(isVoiceCallEnabled)
|
||||
(inboxesList.value || []).filter(
|
||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||
)
|
||||
);
|
||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
|
||||
// Unified behavior: hide when no phone
|
||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||
|
||||
const isInitiatingCall = computed(() => {
|
||||
@@ -66,15 +67,16 @@ const startCall = async inboxId => {
|
||||
contactId: props.contactId,
|
||||
inboxId,
|
||||
});
|
||||
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||
|
||||
// Add call to store immediately so widget shows
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
inboxId,
|
||||
callDirection: 'outbound',
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
voiceCallsStore.setActiveCall({
|
||||
id: response.call_id,
|
||||
callId: response.call_sid,
|
||||
provider: response.provider || 'twilio',
|
||||
direction: 'outbound',
|
||||
status: 'ringing',
|
||||
conversationId: response.conversation_id,
|
||||
caller: null,
|
||||
});
|
||||
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { VOICE_CALL_STATUS } from '../constants';
|
||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||
import { acceptVoiceCallById } from 'dashboard/composables/useVoiceCallSession';
|
||||
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
@@ -30,14 +33,65 @@ const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const { call } = useMessageContext();
|
||||
const router = useRouter();
|
||||
const { contentAttributes, messageType, attachments } = useMessageContext();
|
||||
|
||||
const status = computed(() => call.value?.status);
|
||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||
// NOTE: contentAttributes.data keys are camelCase because MessageList.vue
|
||||
// applies useCamelCase(messages, { deep: true }) before rendering.
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
const status = computed(() => data.value?.status?.toString());
|
||||
|
||||
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
|
||||
// Call source and metadata — all camelCase due to deep transform
|
||||
const callSource = computed(() => data.value?.callSource);
|
||||
const isVoiceCall = computed(() =>
|
||||
['whatsapp', 'twilio'].includes(callSource.value)
|
||||
);
|
||||
const callId = computed(() => data.value?.callId);
|
||||
const acceptedBy = computed(() => data.value?.acceptedBy);
|
||||
const durationSeconds = computed(() => data.value?.durationSeconds);
|
||||
|
||||
// Recording and transcript live on the first audio attachment. After the
|
||||
// call ends, CallRecordingFetchJob creates an Attachment on this message;
|
||||
// Messages::AudioTranscriptionService fills in `transcribedText` when ready.
|
||||
const audioAttachment = computed(
|
||||
() => attachments.value?.find(a => a.fileType === 'audio') || null
|
||||
);
|
||||
const recordingUrl = computed(() => audioAttachment.value?.dataUrl);
|
||||
const transcript = computed(() => audioAttachment.value?.transcribedText);
|
||||
const isJoining = ref(false);
|
||||
const isRejecting = ref(false);
|
||||
const showTranscript = ref(false);
|
||||
|
||||
// Reject is shown only for ringing inbound calls — outbound calls and
|
||||
// in-progress calls don't need a reject button (initiator hangs up via the
|
||||
// floating widget).
|
||||
const showRejectButton = computed(
|
||||
() =>
|
||||
isVoiceCall.value &&
|
||||
!isOutbound.value &&
|
||||
status.value === VOICE_CALL_STATUS.RINGING
|
||||
);
|
||||
|
||||
const formattedDuration = computed(() => {
|
||||
const seconds = durationSeconds.value;
|
||||
if (!seconds || seconds <= 0) return '';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
||||
});
|
||||
|
||||
// Show join/accept button logic
|
||||
// Direct browser↔Meta WebRTC has no rejoin path (Meta caches the prior
|
||||
// DTLS fingerprint), so only ringing calls show the Accept button.
|
||||
const showJoinButton = computed(
|
||||
() => isVoiceCall.value && status.value === VOICE_CALL_STATUS.RINGING
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
@@ -51,6 +105,19 @@ const labelKey = computed(() => {
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
// acceptedBy on outbound calls is the initiator, not the contact — so keep
|
||||
// "They answered" instead of "Answered by <agent>". Only inbound bubbles
|
||||
// should suppress the subtext in favor of the acceptedBy line.
|
||||
if (
|
||||
!isOutbound.value &&
|
||||
acceptedBy.value?.name &&
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS, VOICE_CALL_STATUS.COMPLETED].includes(
|
||||
status.value
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
@@ -62,12 +129,50 @@ const subtextKey = computed(() => {
|
||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
});
|
||||
|
||||
const answeredByText = computed(() => {
|
||||
if (isOutbound.value) return '';
|
||||
if (!acceptedBy.value?.name) return '';
|
||||
return acceptedBy.value.name;
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
|
||||
const handleJoinCall = async () => {
|
||||
if (isJoining.value || !isVoiceCall.value) return;
|
||||
isJoining.value = true;
|
||||
try {
|
||||
const result = await acceptVoiceCallById(callId.value);
|
||||
if (result?.success && result.call) {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: result.call.conversationId },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Voice Call] Accept from bubble failed:', err);
|
||||
} finally {
|
||||
isJoining.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectCall = async () => {
|
||||
if (isRejecting.value || !callId.value) return;
|
||||
isRejecting.value = true;
|
||||
try {
|
||||
await VoiceCallsAPI.reject(callId.value);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Voice Call] Reject from bubble failed:', err);
|
||||
} finally {
|
||||
isRejecting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -88,14 +193,90 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex overflow-hidden flex-col flex-grow">
|
||||
<div class="flex overflow-hidden flex-col flex-grow gap-0.5">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
<span v-if="answeredByText" class="text-xs text-n-slate-11">
|
||||
{{
|
||||
$t('CONVERSATION.VOICE_CALL.ANSWERED_BY', {
|
||||
name: answeredByText,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-else-if="subtextKey" class="text-xs text-n-slate-11">
|
||||
{{ $t(subtextKey) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="formattedDuration && status === VOICE_CALL_STATUS.COMPLETED"
|
||||
class="text-xs text-n-slate-10"
|
||||
>
|
||||
{{ formattedDuration }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="showRejectButton"
|
||||
:disabled="isRejecting"
|
||||
:title="$t('WHATSAPP_CALL.REJECT')"
|
||||
class="flex justify-center items-center w-8 h-8 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors shrink-0"
|
||||
:class="{ 'opacity-75 cursor-wait': isRejecting }"
|
||||
@click="handleRejectCall"
|
||||
>
|
||||
<i
|
||||
v-if="isRejecting"
|
||||
class="i-ph-circle-notch-bold text-sm text-white animate-spin"
|
||||
/>
|
||||
<i v-else class="i-ph-phone-x-bold text-sm text-white" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showJoinButton"
|
||||
:disabled="isJoining"
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-n-teal-9 hover:bg-n-teal-10 rounded-lg transition-colors shrink-0"
|
||||
:class="{ 'opacity-75 cursor-wait': isJoining }"
|
||||
@click="handleJoinCall"
|
||||
>
|
||||
<i
|
||||
v-if="isJoining"
|
||||
class="i-ph-circle-notch-bold text-sm animate-spin"
|
||||
/>
|
||||
<i v-else class="i-ph-phone-bold text-sm" />
|
||||
{{ $t('CONVERSATION.VOICE_CALL.ACCEPT_CALL') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="recordingUrl && status === VOICE_CALL_STATUS.COMPLETED"
|
||||
class="px-3 pb-2"
|
||||
>
|
||||
<audio controls class="w-full h-8" :src="recordingUrl">
|
||||
{{ $t('CONVERSATION.VOICE_CALL.AUDIO_NOT_SUPPORTED') }}
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="transcript && status === VOICE_CALL_STATUS.COMPLETED"
|
||||
class="px-3 pb-3"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1 text-xs text-n-slate-11 hover:text-n-slate-12 transition-colors"
|
||||
@click="showTranscript = !showTranscript"
|
||||
>
|
||||
<i
|
||||
class="text-sm"
|
||||
:class="
|
||||
showTranscript ? 'i-ph-caret-up-bold' : 'i-ph-caret-down-bold'
|
||||
"
|
||||
/>
|
||||
{{ $t('CONVERSATION.VOICE_CALL.TRANSCRIPT') }}
|
||||
</button>
|
||||
<p
|
||||
v-if="showTranscript"
|
||||
class="mt-1 text-xs leading-relaxed text-n-slate-11 whitespace-pre-wrap"
|
||||
>
|
||||
{{ transcript }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
|
||||
Reference in New Issue
Block a user