mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
Merge pull request #1134 from dealerweb/feat/recipient-mentions
feat: mention a recipient by first name with @ in the message body
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
|
||||
// The real composer around the real editor and its "@" recipient list: the
|
||||
// two share the keyboard, and the composer handles Escape (close) and
|
||||
// Ctrl+Enter (send) in the capture phase, before the editor sees them.
|
||||
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
|
||||
// ─── Composer dependency mocks (as in composer-close-guard.test.tsx) ──────────
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: (...args: unknown[]) => updateSetting(...args),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onComposeSend: { intercept: async () => true },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DRAFT = {
|
||||
to: 'Bob Builder <bob@example.com>, carol.smith@example.com',
|
||||
cc: '',
|
||||
bcc: 'Geheim <geheim@example.com>',
|
||||
subject: 'Half-written',
|
||||
body: '<p>Hallo</p>',
|
||||
showCc: false,
|
||||
showBcc: true,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
/** Lets the suggestion's async item lookup finish. */
|
||||
const settle = () => act(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
|
||||
|
||||
/** Lets the editor's delayed focus (and scroll to the cursor) run inside the test. */
|
||||
const nextFrame = () => act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
|
||||
|
||||
async function renderComposer() {
|
||||
const onClose = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT} onClose={onClose} />);
|
||||
// An edit makes the draft dirty, so closing asks first instead of closing.
|
||||
fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
|
||||
// TipTap keeps its editor on the editable element.
|
||||
const dom = await waitFor(() => {
|
||||
const el = document.querySelector('.ProseMirror') as (HTMLElement & { editor?: Editor }) | null;
|
||||
expect(el?.editor).toBeTruthy();
|
||||
return el!;
|
||||
});
|
||||
const editor = dom.editor!;
|
||||
// A new message focuses the To field 100ms after opening. Let that happen
|
||||
// first: arriving later, it would blur the editor and close the list.
|
||||
const to = within(screen.getByTestId('composer-to')).getByRole('combobox');
|
||||
await waitFor(() => expect(document.activeElement).toBe(to));
|
||||
act(() => { editor.commands.setTextSelection(editor.state.doc.content.size - 1); });
|
||||
return { editor, onClose };
|
||||
}
|
||||
|
||||
async function typeInto(editor: Editor, text: string) {
|
||||
act(() => { editor.view.dispatch(editor.state.tr.insertText(text)); });
|
||||
await settle();
|
||||
}
|
||||
|
||||
const press = (editor: Editor, key: string, init: KeyboardEventInit = {}) =>
|
||||
fireEvent.keyDown(editor.view.dom, { key, ...init });
|
||||
|
||||
const mentionList = () => screen.queryByRole('listbox', { name: 'mention_recipients' });
|
||||
|
||||
describe('composer with the @ recipient list', () => {
|
||||
// jsdom has no layout. After an insertion the editor focuses a frame later
|
||||
// and ProseMirror measures a Range to scroll the cursor into view.
|
||||
const range = Range.prototype as unknown as Record<string, unknown>;
|
||||
beforeAll(() => {
|
||||
range.getClientRects = () => [];
|
||||
range.getBoundingClientRect = () => ({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 });
|
||||
});
|
||||
afterAll(() => {
|
||||
delete range.getClientRects;
|
||||
delete range.getBoundingClientRect;
|
||||
});
|
||||
|
||||
it('lists To recipients only - never Bcc', async () => {
|
||||
const { editor } = await renderComposer();
|
||||
await typeInto(editor, ' @');
|
||||
const options = within(await screen.findByRole('listbox', { name: 'mention_recipients' })).getAllByRole('option');
|
||||
expect(options.map((o) => o.textContent)).toEqual(['Bob Builderbob@example.com', 'Carolcarol.smith@example.com']);
|
||||
});
|
||||
|
||||
it('lets Escape close the open list, not the composer; the next Escape asks to close', async () => {
|
||||
const { editor, onClose } = await renderComposer();
|
||||
await typeInto(editor, ' @');
|
||||
await screen.findByRole('listbox', { name: 'mention_recipients' });
|
||||
|
||||
press(editor, 'Escape');
|
||||
await waitFor(() => expect(mentionList()).toBeNull());
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
press(editor, 'Escape');
|
||||
expect(await screen.findByRole('alertdialog')).toHaveTextContent('close_draft_title');
|
||||
});
|
||||
|
||||
it('keeps Ctrl+Enter as the send shortcut while the list is open', async () => {
|
||||
const { editor } = await renderComposer();
|
||||
await typeInto(editor, ' @');
|
||||
await screen.findByRole('listbox', { name: 'mention_recipients' });
|
||||
// The composer takes it first; the editor then leaves it alone - no
|
||||
// recipient picked, no line break.
|
||||
expect(press(editor, 'Enter', { ctrlKey: true })).toBe(false);
|
||||
expect(editor.getText()).toBe('Hallo @');
|
||||
});
|
||||
|
||||
it('picks with the arrows and Enter, or with Tab, instead of breaking the line', async () => {
|
||||
const { editor } = await renderComposer();
|
||||
await typeInto(editor, ' @');
|
||||
await screen.findByRole('listbox', { name: 'mention_recipients' });
|
||||
press(editor, 'ArrowDown');
|
||||
press(editor, 'Enter');
|
||||
await nextFrame();
|
||||
expect(editor.getText()).toBe('Hallo @Carol ');
|
||||
|
||||
await typeInto(editor, '@b');
|
||||
await screen.findByRole('listbox', { name: 'mention_recipients' });
|
||||
press(editor, 'Tab');
|
||||
await nextFrame();
|
||||
expect(editor.getText()).toBe('Hallo @Carol @Bob ');
|
||||
expect(editor.state.doc.childCount).toBe(1);
|
||||
expect(mentionList()).toBeNull();
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the list when the focus moves to another field, which gets Escape back', async () => {
|
||||
const { editor } = await renderComposer();
|
||||
act(() => { editor.view.focus(); });
|
||||
expect(document.activeElement).toBe(editor.view.dom);
|
||||
await typeInto(editor, ' @');
|
||||
await screen.findByRole('listbox', { name: 'mention_recipients' });
|
||||
|
||||
// Shift+Tab, say: the subject field takes the focus.
|
||||
const subject = screen.getByDisplayValue('Half-written more');
|
||||
act(() => { subject.focus(); });
|
||||
await waitFor(() => expect(mentionList()).toBeNull());
|
||||
|
||||
fireEvent.keyDown(subject, { key: 'Escape' });
|
||||
expect(await screen.findByRole('alertdialog')).toHaveTextContent('close_draft_title');
|
||||
});
|
||||
|
||||
it('closes the list on a click elsewhere, but not on a click on the list itself', async () => {
|
||||
const { editor } = await renderComposer();
|
||||
await typeInto(editor, ' @');
|
||||
const list = await screen.findByRole('listbox', { name: 'mention_recipients' });
|
||||
|
||||
// Its scrollbar or padding: the editor keeps the focus, the list stays.
|
||||
expect(fireEvent.mouseDown(list)).toBe(false);
|
||||
fireEvent.pointerDown(list);
|
||||
expect(mentionList()).not.toBeNull();
|
||||
|
||||
fireEvent.pointerDown(screen.getByDisplayValue('Half-written more'));
|
||||
await waitFor(() => expect(mentionList()).toBeNull());
|
||||
});
|
||||
|
||||
it('inserts a clicked recipient', async () => {
|
||||
const { editor } = await renderComposer();
|
||||
await typeInto(editor, ' @');
|
||||
const list = await screen.findByRole('listbox', { name: 'mention_recipients' });
|
||||
fireEvent.mouseDown(within(list).getAllByRole('option')[0]);
|
||||
await nextFrame();
|
||||
expect(editor.getText()).toBe('Hallo @Bob ');
|
||||
await waitFor(() => expect(mentionList()).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { Editor, type AnyExtension } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { Table } from '@tiptap/extension-table';
|
||||
import { TableRow } from '@tiptap/extension-table-row';
|
||||
import { TableHeader } from '@tiptap/extension-table-header';
|
||||
import { TableCell } from '@tiptap/extension-table-cell';
|
||||
import type { SuggestionProps } from '@tiptap/suggestion';
|
||||
|
||||
import { RichTextEditor } from '../rich-text-editor';
|
||||
import { RecipientMention, insertMention } from '../recipient-mention';
|
||||
import { buildMentionCandidates, type MentionCandidate } from '@/lib/recipient-mentions';
|
||||
|
||||
// The avatar resolves app config and favicons; neither matters here.
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
|
||||
const PEOPLE = buildMentionCandidates(
|
||||
[{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' }, { email: 'eva.weber@dornig.de' }],
|
||||
[],
|
||||
);
|
||||
|
||||
/** Lets the suggestion's async item lookup finish. */
|
||||
const settle = () => act(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
|
||||
|
||||
/** Lets the editor's delayed focus (and scroll to the cursor) run inside the test. */
|
||||
const nextFrame = () => act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
|
||||
|
||||
const type = (editor: Editor, text: string) => act(() => {
|
||||
editor.view.dispatch(editor.state.tr.insertText(text));
|
||||
});
|
||||
|
||||
const press = (editor: Editor, key: string) => {
|
||||
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
|
||||
act(() => { editor.view.dom.dispatchEvent(event); });
|
||||
return event;
|
||||
};
|
||||
|
||||
/** An editor whose suggestion list picks the first item on Enter or Tab. */
|
||||
function setup(content: string, candidates: MentionCandidate[] = PEOPLE, extensions: AnyExtension[] = [StarterKit]) {
|
||||
const suggestion: { props: SuggestionProps<MentionCandidate, MentionCandidate> | null } = { props: null };
|
||||
const editor = new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [
|
||||
...extensions,
|
||||
RecipientMention.configure({
|
||||
getCandidates: () => candidates,
|
||||
render: () => ({
|
||||
onStart: (props) => { suggestion.props = props; },
|
||||
onUpdate: (props) => { if (!props.loading) suggestion.props = props; },
|
||||
onKeyDown: ({ event }) => {
|
||||
if ((event.key !== 'Enter' && event.key !== 'Tab') || !suggestion.props?.items.length) return false;
|
||||
suggestion.props.command(suggestion.props.items[0]);
|
||||
return true;
|
||||
},
|
||||
onExit: () => { suggestion.props = null; },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
content,
|
||||
});
|
||||
editor.commands.setTextSelection(endOfText(editor));
|
||||
return { editor, suggestion };
|
||||
}
|
||||
|
||||
/** Position after the document's last text (or inside an empty first block). */
|
||||
function endOfText(editor: Editor): number {
|
||||
let end = 1;
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.isText) end = pos + node.nodeSize;
|
||||
});
|
||||
return end;
|
||||
}
|
||||
|
||||
const labels = (suggestion: { props: SuggestionProps<MentionCandidate, MentionCandidate> | null }) =>
|
||||
suggestion.props?.items.map((c) => c.label) ?? null;
|
||||
|
||||
describe('RecipientMention trigger', () => {
|
||||
it('offers the recipients after an @ that starts a word', async () => {
|
||||
const { editor, suggestion } = setup('<p>Hallo</p>');
|
||||
await type(editor, ' @');
|
||||
await settle();
|
||||
expect(labels(suggestion)).toEqual(['Max', 'Eva']);
|
||||
await type(editor, 'ev');
|
||||
await settle();
|
||||
expect(labels(suggestion)).toEqual(['Eva']);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it('also triggers after a non-breaking space', async () => {
|
||||
const { editor, suggestion } = setup('<p>Hallo </p>');
|
||||
await type(editor, '@');
|
||||
await settle();
|
||||
expect(labels(suggestion)).toEqual(['Max', 'Eva']);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it('never triggers inside a word, so typing an address stays untouched', async () => {
|
||||
const { editor, suggestion } = setup('<p>info</p>');
|
||||
await type(editor, '@');
|
||||
await settle();
|
||||
expect(suggestion.props).toBeNull();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it('stays inactive while nobody matches, leaving Enter and Escape to the editor', async () => {
|
||||
const { editor, suggestion } = setup('<p>Hallo</p>');
|
||||
await type(editor, ' @xy');
|
||||
await settle();
|
||||
expect(suggestion.props).toBeNull();
|
||||
expect(press(editor, 'Escape').defaultPrevented).toBe(false);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it('is off without recipients (e.g. the vacation reply editor)', async () => {
|
||||
const { editor, suggestion } = setup('<p>Hallo</p>', []);
|
||||
await type(editor, ' @');
|
||||
await settle();
|
||||
expect(suggestion.props).toBeNull();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it('is off in code blocks and inline code', async () => {
|
||||
// The cursor lands right after the @ inside the code.
|
||||
for (const content of ['<pre><code>x @</code></pre>', '<p><code>x @</code></p>']) {
|
||||
const { editor, suggestion } = setup(content);
|
||||
await settle();
|
||||
expect(suggestion.props).toBeNull();
|
||||
editor.destroy();
|
||||
}
|
||||
// The same text outside code does offer the list.
|
||||
const { editor, suggestion } = setup('<p>x @</p>');
|
||||
await settle();
|
||||
expect(labels(suggestion)).toEqual(['Max', 'Eva']);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RecipientMention insertion', () => {
|
||||
it('replaces the typed @query with the first name and a space', async () => {
|
||||
const { editor, suggestion } = setup('<p>Hallo</p>');
|
||||
await type(editor, ' @ma');
|
||||
await settle();
|
||||
const enter = press(editor, 'Enter');
|
||||
expect(enter.defaultPrevented).toBe(true);
|
||||
expect(editor.getHTML()).toBe('<p>Hallo @Max </p>');
|
||||
expect(suggestion.props).toBeNull();
|
||||
// The cursor sits after the space, ready to go on typing.
|
||||
await type(editor, 'bitte');
|
||||
expect(editor.getHTML()).toBe('<p>Hallo @Max bitte</p>');
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it('reuses a space that already follows the cursor', async () => {
|
||||
const { editor, suggestion } = setup('<p>Hallo @ma welt</p>');
|
||||
act(() => { editor.commands.setTextSelection(1 + 'Hallo @ma'.length); });
|
||||
await settle();
|
||||
expect(labels(suggestion)).toEqual(['Max']);
|
||||
press(editor, 'Enter');
|
||||
expect(editor.getHTML()).toBe('<p>Hallo @Max welt</p>');
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it('inserts the label as text, never as markup', () => {
|
||||
const { editor } = setup('<p></p>');
|
||||
insertMention(editor, { from: 1, to: 1 }, '<img src=x onerror=alert(1)>');
|
||||
expect(editor.getHTML()).toBe('<p>@<img src=x onerror=alert(1)> </p>');
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it('takes Enter from a list item and Tab from a table cell while the list is open', async () => {
|
||||
const list = setup('<ul><li><p>Hallo</p></li></ul>');
|
||||
await type(list.editor, ' @ma');
|
||||
await settle();
|
||||
press(list.editor, 'Enter');
|
||||
// Still one item: the Enter did not split it. (StarterKit's trailing
|
||||
// node adds an empty paragraph after the list.)
|
||||
expect(list.editor.getHTML()).toContain('<ul><li><p>Hallo @Max </p></li></ul>');
|
||||
list.editor.destroy();
|
||||
|
||||
const table = setup(
|
||||
'<table><tbody><tr><td><p>x</p></td><td><p>y</p></td></tr></tbody></table>',
|
||||
PEOPLE,
|
||||
[StarterKit, Table, TableRow, TableHeader, TableCell],
|
||||
);
|
||||
// After the "x" in the first cell: table, row, cell and paragraph open first.
|
||||
act(() => { table.editor.commands.setTextSelection(4 + 'x'.length); });
|
||||
await type(table.editor, ' @ev');
|
||||
await settle();
|
||||
expect(labels(table.suggestion)).toEqual(['Eva']);
|
||||
press(table.editor, 'Tab');
|
||||
expect(table.editor.getText()).toContain('x @Eva ');
|
||||
expect(table.editor.state.selection.$from.parent.textContent).toBe('x @Eva ');
|
||||
table.editor.destroy();
|
||||
});
|
||||
|
||||
it('closes on Escape without letting the key through, and stays closed while typing on', async () => {
|
||||
const { editor, suggestion } = setup('<p>Hallo</p>');
|
||||
await type(editor, ' @ma');
|
||||
await settle();
|
||||
expect(press(editor, 'Escape').defaultPrevented).toBe(true);
|
||||
expect(suggestion.props).toBeNull();
|
||||
await type(editor, 'x');
|
||||
await settle();
|
||||
expect(suggestion.props).toBeNull();
|
||||
expect(editor.getHTML()).toBe('<p>Hallo @max</p>');
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RichTextEditor recipient list', () => {
|
||||
// jsdom has no layout. After an insertion the editor focuses a frame later
|
||||
// and ProseMirror measures a Range to scroll the cursor into view.
|
||||
const range = Range.prototype as unknown as Record<string, unknown>;
|
||||
beforeAll(() => {
|
||||
range.getClientRects = () => [];
|
||||
range.getBoundingClientRect = () => ({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 });
|
||||
});
|
||||
afterAll(() => {
|
||||
delete range.getClientRects;
|
||||
delete range.getBoundingClientRect;
|
||||
});
|
||||
|
||||
async function renderEditor(onChange = vi.fn()) {
|
||||
let editor: Editor | null = null;
|
||||
render(
|
||||
<RichTextEditor
|
||||
content="<p></p>"
|
||||
onChange={onChange}
|
||||
mentionCandidates={PEOPLE}
|
||||
onEditorReady={(ed) => { editor = ed; }}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(editor).not.toBeNull());
|
||||
const ed = editor as unknown as Editor;
|
||||
act(() => { ed.commands.setTextSelection(1); });
|
||||
return { editor: ed, onChange };
|
||||
}
|
||||
|
||||
it('lists the recipients, moves with the arrows and inserts on Enter', async () => {
|
||||
const { editor, onChange } = await renderEditor();
|
||||
await type(editor, '@');
|
||||
await settle();
|
||||
const list = await screen.findByRole('listbox', { name: 'mention_recipients' });
|
||||
const options = within(list).getAllByRole('option');
|
||||
expect(options.map((o) => o.textContent)).toEqual([
|
||||
'Max Mustermannmax.mustermann@dornig.de',
|
||||
'Evaeva.weber@dornig.de',
|
||||
]);
|
||||
expect(options[0]).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
press(editor, 'ArrowDown');
|
||||
expect(within(list).getAllByRole('option')[1]).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
press(editor, 'Enter');
|
||||
await nextFrame();
|
||||
expect(editor.getText()).toBe('@Eva ');
|
||||
expect(onChange.mock.lastCall?.[0]).toContain('>@Eva </p>');
|
||||
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
|
||||
});
|
||||
|
||||
it('does not pick a recipient on Ctrl+Enter, the composer\'s send shortcut', async () => {
|
||||
const { editor } = await renderEditor();
|
||||
await type(editor, '@');
|
||||
await settle();
|
||||
await screen.findByRole('listbox');
|
||||
// Standing alone, the editor's own keymap makes it a line break.
|
||||
act(() => { editor.view.dom.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true, cancelable: true })); });
|
||||
expect(editor.getText()).not.toMatch(/@(Max|Eva)/);
|
||||
});
|
||||
|
||||
it('inserts the clicked recipient', async () => {
|
||||
const { editor } = await renderEditor();
|
||||
await type(editor, '@');
|
||||
await settle();
|
||||
const list = await screen.findByRole('listbox');
|
||||
fireEvent.mouseDown(within(list).getAllByRole('option')[0]);
|
||||
await nextFrame();
|
||||
expect(editor.getText()).toBe('@Max ');
|
||||
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
|
||||
});
|
||||
|
||||
it('narrows the list as the name is typed', async () => {
|
||||
const { editor } = await renderEditor();
|
||||
await type(editor, '@');
|
||||
await settle();
|
||||
await screen.findByRole('listbox');
|
||||
await type(editor, 'e');
|
||||
await settle();
|
||||
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(1));
|
||||
expect(screen.getByRole('option')).toHaveTextContent('Eva');
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,13 @@ import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
// ─── Heavy component mocks (mirrors recipient-paste.test.tsx) ─────────────────
|
||||
|
||||
const editorProps = vi.hoisted(() => ({ mentionCandidates: [] as Array<{ label: string; email: string }> }));
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
|
||||
RichTextEditor: (props: { mentionCandidates?: Array<{ label: string; email: string }> }) => {
|
||||
editorProps.mentionCandidates = props.mentionCandidates ?? [];
|
||||
return React.createElement('div', { 'data-testid': 'rich-text-editor' });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
@@ -457,3 +462,34 @@ describe('composer reply addressing', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('composer @-mention candidates', () => {
|
||||
const offered = () => editorProps.mentionCandidates.map((c) => `${c.label} <${c.email}>`);
|
||||
|
||||
it('offers everyone a reply-all addresses, by first name', () => {
|
||||
render(<EmailComposer mode="replyAll" replyTo={RECEIVED} />);
|
||||
expect(offered()).toEqual(['Bob <bob@other.com>', 'Carol <carol@other.com>', 'Dave <dave@other.com>']);
|
||||
});
|
||||
|
||||
// Naming a blind-copied recipient in the text would disclose them.
|
||||
it('never offers Bcc recipients', () => {
|
||||
render(
|
||||
<EmailComposer
|
||||
initialData={{
|
||||
to: 'Max Mustermann <max.mustermann@example.com>',
|
||||
cc: 'eva.weber@example.com',
|
||||
bcc: 'Geheim <geheim@example.com>',
|
||||
subject: '',
|
||||
body: '',
|
||||
showCc: true,
|
||||
showBcc: true,
|
||||
selectedIdentityId: null,
|
||||
subAddressTag: '',
|
||||
mode: 'compose',
|
||||
draftId: null,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(offered()).toEqual(['Max <max.mustermann@example.com>', 'Eva <eva.weber@example.com>']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,9 @@ import {
|
||||
ICON_MAP,
|
||||
} from "@/lib/email-composer-utils";
|
||||
import { isValidEmail } from "@/lib/validation";
|
||||
import { buildMentionCandidates } from "@/lib/recipient-mentions";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
import { isRecipientMentionActive } from "@/components/email/recipient-mention";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
|
||||
import { fileStorage } from "@/lib/plugin-storage";
|
||||
@@ -1170,6 +1172,10 @@ export function EmailComposer({
|
||||
const ccStr = formatRecipientList(withInput(cc, ccInput));
|
||||
const bccStr = formatRecipientList(withInput(bcc, bccInput));
|
||||
|
||||
// Who an "@" in the body offers. Never Bcc: naming a blind-copied
|
||||
// recipient in the text would disclose them to everyone else.
|
||||
const mentionCandidates = useMemo(() => buildMentionCandidates(to, cc), [to, cc]);
|
||||
|
||||
// Uploaded/hydrated attachments in ComposerDraftData shape, so a state
|
||||
// snapshot (pro tab move, unmount save) carries them across a remount
|
||||
// instead of silently dropping them (#849). In-flight/failed uploads have
|
||||
@@ -2741,6 +2747,9 @@ export function EmailComposer({
|
||||
|
||||
if (isPlainEscape) {
|
||||
if (activeAutoField) return;
|
||||
// Same for the "@" recipient list in the body: this handler runs in the
|
||||
// capture phase, before the editor could close the list itself.
|
||||
if (isRecipientMentionActive(editorRef.current)) return;
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
return;
|
||||
@@ -3199,6 +3208,7 @@ export function EmailComposer({
|
||||
placeholder={t('body_placeholder')}
|
||||
hasError={validationErrors.body}
|
||||
onEditorReady={(ed) => { editorRef.current = ed; }}
|
||||
mentionCandidates={mentionCandidates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Extension, type Editor, type Range } from "@tiptap/core";
|
||||
import { PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import { Suggestion, exitSuggestion, type SuggestionMount, type SuggestionOptions } from "@tiptap/suggestion";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { filterMentionCandidates, type MentionCandidate } from "@/lib/recipient-mentions";
|
||||
|
||||
export const recipientMentionPluginKey = new PluginKey<{ active: boolean }>("recipientMention");
|
||||
|
||||
/**
|
||||
* Whether the "@" list is open in this editor. It owns Escape then, and the
|
||||
* composer, which handles Escape in the capture phase before the editor
|
||||
* sees it, has to let that one through.
|
||||
*/
|
||||
export function isRecipientMentionActive(editor: Editor | null | undefined): boolean {
|
||||
return !!editor && !editor.isDestroyed && !!recipientMentionPluginKey.getState(editor.state)?.active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the typed "@que" with "@Label " as plain text. Never as HTML: the
|
||||
* label comes from recipient display names, which a reply-all takes from the
|
||||
* incoming message.
|
||||
*/
|
||||
export function insertMention(editor: Editor, range: Range, label: string) {
|
||||
const text = `@${label}`;
|
||||
const after = editor.state.doc.resolve(range.to).nodeAfter;
|
||||
const spaceFollows = !!after?.isText && /^\s/.test(after.text ?? "");
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr }) => {
|
||||
tr.insertText(spaceFollows ? text : `${text} `, range.from, range.to);
|
||||
tr.setSelection(TextSelection.create(tr.doc, range.from + text.length + 1));
|
||||
tr.scrollIntoView();
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export interface RecipientMentionOptions {
|
||||
/** The recipients an @ can address right now; read on every keystroke. */
|
||||
getCandidates: () => MentionCandidate[];
|
||||
render?: SuggestionOptions<MentionCandidate, MentionCandidate>["render"];
|
||||
}
|
||||
|
||||
/**
|
||||
* "@" in the body offers the message's recipients and inserts the chosen
|
||||
* one's first name as plain text. The suggestion is only active while a
|
||||
* recipient matches what was typed, so an @ nobody matches - or one typed
|
||||
* without recipients - leaves Enter and Escape to the editor and composer.
|
||||
*/
|
||||
export const RecipientMention = Extension.create<RecipientMentionOptions>({
|
||||
name: "recipientMention",
|
||||
// While the list is open its Enter, Tab and arrows go ahead of the list,
|
||||
// table and core keymaps, which run at the default priority (100).
|
||||
priority: 101,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
getCandidates: () => [],
|
||||
render: undefined,
|
||||
};
|
||||
},
|
||||
|
||||
// Focus leaving the editor (Shift+Tab, another field) closes the list. Left
|
||||
// open, it would float over that field and keep Escape from the composer.
|
||||
onBlur() {
|
||||
if (isRecipientMentionActive(this.editor)) exitSuggestion(this.editor.view, recipientMentionPluginKey);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { getCandidates, render } = this.options;
|
||||
return [
|
||||
Suggestion<MentionCandidate, MentionCandidate>({
|
||||
editor: this.editor,
|
||||
pluginKey: recipientMentionPluginKey,
|
||||
char: "@",
|
||||
// Only at the start of a word, so info@example.com never triggers.
|
||||
allowedPrefixes: [" ", "\u00a0"],
|
||||
decorationClass: "recipient-mention-query",
|
||||
floatingUi: { strategy: "fixed" },
|
||||
allow: ({ state, range }) => {
|
||||
const $from = state.doc.resolve(range.from);
|
||||
if ($from.parent.type.spec.code) return false;
|
||||
const code = state.schema.marks.code;
|
||||
if (code && state.doc.rangeHasMark(range.from, range.to, code)) return false;
|
||||
const query = state.doc.textBetween(range.from + 1, range.to);
|
||||
return filterMentionCandidates(getCandidates(), query).length > 0;
|
||||
},
|
||||
items: ({ query }) => filterMentionCandidates(getCandidates(), query),
|
||||
command: ({ editor, range, props }) => insertMention(editor, range, props.label),
|
||||
render,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export interface RecipientMentionListHandle {
|
||||
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||
}
|
||||
|
||||
interface RecipientMentionListProps {
|
||||
items: MentionCandidate[];
|
||||
command: (item: MentionCandidate) => void;
|
||||
mount: SuggestionMount;
|
||||
}
|
||||
|
||||
export const RecipientMentionList = forwardRef<RecipientMentionListHandle, RecipientMentionListProps>(
|
||||
function RecipientMentionList({ items, command, mount }, ref) {
|
||||
const t = useTranslations("email_composer");
|
||||
// Every keystroke brings new items; the highlight starts over with them.
|
||||
const [selection, setSelection] = useState({ items, index: 0 });
|
||||
const selected = selection.items === items ? selection.index : 0;
|
||||
const [position, setPosition] = useState<{ x: number; y: number; strategy: "absolute" | "fixed" } | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Anchored under the typed "@..." by the suggestion's own positioning,
|
||||
// which follows scrolling and resizing and dismisses on outside clicks.
|
||||
useLayoutEffect(() => {
|
||||
const element = listRef.current;
|
||||
if (!element) return;
|
||||
return mount(element, {
|
||||
onPosition: ({ x, y, strategy }) => {
|
||||
// Keep the list on screen in narrow panes.
|
||||
const maxX = window.innerWidth - element.offsetWidth - 8;
|
||||
setPosition({ x: Math.max(8, Math.min(x, maxX)), y, strategy });
|
||||
},
|
||||
});
|
||||
}, [mount]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [selected]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: (event) => {
|
||||
if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey || items.length === 0) return false;
|
||||
if (event.key === "ArrowDown") {
|
||||
setSelection({ items, index: (selected + 1) % items.length });
|
||||
return true;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
setSelection({ items, index: (selected + items.length - 1) % items.length });
|
||||
return true;
|
||||
}
|
||||
if ((event.key === "Enter" || event.key === "Tab") && !event.shiftKey) {
|
||||
command(items[selected]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}), [items, selected, command]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label={t("mention_recipients")}
|
||||
className="z-50 w-72 max-w-[calc(100vw-16px)] max-h-48 overflow-y-auto bg-background border border-border rounded-md shadow-lg"
|
||||
// A press anywhere on the list - its scrollbar too - keeps the focus
|
||||
// in the editor, which would otherwise close the list on blur.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
style={{
|
||||
position: position?.strategy ?? "fixed",
|
||||
left: position?.x ?? 0,
|
||||
top: position?.y ?? 0,
|
||||
visibility: position ? undefined : "hidden",
|
||||
}}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={item.email}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === selected}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-start text-sm flex items-center gap-2",
|
||||
i === selected ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
// Keep the editor focused and the typed "@..." in place.
|
||||
e.preventDefault();
|
||||
command(item);
|
||||
}}
|
||||
>
|
||||
<Avatar name={item.name} email={item.email} size="sm" className="shrink-0 w-6 h-6 text-[10px]" />
|
||||
<span className="font-medium truncate">{item.name || item.label}</span>
|
||||
<span className="text-muted-foreground truncate">{item.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useCallback, useState, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
|
||||
import type { SuggestionProps } from "@tiptap/suggestion";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Paragraph from "@tiptap/extension-paragraph";
|
||||
import Heading from "@tiptap/extension-heading";
|
||||
@@ -22,6 +24,8 @@ import { TableCell } from "@tiptap/extension-table-cell";
|
||||
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
import { SignatureBlock } from "@/components/email/signature-block";
|
||||
import { styledBlockAttributes } from "@/components/email/styled-block-attributes";
|
||||
import { RecipientMention, RecipientMentionList, type RecipientMentionListHandle } from "@/components/email/recipient-mention";
|
||||
import type { MentionCandidate } from "@/lib/recipient-mentions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -83,6 +87,8 @@ interface RichTextEditorProps {
|
||||
className?: string;
|
||||
hasError?: boolean;
|
||||
onEditorReady?: (editor: Editor) => void;
|
||||
/** Recipients an "@" in the text offers to mention; none turns it off. */
|
||||
mentionCandidates?: MentionCandidate[];
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
@@ -173,6 +179,7 @@ export function RichTextEditor({
|
||||
className,
|
||||
hasError,
|
||||
onEditorReady,
|
||||
mentionCandidates,
|
||||
}: RichTextEditorProps) {
|
||||
const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport);
|
||||
const tComposer = useTranslations("email_composer");
|
||||
@@ -180,6 +187,10 @@ export function RichTextEditor({
|
||||
onImageUploadRef.current = onImageUpload;
|
||||
const onEditorReadyRef = React.useRef(onEditorReady);
|
||||
onEditorReadyRef.current = onEditorReady;
|
||||
const mentionCandidatesRef = React.useRef<MentionCandidate[]>([]);
|
||||
mentionCandidatesRef.current = mentionCandidates ?? [];
|
||||
const [mention, setMention] = useState<SuggestionProps<MentionCandidate, MentionCandidate> | null>(null);
|
||||
const mentionListRef = useRef<RecipientMentionListHandle>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
@@ -238,6 +249,22 @@ export function RichTextEditor({
|
||||
SignatureBlock.configure({ editHint: tComposer('signature_edit_hint') }),
|
||||
TextDirection,
|
||||
PlainTextPaste,
|
||||
// "@" offers the recipients and inserts a first name (see
|
||||
// recipient-mention.tsx). The editor is built once, so the candidates
|
||||
// and the list are reached through refs and a stable state setter.
|
||||
RecipientMention.configure({
|
||||
getCandidates: () => mentionCandidatesRef.current,
|
||||
render: () => ({
|
||||
onStart: (props) => setMention(props),
|
||||
// Every keystroke first reports a pending lookup with no items;
|
||||
// keeping the last list until the new one is in avoids a flicker.
|
||||
onUpdate: (props) => {
|
||||
if (!props.loading) setMention(props);
|
||||
},
|
||||
onKeyDown: ({ event }) => mentionListRef.current?.onKeyDown(event) ?? false,
|
||||
onExit: () => setMention(null),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
// One newline per paragraph when copying out as plain text, the inverse
|
||||
// of PlainTextPaste: a blank line is an empty paragraph, so the default
|
||||
@@ -797,6 +824,16 @@ export function RichTextEditor({
|
||||
|
||||
{/* Editor */}
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{mention && mention.items.length > 0 && createPortal(
|
||||
<RecipientMentionList
|
||||
ref={mentionListRef}
|
||||
items={mention.items}
|
||||
command={mention.command}
|
||||
mount={mention.mount}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildMentionCandidates, filterMentionCandidates, mentionLabel } from '../recipient-mentions';
|
||||
|
||||
describe('mentionLabel', () => {
|
||||
it('takes the first name from a display name', () => {
|
||||
expect(mentionLabel('Max Mustermann', 'mm@dornig.de')).toBe('Max');
|
||||
expect(mentionLabel('Mustermann, Max', 'mm@dornig.de')).toBe('Max');
|
||||
expect(mentionLabel('Dr. Max Mustermann', 'mm@dornig.de')).toBe('Max');
|
||||
expect(mentionLabel('Prof. Dr.-Ing. Max Mustermann', 'mm@dornig.de')).toBe('Max');
|
||||
expect(mentionLabel('Jürgen Müller', 'jm@dornig.de')).toBe('Jürgen');
|
||||
expect(mentionLabel("'Max Mustermann'", 'mm@dornig.de')).toBe('Max');
|
||||
});
|
||||
|
||||
it('takes the first name from a firstname.lastname address when no name is known', () => {
|
||||
expect(mentionLabel(undefined, 'max.mustermann@dornig.de')).toBe('Max');
|
||||
expect(mentionLabel('', 'anna-lena.schmidt@dornig.de')).toBe('Anna-Lena');
|
||||
expect(mentionLabel(undefined, 'max.mustermann+rechnungen@dornig.de')).toBe('Max');
|
||||
expect(mentionLabel(undefined, 'juergen.mueller@dornig.de')).toBe('Juergen');
|
||||
expect(mentionLabel(undefined, 'info@dornig.de')).toBe('Info');
|
||||
});
|
||||
|
||||
it('lets a firstname.lastname address tell which word of the name is the first name', () => {
|
||||
expect(mentionLabel('Mustermann Max', 'max.mustermann@dornig.de')).toBe('Max');
|
||||
expect(mentionLabel('Müller Jürgen', 'juergen.mueller@dornig.de')).toBe('Jürgen');
|
||||
expect(mentionLabel('Dornig GmbH - Max Mustermann', 'max.mustermann@dornig.de')).toBe('Max');
|
||||
// A one-part mailbox name confirms nothing: it may be the last name.
|
||||
expect(mentionLabel('Max Mustermann', 'mustermann@dornig.de')).toBe('Max');
|
||||
});
|
||||
|
||||
it('treats a display name that is an address or mailbox name like the address', () => {
|
||||
expect(mentionLabel('max.mustermann@dornig.de', 'max.mustermann@dornig.de')).toBe('Max');
|
||||
expect(mentionLabel('max.mustermann', 'mm@dornig.de')).toBe('Max');
|
||||
});
|
||||
|
||||
it('keeps the whole mailbox name when the first name is a bare initial', () => {
|
||||
expect(mentionLabel(undefined, 'm.mustermann@dornig.de')).toBe('m.mustermann');
|
||||
expect(mentionLabel(undefined, 'm.mustermann+news@dornig.de')).toBe('m.mustermann');
|
||||
expect(mentionLabel('M. Mustermann', 'm.mustermann@dornig.de')).toBe('m.mustermann');
|
||||
// The address may know more than the display name.
|
||||
expect(mentionLabel('M. Mustermann', 'max.mustermann@dornig.de')).toBe('Max');
|
||||
});
|
||||
|
||||
it('stays fast on a crafted display name from an incoming message', () => {
|
||||
// Uncapped, trimming this token takes seconds (quadratic backtracking).
|
||||
const crafted = `a${'!'.repeat(100_000)}b`;
|
||||
const started = performance.now();
|
||||
expect(mentionLabel(crafted, 'max.mustermann@dornig.de')).toBe('Max');
|
||||
expect(performance.now() - started).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMentionCandidates', () => {
|
||||
it('offers To and Cc recipients in order, each address once', () => {
|
||||
const candidates = buildMentionCandidates(
|
||||
[{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' }, { email: 'anna.schmidt@dornig.de' }],
|
||||
[{ email: 'MAX.MUSTERMANN@dornig.de' }, { name: 'Eva Weber', email: 'eva.weber@dornig.de' }],
|
||||
);
|
||||
expect(candidates).toEqual([
|
||||
{ label: 'Max', name: 'Max Mustermann', email: 'max.mustermann@dornig.de' },
|
||||
{ label: 'Anna', name: undefined, email: 'anna.schmidt@dornig.de' },
|
||||
{ label: 'Eva', name: 'Eva Weber', email: 'eva.weber@dornig.de' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands contact groups into their members and skips entries without an address', () => {
|
||||
const candidates = buildMentionCandidates(
|
||||
[{
|
||||
name: 'Team',
|
||||
email: '',
|
||||
group: { members: [{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' }, { email: 'eva.weber@dornig.de' }] },
|
||||
}],
|
||||
[{ email: 'not-an-address' }],
|
||||
);
|
||||
expect(candidates.map((c) => c.label)).toEqual(['Max', 'Eva']);
|
||||
});
|
||||
|
||||
it('tells apart recipients who would share a first name', () => {
|
||||
const labels = (to: Array<{ name?: string; email: string }>) => buildMentionCandidates(to, []).map((c) => c.label);
|
||||
expect(labels([
|
||||
{ name: 'Max Mustermann', email: 'a@dornig.de' },
|
||||
{ name: 'Mueller, Max', email: 'b@dornig.de' },
|
||||
{ name: 'Eva Weber', email: 'c@dornig.de' },
|
||||
])).toEqual(['Max Mustermann', 'Max Mueller', 'Eva']);
|
||||
expect(labels([
|
||||
{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' },
|
||||
{ email: 'max.mueller@dornig.de' },
|
||||
])).toEqual(['Max Mustermann', 'max.mueller']);
|
||||
expect(labels([
|
||||
{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' },
|
||||
{ name: 'Max Mustermann', email: 'max.mustermann2@dornig.de' },
|
||||
])).toEqual(['max.mustermann', 'max.mustermann2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterMentionCandidates', () => {
|
||||
const candidates = buildMentionCandidates(
|
||||
[
|
||||
{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' },
|
||||
{ name: 'Jürgen Müller', email: 'juergen.mueller@dornig.de' },
|
||||
{ email: 'anna-lena.schmidt@dornig.de' },
|
||||
],
|
||||
[],
|
||||
);
|
||||
const labels = (query: string) => filterMentionCandidates(candidates, query).map((c) => c.label);
|
||||
|
||||
it('offers everyone for a bare @', () => {
|
||||
expect(labels('')).toEqual(['Max', 'Jürgen', 'Anna-Lena']);
|
||||
});
|
||||
|
||||
it('matches the start of the label, a name word or a mailbox part, ignoring case', () => {
|
||||
expect(labels('ma')).toEqual(['Max']);
|
||||
expect(labels('MUSTER')).toEqual(['Max']);
|
||||
expect(labels('lena')).toEqual(['Anna-Lena']);
|
||||
expect(labels('schm')).toEqual(['Anna-Lena']);
|
||||
expect(labels('xyz')).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds umlaut names typed either way', () => {
|
||||
expect(labels('jü')).toEqual(['Jürgen']);
|
||||
expect(labels('jue')).toEqual(['Jürgen']);
|
||||
expect(labels('mül')).toEqual(['Jürgen']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { expandRecipients, type Recipient } from '@/lib/email-composer-utils';
|
||||
|
||||
/** A recipient an @ in the message body can address. */
|
||||
export interface MentionCandidate {
|
||||
/** What goes after the @ in the body. */
|
||||
label: string;
|
||||
name?: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
function letterCount(value: string): number {
|
||||
return value.match(/\p{L}/gu)?.length ?? 0;
|
||||
}
|
||||
|
||||
/** The mailbox part of an address, without a +subaddress tag. */
|
||||
function localPartOf(email: string): string {
|
||||
const local = email.slice(0, email.lastIndexOf('@'));
|
||||
return local.split('+')[0] || local;
|
||||
}
|
||||
|
||||
/** "Mustermann, Max" -> "Max Mustermann"; any other name as is. */
|
||||
function naturalOrder(name: string): string {
|
||||
const parts = name.split(',');
|
||||
if (parts.length !== 2 || !parts[1].trim()) return name;
|
||||
return `${parts[1].trim()} ${parts[0].trim()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "max.mustermann" -> "Max", "anna-lena.schmidt" -> "Anna-Lena". Null when
|
||||
* the first part is a bare initial ("m.mustermann") - no name to address
|
||||
* anyone by.
|
||||
*/
|
||||
function givenNameFromLocalPart(localPart: string): string | null {
|
||||
const first = localPart.split(/[._]/)[0];
|
||||
if (letterCount(first) <= 1) return null;
|
||||
return first.replace(/(^|-)(\p{Ll})/gu, (_, sep: string, letter: string) => sep + letter.toUpperCase());
|
||||
}
|
||||
|
||||
/** The words of a display name, without the punctuation around them. */
|
||||
function nameWords(name: string): string[] {
|
||||
return naturalOrder(name)
|
||||
.split(/\s+/)
|
||||
.map((raw) => raw.replace(/^[^\p{L}\p{N}]+/u, '').replace(/[^\p{L}\p{N}.]+$/u, ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* The given name in a display name: "Max Mustermann", "Mustermann, Max" and
|
||||
* "Dr. Max Mustermann" all give "Max". Null when the name opens with a bare
|
||||
* initial ("M. Mustermann").
|
||||
*/
|
||||
function givenNameFromDisplayName(name: string): string | null {
|
||||
// Some systems put the mailbox name in the display name.
|
||||
if (!/\s/.test(name) && /\p{L}[._]\p{L}/u.test(name)) return givenNameFromLocalPart(name);
|
||||
for (const word of nameWords(name)) {
|
||||
if (letterCount(word) <= 1) return null;
|
||||
// Titles and abbreviations: "Dr.", "Prof.", "Dipl.-Ing."
|
||||
if (word.endsWith('.')) continue;
|
||||
return word;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const UMLAUT_SPELLINGS: Record<string, string> = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' };
|
||||
|
||||
/** Whether two names match as an address spells them: "Jürgen" and "juergen". */
|
||||
function spelledAlike(a: string, b: string): boolean {
|
||||
const key = (s: string) => fold(s.normalize('NFC').toLowerCase().replace(/[äöüß]/g, (c) => UMLAUT_SPELLINGS[c]));
|
||||
return key(a) === key(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* A display name worth deriving a label from: not empty, not an address.
|
||||
* Capped because a reply-all takes it from the incoming message and the
|
||||
* trimming in nameWords() is quadratic on a crafted run of punctuation; no
|
||||
* real name comes near the cap.
|
||||
*/
|
||||
function usableName(name: string | undefined): string | undefined {
|
||||
const trimmed = name?.trim().slice(0, 200).replace(/^(['"])(.*)\1$/, '$2').trim();
|
||||
return trimmed && !trimmed.includes('@') ? trimmed : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* First name to put after the @. A firstname.lastname@ address tells which
|
||||
* word of the display name is the first name, whatever order the name is
|
||||
* written in ("Mustermann Max", "Firma - Max Mustermann"); otherwise it is
|
||||
* the display name's first word, or the address's first part when no name
|
||||
* is known. A bare initial ("m.mustermann@") keeps the whole mailbox name.
|
||||
*/
|
||||
export function mentionLabel(name: string | undefined, email: string): string {
|
||||
const localPart = localPartOf(email);
|
||||
const fromAddress = givenNameFromLocalPart(localPart);
|
||||
const usable = usableName(name);
|
||||
if (usable) {
|
||||
// Only a two-part mailbox name says which part is the first name.
|
||||
const confirmed = fromAddress && /[._]/.test(localPart)
|
||||
? nameWords(usable).find((word) => spelledAlike(word, fromAddress))
|
||||
: undefined;
|
||||
const given = confirmed || givenNameFromDisplayName(usable);
|
||||
if (given) return given;
|
||||
}
|
||||
return fromAddress ?? localPart;
|
||||
}
|
||||
|
||||
function clashingLabels(candidates: MentionCandidate[]): Set<string> {
|
||||
const seen = new Set<string>();
|
||||
const clashes = new Set<string>();
|
||||
for (const { label } of candidates) {
|
||||
const key = label.toLowerCase();
|
||||
if (seen.has(key)) clashes.add(key);
|
||||
seen.add(key);
|
||||
}
|
||||
return clashes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipients an @ in the message body can address: To and Cc, contact
|
||||
* groups expanded into their members, each address once. Bcc is left out on
|
||||
* purpose - naming a blind-copied recipient in the body would disclose them
|
||||
* to everyone else on the message.
|
||||
*/
|
||||
export function buildMentionCandidates(to: Recipient[], cc: Recipient[]): MentionCandidate[] {
|
||||
const candidates: MentionCandidate[] = expandRecipients([...to, ...cc])
|
||||
.filter((r) => r.email.lastIndexOf('@') > 0)
|
||||
.map((r) => {
|
||||
const name = usableName(r.name);
|
||||
return { label: mentionLabel(name, r.email), name, email: r.email };
|
||||
});
|
||||
// Two recipients who would both be "@Max" are told apart by full name, or
|
||||
// by mailbox name where no name is known - and by mailbox name when even
|
||||
// the full names match.
|
||||
const clashes = clashingLabels(candidates);
|
||||
for (const c of candidates) {
|
||||
if (clashes.has(c.label.toLowerCase())) c.label = c.name ? naturalOrder(c.name) : localPartOf(c.email);
|
||||
}
|
||||
const stillClashing = clashingLabels(candidates);
|
||||
for (const c of candidates) {
|
||||
if (stillClashing.has(c.label.toLowerCase())) c.label = localPartOf(c.email);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function fold(value: string): string {
|
||||
return value.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidates whose label, a word of their name, their address or a part of
|
||||
* their mailbox name starts with what was typed after the @ - case and
|
||||
* accents ignored, so "jue" finds juergen.mueller@ and "jü" finds Jürgen.
|
||||
*/
|
||||
export function filterMentionCandidates(candidates: MentionCandidate[], query: string): MentionCandidate[] {
|
||||
const typed = fold(query);
|
||||
if (!typed) return candidates;
|
||||
return candidates.filter((c) =>
|
||||
[c.label, c.email, ...(c.name?.split(/\s+/) ?? []), ...localPartOf(c.email).split(/[._-]/)]
|
||||
.some((term) => fold(term).startsWith(typed)),
|
||||
);
|
||||
}
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "الاسم المعروض",
|
||||
"autocomplete_search_server": "البحث في الخادم",
|
||||
"autocomplete_searching": "جارٍ البحث...",
|
||||
"mention_recipients": "المستلمون",
|
||||
"toolbar": {
|
||||
"bold": "غامق",
|
||||
"italic": "مائل",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Nom mostrat",
|
||||
"autocomplete_search_server": "Cerca al servidor",
|
||||
"autocomplete_searching": "Cercant...",
|
||||
"mention_recipients": "Destinataris",
|
||||
"toolbar": {
|
||||
"bold": "Negreta",
|
||||
"italic": "Cursiva",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Zobrazované jméno",
|
||||
"autocomplete_search_server": "Hledat na serveru",
|
||||
"autocomplete_searching": "Hledání...",
|
||||
"mention_recipients": "Příjemci",
|
||||
"toolbar": {
|
||||
"bold": "Tučné",
|
||||
"italic": "Kurzíva",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Visningsnavn",
|
||||
"autocomplete_search_server": "Søg på serveren",
|
||||
"autocomplete_searching": "Søger...",
|
||||
"mention_recipients": "Modtagere",
|
||||
"toolbar": {
|
||||
"bold": "Fed",
|
||||
"italic": "Kursiv",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Anzeigename",
|
||||
"autocomplete_search_server": "Auf dem Server suchen",
|
||||
"autocomplete_searching": "Suche läuft...",
|
||||
"mention_recipients": "Empfänger",
|
||||
"toolbar": {
|
||||
"bold": "Fett",
|
||||
"italic": "Kursiv",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Display name",
|
||||
"autocomplete_search_server": "Search the server",
|
||||
"autocomplete_searching": "Searching...",
|
||||
"mention_recipients": "Recipients",
|
||||
"toolbar": {
|
||||
"bold": "Bold",
|
||||
"italic": "Italic",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Nombre para mostrar",
|
||||
"autocomplete_search_server": "Buscar en el servidor",
|
||||
"autocomplete_searching": "Buscando...",
|
||||
"mention_recipients": "Destinatarios",
|
||||
"toolbar": {
|
||||
"bold": "Negrita",
|
||||
"italic": "Cursiva",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "نام نمایشی",
|
||||
"autocomplete_search_server": "جستجو در سرور",
|
||||
"autocomplete_searching": "در حال جستجو...",
|
||||
"mention_recipients": "گیرندگان",
|
||||
"toolbar": {
|
||||
"bold": "پررنگ",
|
||||
"italic": "کج",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Nom d'affichage",
|
||||
"autocomplete_search_server": "Rechercher sur le serveur",
|
||||
"autocomplete_searching": "Recherche en cours...",
|
||||
"mention_recipients": "Destinataires",
|
||||
"toolbar": {
|
||||
"bold": "Gras",
|
||||
"italic": "Italique",
|
||||
|
||||
@@ -690,6 +690,7 @@
|
||||
"recipient_name_placeholder": "שם תצוגה",
|
||||
"autocomplete_search_server": "חיפוש בשרת",
|
||||
"autocomplete_searching": "מחפש...",
|
||||
"mention_recipients": "נמענים",
|
||||
"toolbar": {
|
||||
"bold": "מודגש",
|
||||
"italic": "נטוי",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Megjelenített név",
|
||||
"autocomplete_search_server": "Keresés a kiszolgálón",
|
||||
"autocomplete_searching": "Keresés...",
|
||||
"mention_recipients": "Címzettek",
|
||||
"toolbar": {
|
||||
"bold": "Félkövér",
|
||||
"italic": "Dőlt",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Nome visualizzato",
|
||||
"autocomplete_search_server": "Cerca nel server",
|
||||
"autocomplete_searching": "Ricerca in corso...",
|
||||
"mention_recipients": "Destinatari",
|
||||
"toolbar": {
|
||||
"bold": "Grassetto",
|
||||
"italic": "Corsivo",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "表示名",
|
||||
"autocomplete_search_server": "サーバーを検索",
|
||||
"autocomplete_searching": "検索中...",
|
||||
"mention_recipients": "宛先",
|
||||
"toolbar": {
|
||||
"bold": "太字",
|
||||
"italic": "斜体",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "표시 이름",
|
||||
"autocomplete_search_server": "서버에서 검색",
|
||||
"autocomplete_searching": "검색 중...",
|
||||
"mention_recipients": "수신자",
|
||||
"toolbar": {
|
||||
"bold": "굵게",
|
||||
"italic": "기울임꼴",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Parādāmais vārds",
|
||||
"autocomplete_search_server": "Meklēt serverī",
|
||||
"autocomplete_searching": "Meklē...",
|
||||
"mention_recipients": "Adresāti",
|
||||
"toolbar": {
|
||||
"bold": "Treknraksts",
|
||||
"italic": "Kursīvs",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Дэлгэцийн нэр",
|
||||
"autocomplete_search_server": "Серверээс хайх",
|
||||
"autocomplete_searching": "Хайж байна...",
|
||||
"mention_recipients": "Хүлээн авагчид",
|
||||
"toolbar": {
|
||||
"bold": "Болд",
|
||||
"italic": "Налуу",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Visningsnavn",
|
||||
"autocomplete_search_server": "Søk på serveren",
|
||||
"autocomplete_searching": "Søker...",
|
||||
"mention_recipients": "Mottakere",
|
||||
"toolbar": {
|
||||
"bold": "Fet skrift",
|
||||
"italic": "Kursiv",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Weergavenaam",
|
||||
"autocomplete_search_server": "Op de server zoeken",
|
||||
"autocomplete_searching": "Bezig met zoeken...",
|
||||
"mention_recipients": "Ontvangers",
|
||||
"toolbar": {
|
||||
"bold": "Vet",
|
||||
"italic": "Cursief",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Wyświetlana nazwa",
|
||||
"autocomplete_search_server": "Szukaj na serwerze",
|
||||
"autocomplete_searching": "Wyszukiwanie...",
|
||||
"mention_recipients": "Odbiorcy",
|
||||
"toolbar": {
|
||||
"bold": "Pogrubienie",
|
||||
"italic": "Kursywa",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Nome de exibição",
|
||||
"autocomplete_search_server": "Pesquisar no servidor",
|
||||
"autocomplete_searching": "Pesquisando...",
|
||||
"mention_recipients": "Destinatários",
|
||||
"toolbar": {
|
||||
"bold": "Negrito",
|
||||
"italic": "Itálico",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Numele afișat",
|
||||
"autocomplete_search_server": "Caută pe server",
|
||||
"autocomplete_searching": "Se caută...",
|
||||
"mention_recipients": "Destinatari",
|
||||
"toolbar": {
|
||||
"bold": "Aldin",
|
||||
"italic": "Cursiv",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Отображаемое имя",
|
||||
"autocomplete_search_server": "Искать на сервере",
|
||||
"autocomplete_searching": "Поиск...",
|
||||
"mention_recipients": "Получатели",
|
||||
"toolbar": {
|
||||
"bold": "Жирный",
|
||||
"italic": "Курсив",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Zobrazené meno",
|
||||
"autocomplete_search_server": "Hľadať na serveri",
|
||||
"autocomplete_searching": "Hľadanie...",
|
||||
"mention_recipients": "Príjemcovia",
|
||||
"toolbar": {
|
||||
"bold": "Tučné",
|
||||
"italic": "Kurzíva",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Görünen ad",
|
||||
"autocomplete_search_server": "Sunucuda ara",
|
||||
"autocomplete_searching": "Aranıyor...",
|
||||
"mention_recipients": "Alıcılar",
|
||||
"toolbar": {
|
||||
"bold": "Kalın",
|
||||
"italic": "İtalik",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "Відображуване ім'я",
|
||||
"autocomplete_search_server": "Шукати на сервері",
|
||||
"autocomplete_searching": "Пошук...",
|
||||
"mention_recipients": "Одержувачі",
|
||||
"toolbar": {
|
||||
"bold": "Жирний",
|
||||
"italic": "Курсив",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "顯示名稱",
|
||||
"autocomplete_search_server": "在伺服器上搜尋",
|
||||
"autocomplete_searching": "正在搜尋…",
|
||||
"mention_recipients": "收件者",
|
||||
"toolbar": {
|
||||
"bold": "粗體",
|
||||
"italic": "斜體",
|
||||
|
||||
@@ -725,6 +725,7 @@
|
||||
"recipient_name_placeholder": "显示名称",
|
||||
"autocomplete_search_server": "在服务器上搜索",
|
||||
"autocomplete_searching": "搜索中...",
|
||||
"mention_recipients": "收件人",
|
||||
"toolbar": {
|
||||
"bold": "加粗",
|
||||
"italic": "斜体",
|
||||
|
||||
Generated
+17
-5
@@ -31,6 +31,7 @@
|
||||
"@tiptap/pm": "^3.31.3",
|
||||
"@tiptap/react": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
"@tiptap/suggestion": "^3.31.3",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.4.12",
|
||||
@@ -1329,7 +1330,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
@@ -1339,7 +1339,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
@@ -1349,8 +1348,7 @@
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@formatjs/fast-memoize": {
|
||||
"version": "3.1.2",
|
||||
@@ -4161,6 +4159,21 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/suggestion": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.31.3.tgz",
|
||||
"integrity": "sha512-z1OQ/Yx6seMZehi1AcmNkyJ8qkHQzybArmCyRdmreS4YL9C4bPMBe5lbMfFsArYs+KD5JyHwps5j7J/YE5BIuw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
@@ -6231,7 +6244,6 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"@tiptap/pm": "^3.31.3",
|
||||
"@tiptap/react": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
"@tiptap/suggestion": "^3.31.3",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.4.12",
|
||||
|
||||
Reference in New Issue
Block a user