chore: Improve compose new conversation form (#13176)

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Sivin Varghese
2026-01-13 18:52:10 +05:30
committed by GitHub
parent c483034a07
commit 1a220b2982
9 changed files with 165 additions and 92 deletions

View File

@@ -5,6 +5,7 @@ import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
editorKey: { type: String, default: '' },
label: { type: String, default: '' }, label: { type: String, default: '' },
placeholder: { type: String, default: '' }, placeholder: { type: String, default: '' },
focusOnMount: { type: Boolean, default: false }, focusOnMount: { type: Boolean, default: false },
@@ -96,6 +97,7 @@ watch(
]" ]"
> >
<WootEditor <WootEditor
:editor-id="editorKey"
:model-value="modelValue" :model-value="modelValue"
:placeholder="placeholder" :placeholder="placeholder"
:focus-on-mount="focusOnMount" :focus-on-mount="focusOnMount"
@@ -152,6 +154,13 @@ watch(
} }
} }
} }
.ProseMirror-menubar {
width: fit-content !important;
position: relative !important;
top: unset !important;
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
}
} }
} }
} }

View File

@@ -44,6 +44,12 @@ const emit = defineEmits([
const { t } = useI18n(); const { t } = useI18n();
const attachmentId = ref(0);
const generateUid = () => {
attachmentId.value += 1;
return `attachment-${attachmentId.value}`;
};
const uploadAttachment = ref(null); const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false); const isEmojiPickerOpen = ref(false);
@@ -176,7 +182,8 @@ const onPaste = e => {
.filter(file => file.size > 0) .filter(file => file.size > 0)
.forEach(file => { .forEach(file => {
const { name, type, size } = file; const { name, type, size } = file;
onFileUpload({ file, name, type, size }); // Add unique ID for clipboard-pasted files
onFileUpload({ file, name, type, size, id: generateUid() });
}); });
}; };

View File

@@ -7,6 +7,7 @@ import {
appendSignature, appendSignature,
removeSignature, removeSignature,
getEffectiveChannelType, getEffectiveChannelType,
stripUnsupportedMarkdown,
} from 'dashboard/helper/editorHelper'; } from 'dashboard/helper/editorHelper';
import { import {
buildContactableInboxesList, buildContactableInboxesList,
@@ -47,6 +48,8 @@ const emit = defineEmits([
'createConversation', 'createConversation',
]); ]);
const DEFAULT_FORMATTING = 'Context::Default';
const showContactsDropdown = ref(false); const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false); const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false); const showCcEmailsDropdown = ref(false);
@@ -198,10 +201,22 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
showInboxesDropdown.value = true; showInboxesDropdown.value = true;
}; };
const handleInboxAction = ({ value, action, ...rest }) => { const stripMessageFormatting = channelType => {
if (!state.message || !channelType) return;
state.message = stripUnsupportedMarkdown(state.message, channelType, false);
};
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
v$.value.$reset(); v$.value.$reset();
state.message = '';
emit('updateTargetInbox', { ...rest }); // Strip unsupported formatting when changing the target inbox
if (channelType) {
const newChannelType = getEffectiveChannelType(channelType, medium);
stripMessageFormatting(newChannelType);
}
emit('updateTargetInbox', { ...rest, channelType, medium });
showInboxesDropdown.value = false; showInboxesDropdown.value = false;
state.attachedFiles = []; state.attachedFiles = [];
}; };
@@ -221,7 +236,9 @@ const removeSignatureFromMessage = () => {
const removeTargetInbox = value => { const removeTargetInbox = value => {
v$.value.$reset(); v$.value.$reset();
removeSignatureFromMessage(); removeSignatureFromMessage();
state.message = '';
stripMessageFormatting(DEFAULT_FORMATTING);
emit('updateTargetInbox', value); emit('updateTargetInbox', value);
state.attachedFiles = []; state.attachedFiles = [];
}; };
@@ -324,67 +341,68 @@ const shouldShowMessageEditor = computed(() => {
<template> <template>
<div <div
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0" class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
> >
<ContactSelector <div class="flex-1 overflow-y-auto divide-y divide-n-strong">
:contacts="contacts" <ContactSelector
:selected-contact="selectedContact" :contacts="contacts"
:show-contacts-dropdown="showContactsDropdown" :selected-contact="selectedContact"
:is-loading="isLoading" :show-contacts-dropdown="showContactsDropdown"
:is-creating-contact="isCreatingContact" :is-loading="isLoading"
:contact-id="contactId" :is-creating-contact="isCreatingContact"
:contactable-inboxes-list="contactableInboxesList" :contact-id="contactId"
:show-inboxes-dropdown="showInboxesDropdown" :contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isContactInvalid" :show-inboxes-dropdown="showInboxesDropdown"
@search-contacts="handleContactSearch" :has-errors="validationStates.isContactInvalid"
@set-selected-contact="setSelectedContact" @search-contacts="handleContactSearch"
@clear-selected-contact="clearSelectedContact" @set-selected-contact="setSelectedContact"
@update-dropdown="handleDropdownUpdate" @clear-selected-contact="clearSelectedContact"
/> @update-dropdown="handleDropdownUpdate"
<InboxEmptyState v-if="showNoInboxAlert" /> />
<InboxSelector <InboxEmptyState v-if="showNoInboxAlert" />
v-else <InboxSelector
:target-inbox="targetInbox" v-else
:selected-contact="selectedContact" :target-inbox="targetInbox"
:show-inboxes-dropdown="showInboxesDropdown" :selected-contact="selectedContact"
:contactable-inboxes-list="contactableInboxesList" :show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isInboxInvalid" :contactable-inboxes-list="contactableInboxesList"
@update-inbox="removeTargetInbox" :has-errors="validationStates.isInboxInvalid"
@toggle-dropdown="showInboxesDropdown = $event" @update-inbox="removeTargetInbox"
@handle-inbox-action="handleInboxAction" @toggle-dropdown="showInboxesDropdown = $event"
/> @handle-inbox-action="handleInboxAction"
/>
<EmailOptions <EmailOptions
v-if="inboxTypes.isEmail" v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails" v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails" v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject" v-model:subject="state.subject"
:contacts="contacts" :contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown" :show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown" :show-bcc-emails-dropdown="showBccEmailsDropdown"
:is-loading="isLoading" :is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid" :has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails" @search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails" @search-bcc-emails="searchBccEmails"
@update-dropdown="handleDropdownUpdate" @update-dropdown="handleDropdownUpdate"
/> />
<MessageEditor <MessageEditor
v-if="shouldShowMessageEditor" v-if="shouldShowMessageEditor"
v-model="state.message" v-model="state.message"
:message-signature="messageSignature" :message-signature="messageSignature"
:send-with-signature="sendWithSignature" :send-with-signature="sendWithSignature"
:has-errors="validationStates.isMessageInvalid" :has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0" :channel-type="inboxChannelType"
:channel-type="inboxChannelType" :medium="targetInbox?.medium || ''"
:medium="targetInbox?.medium || ''" />
/>
<AttachmentPreviews <AttachmentPreviews
v-if="state.attachedFiles.length > 0" v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles" :attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event" @update:attachments="state.attachedFiles = $event"
/> />
</div>
<ActionButtons <ActionButtons
:attached-files="state.attachedFiles" :attached-files="state.attachedFiles"

View File

@@ -83,7 +83,7 @@ const targetInboxLabel = computed(() => {
<DropdownMenu <DropdownMenu
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown" v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
:menu-items="contactableInboxesList" :menu-items="contactableInboxesList"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5" class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-fit max-w-sm dark:!outline-n-slate-5"
@action="emit('handleInboxAction', $event)" @action="emit('handleInboxAction', $event)"
/> />
</div> </div>

View File

@@ -6,7 +6,6 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({ const props = defineProps({
hasErrors: { type: Boolean, default: false }, hasErrors: { type: Boolean, default: false },
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false }, sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' }, messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' }, channelType: { type: String, default: '' },
@@ -24,14 +23,14 @@ const modelValue = defineModel({
</script> </script>
<template> <template>
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']"> <div class="flex-1 h-full">
<Editor <Editor
:key="editorKey"
v-model="modelValue" v-model="modelValue"
:editor-key="editorKey"
:placeholder=" :placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER') t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
" "
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]" class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
:class=" :class="
hasErrors hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9' ? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'

View File

@@ -230,7 +230,7 @@ const handleBlur = e => emit('blur', e);
v-if="showDropdownMenu" v-if="showDropdownMenu"
:menu-items="filteredMenuItems" :menu-items="filteredMenuItems"
:is-searching="isLoading" :is-searching="isLoading"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5" class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-[inherit] max-w-md dark:!outline-n-slate-5"
@action="handleDropdownAction" @action="handleDropdownAction"
/> />
</div> </div>

View File

@@ -38,9 +38,14 @@ export function extractTextFromMarkdown(markdown) {
* *
* @param {string} markdown - markdown text to process * @param {string} markdown - markdown text to process
* @param {string} channelType - The channel type to check supported formatting * @param {string} channelType - The channel type to check supported formatting
* @param {boolean} cleanWhitespace - Whether to clean up extra whitespace and blank lines (default: true for signatures)
* @returns {string} - The markdown with unsupported formatting removed * @returns {string} - The markdown with unsupported formatting removed
*/ */
export function stripUnsupportedSignatureMarkdown(markdown, channelType) { export function stripUnsupportedMarkdown(
markdown,
channelType,
cleanWhitespace = true
) {
if (!markdown) return ''; if (!markdown) return '';
const { marks = [], nodes = [] } = FORMATTING[channelType] || {}; const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
@@ -55,6 +60,9 @@ export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
); );
}, markdown); }, markdown);
if (!cleanWhitespace) return result;
// Clean whitespace for signatures
return result return result
.split('\n') .split('\n')
.map(line => line.trim()) .map(line => line.trim())
@@ -155,7 +163,7 @@ export function getEffectiveChannelType(channelType, medium) {
export function appendSignature(body, signature, channelType) { export function appendSignature(body, signature, channelType) {
// Strip only unsupported formatting based on channel capabilities // Strip only unsupported formatting based on channel capabilities
const preparedSignature = channelType const preparedSignature = channelType
? stripUnsupportedSignatureMarkdown(signature, channelType) ? stripUnsupportedMarkdown(signature, channelType)
: signature; : signature;
const cleanedSignature = cleanSignature(preparedSignature); const cleanedSignature = cleanSignature(preparedSignature);
// if signature is already present, return body // if signature is already present, return body
@@ -178,7 +186,7 @@ export function appendSignature(body, signature, channelType) {
export function removeSignature(body, signature, channelType) { export function removeSignature(body, signature, channelType) {
// Build unique list of signature variants to try // Build unique list of signature variants to try
const channelStripped = channelType const channelStripped = channelType
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType)) ? cleanSignature(stripUnsupportedMarkdown(signature, channelType))
: null; : null;
const signaturesToTry = [ const signaturesToTry = [
cleanSignature(signature), cleanSignature(signature),

View File

@@ -5,7 +5,7 @@ import {
replaceSignature, replaceSignature,
cleanSignature, cleanSignature,
extractTextFromMarkdown, extractTextFromMarkdown,
stripUnsupportedSignatureMarkdown, stripUnsupportedMarkdown,
insertAtCursor, insertAtCursor,
findNodeToInsertImage, findNodeToInsertImage,
setURLWithQueryAndSize, setURLWithQueryAndSize,
@@ -145,25 +145,19 @@ describe('appendSignature', () => {
}); });
}); });
describe('stripUnsupportedSignatureMarkdown', () => { describe('stripUnsupportedMarkdown', () => {
const richSignature = const richSignature =
'**Bold** _italic_ [link](http://example.com) ![](http://localhost:3000/image.png)'; '**Bold** _italic_ [link](http://example.com) ![](http://localhost:3000/image.png)';
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => { it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
const result = stripUnsupportedSignatureMarkdown( const result = stripUnsupportedMarkdown(richSignature, 'Channel::Email');
richSignature,
'Channel::Email'
);
expect(result).toContain('**Bold**'); expect(result).toContain('**Bold**');
expect(result).toContain('_italic_'); expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)'); expect(result).toContain('[link](http://example.com)');
expect(result).toContain('![](http://localhost:3000/image.png)'); expect(result).toContain('![](http://localhost:3000/image.png)');
}); });
it('strips images but keeps bold/italic for Api channel', () => { it('strips images but keeps bold/italic for Api channel', () => {
const result = stripUnsupportedSignatureMarkdown( const result = stripUnsupportedMarkdown(richSignature, 'Channel::Api');
richSignature,
'Channel::Api'
);
expect(result).toContain('**Bold**'); expect(result).toContain('**Bold**');
expect(result).toContain('_italic_'); expect(result).toContain('_italic_');
expect(result).toContain('link'); // link text kept expect(result).toContain('link'); // link text kept
@@ -171,20 +165,14 @@ describe('stripUnsupportedSignatureMarkdown', () => {
expect(result).not.toContain('![]('); // image removed expect(result).not.toContain('![]('); // image removed
}); });
it('strips images but keeps bold/italic/link for Telegram channel', () => { it('strips images but keeps bold/italic/link for Telegram channel', () => {
const result = stripUnsupportedSignatureMarkdown( const result = stripUnsupportedMarkdown(richSignature, 'Channel::Telegram');
richSignature,
'Channel::Telegram'
);
expect(result).toContain('**Bold**'); expect(result).toContain('**Bold**');
expect(result).toContain('_italic_'); expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)'); expect(result).toContain('[link](http://example.com)');
expect(result).not.toContain('![]('); expect(result).not.toContain('![](');
}); });
it('strips all formatting for SMS channel', () => { it('strips all formatting for SMS channel', () => {
const result = stripUnsupportedSignatureMarkdown( const result = stripUnsupportedMarkdown(richSignature, 'Channel::Sms');
richSignature,
'Channel::Sms'
);
expect(result).toContain('Bold'); expect(result).toContain('Bold');
expect(result).toContain('italic'); expect(result).toContain('italic');
expect(result).toContain('link'); expect(result).toContain('link');
@@ -194,8 +182,52 @@ describe('stripUnsupportedSignatureMarkdown', () => {
expect(result).not.toContain('![]('); expect(result).not.toContain('![](');
}); });
it('returns empty string for empty input', () => { it('returns empty string for empty input', () => {
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe(''); expect(stripUnsupportedMarkdown('', 'Channel::Api')).toBe('');
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe(''); expect(stripUnsupportedMarkdown(null, 'Channel::Api')).toBe('');
});
describe('with cleanWhitespace parameter', () => {
const textWithWhitespace =
'**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces ';
it('cleans whitespace when cleanWhitespace=true (default)', () => {
const result = stripUnsupportedMarkdown(
textWithWhitespace,
'Channel::Api',
true
);
expect(result).toBe(
'**Bold** text\nWith multiple\nLine breaks\nAnd spaces'
);
expect(result).not.toContain('\n\n');
expect(result).not.toContain(' ');
});
it('preserves whitespace when cleanWhitespace=false', () => {
const result = stripUnsupportedMarkdown(
textWithWhitespace,
'Channel::Api',
false
);
expect(result).toContain('\n\n');
expect(result).toContain(' And spaces ');
expect(result).toBe(
'**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces '
);
});
it('strips formatting but preserves whitespace for messages', () => {
const messageWithFormatting = '**Bold**\n\n`code`\n\nNormal text';
const result = stripUnsupportedMarkdown(
messageWithFormatting,
'Channel::Sms',
false
);
expect(result).toBe('Bold\n\ncode\n\nNormal text');
expect(result).toContain('\n\n');
expect(result).not.toContain('**');
expect(result).not.toContain('`');
});
}); });
}); });

View File

@@ -370,7 +370,7 @@ onUnmounted(() => {
/> />
</div> </div>
<section class="flex flex-col flex-grow w-full h-full overflow-hidden"> <section class="flex flex-col flex-grow w-full h-full overflow-hidden">
<div class="w-full max-w-5xl mx-auto z-[60]"> <div class="w-full max-w-5xl mx-auto z-30">
<div class="flex flex-col w-full px-4"> <div class="flex flex-col w-full px-4">
<SearchHeader <SearchHeader
v-model:filters="filters" v-model:filters="filters"