From 5f32d3d82c893c8c770c0e94847854d6c1746faa Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 27 Aug 2026 11:19:45 -0700 Subject: [PATCH] Choose recipients from the address books Addressing a message worked only if you already knew the name you were half-way through typing. Autocomplete answers "finish this for me"; there was no answer to "who is there?", which is the question someone has when they open a compose window and want the person from the team list whose surname they cannot summon. The To row now opens the address books -- from a button beside Cc and Bcc, where someone thinking about recipients is already looking, and from the To label itself for anyone who tries that first. Search across every book or narrow to one, tick as many people as the message needs, and send them to To, Cc or Bcc. Picking for a field that is hidden opens it, since a Bcc dropped somewhere invisible is worse than no Bcc. Every address is its own row rather than every person. Somebody with a work address and a personal one is a choice the writer has to make, and a picker that listed the card and quietly took the first address would be making it for them. Shared books are in it on the same footing as the reader's own -- that being the point of having added them -- with the account named on each row, so it is never a mystery whose list a name came from. Books that have not been added contribute nothing, the same rule the To field already follows. Verified against the mock: the picker lists the reader's contacts and the shared book's, each row naming its source; ticking one of each and choosing Cc opens the Cc row with both in it. --- web/src/styles/app.css | 5 + web/src/views/compose/Composer.tsx | 28 +++- web/src/views/compose/RecipientPicker.tsx | 161 ++++++++++++++++++++++ 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 web/src/views/compose/RecipientPicker.tsx diff --git a/web/src/styles/app.css b/web/src/styles/app.css index add8b79..b9c3578 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1030,3 +1030,8 @@ button.dp-open:disabled { cursor: default; opacity: .5; } .sidebar .nav-section { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .spin { animation: spin 1s linear infinite; } @media (prefers-reduced-motion: reduce) { .spin { animation: none; } } + +/* The composer's To label doubles as the way into the address books. */ +.composer-field label .link-btn { background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; } +.composer-field label .link-btn:hover { color: var(--accent); } +.composer-field label .link-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; } diff --git a/web/src/views/compose/Composer.tsx b/web/src/views/compose/Composer.tsx index bb368a3..1e0b7d4 100644 --- a/web/src/views/compose/Composer.tsx +++ b/web/src/views/compose/Composer.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { AlertTriangle, ChevronDown, FileText, FolderOpen, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react"; +import { AlertTriangle, BookUser, ChevronDown, FileText, FolderOpen, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react"; import { useCompose, type Draft } from "@/store/compose"; import { useMail } from "@/store/mail"; import { useSettings } from "@/store/settings"; @@ -13,6 +13,7 @@ import { htmlToText, textToHtml } from "@/lib/text"; import { isValidEmail } from "@/lib/address"; import { attachmentIcon } from "../mail/MessageView"; import { FilePicker } from "./FilePicker"; +import { RecipientPicker, type Field } from "./RecipientPicker"; import { useFiles } from "@/store/files"; import { keyboard } from "@/lib/keyboard"; import { useIsMobile } from "@/ui/misc"; @@ -30,6 +31,7 @@ export function Composer({ draft }: { draft: Draft }) { const addFromFiles = useCompose((s) => s.addFromFiles); const filesAvailable = useFiles((s) => s.available); const [pickerOpen, setPickerOpen] = useState(false); + const [addressBookOpen, setAddressBookOpen] = useState(false); const removeAttachment = useCompose((s) => s.removeAttachment); const setIdentity = useCompose((s) => s.setIdentity); const insertTemplate = useCompose((s) => s.insertTemplate); @@ -174,9 +176,17 @@ export function Composer({ draft }: { draft: Draft }) { )}
- + patch({ to })} placeholder="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 + opens it too, for anyone who tries that first. */} + {!d.showCc && } {!d.showBcc && } {!d.showReplyTo && } @@ -244,6 +254,20 @@ export function Composer({ draft }: { draft: Draft }) { } label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} /> {canSchedule && { sendMenu.close(); setScheduleOpen(true); }} />} + {addressBookOpen && ( + { + // Added to whatever is already there, and the field is opened if + // it was hidden -- picking a Bcc should not put one somewhere + // the writer cannot see it. + const existing = field === "to" ? d.to : field === "cc" ? d.cc : d.bcc; + const merged = [...existing]; + for (const a of addresses) if (!merged.some((x) => x.email.toLowerCase() === a.email.toLowerCase())) merged.push(a); + patch({ [field]: merged, ...(field === "cc" ? { showCc: true } : field === "bcc" ? { showBcc: true } : {}) }); + }} + onClose={() => setAddressBookOpen(false)} + /> + )} {pickerOpen && void addFromFiles(key, picked)} onClose={() => setPickerOpen(false)} />} {canSchedule && scheduleOpen && ( setScheduleOpen(false)} onPick={scheduleFor} /> diff --git a/web/src/views/compose/RecipientPicker.tsx b/web/src/views/compose/RecipientPicker.tsx new file mode 100644 index 0000000..f0ad31f --- /dev/null +++ b/web/src/views/compose/RecipientPicker.tsx @@ -0,0 +1,161 @@ +import { useMemo, useState } from "react"; +import { Book, BookOpen, Search, Users, X } from "lucide-react"; +import { Dialog } from "@/ui/dialog"; +import { useContacts } from "@/store/contacts"; +import { contactDisplayName, contactEmails } from "@/lib/contacts"; +import type { ContactCard, EmailAddress } from "@/jmap/types"; + +export type Field = "to" | "cc" | "bcc"; + +/** One selectable address: a card can carry several, so the address is the unit. */ +interface Row { + key: string; + name: string | null; + email: string; + book: string; +} + +/** + * Choose recipients by looking through the address books. + * + * Autocomplete answers "finish this name for me", which is only useful when the + * writer already knows who they want. This answers the other question -- who is + * there? -- so the books can be read rather than recalled, and several people + * picked in one pass rather than typed one at a time. + * + * Each address is its own row, not each person: someone with a work address and + * a personal one is a choice to make, and a picker that offered the card and + * quietly took the first address would make it for them. + * + * Shared books are in here on the same footing as the reader's own, which is + * the point of having added them -- with the account named, so it is never a + * mystery whose list a name came from. + */ +export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, addresses: EmailAddress[]) => void; onClose: () => void }) { + const contacts = useContacts(); + const [q, setQ] = useState(""); + const [bookKey, setBookKey] = useState("all"); + const [picked, setPicked] = useState>({}); + + const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed); + const ownBooks = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)); + + const rows = useMemo(() => { + const out: Row[] = []; + const push = (card: ContactCard, book: string, keyPrefix: string) => { + for (const a of contactEmails(card)) { + if (!a.email) continue; + out.push({ key: `${keyPrefix}:${card.id}:${a.email}`, name: a.name ?? contactDisplayName(card), email: a.email, book }); + } + }; + if (bookKey === "all" || !bookKey.includes(":")) { + for (const c of Object.values(contacts.cards)) { + if (bookKey !== "all" && !c.addressBookIds?.[bookKey]) continue; + push(c, contacts.books[Object.keys(c.addressBookIds ?? {})[0] ?? ""]?.name ?? "Contacts", "own"); + } + } + if (bookKey === "all" || bookKey.includes(":")) { + for (const [key, card] of Object.entries(contacts.sharedCards)) { + const accountId = key.slice(0, key.length - card.id.length - 1); + const inBook = subscribed.find((b) => b.accountId === accountId && card.addressBookIds?.[b.book.id]); + if (!inBook) continue; + if (bookKey !== "all" && bookKey !== `${accountId}:${inBook.book.id}`) continue; + push(card, `${inBook.book.name} · ${inBook.accountName}`, accountId); + } + } + const needle = q.trim().toLowerCase(); + const filtered = needle + ? out.filter((r) => `${r.name ?? ""} ${r.email}`.toLowerCase().includes(needle)) + : out; + return filtered.sort((a, b) => (a.name ?? a.email).localeCompare(b.name ?? b.email)); + }, [contacts.cards, contacts.sharedCards, contacts.books, subscribed, bookKey, q]); + + const chosen = Object.values(picked); + const toggle = (r: Row) => + setPicked((p) => { + const next = { ...p }; + if (next[r.key]) delete next[r.key]; + else next[r.key] = r; + return next; + }); + + const send = (field: Field) => { + onPick(field, chosen.map((r) => ({ name: r.name, email: r.email }))); + onClose(); + }; + + return ( + + + + + + + } + > +
+ {/* Same shape as the contact list's own search box. */} + + +
+ + {chosen.length > 0 && ( +
+ {chosen.map((r) => ( + + ))} +
+ )} + +
+ {!rows.length ? ( +

{q ? "Nobody matches that." : "No contacts in this address book."}

+ ) : ( + rows.map((r) => ( + + )) + )} +
+ + {!ownBooks.length && !subscribed.length && ( +

No address books yet.

+ )} +
+ ); +}