feat: turn @mentions into chips with a hover card

This commit is contained in:
Linus Rath
2026-10-18 17:52:11 +02:00
parent 0ac7bcb385
commit fbae8acfb8
38 changed files with 619 additions and 26 deletions
+31
View File
@@ -817,6 +817,37 @@ optgroup {
cursor: pointer;
}
/* @mention chip (mention-chip.tsx). In the sent mail it is a plain mailto link. */
.tiptap a.mention {
display: inline-block;
padding: 0 0.3em;
border-radius: 0.375rem;
background-color: color-mix(in srgb, var(--color-primary) 14%, transparent);
color: var(--color-primary);
font-weight: 500;
line-height: 1.35;
text-decoration: none;
cursor: default;
user-select: none;
}
.tiptap a.mention:hover {
background-color: color-mix(in srgb, var(--color-primary) 22%, transparent);
}
.tiptap a.mention.ProseMirror-selectednode {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
/* Named in the text but not in To or Cc. */
.tiptap a.mention.mention-outside {
background-color: transparent;
color: var(--color-muted-foreground);
box-shadow: inset 0 0 0 1px currentColor;
border: 0;
}
.tiptap img {
max-width: 100%;
height: auto;
@@ -209,9 +209,9 @@ const settle = () => act(() => new Promise<void>((resolve) => setTimeout(resolve
/** Lets the editor's delayed focus (and scroll to the cursor) run inside the test. */
const nextFrame = () => act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
async function renderComposer() {
async function renderComposer(draft: Partial<typeof DRAFT> = {}) {
const onClose = vi.fn();
render(<EmailComposer initialData={DRAFT} onClose={onClose} />);
render(<EmailComposer initialData={{ ...DRAFT, ...draft }} onClose={onClose} />);
// An edit makes the draft dirty, so closing asks first instead of closing.
fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
// TipTap keeps its editor on the editable element.
@@ -443,4 +443,67 @@ describe('composer with the @ recipient list', () => {
expect(await screen.findByRole('listbox', { name: 'mention_recipients' })).toBeTruthy();
});
});
describe('mention chips', () => {
beforeAll(() => { addressBook.entries = [{ name: 'Dana Scully', email: 'dana@example.com' }]; });
afterAll(() => { addressBook.entries = []; });
const chipPos = (editor: Editor, email: string) => {
let at = -1;
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'mentionChip' && node.attrs.email === email) at = pos;
});
return at;
};
const card = () => screen.queryByTestId('mention-card');
it('shows who a picked mention is when it is clicked, and removes it from there', async () => {
const { editor } = await renderComposer();
await typeInto(editor, ' @b');
await screen.findByRole('listbox', { name: 'mention_recipients' });
press(editor, 'Enter');
await nextFrame();
const chip = editor.view.dom.querySelector('a.mention')!;
expect(chip.textContent).toBe('@Bob');
expect(chip.classList.contains('mention-outside')).toBe(false);
act(() => { editor.commands.setNodeSelection(chipPos(editor, 'bob@example.com')); });
const shown = await screen.findByTestId('mention-card');
expect(shown).toHaveTextContent('Bob Builder');
expect(shown).toHaveTextContent('bob@example.com');
expect(shown).not.toHaveTextContent('mention_card_not_recipient');
expect(within(shown).queryByRole('button', { name: 'mention_card_add' })).toBeNull();
fireEvent.click(within(shown).getByRole('button', { name: 'mention_card_remove' }));
await waitFor(() => expect(card()).toBeNull());
expect(editor.view.dom.querySelector('a.mention')).toBeNull();
expect(editor.getText()).toBe('Hallo ');
});
it('opens on hover and closes when the pointer leaves', async () => {
const { editor } = await renderComposer({ body: '<p>Hallo <a href="mailto:bob@example.com">@Bob</a></p>' });
const chip = editor.view.dom.querySelector('a.mention')!;
fireEvent.mouseOver(chip);
expect(await screen.findByTestId('mention-card')).toHaveTextContent('Bob Builder');
fireEvent.mouseOut(chip, { relatedTarget: document.body });
await waitFor(() => expect(card()).toBeNull());
});
it('marks a mention of someone not on the message and offers to add them', async () => {
const { editor } = await renderComposer({ body: '<p>Hallo <a href="mailto:dana@example.com">@Dana</a></p>' });
const chip = () => editor.view.dom.querySelector('a.mention')!;
expect(chip().classList.contains('mention-outside')).toBe(true);
expect(within(screen.getByTestId('composer-to')).queryByText(/dana/)).toBeNull();
act(() => { editor.commands.setNodeSelection(chipPos(editor, 'dana@example.com')); });
const shown = await screen.findByTestId('mention-card');
expect(shown).toHaveTextContent('Dana Scully');
expect(shown).toHaveTextContent('mention_card_not_recipient');
fireEvent.click(within(shown).getByRole('button', { name: 'mention_card_add' }));
// Added with the name from the contacts, not just the address.
expect(within(screen.getByTestId('composer-to')).getByText(/Dana Scully/)).toBeTruthy();
await waitFor(() => expect(chip().classList.contains('mention-outside')).toBe(false));
expect(card()).not.toHaveTextContent('mention_card_not_recipient');
});
});
});
@@ -144,11 +144,11 @@ describe('RecipientMention insertion', () => {
await settle();
const enter = press(editor, 'Enter');
expect(enter.defaultPrevented).toBe(true);
expect(editor.getHTML()).toBe('<p>Hallo @Max </p>');
expect(editor.getHTML()).toBe('<p>Hallo <a href="mailto:max.mustermann@dornig.de" class="mention">@Max</a> </p>');
expect(suggestion.props).toBeNull();
// The cursor sits after the space, ready to go on typing.
await type(editor, 'bitte');
expect(editor.getHTML()).toBe('<p>Hallo @Max bitte</p>');
expect(editor.getHTML()).toBe('<p>Hallo <a href="mailto:max.mustermann@dornig.de" class="mention">@Max</a> bitte</p>');
editor.destroy();
});
@@ -158,14 +158,47 @@ describe('RecipientMention insertion', () => {
await settle();
expect(labels(suggestion)).toEqual(['Max']);
press(editor, 'Enter');
expect(editor.getHTML()).toBe('<p>Hallo @Max welt</p>');
expect(editor.getHTML()).toBe('<p>Hallo <a href="mailto:max.mustermann@dornig.de" class="mention">@Max</a> welt</p>');
editor.destroy();
});
it('inserts the label as text, never as markup', () => {
const { editor } = setup('<p></p>');
insertMention(editor, { from: 1, to: 1 }, '<img src=x onerror=alert(1)>');
expect(editor.getHTML()).toBe('<p>@&lt;img src=x onerror=alert(1)&gt; </p>');
insertMention(editor, { from: 1, to: 1 }, { label: '<img src=x onerror=alert(1)>', email: 'x@dornig.de' });
expect(editor.getHTML()).toBe('<p><a href="mailto:x@dornig.de" class="mention">@&lt;img src=x onerror=alert(1)&gt;</a> </p>');
editor.destroy();
});
it('inserts one chip carrying the name and the address', async () => {
const { editor } = setup('<p>Hallo</p>');
await type(editor, ' @ma');
await settle();
press(editor, 'Enter');
const chips: string[] = [];
editor.state.doc.descendants((node) => {
if (node.type.name === 'mentionChip') chips.push(`${node.attrs.label} ${node.attrs.email}`);
});
expect(chips).toEqual(['Max max.mustermann@dornig.de']);
editor.destroy();
});
it('keeps a mailto query or fragment out of the link', () => {
const { editor } = setup('<p></p>');
insertMention(editor, { from: 1, to: 1 }, { label: 'X', email: 'x@dornig.de?cc=evil@example.com' });
expect(editor.getHTML()).toContain('href="mailto:x@dornig.de%3Fcc=evil@example.com"');
editor.destroy();
});
it('turns a sent mention back into a chip, and leaves other mailto links alone', () => {
const { editor } = setup(
'<p>Hi <a href="mailto:eva.weber@dornig.de">@Eva</a> and <a href="mailto:info@dornig.de">write us</a></p>',
);
const chips: string[] = [];
editor.state.doc.descendants((node) => {
if (node.type.name === 'mentionChip') chips.push(`${node.attrs.label} ${node.attrs.email}`);
});
expect(chips).toEqual(['Eva eva.weber@dornig.de']);
expect(editor.getText()).toBe('Hi @Eva and write us');
editor.destroy();
});
@@ -176,7 +209,7 @@ describe('RecipientMention insertion', () => {
press(list.editor, 'Enter');
// Still one item: the Enter did not split it. (StarterKit's trailing
// node adds an empty paragraph after the list.)
expect(list.editor.getHTML()).toContain('<ul><li><p>Hallo @Max </p></li></ul>');
expect(list.editor.getHTML()).toContain('<ul><li><p>Hallo <a href="mailto:max.mustermann@dornig.de" class="mention">@Max</a> </p></li></ul>');
list.editor.destroy();
const table = setup(
@@ -191,7 +224,8 @@ describe('RecipientMention insertion', () => {
expect(labels(table.suggestion)).toEqual(['Eva']);
press(table.editor, 'Tab');
expect(table.editor.getText()).toContain('x @Eva ');
expect(table.editor.state.selection.$from.parent.textContent).toBe('x @Eva ');
const cell = table.editor.state.selection.$from.parent;
expect(cell.textBetween(0, cell.content.size, '', (leaf) => `@${leaf.attrs.label}`)).toBe('x @Eva ');
table.editor.destroy();
});
@@ -256,7 +290,7 @@ describe('RichTextEditor recipient list', () => {
press(editor, 'Enter');
await nextFrame();
expect(editor.getText()).toBe('@Eva ');
expect(onChange.mock.lastCall?.[0]).toContain('>@Eva </p>');
expect(onChange.mock.lastCall?.[0]).toContain('<a href="mailto:eva.weber@dornig.de" class="mention">@Eva</a> </p>');
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
});
+341
View File
@@ -0,0 +1,341 @@
"use client";
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Node, type Editor } from "@tiptap/core";
import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { useTranslations } from "next-intl";
import { Avatar } from "@/components/ui/avatar";
import type { MentionCandidate, MentionContact } from "@/lib/recipient-mentions";
/**
* Addresses on the message (To and Cc) that mentions are checked against;
* null while unknown, which marks nobody.
*/
export const mentionRecipientsKey = new PluginKey<Set<string> | null>("mentionRecipients");
/** Tells the editor who is on the message, so chips of anyone else stand out. */
export function setMentionRecipients(editor: Editor, emails: string[] | null) {
if (editor.isDestroyed) return;
const next = emails ? new Set(emails.map((e) => e.trim().toLowerCase())) : null;
const current = mentionRecipientsKey.getState(editor.state);
if (current === next || (current && next && current.size === next.size && [...next].every((e) => current.has(e)))) return;
editor.view.dispatch(editor.state.tr.setMeta(mentionRecipientsKey, next).setMeta("addToHistory", false));
}
/**
* 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))}`;
}
/**
* The mention a link stands for: a mailto: link whose whole text is
* "@Name". That is what a sent mention is, from Bulwark or from Outlook,
* so a reopened draft or a pasted mention becomes a chip again.
*/
export function mentionFromLink(element: HTMLElement): { label: string; email: string } | null {
const href = element.getAttribute("href")?.trim() ?? "";
if (!/^mailto:/i.test(href) || element.children.length > 0) return null;
const text = element.textContent?.trim() ?? "";
if (!text.startsWith("@") || text.length < 2) return null;
let email: string;
try {
email = decodeURIComponent(href.slice("mailto:".length).split("?")[0]).trim();
} catch {
return null;
}
if (email.lastIndexOf("@") <= 0) return null;
return { label: text.slice(1), email };
}
/**
* An @mention in the body: one inline unit, deleted and selected as a
* whole. In the sent message it is a mailto: link reading "@Name", which
* every client shows as a link to the person.
*/
export const MentionChip = Node.create({
name: "mentionChip",
group: "inline",
inline: true,
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
label: { default: "", rendered: false },
email: { default: "", rendered: false },
};
},
parseHTML() {
return [{
tag: "a[href]",
// Ahead of the link mark, which would otherwise keep it as a plain link.
priority: 60,
getAttrs: (element) => mentionFromLink(element as HTMLElement) ?? false,
}];
},
renderHTML({ node }) {
// The label is a text node here, never markup: it comes from display
// names, which a reply-all takes from the incoming message.
return ["a", { href: mentionHref(node.attrs.email as string), class: "mention" }, `@${node.attrs.label as string}`];
},
renderText({ node }) {
return `@${node.attrs.label as string}`;
},
addProseMirrorPlugins() {
const type = this.name;
return [
new Plugin<Set<string> | null>({
key: mentionRecipientsKey,
state: {
init: () => null,
apply: (tr, value) => {
const next = tr.getMeta(mentionRecipientsKey) as Set<string> | null | undefined;
return next === undefined ? value : next;
},
},
props: {
decorations(state) {
const recipients = mentionRecipientsKey.getState(state);
if (!recipients) return null;
const marked: Decoration[] = [];
state.doc.descendants((node, pos) => {
if (node.type.name === type && !recipients.has(String(node.attrs.email).toLowerCase())) {
marked.push(Decoration.node(pos, pos + node.nodeSize, { class: "mention-outside" }));
}
});
return marked.length ? DecorationSet.create(state.doc, marked) : null;
},
},
}),
];
},
});
interface ChipTarget {
pos: number;
element: HTMLElement;
/** Opened by selecting the chip (click, tap, keyboard), not by hovering. */
pinned: boolean;
}
interface MentionChipCardProps {
editor: Editor;
/** The message's To and Cc; undefined when mentions are off. */
recipients?: MentionCandidate[];
/** Looks up the full name of someone who isn't a recipient. */
findContacts?: (query: string) => MentionContact[];
/** The field "Add" puts someone in. */
addsTo: "to" | "cc";
onAdd?: (candidate: MentionCandidate) => void;
}
const HOVER_DELAY = 300;
const LEAVE_DELAY = 150;
/**
* The card for a mention chip: who it is, whether they're on the message,
* and buttons to add them or remove the mention. Hovering a chip shows it;
* selecting the chip (a click or tap) keeps it open.
*/
export function MentionChipCard({ editor, recipients, findContacts, addsTo, onAdd }: MentionChipCardProps) {
const t = useTranslations("email_composer");
const [target, setTarget] = useState<ChipTarget | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const cardRef = useRef<HTMLDivElement>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = () => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
};
const close = useCallback(() => {
clearTimer();
setTarget(null);
}, []);
// Hover: open after a short pause, close a moment after leaving the chip
// unless the pointer moved onto the card.
useEffect(() => {
if (editor.isDestroyed) return;
const dom = editor.view.dom;
const chipAt = (node: EventTarget | null) =>
node instanceof Element ? node.closest<HTMLElement>("a.mention") : null;
const onOver = (event: MouseEvent) => {
const element = chipAt(event.target);
if (!element || !dom.contains(element)) return;
clearTimer();
timer.current = setTimeout(() => {
if (editor.isDestroyed) return;
const pos = editor.view.posAtDOM(element, 0);
setTarget((current) => current?.pinned ? current : { pos, element, pinned: false });
}, HOVER_DELAY);
};
const onOut = (event: MouseEvent) => {
if (!chipAt(event.target) || chipAt(event.relatedTarget)) return;
clearTimer();
timer.current = setTimeout(() => {
setTarget((current) => current?.pinned ? current : null);
}, LEAVE_DELAY);
};
dom.addEventListener("mouseover", onOver);
dom.addEventListener("mouseout", onOut);
return () => {
dom.removeEventListener("mouseover", onOver);
dom.removeEventListener("mouseout", onOut);
clearTimer();
};
}, [editor]);
// Selecting a chip pins the card; moving the selection off it closes it.
useEffect(() => {
const onSelection = () => {
const { selection } = editor.state;
if (selection instanceof NodeSelection && selection.node.type.name === "mentionChip") {
const element = editor.view.nodeDOM(selection.from);
if (element instanceof HTMLElement) {
clearTimer();
setTarget({ pos: selection.from, element, pinned: true });
return;
}
}
setTarget((current) => current?.pinned ? null : current);
};
// A blur closes it too, but not a press on the card's own buttons,
// which keep the focus in the editor.
const onBlur = () => setTarget((current) => current?.pinned ? null : current);
editor.on("selectionUpdate", onSelection);
editor.on("blur", onBlur);
return () => {
editor.off("selectionUpdate", onSelection);
editor.off("blur", onBlur);
};
}, [editor]);
// The chip it belongs to went away (deleted, undone): close.
useEffect(() => {
if (!target) return;
const onUpdate = () => {
if (!target.element.isConnected) close();
};
editor.on("update", onUpdate);
return () => { editor.off("update", onUpdate); };
}, [editor, target, close]);
// Under the chip, kept on screen, following scrolling.
useLayoutEffect(() => {
if (!target) {
setPosition(null);
return;
}
const place = () => {
const card = cardRef.current;
if (!target.element.isConnected) return;
const rect = target.element.getBoundingClientRect();
const width = card?.offsetWidth ?? 0;
const height = card?.offsetHeight ?? 0;
const x = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
const below = rect.bottom + 4;
const y = below + height > window.innerHeight - 8 && rect.top - height - 4 > 8 ? rect.top - height - 4 : below;
setPosition({ x, y });
};
place();
window.addEventListener("scroll", place, true);
window.addEventListener("resize", place);
return () => {
window.removeEventListener("scroll", place, true);
window.removeEventListener("resize", place);
};
}, [target]);
if (!target || editor.isDestroyed) return null;
const node = editor.state.doc.nodeAt(target.pos);
if (!node || node.type.name !== "mentionChip") return null;
const label = node.attrs.label as string;
const email = node.attrs.email as string;
const key = email.toLowerCase();
const recipient = recipients?.find((r) => r.email.toLowerCase() === key);
const contact = recipient ? undefined : findContacts?.(email).find((c) => c.email.toLowerCase() === key);
const name = recipient?.name || contact?.name || label;
const outside = !!recipients && !recipient;
const remove = () => {
const from = target.pos;
// The space inserted after the chip goes with it.
const next = editor.state.doc.textBetween(from + node.nodeSize, Math.min(from + node.nodeSize + 1, editor.state.doc.content.size));
const to = from + node.nodeSize + (next === " " ? 1 : 0);
close();
editor.chain().focus().deleteRange({ from, to }).run();
};
const add = () => {
onAdd?.({ label, name: name !== label ? name : undefined, email, addsTo });
if (!target.pinned) close();
};
return createPortal(
<div
ref={cardRef}
role="dialog"
aria-label={name}
data-testid="mention-card"
className="z-50 w-72 max-w-[calc(100vw-16px)] bg-background border border-border rounded-md shadow-lg p-3 text-sm"
style={{
position: "fixed",
left: position?.x ?? 0,
top: position?.y ?? 0,
visibility: position ? undefined : "hidden",
}}
// Pressing a button keeps the focus (and the chip's selection) in the editor.
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={clearTimer}
onMouseLeave={() => {
if (target.pinned) return;
clearTimer();
timer.current = setTimeout(close, LEAVE_DELAY);
}}
>
<div className="flex items-center gap-2 min-w-0">
<Avatar name={name} email={email} size="sm" className="shrink-0 w-8 h-8 text-xs" />
<div className="min-w-0">
<div className="font-medium text-foreground truncate">{name}</div>
<div className="text-muted-foreground truncate">{email}</div>
</div>
</div>
{outside && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">{t("mention_card_not_recipient")}</p>
)}
<div className="mt-3 flex flex-wrap gap-2">
{outside && onAdd && (
<button
type="button"
onClick={add}
className="px-2.5 py-1 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90"
>
{t("mention_card_add", { field: t(addsTo) })}
</button>
)}
<button
type="button"
onClick={remove}
className="px-2.5 py-1 rounded-md text-xs font-medium bg-muted text-foreground hover:bg-accent"
>
{t("mention_card_remove")}
</button>
</div>
</div>,
document.body,
);
}
+17 -10
View File
@@ -8,6 +8,7 @@ import { useTranslations } from "next-intl";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import type { MentionCandidate } from "@/lib/recipient-mentions";
import { MentionChip } from "@/components/email/mention-chip";
export const recipientMentionPluginKey = new PluginKey<{ active: boolean }>("recipientMention");
@@ -21,20 +22,22 @@ export function isRecipientMentionActive(editor: Editor | null | undefined): boo
}
/**
* Replaces the typed "@que" with "@Label " as plain text. Never as HTML: the
* label comes from recipient display names, which a reply-all takes from the
* incoming message.
* Replaces the typed "@que" with a mention chip and a space. The label is
* an attribute rendered as text, never parsed as HTML: it comes from
* recipient display names, which a reply-all takes from the incoming message.
*/
export function insertMention(editor: Editor, range: Range, label: string) {
const text = `@${label}`;
export function insertMention(editor: Editor, range: Range, person: { label: string; email: string }) {
const after = editor.state.doc.resolve(range.to).nodeAfter;
const spaceFollows = !!after?.isText && /^\s/.test(after.text ?? "");
editor
.chain()
.focus()
.command(({ tr }) => {
tr.insertText(spaceFollows ? text : `${text} `, range.from, range.to);
tr.setSelection(TextSelection.create(tr.doc, range.from + text.length + 1));
.command(({ tr, state }) => {
const marks = state.doc.resolve(range.from).marks();
const chip = state.schema.nodes[MentionChip.name].create({ label: person.label, email: person.email });
tr.replaceWith(range.from, range.to, spaceFollows ? chip : [chip, state.schema.text(" ", marks)]);
// After the chip and its space, ready to go on typing.
tr.setSelection(TextSelection.create(tr.doc, range.from + chip.nodeSize + 1));
tr.scrollIntoView();
return true;
})
@@ -51,7 +54,7 @@ export interface RecipientMentionOptions {
/**
* "@" in the body offers the message's recipients, and contacts who can be
* added as one, and inserts the chosen one's first name as plain text. The
* added as one, and inserts the chosen one's first name as a mention chip. The
* suggestion is only active while someone matches what was typed, so an @
* nobody matches leaves Enter and Escape to the editor and composer.
*/
@@ -61,6 +64,10 @@ export const RecipientMention = Extension.create<RecipientMentionOptions>({
// table and core keymaps, which run at the default priority (100).
priority: 101,
addExtensions() {
return [MentionChip];
},
addOptions() {
return {
getCandidates: () => [],
@@ -96,7 +103,7 @@ export const RecipientMention = Extension.create<RecipientMentionOptions>({
},
items: ({ query }) => getCandidates(query),
command: ({ editor, range, props }) => {
insertMention(editor, range, props.label);
insertMention(editor, range, props);
onPick?.(props);
},
render,
+15
View File
@@ -27,6 +27,7 @@ import { trailingParagraphBreakRule } from "@/components/email/paragraph-breaks"
import { SignatureBlock } from "@/components/email/signature-block";
import { styledBlockAttributes } from "@/components/email/styled-block-attributes";
import { RecipientMention, RecipientMentionList, type RecipientMentionListHandle } from "@/components/email/recipient-mention";
import { MentionChipCard, setMentionRecipients } from "@/components/email/mention-chip";
import { mentionSuggestions, type MentionCandidate, type MentionContact } from "@/lib/recipient-mentions";
import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
@@ -386,6 +387,12 @@ export function RichTextEditor({
if (editor) onEditorReadyRef.current?.(editor);
}, [editor]);
// Mention chips of anyone not in To or Cc are marked, so a mention
// doesn't quietly name someone who never gets the message.
useEffect(() => {
if (editor) setMentionRecipients(editor, mentionCandidates ? mentionCandidates.map((c) => c.email) : null);
}, [editor, mentionCandidates]);
const addLink = useCallback(() => {
if (!editor) return;
const previousUrl = editor.getAttributes("link").href;
@@ -877,6 +884,14 @@ export function RichTextEditor({
{/* Editor */}
<EditorContent editor={editor} />
<MentionChipCard
editor={editor}
recipients={mentionCandidates}
findContacts={findMentionContacts}
addsTo={mentionAddsTo}
onAdd={onMentionAdd}
/>
{mention && mention.items.length > 0 && createPortal(
<RecipientMentionList
ref={mentionListRef}
+6
View File
@@ -30,6 +30,12 @@ describe('htmlToPlainText', () => {
).toBe('alice@example.com');
});
it('renders an @mention as just its text', () => {
expect(
htmlToPlainText('<p>Hi <a href="mailto:dana@example.com" class="mention">@Dana</a> see <a href="mailto:info@example.com">us</a></p>')
).toBe("Hi @Dana see us <mailto:info@example.com>");
});
it('renders links as "text <href>" when they differ', () => {
expect(htmlToPlainText('<p>Visit <a href="https://example.com">our site</a></p>')).toBe(
'Visit our site <https://example.com>'
+2 -2
View File
@@ -155,10 +155,10 @@ describe('mentionSuggestions', () => {
expect(mentionSuggestions([], contacts, 'ma', 'to')).toEqual([]);
});
it('tells a contact apart from a recipient with the same first name', () => {
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 Mustermann max.mustermann@dornig.de -',
'Max max.mustermann@dornig.de -',
'Max Müller max.mueller@dornig.de to',
]);
// The recipient list itself keeps its labels.
+6 -1
View File
@@ -91,7 +91,7 @@ export function htmlToPlainText(html: string, options: HtmlToPlainTextOptions =
const normalized = value.replace(/\s+/g, ' ');
if (!normalized.trim()) return;
const previous = chunks[chunks.length - 1];
if (previous && !previous.endsWith('\n') && !previous.endsWith(' ')) {
if (previous && !previous.endsWith('\n') && !previous.endsWith(' ') && !normalized.startsWith(' ')) {
chunks.push(' ');
}
chunks.push(normalized);
@@ -149,6 +149,11 @@ export function htmlToPlainText(html: string, options: HtmlToPlainTextOptions =
appendText(text);
return;
}
// An @mention reads as just "@Name"; the address is in the To or Cc.
if (/^mailto:/i.test(href) && text.startsWith('@') && element.children.length === 0) {
appendText(text);
return;
}
if (text && href && text !== href) {
appendText(`${text} <${href}>`);
return;
+10 -3
View File
@@ -164,9 +164,16 @@ export function mentionSuggestions(
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: both shown
// by full name, so the list tells them apart.
return distinguishLabels([...matched.map((c) => ({ ...c })), ...extra]);
// A contact who would also be "@Max" next to a recipient Max goes by full
// name, or mailbox name. The recipient keeps "@Max": the contact isn't on
// the message, so readers can't confuse the two.
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];
}
/**
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "البحث في الخادم",
"autocomplete_searching": "جارٍ البحث...",
"mention_recipients": "المستلمون",
"mention_card_not_recipient": "ليس في \"إلى\" أو \"نسخة\"، لذلك لن تصله هذه الرسالة.",
"mention_card_add": "إضافة إلى {field}",
"mention_card_remove": "إزالة الإشارة",
"toolbar": {
"bold": "غامق",
"italic": "مائل",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Cerca al servidor",
"autocomplete_searching": "Cercant...",
"mention_recipients": "Destinataris",
"mention_card_not_recipient": "No és a Per a ni a CC, així que no rebrà aquest missatge.",
"mention_card_add": "Afegeix a {field}",
"mention_card_remove": "Elimina la menció",
"toolbar": {
"bold": "Negreta",
"italic": "Cursiva",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Hledat na serveru",
"autocomplete_searching": "Hledání...",
"mention_recipients": "Příjemci",
"mention_card_not_recipient": "Není v Komu ani v Kopii, takže tuto zprávu nedostane.",
"mention_card_add": "Přidat do {field}",
"mention_card_remove": "Odebrat zmínku",
"toolbar": {
"bold": "Tučné",
"italic": "Kurzíva",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Søg på serveren",
"autocomplete_searching": "Søger...",
"mention_recipients": "Modtagere",
"mention_card_not_recipient": "Ikke i Til eller CC, så vedkommende får ikke beskeden.",
"mention_card_add": "Tilføj til {field}",
"mention_card_remove": "Fjern omtale",
"toolbar": {
"bold": "Fed",
"italic": "Kursiv",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Auf dem Server suchen",
"autocomplete_searching": "Suche läuft...",
"mention_recipients": "Empfänger",
"mention_card_not_recipient": "Nicht in An oder CC, bekommt diese Nachricht also nicht.",
"mention_card_add": "Zu {field} hinzufügen",
"mention_card_remove": "Erwähnung entfernen",
"toolbar": {
"bold": "Fett",
"italic": "Kursiv",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Search the server",
"autocomplete_searching": "Searching...",
"mention_recipients": "Recipients",
"mention_card_not_recipient": "Not in To or Cc, so they won't get this message.",
"mention_card_add": "Add to {field}",
"mention_card_remove": "Remove mention",
"toolbar": {
"bold": "Bold",
"italic": "Italic",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Buscar en el servidor",
"autocomplete_searching": "Buscando...",
"mention_recipients": "Destinatarios",
"mention_card_not_recipient": "No está en Para ni en CC, así que no recibirá este mensaje.",
"mention_card_add": "Añadir a {field}",
"mention_card_remove": "Quitar mención",
"toolbar": {
"bold": "Negrita",
"italic": "Cursiva",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Otsi serverist",
"autocomplete_searching": "Otsimine...",
"mention_recipients": "Adressaadid",
"mention_card_not_recipient": "Pole väljal Saajale ega CC, seega seda kirja ei saa.",
"mention_card_add": "Lisa väljale {field}",
"mention_card_remove": "Eemalda mainimine",
"toolbar": {
"bold": "Rasvane",
"italic": "Kaldkiri",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "جستجو در سرور",
"autocomplete_searching": "در حال جستجو...",
"mention_recipients": "گیرندگان",
"mention_card_not_recipient": "در «به» یا «رونوشت» نیست، پس این پیام را دریافت نمی‌کند.",
"mention_card_add": "افزودن به {field}",
"mention_card_remove": "حذف اشاره",
"toolbar": {
"bold": "پررنگ",
"italic": "کج",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Rechercher sur le serveur",
"autocomplete_searching": "Recherche en cours...",
"mention_recipients": "Destinataires",
"mention_card_not_recipient": "Absent de À et de Cc : cette personne ne recevra pas ce message.",
"mention_card_add": "Ajouter à {field}",
"mention_card_remove": "Retirer la mention",
"toolbar": {
"bold": "Gras",
"italic": "Italique",
+3
View File
@@ -759,6 +759,9 @@
"autocomplete_search_server": "חיפוש בשרת",
"autocomplete_searching": "מחפש...",
"mention_recipients": "נמענים",
"mention_card_not_recipient": "לא נמצא ב\"אל\" או ב\"עותק\", ולכן לא יקבל את ההודעה.",
"mention_card_add": "הוספה ל{field}",
"mention_card_remove": "הסרת האזכור",
"toolbar": {
"bold": "מודגש",
"italic": "נטוי",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Keresés a kiszolgálón",
"autocomplete_searching": "Keresés...",
"mention_recipients": "Címzettek",
"mention_card_not_recipient": "Nincs a Címzett vagy a Másolat mezőben, így nem kapja meg az üzenetet.",
"mention_card_add": "Hozzáadás: {field}",
"mention_card_remove": "Említés eltávolítása",
"toolbar": {
"bold": "Félkövér",
"italic": "Dőlt",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Cerca nel server",
"autocomplete_searching": "Ricerca in corso...",
"mention_recipients": "Destinatari",
"mention_card_not_recipient": "Non è in A o CC, quindi non riceverà questo messaggio.",
"mention_card_add": "Aggiungi in {field}",
"mention_card_remove": "Rimuovi menzione",
"toolbar": {
"bold": "Grassetto",
"italic": "Corsivo",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "サーバーを検索",
"autocomplete_searching": "検索中...",
"mention_recipients": "宛先",
"mention_card_not_recipient": "宛先にも CC にも入っていないため、このメッセージは届きません。",
"mention_card_add": "{field}に追加",
"mention_card_remove": "メンションを削除",
"toolbar": {
"bold": "太字",
"italic": "斜体",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "서버에서 검색",
"autocomplete_searching": "검색 중...",
"mention_recipients": "수신자",
"mention_card_not_recipient": "받는 사람이나 참조에 없어서 이 메시지를 받지 못합니다.",
"mention_card_add": "{field}에 추가",
"mention_card_remove": "멘션 삭제",
"toolbar": {
"bold": "굵게",
"italic": "기울임꼴",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Meklēt serverī",
"autocomplete_searching": "Meklē...",
"mention_recipients": "Adresāti",
"mention_card_not_recipient": "Nav laukā Kam vai Kopija, tāpēc šo vēstuli nesaņems.",
"mention_card_add": "Pievienot laukam {field}",
"mention_card_remove": "Noņemt pieminējumu",
"toolbar": {
"bold": "Treknraksts",
"italic": "Kursīvs",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Серверээс хайх",
"autocomplete_searching": "Хайж байна...",
"mention_recipients": "Хүлээн авагчид",
"mention_card_not_recipient": "Хэнд эсвэл CC-д байхгүй тул энэ захидлыг хүлээн авахгүй.",
"mention_card_add": "{field}-д нэмэх",
"mention_card_remove": "Дурдалтыг хасах",
"toolbar": {
"bold": "Болд",
"italic": "Налуу",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Søk på serveren",
"autocomplete_searching": "Søker...",
"mention_recipients": "Mottakere",
"mention_card_not_recipient": "Ikke i Til eller Kopi, så de får ikke denne meldingen.",
"mention_card_add": "Legg til i {field}",
"mention_card_remove": "Fjern omtale",
"toolbar": {
"bold": "Fet skrift",
"italic": "Kursiv",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Op de server zoeken",
"autocomplete_searching": "Bezig met zoeken...",
"mention_recipients": "Ontvangers",
"mention_card_not_recipient": "Staat niet in Aan of CC en krijgt dit bericht dus niet.",
"mention_card_add": "Toevoegen aan {field}",
"mention_card_remove": "Vermelding verwijderen",
"toolbar": {
"bold": "Vet",
"italic": "Cursief",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Szukaj na serwerze",
"autocomplete_searching": "Wyszukiwanie...",
"mention_recipients": "Odbiorcy",
"mention_card_not_recipient": "Brak w polach Do i DW, więc ta osoba nie dostanie wiadomości.",
"mention_card_add": "Dodaj do {field}",
"mention_card_remove": "Usuń wzmiankę",
"toolbar": {
"bold": "Pogrubienie",
"italic": "Kursywa",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Pesquisar no servidor",
"autocomplete_searching": "Pesquisando...",
"mention_recipients": "Destinatários",
"mention_card_not_recipient": "Não está em Para nem em CC, por isso não vai receber esta mensagem.",
"mention_card_add": "Adicionar a {field}",
"mention_card_remove": "Remover menção",
"toolbar": {
"bold": "Negrito",
"italic": "Itálico",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Caută pe server",
"autocomplete_searching": "Se caută...",
"mention_recipients": "Destinatari",
"mention_card_not_recipient": "Nu este în Către sau CC, deci nu va primi acest mesaj.",
"mention_card_add": "Adaugă la {field}",
"mention_card_remove": "Elimină mențiunea",
"toolbar": {
"bold": "Aldin",
"italic": "Cursiv",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Искать на сервере",
"autocomplete_searching": "Поиск...",
"mention_recipients": "Получатели",
"mention_card_not_recipient": "Нет в полях «Кому» и «Копия», поэтому письмо не получит.",
"mention_card_add": "Добавить в {field}",
"mention_card_remove": "Убрать упоминание",
"toolbar": {
"bold": "Жирный",
"italic": "Курсив",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Hľadať na serveri",
"autocomplete_searching": "Hľadanie...",
"mention_recipients": "Príjemcovia",
"mention_card_not_recipient": "Nie je v poli Komu ani Kópia, takže túto správu nedostane.",
"mention_card_add": "Pridať do {field}",
"mention_card_remove": "Odstrániť zmienku",
"toolbar": {
"bold": "Tučné",
"italic": "Kurzíva",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Sunucuda ara",
"autocomplete_searching": "Aranıyor...",
"mention_recipients": "Alıcılar",
"mention_card_not_recipient": "Kime veya CC alanında değil, bu yüzden iletiyi almayacak.",
"mention_card_add": "{field} alanına ekle",
"mention_card_remove": "Bahsetmeyi kaldır",
"toolbar": {
"bold": "Kalın",
"italic": "İtalik",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "Шукати на сервері",
"autocomplete_searching": "Пошук...",
"mention_recipients": "Одержувачі",
"mention_card_not_recipient": "Немає в полях «Кому» та «Копія», тож лист не отримає.",
"mention_card_add": "Додати в {field}",
"mention_card_remove": "Прибрати згадку",
"toolbar": {
"bold": "Жирний",
"italic": "Курсив",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "在伺服器上搜尋",
"autocomplete_searching": "正在搜尋…",
"mention_recipients": "收件者",
"mention_card_not_recipient": "不在收件者或副本中,因此不會收到這封郵件。",
"mention_card_add": "加入{field}",
"mention_card_remove": "移除提及",
"toolbar": {
"bold": "粗體",
"italic": "斜體",
+3
View File
@@ -794,6 +794,9 @@
"autocomplete_search_server": "在服务器上搜索",
"autocomplete_searching": "搜索中...",
"mention_recipients": "收件人",
"mention_card_not_recipient": "不在收件人或抄送中,因此不会收到这封邮件。",
"mention_card_add": "添加到{field}",
"mention_card_remove": "移除提及",
"toolbar": {
"bold": "加粗",
"italic": "斜体",