mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-11-03 20:48:07 +00:00 
			
		
		
		
	# Pull Request Template ## Description This PR will fix an error `TypeError cannot read properties of undefined (reading '$el')` with the keyboard shortcut `Alt+KeyA` for triggering the file picker. **Issue** I couldn't able reproduce this issue. It might be because `$children` was unavailable at the time of access. **Solution** Previously, it relied on `$children`. Now, it uses the more reliable `querySelector` method to find the target element. Fixes https://chatwoot-p3.sentry.io/issues/5708410274/?alert_rule_id=15157525&alert_timestamp=1723552508790&alert_type=email&environment=production¬ification_uuid=be5966b2-f17d-4273-8709-98e3322f1f6f&project=4507182691975168&referrer=alert_email ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? **Test cases** 1. Open a conversation 2. And click `Alt+KeyA` to trigger the file picker. 3. See if there is any issue or errors. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
		
			
				
	
	
		
			418 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			418 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
<script>
 | 
						|
import { ref, watchEffect, computed } from 'vue';
 | 
						|
import { useUISettings } from 'dashboard/composables/useUISettings';
 | 
						|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
 | 
						|
import FileUpload from 'vue-upload-component';
 | 
						|
import * as ActiveStorage from 'activestorage';
 | 
						|
import inboxMixin from 'shared/mixins/inboxMixin';
 | 
						|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
 | 
						|
import {
 | 
						|
  ALLOWED_FILE_TYPES,
 | 
						|
  ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
 | 
						|
  ALLOWED_FILE_TYPES_FOR_LINE,
 | 
						|
} from 'shared/constants/messages';
 | 
						|
import VideoCallButton from '../VideoCallButton.vue';
 | 
						|
import AIAssistanceButton from '../AIAssistanceButton.vue';
 | 
						|
import { REPLY_EDITOR_MODES } from './constants';
 | 
						|
import { mapGetters } from 'vuex';
 | 
						|
 | 
						|
export default {
 | 
						|
  name: 'ReplyBottomPanel',
 | 
						|
  components: { FileUpload, VideoCallButton, AIAssistanceButton },
 | 
						|
  mixins: [inboxMixin],
 | 
						|
  props: {
 | 
						|
    mode: {
 | 
						|
      type: String,
 | 
						|
      default: REPLY_EDITOR_MODES.REPLY,
 | 
						|
    },
 | 
						|
    onSend: {
 | 
						|
      type: Function,
 | 
						|
      default: () => {},
 | 
						|
    },
 | 
						|
    sendButtonText: {
 | 
						|
      type: String,
 | 
						|
      default: '',
 | 
						|
    },
 | 
						|
    recordingAudioDurationText: {
 | 
						|
      type: String,
 | 
						|
      default: '',
 | 
						|
    },
 | 
						|
    // inbox prop is used in /mixins/inboxMixin,
 | 
						|
    // remove this props when refactoring to composable if not needed
 | 
						|
    // eslint-disable-next-line vue/no-unused-properties
 | 
						|
    inbox: {
 | 
						|
      type: Object,
 | 
						|
      default: () => ({}),
 | 
						|
    },
 | 
						|
    showFileUpload: {
 | 
						|
      type: Boolean,
 | 
						|
      default: false,
 | 
						|
    },
 | 
						|
    showAudioRecorder: {
 | 
						|
      type: Boolean,
 | 
						|
      default: false,
 | 
						|
    },
 | 
						|
    onFileUpload: {
 | 
						|
      type: Function,
 | 
						|
      default: () => {},
 | 
						|
    },
 | 
						|
    toggleEmojiPicker: {
 | 
						|
      type: Function,
 | 
						|
      default: () => {},
 | 
						|
    },
 | 
						|
    toggleAudioRecorder: {
 | 
						|
      type: Function,
 | 
						|
      default: () => {},
 | 
						|
    },
 | 
						|
    toggleAudioRecorderPlayPause: {
 | 
						|
      type: Function,
 | 
						|
      default: () => {},
 | 
						|
    },
 | 
						|
    isRecordingAudio: {
 | 
						|
      type: Boolean,
 | 
						|
      default: false,
 | 
						|
    },
 | 
						|
    recordingAudioState: {
 | 
						|
      type: String,
 | 
						|
      default: '',
 | 
						|
    },
 | 
						|
    isSendDisabled: {
 | 
						|
      type: Boolean,
 | 
						|
      default: false,
 | 
						|
    },
 | 
						|
    showEditorToggle: {
 | 
						|
      type: Boolean,
 | 
						|
      default: false,
 | 
						|
    },
 | 
						|
    isOnPrivateNote: {
 | 
						|
      type: Boolean,
 | 
						|
      default: false,
 | 
						|
    },
 | 
						|
    enableMultipleFileUpload: {
 | 
						|
      type: Boolean,
 | 
						|
      default: true,
 | 
						|
    },
 | 
						|
    hasWhatsappTemplates: {
 | 
						|
      type: Boolean,
 | 
						|
      default: false,
 | 
						|
    },
 | 
						|
    conversationId: {
 | 
						|
      type: Number,
 | 
						|
      required: true,
 | 
						|
    },
 | 
						|
    message: {
 | 
						|
      type: String,
 | 
						|
      default: '',
 | 
						|
    },
 | 
						|
    newConversationModalActive: {
 | 
						|
      type: Boolean,
 | 
						|
      default: false,
 | 
						|
    },
 | 
						|
    portalSlug: {
 | 
						|
      type: String,
 | 
						|
      required: true,
 | 
						|
    },
 | 
						|
  },
 | 
						|
  setup() {
 | 
						|
    const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
 | 
						|
      useUISettings();
 | 
						|
 | 
						|
    const uploadRef = ref(null);
 | 
						|
    // TODO: This is really hacky, we need to replace the file picker component with
 | 
						|
    // a custom one, where the logic and the component markup is isolated.
 | 
						|
    // Once we have the custom component, we can remove the hacky logic below.
 | 
						|
    const uploadRefElem = computed(() => uploadRef.value?.$el);
 | 
						|
 | 
						|
    const keyboardEvents = {
 | 
						|
      'Alt+KeyA': {
 | 
						|
        action: () => {
 | 
						|
          const uploadTriggerButton = document.querySelector(
 | 
						|
            '#conversationAttachment'
 | 
						|
          );
 | 
						|
          uploadTriggerButton.click();
 | 
						|
        },
 | 
						|
        allowOnFocusedInput: true,
 | 
						|
      },
 | 
						|
    };
 | 
						|
 | 
						|
    watchEffect(() => {
 | 
						|
      useKeyboardEvents(keyboardEvents, uploadRefElem);
 | 
						|
    });
 | 
						|
 | 
						|
    return {
 | 
						|
      setSignatureFlagForInbox,
 | 
						|
      fetchSignatureFlagFromUISettings,
 | 
						|
      uploadRef,
 | 
						|
    };
 | 
						|
  },
 | 
						|
  computed: {
 | 
						|
    ...mapGetters({
 | 
						|
      accountId: 'getCurrentAccountId',
 | 
						|
      isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
 | 
						|
      uiFlags: 'integrations/getUIFlags',
 | 
						|
    }),
 | 
						|
    isNote() {
 | 
						|
      return this.mode === REPLY_EDITOR_MODES.NOTE;
 | 
						|
    },
 | 
						|
    wrapClass() {
 | 
						|
      return {
 | 
						|
        'is-note-mode': this.isNote,
 | 
						|
      };
 | 
						|
    },
 | 
						|
    buttonClass() {
 | 
						|
      return {
 | 
						|
        warning: this.isNote,
 | 
						|
      };
 | 
						|
    },
 | 
						|
    showAttachButton() {
 | 
						|
      return this.showFileUpload || this.isNote;
 | 
						|
    },
 | 
						|
    showAudioRecorderButton() {
 | 
						|
      if (this.isALineChannel) {
 | 
						|
        return false;
 | 
						|
      }
 | 
						|
      // Disable audio recorder for safari browser as recording is not supported
 | 
						|
      const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(
 | 
						|
        navigator.userAgent
 | 
						|
      );
 | 
						|
 | 
						|
      return (
 | 
						|
        this.isFeatureEnabledonAccount(
 | 
						|
          this.accountId,
 | 
						|
          FEATURE_FLAGS.VOICE_RECORDER
 | 
						|
        ) &&
 | 
						|
        this.showAudioRecorder &&
 | 
						|
        !isSafari
 | 
						|
      );
 | 
						|
    },
 | 
						|
    showAudioPlayStopButton() {
 | 
						|
      return this.showAudioRecorder && this.isRecordingAudio;
 | 
						|
    },
 | 
						|
    allowedFileTypes() {
 | 
						|
      if (this.isATwilioWhatsAppChannel) {
 | 
						|
        return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
 | 
						|
      }
 | 
						|
      if (this.isALineChannel) {
 | 
						|
        return ALLOWED_FILE_TYPES_FOR_LINE;
 | 
						|
      }
 | 
						|
      return ALLOWED_FILE_TYPES;
 | 
						|
    },
 | 
						|
    enableDragAndDrop() {
 | 
						|
      return !this.newConversationModalActive;
 | 
						|
    },
 | 
						|
    audioRecorderPlayStopIcon() {
 | 
						|
      switch (this.recordingAudioState) {
 | 
						|
        // playing paused recording stopped inactive destroyed
 | 
						|
        case 'playing':
 | 
						|
          return 'microphone-pause';
 | 
						|
        case 'paused':
 | 
						|
          return 'microphone-play';
 | 
						|
        case 'stopped':
 | 
						|
          return 'microphone-play';
 | 
						|
        default:
 | 
						|
          return 'microphone-stop';
 | 
						|
      }
 | 
						|
    },
 | 
						|
    showMessageSignatureButton() {
 | 
						|
      return !this.isOnPrivateNote;
 | 
						|
    },
 | 
						|
    sendWithSignature() {
 | 
						|
      // channelType is sourced from inboxMixin
 | 
						|
      return this.fetchSignatureFlagFromUISettings(this.channelType);
 | 
						|
    },
 | 
						|
    signatureToggleTooltip() {
 | 
						|
      return this.sendWithSignature
 | 
						|
        ? this.$t('CONVERSATION.FOOTER.DISABLE_SIGN_TOOLTIP')
 | 
						|
        : this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
 | 
						|
    },
 | 
						|
    enableInsertArticleInReply() {
 | 
						|
      const isFeatEnabled = this.isFeatureEnabledonAccount(
 | 
						|
        this.accountId,
 | 
						|
        FEATURE_FLAGS.INSERT_ARTICLE_IN_REPLY
 | 
						|
      );
 | 
						|
      return isFeatEnabled && this.portalSlug;
 | 
						|
    },
 | 
						|
    isFetchingAppIntegrations() {
 | 
						|
      return this.uiFlags.isFetching;
 | 
						|
    },
 | 
						|
  },
 | 
						|
  mounted() {
 | 
						|
    ActiveStorage.start();
 | 
						|
  },
 | 
						|
  methods: {
 | 
						|
    toggleMessageSignature() {
 | 
						|
      this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
 | 
						|
    },
 | 
						|
    replaceText(text) {
 | 
						|
      this.$emit('replaceText', text);
 | 
						|
    },
 | 
						|
    toggleInsertArticle() {
 | 
						|
      this.$emit('toggleInsertArticle');
 | 
						|
    },
 | 
						|
  },
 | 
						|
};
 | 
						|
</script>
 | 
						|
 | 
						|
<template>
 | 
						|
  <div class="bottom-box" :class="wrapClass">
 | 
						|
    <div class="left-wrap">
 | 
						|
      <woot-button
 | 
						|
        v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
 | 
						|
        :title="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
 | 
						|
        icon="emoji"
 | 
						|
        emoji="😊"
 | 
						|
        color-scheme="secondary"
 | 
						|
        variant="smooth"
 | 
						|
        size="small"
 | 
						|
        @click="toggleEmojiPicker"
 | 
						|
      />
 | 
						|
      <FileUpload
 | 
						|
        ref="uploadRef"
 | 
						|
        v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
 | 
						|
        input-id="conversationAttachment"
 | 
						|
        :size="4096 * 4096"
 | 
						|
        :accept="allowedFileTypes"
 | 
						|
        :multiple="enableMultipleFileUpload"
 | 
						|
        :drop="enableDragAndDrop"
 | 
						|
        :drop-directory="false"
 | 
						|
        :data="{
 | 
						|
          direct_upload_url: '/rails/active_storage/direct_uploads',
 | 
						|
          direct_upload: true,
 | 
						|
        }"
 | 
						|
        @input-file="onFileUpload"
 | 
						|
      >
 | 
						|
        <woot-button
 | 
						|
          v-if="showAttachButton"
 | 
						|
          class-names="button--upload"
 | 
						|
          :title="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
 | 
						|
          icon="attach"
 | 
						|
          emoji="📎"
 | 
						|
          color-scheme="secondary"
 | 
						|
          variant="smooth"
 | 
						|
          size="small"
 | 
						|
        />
 | 
						|
      </FileUpload>
 | 
						|
      <woot-button
 | 
						|
        v-if="showAudioRecorderButton"
 | 
						|
        v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
 | 
						|
        :icon="!isRecordingAudio ? 'microphone' : 'microphone-off'"
 | 
						|
        emoji="🎤"
 | 
						|
        :color-scheme="!isRecordingAudio ? 'secondary' : 'alert'"
 | 
						|
        variant="smooth"
 | 
						|
        size="small"
 | 
						|
        @click="toggleAudioRecorder"
 | 
						|
      />
 | 
						|
      <woot-button
 | 
						|
        v-if="showEditorToggle"
 | 
						|
        v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
 | 
						|
        icon="quote"
 | 
						|
        emoji="🖊️"
 | 
						|
        color-scheme="secondary"
 | 
						|
        variant="smooth"
 | 
						|
        size="small"
 | 
						|
        @click="$emit('toggleEditor')"
 | 
						|
      />
 | 
						|
      <woot-button
 | 
						|
        v-if="showAudioPlayStopButton"
 | 
						|
        :icon="audioRecorderPlayStopIcon"
 | 
						|
        emoji="🎤"
 | 
						|
        color-scheme="secondary"
 | 
						|
        variant="smooth"
 | 
						|
        size="small"
 | 
						|
        @click="toggleAudioRecorderPlayPause"
 | 
						|
      >
 | 
						|
        <span>{{ recordingAudioDurationText }}</span>
 | 
						|
      </woot-button>
 | 
						|
      <woot-button
 | 
						|
        v-if="showMessageSignatureButton"
 | 
						|
        v-tooltip.top-end="signatureToggleTooltip"
 | 
						|
        icon="signature"
 | 
						|
        color-scheme="secondary"
 | 
						|
        variant="smooth"
 | 
						|
        size="small"
 | 
						|
        :title="signatureToggleTooltip"
 | 
						|
        @click="toggleMessageSignature"
 | 
						|
      />
 | 
						|
      <woot-button
 | 
						|
        v-if="hasWhatsappTemplates"
 | 
						|
        v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
 | 
						|
        icon="whatsapp"
 | 
						|
        color-scheme="secondary"
 | 
						|
        variant="smooth"
 | 
						|
        size="small"
 | 
						|
        :title="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
 | 
						|
        @click="$emit('selectWhatsappTemplate')"
 | 
						|
      />
 | 
						|
      <VideoCallButton
 | 
						|
        v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
 | 
						|
        :conversation-id="conversationId"
 | 
						|
      />
 | 
						|
      <AIAssistanceButton
 | 
						|
        v-if="!isFetchingAppIntegrations"
 | 
						|
        :conversation-id="conversationId"
 | 
						|
        :is-private-note="isOnPrivateNote"
 | 
						|
        :message="message"
 | 
						|
        @replaceText="replaceText"
 | 
						|
      />
 | 
						|
      <transition name="modal-fade">
 | 
						|
        <div
 | 
						|
          v-show="$refs.uploadRef && $refs.uploadRef.dropActive"
 | 
						|
          class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-slate-900 dark:text-slate-50 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
 | 
						|
        >
 | 
						|
          <fluent-icon icon="cloud-backup" size="40" />
 | 
						|
          <h4 class="text-2xl break-words text-slate-900 dark:text-slate-50">
 | 
						|
            {{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }}
 | 
						|
          </h4>
 | 
						|
        </div>
 | 
						|
      </transition>
 | 
						|
      <woot-button
 | 
						|
        v-if="enableInsertArticleInReply"
 | 
						|
        v-tooltip.top-end="$t('HELP_CENTER.ARTICLE_SEARCH.OPEN_ARTICLE_SEARCH')"
 | 
						|
        icon="document-text-link"
 | 
						|
        color-scheme="secondary"
 | 
						|
        variant="smooth"
 | 
						|
        size="small"
 | 
						|
        :title="$t('HELP_CENTER.ARTICLE_SEARCH.OPEN_ARTICLE_SEARCH')"
 | 
						|
        @click="toggleInsertArticle"
 | 
						|
      />
 | 
						|
    </div>
 | 
						|
    <div class="right-wrap">
 | 
						|
      <woot-button
 | 
						|
        size="small"
 | 
						|
        :class-names="buttonClass"
 | 
						|
        :is-disabled="isSendDisabled"
 | 
						|
        @click="onSend"
 | 
						|
      >
 | 
						|
        {{ sendButtonText }}
 | 
						|
      </woot-button>
 | 
						|
    </div>
 | 
						|
  </div>
 | 
						|
</template>
 | 
						|
 | 
						|
<style lang="scss" scoped>
 | 
						|
.bottom-box {
 | 
						|
  @apply flex justify-between py-3 px-4;
 | 
						|
 | 
						|
  &.is-note-mode {
 | 
						|
    @apply bg-yellow-100 dark:bg-yellow-800;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
.left-wrap {
 | 
						|
  @apply items-center flex gap-2;
 | 
						|
}
 | 
						|
 | 
						|
.right-wrap {
 | 
						|
  @apply flex;
 | 
						|
}
 | 
						|
 | 
						|
::v-deep .file-uploads {
 | 
						|
  label {
 | 
						|
    @apply cursor-pointer;
 | 
						|
  }
 | 
						|
  &:hover button {
 | 
						|
    @apply dark:bg-slate-800 bg-slate-100;
 | 
						|
  }
 | 
						|
}
 | 
						|
</style>
 |