Compare commits
143
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d914065e0 | ||
|
|
5d9c979ace | ||
|
|
a985010a40 | ||
|
|
ddc863c6f7 | ||
|
|
d765e7c106 | ||
|
|
0bc890302b | ||
|
|
68973c3b7b | ||
|
|
0875ae719e | ||
|
|
e7d9165945 | ||
|
|
2bb358a787 | ||
|
|
872f09f720 | ||
|
|
9e56a024c5 | ||
|
|
1032097978 | ||
|
|
66b0f51279 | ||
|
|
7a7de71c39 | ||
|
|
62e763ecc2 | ||
|
|
8866071029 | ||
|
|
006d7afa44 | ||
|
|
461292fb2b | ||
|
|
3f65838155 | ||
|
|
12f29d0916 | ||
|
|
3c33d912df | ||
|
|
9d889f691b | ||
|
|
801e1e8ec5 | ||
|
|
7795bc9f1d | ||
|
|
f6a88d7f12 | ||
|
|
6b45b97d9c | ||
|
|
25609d88b2 | ||
|
|
5471e52335 | ||
|
|
f0119b3f2b | ||
|
|
b975333fec | ||
|
|
1610b9a4ba | ||
|
|
2e6b415826 | ||
|
|
cd9daacfcc | ||
|
|
ab8fefd3c9 | ||
|
|
dfdadc470d | ||
|
|
c5236ec67e | ||
|
|
ad48473ced | ||
|
|
02ac8f008f | ||
|
|
29fd83c1ea | ||
|
|
96b11e31a1 | ||
|
|
74f9cd764b | ||
|
|
553da80f80 | ||
|
|
83b59ec969 | ||
|
|
e468e45f4d | ||
|
|
7f59c8b771 | ||
|
|
cb180dfc5f | ||
|
|
20af850432 | ||
|
|
4db856e7da | ||
|
|
6d168f3c96 | ||
|
|
b95b5f62a0 | ||
|
|
639d087e53 | ||
|
|
40d3ec99dc | ||
|
|
3210860841 | ||
|
|
20076678d6 | ||
|
|
5e4fc5264d | ||
|
|
4093a8a48d | ||
|
|
2866b29f3a | ||
|
|
0b3284ac62 | ||
|
|
0b1caa7286 | ||
|
|
8df58d0a93 | ||
|
|
23ccec3ebc | ||
|
|
9e012e923b | ||
|
|
c0f1e9237f | ||
|
|
9aa338da0a | ||
|
|
567631b866 | ||
|
|
f712796df3 | ||
|
|
c1e3e61671 | ||
|
|
17ff977b07 | ||
|
|
cb420d83f6 | ||
|
|
97e7c106e3 | ||
|
|
61a8511f7a | ||
|
|
14a9b48d49 | ||
|
|
e6a35193f3 | ||
|
|
4a79abb367 | ||
|
|
5dda138ee7 | ||
|
|
040c11f0dc | ||
|
|
5086edefb3 | ||
|
|
66d9ddb9c4 | ||
|
|
41a7c88aef | ||
|
|
19b5496c56 | ||
|
|
d693d468d8 | ||
|
|
0b1ad1a854 | ||
|
|
6179adac22 | ||
|
|
3c959f0604 | ||
|
|
772734840d | ||
|
|
77c57acffa | ||
|
|
499ac053ee | ||
|
|
5aa1df81a8 | ||
|
|
81464a9087 | ||
|
|
6ed9500792 | ||
|
|
6835f9c85b | ||
|
|
442631102c | ||
|
|
78c615fe42 | ||
|
|
907011e280 | ||
|
|
a766ebf642 | ||
|
|
0e0e0868d7 | ||
|
|
d9077c64d3 | ||
|
|
1abb51992b | ||
|
|
0282156821 | ||
|
|
257a9129d7 | ||
|
|
1ce316efc7 | ||
|
|
ba94d8c2b0 | ||
|
|
6e11bebeae | ||
|
|
069f25f88a | ||
|
|
63c193358c | ||
|
|
7663e2ac30 | ||
|
|
0567655840 | ||
|
|
c25ed094fa | ||
|
|
112a8f9e9e | ||
|
|
723d416ce3 | ||
|
|
f3df486ce6 | ||
|
|
c7b6a87e77 | ||
|
|
19d91e47f1 | ||
|
|
1c945899c7 | ||
|
|
2c025755e2 | ||
|
|
0618e6e229 | ||
|
|
cbef2d4e66 | ||
|
|
cb93aba55c | ||
|
|
beef43f2bf | ||
|
|
cbd3f759b5 | ||
|
|
a7fdc45977 | ||
|
|
41e3ea0d87 | ||
|
|
fd22017292 | ||
|
|
555894793e | ||
|
|
7c87d3d150 | ||
|
|
c37d1b1d6b | ||
|
|
e5a03d08ea | ||
|
|
bc80985f55 | ||
|
|
83cd568ca2 | ||
|
|
f32a7b3f77 | ||
|
|
ba0fcbfc7f | ||
|
|
546193de2a | ||
|
|
67b522c433 | ||
|
|
f5eca9d1d0 | ||
|
|
5c4db3a6bc | ||
|
|
792dc359d4 | ||
|
|
2b0aaa237f | ||
|
|
48022833ea | ||
|
|
e2b3965a08 | ||
|
|
17e25fa3c4 | ||
|
|
37963cc654 | ||
|
|
c7f5277277 |
@@ -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();
|
||||
@@ -35,8 +35,9 @@ class ContactAPI extends ApiClient {
|
||||
return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
|
||||
}
|
||||
|
||||
getConversations(contactId) {
|
||||
return axios.get(`${this.url}/${contactId}/conversations`);
|
||||
getConversations(contactId, { inboxId } = {}) {
|
||||
const params = inboxId ? { inbox_id: inboxId } : {};
|
||||
return axios.get(`${this.url}/${contactId}/conversations`, { params });
|
||||
}
|
||||
|
||||
getContactableInboxes(contactId) {
|
||||
@@ -47,9 +48,10 @@ class ContactAPI extends ApiClient {
|
||||
return axios.get(`${this.url}/${contactId}/labels`);
|
||||
}
|
||||
|
||||
initiateCall(contactId, inboxId) {
|
||||
initiateCall(contactId, inboxId, conversationId = null) {
|
||||
return axios.post(`${this.url}/${contactId}/call`, {
|
||||
inbox_id: inboxId,
|
||||
conversation_id: conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -14,6 +20,9 @@ import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
const props = defineProps({
|
||||
phone: { type: String, default: '' },
|
||||
contactId: { type: [String, Number], required: true },
|
||||
// When set, the WhatsApp call continues in this conversation (matching the
|
||||
// header button) instead of looking up the contact's most recent one.
|
||||
conversationId: { type: [String, Number], default: null },
|
||||
label: { type: String, default: '' },
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
size: { type: String, default: 'sm' },
|
||||
@@ -38,7 +47,6 @@ const voiceInboxes = computed(() =>
|
||||
);
|
||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
|
||||
// Unified behavior: hide when no phone
|
||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||
|
||||
const isInitiatingCall = computed(() => {
|
||||
@@ -58,17 +66,85 @@ const navigateToConversation = conversationId => {
|
||||
}
|
||||
};
|
||||
|
||||
const startCall = async inboxId => {
|
||||
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).
|
||||
// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
|
||||
// contacts whose latest WhatsApp conversation falls outside the 20 most recent
|
||||
// across all inboxes would be treated as having no conversation.
|
||||
const findWhatsappConversationId = async inboxId => {
|
||||
const { data } = await ContactAPI.getConversations(props.contactId, {
|
||||
inboxId,
|
||||
});
|
||||
const conversations = data?.payload || [];
|
||||
const match = [...conversations].sort(
|
||||
(a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
|
||||
)[0];
|
||||
return match?.id || null;
|
||||
};
|
||||
|
||||
const startWhatsappCall = async (inboxId, conversationIdHint) => {
|
||||
// WhatsApp /initiate is conversation-scoped, so we must hand it a
|
||||
// conversation. Use the caller's hint when given (in-conversation flow);
|
||||
// otherwise pick the most recent one in the inbox.
|
||||
const conversationId =
|
||||
conversationIdHint || (await findWhatsappConversationId(inboxId));
|
||||
if (!conversationId) {
|
||||
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
|
||||
return;
|
||||
}
|
||||
|
||||
const response =
|
||||
await whatsappCallSession.initiateOutboundCall(conversationId);
|
||||
// The composable returns { status: 'locked' } when an init is already in
|
||||
// flight or a call is already active; treat that as a soft no-op rather than
|
||||
// claiming success.
|
||||
if (response?.status === 'locked') return;
|
||||
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();
|
||||
// Stay non-active until the connect cable event arrives — flipping to active
|
||||
// here would start the duration timer before the contact picks up.
|
||||
callsStore.addCall({
|
||||
callSid: response.call_id,
|
||||
callId: response.id,
|
||||
conversationId,
|
||||
inboxId,
|
||||
callDirection: 'outbound',
|
||||
provider: 'whatsapp',
|
||||
});
|
||||
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
navigateToConversation(conversationId);
|
||||
};
|
||||
|
||||
const startCall = async (inboxId, conversationIdHint = null) => {
|
||||
if (isInitiatingCall.value) return;
|
||||
|
||||
const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
|
||||
if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
|
||||
try {
|
||||
await startWhatsappCall(inboxId, conversationIdHint);
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await store.dispatch('contacts/initiateCall', {
|
||||
contactId: props.contactId,
|
||||
inboxId,
|
||||
conversationId: conversationIdHint,
|
||||
});
|
||||
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||
|
||||
// Add call to store immediately so widget shows
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
@@ -86,6 +162,22 @@ const startCall = async inboxId => {
|
||||
};
|
||||
|
||||
const onClick = async () => {
|
||||
// In conversation context, only stay in this conversation if its inbox is
|
||||
// itself voice-capable (works the same for Twilio and WhatsApp). For
|
||||
// non-voice channels (email, web, …) fall back to the picker so the call
|
||||
// goes out via a voice inbox.
|
||||
if (props.conversationId) {
|
||||
const conversation = store.getters.getConversationById(
|
||||
props.conversationId
|
||||
);
|
||||
const conversationInbox = (inboxesList.value || []).find(
|
||||
i => i.id === conversation?.inbox_id
|
||||
);
|
||||
if (conversationInbox && isVoiceCallEnabled(conversationInbox)) {
|
||||
await startCall(conversationInbox.id, props.conversationId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (voiceInboxes.value.length > 1) {
|
||||
dialogRef.value?.open();
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { toRef } from 'vue';
|
||||
import { computed, toRef } from 'vue';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import { useChannelIcon } from './provider';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
@@ -10,9 +11,20 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const channelIcon = useChannelIcon(toRef(props, 'inbox'));
|
||||
const hasVoiceBadge = computed(() => isVoiceCallEnabled(props.inbox));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon :icon="channelIcon" />
|
||||
<span class="relative inline-flex" v-bind="$attrs">
|
||||
<Icon :icon="channelIcon" class="size-full" />
|
||||
<span
|
||||
v-if="hasVoiceBadge"
|
||||
class="absolute -top-1 -right-1 inline-flex items-center justify-center size-2.5 rounded-full bg-n-alpha-2 ring-1 ring-n-solid-1"
|
||||
>
|
||||
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-10" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { computed } from 'vue';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
|
||||
export function useChannelIcon(inbox) {
|
||||
const channelTypeIconMap = {
|
||||
@@ -38,8 +37,15 @@ export function useChannelIcon(inbox) {
|
||||
icon = 'i-woot-whatsapp';
|
||||
}
|
||||
|
||||
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
|
||||
if (isVoiceCallEnabled(inboxDetails)) {
|
||||
// Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium)
|
||||
// is presented as a Voice channel, so show the phone icon.
|
||||
const voiceEnabled =
|
||||
inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
|
||||
if (
|
||||
type === 'Channel::TwilioSms' &&
|
||||
voiceEnabled &&
|
||||
inboxDetails.medium !== 'whatsapp'
|
||||
) {
|
||||
icon = 'i-woot-voice';
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@ import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { VOICE_CALL_STATUS } from '../constants';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import { VOICE_CALL_STATUS, MESSAGE_TYPES } from '../constants';
|
||||
import { useCallActions } from 'dashboard/composables/useCallSession';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { formatDuration } from 'shared/helpers/timeHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
@@ -17,39 +20,50 @@ const LABEL_MAP = {
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||
};
|
||||
|
||||
const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
|
||||
[VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call-bold',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold',
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { call, conversationId, currentUserId, inboxId } = useMessageContext();
|
||||
const {
|
||||
call,
|
||||
attachments,
|
||||
contentAttributes,
|
||||
conversationId,
|
||||
currentUserId,
|
||||
inboxId,
|
||||
sender,
|
||||
messageType,
|
||||
} = useMessageContext();
|
||||
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
|
||||
useCallSession();
|
||||
useCallActions();
|
||||
const whatsappCallSession = useWhatsappCallSession();
|
||||
|
||||
const status = computed(() => call.value?.status);
|
||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||
// Server-side call records use `outgoing`/`incoming`, while the Pinia store
|
||||
// and a few API hops normalise to `outbound`/`inbound`. Accept either so the
|
||||
// bubble label matches the message orientation no matter the source.
|
||||
const isOutbound = computed(() => {
|
||||
const dir = call.value?.direction;
|
||||
if (dir === 'outgoing' || dir === 'outbound') return true;
|
||||
if (dir === 'incoming' || dir === 'inbound') return false;
|
||||
// Fall back to the message orientation: agent-authored messages sit on the
|
||||
// right (outbound) and contact-authored ones on the left.
|
||||
return messageType.value === MESSAGE_TYPES.OUTGOING;
|
||||
});
|
||||
const isWhatsapp = computed(() => call.value?.provider === 'whatsapp');
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
|
||||
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
|
||||
const didCurrentUserAnswer = computed(
|
||||
() =>
|
||||
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
|
||||
);
|
||||
// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
|
||||
// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
|
||||
// Twilio's call-status webhook flipped the call to in-progress before the
|
||||
// participant-join webhook claimed it).
|
||||
const conversationAssignee = computed(() => {
|
||||
const conversation = store.getters.getConversationById?.(
|
||||
conversationId?.value
|
||||
@@ -66,6 +80,19 @@ const displayAgentName = computed(() => {
|
||||
return conversationAssignee.value?.name || null;
|
||||
});
|
||||
|
||||
const audioAttachment = computed(() =>
|
||||
(attachments?.value || []).find(a => a.fileType === 'audio')
|
||||
);
|
||||
|
||||
const durationSeconds = computed(() => {
|
||||
const fromCall = call.value?.durationSeconds || call.value?.duration_seconds;
|
||||
if (fromCall != null) return fromCall;
|
||||
const data = contentAttributes?.value?.data;
|
||||
return data?.durationSeconds || data?.duration_seconds;
|
||||
});
|
||||
|
||||
const formattedDuration = computed(() => formatDuration(durationSeconds.value));
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
@@ -73,24 +100,25 @@ const labelKey = computed(() => {
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
if (isFailed.value) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL'
|
||||
: 'CONVERSATION.VOICE_CALL.MISSED_CALL';
|
||||
}
|
||||
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const formattedDuration = computed(() =>
|
||||
formatDuration(call.value?.durationSeconds)
|
||||
);
|
||||
|
||||
const subtext = computed(() => {
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
return isOutbound.value
|
||||
? t('CONVERSATION.VOICE_CALL.CALLING')
|
||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||
return formattedDuration.value;
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
if (isOutbound.value) return null;
|
||||
if (didCurrentUserAnswer.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
}
|
||||
@@ -99,34 +127,45 @@ const subtext = computed(() => {
|
||||
agentName: displayAgentName.value,
|
||||
});
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
return null;
|
||||
}
|
||||
return isFailed.value
|
||||
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
|
||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
if (isFailed.value) {
|
||||
return isOutbound.value
|
||||
? t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT')
|
||||
: t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
return isOutbound.value
|
||||
? 'i-ph-phone-outgoing-bold'
|
||||
: 'i-ph-phone-incoming-bold';
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
// Subtle icon container — matches the design's tonal swatch over the bubble bg.
|
||||
// Status drives the accent: teal for live, ruby for missed, neutral otherwise.
|
||||
const iconContainerClass = computed(() => {
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return 'bg-n-teal-3 text-n-teal-11';
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return 'bg-n-teal-3 text-n-teal-11';
|
||||
}
|
||||
if (isMissedInbound.value) {
|
||||
return 'bg-n-alpha-2 text-n-ruby-9';
|
||||
}
|
||||
return 'bg-n-alpha-2 text-n-slate-12';
|
||||
});
|
||||
|
||||
const callSid = computed(() => call.value?.providerCallId);
|
||||
|
||||
// Show "Join call" when the call is still ringing, no agent has claimed it,
|
||||
// and the conversation is unassigned or assigned to the current user. Mirrors
|
||||
// the eligibility used by FloatingCallWidget so the bubble can act as a
|
||||
// recovery affordance after a refresh or missed widget.
|
||||
const canJoinCall = computed(() => {
|
||||
if (status.value !== VOICE_CALL_STATUS.RINGING) return false;
|
||||
if (isOutbound.value) return false;
|
||||
if (acceptedByAgentId.value) return false;
|
||||
if (!callSid.value || !inboxId.value || !conversationId.value) return false;
|
||||
// Suppress the button once this call is the local active session — the
|
||||
// message status webhook may lag behind, so we can't rely on `status` alone
|
||||
// to hide it after a successful join from this client.
|
||||
if (hasActiveCall.value && activeCall.value?.callSid === callSid.value)
|
||||
return false;
|
||||
const assignee = conversationAssignee.value;
|
||||
@@ -135,6 +174,7 @@ const canJoinCall = computed(() => {
|
||||
});
|
||||
|
||||
const recordingAttachment = computed(() => {
|
||||
if (audioAttachment.value) return audioAttachment.value;
|
||||
const url = call.value?.recordingUrl;
|
||||
if (!url) return null;
|
||||
return {
|
||||
@@ -162,48 +202,102 @@ const handleJoinCall = async () => {
|
||||
callSid: callSid.value,
|
||||
});
|
||||
};
|
||||
|
||||
const canCallBack = computed(
|
||||
() => isMissedInbound.value && !!inboxId.value && !!conversationId.value
|
||||
);
|
||||
|
||||
const handleCallBack = async () => {
|
||||
if (!canCallBack.value) return;
|
||||
try {
|
||||
if (isWhatsapp.value) {
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
conversationId.value
|
||||
);
|
||||
if (response?.status === 'locked') return;
|
||||
if (response?.call_id) {
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid: response.call_id,
|
||||
callId: response.id,
|
||||
conversationId: conversationId.value,
|
||||
inboxId: inboxId.value,
|
||||
callDirection: 'outbound',
|
||||
provider: 'whatsapp',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await store.dispatch('contacts/initiateCall', {
|
||||
contactId: sender.value?.id,
|
||||
inboxId: inboxId.value,
|
||||
conversationId: conversationId.value,
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-0 border-none" hide-meta>
|
||||
<div class="flex overflow-hidden flex-col w-full max-w-sm">
|
||||
<div class="flex gap-3 items-center p-3 w-full">
|
||||
<BaseBubble class="!p-3 !max-w-md min-w-[240px]" hide-meta>
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
<!-- Header row: icon + title + duration/subtext -->
|
||||
<div class="flex gap-2.5 items-start">
|
||||
<div
|
||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||
:class="bgColor"
|
||||
class="flex justify-center items-center rounded-xl size-11 shrink-0"
|
||||
:class="iconContainerClass"
|
||||
>
|
||||
<Icon
|
||||
class="size-5"
|
||||
:icon="iconName"
|
||||
:class="{
|
||||
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
|
||||
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
|
||||
}"
|
||||
/>
|
||||
<Icon class="size-4" :icon="iconName" />
|
||||
</div>
|
||||
|
||||
<div class="flex overflow-hidden flex-col flex-grow">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
<div class="flex flex-col flex-1 min-w-0 self-center">
|
||||
<span
|
||||
class="font-display text-sm font-medium leading-tight truncate tracking-tight"
|
||||
>
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span v-if="subtext" class="text-xs text-n-slate-11">
|
||||
<span
|
||||
v-if="subtext"
|
||||
class="text-sm leading-tight truncate tracking-tight opacity-75"
|
||||
>
|
||||
{{ subtext }}
|
||||
</span>
|
||||
<button
|
||||
v-if="canJoinCall"
|
||||
type="button"
|
||||
class="p-0 mt-1 text-xs font-medium text-start text-n-teal-10 hover:text-n-teal-11 disabled:opacity-50"
|
||||
:disabled="isJoining"
|
||||
@click="handleJoinCall"
|
||||
>
|
||||
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="recordingAttachment" class="px-3 pb-3">
|
||||
<AudioChip :attachment="recordingAttachment" />
|
||||
</div>
|
||||
<!-- Audio player (when there's a recording) -->
|
||||
<AudioChip
|
||||
v-if="recordingAttachment"
|
||||
:attachment="recordingAttachment"
|
||||
show-transcribed-text
|
||||
/>
|
||||
|
||||
<!-- Call back button (missed inbound) -->
|
||||
<button
|
||||
v-if="canCallBack"
|
||||
type="button"
|
||||
class="flex justify-center items-center gap-2 px-3 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||
@click="handleCallBack"
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-bold" />
|
||||
<span class="text-sm font-medium text-white tracking-tight">
|
||||
{{ $t('CONVERSATION.VOICE_CALL.CALL_BACK') }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Join call button (ringing inbound) -->
|
||||
<button
|
||||
v-if="canJoinCall"
|
||||
type="button"
|
||||
class="flex justify-center items-center gap-2 px-3 h-10 bg-n-teal-9 hover:bg-n-teal-10 disabled:opacity-50 rounded-full transition-colors"
|
||||
:disabled="isJoining"
|
||||
@click="handleJoinCall"
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-bold" />
|
||||
<span class="text-sm font-medium text-white tracking-tight">
|
||||
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
@@ -41,8 +41,33 @@ const playbackSpeed = ref(1);
|
||||
|
||||
const { uid } = getCurrentInstance();
|
||||
|
||||
// MediaRecorder-produced WebM/Opus blobs lack a Duration header → <audio>.duration
|
||||
// resolves to Infinity until we seek past the end, which forces the engine to
|
||||
// scan the file and compute the real length. Safe no-op for files with a real
|
||||
// duration already (mp3/m4a/etc).
|
||||
const resolveStreamingDuration = () => {
|
||||
const el = audioPlayer.value;
|
||||
if (!el) return;
|
||||
const onTimeUpdate = () => {
|
||||
el.removeEventListener('timeupdate', onTimeUpdate);
|
||||
el.currentTime = 0;
|
||||
duration.value = el.duration;
|
||||
};
|
||||
el.addEventListener('timeupdate', onTimeUpdate);
|
||||
try {
|
||||
el.currentTime = Number.MAX_SAFE_INTEGER;
|
||||
} catch {
|
||||
el.removeEventListener('timeupdate', onTimeUpdate);
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
duration.value = audioPlayer.value?.duration;
|
||||
const d = audioPlayer.value?.duration;
|
||||
if (!Number.isFinite(d)) {
|
||||
resolveStreamingDuration();
|
||||
return;
|
||||
}
|
||||
duration.value = d;
|
||||
};
|
||||
|
||||
const playbackSpeedLabel = computed(() => {
|
||||
@@ -53,7 +78,8 @@ const playbackSpeedLabel = computed(() => {
|
||||
// When the onLoadMetadata is called, so we need to set the duration
|
||||
// value when the component is mounted
|
||||
onMounted(() => {
|
||||
duration.value = audioPlayer.value?.duration;
|
||||
const d = audioPlayer.value?.duration;
|
||||
if (Number.isFinite(d)) duration.value = d;
|
||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasVoiceBadge: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -38,10 +42,18 @@ const { t } = useI18n();
|
||||
'cursor-not-allowed disabled:opacity-80': isComingSoon,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex size-10 items-center justify-center rounded-full bg-n-alpha-2"
|
||||
>
|
||||
<Icon :icon="icon" class="text-n-slate-10 size-6" />
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex size-10 items-center justify-center rounded-full bg-n-alpha-2"
|
||||
>
|
||||
<Icon :icon="icon" class="text-n-slate-10 size-6" />
|
||||
</div>
|
||||
<div
|
||||
v-if="hasVoiceBadge"
|
||||
class="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-n-alpha-2 ring-2 ring-n-solid-1"
|
||||
>
|
||||
<Icon icon="i-lucide-audio-lines" class="text-n-slate-10 size-2.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-1.5">
|
||||
|
||||
@@ -81,6 +81,13 @@ const isBeta = computed(() => {
|
||||
return ['tiktok', 'voice'].includes(props.channel.key);
|
||||
});
|
||||
|
||||
const hasVoiceBadge = computed(() => {
|
||||
return (
|
||||
['voice', 'whatsapp'].includes(props.channel.key) &&
|
||||
!!props.enabledFeatures.channel_voice
|
||||
);
|
||||
});
|
||||
|
||||
const onItemClick = () => {
|
||||
if (isActive.value) {
|
||||
emit('channelItemClick', props.channel.key);
|
||||
@@ -95,6 +102,7 @@ const onItemClick = () => {
|
||||
:icon="channel.icon"
|
||||
:is-coming-soon="isComingSoon"
|
||||
:is-beta="isBeta"
|
||||
:has-voice-badge="hasVoiceBadge"
|
||||
:disabled="!isActive"
|
||||
@click="onItemClick"
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<script setup>
|
||||
import { watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CallCard from 'dashboard/components/widgets/call/CallCard.vue';
|
||||
import countriesList from 'shared/constants/countries.js';
|
||||
|
||||
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
@@ -21,19 +27,89 @@ const {
|
||||
formattedCallDuration,
|
||||
} = useCallSession();
|
||||
|
||||
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
|
||||
// don't expose a mic track on the browser side.
|
||||
const isMuted = ref(false);
|
||||
const isWhatsappActive = computed(
|
||||
() => activeCall.value?.provider === 'whatsapp'
|
||||
);
|
||||
|
||||
const mainCardState = computed(() => {
|
||||
if (hasActiveCall.value) return 'ongoing';
|
||||
const direction = incomingCalls.value[0]?.callDirection;
|
||||
return direction === 'outbound' ? 'outgoing' : 'incoming';
|
||||
});
|
||||
|
||||
const toggleMute = () => {
|
||||
isMuted.value = !isMuted.value;
|
||||
setWhatsappCallMuted(isMuted.value);
|
||||
};
|
||||
|
||||
watch(hasActiveCall, active => {
|
||||
if (!active) isMuted.value = false;
|
||||
});
|
||||
|
||||
// Convert ISO 3166-1 alpha-2 country code (e.g. "US") to its regional indicator
|
||||
// flag emoji. Returns empty string if the code is missing or malformed.
|
||||
const countryCodeToFlag = code => {
|
||||
if (!code || code.length !== 2) return '';
|
||||
const base = 0x1f1e6;
|
||||
const offset = 'A'.charCodeAt(0);
|
||||
return String.fromCodePoint(
|
||||
...code
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(c => base + (c.charCodeAt(0) - offset))
|
||||
);
|
||||
};
|
||||
|
||||
const getCallInfo = call => {
|
||||
const conversation = store.getters.getConversationById(call?.conversationId);
|
||||
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
|
||||
const sender = conversation?.meta?.sender;
|
||||
// Inbound WhatsApp calls stash caller info on the call record (from the cable
|
||||
// payload) so the widget has something to show before the conversation lands.
|
||||
const caller = call?.caller;
|
||||
const additional = sender?.additional_attributes || {};
|
||||
const city = additional.city || '';
|
||||
const countryCode = additional.country_code || '';
|
||||
const country =
|
||||
additional.country ||
|
||||
countriesList.find(c => c.id === countryCode.toUpperCase())?.name ||
|
||||
'';
|
||||
// Prefer the richest available location string ("City, Country"); fall back to
|
||||
// whichever single field is present; finally fall back to the inbox name so
|
||||
// there's always something to show.
|
||||
const locationParts = [city, country].filter(Boolean);
|
||||
const location =
|
||||
locationParts.join(', ') || inbox?.name || 'Customer support';
|
||||
return {
|
||||
conversation,
|
||||
inbox,
|
||||
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
|
||||
contactName:
|
||||
sender?.name ||
|
||||
sender?.phone_number ||
|
||||
caller?.name ||
|
||||
caller?.phone ||
|
||||
'Unknown caller',
|
||||
phoneNumber: sender?.phone_number || caller?.phone || '',
|
||||
inboxName: inbox?.name || 'Customer support',
|
||||
avatar: sender?.avatar || sender?.thumbnail,
|
||||
location,
|
||||
countryFlag: countryCodeToFlag(countryCode),
|
||||
hasLocation: locationParts.length > 0,
|
||||
avatar: sender?.avatar || sender?.thumbnail || caller?.avatar,
|
||||
};
|
||||
};
|
||||
|
||||
const goToConversation = call => {
|
||||
const conversationId = call?.conversationId;
|
||||
const accountId = route.params.accountId;
|
||||
if (!conversationId || !accountId) return;
|
||||
router.push({
|
||||
path: frontendURL(conversationUrl({ accountId, id: conversationId })),
|
||||
});
|
||||
};
|
||||
|
||||
const handleEndCall = async () => {
|
||||
const call = activeCall.value;
|
||||
if (!call) return;
|
||||
@@ -49,21 +125,22 @@ const handleEndCall = async () => {
|
||||
};
|
||||
|
||||
const handleJoinCall = async call => {
|
||||
if (!call || isJoining.value) return;
|
||||
const { conversation } = getCallInfo(call);
|
||||
if (!call || !conversation || isJoining.value) return;
|
||||
|
||||
// End current active call before joining new one
|
||||
if (hasActiveCall.value) {
|
||||
await handleEndCall();
|
||||
}
|
||||
|
||||
// The conversation may not be hydrated yet (post-refresh seeding path);
|
||||
// call.inboxId already carries what joinCall needs.
|
||||
const result = await joinCall({
|
||||
conversationId: call.conversationId,
|
||||
inboxId: conversation.inbox_id,
|
||||
inboxId: call.inboxId || conversation?.inbox_id,
|
||||
callSid: call.callSid,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
if (result && conversation) {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: call.conversationId },
|
||||
@@ -71,12 +148,15 @@ const handleJoinCall = async call => {
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-join outbound calls when window is visible
|
||||
// Auto-join outbound calls when window is visible. WhatsApp outbound has no
|
||||
// separate join step (the offer was sent at initiate time and the answer is
|
||||
// applied directly by the cable handler), so this only covers Twilio.
|
||||
watch(
|
||||
() => incomingCalls.value[0],
|
||||
call => {
|
||||
if (
|
||||
call?.callDirection === 'outbound' &&
|
||||
call?.provider !== 'whatsapp' &&
|
||||
!hasActiveCall.value &&
|
||||
WindowVisibilityHelper.isWindowVisible()
|
||||
) {
|
||||
@@ -85,101 +165,70 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Loop the ringtone while an inbound call is unanswered. Stop the moment any
|
||||
// call is active (we joined), every inbound call cleared, or the widget tears
|
||||
// down. Browser autoplay may reject the first play() if the tab has no prior
|
||||
// user gesture; that's fine — the visual widget still surfaces the call.
|
||||
const ringtone = new Audio(RINGTONE_URL);
|
||||
ringtone.loop = true;
|
||||
ringtone.volume = 1;
|
||||
|
||||
const stopRingtone = () => {
|
||||
ringtone.pause();
|
||||
ringtone.currentTime = 0;
|
||||
};
|
||||
|
||||
const ringingInbound = computed(() =>
|
||||
incomingCalls.value.some(call => call.callDirection !== 'outbound')
|
||||
);
|
||||
|
||||
watch(
|
||||
() => ringingInbound.value && !hasActiveCall.value,
|
||||
shouldRing => {
|
||||
if (shouldRing) {
|
||||
ringtone.play().catch(() => {});
|
||||
} else {
|
||||
stopRingtone();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(stopRingtone);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="incomingCalls.length || hasActiveCall"
|
||||
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-72"
|
||||
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-3 w-[400px]"
|
||||
>
|
||||
<!-- Incoming Calls (shown above active call) -->
|
||||
<div
|
||||
<CallCard
|
||||
v-for="call in hasActiveCall ? incomingCalls : []"
|
||||
:key="call.callSid"
|
||||
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||
>
|
||||
<div class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex">
|
||||
<Avatar
|
||||
:src="getCallInfo(call).avatar"
|
||||
:name="getCallInfo(call).contactName"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||
{{ getCallInfo(call).contactName }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11 truncate">
|
||||
{{ getCallInfo(call).inboxName }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||
@click="dismissCall(call.callSid)"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||
</button>
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||
@click="handleJoinCall(call)"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
:call="call"
|
||||
state="incoming"
|
||||
:call-info="getCallInfo(call)"
|
||||
@accept="handleJoinCall(call)"
|
||||
@reject="dismissCall(call.callSid)"
|
||||
@go-to-conversation="goToConversation(call)"
|
||||
/>
|
||||
|
||||
<!-- Main Call Widget -->
|
||||
<div
|
||||
<CallCard
|
||||
v-if="hasActiveCall || incomingCalls.length"
|
||||
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||
>
|
||||
<div
|
||||
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
|
||||
:class="{ 'animate-pulse': !hasActiveCall }"
|
||||
>
|
||||
<Avatar
|
||||
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
|
||||
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
|
||||
</p>
|
||||
<p v-if="hasActiveCall" class="font-mono text-sm text-n-teal-9">
|
||||
{{ formattedCallDuration }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-n-slate-11">
|
||||
{{
|
||||
incomingCalls[0]?.callDirection === 'outbound'
|
||||
? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||
@click="
|
||||
hasActiveCall
|
||||
? handleEndCall()
|
||||
: rejectIncomingCall(incomingCalls[0]?.callSid)
|
||||
"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!hasActiveCall"
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||
@click="handleJoinCall(incomingCalls[0])"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
:call="activeCall || incomingCalls[0]"
|
||||
:state="mainCardState"
|
||||
:call-info="getCallInfo(activeCall || incomingCalls[0])"
|
||||
:duration="hasActiveCall ? formattedCallDuration : ''"
|
||||
:is-muted="isMuted"
|
||||
:show-mute="hasActiveCall && isWhatsappActive"
|
||||
@accept="handleJoinCall(incomingCalls[0])"
|
||||
@reject="rejectIncomingCall(incomingCalls[0]?.callSid)"
|
||||
@end="handleEndCall"
|
||||
@toggle-mute="toggleMute"
|
||||
@go-to-conversation="goToConversation(activeCall || incomingCalls[0])"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
call: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
callInfo: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
// 'incoming' | 'outgoing' | 'ongoing'
|
||||
state: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
duration: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isMuted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showMute: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['accept', 'reject', 'end', 'toggleMute', 'goToConversation']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isOngoing = computed(() => props.state === 'ongoing');
|
||||
const isIncoming = computed(() => props.state === 'incoming');
|
||||
const isOutgoing = computed(() => props.state === 'outgoing');
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
if (isOngoing.value) return 'i-ph-phone-call-bold';
|
||||
if (isOutgoing.value) return 'i-ph-phone-outgoing-bold';
|
||||
return 'i-ph-phone-incoming-bold';
|
||||
});
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (isOngoing.value) return t('CONVERSATION.VOICE_WIDGET.CALL_IN_PROGRESS');
|
||||
if (isOutgoing.value) return t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL');
|
||||
return t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL');
|
||||
});
|
||||
|
||||
const channelIcon = computed(() => {
|
||||
if (props.call?.provider === 'whatsapp') return 'i-ri-whatsapp-fill';
|
||||
return 'i-ph-phone-bold';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-3 pt-4 pb-4 bg-n-solid-2/95 rounded-2xl shadow-xl outline outline-1 outline-n-strong backdrop-blur-md"
|
||||
>
|
||||
<!-- Top section: status badge + location/inbox + duration -->
|
||||
<div class="flex flex-col gap-3 pb-2">
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<!-- Ongoing: status badge on left -->
|
||||
<div v-if="isOngoing" class="flex items-center gap-1.5 shrink-0">
|
||||
<i class="text-sm text-n-teal-9" :class="statusIcon" />
|
||||
<span class="text-xs font-medium text-n-teal-9 tracking-tight">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Caller location (city, country) or fallback to channel + inbox name -->
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span
|
||||
v-if="callInfo.hasLocation && callInfo.countryFlag"
|
||||
class="text-sm leading-none shrink-0"
|
||||
>
|
||||
{{ callInfo.countryFlag }}
|
||||
</span>
|
||||
<i
|
||||
v-else-if="!isOngoing"
|
||||
class="text-sm text-n-slate-10 shrink-0"
|
||||
:class="channelIcon"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-medium text-n-slate-11 tracking-tight truncate"
|
||||
>
|
||||
{{ callInfo.location }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Ongoing: duration on right -->
|
||||
<p
|
||||
v-if="isOngoing"
|
||||
class="font-display text-base font-medium text-n-slate-11 shrink-0 mb-0 tabular-nums tracking-tight"
|
||||
>
|
||||
{{ duration }}
|
||||
</p>
|
||||
<!-- Incoming/Outgoing: status badge on right -->
|
||||
<div v-else class="flex items-center gap-1.5 shrink-0">
|
||||
<i class="text-sm text-n-teal-9" :class="statusIcon" />
|
||||
<span class="text-xs font-medium text-n-teal-9 tracking-tight">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main row: avatar + name/phone + actions -->
|
||||
<div class="flex items-end gap-3 px-4">
|
||||
<div class="shrink-0">
|
||||
<Avatar
|
||||
:src="callInfo.avatar"
|
||||
:name="callInfo.contactName"
|
||||
:size="44"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="font-display text-sm font-medium text-n-slate-12 truncate mb-0.5 tracking-tight leading-tight"
|
||||
>
|
||||
{{ callInfo.contactName }}
|
||||
</p>
|
||||
<p
|
||||
v-if="callInfo.phoneNumber"
|
||||
class="text-sm text-n-slate-11 truncate mb-0 tracking-tight leading-tight"
|
||||
>
|
||||
{{ callInfo.phoneNumber }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<!-- Mute toggle (WhatsApp ongoing only) -->
|
||||
<button
|
||||
v-if="isOngoing && showMute"
|
||||
v-tooltip.top="
|
||||
isMuted
|
||||
? $t('CONVERSATION.VOICE_WIDGET.UNMUTE')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.MUTE')
|
||||
"
|
||||
class="flex justify-center items-center w-10 h-10 rounded-full transition-colors"
|
||||
:class="
|
||||
isMuted
|
||||
? 'bg-n-amber-9 hover:bg-n-amber-10 text-white'
|
||||
: 'bg-n-teal-3 hover:bg-n-teal-4 text-n-teal-11'
|
||||
"
|
||||
@click="$emit('toggleMute')"
|
||||
>
|
||||
<i
|
||||
class="text-base"
|
||||
:class="
|
||||
isMuted ? 'i-ph-microphone-slash-bold' : 'i-ph-microphone-bold'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Accept call (incoming only) -->
|
||||
<button
|
||||
v-if="isIncoming"
|
||||
v-tooltip.top="$t('CONVERSATION.VOICE_WIDGET.JOIN_CALL')"
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors shadow-sm"
|
||||
@click="$emit('accept')"
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-bold" />
|
||||
</button>
|
||||
|
||||
<!-- Reject / end call (all states) -->
|
||||
<button
|
||||
v-tooltip.top="
|
||||
isOngoing
|
||||
? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
|
||||
"
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors shadow-sm"
|
||||
@click="isOngoing ? $emit('end') : $emit('reject')"
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-x-bold rotate-[134deg]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: go to conversation thread -->
|
||||
<button
|
||||
v-if="call?.conversationId"
|
||||
class="flex items-center justify-between gap-2 px-4 pt-3 border-t border-n-strong hover:bg-n-alpha-1 transition-colors group"
|
||||
@click="$emit('goToConversation')"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-n-slate-11 tracking-tight group-hover:text-n-slate-12"
|
||||
>
|
||||
{{ $t('CONVERSATION.VOICE_WIDGET.GO_TO_CONVERSATION') }}
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1 text-n-slate-11 group-hover:text-n-slate-12"
|
||||
>
|
||||
<i class="text-sm i-ph-chat-circle-text-bold" />
|
||||
<span class="text-sm tracking-tight tabular-nums">
|
||||
#{{ call.conversationId }}
|
||||
</span>
|
||||
<i class="text-xs i-ph-caret-right-bold" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import BackButton from '../BackButton.vue';
|
||||
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import MoreActions from './MoreActions.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
@@ -12,9 +13,15 @@ import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import {
|
||||
getVoiceCallProvider,
|
||||
VOICE_CALL_PROVIDERS,
|
||||
} from 'dashboard/helper/inbox';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
@@ -94,6 +101,56 @@ const hasMultipleInboxes = computed(
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
const whatsappCallSession = useWhatsappCallSession();
|
||||
|
||||
const isWhatsappVoiceInbox = computed(
|
||||
() => getVoiceCallProvider(inbox.value) === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||
);
|
||||
|
||||
const isWhatsappCallButtonDisabled = computed(
|
||||
() =>
|
||||
whatsappCallSession.isInitiating.value ||
|
||||
callsStore.hasActiveCall ||
|
||||
callsStore.hasIncomingCall
|
||||
);
|
||||
|
||||
const startWhatsappCall = async () => {
|
||||
if (whatsappCallSession.isInitiating.value) return;
|
||||
try {
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
currentChat.value.id
|
||||
);
|
||||
|
||||
// Composable returns { status: 'locked' } when init is already in flight or
|
||||
// a call is active; soft no-op so a parallel click doesn't trigger a banner.
|
||||
if (response?.status === 'locked') return;
|
||||
// Permission template path returns no call id — show banner, no widget yet.
|
||||
if (!response?.id) {
|
||||
const status = response?.status;
|
||||
const messageKey =
|
||||
status === 'permission_pending'
|
||||
? 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING'
|
||||
: 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED';
|
||||
useAlert(t(messageKey));
|
||||
return;
|
||||
}
|
||||
|
||||
// Stay non-active until Meta delivers the connect webhook (sdp_answer);
|
||||
// flipping to active here would start the duration timer before pickup.
|
||||
callsStore.addCall({
|
||||
callSid: response.call_id,
|
||||
callId: response.id,
|
||||
conversationId: currentChat.value.id,
|
||||
inboxId: inbox.value?.id,
|
||||
callDirection: 'outbound',
|
||||
provider: 'whatsapp',
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CONVERSATION.HEADER.WHATSAPP_CALL_FAILED'));
|
||||
}
|
||||
};
|
||||
|
||||
const copyConversationId = async () => {
|
||||
try {
|
||||
await copyTextToClipboard(String(props.chat.id));
|
||||
@@ -172,6 +229,18 @@ const copyConversationId = async () => {
|
||||
:parent-width="width"
|
||||
class="hidden md:flex"
|
||||
/>
|
||||
<ButtonV4
|
||||
v-if="isWhatsappVoiceInbox"
|
||||
v-tooltip.bottom="$t('CONVERSATION.HEADER.WHATSAPP_CALL')"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
icon="i-lucide-phone"
|
||||
:is-loading="whatsappCallSession.isInitiating.value"
|
||||
:disabled="isWhatsappCallButtonDisabled"
|
||||
class="rounded-md hover:bg-n-alpha-2"
|
||||
@click="startWhatsappCall"
|
||||
/>
|
||||
<MoreActions :conversation-id="currentChat.id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,62 +1,133 @@
|
||||
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import {
|
||||
useWhatsappCallSession,
|
||||
sendWhatsappTerminateBeacon,
|
||||
cleanupWhatsappSession,
|
||||
} from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { handleVoiceCallCreated } from 'dashboard/helper/voice';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
|
||||
export function useCallSession() {
|
||||
const callsStore = useCallsStore();
|
||||
const { t } = useI18n();
|
||||
const isJoining = ref(false);
|
||||
const callDuration = ref(0);
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
callDuration.value = elapsed;
|
||||
const isWhatsappCall = call => call?.provider === 'whatsapp';
|
||||
// Calls seeded after a refresh / arriving via message.updated may lack provider
|
||||
// metadata. Treat anything that has a callId (Meta's wacid) as WhatsApp — the
|
||||
// Twilio path keys off provider_call_id (a CA…) and never sets callId.
|
||||
const isWhatsappLikeCall = call => isWhatsappCall(call) || !!call?.callId;
|
||||
|
||||
// Dismissed call sids must not be re-seeded by the conversation-load watcher.
|
||||
// Lives at module scope so all consumers share the same set.
|
||||
const dismissedCallSids = new Set();
|
||||
const markDismissed = callSid => {
|
||||
if (callSid) dismissedCallSids.add(callSid);
|
||||
};
|
||||
|
||||
// Globals attached once across all useCallSession() consumers — bubbles in a
|
||||
// long thread call this composable many times, and a per-instance Timer +
|
||||
// window listener stack would multiply work.
|
||||
let globalsAttachedCount = 0;
|
||||
let globalDurationTimer = null;
|
||||
const globalCallDuration = ref(0);
|
||||
let storedCallsStoreRef = null;
|
||||
// Shared join lock so two surfaces (bubble + widget) clicking concurrently
|
||||
// see one in-flight join, not two unrelated isJoining refs.
|
||||
const globalIsJoining = ref(false);
|
||||
|
||||
const handleBeforeUnloadGlobal = event => {
|
||||
const store = storedCallsStoreRef;
|
||||
if (!store) return;
|
||||
if (!store.hasActiveCall && !store.hasIncomingCall) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
const handlePageHideGlobal = () => sendWhatsappTerminateBeacon();
|
||||
const handleTwilioDisconnectedGlobal = () =>
|
||||
storedCallsStoreRef?.clearActiveCall();
|
||||
|
||||
const attachGlobalsOnFirstMount = callsStore => {
|
||||
globalsAttachedCount += 1;
|
||||
if (globalsAttachedCount > 1) return;
|
||||
storedCallsStoreRef = callsStore;
|
||||
globalDurationTimer = new Timer(elapsed => {
|
||||
globalCallDuration.value = elapsed;
|
||||
});
|
||||
|
||||
const activeCall = computed(() => callsStore.activeCall);
|
||||
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||
|
||||
watch(
|
||||
hasActiveCall,
|
||||
active => {
|
||||
if (active) {
|
||||
durationTimer.start();
|
||||
} else {
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
TwilioVoiceClient.addEventListener(
|
||||
'call:disconnected',
|
||||
handleTwilioDisconnectedGlobal
|
||||
);
|
||||
window.addEventListener('beforeunload', handleBeforeUnloadGlobal);
|
||||
window.addEventListener('pagehide', handlePageHideGlobal);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
);
|
||||
});
|
||||
const detachGlobalsOnLastUnmount = () => {
|
||||
globalsAttachedCount -= 1;
|
||||
if (globalsAttachedCount > 0) return;
|
||||
globalDurationTimer?.stop();
|
||||
globalDurationTimer = null;
|
||||
globalCallDuration.value = 0;
|
||||
storedCallsStoreRef = null;
|
||||
TwilioVoiceClient.removeEventListener(
|
||||
'call:disconnected',
|
||||
handleTwilioDisconnectedGlobal
|
||||
);
|
||||
window.removeEventListener('beforeunload', handleBeforeUnloadGlobal);
|
||||
window.removeEventListener('pagehide', handlePageHideGlobal);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
durationTimer.stop();
|
||||
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
);
|
||||
});
|
||||
// Build the action surface used by both the root session composable and the
|
||||
// lighter useCallActions consumer. All state is module-scoped — the actions
|
||||
// don't depend on per-instance refs, so they're cheap to call from anywhere.
|
||||
const buildCallActions = ({ callsStore, whatsappSession, t }) => {
|
||||
const findCall = callSid => callsStore.calls.find(c => c.callSid === callSid);
|
||||
|
||||
const endCall = async ({ conversationId, inboxId, callSid }) => {
|
||||
const call = findCall(callSid);
|
||||
if (isWhatsappLikeCall(call)) {
|
||||
// Pass call.callId so a wiped module state (e.g. a prior accept attempt
|
||||
// tore down the WebRTC session) doesn't stop us hitting /terminate.
|
||||
await whatsappSession.endActiveCall(call?.callId);
|
||||
globalDurationTimer?.stop();
|
||||
callsStore.clearActiveCall();
|
||||
return;
|
||||
}
|
||||
|
||||
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
||||
TwilioVoiceClient.endClientCall();
|
||||
durationTimer.stop();
|
||||
globalDurationTimer?.stop();
|
||||
callsStore.clearActiveCall();
|
||||
};
|
||||
|
||||
const joinCall = async ({ conversationId, inboxId, callSid }) => {
|
||||
if (isJoining.value) return null;
|
||||
if (globalIsJoining.value) return null;
|
||||
|
||||
isJoining.value = true;
|
||||
const call = findCall(callSid);
|
||||
// Outbound *WhatsApp* calls have no separate join step — the offer was
|
||||
// sent at initiate time and the answer is applied by the cable handler.
|
||||
// Routing through acceptIncomingCall here would call prepareInboundAnswer →
|
||||
// cleanup() and destroy the live outbound session. Outbound *Twilio*
|
||||
// calls still need joinConference + joinClientCall (FloatingCallWidget
|
||||
// auto-joins them), so don't short-circuit those.
|
||||
if (call?.callDirection === 'outbound' && isWhatsappLikeCall(call)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
globalIsJoining.value = true;
|
||||
try {
|
||||
if (isWhatsappLikeCall(call)) {
|
||||
await whatsappSession.acceptIncomingCall({
|
||||
callId: call.callId,
|
||||
sdpOffer: call.sdpOffer,
|
||||
iceServers: call.iceServers,
|
||||
});
|
||||
callsStore.setCallActive(callSid);
|
||||
globalDurationTimer?.start();
|
||||
return { callId: call.callId };
|
||||
}
|
||||
|
||||
const device = await TwilioVoiceClient.initializeDevice(inboxId);
|
||||
if (!device) return null;
|
||||
|
||||
@@ -73,47 +144,150 @@ export function useCallSession() {
|
||||
});
|
||||
|
||||
callsStore.setCallActive(callSid);
|
||||
durationTimer.start();
|
||||
globalDurationTimer?.start();
|
||||
|
||||
return { conferenceSid: joinResponse?.conference_sid };
|
||||
} catch (error) {
|
||||
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
if (error?.response?.status === 409) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
markDismissed(callSid);
|
||||
callsStore.dismissCall(callSid);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to join call:', error);
|
||||
// Drop any half-built WebRTC state so the next click starts fresh.
|
||||
cleanupWhatsappSession();
|
||||
return null;
|
||||
} finally {
|
||||
isJoining.value = false;
|
||||
globalIsJoining.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const rejectIncomingCall = callSid => {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
callsStore.dismissCall(callSid);
|
||||
// Await provider-side reject before dismissing the local entry; if the API
|
||||
// call fails the call should stay surfaced so the agent can retry instead of
|
||||
// disappearing while the backend still rings.
|
||||
const rejectIncomingCall = async callSid => {
|
||||
const call = findCall(callSid);
|
||||
try {
|
||||
if (isWhatsappLikeCall(call) && call?.callId) {
|
||||
if (call.callDirection === 'outbound') {
|
||||
// Outbound calls that are still ringing must be terminated, not
|
||||
// rejected (reject is the inbound-side verb on Meta's API).
|
||||
await whatsappSession.endActiveCall(call.callId);
|
||||
} else {
|
||||
await whatsappSession.rejectIncomingCall(call.callId);
|
||||
}
|
||||
} else {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
}
|
||||
} finally {
|
||||
markDismissed(callSid);
|
||||
callsStore.dismissCall(callSid);
|
||||
}
|
||||
};
|
||||
|
||||
const dismissCall = callSid => {
|
||||
markDismissed(callSid);
|
||||
callsStore.dismissCall(callSid);
|
||||
};
|
||||
|
||||
return { endCall, joinCall, rejectIncomingCall, dismissCall };
|
||||
};
|
||||
|
||||
const buildReactiveSurface = callsStore => {
|
||||
const activeCall = computed(() => callsStore.activeCall);
|
||||
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||
const formattedCallDuration = computed(() => {
|
||||
const minutes = Math.floor(callDuration.value / 60);
|
||||
const seconds = callDuration.value % 60;
|
||||
const total = globalCallDuration.value;
|
||||
const minutes = Math.floor(total / 60);
|
||||
const seconds = total % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
return {
|
||||
activeCall,
|
||||
incomingCalls,
|
||||
hasActiveCall,
|
||||
isJoining,
|
||||
isJoining: globalIsJoining,
|
||||
formattedCallDuration,
|
||||
joinCall,
|
||||
endCall,
|
||||
rejectIncomingCall,
|
||||
dismissCall,
|
||||
};
|
||||
};
|
||||
|
||||
// Root-mount composable. Call once at the dashboard root (FloatingCallWidget
|
||||
// is the natural anchor — always mounted, lifetime spans the whole session).
|
||||
// This is the only path that registers global window/Twilio listeners and
|
||||
// owns the duration Timer.
|
||||
export function useCallSession() {
|
||||
const store = useStore();
|
||||
const callsStore = useCallsStore();
|
||||
const whatsappSession = useWhatsappCallSession();
|
||||
const { t } = useI18n();
|
||||
|
||||
const reactive = buildReactiveSurface(callsStore);
|
||||
|
||||
// Cable broadcasts (voice_call.incoming / message.created) are one-shot, so
|
||||
// on a hard refresh they leave the calls store empty. Seed it from any
|
||||
// ringing voice_call message in the conversation cache. Skip calls the
|
||||
// agent has already dismissed locally so they don't re-pop on the next
|
||||
// conversation update.
|
||||
const seedCallsFromHydratedMessages = () => {
|
||||
const conversations = store.getters.getAllConversations || [];
|
||||
const currentUserId = store.getters.getCurrentUserID;
|
||||
conversations.forEach(conv => {
|
||||
(conv.messages || []).forEach(msg => {
|
||||
if (msg.content_type !== 'voice_call') return;
|
||||
if (msg.call?.status !== 'ringing') return;
|
||||
const callSid = msg.call?.provider_call_id;
|
||||
if (callSid && dismissedCallSids.has(callSid)) return;
|
||||
handleVoiceCallCreated(msg, currentUserId);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
reactive.hasActiveCall,
|
||||
active => {
|
||||
if (active) {
|
||||
globalDurationTimer?.start();
|
||||
} else {
|
||||
globalDurationTimer?.stop();
|
||||
globalCallDuration.value = 0;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
attachGlobalsOnFirstMount(callsStore);
|
||||
seedCallsFromHydratedMessages();
|
||||
});
|
||||
|
||||
// Re-seed when conversations stream in after mount; addCall merges by callSid
|
||||
// and dismissed sids are filtered, so this is idempotent.
|
||||
watch(
|
||||
() => store.getters.getAllConversations?.length,
|
||||
() => seedCallsFromHydratedMessages()
|
||||
);
|
||||
|
||||
onUnmounted(() => detachGlobalsOnLastUnmount());
|
||||
|
||||
const actions = buildCallActions({ callsStore, whatsappSession, t });
|
||||
|
||||
return { ...reactive, ...actions };
|
||||
}
|
||||
|
||||
// Lightweight consumer for components that need to read state and trigger
|
||||
// actions but should NOT mount global listeners (e.g., per-message bubbles
|
||||
// rendered in a thread). Reads from the same module-level state that
|
||||
// useCallSession owns, so the duration timer and dismissed set stay coherent.
|
||||
export function useCallActions() {
|
||||
const callsStore = useCallsStore();
|
||||
const whatsappSession = useWhatsappCallSession();
|
||||
const { t } = useI18n();
|
||||
|
||||
const reactive = buildReactiveSurface(callsStore);
|
||||
const actions = buildCallActions({ callsStore, whatsappSession, t });
|
||||
|
||||
return { ...reactive, ...actions };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
import { ref } from 'vue';
|
||||
import Cookies from 'js-cookie';
|
||||
import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI';
|
||||
|
||||
// Module-level state lets the cable handlers and unload listeners reach the
|
||||
// live PeerConnection without prop-drilling refs through every composable.
|
||||
let pc = null;
|
||||
let localStream = null;
|
||||
let remoteStream = null;
|
||||
let remoteAudioEl = null;
|
||||
let mediaRecorder = null;
|
||||
let recorderChunks = [];
|
||||
let audioContext = null;
|
||||
let activeCallId = null;
|
||||
// Module-scoped so multiple composable callers (header button + contact-panel
|
||||
// button) share the same lock. A per-instance ref let two parallel callers
|
||||
// both pass the guard and tear down each other's WebRTC state in cleanup().
|
||||
// ref() so consumers can reactively gate buttons on it (the composable
|
||||
// re-exports this as `isInitiating`).
|
||||
const isInitiatingOutbound = ref(false);
|
||||
// Inbound calls record from the moment the agent clicks accept (their click =
|
||||
// pickup). Outbound calls must wait — Meta's `connect` webhook (which lands
|
||||
// during ringing) negotiates remote tracks ~20s before the contact actually
|
||||
// answers, and we don't want pre-pickup audio in the recording. This flag is
|
||||
// flipped to true by armOutboundRecorder() when the ACCEPTED status arrives.
|
||||
let recorderArmed = false;
|
||||
|
||||
const ensureRemoteAudioElement = () => {
|
||||
if (remoteAudioEl) return remoteAudioEl;
|
||||
remoteAudioEl = document.createElement('audio');
|
||||
remoteAudioEl.id = 'whatsapp-call-remote-audio';
|
||||
remoteAudioEl.autoplay = true;
|
||||
remoteAudioEl.playsInline = true;
|
||||
remoteAudioEl.style.display = 'none';
|
||||
document.body.appendChild(remoteAudioEl);
|
||||
return remoteAudioEl;
|
||||
};
|
||||
|
||||
const playRemoteStream = stream => {
|
||||
const el = ensureRemoteAudioElement();
|
||||
el.srcObject = stream;
|
||||
el.play().catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[WhatsApp Call] remote audio play() failed:', err);
|
||||
});
|
||||
};
|
||||
|
||||
// 1s timeslice keeps a recent recording chunk in memory so a remote hangup
|
||||
// that races cleanup still has data to upload.
|
||||
const RECORDING_TIMESLICE_MS = 1000;
|
||||
const ICE_GATHER_TIMEOUT_MS = 10000;
|
||||
|
||||
const RECORDER_MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg;codecs=opus',
|
||||
];
|
||||
|
||||
// Outbound calls have no backend-supplied ice_servers (the call doesn't exist
|
||||
// at offer time). Without STUN the browser only sends host candidates and
|
||||
// browser→Meta media silently drops through any non-trivial NAT.
|
||||
const DEFAULT_OUTBOUND_ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
|
||||
const waitForIceGatheringComplete = peer =>
|
||||
new Promise(resolve => {
|
||||
if (peer.iceGatheringState === 'complete') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(resolve, ICE_GATHER_TIMEOUT_MS);
|
||||
peer.addEventListener('icegatheringstatechange', () => {
|
||||
if (peer.iceGatheringState === 'complete') {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const setupRecorder = () => {
|
||||
if (!localStream || !remoteStream || mediaRecorder) return;
|
||||
// createMediaStreamSource on a stream with no audio tracks wires up to
|
||||
// nothing — the recorded mix would be silence. Wait until ontrack fires.
|
||||
if (remoteStream.getAudioTracks().length === 0) return;
|
||||
|
||||
audioContext = new AudioContext({ sampleRate: 48000 });
|
||||
// AudioContext starts suspended under most autoplay policies; without
|
||||
// resume() the destination stream produces silence.
|
||||
audioContext.resume().catch(() => {});
|
||||
|
||||
const destination = audioContext.createMediaStreamDestination();
|
||||
audioContext.createMediaStreamSource(localStream).connect(destination);
|
||||
audioContext.createMediaStreamSource(remoteStream).connect(destination);
|
||||
|
||||
const mimeType = RECORDER_MIME_CANDIDATES.find(t =>
|
||||
MediaRecorder.isTypeSupported(t)
|
||||
);
|
||||
if (!mimeType) return;
|
||||
|
||||
recorderChunks = [];
|
||||
mediaRecorder = new MediaRecorder(destination.stream, { mimeType });
|
||||
mediaRecorder.ondataavailable = event => {
|
||||
if (event.data && event.data.size > 0) recorderChunks.push(event.data);
|
||||
};
|
||||
mediaRecorder.start(RECORDING_TIMESLICE_MS);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
try {
|
||||
mediaRecorder.stop();
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
if (audioContext && audioContext.state !== 'closed') {
|
||||
audioContext.close().catch(() => {});
|
||||
}
|
||||
if (localStream) localStream.getTracks().forEach(t => t.stop());
|
||||
if (remoteStream) remoteStream.getTracks().forEach(t => t.stop());
|
||||
if (pc) pc.close();
|
||||
if (remoteAudioEl) remoteAudioEl.srcObject = null;
|
||||
|
||||
pc = null;
|
||||
localStream = null;
|
||||
remoteStream = null;
|
||||
mediaRecorder = null;
|
||||
recorderChunks = [];
|
||||
audioContext = null;
|
||||
activeCallId = null;
|
||||
recorderArmed = false;
|
||||
};
|
||||
|
||||
const buildPeerConnection = iceServers => {
|
||||
const config = iceServers && iceServers.length ? { iceServers } : {};
|
||||
pc = new RTCPeerConnection(config);
|
||||
remoteStream = new MediaStream();
|
||||
pc.ontrack = event => {
|
||||
// Reuse the same MediaStream object — the recorder's audioContext source
|
||||
// taps it once, so reassigning would orphan the recorder.
|
||||
const tracks =
|
||||
event.streams && event.streams[0]
|
||||
? event.streams[0].getTracks()
|
||||
: [event.track];
|
||||
tracks.forEach(track => {
|
||||
if (!remoteStream.getTracks().includes(track))
|
||||
remoteStream.addTrack(track);
|
||||
});
|
||||
playRemoteStream(remoteStream);
|
||||
// Only arm the recorder when the call is actually accepted. For outbound
|
||||
// this is the ACCEPTED status webhook; for inbound this is the agent's
|
||||
// own click (acceptIncomingCall flips recorderArmed before returning).
|
||||
if (recorderArmed) setupRecorder();
|
||||
};
|
||||
return pc;
|
||||
};
|
||||
|
||||
const stopRecorderAndUpload = async callId => {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
await new Promise(resolve => {
|
||||
mediaRecorder.addEventListener('stop', resolve, { once: true });
|
||||
try {
|
||||
mediaRecorder.stop();
|
||||
} catch (_) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!recorderChunks.length || !callId) return;
|
||||
|
||||
const blob = new Blob(recorderChunks, { type: recorderChunks[0].type });
|
||||
// Best-effort — the controller's idempotency guard handles a retry.
|
||||
try {
|
||||
await WhatsappCallsAPI.uploadRecording(callId, blob);
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
// devise-token-auth requires access-token / client / uid headers on every
|
||||
// request — navigator.sendBeacon can't set custom headers, so we rehydrate
|
||||
// the auth payload from the cw_d_session_info cookie that the dashboard sets
|
||||
// at login. Used by the page-close terminate path below.
|
||||
const getDeviseAuthHeaders = () => {
|
||||
try {
|
||||
const raw = Cookies.get('cw_d_session_info');
|
||||
if (!raw) return null;
|
||||
const session = JSON.parse(raw);
|
||||
return {
|
||||
'access-token': session['access-token'] || '',
|
||||
client: session.client || '',
|
||||
uid: session.uid || '',
|
||||
expiry: session.expiry || '',
|
||||
'token-type': session['token-type'] || 'Bearer',
|
||||
};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const beaconTerminate = callId => {
|
||||
if (!callId) return;
|
||||
const accountId = window.location.pathname.split('/')[3];
|
||||
if (!accountId) return;
|
||||
const headers = getDeviseAuthHeaders();
|
||||
if (!headers) return;
|
||||
const url = `/api/v1/accounts/${accountId}/whatsapp_calls/${callId}/terminate`;
|
||||
// fetch+keepalive (instead of navigator.sendBeacon) so we can attach auth
|
||||
// headers — without them devise-token-auth 401s and the call stays open on
|
||||
// Meta until its carrier-side timeout (~60s).
|
||||
try {
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: '{}',
|
||||
}).catch(() => {});
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
export const hasActiveWhatsappCall = () => !!(activeCallId || pc);
|
||||
|
||||
export const isLocalWhatsappCall = callId =>
|
||||
!!callId && activeCallId != null && callId === activeCallId;
|
||||
|
||||
export function useWhatsappCallSession() {
|
||||
const prepareInboundAnswer = async (sdpOffer, iceServers) => {
|
||||
cleanup();
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
buildPeerConnection(iceServers);
|
||||
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
return pc.localDescription.sdp;
|
||||
};
|
||||
|
||||
const prepareOutboundOffer = async () => {
|
||||
cleanup();
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
buildPeerConnection(DEFAULT_OUTBOUND_ICE_SERVERS);
|
||||
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
return pc.localDescription.sdp;
|
||||
};
|
||||
|
||||
const acceptIncomingCall = async ({ callId, sdpOffer, iceServers }) => {
|
||||
// The store may not have sdpOffer yet (the cable broadcast can race the
|
||||
// click). Fall back to GET /whatsapp_calls/:id which exposes it.
|
||||
let offer = sdpOffer;
|
||||
let ice = iceServers;
|
||||
if (!offer && callId) {
|
||||
try {
|
||||
const fresh = await WhatsappCallsAPI.show(callId);
|
||||
offer = fresh?.sdp_offer || fresh?.sdpOffer;
|
||||
ice = ice || fresh?.ice_servers || fresh?.iceServers;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
'[WhatsApp Call] failed to fetch call data for accept:',
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!offer) {
|
||||
throw new Error('Missing sdp_offer for accept — call may have ended.');
|
||||
}
|
||||
|
||||
const sdpAnswer = await prepareInboundAnswer(offer, ice);
|
||||
activeCallId = callId;
|
||||
// Inbound: agent's click is the pickup. Arm the recorder before the API
|
||||
// round-trip so when ontrack fires (triggered by setRemoteDescription
|
||||
// back in prepareInboundAnswer) the recorder is already authorized.
|
||||
recorderArmed = true;
|
||||
setupRecorder();
|
||||
await WhatsappCallsAPI.accept(callId, sdpAnswer);
|
||||
};
|
||||
|
||||
const rejectIncomingCall = async callId => {
|
||||
try {
|
||||
await WhatsappCallsAPI.reject(callId);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
const initiateOutboundCall = async conversationId => {
|
||||
// Module-scoped lock + active-session guard so a second click — from the
|
||||
// same composable instance OR a different one (header vs contact panel)
|
||||
// OR while a call is already live — can't tear down the in-flight setup
|
||||
// via prepareOutboundOffer's cleanup().
|
||||
if (isInitiatingOutbound.value) return { status: 'locked' };
|
||||
if (hasActiveWhatsappCall()) return { status: 'locked' };
|
||||
isInitiatingOutbound.value = true;
|
||||
try {
|
||||
const sdpOffer = await prepareOutboundOffer();
|
||||
const response = await WhatsappCallsAPI.initiate(
|
||||
conversationId,
|
||||
sdpOffer
|
||||
);
|
||||
if (response?.id) {
|
||||
activeCallId = response.id;
|
||||
return response;
|
||||
}
|
||||
// No call id back: this is the permission-request branch. The mic +
|
||||
// PeerConnection allocated by prepareOutboundOffer aren't useful until
|
||||
// the contact opts in and the agent retries — release them.
|
||||
cleanup();
|
||||
return response;
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
// BE returns 422 when the contact hasn't opted in (permission template
|
||||
// sent or already pending). Surface it to the caller as a normal
|
||||
// response shape so it can render the banner instead of an error toast.
|
||||
const data = e?.response?.data;
|
||||
if (
|
||||
data?.status === 'permission_requested' ||
|
||||
data?.status === 'permission_pending'
|
||||
) {
|
||||
return { status: data.status };
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
isInitiatingOutbound.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// callIdOverride is the call.id from the dashboard's calls store. Module
|
||||
// `activeCallId` may be null after a prior accept attempt's cleanup() — but
|
||||
// the call still exists on Meta and must still be terminated. Falling back
|
||||
// to the override means hangup is robust to a wiped local session.
|
||||
const endActiveCall = async (callIdOverride = null) => {
|
||||
const callId = activeCallId || callIdOverride;
|
||||
if (!callId) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await stopRecorderAndUpload(callId);
|
||||
await WhatsappCallsAPI.terminate(callId).catch(() => {});
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isInitiating: isInitiatingOutbound,
|
||||
prepareInboundAnswer,
|
||||
prepareOutboundOffer,
|
||||
acceptIncomingCall,
|
||||
rejectIncomingCall,
|
||||
initiateOutboundCall,
|
||||
endActiveCall,
|
||||
};
|
||||
}
|
||||
|
||||
// Cable handlers fire outside any composable instance, so the shared session
|
||||
// surface is exposed as module-level functions for them.
|
||||
|
||||
export const applyOutboundAnswer = async (callId, sdpAnswer) => {
|
||||
if (!pc) return;
|
||||
// voice_call.outbound_connected is broadcast account-wide; only apply
|
||||
// the SDP answer if it's for this tab's in-flight outbound call.
|
||||
if (activeCallId != null && callId !== activeCallId) return;
|
||||
activeCallId = callId;
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
|
||||
};
|
||||
|
||||
// Called by the cable handler when Meta delivers status=ACCEPTED for the
|
||||
// outbound call (real pickup). Flips the recorder gate and starts the
|
||||
// MediaRecorder. Idempotent — safe if ontrack hasn't fired yet (setupRecorder
|
||||
// bails until the remote stream has audio tracks; ontrack will retry).
|
||||
export const armOutboundRecorder = () => {
|
||||
recorderArmed = true;
|
||||
setupRecorder();
|
||||
};
|
||||
|
||||
export const cleanupWhatsappSession = () => cleanup();
|
||||
|
||||
export const handleWhatsappRemoteEnd = async callId => {
|
||||
// Snapshot before cleanup nulls activeCallId.
|
||||
const id = callId || activeCallId;
|
||||
if (!id) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await stopRecorderAndUpload(id);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
export const setWhatsappCallMuted = muted => {
|
||||
if (!localStream) return false;
|
||||
localStream.getAudioTracks().forEach(track => {
|
||||
track.enabled = !muted;
|
||||
});
|
||||
return muted;
|
||||
};
|
||||
|
||||
export const sendWhatsappTerminateBeacon = () => {
|
||||
// Always fire when there's a live callId. The beacon endpoint is idempotent,
|
||||
// so racing it with an in-flight Axios terminate (which unload may abort) is
|
||||
// fine — Meta gets exactly one terminate either way, and we avoid leaving
|
||||
// the call ringing on Meta until its carrier-side timeout.
|
||||
if (!activeCallId) return;
|
||||
beaconTerminate(activeCallId);
|
||||
};
|
||||
@@ -4,6 +4,13 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
|
||||
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,
|
||||
armOutboundRecorder,
|
||||
handleWhatsappRemoteEnd,
|
||||
isLocalWhatsappCall,
|
||||
} from 'dashboard/composables/useWhatsappCallSession';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
|
||||
@@ -35,6 +42,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
'voice_call.incoming': this.onVoiceCallIncoming,
|
||||
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
|
||||
'voice_call.outbound_accepted': this.onVoiceCallOutboundAccepted,
|
||||
'voice_call.ended': this.onVoiceCallEnded,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -205,6 +216,69 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
||||
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallIncoming = data => {
|
||||
if (data?.provider !== 'whatsapp') return;
|
||||
useCallsStore().addCall({
|
||||
callSid: data.call_id,
|
||||
callId: data.id,
|
||||
conversationId: data.conversation_id,
|
||||
inboxId: data.inbox_id,
|
||||
callDirection: 'inbound',
|
||||
provider: 'whatsapp',
|
||||
sdpOffer: data.sdp_offer,
|
||||
iceServers: data.ice_servers,
|
||||
caller: data.caller,
|
||||
});
|
||||
};
|
||||
|
||||
// `connect` is the WebRTC tunnel-ready signal (fires ~20s before pickup
|
||||
// for outbound). Apply the SDP answer so the handshake completes during
|
||||
// ringing, but stay non-active until `outbound_accepted` arrives.
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallOutboundConnected = async data => {
|
||||
if (data?.provider !== 'whatsapp' || !data.sdp_answer) return;
|
||||
// Account-wide broadcast: skip SDPs for calls another agent is handling,
|
||||
// otherwise applyOutboundAnswer would feed a foreign SDP into this tab's
|
||||
// peer connection.
|
||||
if (!isLocalWhatsappCall(data.id)) return;
|
||||
try {
|
||||
await applyOutboundAnswer(data.id, data.sdp_answer);
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
// Real pickup signal — Meta sends status=ACCEPTED on the call when the
|
||||
// contact answers. Flip active (timer starts) and arm the recorder.
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallOutboundAccepted = data => {
|
||||
if (data?.provider !== 'whatsapp') return;
|
||||
const store = useCallsStore();
|
||||
if (!store.calls.some(c => c.callSid === data.call_id)) return;
|
||||
store.setCallActive(data.call_id);
|
||||
armOutboundRecorder();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallEnded = async data => {
|
||||
if (data?.provider !== 'whatsapp') return;
|
||||
// The store entry should always be removed for this account-wide broadcast,
|
||||
// but the WebRTC/recorder teardown must only run for the call this tab owns
|
||||
// — otherwise an unrelated agent's call ending would stop this tab's
|
||||
// recorder and upload its chunks against the wrong call id.
|
||||
if (isLocalWhatsappCall(data.id)) {
|
||||
// Await upload before removeCall — the store's sync teardown would otherwise
|
||||
// wipe the recorder chunks before they reach the server.
|
||||
try {
|
||||
await handleWhatsappRemoteEnd(data.id);
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
useCallsStore().removeCall(data.call_id);
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const INBOX_TYPES = {
|
||||
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
||||
export const VOICE_CALL_PROVIDERS = {
|
||||
TWILIO: 'twilio',
|
||||
WHATSAPP: 'whatsapp',
|
||||
};
|
||||
|
||||
export const getVoiceCallProvider = inbox => {
|
||||
@@ -25,9 +26,11 @@ export const getVoiceCallProvider = inbox => {
|
||||
const channelType = inbox.channel_type || inbox.channelType;
|
||||
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
|
||||
|
||||
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
|
||||
return VOICE_CALL_PROVIDERS.TWILIO;
|
||||
}
|
||||
if (!voiceEnabled) return null;
|
||||
|
||||
if (channelType === INBOX_TYPES.TWILIO) return VOICE_CALL_PROVIDERS.TWILIO;
|
||||
if (channelType === INBOX_TYPES.WHATSAPP)
|
||||
return VOICE_CALL_PROVIDERS.WHATSAPP;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -49,6 +49,8 @@ function extractCallData(message) {
|
||||
const call = message?.call || {};
|
||||
return {
|
||||
callSid: call.provider_call_id,
|
||||
callId: call.id,
|
||||
provider: call.provider,
|
||||
status: call.status,
|
||||
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
conversationId: message?.conversation_id,
|
||||
@@ -60,8 +62,15 @@ function extractCallData(message) {
|
||||
export function handleVoiceCallCreated(message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, callDirection, conversationId, assigneeId, senderId } =
|
||||
extractCallData(message);
|
||||
const {
|
||||
callSid,
|
||||
callId,
|
||||
provider,
|
||||
callDirection,
|
||||
conversationId,
|
||||
assigneeId,
|
||||
senderId,
|
||||
} = extractCallData(message);
|
||||
|
||||
if (
|
||||
!shouldShowCall({
|
||||
@@ -77,6 +86,8 @@ export function handleVoiceCallCreated(message, currentUserId) {
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
callId,
|
||||
provider,
|
||||
conversationId,
|
||||
callDirection,
|
||||
senderId,
|
||||
@@ -131,6 +142,20 @@ export function syncConversationCallVisibility(conversation, currentUserId) {
|
||||
const assigneeId = extractAssigneeId(conversation);
|
||||
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
|
||||
|
||||
// Outbound calls belong to the initiator regardless of who the conversation
|
||||
// is currently assigned to (auto-assignment may flip mid-call). Mirror
|
||||
// shouldShowCall's outbound exception so an in-progress outbound call isn't
|
||||
// ripped out from under the caller when the conversation reassigns.
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.removeCallsForConversation(conversation.id);
|
||||
const callsToRemove = callsStore.calls.filter(
|
||||
call =>
|
||||
call.conversationId === conversation.id &&
|
||||
!shouldShowCall({
|
||||
callDirection: call.callDirection,
|
||||
senderId: call.senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
})
|
||||
);
|
||||
callsToRemove.forEach(call => callsStore.removeCall(call.callSid));
|
||||
}
|
||||
|
||||
@@ -79,13 +79,18 @@
|
||||
"OUTGOING_CALL": "Outgoing call",
|
||||
"CALL_IN_PROGRESS": "Call in progress",
|
||||
"NO_ANSWER": "No answer",
|
||||
"NO_ANSWER_OUTBOUND_LABEL": "No answer",
|
||||
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
|
||||
"MISSED_CALL": "Missed call",
|
||||
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
|
||||
"CALL_ENDED": "Call ended",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"CALLING": "Calling…",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"JOIN_CALL": "Join call"
|
||||
"JOIN_CALL": "Join call",
|
||||
"CALL_BACK": "Call back"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
@@ -100,6 +105,10 @@
|
||||
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
|
||||
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
|
||||
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
|
||||
"WHATSAPP_CALL": "Start WhatsApp call",
|
||||
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
|
||||
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
|
||||
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
|
||||
"SLA_STATUS": {
|
||||
"FRT": "FRT {status}",
|
||||
"NRT": "NRT {status}",
|
||||
@@ -300,7 +309,11 @@
|
||||
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||
"REJECT_CALL": "Reject",
|
||||
"JOIN_CALL": "Join call",
|
||||
"END_CALL": "End call"
|
||||
"END_CALL": "End call",
|
||||
"MUTE": "Mute mic",
|
||||
"UNMUTE": "Unmute mic",
|
||||
"VIEW_CHAT_HISTORY": "View chat history",
|
||||
"GO_TO_CONVERSATION": "Go to conversation thread"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
@@ -637,7 +637,8 @@
|
||||
"BOT_CONFIGURATION": "Bot Configuration",
|
||||
"ACCOUNT_HEALTH": "Account Health",
|
||||
"CSAT": "CSAT",
|
||||
"VOICE": "Voice"
|
||||
"VOICE": "Voice",
|
||||
"CALLS": "Calls"
|
||||
},
|
||||
"VOICE_CONFIGURATION": {
|
||||
"ENABLE_VOICE": {
|
||||
@@ -648,6 +649,25 @@
|
||||
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
||||
}
|
||||
},
|
||||
"WHATSAPP_CALLING": {
|
||||
"ENABLE": {
|
||||
"LABEL": "Enable WhatsApp Calling",
|
||||
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Business phone number",
|
||||
"HELP_TEXT": "WhatsApp number that customers will call."
|
||||
},
|
||||
"HOW_IT_WORKS": {
|
||||
"LABEL": "How it works",
|
||||
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
|
||||
},
|
||||
"PERMISSION_REQUEST_BODY": {
|
||||
"LABEL": "Call permission request message",
|
||||
"HELP_TEXT": "Shown to the contact when they haven't yet consented to receive calls. Leave blank to use the default.",
|
||||
"PLACEHOLDER": "We would like to call you regarding your conversation."
|
||||
}
|
||||
},
|
||||
"CHANNEL_PREFERENCES": "Channel Preferences",
|
||||
"WIDGET_FEATURES": "Widget features",
|
||||
"ACCOUNT_HEALTH": {
|
||||
@@ -800,6 +820,10 @@
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
|
||||
"WHATSAPP_CALLING_ENABLED": {
|
||||
"LABEL": "Enable voice calling",
|
||||
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
|
||||
},
|
||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
|
||||
},
|
||||
"HELP_CENTER": {
|
||||
|
||||
@@ -56,7 +56,10 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
|
||||
...mapGetters({
|
||||
uiFlags: 'contacts/getUIFlags',
|
||||
currentChat: 'getSelectedChat',
|
||||
}),
|
||||
contactProfileLink() {
|
||||
return `/app/accounts/${this.$route.params.accountId}/contacts/${this.contact.id}`;
|
||||
},
|
||||
@@ -305,11 +308,13 @@ export default {
|
||||
<VoiceCallButton
|
||||
:phone="contact.phone_number"
|
||||
:contact-id="contact.id"
|
||||
icon="i-ri-phone-fill"
|
||||
:conversation-id="currentChat?.id"
|
||||
icon="i-lucide-phone"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
class="rounded-md hover:bg-n-alpha-2"
|
||||
:tooltip-label="$t('CONTACT_PANEL.CALL')"
|
||||
slate
|
||||
faded
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
|
||||
|
||||
@@ -22,6 +22,7 @@ import WeeklyAvailability from './components/WeeklyAvailability.vue';
|
||||
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
|
||||
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
|
||||
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
|
||||
import WhatsappCallingPage from './settingsPage/WhatsappCallingPage.vue';
|
||||
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
|
||||
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
|
||||
import BotConfiguration from './components/BotConfiguration.vue';
|
||||
@@ -48,6 +49,7 @@ export default {
|
||||
CollaboratorsPage,
|
||||
ConfigurationPage,
|
||||
VoiceConfigurationPage,
|
||||
WhatsappCallingPage,
|
||||
CustomerSatisfactionPage,
|
||||
FacebookReauthorize,
|
||||
GreetingsEditor,
|
||||
@@ -249,6 +251,23 @@ export default {
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
this.isAWhatsAppCloudChannel &&
|
||||
this.isEmbeddedSignupWhatsApp &&
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.CHANNEL_VOICE
|
||||
)
|
||||
) {
|
||||
visibleToAllChannelTabs = [
|
||||
...visibleToAllChannelTabs,
|
||||
{
|
||||
key: 'calls-configuration',
|
||||
name: this.$t('INBOX_MGMT.TABS.CALLS'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return visibleToAllChannelTabs;
|
||||
},
|
||||
currentInboxId() {
|
||||
@@ -1262,6 +1281,12 @@ export default {
|
||||
>
|
||||
<VoiceConfigurationPage :inbox="inbox" />
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedTabKey === 'calls-configuration'"
|
||||
class="mx-6 max-w-4xl"
|
||||
>
|
||||
<WhatsappCallingPage :inbox="inbox" />
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'csat'">
|
||||
<CustomerSatisfactionPage :inbox="inbox" />
|
||||
</div>
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
<script>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import TextArea from 'next/textarea/TextArea.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SettingsFieldSection,
|
||||
SettingsToggleSection,
|
||||
NextButton,
|
||||
TextArea,
|
||||
},
|
||||
props: {
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
callingEnabled: this.inbox.provider_config?.calling_enabled || false,
|
||||
permissionRequestBody:
|
||||
this.inbox.provider_config?.call_permission_request_body || '',
|
||||
isUpdating: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
phoneNumber() {
|
||||
return (
|
||||
this.inbox.provider_config?.phone_number || this.inbox.phone_number
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'inbox.provider_config.calling_enabled'(val) {
|
||||
this.callingEnabled = val || false;
|
||||
},
|
||||
'inbox.provider_config.call_permission_request_body'(val) {
|
||||
this.permissionRequestBody = val || '';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async updateCallingSettings() {
|
||||
this.isUpdating = true;
|
||||
try {
|
||||
await this.$store.dispatch('inboxes/updateInbox', {
|
||||
id: this.inbox.id,
|
||||
formData: false,
|
||||
channel: {
|
||||
provider_config: {
|
||||
...this.inbox.provider_config,
|
||||
calling_enabled: this.callingEnabled,
|
||||
call_permission_request_body:
|
||||
this.permissionRequestBody.trim() || null,
|
||||
},
|
||||
},
|
||||
});
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
this.isUpdating = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<SettingsToggleSection
|
||||
v-model="callingEnabled"
|
||||
:header="$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE.LABEL')"
|
||||
:description="$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE.DESCRIPTION')"
|
||||
/>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="phoneNumber"
|
||||
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.LABEL')"
|
||||
:help-text="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.HELP_TEXT')"
|
||||
>
|
||||
<woot-code :script="phoneNumber" lang="html" />
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PERMISSION_REQUEST_BODY.LABEL')"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.WHATSAPP_CALLING.PERMISSION_REQUEST_BODY.HELP_TEXT')
|
||||
"
|
||||
>
|
||||
<TextArea
|
||||
v-model="permissionRequestBody"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.WHATSAPP_CALLING.PERMISSION_REQUEST_BODY.PLACEHOLDER')
|
||||
"
|
||||
auto-height
|
||||
resize
|
||||
/>
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.HOW_IT_WORKS.LABEL')"
|
||||
:help-text="$t('INBOX_MGMT.WHATSAPP_CALLING.HOW_IT_WORKS.DESCRIPTION')"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<NextButton
|
||||
:is-loading="isUpdating"
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
@click="updateCallingSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -312,10 +312,14 @@ export const actions = {
|
||||
commit(types.CLEAR_CONTACT_FILTERS);
|
||||
},
|
||||
|
||||
initiateCall: async ({ commit }, { contactId, inboxId }) => {
|
||||
initiateCall: async ({ commit }, { contactId, inboxId, conversationId }) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true });
|
||||
try {
|
||||
const response = await ContactAPI.initiateCall(contactId, inboxId);
|
||||
const response = await ContactAPI.initiateCall(
|
||||
contactId,
|
||||
inboxId,
|
||||
conversationId
|
||||
);
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
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: [],
|
||||
@@ -16,15 +25,28 @@ export const useCallsStore = defineStore('calls', {
|
||||
|
||||
actions: {
|
||||
handleCallStatusChanged({ callSid, status }) {
|
||||
if (TERMINAL_STATUSES.includes(status)) {
|
||||
this.removeCall(callSid);
|
||||
if (!TERMINAL_STATUSES.includes(status)) return;
|
||||
|
||||
const call = this.calls.find(c => c.callSid === callSid);
|
||||
// WhatsApp recordings live in the in-memory recorder until voice_call.ended
|
||||
// uploads them; tearing down here would race-wipe those chunks.
|
||||
if (call?.provider === 'whatsapp') {
|
||||
this.calls = this.calls.filter(c => c.callSid !== callSid);
|
||||
return;
|
||||
}
|
||||
|
||||
this.removeCall(callSid);
|
||||
},
|
||||
|
||||
addCall(callData) {
|
||||
if (!callData?.callSid) return;
|
||||
const exists = this.calls.some(call => call.callSid === callData.callSid);
|
||||
if (exists) 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;
|
||||
}
|
||||
|
||||
this.calls.push({
|
||||
...callData,
|
||||
@@ -35,7 +57,7 @@ export const useCallsStore = defineStore('calls', {
|
||||
removeCall(callSid) {
|
||||
const callToRemove = this.calls.find(c => c.callSid === callSid);
|
||||
if (callToRemove?.isActive) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
teardownByProvider(callToRemove);
|
||||
}
|
||||
this.calls = this.calls.filter(c => c.callSid !== callSid);
|
||||
},
|
||||
@@ -48,7 +70,8 @@ export const useCallsStore = defineStore('calls', {
|
||||
},
|
||||
|
||||
clearActiveCall() {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
const active = this.calls.find(c => c.isActive);
|
||||
teardownByProvider(active);
|
||||
this.calls = this.calls.filter(call => !call.isActive);
|
||||
},
|
||||
|
||||
@@ -61,9 +84,10 @@ export const useCallsStore = defineStore('calls', {
|
||||
call => call.conversationId === conversationId
|
||||
);
|
||||
|
||||
if (callsToRemove.some(call => call.isActive)) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
}
|
||||
// Tear down each active call via its own provider so a WhatsApp call
|
||||
// gets cleanupWhatsappSession() (closes pc, stops recorder/mic) instead
|
||||
// of the Twilio-only endClientCall() — otherwise mic stays open.
|
||||
callsToRemove.filter(call => call.isActive).forEach(teardownByProvider);
|
||||
|
||||
this.calls = this.calls.filter(
|
||||
call => call.conversationId !== conversationId
|
||||
|
||||
Reference in New Issue
Block a user