feat(voice): WhatsApp Cloud Calling — UI [6] (#14346)

## 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) <noreply@anthropic.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Tanmay Deep Sharma
2026-05-22 18:42:39 +05:30
committed by GitHub
co-authored by Claude Opus 4.7 iamsivin Sivin Varghese
parent b9757447a8
commit c4a6a19e9b
51 changed files with 2687 additions and 399 deletions
@@ -17,7 +17,7 @@ const CommandBar = defineAsyncComponent(
);
const FloatingCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
() => import('dashboard/components-next/call/FloatingCallWidget.vue')
);
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
@@ -56,7 +56,10 @@ export default {
};
},
computed: {
...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
...mapGetters({
uiFlags: 'contacts/getUIFlags',
currentChat: 'getSelectedChat',
}),
contactProfileLink() {
return `/app/accounts/${this.$route.params.accountId}/contacts/${this.contact.id}`;
},
@@ -305,11 +308,12 @@ export default {
<VoiceCallButton
:phone="contact.phone_number"
:contact-id="contact.id"
icon="i-ri-phone-fill"
size="sm"
:tooltip-label="$t('CONTACT_PANEL.CALL')"
slate
:conversation-id="currentChat?.id"
icon="i-lucide-phone"
sm
faded
slate
:tooltip-label="$t('CONTACT_PANEL.CALL')"
/>
<NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
@@ -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;
});
@@ -22,6 +22,7 @@ import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
import WhatsappCallingPage from './settingsPage/WhatsappCallingPage.vue';
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue';
@@ -48,6 +49,7 @@ export default {
CollaboratorsPage,
ConfigurationPage,
VoiceConfigurationPage,
WhatsappCallingPage,
CustomerSatisfactionPage,
FacebookReauthorize,
GreetingsEditor,
@@ -249,6 +251,23 @@ export default {
];
}
if (
this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CHANNEL_VOICE
)
) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'calls-configuration',
name: this.$t('INBOX_MGMT.TABS.CALLS'),
},
];
}
return visibleToAllChannelTabs;
},
currentInboxId() {
@@ -1262,6 +1281,12 @@ export default {
>
<VoiceConfigurationPage :inbox="inbox" />
</div>
<div
v-if="selectedTabKey === 'calls-configuration'"
class="mx-6 max-w-4xl"
>
<WhatsappCallingPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'csat'">
<CustomerSatisfactionPage :inbox="inbox" />
</div>
@@ -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',
@@ -0,0 +1,160 @@
<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';
import TextArea from 'next/textarea/TextArea.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
export default {
components: {
SettingsFieldSection,
SettingsToggleSection,
NextButton,
TextArea,
Spinner,
},
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return {
callingEnabled: this.inbox.provider_config?.calling_enabled || false,
permissionRequestBody:
this.inbox.provider_config?.call_permission_request_body || '',
isUpdating: false,
isTogglingCalling: false,
};
},
computed: {
phoneNumber() {
return (
this.inbox.provider_config?.phone_number || this.inbox.phone_number
);
},
},
watch: {
'inbox.provider_config.calling_enabled'(val) {
this.callingEnabled = val || false;
},
'inbox.provider_config.call_permission_request_body'(val) {
this.permissionRequestBody = val || '';
},
},
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 {
await this.$store.dispatch('inboxes/updateInbox', {
id: this.inbox.id,
formData: false,
channel: {
provider_config: {
...this.inbox.provider_config,
call_permission_request_body:
this.permissionRequestBody.trim() || null,
},
},
});
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
const message =
error?.response?.data?.message ||
this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE');
useAlert(message);
} finally {
this.isUpdating = false;
}
},
},
};
</script>
<template>
<div class="flex flex-col gap-6">
<div
class="relative"
:class="{ 'pointer-events-none opacity-60': isTogglingCalling }"
>
<SettingsToggleSection
:model-value="callingEnabled"
:header="$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE.LABEL')"
:description="$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE.DESCRIPTION')"
:hide-toggle="isTogglingCalling"
@update:model-value="handleCallingToggle"
>
<template v-if="isTogglingCalling" #hiddenToggle>
<Spinner class="size-4 text-n-slate-11" />
</template>
</SettingsToggleSection>
</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>