feat(whatsapp): add calling enable/disable toggle in inbox settings UI

This commit is contained in:
Tanmay Deep Sharma
2026-03-06 10:09:53 +05:30
parent fd69b4c8f2
commit f849cbb76a
23 changed files with 1145 additions and 4 deletions
@@ -0,0 +1,42 @@
/* global axios */
class WhatsappCallsAPI {
constructor() {
this.apiVersion = '/api/v1';
}
// eslint-disable-next-line class-methods-use-this
get accountIdFromRoute() {
const isInsideAccountScopedURLs =
window.location.pathname.includes('/app/accounts');
if (isInsideAccountScopedURLs) {
return window.location.pathname.split('/')[3];
}
return '';
}
get baseUrl() {
return `${this.apiVersion}/accounts/${this.accountIdFromRoute}/whatsapp_calls`;
}
accept(callId, sdpAnswer) {
return axios.post(`${this.baseUrl}/${callId}/accept`, {
sdp_answer: sdpAnswer,
});
}
reject(callId) {
return axios.post(`${this.baseUrl}/${callId}/reject`);
}
terminate(callId) {
return axios.post(`${this.baseUrl}/${callId}/terminate`);
}
initiate(conversationId) {
return axios.post(`${this.baseUrl}/initiate`, {
conversation_id: conversationId,
});
}
}
export default new WhatsappCallsAPI();
@@ -0,0 +1,203 @@
<script setup>
import { watch, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const router = useRouter();
const {
activeCall,
incomingCalls,
hasActiveCall,
hasIncomingCall,
isAccepting,
isMuted,
callError,
formattedCallDuration,
acceptCall,
rejectCall,
endActiveCall,
toggleMute,
dismissIncomingCall,
} = useWhatsappCallSession();
// Auto-dismiss ringing calls after 30 seconds
const autoRejectTimers = new Map();
const startAutoRejectTimer = call => {
if (autoRejectTimers.has(call.callId)) return;
const timer = setTimeout(() => {
dismissIncomingCall(call);
autoRejectTimers.delete(call.callId);
}, 30000);
autoRejectTimers.set(call.callId, timer);
};
const clearAutoRejectTimer = callId => {
const timer = autoRejectTimers.get(callId);
if (timer) {
clearTimeout(timer);
autoRejectTimers.delete(callId);
}
};
const handleAccept = async call => {
clearAutoRejectTimer(call.callId);
await acceptCall(call);
if (activeCall.value) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
});
}
};
const handleReject = async call => {
clearAutoRejectTimer(call.callId);
await rejectCall(call);
};
const handleEndCall = async () => {
await endActiveCall();
};
// Start auto-reject timers for each newly added incoming call
watch(
incomingCalls,
calls => {
calls.forEach(call => startAutoRejectTimer(call));
},
{ immediate: true, deep: true }
);
onUnmounted(() => {
autoRejectTimers.forEach(timer => clearTimeout(timer));
autoRejectTimers.clear();
});
</script>
<template>
<div
v-if="hasIncomingCall || hasActiveCall"
class="fixed ltr:right-4 rtl:left-4 bottom-20 z-50 flex flex-col gap-2 w-72"
>
<!-- Error banner -->
<div
v-if="callError"
class="px-3 py-2 bg-n-ruby-3 border border-n-ruby-6 rounded-lg text-xs text-n-ruby-11"
>
{{ callError }}
</div>
<!-- Incoming calls (shown when there's no active call yet) -->
<template v-if="!hasActiveCall">
<div
v-for="call in incomingCalls"
:key="call.callId"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div
class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex"
>
<Avatar
:src="call.caller?.avatar"
:name="call.caller?.name || call.caller?.phone"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{
call.caller?.name ||
call.caller?.phone ||
t('WHATSAPP_CALL.UNKNOWN_CALLER')
}}
</p>
<p class="text-xs text-n-slate-11 truncate">
{{ t('WHATSAPP_CALL.INCOMING_WHATSAPP_CALL') }}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
:title="t('WHATSAPP_CALL.REJECT')"
@click="handleReject(call)"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
:disabled="isAccepting"
:title="t('WHATSAPP_CALL.ACCEPT')"
@click="handleAccept(call)"
>
<i
v-if="isAccepting"
class="text-lg text-white i-ph-circle-notch animate-spin"
/>
<i v-else class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
</template>
<!-- Active call widget -->
<div
v-if="hasActiveCall"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div class="ring-2 ring-n-teal-9 rounded-full inline-flex">
<Avatar
:src="activeCall.caller?.avatar"
:name="activeCall.caller?.name || activeCall.caller?.phone"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{
activeCall.caller?.name ||
activeCall.caller?.phone ||
t('WHATSAPP_CALL.UNKNOWN_CALLER')
}}
</p>
<p class="font-mono text-sm text-n-teal-9">
{{ formattedCallDuration }}
</p>
</div>
<div class="flex shrink-0 gap-2">
<!-- Mute toggle -->
<button
class="flex justify-center items-center w-9 h-9 rounded-full transition-colors"
:class="
isMuted
? 'bg-n-amber-9 hover:bg-n-amber-10'
: 'bg-n-slate-4 hover:bg-n-slate-5'
"
:title="isMuted ? t('WHATSAPP_CALL.UNMUTE') : t('WHATSAPP_CALL.MUTE')"
@click="toggleMute"
>
<i
class="text-base text-white"
:class="
isMuted ? 'i-ph-microphone-slash-bold' : 'i-ph-microphone-bold'
"
/>
</button>
<!-- Hang up -->
<button
class="flex justify-center items-center w-9 h-9 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
:title="t('WHATSAPP_CALL.HANG_UP')"
@click="handleEndCall"
>
<i class="text-base text-white i-ph-phone-x-bold" />
</button>
</div>
</div>
</div>
</template>
@@ -13,6 +13,9 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
import WhatsappCallsAPI from 'dashboard/api/whatsappCalls';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
const props = defineProps({
chat: {
@@ -30,7 +33,8 @@ const store = useStore();
const route = useRoute();
const conversationHeader = ref(null);
const { width } = useElementSize(conversationHeader);
const { isAWebWidgetInbox } = useInbox();
const { isAWebWidgetInbox, isAWhatsAppCloudChannel } = useInbox();
const isInitiatingCall = ref(false);
const currentChat = computed(() => store.getters.getSelectedChat);
const accountId = computed(() => store.getters.getCurrentAccountId);
@@ -90,6 +94,32 @@ const hasMultipleInboxes = computed(
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const canInitiateWhatsappCall = computed(() => {
if (!isAWhatsAppCloudChannel.value) return false;
return !!inbox.value?.callingEnabled;
});
const initiateWhatsappCall = async () => {
if (isInitiatingCall.value || !currentChat.value?.id) return;
isInitiatingCall.value = true;
try {
await WhatsappCallsAPI.initiate(currentChat.value.id);
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: t('WHATSAPP_CALL.CALLING'),
type: 'success',
});
} catch (err) {
const errorMessage =
err.response?.data?.error || t('WHATSAPP_CALL.CALL_FAILED');
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: errorMessage,
type: 'error',
});
} finally {
isInitiatingCall.value = false;
}
};
</script>
<template>
@@ -151,6 +181,19 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
:parent-width="width"
class="hidden md:flex"
/>
<button
v-if="canInitiateWhatsappCall"
v-tooltip="$t('WHATSAPP_CALL.INITIATE_CALL')"
class="flex items-center justify-center w-8 h-8 rounded-lg text-n-slate-11 hover:text-n-slate-12 hover:bg-n-slate-3 transition-colors"
:disabled="isInitiatingCall"
@click="initiateWhatsappCall"
>
<i
v-if="isInitiatingCall"
class="text-base i-ph-circle-notch animate-spin"
/>
<i v-else class="text-base i-ph-phone-bold" />
</button>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
@@ -0,0 +1,191 @@
import { ref, computed, onUnmounted } from 'vue';
import { useWhatsappCallsStore } from 'dashboard/stores/whatsappCalls';
import WhatsappCallsAPI from 'dashboard/api/whatsappCalls';
import Timer from 'dashboard/helper/Timer';
export function useWhatsappCallSession() {
const callsStore = useWhatsappCallsStore();
// WebRTC internals
let peerConnection = null;
let localStream = null;
const remoteAudio = ref(null);
// UI state
const isAccepting = ref(false);
const isMuted = ref(false);
const callError = ref(null);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
callDuration.value = elapsed;
});
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
const firstIncomingCall = computed(() => callsStore.firstIncomingCall);
const formattedCallDuration = computed(() => {
const minutes = Math.floor(callDuration.value / 60);
const seconds = callDuration.value % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
const cleanupWebRTC = () => {
if (localStream) {
localStream.getTracks().forEach(t => t.stop());
localStream = null;
}
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
if (remoteAudio.value) {
remoteAudio.value.srcObject = null;
// Remove dynamically created audio element from DOM
if (remoteAudio.value.parentNode) {
remoteAudio.value.parentNode.removeChild(remoteAudio.value);
}
remoteAudio.value = null;
}
};
/**
* Accepts an incoming WhatsApp call:
* 1. Requests mic access
* 2. Creates RTCPeerConnection with ICE servers from the call payload
* 3. Sets remote description (the SDP offer from Meta)
* 4. Creates an SDP answer
* 5. Posts the SDP answer to Chatwoot backend → Meta API
*/
const acceptCall = async call => {
if (isAccepting.value) return;
isAccepting.value = true;
callError.value = null;
try {
// 1. Get microphone access
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
// 2. Build ICE config
const iceServers = call.iceServers?.length
? call.iceServers
: [{ urls: 'stun:stun.l.google.com:19302' }];
// 3. Create RTCPeerConnection
peerConnection = new RTCPeerConnection({ iceServers });
// 4. Add local audio tracks
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
});
// 5. Handle remote audio stream → play via hidden <audio> element
peerConnection.ontrack = event => {
const [stream] = event.streams;
if (remoteAudio.value) {
remoteAudio.value.srcObject = stream;
remoteAudio.value.play().catch(() => {});
} else {
// Fallback: create audio element dynamically
const audio = document.createElement('audio');
audio.srcObject = stream;
audio.autoplay = true;
document.body.appendChild(audio);
remoteAudio.value = audio;
}
};
// 6. Set remote description from Meta's SDP offer
await peerConnection.setRemoteDescription({
type: 'offer',
sdp: call.sdpOffer,
});
// 7. Create SDP answer
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
// 8. Post the SDP answer to Chatwoot backend
await WhatsappCallsAPI.accept(call.id, answer.sdp);
// 9. Mark as active in store
callsStore.removeIncomingCall(call.callId);
callsStore.setActiveCall({
...call,
peerConnection,
});
durationTimer.start();
} catch (err) {
callError.value =
err.name === 'NotAllowedError'
? 'Microphone access denied. Please allow mic access and try again.'
: 'Failed to accept call. Please try again.';
cleanupWebRTC();
} finally {
isAccepting.value = false;
}
};
const rejectCall = async call => {
try {
await WhatsappCallsAPI.reject(call.id);
} catch {
// Best effort
} finally {
callsStore.removeIncomingCall(call.callId);
}
};
const endActiveCall = async () => {
const call = activeCall.value;
if (!call) return;
try {
await WhatsappCallsAPI.terminate(call.id);
} catch {
// Best effort — always cleanup locally
} finally {
cleanupWebRTC();
callsStore.clearActiveCall();
durationTimer.stop();
callDuration.value = 0;
}
};
const toggleMute = () => {
if (!localStream) return;
const audioTrack = localStream.getAudioTracks()[0];
if (!audioTrack) return;
audioTrack.enabled = !audioTrack.enabled;
isMuted.value = !audioTrack.enabled;
};
const dismissIncomingCall = call => {
callsStore.removeIncomingCall(call.callId);
};
onUnmounted(() => {
durationTimer.stop();
});
return {
activeCall,
incomingCalls,
hasActiveCall,
hasIncomingCall,
firstIncomingCall,
isAccepting,
isMuted,
callError,
formattedCallDuration,
acceptCall,
rejectCall,
endActiveCall,
toggleMute,
dismissIncomingCall,
};
}
@@ -4,6 +4,7 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import { useWhatsappCallsStore } from 'dashboard/stores/whatsappCalls';
const { isImpersonating } = useImpersonation();
@@ -34,6 +35,9 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
'copilot.message.created': this.onCopilotMessageCreated,
'whatsapp_call.incoming': this.onWhatsappCallIncoming,
'whatsapp_call.accepted': this.onWhatsappCallAccepted,
'whatsapp_call.ended': this.onWhatsappCallEnded,
};
}
@@ -200,6 +204,37 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
// eslint-disable-next-line class-methods-use-this
onWhatsappCallIncoming = data => {
const whatsappCallsStore = useWhatsappCallsStore();
whatsappCallsStore.addIncomingCall({
id: data.id,
callId: data.call_id,
direction: data.direction,
inboxId: data.inbox_id,
conversationId: data.conversation_id,
caller: data.caller,
sdpOffer: data.sdp_offer,
iceServers: data.ice_servers,
});
};
// eslint-disable-next-line class-methods-use-this
onWhatsappCallAccepted = data => {
const whatsappCallsStore = useWhatsappCallsStore();
const currentUserId = this.app.$store.getters.getCurrentUserID;
// If accepted by a different agent, remove from incoming list for this agent
if (data.accepted_by_agent_id !== currentUserId) {
whatsappCallsStore.handleCallAcceptedByOther(data.call_id);
}
};
// eslint-disable-next-line class-methods-use-this
onWhatsappCallEnded = data => {
const whatsappCallsStore = useWhatsappCallsStore();
whatsappCallsStore.handleCallEnded(data.call_id);
};
}
export default {
@@ -759,6 +759,9 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
"WHATSAPP_CALLING_TITLE": "WhatsApp Calling",
"WHATSAPP_CALLING_SUBHEADER": "Enable agents to make and receive WhatsApp voice calls directly from Chatwoot. Requires your WhatsApp number to be approved for the Calling API by Meta.",
"WHATSAPP_CALLING_LABEL": "Enable WhatsApp Calling",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
},
"HELP_CENTER": {
@@ -36,6 +36,7 @@ import signup from './signup.json';
import sla from './sla.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import whatsappCall from './whatsappCall.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
import yearInReview from './yearInReview.json';
@@ -79,6 +80,7 @@ export default {
...sla,
...teamsSettings,
...whatsappTemplates,
...whatsappCall,
...contentTemplates,
...mfa,
...yearInReview,
@@ -0,0 +1,17 @@
{
"WHATSAPP_CALL": {
"INCOMING_WHATSAPP_CALL": "Incoming WhatsApp Call",
"OUTGOING_WHATSAPP_CALL": "Outgoing WhatsApp Call",
"ACCEPT": "Accept",
"REJECT": "Reject",
"HANG_UP": "Hang Up",
"MUTE": "Mute",
"UNMUTE": "Unmute",
"INITIATE_CALL": "Call via WhatsApp",
"CALLING": "Calling…",
"CALL_FAILED": "Call failed. Please try again.",
"UNKNOWN_CALLER": "Unknown caller",
"MIC_DENIED": "Microphone access denied. Please allow mic access and try again.",
"CALL_TAKEN": "Call accepted by another agent"
}
}
@@ -20,11 +20,16 @@ const FloatingCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
);
const WhatsappCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/WhatsappCallWidget.vue')
);
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
import { useCallsStore } from 'dashboard/stores/calls';
import { useWhatsappCallsStore } from 'dashboard/stores/whatsappCalls';
export default {
components: {
@@ -36,6 +41,7 @@ export default {
CopilotLauncher,
CopilotContainer,
FloatingCallWidget,
WhatsappCallWidget,
MobileSidebarLauncher,
},
setup() {
@@ -44,6 +50,7 @@ export default {
const { accountId } = useAccount();
const { width: windowWidth } = useWindowSize();
const callsStore = useCallsStore();
const whatsappCallsStore = useWhatsappCallsStore();
return {
uiSettings,
@@ -53,6 +60,10 @@ export default {
windowWidth,
hasActiveCall: computed(() => callsStore.hasActiveCall),
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
hasWhatsappCall: computed(
() =>
whatsappCallsStore.hasActiveCall || whatsappCallsStore.hasIncomingCall
),
};
},
data() {
@@ -163,6 +174,7 @@ export default {
/>
<CopilotContainer />
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
<WhatsappCallWidget v-if="hasWhatsappCall" />
</template>
<AddAccountModal
:show="showCreateAccountModal"
@@ -38,6 +38,7 @@ export default {
isSyncingTemplates: false,
allowedDomains: '',
isUpdatingAllowedDomains: false,
callingEnabled: false,
};
},
validations: {
@@ -66,6 +67,8 @@ export default {
setDefaults() {
this.hmacMandatory = this.inbox.hmac_mandatory || false;
this.allowedDomains = this.inbox.allowed_domains || '';
this.callingEnabled =
this.inbox.provider_config?.calling_enabled || false;
},
handleHmacFlag() {
this.updateInbox();
@@ -131,6 +134,23 @@ export default {
await this.$refs.whatsappReauth.requestAuthorization();
}
},
async updateCallingEnabled() {
try {
await this.$store.dispatch('inboxes/updateInbox', {
id: this.inbox.id,
formData: false,
channel: {
provider_config: {
...this.inbox.provider_config,
calling_enabled: this.callingEnabled,
},
},
});
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async syncTemplates() {
this.isSyncingTemplates = true;
try {
@@ -401,6 +421,22 @@ export default {
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
</NextButton>
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CALLING_TITLE')"
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CALLING_SUBHEADER')"
>
<div class="flex gap-2 items-center">
<input
id="callingEnabled"
v-model="callingEnabled"
type="checkbox"
@change="updateCallingEnabled"
/>
<label for="callingEnabled" class="text-body-main text-n-slate-12">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CALLING_LABEL') }}
</label>
</div>
</SettingsFieldSection>
</div>
<WhatsappReauthorize
v-if="isEmbeddedSignupWhatsApp"
@@ -0,0 +1,48 @@
import { defineStore } from 'pinia';
export const useWhatsappCallsStore = defineStore('whatsappCalls', {
state: () => ({
// Incoming ringing calls waiting for agent action
incomingCalls: [],
// The single active call (accepted + audio connected)
activeCall: null,
}),
getters: {
hasIncomingCall: state => state.incomingCalls.length > 0,
hasActiveCall: state => state.activeCall !== null,
firstIncomingCall: state => state.incomingCalls[0] || null,
},
actions: {
addIncomingCall(callData) {
const exists = this.incomingCalls.some(c => c.callId === callData.callId);
if (exists) return;
this.incomingCalls.push(callData);
},
removeIncomingCall(callId) {
this.incomingCalls = this.incomingCalls.filter(c => c.callId !== callId);
},
setActiveCall(callData) {
this.activeCall = callData;
},
clearActiveCall() {
this.activeCall = null;
},
handleCallAcceptedByOther(callId) {
// Another agent accepted — remove from incoming list for this agent
this.removeIncomingCall(callId);
},
handleCallEnded(callId) {
this.removeIncomingCall(callId);
if (this.activeCall?.callId === callId) {
this.activeCall = null;
}
},
},
});