From 8389db8fcb90951d75fa252d3949c56a1c485133 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:08:21 +0100 Subject: [PATCH] feat: suggest recipients and contacts when typing @ in the message --- src/components/RichTextEditor.tsx | 21 +- .../compose/MentionSuggestionList.tsx | 104 ++++++++ src/lib/__tests__/compose-html.test.ts | 5 + .../__tests__/compose-mentions-wiring.test.ts | 33 +++ src/lib/__tests__/editor-mentions.test.ts | 130 ++++++++++ src/lib/__tests__/recipient-mentions.test.ts | 197 +++++++++++++++ src/lib/compose-html.ts | 3 + src/lib/editor-html.ts | 100 +++++++- src/lib/recipient-mentions.ts | 235 ++++++++++++++++++ src/screens/ComposeScreen.tsx | 51 ++++ 10 files changed, 876 insertions(+), 3 deletions(-) create mode 100644 src/components/compose/MentionSuggestionList.tsx create mode 100644 src/lib/__tests__/compose-mentions-wiring.test.ts create mode 100644 src/lib/__tests__/editor-mentions.test.ts create mode 100644 src/lib/__tests__/recipient-mentions.test.ts create mode 100644 src/lib/recipient-mentions.ts diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 972f9fb0..437988d6 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -60,6 +60,11 @@ export interface RichTextEditorHandle { setImageAlt(cid: string, alt: string): void; /** Insert an HTML fragment at the caret (template bodies, tables). */ insertHtml(html: string): void; + /** + * Replace the "@..." being typed with a mention link to `href` reading + * "@label" (needs `mentions`). + */ + insertMention(label: string, href: string): void; setHtml(html: string): void; /** * Read the live editor DOM content. Resolves with the current innerHTML or @@ -94,12 +99,16 @@ interface Props { collapseQuote?: boolean; /** The quote toggle's accessible names. */ quoteToggleLabels?: { show: string; hide: string }; + /** Report "@" mentions being typed (read once, on mount). */ + mentions?: boolean; + /** What follows the "@" being typed, or null once there is none. */ + onMentionQuery?: (query: string | null) => void; } const RichTextEditor = React.forwardRef(function RichTextEditor( { initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift, - collapseQuote = false, quoteToggleLabels, + collapseQuote = false, quoteToggleLabels, mentions = false, onMentionQuery, }, ref, ) { @@ -113,6 +122,8 @@ const RichTextEditor = React.forwardRef(function Ri const onBlurRef = React.useRef(onBlur); const onViewportShiftRef = React.useRef(onViewportShift); onViewportShiftRef.current = onViewportShift; + const onMentionQueryRef = React.useRef(onMentionQuery); + onMentionQueryRef.current = onMentionQuery; onChangeRef.current = onChange; onSelectionRef.current = onSelectionChange; onFocusRef.current = onFocus; @@ -123,7 +134,7 @@ const RichTextEditor = React.forwardRef(function Ri const html = React.useMemo( () => buildEditorHtml({ initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android', - collapseQuote, quoteToggleLabels, + collapseQuote, quoteToggleLabels, mentions, }), // c reference changes per theme switch but we don't want to lose user // edits on a theme change. Bake in the initial colors only. @@ -202,6 +213,9 @@ const RichTextEditor = React.forwardRef(function Ri insertHtml: (html) => { call(`window.__rne && window.__rne.insertHtml(${JSON.stringify(html)})`); }, + insertMention: (label, href) => { + call(`window.__rne && window.__rne.insertMention(${JSON.stringify(label)}, ${JSON.stringify(href)})`); + }, setHtml: (next) => { call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`); }, @@ -276,6 +290,9 @@ const RichTextEditor = React.forwardRef(function Ri case 'blur': onBlurRef.current?.(); break; + case 'mention': + onMentionQueryRef.current?.(typeof data.payload === 'string' ? data.payload : null); + break; case 'viewportShift': { const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload); if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy); diff --git a/src/components/compose/MentionSuggestionList.tsx b/src/components/compose/MentionSuggestionList.tsx new file mode 100644 index 00000000..6e57af41 --- /dev/null +++ b/src/components/compose/MentionSuggestionList.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useColors } from '../../theme/colors'; +import { spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { useLocaleStore } from '../../stores/locale-store'; +import { getEmailInitials } from '../../lib/avatar-utils'; +import type { MentionCandidate } from '../../lib/recipient-mentions'; + +/** Rows of the list above the keyboard; a short window gets fewer. */ +export function mentionListMaxHeight(shortWindow: boolean): number { + return (shortWindow ? 2 : 4) * ROW_HEIGHT; +} + +const ROW_HEIGHT = 48; + +/** + * Who an "@" in the body can name (webmail RecipientMentionList): the + * recipients first, then contacts, who picking adds to To. Sits between the + * body and the format bar, above the keyboard. + */ +export function MentionSuggestionList({ + items, onPick, shortWindow, +}: { + items: MentionCandidate[]; + onPick: (item: MentionCandidate) => void; + shortWindow: boolean; +}) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + if (items.length === 0) return null; + return ( + + + {items.map((item) => ( + onPick(item)} + style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + accessibilityRole="button" + accessibilityLabel={`@${item.label}, ${item.name && item.name !== item.label ? `${item.name}, ` : ''}${item.email}`} + > + + {getEmailInitials((item.name ?? '').trim(), item.email)} + + {/* What goes in comes first: for "Nagy János" that may be the + surname, which the full name alone would not show. */} + @{item.label} + + {item.name && item.name !== item.label ? `${item.name} · ${item.email}` : item.email} + + {/* Picking a contact also adds them as a recipient; say so first. */} + {item.addsTo && ( + + + {item.addsTo === 'cc' ? t('email_composer.cc', 'Cc') : t('email_composer.to', 'To')} + + )} + + ))} + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + box: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: c.borderLight, + backgroundColor: c.card, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + minHeight: ROW_HEIGHT, + paddingHorizontal: spacing.lg, + }, + rowPressed: { backgroundColor: c.surfaceHover }, + avatar: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: c.primaryBg, + alignItems: 'center', + justifyContent: 'center', + }, + avatarText: { ...typography.captionMedium, color: c.primary }, + label: { ...typography.bodyMedium, color: c.text, flexShrink: 0, maxWidth: '45%' }, + detail: { ...typography.caption, color: c.textSecondary, flex: 1 }, + addsTo: { + ...typography.caption, + color: c.textSecondary, + backgroundColor: c.surfaceActive, + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + overflow: 'hidden', + }, + }); +} diff --git a/src/lib/__tests__/compose-html.test.ts b/src/lib/__tests__/compose-html.test.ts index 1915fdb5..f0a81c19 100644 --- a/src/lib/__tests__/compose-html.test.ts +++ b/src/lib/__tests__/compose-html.test.ts @@ -185,6 +185,11 @@ describe('htmlToPlainText links', () => { it('drops the mailto: prefix when the text is the address', () => { expect(htmlToPlainText('a@b.c')).toBe('a@b.c'); }); + + it('writes an @mention as the name alone, as the webmail does', () => { + expect(htmlToPlainText('

Hi @Max how

', { mailMarkup: true })) + .toBe('Hi @Max how'); + }); }); describe('extractUserAuthoredText', () => { diff --git a/src/lib/__tests__/compose-mentions-wiring.test.ts b/src/lib/__tests__/compose-mentions-wiring.test.ts new file mode 100644 index 00000000..f4957688 --- /dev/null +++ b/src/lib/__tests__/compose-mentions-wiring.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// "@" mentions as wired in ComposeScreen (webmail email-composer.tsx +// mentionCandidates / findMentionContacts / addMentionedRecipient), checked +// on its source (the suite has no RN renderer). +const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8'); + +describe('ComposeScreen mention wiring', () => { + it('turns mentions on in the message editor and follows its query', () => { + const at = source.search(/', at)); + expect(editor).toMatch(/\bmentions\b/); + expect(editor).toMatch(/onMentionQuery=\{setMentionQuery\}/); + }); + + it('offers To and Cc, never Bcc, and leaves out contacts already on the message', () => { + expect(source).toMatch(/buildMentionCandidates\(toRecipients, ccRecipients\)/); + const items = source.slice(source.indexOf('const mentionItems'), source.indexOf('const pickMention')); + expect(items).toMatch(/\.\.\.bccRecipients/); + expect(items).toMatch(/primaryIdentity\?\.bcc/); + expect(items).toMatch(/!onMessage\.has\(s\.email\.trim\(\)\.toLowerCase\(\)\)/); + expect(items).toMatch(/mentionSuggestions\(mentionCandidates, contacts, mentionQuery, 'to'\)/); + }); + + it('inserts the picked mention as a mailto link and adds a contact to To', () => { + const pick = source.slice(source.indexOf('const pickMention'), source.indexOf('const pickMention') + 800); + expect(pick).toMatch(/editorRef\.current\?\.insertMention\(candidate\.label, mentionHref\(candidate\.email\)\)/); + expect(pick).toMatch(/setToRecipients\(\(prev\) => addMentionedRecipient\(prev, candidate,/); + expect(source).toMatch(/([\s\S]*?)<\/script>/); + return m![1]; +} + +const mentionQueryIn = new Function(`${MENTION_QUERY_SOURCE}; return mentionQueryIn;`)() as + (text: string) => { at: number; query: string } | null; + +describe('mentionQueryIn', () => { + it('starts at an @ at the start of a word', () => { + expect(mentionQueryIn('@')).toEqual({ at: 0, query: '' }); + expect(mentionQueryIn('Hi @ma')).toEqual({ at: 3, query: 'ma' }); + expect(mentionQueryIn(`Hi${String.fromCharCode(160)}@jü`)).toEqual({ at: 3, query: 'jü' }); + }); + + it('never starts inside an address, and ends at a space', () => { + expect(mentionQueryIn('info@example')).toBeNull(); + expect(mentionQueryIn('Hi @max and')).toBeNull(); + expect(mentionQueryIn('no at sign')).toBeNull(); + }); +}); + +interface FakeNode { nodeType: number; tagName?: string; data?: string; parentNode?: unknown; textContent?: string; attrs?: Record } + +function boot(mentions: boolean) { + const listeners: Record void>> = {}; + const docListeners: Record void>> = {}; + const posted: Array<{ type: string; payload: unknown }> = []; + const ops: string[] = []; + const editor = { + nodeType: 1, + tagName: 'DIV', + innerHTML: '', + get textContent() { return this.innerHTML.replace(/<[^>]*>/g, ''); }, + scrollHeight: 100, + querySelector: () => null, + setAttribute: () => undefined, + focus: () => undefined, + addEventListener: (name: string, fn: () => void) => { (listeners[name] ??= []).push(fn); }, + }; + const text: FakeNode = { nodeType: 3, data: 'Hi @ma', parentNode: editor }; + let selection = { rangeCount: 1, isCollapsed: true, anchorNode: text as unknown, anchorOffset: 6 }; + const range = { + setStart: (n: FakeNode, o: number) => ops.push(`start ${n === text ? 'text' : '?'} ${o}`), + setEnd: (n: FakeNode, o: number) => ops.push(`end ${n === text ? 'text' : '?'} ${o}`), + deleteContents: () => ops.push('delete'), + insertNode: (n: FakeNode) => ops.push(`insert ${n.tagName ?? JSON.stringify(n.data)}`), + setStartAfter: (n: FakeNode) => ops.push(`caret after ${n.tagName ?? JSON.stringify(n.data)}`), + collapse: () => undefined, + }; + const created: FakeNode[] = []; + const document = { + activeElement: editor as unknown, + getElementById: () => editor, + addEventListener: (name: string, fn: () => void) => { (docListeners[name] ??= []).push(fn); }, + queryCommandState: () => false, + createRange: () => range, + createElement: (tag: string) => { + const el: FakeNode = { nodeType: 1, tagName: tag.toUpperCase(), attrs: {} }; + (el as unknown as { setAttribute: (k: string, v: string) => void }).setAttribute = (k, v) => { el.attrs![k] = v; }; + created.push(el); + return el; + }, + createTextNode: (data: string) => ({ nodeType: 3, data }), + }; + const window: Record = { + ReactNativeWebView: { postMessage: (msg: string) => posted.push(JSON.parse(msg)) }, + addEventListener: () => undefined, + getSelection: () => ({ ...selection, removeAllRanges: () => ops.push('clear'), addRange: () => ops.push('add range') }), + }; + const script = extractScript(buildEditorHtml({ initialHtml: '


', placeholder: '', c: LIGHT_COLORS, mentions })); + new Function('window', 'document', script)(window, document); + const mentionsPosted = () => posted.filter((m) => m.type === 'mention').map((m) => m.payload); + const input = () => listeners.input.forEach((fn) => fn()); + const blur = () => listeners.blur.forEach((fn) => fn()); + const setCaret = (data: string, offset = data.length) => { + text.data = data; + selection = { ...selection, anchorOffset: offset }; + }; + const bridge = window.__rne as { insertMention: (label: string, href: string) => void }; + return { mentionsPosted, input, blur, setCaret, bridge, ops, created, text }; +} + +describe('editor page mentions', () => { + it('reports what follows the @ while it is typed, and null once it ends', () => { + const page = boot(true); + page.input(); + page.setCaret('Hi @max'); + page.input(); + page.setCaret('Hi @max '); + page.input(); + expect(page.mentionsPosted()).toEqual(['ma', 'max', null]); + }); + + it('closes the list when the editor loses focus', () => { + const page = boot(true); + page.input(); + page.blur(); + expect(page.mentionsPosted()).toEqual(['ma', null]); + }); + + it('replaces the typed @query with a mailto link and a space, caret after it', () => { + const page = boot(true); + page.bridge.insertMention('Max ', 'mailto:max@dornig.de'); + expect(page.ops).toEqual([ + 'start text 3', 'end text 6', 'delete', + `insert ${JSON.stringify(String.fromCharCode(160))}`, 'insert A', + `caret after ${JSON.stringify(String.fromCharCode(160))}`, 'clear', 'add range', + ]); + const [link] = page.created; + expect(link.attrs).toEqual({ href: 'mailto:max@dornig.de', class: 'mention' }); + // The label goes in as text, never as markup. + expect(link.textContent).toBe('@Max '); + }); + + it('does nothing in editors that are not a message', () => { + const page = boot(false); + page.input(); + page.bridge.insertMention('Max', 'mailto:max@dornig.de'); + expect(page.mentionsPosted()).toEqual([]); + expect(page.ops).toEqual([]); + }); +}); diff --git a/src/lib/__tests__/recipient-mentions.test.ts b/src/lib/__tests__/recipient-mentions.test.ts new file mode 100644 index 00000000..64b429ce --- /dev/null +++ b/src/lib/__tests__/recipient-mentions.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest'; + +// Ported from the webmail's lib/__tests__/recipient-mentions.test.ts, plus +// the app's own helpers at the end. +import { + addMentionedRecipient, buildMentionCandidates, filterMentionCandidates, mentionHref, mentionLabel, mentionSuggestions, +} 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']); + }); +}); + +describe('mentionSuggestions', () => { + const recipients = buildMentionCandidates( + [{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' }], + [{ name: 'Eva Weber', email: 'eva.weber@dornig.de' }], + ); + const shown = (list: ReturnType) => list.map((c) => `${c.label} ${c.email} ${c.addsTo ?? '-'}`); + + it('offers matching recipients first, then contacts marked with the field they go to', () => { + const contacts = [{ name: 'Marta Klein', email: 'marta@dornig.de' }]; + expect(shown(mentionSuggestions(recipients, contacts, 'ma', 'to'))).toEqual([ + 'Max max.mustermann@dornig.de -', + 'Marta marta@dornig.de to', + ]); + expect(mentionSuggestions(recipients, contacts, 'ma', 'cc')[1].addsTo).toBe('cc'); + }); + + it('leaves out contacts who are already recipients', () => { + const contacts = [{ name: 'Eva W.', email: 'Eva.Weber@dornig.de' }]; + expect(shown(mentionSuggestions(recipients, contacts, 'ev', 'to'))).toEqual(['Eva eva.weber@dornig.de -']); + }); + + it('drops contacts that only matched in the middle of a word', () => { + // The address autocomplete matches anywhere; an @ matches word starts. + const contacts = [{ name: 'Thomas Bauer', email: 'thomas@dornig.de' }]; + expect(mentionSuggestions(recipients, contacts, 'ma', 'to').map((c) => c.email)).toEqual(['max.mustermann@dornig.de']); + }); + + it('skips groups and entries without a usable address', () => { + const contacts = [{ name: 'Marketing', email: '' }, { name: 'Mara', email: 'mara' }]; + expect(mentionSuggestions([], contacts, 'ma', 'to')).toEqual([]); + }); + + it('renames a contact who shares a first name with a recipient, not the recipient', () => { + const contacts = [{ name: 'Max Müller', email: 'max.mueller@dornig.de' }]; + expect(shown(mentionSuggestions(recipients, contacts, 'max', 'to'))).toEqual([ + 'Max max.mustermann@dornig.de -', + 'Max Müller max.mueller@dornig.de to', + ]); + // The recipient list itself keeps its labels. + expect(recipients[0].label).toBe('Max'); + }); + + it('caps the contacts it offers', () => { + const contacts = Array.from({ length: 20 }, (_, i) => ({ name: `Mia ${i}`, email: `mia${i}@dornig.de` })); + expect(mentionSuggestions([], contacts, 'mia', 'to')).toHaveLength(8); + }); +}); + +describe('mentionHref', () => { + it('addresses only the one mailbox, whatever the address carries', () => { + expect(mentionHref('max@dornig.de')).toBe('mailto:max@dornig.de'); + expect(mentionHref('a?cc=evil@x.de#f@dornig.de')).toBe('mailto:a%3Fcc=evil@x.de%23f@dornig.de'); + }); +}); + +describe('addMentionedRecipient', () => { + const make = (c: { name?: string; email: string }): { name?: string; email: string } => ({ name: c.name, email: c.email }); + + it('adds a contact who is not on the message yet', () => { + const next = addMentionedRecipient<{ name?: string; email: string }>([{ email: 'eva@dornig.de' }], { label: 'Max', name: 'Max M', email: 'max@dornig.de', addsTo: 'to' }, make); + expect(next).toEqual([{ email: 'eva@dornig.de' }, { name: 'Max M', email: 'max@dornig.de' }]); + }); + + it('leaves the field alone when the address is there already, also inside a group', () => { + const list = [{ name: 'Team', email: '', group: { members: [{ email: 'MAX@dornig.de' }] } }]; + expect(addMentionedRecipient(list, { label: 'Max', email: 'max@dornig.de', addsTo: 'to' }, make)).toBe(list); + }); +}); diff --git a/src/lib/compose-html.ts b/src/lib/compose-html.ts index a0135d73..f838f677 100644 --- a/src/lib/compose-html.ts +++ b/src/lib/compose-html.ts @@ -154,6 +154,9 @@ function blocksToPlainText(html: string, mailMarkup: boolean): string { if (!href) return inner; const normalizedHref = href.replace(/^mailto:/i, ''); if (!text || text === href || text === normalizedHref) return text || href; + // A mention: a mailto: link reading "@Name" is "@Name" in the text part, + // as the webmail's mention chip writes it (renderText, #1134). + if (/^mailto:/i.test(href) && text.startsWith('@') && text.length > 1) return text; // Angle brackets are restored after the tag strip below. return `${text} \u0001${href}\u0002`; }); diff --git a/src/lib/editor-html.ts b/src/lib/editor-html.ts index 40ba1e33..b5a539ee 100644 --- a/src/lib/editor-html.ts +++ b/src/lib/editor-html.ts @@ -100,6 +100,31 @@ export const FOCUS_END_SOURCE = `function focusEnd(editor) { sel.addRange(range); }`; +/** + * Page-script function for "@" mentions (webmail #1134): given the text of + * the caret's text node up to the caret, where the "@" being typed starts + * and what follows it, or null. Like the webmail's suggestion it only + * starts at the beginning of a word, so info@example.com never triggers, + * and the query ends at the first space. A string, embedded in the page + * script and run by the unit test. No regex: see the template-cooking + * warning at the top. + */ +export const MENTION_QUERY_SOURCE = `function mentionQueryIn(text) { + var at = text.lastIndexOf('@'); + if (at < 0) return null; + if (at > 0) { + var before = text.charCodeAt(at - 1); + if (before !== 32 && before !== 160) return null; + } + var query = text.slice(at + 1); + if (query.length > 64) return null; + for (var i = 0; i < query.length; i++) { + var code = query.charCodeAt(i); + if (code === 32 || code === 160 || code === 10 || code === 9 || code === 64) return null; + } + return { at: at, query: query }; + }`; + /** * Whether the editor WebView may load `url`: only its initial about:blank * document (`source={{ html }}` loads as about:blank on both platforms, as @@ -150,8 +175,14 @@ export function buildEditorHtml(opts: { collapseQuote?: boolean; /** The toggle's accessible names. */ quoteToggleLabels?: { show: string; hide: string }; + /** + * Report an "@" being typed at the start of a word as `mention` messages + * (the query, or null once it ends) and accept `insertMention` (webmail + * recipient mentions, #1134). Off for editors that aren't a message. + */ + mentions?: boolean; }): string { - const { initialHtml, placeholder, c, blockRemoteImages = false, pinVisualViewport = false, collapseQuote = false } = opts; + const { initialHtml, placeholder, c, blockRemoteImages = false, pinVisualViewport = false, collapseQuote = false, mentions = false } = opts; const quoteLabels = opts.quoteToggleLabels ?? { show: 'Show quoted text', hide: 'Hide quoted text' }; // Inject initial content as a JSON-encoded string so any HTML/quotes inside // are safely embedded (no template literal collision with the script body, @@ -202,6 +233,7 @@ export function buildEditorHtml(opts: { color: ${c.textSecondary}; } #editor a { color: ${c.primary}; text-decoration: underline; } + #editor a.mention { text-decoration: none; font-weight: 600; } #editor img { max-width: 100%; height: auto; border-radius: 4px; } #editor pre, #editor code { background: ${c.surfaceActive}; @@ -417,12 +449,46 @@ export function buildEditorHtml(opts: { setQuoteCollapsed(true); } + // ── "@" mentions ─────────────────────────────────────────────────── + var MENTIONS = ${mentions ? 'true' : 'false'}; + var lastMention = null; + ${MENTION_QUERY_SOURCE} + + // The "@..." being typed at the caret: its text node and range, or null. + // Never inside a link (a mention already made) or code. + function currentMention() { + var sel = window.getSelection(); + if (!sel || !sel.rangeCount || !sel.isCollapsed) return null; + var node = sel.anchorNode; + if (!node || node.nodeType !== 3) return null; + var parent = node.parentNode; + while (parent && parent !== editor) { + if (parent.nodeType === 1 && (parent.tagName === 'A' || parent.tagName === 'CODE' || parent.tagName === 'PRE')) return null; + parent = parent.parentNode; + } + if (parent !== editor) return null; + var hit = mentionQueryIn(node.data.slice(0, sel.anchorOffset)); + if (!hit) return null; + return { node: node, start: hit.at, end: sel.anchorOffset, query: hit.query }; + } + + function reportMention(force) { + if (!MENTIONS) return; + var m = force === null ? null : currentMention(); + var query = m ? m.query : null; + if (query === lastMention) return; + lastMention = query; + post('mention', query); + } + editor.addEventListener('input', function () { scheduleChange(); reportHeight(); + reportMention(); }); editor.addEventListener('blur', function () { flushChange(); + reportMention(null); post('blur', null); }); editor.addEventListener('focus', function () { post('focus', null); }); @@ -430,6 +496,7 @@ export function buildEditorHtml(opts: { if (document.activeElement === editor) { revealQuoteAtCaret(); reportSelection(); + reportMention(); } }); @@ -541,6 +608,37 @@ export function buildEditorHtml(opts: { }, focus: function () { editor.focus(); }, focusEnd: function () { focusEnd(editor); }, + // Replace the "@..." being typed with a mention link and a space, the + // caret after it (webmail insertMention). The label and href go in as + // text and attribute, never as markup: a label comes from a display + // name, which a reply-all takes from the incoming message. + insertMention: function (label, href) { + if (!MENTIONS) return; + var m = currentMention(); + if (!m) return; + var range = document.createRange(); + range.setStart(m.node, m.start); + range.setEnd(m.node, m.end); + range.deleteContents(); + var link = document.createElement('a'); + link.setAttribute('href', href); + link.setAttribute('class', 'mention'); + link.textContent = '@' + label; + var space = document.createTextNode(String.fromCharCode(160)); + range.insertNode(space); + range.insertNode(link); + var sel = window.getSelection(); + var caret = document.createRange(); + caret.setStartAfter(space); + caret.collapse(true); + if (sel) { + sel.removeAllRanges(); + sel.addRange(caret); + } + reportChange(); + reportHeight(); + reportMention(); + }, // The description of the inline image with this cid; "" is decorative. setImageAlt: function (cid, alt) { var images = editor.getElementsByTagName('img'); diff --git a/src/lib/recipient-mentions.ts b/src/lib/recipient-mentions.ts new file mode 100644 index 00000000..051b0d6c --- /dev/null +++ b/src/lib/recipient-mentions.ts @@ -0,0 +1,235 @@ +// "@" mentions in the message body (webmail #1134), ported from the webmail's +// lib/recipient-mentions.ts. Typing "@" at the start of a word offers the +// message's To and Cc recipients, then contacts who aren't on the message +// yet; picking one inserts "@FirstName" as a mailto: link and adds a +// contact to To (webmail default `mentionAddsRecipientTo: 'to'`). + +import { expandRecipients, type Recipient } from './recipients'; + +/** 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; + /** Not a recipient yet: picking them adds them to this field. */ + addsTo?: 'to' | 'cc'; +} + +/** Someone from the contacts, the directory or recent recipients. */ +export interface MentionContact { + name?: string; + email: string; +} + +/** How many contacts an @ offers after the recipients. */ +const CONTACT_LIMIT = 8; + +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"). + */ +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. + */ +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; otherwise it is the display + * name's first word, or the address's first part when no name is known. + */ +export function mentionLabel(name: string | undefined, email: string): string { + const localPart = localPartOf(email); + const fromAddress = givenNameFromLocalPart(localPart); + const usable = usableName(name); + if (usable) { + 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; +} + +/** + * Two candidates 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. + */ +function distinguishLabels(candidates: MentionCandidate[]): MentionCandidate[] { + 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; +} + +/** + * 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[] { + return distinguishLabels(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 }; + })); +} + +/** + * What an @ followed by `query` offers: the matching recipients, then + * matching contacts who aren't on the message yet, marked with the field + * picking them adds them to. `contacts` must already leave out Bcc + * recipients, who an @ never names. + */ +export function mentionSuggestions( + recipients: MentionCandidate[], + contacts: MentionContact[], + query: string, + addsTo: 'to' | 'cc', +): MentionCandidate[] { + const matched = filterMentionCandidates(recipients, query); + const seen = new Set(recipients.map((r) => r.email.toLowerCase())); + const extra: MentionCandidate[] = []; + for (const contact of contacts) { + if (extra.length >= CONTACT_LIMIT) break; + const key = contact.email.toLowerCase(); + if (contact.email.lastIndexOf('@') <= 0 || seen.has(key)) continue; + seen.add(key); + const name = usableName(contact.name); + const candidate = { label: mentionLabel(name, contact.email), name, email: contact.email, addsTo }; + if (filterMentionCandidates([candidate], query).length > 0) extra.push(candidate); + } + if (extra.length === 0) return matched; + // A contact who would also be "@Max" next to a recipient Max goes by full + // name, or mailbox name. The recipient keeps "@Max". + const taken = new Set(recipients.map((r) => r.label.toLowerCase())); + distinguishLabels(extra); + for (const c of extra) { + if (!taken.has(c.label.toLowerCase())) continue; + c.label = c.name && !taken.has(naturalOrder(c.name).toLowerCase()) ? naturalOrder(c.name) : localPartOf(c.email); + } + return [...matched, ...extra]; +} + +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)), + ); +} + +/** + * A mailto: href for the address. Characters that would start a query or + * fragment (a crafted reply-all address) are escaped, so the link can only + * ever address this one mailbox. + */ +export function mentionHref(email: string): string { + return `mailto:${email.replace(/[%?#&\s"'<>]/g, (c) => encodeURIComponent(c))}`; +} + +/** + * Add a mentioned contact to To (or Cc) unless the address is on the message + * already, groups included (webmail addMentionedRecipient). Returns the same + * list when nothing changes; `make` builds the field's entry. + */ +export function addMentionedRecipient( + list: T[], + candidate: MentionCandidate, + make: (candidate: MentionCandidate) => T, +): T[] { + const key = candidate.email.trim().toLowerCase(); + if (expandRecipients(list).some((r) => r.email.trim().toLowerCase() === key)) return list; + return [...list, make(candidate)]; +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 83a76e2f..6e9ada68 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -80,6 +80,10 @@ import { FilesPickerSheet } from '../components/compose/FilesPickerSheet'; import { ComposerOptionsSheet, activeComposerOptionCount, type ComposerOptions } from '../components/compose/ComposerOptionsSheet'; import type { MessagePriority } from '../lib/message-priority'; import { supportsSubmissionExtension } from '../lib/submission-options'; +import { + addMentionedRecipient, buildMentionCandidates, mentionHref, mentionSuggestions, type MentionCandidate, +} from '../lib/recipient-mentions'; +import { MentionSuggestionList } from '../components/compose/MentionSuggestionList'; import { assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin, } from '../lib/compose-login'; @@ -1086,6 +1090,47 @@ export default function ComposeScreen({ route, navigation }: Props) { return identities.find((i) => i.id === selectedIdentityId) ?? defaultIdentity; }, [identities, selectedIdentityId]); + // ── "@" mentions in the body (webmail #1134) ────────────────────────── + // What follows the "@" being typed in the editor, null when none is. + const [mentionQuery, setMentionQuery] = React.useState(null); + // Who an "@" offers: To and Cc. Never Bcc - naming a blind-copied + // recipient in the text would disclose them to everyone else. + const mentionCandidates = React.useMemo( + () => buildMentionCandidates(toRecipients, ccRecipients), + [toRecipients, ccRecipients], + ); + // Contacts come after the recipients; anyone already on the message is + // left out, Bcc and the identity's own Bcc included. + const mentionItems = React.useMemo(() => { + if (mentionQuery === null) return []; + const onMessage = new Set( + [...toRecipients, ...ccRecipients, ...bccRecipients] + .flatMap((r) => (r.group ? r.group.members.map((m) => m.email) : [r.email])) + .concat((primaryIdentity?.bcc ?? []).map((r) => r.email)) + .map((e) => e.trim().toLowerCase()), + ); + const contacts = mentionQuery + ? getAutocomplete(mentionQuery, 16) + .filter((s) => !s.group && !!s.email && !onMessage.has(s.email.trim().toLowerCase())) + .map((s) => ({ name: s.name || undefined, email: s.email })) + : []; + return mentionSuggestions(mentionCandidates, contacts, mentionQuery, 'to'); + // contactsVersion/recentVersion re-run the lookup when the store loads. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mentionQuery, mentionCandidates, toRecipients, ccRecipients, bccRecipients, primaryIdentity, getAutocomplete, contactsVersion, recentVersion]); + const pickMention = (candidate: MentionCandidate) => { + editorRef.current?.insertMention(candidate.label, mentionHref(candidate.email)); + // A contact who isn't on the message yet joins To (webmail default). + if (candidate.addsTo === 'cc') { + setCcRecipients((prev) => addMentionedRecipient(prev, candidate, (c) => ({ name: c.name ?? '', email: c.email }))); + setCcVisible(true); + } else if (candidate.addsTo) { + setToRecipients((prev) => addMentionedRecipient(prev, candidate, (c) => ({ name: c.name ?? '', email: c.email }))); + } + setMentionQuery(null); + }; + + // Set once the user picks From by hand: their pick then brings its own // signature, also when it has none (webmail identityPickedByUser). const [identityPickedByUser, setIdentityPickedByUser] = React.useState(false); @@ -3000,6 +3045,8 @@ export default function ComposeScreen({ route, navigation }: Props) { onChange={setBodyHtml} onSelectionChange={setSelState} onViewportShift={onEditorViewportShift} + mentions + onMentionQuery={setMentionQuery} /> )} {/* The empty space under the body looks like part of it (on a @@ -3014,6 +3061,10 @@ export default function ComposeScreen({ route, navigation }: Props) { /> + {!plainTextMode && ( + + )} + {/* A phone in landscape with the keyboard up has room for a few lines of text only; the format bar comes back with the keyboard down. */} {!typingInShortWindow && (