From ddc863c6f78b8c3f14dfe2f1eddc9a9f51fbfc7c Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Tue, 12 May 2026 19:44:20 +0530 Subject: [PATCH] feat(voice): Redesign call widget and in-thread call bubbles Brings the floating call widget and in-thread voice-call bubbles in line with the new Figma design across Twilio and WhatsApp, light and dark. - New CallCard component for the floating widget with incoming / outgoing / ongoing states, rounded-square avatar, status badges, duration timer, and "Go to conversation thread" footer. - Floating widget header now shows the contact's city, country, and country flag (derived from country_code) when available, falling back to the channel icon + inbox name. - In-thread VoiceCall bubble redesigned: 44x44 tonal icon container, title + duration/subtext, audio player + transcript when a recording exists, and a "Call back" pill for missed inbound calls. - isOutbound on the call bubble now accepts both outgoing/outbound and incoming/inbound spellings and falls back to message orientation, so the label can no longer disagree with which side of the thread the bubble sits on. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../message/bubbles/VoiceCall.vue | 190 ++++++++++----- .../components/widgets/FloatingCallWidget.vue | 229 +++++------------- .../components/widgets/call/CallCard.vue | 208 ++++++++++++++++ .../i18n/locale/en/conversation.json | 6 +- 4 files changed, 392 insertions(+), 241 deletions(-) create mode 100644 app/javascript/dashboard/components/widgets/call/CallCard.vue diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index f43149590..44a765f19 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -3,9 +3,12 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { useStore } from 'vuex'; import { useMessageContext } from '../provider.js'; -import { VOICE_CALL_STATUS } from '../constants'; +import { VOICE_CALL_STATUS, MESSAGE_TYPES } from '../constants'; import { useCallActions } from 'dashboard/composables/useCallSession'; +import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession'; +import { useCallsStore } from 'dashboard/stores/calls'; import { formatDuration } from 'shared/helpers/timeHelper'; +import { useAlert } from 'dashboard/composables'; import Icon from 'dashboard/components-next/icon/Icon.vue'; import BaseBubble from 'next/message/bubbles/Base.vue'; @@ -17,17 +20,10 @@ const LABEL_MAP = { }; const ICON_MAP = { - [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call', - [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x', - [VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x', -}; - -const BG_COLOR_MAP = { - [VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9', - [VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse', - [VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11', - [VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9', - [VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9', + [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call-bold', + [VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold', + [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold', + [VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold', }; const { t } = useI18n(); @@ -39,26 +35,35 @@ const { conversationId, currentUserId, inboxId, + sender, + messageType, } = useMessageContext(); -// Lightweight consumer — bubble doesn't own global listeners; the -// FloatingCallWidget is the singleton root that drives the session. const { joinCall, endCall, activeCall, hasActiveCall, isJoining } = useCallActions(); +const whatsappCallSession = useWhatsappCallSession(); const status = computed(() => call.value?.status); -const isOutbound = computed(() => call.value?.direction === 'outgoing'); +// Server-side call records use `outgoing`/`incoming`, while the Pinia store +// and a few API hops normalise to `outbound`/`inbound`. Accept either so the +// bubble label matches the message orientation no matter the source. +const isOutbound = computed(() => { + const dir = call.value?.direction; + if (dir === 'outgoing' || dir === 'outbound') return true; + if (dir === 'incoming' || dir === 'inbound') return false; + // Fall back to the message orientation: agent-authored messages sit on the + // right (outbound) and contact-authored ones on the left. + return messageType.value === MESSAGE_TYPES.OUTGOING; +}); +const isWhatsapp = computed(() => call.value?.provider === 'whatsapp'); const isFailed = computed(() => [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value) ); +const isMissedInbound = computed(() => isFailed.value && !isOutbound.value); const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId); const didCurrentUserAnswer = computed( () => !!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value ); -// Pickup auto-assigns the conversation, so the assignee is a safe display proxy -// for the answerer when the Call payload lacks accepted_by_agent_id (e.g., -// Twilio's call-status webhook flipped the call to in-progress before the -// participant-join webhook claimed it). const conversationAssignee = computed(() => { const conversation = store.getters.getConversationById?.( conversationId?.value @@ -79,13 +84,9 @@ const audioAttachment = computed(() => (attachments?.value || []).find(a => a.fileType === 'audio') ); -// Duration may arrive on call.duration_seconds (push_event_data) or -// content_attributes.data.duration_seconds — and either may be camelCased -// upstream, so check every variant. const durationSeconds = computed(() => { const fromCall = call.value?.durationSeconds || call.value?.duration_seconds; if (fromCall != null) return fromCall; - const data = contentAttributes?.value?.data; return data?.durationSeconds || data?.duration_seconds; }); @@ -117,11 +118,6 @@ const subtext = computed(() => { return formattedDuration.value; } if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) { - // Outbound calls can flip to in-progress before the contact actually - // picks up (e.g., Twilio marks in-progress when the agent joins the - // conference); claiming "they answered" here would be misleading. The - // 'Call in progress' label alone is accurate; the floating widget shows - // the live duration. if (isOutbound.value) return null; if (didCurrentUserAnswer.value) { return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED'); @@ -143,25 +139,33 @@ const subtext = computed(() => { const iconName = computed(() => { if (ICON_MAP[status.value]) return ICON_MAP[status.value]; - return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming'; + return isOutbound.value + ? 'i-ph-phone-outgoing-bold' + : 'i-ph-phone-incoming-bold'; }); -const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9'); +// Subtle icon container — matches the design's tonal swatch over the bubble bg. +// Status drives the accent: teal for live, ruby for missed, neutral otherwise. +const iconContainerClass = computed(() => { + if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) { + return 'bg-n-teal-3 text-n-teal-11'; + } + if (status.value === VOICE_CALL_STATUS.RINGING) { + return 'bg-n-teal-3 text-n-teal-11'; + } + if (isMissedInbound.value) { + return 'bg-n-alpha-2 text-n-ruby-9'; + } + return 'bg-n-alpha-2 text-n-slate-12'; +}); const callSid = computed(() => call.value?.providerCallId); -// Show "Join call" when the call is still ringing, no agent has claimed it, -// and the conversation is unassigned or assigned to the current user. Mirrors -// the eligibility used by FloatingCallWidget so the bubble can act as a -// recovery affordance after a refresh or missed widget. const canJoinCall = computed(() => { if (status.value !== VOICE_CALL_STATUS.RINGING) return false; if (isOutbound.value) return false; if (acceptedByAgentId.value) return false; if (!callSid.value || !inboxId.value || !conversationId.value) return false; - // Suppress the button once this call is the local active session — the - // message status webhook may lag behind, so we can't rely on `status` alone - // to hide it after a successful join from this client. if (hasActiveCall.value && activeCall.value?.callSid === callSid.value) return false; const assignee = conversationAssignee.value; @@ -198,48 +202,102 @@ const handleJoinCall = async () => { callSid: callSid.value, }); }; + +const canCallBack = computed( + () => isMissedInbound.value && !!inboxId.value && !!conversationId.value +); + +const handleCallBack = async () => { + if (!canCallBack.value) return; + try { + if (isWhatsapp.value) { + const response = await whatsappCallSession.initiateOutboundCall( + conversationId.value + ); + if (response?.status === 'locked') return; + if (response?.call_id) { + const callsStore = useCallsStore(); + callsStore.addCall({ + callSid: response.call_id, + callId: response.id, + conversationId: conversationId.value, + inboxId: inboxId.value, + callDirection: 'outbound', + provider: 'whatsapp', + }); + } + return; + } + await store.dispatch('contacts/initiateCall', { + contactId: sender.value?.id, + inboxId: inboxId.value, + conversationId: conversationId.value, + }); + } catch (error) { + useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED')); + } +}; diff --git a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue index 473087f85..087307ff6 100644 --- a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue +++ b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue @@ -6,7 +6,7 @@ import { useCallSession } from 'dashboard/composables/useCallSession'; import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper'; -import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; +import CallCard from 'dashboard/components/widgets/call/CallCard.vue'; const RINGTONE_URL = '/audio/dashboard/bell.mp3'; @@ -33,6 +33,12 @@ const isWhatsappActive = computed( () => activeCall.value?.provider === 'whatsapp' ); +const mainCardState = computed(() => { + if (hasActiveCall.value) return 'ongoing'; + const direction = incomingCalls.value[0]?.callDirection; + return direction === 'outbound' ? 'outgoing' : 'incoming'; +}); + const toggleMute = () => { isMuted.value = !isMuted.value; setWhatsappCallMuted(isMuted.value); @@ -42,6 +48,20 @@ watch(hasActiveCall, active => { if (!active) isMuted.value = false; }); +// Convert ISO 3166-1 alpha-2 country code (e.g. "US") to its regional indicator +// flag emoji. Returns empty string if the code is missing or malformed. +const countryCodeToFlag = code => { + if (!code || code.length !== 2) return ''; + const base = 0x1f1e6; + const offset = 'A'.charCodeAt(0); + return String.fromCodePoint( + ...code + .toUpperCase() + .split('') + .map(c => base + (c.charCodeAt(0) - offset)) + ); +}; + const getCallInfo = call => { const conversation = store.getters.getConversationById(call?.conversationId); const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id); @@ -49,6 +69,16 @@ const getCallInfo = call => { // 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. const caller = call?.caller; + const additional = sender?.additional_attributes || {}; + const city = additional.city || ''; + const country = additional.country || ''; + const countryCode = additional.country_code || ''; + // Prefer the richest available location string ("City, Country"); fall back to + // whichever single field is present; finally fall back to the inbox name so + // there's always something to show. + const locationParts = [city, country].filter(Boolean); + const location = + locationParts.join(', ') || inbox?.name || 'Customer support'; return { conversation, inbox, @@ -60,6 +90,9 @@ const getCallInfo = call => { 'Unknown caller', phoneNumber: sender?.phone_number || caller?.phone || '', inboxName: inbox?.name || 'Customer support', + location, + countryFlag: countryCodeToFlag(countryCode), + hasLocation: locationParts.length > 0, avatar: sender?.avatar || sender?.thumbnail || caller?.avatar, }; }; @@ -164,184 +197,34 @@ onBeforeUnmount(stopRingtone); diff --git a/app/javascript/dashboard/components/widgets/call/CallCard.vue b/app/javascript/dashboard/components/widgets/call/CallCard.vue new file mode 100644 index 000000000..2a5218df4 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/call/CallCard.vue @@ -0,0 +1,208 @@ + + + diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 0e3d2f9d9..4d9543f6a 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -89,7 +89,8 @@ "THEY_ANSWERED": "They answered", "YOU_ANSWERED": "You answered", "AGENT_ANSWERED": "{agentName} answered", - "JOIN_CALL": "Join call" + "JOIN_CALL": "Join call", + "CALL_BACK": "Call back" }, "HEADER": { "RESOLVE_ACTION": "Resolve", @@ -311,7 +312,8 @@ "END_CALL": "End call", "MUTE": "Mute mic", "UNMUTE": "Unmute mic", - "VIEW_CHAT_HISTORY": "View chat history" + "VIEW_CHAT_HISTORY": "View chat history", + "GO_TO_CONVERSATION": "Go to conversation thread" } }, "EMAIL_TRANSCRIPT": {