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:
Gabriel Jablonski 2026-01-13 23:51:38 -03:00 committed by GitHub
parent 4db3c7c7ed
commit 43d7642485
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 182 additions and 90 deletions

View File

@ -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;

View File

@ -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"

View File

@ -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.",

View File

@ -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.",