From b11f3997d4a3143bc5950144fccae1458f5141f0 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 11:52:54 -0700 Subject: [PATCH] Composer: smarter suggestions, links over selections, big images attached 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. --- .../store/__tests__/recipient-suggest.test.ts | 60 +++++++++++ web/src/store/contacts.ts | 28 +++-- web/src/views/compose/Composer.tsx | 6 +- web/src/views/compose/RecipientInput.tsx | 8 +- web/src/views/compose/RichEditor.tsx | 31 +++++- .../compose/__tests__/editor-paste.test.tsx | 102 ++++++++++++++++++ .../__tests__/recipient-exclude.test.tsx | 55 ++++++++++ 7 files changed, 275 insertions(+), 15 deletions(-) create mode 100644 web/src/store/__tests__/recipient-suggest.test.ts create mode 100644 web/src/views/compose/__tests__/editor-paste.test.tsx create mode 100644 web/src/views/compose/__tests__/recipient-exclude.test.tsx diff --git a/web/src/store/__tests__/recipient-suggest.test.ts b/web/src/store/__tests__/recipient-suggest.test.ts new file mode 100644 index 0000000..3f9d84d --- /dev/null +++ b/web/src/store/__tests__/recipient-suggest.test.ts @@ -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 => + ({ 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", "jane@acme.test"), + c2: card("c2", "Robert Jones", "rj@corp.test", { nicknames: { n: { name: "Bobby" } } } as Partial), + c3: card("c3", "Ann Lee", "ann@example.org", { organizations: { o: { name: "Globex Industries" } } } as Partial), + c4: card("c4", "Ann Taylor", "ataylor@example.org"), + }, + }); +}); + +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(["jane@acme.test"]); + expect(await emails("smi jan")).toEqual(["jane@acme.test"]); + }); + + 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(["rj@corp.test"]); + expect(await emails("globex")).toEqual(["ann@example.org"]); + }); + + it("puts someone written to lately ahead of an equal match", async () => { + expect(await emails("ann")).toEqual(["ann@example.org", "ataylor@example.org"]); + useContacts.setState({ recent: [{ name: "Ann Taylor", email: "ataylor@example.org" }] }); + expect((await emails("ann"))[0]).toBe("ataylor@example.org"); + }); + + 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(["rj@corp.test"]); + }); +}); diff --git a/web/src/store/contacts.ts b/web/src/store/contacts.ts index ac3d397..e468cb6 100644 --- a/web/src/store/contacts.ts +++ b/web/src/store/contacts.ts @@ -793,14 +793,29 @@ export const useContacts = create((set, get) => ({ seen.add(k); 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) => + parts.flatMap((p) => (p ?? "").toLowerCase().split(/[\s,.;:@_+()<>"'-]+/)).filter(Boolean); + const score = (name: string | null, email: string, extra: Array = []): number => { const n = (name ?? "").toLowerCase(); const e = email.toLowerCase(); 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; 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 = []; // 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. @@ -808,19 +823,20 @@ export const useContacts = create((set, get) => ({ const own = Object.values(st.cards).map((c) => ({ c, penalty: 0 })); const shared = Object.values(st.sharedCards).map((c) => ({ c, penalty: 0.5 })); 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)) { - const sc = score(a.name, a.email); - if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty }); + 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 + recency(a.email) }); } } for (const p of st.principals) { if (!p.email) continue; 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) { 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)); for (const c of candidates) { diff --git a/web/src/views/compose/Composer.tsx b/web/src/views/compose/Composer.tsx index 0ee071f..20050df 100644 --- a/web/src/views/compose/Composer.tsx +++ b/web/src/views/compose/Composer.tsx @@ -248,7 +248,7 @@ export function Composer({ draft }: { draft: Draft }) { already knows the name they are half-way through typing. */} - patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} /> + patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} /> {/* Beside Cc and Bcc, because that is where someone looks when they are thinking about who the message goes to. The label @@ -268,13 +268,13 @@ export function Composer({ draft }: { draft: Draft }) { {d.showCc && (
- patch({ cc })} /> + patch({ cc })} />
)} {d.showBcc && (
- patch({ bcc })} /> + patch({ bcc })} />
)}
diff --git a/web/src/views/compose/RecipientInput.tsx b/web/src/views/compose/RecipientInput.tsx index 9bb5acf..a7a81ac 100644 --- a/web/src/views/compose/RecipientInput.tsx +++ b/web/src/views/compose/RecipientInput.tsx @@ -12,9 +12,11 @@ interface Props { placeholder?: string; autoFocus?: boolean; 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 [sugg, setSugg] = useState([]); const [active, setActive] = useState(0); @@ -34,7 +36,7 @@ export function RecipientInput({ value, onChange, placeholder, autoFocus, id }: const t = window.setTimeout(() => { void suggest(q).then((list) => { 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())); setSugg(filtered); setActive(0); @@ -42,7 +44,7 @@ export function RecipientInput({ value, onChange, placeholder, autoFocus, id }: }); }, 120); return () => window.clearTimeout(t); - }, [text, suggest, value]); + }, [text, suggest, value, exclude]); const commit = (raw?: string) => { const s = (raw ?? text).trim().replace(/[,;]+$/, ""); diff --git a/web/src/views/compose/RichEditor.tsx b/web/src/views/compose/RichEditor.tsx index defb0e9..66c24a8 100644 --- a/web/src/views/compose/RichEditor.tsx +++ b/web/src/views/compose/RichEditor.tsx @@ -25,6 +25,15 @@ interface Props { imageUpload?: (file: File) => Promise; } +/** 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 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(function RichEdito const f = imgItem.getAsFile(); if (f) { 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; } } + /* + * 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"); if (htmlData) { e.preventDefault(); @@ -175,8 +199,9 @@ export const RichEditor = forwardRef(function RichEdito const files = Array.from(e.dataTransfer.files); if (!files.length) return; e.preventDefault(); - const images = files.filter((f) => f.type.startsWith("image/")); - const others = files.filter((f) => !f.type.startsWith("image/")); + const inline = (f: File) => f.type.startsWith("image/") && !(f.size > INLINE_IMAGE_MAX && onFiles); + const images = files.filter(inline); + const others = files.filter((f) => !inline(f)); images.forEach(insertImageFile); if (others.length) onFiles?.(others); }; diff --git a/web/src/views/compose/__tests__/editor-paste.test.tsx b/web/src/views/compose/__tests__/editor-paste.test.tsx new file mode 100644 index 0000000..c380ee5 --- /dev/null +++ b/web/src/views/compose/__tests__/editor-paste.test.tsx @@ -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:ann@example.com")).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 void>>; + let exec: ReturnType; + + 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( {}} onFiles={onFiles} showToolbar={false} />); + }); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + }); + + const editor = () => host.querySelector('[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(); + }); +}); diff --git a/web/src/views/compose/__tests__/recipient-exclude.test.tsx b/web/src/views/compose/__tests__/recipient-exclude.test.tsx new file mode 100644 index 0000000..b80ad1e --- /dev/null +++ b/web/src/views/compose/__tests__/recipient-exclude.test.tsx @@ -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: "ann@example.org", source: "contact" }, + { name: "Ann Taylor", email: "ataylor@example.org", 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( {}} />); + }); + 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("ataylor@example.org"); + expect(offered).not.toContain("ann@example.org"); + }); +}); -- 2.54.0