mirror of
https://github.com/chatwoot/chatwoot.git
synced 2026-06-19 21:07:35 +08:00
Some checks failed
Frontend Lint & Test / test (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
## Summary Frontend for WhatsApp Cloud Calling: header / contact-panel call buttons, ringing widget, accept/reject/hangup, mute, in-bubble audio player + transcript, recording-on-hangup upload, mid-call reload warning. WebRTC is browser-direct to Meta — no media server bridge. ## Closes - https://linear.app/chatwoot/issue/PLA-150 ## How to test Requires backend support — the controller, services, model changes, and routes ship in **#14334** (`feature/pla-150`). Merge / deploy that first (or simultaneously); the FE alone won't function without those endpoints. Then on staging, for a WhatsApp Cloud + embedded-signup inbox with the new \`Configuration → Enable voice calling\` toggle ON and webhook registered: 1. **Outbound** — open a conversation, click the phone icon in the conversation header (or contact panel), grant mic, your phone rings, answer, audio both ways, hang up. Recording + transcript land in the bubble within ~10s. 2. **Inbound** — call the business number from your phone. The FloatingCallWidget appears bottom-right with caller name. Click accept, audio both ways, hang up. Recording + transcript appear. 3. **Mute** — during an active WhatsApp call, click the mic icon next to hangup. Speech stops reaching Meta until you click again. 4. **Mid-call reload guard** — try `Cmd-R` during an active call; browser shows a confirm prompt. --------- Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]> Co-authored-by: iamsivin <[email protected]> Co-authored-by: Sivin Varghese <[email protected]>
321 lines
12 KiB
JavaScript
321 lines
12 KiB
JavaScript
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';
|
|
|
|
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;
|
|
});
|
|
TwilioVoiceClient.addEventListener(
|
|
'call:disconnected',
|
|
handleTwilioDisconnectedGlobal
|
|
);
|
|
window.addEventListener('beforeunload', handleBeforeUnloadGlobal);
|
|
window.addEventListener('pagehide', handlePageHideGlobal);
|
|
};
|
|
|
|
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);
|
|
};
|
|
|
|
// 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 }) => {
|
|
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 (globalIsJoining.value) return null;
|
|
|
|
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;
|
|
|
|
const joinResponse = await VoiceAPI.joinConference({
|
|
conversationId,
|
|
inboxId,
|
|
callSid,
|
|
});
|
|
|
|
await TwilioVoiceClient.joinClientCall({
|
|
to: joinResponse?.conference_sid,
|
|
conversationId,
|
|
callSid,
|
|
});
|
|
|
|
callsStore.setCallActive(callSid);
|
|
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 {
|
|
globalIsJoining.value = false;
|
|
}
|
|
};
|
|
|
|
// 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 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: globalIsJoiningReadonly,
|
|
formattedCallDuration,
|
|
};
|
|
};
|
|
|
|
// 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 };
|
|
}
|