Merge branch 'develop' into feat/app-store-reviews
This commit is contained in:
@@ -108,7 +108,7 @@ export function useBulkActions() {
|
||||
}
|
||||
}
|
||||
|
||||
// Only used in context menu
|
||||
// Used by both context menu and bulk action bar.
|
||||
async function onRemoveLabels(labelsToRemove, conversationId = null) {
|
||||
try {
|
||||
await store.dispatch('bulkActions/process', {
|
||||
@@ -119,14 +119,24 @@ export function useBulkActions() {
|
||||
},
|
||||
});
|
||||
|
||||
useAlert(
|
||||
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
|
||||
labelName: labelsToRemove[0],
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
// Context-menu remove should not disturb an existing bulk selection.
|
||||
if (conversationId) {
|
||||
useAlert(
|
||||
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
|
||||
labelName: labelsToRemove[0],
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
store.dispatch('bulkActions/clearSelectedConversationIds');
|
||||
useAlert(t('BULK_ACTION.LABELS.REMOVE_SUCCESFUL'));
|
||||
}
|
||||
} catch (err) {
|
||||
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED'));
|
||||
useAlert(
|
||||
conversationId
|
||||
? t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED')
|
||||
: t('BULK_ACTION.LABELS.REMOVE_FAILED')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,12 @@ vi.mock('shared/helpers/mitt', () => ({
|
||||
|
||||
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
actual.default = {
|
||||
track: vi.fn(),
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
track: vi.fn(),
|
||||
},
|
||||
};
|
||||
return actual;
|
||||
});
|
||||
|
||||
describe('useTrack', () => {
|
||||
|
||||
@@ -16,10 +16,12 @@ vi.mock('vue-i18n');
|
||||
vi.mock('dashboard/api/captain/tasks');
|
||||
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
actual.default = {
|
||||
track: vi.fn(),
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
track: vi.fn(),
|
||||
},
|
||||
};
|
||||
return actual;
|
||||
});
|
||||
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
|
||||
CAPTAIN_EVENTS: {
|
||||
|
||||
@@ -1,6 +1,47 @@
|
||||
const keyboardEventsMock = vi.hoisted(() => ({
|
||||
mountedCallbacks: [],
|
||||
unmountedCallbacks: [],
|
||||
registeredKeybindings: {},
|
||||
}));
|
||||
|
||||
vi.mock('vue', async importOriginal => ({
|
||||
...(await importOriginal()),
|
||||
onMounted: vi.fn(callback =>
|
||||
keyboardEventsMock.mountedCallbacks.push(callback)
|
||||
),
|
||||
onUnmounted: vi.fn(callback =>
|
||||
keyboardEventsMock.unmountedCallbacks.push(callback)
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/useDetectKeyboardLayout', () => ({
|
||||
useDetectKeyboardLayout: vi.fn(() => Promise.resolve('QWERTY')),
|
||||
}));
|
||||
|
||||
vi.mock('tinykeys', () => ({
|
||||
createKeybindingsHandler: vi.fn(keybindings => {
|
||||
keyboardEventsMock.registeredKeybindings = keybindings;
|
||||
return event => keybindings[event.shortcut]?.(event);
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { LAYOUT_QWERTZ } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
const mount = async events => {
|
||||
useKeyboardEvents(events);
|
||||
await keyboardEventsMock.mountedCallbacks[0]();
|
||||
return keyboardEventsMock.registeredKeybindings;
|
||||
};
|
||||
|
||||
describe('useKeyboardEvents', () => {
|
||||
beforeEach(() => {
|
||||
keyboardEventsMock.mountedCallbacks = [];
|
||||
keyboardEventsMock.unmountedCallbacks = [];
|
||||
keyboardEventsMock.registeredKeybindings = {};
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(useKeyboardEvents).toBeDefined();
|
||||
});
|
||||
@@ -9,18 +50,46 @@ describe('useKeyboardEvents', () => {
|
||||
expect(useKeyboardEvents).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should set up listeners on mount and remove them on unmount', async () => {
|
||||
const events = {
|
||||
'ALT+KeyL': () => {},
|
||||
};
|
||||
it('registers keybindings on mount and removes the listener on unmount', async () => {
|
||||
const addEventListener = vi.spyOn(document, 'addEventListener');
|
||||
|
||||
const mountedMock = vi.fn();
|
||||
const unmountedMock = vi.fn();
|
||||
useKeyboardEvents(events);
|
||||
mountedMock();
|
||||
unmountedMock();
|
||||
const keybindings = await mount({ 'ALT+KeyL': () => {} });
|
||||
expect(Object.keys(keybindings)).toEqual(['ALT+KeyL']);
|
||||
|
||||
expect(mountedMock).toHaveBeenCalled();
|
||||
expect(unmountedMock).toHaveBeenCalled();
|
||||
const { signal } = addEventListener.mock.calls[0][2];
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
keyboardEventsMock.unmountedCallbacks[0]();
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('prefixes Shift on QWERTZ layouts for the affected keys', async () => {
|
||||
useDetectKeyboardLayout.mockResolvedValueOnce(LAYOUT_QWERTZ);
|
||||
|
||||
const keybindings = await mount({ 'Alt+KeyL': () => {} });
|
||||
|
||||
expect(Object.keys(keybindings)).toEqual(['Shift+Alt+KeyL']);
|
||||
});
|
||||
|
||||
it('ignores shortcuts on focused typeable elements by default', async () => {
|
||||
const action = vi.fn();
|
||||
const keybindings = await mount({
|
||||
'Alt+KeyL': { action, allowOnFocusedInput: false },
|
||||
});
|
||||
|
||||
keybindings['Alt+KeyL']({ target: document.createElement('textarea') });
|
||||
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs shortcuts on focused typeable elements when allowed', async () => {
|
||||
const action = vi.fn();
|
||||
const keybindings = await mount({
|
||||
'Alt+KeyL': { action, allowOnFocusedInput: true },
|
||||
});
|
||||
|
||||
keybindings['Alt+KeyL']({ target: document.createElement('textarea') });
|
||||
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,10 @@ export function useAccount() {
|
||||
});
|
||||
};
|
||||
|
||||
const finishOnboarding = async data => {
|
||||
await store.dispatch('accounts/finishOnboarding', data);
|
||||
};
|
||||
|
||||
return {
|
||||
accountId,
|
||||
route,
|
||||
@@ -61,5 +65,6 @@ export function useAccount() {
|
||||
isCloudFeatureEnabled,
|
||||
isOnChatwootCloud,
|
||||
updateAccount,
|
||||
finishOnboarding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,62 +1,145 @@
|
||||
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||
import { computed, readonly, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import {
|
||||
useWhatsappCallSession,
|
||||
sendWhatsappTerminateBeacon,
|
||||
cleanupWhatsappSession,
|
||||
} from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { handleVoiceCallCreated } from 'dashboard/helper/voice';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
CONTENT_TYPES,
|
||||
VOICE_CALL_DIRECTION,
|
||||
VOICE_CALL_STATUS,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
|
||||
export function useCallSession() {
|
||||
const callsStore = useCallsStore();
|
||||
const { t } = useI18n();
|
||||
const isJoining = ref(false);
|
||||
const callDuration = ref(0);
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
callDuration.value = elapsed;
|
||||
const isWhatsappCall = call => call?.provider === VOICE_CALL_PROVIDERS.WHATSAPP;
|
||||
|
||||
// Dismissed call sids must not be re-seeded by the conversation-load watcher.
|
||||
// Lives at module scope so all consumers share the same set.
|
||||
const dismissedCallSids = new Set();
|
||||
const markDismissed = callSid => {
|
||||
if (callSid) dismissedCallSids.add(callSid);
|
||||
};
|
||||
|
||||
// Globals attached once across all useCallSession() consumers — bubbles in a
|
||||
// long thread call this composable many times, and a per-instance Timer +
|
||||
// window listener stack would multiply work.
|
||||
let globalsAttachedCount = 0;
|
||||
let globalDurationTimer = null;
|
||||
const globalCallDuration = ref(0);
|
||||
let storedCallsStoreRef = null;
|
||||
// Shared join lock so two surfaces (bubble + widget) clicking concurrently
|
||||
// see one in-flight join, not two unrelated isJoining refs.
|
||||
const globalIsJoining = ref(false);
|
||||
const globalIsJoiningReadonly = readonly(globalIsJoining);
|
||||
|
||||
const handleBeforeUnloadGlobal = event => {
|
||||
const store = storedCallsStoreRef;
|
||||
if (!store) return;
|
||||
if (!store.hasActiveCall && !store.hasIncomingCall) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
const handlePageHideGlobal = () => sendWhatsappTerminateBeacon();
|
||||
const handleTwilioDisconnectedGlobal = () =>
|
||||
storedCallsStoreRef?.clearActiveCall();
|
||||
|
||||
const attachGlobalsOnFirstMount = callsStore => {
|
||||
globalsAttachedCount += 1;
|
||||
if (globalsAttachedCount > 1) return;
|
||||
storedCallsStoreRef = callsStore;
|
||||
globalDurationTimer = new Timer(elapsed => {
|
||||
globalCallDuration.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 }
|
||||
TwilioVoiceClient.addEventListener(
|
||||
'call:disconnected',
|
||||
handleTwilioDisconnectedGlobal
|
||||
);
|
||||
window.addEventListener('beforeunload', handleBeforeUnloadGlobal);
|
||||
window.addEventListener('pagehide', handlePageHideGlobal);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
);
|
||||
});
|
||||
const detachGlobalsOnLastUnmount = () => {
|
||||
globalsAttachedCount -= 1;
|
||||
if (globalsAttachedCount > 0) return;
|
||||
globalDurationTimer?.stop();
|
||||
globalDurationTimer = null;
|
||||
globalCallDuration.value = 0;
|
||||
storedCallsStoreRef = null;
|
||||
TwilioVoiceClient.removeEventListener(
|
||||
'call:disconnected',
|
||||
handleTwilioDisconnectedGlobal
|
||||
);
|
||||
window.removeEventListener('beforeunload', handleBeforeUnloadGlobal);
|
||||
window.removeEventListener('pagehide', handlePageHideGlobal);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
durationTimer.stop();
|
||||
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
);
|
||||
});
|
||||
// Build the action surface used by both the root session composable and the
|
||||
// lighter useCallActions consumer. All state is module-scoped — the actions
|
||||
// don't depend on per-instance refs, so they're cheap to call from anywhere.
|
||||
const buildCallActions = ({ callsStore, whatsappSession, t }) => {
|
||||
const findCall = callSid => callsStore.calls.find(c => c.callSid === callSid);
|
||||
|
||||
const endCall = async ({ conversationId, inboxId, callSid }) => {
|
||||
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
||||
TwilioVoiceClient.endClientCall();
|
||||
durationTimer.stop();
|
||||
callsStore.clearActiveCall();
|
||||
const call = findCall(callSid);
|
||||
if (isWhatsappCall(call)) {
|
||||
// Pass call.callId so a wiped module state (e.g. a prior accept attempt
|
||||
// tore down the WebRTC session) doesn't stop us hitting /terminate.
|
||||
await whatsappSession.endActiveCall(call?.callId);
|
||||
globalDurationTimer?.stop();
|
||||
callsStore.clearActiveCall();
|
||||
return;
|
||||
}
|
||||
|
||||
// try/finally so a failed leaveConference (e.g. backend 5xx) still
|
||||
// tears down the local Device and UI state — otherwise the call stays
|
||||
// visually active with the mic open.
|
||||
try {
|
||||
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
||||
} finally {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
globalDurationTimer?.stop();
|
||||
callsStore.clearActiveCall();
|
||||
}
|
||||
};
|
||||
|
||||
const joinCall = async ({ conversationId, inboxId, callSid }) => {
|
||||
if (isJoining.value) return null;
|
||||
if (globalIsJoining.value) return null;
|
||||
|
||||
isJoining.value = true;
|
||||
const call = findCall(callSid);
|
||||
// Outbound *WhatsApp* calls have no separate join step — the offer was
|
||||
// sent at initiate time and the answer is applied by the cable handler.
|
||||
// Routing through acceptIncomingCall here would call prepareInboundAnswer →
|
||||
// cleanup() and destroy the live outbound session. Outbound *Twilio*
|
||||
// calls still need joinConference + joinClientCall (FloatingCallWidget
|
||||
// auto-joins them), so don't short-circuit those.
|
||||
if (
|
||||
call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND &&
|
||||
isWhatsappCall(call)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
globalIsJoining.value = true;
|
||||
try {
|
||||
if (isWhatsappCall(call)) {
|
||||
await whatsappSession.acceptIncomingCall({
|
||||
callId: call.callId,
|
||||
sdpOffer: call.sdpOffer,
|
||||
iceServers: call.iceServers,
|
||||
});
|
||||
callsStore.setCallActive(callSid);
|
||||
globalDurationTimer?.start();
|
||||
return { callId: call.callId };
|
||||
}
|
||||
|
||||
const device = await TwilioVoiceClient.initializeDevice(inboxId);
|
||||
if (!device) return null;
|
||||
|
||||
@@ -73,47 +156,165 @@ export function useCallSession() {
|
||||
});
|
||||
|
||||
callsStore.setCallActive(callSid);
|
||||
durationTimer.start();
|
||||
globalDurationTimer?.start();
|
||||
|
||||
return { conferenceSid: joinResponse?.conference_sid };
|
||||
} catch (error) {
|
||||
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
if (error?.response?.status === 409) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
markDismissed(callSid);
|
||||
callsStore.dismissCall(callSid);
|
||||
} else if (!isWhatsappCall(call)) {
|
||||
// Tear down the Twilio Device on any other join error so a retry
|
||||
// starts from a clean state — joinClientCall can leave the device
|
||||
// half-initialized after a network blip.
|
||||
TwilioVoiceClient.endClientCall();
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to join call:', error);
|
||||
// Drop any half-built WebRTC state so the next click starts fresh.
|
||||
cleanupWhatsappSession();
|
||||
return null;
|
||||
} finally {
|
||||
isJoining.value = false;
|
||||
globalIsJoining.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const rejectIncomingCall = callSid => {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
callsStore.dismissCall(callSid);
|
||||
// Await provider-side reject before dismissing the local entry; if the API
|
||||
// call fails the call should stay surfaced so the agent can retry instead of
|
||||
// disappearing while the backend still rings.
|
||||
const rejectIncomingCall = async callSid => {
|
||||
const call = findCall(callSid);
|
||||
try {
|
||||
if (isWhatsappCall(call) && call?.callId) {
|
||||
if (call.callDirection === VOICE_CALL_DIRECTION.OUTBOUND) {
|
||||
// Outbound calls that are still ringing must be terminated, not
|
||||
// rejected (reject is the inbound-side verb on Meta's API).
|
||||
await whatsappSession.endActiveCall(call.callId);
|
||||
} else {
|
||||
await whatsappSession.rejectIncomingCall(call.callId);
|
||||
}
|
||||
} else if (call?.inboxId && call?.conversationId) {
|
||||
// Twilio incoming reject: agent hasn't joined the Device yet, so
|
||||
// endClientCall is a no-op. End the conference server-side instead
|
||||
// so Twilio hangs up the inbound leg.
|
||||
await VoiceAPI.leaveConference({
|
||||
inboxId: call.inboxId,
|
||||
conversationId: call.conversationId,
|
||||
callSid,
|
||||
});
|
||||
} else {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
}
|
||||
} finally {
|
||||
markDismissed(callSid);
|
||||
callsStore.dismissCall(callSid);
|
||||
}
|
||||
};
|
||||
|
||||
const dismissCall = callSid => {
|
||||
markDismissed(callSid);
|
||||
callsStore.dismissCall(callSid);
|
||||
};
|
||||
|
||||
return { endCall, joinCall, rejectIncomingCall, dismissCall };
|
||||
};
|
||||
|
||||
const buildReactiveSurface = callsStore => {
|
||||
const activeCall = computed(() => callsStore.activeCall);
|
||||
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||
const formattedCallDuration = computed(() => {
|
||||
const minutes = Math.floor(callDuration.value / 60);
|
||||
const seconds = callDuration.value % 60;
|
||||
const total = globalCallDuration.value;
|
||||
const minutes = Math.floor(total / 60);
|
||||
const seconds = total % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
return {
|
||||
activeCall,
|
||||
incomingCalls,
|
||||
hasActiveCall,
|
||||
isJoining,
|
||||
isJoining: globalIsJoiningReadonly,
|
||||
formattedCallDuration,
|
||||
joinCall,
|
||||
endCall,
|
||||
rejectIncomingCall,
|
||||
dismissCall,
|
||||
};
|
||||
};
|
||||
|
||||
// Root-mount composable. Call once at the dashboard root (FloatingCallWidget
|
||||
// is the natural anchor — always mounted, lifetime spans the whole session).
|
||||
// This is the only path that registers global window/Twilio listeners and
|
||||
// owns the duration Timer.
|
||||
export function useCallSession() {
|
||||
const store = useStore();
|
||||
const callsStore = useCallsStore();
|
||||
const whatsappSession = useWhatsappCallSession();
|
||||
const { t } = useI18n();
|
||||
|
||||
const reactive = buildReactiveSurface(callsStore);
|
||||
|
||||
// Cable broadcasts (voice_call.incoming / message.created) are one-shot, so
|
||||
// on a hard refresh they leave the calls store empty. Seed it from any
|
||||
// ringing voice_call message in the conversation cache. Skip calls the
|
||||
// agent has already dismissed locally so they don't re-pop on the next
|
||||
// conversation update.
|
||||
const seedCallsFromHydratedMessages = () => {
|
||||
const conversations = store.getters.getAllConversations || [];
|
||||
const currentUserId = store.getters.getCurrentUserID;
|
||||
const currentUserAvailability = store.getters.getCurrentUserAvailability;
|
||||
conversations.forEach(conv => {
|
||||
(conv.messages || []).forEach(msg => {
|
||||
if (msg.content_type !== CONTENT_TYPES.VOICE_CALL) return;
|
||||
if (msg.call?.status !== VOICE_CALL_STATUS.RINGING) return;
|
||||
const callSid = msg.call?.provider_call_id;
|
||||
if (callSid && dismissedCallSids.has(callSid)) return;
|
||||
handleVoiceCallCreated(msg, currentUserId, currentUserAvailability);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
reactive.hasActiveCall,
|
||||
active => {
|
||||
if (active) {
|
||||
globalDurationTimer?.start();
|
||||
} else {
|
||||
globalDurationTimer?.stop();
|
||||
globalCallDuration.value = 0;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
attachGlobalsOnFirstMount(callsStore);
|
||||
seedCallsFromHydratedMessages();
|
||||
});
|
||||
|
||||
// Re-seed when conversations stream in after mount; addCall merges by callSid
|
||||
// and dismissed sids are filtered, so this is idempotent.
|
||||
watch(
|
||||
() => store.getters.getAllConversations?.length,
|
||||
() => seedCallsFromHydratedMessages()
|
||||
);
|
||||
|
||||
onUnmounted(() => detachGlobalsOnLastUnmount());
|
||||
|
||||
const actions = buildCallActions({ callsStore, whatsappSession, t });
|
||||
|
||||
return { ...reactive, ...actions };
|
||||
}
|
||||
|
||||
// Lightweight consumer for components that need to read state and trigger
|
||||
// actions but should NOT mount global listeners (e.g., per-message bubbles
|
||||
// rendered in a thread). Reads from the same module-level state that
|
||||
// useCallSession owns, so the duration timer and dismissed set stay coherent.
|
||||
export function useCallActions() {
|
||||
const callsStore = useCallsStore();
|
||||
const whatsappSession = useWhatsappCallSession();
|
||||
const { t } = useI18n();
|
||||
|
||||
const reactive = buildReactiveSurface(callsStore);
|
||||
const actions = buildCallActions({ callsStore, whatsappSession, t });
|
||||
|
||||
return { ...reactive, ...actions };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
import { readonly, ref } from 'vue';
|
||||
import Cookies from 'js-cookie';
|
||||
import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI';
|
||||
import { VOICE_CALL_OUTBOUND_INIT_STATUS } from 'dashboard/components-next/message/constants';
|
||||
|
||||
// Module-level state lets the cable handlers and unload listeners reach the
|
||||
// live PeerConnection without prop-drilling refs through every composable.
|
||||
let pc = null;
|
||||
let localStream = null;
|
||||
let remoteStream = null;
|
||||
let remoteAudioEl = null;
|
||||
let mediaRecorder = null;
|
||||
let recorderChunks = [];
|
||||
let audioContext = null;
|
||||
let activeCallId = null;
|
||||
// voice_call.outbound_connected (the sole source of the outbound SDP answer) is
|
||||
// broadcast account-wide and can arrive before the /initiate response sets
|
||||
// activeCallId in this tab. Until we know our own call id we can't tell our
|
||||
// answer from a concurrent agent's, so the cable handler buffers them here keyed
|
||||
// by call id (a single slot would let a concurrent agent's event clobber ours)
|
||||
// and initiateOutboundCall flushes the matching one by id.
|
||||
const pendingOutboundAnswers = new Map();
|
||||
// Module-scoped so multiple composable callers (header button + contact-panel
|
||||
// button) share the same lock. A per-instance ref let two parallel callers
|
||||
// both pass the guard and tear down each other's WebRTC state in cleanup().
|
||||
// ref() so consumers can reactively gate buttons on it (the composable
|
||||
// re-exports this as `isInitiating`).
|
||||
const isInitiatingOutbound = ref(false);
|
||||
const isInitiatingOutboundReadonly = readonly(isInitiatingOutbound);
|
||||
// Inbound calls record from the moment the agent clicks accept (their click =
|
||||
// pickup). Outbound calls must wait — Meta's `connect` webhook (which lands
|
||||
// during ringing) negotiates remote tracks ~20s before the contact actually
|
||||
// answers, and we don't want pre-pickup audio in the recording. This flag is
|
||||
// flipped to true by armOutboundRecorder() when the ACCEPTED status arrives.
|
||||
let recorderArmed = false;
|
||||
|
||||
const ensureRemoteAudioElement = () => {
|
||||
if (remoteAudioEl) return remoteAudioEl;
|
||||
remoteAudioEl = document.createElement('audio');
|
||||
remoteAudioEl.id = 'whatsapp-call-remote-audio';
|
||||
remoteAudioEl.autoplay = true;
|
||||
remoteAudioEl.playsInline = true;
|
||||
remoteAudioEl.style.display = 'none';
|
||||
document.body.appendChild(remoteAudioEl);
|
||||
return remoteAudioEl;
|
||||
};
|
||||
|
||||
const playRemoteStream = stream => {
|
||||
const el = ensureRemoteAudioElement();
|
||||
el.srcObject = stream;
|
||||
el.play().catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[WhatsApp Call] remote audio play() failed:', err);
|
||||
});
|
||||
};
|
||||
|
||||
// 1s timeslice keeps a recent recording chunk in memory so a remote hangup
|
||||
// that races cleanup still has data to upload.
|
||||
const RECORDING_TIMESLICE_MS = 1000;
|
||||
const ICE_GATHER_TIMEOUT_MS = 10000;
|
||||
|
||||
const RECORDER_MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg;codecs=opus',
|
||||
];
|
||||
|
||||
// Outbound calls have no backend-supplied ice_servers (the call doesn't exist
|
||||
// at offer time). Without STUN the browser only sends host candidates and
|
||||
// browser→Meta media silently drops through any non-trivial NAT.
|
||||
const DEFAULT_OUTBOUND_ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
|
||||
const waitForIceGatheringComplete = peer =>
|
||||
new Promise(resolve => {
|
||||
if (peer.iceGatheringState === 'complete') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(resolve, ICE_GATHER_TIMEOUT_MS);
|
||||
peer.addEventListener('icegatheringstatechange', () => {
|
||||
if (peer.iceGatheringState === 'complete') {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const setupRecorder = () => {
|
||||
if (!localStream || !remoteStream || mediaRecorder) return;
|
||||
// createMediaStreamSource on a stream with no audio tracks wires up to
|
||||
// nothing — the recorded mix would be silence. Wait until ontrack fires.
|
||||
if (remoteStream.getAudioTracks().length === 0) return;
|
||||
|
||||
audioContext = new AudioContext({ sampleRate: 48000 });
|
||||
// AudioContext starts suspended under most autoplay policies; without
|
||||
// resume() the destination stream produces silence.
|
||||
audioContext.resume().catch(() => {});
|
||||
|
||||
const destination = audioContext.createMediaStreamDestination();
|
||||
audioContext.createMediaStreamSource(localStream).connect(destination);
|
||||
audioContext.createMediaStreamSource(remoteStream).connect(destination);
|
||||
|
||||
const mimeType = RECORDER_MIME_CANDIDATES.find(t =>
|
||||
MediaRecorder.isTypeSupported(t)
|
||||
);
|
||||
if (!mimeType) return;
|
||||
|
||||
recorderChunks = [];
|
||||
mediaRecorder = new MediaRecorder(destination.stream, { mimeType });
|
||||
mediaRecorder.ondataavailable = event => {
|
||||
if (event.data && event.data.size > 0) recorderChunks.push(event.data);
|
||||
};
|
||||
mediaRecorder.start(RECORDING_TIMESLICE_MS);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
try {
|
||||
mediaRecorder.stop();
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
if (audioContext && audioContext.state !== 'closed') {
|
||||
audioContext.close().catch(() => {});
|
||||
}
|
||||
if (localStream) localStream.getTracks().forEach(t => t.stop());
|
||||
if (remoteStream) remoteStream.getTracks().forEach(t => t.stop());
|
||||
if (pc) pc.close();
|
||||
if (remoteAudioEl) remoteAudioEl.srcObject = null;
|
||||
|
||||
pc = null;
|
||||
localStream = null;
|
||||
remoteStream = null;
|
||||
mediaRecorder = null;
|
||||
recorderChunks = [];
|
||||
audioContext = null;
|
||||
activeCallId = null;
|
||||
pendingOutboundAnswers.clear();
|
||||
recorderArmed = false;
|
||||
};
|
||||
|
||||
const buildPeerConnection = iceServers => {
|
||||
const config = iceServers && iceServers.length ? { iceServers } : {};
|
||||
pc = new RTCPeerConnection(config);
|
||||
remoteStream = new MediaStream();
|
||||
pc.ontrack = event => {
|
||||
// Reuse the same MediaStream object — the recorder's audioContext source
|
||||
// taps it once, so reassigning would orphan the recorder.
|
||||
const tracks =
|
||||
event.streams && event.streams[0]
|
||||
? event.streams[0].getTracks()
|
||||
: [event.track];
|
||||
tracks.forEach(track => {
|
||||
if (!remoteStream.getTracks().includes(track))
|
||||
remoteStream.addTrack(track);
|
||||
});
|
||||
playRemoteStream(remoteStream);
|
||||
// Only arm the recorder when the call is actually accepted. For outbound
|
||||
// this is the ACCEPTED status webhook; for inbound this is the agent's
|
||||
// own click (acceptIncomingCall flips recorderArmed before returning).
|
||||
if (recorderArmed) setupRecorder();
|
||||
};
|
||||
return pc;
|
||||
};
|
||||
|
||||
const stopRecorderAndUpload = async callId => {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
await new Promise(resolve => {
|
||||
mediaRecorder.addEventListener('stop', resolve, { once: true });
|
||||
try {
|
||||
mediaRecorder.stop();
|
||||
} catch (_) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!recorderChunks.length || !callId) return;
|
||||
|
||||
const blob = new Blob(recorderChunks, { type: recorderChunks[0].type });
|
||||
// Best-effort — the controller's idempotency guard handles a retry.
|
||||
try {
|
||||
await WhatsappCallsAPI.uploadRecording(callId, blob);
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
// devise-token-auth requires access-token / client / uid headers on every
|
||||
// request — navigator.sendBeacon can't set custom headers, so we rehydrate
|
||||
// the auth payload from the cw_d_session_info cookie that the dashboard sets
|
||||
// at login. Used by the page-close terminate path below.
|
||||
const getDeviseAuthHeaders = () => {
|
||||
try {
|
||||
const raw = Cookies.get('cw_d_session_info');
|
||||
if (!raw) return null;
|
||||
const session = JSON.parse(raw);
|
||||
return {
|
||||
'access-token': session['access-token'] || '',
|
||||
client: session.client || '',
|
||||
uid: session.uid || '',
|
||||
expiry: session.expiry || '',
|
||||
'token-type': session['token-type'] || 'Bearer',
|
||||
};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const beaconTerminate = callId => {
|
||||
if (!callId) return;
|
||||
const accountId = window.location.pathname.split('/')[3];
|
||||
if (!accountId) return;
|
||||
const headers = getDeviseAuthHeaders();
|
||||
if (!headers) return;
|
||||
const url = `/api/v1/accounts/${accountId}/whatsapp_calls/${callId}/terminate`;
|
||||
// fetch+keepalive (instead of navigator.sendBeacon) so we can attach auth
|
||||
// headers — without them devise-token-auth 401s and the call stays open on
|
||||
// Meta until its carrier-side timeout (~60s).
|
||||
try {
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: '{}',
|
||||
}).catch(() => {});
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
export const hasActiveWhatsappCall = () => !!(activeCallId || pc);
|
||||
|
||||
export const isLocalWhatsappCall = callId =>
|
||||
!!callId && activeCallId != null && callId === activeCallId;
|
||||
|
||||
export function useWhatsappCallSession() {
|
||||
const prepareInboundAnswer = async (sdpOffer, iceServers) => {
|
||||
cleanup();
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
buildPeerConnection(iceServers);
|
||||
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
return pc.localDescription.sdp;
|
||||
};
|
||||
|
||||
const prepareOutboundOffer = async () => {
|
||||
cleanup();
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
buildPeerConnection(DEFAULT_OUTBOUND_ICE_SERVERS);
|
||||
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
return pc.localDescription.sdp;
|
||||
};
|
||||
|
||||
const acceptIncomingCall = async ({ callId, sdpOffer, iceServers }) => {
|
||||
// The store may not have sdpOffer yet (the cable broadcast can race the
|
||||
// click). Fall back to GET /whatsapp_calls/:id which exposes it.
|
||||
let offer = sdpOffer;
|
||||
let ice = iceServers;
|
||||
if (!offer && callId) {
|
||||
try {
|
||||
const fresh = await WhatsappCallsAPI.show(callId);
|
||||
offer = fresh?.sdp_offer || fresh?.sdpOffer;
|
||||
ice = ice || fresh?.ice_servers || fresh?.iceServers;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
'[WhatsApp Call] failed to fetch call data for accept:',
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!offer) {
|
||||
throw new Error('Missing sdp_offer for accept — call may have ended.');
|
||||
}
|
||||
|
||||
// Release the mic + peer connection if anything between here and the accept
|
||||
// round-trip fails — otherwise a rejected accept leaves the mic live and
|
||||
// activeCallId set. Mirrors rejectIncomingCall's self-cleanup; rethrow so
|
||||
// the caller can surface the failure and skip marking the call active.
|
||||
try {
|
||||
const sdpAnswer = await prepareInboundAnswer(offer, ice);
|
||||
activeCallId = callId;
|
||||
// Inbound: agent's click is the pickup. Arm the recorder before the API
|
||||
// round-trip so when ontrack fires (triggered by setRemoteDescription
|
||||
// back in prepareInboundAnswer) the recorder is already authorized.
|
||||
recorderArmed = true;
|
||||
setupRecorder();
|
||||
await WhatsappCallsAPI.accept(callId, sdpAnswer);
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const rejectIncomingCall = async callId => {
|
||||
try {
|
||||
await WhatsappCallsAPI.reject(callId);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
const initiateOutboundCall = async conversationId => {
|
||||
// Module-scoped lock + active-session guard so a second click — from the
|
||||
// same composable instance OR a different one (header vs contact panel)
|
||||
// OR while a call is already live — can't tear down the in-flight setup
|
||||
// via prepareOutboundOffer's cleanup().
|
||||
if (isInitiatingOutbound.value)
|
||||
return { status: VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED };
|
||||
if (hasActiveWhatsappCall())
|
||||
return { status: VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED };
|
||||
isInitiatingOutbound.value = true;
|
||||
try {
|
||||
const sdpOffer = await prepareOutboundOffer();
|
||||
const response = await WhatsappCallsAPI.initiate(
|
||||
conversationId,
|
||||
sdpOffer
|
||||
);
|
||||
if (response?.id) {
|
||||
activeCallId = response.id;
|
||||
// A connect webhook that raced ahead of this response was buffered;
|
||||
// apply our own by id now that we know it, then drop every buffered
|
||||
// answer (concurrent agents' calls aren't ours to apply).
|
||||
const buffered = pendingOutboundAnswers.get(activeCallId);
|
||||
pendingOutboundAnswers.clear();
|
||||
if (buffered) {
|
||||
await pc.setRemoteDescription({
|
||||
type: 'answer',
|
||||
sdp: buffered,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
// No call id back: this is the permission-request branch. The mic +
|
||||
// PeerConnection allocated by prepareOutboundOffer aren't useful until
|
||||
// the contact opts in and the agent retries — release them.
|
||||
cleanup();
|
||||
return response;
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
// BE returns 422 when the contact hasn't opted in (permission template
|
||||
// sent or already pending). Surface it to the caller as a normal
|
||||
// response shape so it can render the banner instead of an error toast.
|
||||
const data = e?.response?.data;
|
||||
if (
|
||||
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_REQUESTED ||
|
||||
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
|
||||
) {
|
||||
return { status: data.status };
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
isInitiatingOutbound.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// callIdOverride is the call.id from the dashboard's calls store. Module
|
||||
// `activeCallId` may be null after a prior accept attempt's cleanup() — but
|
||||
// the call still exists on Meta and must still be terminated. Falling back
|
||||
// to the override means hangup is robust to a wiped local session.
|
||||
const endActiveCall = async (callIdOverride = null) => {
|
||||
const callId = activeCallId || callIdOverride;
|
||||
if (!callId) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Terminate on Meta first so the contact is disconnected immediately. The
|
||||
// recording upload below can be slow on long calls or poor networks, and
|
||||
// the peer connection / mic must not stay live for the contact during it.
|
||||
await WhatsappCallsAPI.terminate(callId).catch(() => {});
|
||||
await stopRecorderAndUpload(callId);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isInitiating: isInitiatingOutboundReadonly,
|
||||
prepareInboundAnswer,
|
||||
prepareOutboundOffer,
|
||||
acceptIncomingCall,
|
||||
rejectIncomingCall,
|
||||
initiateOutboundCall,
|
||||
endActiveCall,
|
||||
};
|
||||
}
|
||||
|
||||
// Cable handlers fire outside any composable instance, so the shared session
|
||||
// surface is exposed as module-level functions for them.
|
||||
|
||||
export const applyOutboundAnswer = async (callId, sdpAnswer) => {
|
||||
// No in-flight peer connection in this tab → not our call.
|
||||
if (!pc) return;
|
||||
// /initiate hasn't returned our call id yet (the connect webhook raced
|
||||
// ahead). Buffer the answer; initiateOutboundCall flushes it by id once it
|
||||
// knows which call is ours, so a concurrent agent's answer is never applied.
|
||||
if (activeCallId == null) {
|
||||
pendingOutboundAnswers.set(callId, sdpAnswer);
|
||||
return;
|
||||
}
|
||||
// activeCallId known → only apply the answer for this tab's call.
|
||||
if (callId !== activeCallId) return;
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
|
||||
};
|
||||
|
||||
// Called by the cable handler when Meta delivers status=ACCEPTED for the
|
||||
// outbound call (real pickup). Flips the recorder gate and starts the
|
||||
// MediaRecorder. Idempotent — safe if ontrack hasn't fired yet (setupRecorder
|
||||
// bails until the remote stream has audio tracks; ontrack will retry).
|
||||
export const armOutboundRecorder = () => {
|
||||
recorderArmed = true;
|
||||
setupRecorder();
|
||||
};
|
||||
|
||||
export const cleanupWhatsappSession = () => cleanup();
|
||||
|
||||
export const handleWhatsappRemoteEnd = async callId => {
|
||||
// Snapshot before cleanup nulls activeCallId.
|
||||
const id = callId || activeCallId;
|
||||
if (!id) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await stopRecorderAndUpload(id);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
export const setWhatsappCallMuted = muted => {
|
||||
if (!localStream) return false;
|
||||
localStream.getAudioTracks().forEach(track => {
|
||||
track.enabled = !muted;
|
||||
});
|
||||
return muted;
|
||||
};
|
||||
|
||||
export const sendWhatsappTerminateBeacon = () => {
|
||||
// Always fire when there's a live callId. The beacon endpoint is idempotent,
|
||||
// so racing it with an in-flight Axios terminate (which unload may abort) is
|
||||
// fine — Meta gets exactly one terminate either way, and we avoid leaving
|
||||
// the call ringing on Meta until its carrier-side timeout.
|
||||
if (!activeCallId) return;
|
||||
beaconTerminate(activeCallId);
|
||||
};
|
||||
Reference in New Issue
Block a user