From 29110ffd6b9f125b9b3c800efbebbcd79c180a62 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 15 Sep 2023 18:46:40 +0530 Subject: [PATCH] feat: Allow signature in the editor directly (#7881) Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth --- .../components/widgets/WootWriter/Editor.vue | 99 ++++++++++- .../widgets/WootWriter/ReplyBottomPanel.vue | 2 +- .../widgets/conversation/ReplyBox.vue | 76 +++++++-- .../dashboard/helper/editorHelper.js | 135 +++++++++++++++ .../helper/specs/editorHelper.spec.js | 157 ++++++++++++++++++ .../settings/profile/MessageSignature.vue | 8 +- .../shared/components/ResizableTextArea.vue | 73 ++++++++ package.json | 2 +- 8 files changed, 520 insertions(+), 32 deletions(-) create mode 100644 app/javascript/dashboard/helper/editorHelper.js create mode 100644 app/javascript/dashboard/helper/specs/editorHelper.spec.js 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![](http://localhost:3000/rails/active_storage/blobs/redirect/some-hash/image.png)', + }, +}; + +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: ![alt text](https://example.com/image.png) + + - 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 @@