diff --git a/app/javascript/dashboard/api/voiceCalls.js b/app/javascript/dashboard/api/voiceCalls.js new file mode 100644 index 000000000..81653cf20 --- /dev/null +++ b/app/javascript/dashboard/api/voiceCalls.js @@ -0,0 +1,68 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class VoiceCallsAPI extends ApiClient { + constructor() { + super('voice_calls', { accountScoped: true }); + } + + show(callId) { + return axios.get(`${this.url}/${callId}`); + } + + // The browser does WebRTC locally and ships the SDP answer up. Rails forwards + // it to Meta via pre_accept_call+accept_call. + accept(callId, { sdpAnswer } = {}) { + return axios.post(`${this.url}/${callId}/accept`, { + sdp_answer: sdpAnswer, + }); + } + + reject(callId) { + return axios.post(`${this.url}/${callId}/reject`); + } + + terminate(callId) { + return axios.post(`${this.url}/${callId}/terminate`); + } + + // Outbound: browser builds the offer first; Rails ships it to Meta and + // creates the Call record. Meta delivers its SDP answer later via the + // connect webhook (broadcast over ActionCable as voice_call.outbound_connected). + initiate(conversationId, provider, { sdpOffer } = {}) { + return axios.post(`${this.url}/initiate`, { + conversation_id: conversationId, + provider, + sdp_offer: sdpOffer, + }); + } + + // Get the current agent's active call (if any). Used on page load to detect + // a stale active session so we can terminate it cleanly. + active() { + return axios.get(`${this.url}/active`); + } + + // Multipart upload of the in-browser MediaRecorder Blob to Rails. The + // controller attaches it to the call's voice_call message; the after-create + // hook on Attachment fires Messages::AudioTranscriptionJob automatically. + uploadRecording(callId, blob, filename) { + const fd = new FormData(); + fd.append('recording', blob, filename); + return axios.post(`${this.url}/${callId}/upload_recording`, fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } + + // URL helpers for navigator.sendBeacon (which can't carry custom headers and + // needs the absolute account-scoped path). + uploadRecordingUrl(callId) { + return `${this.url}/${callId}/upload_recording`; + } + + terminateUrl(callId) { + return `${this.url}/${callId}/terminate`; + } +} + +export default new VoiceCallsAPI(); diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index b258dc763..ba390f173 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -3,10 +3,10 @@ import { computed, ref, useAttrs } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; import { useMapGetter, useStore } from 'dashboard/composables/store'; -import { isVoiceCallEnabled } from 'dashboard/helper/inbox'; +import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { useAlert } from 'dashboard/composables'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; -import { useCallsStore } from 'dashboard/stores/calls'; +import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls'; import Button from 'dashboard/components-next/button/Button.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; @@ -34,11 +34,12 @@ const inboxesList = useMapGetter('inboxes/getInboxes'); const contactsUiFlags = useMapGetter('contacts/getUIFlags'); const voiceInboxes = computed(() => - (inboxesList.value || []).filter(isVoiceCallEnabled) + (inboxesList.value || []).filter( + inbox => inbox.channel_type === INBOX_TYPES.VOICE + ) ); const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0); -// Unified behavior: hide when no phone const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone); const isInitiatingCall = computed(() => { @@ -66,15 +67,16 @@ const startCall = async inboxId => { contactId: props.contactId, inboxId, }); - const { call_sid: callSid, conversation_id: conversationId } = response; - // Add call to store immediately so widget shows - const callsStore = useCallsStore(); - callsStore.addCall({ - callSid, - conversationId, - inboxId, - callDirection: 'outbound', + const voiceCallsStore = useVoiceCallsStore(); + voiceCallsStore.setActiveCall({ + id: response.call_id, + callId: response.call_sid, + provider: response.provider || 'twilio', + direction: 'outbound', + status: 'ringing', + conversationId: response.conversation_id, + caller: null, }); useAlert(t('CONTACT_PANEL.CALL_INITIATED')); diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index a270882ac..871eddc95 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -1,7 +1,10 @@