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"; import { plural, t as translate } from "@/lib/i18n"; 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({}); /* * Both carry the book they were asked for. They used to mean "whatever the * list is showing", which was the whole of the complaint on #174: two * buttons at the foot of the sidebar that did not say which address book * they acted on. Now they are opened from a book's own menu and say so. */ const onImport = (ev: Event) => { const d = (ev as CustomEvent<{ file: File; bookId: string }>).detail; if (d?.file) void importFile(d.file, d.bookId); }; const onExport = (ev: Event) => { const d = (ev as CustomEvent<{ accountId: string | null; bookId: string }>).detail; exportBook(d?.accountId ?? null, d?.bookId ?? "all"); }; 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={translate("Contacts are not available")}>{translate("This account does not have the JMAP contacts capability.")}
; } /* * The cards of the book that was asked for, rather than the cards on screen. * Exporting used to hand you the current list, which meant a search box with * something in it quietly narrowed the export -- fine while the button sat * under that list, wrong now that it is opened from a book in the sidebar. */ const cardsOf = (accountId: string | null, book: string) => { if (accountId) { const prefix = `${accountId}:`; return Object.entries(contacts.sharedCards).filter(([key]) => key.startsWith(prefix)).map(([, c]) => c) .filter((c) => book === "all" || c.addressBookIds?.[book]); } const mine = Object.values(contacts.cards); return book === "all" ? mine : mine.filter((c) => c.addressBookIds?.[book]); }; const exportBook = (accountId: string | null, book: string) => { const cards = cardsOf(accountId, book); if (!cards.length) { toast.error(translate("There is nothing in it to export")); return; } const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([cards.map(toVCard).join("")], { type: "text/vcard" })); a.download = "contacts.vcf"; a.click(); }; const importFile = async (f: File, intoBookId?: string) => { const target = intoBookId && intoBookId !== "all" ? intoBookId : bookId; const book = target !== "all" ? contacts.books[target] : (books.find((b) => b.isDefault) ?? books[0]); if (!book) { toast.error(translate("Create an address book first")); return; } try { const text = await f.text(); /* * Which format, decided by what is in the file rather than by what it is * called. A vCard says so on its first line; an address book exported as * LDIF may arrive as .ldif, .ldi, .txt or with no extension at all, and * the name is the least reliable thing about it. */ const { created, updated, alike } = /^\s*BEGIN:VCARD/im.test(text) ? await contacts.importVCard(text, book.id) : await contacts.importLdif(text, book.id); /* * The counts kept apart, as the calendar import keeps them. "Imported 3 * contacts" over a file of two hundred reads as a failure when the other * hundred and ninety-seven were updated, and a re-import of a corrected * export -- the reason for doing this at all -- creates nothing and would * otherwise report importing nothing. */ const imported = plural(created, { one: "Imported {n} contact", other: "Imported {n} contacts" }); const refreshed = plural(updated, { one: "{n} updated", other: "{n} updated" }); if (!created) toast.success(plural(updated, { one: "Updated {n} contact, nothing new", other: "Updated {n} contacts, nothing new" })); else if (updated) toast.success(`${imported} · ${refreshed}`); else toast.success(imported); /* * Said separately, and after, because it is a different kind of fact. * These were not matched and are here twice now -- an LDIF entry whose * `dn` moved between exports, or one imported before there was a `dn` to * match on. Name-plus-email is enough to notice that and not enough to * merge on, so it is reported and left alone (#223). */ if (alike) { toast.show(plural(alike, { one: "{n} of them looks like a contact you already had", other: "{n} of them look like contacts you already had", }), { duration: 9000 }); } } catch (err) { toast.error(translate("Could not import this file: {error}", { error: (err as Error).message })); } }; return (
setQ(e.target.value)} />
{contacts.loading && !contacts.loaded ? : !list.length ? ( } title={q ? translate("No matches") : translate("No contacts yet")}>{q ? translate("Try another search.") : translate("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" ? {translate("· group")} : null}
{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 }] })} /> ) : (
{translate("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 && (

{translate("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 && (

{translate("Phone")}

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

{translate("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) && (

{translate("Work")}

{org?.name &&
{translate("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 && (

{translate("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) && (

{translate("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 && (

{translate("Notes")}

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

{translate("Members ({count})", { count: Object.keys(c.members ?? {}).length })}

{members.map((m) =>
{ e.preventDefault(); navigate(`/contacts/${m.id}`); }}>{contactDisplayName(m)} {contactEmails(m)[0]?.email}
)} {members.length > 0 && }
)} {c.keywords && Object.keys(c.keywords).length > 0 &&
{Object.keys(c.keywords).map((k) => {k})}
} {c.updated &&

{translate("Updated {date}", { date: 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("-"); }