fix: Handle rich message signatures & attachment overflow (#13045)
This commit is contained in:
@@ -7,7 +7,7 @@ import { vOnClickOutside } from '@vueuse/components';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||
@@ -50,12 +50,6 @@ const EmojiInput = defineAsyncComponent(
|
||||
() => import('shared/components/emoji/EmojiInput.vue')
|
||||
);
|
||||
|
||||
const signatureToApply = computed(() =>
|
||||
props.isEmailOrWebWidgetInbox
|
||||
? props.messageSignature
|
||||
: extractTextFromMarkdown(props.messageSignature)
|
||||
);
|
||||
|
||||
const {
|
||||
fetchSignatureFlagFromUISettings,
|
||||
setSignatureFlagForInbox,
|
||||
@@ -80,12 +74,20 @@ const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
});
|
||||
|
||||
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
|
||||
|
||||
const shouldShowSignatureButton = computed(() => {
|
||||
return (
|
||||
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
|
||||
);
|
||||
});
|
||||
|
||||
const setSignature = () => {
|
||||
if (signatureToApply.value) {
|
||||
if (props.messageSignature) {
|
||||
if (sendWithSignature.value) {
|
||||
emit('addSignature', signatureToApply.value);
|
||||
emit('addSignature', props.messageSignature);
|
||||
} else {
|
||||
emit('removeSignature', signatureToApply.value);
|
||||
emit('removeSignature', props.messageSignature);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -101,7 +103,7 @@ watch(
|
||||
() => props.hasSelectedInbox,
|
||||
newValue => {
|
||||
nextTick(() => {
|
||||
if (newValue && props.isEmailOrWebWidgetInbox) setSignature();
|
||||
if (newValue && !isVoiceInbox.value) setSignature();
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -220,7 +222,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
</FileUpload>
|
||||
<Button
|
||||
v-if="hasSelectedInbox && isRegularMessageMode"
|
||||
v-if="shouldShowSignatureButton"
|
||||
icon="i-lucide-signature"
|
||||
color="slate"
|
||||
size="sm"
|
||||
|
||||
@@ -39,7 +39,7 @@ const removeAttachment = id => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<div class="flex flex-col gap-4 p-4 max-h-48 overflow-y-auto">
|
||||
<div
|
||||
v-if="filteredImageAttachments.length > 0"
|
||||
class="flex flex-wrap gap-3"
|
||||
|
||||
@@ -6,7 +6,6 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
extractTextFromMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
buildContactableInboxesList,
|
||||
@@ -202,11 +201,8 @@ const handleInboxAction = ({ value, action, ...rest }) => {
|
||||
const removeSignatureFromMessage = () => {
|
||||
// Always remove the signature from message content when inbox/contact is removed
|
||||
// to ensure no leftover signature content remains
|
||||
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
|
||||
? props.messageSignature
|
||||
: extractTextFromMarkdown(props.messageSignature);
|
||||
if (signatureToRemove) {
|
||||
state.message = removeSignature(state.message, signatureToRemove);
|
||||
if (props.messageSignature) {
|
||||
state.message = removeSignature(state.message, props.messageSignature);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -228,7 +224,11 @@ const onClickInsertEmoji = emoji => {
|
||||
};
|
||||
|
||||
const handleAddSignature = signature => {
|
||||
state.message = appendSignature(state.message, signature);
|
||||
state.message = appendSignature(
|
||||
state.message,
|
||||
signature,
|
||||
inboxChannelType.value
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveSignature = signature => {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed, nextTick } from 'vue';
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
appendSignature,
|
||||
extractTextFromMarkdown,
|
||||
removeSignature,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
@@ -33,17 +32,13 @@ const state = ref({
|
||||
mentionSearchKey: '',
|
||||
});
|
||||
|
||||
const plainTextSignature = computed(() =>
|
||||
extractTextFromMarkdown(props.messageSignature)
|
||||
);
|
||||
|
||||
watch(
|
||||
modelValue,
|
||||
newValue => {
|
||||
if (props.isEmailOrWebWidgetInbox) return;
|
||||
|
||||
const bodyWithoutSignature = newValue
|
||||
? removeSignature(newValue, plainTextSignature.value)
|
||||
? removeSignature(newValue, props.messageSignature)
|
||||
: '';
|
||||
|
||||
// Check if message starts with slash
|
||||
@@ -67,7 +62,7 @@ const hideMention = () => {
|
||||
const replaceText = async message => {
|
||||
// Only append signature on replace if sendWithSignature is true
|
||||
const finalMessage = props.sendWithSignature
|
||||
? appendSignature(message, plainTextSignature.value)
|
||||
? appendSignature(message, props.messageSignature, props.channelType)
|
||||
: message;
|
||||
|
||||
await nextTick();
|
||||
|
||||
@@ -370,7 +370,7 @@ function addSignature() {
|
||||
// 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 = isBodyEmpty(content);
|
||||
content = appendSignature(content, props.signature);
|
||||
content = appendSignature(content, props.signature, props.channelType);
|
||||
// need to reload first, ensuring that the editorView is updated
|
||||
reloadState(content);
|
||||
|
||||
|
||||
@@ -565,7 +565,7 @@ export default {
|
||||
}
|
||||
|
||||
return this.sendWithSignature
|
||||
? appendSignature(message, this.messageSignature)
|
||||
? appendSignature(message, this.messageSignature, this.channelType)
|
||||
: removeSignature(message, this.messageSignature);
|
||||
},
|
||||
removeFromDraft() {
|
||||
@@ -757,7 +757,11 @@ export default {
|
||||
// if signature is enabled, append it to the message
|
||||
// appendSignature ensures that the signature is not duplicated
|
||||
// so we don't need to check if the signature is already present
|
||||
message = appendSignature(message, this.messageSignature);
|
||||
message = appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
this.channelType
|
||||
);
|
||||
}
|
||||
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
@@ -796,7 +800,11 @@ export default {
|
||||
this.message = '';
|
||||
if (this.sendWithSignature && !this.isPrivate) {
|
||||
// if signature is enabled, append it to the message
|
||||
this.message = appendSignature(this.message, this.messageSignature);
|
||||
this.message = appendSignature(
|
||||
this.message,
|
||||
this.messageSignature,
|
||||
this.channelType
|
||||
);
|
||||
}
|
||||
this.attachedFiles = [];
|
||||
this.isRecordingAudio = false;
|
||||
|
||||
@@ -5,7 +5,7 @@ export const FORMATTING = {
|
||||
// Channel formatting
|
||||
'Channel::Email': {
|
||||
marks: ['strong', 'em', 'code', 'link'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
||||
menu: [
|
||||
'strong',
|
||||
'em',
|
||||
@@ -19,7 +19,7 @@ export const FORMATTING = {
|
||||
},
|
||||
'Channel::WebWidget': {
|
||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
||||
menu: [
|
||||
'strong',
|
||||
'em',
|
||||
@@ -127,7 +127,7 @@ export const FORMATTING = {
|
||||
},
|
||||
'Context::MessageSignature': {
|
||||
marks: ['strong', 'em', 'link'],
|
||||
nodes: [],
|
||||
nodes: ['image'],
|
||||
menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'],
|
||||
},
|
||||
'Context::InboxSettings': {
|
||||
@@ -227,6 +227,11 @@ export const MARKDOWN_PATTERNS = [
|
||||
},
|
||||
];
|
||||
|
||||
export const CHANNEL_WITH_RICH_SIGNATURE = [
|
||||
'Channel::Email',
|
||||
'Channel::WebWidget',
|
||||
];
|
||||
|
||||
// Editor image resize options for Message Editor
|
||||
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
|
||||
{
|
||||
|
||||
@@ -5,9 +5,36 @@ import {
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import {
|
||||
FORMATTING,
|
||||
MARKDOWN_PATTERNS,
|
||||
CHANNEL_WITH_RICH_SIGNATURE,
|
||||
} from 'dashboard/constants/editor';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
/**
|
||||
* 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 {string} - The extracted text.
|
||||
*/
|
||||
export function extractTextFromMarkdown(markdown) {
|
||||
if (!markdown) return '';
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* The delimiter used to separate the signature from the rest of the body.
|
||||
* @type {string}
|
||||
@@ -69,15 +96,32 @@ export function findSignatureInBody(body, signature) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the channel supports image signatures.
|
||||
*
|
||||
* @param {string} channelType - The channel type.
|
||||
* @returns {boolean} - True if the channel supports image signatures.
|
||||
*/
|
||||
export function supportsImageSignature(channelType) {
|
||||
return CHANNEL_WITH_RICH_SIGNATURE.includes(channelType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the signature to the body, separated by the signature delimiter.
|
||||
* Automatically strips images for channels that don't support image signatures.
|
||||
*
|
||||
* @param {string} body - The body to append the signature to.
|
||||
* @param {string} signature - The signature to append.
|
||||
* @param {string} channelType - Optional. The channel type to determine if images should be stripped.
|
||||
* @returns {string} - The body with the signature appended.
|
||||
*/
|
||||
export function appendSignature(body, signature) {
|
||||
const cleanedSignature = cleanSignature(signature);
|
||||
export function appendSignature(body, signature, channelType) {
|
||||
// For channels that don't support images, strip markdown formatting
|
||||
const shouldStripImages = channelType && !supportsImageSignature(channelType);
|
||||
const preparedSignature = shouldStripImages
|
||||
? extractTextFromMarkdown(signature)
|
||||
: signature;
|
||||
const cleanedSignature = cleanSignature(preparedSignature);
|
||||
// if signature is already present, return body
|
||||
if (findSignatureInBody(body, cleanedSignature) > -1) {
|
||||
return body;
|
||||
@@ -88,16 +132,27 @@ export function appendSignature(body, signature) {
|
||||
|
||||
/**
|
||||
* Removes the signature from the body, along with the signature delimiter.
|
||||
* Tries to find both the original signature and the stripped version (for non-image channels).
|
||||
*
|
||||
* @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
|
||||
// Build list of signatures to try: original first, then stripped version
|
||||
// Always try both to handle cases where channelType is unknown or inbox is being removed
|
||||
const cleanedSignature = cleanSignature(signature);
|
||||
const signatureIndex = findSignatureInBody(body, cleanedSignature);
|
||||
const strippedSignature = cleanSignature(extractTextFromMarkdown(signature));
|
||||
const signaturesToTry =
|
||||
cleanedSignature === strippedSignature
|
||||
? [cleanedSignature]
|
||||
: [cleanedSignature, strippedSignature];
|
||||
|
||||
// Find the first matching signature
|
||||
const signatureIndex = signaturesToTry.reduce(
|
||||
(index, sig) => (index === -1 ? findSignatureInBody(body, sig) : index),
|
||||
-1
|
||||
);
|
||||
|
||||
// no need to trim the ends here, because it will simply be removed in the next method
|
||||
let newBody = body;
|
||||
@@ -138,28 +193,6 @@ export function replaceSignature(body, oldSignature, newSignature) {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the editor view into current cursor position
|
||||
*
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
replaceSignature,
|
||||
cleanSignature,
|
||||
extractTextFromMarkdown,
|
||||
supportsImageSignature,
|
||||
insertAtCursor,
|
||||
findNodeToInsertImage,
|
||||
setURLWithQueryAndSize,
|
||||
@@ -144,6 +145,47 @@ describe('appendSignature', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendSignature with channelType', () => {
|
||||
const signatureWithImage =
|
||||
'Thanks\n';
|
||||
const strippedSignature = 'Thanks';
|
||||
|
||||
it('keeps images for Email channel', () => {
|
||||
const result = appendSignature(
|
||||
'Hello',
|
||||
signatureWithImage,
|
||||
'Channel::Email'
|
||||
);
|
||||
expect(result).toContain(';
|
||||
});
|
||||
it('keeps images for WebWidget channel', () => {
|
||||
const result = appendSignature(
|
||||
'Hello',
|
||||
signatureWithImage,
|
||||
'Channel::WebWidget'
|
||||
);
|
||||
expect(result).toContain(';
|
||||
});
|
||||
it('strips images for Api channel', () => {
|
||||
const result = appendSignature('Hello', signatureWithImage, 'Channel::Api');
|
||||
expect(result).not.toContain(';
|
||||
expect(result).toContain(strippedSignature);
|
||||
});
|
||||
it('strips images for WhatsApp channel', () => {
|
||||
const result = appendSignature(
|
||||
'Hello',
|
||||
signatureWithImage,
|
||||
'Channel::Whatsapp'
|
||||
);
|
||||
expect(result).not.toContain(';
|
||||
expect(result).toContain(strippedSignature);
|
||||
});
|
||||
it('keeps images when channelType is not provided', () => {
|
||||
const result = appendSignature('Hello', signatureWithImage);
|
||||
expect(result).toContain(';
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanSignature', () => {
|
||||
it('removes any instance of horizontal rule', () => {
|
||||
const options = [
|
||||
@@ -202,6 +244,37 @@ describe('removeSignature', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeSignature with stripped signature', () => {
|
||||
const signatureWithImage =
|
||||
'Thanks\n';
|
||||
|
||||
it('removes stripped signature from body', () => {
|
||||
// Simulate a body where signature was added with images stripped
|
||||
const bodyWithStrippedSignature = 'Hello\n\n--\n\nThanks';
|
||||
const result = removeSignature(
|
||||
bodyWithStrippedSignature,
|
||||
signatureWithImage
|
||||
);
|
||||
expect(result).toBe('Hello\n\n');
|
||||
});
|
||||
it('removes original signature from body', () => {
|
||||
// Simulate a body where signature was added with images (using cleanSignature format)
|
||||
const cleanedSig = cleanSignature(signatureWithImage);
|
||||
const bodyWithOriginalSignature = `Hello\n\n--\n\n${cleanedSig}`;
|
||||
const result = removeSignature(
|
||||
bodyWithOriginalSignature,
|
||||
signatureWithImage
|
||||
);
|
||||
expect(result).toBe('Hello\n\n');
|
||||
});
|
||||
it('handles signature without images', () => {
|
||||
const simpleSignature = 'Best regards';
|
||||
const body = 'Hello\n\n--\n\nBest regards';
|
||||
const result = removeSignature(body, simpleSignature);
|
||||
expect(result).toBe('Hello\n\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceSignature', () => {
|
||||
it('appends the new signature if not present', () => {
|
||||
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
||||
@@ -258,6 +331,24 @@ describe('extractTextFromMarkdown', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsImageSignature', () => {
|
||||
it('returns true for Email channel', () => {
|
||||
expect(supportsImageSignature('Channel::Email')).toBe(true);
|
||||
});
|
||||
it('returns true for WebWidget channel', () => {
|
||||
expect(supportsImageSignature('Channel::WebWidget')).toBe(true);
|
||||
});
|
||||
it('returns false for Api channel', () => {
|
||||
expect(supportsImageSignature('Channel::Api')).toBe(false);
|
||||
});
|
||||
it('returns false for WhatsApp channel', () => {
|
||||
expect(supportsImageSignature('Channel::Whatsapp')).toBe(false);
|
||||
});
|
||||
it('returns false for Telegram channel', () => {
|
||||
expect(supportsImageSignature('Channel::Telegram')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertAtCursor', () => {
|
||||
it('should return undefined if editorView is not provided', () => {
|
||||
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
|
||||
|
||||
Reference in New Issue
Block a user