diff --git a/App.tsx b/App.tsx index 7d60e871..b2328eec 100644 --- a/App.tsx +++ b/App.tsx @@ -11,6 +11,7 @@ import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream' import { jmapClient } from './src/api/jmap-client'; import type { StateChange } from './src/api/types'; import { dispatchStateChange } from './src/lib/state-change-bus'; +import { startCalendarNotificationSync } from './src/lib/calendar-notifications'; import { sweepStaleExportFiles } from './src/lib/email-export'; import { useFilterStore } from './src/stores/filter-store'; import { useVacationStore } from './src/stores/vacation-store'; @@ -281,6 +282,9 @@ export default function App() { React.useEffect(() => { void useSettingsStore.getState().hydrate(); void useLocaleStore.getState().hydrate(); + // Calendar reminders are kept scheduled from launch, not only once the + // Calendar tab has been opened. + startCalendarNotificationSync(); return useNetworkStore.getState().init(); }, []); diff --git a/src/lib/__tests__/calendar-notifications.test.ts b/src/lib/__tests__/calendar-notifications.test.ts new file mode 100644 index 00000000..d4dc3760 --- /dev/null +++ b/src/lib/__tests__/calendar-notifications.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +type Listener = (state: Record, prev: Record) => void; + +const h = vi.hoisted(() => ({ + pending: [] as Array<{ identifier: string; content: { data: Record } }>, + permission: { granted: true, canAskAgain: true }, + auth: { hasRestoredSession: true, isAuthenticated: true, session: null as unknown }, + settings: { hydrated: true, calendarNotificationsEnabled: true, enableCalendarTasks: false }, + calendar: { + calendars: [{ id: 'cal-1', name: 'Personal' }], + events: [] as unknown[], + tasks: [] as unknown[], + taskOnlyCalendarIds: [] as string[], + loadedRange: null as unknown, + fetchCalendars: vi.fn(async () => undefined), + fetchTasks: vi.fn(async () => undefined), + }, + client: { isConnected: true, accountId: 'acc-1' }, + loadEventsInRange: vi.fn(), + calendarListener: null as Listener | null, +})); + +vi.mock('react-native', () => ({ + Platform: { OS: 'android' }, + AppState: { addEventListener: vi.fn(() => ({ remove: () => undefined })) }, +})); + +vi.mock('expo-notifications', () => ({ + getPermissionsAsync: vi.fn(async () => ({ ...h.permission })), + requestPermissionsAsync: vi.fn(async () => ({ granted: true })), + setNotificationChannelAsync: vi.fn(async () => undefined), + getAllScheduledNotificationsAsync: vi.fn(async () => h.pending), + cancelScheduledNotificationAsync: vi.fn(async () => undefined), + scheduleNotificationAsync: vi.fn(async () => 'id'), + setNotificationHandler: vi.fn(), + AndroidImportance: { HIGH: 4 }, + IosAuthorizationStatus: { PROVISIONAL: 3 }, + SchedulableTriggerInputTypes: { DATE: 'date' }, +})); + +vi.mock('../../api/jmap-client', () => ({ jmapClient: h.client })); +vi.mock('../../stores/auth-store', () => ({ + useAuthStore: { getState: () => h.auth, subscribe: vi.fn() }, +})); +vi.mock('../../stores/settings-store', () => ({ + useSettingsStore: { getState: () => h.settings, subscribe: vi.fn() }, +})); +vi.mock('../../stores/calendar-store', () => ({ + useCalendarStore: { + getState: () => h.calendar, + subscribe: vi.fn((listener: Listener) => { h.calendarListener = listener; }), + }, + loadEventsInRange: h.loadEventsInRange, +})); +vi.mock('../capabilities', () => ({ hasCalendarCapability: () => true })); + +const NOW = new Date('2026-09-24T08:00:00Z').getTime(); +const TAG = 'bulwark-calendar-alert'; + +const upcoming = { + id: 'ev1', + title: 'Standup', + calendarIds: { 'cal-1': true }, + start: '2026-09-24T12:00:00', + timeZone: 'Europe/Berlin', + utcStart: '2026-09-24T10:00:00Z', + utcEnd: '2026-09-24T10:30:00Z', + duration: 'PT30M', + alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' } }, +}; +const upcomingKey = `ev1:a1:${new Date('2026-09-24T09:45:00Z').getTime()}`; + +function reminder(identifier: string, key: string) { + return { identifier, content: { data: { tag: TAG, key } } }; +} + +let mod: typeof import('../calendar-notifications'); +let N: typeof import('expo-notifications'); +let bus: typeof import('../state-change-bus'); + +beforeEach(async () => { + vi.useFakeTimers({ now: NOW }); + vi.resetModules(); + vi.clearAllMocks(); + h.pending = []; + h.permission = { granted: true, canAskAgain: true }; + h.auth = { hasRestoredSession: true, isAuthenticated: true, session: null }; + h.settings = { hydrated: true, calendarNotificationsEnabled: true, enableCalendarTasks: false }; + h.calendar.events = []; + h.calendar.tasks = []; + h.calendar.loadedRange = null; + h.client.isConnected = true; + h.calendarListener = null; + h.loadEventsInRange.mockResolvedValue([upcoming]); + mod = await import('../calendar-notifications'); + N = await import('expo-notifications'); + bus = await import('../state-change-bus'); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('calendar reminders (B17)', () => { + it('schedules from an upcoming window of its own, whatever month the calendar shows', async () => { + // The calendar shows another month: nothing upcoming is in the store. + h.calendar.events = [{ id: 'old', start: '2026-03-02T09:00:00' }]; + + await mod.rescheduleCalendarNotifications(); + + expect(h.loadEventsInRange).toHaveBeenCalledTimes(1); + const [calendars, ids, after, before] = h.loadEventsInRange.mock.calls[0]; + expect(calendars).toBe(h.calendar.calendars); + expect(ids).toEqual(['cal-1']); + expect(new Date(after).getTime()).toBeLessThanOrEqual(NOW); + expect(new Date(before).getTime()).toBeGreaterThanOrEqual(NOW + 7 * 24 * 3600_000); + expect(N.scheduleNotificationAsync).toHaveBeenCalledTimes(1); + const request = vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0]; + expect(request.content.data).toMatchObject({ tag: TAG, key: upcomingKey }); + }); + + it('cancels only its own reminders that are no longer wanted', async () => { + h.pending = [ + reminder('keep', upcomingKey), + reminder('stale', 'gone:a1:1'), + { identifier: 'other', content: { data: { kind: 'something-else' } } }, + ]; + + await mod.rescheduleCalendarNotifications(); + + expect(N.cancelScheduledNotificationAsync).toHaveBeenCalledTimes(1); + expect(N.cancelScheduledNotificationAsync).toHaveBeenCalledWith('stale'); + // Already pending: not scheduled twice. + expect(N.scheduleNotificationAsync).not.toHaveBeenCalled(); + }); + + it('keeps the scheduled reminders when the upcoming events cannot be loaded', async () => { + h.pending = [reminder('keep', upcomingKey)]; + h.loadEventsInRange.mockRejectedValue(new Error('offline')); + + await mod.rescheduleCalendarNotifications(); + h.client.isConnected = false; + await mod.rescheduleCalendarNotifications(); + + expect(N.cancelScheduledNotificationAsync).not.toHaveBeenCalled(); + expect(h.loadEventsInRange).toHaveBeenCalledTimes(1); + }); + + it('cancels its reminders after sign-out or when switched off', async () => { + h.pending = [ + reminder('r1', upcomingKey), + { identifier: 'other', content: { data: {} } }, + ]; + h.auth = { hasRestoredSession: true, isAuthenticated: false, session: null }; + + await mod.rescheduleCalendarNotifications(); + expect(N.cancelScheduledNotificationAsync).toHaveBeenCalledTimes(1); + expect(N.cancelScheduledNotificationAsync).toHaveBeenCalledWith('r1'); + + vi.mocked(N.cancelScheduledNotificationAsync).mockClear(); + h.auth = { hasRestoredSession: true, isAuthenticated: true, session: null }; + h.settings.calendarNotificationsEnabled = false; + await mod.rescheduleCalendarNotifications(); + expect(N.cancelScheduledNotificationAsync).toHaveBeenCalledWith('r1'); + expect(h.loadEventsInRange).not.toHaveBeenCalled(); + }); + + it('asks for permission only once the calendar allows it', async () => { + h.permission = { granted: false, canAskAgain: true }; + + await mod.rescheduleCalendarNotifications(); + expect(N.requestPermissionsAsync).not.toHaveBeenCalled(); + expect(N.scheduleNotificationAsync).not.toHaveBeenCalled(); + + mod.startCalendarNotificationSync({ askPermission: true }); + await mod.rescheduleCalendarNotifications(); + expect(N.requestPermissionsAsync).toHaveBeenCalledTimes(1); + expect(N.scheduleNotificationAsync).toHaveBeenCalledTimes(1); + }); + + it('shows its reminders in the foreground and leaves other notifications alone', async () => { + mod.startCalendarNotificationSync(); + + expect(N.setNotificationHandler).toHaveBeenCalledTimes(1); + const handler = vi.mocked(N.setNotificationHandler).mock.calls[0][0]!; + const ours = await handler.handleNotification({ + request: { content: { data: { tag: TAG, key: upcomingKey } } }, + } as never); + const other = await handler.handleNotification({ + request: { content: { data: {} } }, + } as never); + expect(ours).toMatchObject({ shouldShowBanner: true, shouldShowList: true, shouldPlaySound: true }); + expect(other).toMatchObject({ shouldShowBanner: false, shouldShowList: false, shouldPlaySound: false }); + }); + + it('runs at launch and after calendar changes on the server, not when the view moves', async () => { + mod.startCalendarNotificationSync(); + await vi.advanceTimersByTimeAsync(1500); + expect(h.loadEventsInRange).toHaveBeenCalledTimes(1); + + bus.dispatchStateChange({ '@type': 'StateChange', changed: { 'acc-1': { CalendarEvent: 's2' } } }); + await vi.advanceTimersByTimeAsync(1500); + expect(h.loadEventsInRange).toHaveBeenCalledTimes(2); + + // Navigating the calendar loads another window: no reminder changes. + const prev = { events: [], tasks: h.calendar.tasks, loadedRange: { after: 'a', before: 'b' } }; + h.calendarListener!({ ...prev, events: [{}], loadedRange: { after: 'c', before: 'd' } }, prev); + await vi.advanceTimersByTimeAsync(1500); + expect(h.loadEventsInRange).toHaveBeenCalledTimes(2); + + // An edit in the same window does. + h.calendarListener!({ ...prev, events: [{}], loadedRange: { after: 'a', before: 'b' } }, prev); + await vi.advanceTimersByTimeAsync(1500); + expect(h.loadEventsInRange).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/lib/calendar-notifications.ts b/src/lib/calendar-notifications.ts index 856833e4..6c7bf290 100644 --- a/src/lib/calendar-notifications.ts +++ b/src/lib/calendar-notifications.ts @@ -1,43 +1,64 @@ -import { Platform } from 'react-native'; +import { AppState, Platform } from 'react-native'; import * as Notifications from 'expo-notifications'; -import { useCalendarStore } from '../stores/calendar-store'; +import type { CalendarEvent } from '../api/types'; +import { jmapClient } from '../api/jmap-client'; +import { useAuthStore } from '../stores/auth-store'; +import { loadEventsInRange, useCalendarStore } from '../stores/calendar-store'; import { useSettingsStore } from '../stores/settings-store'; +import { hasCalendarCapability } from './capabilities'; +import { onStateChangeType } from './state-change-bus'; import { getUpcomingAlerts, type ScheduledAlert } from './calendar-alert-scheduler'; // Local reminders for calendar events and tasks. The webmail polls // `getPendingAlerts` every minute while a tab is open; a phone is mostly -// asleep, so instead every (re)load of the calendar schedules OS-level local -// notifications for the alerts that fall due in the next few days and -// cancels the ones that no longer apply. Honours the +// asleep, so instead we schedule OS-level local notifications for the alerts +// that fall due in the next few days and cancel the ones that no longer +// apply. The events come from their own upcoming window, not from the month +// the calendar happens to show, and the sync runs from launch, so reminders +// don't depend on the Calendar tab being open. Honours the // `calendarNotificationsEnabled` setting. const CHANNEL_ID = 'calendar-reminders'; -const HORIZON_MS = 7 * 24 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; +const HORIZON_MS = 7 * DAY_MS; +// The events loaded for the horizon: from a day back (an end-relative alert +// of an event that already started) to a week past it (alerts set up to a +// week before the start). +const LOOKBEHIND_MS = DAY_MS; +const LOOKAHEAD_MS = 7 * DAY_MS; // iOS keeps at most 64 pending local notifications per app; leave room for // the rest of the app. const MAX_SCHEDULED = 48; const DATA_TAG = 'bulwark-calendar-alert'; -let permissionGranted: boolean | null = null; +let permissionGranted = false; +let permissionRequested = false; +// Only the Calendar tab asks for notification permission; the launch-time +// sync uses what was granted and never prompts out of the blue. +let mayAskPermission = false; let channelReady = false; let rescheduleTimer: ReturnType | null = null; let rescheduling: Promise | null = null; let queued = false; let syncStarted = false; -let lastScheduledKeys: string[] = []; +let tasksLoadedFor: string | null = null; + +function isGranted(status: Notifications.NotificationPermissionsStatus): boolean { + return status.granted || status.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL; +} async function ensurePermission(): Promise { - if (permissionGranted !== null) return permissionGranted; + if (permissionGranted) return true; try { const current = await Notifications.getPermissionsAsync(); - let granted = current.granted || current.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL; - if (!granted && current.canAskAgain) { - const requested = await Notifications.requestPermissionsAsync(); - granted = requested.granted || requested.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL; + let granted = isGranted(current); + if (!granted && current.canAskAgain && mayAskPermission && !permissionRequested) { + permissionRequested = true; + granted = isGranted(await Notifications.requestPermissionsAsync()); } - permissionGranted = !!granted; + permissionGranted = granted; } catch { - permissionGranted = false; + return false; } return permissionGranted; } @@ -74,7 +95,6 @@ export async function cancelAllCalendarNotifications(): Promise { } catch { // Nothing to do; the OS keeps whatever it has. } - lastScheduledKeys = []; } async function scheduleOne(alert: ScheduledAlert): Promise { @@ -93,10 +113,59 @@ async function scheduleOne(alert: ScheduledAlert): Promise { }); } +function isSignedOut(): boolean { + const { hasRestoredSession, isAuthenticated } = useAuthStore.getState(); + return hasRestoredSession && !isAuthenticated; +} + /** - * Re-derive the reminders from the store and reconcile them with what the - * OS has pending: cancel alerts that vanished (event deleted, reminder - * removed, task completed) and add the new ones. Concurrent calls coalesce. + * The events whose alerts can fire within the horizon, straight from the + * server. `null` when that can't be known (offline, no session yet, the + * request failed): the caller then leaves the scheduled reminders alone + * instead of cancelling what it can't re-derive. + */ +async function loadUpcomingEvents(now: number): Promise { + if (!jmapClient.isConnected) return null; + if (!hasCalendarCapability()) return []; + if (useCalendarStore.getState().calendars.length === 0) { + await useCalendarStore.getState().fetchCalendars(); + } + const { calendars } = useCalendarStore.getState(); + if (calendars.length === 0) return null; + try { + return await loadEventsInRange( + calendars, + calendars.map((c) => c.id), + new Date(now - LOOKBEHIND_MS).toISOString(), + new Date(now + HORIZON_MS + LOOKAHEAD_MS).toISOString(), + ); + } catch { + return null; + } +} + +// Task reminders read the store's tasks, which the Calendar tab loads. When +// it hasn't, and tasks are switched on, scan them once per account. +async function ensureTasksLoaded(): Promise { + let accountId: string; + try { + accountId = jmapClient.accountId; + } catch { + return; + } + if (tasksLoadedFor === accountId) return; + tasksLoadedFor = accountId; + const { tasks, taskOnlyCalendarIds, fetchTasks } = useCalendarStore.getState(); + if (tasks.length > 0 || taskOnlyCalendarIds.length > 0) return; + if (!useSettingsStore.getState().enableCalendarTasks) return; + await fetchTasks(); +} + +/** + * Derive the reminders from the upcoming window and reconcile them with + * what the OS has pending: cancel the reminders this code scheduled that are + * no longer wanted (event deleted or moved, reminder removed, task + * completed) and add the new ones. Concurrent calls coalesce. */ export async function rescheduleCalendarNotifications(): Promise { if (rescheduling) { @@ -105,17 +174,24 @@ export async function rescheduleCalendarNotifications(): Promise { } rescheduling = (async () => { try { - const enabled = useSettingsStore.getState().calendarNotificationsEnabled; - if (!enabled) { - if (lastScheduledKeys.length > 0) await cancelAllCalendarNotifications(); + const settings = useSettingsStore.getState(); + // Until the settings load, the switch holds its default, not the + // user's choice. + if (!settings.hydrated) return; + if (!settings.calendarNotificationsEnabled || isSignedOut()) { + await cancelAllCalendarNotifications(); return; } if (!(await ensurePermission())) return; await ensureChannel(); - const { events, tasks, calendars } = useCalendarStore.getState(); + const now = Date.now(); + const events = await loadUpcomingEvents(now); + if (!events) return; + await ensureTasksLoaded(); + const { tasks, calendars } = useCalendarStore.getState(); const wanted = getUpcomingAlerts(events, tasks, calendars, { - now: Date.now(), + now, horizonMs: HORIZON_MS, limit: MAX_SCHEDULED, }); @@ -140,7 +216,6 @@ export async function rescheduleCalendarNotifications(): Promise { // A single bad trigger must not block the rest. } } - lastScheduledKeys = [...wantedByKey.keys()]; } catch { // Notifications are best-effort. } finally { @@ -156,34 +231,69 @@ export async function rescheduleCalendarNotifications(): Promise { function scheduleSoon(): void { if (rescheduleTimer) clearTimeout(rescheduleTimer); - // Debounce: a refresh sets events and tasks in two steps. + // Debounce: a refresh sets events and tasks in two steps, and a push + // reaches both the store and the state-change bus. rescheduleTimer = setTimeout(() => { rescheduleTimer = null; void rescheduleCalendarNotifications(); }, 1500); } +// expo-notifications drops a notification that fires while the app is in +// the foreground unless a handler asks for it. Only calendar reminders go +// through expo-notifications (mail push is posted by the native messaging +// services), so show those and leave anything else as it was. +function installForegroundHandler(): void { + Notifications.setNotificationHandler({ + handleNotification: async (notification) => { + const show = alertKeyOf(notification.request) !== null; + return { shouldShowBanner: show, shouldShowList: show, shouldPlaySound: show, shouldSetBadge: false }; + }, + }); +} + /** - * Keep local reminders in sync with the calendar store and the notification - * setting for the rest of the session. Idempotent; call once the calendar - * has been shown. + * Keep local reminders in sync for the rest of the session: at launch, + * sign-in and account switch, when calendar data changes on the server or + * in the app, when the app comes back to the foreground, and when the + * setting flips. Idempotent. `askPermission` lets the sync ask for + * notification permission; the Calendar tab passes it. */ -export function startCalendarNotificationSync(): void { +export function startCalendarNotificationSync(options?: { askPermission?: boolean }): void { + if (options?.askPermission && !mayAskPermission) { + mayAskPermission = true; + if (syncStarted) scheduleSoon(); + } if (syncStarted) return; syncStarted = true; - let prev = useCalendarStore.getState(); - useCalendarStore.subscribe((state) => { - if (state.events !== prev.events || state.tasks !== prev.tasks || state.calendars !== prev.calendars) { - prev = state; + installForegroundHandler(); + // Edits in the app, a refresh after a push, tasks loading. Moving the + // calendar to another window changes no reminder. + useCalendarStore.subscribe((state, prev) => { + const sameWindow = + state.loadedRange?.after === prev.loadedRange?.after && + state.loadedRange?.before === prev.loadedRange?.before; + if (state.tasks !== prev.tasks || (state.events !== prev.events && sameWindow)) { scheduleSoon(); } }); - let prevEnabled = useSettingsStore.getState().calendarNotificationsEnabled; - useSettingsStore.subscribe((state) => { - if (state.calendarNotificationsEnabled !== prevEnabled) { - prevEnabled = state.calendarNotificationsEnabled; + useSettingsStore.subscribe((state, prev) => { + if ( + state.calendarNotificationsEnabled !== prev.calendarNotificationsEnabled || + state.hydrated !== prev.hydrated + ) { scheduleSoon(); } }); + useAuthStore.subscribe((state, prev) => { + if (state.session !== prev.session || state.isAuthenticated !== prev.isAuthenticated) { + scheduleSoon(); + } + }); + onStateChangeType('CalendarEvent', scheduleSoon); + onStateChangeType('Calendar', scheduleSoon); + AppState.addEventListener('change', (state) => { + if (state === 'active') scheduleSoon(); + }); scheduleSoon(); } diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 918bb00b..302833e8 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -340,9 +340,9 @@ export default function CalendarScreen() { React.useEffect(() => { void hydrate(); void fetchCalendarsAction(); - // Local reminders follow the store from here on (honours the - // notifications setting; cancelled/acknowledged alerts are skipped). - startCalendarNotificationSync(); + // Reminders already sync from launch; opening the calendar may also ask + // for notification permission. + startCalendarNotificationSync({ askPermission: true }); }, [hydrate, fetchCalendarsAction]); // Refresh iCal subscriptions whose interval elapsed: on mount and whenever diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index d180442f..98626ac4 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -230,6 +230,62 @@ function unionRange(loaded: LoadedRange, after: string, before: string): LoadedR }; } +/** + * The events of the given calendars (store ids) that overlap [after, before], + * mapped to store ids and with recurring series expanded over the window. + * Doesn't touch the store: fetchEvents keeps the result as the visible + * window, the reminders load their own upcoming window with it. Throws when + * the primary account fails; a failing shared account is skipped. + */ +export async function loadEventsInRange( + calendars: Calendar[], + calendarIds: string[], + after: string, + before: string, +): Promise { + // Group the requested calendars by owning account: the primary account + // (calendars without an accountId tag) plus one group per shared + // account, since CalendarEvent/query is scoped to a single account. The + // incoming ids are store ids (shared calendars are namespaced + // `${accountId}:${id}`); map them back to the raw server ids the query + // filter expects. + const byId = new Map(calendars.map((c) => [c.id, c])); + const groups = new Map(); + for (const id of calendarIds) { + const cal = byId.get(id); + const accountId = cal?.accountId; + const serverId = cal?.originalId || id; + const group = groups.get(accountId); + if (group) group.push(serverId); + else groups.set(accountId, [serverId]); + } + if (groups.size === 0) groups.set(undefined, []); + + const raw: CalendarEvent[] = []; + for (const [accountId, ids] of groups) { + if (accountId && isCalendarAccessDenied(accountId)) continue; + try { + // The window is sent as after/before so accounts with more than + // 1000 objects don't silently lose events and navigating past the + // loaded range doesn't re-download everything. + const eventIds = (await queryEvents(ids, after, before, accountId)) ?? []; + if (eventIds.length === 0) continue; + const fetched = (await fetchEvents(eventIds, accountId)) ?? []; + raw.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId))); + } catch (err) { + // A failing shared account must not hide the user's own events; + // remember an access rejection so it isn't re-probed every fetch. + if (!accountId) throw err; + noteCalendarAccessError(accountId, err); + } + } + // Stalwart returns both Events and Tasks from CalendarEvent/query. Tasks + // (also CalDAV ones without an `@type`) are surfaced by fetchTasks so + // they don't pollute the grid. + const onlyEvents = raw.filter((e) => !isTaskLikeObject(e) && !!e.start); + return expandRecurringEvents(onlyEvents, after, before); +} + export const useCalendarStore = create()( persist( (set, get) => ({ @@ -287,48 +343,7 @@ export const useCalendarStore = create()( if (!jmapClient.isConnected) return; set({ loading: true, error: null }); try { - // Group the requested calendars by owning account: the primary account - // (calendars without an accountId tag) plus one group per shared - // account, since CalendarEvent/query is scoped to a single account. The - // incoming ids are store ids (shared calendars are namespaced - // `${accountId}:${id}`); map them back to the raw server ids the query - // filter expects. - const calendars = get().calendars; - const byId = new Map(calendars.map((c) => [c.id, c])); - const groups = new Map(); - for (const id of calendarIds) { - const cal = byId.get(id); - const accountId = cal?.accountId; - const serverId = cal?.originalId || id; - const group = groups.get(accountId); - if (group) group.push(serverId); - else groups.set(accountId, [serverId]); - } - if (groups.size === 0) groups.set(undefined, []); - - const raw: CalendarEvent[] = []; - for (const [accountId, ids] of groups) { - if (accountId && isCalendarAccessDenied(accountId)) continue; - try { - // The window is sent as after/before so accounts with more than - // 1000 objects don't silently lose events and navigating past the - // loaded range doesn't re-download everything. - const eventIds = (await queryEvents(ids, after, before, accountId)) ?? []; - if (eventIds.length === 0) continue; - const fetched = (await fetchEvents(eventIds, accountId)) ?? []; - raw.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId))); - } catch (err) { - // A failing shared account must not hide the user's own events; - // remember an access rejection so it isn't re-probed every fetch. - if (!accountId) throw err; - noteCalendarAccessError(accountId, err); - } - } - // Stalwart returns both Events and Tasks from CalendarEvent/query. Tasks - // (also CalDAV ones without an `@type`) are surfaced by fetchTasks so - // they don't pollute the grid. - const onlyEvents = raw.filter((e) => !isTaskLikeObject(e) && !!e.start); - const events = expandRecurringEvents(onlyEvents, after, before); + const events = await loadEventsInRange(get().calendars, calendarIds, after, before); set({ events, loadedRange: { after, before }, loading: false }); } catch (err) { set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' });