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:
@@ -74,21 +74,25 @@ const handleEndCall = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleJoinCall = async call => {
|
const handleJoinCall = async call => {
|
||||||
|
if (!call || isJoining.value) return;
|
||||||
const { conversation } = getCallInfo(call);
|
const { conversation } = getCallInfo(call);
|
||||||
if (!call || !conversation || isJoining.value) return;
|
|
||||||
|
|
||||||
// End current active call before joining new one
|
// End current active call before joining new one
|
||||||
if (hasActiveCall.value) {
|
if (hasActiveCall.value) {
|
||||||
await handleEndCall();
|
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({
|
const result = await joinCall({
|
||||||
conversationId: call.conversationId,
|
conversationId: call.conversationId,
|
||||||
inboxId: conversation.inbox_id,
|
inboxId: call.inboxId || conversation?.inbox_id,
|
||||||
callSid: call.callSid,
|
callSid: call.callSid,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result) {
|
if (result && conversation) {
|
||||||
router.push({
|
router.push({
|
||||||
name: 'inbox_conversation',
|
name: 'inbox_conversation',
|
||||||
params: { conversation_id: call.conversationId },
|
params: { conversation_id: call.conversationId },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||||
import { useCallsStore } from 'dashboard/stores/calls';
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
@@ -7,11 +8,13 @@ import {
|
|||||||
sendWhatsappTerminateBeacon,
|
sendWhatsappTerminateBeacon,
|
||||||
cleanupWhatsappSession,
|
cleanupWhatsappSession,
|
||||||
} from 'dashboard/composables/useWhatsappCallSession';
|
} from 'dashboard/composables/useWhatsappCallSession';
|
||||||
|
import { handleVoiceCallCreated } from 'dashboard/helper/voice';
|
||||||
import Timer from 'dashboard/helper/Timer';
|
import Timer from 'dashboard/helper/Timer';
|
||||||
|
|
||||||
const isWhatsappCall = call => call?.provider === 'whatsapp';
|
const isWhatsappCall = call => call?.provider === 'whatsapp';
|
||||||
|
|
||||||
export function useCallSession() {
|
export function useCallSession() {
|
||||||
|
const store = useStore();
|
||||||
const callsStore = useCallsStore();
|
const callsStore = useCallsStore();
|
||||||
const whatsappSession = useWhatsappCallSession();
|
const whatsappSession = useWhatsappCallSession();
|
||||||
const isJoining = ref(false);
|
const isJoining = ref(false);
|
||||||
@@ -23,6 +26,7 @@ export function useCallSession() {
|
|||||||
const activeCall = computed(() => callsStore.activeCall);
|
const activeCall = computed(() => callsStore.activeCall);
|
||||||
const incomingCalls = computed(() => callsStore.incomingCalls);
|
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||||
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||||
|
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
hasActiveCall,
|
hasActiveCall,
|
||||||
@@ -39,13 +43,32 @@ export function useCallSession() {
|
|||||||
|
|
||||||
// Browser-native confirm prompt when reload/close happens mid-call. Reload
|
// Browser-native confirm prompt when reload/close happens mid-call. Reload
|
||||||
// tears down the WebRTC session permanently for WhatsApp (no rejoin) and
|
// 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 => {
|
const handleBeforeUnload = event => {
|
||||||
if (!hasActiveCall.value) return;
|
if (!hasActiveCall.value && !hasIncomingCall.value) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.returnValue = '';
|
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
|
// 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.
|
// best-effort sendBeacon a terminate so the server doesn't keep the call open.
|
||||||
const handlePageHide = () => {
|
const handlePageHide = () => {
|
||||||
@@ -61,8 +84,17 @@ export function useCallSession() {
|
|||||||
);
|
);
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
window.addEventListener('pagehide', handlePageHide);
|
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(() => {
|
onUnmounted(() => {
|
||||||
durationTimer.stop();
|
durationTimer.stop();
|
||||||
TwilioVoiceClient.removeEventListener(
|
TwilioVoiceClient.removeEventListener(
|
||||||
|
|||||||
Reference in New Issue
Block a user