diff --git a/components/email/__tests__/composer-mention.test.tsx b/components/email/__tests__/composer-mention.test.tsx new file mode 100644 index 000000000..b51668969 --- /dev/null +++ b/components/email/__tests__/composer-mention.test.tsx @@ -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) => 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) => 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) => 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) => 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) => 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) => 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) => 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 , carol.smith@example.com', + cc: '', + bcc: 'Geheim ', + subject: 'Half-written', + body: '

Hallo

', + 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((resolve) => setTimeout(resolve, 0))); + +/** Lets the editor's delayed focus (and scroll to the cursor) run inside the test. */ +const nextFrame = () => act(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + +async function renderComposer() { + const onClose = vi.fn(); + render(); + // 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; + 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()); + }); +}); diff --git a/components/email/__tests__/recipient-mention.test.tsx b/components/email/__tests__/recipient-mention.test.tsx new file mode 100644 index 000000000..7f1f7c14c --- /dev/null +++ b/components/email/__tests__/recipient-mention.test.tsx @@ -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((resolve) => setTimeout(resolve, 0))); + +/** Lets the editor's delayed focus (and scroll to the cursor) run inside the test. */ +const nextFrame = () => act(() => new Promise((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 | 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 | 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('

Hallo

'); + 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('

Hallo 

'); + 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('

info

'); + 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('

Hallo

'); + 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('

Hallo

', []); + 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 ['
x @
', '

x @

']) { + 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('

x @

'); + 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('

Hallo

'); + await type(editor, ' @ma'); + await settle(); + const enter = press(editor, 'Enter'); + expect(enter.defaultPrevented).toBe(true); + expect(editor.getHTML()).toBe('

Hallo @Max

'); + expect(suggestion.props).toBeNull(); + // The cursor sits after the space, ready to go on typing. + await type(editor, 'bitte'); + expect(editor.getHTML()).toBe('

Hallo @Max bitte

'); + editor.destroy(); + }); + + it('reuses a space that already follows the cursor', async () => { + const { editor, suggestion } = setup('

Hallo @ma welt

'); + act(() => { editor.commands.setTextSelection(1 + 'Hallo @ma'.length); }); + await settle(); + expect(labels(suggestion)).toEqual(['Max']); + press(editor, 'Enter'); + expect(editor.getHTML()).toBe('

Hallo @Max welt

'); + editor.destroy(); + }); + + it('inserts the label as text, never as markup', () => { + const { editor } = setup('

'); + insertMention(editor, { from: 1, to: 1 }, ''); + expect(editor.getHTML()).toBe('

@<img src=x onerror=alert(1)>

'); + editor.destroy(); + }); + + it('takes Enter from a list item and Tab from a table cell while the list is open', async () => { + const list = setup('
  • Hallo

'); + 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('
  • Hallo @Max

'); + list.editor.destroy(); + + const table = setup( + '

x

y

', + 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('

Hallo

'); + 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('

Hallo @max

'); + 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; + 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( + { 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

'); + 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'); + }); +}); diff --git a/components/email/__tests__/reply-addressing.test.tsx b/components/email/__tests__/reply-addressing.test.tsx index cdabf000f..4b53e1608 100644 --- a/components/email/__tests__/reply-addressing.test.tsx +++ b/components/email/__tests__/reply-addressing.test.tsx @@ -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(); + expect(offered()).toEqual(['Bob ', 'Carol ', 'Dave ']); + }); + + // Naming a blind-copied recipient in the text would disclose them. + it('never offers Bcc recipients', () => { + render( + ', + cc: 'eva.weber@example.com', + bcc: 'Geheim ', + subject: '', + body: '', + showCc: true, + showBcc: true, + selectedIdentityId: null, + subAddressTag: '', + mode: 'compose', + draftId: null, + }} + /> + ); + expect(offered()).toEqual(['Max ', 'Eva ']); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index e38eca129..5ce9068ba 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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} /> )} diff --git a/components/email/recipient-mention.tsx b/components/email/recipient-mention.tsx new file mode 100644 index 000000000..d12f8ddfa --- /dev/null +++ b/components/email/recipient-mention.tsx @@ -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["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({ + 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({ + 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( + 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(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 ( +
e.preventDefault()} + style={{ + position: position?.strategy ?? "fixed", + left: position?.x ?? 0, + top: position?.y ?? 0, + visibility: position ? undefined : "hidden", + }} + > + {items.map((item, i) => ( + + ))} +
+ ); + } +); diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 5189cec97..e22caf8cc 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -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([]); + mentionCandidatesRef.current = mentionCandidates ?? []; + const [mention, setMention] = useState | null>(null); + const mentionListRef = useRef(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 */} + + {mention && mention.items.length > 0 && createPortal( + , + document.body + )} ); } diff --git a/lib/__tests__/recipient-mentions.test.ts b/lib/__tests__/recipient-mentions.test.ts new file mode 100644 index 000000000..d8177ed7c --- /dev/null +++ b/lib/__tests__/recipient-mentions.test.ts @@ -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']); + }); +}); diff --git a/lib/recipient-mentions.ts b/lib/recipient-mentions.ts new file mode 100644 index 000000000..bf2e87174 --- /dev/null +++ b/lib/recipient-mentions.ts @@ -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 = { ä: '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 { + const seen = new Set(); + const clashes = new Set(); + 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)), + ); +} diff --git a/locales/ar/common.json b/locales/ar/common.json index b5942aa1c..3378176fe 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -725,6 +725,7 @@ "recipient_name_placeholder": "الاسم المعروض", "autocomplete_search_server": "البحث في الخادم", "autocomplete_searching": "جارٍ البحث...", + "mention_recipients": "المستلمون", "toolbar": { "bold": "غامق", "italic": "مائل", diff --git a/locales/ca/common.json b/locales/ca/common.json index 7980d9384..99b183e5c 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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", diff --git a/locales/cs/common.json b/locales/cs/common.json index e75d45a38..20b17bf7c 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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", diff --git a/locales/da/common.json b/locales/da/common.json index 74645b094..e324c495c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index 2555d7790..ea0f9e06f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index ad2c3f45e..bd6f2d523 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index 21a79d162..e5beed216 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index 723dfdfd7..6e69abfae 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -725,6 +725,7 @@ "recipient_name_placeholder": "نام نمایشی", "autocomplete_search_server": "جستجو در سرور", "autocomplete_searching": "در حال جستجو...", + "mention_recipients": "گیرندگان", "toolbar": { "bold": "پررنگ", "italic": "کج", diff --git a/locales/fr/common.json b/locales/fr/common.json index 2d8e737f8..371c3ddd3 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index 234e6b769..d6e384f08 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -690,6 +690,7 @@ "recipient_name_placeholder": "שם תצוגה", "autocomplete_search_server": "חיפוש בשרת", "autocomplete_searching": "מחפש...", + "mention_recipients": "נמענים", "toolbar": { "bold": "מודגש", "italic": "נטוי", diff --git a/locales/hu/common.json b/locales/hu/common.json index 98ac6fcc5..6d810b8fd 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index 3f7671ebf..188823fad 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index 299da4724..14e10994d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -725,6 +725,7 @@ "recipient_name_placeholder": "表示名", "autocomplete_search_server": "サーバーを検索", "autocomplete_searching": "検索中...", + "mention_recipients": "宛先", "toolbar": { "bold": "太字", "italic": "斜体", diff --git a/locales/ko/common.json b/locales/ko/common.json index 5e4f69096..bb08dc8b1 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -725,6 +725,7 @@ "recipient_name_placeholder": "표시 이름", "autocomplete_search_server": "서버에서 검색", "autocomplete_searching": "검색 중...", + "mention_recipients": "수신자", "toolbar": { "bold": "굵게", "italic": "기울임꼴", diff --git a/locales/lv/common.json b/locales/lv/common.json index 627ced780..61076ed1b 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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", diff --git a/locales/mn/common.json b/locales/mn/common.json index a494a2a8d..f1e0d92d8 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -725,6 +725,7 @@ "recipient_name_placeholder": "Дэлгэцийн нэр", "autocomplete_search_server": "Серверээс хайх", "autocomplete_searching": "Хайж байна...", + "mention_recipients": "Хүлээн авагчид", "toolbar": { "bold": "Болд", "italic": "Налуу", diff --git a/locales/nb/common.json b/locales/nb/common.json index acdc41371..88615ee59 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index 3470bc713..2d97f0316 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index 6e087623d..512d60f0a 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index 56ba1e162..bb7166a24 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index f00cb6e54..0638da285 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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", diff --git a/locales/ru/common.json b/locales/ru/common.json index f636e4b19..f1e9e78f2 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -725,6 +725,7 @@ "recipient_name_placeholder": "Отображаемое имя", "autocomplete_search_server": "Искать на сервере", "autocomplete_searching": "Поиск...", + "mention_recipients": "Получатели", "toolbar": { "bold": "Жирный", "italic": "Курсив", diff --git a/locales/sk/common.json b/locales/sk/common.json index 63f90755e..87f8b2140 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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", diff --git a/locales/tr/common.json b/locales/tr/common.json index 4d35b4958..0ccc51ea5 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index 6434bf637..ca97e718c 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -725,6 +725,7 @@ "recipient_name_placeholder": "Відображуване ім'я", "autocomplete_search_server": "Шукати на сервері", "autocomplete_searching": "Пошук...", + "mention_recipients": "Одержувачі", "toolbar": { "bold": "Жирний", "italic": "Курсив", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index a207746ac..9d4ba4046 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -725,6 +725,7 @@ "recipient_name_placeholder": "顯示名稱", "autocomplete_search_server": "在伺服器上搜尋", "autocomplete_searching": "正在搜尋…", + "mention_recipients": "收件者", "toolbar": { "bold": "粗體", "italic": "斜體", diff --git a/locales/zh/common.json b/locales/zh/common.json index 6983d8e17..2754fb5de 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -725,6 +725,7 @@ "recipient_name_placeholder": "显示名称", "autocomplete_search_server": "在服务器上搜索", "autocomplete_searching": "搜索中...", + "mention_recipients": "收件人", "toolbar": { "bold": "加粗", "italic": "斜体", diff --git a/package-lock.json b/package-lock.json index cb32cc7f7..05761189a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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, diff --git a/package.json b/package.json index 13c92681a..9665146b1 100644 --- a/package.json +++ b/package.json @@ -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",