mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 03:57:52 +00:00
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 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();
|
||||
|
||||
@@ -288,7 +288,7 @@ export default {
|
||||
}
|
||||
},
|
||||
showMessageSignatureButton() {
|
||||
return !this.isOnPrivateNote && this.isAnEmailChannel;
|
||||
return !this.isOnPrivateNote;
|
||||
},
|
||||
sendWithSignature() {
|
||||
const { send_with_signature: isEnabled } = this.uiSettings;
|
||||
|
||||
@@ -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 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isSignatureEnabledForInbox"
|
||||
v-tooltip="$t('CONVERSATION.FOOTER.MESSAGE_SIGN_TOOLTIP')"
|
||||
v-if="isSignatureEnabledForInbox && !isSignatureAvailable"
|
||||
class="message-signature-wrap"
|
||||
>
|
||||
<p
|
||||
v-if="isSignatureAvailable"
|
||||
v-dompurify-html="formatMessage(messageSignature)"
|
||||
class="message-signature"
|
||||
/>
|
||||
<p v-else class="message-signature">
|
||||
<p class="message-signature">
|
||||
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
|
||||
<router-link :to="profilePath">
|
||||
{{ $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;
|
||||
},
|
||||
|
||||
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
|
||||
id="message-signature-input"
|
||||
v-model="messageSignature"
|
||||
class="message-editor"
|
||||
class="message-editor h-[10rem]"
|
||||
:is-format-mode="true"
|
||||
:placeholder="
|
||||
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')
|
||||
@@ -110,12 +110,6 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.profile--settings--row {
|
||||
.ProseMirror-woot-style {
|
||||
@apply h-20;
|
||||
}
|
||||
}
|
||||
|
||||
.message-editor {
|
||||
@apply px-3 mb-4;
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
extractTextFromMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
export default {
|
||||
props: {
|
||||
@@ -26,21 +32,60 @@ export default {
|
||||
type: Number,
|
||||
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() {
|
||||
return {
|
||||
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: {
|
||||
value() {
|
||||
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() {
|
||||
this.$nextTick(() => {
|
||||
if (this.value) {
|
||||
this.resizeTextarea();
|
||||
this.setCursor();
|
||||
} else {
|
||||
this.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -52,6 +97,34 @@ export default {
|
||||
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) {
|
||||
this.$emit('input', event.target.value);
|
||||
this.resizeTextarea();
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"size-limit": [
|
||||
{
|
||||
"path": "public/packs/js/widget-*.js",
|
||||
"limit": "270 KB"
|
||||
"limit": "275 KB"
|
||||
},
|
||||
{
|
||||
"path": "public/packs/js/sdk.js",
|
||||
|
||||
Reference in New Issue
Block a user