diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index f2383551c..a2b0c3792 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -4,6 +4,8 @@ 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 { canCurrentUserJoinCall } from 'dashboard/helper/voice'; import Icon from 'dashboard/components-next/icon/Icon.vue'; import BaseBubble from 'next/message/bubbles/Base.vue'; @@ -27,9 +29,18 @@ const BG_COLOR_MAP = { [VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9', }; +const JOINABLE_STATUSES = [ + VOICE_CALL_STATUS.RINGING, + VOICE_CALL_STATUS.IN_PROGRESS, +]; + const { t } = useI18n(); const store = useStore(); -const { call, conversationId, currentUserId } = useMessageContext(); +const { call, conversationId, currentUserId, inboxId, sender } = + useMessageContext(); +const { activeCall, isJoining, joinCall } = useCallSession({ + manageSessionState: false, +}); const status = computed(() => call.value?.status); const isOutbound = computed(() => call.value?.direction === 'outgoing'); @@ -104,6 +115,52 @@ const iconName = computed(() => { }); const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9'); + +const callSid = computed(() => call.value?.provider_call_id); +const isAlreadyOnThisCall = computed( + () => !!callSid.value && activeCall.value?.callSid === callSid.value +); +const resolvedInboxId = computed(() => { + if (inboxId?.value) return inboxId.value; + const conversation = store.getters.getConversationById?.( + conversationId?.value + ); + return conversation?.inbox_id || null; +}); +const conversationForVisibility = computed(() => + store.getters.getConversationById?.(conversationId?.value) +); +const isVisibleToCurrentUser = computed(() => + canCurrentUserJoinCall({ + call: call.value, + conversation: conversationForVisibility.value, + senderId: sender?.value?.id, + currentUserId: currentUserId?.value, + }) +); +const canJoin = computed( + () => + JOINABLE_STATUSES.includes(status.value) && + !!callSid.value && + !!resolvedInboxId.value && + !!conversationId?.value && + !isAlreadyOnThisCall.value && + isVisibleToCurrentUser.value +); +const joinLabel = computed(() => + status.value === VOICE_CALL_STATUS.IN_PROGRESS && didCurrentUserAnswer.value + ? t('CONVERSATION.VOICE_CALL.REJOIN_CALL') + : t('CONVERSATION.VOICE_CALL.JOIN_CALL') +); + +const handleJoinClick = async () => { + if (!canJoin.value || isJoining.value) return; + await joinCall({ + conversationId: conversationId.value, + inboxId: resolvedInboxId.value, + callSid: callSid.value, + }); +}; diff --git a/app/javascript/dashboard/composables/useCallSession.js b/app/javascript/dashboard/composables/useCallSession.js index 32a3bd370..b4dcfca36 100644 --- a/app/javascript/dashboard/composables/useCallSession.js +++ b/app/javascript/dashboard/composables/useCallSession.js @@ -6,7 +6,11 @@ import { useCallsStore } from 'dashboard/stores/calls'; import { useAlert } from 'dashboard/composables'; import Timer from 'dashboard/helper/Timer'; -export function useCallSession() { +// `manageSessionState: false` lets sub-consumers (like the timeline VoiceCall +// bubble) reuse joinCall/activeCall without registering duplicate Twilio +// listeners or running their own duration timer — only the floating widget +// owns the session lifecycle. +export function useCallSession({ manageSessionState = true } = {}) { const callsStore = useCallsStore(); const { t } = useI18n(); const isJoining = ref(false); @@ -19,36 +23,38 @@ export function useCallSession() { 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() + if (manageSessionState) { + watch( + hasActiveCall, + active => { + if (active) { + durationTimer.start(); + } else { + durationTimer.stop(); + callDuration.value = 0; + } + }, + { immediate: true } ); - }); - onUnmounted(() => { - durationTimer.stop(); - TwilioVoiceClient.removeEventListener('call:disconnected', () => - callsStore.clearActiveCall() - ); - }); + onMounted(() => { + TwilioVoiceClient.addEventListener('call:disconnected', () => + callsStore.clearActiveCall() + ); + }); + + onUnmounted(() => { + durationTimer.stop(); + TwilioVoiceClient.removeEventListener('call:disconnected', () => + callsStore.clearActiveCall() + ); + }); + } const endCall = async ({ conversationId, inboxId, callSid }) => { await VoiceAPI.leaveConference({ inboxId, conversationId, callSid }); TwilioVoiceClient.endClientCall(); - durationTimer.stop(); + if (manageSessionState) durationTimer.stop(); callsStore.clearActiveCall(); }; @@ -73,7 +79,7 @@ export function useCallSession() { }); callsStore.setCallActive(callSid); - durationTimer.start(); + if (manageSessionState) durationTimer.start(); return { conferenceSid: joinResponse?.conference_sid }; } catch (error) { diff --git a/app/javascript/dashboard/helper/voice.js b/app/javascript/dashboard/helper/voice.js index dc205f8ea..5db217bb1 100644 --- a/app/javascript/dashboard/helper/voice.js +++ b/app/javascript/dashboard/helper/voice.js @@ -45,6 +45,24 @@ const shouldShowCall = ({ return !isAssignedToAnotherAgent(assigneeId, currentUserId); }; +// Whether the current user is allowed to take over / join an existing call. +// Mirrors the floating-widget visibility rules so the bubble's join action +// stays in sync. +export const canCurrentUserJoinCall = ({ + call, + conversation, + senderId, + currentUserId, +}) => { + if (!call) return false; + return shouldShowCall({ + callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound', + senderId, + assigneeId: extractAssigneeId(conversation), + currentUserId, + }); +}; + function extractCallData(message) { const call = message?.call || {}; return { diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index b5b8554e4..39be3a986 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -84,7 +84,9 @@ "NOT_ANSWERED_YET": "Not answered yet", "THEY_ANSWERED": "They answered", "YOU_ANSWERED": "You answered", - "AGENT_ANSWERED": "{agentName} answered" + "AGENT_ANSWERED": "{agentName} answered", + "JOIN_CALL": "Join call", + "REJOIN_CALL": "Rejoin call" }, "HEADER": { "RESOLVE_ACTION": "Resolve",