feat: Allow signature in the editor directly (#7881)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -44,6 +44,10 @@ import {
|
|||||||
import TagAgents from '../conversation/TagAgents';
|
import TagAgents from '../conversation/TagAgents';
|
||||||
import CannedResponse from '../conversation/CannedResponse';
|
import CannedResponse from '../conversation/CannedResponse';
|
||||||
import VariableList from '../conversation/VariableList';
|
import VariableList from '../conversation/VariableList';
|
||||||
|
import {
|
||||||
|
appendSignature,
|
||||||
|
removeSignature,
|
||||||
|
} from 'dashboard/helper/editorHelper';
|
||||||
|
|
||||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||||
@@ -100,6 +104,10 @@ export default {
|
|||||||
enableCannedResponses: { type: Boolean, default: true },
|
enableCannedResponses: { type: Boolean, default: true },
|
||||||
variables: { type: Object, default: () => ({}) },
|
variables: { type: Object, default: () => ({}) },
|
||||||
enabledMenuOptions: { type: Array, 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() {
|
data() {
|
||||||
return {
|
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: {
|
watch: {
|
||||||
showUserMentions(updatedValue) {
|
showUserMentions(updatedValue) {
|
||||||
@@ -244,7 +258,6 @@ export default {
|
|||||||
isPrivate() {
|
isPrivate() {
|
||||||
this.reloadState(this.value);
|
this.reloadState(this.value);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateSelectionWith(newValue, oldValue) {
|
updateSelectionWith(newValue, oldValue) {
|
||||||
if (!this.editorView) {
|
if (!this.editorView) {
|
||||||
return null;
|
return null;
|
||||||
@@ -263,6 +276,12 @@ export default {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
sendWithSignature(newValue) {
|
||||||
|
// see if the allowSignature flag is true
|
||||||
|
if (this.allowSignature) {
|
||||||
|
this.toggleSignatureInEditor(newValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.state = createState(
|
this.state = createState(
|
||||||
@@ -276,7 +295,7 @@ export default {
|
|||||||
mounted() {
|
mounted() {
|
||||||
this.createEditorView();
|
this.createEditorView();
|
||||||
this.editorView.updateState(this.state);
|
this.editorView.updateState(this.state);
|
||||||
this.focusEditorInputField();
|
this.focusEditor(this.value);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
reloadState(content = this.value) {
|
reloadState(content = this.value) {
|
||||||
@@ -288,7 +307,75 @@ export default {
|
|||||||
this.editorMenuOptions
|
this.editorMenuOptions
|
||||||
);
|
);
|
||||||
this.editorView.updateState(this.state);
|
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() {
|
createEditorView() {
|
||||||
this.editorView = new EditorView(this.$refs.editor, {
|
this.editorView = new EditorView(this.$refs.editor, {
|
||||||
@@ -333,9 +420,11 @@ export default {
|
|||||||
this.focusEditorInputField();
|
this.focusEditorInputField();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
focusEditorInputField() {
|
focusEditorInputField(pos = 'end') {
|
||||||
const { tr } = this.editorView.state;
|
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.dispatch(tr.setSelection(selection));
|
||||||
this.editorView.focus();
|
this.editorView.focus();
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
showMessageSignatureButton() {
|
showMessageSignatureButton() {
|
||||||
return !this.isOnPrivateNote && this.isAnEmailChannel;
|
return !this.isOnPrivateNote;
|
||||||
},
|
},
|
||||||
sendWithSignature() {
|
sendWithSignature() {
|
||||||
const { send_with_signature: isEnabled } = this.uiSettings;
|
const { send_with_signature: isEnabled } = this.uiSettings;
|
||||||
|
|||||||
@@ -53,6 +53,9 @@
|
|||||||
class="input"
|
class="input"
|
||||||
:placeholder="messagePlaceHolder"
|
:placeholder="messagePlaceHolder"
|
||||||
:min-height="4"
|
:min-height="4"
|
||||||
|
:signature="signatureToApply"
|
||||||
|
:allow-signature="true"
|
||||||
|
:send-with-signature="sendWithSignature"
|
||||||
@typing-off="onTypingOff"
|
@typing-off="onTypingOff"
|
||||||
@typing-on="onTypingOn"
|
@typing-on="onTypingOn"
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
@@ -69,6 +72,8 @@
|
|||||||
:min-height="4"
|
:min-height="4"
|
||||||
:enable-variables="true"
|
:enable-variables="true"
|
||||||
:variables="messageVariables"
|
:variables="messageVariables"
|
||||||
|
:signature="signatureToApply"
|
||||||
|
:allow-signature="true"
|
||||||
@typing-off="onTypingOff"
|
@typing-off="onTypingOff"
|
||||||
@typing-on="onTypingOn"
|
@typing-on="onTypingOn"
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
@@ -86,16 +91,10 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="isSignatureEnabledForInbox"
|
v-if="isSignatureEnabledForInbox && !isSignatureAvailable"
|
||||||
v-tooltip="$t('CONVERSATION.FOOTER.MESSAGE_SIGN_TOOLTIP')"
|
|
||||||
class="message-signature-wrap"
|
class="message-signature-wrap"
|
||||||
>
|
>
|
||||||
<p
|
<p class="message-signature">
|
||||||
v-if="isSignatureAvailable"
|
|
||||||
v-dompurify-html="formatMessage(messageSignature)"
|
|
||||||
class="message-signature"
|
|
||||||
/>
|
|
||||||
<p v-else class="message-signature">
|
|
||||||
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
|
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
|
||||||
<router-link :to="profilePath">
|
<router-link :to="profilePath">
|
||||||
{{ $t('CONVERSATION.FOOTER.CLICK_HERE') }}
|
{{ $t('CONVERSATION.FOOTER.CLICK_HERE') }}
|
||||||
@@ -184,6 +183,12 @@ import wootConstants from 'dashboard/constants/globals';
|
|||||||
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
||||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||||
import rtlMixin from 'shared/mixins/rtlMixin';
|
import rtlMixin from 'shared/mixins/rtlMixin';
|
||||||
|
import {
|
||||||
|
appendSignature,
|
||||||
|
removeSignature,
|
||||||
|
replaceSignature,
|
||||||
|
extractTextFromMarkdown,
|
||||||
|
} from 'dashboard/helper/editorHelper';
|
||||||
|
|
||||||
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
|
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
|
||||||
|
|
||||||
@@ -471,10 +476,10 @@ export default {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
isSignatureEnabledForInbox() {
|
isSignatureEnabledForInbox() {
|
||||||
return !this.isPrivate && this.isAnEmailChannel && this.sendWithSignature;
|
return !this.isPrivate && this.sendWithSignature;
|
||||||
},
|
},
|
||||||
isSignatureAvailable() {
|
isSignatureAvailable() {
|
||||||
return !!this.messageSignature;
|
return !!this.signatureToApply;
|
||||||
},
|
},
|
||||||
sendWithSignature() {
|
sendWithSignature() {
|
||||||
const { send_with_signature: isEnabled } = this.uiSettings;
|
const { send_with_signature: isEnabled } = this.uiSettings;
|
||||||
@@ -514,6 +519,12 @@ export default {
|
|||||||
});
|
});
|
||||||
return variables;
|
return variables;
|
||||||
},
|
},
|
||||||
|
// ensure that the signature is plain text depending on `showRichContentEditor`
|
||||||
|
signatureToApply() {
|
||||||
|
return this.showRichContentEditor
|
||||||
|
? this.messageSignature
|
||||||
|
: extractTextFromMarkdown(this.messageSignature);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
currentChat(conversation) {
|
currentChat(conversation) {
|
||||||
@@ -581,6 +592,23 @@ export default {
|
|||||||
this.updateUISettings({
|
this.updateUISettings({
|
||||||
display_rich_content_editor: !this.showRichContentEditor,
|
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) {
|
saveDraft(conversationId, replyType) {
|
||||||
if (this.message || this.message === '') {
|
if (this.message || this.message === '') {
|
||||||
@@ -600,9 +628,22 @@ export default {
|
|||||||
getFromDraft() {
|
getFromDraft() {
|
||||||
if (this.conversationIdByRoute) {
|
if (this.conversationIdByRoute) {
|
||||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
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() {
|
removeFromDraft() {
|
||||||
if (this.conversationIdByRoute) {
|
if (this.conversationIdByRoute) {
|
||||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||||
@@ -694,19 +735,14 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.showMentions) {
|
if (!this.showMentions) {
|
||||||
let newMessage = this.message;
|
|
||||||
if (this.isSignatureEnabledForInbox && this.messageSignature) {
|
|
||||||
newMessage += '\n\n' + this.messageSignature;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOnWhatsApp =
|
const isOnWhatsApp =
|
||||||
this.isATwilioWhatsAppChannel ||
|
this.isATwilioWhatsAppChannel ||
|
||||||
this.isAWhatsAppCloudChannel ||
|
this.isAWhatsAppCloudChannel ||
|
||||||
this.is360DialogWhatsAppChannel;
|
this.is360DialogWhatsAppChannel;
|
||||||
if (isOnWhatsApp && !this.isPrivate) {
|
if (isOnWhatsApp && !this.isPrivate) {
|
||||||
this.sendMessageAsMultipleMessages(newMessage);
|
this.sendMessageAsMultipleMessages(this.message);
|
||||||
} else {
|
} else {
|
||||||
const messagePayload = this.getMessagePayload(newMessage);
|
const messagePayload = this.getMessagePayload(this.message);
|
||||||
this.sendMessage(messagePayload);
|
this.sendMessage(messagePayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -828,6 +864,10 @@ export default {
|
|||||||
},
|
},
|
||||||
clearMessage() {
|
clearMessage() {
|
||||||
this.message = '';
|
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.attachedFiles = [];
|
||||||
this.isRecordingAudio = false;
|
this.isRecordingAudio = false;
|
||||||
},
|
},
|
||||||
|
|||||||
135
app/javascript/dashboard/helper/editorHelper.js
Normal file
135
app/javascript/dashboard/helper/editorHelper.js
Normal file
@@ -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
|
||||||
|
}
|
||||||
157
app/javascript/dashboard/helper/specs/editorHelper.spec.js
Normal file
157
app/javascript/dashboard/helper/specs/editorHelper.spec.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<woot-message-editor
|
<woot-message-editor
|
||||||
id="message-signature-input"
|
id="message-signature-input"
|
||||||
v-model="messageSignature"
|
v-model="messageSignature"
|
||||||
class="message-editor"
|
class="message-editor h-[10rem]"
|
||||||
:is-format-mode="true"
|
:is-format-mode="true"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')
|
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')
|
||||||
@@ -110,12 +110,6 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.profile--settings--row {
|
|
||||||
.ProseMirror-woot-style {
|
|
||||||
@apply h-20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-editor {
|
.message-editor {
|
||||||
@apply px-3 mb-4;
|
@apply px-3 mb-4;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import {
|
||||||
|
appendSignature,
|
||||||
|
removeSignature,
|
||||||
|
extractTextFromMarkdown,
|
||||||
|
} from 'dashboard/helper/editorHelper';
|
||||||
|
|
||||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
@@ -26,21 +32,60 @@ export default {
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 2,
|
default: 2,
|
||||||
},
|
},
|
||||||
|
signature: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
// add this as a prop, so that we won't have to include uiSettingsMixin
|
||||||
|
sendWithSignature: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
// allowSignature is a kill switch, ensuring no signature methods are triggered except when this flag is true
|
||||||
|
allowSignature: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
idleTimer: null,
|
idleTimer: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
cleanedSignature() {
|
||||||
|
// clean the signature, this will ensure that we don't have
|
||||||
|
// any markdown formatted text in the signature
|
||||||
|
return extractTextFromMarkdown(this.signature);
|
||||||
|
},
|
||||||
|
},
|
||||||
watch: {
|
watch: {
|
||||||
value() {
|
value() {
|
||||||
this.resizeTextarea();
|
this.resizeTextarea();
|
||||||
|
// 🚨 watch triggers every time the value is changed, we cannot set this to focus then
|
||||||
|
// when this runs, it sets the cursor to the end of the body, ignoring the signature
|
||||||
|
// Suppose if someone manually set the cursor to the middle of the body
|
||||||
|
// and starts typing, the cursor will be set to the end of the body
|
||||||
|
// A surprise cursor jump? Definitely not user-friendly.
|
||||||
|
if (document.activeElement !== this.$refs.textarea) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.setCursor();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendWithSignature(newValue) {
|
||||||
|
if (this.allowSignature) {
|
||||||
|
this.toggleSignatureInEditor(newValue);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
if (this.value) {
|
if (this.value) {
|
||||||
this.resizeTextarea();
|
this.resizeTextarea();
|
||||||
|
this.setCursor();
|
||||||
|
} else {
|
||||||
|
this.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -52,6 +97,34 @@ export default {
|
|||||||
this.$el.style.height = `${this.$el.scrollHeight}px`;
|
this.$el.style.height = `${this.$el.scrollHeight}px`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// 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.
|
||||||
|
toggleSignatureInEditor(signatureEnabled) {
|
||||||
|
const valueWithSignature = signatureEnabled
|
||||||
|
? appendSignature(this.value, this.cleanedSignature)
|
||||||
|
: removeSignature(this.value, this.cleanedSignature);
|
||||||
|
|
||||||
|
this.$emit('input', valueWithSignature);
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.resizeTextarea();
|
||||||
|
this.setCursor();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setCursor() {
|
||||||
|
const textarea = this.$refs.textarea;
|
||||||
|
const bodyWithoutSignature = removeSignature(
|
||||||
|
this.value,
|
||||||
|
this.cleanedSignature
|
||||||
|
);
|
||||||
|
|
||||||
|
// only trim at end, so if there are spaces at the start, those are not removed
|
||||||
|
const bodyEndsAt = bodyWithoutSignature.trimEnd().length;
|
||||||
|
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(bodyEndsAt, bodyEndsAt);
|
||||||
|
},
|
||||||
onInput(event) {
|
onInput(event) {
|
||||||
this.$emit('input', event.target.value);
|
this.$emit('input', event.target.value);
|
||||||
this.resizeTextarea();
|
this.resizeTextarea();
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"size-limit": [
|
"size-limit": [
|
||||||
{
|
{
|
||||||
"path": "public/packs/js/widget-*.js",
|
"path": "public/packs/js/widget-*.js",
|
||||||
"limit": "270 KB"
|
"limit": "275 KB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "public/packs/js/sdk.js",
|
"path": "public/packs/js/sdk.js",
|
||||||
|
|||||||
Reference in New Issue
Block a user