Sync contacts by what changed, and hold fewer calendar windows

A pushed contact change, and every edit or import made here, reloaded the
whole address book. The store now keeps the state its cards were read at
and asks ContactCard/changes what changed since, fetching only those cards,
split to maxObjectsInGet. A server that cannot say falls back to the full
load.

The calendar held every week or month the reader had visited, queried each
of them again on any event change, and walked them all on every render. It
now holds the four most recently shown; a change reloads those in place,
without emptying the view first, and a window dropped is loaded again when
it is next shown. Shared calendars' events are fetched from every account
at once, and instancesIn builds the added-shares set once.

The mock keeps a ContactCard change log, answers ContactCard/changes, and
announces a ContactCard/set, as Stalwart does.
This commit is contained in:
2026-09-16 10:41:21 -07:00
parent 6090442058
commit 360420402d
5 changed files with 295 additions and 22 deletions
+66 -9
View File
@@ -1,7 +1,7 @@
import { create } from "zustand";
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
import { CAP, chunk, client, setErrorMessage } from "@/jmap/client";
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types";
import { CAP, chunk, client, JmapMethodError, setErrorMessage } from "@/jmap/client";
import type { AddressBook, ChangesResponse, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types";
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
import { parseLdif, uidFromDn } from "@/lib/ldif";
import { cardFromLdif } from "@/lib/mozillaAb";
@@ -173,6 +173,8 @@ interface ContactsState {
available: boolean;
books: Record<Id, AddressBook>;
cards: Record<Id, ContactCard>;
/** The server's ContactCard state `cards` was read at, for asking what changed since. */
cardState: string | null;
loaded: boolean;
loading: boolean;
error: string | null;
@@ -189,6 +191,11 @@ interface ContactsState {
init(): Promise<void>;
loadBooks(): 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. */
loadShared(): Promise<void>;
select(selection: BookSelection): void;
@@ -247,6 +254,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
available: false,
books: {},
cards: {},
cardState: null,
loaded: false,
loading: false,
error: null,
@@ -264,7 +272,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
// 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" } });
if (accountId !== get().accountId) set({ accountId, books: {}, cards: {}, cardState: null, loaded: false, selection: { accountId: null, bookId: "all" } });
set({ available });
if (!available) return;
await get().loadBooks();
@@ -408,6 +416,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const cards: Record<Id, ContactCard> = {};
let position = 0;
const limit = 500;
let cardState: string | null = null;
for (let guard = 0; guard < 50; guard++) {
const res = await client.chain([
["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 g = res.get("g")?.[0] as unknown as GetResponse<ContactCard>;
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;
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) {
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) {
const accountId = get().accountId;
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];
}
} finally {
await get().loadAll();
await get().syncCards();
}
return { destroyed: gone.length, unfiled, refused };
},
@@ -574,7 +631,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const err = res.notDestroyed?.[id];
if (err) throw new Error(setErrorMessage(err));
await get().loadBooks();
await get().loadAll();
await get().syncCards();
},
async importVCard(text, addressBookId) {
@@ -623,7 +680,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
matched on it rather than guessed at. */
return { created, updated, alike: 0 };
} 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");
return { created, updated, alike };
} finally {
await get().loadAll();
await get().syncCards();
}
},
@@ -786,7 +843,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
applyChanges(types) {
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();
},
}));