diff --git a/lib/account-state-manager.ts b/lib/account-state-manager.ts index a2164fbf6..ac1509a51 100644 --- a/lib/account-state-manager.ts +++ b/lib/account-state-manager.ts @@ -71,6 +71,7 @@ export function snapshotAccount(accountId: string): void { events: [...calendarState.events], selectedCalendarIds: [...calendarState.selectedCalendarIds], calendarSelectionChosen: calendarState.calendarSelectionChosen, + knownCalendarIds: calendarState.knownCalendarIds, viewMode: calendarState.viewMode, supportsCalendar: calendarState.supportsCalendar, }, diff --git a/stores/__tests__/calendar-selection-after-fetch.test.ts b/stores/__tests__/calendar-selection-after-fetch.test.ts index 114af1b3c..f0ca1c8a7 100644 --- a/stores/__tests__/calendar-selection-after-fetch.test.ts +++ b/stores/__tests__/calendar-selection-after-fetch.test.ts @@ -164,7 +164,7 @@ describe('reconcileSelectedIds with unloaded accounts', () => { // 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'] }); + useCalendarStore.setState({ calendars: [], selectedCalendarIds: ['b', 'ca:b'], knownCalendarIds: null }); const list = () => clientReturning({ calendars: [cal({ id: 'b', accountId: 'x' }), cal({ id: 'ca:b', accountId: 'ca', isShared: true })] }); await useCalendarStore.getState().fetchAllAccountsCalendars( @@ -175,3 +175,66 @@ describe('raw selected ids when the merged list loads', () => { expect(useCalendarStore.getState().selectedCalendarIds).toEqual(['B@h::b', 'B@h::ca:b']); }); }); + +// A calendar shared with the user, or made in another client, after the list +// loaded once started hidden: the kept selection never named it (#1372). +describe('a calendar that appears after the first load', () => { + const shared = cal({ id: 'team:x', accountId: 'team', isShared: true }); + + beforeEach(() => { + useCalendarStore.setState({ calendars: [], selectedCalendarIds: [], calendarSelectionChosen: false, knownCalendarIds: null, error: null }); + }); + + it('starts shown, while one the user hid stays hidden', async () => { + await useCalendarStore.getState().fetchCalendars(clientReturning({ calendars: own })); + useCalendarStore.getState().toggleCalendarVisibility('b'); + + await useCalendarStore.getState().fetchCalendars(clientReturning({ calendars: [...own, shared] })); + + expect(useCalendarStore.getState().selectedCalendarIds).toEqual(['a', 'team:x']); + }); + + it('starts shown after the user hid every calendar', async () => { + await useCalendarStore.getState().fetchCalendars(clientReturning({ calendars: own })); + useCalendarStore.getState().toggleCalendarVisibility('a'); + useCalendarStore.getState().toggleCalendarVisibility('b'); + + await useCalendarStore.getState().fetchCalendars(clientReturning({ calendars: [...own, shared] })); + + expect(useCalendarStore.getState().selectedCalendarIds).toEqual(['team:x']); + }); + + it('stays hidden when the server says it is not visible, or it holds only tasks', async () => { + await useCalendarStore.getState().fetchCalendars(clientReturning({ calendars: own })); + + await useCalendarStore.getState().fetchCalendars(clientReturning({ calendars: [ + ...own, + cal({ id: 'team:x', accountId: 'team', isShared: true, isVisible: false }), + cal({ id: 'todo', accountId: 'me', isTasksOnly: true }), + ] })); + + expect(useCalendarStore.getState().selectedCalendarIds).toEqual(['a', 'b']); + }); + + it('leaves a selection from before the calendars were recorded as it is', async () => { + // Saved by a version that did not record the calendars it had seen: every + // calendar not in the selection may be one the user hid. + useCalendarStore.setState({ selectedCalendarIds: ['a'], knownCalendarIds: null }); + + await useCalendarStore.getState().fetchCalendars(clientReturning({ calendars: [...own, shared] })); + expect(useCalendarStore.getState().selectedCalendarIds).toEqual(['a']); + + await useCalendarStore.getState().fetchCalendars(clientReturning({ calendars: [...own, shared, cal({ id: 'c', accountId: 'me' })] })); + expect(useCalendarStore.getState().selectedCalendarIds).toEqual(['a', 'c']); + }); + + it('starts shown in the merged list of several logins', async () => { + const list = (calendars: Calendar[]) => [{ localAccountId: 'A@h', client: clientReturning({ calendars }) }]; + await useCalendarStore.getState().fetchAllAccountsCalendars(list(own), 'A@h'); + useCalendarStore.getState().toggleCalendarVisibility('A@h::b'); + + await useCalendarStore.getState().fetchAllAccountsCalendars(list([...own, shared]), 'A@h'); + + expect(useCalendarStore.getState().selectedCalendarIds).toEqual(['A@h::a', 'A@h::team:x']); + }); +}); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index d502b4947..bb63755d1 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -544,6 +544,35 @@ function selectionAfterFetch( return reconciled.includes(BIRTHDAY_CALENDAR_ID) ? [...defaultSelected, BIRTHDAY_CALENDAR_ID] : defaultSelected; } +/** + * Add the calendars that appeared since the last fetch, such as one just + * shared with the user or made in another client (#1372). They start shown + * when the default selection would hold them and the server doesn't say + * isVisible: false; a calendar the user hid is known and stays hidden. + * Nothing recorded yet (`known` null) counts every calendar as known. + * Returns the selection and the calendars known from now on. + */ +function withNewCalendars( + selection: string[], + known: string[] | null, + calendars: Calendar[], + defaultSelected: string[], + unloadedPrefixes?: string[], + rawOwner?: string, +): { selectedCalendarIds: string[]; knownCalendarIds: string[] } { + const ids = calendars.map((c) => c.id); + if (!known) return { selectedCalendarIds: selection, knownCalendarIds: ids }; + const seen = new Set(reconcileSelectedIds(known, calendars, unloadedPrefixes, rawOwner)); + const eligible = new Set(defaultSelected); + const added = calendars + .filter((c) => !seen.has(c.id) && eligible.has(c.id) && c.isVisible !== false && !selection.includes(c.id)) + .map((c) => c.id); + return { + selectedCalendarIds: added.length > 0 ? [...selection, ...added] : selection, + knownCalendarIds: [...new Set([...seen, ...ids])].filter((id) => id !== BIRTHDAY_CALENDAR_ID), + }; +} + // In-flight refresh dedup. Concurrent callers (auto-interval + // manual refresh, two account-switch reloads, etc.) share the same // promise instead of double-fetching and racing the diff/import phase. @@ -776,6 +805,8 @@ interface CalendarStore { selectedCalendarIds: string[]; /** The user picked the visible calendars by hand: an empty pick hides them all. */ calendarSelectionChosen: boolean; + /** Calendars the last fetch returned: one not in it is new and starts shown (#1372). Null before the first record. */ + knownCalendarIds: string[] | null; selectedEventId: string | null; isLoading: boolean; isLoadingEvents: boolean; @@ -864,6 +895,7 @@ const initialState = { selectedDate: displayNow(), selectedCalendarIds: [] as string[], calendarSelectionChosen: false, + knownCalendarIds: null as string[] | null, selectedEventId: null as string | null, isLoading: false, isLoadingEvents: false, @@ -926,17 +958,24 @@ export const useCalendarStore = create()( // Default the visible selection to event calendars only - tasks-only // calendars stay out of the event grid. const defaultSelected = calendars.filter(c => !c.isTasksOnly).map(c => c.id); + // Shared calendars' ids are `:` (getAllCalendars). + const unloadedPrefixes = failedAccountIds.map((accountId) => `${accountId}:`); set({ calendars, isLoading: false, - selectedCalendarIds: selectionAfterFetch( - get().selectedCalendarIds, + ...withNewCalendars( + selectionAfterFetch( + get().selectedCalendarIds, + calendars, + defaultSelected, + unloadedPrefixes, + undefined, + get().calendarSelectionChosen, + ), + get().knownCalendarIds, calendars, defaultSelected, - // Shared calendars' ids are `:` (getAllCalendars). - failedAccountIds.map((accountId) => `${accountId}:`), - undefined, - get().calendarSelectionChosen, + unloadedPrefixes, ), }); } catch (error) { @@ -1014,16 +1053,24 @@ export const useCalendarStore = create()( ); if (currentStoreEpoch() !== epoch) return; const calendars = sortCalendars(results.flat()); + const defaultSelected = calendars.map(c => c.id); set({ calendars, isLoading: false, - selectedCalendarIds: selectionAfterFetch( - get().selectedCalendarIds, + ...withNewCalendars( + selectionAfterFetch( + get().selectedCalendarIds, + calendars, + defaultSelected, + unloadedPrefixes, + activeLocalAccountId ?? undefined, + get().calendarSelectionChosen, + ), + get().knownCalendarIds, calendars, - calendars.map(c => c.id), + defaultSelected, unloadedPrefixes, activeLocalAccountId ?? undefined, - get().calendarSelectionChosen, ), }); } catch (error) { @@ -1665,6 +1712,10 @@ export const useCalendarStore = create()( : state.selectedCalendarIds.filter(id => id !== calendarId), // Showing birthdays is a setting, not a pick of calendars. calendarSelectionChosen: state.calendarSelectionChosen || calendarId !== BIRTHDAY_CALENDAR_ID, + // A calendar hidden before the next fetch recorded it stays hidden. + knownCalendarIds: state.knownCalendarIds && !state.knownCalendarIds.includes(calendarId) && calendarId !== BIRTHDAY_CALENDAR_ID + ? [...state.knownCalendarIds, calendarId] + : state.knownCalendarIds, })); void calendarHooks.onCalendarVisibilityToggle.emit({ calendarId, visible }); }, @@ -2073,6 +2124,7 @@ export const useCalendarStore = create()( partialize: (state) => ({ selectedCalendarIds: state.selectedCalendarIds, calendarSelectionChosen: state.calendarSelectionChosen, + knownCalendarIds: state.knownCalendarIds, viewMode: state.viewMode, icalSubscriptions: state.icalSubscriptions, deletedSubscriptionIds: state.deletedSubscriptionIds,