fix(voice): accept calls without conversation hydration; warn on refresh during ringing

- FloatingCallWidget: drop !conversation early-return in handleJoinCall —
  on a fresh account or after a hard refresh the inbound call's conversation
  may not be in the Vuex store yet, which silently no-op'd Accept while
  Reject worked (Reject doesn't read the conversation).
- useCallSession: seed the calls store from already-loaded voice_call
  messages with status='ringing' on mount and whenever the conversation
  list changes. Cable events (voice_call.incoming, message.created) are
  one-shot and not replayed on reconnect, so without seeding a refresh
  during a ringing call leaves the FloatingCallWidget empty.
- useCallSession: extend the beforeunload warning to fire while a call is
  ringing too — losing a ringing inbound to refresh is the same UX hit as
  losing an active one.
This commit is contained in:
Tanmay Deep Sharma
2026-05-02 17:35:53 +07:00
parent 6835f9c85b
commit 6ed9500792
2 changed files with 41 additions and 5 deletions
@@ -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 },
@@ -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(