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 },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
@@ -106,6 +107,7 @@ watch(
|
||||
:allow-signature="allowSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
buildContactableInboxesList,
|
||||
@@ -86,6 +87,12 @@ const whatsappMessageTemplates = computed(() =>
|
||||
|
||||
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||
|
||||
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||
);
|
||||
|
||||
const validationRules = computed(() => ({
|
||||
selectedContact: { required },
|
||||
targetInbox: { required },
|
||||
@@ -202,7 +209,11 @@ const removeSignatureFromMessage = () => {
|
||||
// Always remove the signature from message content when inbox/contact is removed
|
||||
// to ensure no leftover signature content remains
|
||||
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 = () => {
|
||||
removeSignatureFromMessage();
|
||||
emit('clearSelectedContact');
|
||||
state.attachedFiles = [];
|
||||
removeSignatureFromMessage();
|
||||
};
|
||||
|
||||
const onClickInsertEmoji = emoji => {
|
||||
@@ -227,12 +238,16 @@ const handleAddSignature = signature => {
|
||||
state.message = appendSignature(
|
||||
state.message,
|
||||
signature,
|
||||
inboxChannelType.value
|
||||
effectiveChannelType.value
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveSignature = signature => {
|
||||
state.message = removeSignature(state.message, signature);
|
||||
state.message = removeSignature(
|
||||
state.message,
|
||||
signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
};
|
||||
|
||||
const handleAttachFile = files => {
|
||||
@@ -356,10 +371,10 @@ const shouldShowMessageEditor = computed(() => {
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:has-attachments="state.attachedFiles.length > 0"
|
||||
:channel-type="inboxChannelType"
|
||||
:medium="targetInbox?.medium || ''"
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
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({
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, required: true },
|
||||
defineProps({
|
||||
hasErrors: { type: Boolean, default: false },
|
||||
hasAttachments: { type: Boolean, default: false },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
messageSignature: { type: String, default: '' },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -25,98 +18,28 @@ const modelValue = defineModel({
|
||||
type: String,
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
||||
<template v-if="isEmailOrWebWidgetInbox">
|
||||
<Editor
|
||||
v-model="modelValue"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
/>
|
||||
</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>
|
||||
<Editor
|
||||
v-model="modelValue"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
getFormattingForEditor,
|
||||
getSelectionCoords,
|
||||
calculateMenuPosition,
|
||||
getEffectiveChannelType,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
@@ -81,6 +82,7 @@ const props = defineProps({
|
||||
// are triggered except when this flag is true
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||
focusOnMount: { type: Boolean, default: true },
|
||||
});
|
||||
@@ -105,10 +107,16 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(props.channelType, props.medium)
|
||||
);
|
||||
|
||||
const editorSchema = computed(() => {
|
||||
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);
|
||||
return buildMessageSchema(formatting.marks, formatting.nodes);
|
||||
});
|
||||
@@ -116,7 +124,7 @@ const editorSchema = computed(() => {
|
||||
const editorMenuOptions = computed(() => {
|
||||
const formatType = props.isPrivate
|
||||
? DEFAULT_FORMATTING
|
||||
: props.channelType || DEFAULT_FORMATTING;
|
||||
: effectiveChannelType.value || DEFAULT_FORMATTING;
|
||||
const formatting = getFormattingForEditor(formatType);
|
||||
return formatting.menu;
|
||||
});
|
||||
@@ -301,8 +309,13 @@ function isBodyEmpty(content) {
|
||||
|
||||
// if the signature is present, we need to remove it before checking
|
||||
// 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
|
||||
? removeSignatureHelper(content, props.signature)
|
||||
? removeSignatureHelper(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
)
|
||||
: content;
|
||||
|
||||
// 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
|
||||
// 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, props.channelType);
|
||||
content = appendSignature(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
// need to reload first, ensuring that the editorView is updated
|
||||
reloadState(content);
|
||||
|
||||
@@ -382,7 +399,11 @@ function addSignature() {
|
||||
function removeSignature() {
|
||||
if (!props.signature) return;
|
||||
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
|
||||
reloadState(content);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
@@ -564,9 +565,13 @@ export default {
|
||||
return message;
|
||||
}
|
||||
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
return this.sendWithSignature
|
||||
? appendSignature(message, this.messageSignature, this.channelType)
|
||||
: removeSignature(message, this.messageSignature);
|
||||
? appendSignature(message, this.messageSignature, effectiveChannelType)
|
||||
: removeSignature(message, this.messageSignature, effectiveChannelType);
|
||||
},
|
||||
removeFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
@@ -757,10 +762,14 @@ 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
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
message = appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
this.channelType
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
|
||||
@@ -800,10 +809,14 @@ export default {
|
||||
this.message = '';
|
||||
if (this.sendWithSignature && !this.isPrivate) {
|
||||
// if signature is enabled, append it to the message
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
this.message = appendSignature(
|
||||
this.message,
|
||||
this.messageSignature,
|
||||
this.channelType
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
this.attachedFiles = [];
|
||||
@@ -1121,6 +1134,7 @@ export default {
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:channel-type="channelType"
|
||||
:medium="inbox.medium"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
|
||||
@@ -210,7 +210,12 @@ export const MARKDOWN_PATTERNS = [
|
||||
type: 'em', // PM: em, eg: *italic* or _italic_
|
||||
patterns: [
|
||||
{ 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
|
||||
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
|
||||
{
|
||||
|
||||
@@ -5,11 +5,8 @@ import {
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
import {
|
||||
FORMATTING,
|
||||
MARKDOWN_PATTERNS,
|
||||
CHANNEL_WITH_RICH_SIGNATURE,
|
||||
} from 'dashboard/constants/editor';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
/**
|
||||
@@ -35,6 +32,56 @@ export function extractTextFromMarkdown(markdown) {
|
||||
.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.
|
||||
* @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.
|
||||
* @returns {boolean} - True if the channel supports image signatures.
|
||||
* @param {string} channelType - The channel type
|
||||
* @param {string} medium - Optional. The medium for Twilio channels (sms/whatsapp)
|
||||
* @returns {string} - The effective channel type for formatting
|
||||
*/
|
||||
export function supportsImageSignature(channelType) {
|
||||
return CHANNEL_WITH_RICH_SIGNATURE.includes(channelType);
|
||||
export function getEffectiveChannelType(channelType, medium) {
|
||||
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.
|
||||
* 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} 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.
|
||||
*/
|
||||
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)
|
||||
// Strip only unsupported formatting based on channel capabilities
|
||||
const preparedSignature = channelType
|
||||
? stripUnsupportedSignatureMarkdown(signature, channelType)
|
||||
: signature;
|
||||
const cleanedSignature = cleanSignature(preparedSignature);
|
||||
// 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.
|
||||
* 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} 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.
|
||||
*/
|
||||
export function removeSignature(body, signature) {
|
||||
// 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
|
||||
export function removeSignature(body, signature, channelType) {
|
||||
// Build list of signatures to try: original, channel-stripped, and fully stripped
|
||||
const cleanedSignature = cleanSignature(signature);
|
||||
const strippedSignature = cleanSignature(extractTextFromMarkdown(signature));
|
||||
const signaturesToTry =
|
||||
cleanedSignature === strippedSignature
|
||||
? [cleanedSignature]
|
||||
: [cleanedSignature, strippedSignature];
|
||||
const channelStripped = channelType
|
||||
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
|
||||
: null;
|
||||
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
|
||||
|
||||
// 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
|
||||
const signatureIndex = signaturesToTry.reduce(
|
||||
|
||||
@@ -13,6 +13,11 @@ export const INBOX_TYPES = {
|
||||
VOICE: 'Channel::Voice',
|
||||
};
|
||||
|
||||
export const TWILIO_CHANNEL_MEDIUM = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
SMS: 'sms',
|
||||
};
|
||||
|
||||
const INBOX_ICON_MAP_FILL = {
|
||||
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
|
||||
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
replaceSignature,
|
||||
cleanSignature,
|
||||
extractTextFromMarkdown,
|
||||
supportsImageSignature,
|
||||
stripUnsupportedSignatureMarkdown,
|
||||
insertAtCursor,
|
||||
findNodeToInsertImage,
|
||||
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', () => {
|
||||
const signatureWithImage =
|
||||
'Thanks\n';
|
||||
const strippedSignature = 'Thanks';
|
||||
|
||||
it('keeps images for Email channel', () => {
|
||||
const result = appendSignature(
|
||||
@@ -166,24 +219,31 @@ describe('appendSignature with channelType', () => {
|
||||
);
|
||||
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');
|
||||
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(
|
||||
'Hello',
|
||||
signatureWithImage,
|
||||
'Channel::Whatsapp'
|
||||
);
|
||||
expect(result).not.toContain(';
|
||||
expect(result).toContain(strippedSignature);
|
||||
expect(result).toContain('Thanks');
|
||||
});
|
||||
it('keeps images when channelType is not provided', () => {
|
||||
const result = appendSignature('Hello', signatureWithImage);
|
||||
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', () => {
|
||||
@@ -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', () => {
|
||||
it('should return undefined if editorView is not provided', () => {
|
||||
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', () => {
|
||||
expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe(
|
||||
'inline code'
|
||||
|
||||
Reference in New Issue
Block a user