chore: Strip unsupported signature formatting by channel (#13046)
# Pull Request Template ## Description 1. This PR is an enhancement to https://github.com/chatwoot/chatwoot/pull/13045 It strips unsupported formatting from **message signatures** based on each channel’s formatting capabilities defined in the `FORMATTING` config 2. Remove usage of plain editor in Compose new conversation modal Only the following signature elements are considered: <strong>bold (<code inline="">strong</code>), italic (<code inline="">em</code>), links (<code inline="">link</code>), images (<code inline="">image</code>)</strong>.</p> Any formatting not supported by the target channel is automatically removed before the signature is appended. <h3>Channel-wise Signature Formatting Support</h3> Channel | Keeps in Signature | Strips from Signature -- | -- | -- Email | bold, italic, links, images | — WebWidget | bold, italic, links, images | — API | bold, italic | links, images WhatsApp | bold, italic | links, images Telegram | bold, italic, links | images Facebook | bold, italic | links, images Instagram | bold, italic | links, images Line | bold, italic | links, images SMS | — | everything Twilio SMS | — | everything Twitter/X | — | everything <hr> <h3>📝 Note</h3> <blockquote> <p>Message signatures only support <strong>bold, italic, links, and images</strong>.<br> Other formatting options available in the editor (lists, code blocks, strike-through, etc.) do <strong>not apply</strong> to signatures and are ignored.</p> </blockquote> ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Loom video https://www.loom.com/share/d325ab86ca514c6d8f90dfe72a8928dd ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -24,6 +24,7 @@ const props = defineProps({
|
|||||||
allowSignature: { type: Boolean, default: false },
|
allowSignature: { type: Boolean, default: false },
|
||||||
sendWithSignature: { type: Boolean, default: false },
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
channelType: { type: String, default: '' },
|
channelType: { type: String, default: '' },
|
||||||
|
medium: { type: String, default: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']);
|
const emit = defineEmits(['update:modelValue']);
|
||||||
@@ -106,6 +107,7 @@ watch(
|
|||||||
:allow-signature="allowSignature"
|
:allow-signature="allowSignature"
|
||||||
:send-with-signature="sendWithSignature"
|
:send-with-signature="sendWithSignature"
|
||||||
:channel-type="channelType"
|
:channel-type="channelType"
|
||||||
|
:medium="medium"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
@blur="handleBlur"
|
@blur="handleBlur"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
|||||||
import {
|
import {
|
||||||
appendSignature,
|
appendSignature,
|
||||||
removeSignature,
|
removeSignature,
|
||||||
|
getEffectiveChannelType,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
import {
|
import {
|
||||||
buildContactableInboxesList,
|
buildContactableInboxesList,
|
||||||
@@ -86,6 +87,12 @@ const whatsappMessageTemplates = computed(() =>
|
|||||||
|
|
||||||
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||||
|
|
||||||
|
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||||
|
|
||||||
|
const effectiveChannelType = computed(() =>
|
||||||
|
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||||
|
);
|
||||||
|
|
||||||
const validationRules = computed(() => ({
|
const validationRules = computed(() => ({
|
||||||
selectedContact: { required },
|
selectedContact: { required },
|
||||||
targetInbox: { required },
|
targetInbox: { required },
|
||||||
@@ -202,7 +209,11 @@ const removeSignatureFromMessage = () => {
|
|||||||
// Always remove the signature from message content when inbox/contact is removed
|
// Always remove the signature from message content when inbox/contact is removed
|
||||||
// to ensure no leftover signature content remains
|
// to ensure no leftover signature content remains
|
||||||
if (props.messageSignature) {
|
if (props.messageSignature) {
|
||||||
state.message = removeSignature(state.message, props.messageSignature);
|
state.message = removeSignature(
|
||||||
|
state.message,
|
||||||
|
props.messageSignature,
|
||||||
|
effectiveChannelType.value
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -214,9 +225,9 @@ const removeTargetInbox = value => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearSelectedContact = () => {
|
const clearSelectedContact = () => {
|
||||||
|
removeSignatureFromMessage();
|
||||||
emit('clearSelectedContact');
|
emit('clearSelectedContact');
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
removeSignatureFromMessage();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickInsertEmoji = emoji => {
|
const onClickInsertEmoji = emoji => {
|
||||||
@@ -227,12 +238,16 @@ const handleAddSignature = signature => {
|
|||||||
state.message = appendSignature(
|
state.message = appendSignature(
|
||||||
state.message,
|
state.message,
|
||||||
signature,
|
signature,
|
||||||
inboxChannelType.value
|
effectiveChannelType.value
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveSignature = signature => {
|
const handleRemoveSignature = signature => {
|
||||||
state.message = removeSignature(state.message, signature);
|
state.message = removeSignature(
|
||||||
|
state.message,
|
||||||
|
signature,
|
||||||
|
effectiveChannelType.value
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAttachFile = files => {
|
const handleAttachFile = files => {
|
||||||
@@ -356,10 +371,10 @@ const shouldShowMessageEditor = computed(() => {
|
|||||||
v-model="state.message"
|
v-model="state.message"
|
||||||
:message-signature="messageSignature"
|
:message-signature="messageSignature"
|
||||||
:send-with-signature="sendWithSignature"
|
:send-with-signature="sendWithSignature"
|
||||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
|
||||||
:has-errors="validationStates.isMessageInvalid"
|
:has-errors="validationStates.isMessageInvalid"
|
||||||
:has-attachments="state.attachedFiles.length > 0"
|
:has-attachments="state.attachedFiles.length > 0"
|
||||||
:channel-type="inboxChannelType"
|
:channel-type="inboxChannelType"
|
||||||
|
:medium="targetInbox?.medium || ''"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AttachmentPreviews
|
<AttachmentPreviews
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, nextTick } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import {
|
|
||||||
appendSignature,
|
|
||||||
removeSignature,
|
|
||||||
} from 'dashboard/helper/editorHelper';
|
|
||||||
|
|
||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
|
||||||
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
isEmailOrWebWidgetInbox: { type: Boolean, required: true },
|
|
||||||
hasErrors: { type: Boolean, default: false },
|
hasErrors: { type: Boolean, default: false },
|
||||||
hasAttachments: { type: Boolean, default: false },
|
hasAttachments: { type: Boolean, default: false },
|
||||||
sendWithSignature: { type: Boolean, default: false },
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
channelType: { type: String, default: '' },
|
channelType: { type: String, default: '' },
|
||||||
|
medium: { type: String, default: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -25,54 +18,10 @@ const modelValue = defineModel({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = ref({
|
|
||||||
hasSlashCommand: false,
|
|
||||||
showMentions: false,
|
|
||||||
mentionSearchKey: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
modelValue,
|
|
||||||
newValue => {
|
|
||||||
if (props.isEmailOrWebWidgetInbox) return;
|
|
||||||
|
|
||||||
const bodyWithoutSignature = newValue
|
|
||||||
? removeSignature(newValue, props.messageSignature)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Check if message starts with slash
|
|
||||||
const startsWithSlash = bodyWithoutSignature.startsWith('/');
|
|
||||||
|
|
||||||
// Update slash command and mentions state
|
|
||||||
state.value = {
|
|
||||||
...state.value,
|
|
||||||
hasSlashCommand: startsWithSlash,
|
|
||||||
showMentions: startsWithSlash,
|
|
||||||
mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const hideMention = () => {
|
|
||||||
state.value.showMentions = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceText = async message => {
|
|
||||||
// Only append signature on replace if sendWithSignature is true
|
|
||||||
const finalMessage = props.sendWithSignature
|
|
||||||
? appendSignature(message, props.messageSignature, props.channelType)
|
|
||||||
: message;
|
|
||||||
|
|
||||||
await nextTick();
|
|
||||||
modelValue.value = finalMessage;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
||||||
<template v-if="isEmailOrWebWidgetInbox">
|
|
||||||
<Editor
|
<Editor
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
@@ -90,33 +39,7 @@ const replaceText = async message => {
|
|||||||
allow-signature
|
allow-signature
|
||||||
:send-with-signature="sendWithSignature"
|
:send-with-signature="sendWithSignature"
|
||||||
:channel-type="channelType"
|
:channel-type="channelType"
|
||||||
|
:medium="medium"
|
||||||
/>
|
/>
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<TextArea
|
|
||||||
v-model="modelValue"
|
|
||||||
:placeholder="
|
|
||||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
|
||||||
"
|
|
||||||
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
|
|
||||||
:custom-text-area-class="
|
|
||||||
hasErrors
|
|
||||||
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
auto-height
|
|
||||||
allow-signature
|
|
||||||
:signature="messageSignature"
|
|
||||||
:send-with-signature="sendWithSignature"
|
|
||||||
>
|
|
||||||
<CannedResponse
|
|
||||||
v-if="state.showMentions && state.hasSlashCommand"
|
|
||||||
v-on-clickaway="hideMention"
|
|
||||||
class="normal-editor__canned-box"
|
|
||||||
:search-key="state.mentionSearchKey"
|
|
||||||
@replace="replaceText"
|
|
||||||
/>
|
|
||||||
</TextArea>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
getFormattingForEditor,
|
getFormattingForEditor,
|
||||||
getSelectionCoords,
|
getSelectionCoords,
|
||||||
calculateMenuPosition,
|
calculateMenuPosition,
|
||||||
|
getEffectiveChannelType,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
import {
|
import {
|
||||||
hasPressedEnterAndNotCmdOrShift,
|
hasPressedEnterAndNotCmdOrShift,
|
||||||
@@ -81,6 +82,7 @@ const props = defineProps({
|
|||||||
// are triggered except when this flag is true
|
// are triggered except when this flag is true
|
||||||
allowSignature: { type: Boolean, default: false },
|
allowSignature: { type: Boolean, default: false },
|
||||||
channelType: { type: String, default: '' },
|
channelType: { type: String, default: '' },
|
||||||
|
medium: { type: String, default: '' },
|
||||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||||
focusOnMount: { type: Boolean, default: true },
|
focusOnMount: { type: Boolean, default: true },
|
||||||
});
|
});
|
||||||
@@ -105,10 +107,16 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
|
|||||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||||
const DEFAULT_FORMATTING = 'Context::Default';
|
const DEFAULT_FORMATTING = 'Context::Default';
|
||||||
|
|
||||||
|
const effectiveChannelType = computed(() =>
|
||||||
|
getEffectiveChannelType(props.channelType, props.medium)
|
||||||
|
);
|
||||||
|
|
||||||
const editorSchema = computed(() => {
|
const editorSchema = computed(() => {
|
||||||
if (!props.channelType) return messageSchema;
|
if (!props.channelType) return messageSchema;
|
||||||
|
|
||||||
const formatType = props.isPrivate ? DEFAULT_FORMATTING : props.channelType;
|
const formatType = props.isPrivate
|
||||||
|
? DEFAULT_FORMATTING
|
||||||
|
: effectiveChannelType.value;
|
||||||
const formatting = getFormattingForEditor(formatType);
|
const formatting = getFormattingForEditor(formatType);
|
||||||
return buildMessageSchema(formatting.marks, formatting.nodes);
|
return buildMessageSchema(formatting.marks, formatting.nodes);
|
||||||
});
|
});
|
||||||
@@ -116,7 +124,7 @@ const editorSchema = computed(() => {
|
|||||||
const editorMenuOptions = computed(() => {
|
const editorMenuOptions = computed(() => {
|
||||||
const formatType = props.isPrivate
|
const formatType = props.isPrivate
|
||||||
? DEFAULT_FORMATTING
|
? DEFAULT_FORMATTING
|
||||||
: props.channelType || DEFAULT_FORMATTING;
|
: effectiveChannelType.value || DEFAULT_FORMATTING;
|
||||||
const formatting = getFormattingForEditor(formatType);
|
const formatting = getFormattingForEditor(formatType);
|
||||||
return formatting.menu;
|
return formatting.menu;
|
||||||
});
|
});
|
||||||
@@ -301,8 +309,13 @@ function isBodyEmpty(content) {
|
|||||||
|
|
||||||
// if the signature is present, we need to remove it before checking
|
// if the signature is present, we need to remove it before checking
|
||||||
// note that we don't update the editorView, so this is safe
|
// note that we don't update the editorView, so this is safe
|
||||||
|
// Use effective channel type to match how signature was appended
|
||||||
const bodyWithoutSignature = props.signature
|
const bodyWithoutSignature = props.signature
|
||||||
? removeSignatureHelper(content, props.signature)
|
? removeSignatureHelper(
|
||||||
|
content,
|
||||||
|
props.signature,
|
||||||
|
effectiveChannelType.value
|
||||||
|
)
|
||||||
: content;
|
: content;
|
||||||
|
|
||||||
// trimming should remove all the whitespaces, so we can check the length
|
// trimming should remove all the whitespaces, so we can check the length
|
||||||
@@ -370,7 +383,11 @@ function addSignature() {
|
|||||||
// see if the content is empty, if it is before appending the signature
|
// 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
|
// we need to add a paragraph node and move the cursor at the start of the editor
|
||||||
const contentWasEmpty = isBodyEmpty(content);
|
const contentWasEmpty = isBodyEmpty(content);
|
||||||
content = appendSignature(content, props.signature, props.channelType);
|
content = appendSignature(
|
||||||
|
content,
|
||||||
|
props.signature,
|
||||||
|
effectiveChannelType.value
|
||||||
|
);
|
||||||
// need to reload first, ensuring that the editorView is updated
|
// need to reload first, ensuring that the editorView is updated
|
||||||
reloadState(content);
|
reloadState(content);
|
||||||
|
|
||||||
@@ -382,7 +399,11 @@ function addSignature() {
|
|||||||
function removeSignature() {
|
function removeSignature() {
|
||||||
if (!props.signature) return;
|
if (!props.signature) return;
|
||||||
let content = props.modelValue;
|
let content = props.modelValue;
|
||||||
content = removeSignatureHelper(content, props.signature);
|
content = removeSignatureHelper(
|
||||||
|
content,
|
||||||
|
props.signature,
|
||||||
|
effectiveChannelType.value
|
||||||
|
);
|
||||||
// reload the state, ensuring that the editorView is updated
|
// reload the state, ensuring that the editorView is updated
|
||||||
reloadState(content);
|
reloadState(content);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
|||||||
import {
|
import {
|
||||||
appendSignature,
|
appendSignature,
|
||||||
removeSignature,
|
removeSignature,
|
||||||
|
getEffectiveChannelType,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
|
|
||||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||||
@@ -564,9 +565,13 @@ export default {
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectiveChannelType = getEffectiveChannelType(
|
||||||
|
this.channelType,
|
||||||
|
this.inbox?.medium || ''
|
||||||
|
);
|
||||||
return this.sendWithSignature
|
return this.sendWithSignature
|
||||||
? appendSignature(message, this.messageSignature, this.channelType)
|
? appendSignature(message, this.messageSignature, effectiveChannelType)
|
||||||
: removeSignature(message, this.messageSignature);
|
: removeSignature(message, this.messageSignature, effectiveChannelType);
|
||||||
},
|
},
|
||||||
removeFromDraft() {
|
removeFromDraft() {
|
||||||
if (this.conversationIdByRoute) {
|
if (this.conversationIdByRoute) {
|
||||||
@@ -757,10 +762,14 @@ export default {
|
|||||||
// if signature is enabled, append it to the message
|
// if signature is enabled, append it to the message
|
||||||
// appendSignature ensures that the signature is not duplicated
|
// appendSignature ensures that the signature is not duplicated
|
||||||
// so we don't need to check if the signature is already present
|
// so we don't need to check if the signature is already present
|
||||||
|
const effectiveChannelType = getEffectiveChannelType(
|
||||||
|
this.channelType,
|
||||||
|
this.inbox?.medium || ''
|
||||||
|
);
|
||||||
message = appendSignature(
|
message = appendSignature(
|
||||||
message,
|
message,
|
||||||
this.messageSignature,
|
this.messageSignature,
|
||||||
this.channelType
|
effectiveChannelType
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,10 +809,14 @@ export default {
|
|||||||
this.message = '';
|
this.message = '';
|
||||||
if (this.sendWithSignature && !this.isPrivate) {
|
if (this.sendWithSignature && !this.isPrivate) {
|
||||||
// if signature is enabled, append it to the message
|
// if signature is enabled, append it to the message
|
||||||
|
const effectiveChannelType = getEffectiveChannelType(
|
||||||
|
this.channelType,
|
||||||
|
this.inbox?.medium || ''
|
||||||
|
);
|
||||||
this.message = appendSignature(
|
this.message = appendSignature(
|
||||||
this.message,
|
this.message,
|
||||||
this.messageSignature,
|
this.messageSignature,
|
||||||
this.channelType
|
effectiveChannelType
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.attachedFiles = [];
|
this.attachedFiles = [];
|
||||||
@@ -1121,6 +1134,7 @@ export default {
|
|||||||
:signature="messageSignature"
|
:signature="messageSignature"
|
||||||
allow-signature
|
allow-signature
|
||||||
:channel-type="channelType"
|
:channel-type="channelType"
|
||||||
|
:medium="inbox.medium"
|
||||||
@typing-off="onTypingOff"
|
@typing-off="onTypingOff"
|
||||||
@typing-on="onTypingOn"
|
@typing-on="onTypingOn"
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
|
|||||||
@@ -210,7 +210,12 @@ export const MARKDOWN_PATTERNS = [
|
|||||||
type: 'em', // PM: em, eg: *italic* or _italic_
|
type: 'em', // PM: em, eg: *italic* or _italic_
|
||||||
patterns: [
|
patterns: [
|
||||||
{ pattern: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, replacement: '$1' },
|
{ pattern: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, replacement: '$1' },
|
||||||
{ pattern: /(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, replacement: '$1' },
|
// Match _text_ only at word boundaries (whitespace/string start/end)
|
||||||
|
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
|
||||||
|
{
|
||||||
|
pattern: /(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
|
||||||
|
replacement: '$1',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -227,11 +232,6 @@ export const MARKDOWN_PATTERNS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CHANNEL_WITH_RICH_SIGNATURE = [
|
|
||||||
'Channel::Email',
|
|
||||||
'Channel::WebWidget',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Editor image resize options for Message Editor
|
// Editor image resize options for Message Editor
|
||||||
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
|
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,11 +5,8 @@ import {
|
|||||||
} from '@chatwoot/prosemirror-schema';
|
} from '@chatwoot/prosemirror-schema';
|
||||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||||
import * as Sentry from '@sentry/vue';
|
import * as Sentry from '@sentry/vue';
|
||||||
import {
|
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||||
FORMATTING,
|
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||||
MARKDOWN_PATTERNS,
|
|
||||||
CHANNEL_WITH_RICH_SIGNATURE,
|
|
||||||
} from 'dashboard/constants/editor';
|
|
||||||
import camelcaseKeys from 'camelcase-keys';
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,6 +32,56 @@ export function extractTextFromMarkdown(markdown) {
|
|||||||
.trim(); // Trim any extra space
|
.trim(); // Trim any extra space
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip unsupported markdown formatting based on channel capabilities.
|
||||||
|
*
|
||||||
|
* @param {string} markdown - markdown text to process
|
||||||
|
* @param {string} channelType - The channel type to check supported formatting
|
||||||
|
* @returns {string} - The markdown with unsupported formatting removed
|
||||||
|
*/
|
||||||
|
export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
|
||||||
|
if (!markdown) return '';
|
||||||
|
|
||||||
|
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
|
||||||
|
const has = (arr, key) => arr.includes(key);
|
||||||
|
|
||||||
|
// Define stripping rules: [condition, pattern, replacement]
|
||||||
|
const rules = [
|
||||||
|
[!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''],
|
||||||
|
[!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'],
|
||||||
|
[!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''],
|
||||||
|
[!has(marks, 'code'), /`([^`]+)`/g, '$1'],
|
||||||
|
[!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'],
|
||||||
|
[!has(marks, 'strong'), /__([^_]+)__/g, '$1'],
|
||||||
|
[!has(marks, 'em'), /\*([^*]+)\*/g, '$1'],
|
||||||
|
// Match _text_ only at word boundaries (whitespace/string start/end)
|
||||||
|
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
|
||||||
|
[
|
||||||
|
!has(marks, 'em'),
|
||||||
|
/(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
|
||||||
|
'$1',
|
||||||
|
],
|
||||||
|
[!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'],
|
||||||
|
[!has(nodes, 'blockquote'), /^>\s?/gm, ''],
|
||||||
|
[!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''],
|
||||||
|
[!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''],
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = rules.reduce(
|
||||||
|
(text, [shouldStrip, pattern, replacement]) =>
|
||||||
|
shouldStrip ? text.replace(pattern, replacement) : text,
|
||||||
|
markdown
|
||||||
|
);
|
||||||
|
|
||||||
|
return result
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
.replace(/\n{2,}/g, '\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The delimiter used to separate the signature from the rest of the body.
|
* The delimiter used to separate the signature from the rest of the body.
|
||||||
* @type {string}
|
* @type {string}
|
||||||
@@ -97,29 +144,36 @@ export function findSignatureInBody(body, signature) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the channel supports image signatures.
|
* Gets the effective channel type for formatting purposes.
|
||||||
|
* For Twilio channels, returns WhatsApp or Twilio based on medium.
|
||||||
*
|
*
|
||||||
* @param {string} channelType - The channel type.
|
* @param {string} channelType - The channel type
|
||||||
* @returns {boolean} - True if the channel supports image signatures.
|
* @param {string} medium - Optional. The medium for Twilio channels (sms/whatsapp)
|
||||||
|
* @returns {string} - The effective channel type for formatting
|
||||||
*/
|
*/
|
||||||
export function supportsImageSignature(channelType) {
|
export function getEffectiveChannelType(channelType, medium) {
|
||||||
return CHANNEL_WITH_RICH_SIGNATURE.includes(channelType);
|
if (channelType === INBOX_TYPES.TWILIO) {
|
||||||
|
return medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||||
|
? INBOX_TYPES.WHATSAPP
|
||||||
|
: INBOX_TYPES.TWILIO;
|
||||||
|
}
|
||||||
|
return channelType;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Appends the signature to the body, separated by the signature delimiter.
|
* Appends the signature to the body, separated by the signature delimiter.
|
||||||
* Automatically strips images for channels that don't support image signatures.
|
* Automatically strips unsupported formatting based on channel capabilities.
|
||||||
*
|
*
|
||||||
* @param {string} body - The body to append the signature to.
|
* @param {string} body - The body to append the signature to.
|
||||||
* @param {string} signature - The signature to append.
|
* @param {string} signature - The signature to append.
|
||||||
* @param {string} channelType - Optional. The channel type to determine if images should be stripped.
|
* @param {string} channelType - Optional. The effective channel type to determine supported formatting.
|
||||||
|
* For Twilio channels, pass the result of getEffectiveChannelType().
|
||||||
* @returns {string} - The body with the signature appended.
|
* @returns {string} - The body with the signature appended.
|
||||||
*/
|
*/
|
||||||
export function appendSignature(body, signature, channelType) {
|
export function appendSignature(body, signature, channelType) {
|
||||||
// For channels that don't support images, strip markdown formatting
|
// Strip only unsupported formatting based on channel capabilities
|
||||||
const shouldStripImages = channelType && !supportsImageSignature(channelType);
|
const preparedSignature = channelType
|
||||||
const preparedSignature = shouldStripImages
|
? stripUnsupportedSignatureMarkdown(signature, channelType)
|
||||||
? extractTextFromMarkdown(signature)
|
|
||||||
: signature;
|
: signature;
|
||||||
const cleanedSignature = cleanSignature(preparedSignature);
|
const cleanedSignature = cleanSignature(preparedSignature);
|
||||||
// if signature is already present, return body
|
// if signature is already present, return body
|
||||||
@@ -132,21 +186,28 @@ export function appendSignature(body, signature, channelType) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the signature from the body, along with the signature delimiter.
|
* 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).
|
* Tries to find both the original signature and the stripped version.
|
||||||
*
|
*
|
||||||
* @param {string} body - The body to remove the signature from.
|
* @param {string} body - The body to remove the signature from.
|
||||||
* @param {string} signature - The signature to remove.
|
* @param {string} signature - The signature to remove.
|
||||||
|
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
|
||||||
|
* For Twilio channels, pass the result of getEffectiveChannelType().
|
||||||
* @returns {string} - The body with the signature removed.
|
* @returns {string} - The body with the signature removed.
|
||||||
*/
|
*/
|
||||||
export function removeSignature(body, signature) {
|
export function removeSignature(body, signature, channelType) {
|
||||||
// Build list of signatures to try: original first, then stripped version
|
// Build list of signatures to try: original, channel-stripped, and fully stripped
|
||||||
// Always try both to handle cases where channelType is unknown or inbox is being removed
|
|
||||||
const cleanedSignature = cleanSignature(signature);
|
const cleanedSignature = cleanSignature(signature);
|
||||||
const strippedSignature = cleanSignature(extractTextFromMarkdown(signature));
|
const channelStripped = channelType
|
||||||
const signaturesToTry =
|
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
|
||||||
cleanedSignature === strippedSignature
|
: null;
|
||||||
? [cleanedSignature]
|
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
|
||||||
: [cleanedSignature, strippedSignature];
|
|
||||||
|
// Try signatures in order: original → channel-specific → fully stripped
|
||||||
|
const signaturesToTry = [
|
||||||
|
cleanedSignature,
|
||||||
|
channelStripped,
|
||||||
|
fullyStripped,
|
||||||
|
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
|
||||||
|
|
||||||
// Find the first matching signature
|
// Find the first matching signature
|
||||||
const signatureIndex = signaturesToTry.reduce(
|
const signatureIndex = signaturesToTry.reduce(
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export const INBOX_TYPES = {
|
|||||||
VOICE: 'Channel::Voice',
|
VOICE: 'Channel::Voice',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const TWILIO_CHANNEL_MEDIUM = {
|
||||||
|
WHATSAPP: 'whatsapp',
|
||||||
|
SMS: 'sms',
|
||||||
|
};
|
||||||
|
|
||||||
const INBOX_ICON_MAP_FILL = {
|
const INBOX_ICON_MAP_FILL = {
|
||||||
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
|
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
|
||||||
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
|
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
replaceSignature,
|
replaceSignature,
|
||||||
cleanSignature,
|
cleanSignature,
|
||||||
extractTextFromMarkdown,
|
extractTextFromMarkdown,
|
||||||
supportsImageSignature,
|
stripUnsupportedSignatureMarkdown,
|
||||||
insertAtCursor,
|
insertAtCursor,
|
||||||
findNodeToInsertImage,
|
findNodeToInsertImage,
|
||||||
setURLWithQueryAndSize,
|
setURLWithQueryAndSize,
|
||||||
@@ -145,10 +145,63 @@ describe('appendSignature', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('stripUnsupportedSignatureMarkdown', () => {
|
||||||
|
const richSignature =
|
||||||
|
'**Bold** _italic_ [link](http://example.com) ';
|
||||||
|
|
||||||
|
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
|
||||||
|
const result = stripUnsupportedSignatureMarkdown(
|
||||||
|
richSignature,
|
||||||
|
'Channel::Email'
|
||||||
|
);
|
||||||
|
expect(result).toContain('**Bold**');
|
||||||
|
expect(result).toContain('_italic_');
|
||||||
|
expect(result).toContain('[link](http://example.com)');
|
||||||
|
expect(result).toContain('');
|
||||||
|
});
|
||||||
|
it('strips images but keeps bold/italic for Api channel', () => {
|
||||||
|
const result = stripUnsupportedSignatureMarkdown(
|
||||||
|
richSignature,
|
||||||
|
'Channel::Api'
|
||||||
|
);
|
||||||
|
expect(result).toContain('**Bold**');
|
||||||
|
expect(result).toContain('_italic_');
|
||||||
|
expect(result).toContain('link'); // link text kept
|
||||||
|
expect(result).not.toContain('[link]('); // link syntax removed
|
||||||
|
expect(result).not.toContain('; // image removed
|
||||||
|
});
|
||||||
|
it('strips images but keeps bold/italic/link for Telegram channel', () => {
|
||||||
|
const result = stripUnsupportedSignatureMarkdown(
|
||||||
|
richSignature,
|
||||||
|
'Channel::Telegram'
|
||||||
|
);
|
||||||
|
expect(result).toContain('**Bold**');
|
||||||
|
expect(result).toContain('_italic_');
|
||||||
|
expect(result).toContain('[link](http://example.com)');
|
||||||
|
expect(result).not.toContain(';
|
||||||
|
});
|
||||||
|
it('strips all formatting for SMS channel', () => {
|
||||||
|
const result = stripUnsupportedSignatureMarkdown(
|
||||||
|
richSignature,
|
||||||
|
'Channel::Sms'
|
||||||
|
);
|
||||||
|
expect(result).toContain('Bold');
|
||||||
|
expect(result).toContain('italic');
|
||||||
|
expect(result).toContain('link');
|
||||||
|
expect(result).not.toContain('**');
|
||||||
|
expect(result).not.toContain('_');
|
||||||
|
expect(result).not.toContain('[');
|
||||||
|
expect(result).not.toContain(';
|
||||||
|
});
|
||||||
|
it('returns empty string for empty input', () => {
|
||||||
|
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe('');
|
||||||
|
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('appendSignature with channelType', () => {
|
describe('appendSignature with channelType', () => {
|
||||||
const signatureWithImage =
|
const signatureWithImage =
|
||||||
'Thanks\n';
|
'Thanks\n';
|
||||||
const strippedSignature = 'Thanks';
|
|
||||||
|
|
||||||
it('keeps images for Email channel', () => {
|
it('keeps images for Email channel', () => {
|
||||||
const result = appendSignature(
|
const result = appendSignature(
|
||||||
@@ -166,24 +219,31 @@ describe('appendSignature with channelType', () => {
|
|||||||
);
|
);
|
||||||
expect(result).toContain(';
|
expect(result).toContain(';
|
||||||
});
|
});
|
||||||
it('strips images for Api channel', () => {
|
it('strips images but keeps text for Api channel', () => {
|
||||||
const result = appendSignature('Hello', signatureWithImage, 'Channel::Api');
|
const result = appendSignature('Hello', signatureWithImage, 'Channel::Api');
|
||||||
expect(result).not.toContain(';
|
expect(result).not.toContain(';
|
||||||
expect(result).toContain(strippedSignature);
|
expect(result).toContain('Thanks');
|
||||||
});
|
});
|
||||||
it('strips images for WhatsApp channel', () => {
|
it('strips images but keeps text for WhatsApp channel', () => {
|
||||||
const result = appendSignature(
|
const result = appendSignature(
|
||||||
'Hello',
|
'Hello',
|
||||||
signatureWithImage,
|
signatureWithImage,
|
||||||
'Channel::Whatsapp'
|
'Channel::Whatsapp'
|
||||||
);
|
);
|
||||||
expect(result).not.toContain(';
|
expect(result).not.toContain(';
|
||||||
expect(result).toContain(strippedSignature);
|
expect(result).toContain('Thanks');
|
||||||
});
|
});
|
||||||
it('keeps images when channelType is not provided', () => {
|
it('keeps images when channelType is not provided', () => {
|
||||||
const result = appendSignature('Hello', signatureWithImage);
|
const result = appendSignature('Hello', signatureWithImage);
|
||||||
expect(result).toContain(';
|
expect(result).toContain(';
|
||||||
});
|
});
|
||||||
|
it('keeps bold/italic for channels that support them', () => {
|
||||||
|
const boldSignature = '**Bold** *italic* Thanks';
|
||||||
|
const result = appendSignature('Hello', boldSignature, 'Channel::Api');
|
||||||
|
// Api supports strong and em
|
||||||
|
expect(result).toContain('**Bold**');
|
||||||
|
expect(result).toContain('*italic*');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('cleanSignature', () => {
|
describe('cleanSignature', () => {
|
||||||
@@ -331,24 +391,6 @@ 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', () => {
|
describe('insertAtCursor', () => {
|
||||||
it('should return undefined if editorView is not provided', () => {
|
it('should return undefined if editorView is not provided', () => {
|
||||||
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
|
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
|
||||||
@@ -884,6 +926,26 @@ describe('stripUnsupportedFormatting', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves underscores in URLs and mid-word positions', () => {
|
||||||
|
// Underscores in URLs should not be stripped as italic formatting
|
||||||
|
expect(
|
||||||
|
stripUnsupportedFormatting(
|
||||||
|
'https://www.chatwoot.com/new_first_second-third/ssd',
|
||||||
|
emptySchema
|
||||||
|
)
|
||||||
|
).toBe('https://www.chatwoot.com/new_first_second-third/ssd');
|
||||||
|
|
||||||
|
// Underscores in variable names should not be stripped
|
||||||
|
expect(
|
||||||
|
stripUnsupportedFormatting('some_variable_name', emptySchema)
|
||||||
|
).toBe('some_variable_name');
|
||||||
|
|
||||||
|
// But actual italic formatting with spaces should still be stripped
|
||||||
|
expect(
|
||||||
|
stripUnsupportedFormatting('hello _world_ there', emptySchema)
|
||||||
|
).toBe('hello world there');
|
||||||
|
});
|
||||||
|
|
||||||
it('strips inline code formatting', () => {
|
it('strips inline code formatting', () => {
|
||||||
expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe(
|
expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe(
|
||||||
'inline code'
|
'inline code'
|
||||||
|
|||||||
Reference in New Issue
Block a user