mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: turn @mentions into chips with a hover card
This commit is contained in:
@@ -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>@<img src=x onerror=alert(1)> </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">@<img src=x onerror=alert(1)></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());
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>'
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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": "مائل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "کج",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "נטוי",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "斜体",
|
||||
|
||||
@@ -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": "기울임꼴",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Налуу",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Курсив",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Курсив",
|
||||
|
||||
@@ -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": "斜體",
|
||||
|
||||
@@ -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": "斜体",
|
||||
|
||||
Reference in New Issue
Block a user