diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
index abd8209b8..8a64d9c1f 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
@@ -44,6 +44,10 @@ import {
import TagAgents from '../conversation/TagAgents';
import CannedResponse from '../conversation/CannedResponse';
import VariableList from '../conversation/VariableList';
+import {
+ appendSignature,
+ removeSignature,
+} from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
@@ -100,6 +104,10 @@ export default {
enableCannedResponses: { type: Boolean, default: true },
variables: { type: Object, default: () => ({}) },
enabledMenuOptions: { type: Array, default: () => [] },
+ signature: { type: String, default: '' },
+ // allowSignature is a kill switch, ensuring no signature methods
+ // are triggered except when this flag is true
+ allowSignature: { type: Boolean, default: false },
},
data() {
return {
@@ -220,6 +228,12 @@ export default {
}),
];
},
+ sendWithSignature() {
+ // this is considered the source of truth, we watch this property
+ // on change, we toggle the signature in the editor
+ const { send_with_signature: isEnabled } = this.uiSettings;
+ return isEnabled && this.allowSignature && !this.isPrivate;
+ },
},
watch: {
showUserMentions(updatedValue) {
@@ -244,7 +258,6 @@ export default {
isPrivate() {
this.reloadState(this.value);
},
-
updateSelectionWith(newValue, oldValue) {
if (!this.editorView) {
return null;
@@ -263,6 +276,12 @@ export default {
}
return null;
},
+ sendWithSignature(newValue) {
+ // see if the allowSignature flag is true
+ if (this.allowSignature) {
+ this.toggleSignatureInEditor(newValue);
+ }
+ },
},
created() {
this.state = createState(
@@ -276,7 +295,7 @@ export default {
mounted() {
this.createEditorView();
this.editorView.updateState(this.state);
- this.focusEditorInputField();
+ this.focusEditor(this.value);
},
methods: {
reloadState(content = this.value) {
@@ -288,7 +307,75 @@ export default {
this.editorMenuOptions
);
this.editorView.updateState(this.state);
- this.focusEditorInputField();
+
+ this.focusEditor(content);
+ },
+ focusEditor(content) {
+ if (this.isBodyEmpty(content) && this.sendWithSignature) {
+ // reload state can be called when switching between conversations, or when drafts is loaded
+ // these drafts can also have a signature, so we need to check if the body is empty
+ // and handle things accordingly
+ this.handleEmptyBodyWithSignature();
+ } else {
+ // this is in the else block, handleEmptyBodyWithSignature also has a call to the focus method
+ // the position is set to start, because the signature is added at the end of the body
+ this.focusEditorInputField('end');
+ }
+ },
+ toggleSignatureInEditor(signatureEnabled) {
+ // The toggleSignatureInEditor gets the new value from the
+ // watcher, this means that if the value is true, the signature
+ // is supposed to be added, else we remove it.
+ if (signatureEnabled) {
+ this.addSignature();
+ } else {
+ this.removeSignature();
+ }
+ },
+ addSignature() {
+ let content = this.value;
+ // see if the content is empty, if it is before appending the signature
+ // we need to add a paragraph node and move the cursor at the start of the editor
+ const contentWasEmpty = this.isBodyEmpty(content);
+ content = appendSignature(content, this.signature);
+ // need to reload first, ensuring that the editorView is updated
+ this.reloadState(content);
+
+ if (contentWasEmpty) {
+ this.handleEmptyBodyWithSignature();
+ }
+ },
+ removeSignature() {
+ if (!this.signature) return;
+ let content = this.value;
+ content = removeSignature(content, this.signature);
+ // reload the state, ensuring that the editorView is updated
+ this.reloadState(content);
+ },
+ isBodyEmpty(content) {
+ // if content is undefined, we assume that the body is empty
+ if (!content) return true;
+
+ // if the signature is present, we need to remove it before checking
+ // note that we don't update the editorView, so this is safe
+ const bodyWithoutSignature = this.signature
+ ? removeSignature(content, this.signature)
+ : content;
+
+ // trimming should remove all the whitespaces, so we can check the length
+ return bodyWithoutSignature.trim().length === 0;
+ },
+ handleEmptyBodyWithSignature() {
+ const { schema, tr } = this.state;
+
+ // create a paragraph node and
+ // start a transaction to append it at the end
+ const paragraph = schema.nodes.paragraph.create();
+ const paragraphTransaction = tr.insert(0, paragraph);
+ this.editorView.dispatch(paragraphTransaction);
+
+ // Set the focus at the start of the input field
+ this.focusEditorInputField('start');
},
createEditorView() {
this.editorView = new EditorView(this.$refs.editor, {
@@ -333,9 +420,11 @@ export default {
this.focusEditorInputField();
}
},
- focusEditorInputField() {
+ focusEditorInputField(pos = 'end') {
const { tr } = this.editorView.state;
- const selection = Selection.atEnd(tr.doc);
+
+ const selection =
+ pos === 'end' ? Selection.atEnd(tr.doc) : Selection.atStart(tr.doc);
this.editorView.dispatch(tr.setSelection(selection));
this.editorView.focus();
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
index 2a7d77fa7..f44747a44 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
@@ -288,7 +288,7 @@ export default {
}
},
showMessageSignatureButton() {
- return !this.isOnPrivateNote && this.isAnEmailChannel;
+ return !this.isOnPrivateNote;
},
sendWithSignature() {
const { send_with_signature: isEnabled } = this.uiSettings;
diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
index 470da3cc5..56410fdad 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
@@ -53,6 +53,9 @@
class="input"
:placeholder="messagePlaceHolder"
:min-height="4"
+ :signature="signatureToApply"
+ :allow-signature="true"
+ :send-with-signature="sendWithSignature"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@@ -69,6 +72,8 @@
:min-height="4"
:enable-variables="true"
:variables="messageVariables"
+ :signature="signatureToApply"
+ :allow-signature="true"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@@ -86,16 +91,10 @@
/>
-
-
+
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
{{ $t('CONVERSATION.FOOTER.CLICK_HERE') }}
@@ -184,6 +183,12 @@ import wootConstants from 'dashboard/constants/globals';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import rtlMixin from 'shared/mixins/rtlMixin';
+import {
+ appendSignature,
+ removeSignature,
+ replaceSignature,
+ extractTextFromMarkdown,
+} from 'dashboard/helper/editorHelper';
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
@@ -471,10 +476,10 @@ export default {
);
},
isSignatureEnabledForInbox() {
- return !this.isPrivate && this.isAnEmailChannel && this.sendWithSignature;
+ return !this.isPrivate && this.sendWithSignature;
},
isSignatureAvailable() {
- return !!this.messageSignature;
+ return !!this.signatureToApply;
},
sendWithSignature() {
const { send_with_signature: isEnabled } = this.uiSettings;
@@ -514,6 +519,12 @@ export default {
});
return variables;
},
+ // ensure that the signature is plain text depending on `showRichContentEditor`
+ signatureToApply() {
+ return this.showRichContentEditor
+ ? this.messageSignature
+ : extractTextFromMarkdown(this.messageSignature);
+ },
},
watch: {
currentChat(conversation) {
@@ -581,6 +592,23 @@ export default {
this.updateUISettings({
display_rich_content_editor: !this.showRichContentEditor,
});
+
+ const plainTextSignature = extractTextFromMarkdown(this.messageSignature);
+
+ if (!this.showRichContentEditor && this.messageSignature) {
+ // remove the old signature -> extract text from markdown -> attach new signature
+ let message = removeSignature(this.message, this.messageSignature);
+ message = extractTextFromMarkdown(message);
+ message = appendSignature(message, plainTextSignature);
+
+ this.message = message;
+ } else {
+ this.message = replaceSignature(
+ this.message,
+ plainTextSignature,
+ this.messageSignature
+ );
+ }
},
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
@@ -600,9 +628,22 @@ export default {
getFromDraft() {
if (this.conversationIdByRoute) {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
- this.message = this.$store.getters['draftMessages/get'](key) || '';
+ 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;
+ }
+
+ return this.sendWithSignature
+ ? appendSignature(message, this.signatureToApply)
+ : removeSignature(message, this.signatureToApply);
+ },
removeFromDraft() {
if (this.conversationIdByRoute) {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
@@ -694,19 +735,14 @@ export default {
return;
}
if (!this.showMentions) {
- let newMessage = this.message;
- if (this.isSignatureEnabledForInbox && this.messageSignature) {
- newMessage += '\n\n' + this.messageSignature;
- }
-
const isOnWhatsApp =
this.isATwilioWhatsAppChannel ||
this.isAWhatsAppCloudChannel ||
this.is360DialogWhatsAppChannel;
if (isOnWhatsApp && !this.isPrivate) {
- this.sendMessageAsMultipleMessages(newMessage);
+ this.sendMessageAsMultipleMessages(this.message);
} else {
- const messagePayload = this.getMessagePayload(newMessage);
+ const messagePayload = this.getMessagePayload(this.message);
this.sendMessage(messagePayload);
}
@@ -828,6 +864,10 @@ export default {
},
clearMessage() {
this.message = '';
+ if (this.sendWithSignature && !this.isPrivate) {
+ // if signature is enabled, append it to the message
+ this.message = appendSignature(this.message, this.signatureToApply);
+ }
this.attachedFiles = [];
this.isRecordingAudio = false;
},
diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js
new file mode 100644
index 000000000..31d070732
--- /dev/null
+++ b/app/javascript/dashboard/helper/editorHelper.js
@@ -0,0 +1,135 @@
+/**
+ * The delimiter used to separate the signature from the rest of the body.
+ * @type {string}
+ */
+export const SIGNATURE_DELIMITER = '--';
+
+/**
+ * Trim the signature and remove all " \r" from the signature
+ * 1. Trim any extra lines or spaces at the start or end of the string
+ * 2. Converts all \r or \r\n to \f
+ */
+export function cleanSignature(signature) {
+ return signature.trim().replace(/\r\n?/g, '\n');
+}
+
+/**
+ * Adds the signature delimiter to the beginning of the signature.
+ *
+ * @param {string} signature - The signature to add the delimiter to.
+ * @returns {string} - The signature with the delimiter added.
+ */
+function appendDelimiter(signature) {
+ return `${SIGNATURE_DELIMITER}\n\n${cleanSignature(signature)}`;
+}
+
+/**
+ * Check if there's an unedited signature at the end of the body
+ * If there is, return the index of the signature, If there isn't, return -1
+ *
+ * @param {string} body - The body to search for the signature.
+ * @param {string} signature - The signature to search for.
+ * @returns {number} - The index of the last occurrence of the signature in the body, or -1 if not found.
+ */
+export function findSignatureInBody(body, signature) {
+ const trimmedBody = body.trimEnd();
+ const cleanedSignature = cleanSignature(signature);
+
+ // check if body ends with signature
+ if (trimmedBody.endsWith(cleanedSignature)) {
+ return body.lastIndexOf(cleanedSignature);
+ }
+
+ return -1;
+}
+
+/**
+ * Appends the signature to the body, separated by the signature delimiter.
+ *
+ * @param {string} body - The body to append the signature to.
+ * @param {string} signature - The signature to append.
+ * @returns {string} - The body with the signature appended.
+ */
+export function appendSignature(body, signature) {
+ const cleanedSignature = cleanSignature(signature);
+ // if signature is already present, return body
+ if (findSignatureInBody(body, cleanedSignature) > -1) {
+ return body;
+ }
+
+ return `${body.trimEnd()}\n\n${appendDelimiter(cleanedSignature)}`;
+}
+
+/**
+ * Removes the signature from the body, along with the signature delimiter.
+ *
+ * @param {string} body - The body to remove the signature from.
+ * @param {string} signature - The signature to remove.
+ * @returns {string} - The body with the signature removed.
+ */
+export function removeSignature(body, signature) {
+ // this will find the index of the signature if it exists
+ // Regardless of extra spaces or new lines after the signature, the index will be the same if present
+ const cleanedSignature = cleanSignature(signature);
+ const signatureIndex = findSignatureInBody(body, cleanedSignature);
+
+ // no need to trim the ends here, because it will simply be removed in the next method
+ let newBody = body;
+
+ // if signature is present, remove it and trim it
+ // trimming will ensure any spaces or new lines before the signature are removed
+ // This means we will have the delimiter at the end
+ if (signatureIndex > -1) {
+ newBody = newBody.substring(0, signatureIndex).trimEnd();
+ }
+
+ // let's find the delimiter and remove it
+ const delimiterIndex = newBody.lastIndexOf(SIGNATURE_DELIMITER);
+ if (
+ delimiterIndex !== -1 &&
+ delimiterIndex === newBody.length - SIGNATURE_DELIMITER.length // this will ensure the delimiter is at the end
+ ) {
+ // if the delimiter is at the end, remove it
+ newBody = newBody.substring(0, delimiterIndex);
+ }
+
+ // return the value
+ return newBody;
+}
+
+/**
+ * Replaces the old signature with the new signature.
+ * If the old signature is not present, it will append the new signature.
+ *
+ * @param {string} body - The body to replace the signature in.
+ * @param {string} oldSignature - The signature to replace.
+ * @param {string} newSignature - The signature to replace the old signature with.
+ * @returns {string} - The body with the old signature replaced with the new signature.
+ *
+ */
+export function replaceSignature(body, oldSignature, newSignature) {
+ const withoutSignature = removeSignature(body, oldSignature);
+ return appendSignature(withoutSignature, newSignature);
+}
+
+/**
+ * Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
+ * Links will be converted to text, and not removed.
+ *
+ * @param {string} markdown - markdown text to be extracted
+ * @returns
+ */
+export function extractTextFromMarkdown(markdown) {
+ return markdown
+ .replace(/```[\s\S]*?```/g, '') // Remove code blocks
+ .replace(/`.*?`/g, '') // Remove inline code
+ .replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
+ .replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
+ .split('\n')
+ .map(line => line.trim())
+ .filter(Boolean)
+ .join('\n') // Trim each line & remove any lines only having spaces
+ .replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
+ .trim(); // Trim any extra space
+}
diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
new file mode 100644
index 000000000..1c79d6e21
--- /dev/null
+++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
@@ -0,0 +1,157 @@
+import {
+ findSignatureInBody,
+ appendSignature,
+ removeSignature,
+ replaceSignature,
+ extractTextFromMarkdown,
+} from '../editorHelper';
+
+const NEW_SIGNATURE = 'This is a new signature';
+
+const DOES_NOT_HAVE_SIGNATURE = {
+ 'no signature': {
+ body: 'This is a test',
+ signature: 'This is a signature',
+ },
+ 'text after signature': {
+ body: 'This is a test\n\n--\n\nThis is a signature\n\nThis is more text',
+ signature: 'This is a signature',
+ },
+ signature_has_images: {
+ body: 'This is a test',
+ signature:
+ 'Testing \n',
+ },
+};
+
+const HAS_SIGNATURE = {
+ 'signature at end': {
+ body: 'This is a test\n\n--\n\nThis is a signature',
+ signature: 'This is a signature',
+ },
+ 'signature at end with spaces and new lines': {
+ body: 'This is a test\n\n--\n\nThis is a signature \n\n',
+ signature: 'This is a signature ',
+ },
+ 'no text before signature': {
+ body: '\n\n--\n\nThis is a signature',
+ signature: 'This is a signature',
+ },
+};
+
+describe('findSignatureInBody', () => {
+ it('returns -1 if there is no signature', () => {
+ Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
+ const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
+ expect(findSignatureInBody(body, signature)).toBe(-1);
+ });
+ });
+ it('returns the index of the signature if there is one', () => {
+ Object.keys(HAS_SIGNATURE).forEach(key => {
+ const { body, signature } = HAS_SIGNATURE[key];
+ expect(findSignatureInBody(body, signature)).toBeGreaterThan(0);
+ });
+ });
+});
+
+describe('appendSignature', () => {
+ it('appends the signature if it is not present', () => {
+ Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
+ const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
+ expect(appendSignature(body, signature)).toBe(
+ `${body}\n\n--\n\n${signature}`
+ );
+ });
+ });
+ it('does not append signature if already present', () => {
+ Object.keys(HAS_SIGNATURE).forEach(key => {
+ const { body, signature } = HAS_SIGNATURE[key];
+ expect(appendSignature(body, signature)).toBe(body);
+ });
+ });
+});
+
+describe('removeSignature', () => {
+ it('does not remove signature if not present', () => {
+ Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
+ const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
+ expect(removeSignature(body, signature)).toBe(body);
+ });
+ });
+ it('removes signature if present at the end', () => {
+ const { body, signature } = HAS_SIGNATURE['signature at end'];
+ expect(removeSignature(body, signature)).toBe('This is a test\n\n');
+ });
+ it('removes signature if present with spaces and new lines', () => {
+ const { body, signature } = HAS_SIGNATURE[
+ 'signature at end with spaces and new lines'
+ ];
+ expect(removeSignature(body, signature)).toBe('This is a test\n\n');
+ });
+ it('removes signature if present without text before it', () => {
+ const { body, signature } = HAS_SIGNATURE['no text before signature'];
+ expect(removeSignature(body, signature)).toBe('\n\n');
+ });
+ it('removes just the delimiter if no signature is present', () => {
+ expect(removeSignature('This is a test\n\n--', 'This is a signature')).toBe(
+ 'This is a test\n\n'
+ );
+ });
+});
+
+describe('replaceSignature', () => {
+ it('appends the new signature if not present', () => {
+ Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
+ const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
+ expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
+ `${body}\n\n--\n\n${NEW_SIGNATURE}`
+ );
+ });
+ });
+ it('removes signature if present at the end', () => {
+ const { body, signature } = HAS_SIGNATURE['signature at end'];
+ expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
+ `This is a test\n\n--\n\n${NEW_SIGNATURE}`
+ );
+ });
+ it('removes signature if present with spaces and new lines', () => {
+ const { body, signature } = HAS_SIGNATURE[
+ 'signature at end with spaces and new lines'
+ ];
+ expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
+ `This is a test\n\n--\n\n${NEW_SIGNATURE}`
+ );
+ });
+ it('removes signature if present without text before it', () => {
+ const { body, signature } = HAS_SIGNATURE['no text before signature'];
+ expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
+ `\n\n--\n\n${NEW_SIGNATURE}`
+ );
+ });
+});
+
+describe('extractTextFromMarkdown', () => {
+ it('should extract text from markdown and remove all images, code blocks, links, headers, bold, italic, lists etc.', () => {
+ const markdown = `
+ # Hello World
+
+ This is a **bold** text with a [link](https://example.com).
+
+ \`\`\`javascript
+ const foo = 'bar';
+ console.log(foo);
+ \`\`\`
+
+ Here's an image: 
+
+ - List item 1
+ - List item 2
+
+ *Italic text*
+ `;
+
+ const expected =
+ "Hello World\nThis is a bold text with a link.\nHere's an image:\nList item 1\nList item 2\nItalic text";
+ expect(extractTextFromMarkdown(markdown)).toEqual(expected);
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue
index ad3474ca7..c65b85a14 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue
@@ -15,7 +15,7 @@
-.profile--settings--row {
- .ProseMirror-woot-style {
- @apply h-20;
- }
-}
-
.message-editor {
@apply px-3 mb-4;
diff --git a/app/javascript/shared/components/ResizableTextArea.vue b/app/javascript/shared/components/ResizableTextArea.vue
index ce2a42488..bc7c89dc0 100644
--- a/app/javascript/shared/components/ResizableTextArea.vue
+++ b/app/javascript/shared/components/ResizableTextArea.vue
@@ -11,6 +11,12 @@