The contacts half of the rule that shipped for events, and only the half that can be decided. A vCard carries a UID its author meant, so a card whose UID this book already holds is that card, and re-importing an export left a second copy of every one of them. Reported on #174 by the reporter's colleague, and decided on #173: skip on a UID that is already here, import what arrives without one, since nothing can be matched on an identity that is not there. LDIF is deliberately untouched and now says so in the type. Mozilla's schema defines no UID and the dn is not an identity outside the directory it came from, so the import invents a UID that can never match one already present. Guessing instead from a name and an address is the open question on #223, and a guess that merges two people who share a name is worse than a duplicate somebody can see and delete. Both imports answer with the same shape, so a caller does not have to know which one it called. LDIF's skipped is always 0, which is the honest number rather than a missing field. The UIDs are asked of the server rather than read from the cards in the store. The store's copy is complete once the view has loaded, and importing does not wait for a view. Two callers, two messages. The contacts import reports both counts, as the calendar import does: "Imported 3 contacts" over a file of two hundred reads as a failure when the rest were already here. And a vCard attached to a message -- usually one you have been sent before -- now says it is already in your contacts rather than reporting that it added none. Refs #223; the LDIF half stays open.
46 lines
2.0 KiB
TypeScript
46 lines
2.0 KiB
TypeScript
import { useState } from "react";
|
|
import { UserPlus } from "lucide-react";
|
|
import type { EmailBodyPart, Id } from "@/jmap/types";
|
|
import { useContacts } from "@/store/contacts";
|
|
import { client } from "@/jmap/client";
|
|
import { toast } from "@/ui/toast";
|
|
import { plural, t } from "@/lib/i18n";
|
|
|
|
export function VCardCard({ part, accountId }: { part: EmailBodyPart; accountId: Id }) {
|
|
const contacts = useContacts();
|
|
const [busy, setBusy] = useState(false);
|
|
const [done, setDone] = useState(false);
|
|
if (!contacts.available || !part.blobId) return null;
|
|
const add = async () => {
|
|
setBusy(true);
|
|
try {
|
|
const text = await client.fetchBlobText(accountId, part.blobId!, "text/vcard");
|
|
const book = Object.values(contacts.books).find((b) => b.isDefault) ?? Object.values(contacts.books)[0];
|
|
if (!book) throw new Error("No address book available");
|
|
const { created, skipped } = await contacts.importVCard(text, book.id);
|
|
setDone(true);
|
|
/*
|
|
* A card attached to a message is usually one you have already been sent
|
|
* once. Saying "Added 0 contacts" for that would read as a failure; it is
|
|
* the opposite -- there was nothing to do.
|
|
*/
|
|
if (!created && skipped) toast.success(plural(skipped, { one: "Already in your contacts", other: "All {n} are already in your contacts" }));
|
|
else toast.success(plural(created, { one: "Added {n} contact", other: "Added {n} contacts" }));
|
|
} catch (err) {
|
|
toast.error((err as Error).message);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
return (
|
|
<div className="vcard-card">
|
|
<UserPlus size={20} style={{ color: "var(--accent)" }} />
|
|
<div className="grow">
|
|
<div style={{ fontWeight: 600 }}>{part.name ?? "Contact card"}</div>
|
|
<div className="hint">{t("vCard attachment")}</div>
|
|
</div>
|
|
<button className="btn btn-sm" disabled={busy || done} onClick={() => void add()}>{done ? "Added" : "Add to contacts"}</button>
|
|
</div>
|
|
);
|
|
}
|