From bbfcdb3d428b7485e42aeedf9435b3e8c642a7f5 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 18 Mar 2025 14:01:18 +0530 Subject: [PATCH 001/107] chore: Improvements in image attachment viewer (#11040) This PR includes improvements in image attachment/gallery viewer: 1. Added double-click zoom functionality (depreciated click to zoom) 2. Implemented scroll zoom based on cursor position 3. Increase the zoom scale 4. Improved layout and styling for better usability Fixes https://linear.app/chatwoot/issue/CW-4127/zoom-images-from-a-specific-location ## How Has This Been Tested? Loom video https://www.loom.com/share/b21e00db3bc74231a90202eb6eb2fb5a?sid=a0651bf1-0952-430b-a5a9-83bf0858e059 --------- Co-authored-by: Pranav Co-authored-by: Shivam Mishra --- .../conversation/components/GalleryView.vue | 255 ++++++++---------- .../composables/spec/useImageZoom.spec.js | 141 ++++++++++ .../dashboard/composables/useImageZoom.js | 186 +++++++++++++ package.json | 2 +- pnpm-lock.yaml | 10 +- 5 files changed, 439 insertions(+), 155 deletions(-) create mode 100644 app/javascript/dashboard/composables/spec/useImageZoom.spec.js create mode 100644 app/javascript/dashboard/composables/useImageZoom.js diff --git a/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue b/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue index f6e496ce6..5c56639f4 100644 --- a/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue @@ -1,10 +1,11 @@ - - - - diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue index 0de55a92e..b423ecfc6 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue @@ -7,8 +7,8 @@ import ContactInfoRow from './ContactInfoRow.vue'; import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import SocialIcons from './SocialIcons.vue'; import EditContact from './EditContact.vue'; -import NewConversation from './NewConversation.vue'; import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue'; +import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import NextButton from 'dashboard/components-next/button/Button.vue'; @@ -25,8 +25,8 @@ export default { ContactInfoRow, EditContact, Thumbnail, + ComposeConversation, SocialIcons, - NewConversation, ContactMergeModal, }, props: { @@ -49,7 +49,6 @@ export default { data() { return { showEditModal: false, - showConversationModal: false, showMergeModal: false, showDeleteModal: false, }; @@ -92,17 +91,29 @@ export default { return ` ${this.contact.name}?`; }, }, + watch: { + 'contact.id': { + handler(id) { + this.$store.dispatch('contacts/fetchContactableInbox', id); + }, + immediate: true, + }, + }, methods: { dynamicTime, toggleEditModal() { this.showEditModal = !this.showEditModal; }, - toggleConversationModal() { - this.showConversationModal = !this.showConversationModal; - emitter.emit( - BUS_EVENTS.NEW_CONVERSATION_MODAL, - this.showConversationModal - ); + openComposeConversationModal(toggleFn) { + toggleFn(); + // Flag to prevent triggering drag n drop, + // When compose modal is active + emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true); + }, + closeComposeConversationModal() { + // Flag to enable drag n drop, + // When compose modal is closed + emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false); }, toggleDeleteModal() { this.showDeleteModal = !this.showDeleteModal; @@ -113,7 +124,6 @@ export default { }, closeDelete() { this.showDeleteModal = false; - this.showConversationModal = false; this.showEditModal = false; }, findCountryFlag(countryCode, cityAndCountry) { @@ -250,14 +260,22 @@ export default {
- + + + - -import { ref } from 'vue'; -// constants & helpers -import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; -import { ExceptionWithMessage } from 'shared/helpers/CustomErrors'; -import { getInboxSource, INBOX_TYPES } from 'dashboard/helper/inbox'; - -// store -import { mapGetters } from 'vuex'; - -// composables -import { useUISettings } from 'dashboard/composables/useUISettings'; -import { useAlert } from 'dashboard/composables'; -import { required, requiredIf } from '@vuelidate/validators'; -import { useVuelidate } from '@vuelidate/core'; - -// mixins -import fileUploadMixin from 'dashboard/mixins/fileUploadMixin'; -import inboxMixin from 'shared/mixins/inboxMixin'; - -// components -import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue'; -import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue'; -import InboxDropdownItem from 'dashboard/components/widgets/InboxDropdownItem.vue'; -import MessageSignatureMissingAlert from 'dashboard/components/widgets/conversation/MessageSignatureMissingAlert.vue'; -import ReplyEmailHead from 'dashboard/components/widgets/conversation/ReplyEmailHead.vue'; -import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; -import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; -import FileUpload from 'vue-upload-component'; -import WhatsappTemplates from './WhatsappTemplates.vue'; - -import { - appendSignature, - removeSignature, -} from 'dashboard/helper/editorHelper'; - -export default { - components: { - Thumbnail, - WootMessageEditor, - ReplyEmailHead, - CannedResponse, - WhatsappTemplates, - InboxDropdownItem, - FileUpload, - AttachmentPreview, - MessageSignatureMissingAlert, - }, - mixins: [inboxMixin, fileUploadMixin], - props: { - contact: { - type: Object, - default: () => ({}), - }, - onSubmit: { - type: Function, - default: () => {}, - }, - }, - emits: ['cancel', 'success'], - setup() { - const { fetchSignatureFlagFromUISettings, setSignatureFlagForInbox } = - useUISettings(); - const v$ = useVuelidate(); - const uploadAttachment = ref(false); - - return { - fetchSignatureFlagFromUISettings, - setSignatureFlagForInbox, - v$, - uploadAttachment, - }; - }, - data() { - return { - name: '', - subject: '', - message: '', - showCannedResponseMenu: false, - cannedResponseSearchKey: '', - bccEmails: '', - ccEmails: '', - targetInbox: {}, - whatsappTemplateSelected: false, - attachedFiles: [], - }; - }, - validations() { - return { - subject: { - required: requiredIf(this.isAnEmailInbox), - }, - message: { - required, - }, - targetInbox: { - required, - }, - }; - }, - computed: { - ...mapGetters({ - uiFlags: 'contacts/getUIFlags', - conversationsUiFlags: 'contactConversations/getUIFlags', - currentUser: 'getCurrentUser', - globalConfig: 'globalConfig/get', - messageSignature: 'getMessageSignature', - inboxesList: 'inboxes/getInboxes', - }), - sendWithSignature() { - return this.fetchSignatureFlagFromUISettings(this.channelType); - }, - signatureToApply() { - return this.messageSignature; - }, - newMessagePayload() { - const payload = { - inboxId: this.targetInbox.id, - sourceId: this.targetInbox.sourceId, - contactId: this.contact.id, - message: { content: this.message }, - mailSubject: this.subject, - assigneeId: this.currentUser.id, - }; - - if (this.attachedFiles && this.attachedFiles.length) { - payload.files = []; - this.setAttachmentPayload(payload); - } - - if (this.ccEmails) { - payload.message.cc_emails = this.ccEmails; - } - - if (this.bccEmails) { - payload.message.bcc_emails = this.bccEmails; - } - return payload; - }, - selectedInbox: { - get() { - const inboxList = this.contact.contact_inboxes || []; - const selectedContactInbox = inboxList.find( - inbox => inbox.inbox?.id && inbox.inbox?.id === this.targetInbox?.id - ); - - if (!selectedContactInbox) { - return { inbox: {} }; - } - - // Find the matching inbox from the inboxesList - const matchingInbox = - this.inboxesList.find( - item => item.id === selectedContactInbox.inbox?.id - ) || {}; - - // The entire inbox payload is not available in this object, so we need to patch it from the store - return { - ...selectedContactInbox, - inbox: { - ...matchingInbox, - ...selectedContactInbox.inbox, - sourceId: selectedContactInbox.source_id || matchingInbox.sourceId, - }, - }; - }, - set(value) { - this.targetInbox = value.inbox; - }, - }, - showNoInboxAlert() { - if (!this.contact.contact_inboxes) { - return false; - } - return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes; - }, - isSignatureEnabledForInbox() { - return this.isAnEmailInbox && this.sendWithSignature; - }, - signatureToggleTooltip() { - return this.sendWithSignature - ? this.$t('CONVERSATION.FOOTER.DISABLE_SIGN_TOOLTIP') - : this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP'); - }, - - inboxes() { - const inboxList = this.contact.contact_inboxes || []; - if (!inboxList.length) return []; - - return inboxList.map(inbox => { - const matchingInbox = - this.inboxesList.find(item => item.id === inbox.inbox?.id) || {}; - - // Create merged object with a clear property order - return { - ...matchingInbox, - ...inbox.inbox, - sourceId: inbox.source_id, - }; - }); - }, - isAnEmailInbox() { - return ( - this.selectedInbox && - this.selectedInbox.inbox.channel_type === INBOX_TYPES.EMAIL - ); - }, - isAnWebWidgetInbox() { - return ( - this.selectedInbox && - this.selectedInbox.inbox.channel_type === INBOX_TYPES.WEB - ); - }, - isEmailOrWebWidgetInbox() { - return this.isAnEmailInbox || this.isAnWebWidgetInbox; - }, - hasWhatsappTemplates() { - return !!this.selectedInbox.inbox?.message_templates; - }, - hasAttachments() { - return this.attachedFiles.length; - }, - inbox() { - return this.targetInbox; - }, - allowedFileTypes() { - return ALLOWED_FILE_TYPES; - }, - }, - watch: { - message(value) { - this.hasSlashCommand = value[0] === '/' && !this.isEmailOrWebWidgetInbox; - const hasNextWord = value.includes(' '); - const isShortCodeActive = this.hasSlashCommand && !hasNextWord; - if (isShortCodeActive) { - this.cannedResponseSearchKey = value.substring(1); - this.showCannedResponseMenu = true; - } else { - this.cannedResponseSearchKey = ''; - this.showCannedResponseMenu = false; - } - }, - targetInbox() { - this.setSignature(); - }, - }, - mounted() { - this.setSignature(); - }, - methods: { - setSignature() { - if (this.messageSignature) { - if (this.isSignatureEnabledForInbox) { - this.message = appendSignature(this.message, this.signatureToApply); - } else { - this.message = removeSignature(this.message, this.signatureToApply); - } - } - }, - setAttachmentPayload(payload) { - this.attachedFiles.forEach(attachment => { - if (this.globalConfig.directUploadsEnabled) { - payload.files.push(attachment.blobSignedId); - } else { - payload.files.push(attachment.resource.file); - } - }); - }, - attachFile({ blob, file }) { - const reader = new FileReader(); - reader.readAsDataURL(file.file); - reader.onloadend = () => { - this.attachedFiles.push({ - currentChatId: this.contact.id, - resource: blob || file, - isPrivate: this.isPrivate, - thumb: reader.result, - blobSignedId: blob ? blob.signed_id : undefined, - }); - }; - }, - removeAttachment(attachments) { - this.attachedFiles = attachments; - }, - onCancel() { - this.$emit('cancel'); - }, - onSuccess() { - this.$emit('success'); - }, - replaceTextWithCannedResponse(message) { - this.message = message; - }, - toggleCannedMenu(value) { - this.showCannedMenu = value; - }, - prepareWhatsAppMessagePayload({ message: content, templateParams }) { - const payload = { - inboxId: this.targetInbox.id, - sourceId: this.targetInbox.sourceId, - contactId: this.contact.id, - message: { content, template_params: templateParams }, - assigneeId: this.currentUser.id, - }; - return payload; - }, - onFormSubmit() { - const isFromWhatsApp = false; - this.v$.$touch(); - if (this.v$.$invalid) { - return; - } - this.createConversation({ - payload: this.newMessagePayload, - isFromWhatsApp, - }); - }, - async createConversation({ payload, isFromWhatsApp }) { - try { - const data = await this.onSubmit(payload, isFromWhatsApp); - const action = { - type: 'link', - to: `/app/accounts/${data.account_id}/conversations/${data.id}`, - message: this.$t('NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'), - }; - this.onSuccess(); - useAlert(this.$t('NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action); - } catch (error) { - if (error instanceof ExceptionWithMessage) { - useAlert(error.data); - } else { - useAlert(this.$t('NEW_CONVERSATION.FORM.ERROR_MESSAGE')); - } - } - }, - - toggleWaTemplate(val) { - this.whatsappTemplateSelected = val; - }, - async onSendWhatsAppReply(messagePayload) { - const isFromWhatsApp = true; - const payload = this.prepareWhatsAppMessagePayload(messagePayload); - await this.createConversation({ payload, isFromWhatsApp }); - }, - inboxReadableIdentifier(inbox) { - return `${inbox.name} (${inbox.channel_type})`; - }, - computedInboxSource(inbox) { - if (!inbox.channel_type) return ''; - const classByType = getInboxSource( - inbox.channel_type, - inbox.phone_number, - inbox - ); - return classByType; - }, - toggleMessageSignature() { - this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature); - this.setSignature(); - }, - }, -}; - - - -