fix: keep the calendars shown or hidden in the Pro interface as the user set them

This commit is contained in:
Linus Rath
2026-10-26 15:15:58 +01:00
parent 7da326b86e
commit 87701c8c20
4 changed files with 43 additions and 12 deletions
+11 -7
View File
@@ -124,6 +124,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded();
// The Pro interface takes over this route; the Pro tab loads the data then.
const proInterfaceActive = useProInterfaceActive();
// Mountain View's desktop chrome: its top bar in place of the left rail.
const mvShell = useMountainViewShell(isEmbedded);
// When the pane (Pro shell) or window is narrower than `lg`, the sidebar
@@ -364,14 +366,17 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
// Single-account fetch path. The Pro shell aggregates calendars from
// every connected account via [[useProMultiAccountCalendars]] below, so
// skip this fetch there to avoid clobbering the merged list with the
// active client's calendars only.
// active client's calendars only. That includes the standard instance
// that renders while the Pro interface takes over the route: its raw ids
// replaced the merged list after the Pro tab loaded it, and the selection
// the user made in between went to another account's calendar.
useEffect(() => {
if (isEmbedded) return;
if (isEmbedded || proInterfaceActive) return;
if (client && !hasFetched.current) {
hasFetched.current = true;
fetchCalendars(client);
}
}, [client, fetchCalendars, isEmbedded]);
}, [client, fetchCalendars, isEmbedded, proInterfaceActive]);
// Auto-refresh iCal subscriptions
useEffect(() => {
@@ -496,11 +501,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
useEffect(() => {
if (isEmbedded) return;
if (isEmbedded || proInterfaceActive) return;
if (client && calendars.length > 0 && dateRange) {
fetchEvents(client, dateRange.start, dateRange.end);
}
}, [client, calendars.length, dateRange, fetchEvents, isEmbedded]);
}, [client, calendars.length, dateRange, fetchEvents, isEmbedded, proInterfaceActive]);
// Pro shell only: aggregate calendars and events from every connected
// account so the sidebar lists them all (and the views render their
@@ -851,7 +856,6 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
// The permalink for what the calendar is currently showing. In the Pro
// shell only the focused tab writes the address bar; the standard instance
// that renders while Pro takes over a route stays silent.
const proInterfaceActive = useProInterfaceActive();
const isFocusedProTab = useIsFocusedProTab();
const isPaneScoped = useIsPaneScoped();
const eventLinkId = showEventModal && editEvent ? editEvent.id : null;
@@ -933,7 +937,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onRefresh: async () => {
if (!client) return;
const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId
? fetchAllAccountsCalendarsFn(accountClients)
? fetchAllAccountsCalendarsFn(accountClients, activeAccountId)
: fetchCalendars(client);
await Promise.all([
calendarRefresh,
+1 -1
View File
@@ -49,7 +49,7 @@ export function useProMultiAccountCalendars(start: string | null, end: string |
// bootstrapping).
useEffect(() => {
if (!enabled || !activeAccountId || accountClients.length === 0) return;
void fetchAllAccountsCalendars(accountClients);
void fetchAllAccountsCalendars(accountClients, activeAccountId);
}, [enabled, activeAccountId, accountClients, fetchAllAccountsCalendars]);
// Fetch events for the current visible date range across all accounts.
@@ -115,3 +115,21 @@ describe('reconcileSelectedIds with unloaded accounts', () => {
expect(reconcileSelectedIds(['B@h::b'], calendars)).toEqual(['A@h::b']);
});
});
// Stalwart gives every account's default calendar the same id ("b"). The
// raw ids a single-account load selected are the active login's; turning to
// the merged list mapped them onto whichever account sorted first, so the
// active account's calendars went hidden and another's showed.
describe('raw selected ids when the merged list loads', () => {
it("map onto the active login's calendars", async () => {
useCalendarStore.setState({ calendars: [], selectedCalendarIds: ['b', 'ca:b'] });
const list = () => clientReturning({ calendars: [cal({ id: 'b', accountId: 'x' }), cal({ id: 'ca:b', accountId: 'ca', isShared: true })] });
await useCalendarStore.getState().fetchAllAccountsCalendars(
[{ localAccountId: 'A@h', client: list() }, { localAccountId: 'B@h', client: list() }],
'B@h',
);
expect(useCalendarStore.getState().selectedCalendarIds).toEqual(['B@h::b', 'B@h::ca:b']);
});
});
+13 -4
View File
@@ -487,12 +487,15 @@ async function refetchAfterOccurrenceMutation(): Promise<void> {
* calendar is the same. Remap by raw identity so the selection survives instead
* of silently resetting to "all". Keeps BIRTHDAY_CALENDAR_ID and ids starting
* with one of `unloadedPrefixes` (calendars of an account that failed to load
* are unknown, not deleted); drops ids that resolve to no calendar.
* are unknown, not deleted); drops ids that resolve to no calendar. A raw id
* names a calendar of `rawOwner` (the login a single-account load was for)
* when it has one: raw ids repeat across accounts.
*/
export function reconcileSelectedIds(
selectedCalendarIds: string[],
calendars: Calendar[],
unloadedPrefixes: string[] = [],
rawOwner?: string,
): string[] {
const byStoreId = new Set(calendars.map((c) => c.id));
const rawToStoreId = new Map<string, string>();
@@ -507,6 +510,9 @@ export function reconcileSelectedIds(
if (id === BIRTHDAY_CALENDAR_ID || byStoreId.has(id)) mapped = id;
// Before the raw remap: another account may hold the same raw id.
else if (unloadedPrefixes.some((prefix) => id.startsWith(prefix))) mapped = id;
else if (rawOwner && !id.includes(CROSS_ACCOUNT_ID_DELIMITER) && byStoreId.has(buildCrossAccountIdPrefix(rawOwner) + id)) {
mapped = buildCrossAccountIdPrefix(rawOwner) + id;
}
else mapped = rawToStoreId.get(rawIdentityOf(id));
if (mapped && !seen.has(mapped)) {
out.push(mapped);
@@ -527,8 +533,9 @@ function selectionAfterFetch(
calendars: Calendar[],
defaultSelected: string[],
unloadedPrefixes?: string[],
rawOwner?: string,
): string[] {
const reconciled = reconcileSelectedIds(selectedCalendarIds, calendars, unloadedPrefixes);
const reconciled = reconcileSelectedIds(selectedCalendarIds, calendars, unloadedPrefixes, rawOwner);
if (reconciled.some((id) => id !== BIRTHDAY_CALENDAR_ID)) return reconciled;
return reconciled.includes(BIRTHDAY_CALENDAR_ID) ? [...defaultSelected, BIRTHDAY_CALENDAR_ID] : defaultSelected;
}
@@ -785,7 +792,8 @@ interface CalendarStore {
setSupported: (supported: boolean) => void;
fetchCalendars: (client: IJMAPClient) => Promise<void>;
fetchEvents: (client: IJMAPClient, start: string, end: string, options?: FetchEventsOptions) => Promise<void>;
fetchAllAccountsCalendars: (accounts: CalendarAccountClient[]) => Promise<void>;
/** `activeLocalAccountId`: the login whose calendars un-namespaced selected ids name. */
fetchAllAccountsCalendars: (accounts: CalendarAccountClient[], activeLocalAccountId?: string | null) => Promise<void>;
fetchAllAccountsEvents: (accounts: CalendarAccountClient[], start: string, end: string, options?: FetchEventsOptions) => Promise<void>;
createEvent: (client: IJMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
updateEvent: (
@@ -975,7 +983,7 @@ export const useCalendarStore = create<CalendarStore>()(
}
},
fetchAllAccountsCalendars: async (accounts) => {
fetchAllAccountsCalendars: async (accounts, activeLocalAccountId) => {
const epoch = currentStoreEpoch();
set({ isLoading: true, error: null });
try {
@@ -1005,6 +1013,7 @@ export const useCalendarStore = create<CalendarStore>()(
calendars,
calendars.map(c => c.id),
unloadedPrefixes,
activeLocalAccountId ?? undefined,
),
});
} catch (error) {