import { useMemo, useState } from "react"; import { Plus, Trash2, Camera, X } from "lucide-react"; import type { ContactCard, JSContactAddress, JSContactEmail, JSContactPhone } from "@/jmap/types"; import { useContacts } from "@/store/contacts"; import { buildName, contactDisplayName, nameParts, newKey, withPhoto } from "@/lib/contacts"; import { Dialog } from "@/ui/dialog"; import { DateField } from "@/ui/datefield"; import { toast } from "@/ui/toast"; import { client } from "@/jmap/client"; import { t } from "@/lib/i18n"; interface Props { card: Partial; defaultBookId: string | null; onClose: () => void; onSaved: (id: string) => void; } const EMAIL_CTX = ["private", "work", "other"]; const PHONE_CTX = ["mobile", "private", "work", "fax", "other"]; const ADDR_CTX = ["private", "work", "other"]; type EmailRow = { key: string; address: string; ctx: string }; type PhoneRow = { key: string; number: string; ctx: string }; type AddrRow = { key: string; ctx: string; street: string; city: string; region: string; postcode: string; country: string }; export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props) { const contacts = useContacts(); const isNew = !card.id; // Read from the card whether it is saved or seeded (e.g. from a message header). const np = nameParts(card as ContactCard); const [kind, setKind] = useState<"individual" | "group" | "org">((card.kind as "individual" | "group" | "org") ?? "individual"); const [given, setGiven] = useState(np.given); const [surname, setSurname] = useState(np.surname); const [prefix, setPrefix] = useState(np.prefix); const [middle, setMiddle] = useState(np.middle); const [suffix, setSuffix] = useState(np.suffix); const [nickname, setNickname] = useState(Object.values(card.nicknames ?? {})[0]?.name ?? ""); const [company, setCompany] = useState(Object.values(card.organizations ?? {})[0]?.name ?? ""); const [jobTitle, setJobTitle] = useState(Object.values(card.titles ?? {})[0]?.name ?? ""); const [emails, setEmails] = useState(() => Object.entries(card.emails ?? {}).map(([key, e]) => ({ key, address: e.address, ctx: Object.keys(e.contexts ?? {})[0] ?? "other" }))); const [phones, setPhones] = useState(() => Object.entries(card.phones ?? {}).map(([key, p]) => ({ key, number: p.number, ctx: Object.keys(p.features ?? {})[0] ?? Object.keys(p.contexts ?? {})[0] ?? "other" }))); const [addrs, setAddrs] = useState(() => Object.entries(card.addresses ?? {}).map(([key, a]) => { const get = (k: string) => (a.components ?? []).filter((c) => c.kind === k).map((c) => c.value).join(" "); return { key, ctx: Object.keys(a.contexts ?? {})[0] ?? "other", street: [get("number"), get("name"), get("apartment")].filter(Boolean).join(" ") || (a.full ?? ""), city: get("locality"), region: get("region"), postcode: get("postcode"), country: get("country") }; })); const [birthday, setBirthday] = useState(() => { const b = Object.values(card.anniversaries ?? {}).find((a) => a.kind === "birth")?.date; return b?.year && b.month && b.day ? `${b.year}-${String(b.month).padStart(2, "0")}-${String(b.day).padStart(2, "0")}` : ""; }); const [website, setWebsite] = useState(Object.values(card.links ?? {})[0]?.uri ?? ""); const [note, setNote] = useState(Object.values(card.notes ?? {})[0]?.note ?? ""); const [bookId, setBookId] = useState(Object.keys(card.addressBookIds ?? {})[0] ?? defaultBookId ?? ""); const [photo, setPhoto] = useState<{ dataUrl: string; type: string } | null>(null); const [removePhoto, setRemovePhoto] = useState(false); const [memberUids, setMemberUids] = useState(Object.keys(card.members ?? {})); const [memberQuery, setMemberQuery] = useState(""); const [busy, setBusy] = useState(false); const books = Object.values(contacts.books); const existingPhoto = card.id && contacts.accountId ? Object.values(card.media ?? {}).find((m) => m.kind === "photo") : undefined; const memberCandidates = useMemo(() => { if (!memberQuery.trim()) return []; return contacts.search(memberQuery).filter((c) => c.kind !== "group" && !memberUids.includes(c.uid)).slice(0, 6); }, [memberQuery, contacts, memberUids]); const save = async () => { if (!bookId) { toast.error(t("Choose an address book")); return; } setBusy(true); try { const obj: Record = {}; obj.kind = kind; const name = buildName({ given, surname, middle, prefix, suffix }); if (kind === "individual") obj.name = name ?? null; else { obj.name = company ? { "@type": "Name", full: company } : (name ?? null); } obj.nicknames = nickname ? { [newKey("n")]: { "@type": "Nickname", name: nickname } } : null; obj.organizations = company ? { [newKey("o")]: { "@type": "Organization", name: company } } : null; obj.titles = jobTitle ? { [newKey("t")]: { "@type": "Title", name: jobTitle, kind: "title" } } : null; const em: Record = {}; emails.filter((e) => e.address.trim()).forEach((e, i) => { em[e.key] = { "@type": "EmailAddress", address: e.address.trim(), contexts: e.ctx !== "other" ? { [e.ctx]: true } : undefined, pref: i === 0 ? 1 : undefined }; }); obj.emails = Object.keys(em).length ? em : null; const ph: Record = {}; phones.filter((p) => p.number.trim()).forEach((p) => { ph[p.key] = { "@type": "Phone", number: p.number.trim(), ...(["mobile", "fax"].includes(p.ctx) ? { features: { [p.ctx === "mobile" ? "mobile" : "fax"]: true } } : p.ctx !== "other" ? { contexts: { [p.ctx]: true } } : {}) }; }); obj.phones = Object.keys(ph).length ? ph : null; const ad: Record = {}; addrs.filter((a) => a.street || a.city || a.country || a.postcode).forEach((a) => { const components: JSContactAddress["components"] = []; if (a.street) components.push({ "@type": "AddressComponent", kind: "name", value: a.street }); if (a.city) components.push({ "@type": "AddressComponent", kind: "locality", value: a.city }); if (a.region) components.push({ "@type": "AddressComponent", kind: "region", value: a.region }); if (a.postcode) components.push({ "@type": "AddressComponent", kind: "postcode", value: a.postcode }); if (a.country) components.push({ "@type": "AddressComponent", kind: "country", value: a.country }); ad[a.key] = { "@type": "Address", components, contexts: a.ctx !== "other" ? { [a.ctx]: true } : undefined }; }); obj.addresses = Object.keys(ad).length ? ad : null; if (birthday) { const [y, m, d] = birthday.split("-").map(Number) as [number, number, number]; obj.anniversaries = { [newKey("a")]: { "@type": "Anniversary", kind: "birth", date: { "@type": "PartialDate", year: y, month: m, day: d } } }; } else obj.anniversaries = null; obj.links = website ? { [newKey("l")]: { "@type": "Link", uri: /^https?:/i.test(website) ? website : `https://${website}` } } : null; obj.notes = note.trim() ? { [newKey("x")]: { "@type": "Note", note: note.trim() } } : null; obj.members = kind === "group" && memberUids.length ? Object.fromEntries(memberUids.map((u) => [u, true])) : null; // Inline, not uploaded: see `withPhoto`. The card's other media stays. if (photo) obj.media = withPhoto(card.media, photo); else if (removePhoto) obj.media = withPhoto(card.media, null); if (isNew) { const id = await contacts.createCard(obj as Partial, bookId); toast.success(t("Contact created")); onSaved(id); } else { const patch: Record = { ...obj }; const curBook = Object.keys(card.addressBookIds ?? {})[0]; if (curBook !== bookId) patch.addressBookIds = { [bookId]: true }; if (!photo && !removePhoto) delete patch.media; await contacts.updateCard(card.id!, patch); toast.success(t("Contact saved")); onSaved(card.id!); } } catch (err) { toast.error((err as Error).message); } finally { setBusy(false); } }; const onPhoto = (f: File) => { const img = new Image(); const url = URL.createObjectURL(f); img.onload = () => { const size = 256; const c = document.createElement("canvas"); c.width = size; c.height = size; const ctx = c.getContext("2d")!; const s = Math.min(img.width, img.height); ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size); setPhoto({ dataUrl: c.toDataURL("image/jpeg", 0.85), type: "image/jpeg" }); setRemovePhoto(false); URL.revokeObjectURL(url); }; img.src = url; }; const photoSrc = photo?.dataUrl ?? (!removePhoto && existingPhoto ? (existingPhoto.uri?.startsWith("data:") ? existingPhoto.uri : existingPhoto.blobId ? client.downloadUrl(contacts.accountId!, existingPhoto.blobId, "photo", existingPhoto.mediaType ?? "image/jpeg", true) : null) : null); return ( }>
{photoSrc && }
{kind === "individual" ? ( <>
setGiven(e.target.value)} autoFocus />
setSurname(e.target.value)} />
{t("More name fields")}
setPrefix(e.target.value)} placeholder={t("Dr.")} />
setMiddle(e.target.value)} />
setSuffix(e.target.value)} placeholder={t("Jr.")} />
setNickname(e.target.value)} />
setCompany(e.target.value)} />
setJobTitle(e.target.value)} />
) : (
setCompany(e.target.value)} autoFocus />
)} {kind === "group" && (
{memberUids.map((uid) => { const m = Object.values(contacts.cards).find((x) => x.uid === uid); return {m ? contactDisplayName(m) : uid}; })}
setMemberQuery(e.target.value)} /> {memberCandidates.length > 0 && (
{memberCandidates.map((c) =>
{ e.preventDefault(); setMemberUids([...memberUids, c.uid]); setMemberQuery(""); }}>{contactDisplayName(c)}{Object.values(c.emails ?? {})[0]?.address}
)}
)}
)}
{emails.map((e, i) => (
setEmails(emails.map((x, j) => (j === i ? { ...x, address: ev.target.value } : x)))} />
))}
{phones.map((p, i) => (
setPhones(phones.map((x, j) => (j === i ? { ...x, number: ev.target.value } : x)))} />
))}
{addrs.map((a, i) => (
setAddrs(addrs.map((x, j) => (j === i ? { ...x, street: ev.target.value } : x)))} /> setAddrs(addrs.map((x, j) => (j === i ? { ...x, city: ev.target.value } : x)))} /> setAddrs(addrs.map((x, j) => (j === i ? { ...x, region: ev.target.value } : x)))} /> setAddrs(addrs.map((x, j) => (j === i ? { ...x, postcode: ev.target.value } : x)))} /> setAddrs(addrs.map((x, j) => (j === i ? { ...x, country: ev.target.value } : x)))} />
))}
setWebsite(e.target.value)} placeholder={t("https://")} />