import { useEffect, useMemo, useState } from "react"; import { useLocation } from "wouter"; import { ArrowLeft, Building2, Cake, Calendar as CalIcon, Download, Globe, Mail, MapPin, Pencil, Phone, Pin, Plus, Search, StickyNote, Trash2, Users } from "lucide-react"; import { useContacts } from "@/store/contacts"; import { useCompose } from "@/store/compose"; import type { ContactCard } from "@/jmap/types"; import { contactDisplayName, contactEmails, contactPhoto, formatAddressLines, sortKey, toVCard } from "@/lib/contacts"; import { formatDate, formatDateLong } from "@/lib/datetime"; import { Avatar, Empty, Spinner, useIsNarrow } from "@/ui/misc"; import { confirmDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; import { ContactEditor } from "./ContactEditor"; import { avatarColor } from "@/lib/address"; export function ContactsView({ id }: { id?: string }) { const [, navigate] = useLocation(); const contacts = useContacts(); const narrow = useIsNarrow(); const [q, setQ] = useState(""); /* The book being shown lives in the store, because the list that chooses it is the app's own sidebar rather than anything this view owns. */ const sel = contacts.selection; const bookId = sel.bookId; const [editing, setEditing] = useState | null>(null); const openCompose = useCompose((s) => s.open); useEffect(() => { if (contacts.available && !contacts.loaded && !contacts.loading) void contacts.loadAll(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [contacts.available, contacts.loaded]); useEffect(() => { const onNew = () => setEditing({}); const onImport = (ev: Event) => { const f = (ev as CustomEvent).detail; if (f) void importFile(f); }; const onExport = () => exportAll(); window.addEventListener("ihm:new-contact", onNew); window.addEventListener("ihm:contacts-import", onImport); window.addEventListener("ihm:contacts-export", onExport); return () => { window.removeEventListener("ihm:new-contact", onNew); window.removeEventListener("ihm:contacts-import", onImport); window.removeEventListener("ihm:contacts-export", onExport); }; // eslint-disable-next-line react-hooks/exhaustive-deps }); const list = useMemo(() => { // A shared book lists that account's cards; anything else lists the // reader's own. They are never mixed: whose contacts you are looking at is // the one thing this view must not be vague about. if (sel.accountId) { const prefix = `${sel.accountId}:`; const theirs = Object.entries(contacts.sharedCards) .filter(([key]) => key.startsWith(prefix)) .map(([, c]) => c) .filter((c) => bookId === "all" || c.addressBookIds?.[bookId]); return contacts.filterCards(theirs, q); } const all = contacts.search(q); return bookId === "all" ? all : all.filter((c) => c.addressBookIds?.[bookId]); }, [contacts, q, bookId, sel.accountId]); const selected = id ? contacts.cards[id] ?? Object.entries(contacts.sharedCards).find(([key]) => key.endsWith(`:${id}`))?.[1] : undefined; const books = Object.values(contacts.books).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)); const groups = useMemo(() => { const out: Array<{ letter: string; items: ContactCard[] }> = []; for (const c of list) { const letter = (sortKey(c)[0] ?? "#").toUpperCase(); const key = /[A-Z]/.test(letter) ? letter : "#"; const g = out[out.length - 1]; if (g && g.letter === key) g.items.push(c); else out.push({ letter: key, items: [c] }); } return out; }, [list]); if (!contacts.available) { return
} title="Contacts are not available">This account does not have the JMAP contacts capability.
; } const exportAll = () => { const text = list.map(toVCard).join(""); const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([text], { type: "text/vcard" })); a.download = "contacts.vcf"; a.click(); }; const importFile = async (f: File) => { const book = bookId !== "all" ? contacts.books[bookId] : (books.find((b) => b.isDefault) ?? books[0]); if (!book) { toast.error("Create an address book first"); return; } try { const n = await contacts.importVCard(await f.text(), book.id); toast.success(`Imported ${n} contact${n === 1 ? "" : "s"}`); } catch (err) { toast.error((err as Error).message); } }; return (
setQ(e.target.value)} />
{contacts.loading && !contacts.loaded ? : !list.length ? ( } title={q ? "No matches" : "No contacts yet"}>{q ? "Try another search." : "Add a contact or import a vCard file."} ) : groups.map((g) => (
{g.letter}
{g.items.map((c) => { const email = contactEmails(c)[0]?.email; const photo = contacts.accountId ? contactPhoto(c, contacts.accountId) : null; return (
navigate(`/contacts/${c.id}`)}> {photo ? : c.kind === "group" ? : contactDisplayName(c).slice(0, 1).toUpperCase()}
{contactDisplayName(c)}{c.kind === "group" ? · group : ""}
{email ?? Object.values(c.phones ?? {})[0]?.number ?? Object.values(c.organizations ?? {})[0]?.name ?? ""}
); })}
))}
{selected ? ( navigate("/contacts")} onEdit={() => setEditing(selected)} narrow={narrow} onEmail={(addr) => openCompose({ to: [{ name: contactDisplayName(selected), email: addr }] })} /> ) : (
Select a contact
)}
{editing && b.isDefault)?.id ?? books[0]?.id ?? null)} onClose={() => setEditing(null)} onSaved={(cid) => { setEditing(null); navigate(`/contacts/${cid}`); }} />}
); } function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: ContactCard; onBack: () => void; onEdit: () => void; narrow: boolean; onEmail: (addr: string) => void }) { const contacts = useContacts(); const [, navigate] = useLocation(); const photo = contacts.accountId ? contactPhoto(c, contacts.accountId) : null; const name = contactDisplayName(c); const org = Object.values(c.organizations ?? {})[0]; const title = Object.values(c.titles ?? {})[0]; const books = Object.keys(c.addressBookIds ?? {}).map((id) => contacts.books[id]?.name).filter(Boolean); const members = c.kind === "group" ? Object.keys(c.members ?? {}).map((uid) => Object.values(contacts.cards).find((x) => x.uid === uid)).filter((x): x is ContactCard => Boolean(x)) : []; const ctxLabel = (ctx?: Record, label?: string) => label || Object.keys(ctx ?? {}).join(", ") || ""; return (
{narrow && }
{photo ? : c.kind === "group" ? : name.slice(0, 1).toUpperCase()}

{name}

{(title?.name || org?.name) &&
{[title?.name, org?.name].filter(Boolean).join(" · ")}
} {Object.values(c.nicknames ?? {})[0]?.name &&
“{Object.values(c.nicknames ?? {})[0]!.name}”
} {books.length > 0 &&
{books.join(", ")}
}
{Object.values(c.emails ?? {}).length > 0 && (

Email

{Object.values(c.emails ?? {}).map((e, i) => (
{ctxLabel(e.contexts, e.label) || "email"} { ev.preventDefault(); onEmail(e.address); }}>{e.address}
))}
)} {Object.values(c.phones ?? {}).length > 0 && (

Phone

{Object.values(c.phones ?? {}).map((p, i) => (
{ctxLabel({ ...p.contexts, ...p.features }, p.label) || "phone"}{p.number}
))}
)} {Object.values(c.addresses ?? {}).length > 0 && (

Address

{Object.values(c.addresses ?? {}).map((a, i) => (
{ctxLabel(a.contexts) || "address"}{formatAddressLines(a).map((l, j) =>
{l}
)}
))}
)} {(org || Object.values(c.titles ?? {}).length > 1) && (

Work

{org?.name &&
Company{org.name}{org.units?.length ? ` · ${org.units.map((u) => u.name).join(", ")}` : ""}
} {Object.values(c.titles ?? {}).map((t, i) =>
{t.kind === "role" ? "Role" : "Title"}{t.name}
)}
)} {Object.values(c.anniversaries ?? {}).length > 0 && (

Dates

{Object.values(c.anniversaries ?? {}).map((a, i) =>
{a.kind === "birth" ? "Birthday" : a.kind === "wedding" ? "Anniversary" : a.kind}{fmtPartial(a.date)}
)}
)} {(Object.values(c.links ?? {}).length > 0 || Object.values(c.onlineServices ?? {}).length > 0) && (

Online

{Object.values(c.links ?? {}).map((l, i) =>
{l.label ?? "Website"}{l.uri}
)} {Object.values(c.onlineServices ?? {}).map((s, i) =>
{s.service ?? s.label ?? "IM"}{s.user ?? s.uri}
)}
)} {Object.values(c.notes ?? {}).length > 0 && (

Notes

{Object.values(c.notes ?? {}).map((n, i) =>
{n.note}
)}
)} {c.kind === "group" && (

Members ({Object.keys(c.members ?? {}).length})

{members.map((m) => )} {members.length > 0 && }
)} {c.keywords && Object.keys(c.keywords).length > 0 &&
{Object.keys(c.keywords).map((k) => {k})}
} {c.updated &&

Updated {formatDate(new Date(c.updated))}

}
); } function fmtPartial(d: { year?: number; month?: number; day?: number; utc?: string }): string { if (d.utc) return formatDate(new Date(d.utc)); if (d.year && d.month && d.day) return formatDateLong(new Date(d.year, d.month - 1, d.day)); if (d.month && d.day) return formatDateLong(new Date(2000, d.month - 1, d.day), false); return [d.year, d.month, d.day].filter(Boolean).join("-"); }