feat(voice): WhatsApp calling UI — inbox toggle, call widget, badges

Frontend for WhatsApp WABA calling: calling status toggle in inbox settings, WhatsApp Call channel tile, voice badges, call widget redesign, and conversation header call button.
This commit is contained in:
Tanmay Deep Sharma
2026-05-20 15:57:05 +05:30
parent 0ef7585ccb
commit a5ce1edf20
18 changed files with 350 additions and 94 deletions
+8
View File
@@ -52,6 +52,14 @@ class Inboxes extends CacheEnabledApiClient {
resetSecret(inboxId) {
return axios.post(`${this.url}/${inboxId}/reset_secret`);
}
enableWhatsappCalling(inboxId) {
return axios.post(`${this.url}/${inboxId}/enable_whatsapp_calling`);
}
disableWhatsappCalling(inboxId) {
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
}
}
export default new Inboxes();
@@ -41,7 +41,8 @@ describe('#ContactsAPI', () => {
it('#getConversations', () => {
contactAPI.getConversations(1);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/1/conversations'
'/api/v1/contacts/1/conversations',
{ params: {} }
);
});
@@ -59,6 +59,10 @@ const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
const endReason = computed(() => call.value?.endReason);
const wasDeclinedByAgent = computed(
() => isMissedInbound.value && endReason.value === 'agent_rejected'
);
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
const didCurrentUserAnswer = computed(
() =>
@@ -130,9 +134,15 @@ const subtext = computed(() => {
return null;
}
if (isFailed.value) {
return isOutbound.value
? t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT')
: t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
if (isOutbound.value) {
return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT');
}
if (wasDeclinedByAgent.value && displayAgentName.value) {
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_DECLINED_BY', {
agentName: displayAgentName.value,
});
}
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
}
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
});
@@ -56,6 +56,14 @@ const isActive = computed(() => {
return props.enabledFeatures.channel_voice;
}
if (key === 'whatsapp_call') {
return (
props.enabledFeatures.channel_voice &&
!!window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
}
return [
'website',
'twilio',
@@ -78,12 +86,12 @@ const isComingSoon = computed(() => {
});
const isBeta = computed(() => {
return ['tiktok', 'voice'].includes(props.channel.key);
return ['tiktok', 'voice', 'whatsapp_call'].includes(props.channel.key);
});
const hasVoiceBadge = computed(() => {
return (
['voice', 'whatsapp'].includes(props.channel.key) &&
['voice', 'whatsapp_call'].includes(props.channel.key) &&
!!props.enabledFeatures.channel_voice
);
});
@@ -34,9 +34,17 @@ const isWhatsappActive = computed(
() => activeCall.value?.provider === 'whatsapp'
);
const primaryIncomingCall = computed(() =>
hasActiveCall.value ? null : incomingCalls.value[0] || null
);
const stackedIncomingCalls = computed(() =>
hasActiveCall.value ? incomingCalls.value : incomingCalls.value.slice(1)
);
const mainCardState = computed(() => {
if (hasActiveCall.value) return 'ongoing';
const direction = incomingCalls.value[0]?.callDirection;
const direction = primaryIncomingCall.value?.callDirection;
return direction === 'outbound' ? 'outgoing' : 'incoming';
});
@@ -65,12 +73,18 @@ const countryCodeToFlag = code => {
const getCallInfo = call => {
const conversation = store.getters.getConversationById(call?.conversationId);
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
// Look up inbox from the call's own inboxId — the conversation can drop out
// of the Vuex store when the user navigates between inbox views, so going
// through `conversation.inbox_id` would lose the inbox name (and fall back
// to the literal "Customer support" string).
const inbox = store.getters['inboxes/getInbox'](call?.inboxId);
const sender = conversation?.meta?.sender;
// Inbound WhatsApp calls stash caller info on the call record (from the cable
// payload) so the widget has something to show before the conversation lands.
// `caller` is the snapshot captured when the call first landed (from the
// message sender or the WhatsApp cable payload). It outlives the
// conversation being in the store, so prefer it for display.
const caller = call?.caller;
const additional = sender?.additional_attributes || {};
const additional =
sender?.additional_attributes || caller?.additionalAttributes || {};
const city = additional.city || '';
const countryCode = additional.country_code || '';
const country =
@@ -87,17 +101,17 @@ const getCallInfo = call => {
conversation,
inbox,
contactName:
sender?.name ||
sender?.phone_number ||
caller?.name ||
sender?.name ||
caller?.phone ||
sender?.phone_number ||
'Unknown caller',
phoneNumber: sender?.phone_number || caller?.phone || '',
phoneNumber: caller?.phone || sender?.phone_number || '',
inboxName: inbox?.name || 'Customer support',
location,
countryFlag: countryCodeToFlag(countryCode),
hasLocation: locationParts.length > 0,
avatar: sender?.avatar || sender?.thumbnail || caller?.avatar,
avatar: caller?.avatar || sender?.avatar || sender?.thumbnail,
};
};
@@ -168,7 +182,10 @@ watch(
// Loop the ringtone while an inbound call is unanswered. Stop the moment any
// call is active (we joined), every inbound call cleared, or the widget tears
// down. Browser autoplay may reject the first play() if the tab has no prior
// down. The watcher only fires on the boolean transitioning, so additional
// ringing calls arriving while one is already ringing don't restart the audio
// — they silently stack into the UI without producing a fresh ring.
// Browser autoplay may reject the first play() if the tab has no prior
// user gesture; that's fine — the visual widget still surfaces the call.
const ringtone = new Audio(RINGTONE_URL);
ringtone.loop = true;
@@ -203,9 +220,9 @@ onBeforeUnmount(stopRingtone);
v-if="incomingCalls.length || hasActiveCall"
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-3 w-[400px]"
>
<!-- Incoming Calls (shown above active call) -->
<!-- Stacked incoming calls (shown above the primary card) -->
<CallCard
v-for="call in hasActiveCall ? incomingCalls : []"
v-for="call in stackedIncomingCalls"
:key="call.callSid"
:call="call"
state="incoming"
@@ -217,18 +234,18 @@ onBeforeUnmount(stopRingtone);
<!-- Main Call Widget -->
<CallCard
v-if="hasActiveCall || incomingCalls.length"
:call="activeCall || incomingCalls[0]"
v-if="hasActiveCall || primaryIncomingCall"
:call="activeCall || primaryIncomingCall"
:state="mainCardState"
:call-info="getCallInfo(activeCall || incomingCalls[0])"
:call-info="getCallInfo(activeCall || primaryIncomingCall)"
:duration="hasActiveCall ? formattedCallDuration : ''"
:is-muted="isMuted"
:show-mute="hasActiveCall && isWhatsappActive"
@accept="handleJoinCall(incomingCalls[0])"
@reject="rejectIncomingCall(incomingCalls[0]?.callSid)"
@accept="handleJoinCall(primaryIncomingCall)"
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
@end="handleEndCall"
@toggle-mute="toggleMute"
@go-to-conversation="goToConversation(activeCall || incomingCalls[0])"
@go-to-conversation="goToConversation(activeCall || primaryIncomingCall)"
/>
</div>
</template>
@@ -19,6 +19,7 @@ import {
} from 'dashboard/helper/inbox';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import { useCallsStore } from 'dashboard/stores/calls';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
@@ -103,16 +104,32 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const callsStore = useCallsStore();
const whatsappCallSession = useWhatsappCallSession();
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
const isVoiceCallInbox = computed(() => voiceCallProvider.value !== null);
const isWhatsappVoiceInbox = computed(
() => getVoiceCallProvider(inbox.value) === VOICE_CALL_PROVIDERS.WHATSAPP
() => voiceCallProvider.value === VOICE_CALL_PROVIDERS.WHATSAPP
);
const isWhatsappCallButtonDisabled = computed(
() =>
whatsappCallSession.isInitiating.value ||
callsStore.hasActiveCall ||
callsStore.hasIncomingCall
const isCallButtonDisabled = computed(() => {
if (callsStore.hasActiveCall || callsStore.hasIncomingCall) return true;
if (isWhatsappVoiceInbox.value) {
return whatsappCallSession.isInitiating.value;
}
return contactsUiFlags.value?.isInitiatingCall || false;
});
const isCallButtonLoading = computed(() =>
isWhatsappVoiceInbox.value
? whatsappCallSession.isInitiating.value
: !!contactsUiFlags.value?.isInitiatingCall
);
const callButtonTooltip = computed(() =>
isWhatsappVoiceInbox.value
? t('CONVERSATION.HEADER.WHATSAPP_CALL')
: t('CONVERSATION.HEADER.VOICE_CALL')
);
const startWhatsappCall = async () => {
@@ -151,6 +168,31 @@ const startWhatsappCall = async () => {
}
};
const startTwilioCall = async () => {
if (contactsUiFlags.value?.isInitiatingCall) return;
try {
const response = await store.dispatch('contacts/initiateCall', {
contactId: currentContact.value.id,
inboxId: inbox.value?.id,
conversationId: currentChat.value.id,
});
callsStore.addCall({
callSid: response?.call_sid,
conversationId: response?.conversation_id ?? currentChat.value.id,
inboxId: inbox.value?.id,
callDirection: 'outbound',
});
} catch (error) {
useAlert(error?.message || t('CONVERSATION.HEADER.VOICE_CALL_FAILED'));
}
};
const startCall = () => {
if (isWhatsappVoiceInbox.value) return startWhatsappCall();
return startTwilioCall();
};
const copyConversationId = async () => {
try {
await copyTextToClipboard(String(props.chat.id));
@@ -230,16 +272,16 @@ const copyConversationId = async () => {
class="hidden md:flex"
/>
<ButtonV4
v-if="isWhatsappVoiceInbox"
v-tooltip.bottom="$t('CONVERSATION.HEADER.WHATSAPP_CALL')"
v-if="isVoiceCallInbox"
v-tooltip.bottom="callButtonTooltip"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-phone"
:is-loading="whatsappCallSession.isInitiating.value"
:disabled="isWhatsappCallButtonDisabled"
:is-loading="isCallButtonLoading"
:disabled="isCallButtonDisabled"
class="rounded-md hover:bg-n-alpha-2"
@click="startWhatsappCall"
@click="startCall"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
@@ -14,10 +14,6 @@ import { handleVoiceCallCreated } from 'dashboard/helper/voice';
import Timer from 'dashboard/helper/Timer';
const isWhatsappCall = call => call?.provider === 'whatsapp';
// Calls seeded after a refresh / arriving via message.updated may lack provider
// metadata. Treat anything that has a callId (Meta's wacid) as WhatsApp — the
// Twilio path keys off provider_call_id (a CA…) and never sets callId.
const isWhatsappLikeCall = call => isWhatsappCall(call) || !!call?.callId;
// Dismissed call sids must not be re-seeded by the conversation-load watcher.
// Lives at module scope so all consumers share the same set.
@@ -86,7 +82,7 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
const endCall = async ({ conversationId, inboxId, callSid }) => {
const call = findCall(callSid);
if (isWhatsappLikeCall(call)) {
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);
@@ -111,13 +107,13 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
// 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 === 'outbound' && isWhatsappLikeCall(call)) {
if (call?.callDirection === 'outbound' && isWhatsappCall(call)) {
return null;
}
globalIsJoining.value = true;
try {
if (isWhatsappLikeCall(call)) {
if (isWhatsappCall(call)) {
await whatsappSession.acceptIncomingCall({
callId: call.callId,
sdpOffer: call.sdpOffer,
@@ -170,7 +166,7 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
const rejectIncomingCall = async callSid => {
const call = findCall(callSid);
try {
if (isWhatsappLikeCall(call) && call?.callId) {
if (isWhatsappCall(call) && call?.callId) {
if (call.callDirection === 'outbound') {
// Outbound calls that are still ringing must be terminated, not
// rejected (reject is the inbound-side verb on Meta's API).
@@ -178,6 +174,15 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
} 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();
}
@@ -234,13 +239,14 @@ export function useCallSession() {
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 !== 'voice_call') return;
if (msg.call?.status !== 'ringing') return;
const callSid = msg.call?.provider_call_id;
if (callSid && dismissedCallSids.has(callSid)) return;
handleVoiceCallCreated(msg, currentUserId);
handleVoiceCallCreated(msg, currentUserId, currentUserAvailability);
});
});
};
@@ -217,9 +217,14 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallIncoming = data => {
if (data?.provider !== 'whatsapp') return;
// Defense in depth: the server already filters to online agent streams,
// but if anything ever broadcasts to a broader stream (e.g. account-wide),
// an agent who's set availability=offline/busy shouldn't ring.
const availability = this.app.$store.getters.getCurrentUserAvailability;
if (availability !== 'online') return;
useCallsStore().addCall({
callSid: data.call_id,
callId: data.id,
+50 -2
View File
@@ -45,6 +45,29 @@ const shouldShowCall = ({
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
};
// Offline/busy agents shouldn't get a ringing popup for inbound calls, but
// outbound calls always belong to the initiator regardless of their status,
// and existing (already-surfaced) calls keep going so a status change
// mid-call doesn't yank away an active widget.
const shouldRingInbound = (callDirection, currentUserAvailability) => {
if (callDirection === 'outbound') return true;
return currentUserAvailability === 'online';
};
function extractCallerSnapshot(message) {
// Snapshot caller info from the message at add-time so the widget can keep
// rendering it after the user navigates away from a conversation list that
// had the conversation hydrated (and Vuex evicts it from the store).
const sender = message?.sender;
if (!sender) return null;
return {
name: sender.name,
phone: sender.phone_number,
avatar: sender.avatar || sender.thumbnail,
additionalAttributes: sender.additional_attributes || {},
};
}
function extractCallData(message) {
const call = message?.call || {};
return {
@@ -54,12 +77,18 @@ function extractCallData(message) {
status: call.status,
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
conversationId: message?.conversation_id,
inboxId: message?.inbox_id ?? message?.conversation?.inbox_id,
assigneeId: extractAssigneeId(message?.conversation),
senderId: message?.sender?.id,
caller: extractCallerSnapshot(message),
};
}
export function handleVoiceCallCreated(message, currentUserId) {
export function handleVoiceCallCreated(
message,
currentUserId,
currentUserAvailability
) {
if (!isVoiceCallMessage(message)) return;
const {
@@ -68,6 +97,7 @@ export function handleVoiceCallCreated(message, currentUserId) {
provider,
callDirection,
conversationId,
inboxId,
assigneeId,
senderId,
} = extractCallData(message);
@@ -83,25 +113,37 @@ export function handleVoiceCallCreated(message, currentUserId) {
return;
}
if (!shouldRingInbound(callDirection, currentUserAvailability)) return;
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
callId,
provider,
conversationId,
inboxId,
callDirection,
senderId,
caller: extractCallerSnapshot(message),
});
}
export function handleVoiceCallUpdated(commit, message, currentUserId) {
export function handleVoiceCallUpdated(
commit,
message,
currentUserId,
currentUserAvailability
) {
if (!isVoiceCallMessage(message)) return;
const {
callSid,
callId,
provider,
status,
callDirection,
conversationId,
inboxId,
assigneeId,
senderId,
} = extractCallData(message);
@@ -129,11 +171,17 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
}
if (status === 'ringing') {
if (!shouldRingInbound(callDirection, currentUserAvailability)) return;
callsStore.addCall({
callSid,
callId,
provider,
conversationId,
inboxId,
callDirection,
senderId,
caller: extractCallerSnapshot(message),
});
}
}
@@ -83,6 +83,7 @@
"NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up",
"MISSED_CALL": "Missed call",
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"CALLING": "Calling…",
@@ -107,6 +108,8 @@
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"VOICE_CALL": "Start call",
"VOICE_CALL_FAILED": "Could not start the call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": {
@@ -308,6 +308,7 @@
"AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
"PROCESSING": "Setting up your WhatsApp Business Account",
"ENABLING_CALLING": "Enabling WhatsApp Calling on your number…",
"LOADING_SDK": "Loading Facebook SDK...",
"CANCELLED": "WhatsApp Signup was cancelled",
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
@@ -317,7 +318,8 @@
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow"
"MANUAL_LINK_TEXT": "manual setup flow",
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
},
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
@@ -465,6 +467,10 @@
"TITLE": "WhatsApp",
"DESCRIPTION": "Support your customers on WhatsApp"
},
"WHATSAPP_CALL": {
"TITLE": "WhatsApp Call",
"DESCRIPTION": "Take voice calls on your WhatsApp number"
},
"EMAIL": {
"TITLE": "Email",
"DESCRIPTION": "Connect with Gmail, Outlook, or other providers"
@@ -654,6 +660,7 @@
"LABEL": "Enable WhatsApp Calling",
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
},
"ENABLE_FAILED": "Voice calling couldn't be turned on for this number — it isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then try again.",
"PHONE_NUMBER": {
"LABEL": "Business phone number",
"HELP_TEXT": "WhatsApp number that customers will call."
@@ -7,6 +7,7 @@ import Api from './channels/Api.vue';
import Email from './channels/Email.vue';
import Sms from './channels/Sms.vue';
import Whatsapp from './channels/Whatsapp.vue';
import WhatsappCall from './channels/WhatsappCall.vue';
import Line from './channels/Line.vue';
import Telegram from './channels/Telegram.vue';
import Instagram from './channels/Instagram.vue';
@@ -21,6 +22,7 @@ const channelViewList = {
email: Email,
sms: Sms,
whatsapp: Whatsapp,
whatsapp_call: WhatsappCall,
line: Line,
telegram: Telegram,
instagram: Instagram,
@@ -95,6 +95,13 @@ const channelList = computed(() => {
icon: 'i-woot-voice',
});
channels.push({
key: 'whatsapp_call',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP_CALL.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP_CALL.DESCRIPTION'),
icon: 'i-woot-whatsapp',
});
return channels;
});
@@ -0,0 +1,11 @@
<script setup>
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
</script>
<template>
<div class="overflow-auto col-span-6 p-6 w-full h-full">
<div class="px-6 py-5 rounded-2xl border border-n-weak">
<WhatsappEmbeddedSignup enable-calling-on-complete />
</div>
</div>
</template>
@@ -7,6 +7,7 @@ import { useAlert } from 'dashboard/composables';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import InboxesAPI from 'dashboard/api/inboxes';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import globalConstants from 'dashboard/constants/globals.js';
import {
@@ -16,6 +17,13 @@ import {
isValidBusinessData,
} from './whatsapp/utils';
const props = defineProps({
enableCallingOnComplete: {
type: Boolean,
default: false,
},
});
const store = useStore();
const router = useRouter();
const { t } = useI18n();
@@ -65,11 +73,27 @@ const handleSignupCancellation = () => {
isAuthenticating.value = false;
};
const handleSignupSuccess = inboxData => {
isProcessing.value = false;
isAuthenticating.value = false;
const enableCallingForInbox = async inboxId => {
try {
await InboxesAPI.enableWhatsappCalling(inboxId);
} catch (_) {
useAlert(
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CALLING_ENABLE_FAILED')
);
}
};
const handleSignupSuccess = async inboxData => {
if (inboxData && inboxData.id) {
if (props.enableCallingOnComplete) {
isProcessing.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.ENABLING_CALLING'
);
await enableCallingForInbox(inboxData.id);
}
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
router.replace({
name: 'settings_inboxes_add_agents',
@@ -79,6 +103,8 @@ const handleSignupSuccess = inboxData => {
},
});
} else {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
router.replace({
name: 'settings_inbox_list',
@@ -1,5 +1,6 @@
<script>
import { useAlert } from 'dashboard/composables';
import InboxesAPI from 'dashboard/api/inboxes';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -24,6 +25,7 @@ export default {
permissionRequestBody:
this.inbox.provider_config?.call_permission_request_body || '',
isUpdating: false,
isTogglingCalling: false,
};
},
computed: {
@@ -42,6 +44,29 @@ export default {
},
},
methods: {
async handleCallingToggle(newValue) {
if (this.isTogglingCalling) return;
const previousValue = this.callingEnabled;
this.callingEnabled = newValue;
this.isTogglingCalling = true;
try {
if (newValue) {
await InboxesAPI.enableWhatsappCalling(this.inbox.id);
} else {
await InboxesAPI.disableWhatsappCalling(this.inbox.id);
}
await this.$store.dispatch('inboxes/get', this.inbox.id);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (_) {
this.callingEnabled = previousValue;
const fallbackKey = newValue
? 'INBOX_MGMT.WHATSAPP_CALLING.ENABLE_FAILED'
: 'INBOX_MGMT.EDIT.API.ERROR_MESSAGE';
useAlert(this.$t(fallbackKey));
} finally {
this.isTogglingCalling = false;
}
},
async updateCallingSettings() {
this.isUpdating = true;
try {
@@ -51,7 +76,6 @@ export default {
channel: {
provider_config: {
...this.inbox.provider_config,
calling_enabled: this.callingEnabled,
call_permission_request_body:
this.permissionRequestBody.trim() || null,
},
@@ -59,7 +83,10 @@ export default {
});
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
const message =
error?.response?.data?.message ||
this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE');
useAlert(message);
} finally {
this.isUpdating = false;
}
@@ -70,47 +97,61 @@ export default {
<template>
<div class="flex flex-col gap-6">
<SettingsToggleSection
v-model="callingEnabled"
:header="$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE.LABEL')"
:description="$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE.DESCRIPTION')"
/>
<SettingsFieldSection
v-if="phoneNumber"
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.LABEL')"
:help-text="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.HELP_TEXT')"
<div
class="relative"
:class="{ 'pointer-events-none opacity-60': isTogglingCalling }"
>
<woot-code :script="phoneNumber" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PERMISSION_REQUEST_BODY.LABEL')"
:help-text="
$t('INBOX_MGMT.WHATSAPP_CALLING.PERMISSION_REQUEST_BODY.HELP_TEXT')
"
>
<TextArea
v-model="permissionRequestBody"
:placeholder="
$t('INBOX_MGMT.WHATSAPP_CALLING.PERMISSION_REQUEST_BODY.PLACEHOLDER')
"
auto-height
resize
<SettingsToggleSection
:model-value="callingEnabled"
:header="$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE.LABEL')"
:description="$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE.DESCRIPTION')"
@update:model-value="handleCallingToggle"
/>
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.HOW_IT_WORKS.LABEL')"
:help-text="$t('INBOX_MGMT.WHATSAPP_CALLING.HOW_IT_WORKS.DESCRIPTION')"
/>
<div>
<NextButton
:is-loading="isUpdating"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
@click="updateCallingSettings"
<span
v-if="isTogglingCalling"
class="absolute top-3 right-12 i-lucide-loader-circle animate-spin size-4 text-n-slate-11"
/>
</div>
<template v-if="callingEnabled">
<SettingsFieldSection
v-if="phoneNumber"
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.LABEL')"
:help-text="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.HELP_TEXT')"
>
<woot-code :script="phoneNumber" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PERMISSION_REQUEST_BODY.LABEL')"
:help-text="
$t('INBOX_MGMT.WHATSAPP_CALLING.PERMISSION_REQUEST_BODY.HELP_TEXT')
"
>
<TextArea
v-model="permissionRequestBody"
:placeholder="
$t(
'INBOX_MGMT.WHATSAPP_CALLING.PERMISSION_REQUEST_BODY.PLACEHOLDER'
)
"
auto-height
resize
/>
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.HOW_IT_WORKS.LABEL')"
:help-text="$t('INBOX_MGMT.WHATSAPP_CALLING.HOW_IT_WORKS.DESCRIPTION')"
/>
<div>
<NextButton
:is-loading="isUpdating"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
@click="updateCallingSettings"
/>
</div>
</template>
</div>
</template>
@@ -329,12 +329,21 @@ const actions = {
});
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
}
handleVoiceCallCreated(message, rootGetters?.getCurrentUserID);
handleVoiceCallCreated(
message,
rootGetters?.getCurrentUserID,
rootGetters?.getCurrentUserAvailability
);
},
updateMessage({ commit, rootGetters }, message) {
commit(types.ADD_MESSAGE, message);
handleVoiceCallUpdated(commit, message, rootGetters?.getCurrentUserID);
handleVoiceCallUpdated(
commit,
message,
rootGetters?.getCurrentUserID,
rootGetters?.getCurrentUserAvailability
);
},
deleteMessage: async function deleteLabels(
+6 -1
View File
@@ -44,7 +44,12 @@ export const useCallsStore = defineStore('calls', {
if (existing) {
// Merge so a later cable event with sdp_offer/provider/caller fills in
// gaps left by the earlier message.created path (and vice versa).
Object.assign(existing, callData, { isActive: existing.isActive });
// Preserve a previously-captured caller snapshot when the incoming
// event has no sender info, otherwise the widget would flip to
// "Unknown caller" on the next status update.
const next = { ...callData };
if (existing.caller && !next.caller) delete next.caller;
Object.assign(existing, next, { isActive: existing.isActive });
return;
}