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:
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user