mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 19:17:48 +00:00 
			
		
		
		
	 b75ea7a762
			
		
	
	b75ea7a762
	
	
	
		
			
			This PR has two changes to speed up contact filtering ### Updated Base Relation Update the `base_relation` to use resolved contacts scope to improve perf when filtering conversations. This narrows the search space drastically, and what is usually a sequential scan becomes a index scan for that `account_id` ref: https://github.com/chatwoot/chatwoot/pull/9347 ref: https://github.com/chatwoot/chatwoot/pull/7175/ Result: https://explain.dalibo.com/plan/c8a8gb17f0275fgf#plan ## Selective filtering in Compose New Conversation We also cost of filtering in compose new conversation dialog by reducing the search space based on the search candidate. For instance, a search term that obviously can’t be a phone, we exclude that from the filter. Similarly we skip name lookups for email-shaped queries. Removing the phone number took the query times from 50 seconds to under 1 seconds ### Comparison 1. Only Email: https://explain.dalibo.com/plan/h91a6844a4438a6a 2. Email + Name: https://explain.dalibo.com/plan/beg3aah05ch9ade0 3. Email + Name + Phone: https://explain.dalibo.com/plan/c8a8gb17f0275fgf
		
			
				
	
	
		
			391 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			391 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <script setup>
 | |
| import { reactive, ref, computed } from 'vue';
 | |
| import { useVuelidate } from '@vuelidate/core';
 | |
| import { required, requiredIf } from '@vuelidate/validators';
 | |
| import { INBOX_TYPES } from 'dashboard/helper/inbox';
 | |
| import {
 | |
|   appendSignature,
 | |
|   removeSignature,
 | |
|   extractTextFromMarkdown,
 | |
| } from 'dashboard/helper/editorHelper';
 | |
| import {
 | |
|   buildContactableInboxesList,
 | |
|   prepareNewMessagePayload,
 | |
|   prepareWhatsAppMessagePayload,
 | |
| } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
 | |
| 
 | |
| import ContactSelector from './ContactSelector.vue';
 | |
| import InboxSelector from './InboxSelector.vue';
 | |
| import EmailOptions from './EmailOptions.vue';
 | |
| import MessageEditor from './MessageEditor.vue';
 | |
| import ActionButtons from './ActionButtons.vue';
 | |
| import InboxEmptyState from './InboxEmptyState.vue';
 | |
| import AttachmentPreviews from './AttachmentPreviews.vue';
 | |
| 
 | |
| const props = defineProps({
 | |
|   contacts: { type: Array, default: () => [] },
 | |
|   contactId: { type: String, default: null },
 | |
|   selectedContact: { type: Object, default: null },
 | |
|   targetInbox: { type: Object, default: null },
 | |
|   currentUser: { type: Object, default: null },
 | |
|   isCreatingContact: { type: Boolean, default: false },
 | |
|   isFetchingInboxes: { type: Boolean, default: false },
 | |
|   isLoading: { type: Boolean, default: false },
 | |
|   isDirectUploadsEnabled: { type: Boolean, default: false },
 | |
|   contactConversationsUiFlags: { type: Object, default: null },
 | |
|   contactsUiFlags: { type: Object, default: null },
 | |
|   messageSignature: { type: String, default: '' },
 | |
|   sendWithSignature: { type: Boolean, default: false },
 | |
| });
 | |
| 
 | |
| const emit = defineEmits([
 | |
|   'searchContacts',
 | |
|   'discard',
 | |
|   'updateSelectedContact',
 | |
|   'updateTargetInbox',
 | |
|   'clearSelectedContact',
 | |
|   'createConversation',
 | |
| ]);
 | |
| 
 | |
| const showContactsDropdown = ref(false);
 | |
| const showInboxesDropdown = ref(false);
 | |
| const showCcEmailsDropdown = ref(false);
 | |
| const showBccEmailsDropdown = ref(false);
 | |
| 
 | |
| const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
 | |
| 
 | |
| const state = reactive({
 | |
|   message: '',
 | |
|   subject: '',
 | |
|   ccEmails: '',
 | |
|   bccEmails: '',
 | |
|   attachedFiles: [],
 | |
| });
 | |
| 
 | |
| const inboxTypes = computed(() => ({
 | |
|   isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
 | |
|   isTwilio: props.targetInbox?.channelType === INBOX_TYPES.TWILIO,
 | |
|   isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP,
 | |
|   isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
 | |
|   isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
 | |
|   isEmailOrWebWidget:
 | |
|     props.targetInbox?.channelType === INBOX_TYPES.EMAIL ||
 | |
|     props.targetInbox?.channelType === INBOX_TYPES.WEB,
 | |
|   isTwilioSMS:
 | |
|     props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
 | |
|     props.targetInbox?.medium === 'sms',
 | |
|   isTwilioWhatsapp:
 | |
|     props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
 | |
|     props.targetInbox?.medium === 'whatsapp',
 | |
| }));
 | |
| 
 | |
| const whatsappMessageTemplates = computed(() =>
 | |
|   Object.keys(props.targetInbox?.messageTemplates || {}).length
 | |
|     ? props.targetInbox.messageTemplates
 | |
|     : []
 | |
| );
 | |
| 
 | |
| const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
 | |
| 
 | |
| const validationRules = computed(() => ({
 | |
|   selectedContact: { required },
 | |
|   targetInbox: { required },
 | |
|   message: { required: requiredIf(!inboxTypes.value.isWhatsapp) },
 | |
|   subject: { required: requiredIf(inboxTypes.value.isEmail) },
 | |
| }));
 | |
| 
 | |
| const v$ = useVuelidate(validationRules, {
 | |
|   selectedContact: computed(() => props.selectedContact),
 | |
|   targetInbox: computed(() => props.targetInbox),
 | |
|   message: computed(() => state.message),
 | |
|   subject: computed(() => state.subject),
 | |
| });
 | |
| 
 | |
| const validationStates = computed(() => ({
 | |
|   isContactInvalid:
 | |
|     v$.value.selectedContact.$dirty && v$.value.selectedContact.$invalid,
 | |
|   isInboxInvalid: v$.value.targetInbox.$dirty && v$.value.targetInbox.$invalid,
 | |
|   isSubjectInvalid: v$.value.subject.$dirty && v$.value.subject.$invalid,
 | |
|   isMessageInvalid: v$.value.message.$dirty && v$.value.message.$invalid,
 | |
| }));
 | |
| 
 | |
| const newMessagePayload = () => {
 | |
|   const { message, subject, ccEmails, bccEmails, attachedFiles } = state;
 | |
|   return prepareNewMessagePayload({
 | |
|     targetInbox: props.targetInbox,
 | |
|     selectedContact: props.selectedContact,
 | |
|     message,
 | |
|     subject,
 | |
|     ccEmails,
 | |
|     bccEmails,
 | |
|     currentUser: props.currentUser,
 | |
|     attachedFiles,
 | |
|     directUploadsEnabled: props.isDirectUploadsEnabled,
 | |
|   });
 | |
| };
 | |
| 
 | |
| const contactableInboxesList = computed(() => {
 | |
|   return buildContactableInboxesList(props.selectedContact?.contactInboxes);
 | |
| });
 | |
| 
 | |
| const showNoInboxAlert = computed(() => {
 | |
|   return (
 | |
|     props.selectedContact &&
 | |
|     contactableInboxesList.value.length === 0 &&
 | |
|     !props.contactsUiFlags.isFetchingInboxes &&
 | |
|     !props.isFetchingInboxes
 | |
|   );
 | |
| });
 | |
| 
 | |
| const isAnyDropdownActive = computed(() => {
 | |
|   return (
 | |
|     showContactsDropdown.value ||
 | |
|     showInboxesDropdown.value ||
 | |
|     showCcEmailsDropdown.value ||
 | |
|     showBccEmailsDropdown.value
 | |
|   );
 | |
| });
 | |
| 
 | |
| const handleContactSearch = value => {
 | |
|   showContactsDropdown.value = true;
 | |
|   const query = typeof value === 'string' ? value.trim() : '';
 | |
|   const hasAlphabet = Array.from(query).some(char => {
 | |
|     const lower = char.toLowerCase();
 | |
|     const upper = char.toUpperCase();
 | |
|     return lower !== upper;
 | |
|   });
 | |
|   const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
 | |
| 
 | |
|   const keys = ['email', 'phone_number', 'name'].filter(key => {
 | |
|     if (key === 'phone_number' && hasAlphabet) return false;
 | |
|     if (key === 'name' && isEmailLike) return false;
 | |
|     return true;
 | |
|   });
 | |
| 
 | |
|   emit('searchContacts', { keys, query: value });
 | |
| };
 | |
| 
 | |
| const handleDropdownUpdate = (type, value) => {
 | |
|   if (type === 'cc') {
 | |
|     showCcEmailsDropdown.value = value;
 | |
|   } else if (type === 'bcc') {
 | |
|     showBccEmailsDropdown.value = value;
 | |
|   } else {
 | |
|     showContactsDropdown.value = value;
 | |
|   }
 | |
| };
 | |
| 
 | |
| const searchCcEmails = value => {
 | |
|   showCcEmailsDropdown.value = true;
 | |
|   emit('searchContacts', { keys: ['email'], query: value });
 | |
| };
 | |
| 
 | |
| const searchBccEmails = value => {
 | |
|   showBccEmailsDropdown.value = true;
 | |
|   emit('searchContacts', { keys: ['email'], query: value });
 | |
| };
 | |
| 
 | |
| const setSelectedContact = async ({ value, action, ...rest }) => {
 | |
|   v$.value.$reset();
 | |
|   emit('updateSelectedContact', { value, action, ...rest });
 | |
|   showContactsDropdown.value = false;
 | |
|   showInboxesDropdown.value = true;
 | |
| };
 | |
| 
 | |
| const handleInboxAction = ({ value, action, ...rest }) => {
 | |
|   v$.value.$reset();
 | |
|   emit('updateTargetInbox', { ...rest });
 | |
|   showInboxesDropdown.value = false;
 | |
|   state.attachedFiles = [];
 | |
| };
 | |
| 
 | |
| const removeTargetInbox = value => {
 | |
|   v$.value.$reset();
 | |
|   // Remove the signature from message content
 | |
|   // Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp)
 | |
|   if (props.sendWithSignature) {
 | |
|     const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
 | |
|       ? props.messageSignature
 | |
|       : extractTextFromMarkdown(props.messageSignature);
 | |
|     state.message = removeSignature(state.message, signatureToRemove);
 | |
|   }
 | |
|   emit('updateTargetInbox', value);
 | |
|   state.attachedFiles = [];
 | |
| };
 | |
| 
 | |
| const clearSelectedContact = () => {
 | |
|   emit('clearSelectedContact');
 | |
|   state.attachedFiles = [];
 | |
| };
 | |
| 
 | |
| const onClickInsertEmoji = emoji => {
 | |
|   state.message += emoji;
 | |
| };
 | |
| 
 | |
| const handleAddSignature = signature => {
 | |
|   state.message = appendSignature(state.message, signature);
 | |
| };
 | |
| 
 | |
| const handleRemoveSignature = signature => {
 | |
|   state.message = removeSignature(state.message, signature);
 | |
| };
 | |
| 
 | |
| const handleAttachFile = files => {
 | |
|   state.attachedFiles = files;
 | |
| };
 | |
| 
 | |
| const clearForm = () => {
 | |
|   Object.assign(state, {
 | |
|     message: '',
 | |
|     subject: '',
 | |
|     ccEmails: '',
 | |
|     bccEmails: '',
 | |
|     attachedFiles: [],
 | |
|   });
 | |
|   v$.value.$reset();
 | |
| };
 | |
| 
 | |
| const handleSendMessage = async () => {
 | |
|   const isValid = await v$.value.$validate();
 | |
|   if (!isValid) return;
 | |
| 
 | |
|   try {
 | |
|     const success = await emit('createConversation', {
 | |
|       payload: newMessagePayload(),
 | |
|       isFromWhatsApp: false,
 | |
|     });
 | |
|     if (success) {
 | |
|       clearForm();
 | |
|     }
 | |
|   } catch (error) {
 | |
|     // Form will not be cleared if conversation creation fails
 | |
|   }
 | |
| };
 | |
| 
 | |
| const handleSendWhatsappMessage = async ({ message, templateParams }) => {
 | |
|   const whatsappMessagePayload = prepareWhatsAppMessagePayload({
 | |
|     targetInbox: props.targetInbox,
 | |
|     selectedContact: props.selectedContact,
 | |
|     message,
 | |
|     templateParams,
 | |
|     currentUser: props.currentUser,
 | |
|   });
 | |
|   await emit('createConversation', {
 | |
|     payload: whatsappMessagePayload,
 | |
|     isFromWhatsApp: true,
 | |
|   });
 | |
| };
 | |
| 
 | |
| const handleSendTwilioMessage = async ({ message, templateParams }) => {
 | |
|   const twilioMessagePayload = prepareWhatsAppMessagePayload({
 | |
|     targetInbox: props.targetInbox,
 | |
|     selectedContact: props.selectedContact,
 | |
|     message,
 | |
|     templateParams,
 | |
|     currentUser: props.currentUser,
 | |
|   });
 | |
|   await emit('createConversation', {
 | |
|     payload: twilioMessagePayload,
 | |
|     isFromWhatsApp: true,
 | |
|   });
 | |
| };
 | |
| 
 | |
| const shouldShowMessageEditor = computed(() => {
 | |
|   return (
 | |
|     !inboxTypes.value.isWhatsapp &&
 | |
|     !showNoInboxAlert.value &&
 | |
|     !inboxTypes.value.isTwilioWhatsapp
 | |
|   );
 | |
| });
 | |
| </script>
 | |
| 
 | |
| <template>
 | |
|   <div
 | |
|     class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0"
 | |
|   >
 | |
|     <ContactSelector
 | |
|       :contacts="contacts"
 | |
|       :selected-contact="selectedContact"
 | |
|       :show-contacts-dropdown="showContactsDropdown"
 | |
|       :is-loading="isLoading"
 | |
|       :is-creating-contact="isCreatingContact"
 | |
|       :contact-id="contactId"
 | |
|       :contactable-inboxes-list="contactableInboxesList"
 | |
|       :show-inboxes-dropdown="showInboxesDropdown"
 | |
|       :has-errors="validationStates.isContactInvalid"
 | |
|       @search-contacts="handleContactSearch"
 | |
|       @set-selected-contact="setSelectedContact"
 | |
|       @clear-selected-contact="clearSelectedContact"
 | |
|       @update-dropdown="handleDropdownUpdate"
 | |
|     />
 | |
|     <InboxEmptyState v-if="showNoInboxAlert" />
 | |
|     <InboxSelector
 | |
|       v-else
 | |
|       :target-inbox="targetInbox"
 | |
|       :selected-contact="selectedContact"
 | |
|       :show-inboxes-dropdown="showInboxesDropdown"
 | |
|       :contactable-inboxes-list="contactableInboxesList"
 | |
|       :has-errors="validationStates.isInboxInvalid"
 | |
|       @update-inbox="removeTargetInbox"
 | |
|       @toggle-dropdown="showInboxesDropdown = $event"
 | |
|       @handle-inbox-action="handleInboxAction"
 | |
|     />
 | |
| 
 | |
|     <EmailOptions
 | |
|       v-if="inboxTypes.isEmail"
 | |
|       v-model:cc-emails="state.ccEmails"
 | |
|       v-model:bcc-emails="state.bccEmails"
 | |
|       v-model:subject="state.subject"
 | |
|       :contacts="contacts"
 | |
|       :show-cc-emails-dropdown="showCcEmailsDropdown"
 | |
|       :show-bcc-emails-dropdown="showBccEmailsDropdown"
 | |
|       :is-loading="isLoading"
 | |
|       :has-errors="validationStates.isSubjectInvalid"
 | |
|       @search-cc-emails="searchCcEmails"
 | |
|       @search-bcc-emails="searchBccEmails"
 | |
|       @update-dropdown="handleDropdownUpdate"
 | |
|     />
 | |
| 
 | |
|     <MessageEditor
 | |
|       v-if="shouldShowMessageEditor"
 | |
|       v-model="state.message"
 | |
|       :message-signature="messageSignature"
 | |
|       :send-with-signature="sendWithSignature"
 | |
|       :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
 | |
|       :has-errors="validationStates.isMessageInvalid"
 | |
|       :has-attachments="state.attachedFiles.length > 0"
 | |
|     />
 | |
| 
 | |
|     <AttachmentPreviews
 | |
|       v-if="state.attachedFiles.length > 0"
 | |
|       :attachments="state.attachedFiles"
 | |
|       @update:attachments="state.attachedFiles = $event"
 | |
|     />
 | |
| 
 | |
|     <ActionButtons
 | |
|       :attached-files="state.attachedFiles"
 | |
|       :is-whatsapp-inbox="inboxTypes.isWhatsapp"
 | |
|       :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
 | |
|       :is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
 | |
|       :is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
 | |
|       :message-templates="whatsappMessageTemplates"
 | |
|       :channel-type="inboxChannelType"
 | |
|       :is-loading="isCreating"
 | |
|       :disable-send-button="isCreating"
 | |
|       :has-selected-inbox="!!targetInbox"
 | |
|       :inbox-id="targetInbox?.id"
 | |
|       :has-no-inbox="showNoInboxAlert"
 | |
|       :is-dropdown-active="isAnyDropdownActive"
 | |
|       :message-signature="messageSignature"
 | |
|       @insert-emoji="onClickInsertEmoji"
 | |
|       @add-signature="handleAddSignature"
 | |
|       @remove-signature="handleRemoveSignature"
 | |
|       @attach-file="handleAttachFile"
 | |
|       @discard="$emit('discard')"
 | |
|       @send-message="handleSendMessage"
 | |
|       @send-whatsapp-message="handleSendWhatsappMessage"
 | |
|       @send-twilio-message="handleSendTwilioMessage"
 | |
|     />
 | |
|   </div>
 | |
| </template>
 |