diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js new file mode 100644 index 000000000..ec24aae34 --- /dev/null +++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js @@ -0,0 +1,45 @@ +/* global axios */ +import ApiClient from '../../ApiClient'; + +class WhatsappCallsAPI extends ApiClient { + constructor() { + super('whatsapp_calls', { accountScoped: true }); + } + + show(callId) { + return axios.get(`${this.url}/${callId}`).then(r => r.data); + } + + initiate(conversationId, sdpOffer) { + return axios + .post(`${this.url}/initiate`, { + conversation_id: conversationId, + sdp_offer: sdpOffer, + }) + .then(r => r.data); + } + + accept(callId, sdpAnswer) { + return axios + .post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer }) + .then(r => r.data); + } + + reject(callId) { + return axios.post(`${this.url}/${callId}/reject`).then(r => r.data); + } + + terminate(callId) { + return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data); + } + + uploadRecording(callId, blob, filename = 'call-recording.webm') { + const formData = new FormData(); + formData.append('recording', blob, filename); + return axios + .post(`${this.url}/${callId}/upload_recording`, formData) + .then(r => r.data); + } +} + +export default new WhatsappCallsAPI(); diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index bae5623a7..c39a4cf9d 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -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, }); } diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index cc564fe96..114dbb6f4 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -52,6 +52,14 @@ class Inboxes extends CacheEnabledApiClient { resetSecret(inboxId) { return axios.post(`${this.url}/${inboxId}/reset_secret`); } + + enableWhatsappCalling(inboxId) { + return axios.post(`${this.url}/${inboxId}/enable_whatsapp_calling`); + } + + disableWhatsappCalling(inboxId) { + return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index b21aeb102..f55ecdfaa 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -41,7 +41,8 @@ describe('#ContactsAPI', () => { it('#getConversations', () => { contactAPI.getConversations(1); expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/contacts/1/conversations' + '/api/v1/contacts/1/conversations', + { params: {} } ); }); diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index b258dc763..7e2b6f0c4 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -3,10 +3,20 @@ 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 { + VOICE_CALL_DIRECTION, + VOICE_CALL_OUTBOUND_INIT_STATUS, +} from 'dashboard/components-next/message/constants'; 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 +24,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' }, @@ -30,6 +43,7 @@ const { t } = useI18n(); const dialogRef = ref(null); +const callsStore = useCallsStore(); const inboxesList = useMapGetter('inboxes/getInboxes'); const contactsUiFlags = useMapGetter('contacts/getUIFlags'); @@ -38,13 +52,22 @@ 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(() => { return contactsUiFlags.value?.isInitiatingCall || false; }); +// Mirror the conversation-header button: block a new call whenever any provider +// call is already active or ringing, otherwise starting a WhatsApp call here +// would leave a still-live Twilio (or other) session with no visible control. +const isCallButtonDisabled = computed( + () => + callsStore.hasActiveCall || + callsStore.hasIncomingCall || + isInitiatingCall.value +); + const navigateToConversation = conversationId => { const accountId = route.params.accountId; if (conversationId && accountId) { @@ -58,23 +81,96 @@ const navigateToConversation = conversationId => { } }; -const startCall = async inboxId => { - if (isInitiatingCall.value) return; +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 === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return; + if (!response?.id) { + // Permission template path returns no call id. Mirror the header button and + // surface whether the request was just sent or is already pending instead of + // claiming the call started. The permission message lands in the + // conversation, so still navigate there. + const messageKey = + response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING + ? 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_PENDING' + : 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_REQUESTED'; + useAlert(t(messageKey)); + navigateToConversation(conversationId); + return; + } + + // 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: VOICE_CALL_DIRECTION.OUTBOUND, + provider: VOICE_CALL_PROVIDERS.WHATSAPP, + }); + + useAlert(t('CONTACT_PANEL.CALL_INITIATED')); + navigateToConversation(conversationId); +}; + +const startCall = async (inboxId, conversationIdHint = null) => { + if (isCallButtonDisabled.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, conversationId, inboxId, - callDirection: 'outbound', + callDirection: VOICE_CALL_DIRECTION.OUTBOUND, }); useAlert(t('CONTACT_PANEL.CALL_INITIATED')); @@ -86,6 +182,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; @@ -106,7 +218,7 @@ const onPickInbox = async inbox => { v-if="shouldRender" v-tooltip.top-end="tooltipLabel || null" v-bind="attrs" - :disabled="isInitiatingCall" + :disabled="isCallButtonDisabled" :is-loading="isInitiatingCall" :label="label" :icon="icon" diff --git a/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue b/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue index ab3f8488e..332b91066 100644 --- a/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue +++ b/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue @@ -32,7 +32,11 @@ const modelValue = defineModel({ type: Boolean, default: false }); {{ header }} -
+
diff --git a/app/javascript/dashboard/components-next/call/CallCard.story.vue b/app/javascript/dashboard/components-next/call/CallCard.story.vue new file mode 100644 index 000000000..0f9ba4660 --- /dev/null +++ b/app/javascript/dashboard/components-next/call/CallCard.story.vue @@ -0,0 +1,179 @@ + + + diff --git a/app/javascript/dashboard/components-next/call/CallCard.vue b/app/javascript/dashboard/components-next/call/CallCard.vue new file mode 100644 index 000000000..fcd7ad232 --- /dev/null +++ b/app/javascript/dashboard/components-next/call/CallCard.vue @@ -0,0 +1,222 @@ + + + diff --git a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue new file mode 100644 index 000000000..39e75ddbf --- /dev/null +++ b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue @@ -0,0 +1,256 @@ + + + diff --git a/app/javascript/dashboard/components-next/icon/ChannelIcon.vue b/app/javascript/dashboard/components-next/icon/ChannelIcon.vue index 68102dbd3..aef6a57ec 100644 --- a/app/javascript/dashboard/components-next/icon/ChannelIcon.vue +++ b/app/javascript/dashboard/components-next/icon/ChannelIcon.vue @@ -1,5 +1,6 @@ diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js index d7a9c93ad..40fa2da98 100644 --- a/app/javascript/dashboard/components-next/icon/provider.js +++ b/app/javascript/dashboard/components-next/icon/provider.js @@ -1,5 +1,5 @@ +import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox'; import { computed } from 'vue'; -import { isVoiceCallEnabled } from 'dashboard/helper/inbox'; export function useChannelIcon(inbox) { const channelTypeIconMap = { @@ -27,19 +27,29 @@ export function useChannelIcon(inbox) { const type = inboxDetails.channel_type; let icon = channelTypeIconMap[type]; - if (type === 'Channel::Email' && inboxDetails.provider) { + if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) { if (Object.keys(providerIconMap).includes(inboxDetails.provider)) { icon = providerIconMap[inboxDetails.provider]; } } // Special case for Twilio whatsapp - if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') { + if ( + type === INBOX_TYPES.TWILIO && + inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP + ) { 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 === INBOX_TYPES.TWILIO && + voiceEnabled && + inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP + ) { icon = 'i-woot-voice'; } diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index a0f950ad4..ae9c4ec75 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -2,14 +2,27 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { useStore } from 'vuex'; +import { useMapGetter } from 'dashboard/composables/store'; import { useMessageContext } from '../provider.js'; -import { VOICE_CALL_STATUS } from '../constants'; -import { useCallSession } from 'dashboard/composables/useCallSession'; +import { + VOICE_CALL_STATUS, + VOICE_CALL_DIRECTION, + VOICE_CALL_OUTBOUND_INIT_STATUS, + VOICE_CALL_END_REASON, + MESSAGE_TYPES, + ATTACHMENT_TYPES, +} from '../constants'; +import { useCallActions } from 'dashboard/composables/useCallSession'; +import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession'; +import { useCallsStore } from 'dashboard/stores/calls'; +import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox'; 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'; import AudioChip from 'next/message/chips/Audio.vue'; +import NextButton from 'dashboard/components-next/button/Button.vue'; const LABEL_MAP = { [VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS', @@ -17,39 +30,71 @@ 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 callsStore = useCallsStore(); +const contactsUiFlags = useMapGetter('contacts/getUIFlags'); +const isInitiatingCall = computed( + () => contactsUiFlags.value?.isInitiatingCall || false +); 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 === VOICE_CALL_DIRECTION.OUTGOING || + dir === VOICE_CALL_DIRECTION.OUTBOUND + ) + return true; + if ( + dir === VOICE_CALL_DIRECTION.INCOMING || + dir === VOICE_CALL_DIRECTION.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 === VOICE_CALL_PROVIDERS.WHATSAPP +); const isFailed = computed(() => [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value) ); +const isMissedInbound = computed(() => isFailed.value && !isOutbound.value); +const endReason = computed(() => call.value?.endReason); +const wasDeclinedByAgent = computed( + () => + isMissedInbound.value && + endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED +); 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 +111,19 @@ const displayAgentName = computed(() => { return conversationAssignee.value?.name || null; }); +const audioAttachment = computed(() => + (attachments?.value || []).find(a => a.fileType === ATTACHMENT_TYPES.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 +131,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 +158,51 @@ 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) { + if (isOutbound.value) { + return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT'); + } + if (wasDeclinedByAgent.value && displayAgentName.value) { + return t('CONVERSATION.VOICE_CALL.MISSED_CALL_DECLINED_BY', { + agentName: displayAgentName.value, + }); + } + return 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,11 +211,12 @@ const canJoinCall = computed(() => { }); const recordingAttachment = computed(() => { + if (audioAttachment.value) return audioAttachment.value; const url = call.value?.recordingUrl; if (!url) return null; return { dataUrl: url, - fileType: 'audio', + fileType: ATTACHMENT_TYPES.AUDIO, extension: 'wav', transcribedText: call.value?.transcript || '', }; @@ -162,48 +239,117 @@ const handleJoinCall = async () => { callSid: callSid.value, }); }; + +const canCallBack = computed( + () => + isMissedInbound.value && + !!inboxId.value && + !!conversationId.value && + !hasActiveCall.value && + !callsStore.hasIncomingCall +); + +const handleCallBack = async () => { + if (!canCallBack.value || isInitiatingCall.value) return; + try { + if (isWhatsapp.value) { + const response = await whatsappCallSession.initiateOutboundCall( + conversationId.value + ); + if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return; + // Permission template path returns no call id — show banner, no widget yet. + if (!response?.id) { + useAlert( + response?.status === + VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING + ? t('CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING') + : t('CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED') + ); + return; + } + callsStore.addCall({ + callSid: response.call_id, + callId: response.id, + conversationId: conversationId.value, + inboxId: inboxId.value, + callDirection: VOICE_CALL_DIRECTION.OUTBOUND, + provider: VOICE_CALL_PROVIDERS.WHATSAPP, + }); + return; + } + const response = await store.dispatch('contacts/initiateCall', { + contactId: sender.value?.id, + inboxId: inboxId.value, + conversationId: conversationId.value, + }); + callsStore.addCall({ + callSid: response?.call_sid, + conversationId: response?.conversation_id ?? conversationId.value, + inboxId: inboxId.value, + callDirection: VOICE_CALL_DIRECTION.OUTBOUND, + }); + } catch (error) { + useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED')); + } +}; diff --git a/app/javascript/dashboard/components-next/message/chips/Audio.vue b/app/javascript/dashboard/components-next/message/chips/Audio.vue index 9c7a44b23..ec50d4a62 100644 --- a/app/javascript/dashboard/components-next/message/chips/Audio.vue +++ b/app/javascript/dashboard/components-next/message/chips/Audio.vue @@ -41,8 +41,33 @@ const playbackSpeed = ref(1); const { uid } = getCurrentInstance(); +// MediaRecorder-produced WebM/Opus blobs lack a Duration header →