Files
ihasmail/web/src/views/contacts/ContactsView.tsx
T
jcoffey-dev 3f4b33cb51 Finish extraction: 100%, and a coverage number worth believing
The 143 the codemod refused turned out to be two different things, and only
one of them needed a person.

A third were phrases sitting next to an icon -- `<Plus /> New rule`. The
refusal rule was "has siblings", which is broader than the danger: what breaks
a translation is a sibling that renders *text*, splitting a sentence into
fragments no one can reorder. An element beside a phrase does not. Narrowing
the rule to text-producing siblings let the codemod take 73 more.

The rest were real sentences with values in the middle, rebuilt by hand as
named placeholders -- "Your active script “{name}” was written by hand",
"Waiting on the server — goes out {when}." Named rather than positional
because a translator moves the parts around; counted things go through
plural() so Russian and Ukrainian get their three forms rather than English's
two.

Sentences with an element inside them needed something new. `Open <code>mailto:
</code> links in ihasmail` has two obvious treatments and both are wrong:
splitting it into two t() calls hands over fragments that cannot be reordered,
and dropping the <code> keeps the sentence whole but loses the monospace that
said "this is a literal". tNode() keeps the sentence whole and makes the
element a named hole in it, so a translator sees one sentence and can put the
hole where their language wants it. The German test asserts exactly that: the
same call renders the code first when the catalogue says so.

The coverage number was also lying, and it is worth saying how. It counted
text inside <code> and inside translate="no" as untranslated work, and
placeholders like "123456" and "+1 555 0100" -- a one-time code and a phone
format. None of those will ever be translated, so the report sat at 21 with 6
real items left. A number with an unreachable floor is something to argue with
rather than act on, so the tool now applies the same rules the codemod does.

596 wrapped, nothing remaining. Verified in the browser across 15 views, which
is where the last bulk pass hid a bug the tests could not see: no entities, no
unfilled placeholders, no raw t( in rendered text, and the toggle switches that
looked like emptied labels are text-free by design.
2026-08-31 10:41:24 -07:00

243 lines
16 KiB
TypeScript

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 { 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<Partial<ContactCard> | 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<File>).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 <div className="p-16"><Empty icon={<Users size={40} />} title={translate("Contacts are not available")}>{translate("This account does not have the JMAP contacts capability.")}</Empty></div>;
}
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 (
<div className={`contacts-layout ${selected || editing ? "detail" : ""}`}>
<section className="contacts-list">
<div className="list-search row">
<div className="search-input" style={{ flex: 1, height: 38, background: "var(--bg-sunken)", borderRadius: 999, display: "flex", alignItems: "center", gap: 8, padding: "0 12px" }}>
<Search size={16} className="muted" />
<input style={{ flex: 1, border: 0, background: "transparent", outline: "none" }} placeholder={translate("Search contacts")} value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<button className="icon-btn" title={translate("New contact")} onClick={() => setEditing({})}><Plus size={20} /></button>
</div>
<div className="contacts-scroll">
{contacts.loading && !contacts.loaded ? <Spinner label={translate("Loading contacts…")} /> : !list.length ? (
<Empty icon={<Users size={36} />} title={q ? "No matches" : "No contacts yet"}>{q ? "Try another search." : "Add a contact or import a vCard file."}</Empty>
) : groups.map((g) => (
<div key={g.letter}>
<div className="contact-letter">{g.letter}</div>
{g.items.map((c) => {
const email = contactEmails(c)[0]?.email;
const photo = contacts.accountId ? contactPhoto(c, contacts.accountId) : null;
return (
<div key={c.id} className={`contact-row ${id === c.id ? "active" : ""}`} onClick={() => navigate(`/contacts/${c.id}`)}>
<span className="avatar" style={{ background: photo ? "transparent" : avatarColor(email ?? contactDisplayName(c)) }}>{photo ? <img src={photo} alt="" /> : c.kind === "group" ? <Users size={16} /> : contactDisplayName(c).slice(0, 1).toUpperCase()}</span>
<div className="grow" style={{ minWidth: 0 }}>
<div className="c-name"><span>{contactDisplayName(c)}</span>{c.kind === "group" ? <span className="hint"> {translate("· group")}</span> : null}</div>
<div className="c-email">{email ?? Object.values(c.phones ?? {})[0]?.number ?? Object.values(c.organizations ?? {})[0]?.name ?? ""}</div>
</div>
</div>
);
})}
</div>
))}
</div>
</section>
<section className="contact-detail">
{selected ? (
<ContactDetail card={selected} onBack={() => navigate("/contacts")} onEdit={() => setEditing(selected)} narrow={narrow} onEmail={(addr) => openCompose({ to: [{ name: contactDisplayName(selected), email: addr }] })} />
) : (
<div className="no-thread"><Users size={48} style={{ color: "var(--fg-faint)" }} /><div>{translate("Select a contact")}</div></div>
)}
</section>
{editing && <ContactEditor card={editing} defaultBookId={bookId !== "all" ? bookId : (books.find((b) => b.isDefault)?.id ?? books[0]?.id ?? null)} onClose={() => setEditing(null)} onSaved={(cid) => { setEditing(null); navigate(`/contacts/${cid}`); }} />}
</div>
);
}
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<string, boolean>, label?: string) => label || Object.keys(ctx ?? {}).join(", ") || "";
return (
<div>
<div className="row" style={{ marginBottom: 12 }}>
{narrow && <button className="icon-btn" onClick={onBack} aria-label={translate("Back")}><ArrowLeft size={20} /></button>}
<span className="spacer" />
<button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> {translate("Edit")}</button>
<button className="btn btn-sm" onClick={() => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([toVCard(c)], { type: "text/vcard" })); a.download = `${name.replace(/[^\w.-]+/g, "_")}.vcf`; a.click(); }}><Download size={14} /> {translate("vCard")}</button>
<button className="btn btn-sm btn-ghost" style={{ color: "var(--danger)" }} onClick={async () => { if (await confirmDialog({ title: `Delete ${name}?`, confirmLabel: "Delete", danger: true })) { try { await contacts.destroyCards([c.id]); toast.success("Contact deleted"); navigate("/contacts"); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={14} /></button>
</div>
<div className="contact-hero">
<span className="avatar xl" style={{ background: photo ? "transparent" : avatarColor(contactEmails(c)[0]?.email ?? name) }}>{photo ? <img src={photo} alt="" /> : c.kind === "group" ? <Users size={36} /> : name.slice(0, 1).toUpperCase()}</span>
<div>
<h1>{name}</h1>
{(title?.name || org?.name) && <div className="sub">{[title?.name, org?.name].filter(Boolean).join(" · ")}</div>}
{Object.values(c.nicknames ?? {})[0]?.name && <div className="sub">{Object.values(c.nicknames ?? {})[0]!.name}</div>}
{books.length > 0 && <div className="hint">{books.join(", ")}</div>}
</div>
</div>
{Object.values(c.emails ?? {}).length > 0 && (
<div className="contact-section"><h3>{translate("Email")}</h3>
{Object.values(c.emails ?? {}).map((e, i) => (
<div key={i} className="contact-kv"><span className="k">{ctxLabel(e.contexts, e.label) || "email"}</span><span className="v row gap-8"><a href={`mailto:${e.address}`} onClick={(ev) => { ev.preventDefault(); onEmail(e.address); }}>{e.address}</a><button className="icon-btn xs" title={translate("Compose")} onClick={() => onEmail(e.address)}><Mail size={14} /></button></span></div>
))}
</div>
)}
{Object.values(c.phones ?? {}).length > 0 && (
<div className="contact-section"><h3>{translate("Phone")}</h3>
{Object.values(c.phones ?? {}).map((p, i) => (
<div key={i} className="contact-kv"><span className="k">{ctxLabel({ ...p.contexts, ...p.features }, p.label) || "phone"}</span><span className="v row gap-8"><Phone size={14} className="muted" /><a href={`tel:${p.number}`}>{p.number}</a></span></div>
))}
</div>
)}
{Object.values(c.addresses ?? {}).length > 0 && (
<div className="contact-section"><h3>{translate("Address")}</h3>
{Object.values(c.addresses ?? {}).map((a, i) => (
<div key={i} className="contact-kv"><span className="k">{ctxLabel(a.contexts) || "address"}</span><span className="v row gap-8" style={{ alignItems: "flex-start" }}><MapPin size={14} className="muted" style={{ marginTop: 3 }} /><span>{formatAddressLines(a).map((l, j) => <div key={j}>{l}</div>)}</span></span></div>
))}
</div>
)}
{(org || Object.values(c.titles ?? {}).length > 1) && (
<div className="contact-section"><h3>{translate("Work")}</h3>
{org?.name && <div className="contact-kv"><span className="k">{translate("Company")}</span><span className="v row gap-8"><Building2 size={14} className="muted" />{`${org.name}${org.units?.length ? ` · ${org.units.map((u) => u.name).join(", ")}` : ""}`}</span></div>}
{Object.values(c.titles ?? {}).map((t, i) => <div key={i} className="contact-kv"><span className="k">{t.kind === "role" ? "Role" : "Title"}</span><span className="v">{t.name}</span></div>)}
</div>
)}
{Object.values(c.anniversaries ?? {}).length > 0 && (
<div className="contact-section"><h3>{translate("Dates")}</h3>
{Object.values(c.anniversaries ?? {}).map((a, i) => <div key={i} className="contact-kv"><span className="k">{a.kind === "birth" ? "Birthday" : a.kind === "wedding" ? "Anniversary" : a.kind}</span><span className="v row gap-8"><Cake size={14} className="muted" />{fmtPartial(a.date)}</span></div>)}
</div>
)}
{(Object.values(c.links ?? {}).length > 0 || Object.values(c.onlineServices ?? {}).length > 0) && (
<div className="contact-section"><h3>{translate("Online")}</h3>
{Object.values(c.links ?? {}).map((l, i) => <div key={`l${i}`} className="contact-kv"><span className="k">{l.label ?? "Website"}</span><span className="v row gap-8"><Globe size={14} className="muted" /><a href={l.uri} target="_blank" rel="noreferrer">{l.uri}</a></span></div>)}
{Object.values(c.onlineServices ?? {}).map((s, i) => <div key={`s${i}`} className="contact-kv"><span className="k">{s.service ?? s.label ?? "IM"}</span><span className="v">{s.user ?? s.uri}</span></div>)}
</div>
)}
{Object.values(c.notes ?? {}).length > 0 && (
<div className="contact-section"><h3>{translate("Notes")}</h3>
{Object.values(c.notes ?? {}).map((n, i) => <div key={i} className="contact-kv"><span className="k"><StickyNote size={14} /></span><span className="v" style={{ whiteSpace: "pre-wrap" }}>{n.note}</span></div>)}
</div>
)}
{c.kind === "group" && (
<div className="contact-section"><h3>{translate("Members ({count})", { count: Object.keys(c.members ?? {}).length })}</h3>
{members.map((m) => <div key={m.id} className="contact-kv"><span className="k"><Avatar who={{ name: contactDisplayName(m), email: contactEmails(m)[0]?.email }} size="sm" /></span><span className="v"><a href={`/contacts/${m.id}`} onClick={(e) => { e.preventDefault(); navigate(`/contacts/${m.id}`); }}>{contactDisplayName(m)}</a> <span className="hint">{contactEmails(m)[0]?.email}</span></span></div>)}
{members.length > 0 && <button className="btn btn-sm mt-8" onClick={() => useCompose.getState().open({ to: members.flatMap((m) => contactEmails(m).slice(0, 1)) })}><Mail size={14} /> {translate("Email group")}</button>}
</div>
)}
{c.keywords && Object.keys(c.keywords).length > 0 && <div className="row wrap gap-4 mt-8">{Object.keys(c.keywords).map((k) => <span key={k} className="chip"><Pin size={12} /> {k}</span>)}</div>}
{c.updated && <p className="hint mt-16"><CalIcon size={12} /> {translate("Updated {date}", { date: formatDate(new Date(c.updated)) })}</p>}
</div>
);
}
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("-");
}