diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index ff1c616de..90b7a0c31 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -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" diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 52ca95a07..77267707b 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -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 || ''" /> -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; -}; diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 318727acc..7a2a489ac 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -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); } diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 0e698e0c6..6f955ffd5 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -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" diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index eaec36371..087caa99f 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -210,7 +210,12 @@ export const MARKDOWN_PATTERNS = [ type: 'em', // PM: em, eg: *italic* or _italic_ patterns: [ { pattern: /(? 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( diff --git a/app/javascript/dashboard/helper/inbox.js b/app/javascript/dashboard/helper/inbox.js index 501375ab7..05823e8a7 100644 --- a/app/javascript/dashboard/helper/inbox.js +++ b/app/javascript/dashboard/helper/inbox.js @@ -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', diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index 07dfbf1e5..1ae765ca7 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -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) ![](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', () => { const signatureWithImage = 'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)'; - const strippedSignature = 'Thanks'; it('keeps images for Email channel', () => { const result = appendSignature( @@ -166,24 +219,31 @@ describe('appendSignature with channelType', () => { ); 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'); 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('![](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', () => { @@ -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'