feat: Standardize rich editor across all channels (#12600)

# Pull Request Template

## Description

This PR includes,

1. **Channel-specific formatting and menu options** for the rich reply
editor.
2. **Removal of the plain reply editor** and full **standardization** on
the rich reply editor across all channels.
3. **Fix for multiple canned responses insertion:**
* **Before:** The plain editor only allowed inserting canned responses
at the beginning of a message, making it impossible to combine multiple
canned responses in a single reply. This caused inconsistent behavior
across the app.
* **Solution:** Replaced the plain reply editor with the rich
(ProseMirror) editor to ensure a unified experience. Agents can now
insert multiple canned responses at any cursor position.
4. **Floating editor menu** for the reply box to improve accessibility
and overall user experience.
5. **New Strikethrough formatting option** added to the editor menu.

---

**Editor repo PR**:
https://github.com/chatwoot/prosemirror-schema/pull/36

Fixes https://github.com/chatwoot/chatwoot/issues/12517,
[CW-5924](https://linear.app/chatwoot/issue/CW-5924/standardize-the-editor),
[CW-5679](https://linear.app/chatwoot/issue/CW-5679/allow-inserting-multiple-canned-responses-in-a-single-message)

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Screenshot
**Dark**
<img width="850" height="345" alt="image"
src="https://github.com/user-attachments/assets/47748e6c-380f-44a3-9e3b-c27e0c830bd0"
/>

**Light**
<img width="850" height="345" alt="image"
src="https://github.com/user-attachments/assets/6746cf32-bf63-4280-a5bd-bbd42c3cbe84"
/>


## 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>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
This commit is contained in:
Sivin Varghese
2025-12-08 14:43:45 +05:30
committed by GitHub
parent eb759255d8
commit 399c91adaa
33 changed files with 1351 additions and 334 deletions

View File

@@ -215,7 +215,7 @@ group :production do
end end
group :development do group :development do
gem 'annotate' gem 'annotaterb'
gem 'bullet' gem 'bullet'
gem 'letter_opener' gem 'letter_opener'
gem 'scss_lint', require: false gem 'scss_lint', require: false

View File

@@ -128,9 +128,9 @@ GEM
selectize-rails (~> 0.6) selectize-rails (~> 0.6)
ai-agents (0.7.0) ai-agents (0.7.0)
ruby_llm (~> 1.8.2) ruby_llm (~> 1.8.2)
annotate (3.2.0) annotaterb (4.20.0)
activerecord (>= 3.2, < 8.0) activerecord (>= 6.0.0)
rake (>= 10.4, < 14.0) activesupport (>= 6.0.0)
ast (2.4.3) ast (2.4.3)
attr_extras (7.1.0) attr_extras (7.1.0)
audited (5.4.1) audited (5.4.1)
@@ -1018,7 +1018,7 @@ DEPENDENCIES
administrate-field-active_storage (>= 1.0.3) administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0) administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.7.0) ai-agents (>= 0.7.0)
annotate annotaterb
attr_extras attr_extras
audited (~> 5.4, >= 5.4.1) audited (~> 5.4, >= 5.4.1)
aws-actionmailbox-ses (~> 0) aws-actionmailbox-ses (~> 0)

View File

@@ -19,7 +19,6 @@ const props = defineProps({
}, },
enableVariables: { type: Boolean, default: false }, enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true }, enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false }, enableCaptainTools: { type: Boolean, default: false },
signature: { type: String, default: '' }, signature: { type: String, default: '' },
allowSignature: { type: Boolean, default: false }, allowSignature: { type: Boolean, default: false },
@@ -102,7 +101,6 @@ watch(
:disabled="disabled" :disabled="disabled"
:enable-variables="enableVariables" :enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses" :enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools" :enable-captain-tools="enableCaptainTools"
:signature="signature" :signature="signature"
:allow-signature="allowSignature" :allow-signature="allowSignature"
@@ -139,19 +137,6 @@ watch(
.editor-wrapper { .editor-wrapper {
::v-deep { ::v-deep {
.ProseMirror-menubar-wrapper { .ProseMirror-menubar-wrapper {
@apply gap-2 !important;
.ProseMirror-menubar {
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
.ProseMirror-menuitem {
@apply h-5 !important;
}
.ProseMirror-icon {
@apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
}
}
.ProseMirror.ProseMirror-woot-style { .ProseMirror.ProseMirror-woot-style {
p { p {
@apply first:mt-0 !important; @apply first:mt-0 !important;

View File

@@ -172,7 +172,7 @@ const previewArticle = () => {
@apply mr-0; @apply mr-0;
.ProseMirror-icon { .ProseMirror-icon {
@apply p-0 mt-1 !mr-0; @apply p-0 mt-0 !mr-0;
svg { svg {
width: 20px !important; width: 20px !important;

View File

@@ -26,13 +26,11 @@ import { useAlert } from 'dashboard/composables';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
MESSAGE_EDITOR_MENU_OPTIONS,
MESSAGE_EDITOR_IMAGE_RESIZES,
} from 'dashboard/constants/editor';
import { import {
messageSchema, messageSchema,
buildMessageSchema,
buildEditor, buildEditor,
EditorView, EditorView,
MessageMarkdownTransformer, MessageMarkdownTransformer,
@@ -53,6 +51,9 @@ import {
removeSignature as removeSignatureHelper, removeSignature as removeSignatureHelper,
scrollCursorIntoView, scrollCursorIntoView,
setURLWithQueryAndSize, setURLWithQueryAndSize,
getFormattingForEditor,
getSelectionCoords,
calculateMenuPosition,
} from 'dashboard/helper/editorHelper'; } from 'dashboard/helper/editorHelper';
import { import {
hasPressedEnterAndNotCmdOrShift, hasPressedEnterAndNotCmdOrShift,
@@ -75,7 +76,6 @@ const props = defineProps({
enableCannedResponses: { type: Boolean, default: true }, enableCannedResponses: { type: Boolean, default: true },
enableCaptainTools: { type: Boolean, default: false }, enableCaptainTools: { type: Boolean, default: false },
variables: { type: Object, default: () => ({}) }, variables: { type: Object, default: () => ({}) },
enabledMenuOptions: { type: Array, default: () => [] },
signature: { type: String, default: '' }, signature: { type: String, default: '' },
// allowSignature is a kill switch, ensuring no signature methods // allowSignature is a kill switch, ensuring no signature methods
// are triggered except when this flag is true // are triggered except when this flag is true
@@ -103,22 +103,34 @@ const { t } = useI18n();
const TYPING_INDICATOR_IDLE_TIME = 4000; 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 createState = ( const editorSchema = computed(() => {
content, if (!props.channelType) return messageSchema;
placeholder,
plugins = [], const formatType = props.isPrivate ? DEFAULT_FORMATTING : props.channelType;
methods = {}, const formatting = getFormattingForEditor(formatType);
enabledMenuOptions = [] return buildMessageSchema(formatting.marks, formatting.nodes);
) => { });
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
? DEFAULT_FORMATTING
: props.channelType || DEFAULT_FORMATTING;
const formatting = getFormattingForEditor(formatType);
return formatting.menu;
});
const createState = (content, placeholder, plugins = [], methods = {}) => {
const schema = editorSchema.value;
return EditorState.create({ return EditorState.create({
doc: new MessageMarkdownTransformer(messageSchema).parse(content), doc: new MessageMarkdownTransformer(schema).parse(content),
plugins: buildEditor({ plugins: buildEditor({
schema: messageSchema, schema,
placeholder, placeholder,
methods, methods,
plugins, plugins,
enabledMenuOptions, enabledMenuOptions: editorMenuOptions.value,
}), }),
}); });
}; };
@@ -153,6 +165,8 @@ const range = ref(null);
const isImageNodeSelected = ref(false); const isImageNodeSelected = ref(false);
const toolbarPosition = ref({ top: 0, left: 0 }); const toolbarPosition = ref({ top: 0, left: 0 });
const selectedImageNode = ref(null); const selectedImageNode = ref(null);
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
const showSelectionMenu = ref(false);
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES; const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
// element ref // element ref
@@ -174,12 +188,6 @@ const shouldShowCannedResponses = computed(() => {
); );
}); });
const editorMenuOptions = computed(() => {
return props.enabledMenuOptions.length
? props.enabledMenuOptions
: MESSAGE_EDITOR_MENU_OPTIONS;
});
function createSuggestionPlugin({ function createSuggestionPlugin({
trigger, trigger,
minChars = 0, minChars = 0,
@@ -400,6 +408,38 @@ function setToolbarPosition() {
}; };
} }
function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value;
if (!selection || !wrapper) return;
const rect = wrapper.getBoundingClientRect();
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
// Calculate coords and final position
const coords = getSelectionCoords(editorView, selection, rect);
const { left, top, width } = calculateMenuPosition(coords, rect, isRtl);
wrapper.style.setProperty('--selection-left', `${left}px`);
wrapper.style.setProperty(
'--selection-right',
`${rect.width - left - width}px`
);
wrapper.style.setProperty('--selection-top', `${top}px`);
}
function checkSelection(editorState) {
showSelectionMenu.value = false;
const hasSelection = editorState.selection.from !== editorState.selection.to;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
const wrapper = editorRoot.value;
if (!wrapper) return;
wrapper.classList.toggle('has-selection', hasSelection);
if (hasSelection) setMenubarPosition(editorState);
}
function setURLWithQueryAndImageSize(size) { function setURLWithQueryAndImageSize(size) {
if (!props.showImageResizeToolbar) { if (!props.showImageResizeToolbar) {
return; return;
@@ -529,7 +569,9 @@ async function insertNodeIntoEditor(node, from = 0, to = 0) {
function insertContentIntoEditor(content, defaultFrom = 0) { function insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || editorView.state.selection.from || 0; const from = defaultFrom || editorView.state.selection.from || 0;
let node = new MessageMarkdownTransformer(messageSchema).parse(content); // Use the editor's current schema to ensure compatibility with buildMessageSchema
const currentSchema = editorView.state.schema;
let node = new MessageMarkdownTransformer(currentSchema).parse(content);
insertNodeIntoEditor(node, from, undefined); insertNodeIntoEditor(node, from, undefined);
} }
@@ -596,6 +638,7 @@ function createEditorView() {
if (tx.docChanged) { if (tx.docChanged) {
emitOnChange(); emitOnChange();
} }
checkSelection(state);
}, },
handleDOMEvents: { handleDOMEvents: {
keyup: () => { keyup: () => {
@@ -761,15 +804,33 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@import '@chatwoot/prosemirror-schema/src/styles/base.scss'; @import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.ProseMirror-menubar-wrapper { .ProseMirror-menubar-wrapper {
@apply flex flex-col; @apply flex flex-col gap-3;
.ProseMirror-menubar { .ProseMirror-menubar {
min-height: 1.25rem !important; min-height: 1.25rem !important;
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11; @apply items-center gap-4 flex pb-0 bg-transparent text-n-slate-11 relative ltr:-left-[3px] rtl:-right-[3px];
.ProseMirror-menu-active { .ProseMirror-menu-active {
@apply bg-n-slate-5 dark:bg-n-solid-3; @apply bg-n-slate-5 dark:bg-n-solid-3 !important;
} }
.ProseMirror-menuitem {
@apply mr-0 size-4 flex items-center justify-center;
.ProseMirror-icon {
@apply size-4 flex items-center justify-center flex-shrink-0;
svg {
@apply size-full;
}
}
}
}
.ProseMirror-menubar:not(:has(*)) {
max-height: none !important;
min-height: 0 !important;
padding: 0 !important;
} }
> .ProseMirror { > .ProseMirror {
@@ -860,4 +921,53 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.editor-warning__message { .editor-warning__message {
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0; @apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
} }
// Float editor menu
.popover-prosemirror-menu {
position: relative;
.ProseMirror p:last-child {
margin-bottom: 10px !important;
}
.ProseMirror-menubar {
display: none; // Hide by default
}
&.has-selection {
// Hide menu completely when it has no items
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
}
.ProseMirror-menubar {
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
width: fit-content !important;
position: absolute !important;
// Default/LTR: position from left
top: var(--selection-top);
left: var(--selection-left);
// RTL: position from right instead
[dir='rtl'] & {
left: auto;
right: var(--selection-right);
}
.ProseMirror-menuitem {
@apply mr-0 size-4 flex items-center;
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0;
}
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
}
}
}
}
</style> </style>

View File

@@ -78,10 +78,6 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
showEditorToggle: {
type: Boolean,
default: false,
},
isOnPrivateNote: { isOnPrivateNote: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -130,7 +126,6 @@ export default {
emits: [ emits: [
'replaceText', 'replaceText',
'toggleInsertArticle', 'toggleInsertArticle',
'toggleEditor',
'selectWhatsappTemplate', 'selectWhatsappTemplate',
'selectContentTemplate', 'selectContentTemplate',
'toggleQuotedReply', 'toggleQuotedReply',
@@ -325,18 +320,8 @@ export default {
sm sm
@click="toggleAudioRecorder" @click="toggleAudioRecorder"
/> />
<NextButton
v-if="showEditorToggle"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
icon="i-ph-quotes"
slate
faded
sm
@click="$emit('toggleEditor')"
/>
<NextButton <NextButton
v-if="showAudioPlayStopButton" v-if="showAudioPlayStopButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
:icon="audioRecorderPlayStopIcon" :icon="audioRecorderPlayStopIcon"
slate slate
faded faded

View File

@@ -7,9 +7,7 @@ import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue'; import ReplyToMessage from './ReplyToMessage.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue'; import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue'; import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue'; import ReplyEmailHead from './ReplyEmailHead.vue';
@@ -45,8 +43,6 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import { import {
appendSignature, appendSignature,
removeSignature, removeSignature,
replaceSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper'; } from 'dashboard/helper/editorHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -61,7 +57,6 @@ export default {
ArticleSearchPopover, ArticleSearchPopover,
AttachmentPreview, AttachmentPreview,
AudioRecorder, AudioRecorder,
CannedResponse,
ReplyBoxBanner, ReplyBoxBanner,
EmojiInput, EmojiInput,
MessageSignatureMissingAlert, MessageSignatureMissingAlert,
@@ -69,7 +64,6 @@ export default {
ReplyEmailHead, ReplyEmailHead,
ReplyToMessage, ReplyToMessage,
ReplyTopPanel, ReplyTopPanel,
ResizableTextArea,
ContentTemplates, ContentTemplates,
WhatsappTemplates, WhatsappTemplates,
WootMessageEditor, WootMessageEditor,
@@ -86,7 +80,6 @@ export default {
setup() { setup() {
const { const {
uiSettings, uiSettings,
updateUISettings,
isEditorHotKeyEnabled, isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings, fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox, setQuotedReplyFlagForInbox,
@@ -97,7 +90,6 @@ export default {
return { return {
uiSettings, uiSettings,
updateUISettings,
isEditorHotKeyEnabled, isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings, fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox, setQuotedReplyFlagForInbox,
@@ -115,10 +107,7 @@ export default {
isRecordingAudio: false, isRecordingAudio: false,
recordingAudioState: '', recordingAudioState: '',
recordingAudioDurationText: '', recordingAudioDurationText: '',
isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY, replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '', bccEmails: '',
ccEmails: '', ccEmails: '',
toEmails: '', toEmails: '',
@@ -159,20 +148,6 @@ export default {
!this.is360DialogWhatsAppChannel !this.is360DialogWhatsAppChannel
); );
}, },
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
if (this.isAPIInbox) {
const {
display_rich_content_editor: displayRichContentEditor = false,
} = this.uiSettings;
return displayRichContentEditor;
}
return false;
},
showWhatsappTemplates() { showWhatsappTemplates() {
// We support templates for API channels if someone updates templates manually via API // We support templates for API channels if someone updates templates manually via API
// That's why we don't explicitly check for channel type here // That's why we don't explicitly check for channel type here
@@ -300,9 +275,6 @@ export default {
hasAttachments() { hasAttachments() {
return this.attachedFiles.length; return this.attachedFiles.length;
}, },
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel;
},
showAudioRecorder() { showAudioRecorder() {
return !this.isOnPrivateNote && this.showFileUpload; return !this.isOnPrivateNote && this.showFileUpload;
}, },
@@ -342,21 +314,11 @@ export default {
return !this.isPrivate && this.sendWithSignature; return !this.isPrivate && this.sendWithSignature;
}, },
isSignatureAvailable() { isSignatureAvailable() {
return !!this.signatureToApply; return !!this.messageSignature;
}, },
sendWithSignature() { sendWithSignature() {
return this.fetchSignatureFlagFromUISettings(this.channelType); return this.fetchSignatureFlagFromUISettings(this.channelType);
}, },
editorMessageKey() {
const { editor_message_key: isEnabled } = this.uiSettings;
return isEnabled;
},
commandPlusEnterToSendEnabled() {
return this.editorMessageKey === 'cmd_enter';
},
enterToSendEnabled() {
return this.editorMessageKey === 'enter';
},
conversationId() { conversationId() {
return this.currentChat.id; return this.currentChat.id;
}, },
@@ -383,12 +345,6 @@ export default {
}); });
return variables; return variables;
}, },
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
connectedPortalSlug() { connectedPortalSlug() {
const { help_center: portal = {} } = this.inbox; const { help_center: portal = {} } = this.inbox;
const { slug = '' } = portal; const { slug = '' } = portal;
@@ -481,25 +437,7 @@ export default {
this.resetRecorderAndClearAttachments(); this.resetRecorderAndClearAttachments();
} }
}, },
message(updatedMessage) { message() {
// Check if the message starts with a slash.
const bodyWithoutSignature = removeSignature(
updatedMessage,
this.signatureToApply
);
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Determine if the user is potentially typing a slash command.
// This is true if the message starts with a slash and the rich content editor is not active.
this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor;
this.showMentions = this.hasSlashCommand;
// If a slash command is active, extract the command text after the slash.
// If not, reset the mentionSearchKey.
this.mentionSearchKey = this.hasSlashCommand
? bodyWithoutSignature.substring(1)
: '';
// Autosave the current message draft. // Autosave the current message draft.
this.doAutoSaveDraft(); this.doAutoSaveDraft();
}, },
@@ -512,7 +450,7 @@ export default {
mounted() { mounted() {
this.getFromDraft(); this.getFromDraft();
// Don't use the keyboard listener mixin here as the events here are supposed to be // Don't use the keyboard listener mixin here as the events here are supposed to be
// working even if input/textarea is focussed. // working even if the editor is focussed.
document.addEventListener('paste', this.onPaste); document.addEventListener('paste', this.onPaste);
document.addEventListener('keydown', this.handleKeyEvents); document.addEventListener('keydown', this.handleKeyEvents);
this.setCCAndToEmailsFromLastChat(); this.setCCAndToEmailsFromLastChat();
@@ -549,45 +487,17 @@ export default {
methods: { methods: {
handleInsert(article) { handleInsert(article) {
const { url, title } = article; const { url, title } = article;
if (this.isRichEditorEnabled) { // Removing empty lines from the title
// Removing empty lines from the title const lines = title.split('\n');
const lines = title.split('\n'); const nonEmptyLines = lines.filter(line => line.trim() !== '');
const nonEmptyLines = lines.filter(line => line.trim() !== ''); const filteredMarkdown = nonEmptyLines.join(' ');
const filteredMarkdown = nonEmptyLines.join(' '); emitter.emit(
emitter.emit( BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
BUS_EVENTS.INSERT_INTO_RICH_EDITOR, `[${filteredMarkdown}](${url})`
`[${filteredMarkdown}](${url})` );
);
} else {
this.addIntoEditor(
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK); useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
}, },
toggleRichContentEditor() {
this.updateUISettings({
display_rich_content_editor: !this.showRichContentEditor,
});
const plainTextSignature = extractTextFromMarkdown(this.messageSignature);
if (!this.showRichContentEditor && this.messageSignature) {
// remove the old signature -> extract text from markdown -> attach new signature
let message = removeSignature(this.message, this.messageSignature);
message = extractTextFromMarkdown(message);
message = appendSignature(message, plainTextSignature);
this.message = message;
} else {
this.message = replaceSignature(
this.message,
plainTextSignature,
this.messageSignature
);
}
},
toggleQuotedReply() { toggleQuotedReply() {
if (!this.isAnEmailChannel) { if (!this.isAnEmailChannel) {
return; return;
@@ -655,8 +565,8 @@ export default {
} }
return this.sendWithSignature return this.sendWithSignature
? appendSignature(message, this.signatureToApply) ? appendSignature(message, this.messageSignature)
: removeSignature(message, this.signatureToApply); : removeSignature(message, this.messageSignature);
}, },
removeFromDraft() { removeFromDraft() {
if (this.conversationIdByRoute) { if (this.conversationIdByRoute) {
@@ -672,7 +582,6 @@ export default {
Escape: { Escape: {
action: () => { action: () => {
this.hideEmojiPicker(); this.hideEmojiPicker();
this.hideMentions();
}, },
allowOnFocusedInput: true, allowOnFocusedInput: true,
}, },
@@ -715,9 +624,6 @@ export default {
}, },
onPaste(e) { onPaste(e) {
const data = e.clipboardData.files; const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput.$el.blur();
}
if (!data.length || !data[0]) { if (!data.length || !data[0]) {
return; return;
} }
@@ -851,7 +757,7 @@ 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
message = appendSignature(message, this.signatureToApply); message = appendSignature(message, this.messageSignature);
} }
const updatedMessage = replaceVariablesInMessage({ const updatedMessage = replaceVariablesInMessage({
@@ -875,40 +781,22 @@ export default {
}); });
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode; this.replyType = mode;
if (this.showRichContentEditor) { if (this.isRecordingAudio) {
if (this.isRecordingAudio) { this.toggleAudioRecorder();
this.toggleAudioRecorder();
}
return;
} }
this.$nextTick(() => this.$refs.messageInput.focus());
}, },
clearEditorSelection() { clearEditorSelection() {
this.updateEditorSelectionWith = ''; this.updateEditorSelectionWith = '';
}, },
insertIntoTextEditor(text, selectionStart, selectionEnd) {
const { message } = this;
const newMessage =
message.slice(0, selectionStart) +
text +
message.slice(selectionEnd, message.length);
this.message = newMessage;
},
addIntoEditor(content) { addIntoEditor(content) {
if (this.showRichContentEditor) { this.updateEditorSelectionWith = content;
this.updateEditorSelectionWith = content; this.onFocus();
this.onFocus();
}
if (!this.showRichContentEditor) {
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
}
}, },
clearMessage() { clearMessage() {
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
this.message = appendSignature(this.message, this.signatureToApply); this.message = appendSignature(this.message, this.messageSignature);
} }
this.attachedFiles = []; this.attachedFiles = [];
this.isRecordingAudio = false; this.isRecordingAudio = false;
@@ -926,19 +814,15 @@ export default {
}, },
toggleAudioRecorder() { toggleAudioRecorder() {
this.isRecordingAudio = !this.isRecordingAudio; this.isRecordingAudio = !this.isRecordingAudio;
this.isRecorderAudioStopped = !this.isRecordingAudio;
if (!this.isRecordingAudio) { if (!this.isRecordingAudio) {
this.resetAudioRecorderInput(); this.resetAudioRecorderInput();
} }
}, },
toggleAudioRecorderPlayPause() { toggleAudioRecorderPlayPause() {
if (!this.isRecordingAudio) { if (!this.$refs.audioRecorderInput) return;
return; if (!this.recordingAudioState) {
}
if (!this.isRecorderAudioStopped) {
this.isRecorderAudioStopped = true;
this.$refs.audioRecorderInput.stopRecording(); this.$refs.audioRecorderInput.stopRecording();
} else if (this.isRecorderAudioStopped) { } else {
this.$refs.audioRecorderInput.playPause(); this.$refs.audioRecorderInput.playPause();
} }
}, },
@@ -947,9 +831,6 @@ export default {
this.toggleEmojiPicker(); this.toggleEmojiPicker();
} }
}, },
hideMentions() {
this.showMentions = false;
},
onTypingOn() { onTypingOn() {
this.toggleTyping('on'); this.toggleTyping('on');
}, },
@@ -1196,13 +1077,6 @@ export default {
:message="inReplyTo" :message="inReplyTo"
@dismiss="resetReplyToMessage" @dismiss="resetReplyToMessage"
/> />
<CannedResponse
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
class="normal-editor__canned-box"
:search-key="mentionSearchKey"
@replace="replaceText"
/>
<EmojiInput <EmojiInput
v-if="showEmojiPicker" v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker" v-on-clickaway="hideEmojiPicker"
@@ -1226,33 +1100,17 @@ export default {
@play="recordingAudioState = 'playing'" @play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'" @pause="recordingAudioState = 'paused'"
/> />
<ResizableTextArea
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="rounded-none input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
allow-signature
:send-with-signature="sendWithSignature"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
/>
<WootMessageEditor <WootMessageEditor
v-else
v-model="message" v-model="message"
:editor-id="editorStateId" :editor-id="editorStateId"
class="input" class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote" :is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder" :placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith" :update-selection-with="updateEditorSelectionWith"
:min-height="4" :min-height="4"
enable-variables enable-variables
:variables="messageVariables" :variables="messageVariables"
:signature="signatureToApply" :signature="messageSignature"
allow-signature allow-signature
:channel-type="channelType" :channel-type="channelType"
@typing-off="onTypingOff" @typing-off="onTypingOff"
@@ -1302,7 +1160,6 @@ export default {
:recording-audio-state="recordingAudioState" :recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel" :send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder" :show-audio-recorder="showAudioRecorder"
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
:show-emoji-picker="showEmojiPicker" :show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload" :show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle" :show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
@@ -1315,7 +1172,6 @@ export default {
:new-conversation-modal-active="newConversationModalActive" :new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal" @select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal" @select-content-template="openContentTemplateModal"
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText" @replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle" @toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply" @toggle-quoted-reply="toggleQuotedReply"
@@ -1369,10 +1225,6 @@ export default {
.reply-box__top { .reply-box__top {
@apply relative py-0 px-4 -mt-px; @apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
}
} }
.emoji-dialog { .emoji-dialog {
@@ -1392,9 +1244,4 @@ export default {
@apply ltr:left-1 rtl:right-1 -bottom-2; @apply ltr:left-1 rtl:right-1 -bottom-2;
} }
} }
.normal-editor__canned-box {
width: calc(100% - 2 * 1rem);
left: 1rem;
}
</style> </style>

View File

@@ -1,23 +1,143 @@
export const MESSAGE_EDITOR_MENU_OPTIONS = [ // Formatting rules for different contexts (channels and special contexts)
'strong', // marks: inline formatting (strong, em, code, link, strike)
'em', // nodes: block structures (bulletList, orderedList, codeBlock, blockquote)
'link', export const FORMATTING = {
'undo', // Channel formatting
'redo', 'Channel::Email': {
'bulletList', marks: ['strong', 'em', 'code', 'link'],
'orderedList', nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
'code', menu: [
]; 'strong',
'em',
export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [ 'code',
'strong', 'link',
'em', 'bulletList',
'link', 'orderedList',
'undo', 'undo',
'redo', 'redo',
'imageUpload', ],
]; },
'Channel::WebWidget': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
menu: [
'strong',
'em',
'code',
'link',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::Api': {
marks: [],
nodes: [],
menu: [],
},
'Channel::FacebookPage': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'strong',
'em',
'code',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::TwitterProfile': {
marks: [],
nodes: [],
menu: [],
},
'Channel::TwilioSms': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Sms': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Whatsapp': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'strong',
'em',
'code',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::Line': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['codeBlock'],
menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
},
'Channel::Telegram': {
marks: ['strong', 'em', 'link', 'code'],
nodes: [],
menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
},
'Channel::Instagram': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList'],
menu: [
'strong',
'em',
'code',
'bulletList',
'orderedList',
'strike',
'undo',
'redo',
],
},
'Channel::Voice': {
marks: [],
nodes: [],
menu: [],
},
// Special contexts (not actual channels)
'Context::Default': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
menu: [
'strong',
'em',
'code',
'link',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Context::MessageSignature': {
marks: ['strong', 'em', 'link'],
nodes: [],
menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'],
},
'Context::InboxSettings': {
marks: ['strong', 'em', 'link'],
nodes: [],
menu: ['strong', 'em', 'link', 'undo', 'redo'],
},
};
// Editor menu options for Full Editor
export const ARTICLE_EDITOR_MENU_OPTIONS = [ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'strong', 'strong',
'em', 'em',
@@ -33,14 +153,7 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'code', 'code',
]; ];
export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [ // Editor image resize options for Message Editor
'strong',
'em',
'link',
'undo',
'redo',
];
export const MESSAGE_EDITOR_IMAGE_RESIZES = [ export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{ {
name: 'Small', name: 'Small',

View File

@@ -5,6 +5,7 @@ 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 { FORMATTING } from 'dashboard/constants/editor';
/** /**
* 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.
@@ -314,7 +315,7 @@ const createNode = (editorView, nodeType, content) => {
return mentionNode; return mentionNode;
} }
case 'cannedResponse': case 'cannedResponse':
return new MessageMarkdownTransformer(messageSchema).parse(content); return new MessageMarkdownTransformer(state.schema).parse(content);
case 'variable': case 'variable':
return state.schema.text(`{{${content}}}`); return state.schema.text(`{{${content}}}`);
case 'emoji': case 'emoji':
@@ -389,3 +390,85 @@ export const getContentNode = (
? creator(editorView, content, from, to, variables) ? creator(editorView, content, from, to, variables)
: { node: null, from, to }; : { node: null, from, to };
}; };
/**
* Get the formatting configuration for a specific channel type.
* Returns the appropriate marks, nodes, and menu items for the editor.
*
* @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget')
* @returns {Object} The formatting configuration with marks, nodes, and menu properties
*/
export function getFormattingForEditor(channelType) {
return FORMATTING[channelType] || FORMATTING['Context::Default'];
}
/**
* Menu Positioning Helpers
* Handles floating menu bar positioning for text selection in the editor.
*/
const MENU_CONFIG = { H: 46, W: 300, GAP: 10 };
/**
* Calculate selection coordinates with bias to handle line-wraps correctly.
* @param {EditorView} editorView - ProseMirror editor view
* @param {Selection} selection - Current text selection
* @param {DOMRect} rect - Container bounding rect
* @returns {{start: Object, end: Object, selTop: number, onTop: boolean}}
*/
export function getSelectionCoords(editorView, selection, rect) {
const start = editorView.coordsAtPos(selection.from, 1);
const end = editorView.coordsAtPos(selection.to, -1);
const selTop = Math.min(start.top, end.top);
const spaceAbove = selTop - rect.top;
const onTop =
spaceAbove > MENU_CONFIG.H + MENU_CONFIG.GAP || end.bottom > rect.bottom;
return { start, end, selTop, onTop };
}
/**
* Calculate anchor position based on selection visibility and RTL direction.
* @param {Object} coords - Selection coordinates from getSelectionCoords
* @param {DOMRect} rect - Container bounding rect
* @param {boolean} isRtl - Whether text direction is RTL
* @returns {number} Anchor x-position for menu
*/
export function getMenuAnchor(coords, rect, isRtl) {
const { start, end, onTop } = coords;
if (!onTop) return end.left;
// If start of selection is visible, align to text. Else stick to container edge.
if (start.top >= rect.top) return isRtl ? start.right : start.left;
return isRtl ? rect.right - MENU_CONFIG.GAP : rect.left + MENU_CONFIG.GAP;
}
/**
* Calculate final menu position (left, top) within container bounds.
* @param {Object} coords - Selection coordinates from getSelectionCoords
* @param {DOMRect} rect - Container bounding rect
* @param {boolean} isRtl - Whether text direction is RTL
* @returns {{left: number, top: number, width: number}}
*/
export function calculateMenuPosition(coords, rect, isRtl) {
const { start, end, selTop, onTop } = coords;
const anchor = getMenuAnchor(coords, rect, isRtl);
// Calculate Left: shift by width if RTL, then make relative to container
const rawLeft = (isRtl ? anchor - MENU_CONFIG.W : anchor) - rect.left;
// Ensure menu stays within container bounds
const left = Math.min(Math.max(0, rawLeft), rect.width - MENU_CONFIG.W);
// Calculate Top: align to selection or bottom of selection
const top = onTop
? Math.max(-26, selTop - rect.top - MENU_CONFIG.H - MENU_CONFIG.GAP)
: Math.max(start.bottom, end.bottom) - rect.top + MENU_CONFIG.GAP;
return { left, top, width: MENU_CONFIG.W };
}
/* End Menu Positioning Helpers */

View File

@@ -1,15 +1,11 @@
// Moved from editorHelper.spec.js to editorContentHelper.spec.js // Moved from editorHelper.spec.js to editorContentHelper.spec.js
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs // the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
import { getContentNode } from '../editorHelper'; import { getContentNode } from '../editorHelper';
import { import { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema';
MessageMarkdownTransformer,
messageSchema,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils'; import { replaceVariablesInMessage } from '@chatwoot/utils';
vi.mock('@chatwoot/prosemirror-schema', () => ({ vi.mock('@chatwoot/prosemirror-schema', () => ({
MessageMarkdownTransformer: vi.fn(), MessageMarkdownTransformer: vi.fn(),
messageSchema: {},
})); }));
vi.mock('@chatwoot/utils', () => ({ vi.mock('@chatwoot/utils', () => ({
@@ -62,12 +58,18 @@ describe('getContentNode', () => {
const to = 10; const to = 10;
const updatedMessage = 'Hello John'; const updatedMessage = 'Hello John';
replaceVariablesInMessage.mockReturnValue(updatedMessage); // Mock the node that will be returned by parse
MessageMarkdownTransformer.mockImplementation(() => ({ const mockNode = { textContent: updatedMessage };
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
}));
const { node } = getContentNode( replaceVariablesInMessage.mockReturnValue(updatedMessage);
// Mock MessageMarkdownTransformer instance with parse method
const mockTransformer = {
parse: vi.fn().mockReturnValue(mockNode),
};
MessageMarkdownTransformer.mockImplementation(() => mockTransformer);
const result = getContentNode(
editorView, editorView,
'cannedResponse', 'cannedResponse',
content, content,
@@ -79,8 +81,15 @@ describe('getContentNode', () => {
message: content, message: content,
variables, variables,
}); });
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema); expect(MessageMarkdownTransformer).toHaveBeenCalledWith(
expect(node.textContent).toBe(updatedMessage); editorView.state.schema
);
expect(mockTransformer.parse).toHaveBeenCalledWith(updatedMessage);
expect(result.node).toBe(mockNode);
expect(result.node.textContent).toBe(updatedMessage);
// When textContent matches updatedMessage, from should remain unchanged
expect(result.from).toBe(from);
expect(result.to).toBe(to);
}); });
}); });

View File

@@ -9,7 +9,12 @@ import {
findNodeToInsertImage, findNodeToInsertImage,
setURLWithQueryAndSize, setURLWithQueryAndSize,
getContentNode, getContentNode,
getFormattingForEditor,
getSelectionCoords,
getMenuAnchor,
calculateMenuPosition,
} from '../editorHelper'; } from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema'; import { EditorState } from '@chatwoot/prosemirror-schema';
import { EditorView } from '@chatwoot/prosemirror-schema'; import { EditorView } from '@chatwoot/prosemirror-schema';
import { Schema } from 'prosemirror-model'; import { Schema } from 'prosemirror-model';
@@ -258,15 +263,11 @@ describe('insertAtCursor', () => {
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
it('should unwrap doc nodes that are wrapped in a paragraph', () => { it('should insert text node at cursor position', () => {
const docNode = schema.node('doc', null, [
schema.node('paragraph', null, [schema.text('Hello')]),
]);
const editorState = createEditorState(); const editorState = createEditorState();
const editorView = new EditorView(document.body, { state: editorState }); const editorView = new EditorView(document.body, { state: editorState });
insertAtCursor(editorView, docNode, 0); insertAtCursor(editorView, schema.text('Hello'), 0);
// Check if node was unwrapped and inserted correctly // Check if node was unwrapped and inserted correctly
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello'); expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
@@ -626,3 +627,178 @@ describe('getContentNode', () => {
}); });
}); });
}); });
describe('getFormattingForEditor', () => {
describe('channel-specific formatting', () => {
it('returns full formatting for Email channel', () => {
const result = getFormattingForEditor('Channel::Email');
expect(result).toEqual(FORMATTING['Channel::Email']);
});
it('returns full formatting for WebWidget channel', () => {
const result = getFormattingForEditor('Channel::WebWidget');
expect(result).toEqual(FORMATTING['Channel::WebWidget']);
});
it('returns limited formatting for WhatsApp channel', () => {
const result = getFormattingForEditor('Channel::Whatsapp');
expect(result).toEqual(FORMATTING['Channel::Whatsapp']);
});
it('returns no formatting for API channel', () => {
const result = getFormattingForEditor('Channel::Api');
expect(result).toEqual(FORMATTING['Channel::Api']);
});
it('returns limited formatting for FacebookPage channel', () => {
const result = getFormattingForEditor('Channel::FacebookPage');
expect(result).toEqual(FORMATTING['Channel::FacebookPage']);
});
it('returns no formatting for TwitterProfile channel', () => {
const result = getFormattingForEditor('Channel::TwitterProfile');
expect(result).toEqual(FORMATTING['Channel::TwitterProfile']);
});
it('returns no formatting for SMS channel', () => {
const result = getFormattingForEditor('Channel::Sms');
expect(result).toEqual(FORMATTING['Channel::Sms']);
});
it('returns limited formatting for Telegram channel', () => {
const result = getFormattingForEditor('Channel::Telegram');
expect(result).toEqual(FORMATTING['Channel::Telegram']);
});
it('returns formatting for Instagram channel', () => {
const result = getFormattingForEditor('Channel::Instagram');
expect(result).toEqual(FORMATTING['Channel::Instagram']);
});
});
describe('context-specific formatting', () => {
it('returns default formatting for Context::Default', () => {
const result = getFormattingForEditor('Context::Default');
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns signature formatting for Context::MessageSignature', () => {
const result = getFormattingForEditor('Context::MessageSignature');
expect(result).toEqual(FORMATTING['Context::MessageSignature']);
});
it('returns widget builder formatting for Context::InboxSettings', () => {
const result = getFormattingForEditor('Context::InboxSettings');
expect(result).toEqual(FORMATTING['Context::InboxSettings']);
});
});
describe('fallback behavior', () => {
it('returns default formatting for unknown channel type', () => {
const result = getFormattingForEditor('Channel::Unknown');
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for null channel type', () => {
const result = getFormattingForEditor(null);
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for undefined channel type', () => {
const result = getFormattingForEditor(undefined);
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for empty string', () => {
const result = getFormattingForEditor('');
expect(result).toEqual(FORMATTING['Context::Default']);
});
});
describe('return value structure', () => {
it('always returns an object with marks, nodes, and menu properties', () => {
const result = getFormattingForEditor('Channel::Email');
expect(result).toHaveProperty('marks');
expect(result).toHaveProperty('nodes');
expect(result).toHaveProperty('menu');
expect(Array.isArray(result.marks)).toBe(true);
expect(Array.isArray(result.nodes)).toBe(true);
expect(Array.isArray(result.menu)).toBe(true);
});
});
});
describe('Menu positioning helpers', () => {
const mockEditorView = {
coordsAtPos: vi.fn((pos, bias) => {
// Return different coords based on position
if (bias === 1) return { top: 100, bottom: 120, left: 50, right: 100 };
return { top: 100, bottom: 120, left: 150, right: 200 };
}),
};
const wrapperRect = { top: 50, bottom: 300, left: 0, right: 400, width: 400 };
describe('getSelectionCoords', () => {
it('returns selection coordinates with onTop flag', () => {
const selection = { from: 0, to: 10 };
const result = getSelectionCoords(mockEditorView, selection, wrapperRect);
expect(result).toHaveProperty('start');
expect(result).toHaveProperty('end');
expect(result).toHaveProperty('selTop');
expect(result).toHaveProperty('onTop');
});
});
describe('getMenuAnchor', () => {
it('returns end.left when menu is below selection', () => {
const coords = { start: { left: 50 }, end: { left: 150 }, onTop: false };
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(150);
});
it('returns start.left for LTR when menu is above and visible', () => {
const coords = { start: { top: 100, left: 50 }, end: {}, onTop: true };
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(50);
});
it('returns start.right for RTL when menu is above and visible', () => {
const coords = { start: { top: 100, right: 100 }, end: {}, onTop: true };
expect(getMenuAnchor(coords, wrapperRect, true)).toBe(100);
});
});
describe('calculateMenuPosition', () => {
it('returns bounded left and top positions', () => {
const coords = {
start: { top: 100, bottom: 120, left: 50 },
end: { top: 100, bottom: 120, left: 150 },
selTop: 100,
onTop: false,
};
const result = calculateMenuPosition(coords, wrapperRect, false);
expect(result).toHaveProperty('left');
expect(result).toHaveProperty('top');
expect(result).toHaveProperty('width', 300);
expect(result.left).toBeGreaterThanOrEqual(0);
});
});
});

View File

@@ -196,7 +196,6 @@
"INSERT_READ_MORE": "Read more", "INSERT_READ_MORE": "Read more",
"DISMISS_REPLY": "Dismiss reply", "DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:", "REPLYING_TO": "Replying to:",
"TIP_FORMAT_ICON": "Show rich text editor",
"TIP_EMOJI_ICON": "Show emoji selector", "TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files", "TIP_ATTACH_ICON": "Attach files",
"TIP_AUDIORECORDER_ICON": "Record audio", "TIP_AUDIORECORDER_ICON": "Record audio",

View File

@@ -27,7 +27,6 @@ import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue'; import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import { getInboxIconByType } from 'dashboard/helper/inbox'; import { getInboxIconByType } from 'dashboard/helper/inbox';
import Editor from 'dashboard/components-next/Editor/Editor.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue';
@@ -81,7 +80,6 @@ export default {
selectedTabIndex: 0, selectedTabIndex: 0,
selectedPortalSlug: '', selectedPortalSlug: '',
showBusinessNameInput: false, showBusinessNameInput: false,
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
healthData: null, healthData: null,
isLoadingHealth: false, isLoadingHealth: false,
healthError: null, healthError: null,
@@ -626,7 +624,7 @@ export default {
) )
" "
:max-length="255" :max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions" channel-type="Context::InboxSettings"
/> />
<label v-if="isAWebWidgetInbox" class="pb-4"> <label v-if="isAWebWidgetInbox" class="pb-4">

View File

@@ -7,7 +7,6 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators'; import { required } from '@vuelidate/validators';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage'; import { LocalStorage } from 'shared/helpers/localStorage';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue'; import Avatar from 'next/avatar/Avatar.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue';
@@ -76,7 +75,6 @@ export default {
checked: false, checked: false,
}, },
], ],
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
}; };
}, },
computed: { computed: {
@@ -337,7 +335,7 @@ export default {
) )
" "
:max-length="255" :max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions" channel-type="Context::InboxSettings"
class="mb-4" class="mb-4"
/> />
<label> <label>

View File

@@ -5,7 +5,6 @@ import router from '../../../../index';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import PageHeader from '../../SettingsSubPageHeader.vue'; import PageHeader from '../../SettingsSubPageHeader.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue'; import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import Editor from 'dashboard/components-next/Editor/Editor.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default { export default {
@@ -24,7 +23,6 @@ export default {
channelWelcomeTagline: '', channelWelcomeTagline: '',
greetingEnabled: false, greetingEnabled: false,
greetingMessage: '', greetingMessage: '',
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
}; };
}, },
computed: { computed: {
@@ -147,7 +145,7 @@ export default {
) )
" "
:max-length="255" :max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions" channel-type="Context::InboxSettings"
class="mb-4" class="mb-4"
/> />

View File

@@ -1,7 +1,6 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({ const props = defineProps({
@@ -12,7 +11,6 @@ const props = defineProps({
}); });
const emit = defineEmits(['updateSignature']); const emit = defineEmits(['updateSignature']);
const customEditorMenuList = MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS;
const signature = ref(props.messageSignature); const signature = ref(props.messageSignature);
watch( watch(
() => props.messageSignature ?? '', () => props.messageSignature ?? '',
@@ -34,7 +32,7 @@ const updateSignature = () => {
class="message-editor h-[10rem] !px-3" class="message-editor h-[10rem] !px-3"
is-format-mode is-format-mode
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')" :placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
:enabled-menu-options="customEditorMenuList" channel-type="Context::MessageSignature"
:enable-suggestions="false" :enable-suggestions="false"
show-image-resize-toolbar show-image-resize-toolbar
/> />

View File

@@ -130,30 +130,39 @@ class Channel::Telegram < ApplicationRecord
def convert_markdown_to_telegram_html(text) def convert_markdown_to_telegram_html(text)
# ref: https://core.telegram.org/bots/api#html-style # ref: https://core.telegram.org/bots/api#html-style
# escape html tags in text. We are subbing \n to <br> since commonmark will strip exta '\n' # Escape HTML entities first to prevent HTML injection
text = CGI.escapeHTML(text.gsub("\n", '<br>')) # This ensures only markdown syntax is converted, not raw HTML
escaped_text = CGI.escapeHTML(text)
# convert markdown to html # Parse markdown with extensions:
html = CommonMarker.render_html(text).strip # - strikethrough: support ~~text~~
# - hardbreaks: preserve all newlines as <br>
html = CommonMarker.render_html(escaped_text, [:HARDBREAKS], [:strikethrough]).strip
# remove all html tags except b, strong, i, em, u, ins, s, strike, del, a, code, pre, blockquote # Convert paragraph breaks to double newlines to preserve them
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html, tags: %w[b strong i em u ins s strike del a code pre blockquote], # CommonMarker creates <p> tags for paragraph breaks, but Telegram doesn't support <p>
attributes: %w[href]) html_with_breaks = html.gsub(%r{</p>\s*<p>}, "\n\n")
# converted escaped br tags to \n # Remove opening and closing <p> tags
stripped_html.gsub('&lt;br&gt;', "\n") html_with_breaks = html_with_breaks.gsub(%r{</?p>}, '')
# Sanitize to only allowed tags
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html_with_breaks, tags: %w[b strong i em u ins s strike del a code pre blockquote],
attributes: %w[href])
# Convert <br /> tags to newlines for Telegram
stripped_html.gsub(%r{<br\s*/?>}, "\n")
end end
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil, business_connection_id: nil) def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil, business_connection_id: nil)
text_payload = convert_markdown_to_telegram_html(text) # text is already converted to HTML by MessageContentPresenter
business_body = {} business_body = {}
business_body[:business_connection_id] = business_connection_id if business_connection_id business_body[:business_connection_id] = business_connection_id if business_connection_id
HTTParty.post("#{telegram_api_url}/sendMessage", HTTParty.post("#{telegram_api_url}/sendMessage",
body: { body: {
chat_id: chat_id, chat_id: chat_id,
text: text_payload, text: text,
reply_markup: reply_markup, reply_markup: reply_markup,
parse_mode: 'HTML', parse_mode: 'HTML',
reply_to_message_id: reply_to_message_id reply_to_message_id: reply_to_message_id

View File

@@ -1,11 +1,17 @@
class MessageContentPresenter < SimpleDelegator class MessageContentPresenter < SimpleDelegator
def outgoing_content def outgoing_content
return content unless should_append_survey_link? content_to_send = if should_append_survey_link?
survey_link = survey_url(conversation.uuid)
custom_message = inbox.csat_config&.dig('message')
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link)
else
content
end
survey_link = survey_url(conversation.uuid) Messages::MarkdownRendererService.new(
custom_message = inbox.csat_config&.dig('message') content_to_send,
conversation.inbox.channel_type
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link) ).render
end end
private private

View File

@@ -0,0 +1,64 @@
class Messages::MarkdownRendererService
CHANNEL_RENDERERS = {
'Channel::Email' => :render_html,
'Channel::WebWidget' => :render_html,
'Channel::Telegram' => :render_telegram_html,
'Channel::Whatsapp' => :render_whatsapp,
'Channel::FacebookPage' => :render_instagram,
'Channel::Instagram' => :render_instagram,
'Channel::Line' => :render_line,
'Channel::TwitterProfile' => :render_plain_text,
'Channel::Sms' => :render_plain_text,
'Channel::TwilioSms' => :render_plain_text
}.freeze
def initialize(content, channel_type)
@content = content
@channel_type = channel_type
end
def render
return @content if @content.blank?
renderer_method = CHANNEL_RENDERERS[@channel_type]
renderer_method ? send(renderer_method) : @content
end
private
def commonmarker_doc
@commonmarker_doc ||= CommonMarker.render_doc(@content, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
end
def render_html
markdown_renderer = BaseMarkdownRenderer.new
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough])
markdown_renderer.render(doc)
end
def render_telegram_html
renderer = Messages::MarkdownRenderers::TelegramRenderer.new
doc = CommonMarker.render_doc(@content, [:STRIKETHROUGH_DOUBLE_TILDE], [:strikethrough])
renderer.render(doc).gsub(/\n+\z/, '')
end
def render_whatsapp
renderer = Messages::MarkdownRenderers::WhatsAppRenderer.new
renderer.render(commonmarker_doc).gsub(/\n+\z/, '')
end
def render_instagram
renderer = Messages::MarkdownRenderers::InstagramRenderer.new
renderer.render(commonmarker_doc).gsub(/\n+\z/, '')
end
def render_line
renderer = Messages::MarkdownRenderers::LineRenderer.new
renderer.render(commonmarker_doc).gsub(/\n+\z/, '')
end
def render_plain_text
renderer = Messages::MarkdownRenderers::PlainTextRenderer.new
renderer.render(commonmarker_doc).gsub(/\n+\z/, '')
end
end

View File

@@ -0,0 +1,39 @@
class Messages::MarkdownRenderers::BaseMarkdownRenderer < CommonMarker::Renderer
def document(_node)
out(:children)
end
def paragraph(_node)
out(:children)
cr
end
def text(node)
out(node.string_content)
end
def softbreak(_node)
out(' ')
end
def linebreak(_node)
out("\n")
end
def strikethrough(_node)
out('<del>')
out(:children)
out('</del>')
end
def method_missing(method_name, node = nil, *args, **kwargs, &)
return super unless node.is_a?(CommonMarker::Node)
out(:children)
cr unless %i[text softbreak linebreak].include?(node.type)
end
def respond_to_missing?(_method_name, _include_private = false)
true
end
end

View File

@@ -0,0 +1,44 @@
class Messages::MarkdownRenderers::InstagramRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def strong(_node)
out('*', :children, '*')
end
def emph(_node)
out('_', :children, '_')
end
def code(node)
out(node.string_content)
end
def link(node)
out(node.url)
end
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('- ', :children)
end
cr
end
def blockquote(_node)
out(:children)
cr
end
end

View File

@@ -0,0 +1,36 @@
class Messages::MarkdownRenderers::LineRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def strong(_node)
out(' *', :children, '* ')
end
def emph(_node)
out(' _', :children, '_ ')
end
def code(node)
out(' `', node.string_content, '` ')
end
def link(node)
out(node.url)
end
def list(_node)
out(:children)
cr
end
def list_item(_node)
out(:children)
cr
end
def code_block(node)
out(' ```', "\n", node.string_content, '``` ', "\n")
end
def blockquote(_node)
out(:children)
cr
end
end

View File

@@ -0,0 +1,58 @@
class Messages::MarkdownRenderers::PlainTextRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def link(node)
out(:children)
out(' ', node.url) if node.url.present?
end
def strong(_node)
out(:children)
end
def emph(_node)
out(:children)
end
def code(node)
out(node.string_content)
end
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('- ', :children)
end
cr
end
def blockquote(_node)
out(:children)
cr
end
def code_block(node)
out(node.string_content, "\n")
end
def header(_node)
out(:children)
cr
end
def thematic_break(_node)
out("\n")
end
end

View File

@@ -0,0 +1,60 @@
class Messages::MarkdownRenderers::TelegramRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def strong(_node)
out('<strong>', :children, '</strong>')
end
def emph(_node)
out('<em>', :children, '</em>')
end
def code(node)
out('<code>', node.string_content, '</code>')
end
def link(node)
out('<a href="', node.url, '">', :children, '</a>')
end
def strikethrough(_node)
out('<del>', :children, '</del>')
end
def blockquote(_node)
out('<blockquote>', :children, '</blockquote>')
end
def code_block(node)
out('<pre>', node.string_content, '</pre>')
end
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('• ', :children)
end
cr
end
def header(_node)
out('<strong>', :children, '</strong>')
cr
end
def softbreak(_node)
out("\n")
end
end

View File

@@ -0,0 +1,32 @@
class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def strong(_node)
out('*', :children, '*')
end
def emph(_node)
out('_', :children, '_')
end
def code(node)
out('`', node.string_content, '`')
end
def link(node)
out(node.url)
end
def list(_node)
out(:children)
cr
end
def list_item(_node)
out('- ', :children)
cr
end
def blockquote(_node)
out('> ', :children)
cr
end
end

View File

@@ -1,7 +1,7 @@
<% if @message.content_attributes.dig('email', 'html_content', 'reply').present? %> <% if @message.content_attributes.dig('email', 'html_content', 'reply').present? %>
<%= @message.content_attributes.dig('email', 'html_content', 'reply').html_safe %> <%= @message.content_attributes.dig('email', 'html_content', 'reply').html_safe %>
<% elsif @message.content %> <% elsif @message.content %>
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %> <%= @message.outgoing_content.html_safe %>
<% end %> <% end %>
<% if @large_attachments.present? %> <% if @large_attachments.present? %>
<p>Attachments:</p> <p>Attachments:</p>

View File

@@ -5,7 +5,7 @@ class ChatwootMarkdownRenderer
def render_message def render_message
markdown_renderer = BaseMarkdownRenderer.new markdown_renderer = BaseMarkdownRenderer.new
doc = CommonMarker.render_doc(@content, :DEFAULT) doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough])
html = markdown_renderer.render(doc) html = markdown_renderer.render(doc)
render_as_html_safe(html) render_as_html_safe(html)
end end

View File

@@ -2,11 +2,14 @@
# NOTE: are sensitive to local FS writes, and besides -- it's just not proper # NOTE: are sensitive to local FS writes, and besides -- it's just not proper
# NOTE: to have a dev-mode tool do its thing in production. # NOTE: to have a dev-mode tool do its thing in production.
if Rails.env.development? if Rails.env.development?
require 'annotate' require 'annotate_rb'
AnnotateRb::Core.load_rake_tasks
task :set_annotation_options do task :set_annotation_options do
# You can override any of these by setting an environment variable of the # You can override any of these by setting an environment variable of the
# same name. # same name.
Annotate.set_defaults( AnnotateRb::Options.set_defaults(
'additional_file_patterns' => [], 'additional_file_patterns' => [],
'routes' => 'false', 'routes' => 'false',
'models' => 'true', 'models' => 'true',
@@ -55,6 +58,4 @@ if Rails.env.development?
'with_comment' => 'true' 'with_comment' => 'true'
) )
end end
Annotate.load_tasks
end end

View File

@@ -33,7 +33,7 @@
"dependencies": { "dependencies": {
"@breezystack/lamejs": "^1.2.7", "@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3", "@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.2.3", "@chatwoot/prosemirror-schema": "1.2.5",
"@chatwoot/utils": "^0.0.51", "@chatwoot/utils": "^0.0.51",
"@formkit/core": "^1.6.7", "@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7", "@formkit/vue": "^1.6.7",

10
pnpm-lock.yaml generated
View File

@@ -20,8 +20,8 @@ importers:
specifier: 1.2.3 specifier: 1.2.3
version: 1.2.3 version: 1.2.3
'@chatwoot/prosemirror-schema': '@chatwoot/prosemirror-schema':
specifier: 1.2.3 specifier: 1.2.5
version: 1.2.3 version: 1.2.5
'@chatwoot/utils': '@chatwoot/utils':
specifier: ^0.0.51 specifier: ^0.0.51
version: 0.0.51 version: 0.0.51
@@ -421,8 +421,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3': '@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
'@chatwoot/prosemirror-schema@1.2.3': '@chatwoot/prosemirror-schema@1.2.5':
resolution: {integrity: sha512-q/EfirVK9jt8FJAx3Gf6y3LoVadmYVLknbYvPrkUe81WO0f2mkZ/kY2UQgpUISVvOGEkCH4bkfYMp5UQ+Buz3g==} resolution: {integrity: sha512-nwi0G17jLiRwIzjjQXr9gTZRcDf5BQzo44XxO6CItfR02W5RExugurvHlpc88R4tUrDtIQHGM2Q2vijvFUNIkA==}
'@chatwoot/utils@0.0.51': '@chatwoot/utils@0.0.51':
resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==} resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==}
@@ -4862,7 +4862,7 @@ snapshots:
hotkeys-js: 3.8.7 hotkeys-js: 3.8.7
lit: 2.2.6 lit: 2.2.6
'@chatwoot/prosemirror-schema@1.2.3': '@chatwoot/prosemirror-schema@1.2.5':
dependencies: dependencies:
markdown-it-sup: 2.0.0 markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0 prosemirror-commands: 1.6.0

View File

@@ -10,7 +10,7 @@ RSpec.describe ChatwootMarkdownRenderer do
let(:html_content) { '<p>This is a <em>test</em> content with <sup>markdown</sup></p>' } let(:html_content) { '<p>This is a <em>test</em> content with <sup>markdown</sup></p>' }
before do before do
allow(CommonMarker).to receive(:render_doc).with(markdown_content, :DEFAULT).and_return(doc) allow(CommonMarker).to receive(:render_doc).with(markdown_content, :DEFAULT, [:strikethrough]).and_return(doc)
allow(CustomMarkdownRenderer).to receive(:new).and_return(markdown_renderer) allow(CustomMarkdownRenderer).to receive(:new).and_return(markdown_renderer)
allow(markdown_renderer).to receive(:render).with(doc).and_return(html_content) allow(markdown_renderer).to receive(:render).with(doc).and_return(html_content)
end end
@@ -86,6 +86,7 @@ RSpec.describe ChatwootMarkdownRenderer do
let(:rendered_content) { renderer.render_markdown_to_plain_text } let(:rendered_content) { renderer.render_markdown_to_plain_text }
before do before do
allow(CommonMarker).to receive(:render_doc).with(markdown_content, :DEFAULT).and_return(doc)
allow(doc).to receive(:to_plaintext).and_return(plain_text_content) allow(doc).to receive(:to_plaintext).and_return(plain_text_content)
end end

View File

@@ -10,8 +10,8 @@ RSpec.describe MessageContentPresenter do
let(:content_type) { 'text' } let(:content_type) { 'text' }
let(:content) { 'Regular message' } let(:content) { 'Regular message' }
it 'returns regular content' do it 'returns content transformed for channel (HTML for WebWidget)' do
expect(presenter.outgoing_content).to eq('Regular message') expect(presenter.outgoing_content).to eq("<p>Regular message</p>\n")
end end
end end
@@ -23,8 +23,8 @@ RSpec.describe MessageContentPresenter do
allow(message.inbox).to receive(:web_widget?).and_return(true) allow(message.inbox).to receive(:web_widget?).and_return(true)
end end
it 'returns regular content without survey URL' do it 'returns content without survey URL (HTML for WebWidget)' do
expect(presenter.outgoing_content).to eq('Rate your experience') expect(presenter.outgoing_content).to eq("<p>Rate your experience</p>\n")
end end
end end
@@ -36,18 +36,20 @@ RSpec.describe MessageContentPresenter do
allow(message.inbox).to receive(:web_widget?).and_return(false) allow(message.inbox).to receive(:web_widget?).and_return(false)
end end
it 'returns I18n default message when no CSAT config and dynamically generates survey URL' do it 'returns I18n default message when no CSAT config and dynamically generates survey URL (HTML format)' do
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}" expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
expect(presenter.outgoing_content).to include(expected_url) expect(presenter.outgoing_content).to include(expected_url)
expect(presenter.outgoing_content).to include('<p>')
end end
end end
it 'returns CSAT config message when config exists and dynamically generates survey URL' do it 'returns CSAT config message when config exists and dynamically generates survey URL (HTML format)' do
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom CSAT message' }) allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom CSAT message' })
expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}" expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
expect(presenter.outgoing_content).to eq("Custom CSAT message #{expected_url}") expected_content = "<p>Custom CSAT message #{expected_url}</p>\n"
expect(presenter.outgoing_content).to eq(expected_content)
end end
end end
end end

View File

@@ -0,0 +1,366 @@
require 'rails_helper'
RSpec.describe Messages::MarkdownRendererService, type: :service do
describe '#render' do
context 'when content is blank' do
it 'returns the content as-is for nil' do
result = described_class.new(nil, 'Channel::Whatsapp').render
expect(result).to be_nil
end
it 'returns the content as-is for empty string' do
result = described_class.new('', 'Channel::Whatsapp').render
expect(result).to eq('')
end
end
context 'when channel is Channel::Whatsapp' do
let(:channel_type) { 'Channel::Whatsapp' }
it 'converts bold from double to single asterisk' do
content = '**bold text**'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('*bold text*')
end
it 'keeps italic with underscore' do
content = '_italic text_'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('_italic text_')
end
it 'keeps code with backticks' do
content = '`code`'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('`code`')
end
it 'converts links to URLs only' do
content = '[link text](https://example.com)'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('https://example.com')
end
it 'handles combined formatting' do
content = '**bold** _italic_ `code` [link](https://example.com)'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('*bold* _italic_ `code` https://example.com')
end
it 'handles nested formatting' do
content = '**bold _italic_**'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('*bold _italic_*')
end
it 'converts bullet lists' do
content = "- item 1\n- item 2"
result = described_class.new(content, channel_type).render
expect(result.strip).to include('- item 1')
expect(result.strip).to include('- item 2')
end
end
context 'when channel is Channel::Instagram' do
let(:channel_type) { 'Channel::Instagram' }
it 'converts bold from double to single asterisk' do
content = '**bold text**'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('*bold text*')
end
it 'keeps italic with underscore' do
content = '_italic text_'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('_italic text_')
end
it 'strips code backticks' do
content = '`code`'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code')
end
it 'converts links to URLs only' do
content = '[link text](https://example.com)'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('https://example.com')
end
it 'preserves bullet list markers' do
content = "- first item\n- second item"
result = described_class.new(content, channel_type).render
expect(result).to include('- first item')
expect(result).to include('- second item')
end
it 'preserves ordered list markers with numbering' do
content = "1. first step\n2. second step"
result = described_class.new(content, channel_type).render
expect(result).to include('1. first step')
expect(result).to include('2. second step')
end
end
context 'when channel is Channel::Line' do
let(:channel_type) { 'Channel::Line' }
it 'adds spaces around bold markers' do
content = '**bold**'
result = described_class.new(content, channel_type).render
expect(result).to include(' *bold* ')
end
it 'adds spaces around italic markers' do
content = '_italic_'
result = described_class.new(content, channel_type).render
expect(result).to include(' _italic_ ')
end
it 'adds spaces around code markers' do
content = '`code`'
result = described_class.new(content, channel_type).render
expect(result).to include(' `code` ')
end
end
context 'when channel is Channel::Sms' do
let(:channel_type) { 'Channel::Sms' }
it 'strips all markdown formatting' do
content = '**bold** _italic_ `code`'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('bold italic code')
end
it 'preserves URLs from links in plain text format' do
content = '[link text](https://example.com)'
result = described_class.new(content, channel_type).render
expect(result).to eq('link text https://example.com')
end
it 'preserves URLs in messages with multiple links' do
content = 'Visit [our site](https://example.com) or [help center](https://help.example.com)'
result = described_class.new(content, channel_type).render
expect(result).to eq('Visit our site https://example.com or help center https://help.example.com')
end
it 'preserves link text and URL when both are present' do
content = '[Reset password](https://example.com/reset)'
result = described_class.new(content, channel_type).render
expect(result).to eq('Reset password https://example.com/reset')
end
it 'handles complex markdown' do
content = "# Heading\n\n**bold** _italic_ [link](https://example.com)"
result = described_class.new(content, channel_type).render
expect(result).to include('Heading')
expect(result).to include('bold')
expect(result).to include('italic')
expect(result).to include('link https://example.com')
expect(result).not_to include('**')
expect(result).not_to include('_')
expect(result).not_to include('[')
end
it 'preserves bullet list markers' do
content = "- first item\n- second item\n- third item"
result = described_class.new(content, channel_type).render
expect(result).to include('- first item')
expect(result).to include('- second item')
expect(result).to include('- third item')
end
it 'preserves ordered list markers with numbering' do
content = "1. first step\n2. second step\n3. third step"
result = described_class.new(content, channel_type).render
expect(result).to include('1. first step')
expect(result).to include('2. second step')
expect(result).to include('3. third step')
end
end
context 'when channel is Channel::Telegram' do
let(:channel_type) { 'Channel::Telegram' }
it 'converts to HTML format' do
content = '**bold** _italic_ `code`'
result = described_class.new(content, channel_type).render
expect(result).to include('<strong>bold</strong>')
expect(result).to include('<em>italic</em>')
expect(result).to include('<code>code</code>')
end
it 'handles links' do
content = '[link text](https://example.com)'
result = described_class.new(content, channel_type).render
expect(result).to include('<a href="https://example.com">link text</a>')
end
it 'preserves newlines' do
content = "line 1\nline 2"
result = described_class.new(content, channel_type).render
expect(result).to include("\n")
end
it 'converts strikethrough to HTML' do
content = '~~strikethrough text~~'
result = described_class.new(content, channel_type).render
expect(result).to include('<del>strikethrough text</del>')
end
it 'converts blockquotes to HTML' do
content = '> quoted text'
result = described_class.new(content, channel_type).render
expect(result).to include('<blockquote>')
expect(result).to include('quoted text')
end
end
context 'when channel is Channel::Email' do
let(:channel_type) { 'Channel::Email' }
it 'renders full HTML' do
content = '**bold** _italic_'
result = described_class.new(content, channel_type).render
expect(result).to include('<strong>bold</strong>')
expect(result).to include('<em>italic</em>')
end
it 'renders ordered lists as HTML' do
content = "1. first\n2. second"
result = described_class.new(content, channel_type).render
expect(result).to include('<ol>')
expect(result).to include('<li>first</li>')
end
it 'converts strikethrough to HTML' do
content = '~~strikethrough text~~'
result = described_class.new(content, channel_type).render
expect(result).to include('<del>strikethrough text</del>')
end
end
context 'when channel is Channel::WebWidget' do
let(:channel_type) { 'Channel::WebWidget' }
it 'renders full HTML like Email' do
content = '**bold** _italic_ `code`'
result = described_class.new(content, channel_type).render
expect(result).to include('<strong>bold</strong>')
expect(result).to include('<em>italic</em>')
expect(result).to include('<code>code</code>')
end
it 'converts strikethrough to HTML' do
content = '~~strikethrough text~~'
result = described_class.new(content, channel_type).render
expect(result).to include('<del>strikethrough text</del>')
end
end
context 'when channel is Channel::FacebookPage' do
let(:channel_type) { 'Channel::FacebookPage' }
it 'converts bold to single asterisk like Instagram' do
content = '**bold text**'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('*bold text*')
end
it 'strips unsupported formatting' do
content = '`code` ~~strike~~'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code ~~strike~~')
end
it 'preserves bullet list markers like Instagram' do
content = "- first item\n- second item"
result = described_class.new(content, channel_type).render
expect(result).to include('- first item')
expect(result).to include('- second item')
end
it 'preserves ordered list markers with numbering like Instagram' do
content = "1. first step\n2. second step"
result = described_class.new(content, channel_type).render
expect(result).to include('1. first step')
expect(result).to include('2. second step')
end
end
context 'when channel is Channel::TwilioSms' do
let(:channel_type) { 'Channel::TwilioSms' }
it 'strips all markdown like SMS' do
content = '**bold** _italic_'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('bold italic')
end
end
context 'when channel is Channel::Api' do
let(:channel_type) { 'Channel::Api' }
it 'preserves markdown as-is' do
content = '**bold** _italic_ `code`'
result = described_class.new(content, channel_type).render
expect(result).to eq('**bold** _italic_ `code`')
end
it 'preserves links with markdown syntax' do
content = '[Click here](https://example.com)'
result = described_class.new(content, channel_type).render
expect(result).to eq('[Click here](https://example.com)')
end
it 'preserves lists with markdown syntax' do
content = "- Item 1\n- Item 2"
result = described_class.new(content, channel_type).render
expect(result).to eq("- Item 1\n- Item 2")
end
end
context 'when channel is Channel::TwitterProfile' do
let(:channel_type) { 'Channel::TwitterProfile' }
it 'strips all markdown like SMS' do
content = '**bold** [link](https://example.com)'
result = described_class.new(content, channel_type).render
expect(result).to include('bold')
expect(result).to include('link https://example.com')
expect(result).not_to include('**')
expect(result).not_to include('[')
end
it 'preserves URLs from links' do
content = '[Reset password](https://example.com/reset)'
result = described_class.new(content, channel_type).render
expect(result).to eq('Reset password https://example.com/reset')
end
end
context 'when testing all formatting types' do
let(:channel_type) { 'Channel::Whatsapp' }
it 'handles ordered lists' do
content = "1. first\n2. second\n3. third"
result = described_class.new(content, channel_type).render
expect(result).to include('first')
expect(result).to include('second')
expect(result).to include('third')
end
end
context 'when channel is unknown' do
let(:channel_type) { 'Channel::Unknown' }
it 'returns content as-is' do
content = '**bold** _italic_'
result = described_class.new(content, channel_type).render
expect(result).to eq(content)
end
end
end
end