Merge pull request #383 from Coffey-Labs/perf/lazy-store-init
Ask shared accounts together, and about files only when Files opens
This commit is contained in:
@@ -0,0 +1,81 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { CAP, client } from "@/jmap/client";
|
||||||
|
import type { JmapSession } from "@/jmap/types";
|
||||||
|
import { useSession } from "@/store/session";
|
||||||
|
import { useFiles } from "@/store/files";
|
||||||
|
import { useContacts } from "@/store/contacts";
|
||||||
|
import { useCalendar } from "@/store/calendar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What signing in costs for each account somebody has shared with the reader.
|
||||||
|
*
|
||||||
|
* The files, contacts and calendar stores each asked every shared account a
|
||||||
|
* question at sign-in, one account after another -- a request apiece, before
|
||||||
|
* the reader had opened any of those views. The questions now go out together,
|
||||||
|
* and Files does not ask at all until it is opened.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SHARED = ["s1", "s2", "s3"];
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.filenode]: {}, [CAP.contacts]: {}, [CAP.calendars]: {} },
|
||||||
|
accounts: {
|
||||||
|
own: { name: "[email protected]", isPersonal: true, accountCapabilities: { [CAP.filenode]: {}, [CAP.contacts]: {}, [CAP.calendars]: {} } },
|
||||||
|
...Object.fromEntries(SHARED.map((id) => [id, { name: `${id}@example.com`, isPersonal: false, accountCapabilities: {} }])),
|
||||||
|
},
|
||||||
|
primaryAccounts: { [CAP.filenode]: "own", [CAP.contacts]: "own", [CAP.calendars]: "own" },
|
||||||
|
state: "s",
|
||||||
|
} as unknown as JmapSession;
|
||||||
|
|
||||||
|
type Call = [string, Record<string, unknown>, string];
|
||||||
|
|
||||||
|
let requests: Call[][];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
requests = [];
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||||
|
const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: Call[] };
|
||||||
|
requests.push(methodCalls);
|
||||||
|
const methodResponses = methodCalls.map(([name, args, id]) => {
|
||||||
|
const accountId = args.accountId as string;
|
||||||
|
if (name === "FileNode/query") return [name, { accountId, ids: accountId === "s2" ? ["f1"] : [], total: 0, position: 0, queryState: "q" }, id];
|
||||||
|
if (name === "Calendar/get") return [name, { accountId, state: "1", list: [{ id: `cal-${accountId}`, name: `Calendar of ${accountId}` }], notFound: [] }, id];
|
||||||
|
return [name, { accountId, state: "1", list: [], notFound: [] }, id];
|
||||||
|
});
|
||||||
|
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
|
||||||
|
}));
|
||||||
|
client.session = session;
|
||||||
|
useSession.setState({ status: "authenticated", session, accountId: "own" });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shared accounts", () => {
|
||||||
|
it("are not asked about files at sign-in", async () => {
|
||||||
|
await useFiles.getState().init();
|
||||||
|
expect(requests).toHaveLength(0);
|
||||||
|
expect(useFiles.getState().available).toBe(true);
|
||||||
|
expect(useFiles.getState().ownAccountId).toBe("own");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("are asked about files together when Files wants to know", async () => {
|
||||||
|
await useFiles.getState().discoverShared();
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0]!.map(([n, a]) => `${n} ${a.accountId}`)).toEqual(SHARED.map((id) => `FileNode/query ${id}`));
|
||||||
|
expect(useFiles.getState().sharedAccounts).toEqual([{ id: "s2", name: "[email protected]" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("are asked about address books in one request", async () => {
|
||||||
|
await useContacts.getState().loadShared();
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0]!.filter(([n]) => n === "AddressBook/get")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("are asked about calendars in one request, and listed in the session's order", async () => {
|
||||||
|
await useCalendar.getState().loadSharedCalendars();
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(useCalendar.getState().sharedCalendars.map((c) => c.accountId)).toEqual(SHARED);
|
||||||
|
});
|
||||||
|
});
|
||||||
+16
-16
@@ -409,14 +409,13 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} });
|
if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} });
|
||||||
set({ available });
|
set({ available });
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
await get().loadCalendars();
|
// Side by side: none of the three waits on another, and together they share a request.
|
||||||
|
const identities = client.call<GetResponse<ParticipantIdentity>>("ParticipantIdentity/get", { accountId, ids: null }).then(
|
||||||
|
(res) => set({ identities: res.list }),
|
||||||
|
() => set({ identities: [] }),
|
||||||
|
);
|
||||||
void get().loadSharedCalendars();
|
void get().loadSharedCalendars();
|
||||||
try {
|
await Promise.all([get().loadCalendars(), identities]);
|
||||||
const res = await client.call<GetResponse<ParticipantIdentity>>("ParticipantIdentity/get", { accountId, ids: null });
|
|
||||||
set({ identities: res.list });
|
|
||||||
} catch {
|
|
||||||
set({ identities: [] });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -434,15 +433,16 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
const session = useSession.getState();
|
const session = useSession.getState();
|
||||||
const own = session.ownAccountFor(CAP.calendars);
|
const own = session.ownAccountFor(CAP.calendars);
|
||||||
const accounts = Object.entries(session.session?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
|
const accounts = Object.entries(session.session?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
|
||||||
const found: SharedCalendar[] = [];
|
// Every account at once, in one request, and listed in the session's order.
|
||||||
for (const [accountId, account] of accounts) {
|
const answers = await Promise.all(
|
||||||
try {
|
accounts.map(([accountId, account]) =>
|
||||||
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null, properties: CALENDAR_PROPS });
|
client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null, properties: CALENDAR_PROPS }).then(
|
||||||
for (const calendar of res.list) found.push({ accountId, accountName: account.name, calendar });
|
(res) => res.list.map((calendar): SharedCalendar => ({ accountId, accountName: account.name, calendar })),
|
||||||
} catch {
|
(): SharedCalendar[] => [],
|
||||||
continue;
|
),
|
||||||
}
|
),
|
||||||
}
|
);
|
||||||
|
const found = answers.flat();
|
||||||
set({ sharedCalendars: found });
|
set({ sharedCalendars: found });
|
||||||
// Fill in whatever windows are already on screen.
|
// Fill in whatever windows are already on screen.
|
||||||
for (const key of Object.keys(get().ranges)) {
|
for (const key of Object.keys(get().ranges)) {
|
||||||
|
|||||||
@@ -294,7 +294,9 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
const books: SharedBook[] = [];
|
const books: SharedBook[] = [];
|
||||||
const cards: Record<string, ContactCard> = {};
|
const cards: Record<string, ContactCard> = {};
|
||||||
for (const [accountId, account] of accounts) {
|
// Every account at once: calls made in one tick share a request, where a
|
||||||
|
// loop sent one after another for each account shared with the reader.
|
||||||
|
await Promise.all(accounts.map(async ([accountId, account]) => {
|
||||||
try {
|
try {
|
||||||
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null, properties: ADDRESS_BOOK_PROPS });
|
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null, properties: ADDRESS_BOOK_PROPS });
|
||||||
for (const book of res.list) books.push({ accountId, accountName: account.name, book });
|
for (const book of res.list) books.push({ accountId, accountName: account.name, book });
|
||||||
@@ -310,7 +312,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
*/
|
*/
|
||||||
const added = new Set(useSettings.getState().settings.addedShares);
|
const added = new Set(useSettings.getState().settings.addedShares);
|
||||||
const wanted = new Set(res.list.filter((b) => b.isSubscribed || added.has(sharedKey(accountId, b.id))).map((b) => b.id));
|
const wanted = new Set(res.list.filter((b) => b.isSubscribed || added.has(sharedKey(accountId, b.id))).map((b) => b.id));
|
||||||
if (!wanted.size) continue;
|
if (!wanted.size) return;
|
||||||
// One page. A shared book is a colleague's contacts, not an archive,
|
// 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.
|
// and the alternative is holding the reader's own list hostage to it.
|
||||||
const cardsRes = await client.chain([
|
const cardsRes = await client.chain([
|
||||||
@@ -325,9 +327,11 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
} catch {
|
} catch {
|
||||||
// An account that refuses is one that shared nothing here. Not an
|
// 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.
|
// error to show: the reader did not ask for it and cannot act on it.
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}));
|
||||||
|
// Answers arrive in any order; list the books in the session's.
|
||||||
|
const order = new Map(accounts.map(([id], i) => [id, i]));
|
||||||
|
books.sort((a, b) => (order.get(a.accountId) ?? 0) - (order.get(b.accountId) ?? 0));
|
||||||
set({ sharedBooks: books, sharedCards: cards, sharedLoaded: true });
|
set({ sharedBooks: books, sharedCards: cards, sharedLoaded: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+31
-11
@@ -50,7 +50,10 @@ interface FilesState {
|
|||||||
*/
|
*/
|
||||||
draggingIds: Id[];
|
draggingIds: Id[];
|
||||||
|
|
||||||
|
/** Whether Files is available and which account is the reader's. No round trip. */
|
||||||
init(): Promise<void>;
|
init(): Promise<void>;
|
||||||
|
/** Ask each shared account whether it holds files; see the note on it. */
|
||||||
|
discoverShared(): Promise<void>;
|
||||||
/** Browse an account: the reader's own, or one shared with them. */
|
/** Browse an account: the reader's own, or one shared with them. */
|
||||||
openAccount(accountId: Id | null): void;
|
openAccount(accountId: Id | null): void;
|
||||||
loadChildren(parentId: Id | null): Promise<void>;
|
loadChildren(parentId: Id | null): Promise<void>;
|
||||||
@@ -138,6 +141,17 @@ export const useFiles = create<FilesState>((set, get) => ({
|
|||||||
const session = useSession.getState();
|
const session = useSession.getState();
|
||||||
const ownAccountId = session.ownAccountFor(CAP.filenode);
|
const ownAccountId = session.ownAccountFor(CAP.filenode);
|
||||||
const available = Boolean(ownAccountId && client.hasCapability(CAP.filenode));
|
const available = Boolean(ownAccountId && client.hasCapability(CAP.filenode));
|
||||||
|
// Stay where the reader is if the session still offers that account;
|
||||||
|
// whether it still holds files is `discoverShared`'s to say.
|
||||||
|
const browsing = get().accountId;
|
||||||
|
const offered = Object.entries(session.session?.accounts ?? {}).some(([id, a]) => id === browsing && a.isPersonal === false);
|
||||||
|
if (!(browsing && (browsing === ownAccountId || offered))) set(emptyForAccount(ownAccountId));
|
||||||
|
set({ available, ownAccountId });
|
||||||
|
},
|
||||||
|
|
||||||
|
async discoverShared() {
|
||||||
|
const session = useSession.getState();
|
||||||
|
const ownAccountId = get().ownAccountId;
|
||||||
/*
|
/*
|
||||||
* Which accounts hold shared files cannot be worked out from capabilities:
|
* Which accounts hold shared files cannot be worked out from capabilities:
|
||||||
* Stalwart advertises the whole set on a shared account -- mail, calendars,
|
* Stalwart advertises the whole set on a shared account -- mail, calendars,
|
||||||
@@ -151,23 +165,29 @@ export const useFiles = create<FilesState>((set, get) => ({
|
|||||||
* account whose calendar or contacts were the thing actually shared. An
|
* account whose calendar or contacts were the thing actually shared. An
|
||||||
* account that shares no files does not belong in a list of shared files.
|
* account that shares no files does not belong in a list of shared files.
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* Not at sign-in: only the Files view and the file picker list shared
|
||||||
|
* accounts, and each opening asks afresh. The questions go out together --
|
||||||
|
* calls made in one tick share a request -- rather than one account after
|
||||||
|
* another.
|
||||||
|
*/
|
||||||
const s = session.session;
|
const s = session.session;
|
||||||
const candidates = Object.entries(s?.accounts ?? {}).filter(([, a]) => a.isPersonal === false);
|
const candidates = Object.entries(s?.accounts ?? {}).filter(([, a]) => a.isPersonal === false);
|
||||||
const sharedAccounts: SharedAccount[] = [];
|
const answers = await Promise.all(
|
||||||
for (const [id, a] of candidates) {
|
candidates.map(([id, a]) =>
|
||||||
try {
|
client.call<QueryResponse>("FileNode/query", { accountId: id, limit: 1 }).then(
|
||||||
const res = await client.call<QueryResponse>("FileNode/query", { accountId: id, limit: 1 });
|
(res): SharedAccount | null => (res.ids.length ? { id, name: a.name } : null),
|
||||||
if (res.ids.length) sharedAccounts.push({ id, name: a.name });
|
// Refused means nothing here is ours to see, which is the same answer.
|
||||||
} catch {
|
() => null,
|
||||||
// Refused means nothing here is ours to see, which is the same answer.
|
),
|
||||||
continue;
|
),
|
||||||
}
|
);
|
||||||
}
|
const sharedAccounts = answers.filter((a): a is SharedAccount => a !== null);
|
||||||
// Stay where the reader is if they are reading a share that still exists.
|
// Stay where the reader is if they are reading a share that still exists.
|
||||||
const browsing = get().accountId;
|
const browsing = get().accountId;
|
||||||
const keep = browsing && (browsing === ownAccountId || sharedAccounts.some((a) => a.id === browsing));
|
const keep = browsing && (browsing === ownAccountId || sharedAccounts.some((a) => a.id === browsing));
|
||||||
if (!keep) set(emptyForAccount(ownAccountId));
|
if (!keep) set(emptyForAccount(ownAccountId));
|
||||||
set({ available, ownAccountId, sharedAccounts });
|
set({ sharedAccounts });
|
||||||
},
|
},
|
||||||
|
|
||||||
openAccount(accountId) {
|
openAccount(accountId) {
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile
|
|||||||
const [picked, setPicked] = useState<Record<string, FileNode>>({});
|
const [picked, setPicked] = useState<Record<string, FileNode>>({});
|
||||||
const [returnTo] = useState(() => files.accountId);
|
const [returnTo] = useState(() => files.accountId);
|
||||||
|
|
||||||
|
// Shared accounts are not looked for at sign-in; the picker lists them, so it asks.
|
||||||
|
useEffect(() => {
|
||||||
|
void useFiles.getState().discoverShared();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void files.loadChildren(cur);
|
void files.loadChildren(cur);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ async function refreshShares(force = false): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await useFiles.getState().init();
|
await useFiles.getState().init();
|
||||||
|
await useFiles.getState().discoverShared();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user