Ask shared accounts together, and about files only when Files opens

At sign-in the files, contacts and calendar stores each asked every shared
account a question, one account after another: a request apiece before the
reader had opened any of those views.

Files now only works out at sign-in whether it is available. Which shared
accounts hold files is asked when the Files view or the file picker opens,
which the Files view already did on every visit. Shared address books and
calendars are asked for in one request, and the calendar store loads its
calendars, identities and shared calendars side by side.
This commit is contained in:
2026-09-16 10:04:24 -07:00
parent 9560ad06f4
commit 4c7b2ec370
6 changed files with 142 additions and 31 deletions
@@ -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
View File
@@ -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)) {
+8 -4
View File
@@ -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
View File
@@ -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) {
+5
View File
@@ -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
+1
View File
@@ -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();
} }