fix(voice): outbound STUN, mute toggle, skip voice_call bubble in send pipeline

This commit is contained in:
Tanmay Deep Sharma
2026-04-30 20:43:35 +07:00
parent c25ed094fa
commit 0567655840
4 changed files with 70 additions and 4 deletions
@@ -1,8 +1,9 @@
<script setup>
import { watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
@@ -21,6 +22,22 @@ const {
formattedCallDuration,
} = useCallSession();
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
// don't expose a mic track on the browser side.
const isMuted = ref(false);
const isWhatsappActive = computed(
() => activeCall.value?.provider === 'whatsapp'
);
const toggleMute = () => {
isMuted.value = !isMuted.value;
setWhatsappCallMuted(isMuted.value);
};
watch(hasActiveCall, active => {
if (!active) isMuted.value = false;
});
const getCallInfo = call => {
const conversation = store.getters.getConversationById(call?.conversationId);
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
@@ -162,6 +179,30 @@ watch(
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
v-if="hasActiveCall && isWhatsappActive"
v-tooltip.top="
isMuted
? $t('CONVERSATION.VOICE_WIDGET.UNMUTE')
: $t('CONVERSATION.VOICE_WIDGET.MUTE')
"
class="flex justify-center items-center w-10 h-10 rounded-full transition-colors"
:class="
isMuted
? 'bg-n-amber-9 hover:bg-n-amber-10'
: 'bg-n-slate-3 hover:bg-n-slate-4'
"
@click="toggleMute"
>
<i
class="text-lg"
:class="
isMuted
? 'text-white i-ph-microphone-slash-bold'
: 'text-n-slate-12 i-ph-microphone-bold'
"
/>
</button>
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="
@@ -46,6 +46,11 @@ 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.
const DEFAULT_OUTBOUND_ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }];
const waitForIceGatheringComplete = peer =>
new Promise(resolve => {
if (peer.iceGatheringState === 'complete') {
@@ -174,7 +179,7 @@ export function useWhatsappCallSession() {
const prepareOutboundOffer = async () => {
cleanup();
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
buildPeerConnection();
buildPeerConnection(DEFAULT_OUTBOUND_ICE_SERVERS);
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
@@ -261,6 +266,22 @@ export const hasActiveWhatsappCall = () => Boolean(activeCallId);
// is removed by a cable end-event (the other side hung up).
export const cleanupWhatsappSession = () => cleanup();
// Mute helpers — toggle the mic track's enabled flag (instantaneous, no renegotiation).
export const setWhatsappCallMuted = muted => {
if (!localStream) return false;
localStream.getAudioTracks().forEach(track => {
track.enabled = !muted;
});
return muted;
};
export const isWhatsappCallMuted = () => {
if (!localStream) return false;
const tracks = localStream.getAudioTracks();
if (!tracks.length) return false;
return !tracks[0].enabled;
};
// Best-effort terminate when the tab actually closes after the beforeunload prompt.
export const sendWhatsappTerminateBeacon = () => {
if (!activeCallId || intentionallyClosing) return;
@@ -301,7 +301,9 @@
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
"END_CALL": "End call",
"MUTE": "Mute mic",
"UNMUTE": "Unmute mic"
}
},
"EMAIL_TRANSCRIPT": {