chore(voice): trim dead exports, fix duplicate-OR bug, drop redundant comments
Branch review pass: - useWhatsappCallSession: drop unused `error` ref and unused exports (hasActiveWhatsappCall, isWhatsappCallMuted), fold sendWhatsappCallBeacon into a private beaconTerminate helper since only sendWhatsappTerminateBeacon consumed it externally. Trim WHAT-comments; keep WHY-comments (browser-quirk explanations, race-condition notes, auth-cookie rationale). - VoiceCall.vue bubble: fix `data || data` short-circuit that did nothing — upstream key transform was the same on both branches; collapse to one read. - calls/useCallSession/actionCable/FloatingCallWidget/VoiceCallButton: drop comments that just describe what the next line already says. Net diff: -63 lines across 7 files. No behavior change.
This commit is contained in:
@@ -44,7 +44,6 @@ const voiceInboxes = computed(() =>
|
||||
);
|
||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
|
||||
// Unified behavior: hide when no phone
|
||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||
|
||||
const isInitiatingCall = computed(() => {
|
||||
@@ -128,7 +127,6 @@ const startCall = async inboxId => {
|
||||
});
|
||||
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||
|
||||
// Add call to store immediately so widget shows
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
|
||||
@@ -43,15 +43,14 @@ const audioAttachment = computed(() =>
|
||||
(attachments?.value || []).find(a => a.fileType === 'audio')
|
||||
);
|
||||
|
||||
// Duration lives in two places depending on which payload the FE got:
|
||||
// - call.duration_seconds / call.durationSeconds (push_event_data shape)
|
||||
// - content_attributes.data.duration_seconds (message-side mirror)
|
||||
// Both can be camelCased by useTransformKeys upstream — check every variant.
|
||||
// Duration may arrive on call.duration_seconds (push_event_data) or
|
||||
// content_attributes.data.duration_seconds — and either may be camelCased
|
||||
// upstream, so check every variant.
|
||||
const durationSeconds = computed(() => {
|
||||
const fromCall = call.value?.durationSeconds || call.value?.duration_seconds;
|
||||
if (fromCall != null) return fromCall;
|
||||
|
||||
const data = contentAttributes?.value?.data || contentAttributes?.value?.data;
|
||||
const data = contentAttributes?.value?.data;
|
||||
return data?.durationSeconds || data?.duration_seconds;
|
||||
});
|
||||
|
||||
@@ -118,8 +117,6 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
<!-- When the audio chip is rendered it already shows duration in
|
||||
its own player; suppress here to avoid two competing numbers. -->
|
||||
{{
|
||||
audioAttachment
|
||||
? $t(subtextKey)
|
||||
|
||||
@@ -77,15 +77,12 @@ const handleJoinCall = async call => {
|
||||
if (!call || isJoining.value) return;
|
||||
const { conversation } = getCallInfo(call);
|
||||
|
||||
// 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.
|
||||
// The conversation may not be hydrated yet (post-refresh seeding path);
|
||||
// call.inboxId already carries what joinCall needs.
|
||||
const result = await joinCall({
|
||||
conversationId: call.conversationId,
|
||||
inboxId: call.inboxId || conversation?.inbox_id,
|
||||
|
||||
@@ -41,22 +41,19 @@ export function useCallSession() {
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 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. 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.
|
||||
// Warn before a refresh/close drops a live or ringing call. Cable events
|
||||
// aren't replayed on reconnect, so a confirmed refresh during ringing would
|
||||
// leave the agent unable to accept; for active calls the WebRTC session
|
||||
// dies outright (no rejoin path).
|
||||
const handleBeforeUnload = event => {
|
||||
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.
|
||||
// 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.
|
||||
const seedCallsFromHydratedMessages = () => {
|
||||
const conversations = store.getters.getAllConversations || [];
|
||||
const currentUserId = store.getters.getCurrentUserID;
|
||||
@@ -69,11 +66,8 @@ export function useCallSession() {
|
||||
});
|
||||
};
|
||||
|
||||
// pagehide fires after the user confirms the refresh prompt. Terminate the
|
||||
// active call only — its WebRTC session dies with the page and can't be
|
||||
// rejoined. Ringing calls intentionally stay alive on Meta so the agent can
|
||||
// pick them up after the page reloads (FloatingCallWidget rehydrates them
|
||||
// via seedCallsFromHydratedMessages once the conversation messages land).
|
||||
// Terminate only the active call — ringing calls stay alive on Meta so the
|
||||
// agent can pick them up after reload (seeded back via the watcher above).
|
||||
const handlePageHide = () => {
|
||||
sendWhatsappTerminateBeacon();
|
||||
};
|
||||
@@ -90,9 +84,7 @@ export function useCallSession() {
|
||||
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).
|
||||
// Re-seed when conversations stream in after mount; addCall merges by callSid.
|
||||
watch(
|
||||
() => store.getters.getAllConversations?.length,
|
||||
() => seedCallsFromHydratedMessages()
|
||||
@@ -163,9 +155,7 @@ export function useCallSession() {
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to join call:', error);
|
||||
// Tear down any half-built WebRTC state so the user's next click starts
|
||||
// fresh; otherwise the leftover pc + mic stream survives and confuses
|
||||
// the second-attempt SDP exchange.
|
||||
// Drop any half-built WebRTC state so the next click starts fresh.
|
||||
cleanupWhatsappSession();
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
@@ -2,9 +2,8 @@ import { ref } from 'vue';
|
||||
import Cookies from 'js-cookie';
|
||||
import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI';
|
||||
|
||||
// Browser ↔ Meta WebRTC is a singleton — only one PeerConnection at a time can
|
||||
// hold the user's mic. Module-level state lets cable handlers and the pagehide
|
||||
// listener reach the live session without prop-drilling refs through composables.
|
||||
// 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;
|
||||
@@ -15,9 +14,6 @@ let audioContext = null;
|
||||
let activeCallId = null;
|
||||
let intentionallyClosing = false;
|
||||
|
||||
// Lazily attach a hidden <audio autoplay> to the document so Meta's track
|
||||
// actually plays through the speakers — without this, mic flows to Meta but
|
||||
// the user hears nothing back.
|
||||
const ensureRemoteAudioElement = () => {
|
||||
if (remoteAudioEl) return remoteAudioEl;
|
||||
remoteAudioEl = document.createElement('audio');
|
||||
@@ -32,15 +28,14 @@ const ensureRemoteAudioElement = () => {
|
||||
const playRemoteStream = stream => {
|
||||
const el = ensureRemoteAudioElement();
|
||||
el.srcObject = stream;
|
||||
// play() may reject under autoplay policies; surface to console but don't crash the call.
|
||||
el.play().catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[WhatsApp Call] remote audio play() failed:', err);
|
||||
});
|
||||
};
|
||||
|
||||
// Smaller timeslice → chunks flush to memory every second so a remote hangup
|
||||
// that races cleanup still leaves data behind to upload.
|
||||
// 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 = [
|
||||
@@ -49,9 +44,9 @@ const RECORDER_MIME_CANDIDATES = [
|
||||
'audio/ogg;codecs=opus',
|
||||
];
|
||||
|
||||
// Outbound calls don't get ice_servers from the backend (call doesn't exist yet
|
||||
// at offer time). Without STUN the browser only has host candidates which can't
|
||||
// reach Meta through NAT, so the browser→Meta direction silently drops media.
|
||||
// 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 =>
|
||||
@@ -95,16 +90,15 @@ const cleanup = () => {
|
||||
intentionallyClosing = false;
|
||||
};
|
||||
|
||||
// Mix local mic + remote audio via Web Audio so the recording captures both legs.
|
||||
const setupRecorder = () => {
|
||||
if (!localStream || !remoteStream || mediaRecorder) return;
|
||||
// Without at least one remote track, createMediaStreamSource on remoteStream
|
||||
// wires up to nothing — the recorded mix is effectively just silence.
|
||||
// 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. Resume so the
|
||||
// graph actually runs; otherwise the destination stream produces silence.
|
||||
// AudioContext starts suspended under most autoplay policies; without
|
||||
// resume() the destination stream produces silence.
|
||||
audioContext.resume().catch(() => {});
|
||||
|
||||
const destination = audioContext.createMediaStreamDestination();
|
||||
@@ -129,9 +123,8 @@ const buildPeerConnection = iceServers => {
|
||||
pc = new RTCPeerConnection(config);
|
||||
remoteStream = new MediaStream();
|
||||
pc.ontrack = event => {
|
||||
// Add to the stable placeholder stream so any sources/recorders referencing
|
||||
// it stay connected — never reassign the variable, since the recorder's
|
||||
// audioContext source taps the original MediaStream object.
|
||||
// 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()
|
||||
@@ -141,8 +134,6 @@ const buildPeerConnection = iceServers => {
|
||||
remoteStream.addTrack(track);
|
||||
});
|
||||
playRemoteStream(remoteStream);
|
||||
// Defer recorder setup until we actually have remote tracks; createMediaStreamSource
|
||||
// on an empty MediaStream is unreliable across browsers.
|
||||
setupRecorder();
|
||||
};
|
||||
return pc;
|
||||
@@ -162,16 +153,60 @@ const stopRecorderAndUpload = async callId => {
|
||||
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 (_) {
|
||||
/* best-effort — server-side idempotency guard handles a retry */
|
||||
/* 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 function useWhatsappCallSession() {
|
||||
const isInitiating = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const prepareInboundAnswer = async (sdpOffer, iceServers) => {
|
||||
cleanup();
|
||||
@@ -182,7 +217,6 @@ export function useWhatsappCallSession() {
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
// Recorder fires from ontrack once remote tracks arrive.
|
||||
return pc.localDescription.sdp;
|
||||
};
|
||||
|
||||
@@ -198,9 +232,8 @@ export function useWhatsappCallSession() {
|
||||
};
|
||||
|
||||
const acceptIncomingCall = async ({ callId, sdpOffer, iceServers }) => {
|
||||
// The store may not have sdpOffer yet (cable's voice_call.incoming raced
|
||||
// the click), so fall back to GET /whatsapp_calls/:id which exposes the
|
||||
// SDP offer + ICE servers from the show jbuilder.
|
||||
// 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) {
|
||||
@@ -237,19 +270,17 @@ export function useWhatsappCallSession() {
|
||||
const initiateOutboundCall = async conversationId => {
|
||||
if (isInitiating.value) return null;
|
||||
isInitiating.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const sdpOffer = await prepareOutboundOffer();
|
||||
const response = await WhatsappCallsAPI.initiate(
|
||||
conversationId,
|
||||
sdpOffer
|
||||
);
|
||||
// Permission flow returns no call id — let the caller render the banner.
|
||||
// The permission-request branch returns no call id; let the caller render the banner.
|
||||
activeCallId = response?.id || null;
|
||||
return response;
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
error.value = e;
|
||||
throw e;
|
||||
} finally {
|
||||
isInitiating.value = false;
|
||||
@@ -273,7 +304,6 @@ export function useWhatsappCallSession() {
|
||||
|
||||
return {
|
||||
isInitiating,
|
||||
error,
|
||||
prepareInboundAnswer,
|
||||
prepareOutboundOffer,
|
||||
acceptIncomingCall,
|
||||
@@ -283,23 +313,17 @@ export function useWhatsappCallSession() {
|
||||
};
|
||||
}
|
||||
|
||||
// Cable handlers fire outside any composable instance; expose the shared session
|
||||
// surface so they can apply the outbound answer onto the live PeerConnection.
|
||||
// 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) => {
|
||||
if (!pc) return;
|
||||
activeCallId = callId;
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
|
||||
// Recorder + audio playback fire from ontrack as soon as Meta's tracks arrive.
|
||||
};
|
||||
|
||||
export const hasActiveWhatsappCall = () => Boolean(activeCallId);
|
||||
|
||||
// Used by the calls store as a sync teardown safety net.
|
||||
export const cleanupWhatsappSession = () => cleanup();
|
||||
|
||||
// Cable-driven end (contact hung up / call timed out). Flush any in-memory
|
||||
// recorder chunks and upload them so the resulting message bubble shows the
|
||||
// audio + transcript — without this, only agent-initiated hangups upload.
|
||||
export const handleWhatsappRemoteEnd = async callId => {
|
||||
// Snapshot before cleanup nulls activeCallId.
|
||||
const id = callId || activeCallId;
|
||||
@@ -314,7 +338,6 @@ export const handleWhatsappRemoteEnd = async callId => {
|
||||
}
|
||||
};
|
||||
|
||||
// Mute helpers — toggle the mic track's enabled flag (instantaneous, no renegotiation).
|
||||
export const setWhatsappCallMuted = muted => {
|
||||
if (!localStream) return false;
|
||||
localStream.getAudioTracks().forEach(track => {
|
||||
@@ -323,66 +346,7 @@ export const setWhatsappCallMuted = muted => {
|
||||
return muted;
|
||||
};
|
||||
|
||||
export const isWhatsappCallMuted = () => {
|
||||
if (!localStream) return false;
|
||||
const tracks = localStream.getAudioTracks();
|
||||
if (!tracks.length) return false;
|
||||
return !tracks[0].enabled;
|
||||
};
|
||||
|
||||
// devise-token-auth requires access-token / client / uid headers on every
|
||||
// request — navigator.sendBeacon can't set custom headers, so the dashboard
|
||||
// stashes the auth payload in the cw_d_session_info cookie at login and we
|
||||
// rehydrate it here for unload-time requests.
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// Best-effort terminate beacon for any WhatsApp call — the backend's terminate
|
||||
// endpoint handles both ringing and in_progress states (terminate while ringing
|
||||
// records 'no_answer' which is the right shape for "agent left the page").
|
||||
// Browser-direct WebRTC has no rejoin path, so the only sensible thing on page
|
||||
// close is to release the call on Meta's side.
|
||||
//
|
||||
// Uses fetch+keepalive instead of navigator.sendBeacon so we can attach the
|
||||
// devise-token-auth headers — without them the request 401s and the call
|
||||
// stays open on Meta until the carrier-side timeout (~60s).
|
||||
export const sendWhatsappCallBeacon = 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`;
|
||||
try {
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: '{}',
|
||||
}).catch(() => {});
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
// Backward-compat wrapper for the active-call case — guarded by
|
||||
// intentionallyClosing so we don't double-terminate after an explicit hangup.
|
||||
export const sendWhatsappTerminateBeacon = () => {
|
||||
if (!activeCallId || intentionallyClosing) return;
|
||||
sendWhatsappCallBeacon(activeCallId);
|
||||
beaconTerminate(activeCallId);
|
||||
};
|
||||
|
||||
@@ -40,8 +40,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
// WhatsApp call SDP exchange happens via these events; Twilio-shaped voice_call.*
|
||||
// events also flow through here but are ignored when provider !== 'whatsapp'.
|
||||
'voice_call.incoming': this.onVoiceCallIncoming,
|
||||
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
|
||||
'voice_call.ended': this.onVoiceCallEnded,
|
||||
@@ -228,8 +226,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
provider: 'whatsapp',
|
||||
sdpOffer: data.sdp_offer,
|
||||
iceServers: data.ice_servers,
|
||||
// Caller info for the FloatingCallWidget so it doesn't show "Unknown caller"
|
||||
// before the conversation/contact has loaded into the store.
|
||||
caller: data.caller,
|
||||
});
|
||||
};
|
||||
@@ -243,13 +239,12 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallEnded = async data => {
|
||||
if (data?.provider !== 'whatsapp') return;
|
||||
// Must await the upload-and-cleanup BEFORE removeCall, because the store's
|
||||
// sync teardownByProvider -> cleanupWhatsappSession would otherwise wipe
|
||||
// mediaRecorder + recorderChunks before any upload microtask gets to run.
|
||||
// Await upload before removeCall — the store's sync teardown would otherwise
|
||||
// wipe the recorder chunks before they reach the server.
|
||||
try {
|
||||
await handleWhatsappRemoteEnd(data.id);
|
||||
} catch (_) {
|
||||
/* noop — upload is best-effort */
|
||||
/* noop */
|
||||
}
|
||||
useCallsStore().removeCall(data.call_id);
|
||||
};
|
||||
|
||||
@@ -28,12 +28,8 @@ export const useCallsStore = defineStore('calls', {
|
||||
if (!TERMINAL_STATUSES.includes(status)) return;
|
||||
|
||||
const call = this.calls.find(c => c.callSid === callSid);
|
||||
// For WhatsApp, the upload-and-cleanup must happen before the recorder
|
||||
// state is wiped — that runs from the voice_call.ended cable handler.
|
||||
// If we tear down here (race-winning the cable end-event), the recorder
|
||||
// chunks are gone before they get uploaded, so the recording is lost.
|
||||
// Just drop the call from the store; voice_call.ended will idempotently
|
||||
// finish cleanup once it arrives.
|
||||
// WhatsApp recordings live in the in-memory recorder until voice_call.ended
|
||||
// uploads them; tearing down here would race-wipe those chunks.
|
||||
if (call?.provider === 'whatsapp') {
|
||||
this.calls = this.calls.filter(c => c.callSid !== callSid);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user