From fbae8acfb805b28a629e8010f1f9c4577d31b4b5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:52:02 +0200 Subject: [PATCH] feat: turn @mentions into chips with a hover card --- app/globals.css | 31 ++ .../email/__tests__/composer-mention.test.tsx | 67 +++- .../__tests__/recipient-mention.test.tsx | 50 ++- components/email/mention-chip.tsx | 341 ++++++++++++++++++ components/email/recipient-mention.tsx | 27 +- components/email/rich-text-editor.tsx | 15 + lib/__tests__/html-to-text.test.ts | 6 + lib/__tests__/recipient-mentions.test.ts | 4 +- lib/html-to-text.ts | 7 +- lib/recipient-mentions.ts | 13 +- locales/ar/common.json | 3 + locales/ca/common.json | 3 + locales/cs/common.json | 3 + locales/da/common.json | 3 + locales/de/common.json | 3 + locales/en/common.json | 3 + locales/es/common.json | 3 + locales/et/common.json | 3 + locales/fa/common.json | 3 + locales/fr/common.json | 3 + locales/he/common.json | 3 + locales/hu/common.json | 3 + locales/it/common.json | 3 + locales/ja/common.json | 3 + locales/ko/common.json | 3 + locales/lv/common.json | 3 + locales/mn/common.json | 3 + locales/nb/common.json | 3 + locales/nl/common.json | 3 + locales/pl/common.json | 3 + locales/pt/common.json | 3 + locales/ro/common.json | 3 + locales/ru/common.json | 3 + locales/sk/common.json | 3 + locales/tr/common.json | 3 + locales/uk/common.json | 3 + locales/zh-TW/common.json | 3 + locales/zh/common.json | 3 + 38 files changed, 619 insertions(+), 26 deletions(-) create mode 100644 components/email/mention-chip.tsx 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('
  • Hallo @Max

'); + 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); + }} + > +
+ +
+
{name}
+
{email}
+
+
+ {outside && ( +

{t("mention_card_not_recipient")}

+ )} +
+ {outside && onAdd && ( + + )} + +
+
, + 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": "斜体",