> Reopened from #13613, now from a personal fork (`gabrieljablonski/chatwoot`) so maintainers can push edits — organization-owned forks don't support "Allow edits from maintainers". The previous PR is closed in favor of this one; same commits, same diff. ## Description This PR adds support for sending voice messages (voice notes) through the WhatsApp Cloud API. When agents record audio in Chatwoot, it is now transcoded in the browser from WebM/Opus to OGG/Opus and sent with the `voice: true` flag, so it appears as a native voice note bubble on WhatsApp — not as a file/document attachment. Closes #13283 **Key Changes:** - Added `webmOpusToOgg.js` — a pure JS EBML parser + OGG page builder that remuxes browser-recorded WebM/Opus audio into OGG/Opus entirely client-side, with no server-side dependencies. - Updated `AudioRecorder.vue` to use an explicit `mimeType` hint, proper resource cleanup, and an `AUDIO_EXTENSION_MAP` for correct file extensions. - Renamed `mp3ConversionUtils.js` → `audioConversionUtils.js` and added OGG conversion support via the new remuxer. - Updated `ReplyBox.vue` to request OGG format for WhatsApp channels, pass `isVoiceMessage` per-attachment, and handle recording errors with a user-facing alert. - Updated `MessageBuilder` to read the `is_voice_message` param and persist it in attachment metadata. - Updated `WhatsappCloudService` to: - Normalize `audio/opus` → `audio/ogg` content type on ActiveStorage blobs (works around Marcel gem re-detection). - Send the `voice: true` flag when the attachment is a voice message with `audio/ogg` content type. - Use WhatsApp Cloud API `v24.0` for the attachment endpoint. - Added `AUDIO_CONVERSION_FAILED` i18n key. **How it works:** 1. The browser records audio as WebM/Opus (Chrome/Firefox default). 2. `audioConversionUtils.js` remuxes it to OGG/Opus using the pure-JS `webmOpusToOgg` remuxer — no server transcoding needed. 3. The OGG file is uploaded with `is_voice_message: true` in the form payload. 4. `MessageBuilder` persists `is_voice_message` in the attachment's `meta` hash. 5. `WhatsappCloudService` normalizes the blob content type if needed, then sends the attachment with `voice: true` so WhatsApp renders it as a voice note. ## Type of change - [X] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? 1. Record a voice message in a WhatsApp Cloud conversation. 2. Verify the audio is transcoded to OGG (check file extension in the attachment preview). 3. Verify the message arrives on WhatsApp as a voice note bubble (not a document/file). 4. Send an image or document attachment and verify it still works as before (no `voice` flag). 5. Send a regular (non-voice) audio file and verify it arrives without the voice flag. --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1499 lines
48 KiB
Vue
1499 lines
48 KiB
Vue
<script>
|
|
import { defineAsyncComponent, useTemplateRef } from 'vue';
|
|
import { mapGetters } from 'vuex';
|
|
import { useAlert } from 'dashboard/composables';
|
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
|
import { useTrack } from 'dashboard/composables';
|
|
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
|
|
|
import ReplyToMessage from './ReplyToMessage.vue';
|
|
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
|
|
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
|
|
import ReplyEmailHead from './ReplyEmailHead.vue';
|
|
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
|
|
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
|
|
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
|
|
import CopilotEditorSection from './CopilotEditorSection.vue';
|
|
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
|
|
import ReplyBoxBanner from './ReplyBoxBanner.vue';
|
|
import QuotedEmailPreview from './QuotedEmailPreview.vue';
|
|
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
|
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
|
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
|
|
import { AUDIO_FORMATS } from 'shared/constants/messages';
|
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
|
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
|
|
import {
|
|
getMessageVariables,
|
|
getUndefinedVariablesInMessage,
|
|
} from '@chatwoot/utils';
|
|
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
|
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
|
|
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
|
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
|
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
|
|
import wootConstants from 'dashboard/constants/globals';
|
|
import {
|
|
extractQuotedEmailText,
|
|
buildQuotedEmailHeader,
|
|
truncatePreviewText,
|
|
appendQuotedTextToMessage,
|
|
} from 'dashboard/helper/quotedEmailHelper';
|
|
import {
|
|
CONVERSATION_EVENTS,
|
|
CAPTAIN_EVENTS,
|
|
} from '../../../helper/AnalyticsHelper/events';
|
|
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
|
import {
|
|
appendSignature,
|
|
removeSignature,
|
|
getEffectiveChannelType,
|
|
} from 'dashboard/helper/editorHelper';
|
|
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
|
|
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
|
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
|
|
|
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
|
import { LocalStorage } from 'shared/helpers/localStorage';
|
|
import { emitter } from 'shared/helpers/mitt';
|
|
const EmojiInput = defineAsyncComponent(
|
|
() => import('shared/components/emoji/EmojiInput.vue')
|
|
);
|
|
|
|
export default {
|
|
components: {
|
|
ArticleSearchPopover,
|
|
AttachmentPreview,
|
|
AudioRecorder,
|
|
ReplyBoxBanner,
|
|
EmojiInput,
|
|
MessageSignatureMissingAlert,
|
|
ReplyBottomPanel,
|
|
ReplyEmailHead,
|
|
ReplyToMessage,
|
|
ReplyTopPanel,
|
|
ContentTemplates,
|
|
WhatsappTemplates,
|
|
WootMessageEditor,
|
|
QuotedEmailPreview,
|
|
CopilotEditorSection,
|
|
CopilotReplyBottomPanel,
|
|
},
|
|
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
|
emits: ['toggleEditorSize'],
|
|
setup() {
|
|
const {
|
|
uiSettings,
|
|
isEditorHotKeyEnabled,
|
|
fetchSignatureFlagFromUISettings,
|
|
setQuotedReplyFlagForInbox,
|
|
fetchQuotedReplyFlagFromUISettings,
|
|
} = useUISettings();
|
|
|
|
const replyEditor = useTemplateRef('replyEditor');
|
|
const messageEditor = useTemplateRef('messageEditor');
|
|
const copilot = useCopilotReply();
|
|
const shortcutKey = useKbd(['$mod', '+', 'enter']);
|
|
|
|
return {
|
|
uiSettings,
|
|
isEditorHotKeyEnabled,
|
|
fetchSignatureFlagFromUISettings,
|
|
setQuotedReplyFlagForInbox,
|
|
fetchQuotedReplyFlagFromUISettings,
|
|
replyEditor,
|
|
messageEditor,
|
|
copilot,
|
|
shortcutKey,
|
|
};
|
|
},
|
|
data() {
|
|
return {
|
|
message: '',
|
|
inReplyTo: {},
|
|
isFocused: false,
|
|
showEmojiPicker: false,
|
|
attachedFiles: [],
|
|
isRecordingAudio: false,
|
|
recordingAudioState: '',
|
|
recordingAudioDurationText: '',
|
|
replyType: REPLY_EDITOR_MODES.REPLY,
|
|
bccEmails: '',
|
|
ccEmails: '',
|
|
toEmails: '',
|
|
doAutoSaveDraft: () => {},
|
|
showWhatsAppTemplatesModal: false,
|
|
showContentTemplatesModal: false,
|
|
updateEditorSelectionWith: '',
|
|
undefinedVariableMessage: '',
|
|
showMentions: false,
|
|
showUserMentions: false,
|
|
showCannedMenu: false,
|
|
showVariablesMenu: false,
|
|
newConversationModalActive: false,
|
|
showArticleSearchPopover: false,
|
|
hasRecordedAudio: false,
|
|
copilotAcceptedMessages: {},
|
|
};
|
|
},
|
|
computed: {
|
|
...mapGetters({
|
|
currentChat: 'getSelectedChat',
|
|
messageSignature: 'getMessageSignature',
|
|
currentUser: 'getCurrentUser',
|
|
lastEmail: 'getLastEmailInSelectedChat',
|
|
globalConfig: 'globalConfig/get',
|
|
}),
|
|
currentContact() {
|
|
const senderId = this.currentChat?.meta?.sender?.id;
|
|
if (!senderId) return {};
|
|
return this.$store.getters['contacts/getContact'](senderId);
|
|
},
|
|
shouldShowReplyToMessage() {
|
|
return (
|
|
this.inReplyTo?.id &&
|
|
!this.isPrivate &&
|
|
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
|
|
!this.is360DialogWhatsAppChannel &&
|
|
!this.copilot.isActive.value
|
|
);
|
|
},
|
|
showWhatsappTemplates() {
|
|
// We support templates for API channels if someone updates templates manually via API
|
|
// That's why we don't explicitly check for channel type here
|
|
const templates = this.$store.getters['inboxes/getWhatsAppTemplates'](
|
|
this.inboxId
|
|
);
|
|
return !!(templates && templates.length) && !this.isPrivate;
|
|
},
|
|
showContentTemplates() {
|
|
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
|
},
|
|
isPrivate() {
|
|
if (
|
|
this.currentChat.can_reply ||
|
|
this.isAWhatsAppChannel ||
|
|
this.isAPIInbox
|
|
) {
|
|
return this.isOnPrivateNote;
|
|
}
|
|
return true;
|
|
},
|
|
hasMeaningfulEditorContent() {
|
|
const body = this.message || '';
|
|
// Only strip the signature when it's actually being auto-appended.
|
|
// If the toggle is off, the agent's text might happen to match their
|
|
// saved signature and we'd incorrectly treat it as empty.
|
|
const shouldStripSignature =
|
|
!this.isPrivate && this.sendWithSignature && !!this.messageSignature;
|
|
if (!shouldStripSignature) return !!body.trim();
|
|
const stripped = removeSignature(
|
|
body,
|
|
this.messageSignature,
|
|
getEffectiveChannelType(this.channelType, this.inbox?.medium || '')
|
|
);
|
|
return !!stripped.trim();
|
|
},
|
|
isReplyRestricted() {
|
|
return (
|
|
!this.currentChat?.can_reply &&
|
|
!(this.isAWhatsAppChannel || this.isAPIInbox)
|
|
);
|
|
},
|
|
inboxId() {
|
|
return this.currentChat.inbox_id;
|
|
},
|
|
inbox() {
|
|
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
|
},
|
|
messagePlaceHolder() {
|
|
if (this.isEditorDisabled) {
|
|
if (this.isAWhatsAppChannel) {
|
|
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP');
|
|
}
|
|
if (this.isAPIInbox) {
|
|
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_API');
|
|
}
|
|
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
|
|
}
|
|
return this.isPrivate
|
|
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
|
|
: this.$t('CONVERSATION.FOOTER.MSG_INPUT');
|
|
},
|
|
isMessageLengthReachingThreshold() {
|
|
return this.message.length > this.maxLength - 50;
|
|
},
|
|
charactersRemaining() {
|
|
return this.maxLength - this.message.length;
|
|
},
|
|
isReplyButtonDisabled() {
|
|
if (this.isEditorDisabled) return true;
|
|
if (this.isATwitterInbox) return true;
|
|
if (this.hasAttachments || this.hasRecordedAudio) return false;
|
|
|
|
return (
|
|
this.isMessageEmpty ||
|
|
this.message.length === 0 ||
|
|
this.message.length > this.maxLength
|
|
);
|
|
},
|
|
sender() {
|
|
return {
|
|
name: this.currentUser.name,
|
|
thumbnail: this.currentUser.avatar_url,
|
|
};
|
|
},
|
|
conversationType() {
|
|
const { additional_attributes: additionalAttributes } = this.currentChat;
|
|
const type = additionalAttributes ? additionalAttributes.type : '';
|
|
return type || '';
|
|
},
|
|
maxLength() {
|
|
if (this.isPrivate) {
|
|
return MESSAGE_MAX_LENGTH.GENERAL;
|
|
}
|
|
if (this.isAFacebookInbox) {
|
|
return MESSAGE_MAX_LENGTH.FACEBOOK;
|
|
}
|
|
if (this.isAnInstagramChannel) {
|
|
return MESSAGE_MAX_LENGTH.INSTAGRAM;
|
|
}
|
|
if (this.isATelegramChannel) {
|
|
return MESSAGE_MAX_LENGTH.TELEGRAM;
|
|
}
|
|
if (this.isATiktokChannel) {
|
|
return MESSAGE_MAX_LENGTH.TIKTOK;
|
|
}
|
|
if (this.isATwilioWhatsAppChannel) {
|
|
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
|
|
}
|
|
if (this.isAWhatsAppCloudChannel) {
|
|
return MESSAGE_MAX_LENGTH.WHATSAPP_CLOUD;
|
|
}
|
|
if (this.isASmsInbox) {
|
|
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
|
|
}
|
|
if (this.isAnEmailChannel) {
|
|
return MESSAGE_MAX_LENGTH.EMAIL;
|
|
}
|
|
if (this.isATwilioSMSChannel) {
|
|
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
|
|
}
|
|
if (this.isAWhatsAppChannel) {
|
|
return MESSAGE_MAX_LENGTH.WHATSAPP_CLOUD;
|
|
}
|
|
return MESSAGE_MAX_LENGTH.GENERAL;
|
|
},
|
|
showFileUpload() {
|
|
const { image_send: imageSend } =
|
|
this.currentChat?.additional_attributes?.tiktok_capabilities ?? {};
|
|
const tiktokAttachmentSupported = imageSend ?? true;
|
|
|
|
return (
|
|
this.isAWebWidgetInbox ||
|
|
this.isAFacebookInbox ||
|
|
this.isAWhatsAppChannel ||
|
|
this.isAPIInbox ||
|
|
this.isAnEmailChannel ||
|
|
this.isASmsInbox ||
|
|
this.isATelegramChannel ||
|
|
this.isALineChannel ||
|
|
this.isAnInstagramChannel ||
|
|
(this.isATiktokChannel && tiktokAttachmentSupported)
|
|
);
|
|
},
|
|
replyButtonLabel() {
|
|
let sendMessageText = this.$t('CONVERSATION.REPLYBOX.SEND');
|
|
if (this.isPrivate) {
|
|
sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE');
|
|
}
|
|
const keyLabel = this.isEditorHotKeyEnabled('cmd_enter')
|
|
? `(${this.shortcutKey})`
|
|
: '(↵)';
|
|
return `${sendMessageText} ${keyLabel}`;
|
|
},
|
|
replyBoxClass() {
|
|
return {
|
|
'is-private': this.isPrivate,
|
|
'is-focused': this.isFocused || this.hasAttachments,
|
|
};
|
|
},
|
|
hasAttachments() {
|
|
return this.attachedFiles.length;
|
|
},
|
|
showAudioRecorder() {
|
|
return !this.isOnPrivateNote && this.showFileUpload;
|
|
},
|
|
showAudioRecorderEditor() {
|
|
return this.showAudioRecorder && this.isRecordingAudio;
|
|
},
|
|
isOnPrivateNote() {
|
|
return this.replyType === REPLY_EDITOR_MODES.NOTE;
|
|
},
|
|
isOnExpandedLayout() {
|
|
const {
|
|
LAYOUT_TYPES: { CONDENSED },
|
|
} = wootConstants;
|
|
const { conversation_display_type: conversationDisplayType = CONDENSED } =
|
|
this.uiSettings;
|
|
return conversationDisplayType !== CONDENSED;
|
|
},
|
|
isMessageEmpty() {
|
|
if (!this.message) {
|
|
return true;
|
|
}
|
|
return !this.message.trim().replace(/\n/g, '').length;
|
|
},
|
|
showReplyHead() {
|
|
return !this.isOnPrivateNote && this.isAnEmailChannel;
|
|
},
|
|
enableMultipleFileUpload() {
|
|
return (
|
|
this.isAnEmailChannel ||
|
|
this.isAWebWidgetInbox ||
|
|
this.isAPIInbox ||
|
|
this.isAWhatsAppChannel ||
|
|
this.isATelegramChannel
|
|
);
|
|
},
|
|
isSignatureEnabledForInbox() {
|
|
return !this.isPrivate && this.sendWithSignature;
|
|
},
|
|
isSignatureAvailable() {
|
|
return !!this.messageSignature;
|
|
},
|
|
sendWithSignature() {
|
|
return this.fetchSignatureFlagFromUISettings(this.channelType);
|
|
},
|
|
conversationId() {
|
|
return this.currentChat.id;
|
|
},
|
|
conversationIdByRoute() {
|
|
return this.conversationId;
|
|
},
|
|
editorStateId() {
|
|
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
|
},
|
|
audioRecordFormat() {
|
|
if (this.isAWhatsAppChannel) {
|
|
return AUDIO_FORMATS.OGG;
|
|
}
|
|
if (this.isATelegramChannel) {
|
|
return AUDIO_FORMATS.MP3;
|
|
}
|
|
if (this.isAPIInbox) {
|
|
return AUDIO_FORMATS.MP3;
|
|
}
|
|
return AUDIO_FORMATS.WAV;
|
|
},
|
|
messageVariables() {
|
|
const variables = getMessageVariables({
|
|
conversation: this.currentChat,
|
|
contact: this.currentContact,
|
|
inbox: this.inbox,
|
|
});
|
|
return variables;
|
|
},
|
|
connectedPortalSlug() {
|
|
const { help_center: portal = {} } = this.inbox;
|
|
const { slug = '' } = portal;
|
|
return slug;
|
|
},
|
|
quotedReplyPreference() {
|
|
if (!this.isAnEmailChannel) {
|
|
return false;
|
|
}
|
|
|
|
return !!this.fetchQuotedReplyFlagFromUISettings(this.channelType);
|
|
},
|
|
lastEmailWithQuotedContent() {
|
|
if (!this.isAnEmailChannel) {
|
|
return null;
|
|
}
|
|
|
|
const lastEmail = this.lastEmail;
|
|
if (!lastEmail || lastEmail.private) {
|
|
return null;
|
|
}
|
|
|
|
return lastEmail;
|
|
},
|
|
quotedEmailText() {
|
|
return extractQuotedEmailText(this.lastEmailWithQuotedContent);
|
|
},
|
|
quotedEmailPreviewText() {
|
|
return truncatePreviewText(this.quotedEmailText, 80);
|
|
},
|
|
shouldShowQuotedReplyToggle() {
|
|
return this.isAnEmailChannel && !this.isOnPrivateNote;
|
|
},
|
|
shouldShowQuotedPreview() {
|
|
return (
|
|
this.shouldShowQuotedReplyToggle &&
|
|
this.quotedReplyPreference &&
|
|
!!this.quotedEmailText
|
|
);
|
|
},
|
|
isDefaultEditorMode() {
|
|
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
|
|
},
|
|
isEditorDisabled() {
|
|
return (
|
|
(this.isAWhatsAppChannel || this.isAPIInbox) &&
|
|
!this.isOnPrivateNote &&
|
|
!this.currentChat.can_reply
|
|
);
|
|
},
|
|
},
|
|
watch: {
|
|
currentChat(conversation, oldConversation) {
|
|
const { can_reply: canReply } = conversation;
|
|
if (oldConversation && oldConversation.id !== conversation.id) {
|
|
// Only update email fields when switching to a completely different conversation (by ID)
|
|
// This prevents overwriting user input (e.g., CC/BCC fields) when performing actions
|
|
// like self-assign or other updates that do not actually change the conversation context
|
|
this.setCCAndToEmailsFromLastChat();
|
|
// Reset Copilot editor state (includes cancelling ongoing generation)
|
|
this.copilot.reset();
|
|
}
|
|
|
|
if (this.isOnPrivateNote) {
|
|
return;
|
|
}
|
|
|
|
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) {
|
|
this.replyType = REPLY_EDITOR_MODES.REPLY;
|
|
} else {
|
|
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
|
}
|
|
|
|
this.fetchAndSetReplyTo();
|
|
},
|
|
// When moving from one conversation to another, the store may not have the
|
|
// list of all the messages. A fetch is subsequently made to get the messages.
|
|
// This watcher handles two main cases:
|
|
// 1. When switching conversations and messages are fetched/updated, ensures CC/BCC fields are set from the latest OUTGOING/INCOMING email (not activity/private messages).
|
|
// 2. Fixes and issue where CC/BCC fields could be reset/lost after assignment/activity actions or message mutations that did not represent a true email context change.
|
|
lastEmail: {
|
|
handler(lastEmail) {
|
|
if (!lastEmail) return;
|
|
this.setCCAndToEmailsFromLastChat();
|
|
},
|
|
deep: true,
|
|
},
|
|
conversationIdByRoute(conversationId, oldConversationId) {
|
|
if (conversationId !== oldConversationId) {
|
|
this.setToDraft(oldConversationId, this.replyType);
|
|
this.getFromDraft();
|
|
this.resetRecorderAndClearAttachments();
|
|
}
|
|
},
|
|
message() {
|
|
// Autosave the current message draft.
|
|
this.doAutoSaveDraft();
|
|
},
|
|
replyType(updatedReplyType, oldReplyType) {
|
|
this.setToDraft(this.conversationIdByRoute, oldReplyType);
|
|
this.getFromDraft();
|
|
},
|
|
},
|
|
|
|
mounted() {
|
|
this.getFromDraft();
|
|
// Don't use the keyboard listener mixin here as the events here are supposed to be
|
|
// working even if the editor is focussed.
|
|
document.addEventListener('paste', this.onPaste);
|
|
document.addEventListener('keydown', this.handleKeyEvents);
|
|
this.setCCAndToEmailsFromLastChat();
|
|
this.doAutoSaveDraft = debounce(
|
|
() => {
|
|
this.saveDraft(this.conversationIdByRoute, this.replyType);
|
|
},
|
|
500,
|
|
true
|
|
);
|
|
|
|
this.fetchAndSetReplyTo();
|
|
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
|
|
|
|
// A hacky fix to solve the drag and drop
|
|
// Is showing on top of new conversation modal drag and drop
|
|
// TODO need to find a better solution
|
|
emitter.on(
|
|
BUS_EVENTS.NEW_CONVERSATION_MODAL,
|
|
this.onNewConversationModalActive
|
|
);
|
|
emitter.on(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
|
|
emitter.on(CMD_AI_ASSIST, this.executeCopilotAction);
|
|
},
|
|
unmounted() {
|
|
document.removeEventListener('paste', this.onPaste);
|
|
document.removeEventListener('keydown', this.handleKeyEvents);
|
|
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
|
|
emitter.off(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
|
|
emitter.off(
|
|
BUS_EVENTS.NEW_CONVERSATION_MODAL,
|
|
this.onNewConversationModalActive
|
|
);
|
|
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
|
|
},
|
|
methods: {
|
|
getDraftKey(
|
|
conversationId = this.conversationIdByRoute,
|
|
replyType = this.replyType
|
|
) {
|
|
return `draft-${conversationId}-${replyType}`;
|
|
},
|
|
getCopilotAcceptedMessage(replyType = this.replyType) {
|
|
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
|
return this.copilotAcceptedMessages[key] || '';
|
|
},
|
|
setCopilotAcceptedMessage(message, replyType = this.replyType) {
|
|
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
|
this.copilotAcceptedMessages[key] = trimContent(
|
|
message || '',
|
|
this.maxLength
|
|
);
|
|
},
|
|
clearCopilotAcceptedMessage(replyType = this.replyType) {
|
|
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
|
delete this.copilotAcceptedMessages[key];
|
|
},
|
|
handleInsert(article) {
|
|
const { url, title } = article;
|
|
// Removing empty lines from the title
|
|
const lines = title.split('\n');
|
|
const nonEmptyLines = lines.filter(line => line.trim() !== '');
|
|
const filteredMarkdown = nonEmptyLines.join(' ');
|
|
emitter.emit(
|
|
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
|
|
`[${filteredMarkdown}](${url})`
|
|
);
|
|
|
|
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
|
|
},
|
|
toggleQuotedReply() {
|
|
if (!this.isAnEmailChannel) {
|
|
return;
|
|
}
|
|
|
|
const nextValue = !this.quotedReplyPreference;
|
|
this.setQuotedReplyFlagForInbox(this.channelType, nextValue);
|
|
},
|
|
shouldIncludeQuotedEmail() {
|
|
return (
|
|
this.quotedReplyPreference &&
|
|
this.shouldShowQuotedReplyToggle &&
|
|
!!this.quotedEmailText
|
|
);
|
|
},
|
|
getMessageWithQuotedEmailText(message) {
|
|
if (!this.shouldIncludeQuotedEmail()) {
|
|
return message;
|
|
}
|
|
|
|
const quotedText = this.quotedEmailText || '';
|
|
const header = buildQuotedEmailHeader(
|
|
this.lastEmailWithQuotedContent,
|
|
this.currentContact,
|
|
this.inbox
|
|
);
|
|
|
|
return appendQuotedTextToMessage(message, quotedText, header);
|
|
},
|
|
resetRecorderAndClearAttachments() {
|
|
// Reset audio recorder UI state
|
|
this.resetAudioRecorderInput();
|
|
// Reset attached files
|
|
this.attachedFiles = [];
|
|
},
|
|
saveDraft(conversationId, replyType) {
|
|
if (this.message || this.message === '') {
|
|
const key = this.getDraftKey(conversationId, replyType);
|
|
const draftToSave = trimContent(this.message || '', this.maxLength);
|
|
|
|
this.$store.dispatch('draftMessages/set', {
|
|
key,
|
|
message: draftToSave,
|
|
});
|
|
}
|
|
},
|
|
setToDraft(conversationId, replyType) {
|
|
this.saveDraft(conversationId, replyType);
|
|
this.message = '';
|
|
},
|
|
getFromDraft() {
|
|
if (this.conversationIdByRoute) {
|
|
const key = this.getDraftKey();
|
|
const messageFromStore =
|
|
this.$store.getters['draftMessages/get'](key) || '';
|
|
|
|
// ensure that the message has signature set based on the ui setting
|
|
this.message = this.toggleSignatureForDraft(messageFromStore);
|
|
}
|
|
},
|
|
toggleSignatureForDraft(message) {
|
|
if (this.isPrivate) {
|
|
return message;
|
|
}
|
|
|
|
// Even when editor is disabled (e.g. WhatsApp/API can't reply), we must
|
|
// still normalize stale signatures out of drafts when signature is off.
|
|
if (this.isEditorDisabled && this.sendWithSignature) {
|
|
return message;
|
|
}
|
|
|
|
const effectiveChannelType = getEffectiveChannelType(
|
|
this.channelType,
|
|
this.inbox?.medium || ''
|
|
);
|
|
|
|
return this.sendWithSignature
|
|
? appendSignature(message, this.messageSignature, effectiveChannelType)
|
|
: removeSignature(message, this.messageSignature, effectiveChannelType);
|
|
},
|
|
removeFromDraft() {
|
|
if (this.conversationIdByRoute) {
|
|
const key = this.getDraftKey();
|
|
this.$store.dispatch('draftMessages/delete', { key });
|
|
}
|
|
},
|
|
getElementToBind() {
|
|
return this.replyEditor;
|
|
},
|
|
getKeyboardEvents() {
|
|
return {
|
|
Escape: {
|
|
action: () => {
|
|
this.hideEmojiPicker();
|
|
},
|
|
allowOnFocusedInput: true,
|
|
},
|
|
'$mod+KeyK': {
|
|
action: e => {
|
|
e.preventDefault();
|
|
const ninja = document.querySelector('ninja-keys');
|
|
ninja.open();
|
|
},
|
|
allowOnFocusedInput: true,
|
|
},
|
|
Enter: {
|
|
action: e => {
|
|
if (this.isAValidEvent('enter')) {
|
|
this.onSendReply();
|
|
e.preventDefault();
|
|
}
|
|
},
|
|
allowOnFocusedInput: true,
|
|
},
|
|
'$mod+Enter': {
|
|
action: () => {
|
|
if (this.copilot.isActive.value && this.isFocused) {
|
|
this.onSubmitCopilotReply();
|
|
} else if (this.isAValidEvent('cmd_enter')) {
|
|
this.onSendReply();
|
|
}
|
|
},
|
|
allowOnFocusedInput: true,
|
|
},
|
|
};
|
|
},
|
|
isAValidEvent(selectedKey) {
|
|
return (
|
|
!this.showUserMentions &&
|
|
!this.showMentions &&
|
|
!this.showCannedMenu &&
|
|
!this.showVariablesMenu &&
|
|
this.isFocused &&
|
|
this.isEditorHotKeyEnabled(selectedKey)
|
|
);
|
|
},
|
|
onPaste(e) {
|
|
// Don't handle paste if compose new conversation modal is open
|
|
if (this.newConversationModalActive) return;
|
|
|
|
// Don't handle paste if editor is disabled
|
|
if (this.isEditorDisabled) return;
|
|
if (!this.showFileUpload && !this.isOnPrivateNote) return;
|
|
|
|
// Filter valid files (non-zero size)
|
|
Array.from(e.clipboardData.files)
|
|
.filter(file => file.size > 0)
|
|
.filter(file => {
|
|
const isAllowed = isFileTypeAllowedForChannel(file, {
|
|
channelType: this.channelType || this.inbox?.channel_type,
|
|
medium: this.inbox?.medium,
|
|
conversationType: this.conversationType,
|
|
isInstagramChannel: this.isAnInstagramChannel,
|
|
isOnPrivateNote: this.isOnPrivateNote,
|
|
});
|
|
|
|
if (!isAllowed) {
|
|
useAlert(
|
|
this.$t('CONVERSATION.FILE_TYPE_NOT_SUPPORTED', {
|
|
fileName: file.name,
|
|
})
|
|
);
|
|
}
|
|
|
|
return isAllowed;
|
|
})
|
|
.forEach(file => {
|
|
const { name, type, size } = file;
|
|
this.onFileUpload({ name, type, size, file });
|
|
});
|
|
},
|
|
toggleUserMention(currentMentionState) {
|
|
this.showUserMentions = currentMentionState;
|
|
},
|
|
toggleCannedMenu(value) {
|
|
this.showCannedMenu = value;
|
|
},
|
|
toggleVariablesMenu(value) {
|
|
this.showVariablesMenu = value;
|
|
},
|
|
openWhatsappTemplateModal() {
|
|
this.showWhatsAppTemplatesModal = true;
|
|
},
|
|
hideWhatsappTemplatesModal() {
|
|
this.showWhatsAppTemplatesModal = false;
|
|
},
|
|
openContentTemplateModal() {
|
|
this.showContentTemplatesModal = true;
|
|
},
|
|
hideContentTemplatesModal() {
|
|
this.showContentTemplatesModal = false;
|
|
},
|
|
confirmOnSendReply() {
|
|
if (this.isReplyButtonDisabled) {
|
|
return;
|
|
}
|
|
if (!this.showMentions) {
|
|
const copilotAcceptedMessage = this.getCopilotAcceptedMessage();
|
|
const isOnWhatsApp =
|
|
this.isATwilioWhatsAppChannel ||
|
|
this.isAWhatsAppCloudChannel ||
|
|
this.is360DialogWhatsAppChannel;
|
|
// Instagram and TikTok do not support sending text and attachments in the same message.
|
|
// For Instagram, combining them causes duplicate messages due to separate echo events per component.
|
|
// For TikTok, the API rejects messages that mix text and media.
|
|
// To handle both cases, text and attachments are always sent as separate messages.
|
|
const isOnInstagram = this.isAnInstagramChannel;
|
|
const isOnTiktok = this.isATiktokChannel;
|
|
if ((isOnWhatsApp || isOnInstagram || isOnTiktok) && !this.isPrivate) {
|
|
this.sendMessageAsMultipleMessages(
|
|
this.message,
|
|
copilotAcceptedMessage
|
|
);
|
|
} else {
|
|
const messagePayload = this.getMessagePayload(this.message);
|
|
this.sendMessage(
|
|
messagePayload,
|
|
this.message,
|
|
copilotAcceptedMessage
|
|
);
|
|
}
|
|
|
|
if (!this.isPrivate) {
|
|
this.clearEmailField();
|
|
}
|
|
|
|
this.clearMessage();
|
|
this.hideEmojiPicker();
|
|
}
|
|
},
|
|
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
|
|
const messages = this.getMultipleMessagesPayload(message);
|
|
messages.forEach(messagePayload => {
|
|
this.sendMessage(
|
|
messagePayload,
|
|
messagePayload.message || '',
|
|
copilotAcceptedMessage
|
|
);
|
|
});
|
|
},
|
|
sendMessageAnalyticsData(
|
|
isPrivate,
|
|
{ editorMessage = '', copilotAcceptedMessage = '' } = {}
|
|
) {
|
|
const normalizeForComparison = message => {
|
|
let normalizedMessage = message || '';
|
|
|
|
if (this.sendWithSignature && this.messageSignature && !isPrivate) {
|
|
const effectiveChannelType = getEffectiveChannelType(
|
|
this.channelType,
|
|
this.inbox?.medium || ''
|
|
);
|
|
normalizedMessage = removeSignature(
|
|
normalizedMessage,
|
|
this.messageSignature,
|
|
effectiveChannelType
|
|
);
|
|
}
|
|
|
|
return trimContent(normalizedMessage);
|
|
};
|
|
|
|
const normalizedAcceptedMessage = normalizeForComparison(
|
|
copilotAcceptedMessage
|
|
);
|
|
const normalizedEditorMessage = normalizeForComparison(editorMessage);
|
|
|
|
if (normalizedAcceptedMessage && normalizedEditorMessage) {
|
|
useTrack(CAPTAIN_EVENTS.AI_ASSISTED_MESSAGE_SENT, {
|
|
conversationId: this.conversationIdByRoute,
|
|
channelType: this.channelType,
|
|
editedBeforeSend:
|
|
normalizedAcceptedMessage !== normalizedEditorMessage,
|
|
isPrivate,
|
|
});
|
|
}
|
|
|
|
// Analytics data for message signature is enabled or not in channels
|
|
return isPrivate
|
|
? useTrack(CONVERSATION_EVENTS.SENT_PRIVATE_NOTE)
|
|
: useTrack(CONVERSATION_EVENTS.SENT_MESSAGE, {
|
|
channelType: this.channelType,
|
|
signatureEnabled: this.sendWithSignature,
|
|
hasReplyTo: !!this.inReplyTo?.id,
|
|
});
|
|
},
|
|
async onSendReply() {
|
|
const undefinedVariables = getUndefinedVariablesInMessage({
|
|
message: this.message,
|
|
variables: this.messageVariables,
|
|
});
|
|
if (undefinedVariables.length > 0) {
|
|
const undefinedVariablesCount =
|
|
undefinedVariables.length > 1 ? undefinedVariables.length : 1;
|
|
this.undefinedVariableMessage = this.$t(
|
|
'CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.MESSAGE',
|
|
{
|
|
undefinedVariablesCount,
|
|
undefinedVariables: undefinedVariables.join(', '),
|
|
}
|
|
);
|
|
|
|
const ok = await this.$refs.confirmDialog.showConfirmation();
|
|
if (ok) {
|
|
this.confirmOnSendReply();
|
|
}
|
|
} else {
|
|
this.confirmOnSendReply();
|
|
}
|
|
},
|
|
async sendMessage(
|
|
messagePayload,
|
|
editorMessage = '',
|
|
copilotAcceptedMessage = ''
|
|
) {
|
|
try {
|
|
await this.$store.dispatch(
|
|
'createPendingMessageAndSend',
|
|
messagePayload
|
|
);
|
|
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
|
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
|
|
this.removeFromDraft();
|
|
this.sendMessageAnalyticsData(messagePayload.private, {
|
|
editorMessage,
|
|
copilotAcceptedMessage,
|
|
});
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
|
|
useAlert(errorMessage);
|
|
}
|
|
},
|
|
async onSendWhatsAppReply(messagePayload) {
|
|
this.sendMessage({
|
|
conversationId: this.currentChat.id,
|
|
...messagePayload,
|
|
});
|
|
this.hideWhatsappTemplatesModal();
|
|
},
|
|
async onSendContentTemplateReply(messagePayload) {
|
|
this.sendMessage({
|
|
conversationId: this.currentChat.id,
|
|
...messagePayload,
|
|
});
|
|
this.hideContentTemplatesModal();
|
|
},
|
|
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
|
|
// Clear attachments when switching between private note and reply modes
|
|
// This is to prevent from breaking the upload rules
|
|
if (this.attachedFiles.length > 0) this.attachedFiles = [];
|
|
|
|
const { can_reply: canReply } = this.currentChat;
|
|
this.$store.dispatch('draftMessages/setReplyEditorMode', {
|
|
mode,
|
|
});
|
|
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
|
|
this.replyType = mode;
|
|
if (this.isRecordingAudio) {
|
|
this.toggleAudioRecorder();
|
|
}
|
|
},
|
|
clearEditorSelection() {
|
|
this.updateEditorSelectionWith = '';
|
|
},
|
|
addIntoEditor(content) {
|
|
this.updateEditorSelectionWith = content;
|
|
this.onFocus();
|
|
},
|
|
executeCopilotAction(action, data) {
|
|
this.copilot.execute(action, data);
|
|
},
|
|
clearMessage() {
|
|
this.message = '';
|
|
this.clearCopilotAcceptedMessage();
|
|
if (this.sendWithSignature && !this.isPrivate) {
|
|
// if signature is enabled, append it to the message
|
|
const effectiveChannelType = getEffectiveChannelType(
|
|
this.channelType,
|
|
this.inbox?.medium || ''
|
|
);
|
|
this.message = appendSignature(
|
|
this.message,
|
|
this.messageSignature,
|
|
effectiveChannelType
|
|
);
|
|
}
|
|
this.attachedFiles = [];
|
|
this.isRecordingAudio = false;
|
|
this.resetReplyToMessage();
|
|
this.resetAudioRecorderInput();
|
|
},
|
|
clearEmailField() {
|
|
this.ccEmails = '';
|
|
this.bccEmails = '';
|
|
this.toEmails = '';
|
|
},
|
|
|
|
toggleEmojiPicker() {
|
|
this.showEmojiPicker = !this.showEmojiPicker;
|
|
},
|
|
toggleAudioRecorder() {
|
|
this.isRecordingAudio = !this.isRecordingAudio;
|
|
if (!this.isRecordingAudio) {
|
|
this.resetAudioRecorderInput();
|
|
}
|
|
},
|
|
toggleAudioRecorderPlayPause() {
|
|
if (!this.$refs.audioRecorderInput) return;
|
|
if (!this.recordingAudioState) {
|
|
this.$refs.audioRecorderInput.stopRecording();
|
|
} else {
|
|
this.$refs.audioRecorderInput.playPause();
|
|
}
|
|
},
|
|
hideEmojiPicker() {
|
|
if (this.showEmojiPicker) {
|
|
this.toggleEmojiPicker();
|
|
}
|
|
},
|
|
onTypingOn() {
|
|
this.toggleTyping('on');
|
|
},
|
|
onTypingOff() {
|
|
this.toggleTyping('off');
|
|
},
|
|
onBlur() {
|
|
this.isFocused = false;
|
|
this.saveDraft(this.conversationIdByRoute, this.replyType);
|
|
},
|
|
onFocus() {
|
|
this.isFocused = true;
|
|
},
|
|
onRecordProgressChanged(duration) {
|
|
this.recordingAudioDurationText = duration;
|
|
},
|
|
onFinishRecorder(file) {
|
|
this.recordingAudioState = 'stopped';
|
|
this.hasRecordedAudio = true;
|
|
// Added a new key isVoiceMessage to the file to identify recorded audio
|
|
// Because to filter and show only non recorded audio and other attachments
|
|
const autoRecordedFile = {
|
|
...file,
|
|
isVoiceMessage: true,
|
|
};
|
|
return file && this.onFileUpload(autoRecordedFile);
|
|
},
|
|
onRecordError() {
|
|
this.toggleAudioRecorder();
|
|
useAlert(this.$t('CONVERSATION.REPLYBOX.AUDIO_CONVERSION_FAILED'));
|
|
},
|
|
toggleTyping(status) {
|
|
const conversationId = this.currentChat.id;
|
|
const isPrivate = this.isPrivate;
|
|
|
|
if (!conversationId) {
|
|
return;
|
|
}
|
|
|
|
this.$store.dispatch('conversationTypingStatus/toggleTyping', {
|
|
status,
|
|
conversationId,
|
|
isPrivate,
|
|
});
|
|
},
|
|
attachFile({ blob, file }) {
|
|
if (!this.showFileUpload && !this.isOnPrivateNote) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.readAsDataURL(file.file);
|
|
reader.onloadend = () => {
|
|
this.attachedFiles.push({
|
|
currentChatId: this.currentChat.id,
|
|
resource: blob || file,
|
|
isPrivate: this.isPrivate,
|
|
thumb: reader.result,
|
|
blobSignedId: blob ? blob.signed_id : undefined,
|
|
isVoiceMessage: file?.isVoiceMessage || false,
|
|
});
|
|
};
|
|
},
|
|
removeAttachment(attachments) {
|
|
this.attachedFiles = attachments;
|
|
},
|
|
setReplyToInPayload(payload) {
|
|
if (this.inReplyTo?.id) {
|
|
return {
|
|
...payload,
|
|
contentAttributes: {
|
|
...payload.contentAttributes,
|
|
in_reply_to: this.inReplyTo.id,
|
|
},
|
|
};
|
|
}
|
|
|
|
return payload;
|
|
},
|
|
getMultipleMessagesPayload(message) {
|
|
const multipleMessagePayload = [];
|
|
|
|
if (this.attachedFiles && this.attachedFiles.length) {
|
|
let caption =
|
|
this.isAnInstagramChannel || this.isATiktokChannel ? '' : message;
|
|
this.attachedFiles.forEach(attachment => {
|
|
const attachedFile = this.globalConfig.directUploadsEnabled
|
|
? attachment.blobSignedId
|
|
: attachment.resource.file;
|
|
let attachmentPayload = {
|
|
conversationId: this.currentChat.id,
|
|
files: [attachedFile],
|
|
private: false,
|
|
message: caption,
|
|
sender: this.sender,
|
|
isVoiceMessage: attachment.isVoiceMessage || false,
|
|
};
|
|
|
|
attachmentPayload = this.setReplyToInPayload(attachmentPayload);
|
|
multipleMessagePayload.push(attachmentPayload);
|
|
// For WhatsApp, only the first attachment gets a caption
|
|
if (!this.isAnInstagramChannel) caption = '';
|
|
});
|
|
}
|
|
|
|
const hasNoAttachments =
|
|
!this.attachedFiles || !this.attachedFiles.length;
|
|
// For Instagram and TikTok, text must always be sent as a separate message (no captions on attachments).
|
|
// For WhatsApp, we only need a text message if there are no attachments.
|
|
if (
|
|
((this.isAnInstagramChannel || this.isATiktokChannel) &&
|
|
this.message) ||
|
|
(!(this.isAnInstagramChannel || this.isATiktokChannel) &&
|
|
hasNoAttachments)
|
|
) {
|
|
let messagePayload = {
|
|
conversationId: this.currentChat.id,
|
|
message,
|
|
private: false,
|
|
sender: this.sender,
|
|
};
|
|
|
|
messagePayload = this.setReplyToInPayload(messagePayload);
|
|
|
|
multipleMessagePayload.push(messagePayload);
|
|
}
|
|
|
|
return multipleMessagePayload;
|
|
},
|
|
getMessagePayload(message) {
|
|
const messageWithQuote = this.getMessageWithQuotedEmailText(message);
|
|
|
|
let messagePayload = {
|
|
conversationId: this.currentChat.id,
|
|
message: messageWithQuote,
|
|
private: this.isPrivate,
|
|
sender: this.sender,
|
|
};
|
|
messagePayload = this.setReplyToInPayload(messagePayload);
|
|
|
|
if (this.attachedFiles && this.attachedFiles.length) {
|
|
messagePayload.files = [];
|
|
this.attachedFiles.forEach(attachment => {
|
|
if (this.globalConfig.directUploadsEnabled) {
|
|
messagePayload.files.push(attachment.blobSignedId);
|
|
if (attachment.isVoiceMessage) {
|
|
messagePayload.isVoiceMessage = true;
|
|
}
|
|
} else {
|
|
messagePayload.files.push(attachment.resource.file);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (this.ccEmails && !this.isOnPrivateNote) {
|
|
messagePayload.ccEmails = this.ccEmails;
|
|
}
|
|
|
|
if (this.bccEmails && !this.isOnPrivateNote) {
|
|
messagePayload.bccEmails = this.bccEmails;
|
|
}
|
|
|
|
if (this.toEmails && !this.isOnPrivateNote) {
|
|
messagePayload.toEmails = this.toEmails;
|
|
}
|
|
return messagePayload;
|
|
},
|
|
setCcEmails(value) {
|
|
this.bccEmails = value.bccEmails;
|
|
this.ccEmails = value.ccEmails;
|
|
},
|
|
setCCAndToEmailsFromLastChat() {
|
|
const conversationContact = this.currentChat?.meta?.sender?.email || '';
|
|
const { email: inboxEmail, forward_to_email: forwardToEmail } =
|
|
this.inbox;
|
|
|
|
const { cc, bcc, to } = getRecipients(
|
|
this.lastEmail,
|
|
conversationContact,
|
|
inboxEmail,
|
|
forwardToEmail
|
|
);
|
|
|
|
this.toEmails = to.join(', ');
|
|
this.ccEmails = cc.join(', ');
|
|
this.bccEmails = bcc.join(', ');
|
|
},
|
|
fetchAndSetReplyTo() {
|
|
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
|
|
const replyToMessageId = LocalStorage.getFromJsonStore(
|
|
replyStorageKey,
|
|
this.conversationId
|
|
);
|
|
|
|
this.inReplyTo = this.currentChat?.messages?.find(message => {
|
|
if (message.id === replyToMessageId) {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
},
|
|
onReplyToMessage() {
|
|
this.fetchAndSetReplyTo();
|
|
if (this.inReplyTo) {
|
|
this.$nextTick(() => {
|
|
const pos = this.isSignatureEnabledForInbox ? 'start' : 'end';
|
|
this.messageEditor?.focusEditorInputField(pos);
|
|
});
|
|
}
|
|
},
|
|
resetReplyToMessage() {
|
|
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
|
|
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
|
|
emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
|
|
},
|
|
onNewConversationModalActive(isActive) {
|
|
// Issue is if the new conversation modal is open and we drag and drop the file
|
|
// then the file is not getting attached to the new conversation modal
|
|
// and it is getting attached to the current conversation reply box
|
|
// so to fix this we are removing the drag and drop event listener from the current conversation reply box
|
|
// When new conversation modal is open
|
|
this.newConversationModalActive = isActive;
|
|
},
|
|
onSearchPopoverClose() {
|
|
this.showArticleSearchPopover = false;
|
|
},
|
|
toggleInsertArticle() {
|
|
this.showArticleSearchPopover = !this.showArticleSearchPopover;
|
|
},
|
|
resetAudioRecorderInput() {
|
|
this.recordingAudioDurationText = '00:00';
|
|
this.isRecordingAudio = false;
|
|
this.recordingAudioState = '';
|
|
this.hasRecordedAudio = false;
|
|
// Only clear the recorded audio when we click toggle button.
|
|
this.attachedFiles = this.attachedFiles.filter(
|
|
file => !file?.isVoiceMessage
|
|
);
|
|
},
|
|
toggleEditorSize() {
|
|
this.$emit('toggleEditorSize');
|
|
this.$nextTick(() => this.messageEditor?.focusEditorInputField());
|
|
},
|
|
onSubmitCopilotReply() {
|
|
const acceptedMessage = this.copilot.accept();
|
|
this.message = acceptedMessage;
|
|
this.setCopilotAcceptedMessage(acceptedMessage);
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<ReplyBoxBanner :message="message" :is-on-private-note="isOnPrivateNote" />
|
|
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
|
|
<ReplyTopPanel
|
|
:mode="replyType"
|
|
:conversation-id="conversationId"
|
|
:is-reply-restricted="isReplyRestricted"
|
|
:disabled="
|
|
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
|
|
showAudioRecorderEditor
|
|
"
|
|
:is-editor-disabled="isEditorDisabled"
|
|
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
|
:characters-remaining="charactersRemaining"
|
|
:editor-content="message"
|
|
:has-content="hasMeaningfulEditorContent"
|
|
@set-reply-mode="setReplyMode"
|
|
@toggle-editor-size="toggleEditorSize"
|
|
@toggle-copilot="copilot.toggleEditor"
|
|
@execute-copilot-action="executeCopilotAction"
|
|
/>
|
|
<ArticleSearchPopover
|
|
v-if="showArticleSearchPopover && connectedPortalSlug"
|
|
:selected-portal-slug="connectedPortalSlug"
|
|
@insert="handleInsert"
|
|
@close="onSearchPopoverClose"
|
|
/>
|
|
<Transition
|
|
mode="out-in"
|
|
enter-active-class="transition-all duration-300 ease-out"
|
|
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
|
|
enter-to-class="opacity-100 translate-y-0 scale-100"
|
|
leave-active-class="transition-all duration-200 ease-in"
|
|
leave-from-class="opacity-100 translate-y-0 scale-100"
|
|
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
|
|
>
|
|
<div :key="copilot.editorTransitionKey.value" class="reply-box__top">
|
|
<ReplyToMessage
|
|
v-if="shouldShowReplyToMessage"
|
|
:message="inReplyTo"
|
|
@dismiss="resetReplyToMessage"
|
|
/>
|
|
<EmojiInput
|
|
v-if="showEmojiPicker"
|
|
v-on-clickaway="hideEmojiPicker"
|
|
:class="{
|
|
'emoji-dialog--expanded': isOnExpandedLayout,
|
|
}"
|
|
:on-click="addIntoEditor"
|
|
/>
|
|
<ReplyEmailHead
|
|
v-if="showReplyHead && isDefaultEditorMode"
|
|
v-model:cc-emails="ccEmails"
|
|
v-model:bcc-emails="bccEmails"
|
|
v-model:to-emails="toEmails"
|
|
/>
|
|
<AudioRecorder
|
|
v-if="showAudioRecorderEditor"
|
|
ref="audioRecorderInput"
|
|
:audio-record-format="audioRecordFormat"
|
|
@recorder-progress-changed="onRecordProgressChanged"
|
|
@finish-record="onFinishRecorder"
|
|
@record-error="onRecordError"
|
|
@play="recordingAudioState = 'playing'"
|
|
@pause="recordingAudioState = 'paused'"
|
|
/>
|
|
<CopilotEditorSection
|
|
v-if="copilot.isActive.value && !showAudioRecorderEditor"
|
|
:show-copilot-editor="copilot.showEditor.value"
|
|
:is-generating-content="copilot.isGenerating.value"
|
|
:generated-content="copilot.generatedContent.value"
|
|
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
|
|
@focus="onFocus"
|
|
@blur="onBlur"
|
|
@clear-selection="clearEditorSelection"
|
|
@close="copilot.showEditor.value = false"
|
|
@content-ready="copilot.setContentReady"
|
|
@send="copilot.sendFollowUp"
|
|
/>
|
|
<WootMessageEditor
|
|
v-else-if="!showAudioRecorderEditor"
|
|
ref="messageEditor"
|
|
v-model="message"
|
|
:conversation-id="conversationId"
|
|
:editor-id="editorStateId"
|
|
class="input popover-prosemirror-menu"
|
|
:is-private="isOnPrivateNote"
|
|
:placeholder="messagePlaceHolder"
|
|
:update-selection-with="updateEditorSelectionWith"
|
|
:min-height="4"
|
|
:disabled="isEditorDisabled"
|
|
enable-variables
|
|
:variables="messageVariables"
|
|
:signature="messageSignature"
|
|
allow-signature
|
|
:channel-type="channelType"
|
|
:medium="inbox.medium"
|
|
@typing-off="onTypingOff"
|
|
@typing-on="onTypingOn"
|
|
@focus="onFocus"
|
|
@blur="onBlur"
|
|
@toggle-user-mention="toggleUserMention"
|
|
@toggle-canned-menu="toggleCannedMenu"
|
|
@toggle-variables-menu="toggleVariablesMenu"
|
|
@clear-selection="clearEditorSelection"
|
|
@execute-copilot-action="executeCopilotAction"
|
|
/>
|
|
|
|
<QuotedEmailPreview
|
|
v-if="shouldShowQuotedPreview && isDefaultEditorMode"
|
|
:quoted-email-text="quotedEmailText"
|
|
:preview-text="quotedEmailPreviewText"
|
|
class="mb-2"
|
|
@toggle="toggleQuotedReply"
|
|
/>
|
|
|
|
<div
|
|
v-if="hasAttachments && isDefaultEditorMode"
|
|
class="bg-transparent py-0 mb-2"
|
|
@paste="onPaste"
|
|
>
|
|
<AttachmentPreview
|
|
class="mt-2"
|
|
:attachments="attachedFiles"
|
|
@remove-attachment="removeAttachment"
|
|
/>
|
|
</div>
|
|
<MessageSignatureMissingAlert
|
|
v-if="
|
|
isSignatureEnabledForInbox &&
|
|
!isSignatureAvailable &&
|
|
isDefaultEditorMode
|
|
"
|
|
class="mb-2"
|
|
/>
|
|
</div>
|
|
</Transition>
|
|
|
|
<Transition
|
|
mode="out-in"
|
|
enter-active-class="transition-all duration-300 ease-out"
|
|
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
|
|
enter-to-class="opacity-100 translate-y-0 scale-100"
|
|
leave-active-class="transition-all duration-200 ease-in"
|
|
leave-from-class="opacity-100 translate-y-0 scale-100"
|
|
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
|
|
>
|
|
<CopilotReplyBottomPanel
|
|
v-if="copilot.isActive.value"
|
|
key="copilot-bottom-panel"
|
|
:is-generating-content="copilot.isButtonDisabled.value"
|
|
@submit="onSubmitCopilotReply"
|
|
@cancel="copilot.reset"
|
|
/>
|
|
<ReplyBottomPanel
|
|
v-else
|
|
key="reply-bottom-panel"
|
|
:conversation-id="conversationId"
|
|
:enable-multiple-file-upload="enableMultipleFileUpload"
|
|
:enable-whats-app-templates="showWhatsappTemplates"
|
|
:enable-content-templates="showContentTemplates"
|
|
:inbox="inbox"
|
|
:is-on-private-note="isOnPrivateNote"
|
|
:is-recording-audio="isRecordingAudio"
|
|
:is-send-disabled="isReplyButtonDisabled"
|
|
:is-note="isPrivate"
|
|
:is-editor-disabled="isEditorDisabled"
|
|
:on-file-upload="onFileUpload"
|
|
:on-send="onSendReply"
|
|
:conversation-type="conversationType"
|
|
:recording-audio-duration-text="recordingAudioDurationText"
|
|
:recording-audio-state="recordingAudioState"
|
|
:send-button-text="replyButtonLabel"
|
|
:show-audio-recorder="showAudioRecorder"
|
|
:show-emoji-picker="showEmojiPicker"
|
|
:show-file-upload="showFileUpload"
|
|
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
|
|
:quoted-reply-enabled="quotedReplyPreference"
|
|
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
|
|
:toggle-audio-recorder="toggleAudioRecorder"
|
|
:toggle-emoji-picker="toggleEmojiPicker"
|
|
:message="message"
|
|
:portal-slug="connectedPortalSlug"
|
|
:new-conversation-modal-active="newConversationModalActive"
|
|
@select-whatsapp-template="openWhatsappTemplateModal"
|
|
@select-content-template="openContentTemplateModal"
|
|
@toggle-insert-article="toggleInsertArticle"
|
|
@toggle-quoted-reply="toggleQuotedReply"
|
|
/>
|
|
</Transition>
|
|
|
|
<WhatsappTemplates
|
|
:inbox-id="inbox.id"
|
|
:show="showWhatsAppTemplatesModal"
|
|
@close="hideWhatsappTemplatesModal"
|
|
@on-send="onSendWhatsAppReply"
|
|
@cancel="hideWhatsappTemplatesModal"
|
|
/>
|
|
|
|
<ContentTemplates
|
|
:inbox-id="inbox.id"
|
|
:show="showContentTemplatesModal"
|
|
@close="hideContentTemplatesModal"
|
|
@on-send="onSendContentTemplateReply"
|
|
@cancel="hideContentTemplatesModal"
|
|
/>
|
|
|
|
<woot-confirm-modal
|
|
ref="confirmDialog"
|
|
:title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')"
|
|
:description="undefinedVariableMessage"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.send-button {
|
|
@apply mb-0;
|
|
}
|
|
|
|
.reply-box {
|
|
@apply relative mb-2 mx-2 border border-n-weak rounded-xl bg-n-solid-1;
|
|
|
|
&.is-private {
|
|
@apply bg-n-solid-amber dark:border-n-amber-3/10 border-n-amber-12/5;
|
|
}
|
|
}
|
|
|
|
.send-button {
|
|
@apply mb-0;
|
|
}
|
|
|
|
.reply-box__top {
|
|
@apply relative py-0 px-3 -mt-px;
|
|
}
|
|
|
|
.emoji-dialog {
|
|
@apply top-[unset] -bottom-10 ltr:-left-80 ltr:right-[unset] rtl:left-[unset] rtl:-right-80;
|
|
|
|
&::before {
|
|
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08));
|
|
@apply ltr:-right-4 bottom-2 rtl:-left-4 ltr:rotate-[270deg] rtl:rotate-[90deg];
|
|
}
|
|
}
|
|
|
|
.emoji-dialog--expanded {
|
|
@apply left-[unset] bottom-0 absolute z-[100];
|
|
|
|
&::before {
|
|
transform: rotate(0deg);
|
|
@apply ltr:left-1 rtl:right-1 -bottom-2;
|
|
}
|
|
}
|
|
</style>
|