feat(voice): Incoming voice calls [EE] (#12361)
This PR delivers the first slice of the voice channel: inbound call handling. When a customer calls a configured voice number, Chatwoot now creates a new conversation and shows a dedicated call bubble in the UI. As the call progresses (ringing, answered, completed), its status updates in real time in both the conversation list and the call bubble, so agents can instantly see what’s happening. This focuses on the inbound flow and is part of breaking the larger voice feature into smaller, functional, and testable units; further enhancements will follow in subsequent PRs. references: #11602 , #11481 ## Testing - Configure a Voice inbox in Chatwoot with your Twilio number. - Place a call to that number. - Verify a new conversation appears in the Voice inbox for the call. - Open it and confirm a dedicated voice call message bubble is shown. - Watch status update live (ringing/answered); hang up and see it change to completed in both the bubble and conversation list. - to test missed call status, make sure to hangup the call before the please wait while we connect you to an agent message plays ## Screens <img width="400" alt="Screenshot 2025-09-03 at 3 11 25 PM" src="https://github.com/user-attachments/assets/d6a1d2ff-2ded-47b7-9144-a9d898beb380" /> <img width="700" alt="Screenshot 2025-09-03 at 3 11 33 PM" src="https://github.com/user-attachments/assets/c25e6a1e-a885-47f7-b3d7-c3e15eef18c7" /> <img width="700" alt="Screenshot 2025-09-03 at 3 11 57 PM" src="https://github.com/user-attachments/assets/29e7366d-b1d4-4add-a062-4646d2bff435" /> <img width="442" height="255" alt="Screenshot 2025-09-04 at 11 55 01 PM" src="https://github.com/user-attachments/assets/703126f6-a448-49d9-9c02-daf3092cc7f9" /> --------- Co-authored-by: Muhsin <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -36,6 +36,7 @@ import DyteBubble from './bubbles/Dyte.vue';
|
||||
import LocationBubble from './bubbles/Location.vue';
|
||||
import CSATBubble from './bubbles/CSAT.vue';
|
||||
import FormBubble from './bubbles/Form.vue';
|
||||
import VoiceCallBubble from './bubbles/VoiceCall.vue';
|
||||
|
||||
import MessageError from './MessageError.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
@@ -280,6 +281,10 @@ const componentToRender = computed(() => {
|
||||
return FormBubble;
|
||||
}
|
||||
|
||||
if (props.contentType === CONTENT_TYPES.VOICE_CALL) {
|
||||
return VoiceCallBubble;
|
||||
}
|
||||
|
||||
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
|
||||
return EmailBubble;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import { useI18n } from 'vue-i18n';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
|
||||
|
||||
const props = defineProps({
|
||||
hideMeta: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
|
||||
useMessageContext();
|
||||
const { t } = useI18n();
|
||||
@@ -64,6 +68,13 @@ const scrollToMessage = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowMeta = computed(
|
||||
() =>
|
||||
!props.hideMeta &&
|
||||
!shouldGroupWithNext.value &&
|
||||
variant.value !== MESSAGE_VARIANTS.ACTIVITY
|
||||
);
|
||||
|
||||
const replyToPreview = computed(() => {
|
||||
if (!inReplyTo) return '';
|
||||
|
||||
@@ -93,16 +104,16 @@ const replyToPreview = computed(() => {
|
||||
>
|
||||
<div
|
||||
v-if="inReplyTo"
|
||||
class="bg-n-alpha-black1 rounded-lg p-2 -mx-1 mb-2 cursor-pointer"
|
||||
class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1"
|
||||
@click="scrollToMessage"
|
||||
>
|
||||
<span class="line-clamp-2 break-all">
|
||||
<span class="break-all line-clamp-2">
|
||||
{{ replyToPreview }}
|
||||
</span>
|
||||
</div>
|
||||
<slot />
|
||||
<MessageMeta
|
||||
v-if="!shouldGroupWithNext && variant !== MESSAGE_VARIANTS.ACTIVITY"
|
||||
v-if="shouldShowMeta"
|
||||
:class="[
|
||||
flexOrientationClass,
|
||||
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
||||
|
||||
const { contentAttributes } = useMessageContext();
|
||||
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
|
||||
const status = computed(() => data.value?.status);
|
||||
const direction = computed(() => data.value?.call_direction);
|
||||
|
||||
const { labelKey, subtextKey, bubbleIconBg, bubbleIconName } =
|
||||
useVoiceCallStatus(status, direction);
|
||||
|
||||
const containerRingClass = computed(() => {
|
||||
return status.value === 'ringing' ? 'ring-1 ring-emerald-300' : '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-0 border-none" hide-meta>
|
||||
<div
|
||||
class="flex overflow-hidden flex-col w-full max-w-xs bg-white rounded-lg border border-slate-100 text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
:class="containerRingClass"
|
||||
>
|
||||
<div class="flex gap-3 items-center p-3 w-full">
|
||||
<div
|
||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||
:class="bubbleIconBg"
|
||||
>
|
||||
<span class="text-xl" :class="bubbleIconName" />
|
||||
</div>
|
||||
|
||||
<div class="flex overflow-hidden flex-col flex-grow">
|
||||
<span class="text-base font-medium truncate">{{ $t(labelKey) }}</span>
|
||||
<span class="text-xs text-slate-500">{{ $t(subtextKey) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -64,6 +64,7 @@ export const CONTENT_TYPES = {
|
||||
INPUT_CSAT: 'input_csat',
|
||||
INTEGRATIONS: 'integrations',
|
||||
STICKER: 'sticker',
|
||||
VOICE_CALL: 'voice_call',
|
||||
};
|
||||
|
||||
export const MEDIA_TYPES = [
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
@@ -82,6 +83,16 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const callStatus = computed(
|
||||
() => props.chat.additional_attributes?.call_status
|
||||
);
|
||||
const callDirection = computed(
|
||||
() => props.chat.additional_attributes?.call_direction
|
||||
);
|
||||
|
||||
const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } =
|
||||
useVoiceCallStatus(callStatus, callDirection);
|
||||
|
||||
const inboxId = computed(() => props.chat.inbox_id);
|
||||
|
||||
const inbox = computed(() => {
|
||||
@@ -306,14 +317,30 @@ const deleteConversation = () => {
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
<div
|
||||
v-if="callStatus"
|
||||
key="voice-status-row"
|
||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="messagePreviewClass"
|
||||
>
|
||||
<span
|
||||
class="inline-block -mt-0.5 align-middle text-[16px] i-ph-phone-incoming"
|
||||
:class="[voiceIconColor]"
|
||||
/>
|
||||
<span class="mx-1">
|
||||
{{ $t(voiceLabelKey) }}
|
||||
</span>
|
||||
</div>
|
||||
<MessagePreview
|
||||
v-if="lastMessageInChat"
|
||||
v-else-if="lastMessageInChat"
|
||||
key="message-preview"
|
||||
:message="lastMessageInChat"
|
||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
|
||||
:class="messagePreviewClass"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
key="no-messages"
|
||||
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="messagePreviewClass"
|
||||
>
|
||||
|
||||
161
app/javascript/dashboard/composables/useVoiceCallStatus.js
Normal file
161
app/javascript/dashboard/composables/useVoiceCallStatus.js
Normal file
@@ -0,0 +1,161 @@
|
||||
import { computed, unref } from 'vue';
|
||||
|
||||
const CALL_STATUSES = {
|
||||
IN_PROGRESS: 'in-progress',
|
||||
RINGING: 'ringing',
|
||||
NO_ANSWER: 'no-answer',
|
||||
BUSY: 'busy',
|
||||
FAILED: 'failed',
|
||||
COMPLETED: 'completed',
|
||||
CANCELED: 'canceled',
|
||||
};
|
||||
|
||||
const CALL_DIRECTIONS = {
|
||||
INBOUND: 'inbound',
|
||||
OUTBOUND: 'outbound',
|
||||
};
|
||||
|
||||
/**
|
||||
* Composable for handling voice call status display logic
|
||||
* @param {Ref|string} statusRef - Call status (ringing, in-progress, etc.)
|
||||
* @param {Ref|string} directionRef - Call direction (inbound, outbound)
|
||||
* @returns {Object} UI properties for displaying call status
|
||||
*/
|
||||
export function useVoiceCallStatus(statusRef, directionRef) {
|
||||
const status = computed(() => unref(statusRef)?.toString());
|
||||
const direction = computed(() => unref(directionRef)?.toString());
|
||||
|
||||
// Status group helpers
|
||||
const isFailedStatus = computed(() =>
|
||||
[
|
||||
CALL_STATUSES.NO_ANSWER,
|
||||
CALL_STATUSES.BUSY,
|
||||
CALL_STATUSES.FAILED,
|
||||
].includes(status.value)
|
||||
);
|
||||
const isEndedStatus = computed(() =>
|
||||
[CALL_STATUSES.COMPLETED, CALL_STATUSES.CANCELED].includes(status.value)
|
||||
);
|
||||
const isOutbound = computed(
|
||||
() => direction.value === CALL_DIRECTIONS.OUTBOUND
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
const s = status.value;
|
||||
|
||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS';
|
||||
}
|
||||
|
||||
if (s === CALL_STATUSES.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
|
||||
if (s === CALL_STATUSES.NO_ANSWER) {
|
||||
return 'CONVERSATION.VOICE_CALL.MISSED_CALL';
|
||||
}
|
||||
|
||||
if (isFailedStatus.value) {
|
||||
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
|
||||
}
|
||||
|
||||
if (isEndedStatus.value) {
|
||||
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
|
||||
}
|
||||
|
||||
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
const s = status.value;
|
||||
|
||||
if (s === CALL_STATUSES.RINGING) {
|
||||
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
}
|
||||
|
||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
||||
}
|
||||
|
||||
if (isFailedStatus.value) {
|
||||
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
|
||||
}
|
||||
|
||||
if (isEndedStatus.value) {
|
||||
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
|
||||
}
|
||||
|
||||
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
});
|
||||
|
||||
const bubbleIconName = computed(() => {
|
||||
const s = status.value;
|
||||
|
||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'i-ph-phone-outgoing-fill'
|
||||
: 'i-ph-phone-incoming-fill';
|
||||
}
|
||||
|
||||
if (isFailedStatus.value) {
|
||||
return 'i-ph-phone-x-fill';
|
||||
}
|
||||
|
||||
// For ringing/completed/canceled show direction when possible
|
||||
return isOutbound.value
|
||||
? 'i-ph-phone-outgoing-fill'
|
||||
: 'i-ph-phone-incoming-fill';
|
||||
});
|
||||
|
||||
const bubbleIconBg = computed(() => {
|
||||
const s = status.value;
|
||||
|
||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||
return 'bg-n-teal-9';
|
||||
}
|
||||
|
||||
if (isFailedStatus.value) {
|
||||
return 'bg-n-ruby-9';
|
||||
}
|
||||
|
||||
if (isEndedStatus.value) {
|
||||
return 'bg-n-slate-11';
|
||||
}
|
||||
|
||||
// default (e.g., ringing)
|
||||
return 'bg-n-teal-9 animate-pulse';
|
||||
});
|
||||
|
||||
const listIconColor = computed(() => {
|
||||
const s = status.value;
|
||||
|
||||
if (s === CALL_STATUSES.IN_PROGRESS || s === CALL_STATUSES.RINGING) {
|
||||
return 'text-n-teal-9';
|
||||
}
|
||||
|
||||
if (isFailedStatus.value) {
|
||||
return 'text-n-ruby-9';
|
||||
}
|
||||
|
||||
if (isEndedStatus.value) {
|
||||
return 'text-n-slate-11';
|
||||
}
|
||||
|
||||
return 'text-n-teal-9';
|
||||
});
|
||||
|
||||
return {
|
||||
status,
|
||||
labelKey,
|
||||
subtextKey,
|
||||
bubbleIconName,
|
||||
bubbleIconBg,
|
||||
listIconColor,
|
||||
};
|
||||
}
|
||||
@@ -71,6 +71,17 @@
|
||||
"SHOW_LABELS": "Show labels",
|
||||
"HIDE_LABELS": "Hide labels"
|
||||
},
|
||||
"VOICE_CALL": {
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
"OUTGOING_CALL": "Outgoing call",
|
||||
"CALL_IN_PROGRESS": "Call in progress",
|
||||
"NO_ANSWER": "No answer",
|
||||
"MISSED_CALL": "Missed call",
|
||||
"CALL_ENDED": "Call ended",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
"REOPEN_ACTION": "Reopen",
|
||||
|
||||
Reference in New Issue
Block a user