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
@@ -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>