diff --git a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue index 4c8e22522..a8f4326b1 100644 --- a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue +++ b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue @@ -74,21 +74,25 @@ 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(); } + // After a hard refresh the conversation may not be hydrated yet — but the call + // object already carries inboxId from the cable / seeding path, so accept can + // proceed without it. Twilio still needs inboxId for initializeDevice; falls + // back to the conversation's inbox_id when present. 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 }, diff --git a/app/javascript/dashboard/composables/useCallSession.js b/app/javascript/dashboard/composables/useCallSession.js index 179d5818b..39312eb4d 100644 --- a/app/javascript/dashboard/composables/useCallSession.js +++ b/app/javascript/dashboard/composables/useCallSession.js @@ -1,4 +1,5 @@ import { computed, ref, watch, onUnmounted, onMounted } from 'vue'; +import { useStore } from 'vuex'; import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient'; import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient'; import { useCallsStore } from 'dashboard/stores/calls'; @@ -7,11 +8,13 @@ import { sendWhatsappTerminateBeacon, cleanupWhatsappSession, } from 'dashboard/composables/useWhatsappCallSession'; +import { handleVoiceCallCreated } from 'dashboard/helper/voice'; import Timer from 'dashboard/helper/Timer'; const isWhatsappCall = call => call?.provider === 'whatsapp'; export function useCallSession() { + const store = useStore(); const callsStore = useCallsStore(); const whatsappSession = useWhatsappCallSession(); const isJoining = ref(false); @@ -23,6 +26,7 @@ export function useCallSession() { const activeCall = computed(() => callsStore.activeCall); const incomingCalls = computed(() => callsStore.incomingCalls); const hasActiveCall = computed(() => callsStore.hasActiveCall); + const hasIncomingCall = computed(() => callsStore.hasIncomingCall); watch( hasActiveCall, @@ -39,13 +43,32 @@ export function useCallSession() { // Browser-native confirm prompt when reload/close happens mid-call. Reload // tears down the WebRTC session permanently for WhatsApp (no rejoin) and - // drops the agent leg for Twilio, so warn either way. + // drops the agent leg for Twilio. Also warn while a call is ringing — the + // cable broadcast that delivered the incoming-call event isn't replayed on + // refresh, so the agent loses the ability to accept it. const handleBeforeUnload = event => { - if (!hasActiveCall.value) return; + if (!hasActiveCall.value && !hasIncomingCall.value) return; event.preventDefault(); event.returnValue = ''; }; + // Hydrate the calls store from already-loaded conversation messages. The + // voice_call.incoming / message.created cable events are one-shot and aren't + // replayed when the page reconnects, so without this seeding a hard refresh + // during a ringing call would leave the FloatingCallWidget empty even though + // the call is still ringing on Meta's side. + 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; + handleVoiceCallCreated(msg, currentUserId); + }); + }); + }; + // pagehide fires after the user confirms the prompt. Let the WhatsApp session // best-effort sendBeacon a terminate so the server doesn't keep the call open. const handlePageHide = () => { @@ -61,8 +84,17 @@ export function useCallSession() { ); window.addEventListener('beforeunload', handleBeforeUnload); window.addEventListener('pagehide', handlePageHide); + seedCallsFromHydratedMessages(); }); + // Conversations are typically fetched after this composable mounts, so the + // initial seed pass runs before any messages exist. Re-seed whenever the + // conversation list changes — addCall is idempotent (it merges by callSid). + watch( + () => store.getters.getAllConversations?.length, + () => seedCallsFromHydratedMessages() + ); + onUnmounted(() => { durationTimer.stop(); TwilioVoiceClient.removeEventListener(