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:
Sivin Varghese
2025-12-11 19:58:59 +05:30
committed by GitHub
parent 2bd8e76886
commit df4c8cf58b
9 changed files with 270 additions and 167 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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>

View File

@@ -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);
} }

View File

@@ -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"

View File

@@ -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 = [
{ {

View File

@@ -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(

View File

@@ -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',

View File

@@ -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) ![](http://localhost:3000/image.png)';
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('![](http://localhost:3000/image.png)');
});
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![](http://localhost:3000/image.png?cw_image_height=24px)'; 'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)';
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('![](http://localhost:3000/image.png'); expect(result).toContain('![](http://localhost:3000/image.png');
}); });
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('![](http://localhost:3000/image.png'); expect(result).toContain('![](http://localhost:3000/image.png');
}); });
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'