Merge pull request #384 from Coffey-Labs/perf/contacts-calendar-changes

Sync contacts by what changed, and hold fewer calendar windows
This commit is contained in:
jcoffey
2026-09-16 10:45:12 -07:00
committed by GitHub
5 changed files with 295 additions and 22 deletions
+12
View File
@@ -18,6 +18,18 @@ export function recordEmailChange(change: { created?: string[]; updated?: string
if (emailChanges.length > 200) emailChanges.splice(0, emailChanges.length - 200); if (emailChanges.length > 200) emailChanges.splice(0, emailChanges.length - 200);
} }
/** The same for contact cards, so `ContactCard/changes` can answer too. */
export const cardChanges: Array<{ state: number; created: string[]; updated: string[]; destroyed: string[] }> = [];
/** Changes at or below this state have been dropped from the log, so a client that far behind cannot be answered. */
export const cardLog = { floor: 0 };
export function recordCardChange(change: { created?: string[]; updated?: string[]; destroyed?: string[] }) {
cardChanges.push({ state: state.n, created: change.created ?? [], updated: change.updated ?? [], destroyed: change.destroyed ?? [] });
if (cardChanges.length > 200) {
const dropped = cardChanges.splice(0, cardChanges.length - 200);
cardLog.floor = dropped[dropped.length - 1]!.state;
}
}
export function broadcast(types: string[]) { export function broadcast(types: string[]) {
const payload = `event: state\ndata: ${JSON.stringify({ "@type": "StateChange", changed: { [ACCOUNT]: Object.fromEntries(types.map((t) => [t, String(state.n)])) } })}\n\n`; const payload = `event: state\ndata: ${JSON.stringify({ "@type": "StateChange", changed: { [ACCOUNT]: Object.fromEntries(types.map((t) => [t, String(state.n)])) } })}\n\n`;
for (const c of sseClients) c.write(payload); for (const c of sseClients) c.write(payload);
+24 -2
View File
@@ -1,5 +1,5 @@
import { checkOtp } from "./auth.js"; import { checkOtp } from "./auth.js";
import { emailChanges, recordEmailChange, broadcast } from "./events.js"; import { cardChanges, cardLog, emailChanges, recordCardChange, recordEmailChange, broadcast } from "./events.js";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js"; import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
import { holdUntilOf, undoStatusOf } from "./futurerelease.js"; import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
@@ -428,7 +428,29 @@ export const handlers: Record<string, Handler> = {
// An empty `properties` list returns `id` alone, which `pick` already does. // An empty `properties` list returns `id` alone, which `pick` already does.
// 0.16.22 made Stalwart agree; through 0.16.21 it returned every property. // 0.16.22 made Stalwart agree; through 0.16.21 it returned every property.
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a), "ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
"ContactCard/set": genericSet(cards, "cc"), /*
* Recorded and announced like Email/set, so the client's incremental sync
* (`ContactCard/changes`, then fetching what it names) runs here too. A
* state older than the log's window cannot be answered, as on a real server.
*/
"ContactCard/set": (a) => {
const r = genericSet(cards, "cc")(a);
nextState();
recordCardChange({
created: Object.values((r.created ?? {}) as Record<string, { id: string }>).map((x) => x.id),
updated: Object.keys((r.updated ?? {}) as Obj),
destroyed: (r.destroyed as string[] | undefined) ?? [],
});
broadcast(["ContactCard"]);
return r;
},
"ContactCard/changes": (a) => {
const since = Number(a.sinceState ?? 0);
if (since < cardLog.floor) throw new MethodError("cannotCalculateChanges", "That state is too old to answer from.");
const relevant = cardChanges.filter((c) => c.state > since);
const pick = (k: "created" | "updated" | "destroyed") => [...new Set(relevant.flatMap((c) => c[k]))];
return { accountId: a.accountId ?? ACCOUNT, oldState: String(a.sinceState ?? "1"), newState: String(state.n), hasMoreChanges: false, created: pick("created"), updated: pick("updated"), destroyed: pick("destroyed") };
},
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; }, "ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
"FileNode/query": (a) => { "FileNode/query": (a) => {
const f = (a.filter as Obj) ?? {}; const f = (a.filter as Obj) ?? {};
@@ -0,0 +1,150 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types";
import { useContacts } from "@/store/contacts";
import { keepRecent, RANGES_KEPT, useCalendar } from "@/store/calendar";
/**
* What a change to one contact or one event costs.
*
* A pushed ContactCard change used to reload the whole address book, and a
* CalendarEvent change queried every window the reader had ever visited again.
*/
type Call = [string, Record<string, unknown>, string];
let calls: Call[];
let reply: (name: string, args: Record<string, unknown>) => unknown;
beforeEach(() => {
calls = [];
client.session = {
capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 2 }, [CAP.contacts]: {}, [CAP.calendars]: {} },
accounts: {},
primaryAccounts: {},
state: "s",
} as unknown as JmapSession;
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: Call[] };
const methodResponses = methodCalls.map(([name, args, id]) => {
calls.push([name, args, id]);
const out = reply(name, args);
return out instanceof Error ? ["error", { type: out.message }, id] : [name, out, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
}));
});
afterEach(() => {
vi.unstubAllGlobals();
});
const card = (id: string, name: string) => ({ id, addressBookIds: { b1: true }, name: { full: name } });
describe("syncCards", () => {
beforeEach(() => {
useContacts.setState({
accountId: "a1",
available: true,
loaded: true,
loading: false,
cardState: "10",
cards: { c1: card("c1", "Ann"), c2: card("c2", "Bob"), c3: card("c3", "Cy") } as never,
});
});
it("fetches only what changed, in batches the server takes", async () => {
reply = (name, args) => {
if (name === "ContactCard/changes") return { oldState: "10", newState: "12", hasMoreChanges: false, created: ["c4", "c5", "c6"], updated: ["c1"], destroyed: ["c2"] };
if (name === "ContactCard/get") return { state: "12", list: (args.ids as string[]).map((id) => card(id, `new ${id}`)), notFound: [] };
throw new Error(`unexpected ${name}`);
};
await useContacts.getState().syncCards();
const names = calls.map(([n]) => n);
expect(names).not.toContain("ContactCard/query");
const gets = calls.filter(([n]) => n === "ContactCard/get").map(([, a]) => a.ids as string[]);
expect(gets.every((ids) => ids.length <= 2)).toBe(true);
expect(gets.flat().sort()).toEqual(["c1", "c4", "c5", "c6"]);
const s = useContacts.getState();
expect(Object.keys(s.cards).sort()).toEqual(["c1", "c3", "c4", "c5", "c6"]);
expect(s.cards.c1!.name!.full).toBe("new c1");
expect(s.cards.c3!.name!.full).toBe("Cy");
expect(s.cardState).toBe("12");
});
it("follows changes across pages", async () => {
reply = (name, args) => {
if (name === "ContactCard/changes") {
return args.sinceState === "10"
? { oldState: "10", newState: "11", hasMoreChanges: true, created: [], updated: ["c1"], destroyed: [] }
: { oldState: "11", newState: "13", hasMoreChanges: false, created: [], updated: [], destroyed: ["c1"] };
}
return { state: "13", list: [], notFound: [] };
};
await useContacts.getState().syncCards();
// Updated on the first page and destroyed on the second: gone, and not fetched.
expect(useContacts.getState().cards.c1).toBeUndefined();
expect(calls.filter(([n]) => n === "ContactCard/get")).toHaveLength(0);
expect(useContacts.getState().cardState).toBe("13");
});
it("reloads everything when the server cannot say what changed", async () => {
reply = (name) => {
if (name === "ContactCard/changes") return new Error("cannotCalculateChanges");
if (name === "ContactCard/query") return { ids: ["c9"], total: 1, position: 0, queryState: "q" };
if (name === "ContactCard/get") return { state: "20", list: [card("c9", "Zed")], notFound: [] };
throw new Error(`unexpected ${name}`);
};
await useContacts.getState().syncCards();
const s = useContacts.getState();
expect(Object.keys(s.cards)).toEqual(["c9"]);
expect(s.cardState).toBe("20");
});
it("records the state a full load was read at", async () => {
useContacts.setState({ cardState: null, loaded: false });
reply = (name) => {
if (name === "ContactCard/query") return { ids: ["c1"], total: 1, position: 0, queryState: "q" };
return { state: "30", list: [card("c1", "Ann")], notFound: [] };
};
await useContacts.getState().loadAll();
expect(useContacts.getState().cardState).toBe("30");
});
});
describe("calendar windows", () => {
const day = 86_400_000;
const windowAt = (n: number) => [new Date(n * 7 * day), new Date((n + 1) * 7 * day)] as const;
beforeEach(() => {
useCalendar.setState({ accountId: "a1", available: true, ranges: {}, sharedRanges: {}, events: {}, sharedCalendars: [] });
reply = (name) => (name === "CalendarEvent/query" ? { ids: [], total: 0, position: 0, queryState: "q" } : { state: "1", list: [], notFound: [] });
});
it("keeps only the most recent few", () => {
let ranges: Record<string, string[]> = {};
for (let i = 0; i < RANGES_KEPT + 3; i++) ranges = keepRecent(ranges, `k${i}`, []);
expect(Object.keys(ranges)).toEqual(Array.from({ length: RANGES_KEPT }, (_, i) => `k${i + 3}`));
// Seeing one again moves it to the back of the queue.
ranges = keepRecent(ranges, "k3", ["e"]);
expect(Object.keys(ranges).at(-1)).toBe("k3");
expect(ranges.k3).toEqual(["e"]);
});
it("queries only the windows it holds when an event changes", async () => {
const store = useCalendar.getState();
for (let i = 0; i < 10; i++) {
const [a, b] = windowAt(i);
await store.loadRange(a, b);
}
expect(Object.keys(useCalendar.getState().ranges)).toHaveLength(RANGES_KEPT);
calls = [];
useCalendar.getState().applyChanges(new Set(["CalendarEvent"]));
await vi.waitFor(() => expect(calls.filter(([n]) => n === "CalendarEvent/query")).toHaveLength(RANGES_KEPT));
});
it("does not empty the windows while they reload", () => {
useCalendar.setState({ ranges: { [`${7 * day}|${14 * day}`]: ["e1"] } });
useCalendar.getState().invalidate();
expect(Object.values(useCalendar.getState().ranges)).toEqual([["e1"]]);
});
});
+43 -11
View File
@@ -498,7 +498,8 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const accounts = [...new Set(shared.map((c) => c.accountId))]; const accounts = [...new Set(shared.map((c) => c.accountId))];
const ids: string[] = []; const ids: string[] = [];
const events: Record<string, CalendarEvent> = {}; const events: Record<string, CalendarEvent> = {};
for (const accountId of accounts) { // Every account at once, rather than one waiting on the last.
await Promise.all(accounts.map(async (accountId) => {
try { try {
const res = await client.chain([ const res = await client.chain([
["CalendarEvent/query", { accountId, filter: { after: toLocalDateTime(start), before: toLocalDateTime(end) }, timeZone: tz, sort: [{ property: "start", isAscending: true }], expandRecurrences: true, limit: 2000 }, "q"], ["CalendarEvent/query", { accountId, filter: { after: toLocalDateTime(start), before: toLocalDateTime(end) }, timeZone: tz, sort: [{ property: "start", isAscending: true }], expandRecurrences: true, limit: 2000 }, "q"],
@@ -512,10 +513,9 @@ export const useCalendar = create<CalendarState>((set, get) => ({
} }
} catch { } catch {
// One account refusing must not empty the calendar of the others. // One account refusing must not empty the calendar of the others.
continue;
} }
} }));
set((s) => ({ sharedEvents: { ...s.sharedEvents, ...events }, sharedRanges: { ...s.sharedRanges, [key]: ids } })); set((s) => ({ sharedEvents: { ...s.sharedEvents, ...events }, sharedRanges: key in s.ranges ? { ...s.sharedRanges, [key]: ids } : s.sharedRanges }));
}, },
async loadCalendars() { async loadCalendars() {
@@ -535,7 +535,12 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const accountId = get().accountId; const accountId = get().accountId;
if (!accountId) return; if (!accountId) return;
const key = `${start.getTime()}|${end.getTime()}`; const key = `${start.getTime()}|${end.getTime()}`;
if (!force && get().ranges[key]) return; const held = get().ranges[key];
if (!force && held) {
// Shown again, so the last to be dropped.
set((s) => ({ ranges: keepRecent(s.ranges, key, held) }));
return;
}
set({ loading: true }); set({ loading: true });
const tz = settings().timeZone ?? browserTimeZone; const tz = settings().timeZone ?? browserTimeZone;
try { try {
@@ -560,7 +565,8 @@ export const useCalendar = create<CalendarState>((set, get) => ({
set((s) => { set((s) => {
const events = { ...s.events }; const events = { ...s.events };
for (const e of g.list) events[e.id] = e; for (const e of g.list) events[e.id] = e;
return { events, ranges: { ...s.ranges, [key]: q.ids }, loading: false, error: null }; const ranges = keepRecent(s.ranges, key, q.ids);
return { events, ranges, sharedRanges: onlyKeys(s.sharedRanges, ranges), loading: false, error: null };
}); });
void get().loadSharedRange(start, end); void get().loadSharedRange(start, end);
} catch (err) { } catch (err) {
@@ -665,6 +671,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
hiding one is remembered under the same account-qualified key. */ hiding one is remembered under the same account-qualified key. */
const sharedKeys = new Set<string>(); const sharedKeys = new Set<string>();
for (const list of Object.values(sharedRanges)) for (const k of list) sharedKeys.add(k); for (const list of Object.values(sharedRanges)) for (const k of list) sharedKeys.add(k);
const added = new Set(settings().addedShares);
for (const k of sharedKeys) { for (const k of sharedKeys) {
const e = sharedEvents[k]; const e = sharedEvents[k];
if (!e) continue; if (!e) continue;
@@ -676,7 +683,6 @@ export const useCalendar = create<CalendarState>((set, get) => ({
an account linked for its files offered its calendar too. `isSubscribed` an account linked for its files offered its calendar too. `isSubscribed`
is the only thing separating "shared with me" from "reachable", so is the only thing separating "shared with me" from "reachable", so
nothing unsubscribed is drawn. */ nothing unsubscribed is drawn. */
const added = new Set(settings().addedShares);
const theirs: Record<Id, Calendar> = {}; const theirs: Record<Id, Calendar> = {};
for (const c of sharedCalendars) { for (const c of sharedCalendars) {
if (c.accountId !== accountId) continue; if (c.accountId !== accountId) continue;
@@ -1000,11 +1006,16 @@ export const useCalendar = create<CalendarState>((set, get) => ({
if (types.has("CalendarEvent")) get().invalidate(); if (types.has("CalendarEvent")) get().invalidate();
}, },
/*
* Load the windows held again, after something changed.
*
* Only the few most recently shown are held (see `keepRecent`), so this is a
* handful of queries rather than one for every month ever looked at. They
* are replaced where they are, not emptied first: clearing them made the
* calendar go blank until the answers came back.
*/
invalidate() { invalidate() {
// Force reload of all ranges currently cached. for (const k of Object.keys(get().ranges)) {
const keys = Object.keys(get().ranges);
set({ ranges: {} });
for (const k of keys) {
const [s, e] = k.split("|").map(Number) as [number, number]; const [s, e] = k.split("|").map(Number) as [number, number];
void get().loadRange(new Date(s), new Date(e), true); void get().loadRange(new Date(s), new Date(e), true);
} }
@@ -1015,6 +1026,27 @@ export const useCalendar = create<CalendarState>((set, get) => ({
}, },
})); }));
/**
* How many loaded windows are held.
*
* Every week or month the reader visited used to stay, and each was queried
* again whenever any event changed, and walked by every render. A window
* dropped here is simply loaded again if the reader goes back to it.
*/
export const RANGES_KEPT = 4;
/** `ranges` with `key` set and moved to the end, trimmed to the most recent `RANGES_KEPT`. */
export function keepRecent(ranges: Record<string, Id[]>, key: string, ids: Id[]): Record<string, Id[]> {
const { [key]: _old, ...rest } = ranges;
const entries = [...Object.entries(rest), [key, ids] as [string, Id[]]];
return Object.fromEntries(entries.slice(-RANGES_KEPT));
}
/** `map` restricted to the windows still held. */
function onlyKeys<T>(map: Record<string, T>, held: Record<string, unknown>): Record<string, T> {
return Object.fromEntries(Object.entries(map).filter(([k]) => k in held));
}
/** /**
* Whether an event is part of a series. * Whether an event is part of a series.
* *
+66 -9
View File
@@ -1,7 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { accountKey, loadRaw, saveJson } from "@/lib/storage"; import { accountKey, loadRaw, saveJson } from "@/lib/storage";
import { CAP, chunk, client, setErrorMessage } from "@/jmap/client"; import { CAP, chunk, client, JmapMethodError, setErrorMessage } from "@/jmap/client";
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types"; import type { AddressBook, ChangesResponse, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types";
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts"; import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
import { parseLdif, uidFromDn } from "@/lib/ldif"; import { parseLdif, uidFromDn } from "@/lib/ldif";
import { cardFromLdif } from "@/lib/mozillaAb"; import { cardFromLdif } from "@/lib/mozillaAb";
@@ -173,6 +173,8 @@ interface ContactsState {
available: boolean; available: boolean;
books: Record<Id, AddressBook>; books: Record<Id, AddressBook>;
cards: Record<Id, ContactCard>; cards: Record<Id, ContactCard>;
/** The server's ContactCard state `cards` was read at, for asking what changed since. */
cardState: string | null;
loaded: boolean; loaded: boolean;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
@@ -189,6 +191,11 @@ interface ContactsState {
init(): Promise<void>; init(): Promise<void>;
loadBooks(): Promise<void>; loadBooks(): Promise<void>;
loadAll(): Promise<void>; loadAll(): Promise<void>;
/**
* Bring `cards` up to date with what changed on the server, or load them all
* when that cannot be worked out.
*/
syncCards(): Promise<void>;
/** Books and cards from accounts that shared with the reader. */ /** Books and cards from accounts that shared with the reader. */
loadShared(): Promise<void>; loadShared(): Promise<void>;
select(selection: BookSelection): void; select(selection: BookSelection): void;
@@ -247,6 +254,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
available: false, available: false,
books: {}, books: {},
cards: {}, cards: {},
cardState: null,
loaded: false, loaded: false,
loading: false, loading: false,
error: null, error: null,
@@ -264,7 +272,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
// should move when the switcher does. // should move when the switcher does.
const accountId = useSession.getState().ownAccountFor(CAP.contacts); const accountId = useSession.getState().ownAccountFor(CAP.contacts);
const available = Boolean(accountId && client.hasCapability(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" } }); if (accountId !== get().accountId) set({ accountId, books: {}, cards: {}, cardState: null, loaded: false, selection: { accountId: null, bookId: "all" } });
set({ available }); set({ available });
if (!available) return; if (!available) return;
await get().loadBooks(); await get().loadBooks();
@@ -408,6 +416,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const cards: Record<Id, ContactCard> = {}; const cards: Record<Id, ContactCard> = {};
let position = 0; let position = 0;
const limit = 500; const limit = 500;
let cardState: string | null = null;
for (let guard = 0; guard < 50; guard++) { for (let guard = 0; guard < 50; guard++) {
const res = await client.chain([ const res = await client.chain([
["ContactCard/query", { accountId, position, limit, calculateTotal: true }, "q"], ["ContactCard/query", { accountId, position, limit, calculateTotal: true }, "q"],
@@ -416,15 +425,63 @@ export const useContacts = create<ContactsState>((set, get) => ({
const q = res.get("q")?.[0] as unknown as QueryResponse; const q = res.get("q")?.[0] as unknown as QueryResponse;
const g = res.get("g")?.[0] as unknown as GetResponse<ContactCard>; const g = res.get("g")?.[0] as unknown as GetResponse<ContactCard>;
for (const c of g.list) cards[c.id] = c; for (const c of g.list) cards[c.id] = c;
// The first page's: a change made while the rest were being read is
// then reported again by the next sync, rather than missed.
cardState ??= g.state;
position += q.ids.length; position += q.ids.length;
if (q.ids.length < limit || (q.total != null && position >= q.total)) break; if (q.ids.length < limit || (q.total != null && position >= q.total)) break;
} }
set({ cards, loaded: true, loading: false, error: null }); set({ cards, cardState, loaded: true, loading: false, error: null });
} catch (err) { } catch (err) {
set({ loading: false, error: (err as Error).message }); set({ loading: false, error: (err as Error).message });
} }
}, },
/*
* What changed, rather than everything again.
*
* Every push that touched a card, and every edit made here, used to reload
* the whole address book -- up to fifty pages of five hundred cards with all
* their properties -- to pick up one change. ContactCard/changes names what
* changed since the state the cards were read at, and only those are fetched.
* A server that cannot say (`cannotCalculateChanges`), or any other failure,
* falls back to the full load, which is what happened before.
*/
async syncCards() {
const { accountId, cardState, loaded } = get();
if (!accountId || !loaded || !cardState) return get().loadAll();
try {
const changed = new Set<Id>();
const destroyed = new Set<Id>();
let since = cardState;
for (let guard = 0; guard < 50; guard++) {
const ch = await client.call<ChangesResponse>("ContactCard/changes", { accountId, sinceState: since, maxChanges: 500 });
for (const id of [...ch.created, ...ch.updated]) { changed.add(id); destroyed.delete(id); }
for (const id of ch.destroyed) { destroyed.add(id); changed.delete(id); }
since = ch.newState;
if (!ch.hasMoreChanges) break;
}
const fetched = await Promise.all(
chunk([...changed], client.maxObjectsInGet).map((part) => client.call<GetResponse<ContactCard>>("ContactCard/get", { accountId, ids: part })),
);
if (get().accountId !== accountId) return;
set((s) => {
const cards = { ...s.cards };
for (const id of destroyed) delete cards[id];
for (const r of fetched) {
for (const c of r.list) cards[c.id] = c;
// An id listed as changed but gone by the time it was asked for.
for (const id of r.notFound ?? []) delete cards[id];
}
return { cards, cardState: since, error: null };
});
} catch (err) {
if (!(err instanceof JmapMethodError) || err.type !== "cannotCalculateChanges") console.warn("[ihasmail] contact sync failed, reloading:", err);
set({ cardState: null });
await get().loadAll();
}
},
async getCard(id) { async getCard(id) {
const accountId = get().accountId; const accountId = get().accountId;
if (!accountId) return null; if (!accountId) return null;
@@ -546,7 +603,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
refused ??= Object.values(res.notDestroyed ?? {})[0] ?? Object.values(res.notUpdated ?? {})[0]; refused ??= Object.values(res.notDestroyed ?? {})[0] ?? Object.values(res.notUpdated ?? {})[0];
} }
} finally { } finally {
await get().loadAll(); await get().syncCards();
} }
return { destroyed: gone.length, unfiled, refused }; return { destroyed: gone.length, unfiled, refused };
}, },
@@ -574,7 +631,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const err = res.notDestroyed?.[id]; const err = res.notDestroyed?.[id];
if (err) throw new Error(setErrorMessage(err)); if (err) throw new Error(setErrorMessage(err));
await get().loadBooks(); await get().loadBooks();
await get().loadAll(); await get().syncCards();
}, },
async importVCard(text, addressBookId) { async importVCard(text, addressBookId) {
@@ -623,7 +680,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
matched on it rather than guessed at. */ matched on it rather than guessed at. */
return { created, updated, alike: 0 }; return { created, updated, alike: 0 };
} finally { } finally {
await get().loadAll(); await get().syncCards();
} }
}, },
@@ -691,7 +748,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
if (!created && !updated) throw new Error(refused ? setErrorMessage(refused) : "the server did not accept any of its contacts"); if (!created && !updated) throw new Error(refused ? setErrorMessage(refused) : "the server did not accept any of its contacts");
return { created, updated, alike }; return { created, updated, alike };
} finally { } finally {
await get().loadAll(); await get().syncCards();
} }
}, },
@@ -786,7 +843,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
applyChanges(types) { applyChanges(types) {
if (types.has("AddressBook")) { void get().loadBooks(); void get().loadShared(); } if (types.has("AddressBook")) { void get().loadBooks(); void get().loadShared(); }
if (types.has("ContactCard") && get().loaded) void get().loadAll(); if (types.has("ContactCard") && get().loaded) void get().syncCards();
}, },
})); }));