feat: suggest recipients and contacts when typing @ in the message

This commit is contained in:
Linus Rath
2026-10-31 15:08:21 +01:00
parent 649d3adc0f
commit 8389db8fcb
10 changed files with 876 additions and 3 deletions
+19 -2
View File
@@ -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<RichTextEditorHandle, Props>(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<RichTextEditorHandle, Props>(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<RichTextEditorHandle, Props>(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<RichTextEditorHandle, Props>(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<RichTextEditorHandle, Props>(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);
@@ -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 (
<View
style={[styles.box, { maxHeight: mentionListMaxHeight(shortWindow) }]}
accessibilityLabel={t('email_composer.mention_recipients', 'Recipients')}
>
<ScrollView keyboardShouldPersistTaps="always" nestedScrollEnabled>
{items.map((item) => (
<Pressable
key={item.email}
onPress={() => onPick(item)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="button"
accessibilityLabel={`@${item.label}, ${item.name && item.name !== item.label ? `${item.name}, ` : ''}${item.email}`}
>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{getEmailInitials((item.name ?? '').trim(), item.email)}</Text>
</View>
{/* What goes in comes first: for "Nagy János" that may be the
surname, which the full name alone would not show. */}
<Text style={styles.label} numberOfLines={1}>@{item.label}</Text>
<Text style={styles.detail} numberOfLines={1}>
{item.name && item.name !== item.label ? `${item.name} · ${item.email}` : item.email}
</Text>
{/* Picking a contact also adds them as a recipient; say so first. */}
{item.addsTo && (
<Text style={styles.addsTo}>
+ {item.addsTo === 'cc' ? t('email_composer.cc', 'Cc') : t('email_composer.to', 'To')}
</Text>
)}
</Pressable>
))}
</ScrollView>
</View>
);
}
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',
},
});
}
+5
View File
@@ -185,6 +185,11 @@ describe('htmlToPlainText links', () => {
it('drops the mailto: prefix when the text is the address', () => {
expect(htmlToPlainText('<a href="mailto:a@b.c">a@b.c</a>')).toBe('a@b.c');
});
it('writes an @mention as the name alone, as the webmail does', () => {
expect(htmlToPlainText('<p>Hi <a href="mailto:max@x.de" class="mention">@Max</a>&nbsp;how</p>', { mailMarkup: true }))
.toBe('Hi @Max how');
});
});
describe('extractUserAuthoredText', () => {
@@ -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(/<RichTextEditor\s/);
const editor = source.slice(at, source.indexOf('/>', 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(/<MentionSuggestionList items=\{mentionItems\} onPick=\{pickMention\}/);
});
});
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest';
import { buildEditorHtml, MENTION_QUERY_SOURCE } from '../editor-html';
import { LIGHT_COLORS } from '../../theme/tokens';
// "@" mentions in the composer's editor page (webmail #1134): the page
// reports the "@..." being typed and swaps it for a mention link on request.
function extractScript(html: string): string {
const m = html.match(/<script(?: nonce="[0-9a-f]+")?>([\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<string, string> }
function boot(mentions: boolean) {
const listeners: Record<string, Array<() => void>> = {};
const docListeners: Record<string, Array<() => 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<string, unknown> = {
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: '<p><br></p>', 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 <b>', '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 <b>');
});
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([]);
});
});
@@ -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<typeof mentionSuggestions>) => 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);
});
});
+3
View File
@@ -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`;
});
+99 -1
View File
@@ -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');
+235
View File
@@ -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<string, string> = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' };
/** Whether two names match as an address spells them: "Jürgen" and "juergen". */
function spelledAlike(a: string, b: string): boolean {
const key = (s: string) => fold(s.normalize('NFC').toLowerCase().replace(/[äöüß]/g, (c) => UMLAUT_SPELLINGS[c]));
return key(a) === key(b);
}
/**
* A display name worth deriving a label from: not empty, not an address.
* Capped because a reply-all takes it from the incoming message.
*/
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<string> {
const seen = new Set<string>();
const clashes = new Set<string>();
for (const { label } of candidates) {
const key = label.toLowerCase();
if (seen.has(key)) clashes.add(key);
seen.add(key);
}
return clashes;
}
/**
* 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<T extends Recipient>(
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)];
}
+51
View File
@@ -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<string | null>(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<MentionCandidate[]>(() => {
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) {
/>
</ScrollView>
{!plainTextMode && (
<MentionSuggestionList items={mentionItems} onPick={pickMention} shortWindow={typingInShortWindow} />
)}
{/* 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 && (