Composer: smarter suggestions, links over selections, big images attached
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m5s
ci / announce (pull_request) Skipped

Recipient suggestions:
- the words typed match in any order, each one the start of a word in the
  name, a nickname, the organization or the address: "jane smi" finds
  "Smith, Jane", and "globex" finds the people at Globex;
- someone written to lately ranks a little above an equal match;
- an address already in To, Cc or Bcc is no longer offered in the other
  two fields.

Pasting:
- a single web or mailto address pasted over selected words makes those
  words the link, instead of replacing them with the address;
- a pasted or dropped image over 10 MB goes in as an attachment rather
  than inline, where it would swell every reply.

No new strings.
This commit is contained in:
jcoffey-dev committed 2026-10-05 11:52:54 -07:00
1 parent 3434a5ed39
commit b11f3997d4
7 files changed
+275 -15

No files matched your search

@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useContacts } from "@/store/contacts";
import type { ContactCard } from "@/jmap/types";
/*
* What the recipient field offers as you type. Matching used to take the
* typed text as one piece, so "jane smi" found nobody filed as "Smith, Jane",
* and a nickname or a company name found nothing at all.
*/
const card = (id: string, full: string, email: string, extra: Partial<ContactCard> = {}): ContactCard =>
({ id, uid: id, addressBookIds: { b1: true }, name: { full }, emails: { e: { address: email } }, ...extra }) as unknown as ContactCard;
beforeEach(() => {
useContacts.setState({
accountId: "a1",
available: false,
loaded: true,
loading: false,
principalsLoaded: true,
principals: [],
sharedCards: {},
recent: [],
cards: {
c1: card("c1", "Smith, Jane", "[email protected]"),
c2: card("c2", "Robert Jones", "[email protected]", { nicknames: { n: { name: "Bobby" } } } as Partial<ContactCard>),
c3: card("c3", "Ann Lee", "[email protected]", { organizations: { o: { name: "Globex Industries" } } } as Partial<ContactCard>),
c4: card("c4", "Ann Taylor", "[email protected]"),
},
});
});
const emails = async (q: string) => (await useContacts.getState().suggest(q)).map((s) => s.email);
describe("recipient suggestions", () => {
it("matches the words typed in any order", async () => {
expect(await emails("jane smi")).toEqual(["[email protected]"]);
expect(await emails("smi jan")).toEqual(["[email protected]"]);
});
it("does not match when one of the words fits nobody", async () => {
expect(await emails("jane xyz")).toEqual([]);
});
it("matches a nickname and an organization", async () => {
expect(await emails("bobby")).toEqual(["[email protected]"]);
expect(await emails("globex")).toEqual(["[email protected]"]);
});
it("puts someone written to lately ahead of an equal match", async () => {
expect(await emails("ann")).toEqual(["[email protected]", "[email protected]"]);
useContacts.setState({ recent: [{ name: "Ann Taylor", email: "[email protected]" }] });
expect((await emails("ann"))[0]).toBe("[email protected]");
});
it("still ranks a match at the start above a word inside the name", async () => {
// "jo" starts Jones's surname word; it starts no other card's name or address.
expect(await emails("jo")).toEqual(["[email protected]"]);
});
});
+22 -6
View File
@@ -793,14 +793,29 @@ export const useContacts = create<ContactsState>((set, get) => ({
seen.add(k); seen.add(k);
out.push(s); out.push(s);
}; };
const score = (name: string | null, email: string): number => { /*
* Words in any order: "jane smi" finds "Smith, Jane", and a nickname or the
* organization counts as much as the name. Each word typed has to start a
* word of the person's name, nickname, organization or address.
*/
const tokens = q.split(/\s+/).filter(Boolean);
const wordsOf = (parts: Array<string | null | undefined>) =>
parts.flatMap((p) => (p ?? "").toLowerCase().split(/[\s,.;:@_+()<>"'-]+/)).filter(Boolean);
const score = (name: string | null, email: string, extra: Array<string | null | undefined> = []): number => {
const n = (name ?? "").toLowerCase(); const n = (name ?? "").toLowerCase();
const e = email.toLowerCase(); const e = email.toLowerCase();
if (e.startsWith(q) || n.startsWith(q)) return 0; if (e.startsWith(q) || n.startsWith(q)) return 0;
if (n.split(/\s+/).some((w) => w.startsWith(q))) return 1; const words = wordsOf([name, email, ...extra]);
if (tokens.every((t) => words.some((w) => w.startsWith(t)))) return 1;
if (e.includes(q) || n.includes(q)) return 2; if (e.includes(q) || n.includes(q)) return 2;
return 99; return 99;
}; };
// Someone written to lately ranks a little above someone not, within the same kind of match.
const recentRank = new Map(st.recent.map((r, i) => [r.email.toLowerCase(), i] as const));
const recency = (email: string) => {
const i = recentRank.get(email.toLowerCase());
return i === undefined ? 0 : -0.3 * (1 - i / Math.max(1, st.recent.length));
};
const candidates: Array<Suggestion & { score: number }> = []; const candidates: Array<Suggestion & { score: number }> = [];
// A shared address book is only useful if it answers when you are writing // A shared address book is only useful if it answers when you are writing
// to someone in it, so its cards are offered alongside the reader's own. // to someone in it, so its cards are offered alongside the reader's own.
@@ -808,19 +823,20 @@ export const useContacts = create<ContactsState>((set, get) => ({
const own = Object.values(st.cards).map((c) => ({ c, penalty: 0 })); const own = Object.values(st.cards).map((c) => ({ c, penalty: 0 }));
const shared = Object.values(st.sharedCards).map((c) => ({ c, penalty: 0.5 })); const shared = Object.values(st.sharedCards).map((c) => ({ c, penalty: 0.5 }));
for (const { c, penalty } of [...own, ...shared]) { for (const { c, penalty } of [...own, ...shared]) {
const extra = [...Object.values(c.nicknames ?? {}).map((x) => x.name), ...Object.values(c.organizations ?? {}).map((x) => x.name)];
for (const a of contactEmails(c)) { for (const a of contactEmails(c)) {
const sc = score(a.name, a.email); const sc = score(a.name, a.email, extra);
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty }); if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty + recency(a.email) });
} }
} }
for (const p of st.principals) { for (const p of st.principals) {
if (!p.email) continue; if (!p.email) continue;
const sc = score(p.name, p.email); const sc = score(p.name, p.email);
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 }); if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 + recency(p.email) });
} }
for (const r of st.recent) { for (const r of st.recent) {
const sc = score(r.name, r.email); const sc = score(r.name, r.email);
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 }); if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 + recency(r.email) });
} }
candidates.sort((a, b) => a.score - b.score || (a.name ?? a.email).localeCompare(b.name ?? b.email)); candidates.sort((a, b) => a.score - b.score || (a.name ?? a.email).localeCompare(b.name ?? b.email));
for (const c of candidates) { for (const c of candidates) {
+3 -3
View File
@@ -248,7 +248,7 @@ export function Composer({ draft }: { draft: Draft }) {
already knows the name they are half-way through typing. */} already knows the name they are half-way through typing. */}
<button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title={translate("Choose from address books")}>{translate("To")}</button> <button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title={translate("Choose from address books")}>{translate("To")}</button>
</label> </label>
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} /> <RecipientInput id={`${key}-to`} value={d.to} exclude={[...d.cc, ...d.bcc]} onChange={(to) => patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} />
<span className="field-extra"> <span className="field-extra">
{/* Beside Cc and Bcc, because that is where someone looks when {/* Beside Cc and Bcc, because that is where someone looks when
they are thinking about who the message goes to. The label they are thinking about who the message goes to. The label
@@ -268,13 +268,13 @@ export function Composer({ draft }: { draft: Draft }) {
{d.showCc && ( {d.showCc && (
<div className="composer-field"> <div className="composer-field">
<label htmlFor={`${key}-cc`}>{translate("Cc")}</label> <label htmlFor={`${key}-cc`}>{translate("Cc")}</label>
<RecipientInput id={`${key}-cc`} value={d.cc} onChange={(cc) => patch({ cc })} /> <RecipientInput id={`${key}-cc`} value={d.cc} exclude={[...d.to, ...d.bcc]} onChange={(cc) => patch({ cc })} />
</div> </div>
)} )}
{d.showBcc && ( {d.showBcc && (
<div className="composer-field"> <div className="composer-field">
<label htmlFor={`${key}-bcc`}>{translate("Bcc")}</label> <label htmlFor={`${key}-bcc`}>{translate("Bcc")}</label>
<RecipientInput id={`${key}-bcc`} value={d.bcc} onChange={(bcc) => patch({ bcc })} /> <RecipientInput id={`${key}-bcc`} value={d.bcc} exclude={[...d.to, ...d.cc]} onChange={(bcc) => patch({ bcc })} />
</div> </div>
)} )}
<div className="composer-field"> <div className="composer-field">
+5 -3
View File
@@ -12,9 +12,11 @@ interface Props {
placeholder?: string; placeholder?: string;
autoFocus?: boolean; autoFocus?: boolean;
id?: string; id?: string;
/** Addresses already in the message's other recipient fields, not to be suggested again. */
exclude?: EmailAddress[];
} }
export function RecipientInput({ value, onChange, placeholder, autoFocus, id }: Props) { export function RecipientInput({ value, onChange, placeholder, autoFocus, id, exclude }: Props) {
const [text, setText] = useState(""); const [text, setText] = useState("");
const [sugg, setSugg] = useState<Suggestion[]>([]); const [sugg, setSugg] = useState<Suggestion[]>([]);
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
@@ -34,7 +36,7 @@ export function RecipientInput({ value, onChange, placeholder, autoFocus, id }:
const t = window.setTimeout(() => { const t = window.setTimeout(() => {
void suggest(q).then((list) => { void suggest(q).then((list) => {
if (id !== reqId.current) return; if (id !== reqId.current) return;
const existing = new Set(value.map((v) => v.email.toLowerCase())); const existing = new Set([...value, ...(exclude ?? [])].map((v) => v.email.toLowerCase()));
const filtered = list.filter((s) => !existing.has(s.email.toLowerCase())); const filtered = list.filter((s) => !existing.has(s.email.toLowerCase()));
setSugg(filtered); setSugg(filtered);
setActive(0); setActive(0);
@@ -42,7 +44,7 @@ export function RecipientInput({ value, onChange, placeholder, autoFocus, id }:
}); });
}, 120); }, 120);
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, [text, suggest, value]); }, [text, suggest, value, exclude]);
const commit = (raw?: string) => { const commit = (raw?: string) => {
const s = (raw ?? text).trim().replace(/[,;]+$/, ""); const s = (raw ?? text).trim().replace(/[,;]+$/, "");
+28 -3
View File
@@ -25,6 +25,15 @@ interface Props {
imageUpload?: (file: File) => Promise<string>; imageUpload?: (file: File) => Promise<string>;
} }
/** Pasted or dropped images larger than this go in as attachments, not inline. */
export const INLINE_IMAGE_MAX = 10 * 1024 * 1024;
/** A single http(s) or mailto address, nothing else: what may become a link over a selection. */
export function isLinkToPaste(text: string): boolean {
if (!text || /\s/.test(text)) return false;
return /^(https?:\/\/[^\s]+|mailto:[^\s@]+@[^\s@]+)$/i.test(text);
}
const EMOJI = "😀 😃 😄 😁 😆 😅 😂 🤣 🙂 😉 😊 😇 🥰 😍 😘 😋 😜 🤪 🤗 🤔 🤫 🤐 😐 😑 😶 😏 😒 🙄 😬 😌 😔 😪 😴 😷 🤒 🤕 🤢 🤮 🥵 🥶 🥴 😵 🤯 🤠 🥳 😎 🤓 🧐 😕 😟 🙁 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 👍 👎 👌 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 👇 ☝️ 👋 🤚 🖐️ ✋ 🖖 👏 🙌 👐 🤲 🤝 🙏 💪 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ❣️ 💕 💯 💥 🔥 ✨ 🎉 🎊 🎈 🎁 🏆 ⭐ 🌟 ☀️ 🌙 ⚡ ☕ 🍕 🍺 🚀 ✈️ 🏠 💼 📅 📎 📌 ✅ ❌ ⚠️ ❓ ❗ 💡 🔔 📧 🙈 🙉 🙊 🐱 🐶 🦊 🐼".split(" "); const EMOJI = "😀 😃 😄 😁 😆 😅 😂 🤣 🙂 😉 😊 😇 🥰 😍 😘 😋 😜 🤪 🤗 🤔 🤫 🤐 😐 😑 😶 😏 😒 🙄 😬 😌 😔 😪 😴 😷 🤒 🤕 🤢 🤮 🥵 🥶 🥴 😵 🤯 🤠 🥳 😎 🤓 🧐 😕 😟 🙁 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 👍 👎 👌 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 👇 ☝️ 👋 🤚 🖐️ ✋ 🖖 👏 🙌 👐 🤲 🤝 🙏 💪 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ❣️ 💕 💯 💥 🔥 ✨ 🎉 🎊 🎈 🎁 🏆 ⭐ 🌟 ☀️ 🌙 ⚡ ☕ 🍕 🍺 🚀 ✈️ 🏠 💼 📅 📎 📌 ✅ ❌ ⚠️ ❓ ❗ 💡 🔔 📧 🙈 🙉 🙊 🐱 🐶 🦊 🐼".split(" ");
const COLORS = ["#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#ffffff", "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff", "#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc", "#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0", "#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79"]; const COLORS = ["#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#ffffff", "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff", "#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc", "#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0", "#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79"];
@@ -133,10 +142,25 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
const f = imgItem.getAsFile(); const f = imgItem.getAsFile();
if (f) { if (f) {
e.preventDefault(); e.preventDefault();
insertImageFile(f); // A large image is a file to send, not a picture in the text: it goes
// in as an attachment, where an inline copy would swell every reply.
if (f.size > INLINE_IMAGE_MAX && onFiles) onFiles([f]);
else insertImageFile(f);
return; return;
} }
} }
/*
* A link pasted over selected words makes those words the link, the way
* most editors do, instead of replacing them with the address.
*/
const pastedText = e.clipboardData.getData("text/plain").trim();
const sel = window.getSelection();
if (isLinkToPaste(pastedText) && sel && !sel.isCollapsed && elRef.current?.contains(sel.anchorNode)) {
e.preventDefault();
document.execCommand("createLink", false, pastedText);
emit();
return;
}
const htmlData = e.clipboardData.getData("text/html"); const htmlData = e.clipboardData.getData("text/html");
if (htmlData) { if (htmlData) {
e.preventDefault(); e.preventDefault();
@@ -175,8 +199,9 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
const files = Array.from(e.dataTransfer.files); const files = Array.from(e.dataTransfer.files);
if (!files.length) return; if (!files.length) return;
e.preventDefault(); e.preventDefault();
const images = files.filter((f) => f.type.startsWith("image/")); const inline = (f: File) => f.type.startsWith("image/") && !(f.size > INLINE_IMAGE_MAX && onFiles);
const others = files.filter((f) => !f.type.startsWith("image/")); const images = files.filter(inline);
const others = files.filter((f) => !inline(f));
images.forEach(insertImageFile); images.forEach(insertImageFile);
if (others.length) onFiles?.(others); if (others.length) onFiles?.(others);
}; };
@@ -0,0 +1,102 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { INLINE_IMAGE_MAX, RichEditor, isLinkToPaste } from "../RichEditor";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
/*
* Two things pasting into a message now does: a link pasted over selected
* words turns those words into the link, and an image too large to belong
* in the text goes in as an attachment. jsdom does no editing, so these check
* the command and the callback the editor reaches for.
*/
describe("what counts as a link to paste", () => {
it("takes one web or mailto address", () => {
expect(isLinkToPaste("https://example.com/a?b=1")).toBe(true);
expect(isLinkToPaste("http://example.com")).toBe(true);
expect(isLinkToPaste("mailto:[email protected]")).toBe(true);
});
it("leaves ordinary text and anything with spaces alone", () => {
expect(isLinkToPaste("see https://example.com")).toBe(false);
expect(isLinkToPaste("example.com")).toBe(false);
expect(isLinkToPaste("javascript:alert(1)")).toBe(false);
expect(isLinkToPaste("")).toBe(false);
});
});
describe("pasting into the editor", () => {
let host: HTMLDivElement;
let root: Root;
let onFiles: ReturnType<typeof vi.fn<(files: File[]) => void>>;
let exec: ReturnType<typeof vi.fn>;
beforeEach(async () => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
onFiles = vi.fn<(files: File[]) => void>();
exec = vi.fn(() => true);
(document as unknown as { execCommand: unknown }).execCommand = exec;
await act(async () => {
root.render(<RichEditor html="<p>read the docs here</p>" onChange={() => {}} onFiles={onFiles} showToolbar={false} />);
});
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
const editor = () => host.querySelector<HTMLElement>('[contenteditable="true"]')!;
const paste = async (data: { text?: string; html?: string; files?: File[] }) => {
const items = (data.files ?? []).map((f) => ({ type: f.type, kind: "file", getAsFile: () => f }));
const ev = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(ev, "clipboardData", {
value: { items, getData: (t: string) => (t === "text/plain" ? data.text ?? "" : t === "text/html" ? data.html ?? "" : "") },
});
await act(async () => editor().dispatchEvent(ev));
return ev;
};
const select = (word: string) => {
const text = editor().querySelector("p")!.firstChild!;
const i = text.textContent!.indexOf(word);
const r = document.createRange();
r.setStart(text, i);
r.setEnd(text, i + word.length);
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(r);
};
it("turns selected words into the pasted link", async () => {
select("docs");
const ev = await paste({ text: "https://example.com/docs" });
expect(ev.defaultPrevented).toBe(true);
expect(exec).toHaveBeenCalledWith("createLink", false, "https://example.com/docs");
});
it("pastes a link as text when nothing is selected", async () => {
window.getSelection()!.removeAllRanges();
await paste({ text: "https://example.com/docs" });
expect(exec).not.toHaveBeenCalledWith("createLink", expect.anything(), expect.anything());
});
it("attaches a pasted image too large to sit in the text", async () => {
const big = new File([new Uint8Array(8)], "huge.png", { type: "image/png" });
Object.defineProperty(big, "size", { value: INLINE_IMAGE_MAX + 1 });
await paste({ files: [big] });
expect(onFiles).toHaveBeenCalledWith([big]);
});
it("keeps a normal-sized pasted image in the text", async () => {
const small = new File([new Uint8Array(8)], "shot.png", { type: "image/png" });
await paste({ files: [small] });
expect(onFiles).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,55 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RecipientInput } from "../RecipientInput";
import { useContacts } from "@/store/contacts";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/*
* Someone already in Cc was still offered when typing in To, so it was easy
* to address the same person twice from two fields.
*/
describe("suggestions across To, Cc and Bcc", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.useFakeTimers();
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
useContacts.setState({
suggest: (async () => [
{ name: "Ann Lee", email: "[email protected]", source: "contact" },
{ name: "Ann Taylor", email: "[email protected]", source: "contact" },
]) as never,
});
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.useRealTimers();
});
it("leaves out an address already in another recipient field", async () => {
await act(async () => {
root.render(<RecipientInput value={[]} exclude={[{ name: null, email: "[email protected]" }]} onChange={() => {}} />);
});
const input = host.querySelector("input")!;
await act(async () => {
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
set.call(input, "ann");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
vi.advanceTimersByTime(200);
await Promise.resolve();
});
const offered = host.textContent ?? "";
expect(offered).toContain("[email protected]");
expect(offered).not.toContain("[email protected]");
});
});