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:
2
Gemfile
2
Gemfile
@@ -215,7 +215,7 @@ group :production do
|
||||
end
|
||||
|
||||
group :development do
|
||||
gem 'annotate'
|
||||
gem 'annotaterb'
|
||||
gem 'bullet'
|
||||
gem 'letter_opener'
|
||||
gem 'scss_lint', require: false
|
||||
|
||||
@@ -128,9 +128,9 @@ GEM
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.7.0)
|
||||
ruby_llm (~> 1.8.2)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
ast (2.4.3)
|
||||
attr_extras (7.1.0)
|
||||
audited (5.4.1)
|
||||
@@ -1018,7 +1018,7 @@ DEPENDENCIES
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents (>= 0.7.0)
|
||||
annotate
|
||||
annotaterb
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
aws-actionmailbox-ses (~> 0)
|
||||
|
||||
@@ -19,7 +19,6 @@ const props = defineProps({
|
||||
},
|
||||
enableVariables: { type: Boolean, default: false },
|
||||
enableCannedResponses: { type: Boolean, default: true },
|
||||
enabledMenuOptions: { type: Array, default: () => [] },
|
||||
enableCaptainTools: { type: Boolean, default: false },
|
||||
signature: { type: String, default: '' },
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
@@ -102,7 +101,6 @@ watch(
|
||||
:disabled="disabled"
|
||||
:enable-variables="enableVariables"
|
||||
:enable-canned-responses="enableCannedResponses"
|
||||
:enabled-menu-options="enabledMenuOptions"
|
||||
:enable-captain-tools="enableCaptainTools"
|
||||
:signature="signature"
|
||||
:allow-signature="allowSignature"
|
||||
@@ -139,19 +137,6 @@ watch(
|
||||
.editor-wrapper {
|
||||
::v-deep {
|
||||
.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 {
|
||||
p {
|
||||
@apply first:mt-0 !important;
|
||||
|
||||
@@ -172,7 +172,7 @@ const previewArticle = () => {
|
||||
@apply mr-0;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-0 mt-1 !mr-0;
|
||||
@apply p-0 mt-0 !mr-0;
|
||||
|
||||
svg {
|
||||
width: 20px !important;
|
||||
|
||||
@@ -26,13 +26,11 @@ import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import {
|
||||
MESSAGE_EDITOR_MENU_OPTIONS,
|
||||
MESSAGE_EDITOR_IMAGE_RESIZES,
|
||||
} from 'dashboard/constants/editor';
|
||||
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
|
||||
|
||||
import {
|
||||
messageSchema,
|
||||
buildMessageSchema,
|
||||
buildEditor,
|
||||
EditorView,
|
||||
MessageMarkdownTransformer,
|
||||
@@ -53,6 +51,9 @@ import {
|
||||
removeSignature as removeSignatureHelper,
|
||||
scrollCursorIntoView,
|
||||
setURLWithQueryAndSize,
|
||||
getFormattingForEditor,
|
||||
getSelectionCoords,
|
||||
calculateMenuPosition,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
@@ -75,7 +76,6 @@ const props = defineProps({
|
||||
enableCannedResponses: { type: Boolean, default: true },
|
||||
enableCaptainTools: { type: Boolean, default: false },
|
||||
variables: { type: Object, default: () => ({}) },
|
||||
enabledMenuOptions: { type: Array, default: () => [] },
|
||||
signature: { type: String, default: '' },
|
||||
// allowSignature is a kill switch, ensuring no signature methods
|
||||
// are triggered except when this flag is true
|
||||
@@ -103,22 +103,34 @@ const { t } = useI18n();
|
||||
|
||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
|
||||
const createState = (
|
||||
content,
|
||||
placeholder,
|
||||
plugins = [],
|
||||
methods = {},
|
||||
enabledMenuOptions = []
|
||||
) => {
|
||||
const editorSchema = computed(() => {
|
||||
if (!props.channelType) return messageSchema;
|
||||
|
||||
const formatType = props.isPrivate ? DEFAULT_FORMATTING : props.channelType;
|
||||
const formatting = getFormattingForEditor(formatType);
|
||||
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({
|
||||
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
|
||||
doc: new MessageMarkdownTransformer(schema).parse(content),
|
||||
plugins: buildEditor({
|
||||
schema: messageSchema,
|
||||
schema,
|
||||
placeholder,
|
||||
methods,
|
||||
plugins,
|
||||
enabledMenuOptions,
|
||||
enabledMenuOptions: editorMenuOptions.value,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -153,6 +165,8 @@ const range = ref(null);
|
||||
const isImageNodeSelected = ref(false);
|
||||
const toolbarPosition = ref({ top: 0, left: 0 });
|
||||
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;
|
||||
|
||||
// element ref
|
||||
@@ -174,12 +188,6 @@ const shouldShowCannedResponses = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const editorMenuOptions = computed(() => {
|
||||
return props.enabledMenuOptions.length
|
||||
? props.enabledMenuOptions
|
||||
: MESSAGE_EDITOR_MENU_OPTIONS;
|
||||
});
|
||||
|
||||
function createSuggestionPlugin({
|
||||
trigger,
|
||||
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) {
|
||||
if (!props.showImageResizeToolbar) {
|
||||
return;
|
||||
@@ -529,7 +569,9 @@ async function insertNodeIntoEditor(node, from = 0, to = 0) {
|
||||
|
||||
function insertContentIntoEditor(content, defaultFrom = 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);
|
||||
}
|
||||
@@ -596,6 +638,7 @@ function createEditorView() {
|
||||
if (tx.docChanged) {
|
||||
emitOnChange();
|
||||
}
|
||||
checkSelection(state);
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keyup: () => {
|
||||
@@ -761,15 +804,33 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
|
||||
|
||||
.ProseMirror-menubar-wrapper {
|
||||
@apply flex flex-col;
|
||||
@apply flex flex-col gap-3;
|
||||
|
||||
.ProseMirror-menubar {
|
||||
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 {
|
||||
@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 {
|
||||
@@ -860,4 +921,53 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
.editor-warning__message {
|
||||
@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>
|
||||
|
||||
@@ -78,10 +78,6 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showEditorToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isOnPrivateNote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -130,7 +126,6 @@ export default {
|
||||
emits: [
|
||||
'replaceText',
|
||||
'toggleInsertArticle',
|
||||
'toggleEditor',
|
||||
'selectWhatsappTemplate',
|
||||
'selectContentTemplate',
|
||||
'toggleQuotedReply',
|
||||
@@ -325,18 +320,8 @@ export default {
|
||||
sm
|
||||
@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
|
||||
v-if="showAudioPlayStopButton"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
|
||||
:icon="audioRecorderPlayStopIcon"
|
||||
slate
|
||||
faded
|
||||
|
||||
@@ -7,9 +7,7 @@ import { useTrack } from 'dashboard/composables';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import CannedResponse from './CannedResponse.vue';
|
||||
import ReplyToMessage from './ReplyToMessage.vue';
|
||||
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
|
||||
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
|
||||
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
|
||||
import ReplyEmailHead from './ReplyEmailHead.vue';
|
||||
@@ -45,8 +43,6 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
replaceSignature,
|
||||
extractTextFromMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
@@ -61,7 +57,6 @@ export default {
|
||||
ArticleSearchPopover,
|
||||
AttachmentPreview,
|
||||
AudioRecorder,
|
||||
CannedResponse,
|
||||
ReplyBoxBanner,
|
||||
EmojiInput,
|
||||
MessageSignatureMissingAlert,
|
||||
@@ -69,7 +64,6 @@ export default {
|
||||
ReplyEmailHead,
|
||||
ReplyToMessage,
|
||||
ReplyTopPanel,
|
||||
ResizableTextArea,
|
||||
ContentTemplates,
|
||||
WhatsappTemplates,
|
||||
WootMessageEditor,
|
||||
@@ -86,7 +80,6 @@ export default {
|
||||
setup() {
|
||||
const {
|
||||
uiSettings,
|
||||
updateUISettings,
|
||||
isEditorHotKeyEnabled,
|
||||
fetchSignatureFlagFromUISettings,
|
||||
setQuotedReplyFlagForInbox,
|
||||
@@ -97,7 +90,6 @@ export default {
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
updateUISettings,
|
||||
isEditorHotKeyEnabled,
|
||||
fetchSignatureFlagFromUISettings,
|
||||
setQuotedReplyFlagForInbox,
|
||||
@@ -115,10 +107,7 @@ export default {
|
||||
isRecordingAudio: false,
|
||||
recordingAudioState: '',
|
||||
recordingAudioDurationText: '',
|
||||
isUploading: false,
|
||||
replyType: REPLY_EDITOR_MODES.REPLY,
|
||||
mentionSearchKey: '',
|
||||
hasSlashCommand: false,
|
||||
bccEmails: '',
|
||||
ccEmails: '',
|
||||
toEmails: '',
|
||||
@@ -159,20 +148,6 @@ export default {
|
||||
!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() {
|
||||
// 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
|
||||
@@ -300,9 +275,6 @@ export default {
|
||||
hasAttachments() {
|
||||
return this.attachedFiles.length;
|
||||
},
|
||||
isRichEditorEnabled() {
|
||||
return this.isAWebWidgetInbox || this.isAnEmailChannel;
|
||||
},
|
||||
showAudioRecorder() {
|
||||
return !this.isOnPrivateNote && this.showFileUpload;
|
||||
},
|
||||
@@ -342,21 +314,11 @@ export default {
|
||||
return !this.isPrivate && this.sendWithSignature;
|
||||
},
|
||||
isSignatureAvailable() {
|
||||
return !!this.signatureToApply;
|
||||
return !!this.messageSignature;
|
||||
},
|
||||
sendWithSignature() {
|
||||
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() {
|
||||
return this.currentChat.id;
|
||||
},
|
||||
@@ -383,12 +345,6 @@ export default {
|
||||
});
|
||||
return variables;
|
||||
},
|
||||
// ensure that the signature is plain text depending on `showRichContentEditor`
|
||||
signatureToApply() {
|
||||
return this.showRichContentEditor
|
||||
? this.messageSignature
|
||||
: extractTextFromMarkdown(this.messageSignature);
|
||||
},
|
||||
connectedPortalSlug() {
|
||||
const { help_center: portal = {} } = this.inbox;
|
||||
const { slug = '' } = portal;
|
||||
@@ -481,25 +437,7 @@ export default {
|
||||
this.resetRecorderAndClearAttachments();
|
||||
}
|
||||
},
|
||||
message(updatedMessage) {
|
||||
// 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)
|
||||
: '';
|
||||
|
||||
message() {
|
||||
// Autosave the current message draft.
|
||||
this.doAutoSaveDraft();
|
||||
},
|
||||
@@ -512,7 +450,7 @@ export default {
|
||||
mounted() {
|
||||
this.getFromDraft();
|
||||
// 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('keydown', this.handleKeyEvents);
|
||||
this.setCCAndToEmailsFromLastChat();
|
||||
@@ -549,7 +487,6 @@ export default {
|
||||
methods: {
|
||||
handleInsert(article) {
|
||||
const { url, title } = article;
|
||||
if (this.isRichEditorEnabled) {
|
||||
// Removing empty lines from the title
|
||||
const lines = title.split('\n');
|
||||
const nonEmptyLines = lines.filter(line => line.trim() !== '');
|
||||
@@ -558,36 +495,9 @@ export default {
|
||||
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
|
||||
`[${filteredMarkdown}](${url})`
|
||||
);
|
||||
} else {
|
||||
this.addIntoEditor(
|
||||
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!this.isAnEmailChannel) {
|
||||
return;
|
||||
@@ -655,8 +565,8 @@ export default {
|
||||
}
|
||||
|
||||
return this.sendWithSignature
|
||||
? appendSignature(message, this.signatureToApply)
|
||||
: removeSignature(message, this.signatureToApply);
|
||||
? appendSignature(message, this.messageSignature)
|
||||
: removeSignature(message, this.messageSignature);
|
||||
},
|
||||
removeFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
@@ -672,7 +582,6 @@ export default {
|
||||
Escape: {
|
||||
action: () => {
|
||||
this.hideEmojiPicker();
|
||||
this.hideMentions();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
@@ -715,9 +624,6 @@ export default {
|
||||
},
|
||||
onPaste(e) {
|
||||
const data = e.clipboardData.files;
|
||||
if (!this.showRichContentEditor && data.length !== 0) {
|
||||
this.$refs.messageInput.$el.blur();
|
||||
}
|
||||
if (!data.length || !data[0]) {
|
||||
return;
|
||||
}
|
||||
@@ -851,7 +757,7 @@ 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
|
||||
message = appendSignature(message, this.signatureToApply);
|
||||
message = appendSignature(message, this.messageSignature);
|
||||
}
|
||||
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
@@ -875,40 +781,22 @@ export default {
|
||||
});
|
||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
this.replyType = mode;
|
||||
if (this.showRichContentEditor) {
|
||||
if (this.isRecordingAudio) {
|
||||
this.toggleAudioRecorder();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.$nextTick(() => this.$refs.messageInput.focus());
|
||||
},
|
||||
clearEditorSelection() {
|
||||
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) {
|
||||
if (this.showRichContentEditor) {
|
||||
this.updateEditorSelectionWith = content;
|
||||
this.onFocus();
|
||||
}
|
||||
if (!this.showRichContentEditor) {
|
||||
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
|
||||
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
|
||||
}
|
||||
},
|
||||
clearMessage() {
|
||||
this.message = '';
|
||||
if (this.sendWithSignature && !this.isPrivate) {
|
||||
// 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.isRecordingAudio = false;
|
||||
@@ -926,19 +814,15 @@ export default {
|
||||
},
|
||||
toggleAudioRecorder() {
|
||||
this.isRecordingAudio = !this.isRecordingAudio;
|
||||
this.isRecorderAudioStopped = !this.isRecordingAudio;
|
||||
if (!this.isRecordingAudio) {
|
||||
this.resetAudioRecorderInput();
|
||||
}
|
||||
},
|
||||
toggleAudioRecorderPlayPause() {
|
||||
if (!this.isRecordingAudio) {
|
||||
return;
|
||||
}
|
||||
if (!this.isRecorderAudioStopped) {
|
||||
this.isRecorderAudioStopped = true;
|
||||
if (!this.$refs.audioRecorderInput) return;
|
||||
if (!this.recordingAudioState) {
|
||||
this.$refs.audioRecorderInput.stopRecording();
|
||||
} else if (this.isRecorderAudioStopped) {
|
||||
} else {
|
||||
this.$refs.audioRecorderInput.playPause();
|
||||
}
|
||||
},
|
||||
@@ -947,9 +831,6 @@ export default {
|
||||
this.toggleEmojiPicker();
|
||||
}
|
||||
},
|
||||
hideMentions() {
|
||||
this.showMentions = false;
|
||||
},
|
||||
onTypingOn() {
|
||||
this.toggleTyping('on');
|
||||
},
|
||||
@@ -1196,13 +1077,6 @@ export default {
|
||||
:message="inReplyTo"
|
||||
@dismiss="resetReplyToMessage"
|
||||
/>
|
||||
<CannedResponse
|
||||
v-if="showMentions && hasSlashCommand"
|
||||
v-on-clickaway="hideMentions"
|
||||
class="normal-editor__canned-box"
|
||||
:search-key="mentionSearchKey"
|
||||
@replace="replaceText"
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
@@ -1226,33 +1100,17 @@ export default {
|
||||
@play="recordingAudioState = 'playing'"
|
||||
@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
|
||||
v-else
|
||||
v-model="message"
|
||||
:editor-id="editorStateId"
|
||||
class="input"
|
||||
class="input popover-prosemirror-menu"
|
||||
:is-private="isOnPrivateNote"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:update-selection-with="updateEditorSelectionWith"
|
||||
:min-height="4"
|
||||
enable-variables
|
||||
:variables="messageVariables"
|
||||
:signature="signatureToApply"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:channel-type="channelType"
|
||||
@typing-off="onTypingOff"
|
||||
@@ -1302,7 +1160,6 @@ export default {
|
||||
:recording-audio-state="recordingAudioState"
|
||||
:send-button-text="replyButtonLabel"
|
||||
:show-audio-recorder="showAudioRecorder"
|
||||
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
|
||||
:show-emoji-picker="showEmojiPicker"
|
||||
:show-file-upload="showFileUpload"
|
||||
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
|
||||
@@ -1315,7 +1172,6 @@ export default {
|
||||
:new-conversation-modal-active="newConversationModalActive"
|
||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||
@select-content-template="openContentTemplateModal"
|
||||
@toggle-editor="toggleRichContentEditor"
|
||||
@replace-text="replaceText"
|
||||
@toggle-insert-article="toggleInsertArticle"
|
||||
@toggle-quoted-reply="toggleQuotedReply"
|
||||
@@ -1369,10 +1225,6 @@ export default {
|
||||
|
||||
.reply-box__top {
|
||||
@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 {
|
||||
@@ -1392,9 +1244,4 @@ export default {
|
||||
@apply ltr:left-1 rtl:right-1 -bottom-2;
|
||||
}
|
||||
}
|
||||
|
||||
.normal-editor__canned-box {
|
||||
width: calc(100% - 2 * 1rem);
|
||||
left: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,23 +1,143 @@
|
||||
export const MESSAGE_EDITOR_MENU_OPTIONS = [
|
||||
// Formatting rules for different contexts (channels and special contexts)
|
||||
// marks: inline formatting (strong, em, code, link, strike)
|
||||
// nodes: block structures (bulletList, orderedList, codeBlock, blockquote)
|
||||
export const FORMATTING = {
|
||||
// Channel formatting
|
||||
'Channel::Email': {
|
||||
marks: ['strong', 'em', 'code', 'link'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
|
||||
menu: [
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'link',
|
||||
'undo',
|
||||
'redo',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'code',
|
||||
];
|
||||
|
||||
export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
|
||||
'strong',
|
||||
'em',
|
||||
'link',
|
||||
'undo',
|
||||
'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 = [
|
||||
'strong',
|
||||
'em',
|
||||
@@ -33,14 +153,7 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
||||
'code',
|
||||
];
|
||||
|
||||
export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
|
||||
'strong',
|
||||
'em',
|
||||
'link',
|
||||
'undo',
|
||||
'redo',
|
||||
];
|
||||
|
||||
// Editor image resize options for Message Editor
|
||||
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
|
||||
{
|
||||
name: 'Small',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
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.
|
||||
@@ -314,7 +315,7 @@ const createNode = (editorView, nodeType, content) => {
|
||||
return mentionNode;
|
||||
}
|
||||
case 'cannedResponse':
|
||||
return new MessageMarkdownTransformer(messageSchema).parse(content);
|
||||
return new MessageMarkdownTransformer(state.schema).parse(content);
|
||||
case 'variable':
|
||||
return state.schema.text(`{{${content}}}`);
|
||||
case 'emoji':
|
||||
@@ -389,3 +390,85 @@ export const getContentNode = (
|
||||
? creator(editorView, content, from, to, variables)
|
||||
: { 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 */
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
|
||||
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
|
||||
import { getContentNode } from '../editorHelper';
|
||||
import {
|
||||
MessageMarkdownTransformer,
|
||||
messageSchema,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
|
||||
vi.mock('@chatwoot/prosemirror-schema', () => ({
|
||||
MessageMarkdownTransformer: vi.fn(),
|
||||
messageSchema: {},
|
||||
}));
|
||||
|
||||
vi.mock('@chatwoot/utils', () => ({
|
||||
@@ -62,12 +58,18 @@ describe('getContentNode', () => {
|
||||
const to = 10;
|
||||
const updatedMessage = 'Hello John';
|
||||
|
||||
replaceVariablesInMessage.mockReturnValue(updatedMessage);
|
||||
MessageMarkdownTransformer.mockImplementation(() => ({
|
||||
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
|
||||
}));
|
||||
// Mock the node that will be returned by parse
|
||||
const mockNode = { 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,
|
||||
'cannedResponse',
|
||||
content,
|
||||
@@ -79,8 +81,15 @@ describe('getContentNode', () => {
|
||||
message: content,
|
||||
variables,
|
||||
});
|
||||
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
|
||||
expect(node.textContent).toBe(updatedMessage);
|
||||
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
findNodeToInsertImage,
|
||||
setURLWithQueryAndSize,
|
||||
getContentNode,
|
||||
getFormattingForEditor,
|
||||
getSelectionCoords,
|
||||
getMenuAnchor,
|
||||
calculateMenuPosition,
|
||||
} from '../editorHelper';
|
||||
import { FORMATTING } from 'dashboard/constants/editor';
|
||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||
import { EditorView } from '@chatwoot/prosemirror-schema';
|
||||
import { Schema } from 'prosemirror-model';
|
||||
@@ -258,15 +263,11 @@ describe('insertAtCursor', () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should unwrap doc nodes that are wrapped in a paragraph', () => {
|
||||
const docNode = schema.node('doc', null, [
|
||||
schema.node('paragraph', null, [schema.text('Hello')]),
|
||||
]);
|
||||
|
||||
it('should insert text node at cursor position', () => {
|
||||
const editorState = createEditorState();
|
||||
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
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,7 +196,6 @@
|
||||
"INSERT_READ_MORE": "Read more",
|
||||
"DISMISS_REPLY": "Dismiss reply",
|
||||
"REPLYING_TO": "Replying to:",
|
||||
"TIP_FORMAT_ICON": "Show rich text editor",
|
||||
"TIP_EMOJI_ICON": "Show emoji selector",
|
||||
"TIP_ATTACH_ICON": "Attach files",
|
||||
"TIP_AUDIORECORDER_ICON": "Record audio",
|
||||
|
||||
@@ -27,7 +27,6 @@ import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
|
||||
@@ -81,7 +80,6 @@ export default {
|
||||
selectedTabIndex: 0,
|
||||
selectedPortalSlug: '',
|
||||
showBusinessNameInput: false,
|
||||
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
|
||||
healthData: null,
|
||||
isLoadingHealth: false,
|
||||
healthError: null,
|
||||
@@ -626,7 +624,7 @@ export default {
|
||||
)
|
||||
"
|
||||
:max-length="255"
|
||||
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
|
||||
channel-type="Context::InboxSettings"
|
||||
/>
|
||||
|
||||
<label v-if="isAWebWidgetInbox" class="pb-4">
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/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 Avatar from 'next/avatar/Avatar.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
@@ -76,7 +75,6 @@ export default {
|
||||
checked: false,
|
||||
},
|
||||
],
|
||||
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -337,7 +335,7 @@ export default {
|
||||
)
|
||||
"
|
||||
:max-length="255"
|
||||
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
|
||||
channel-type="Context::InboxSettings"
|
||||
class="mb-4"
|
||||
/>
|
||||
<label>
|
||||
|
||||
@@ -5,7 +5,6 @@ import router from '../../../../index';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import PageHeader from '../../SettingsSubPageHeader.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';
|
||||
|
||||
export default {
|
||||
@@ -24,7 +23,6 @@ export default {
|
||||
channelWelcomeTagline: '',
|
||||
greetingEnabled: false,
|
||||
greetingMessage: '',
|
||||
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -147,7 +145,7 @@ export default {
|
||||
)
|
||||
"
|
||||
:max-length="255"
|
||||
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
|
||||
channel-type="Context::InboxSettings"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, watch } from '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';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -12,7 +11,6 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updateSignature']);
|
||||
const customEditorMenuList = MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS;
|
||||
const signature = ref(props.messageSignature);
|
||||
watch(
|
||||
() => props.messageSignature ?? '',
|
||||
@@ -34,7 +32,7 @@ const updateSignature = () => {
|
||||
class="message-editor h-[10rem] !px-3"
|
||||
is-format-mode
|
||||
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
|
||||
:enabled-menu-options="customEditorMenuList"
|
||||
channel-type="Context::MessageSignature"
|
||||
:enable-suggestions="false"
|
||||
show-image-resize-toolbar
|
||||
/>
|
||||
|
||||
@@ -130,30 +130,39 @@ class Channel::Telegram < ApplicationRecord
|
||||
def convert_markdown_to_telegram_html(text)
|
||||
# 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'
|
||||
text = CGI.escapeHTML(text.gsub("\n", '<br>'))
|
||||
# Escape HTML entities first to prevent HTML injection
|
||||
# This ensures only markdown syntax is converted, not raw HTML
|
||||
escaped_text = CGI.escapeHTML(text)
|
||||
|
||||
# convert markdown to html
|
||||
html = CommonMarker.render_html(text).strip
|
||||
# Parse markdown with extensions:
|
||||
# - 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
|
||||
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html, tags: %w[b strong i em u ins s strike del a code pre blockquote],
|
||||
# Convert paragraph breaks to double newlines to preserve them
|
||||
# CommonMarker creates <p> tags for paragraph breaks, but Telegram doesn't support <p>
|
||||
html_with_breaks = html.gsub(%r{</p>\s*<p>}, "\n\n")
|
||||
|
||||
# Remove opening and closing <p> tags
|
||||
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])
|
||||
|
||||
# converted escaped br tags to \n
|
||||
stripped_html.gsub('<br>', "\n")
|
||||
# Convert <br /> tags to newlines for Telegram
|
||||
stripped_html.gsub(%r{<br\s*/?>}, "\n")
|
||||
end
|
||||
|
||||
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_connection_id] = business_connection_id if business_connection_id
|
||||
|
||||
HTTParty.post("#{telegram_api_url}/sendMessage",
|
||||
body: {
|
||||
chat_id: chat_id,
|
||||
text: text_payload,
|
||||
text: text,
|
||||
reply_markup: reply_markup,
|
||||
parse_mode: 'HTML',
|
||||
reply_to_message_id: reply_to_message_id
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
class MessageContentPresenter < SimpleDelegator
|
||||
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
|
||||
|
||||
Messages::MarkdownRendererService.new(
|
||||
content_to_send,
|
||||
conversation.inbox.channel_type
|
||||
).render
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
64
app/services/messages/markdown_renderer_service.rb
Normal file
64
app/services/messages/markdown_renderer_service.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
36
app/services/messages/markdown_renderers/line_renderer.rb
Normal file
36
app/services/messages/markdown_renderers/line_renderer.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,7 +1,7 @@
|
||||
<% if @message.content_attributes.dig('email', 'html_content', 'reply').present? %>
|
||||
<%= @message.content_attributes.dig('email', 'html_content', 'reply').html_safe %>
|
||||
<% elsif @message.content %>
|
||||
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
|
||||
<%= @message.outgoing_content.html_safe %>
|
||||
<% end %>
|
||||
<% if @large_attachments.present? %>
|
||||
<p>Attachments:</p>
|
||||
|
||||
@@ -5,7 +5,7 @@ class ChatwootMarkdownRenderer
|
||||
|
||||
def render_message
|
||||
markdown_renderer = BaseMarkdownRenderer.new
|
||||
doc = CommonMarker.render_doc(@content, :DEFAULT)
|
||||
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough])
|
||||
html = markdown_renderer.render(doc)
|
||||
render_as_html_safe(html)
|
||||
end
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
# 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.
|
||||
if Rails.env.development?
|
||||
require 'annotate'
|
||||
require 'annotate_rb'
|
||||
|
||||
AnnotateRb::Core.load_rake_tasks
|
||||
|
||||
task :set_annotation_options do
|
||||
# You can override any of these by setting an environment variable of the
|
||||
# same name.
|
||||
Annotate.set_defaults(
|
||||
AnnotateRb::Options.set_defaults(
|
||||
'additional_file_patterns' => [],
|
||||
'routes' => 'false',
|
||||
'models' => 'true',
|
||||
@@ -55,6 +58,4 @@ if Rails.env.development?
|
||||
'with_comment' => 'true'
|
||||
)
|
||||
end
|
||||
|
||||
Annotate.load_tasks
|
||||
end
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"dependencies": {
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.2.5",
|
||||
"@chatwoot/utils": "^0.0.51",
|
||||
"@formkit/core": "^1.6.7",
|
||||
"@formkit/vue": "^1.6.7",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -20,8 +20,8 @@ importers:
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
'@chatwoot/prosemirror-schema':
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
specifier: 1.2.5
|
||||
version: 1.2.5
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.51
|
||||
version: 0.0.51
|
||||
@@ -421,8 +421,8 @@ packages:
|
||||
'@chatwoot/ninja-keys@1.2.3':
|
||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.2.3':
|
||||
resolution: {integrity: sha512-q/EfirVK9jt8FJAx3Gf6y3LoVadmYVLknbYvPrkUe81WO0f2mkZ/kY2UQgpUISVvOGEkCH4bkfYMp5UQ+Buz3g==}
|
||||
'@chatwoot/prosemirror-schema@1.2.5':
|
||||
resolution: {integrity: sha512-nwi0G17jLiRwIzjjQXr9gTZRcDf5BQzo44XxO6CItfR02W5RExugurvHlpc88R4tUrDtIQHGM2Q2vijvFUNIkA==}
|
||||
|
||||
'@chatwoot/utils@0.0.51':
|
||||
resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==}
|
||||
@@ -4862,7 +4862,7 @@ snapshots:
|
||||
hotkeys-js: 3.8.7
|
||||
lit: 2.2.6
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.2.3':
|
||||
'@chatwoot/prosemirror-schema@1.2.5':
|
||||
dependencies:
|
||||
markdown-it-sup: 2.0.0
|
||||
prosemirror-commands: 1.6.0
|
||||
|
||||
@@ -10,7 +10,7 @@ RSpec.describe ChatwootMarkdownRenderer do
|
||||
let(:html_content) { '<p>This is a <em>test</em> content with <sup>markdown</sup></p>' }
|
||||
|
||||
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(markdown_renderer).to receive(:render).with(doc).and_return(html_content)
|
||||
end
|
||||
@@ -86,6 +86,7 @@ RSpec.describe ChatwootMarkdownRenderer do
|
||||
let(:rendered_content) { renderer.render_markdown_to_plain_text }
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ RSpec.describe MessageContentPresenter do
|
||||
let(:content_type) { 'text' }
|
||||
let(:content) { 'Regular message' }
|
||||
|
||||
it 'returns regular content' do
|
||||
expect(presenter.outgoing_content).to eq('Regular message')
|
||||
it 'returns content transformed for channel (HTML for WebWidget)' do
|
||||
expect(presenter.outgoing_content).to eq("<p>Regular message</p>\n")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,8 +23,8 @@ RSpec.describe MessageContentPresenter do
|
||||
allow(message.inbox).to receive(:web_widget?).and_return(true)
|
||||
end
|
||||
|
||||
it 'returns regular content without survey URL' do
|
||||
expect(presenter.outgoing_content).to eq('Rate your experience')
|
||||
it 'returns content without survey URL (HTML for WebWidget)' do
|
||||
expect(presenter.outgoing_content).to eq("<p>Rate your experience</p>\n")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,18 +36,20 @@ RSpec.describe MessageContentPresenter do
|
||||
allow(message.inbox).to receive(:web_widget?).and_return(false)
|
||||
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
|
||||
expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
|
||||
expect(presenter.outgoing_content).to include(expected_url)
|
||||
expect(presenter.outgoing_content).to include('<p>')
|
||||
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
|
||||
allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom CSAT message' })
|
||||
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
|
||||
|
||||
366
spec/services/messages/markdown_renderer_service_spec.rb
Normal file
366
spec/services/messages/markdown_renderer_service_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user