Files
ihasmail/web/src/views/compose/RecipientPicker.tsx
T
jcoffey-dev 3a2f60189f Load the contacts the recipient picker is meant to show
The picker opened on "No contacts in this address book" -- about an
address book with contacts in it. Nothing was wrong with the button, and
that is why it read as one: it opened, correctly, onto nothing.

Contacts are fetched on demand. `loadAll` runs when the Contacts view
mounts, and `suggest` kicks it off itself, which is why autocomplete has
always worked from anywhere. The picker did neither, so opening a
composer without having visited Contacts first -- which is most of the
time, and every time in a fresh tab -- showed an empty list over a full
account. Anyone who had been to Contacts that session saw it work, which
is the sort of difference that reads as browser-specific when it is not.

It asks for them now, and says it is loading rather than that there are
none.

While here: the picker decided which shared books to offer on
`isSubscribed` alone. Stalwart refuses that flag on a book shared
read-only, so those are recorded in settings instead -- for an address
book it is the *only* record -- and filtering on the server's flag left
every shared book out of the picker while the sidebar showed it. Both now
ask the same question.

Verified against the mock from a genuinely cold store -- cards emptied,
`loaded` false, opening the picker as the first thing that wants them:
eight rows, from the reader's own book and a shared one, where before
there were none.
2026-08-27 13:01:01 -07:00

185 lines
8.0 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { Book, BookOpen, Search, Users, X } from "lucide-react";
import { Spinner } from "@/ui/misc";
import { Dialog } from "@/ui/dialog";
import { useContacts } from "@/store/contacts";
import { useSettings } from "@/store/settings";
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>>({});
/*
* Contacts are fetched on demand, and nothing had demanded them.
*
* `loadAll` runs when the Contacts view mounts, and `suggest` kicks it off
* itself so autocomplete works from anywhere. This did neither, so opening a
* composer without having visited Contacts first showed an empty picker over
* a full address book -- "no contacts in this address book", about a book
* with contacts in it.
*/
useEffect(() => {
if (contacts.available && !contacts.loaded && !contacts.loading) void contacts.loadAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contacts.available, contacts.loaded]);
/* Added counts whether the server remembered it or the settings did --
Stalwart refuses the flag on a book shared read-only, so for those the
settings are the only record and filtering on `isSubscribed` alone would
leave every shared book out of the picker. */
const addedShares = new Set(useSettings((s) => s.settings).addedShares);
const subscribed = contacts.sharedBooks.filter((b) => b.book.isSubscribed || addedShares.has(`${b.accountId}:${b.book.id}`));
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" }}>
{contacts.loading && !rows.length ? (
<Spinner label="Loading contacts…" />
) : !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>
);
}