diff --git a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js new file mode 100644 index 000000000..67f74a171 --- /dev/null +++ b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js @@ -0,0 +1,95 @@ +import { Device } from '@twilio/voice-sdk'; +import VoiceAPI from './voiceAPIClient'; + +const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected'); + +class TwilioVoiceClient extends EventTarget { + constructor() { + super(); + this.device = null; + this.activeConnection = null; + this.initialized = false; + this.inboxId = null; + } + + async initializeDevice(inboxId) { + this.destroyDevice(); + + const response = await VoiceAPI.getToken(inboxId); + const { token, account_id } = response || {}; + if (!token) throw new Error('Invalid token'); + + this.device = new Device(token, { + allowIncomingWhileBusy: true, + disableAudioContextSounds: true, + appParams: { account_id }, + }); + + this.device.removeAllListeners(); + this.device.on('connect', conn => { + this.activeConnection = conn; + conn.on('disconnect', this.onDisconnect); + }); + + this.device.on('disconnect', this.onDisconnect); + + this.device.on('tokenWillExpire', async () => { + const r = await VoiceAPI.getToken(this.inboxId); + if (r?.token) this.device.updateToken(r.token); + }); + + this.initialized = true; + this.inboxId = inboxId; + + return this.device; + } + + get hasActiveConnection() { + return !!this.activeConnection; + } + + endClientCall() { + if (this.activeConnection) { + this.activeConnection.disconnect(); + } + this.activeConnection = null; + if (this.device) { + this.device.disconnectAll(); + } + } + + destroyDevice() { + if (this.device) { + this.device.destroy(); + } + this.activeConnection = null; + this.device = null; + this.initialized = false; + this.inboxId = null; + } + + async joinClientCall({ to, conversationId }) { + if (!this.device || !this.initialized || !to) return null; + if (this.activeConnection) return this.activeConnection; + + const params = { + To: to, + is_agent: 'true', + conversation_id: conversationId, + }; + + const connection = await this.device.connect({ params }); + this.activeConnection = connection; + + connection.on('disconnect', this.onDisconnect); + + return connection; + } + + onDisconnect = () => { + this.activeConnection = null; + this.dispatchEvent(createCallDisconnectedEvent()); + }; +} + +export default new TwilioVoiceClient(); diff --git a/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js b/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js new file mode 100644 index 000000000..6e1e548c8 --- /dev/null +++ b/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js @@ -0,0 +1,40 @@ +/* global axios */ +import ApiClient from '../../ApiClient'; +import ContactsAPI from '../../contacts'; + +class VoiceAPI extends ApiClient { + constructor() { + super('voice', { accountScoped: true }); + } + + // eslint-disable-next-line class-methods-use-this + initiateCall(contactId, inboxId) { + return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data); + } + + leaveConference(inboxId, conversationId) { + return axios + .delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, { + params: { conversation_id: conversationId }, + }) + .then(r => r.data); + } + + joinConference({ conversationId, inboxId, callSid }) { + return axios + .post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, { + conversation_id: conversationId, + call_sid: callSid, + }) + .then(r => r.data); + } + + getToken(inboxId) { + if (!inboxId) return Promise.reject(new Error('Inbox ID is required')); + return axios + .get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`) + .then(r => r.data); + } +} + +export default new VoiceAPI(); diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index 85738d9de..e9183ce9b 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -6,6 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store'; 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 Button from 'dashboard/components-next/button/Button.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; @@ -67,6 +68,17 @@ 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', + }); + useAlert(t('CONTACT_PANEL.CALL_INITIATED')); navigateToConversation(response?.conversation_id); } catch (error) { diff --git a/app/javascript/dashboard/components-next/button/Button.vue b/app/javascript/dashboard/components-next/button/Button.vue index 1dfe488e9..b3c950053 100644 --- a/app/javascript/dashboard/components-next/button/Button.vue +++ b/app/javascript/dashboard/components-next/button/Button.vue @@ -146,7 +146,7 @@ const STYLE_CONFIG = { solid: 'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent', faded: - 'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent', + 'bg-n-teal-9/10 text-n-teal-11 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent', outline: 'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9', link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent', diff --git a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue new file mode 100644 index 000000000..4515b5c33 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue @@ -0,0 +1,184 @@ + + + diff --git a/app/javascript/dashboard/composables/useCallSession.js b/app/javascript/dashboard/composables/useCallSession.js new file mode 100644 index 000000000..f58d784b8 --- /dev/null +++ b/app/javascript/dashboard/composables/useCallSession.js @@ -0,0 +1,110 @@ +import { computed, ref, watch, onUnmounted, onMounted } from 'vue'; +import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient'; +import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient'; +import { useCallsStore } from 'dashboard/stores/calls'; +import Timer from 'dashboard/helper/Timer'; + +export function useCallSession() { + const callsStore = useCallsStore(); + const isJoining = ref(false); + const callDuration = ref(0); + const durationTimer = new Timer(elapsed => { + callDuration.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 } + ); + + onMounted(() => { + TwilioVoiceClient.addEventListener('call:disconnected', () => + callsStore.clearActiveCall() + ); + }); + + onUnmounted(() => { + durationTimer.stop(); + TwilioVoiceClient.removeEventListener('call:disconnected', () => + callsStore.clearActiveCall() + ); + }); + + const endCall = async ({ conversationId, inboxId }) => { + await VoiceAPI.leaveConference(inboxId, conversationId); + TwilioVoiceClient.endClientCall(); + durationTimer.stop(); + callsStore.clearActiveCall(); + }; + + const joinCall = async ({ conversationId, inboxId, callSid }) => { + if (isJoining.value) return null; + + isJoining.value = true; + try { + const device = await TwilioVoiceClient.initializeDevice(inboxId); + if (!device) return null; + + const joinResponse = await VoiceAPI.joinConference({ + conversationId, + inboxId, + callSid, + }); + + await TwilioVoiceClient.joinClientCall({ + to: joinResponse?.conference_sid, + conversationId, + }); + + callsStore.setCallActive(callSid); + durationTimer.start(); + + return { conferenceSid: joinResponse?.conference_sid }; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to join call:', error); + return null; + } finally { + isJoining.value = false; + } + }; + + const rejectIncomingCall = callSid => { + TwilioVoiceClient.endClientCall(); + callsStore.dismissCall(callSid); + }; + + const dismissCall = callSid => { + callsStore.dismissCall(callSid); + }; + + const formattedCallDuration = computed(() => { + const minutes = Math.floor(callDuration.value / 60); + const seconds = callDuration.value % 60; + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + }); + + return { + activeCall, + incomingCalls, + hasActiveCall, + isJoining, + formattedCallDuration, + joinCall, + endCall, + rejectIncomingCall, + dismissCall, + }; +} diff --git a/app/javascript/dashboard/helper/Timer.js b/app/javascript/dashboard/helper/Timer.js new file mode 100644 index 000000000..f706de867 --- /dev/null +++ b/app/javascript/dashboard/helper/Timer.js @@ -0,0 +1,28 @@ +export default class Timer { + constructor(onTick = null) { + this.elapsed = 0; + this.intervalId = null; + this.onTick = onTick; + } + + start() { + if (this.intervalId) { + clearInterval(this.intervalId); + } + this.elapsed = 0; + this.intervalId = setInterval(() => { + this.elapsed += 1; + if (this.onTick) { + this.onTick(this.elapsed); + } + }, 1000); + } + + stop() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + this.elapsed = 0; + } +} diff --git a/app/javascript/dashboard/helper/specs/Timer.spec.js b/app/javascript/dashboard/helper/specs/Timer.spec.js new file mode 100644 index 000000000..8886726cc --- /dev/null +++ b/app/javascript/dashboard/helper/specs/Timer.spec.js @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Timer from '../Timer'; + +describe('Timer', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + describe('constructor', () => { + it('initializes with elapsed 0 and no interval', () => { + const timer = new Timer(); + expect(timer.elapsed).toBe(0); + expect(timer.intervalId).toBeNull(); + }); + + it('accepts an onTick callback', () => { + const onTick = vi.fn(); + const timer = new Timer(onTick); + expect(timer.onTick).toBe(onTick); + }); + }); + + describe('start', () => { + it('starts the timer and increments elapsed every second', () => { + const timer = new Timer(); + timer.start(); + + expect(timer.elapsed).toBe(0); + + vi.advanceTimersByTime(1000); + expect(timer.elapsed).toBe(1); + + vi.advanceTimersByTime(1000); + expect(timer.elapsed).toBe(2); + + vi.advanceTimersByTime(3000); + expect(timer.elapsed).toBe(5); + }); + + it('calls onTick callback with elapsed value', () => { + const onTick = vi.fn(); + const timer = new Timer(onTick); + timer.start(); + + vi.advanceTimersByTime(1000); + expect(onTick).toHaveBeenCalledWith(1); + + vi.advanceTimersByTime(1000); + expect(onTick).toHaveBeenCalledWith(2); + + expect(onTick).toHaveBeenCalledTimes(2); + }); + + it('resets elapsed to 0 when restarted', () => { + const timer = new Timer(); + timer.start(); + + vi.advanceTimersByTime(5000); + expect(timer.elapsed).toBe(5); + + timer.start(); + expect(timer.elapsed).toBe(0); + + vi.advanceTimersByTime(2000); + expect(timer.elapsed).toBe(2); + }); + + it('clears previous interval when restarted', () => { + const timer = new Timer(); + timer.start(); + const firstIntervalId = timer.intervalId; + + timer.start(); + expect(timer.intervalId).not.toBe(firstIntervalId); + }); + }); + + describe('stop', () => { + it('stops the timer and resets elapsed to 0', () => { + const timer = new Timer(); + timer.start(); + + vi.advanceTimersByTime(3000); + expect(timer.elapsed).toBe(3); + + timer.stop(); + expect(timer.elapsed).toBe(0); + expect(timer.intervalId).toBeNull(); + }); + + it('prevents further increments after stopping', () => { + const timer = new Timer(); + timer.start(); + + vi.advanceTimersByTime(2000); + timer.stop(); + + vi.advanceTimersByTime(5000); + expect(timer.elapsed).toBe(0); + }); + + it('handles stop when timer is not running', () => { + const timer = new Timer(); + expect(() => timer.stop()).not.toThrow(); + expect(timer.elapsed).toBe(0); + }); + }); +}); diff --git a/app/javascript/dashboard/helper/voice.js b/app/javascript/dashboard/helper/voice.js new file mode 100644 index 000000000..9f753a811 --- /dev/null +++ b/app/javascript/dashboard/helper/voice.js @@ -0,0 +1,79 @@ +import { CONTENT_TYPES } from 'dashboard/components-next/message/constants'; +import { useCallsStore } from 'dashboard/stores/calls'; +import types from 'dashboard/store/mutation-types'; + +export const TERMINAL_STATUSES = [ + 'completed', + 'busy', + 'failed', + 'no-answer', + 'canceled', + 'missed', + 'ended', +]; + +export const isInbound = direction => direction === 'inbound'; + +const isVoiceCallMessage = message => { + return CONTENT_TYPES.VOICE_CALL === message?.content_type; +}; + +const shouldSkipCall = (callDirection, senderId, currentUserId) => { + return callDirection === 'outbound' && senderId !== currentUserId; +}; + +function extractCallData(message) { + const contentData = message?.content_attributes?.data || {}; + return { + callSid: contentData.call_sid, + status: contentData.status, + callDirection: contentData.call_direction, + conversationId: message?.conversation_id, + senderId: message?.sender?.id, + }; +} + +export function handleVoiceCallCreated(message, currentUserId) { + if (!isVoiceCallMessage(message)) return; + + const { callSid, callDirection, conversationId, senderId } = + extractCallData(message); + + if (shouldSkipCall(callDirection, senderId, currentUserId)) return; + + const callsStore = useCallsStore(); + callsStore.addCall({ + callSid, + conversationId, + callDirection, + senderId, + }); +} + +export function handleVoiceCallUpdated(commit, message, currentUserId) { + if (!isVoiceCallMessage(message)) return; + + const { callSid, status, callDirection, conversationId, senderId } = + extractCallData(message); + + const callsStore = useCallsStore(); + + callsStore.handleCallStatusChanged({ callSid, status, conversationId }); + + const callInfo = { conversationId, callStatus: status }; + commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo); + commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo); + + const isNewCall = + status === 'ringing' && + !shouldSkipCall(callDirection, senderId, currentUserId); + + if (isNewCall) { + callsStore.addCall({ + callSid, + conversationId, + callDirection, + senderId, + }); + } +} diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index d2f31e000..ec355e412 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -275,6 +275,16 @@ "SIDEBAR": { "CONTACT": "Contact", "COPILOT": "Copilot" + }, + "VOICE_WIDGET": { + "INCOMING_CALL": "Incoming call", + "OUTGOING_CALL": "Outgoing call", + "CALL_IN_PROGRESS": "Call in progress", + "NOT_ANSWERED_YET": "Not answered yet", + "HANDLED_IN_ANOTHER_TAB": "Being handled in another tab", + "REJECT_CALL": "Reject", + "JOIN_CALL": "Join call", + "END_CALL": "End call" } }, "EMAIL_TRANSCRIPT": { diff --git a/app/javascript/dashboard/routes/dashboard/Dashboard.vue b/app/javascript/dashboard/routes/dashboard/Dashboard.vue index 0a9f841fa..8b79b1372 100644 --- a/app/javascript/dashboard/routes/dashboard/Dashboard.vue +++ b/app/javascript/dashboard/routes/dashboard/Dashboard.vue @@ -1,5 +1,5 @@