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("all"); const [picked, setPicked] = useState>({}); /* * 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 ( } >
{/* Same shape as the contact list's own search box. */}
{chosen.length > 0 && (
{chosen.map((r) => ( ))}
)}
{contacts.loading && !rows.length ? ( ) : !rows.length ? (

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

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

No address books yet.

)}
); }