diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index b824e25a8..ede2678b5 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -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, diff --git a/hooks/use-pro-multi-account-calendars.ts b/hooks/use-pro-multi-account-calendars.ts index 2acac5ff7..7db808cce 100644 --- a/hooks/use-pro-multi-account-calendars.ts +++ b/hooks/use-pro-multi-account-calendars.ts @@ -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. diff --git a/stores/__tests__/calendar-selection-after-fetch.test.ts b/stores/__tests__/calendar-selection-after-fetch.test.ts index a0afa4654..4783f78cf 100644 --- a/stores/__tests__/calendar-selection-after-fetch.test.ts +++ b/stores/__tests__/calendar-selection-after-fetch.test.ts @@ -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']); + }); +}); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 5b5acb9f4..55af17e00 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -487,12 +487,15 @@ async function refetchAfterOccurrenceMutation(): Promise { * 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(); @@ -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; fetchEvents: (client: IJMAPClient, start: string, end: string, options?: FetchEventsOptions) => Promise; - fetchAllAccountsCalendars: (accounts: CalendarAccountClient[]) => Promise; + /** `activeLocalAccountId`: the login whose calendars un-namespaced selected ids name. */ + fetchAllAccountsCalendars: (accounts: CalendarAccountClient[], activeLocalAccountId?: string | null) => Promise; fetchAllAccountsEvents: (accounts: CalendarAccountClient[], start: string, end: string, options?: FetchEventsOptions) => Promise; createEvent: (client: IJMAPClient, event: Partial, sendSchedulingMessages?: boolean) => Promise; updateEvent: ( @@ -975,7 +983,7 @@ export const useCalendarStore = create()( } }, - fetchAllAccountsCalendars: async (accounts) => { + fetchAllAccountsCalendars: async (accounts, activeLocalAccountId) => { const epoch = currentStoreEpoch(); set({ isLoading: true, error: null }); try { @@ -1005,6 +1013,7 @@ export const useCalendarStore = create()( calendars, calendars.map(c => c.id), unloadedPrefixes, + activeLocalAccountId ?? undefined, ), }); } catch (error) {