feat: Add plain-text editor for non-rich content channels (#13058)

# Pull Request Template

## Description

This PR restores the plain text editor for all channels except Website,
Email, and API.

## Type of change

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


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] 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
- [ ] 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
This commit is contained in:
Sivin Varghese 2025-12-12 10:14:22 +05:30 committed by GitHub
parent df4c8cf58b
commit 696564863c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 186 additions and 43 deletions

View File

@ -200,6 +200,7 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
const handleInboxAction = ({ value, action, ...rest }) => { const handleInboxAction = ({ value, action, ...rest }) => {
v$.value.$reset(); v$.value.$reset();
state.message = '';
emit('updateTargetInbox', { ...rest }); emit('updateTargetInbox', { ...rest });
showInboxesDropdown.value = false; showInboxesDropdown.value = false;
state.attachedFiles = []; state.attachedFiles = [];
@ -220,6 +221,7 @@ const removeSignatureFromMessage = () => {
const removeTargetInbox = value => { const removeTargetInbox = value => {
v$.value.$reset(); v$.value.$reset();
removeSignatureFromMessage(); removeSignatureFromMessage();
state.message = '';
emit('updateTargetInbox', value); emit('updateTargetInbox', value);
state.attachedFiles = []; state.attachedFiles = [];
}; };
@ -227,6 +229,7 @@ const removeTargetInbox = value => {
const clearSelectedContact = () => { const clearSelectedContact = () => {
removeSignatureFromMessage(); removeSignatureFromMessage();
emit('clearSelectedContact'); emit('clearSelectedContact');
state.message = '';
state.attachedFiles = []; state.attachedFiles = [];
}; };

View File

@ -1,9 +1,10 @@
<script setup> <script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Editor from 'dashboard/components-next/Editor/Editor.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue';
defineProps({ const props = defineProps({
hasErrors: { type: Boolean, default: false }, hasErrors: { type: Boolean, default: false },
hasAttachments: { type: Boolean, default: false }, hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false }, sendWithSignature: { type: Boolean, default: false },
@ -12,6 +13,8 @@ defineProps({
medium: { type: String, default: '' }, medium: { type: String, default: '' },
}); });
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
const { t } = useI18n(); const { t } = useI18n();
const modelValue = defineModel({ const modelValue = defineModel({
@ -23,6 +26,7 @@ const modelValue = defineModel({
<template> <template>
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']"> <div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
<Editor <Editor
:key="editorKey"
v-model="modelValue" v-model="modelValue"
:placeholder=" :placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER') t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')

View File

@ -7,7 +7,9 @@ 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';
@ -44,6 +46,7 @@ import {
appendSignature, appendSignature,
removeSignature, removeSignature,
getEffectiveChannelType, getEffectiveChannelType,
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';
@ -69,6 +72,8 @@ export default {
WhatsappTemplates, WhatsappTemplates,
WootMessageEditor, WootMessageEditor,
QuotedEmailPreview, QuotedEmailPreview,
ResizableTextArea,
CannedResponse,
}, },
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins], mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: { props: {
@ -109,6 +114,8 @@ export default {
recordingAudioState: '', recordingAudioState: '',
recordingAudioDurationText: '', recordingAudioDurationText: '',
replyType: REPLY_EDITOR_MODES.REPLY, replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '', bccEmails: '',
ccEmails: '', ccEmails: '',
toEmails: '', toEmails: '',
@ -137,9 +144,12 @@ export default {
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}), }),
currentContact() { currentContact() {
return this.$store.getters['contacts/getContact']( const senderId = this.currentChat?.meta?.sender?.id;
this.currentChat.meta.sender.id if (!senderId) return {};
); return this.$store.getters['contacts/getContact'](senderId);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
}, },
shouldShowReplyToMessage() { shouldShowReplyToMessage() {
return ( return (
@ -396,6 +406,26 @@ export default {
!!this.quotedEmailText !!this.quotedEmailText
); );
}, },
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
if (this.isAPIInbox) {
const {
display_rich_content_editor: displayRichContentEditor = false,
} = this.uiSettings;
return displayRichContentEditor;
}
return false;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
}, },
watch: { watch: {
currentChat(conversation, oldConversation) { currentChat(conversation, oldConversation) {
@ -438,7 +468,25 @@ export default {
this.resetRecorderAndClearAttachments(); this.resetRecorderAndClearAttachments();
} }
}, },
message() { 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)
: '';
// Autosave the current message draft. // Autosave the current message draft.
this.doAutoSaveDraft(); this.doAutoSaveDraft();
}, },
@ -488,6 +536,7 @@ 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() !== '');
@ -496,6 +545,11 @@ export default {
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);
}, },
@ -564,14 +618,26 @@ export default {
if (this.isPrivate) { if (this.isPrivate) {
return message; return message;
} }
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType( const effectiveChannelType = getEffectiveChannelType(
this.channelType, this.channelType,
this.inbox?.medium || '' this.inbox?.medium || ''
); );
return this.sendWithSignature return this.sendWithSignature
? appendSignature(message, this.messageSignature, effectiveChannelType) ? appendSignature(
: removeSignature(message, this.messageSignature, effectiveChannelType); message,
this.messageSignature,
effectiveChannelType
)
: removeSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
return this.sendWithSignature
? appendSignature(message, this.signatureToApply)
: removeSignature(message, this.signatureToApply);
}, },
removeFromDraft() { removeFromDraft() {
if (this.conversationIdByRoute) { if (this.conversationIdByRoute) {
@ -587,6 +653,7 @@ export default {
Escape: { Escape: {
action: () => { action: () => {
this.hideEmojiPicker(); this.hideEmojiPicker();
this.hideMentions();
}, },
allowOnFocusedInput: true, allowOnFocusedInput: true,
}, },
@ -629,6 +696,9 @@ 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;
} }
@ -762,6 +832,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
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType( const effectiveChannelType = getEffectiveChannelType(
this.channelType, this.channelType,
this.inbox?.medium || '' this.inbox?.medium || ''
@ -771,6 +842,9 @@ export default {
this.messageSignature, this.messageSignature,
effectiveChannelType effectiveChannelType
); );
} else {
message = appendSignature(message, this.signatureToApply);
}
} }
const updatedMessage = replaceVariablesInMessage({ const updatedMessage = replaceVariablesInMessage({
@ -794,21 +868,40 @@ 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
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType( const effectiveChannelType = getEffectiveChannelType(
this.channelType, this.channelType,
this.inbox?.medium || '' this.inbox?.medium || ''
@ -818,6 +911,9 @@ export default {
this.messageSignature, this.messageSignature,
effectiveChannelType effectiveChannelType
); );
} else {
this.message = appendSignature(this.message, this.signatureToApply);
}
} }
this.attachedFiles = []; this.attachedFiles = [];
this.isRecordingAudio = false; this.isRecordingAudio = false;
@ -852,6 +948,9 @@ export default {
this.toggleEmojiPicker(); this.toggleEmojiPicker();
} }
}, },
hideMentions() {
this.showMentions = false;
},
onTypingOn() { onTypingOn() {
this.toggleTyping('on'); this.toggleTyping('on');
}, },
@ -1098,6 +1197,13 @@ 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"
@ -1121,7 +1227,23 @@ 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 popover-prosemirror-menu" class="input popover-prosemirror-menu"
@ -1247,6 +1369,10 @@ 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 {
@ -1266,4 +1392,9 @@ 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

@ -111,10 +111,15 @@ export default {
// watcher, this means that if the value is true, the signature // watcher, this means that if the value is true, the signature
// is supposed to be added, else we remove it. // is supposed to be added, else we remove it.
toggleSignatureInEditor(signatureEnabled) { toggleSignatureInEditor(signatureEnabled) {
const valueWithSignature = signatureEnabled let valueWithSignature = signatureEnabled
? appendSignature(this.modelValue, this.cleanedSignature) ? appendSignature(this.modelValue, this.cleanedSignature)
: removeSignature(this.modelValue, this.cleanedSignature); : removeSignature(this.modelValue, this.cleanedSignature);
// Clean up whitespace when removing signature from empty body
if (!signatureEnabled && !valueWithSignature.trim()) {
valueWithSignature = '';
}
this.$emit('update:modelValue', valueWithSignature); this.$emit('update:modelValue', valueWithSignature);
this.$emit('input', valueWithSignature); this.$emit('input', valueWithSignature);