mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: suggest recipients and contacts when typing @ in the message
This commit is contained in:
@@ -60,6 +60,11 @@ export interface RichTextEditorHandle {
|
|||||||
setImageAlt(cid: string, alt: string): void;
|
setImageAlt(cid: string, alt: string): void;
|
||||||
/** Insert an HTML fragment at the caret (template bodies, tables). */
|
/** Insert an HTML fragment at the caret (template bodies, tables). */
|
||||||
insertHtml(html: string): void;
|
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;
|
setHtml(html: string): void;
|
||||||
/**
|
/**
|
||||||
* Read the live editor DOM content. Resolves with the current innerHTML or
|
* Read the live editor DOM content. Resolves with the current innerHTML or
|
||||||
@@ -94,12 +99,16 @@ interface Props {
|
|||||||
collapseQuote?: boolean;
|
collapseQuote?: boolean;
|
||||||
/** The quote toggle's accessible names. */
|
/** The quote toggle's accessible names. */
|
||||||
quoteToggleLabels?: { show: string; hide: string };
|
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(
|
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
|
||||||
{
|
{
|
||||||
initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift,
|
initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift,
|
||||||
collapseQuote = false, quoteToggleLabels,
|
collapseQuote = false, quoteToggleLabels, mentions = false, onMentionQuery,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
@@ -113,6 +122,8 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
const onBlurRef = React.useRef(onBlur);
|
const onBlurRef = React.useRef(onBlur);
|
||||||
const onViewportShiftRef = React.useRef(onViewportShift);
|
const onViewportShiftRef = React.useRef(onViewportShift);
|
||||||
onViewportShiftRef.current = onViewportShift;
|
onViewportShiftRef.current = onViewportShift;
|
||||||
|
const onMentionQueryRef = React.useRef(onMentionQuery);
|
||||||
|
onMentionQueryRef.current = onMentionQuery;
|
||||||
onChangeRef.current = onChange;
|
onChangeRef.current = onChange;
|
||||||
onSelectionRef.current = onSelectionChange;
|
onSelectionRef.current = onSelectionChange;
|
||||||
onFocusRef.current = onFocus;
|
onFocusRef.current = onFocus;
|
||||||
@@ -123,7 +134,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
const html = React.useMemo(
|
const html = React.useMemo(
|
||||||
() => buildEditorHtml({
|
() => buildEditorHtml({
|
||||||
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
|
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
|
// 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.
|
// 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) => {
|
insertHtml: (html) => {
|
||||||
call(`window.__rne && window.__rne.insertHtml(${JSON.stringify(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) => {
|
setHtml: (next) => {
|
||||||
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
|
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
|
||||||
},
|
},
|
||||||
@@ -276,6 +290,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
case 'blur':
|
case 'blur':
|
||||||
onBlurRef.current?.();
|
onBlurRef.current?.();
|
||||||
break;
|
break;
|
||||||
|
case 'mention':
|
||||||
|
onMentionQueryRef.current?.(typeof data.payload === 'string' ? data.payload : null);
|
||||||
|
break;
|
||||||
case 'viewportShift': {
|
case 'viewportShift': {
|
||||||
const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload);
|
const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload);
|
||||||
if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy);
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -185,6 +185,11 @@ describe('htmlToPlainText links', () => {
|
|||||||
it('drops the mailto: prefix when the text is the address', () => {
|
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');
|
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> how</p>', { mailMarkup: true }))
|
||||||
|
.toBe('Hi @Max how');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('extractUserAuthoredText', () => {
|
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\}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -154,6 +154,9 @@ function blocksToPlainText(html: string, mailMarkup: boolean): string {
|
|||||||
if (!href) return inner;
|
if (!href) return inner;
|
||||||
const normalizedHref = href.replace(/^mailto:/i, '');
|
const normalizedHref = href.replace(/^mailto:/i, '');
|
||||||
if (!text || text === href || text === normalizedHref) return text || href;
|
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.
|
// Angle brackets are restored after the tag strip below.
|
||||||
return `${text} \u0001${href}\u0002`;
|
return `${text} \u0001${href}\u0002`;
|
||||||
});
|
});
|
||||||
|
|||||||
+99
-1
@@ -100,6 +100,31 @@ export const FOCUS_END_SOURCE = `function focusEnd(editor) {
|
|||||||
sel.addRange(range);
|
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
|
* Whether the editor WebView may load `url`: only its initial about:blank
|
||||||
* document (`source={{ html }}` loads as about:blank on both platforms, as
|
* document (`source={{ html }}` loads as about:blank on both platforms, as
|
||||||
@@ -150,8 +175,14 @@ export function buildEditorHtml(opts: {
|
|||||||
collapseQuote?: boolean;
|
collapseQuote?: boolean;
|
||||||
/** The toggle's accessible names. */
|
/** The toggle's accessible names. */
|
||||||
quoteToggleLabels?: { show: string; hide: string };
|
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 {
|
}): 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' };
|
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
|
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
||||||
// are safely embedded (no template literal collision with the script body,
|
// are safely embedded (no template literal collision with the script body,
|
||||||
@@ -202,6 +233,7 @@ export function buildEditorHtml(opts: {
|
|||||||
color: ${c.textSecondary};
|
color: ${c.textSecondary};
|
||||||
}
|
}
|
||||||
#editor a { color: ${c.primary}; text-decoration: underline; }
|
#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 img { max-width: 100%; height: auto; border-radius: 4px; }
|
||||||
#editor pre, #editor code {
|
#editor pre, #editor code {
|
||||||
background: ${c.surfaceActive};
|
background: ${c.surfaceActive};
|
||||||
@@ -417,12 +449,46 @@ export function buildEditorHtml(opts: {
|
|||||||
setQuoteCollapsed(true);
|
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 () {
|
editor.addEventListener('input', function () {
|
||||||
scheduleChange();
|
scheduleChange();
|
||||||
reportHeight();
|
reportHeight();
|
||||||
|
reportMention();
|
||||||
});
|
});
|
||||||
editor.addEventListener('blur', function () {
|
editor.addEventListener('blur', function () {
|
||||||
flushChange();
|
flushChange();
|
||||||
|
reportMention(null);
|
||||||
post('blur', null);
|
post('blur', null);
|
||||||
});
|
});
|
||||||
editor.addEventListener('focus', function () { post('focus', null); });
|
editor.addEventListener('focus', function () { post('focus', null); });
|
||||||
@@ -430,6 +496,7 @@ export function buildEditorHtml(opts: {
|
|||||||
if (document.activeElement === editor) {
|
if (document.activeElement === editor) {
|
||||||
revealQuoteAtCaret();
|
revealQuoteAtCaret();
|
||||||
reportSelection();
|
reportSelection();
|
||||||
|
reportMention();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -541,6 +608,37 @@ export function buildEditorHtml(opts: {
|
|||||||
},
|
},
|
||||||
focus: function () { editor.focus(); },
|
focus: function () { editor.focus(); },
|
||||||
focusEnd: function () { focusEnd(editor); },
|
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.
|
// The description of the inline image with this cid; "" is decorative.
|
||||||
setImageAlt: function (cid, alt) {
|
setImageAlt: function (cid, alt) {
|
||||||
var images = editor.getElementsByTagName('img');
|
var images = editor.getElementsByTagName('img');
|
||||||
|
|||||||
@@ -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)];
|
||||||
|
}
|
||||||
@@ -80,6 +80,10 @@ import { FilesPickerSheet } from '../components/compose/FilesPickerSheet';
|
|||||||
import { ComposerOptionsSheet, activeComposerOptionCount, type ComposerOptions } from '../components/compose/ComposerOptionsSheet';
|
import { ComposerOptionsSheet, activeComposerOptionCount, type ComposerOptions } from '../components/compose/ComposerOptionsSheet';
|
||||||
import type { MessagePriority } from '../lib/message-priority';
|
import type { MessagePriority } from '../lib/message-priority';
|
||||||
import { supportsSubmissionExtension } from '../lib/submission-options';
|
import { supportsSubmissionExtension } from '../lib/submission-options';
|
||||||
|
import {
|
||||||
|
addMentionedRecipient, buildMentionCandidates, mentionHref, mentionSuggestions, type MentionCandidate,
|
||||||
|
} from '../lib/recipient-mentions';
|
||||||
|
import { MentionSuggestionList } from '../components/compose/MentionSuggestionList';
|
||||||
import {
|
import {
|
||||||
assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
|
assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
|
||||||
} from '../lib/compose-login';
|
} from '../lib/compose-login';
|
||||||
@@ -1086,6 +1090,47 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
return identities.find((i) => i.id === selectedIdentityId) ?? defaultIdentity;
|
return identities.find((i) => i.id === selectedIdentityId) ?? defaultIdentity;
|
||||||
}, [identities, selectedIdentityId]);
|
}, [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
|
// Set once the user picks From by hand: their pick then brings its own
|
||||||
// signature, also when it has none (webmail identityPickedByUser).
|
// signature, also when it has none (webmail identityPickedByUser).
|
||||||
const [identityPickedByUser, setIdentityPickedByUser] = React.useState(false);
|
const [identityPickedByUser, setIdentityPickedByUser] = React.useState(false);
|
||||||
@@ -3000,6 +3045,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
onChange={setBodyHtml}
|
onChange={setBodyHtml}
|
||||||
onSelectionChange={setSelState}
|
onSelectionChange={setSelState}
|
||||||
onViewportShift={onEditorViewportShift}
|
onViewportShift={onEditorViewportShift}
|
||||||
|
mentions
|
||||||
|
onMentionQuery={setMentionQuery}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* The empty space under the body looks like part of it (on a
|
{/* 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>
|
</ScrollView>
|
||||||
|
|
||||||
|
{!plainTextMode && (
|
||||||
|
<MentionSuggestionList items={mentionItems} onPick={pickMention} shortWindow={typingInShortWindow} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* A phone in landscape with the keyboard up has room for a few lines
|
{/* 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. */}
|
of text only; the format bar comes back with the keyboard down. */}
|
||||||
{!typingInShortWindow && (
|
{!typingInShortWindow && (
|
||||||
|
|||||||
Reference in New Issue
Block a user