mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +00:00 
			
		
		
		
	 6bdd4f0670
			
		
	
	6bdd4f0670
	
	
	
		
			
			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>
		
			
				
	
	
		
			410 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			410 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <script setup>
 | |
| 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';
 | |
| import InboxName from '../InboxName.vue';
 | |
| import ConversationContextMenu from './contextMenu/Index.vue';
 | |
| import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
 | |
| import CardLabels from './conversationCardComponents/CardLabels.vue';
 | |
| import PriorityMark from './PriorityMark.vue';
 | |
| import SLACardLabel from './components/SLACardLabel.vue';
 | |
| import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
 | |
| 
 | |
| const props = defineProps({
 | |
|   activeLabel: { type: String, default: '' },
 | |
|   chat: { type: Object, default: () => ({}) },
 | |
|   hideInboxName: { type: Boolean, default: false },
 | |
|   hideThumbnail: { type: Boolean, default: false },
 | |
|   teamId: { type: [String, Number], default: 0 },
 | |
|   foldersId: { type: [String, Number], default: 0 },
 | |
|   showAssignee: { type: Boolean, default: false },
 | |
|   conversationType: { type: String, default: '' },
 | |
|   selected: { type: Boolean, default: false },
 | |
|   compact: { type: Boolean, default: false },
 | |
|   enableContextMenu: { type: Boolean, default: false },
 | |
|   allowedContextMenuOptions: { type: Array, default: () => [] },
 | |
| });
 | |
| 
 | |
| const emit = defineEmits([
 | |
|   'contextMenuToggle',
 | |
|   'assignAgent',
 | |
|   'assignLabel',
 | |
|   'assignTeam',
 | |
|   'markAsUnread',
 | |
|   'markAsRead',
 | |
|   'assignPriority',
 | |
|   'updateConversationStatus',
 | |
|   'deleteConversation',
 | |
|   'selectConversation',
 | |
|   'deSelectConversation',
 | |
| ]);
 | |
| 
 | |
| const router = useRouter();
 | |
| const store = useStore();
 | |
| 
 | |
| const hovered = ref(false);
 | |
| const showContextMenu = ref(false);
 | |
| const contextMenu = ref({
 | |
|   x: null,
 | |
|   y: null,
 | |
| });
 | |
| 
 | |
| const currentChat = useMapGetter('getSelectedChat');
 | |
| const inboxesList = useMapGetter('inboxes/getInboxes');
 | |
| const activeInbox = useMapGetter('getSelectedInbox');
 | |
| const accountId = useMapGetter('getCurrentAccountId');
 | |
| 
 | |
| const chatMetadata = computed(() => props.chat.meta || {});
 | |
| 
 | |
| const assignee = computed(() => chatMetadata.value.assignee || {});
 | |
| 
 | |
| const senderId = computed(() => chatMetadata.value.sender?.id);
 | |
| 
 | |
| const currentContact = computed(() => {
 | |
|   return senderId.value
 | |
|     ? store.getters['contacts/getContact'](senderId.value)
 | |
|     : {};
 | |
| });
 | |
| 
 | |
| const isActiveChat = computed(() => {
 | |
|   return currentChat.value.id === props.chat.id;
 | |
| });
 | |
| 
 | |
| const unreadCount = computed(() => props.chat.unread_count);
 | |
| 
 | |
| const hasUnread = computed(() => unreadCount.value > 0);
 | |
| 
 | |
| 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(() => {
 | |
|   return inboxId.value ? store.getters['inboxes/getInbox'](inboxId.value) : {};
 | |
| });
 | |
| 
 | |
| const showInboxName = computed(() => {
 | |
|   return (
 | |
|     !props.hideInboxName &&
 | |
|     isInboxNameVisible.value &&
 | |
|     inboxesList.value.length > 1
 | |
|   );
 | |
| });
 | |
| 
 | |
| const showMetaSection = computed(() => {
 | |
|   return (
 | |
|     showInboxName.value ||
 | |
|     (props.showAssignee && assignee.value.name) ||
 | |
|     props.chat.priority
 | |
|   );
 | |
| });
 | |
| 
 | |
| const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
 | |
| 
 | |
| const showLabelsSection = computed(() => {
 | |
|   return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
 | |
| });
 | |
| 
 | |
| const messagePreviewClass = computed(() => {
 | |
|   return [
 | |
|     hasUnread.value ? 'font-medium text-n-slate-12' : 'text-n-slate-11',
 | |
|     !props.compact && hasUnread.value ? 'ltr:pr-4 rtl:pl-4' : '',
 | |
|     props.compact && hasUnread.value ? 'ltr:pr-6 rtl:pl-6' : '',
 | |
|   ];
 | |
| });
 | |
| 
 | |
| const conversationPath = computed(() => {
 | |
|   return frontendURL(
 | |
|     conversationUrl({
 | |
|       accountId: accountId.value,
 | |
|       activeInbox: activeInbox.value,
 | |
|       id: props.chat.id,
 | |
|       label: props.activeLabel,
 | |
|       teamId: props.teamId,
 | |
|       conversationType: props.conversationType,
 | |
|       foldersId: props.foldersId,
 | |
|     })
 | |
|   );
 | |
| });
 | |
| 
 | |
| const onCardClick = e => {
 | |
|   const path = conversationPath.value;
 | |
|   if (!path) return;
 | |
| 
 | |
|   // Handle Ctrl/Cmd + Click for new tab
 | |
|   if (e.metaKey || e.ctrlKey) {
 | |
|     e.preventDefault();
 | |
|     window.open(
 | |
|       `${window.chatwootConfig.hostURL}${path}`,
 | |
|       '_blank',
 | |
|       'noopener,noreferrer'
 | |
|     );
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   // Skip if already active
 | |
|   if (isActiveChat.value) return;
 | |
| 
 | |
|   router.push({ path });
 | |
| };
 | |
| 
 | |
| const onThumbnailHover = () => {
 | |
|   hovered.value = !props.hideThumbnail;
 | |
| };
 | |
| 
 | |
| const onThumbnailLeave = () => {
 | |
|   hovered.value = false;
 | |
| };
 | |
| 
 | |
| const onSelectConversation = checked => {
 | |
|   if (checked) {
 | |
|     emit('selectConversation', props.chat.id, inbox.value.id);
 | |
|   } else {
 | |
|     emit('deSelectConversation', props.chat.id, inbox.value.id);
 | |
|   }
 | |
| };
 | |
| 
 | |
| const openContextMenu = e => {
 | |
|   if (!props.enableContextMenu) return;
 | |
|   e.preventDefault();
 | |
|   emit('contextMenuToggle', true);
 | |
|   contextMenu.value.x = e.pageX || e.clientX;
 | |
|   contextMenu.value.y = e.pageY || e.clientY;
 | |
|   showContextMenu.value = true;
 | |
| };
 | |
| 
 | |
| const closeContextMenu = () => {
 | |
|   emit('contextMenuToggle', false);
 | |
|   showContextMenu.value = false;
 | |
|   contextMenu.value.x = null;
 | |
|   contextMenu.value.y = null;
 | |
| };
 | |
| 
 | |
| const onUpdateConversation = (status, snoozedUntil) => {
 | |
|   closeContextMenu();
 | |
|   emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
 | |
| };
 | |
| 
 | |
| const onAssignAgent = agent => {
 | |
|   emit('assignAgent', agent, [props.chat.id]);
 | |
|   closeContextMenu();
 | |
| };
 | |
| 
 | |
| const onAssignLabel = label => {
 | |
|   emit('assignLabel', [label.title], [props.chat.id]);
 | |
|   closeContextMenu();
 | |
| };
 | |
| 
 | |
| const onAssignTeam = team => {
 | |
|   emit('assignTeam', team, props.chat.id);
 | |
|   closeContextMenu();
 | |
| };
 | |
| 
 | |
| const markAsUnread = () => {
 | |
|   emit('markAsUnread', props.chat.id);
 | |
|   closeContextMenu();
 | |
| };
 | |
| 
 | |
| const markAsRead = () => {
 | |
|   emit('markAsRead', props.chat.id);
 | |
|   closeContextMenu();
 | |
| };
 | |
| 
 | |
| const assignPriority = priority => {
 | |
|   emit('assignPriority', priority, props.chat.id);
 | |
|   closeContextMenu();
 | |
| };
 | |
| 
 | |
| const deleteConversation = () => {
 | |
|   emit('deleteConversation', props.chat.id);
 | |
|   closeContextMenu();
 | |
| };
 | |
| </script>
 | |
| 
 | |
| <template>
 | |
|   <div
 | |
|     class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
 | |
|     :class="{
 | |
|       'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
 | |
|         isActiveChat,
 | |
|       'bg-n-slate-2 dark:bg-n-slate-3': selected,
 | |
|       'px-0': compact,
 | |
|       'px-3': !compact,
 | |
|     }"
 | |
|     @click="onCardClick"
 | |
|     @contextmenu="openContextMenu($event)"
 | |
|   >
 | |
|     <div
 | |
|       class="relative"
 | |
|       @mouseenter="onThumbnailHover"
 | |
|       @mouseleave="onThumbnailLeave"
 | |
|     >
 | |
|       <Avatar
 | |
|         v-if="!hideThumbnail"
 | |
|         :name="currentContact.name"
 | |
|         :src="currentContact.thumbnail"
 | |
|         :size="32"
 | |
|         :status="currentContact.availability_status"
 | |
|         :class="!showInboxName ? 'mt-4' : 'mt-8'"
 | |
|         hide-offline-status
 | |
|         rounded-full
 | |
|       >
 | |
|         <template #overlay="{ size }">
 | |
|           <label
 | |
|             v-if="hovered || selected"
 | |
|             class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px]"
 | |
|             :style="{ width: `${size}px`, height: `${size}px` }"
 | |
|             @click.stop
 | |
|           >
 | |
|             <input
 | |
|               :value="selected"
 | |
|               :checked="selected"
 | |
|               class="!m-0 cursor-pointer"
 | |
|               type="checkbox"
 | |
|               @change="onSelectConversation($event.target.checked)"
 | |
|             />
 | |
|           </label>
 | |
|         </template>
 | |
|       </Avatar>
 | |
|     </div>
 | |
|     <div
 | |
|       class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
 | |
|     >
 | |
|       <div
 | |
|         v-if="showMetaSection"
 | |
|         class="flex items-center min-w-0 gap-1"
 | |
|         :class="{
 | |
|           'ltr:ml-2 rtl:mr-2': !compact,
 | |
|           'mx-2': compact,
 | |
|         }"
 | |
|       >
 | |
|         <InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
 | |
|         <div
 | |
|           class="flex items-center gap-2 flex-shrink-0"
 | |
|           :class="{
 | |
|             'flex-1 justify-between': !showInboxName,
 | |
|           }"
 | |
|         >
 | |
|           <span
 | |
|             v-if="showAssignee && assignee.name"
 | |
|             class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
 | |
|           >
 | |
|             <fluent-icon icon="person" size="12" class="text-n-slate-11" />
 | |
|             {{ assignee.name }}
 | |
|           </span>
 | |
|           <PriorityMark :priority="chat.priority" class="flex-shrink-0" />
 | |
|         </div>
 | |
|       </div>
 | |
|       <h4
 | |
|         class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap flex-1 min-w-0 ltr:pr-16 rtl:pl-16 text-n-slate-12"
 | |
|         :class="hasUnread ? 'font-semibold' : 'font-medium'"
 | |
|       >
 | |
|         {{ 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-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"
 | |
|       >
 | |
|         <fluent-icon
 | |
|           size="16"
 | |
|           class="-mt-0.5 align-middle inline-block text-n-slate-10"
 | |
|           icon="info"
 | |
|         />
 | |
|         <span class="mx-0.5">
 | |
|           {{ $t(`CHAT_LIST.NO_MESSAGES`) }}
 | |
|         </span>
 | |
|       </p>
 | |
|       <div
 | |
|         class="absolute flex flex-col ltr:right-3 rtl:left-3"
 | |
|         :class="showMetaSection ? 'top-8' : 'top-4'"
 | |
|       >
 | |
|         <span class="ml-auto font-normal leading-4 text-xxs">
 | |
|           <TimeAgo
 | |
|             :last-activity-timestamp="chat.timestamp"
 | |
|             :created-at-timestamp="chat.created_at"
 | |
|           />
 | |
|         </span>
 | |
|         <span
 | |
|           class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
 | |
|           :class="hasUnread ? 'block' : 'hidden'"
 | |
|         >
 | |
|           {{ unreadCount > 9 ? '9+' : unreadCount }}
 | |
|         </span>
 | |
|       </div>
 | |
|       <CardLabels
 | |
|         v-if="showLabelsSection"
 | |
|         :conversation-labels="chat.labels"
 | |
|         class="mt-0.5 mx-2 mb-0"
 | |
|       >
 | |
|         <template v-if="hasSlaPolicyId" #before>
 | |
|           <SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
 | |
|         </template>
 | |
|       </CardLabels>
 | |
|     </div>
 | |
|     <ContextMenu
 | |
|       v-if="showContextMenu"
 | |
|       :x="contextMenu.x"
 | |
|       :y="contextMenu.y"
 | |
|       @close="closeContextMenu"
 | |
|     >
 | |
|       <ConversationContextMenu
 | |
|         :status="chat.status"
 | |
|         :inbox-id="inbox.id"
 | |
|         :priority="chat.priority"
 | |
|         :chat-id="chat.id"
 | |
|         :has-unread-messages="hasUnread"
 | |
|         :conversation-url="conversationPath"
 | |
|         :allowed-options="allowedContextMenuOptions"
 | |
|         @update-conversation="onUpdateConversation"
 | |
|         @assign-agent="onAssignAgent"
 | |
|         @assign-label="onAssignLabel"
 | |
|         @assign-team="onAssignTeam"
 | |
|         @mark-as-unread="markAsUnread"
 | |
|         @mark-as-read="markAsRead"
 | |
|         @assign-priority="assignPriority"
 | |
|         @delete-conversation="deleteConversation"
 | |
|         @close="closeContextMenu"
 | |
|       />
 | |
|     </ContextMenu>
 | |
|   </div>
 | |
| </template>
 |