Somebody arriving from SOGo, Thunderbird or an LDAP directory has their contacts in LDIF, and until now the only way in was vCard. Nothing on the server reads LDIF, so this reads it here, in two pieces that are two different problems. `ldif.ts` is RFC 2849 and nothing else: folded lines, base64 values, case-insensitive attribute names, options, comments, `version:` headers, change records. It knows no attribute by name. `mozillaAb.ts` knows the attributes and no syntax -- Mozilla's address book schema, which is what Thunderbird and SOGo write and what the issue asks for by name. LDIF says nothing about what any attribute means, so a file is only readable against a schema, and keeping the two apart is what would let a second schema be added without touching the reader. Work and home addresses, which the schema keeps in two separate sets of attributes, come across as two addresses. So do every phone kind, the second email, the organisation and its units, job title, nickname, web pages and the AIM handle. The four custom fields have no equivalent in JSContact and are appended to the note, labelled as Thunderbird labels them: keeping something somebody chose to write down is worth more than the tidiness of dropping it. An entry with neither a name nor an address is skipped rather than imported as a blank row that is impossible to identify and tedious to find again to delete. The distinguished name is not used as the contact's uid: it says where an entry sat in somebody else's directory. One import control takes either format and decides by what is in the file rather than by what it is called, because an address book exported as LDIF arrives as .ldif, .ldi, .txt or with no extension at all. Closes #174
252 lines
16 KiB
TypeScript
252 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 { 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<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(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 n = /^\s*BEGIN:VCARD/im.test(text)
|
|
? await contacts.importVCard(text, book.id)
|
|
: await contacts.importLdif(text, book.id);
|
|
toast.success(plural(n, { one: "Imported {n} contact", other: "Imported {n} contacts" }));
|
|
} catch (err) {
|
|
toast.error(translate("Could not import this file: {error}", { 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 ? translate("No matches") : translate("No contacts yet")}>{q ? translate("Try another search.") : translate("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: translate("Delete {name}?", { name }), confirmLabel: translate("Delete"), danger: true })) { try { await contacts.destroyCards([c.id]); toast.success(translate("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("-");
|
|
}
|