From 43d7642485fb6255ff38d8e34cca8f5879e0fa8d Mon Sep 17 00:00:00 2001 From: Gabriel Jablonski Date: Tue, 13 Jan 2026 23:51:38 -0300 Subject: [PATCH] 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 --- .../components/widgets/WootWriter/Editor.vue | 111 ++++++++++--- .../widgets/conversation/ReplyBox.vue | 157 ++++++++++-------- .../i18n/locale/en/conversation.json | 2 + .../i18n/locale/pt_BR/conversation.json | 2 + 4 files changed, 182 insertions(+), 90 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index a1a521c75..1d32bc4d3 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -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" /> + +
+
+ {{ t('CONVERSATION.FOOTER.SIGNATURE_LABEL_TOP') }} +
+
+
+ {{ signatureSeparator }} +
+
+ +
+
+ {{ t('CONVERSATION.FOOTER.SIGNATURE_LABEL_BOTTOM') }} +
+
+ {{ signatureSeparator }} +
+
+
@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; diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 766cb4c0b..c5f23f090 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -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'" /> - +
+ +
+
+ {{ $t('CONVERSATION.FOOTER.SIGNATURE_LABEL_TOP') }} +
+
+
+ {{ signatureSeparator }} +
+
+ + +
+
+ {{ $t('CONVERSATION.FOOTER.SIGNATURE_LABEL_BOTTOM') }} +
+
+ {{ signatureSeparator }} +
+
+
+