diff --git a/web/src/App.tsx b/web/src/App.tsx index ca088b7..d1f5765 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -258,8 +258,8 @@ function AuthedApp() { }, [accountId]); /* - * inbuxa AL-7: a delegated account's mail, when it comes into view, and the - * reader's own when it goes back. Push carries nothing for an account only + * inbuxa AL-7: a delegated account, the whole of it, when it comes into + * view, and the reader's own when it goes back. Push carries nothing for an account only * shared with the reader, so while one is open it is polled. */ const viewedOnce = useRef(false); @@ -271,6 +271,10 @@ function AuthedApp() { const mail = useMail.getState(); void mail.loadMailboxes(); void mail.loadIdentities(); + // The whole account follows: calendar, contacts and files too + void useCalendar.getState().init(); + void useContacts.getState().init(); + void useFiles.getState().init(); if (!viewing) return; const poll = window.setInterval(() => { if (document.visibilityState === "visible") { diff --git a/web/src/store/__tests__/delegated-accounts.test.ts b/web/src/store/__tests__/delegated-accounts.test.ts index a7dcbce..cb2d97e 100644 --- a/web/src/store/__tests__/delegated-accounts.test.ts +++ b/web/src/store/__tests__/delegated-accounts.test.ts @@ -18,6 +18,9 @@ const delegated = (access: string, sendAs = false) => ({ isReadOnly: access === "read", accountCapabilities: { [CAP.mail]: {}, + [CAP.calendars]: {}, + [CAP.contacts]: {}, + [CAP.filenode]: {}, "urn:inbuxa:jmap": { delegation: { locked: true, access, sendAs, until: null } }, }, }); @@ -26,11 +29,15 @@ const sessionWith = (locked: Record | null) => ({ capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.mail]: {} }, accounts: { - own: { name: "me@example.com", isPersonal: true, accountCapabilities: { [CAP.mail]: {} } }, + own: { + name: "me@example.com", + isPersonal: true, + accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {}, [CAP.contacts]: {}, [CAP.filenode]: {} }, + }, shared: { name: "team@example.com", isPersonal: false, accountCapabilities: { [CAP.mail]: {} } }, ...(locked ? { locked } : {}), }, - primaryAccounts: { [CAP.mail]: "own" }, + primaryAccounts: { [CAP.mail]: "own", [CAP.calendars]: "own", [CAP.contacts]: "own", [CAP.filenode]: "own" }, state: "s", }) as unknown as JmapSession; @@ -94,6 +101,19 @@ describe("viewing a locked account", () => { expect(useMail.getState().accountId).toBe("own"); }); + it("brings the whole account: calendar, contacts and files, never the reader's settings", () => { + const s = () => useSession.getState(); + expect(s().viewAccountFor(CAP.calendars)).toBe("own"); + s().view("locked"); + for (const cap of [CAP.calendars, CAP.contacts, CAP.filenode]) { + expect(s().viewAccountFor(cap)).toBe("locked"); + } + // Settings live in the reader's own Files, whatever is in view + expect(s().ownAccountFor(CAP.filenode)).toBe("own"); + s().view(null); + expect(s().viewAccountFor(CAP.contacts)).toBe("own"); + }); + it("refuses an account that isn't delegated", () => { useSession.getState().view("shared"); expect(useSession.getState().viewing).toBeNull(); diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index af9df35..2ea0e93 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -404,7 +404,8 @@ export const useCalendar = create((set, get) => ({ async init() { // The reader's own: a shared calendar is shown beside theirs, not instead. - const accountId = useSession.getState().ownAccountFor(CAP.calendars); + // inbuxa AL-7: or the locked account in view, the whole of it + const accountId = useSession.getState().viewAccountFor(CAP.calendars); const available = Boolean(accountId && client.hasCapability(CAP.calendars)); if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} }); set({ available }); @@ -431,7 +432,7 @@ export const useCalendar = create((set, get) => ({ */ async loadSharedCalendars() { const session = useSession.getState(); - const own = session.ownAccountFor(CAP.calendars); + const own = session.viewAccountFor(CAP.calendars); const accounts = Object.entries(session.session?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own); // Every account at once, in one request, and listed in the session's order. const answers = await Promise.all( diff --git a/web/src/store/contacts.ts b/web/src/store/contacts.ts index ca4a9da..ac3d397 100644 --- a/web/src/store/contacts.ts +++ b/web/src/store/contacts.ts @@ -270,7 +270,8 @@ export const useContacts = create((set, get) => ({ // The reader's own, not whichever account is selected: a shared address // book is shown beside theirs rather than instead of it, so nothing here // should move when the switcher does. - const accountId = useSession.getState().ownAccountFor(CAP.contacts); + // inbuxa AL-7: or the locked account in view, the whole of it + const accountId = useSession.getState().viewAccountFor(CAP.contacts); const available = Boolean(accountId && client.hasCapability(CAP.contacts)); if (accountId !== get().accountId) set({ accountId, books: {}, cards: {}, cardState: null, loaded: false, selection: { accountId: null, bookId: "all" } }); set({ available }); @@ -300,7 +301,7 @@ export const useContacts = create((set, get) => ({ */ async loadShared() { const session = useSession.getState(); - const own = session.ownAccountFor(CAP.contacts); + const own = session.viewAccountFor(CAP.contacts); const s = session.session; const accounts = Object.entries(s?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own); if (!accounts.length) { diff --git a/web/src/store/files.ts b/web/src/store/files.ts index 1bd9256..a586fdb 100644 --- a/web/src/store/files.ts +++ b/web/src/store/files.ts @@ -139,13 +139,16 @@ export const useFiles = create((set, get) => ({ async init() { const session = useSession.getState(); - const ownAccountId = session.ownAccountFor(CAP.filenode); + // inbuxa AL-7: home is the locked account in view, the whole of it + const ownAccountId = session.viewAccountFor(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. + // whether it still holds files is `discoverShared`'s to say. A new home + // (a locked account opened or left) starts there. 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)); + const moved = ownAccountId !== get().ownAccountId; + if (moved || !(browsing && (browsing === ownAccountId || offered))) set(emptyForAccount(ownAccountId)); set({ available, ownAccountId }); }, diff --git a/web/src/store/session.ts b/web/src/store/session.ts index 4372063..a5a5a21 100644 --- a/web/src/store/session.ts +++ b/web/src/store/session.ts @@ -19,9 +19,9 @@ interface SessionState { /** Selected mail account (defaults to primary). */ accountId: Id | null; /** - * A locked account handed to the reader that the mail view shows instead - * of their own (inbuxa AL-7). Only mail follows it: settings, filters, - * push and everything else stay the reader's own. + * A locked account handed to the reader that the app shows instead of + * their own (inbuxa AL-7): its mail, calendar, contacts and files. The + * reader's settings, filters, signatures and push stay their own. */ viewing: Id | null; /** The name of an account whose delegation ended while it was in view. */ @@ -42,6 +42,13 @@ interface SessionState { accountFor(cap: string): Id | null; /** The user's own account for a capability, whatever they are looking at. */ ownAccountFor(cap: string): Id | null; + /** + * inbuxa AL-7: the account calendar, contacts and files show: the locked + * account in view, if it offers `cap`, else the reader's own. Never for + * anything the reader keeps (settings, signatures, push): those stay + * `ownAccountFor`. + */ + viewAccountFor(cap: string): Id | null; } let refreshing: Promise | null = null; @@ -161,6 +168,13 @@ export const useSession = create((set, get) => ({ ownAccountFor(cap) { return ownAccountForCapability(get().session, cap); }, + + viewAccountFor(cap) { + const { session, viewing } = get(); + const viewed = viewing ? session?.accounts[viewing] : undefined; + if (viewing && viewed && cap in (viewed.accountCapabilities ?? {})) return viewing; + return ownAccountForCapability(session, cap); + }, })); function applySession(s: JmapSession, set: (p: Partial) => void) {