Files
ihasmail-inbuxa/web/src/views/compose/RecipientPicker.tsx
T
jcoffey-dev 5f32d3d82c 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.
2026-08-27 11:19:45 -07:00

162 lines
6.7 KiB
TypeScript

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<string>("all");
const [picked, setPicked] = useState<Record<string, Row>>({});
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 (
<Dialog
open
onClose={onClose}
title="Choose recipients"
size="lg"
footer={
<>
<button className="btn" onClick={onClose}>Cancel</button>
<button className="btn" disabled={!chosen.length} onClick={() => send("bcc")}>Bcc</button>
<button className="btn" disabled={!chosen.length} onClick={() => send("cc")}>Cc</button>
<button className="btn btn-primary" disabled={!chosen.length} onClick={() => send("to")}>
{chosen.length > 1 ? `To — ${chosen.length} people` : "To"}
</button>
</>
}
>
<div className="row gap-8" style={{ marginBottom: 10 }}>
{/* Same shape as the contact list's own search box. */}
<label className="search-input grow" style={{ height: 38, background: "var(--bg-sunken)", borderRadius: 999, display: "flex", alignItems: "center", gap: 8, padding: "0 12px" }}>
<Search size={15} className="faint" />
<input
className="grow"
style={{ background: "none", border: 0, outline: "none", color: "inherit", font: "inherit" }}
placeholder="Search names and addresses"
value={q}
onChange={(e) => setQ(e.target.value)}
autoFocus
/>
</label>
<select className="select" value={bookKey} onChange={(e) => setBookKey(e.target.value)} aria-label="Address book">
<option value="all">All address books</option>
{ownBooks.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
{subscribed.map((b) => (
<option key={`${b.accountId}:${b.book.id}`} value={`${b.accountId}:${b.book.id}`}>
{b.book.name} · {b.accountName}
</option>
))}
</select>
</div>
{chosen.length > 0 && (
<div className="row wrap gap-4" style={{ marginBottom: 10 }}>
{chosen.map((r) => (
<button key={r.key} className="chip" onClick={() => toggle(r)} title="Remove">
{r.name ?? r.email} <X size={12} />
</button>
))}
</div>
)}
<div style={{ maxHeight: "48vh", overflowY: "auto" }}>
{!rows.length ? (
<p className="hint">{q ? "Nobody matches that." : "No contacts in this address book."}</p>
) : (
rows.map((r) => (
<label key={r.key} className="menu-item" style={{ cursor: "pointer" }}>
<input type="checkbox" checked={Boolean(picked[r.key])} onChange={() => toggle(r)} />
{r.book.includes("·") ? <BookOpen size={16} className="faint" /> : <Book size={16} className="faint" />}
<span className="grow truncate">
{r.name ?? r.email}
{r.name && <span className="hint"> · {r.email}</span>}
</span>
<span className="hint nowrap">{r.book}</span>
</label>
))
)}
</div>
{!ownBooks.length && !subscribed.length && (
<p className="hint" style={{ marginTop: 8 }}><Users size={12} /> No address books yet.</p>
)}
</Dialog>
);
}