-
+
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 (
+
+ );
+}