diff --git a/app/globals.css b/app/globals.css
index dfb768ca0..0e0543dfa 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -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;
diff --git a/components/email/__tests__/composer-mention.test.tsx b/components/email/__tests__/composer-mention.test.tsx
index d8df1d07a..317835ae8 100644
--- a/components/email/__tests__/composer-mention.test.tsx
+++ b/components/email/__tests__/composer-mention.test.tsx
@@ -209,9 +209,9 @@ const settle = () => act(() => new Promise((resolve) => setTimeout(resolve
/** Lets the editor's delayed focus (and scroll to the cursor) run inside the test. */
const nextFrame = () => act(() => new Promise((resolve) => requestAnimationFrame(() => resolve())));
-async function renderComposer() {
+async function renderComposer(draft: Partial = {}) {
const onClose = vi.fn();
- render( );
+ render( );
// An edit makes the draft dirty, so closing asks first instead of closing.
fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
// TipTap keeps its editor on the editable element.
@@ -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: 'Hallo @Bob
' });
+ 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: 'Hallo @Dana
' });
+ 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');
+ });
+ });
});
diff --git a/components/email/__tests__/recipient-mention.test.tsx b/components/email/__tests__/recipient-mention.test.tsx
index 0e26193e6..851e3a42b 100644
--- a/components/email/__tests__/recipient-mention.test.tsx
+++ b/components/email/__tests__/recipient-mention.test.tsx
@@ -144,11 +144,11 @@ describe('RecipientMention insertion', () => {
await settle();
const enter = press(editor, 'Enter');
expect(enter.defaultPrevented).toBe(true);
- expect(editor.getHTML()).toBe('Hallo @Max
');
+ expect(editor.getHTML()).toBe('Hallo @Max
');
expect(suggestion.props).toBeNull();
// The cursor sits after the space, ready to go on typing.
await type(editor, 'bitte');
- expect(editor.getHTML()).toBe('Hallo @Max bitte
');
+ expect(editor.getHTML()).toBe('Hallo @Max bitte
');
editor.destroy();
});
@@ -158,14 +158,47 @@ describe('RecipientMention insertion', () => {
await settle();
expect(labels(suggestion)).toEqual(['Max']);
press(editor, 'Enter');
- expect(editor.getHTML()).toBe('Hallo @Max welt
');
+ expect(editor.getHTML()).toBe('Hallo @Max welt
');
editor.destroy();
});
it('inserts the label as text, never as markup', () => {
const { editor } = setup('
');
- insertMention(editor, { from: 1, to: 1 }, ' ');
- expect(editor.getHTML()).toBe('@<img src=x onerror=alert(1)>
');
+ insertMention(editor, { from: 1, to: 1 }, { label: ' ', email: 'x@dornig.de' });
+ expect(editor.getHTML()).toBe('@<img src=x onerror=alert(1)>
');
+ editor.destroy();
+ });
+
+ it('inserts one chip carrying the name and the address', async () => {
+ const { editor } = setup('Hallo
');
+ 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('
');
+ 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(
+ 'Hi @Eva and write us
',
+ );
+ 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('');
+ expect(list.editor.getHTML()).toContain('');
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
');
+ expect(onChange.mock.lastCall?.[0]).toContain('@Eva ');
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
});
diff --git a/components/email/mention-chip.tsx b/components/email/mention-chip.tsx
new file mode 100644
index 000000000..314dec1b6
--- /dev/null
+++ b/components/email/mention-chip.tsx
@@ -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 | 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 | null>({
+ key: mentionRecipientsKey,
+ state: {
+ init: () => null,
+ apply: (tr, value) => {
+ const next = tr.getMeta(mentionRecipientsKey) as Set | 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(null);
+ const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
+ const cardRef = useRef(null);
+ const timer = useRef | 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("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(
+ e.preventDefault()}
+ onMouseEnter={clearTimer}
+ onMouseLeave={() => {
+ if (target.pinned) return;
+ clearTimer();
+ timer.current = setTimeout(close, LEAVE_DELAY);
+ }}
+ >
+
+ {outside && (
+
{t("mention_card_not_recipient")}
+ )}
+
+ {outside && onAdd && (
+
+ {t("mention_card_add", { field: t(addsTo) })}
+
+ )}
+
+ {t("mention_card_remove")}
+
+
+
,
+ document.body,
+ );
+}
diff --git a/components/email/recipient-mention.tsx b/components/email/recipient-mention.tsx
index 2fa09a275..5032eb62d 100644
--- a/components/email/recipient-mention.tsx
+++ b/components/email/recipient-mention.tsx
@@ -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({
// 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({
},
items: ({ query }) => getCandidates(query),
command: ({ editor, range, props }) => {
- insertMention(editor, range, props.label);
+ insertMention(editor, range, props);
onPick?.(props);
},
render,
diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx
index a05a824c2..85a81c5cb 100644
--- a/components/email/rich-text-editor.tsx
+++ b/components/email/rich-text-editor.tsx
@@ -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 */}
+
+
{mention && mention.items.length > 0 && createPortal(
{
).toBe('alice@example.com');
});
+ it('renders an @mention as just its text', () => {
+ expect(
+ htmlToPlainText('Hi @Dana see us
')
+ ).toBe("Hi @Dana see us ");
+ });
+
it('renders links as "text " when they differ', () => {
expect(htmlToPlainText('Visit our site
')).toBe(
'Visit our site '
diff --git a/lib/__tests__/recipient-mentions.test.ts b/lib/__tests__/recipient-mentions.test.ts
index dccc5ef0a..6d431cc60 100644
--- a/lib/__tests__/recipient-mentions.test.ts
+++ b/lib/__tests__/recipient-mentions.test.ts
@@ -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.
diff --git a/lib/html-to-text.ts b/lib/html-to-text.ts
index bd0ed7e75..e2316ff14 100644
--- a/lib/html-to-text.ts
+++ b/lib/html-to-text.ts
@@ -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;
diff --git a/lib/recipient-mentions.ts b/lib/recipient-mentions.ts
index ae2e0c6a6..c52bcc4b4 100644
--- a/lib/recipient-mentions.ts
+++ b/lib/recipient-mentions.ts
@@ -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];
}
/**
diff --git a/locales/ar/common.json b/locales/ar/common.json
index ea74d2b4b..94d47c4a9 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -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": "مائل",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 647a0cd5e..8a137fcd9 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -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",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 3a252e3f1..3232e7647 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -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",
diff --git a/locales/da/common.json b/locales/da/common.json
index d9fef8324..450e650ec 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -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",
diff --git a/locales/de/common.json b/locales/de/common.json
index cb2a4598e..cda2d2d80 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -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",
diff --git a/locales/en/common.json b/locales/en/common.json
index f3b7ef705..f3953ff72 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -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",
diff --git a/locales/es/common.json b/locales/es/common.json
index 93a8f692f..b71cee3f3 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -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",
diff --git a/locales/et/common.json b/locales/et/common.json
index 9982655ec..b1704049f 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -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",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index e6720d667..ceed4146e 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -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": "کج",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 76da57c94..cc88f4a75 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -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",
diff --git a/locales/he/common.json b/locales/he/common.json
index 94fef9532..b62151940 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -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": "נטוי",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 03a8753e1..e372879ce 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -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",
diff --git a/locales/it/common.json b/locales/it/common.json
index 3bea4b3b2..457be8ae8 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -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",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index f6655150e..a13d7b83c 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -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": "斜体",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 32b2eb54f..17ed49b6f 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -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": "기울임꼴",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 32c215cc7..5ca5f882f 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -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",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 74a375efc..b2a713c54 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -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": "Налуу",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 9b1835671..a1b1650f5 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -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",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 2f95722ee..a8380f379 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -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",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index ab09cae85..8568c0b1b 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -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",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 31823f01e..4caa3a523 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -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",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index db1677e65..977b02210 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -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",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 486304acf..0e0f194b0 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -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": "Курсив",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 693e69e5e..e74fe5089 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -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",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index d6566d87a..d03297699 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -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",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 1ae1d5db6..05f138b15 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -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": "Курсив",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 88fccc658..bc242d47a 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -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": "斜體",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index a29d571dc..e83c34f17 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -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": "斜体",