Open a locked account whole: calendar, contacts and files too
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 1m7s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 35s

Switching to a locked account handed to the reader moved only the mail.
Now calendar, contacts and files follow it as well, through a new
viewAccountFor that the three stores use for what they show. Settings,
signatures and push keep ownAccountFor, so nothing of the reader's is
ever written into the locked account (inbuxa AL-7).
This commit is contained in:
jcoffey-dev committed 2026-09-27 18:04:38 -07:00
1 parent 22d891b1ed
commit 173680cc41
6 files changed
+57 -14

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) {