import { create } from "zustand"; import { CAP, client, setErrorMessage } from "@/jmap/client"; import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types"; import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts"; import { toast } from "@/ui/toast"; import { useSession } from "./session"; import { useMail } from "./mail"; export interface Suggestion { name: string | null; email: string; source: "contact" | "gal" | "recent"; contactId?: Id; photo?: string | null; } /** A book somebody else shared, and the account it lives in. */ export interface SharedBook { accountId: Id; accountName: string; book: AddressBook; } /** Which book the contact list is showing. `accountId` null means the reader's. */ export interface BookSelection { accountId: Id | null; bookId: Id | "all"; } /** Cards from shared accounts are keyed by account too: ids collide across them. */ export const sharedKey = (accountId: Id, id: Id): string => `${accountId}:${id}`; interface ContactsState { accountId: Id | null; available: boolean; books: Record; cards: Record; loaded: boolean; loading: boolean; error: string | null; principals: Principal[]; principalsLoaded: boolean; recent: EmailAddress[]; /** Address books shared with the reader, from every non-personal account. */ sharedBooks: SharedBook[]; /** Their cards, keyed by account and id. See `sharedKey`. */ sharedCards: Record; sharedLoaded: boolean; selection: BookSelection; init(): Promise; loadBooks(): Promise; loadAll(): Promise; /** Books and cards from accounts that shared with the reader. */ loadShared(): Promise; select(selection: BookSelection): void; /** Add a shared address book to, or remove it from, the reader's own view. */ setBookSubscribed(accountId: Id, bookId: Id, subscribed: boolean): Promise; /** The account a card belongs to, null for the reader's own. */ accountOfCard(id: Id): Id | null; getCard(id: Id): Promise; search(text: string): ContactCard[]; /** The search filter itself, so a shared book can be filtered the same way. */ filterCards(cards: ContactCard[], text: string): ContactCard[]; createCard(card: Partial, addressBookId: Id): Promise; updateCard(id: Id, patch: Record): Promise; destroyCards(ids: Id[]): Promise; createBook(name: string): Promise; updateBook(id: Id, patch: Partial): Promise; destroyBook(id: Id): Promise; importVCard(text: string, addressBookId: Id): Promise; loadPrincipals(): Promise; suggest(query: string, limit?: number): Promise; addRecent(addrs: EmailAddress[]): void; lookupByEmail(email: string): ContactCard | undefined; applyChanges(types: Set): void; } export const CARD_PROPS = undefined; // all properties export const useContacts = create((set, get) => ({ accountId: null, available: false, books: {}, cards: {}, loaded: false, loading: false, error: null, principals: [], principalsLoaded: false, recent: [], sharedBooks: [], sharedCards: {}, sharedLoaded: false, selection: { accountId: null, bookId: "all" }, async init() { // The reader's own, not whichever account is selected: a shared address // book is shown beside theirs rather than instead of it, so nothing here // should move when the switcher does. const accountId = useSession.getState().ownAccountFor(CAP.contacts); const available = Boolean(accountId && client.hasCapability(CAP.contacts)); if (accountId !== get().accountId) set({ accountId, books: {}, cards: {}, loaded: false, selection: { accountId: null, bookId: "all" } }); set({ available }); if (!available) return; await get().loadBooks(); void get().loadShared(); }, /* * Books and cards from accounts that shared with the reader. * * These are held apart from the reader's own rather than merged into them, * because ids are only unique within an account: two accounts each having a * book "ab1" is ordinary, and a flat map keyed on the bare id would have one * quietly replace the other. `sharedKey` keeps them apart. * * Loaded eagerly, unlike the shared folders in Files, because these are not * only browsed -- they have to answer when someone types a name into a To * field, which cannot wait for a folder to be opened first. */ async loadShared() { const session = useSession.getState(); const own = session.ownAccountFor(CAP.contacts); const s = session.session; const accounts = Object.entries(s?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own); if (!accounts.length) { set({ sharedBooks: [], sharedCards: {}, sharedLoaded: true }); return; } const books: SharedBook[] = []; const cards: Record = {}; for (const [accountId, account] of accounts) { try { const res = await client.call>("AddressBook/get", { accountId, ids: null }); for (const book of res.list) books.push({ accountId, accountName: account.name, book }); /* * Cards come only from books the reader has added. * * Stalwart hands back every book in a reachable account with full * rights on each, shared or not -- an account linked for its files * offered its address book too -- so `isSubscribed` is the only thing * separating "shared with me" from "reachable". Loading the rest would * put a stranger's contacts in the To field, which is the one place * this must not guess. */ const wanted = new Set(res.list.filter((b) => b.isSubscribed).map((b) => b.id)); if (!wanted.size) continue; // One page. A shared book is a colleague's contacts, not an archive, // and the alternative is holding the reader's own list hostage to it. const cardsRes = await client.chain([ ["ContactCard/query", { accountId, limit: 500 }, "q"], ["ContactCard/get", { accountId, "#ids": { resultOf: "q", name: "ContactCard/query", path: "/ids" } }, "g"], ]); const g = cardsRes.get("g")?.[0] as unknown as GetResponse; for (const c of g.list) { if (!Object.keys(c.addressBookIds ?? {}).some((id) => wanted.has(id))) continue; cards[sharedKey(accountId, c.id)] = c; } } catch { // An account that refuses is one that shared nothing here. Not an // error to show: the reader did not ask for it and cannot act on it. continue; } } set({ sharedBooks: books, sharedCards: cards, sharedLoaded: true }); }, async setBookSubscribed(accountId, bookId, subscribed) { /* * `notUpdated` matters more here than anywhere else this pattern is used. * Subscribing is a write to somebody *else's* account, so it is the one * call in the app that a perfectly healthy server is entitled to refuse -- * and a refusal arrives as a successful response carrying a per-object * failure, not as a thrown error. Ignoring it made a refused subscribe look * exactly like a button that does nothing. */ try { const res = await client.call("AddressBook/set", { accountId, update: { [bookId]: { isSubscribed: subscribed } } }); const err = res.notUpdated?.[bookId]; if (err) throw new Error(setErrorMessage(err)); } catch (err) { toast.error(`Could not ${subscribed ? "add" : "remove"} that address book: ${(err as Error).message}`); return; } if (!subscribed && get().selection.accountId === accountId && get().selection.bookId === bookId) { set({ selection: { accountId: null, bookId: "all" } }); } await get().loadShared(); }, select(selection) { set({ selection }); }, accountOfCard(id) { if (get().cards[id]) return null; const hit = Object.entries(get().sharedCards).find(([key]) => key.endsWith(`:${id}`)); return hit ? hit[0].slice(0, hit[0].length - id.length - 1) : null; }, async loadBooks() { const accountId = get().accountId; if (!accountId) return; try { const res = await client.call>("AddressBook/get", { accountId, ids: null }); const books: Record = {}; for (const b of res.list) books[b.id] = b; set({ books, error: null }); } catch (err) { set({ error: (err as Error).message }); } }, async loadAll() { const accountId = get().accountId; if (!accountId || get().loading) return; set({ loading: true }); try { const cards: Record = {}; let position = 0; const limit = 500; for (let guard = 0; guard < 50; guard++) { const res = await client.chain([ ["ContactCard/query", { accountId, position, limit, calculateTotal: true }, "q"], ["ContactCard/get", { accountId, "#ids": { resultOf: "q", name: "ContactCard/query", path: "/ids" } }, "g"], ]); const q = res.get("q")?.[0] as unknown as QueryResponse; const g = res.get("g")?.[0] as unknown as GetResponse; for (const c of g.list) cards[c.id] = c; position += q.ids.length; if (q.ids.length < limit || (q.total != null && position >= q.total)) break; } set({ cards, loaded: true, loading: false, error: null }); } catch (err) { set({ loading: false, error: (err as Error).message }); } }, async getCard(id) { const accountId = get().accountId; if (!accountId) return null; const res = await client.call>("ContactCard/get", { accountId, ids: [id] }); const c = res.list[0]; if (c) set((s) => ({ cards: { ...s.cards, [c.id]: c } })); return c ?? null; }, filterCards(cards, text) { const q = text.trim().toLowerCase(); const filtered = q ? cards.filter((c) => { const hay = [contactDisplayName(c), ...Object.values(c.emails ?? {}).map((e) => e.address), ...Object.values(c.phones ?? {}).map((p) => p.number), ...Object.values(c.organizations ?? {}).map((o) => o.name ?? ""), ...Object.values(c.nicknames ?? {}).map((n) => n.name)] .join(" ") .toLowerCase(); return hay.includes(q); }) : cards; return filtered.sort((a, b) => sortKey(a).localeCompare(sortKey(b))); }, search(text) { return get().filterCards(Object.values(get().cards), text); }, async createCard(card, addressBookId) { const accountId = get().accountId!; const obj = { "@type": "Card", version: "1.0", uid: crypto.randomUUID(), kind: "individual", ...card, addressBookIds: { [addressBookId]: true } }; const res = await client.call>("ContactCard/set", { accountId, create: { c: obj } }); const err = res.notCreated?.c; if (err) throw new Error(setErrorMessage(err)); const id = res.created!.c!.id; await get().getCard(id); return id; }, async updateCard(id, patch) { const accountId = get().accountId!; const res = await client.call("ContactCard/set", { accountId, update: { [id]: patch } }); const err = res.notUpdated?.[id]; if (err) throw new Error(setErrorMessage(err)); await get().getCard(id); }, async destroyCards(ids) { const accountId = get().accountId!; const res = await client.call("ContactCard/set", { accountId, destroy: ids }); const failed = Object.values(res.notDestroyed ?? {})[0]; if (failed) throw new Error(setErrorMessage(failed)); set((s) => { const cards = { ...s.cards }; for (const id of ids) delete cards[id]; return { cards }; }); }, async createBook(name) { const accountId = get().accountId!; const res = await client.call>("AddressBook/set", { accountId, create: { b: { name } } }); const err = res.notCreated?.b; if (err) throw new Error(setErrorMessage(err)); await get().loadBooks(); return res.created!.b!.id; }, async updateBook(id, patch) { const accountId = get().accountId!; const res = await client.call("AddressBook/set", { accountId, update: { [id]: patch } }); const err = res.notUpdated?.[id]; if (err) throw new Error(setErrorMessage(err)); await get().loadBooks(); }, async destroyBook(id) { const accountId = get().accountId!; const res = await client.call("AddressBook/set", { accountId, destroy: [id], onDestroyRemoveContents: true }); const err = res.notDestroyed?.[id]; if (err) throw new Error(setErrorMessage(err)); await get().loadBooks(); await get().loadAll(); }, async importVCard(text, addressBookId) { const accountId = get().accountId!; const up = await client.upload(accountId, new Blob([text], { type: "text/vcard" }), { type: "text/vcard" }); const parsed = await client.call<{ parsed?: Record; notParsable?: Id[] }>("ContactCard/parse", { accountId, blobIds: [up.blobId] }); const entry = parsed.parsed?.[up.blobId]; const cards: ContactCard[] = entry ? (Array.isArray(entry) ? entry : [entry]) : []; if (!cards.length) throw new Error("No contacts found in file"); const create: Record = {}; cards.forEach((c, i) => { const { id: _id, addressBookIds: _ab, ...rest } = c as ContactCard & { id?: Id }; create[`c${i}`] = { ...rest, uid: rest.uid || crypto.randomUUID(), addressBookIds: { [addressBookId]: true } }; }); const res = await client.call>("ContactCard/set", { accountId, create }); await get().loadAll(); return Object.keys(res.created ?? {}).length; }, async loadPrincipals() { if (get().principalsLoaded) return; const accountId = useSession.getState().accountFor(CAP.principals); if (!accountId || !client.hasCapability(CAP.principals)) { set({ principalsLoaded: true }); return; } try { const res = await client.chain([ ["Principal/query", { accountId, limit: 1000 }, "q"], ["Principal/get", { accountId, "#ids": { resultOf: "q", name: "Principal/query", path: "/ids" }, properties: ["id", "type", "name", "description", "email", "timeZone"] }, "g"], ]); const g = res.get("g")?.[0] as unknown as GetResponse; set({ principals: g.list, principalsLoaded: true }); } catch { set({ principalsLoaded: true }); } }, async suggest(query, limit = 8) { const q = query.trim().toLowerCase(); if (!q) return []; const st = get(); if (!st.loaded && st.available && !st.loading) void st.loadAll(); if (!st.principalsLoaded) void st.loadPrincipals(); const out: Suggestion[] = []; const seen = new Set(); const add = (s: Suggestion) => { const k = s.email.toLowerCase(); if (!k || seen.has(k)) return; seen.add(k); out.push(s); }; const score = (name: string | null, email: string): number => { const n = (name ?? "").toLowerCase(); const e = email.toLowerCase(); if (e.startsWith(q) || n.startsWith(q)) return 0; if (n.split(/\s+/).some((w) => w.startsWith(q))) return 1; if (e.includes(q) || n.includes(q)) return 2; return 99; }; const candidates: Array = []; // A shared address book is only useful if it answers when you are writing // to someone in it, so its cards are offered alongside the reader's own. // They rank a shade lower, so a name in both wins from your own book. const own = Object.values(st.cards).map((c) => ({ c, penalty: 0 })); const shared = Object.values(st.sharedCards).map((c) => ({ c, penalty: 0.5 })); for (const { c, penalty } of [...own, ...shared]) { for (const a of contactEmails(c)) { const sc = score(a.name, a.email); if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty }); } } for (const p of st.principals) { if (!p.email) continue; const sc = score(p.name, p.email); if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 }); } for (const r of st.recent) { const sc = score(r.name, r.email); if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 }); } candidates.sort((a, b) => a.score - b.score || (a.name ?? a.email).localeCompare(b.name ?? b.email)); for (const c of candidates) { add(c); if (out.length >= limit) break; } return out; }, addRecent(addrs) { const cur = get().recent; const next = [...addrs.filter((a) => a.email), ...cur.filter((r) => !addrs.some((a) => a.email.toLowerCase() === r.email.toLowerCase()))].slice(0, 200); set({ recent: next }); try { localStorage.setItem(`ihasmail:${get().accountId}:recent`, JSON.stringify(next)); } catch { /* ignore */ } }, lookupByEmail(email) { const e = email.toLowerCase(); const match = (c: ContactCard) => Object.values(c.emails ?? {}).some((x) => x.address.toLowerCase() === e); // The reader's own books first: a card they wrote themselves should win // over a colleague's version of the same person. return Object.values(get().cards).find(match) ?? Object.values(get().sharedCards).find(match); }, applyChanges(types) { if (types.has("AddressBook")) { void get().loadBooks(); void get().loadShared(); } if (types.has("ContactCard") && get().loaded) void get().loadAll(); }, })); useSession.subscribe((s) => { if (s.status === "authenticated") { const accountId = s.accountFor(CAP.contacts); let recent: EmailAddress[] = []; try { recent = JSON.parse(localStorage.getItem(`ihasmail:${accountId}:recent`) ?? "[]") as EmailAddress[]; } catch { /* ignore */ } useContacts.setState({ recent }); } else { useContacts.setState({ accountId: null, books: {}, cards: {}, loaded: false, principals: [], principalsLoaded: false }); } }); // Harvest recent recipients from Sent when the mail store learns about them. useMail.subscribe((s, prev) => { if (s.emails === prev.emails) return; const sentId = s.roleId("sent"); if (!sentId) return; // cheap: only look at newly-added emails in Sent const addrs: EmailAddress[] = []; for (const id of Object.keys(s.emails)) { if (prev.emails[id]) continue; const e = s.emails[id]!; if (e.mailboxIds[sentId]) addrs.push(...(e.to ?? []), ...(e.cc ?? [])); } if (addrs.length) useContacts.getState().addRecent(addrs.slice(0, 50)); });