feat: signature preview and fix sending signature on whatsapp cloud channel (#183)
* fix: send signature on whatsapp cloud provider * fix: simplify attachment checks and remove redundant comments in ReplyBox * feat: signature preview * fix: update default signature position to top in Editor and ReplyBox components * fix: refactor signature application logic in ReplyBox component
This commit is contained in:
parent
4db3c7c7ed
commit
43d7642485
@ -24,6 +24,7 @@ import { useTrack } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
@ -47,7 +48,6 @@ import {
|
||||
import {
|
||||
findNodeToInsertImage,
|
||||
getContentNode,
|
||||
cleanSignature,
|
||||
insertAtCursor,
|
||||
scrollCursorIntoView,
|
||||
setURLWithQueryAndSize,
|
||||
@ -146,6 +146,8 @@ const createState = (content, placeholder, plugins = [], methods = {}) => {
|
||||
const { isEditorHotKeyEnabled, fetchSignatureFlagFromUISettings } =
|
||||
useUISettings();
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const typingIndicator = createTypingIndicator(
|
||||
@ -273,8 +275,7 @@ const plugins = computed(() => {
|
||||
});
|
||||
|
||||
const sendWithSignature = computed(() => {
|
||||
// this is considered the source of truth, we watch this property
|
||||
// on change, we toggle the signature in the editor
|
||||
// this is considered the source of truth for signature display
|
||||
if (props.allowSignature && !props.isPrivate && props.channelType) {
|
||||
return fetchSignatureFlagFromUISettings(props.channelType);
|
||||
}
|
||||
@ -282,6 +283,23 @@ const sendWithSignature = computed(() => {
|
||||
return false;
|
||||
});
|
||||
|
||||
const signaturePosition = computed(() => {
|
||||
return currentUser.value?.ui_settings?.signature_position || 'top';
|
||||
});
|
||||
|
||||
const signatureSeparator = computed(() => {
|
||||
return currentUser.value?.ui_settings?.signature_separator || 'blank';
|
||||
});
|
||||
|
||||
const shouldShowSignaturePreview = computed(() => {
|
||||
return sendWithSignature.value && props.signature;
|
||||
});
|
||||
|
||||
const formattedSignature = computed(() => {
|
||||
if (!props.signature) return '';
|
||||
return formatMessage(props.signature, false, false);
|
||||
});
|
||||
|
||||
watch(showUserMentions, updatedValue => {
|
||||
emit('toggleUserMention', props.isPrivate && updatedValue);
|
||||
});
|
||||
@ -298,27 +316,10 @@ watch(showToolsMenu, updatedValue => {
|
||||
function focusEditorInputField(pos = 'end') {
|
||||
const { tr } = editorView.state;
|
||||
|
||||
// Check if signature is at start and adjust cursor position accordingly
|
||||
const signaturePosition =
|
||||
currentUser.value?.ui_settings?.signature_position || 'top';
|
||||
const hasSignature = sendWithSignature.value && props.signature;
|
||||
|
||||
let selection;
|
||||
if (pos === 'end' || !hasSignature || signaturePosition !== 'top') {
|
||||
selection =
|
||||
pos === 'end' ? Selection.atEnd(tr.doc) : Selection.atStart(tr.doc);
|
||||
} else {
|
||||
// Position cursor after signature when signature is at start
|
||||
const signatureLength = props.signature
|
||||
? cleanSignature(props.signature).length
|
||||
: 0;
|
||||
const separatorLength =
|
||||
currentUser.value?.ui_settings?.signature_separator === '--' ? 6 : 2; // "\n--\n" vs "\n\n"
|
||||
const cursorPos = signatureLength + separatorLength;
|
||||
selection = Selection.near(
|
||||
tr.doc.resolve(Math.min(cursorPos, tr.doc.content.size))
|
||||
);
|
||||
}
|
||||
// Signature is now displayed as read-only preview outside the editor,
|
||||
// so cursor positioning is straightforward
|
||||
const selection =
|
||||
pos === 'end' ? Selection.atEnd(tr.doc) : Selection.atStart(tr.doc);
|
||||
|
||||
editorView.dispatch(tr.setSelection(selection));
|
||||
editorView.focus();
|
||||
@ -765,7 +766,33 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
hidden
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<!-- Signature preview at top -->
|
||||
<div
|
||||
v-if="shouldShowSignaturePreview && signaturePosition === 'top'"
|
||||
class="signature-preview signature-preview--top"
|
||||
>
|
||||
<div class="signature-label">
|
||||
{{ t('CONVERSATION.FOOTER.SIGNATURE_LABEL_TOP') }}
|
||||
</div>
|
||||
<div v-dompurify-html="formattedSignature" class="signature-content" />
|
||||
<div v-if="signatureSeparator === '--'" class="signature-separator">
|
||||
{{ signatureSeparator }}
|
||||
</div>
|
||||
</div>
|
||||
<div ref="editor" />
|
||||
<!-- Signature preview at bottom -->
|
||||
<div
|
||||
v-if="shouldShowSignaturePreview && signaturePosition === 'bottom'"
|
||||
class="signature-preview signature-preview--bottom"
|
||||
>
|
||||
<div class="signature-label">
|
||||
{{ t('CONVERSATION.FOOTER.SIGNATURE_LABEL_BOTTOM') }}
|
||||
</div>
|
||||
<div v-if="signatureSeparator === '--'" class="signature-separator">
|
||||
{{ signatureSeparator }}
|
||||
</div>
|
||||
<div v-dompurify-html="formattedSignature" class="signature-content" />
|
||||
</div>
|
||||
<div
|
||||
v-show="isImageNodeSelected && showImageResizeToolbar"
|
||||
class="absolute shadow-md rounded-[6px] flex gap-1 py-1 px-1 bg-n-solid-3 outline outline-1 outline-n-weak text-n-slate-12"
|
||||
@ -790,6 +817,42 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
<style lang="scss">
|
||||
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
|
||||
|
||||
.signature-preview {
|
||||
@apply px-1 py-1 text-n-slate-10 text-sm pointer-events-none select-none opacity-70;
|
||||
|
||||
&--top {
|
||||
@apply border-b border-n-weak pb-1;
|
||||
|
||||
.signature-separator {
|
||||
@apply text-n-slate-9 mt-1 mb-0;
|
||||
}
|
||||
}
|
||||
|
||||
&--bottom {
|
||||
@apply border-t border-n-weak pt-1 mt-2;
|
||||
|
||||
.signature-separator {
|
||||
@apply text-n-slate-9 mb-1 mt-0;
|
||||
}
|
||||
}
|
||||
|
||||
.signature-label {
|
||||
@apply text-xs text-n-slate-9 mb-1;
|
||||
}
|
||||
|
||||
.signature-content {
|
||||
@apply break-words;
|
||||
|
||||
:deep(p) {
|
||||
@apply m-0 text-n-slate-10;
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
@apply text-n-slate-10 no-underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menubar-wrapper {
|
||||
@apply flex flex-col gap-3;
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
@ -42,12 +43,7 @@ import {
|
||||
} from 'dashboard/helper/quotedEmailHelper';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
extractTextFromMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import { appendSignature } from 'dashboard/helper/editorHelper';
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
@ -92,6 +88,8 @@ export default {
|
||||
fetchQuotedReplyFlagFromUISettings,
|
||||
} = useUISettings();
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const replyEditor = useTemplateRef('replyEditor');
|
||||
|
||||
return {
|
||||
@ -101,6 +99,7 @@ export default {
|
||||
setQuotedReplyFlagForInbox,
|
||||
fetchQuotedReplyFlagFromUISettings,
|
||||
replyEditor,
|
||||
formatMessage,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@ -418,11 +417,24 @@ export default {
|
||||
|
||||
return false;
|
||||
},
|
||||
// ensure that the signature is plain text depending on `showRichContentEditor`
|
||||
signatureToApply() {
|
||||
return this.showRichContentEditor
|
||||
? this.messageSignature
|
||||
: extractTextFromMarkdown(this.messageSignature);
|
||||
// Signature preview for non-rich editor (WhatsApp, etc.)
|
||||
shouldShowSignaturePreview() {
|
||||
return (
|
||||
this.sendWithSignature &&
|
||||
this.messageSignature &&
|
||||
!this.isPrivate &&
|
||||
!this.showRichContentEditor
|
||||
);
|
||||
},
|
||||
signaturePosition() {
|
||||
return this.currentUser?.ui_settings?.signature_position || 'top';
|
||||
},
|
||||
signatureSeparator() {
|
||||
return this.currentUser?.ui_settings?.signature_separator || 'blank';
|
||||
},
|
||||
formattedSignature() {
|
||||
if (!this.messageSignature) return '';
|
||||
return this.formatMessage(this.messageSignature, false, false);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@ -606,31 +618,6 @@ export default {
|
||||
this.message = messageFromStore;
|
||||
}
|
||||
},
|
||||
toggleSignatureForDraft(message) {
|
||||
if (this.isPrivate) {
|
||||
return message;
|
||||
}
|
||||
if (this.showRichContentEditor) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
return this.sendWithSignature
|
||||
? appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
)
|
||||
: removeSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
return this.sendWithSignature
|
||||
? appendSignature(message, this.signatureToApply)
|
||||
: removeSignature(message, this.signatureToApply);
|
||||
},
|
||||
removeFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
@ -686,6 +673,18 @@ export default {
|
||||
this.isEditorHotKeyEnabled(selectedKey)
|
||||
);
|
||||
},
|
||||
applySignatureToMessage(message) {
|
||||
if (!this.sendWithSignature || !this.messageSignature) {
|
||||
return message;
|
||||
}
|
||||
const { signature_position, signature_separator } =
|
||||
this.currentUser?.ui_settings || {};
|
||||
const signatureSettings = {
|
||||
position: signature_position || 'top',
|
||||
separator: signature_separator || 'blank',
|
||||
};
|
||||
return appendSignature(message, this.messageSignature, signatureSettings);
|
||||
},
|
||||
onPaste(e) {
|
||||
// Don't handle paste if compose new conversation modal is open
|
||||
if (this.newConversationModalActive) {
|
||||
@ -999,8 +998,10 @@ export default {
|
||||
getMultipleMessagesPayload(message) {
|
||||
const multipleMessagePayload = [];
|
||||
|
||||
if (this.attachedFiles && this.attachedFiles.length) {
|
||||
let caption = this.isAnInstagramChannel ? '' : message;
|
||||
const messageWithSignature = this.applySignatureToMessage(message);
|
||||
|
||||
if (this.attachedFiles?.length) {
|
||||
let caption = this.isAnInstagramChannel ? '' : messageWithSignature;
|
||||
this.attachedFiles.forEach(attachment => {
|
||||
const attachedFile = this.globalConfig.directUploadsEnabled
|
||||
? attachment.blobSignedId
|
||||
@ -1020,8 +1021,7 @@ export default {
|
||||
});
|
||||
}
|
||||
|
||||
const hasNoAttachments =
|
||||
!this.attachedFiles || !this.attachedFiles.length;
|
||||
const hasNoAttachments = !this.attachedFiles?.length;
|
||||
// For Instagram, we need a separate text message
|
||||
// For WhatsApp, we only need a text message if there are no attachments
|
||||
if (
|
||||
@ -1030,7 +1030,7 @@ export default {
|
||||
) {
|
||||
let messagePayload = {
|
||||
conversationId: this.currentChat.id,
|
||||
message,
|
||||
message: messageWithSignature,
|
||||
private: false,
|
||||
sender: this.sender,
|
||||
};
|
||||
@ -1044,18 +1044,8 @@ export default {
|
||||
},
|
||||
getMessagePayload(message) {
|
||||
let finalMessage = this.getMessageWithQuotedEmailText(message);
|
||||
if (this.sendWithSignature && !this.isPrivate && this.messageSignature) {
|
||||
const { signature_position, signature_separator } =
|
||||
this.currentUser?.ui_settings || {};
|
||||
const signatureSettings = {
|
||||
position: signature_position || 'top',
|
||||
separator: signature_separator || 'blank',
|
||||
};
|
||||
finalMessage = appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
signatureSettings
|
||||
);
|
||||
if (!this.isPrivate) {
|
||||
finalMessage = this.applySignatureToMessage(finalMessage);
|
||||
}
|
||||
|
||||
let messagePayload = {
|
||||
@ -1066,7 +1056,7 @@ export default {
|
||||
};
|
||||
messagePayload = this.setReplyToInPayload(messagePayload);
|
||||
|
||||
if (this.attachedFiles && this.attachedFiles.length) {
|
||||
if (this.attachedFiles?.length) {
|
||||
messagePayload.files = [];
|
||||
messagePayload.isRecordedAudio = [];
|
||||
this.attachedFiles.forEach(attachment => {
|
||||
@ -1222,18 +1212,51 @@ export default {
|
||||
@play="recordingAudioState = 'playing'"
|
||||
@pause="recordingAudioState = 'paused'"
|
||||
/>
|
||||
<ResizableTextArea
|
||||
v-else-if="!showRichContentEditor"
|
||||
ref="messageInput"
|
||||
v-model="message"
|
||||
class="rounded-none input"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:min-height="4"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<div v-else-if="!showRichContentEditor" class="w-full">
|
||||
<!-- Signature preview at top for non-rich editor -->
|
||||
<div
|
||||
v-if="shouldShowSignaturePreview && signaturePosition === 'top'"
|
||||
class="signature-preview px-2 py-1 text-slate-500 dark:text-slate-400 text-sm opacity-70 select-none border-b border-slate-100 dark:border-slate-700"
|
||||
>
|
||||
<div class="text-xs text-slate-400 dark:text-slate-500 mb-1">
|
||||
{{ $t('CONVERSATION.FOOTER.SIGNATURE_LABEL_TOP') }}
|
||||
</div>
|
||||
<div v-dompurify-html="formattedSignature" />
|
||||
<div
|
||||
v-if="signatureSeparator === '--'"
|
||||
class="text-slate-400 dark:text-slate-500 mt-1"
|
||||
>
|
||||
{{ signatureSeparator }}
|
||||
</div>
|
||||
</div>
|
||||
<ResizableTextArea
|
||||
ref="messageInput"
|
||||
v-model="message"
|
||||
class="rounded-none input"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:min-height="4"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<!-- Signature preview at bottom for non-rich editor -->
|
||||
<div
|
||||
v-if="shouldShowSignaturePreview && signaturePosition === 'bottom'"
|
||||
class="signature-preview px-2 py-1 mt-2 text-slate-500 dark:text-slate-400 text-sm opacity-70 select-none border-t border-slate-100 dark:border-slate-700"
|
||||
>
|
||||
<div class="text-xs text-slate-400 dark:text-slate-500 mb-1">
|
||||
{{ $t('CONVERSATION.FOOTER.SIGNATURE_LABEL_BOTTOM') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="signatureSeparator === '--'"
|
||||
class="text-slate-400 dark:text-slate-500 mb-1"
|
||||
>
|
||||
{{ signatureSeparator }}
|
||||
</div>
|
||||
<div v-dompurify-html="formattedSignature" />
|
||||
</div>
|
||||
</div>
|
||||
<WootMessageEditor
|
||||
v-else
|
||||
v-model="message"
|
||||
@ -1245,6 +1268,8 @@ export default {
|
||||
:min-height="4"
|
||||
enable-variables
|
||||
:variables="messageVariables"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:channel-type="channelType"
|
||||
:medium="inbox.medium"
|
||||
@typing-off="onTypingOff"
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
"MESSAGE_SIGN_TOOLTIP": "Message signature",
|
||||
"ENABLE_SIGN_TOOLTIP": "Enable signature",
|
||||
"DISABLE_SIGN_TOOLTIP": "Disable signature",
|
||||
"SIGNATURE_LABEL_TOP": "↓ Signature",
|
||||
"SIGNATURE_LABEL_BOTTOM": "↑ Signature",
|
||||
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
|
||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||
|
||||
@ -184,6 +184,8 @@
|
||||
"MESSAGE_SIGN_TOOLTIP": "Assinatura de mensagem",
|
||||
"ENABLE_SIGN_TOOLTIP": "Ativar assinatura",
|
||||
"DISABLE_SIGN_TOOLTIP": "Desativar assinatura",
|
||||
"SIGNATURE_LABEL_TOP": "↓ Assinatura",
|
||||
"SIGNATURE_LABEL_BOTTOM": "↑ Assinatura",
|
||||
"MSG_INPUT": "Shift + enter para nova linha. Digite '/' para selecionar uma Resposta Pronta.",
|
||||
"PRIVATE_MSG_INPUT": "A mensagem será visível apenas para agentes",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "A assinatura da mensagem não está configurada. Por favor, configure-a nas configurações do perfil.",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user