Open a locked account whole: calendar, contacts and files too #26

Merged
jcoffey-dev merged 1 commits from feature/delegate-whole-account into main 2026-09-28 01:06:55 +00:00
6 changed files with 57 additions and 14 deletions

No files matched your search

+6 -2
View File
@@ -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") {
@@ -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<string, unknown> | null) =>
({
capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.mail]: {} },
accounts: {
own: { name: "[email protected]", isPersonal: true, accountCapabilities: { [CAP.mail]: {} } },
own: {
name: "[email protected]",
isPersonal: true,
accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {}, [CAP.contacts]: {}, [CAP.filenode]: {} },
},
shared: { name: "[email protected]", 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();
+3 -2
View File
@@ -404,7 +404,8 @@ export const useCalendar = create<CalendarState>((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<CalendarState>((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(
+3 -2
View File
@@ -270,7 +270,8 @@ export const useContacts = create<ContactsState>((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<ContactsState>((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) {
+6 -3
View File
@@ -139,13 +139,16 @@ export const useFiles = create<FilesState>((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 });
},
+17 -3
View File
@@ -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<void> | null = null;
@@ -161,6 +168,13 @@ export const useSession = create<SessionState>((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<SessionState>) => void) {