diff --git a/App.tsx b/App.tsx index f53bd60c..893dbde7 100644 --- a/App.tsx +++ b/App.tsx @@ -11,7 +11,14 @@ 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 { + startCalendarNotificationSync, + startCalendarReminderTapHandling, +} from './src/lib/calendar-notifications'; +import { + setPendingCalendarOpen, + type CalendarReminderTarget, +} from './src/navigation/pending-calendar-open'; import { sweepStaleExportFiles } from './src/lib/email-export'; import { useFilterStore } from './src/stores/filter-store'; import { useVacationStore } from './src/stores/vacation-store'; @@ -95,6 +102,26 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi }); } +// A tapped calendar reminder opens its event (or task) in the Calendar tab, +// under the account it was scheduled for. The target is parked for +// CalendarScreen, which also picks it up when it mounts after a cold start. +async function openCalendarReminder(target: CalendarReminderTarget): Promise { + const auth = useAuthStore.getState(); + if (target.appAccountId && target.appAccountId !== auth.activeAccountId) { + if (!useAccountStore.getState().getAccountById(target.appAccountId)) return; + await auth.switchAccount(target.appAccountId); + if (useAuthStore.getState().activeAccountId !== target.appAccountId) return; + } + setPendingCalendarOpen(target); + // On a cold start the navigator mounts right after the auth gate flips. + for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (navigationRef.isReady()) { + navigationRef.navigate('MainTabs', { screen: 'Calendar' } as never); + } +} + // Deep links (bulwarkmobile://, webmail https permalinks, mailto:) and // Android share-sheet payloads all end up here once the navigator is ready. async function openDeepLink(link: DeepLink): Promise { @@ -406,6 +433,15 @@ export default function App() { }; }, [isAuthenticated]); + // Calendar reminders are local expo notifications; their taps come through + // expo-notifications, not the native tap store used for mail push above. + React.useEffect(() => { + if (!isAuthenticated) return; + return startCalendarReminderTapHandling((target) => { + void openCalendarReminder(target); + }); + }, [isAuthenticated]); + // Deep links and share-sheet payloads. The cold-start URL / share is read // once auth is restored so the target screen has credentials. React.useEffect(() => { diff --git a/locales/rn/en.json b/locales/rn/en.json index 7fe1e21c..17674de9 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -84,6 +84,7 @@ "add_placeholder": "Add a task…", "add_time": "Add time", "delete_error": "Failed to delete task", + "not_found": "This task is no longer available.", "save_error": "Failed to save task", "update_error": "Failed to update task" } diff --git a/src/lib/__tests__/calendar-notifications.test.ts b/src/lib/__tests__/calendar-notifications.test.ts index d4dc3760..a0cba412 100644 --- a/src/lib/__tests__/calendar-notifications.test.ts +++ b/src/lib/__tests__/calendar-notifications.test.ts @@ -5,7 +5,9 @@ type Listener = (state: Record, prev: Record) 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 }, + auth: { hasRestoredSession: true, isAuthenticated: true, session: null as unknown, activeAccountId: 'app-1' as string | null }, + lastResponse: null as unknown, + responseListener: null as ((response: unknown) => void) | null, settings: { hydrated: true, calendarNotificationsEnabled: true, enableCalendarTasks: false }, calendar: { calendars: [{ id: 'cal-1', name: 'Personal' }], @@ -34,6 +36,13 @@ vi.mock('expo-notifications', () => ({ cancelScheduledNotificationAsync: vi.fn(async () => undefined), scheduleNotificationAsync: vi.fn(async () => 'id'), setNotificationHandler: vi.fn(), + getLastNotificationResponseAsync: vi.fn(async () => h.lastResponse), + clearLastNotificationResponseAsync: vi.fn(async () => undefined), + addNotificationResponseReceivedListener: vi.fn((listener: (response: unknown) => void) => { + h.responseListener = listener; + return { remove: vi.fn() }; + }), + DEFAULT_ACTION_IDENTIFIER: 'expo.modules.notifications.actions.DEFAULT', AndroidImportance: { HIGH: 4 }, IosAuthorizationStatus: { PROVISIONAL: 3 }, SchedulableTriggerInputTypes: { DATE: 'date' }, @@ -85,7 +94,9 @@ beforeEach(async () => { vi.clearAllMocks(); h.pending = []; h.permission = { granted: true, canAskAgain: true }; - h.auth = { hasRestoredSession: true, isAuthenticated: true, session: null }; + h.auth = { hasRestoredSession: true, isAuthenticated: true, session: null, activeAccountId: 'app-1' }; + h.lastResponse = null; + h.responseListener = null; h.settings = { hydrated: true, calendarNotificationsEnabled: true, enableCalendarTasks: false }; h.calendar.events = []; h.calendar.tasks = []; @@ -152,14 +163,14 @@ describe('calendar reminders (B17)', () => { reminder('r1', upcomingKey), { identifier: 'other', content: { data: {} } }, ]; - h.auth = { hasRestoredSession: true, isAuthenticated: false, session: null }; + h.auth = { hasRestoredSession: true, isAuthenticated: false, session: null, activeAccountId: 'app-1' }; 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.auth = { hasRestoredSession: true, isAuthenticated: true, session: null, activeAccountId: 'app-1' }; h.settings.calendarNotificationsEnabled = false; await mod.rescheduleCalendarNotifications(); expect(N.cancelScheduledNotificationAsync).toHaveBeenCalledWith('r1'); @@ -215,3 +226,65 @@ describe('calendar reminders (B17)', () => { expect(h.loadEventsInRange).toHaveBeenCalledTimes(3); }); }); + +describe('tapping a calendar reminder', () => { + const DEFAULT = 'expo.modules.notifications.actions.DEFAULT'; + function response(identifier: string, data: Record, action = DEFAULT) { + return { actionIdentifier: action, notification: { request: { identifier, content: { data } } } }; + } + + it('schedules each reminder with what a tap needs to find the event again', async () => { + h.loadEventsInRange.mockResolvedValue([ + { ...upcoming, id: 'acc-2:ev1:2026-09-24T10:00:00Z', originalId: 'ev1', accountId: 'acc-2', recurrenceId: '2026-09-24T10:00:00Z' }, + ]); + + await mod.rescheduleCalendarNotifications(); + + const request = vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0]; + expect(request.content.data).toEqual({ + tag: TAG, + key: expect.any(String), + eventId: 'acc-2:ev1:2026-09-24T10:00:00Z', + kind: 'event', + serverId: 'ev1', + accountId: 'acc-2', + recurrenceId: '2026-09-24T10:00:00Z', + startMs: new Date('2026-09-24T10:00:00Z').getTime(), + appAccountId: 'app-1', + }); + }); + + it('opens the reminder that launched the app, once', async () => { + const data = { tag: TAG, key: upcomingKey, eventId: 'ev1', kind: 'event', serverId: 'ev1', startMs: 5, appAccountId: 'app-1' }; + h.lastResponse = response('n1', data); + const open = vi.fn(); + + mod.startCalendarReminderTapHandling(open); + await vi.advanceTimersByTimeAsync(0); + // Some platforms report the launching tap to the listener as well. + h.responseListener!(response('n1', data)); + + expect(open).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledWith({ + kind: 'event', eventId: 'ev1', serverId: 'ev1', startMs: 5, appAccountId: 'app-1', + accountId: undefined, recurrenceId: undefined, + }); + expect(N.clearLastNotificationResponseAsync).toHaveBeenCalled(); + }); + + it('opens tapped reminders while running and ignores every other notification', async () => { + const open = vi.fn(); + const stop = mod.startCalendarReminderTapHandling(open); + await vi.advanceTimersByTimeAsync(0); + + h.responseListener!(response('mail', { emailId: 'e1', threadId: 't1' })); + h.responseListener!(response('dismissed', { tag: TAG, key: 'k', eventId: 'ev2' }, 'expo.modules.notifications.actions.DISMISS')); + h.responseListener!(response('task', { tag: TAG, key: 'k2', eventId: 't1', kind: 'task' })); + expect(open).toHaveBeenCalledTimes(1); + expect(open.mock.calls[0][0]).toMatchObject({ kind: 'task', eventId: 't1' }); + + stop(); + h.responseListener!(response('later', { tag: TAG, key: 'k3', eventId: 'ev3' })); + expect(open).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/__tests__/calendar-reminder-open.test.ts b/src/lib/__tests__/calendar-reminder-open.test.ts new file mode 100644 index 00000000..338c1633 --- /dev/null +++ b/src/lib/__tests__/calendar-reminder-open.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const h = vi.hoisted(() => ({ + store: { + calendars: [{ id: 'cal-1' }, { id: 'acc-2:cal-9', originalId: 'cal-9', accountId: 'acc-2' }] as unknown[], + events: [] as unknown[], + tasks: [] as unknown[], + fetchCalendars: vi.fn(async () => undefined), + fetchTasks: vi.fn(async () => undefined), + }, + loadEventsInRange: vi.fn(), + getEvents: vi.fn(), + addToast: vi.fn(), +})); + +vi.mock('../../stores/calendar-store', () => ({ + useCalendarStore: { getState: () => h.store }, + loadEventsInRange: h.loadEventsInRange, + mapServerEventToStoreEvent: (event: Record, _cals: unknown, accountId?: string) => + accountId ? { ...event, id: `${accountId}:${event.id}`, originalId: event.id, accountId } : event, +})); +vi.mock('../../api/calendar', () => ({ getEvents: h.getEvents })); +vi.mock('../../stores/toast-store', () => ({ + useToastStore: { getState: () => ({ addToast: h.addToast }) }, +})); +vi.mock('../../stores/locale-store', () => ({ + useLocaleStore: { getState: () => ({ t: (_key: string, fallback: string) => fallback }) }, +})); + +import { + openReminderTarget, + resolveReminderEvent, + resolveReminderTask, +} from '../calendar-reminder-open'; + +const START = new Date('2026-09-24T10:00:00Z').getTime(); + +beforeEach(() => { + vi.clearAllMocks(); + h.store.events = []; + h.store.tasks = []; + h.loadEventsInRange.mockResolvedValue([]); + h.getEvents.mockResolvedValue([]); +}); + +describe('resolveReminderEvent', () => { + it('takes the event from the loaded window', async () => { + const ev = { id: 'ev1', title: 'Standup' }; + h.store.events = [ev]; + + expect(await resolveReminderEvent({ kind: 'event', eventId: 'ev1', serverId: 'ev1', startMs: START })).toBe(ev); + expect(h.loadEventsInRange).not.toHaveBeenCalled(); + }); + + it('loads the day around the occurrence when the calendar shows another window', async () => { + const occurrence = { + id: 'acc-2:ev1:2026-09-24T10:00:00Z', originalId: 'ev1', accountId: 'acc-2', + recurrenceId: '2026-09-24T10:00:00Z', + }; + const other = { ...occurrence, id: 'acc-2:ev1:2026-09-25T10:00:00Z', recurrenceId: '2026-09-25T10:00:00Z' }; + h.loadEventsInRange.mockResolvedValue([other, occurrence]); + + const found = await resolveReminderEvent({ + kind: 'event', eventId: 'stale-id', serverId: 'ev1', accountId: 'acc-2', + recurrenceId: '2026-09-24T10:00:00Z', startMs: START, + }); + + expect(found).toBe(occurrence); + const [, ids, after, before] = h.loadEventsInRange.mock.calls[0]; + expect(ids).toEqual(['cal-1', 'acc-2:cal-9']); + expect(new Date(after).getTime()).toBeLessThan(START); + expect(new Date(before).getTime()).toBeGreaterThan(START); + }); + + it('falls back to the stored event when the occurrence is not around its old start', async () => { + h.getEvents.mockResolvedValue([{ id: 'ev1', title: 'Moved' }]); + + const found = await resolveReminderEvent({ + kind: 'event', eventId: 'acc-2:ev1', serverId: 'ev1', accountId: 'acc-2', startMs: START, + }); + + expect(h.getEvents).toHaveBeenCalledWith(['ev1'], 'acc-2'); + expect(found).toMatchObject({ id: 'acc-2:ev1', title: 'Moved' }); + }); + + it('returns null for a deleted event', async () => { + expect(await resolveReminderEvent({ kind: 'event', eventId: 'gone', serverId: 'gone', startMs: START })).toBeNull(); + }); +}); + +describe('resolveReminderTask', () => { + it('loads the tasks when the Calendar tab has not yet', async () => { + h.store.fetchTasks.mockImplementationOnce(async () => { + h.store.tasks = [{ id: 't1', title: 'Pay rent' }]; + }); + + expect(await resolveReminderTask({ kind: 'task', eventId: 't1', serverId: 't1' })) + .toMatchObject({ id: 't1' }); + expect(h.store.fetchTasks).toHaveBeenCalledTimes(1); + }); +}); + +describe('openReminderTarget', () => { + it('opens the event in the detail sheet and a task in the tasks sheet', async () => { + h.store.events = [{ id: 'ev1' }]; + h.store.tasks = [{ id: 't1' }]; + const onEvent = vi.fn(); + const onTask = vi.fn(); + + expect(await openReminderTarget({ kind: 'event', eventId: 'ev1' }, { onEvent, onTask })).toBe(true); + expect(await openReminderTarget({ kind: 'task', eventId: 't1' }, { onEvent, onTask })).toBe(true); + + expect(onEvent).toHaveBeenCalledWith({ id: 'ev1' }); + expect(onTask).toHaveBeenCalledWith('t1'); + expect(h.addToast).not.toHaveBeenCalled(); + }); + + it('says so when the event is gone or cannot be loaded', async () => { + h.getEvents.mockRejectedValue(new Error('offline')); + const onEvent = vi.fn(); + + expect(await openReminderTarget( + { kind: 'event', eventId: 'ev1', serverId: 'ev1' }, + { onEvent, onTask: vi.fn() }, + )).toBe(false); + + expect(onEvent).not.toHaveBeenCalled(); + expect(h.addToast).toHaveBeenCalledWith({ type: 'error', title: 'This event is no longer available.' }); + }); +}); diff --git a/src/lib/calendar-alert-scheduler.ts b/src/lib/calendar-alert-scheduler.ts index 4d6d2203..dbe5efd4 100644 --- a/src/lib/calendar-alert-scheduler.ts +++ b/src/lib/calendar-alert-scheduler.ts @@ -16,6 +16,12 @@ export interface ScheduledAlert { title: string; body: string; kind: 'event' | 'task'; + // What a tap on the reminder opens (see calendar-reminder-open): the + // stored event's raw id and account, and the occurrence. + serverId?: string; + accountId?: string; + recurrenceId?: string; + startMs?: number; } const DURATION_RE = /^(-?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/; @@ -148,6 +154,10 @@ export function getUpcomingAlerts( title: event.title || '(No title)', body: Number.isNaN(startMs) ? '' : `Starts ${formatWhen(fireTimeMs, startMs)}`, kind: 'event', + serverId: event.originalId ?? event.id, + accountId: event.accountId, + recurrenceId: event.recurrenceId, + startMs: Number.isNaN(startMs) ? undefined : startMs, }); } } @@ -169,6 +179,8 @@ export function getUpcomingAlerts( title: task.title || '(No title)', body: 'Task due', kind: 'task', + serverId: task.originalId ?? task.id, + accountId: task.accountId, }); } } diff --git a/src/lib/calendar-notifications.ts b/src/lib/calendar-notifications.ts index 6c7bf290..a61e92bf 100644 --- a/src/lib/calendar-notifications.ts +++ b/src/lib/calendar-notifications.ts @@ -8,6 +8,7 @@ import { useSettingsStore } from '../stores/settings-store'; import { hasCalendarCapability } from './capabilities'; import { onStateChangeType } from './state-change-bus'; import { getUpcomingAlerts, type ScheduledAlert } from './calendar-alert-scheduler'; +import type { CalendarReminderTarget } from '../navigation/pending-calendar-open'; // Local reminders for calendar events and tasks. The webmail polls // `getPendingAlerts` every minute while a tab is open; a phone is mostly @@ -98,12 +99,25 @@ export async function cancelAllCalendarNotifications(): Promise { } async function scheduleOne(alert: ScheduledAlert): Promise { + const data: Record = { + tag: DATA_TAG, + key: alert.key, + eventId: alert.eventId, + kind: alert.kind, + // Enough to find the event again when the reminder is tapped. + serverId: alert.serverId, + accountId: alert.accountId, + recurrenceId: alert.recurrenceId, + startMs: alert.startMs, + appAccountId: useAuthStore.getState().activeAccountId ?? undefined, + }; + for (const k of Object.keys(data)) if (data[k] === undefined) delete data[k]; await Notifications.scheduleNotificationAsync({ content: { title: alert.title, body: alert.body, sound: 'default', - data: { tag: DATA_TAG, key: alert.key, eventId: alert.eventId, kind: alert.kind }, + data, }, trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, @@ -252,6 +266,60 @@ function installForegroundHandler(): void { }); } +/** The event or task a calendar reminder notification points at; null for anything else. */ +export function reminderTargetOf(request: Notifications.NotificationRequest): CalendarReminderTarget | null { + if (alertKeyOf(request) === null) return null; + const data = request.content.data as Record; + const str = (v: unknown) => (typeof v === 'string' && v ? v : undefined); + const eventId = str(data.eventId); + if (!eventId) return null; + return { + kind: data.kind === 'task' ? 'task' : 'event', + eventId, + serverId: str(data.serverId), + accountId: str(data.accountId), + recurrenceId: str(data.recurrenceId), + startMs: typeof data.startMs === 'number' && Number.isFinite(data.startMs) ? data.startMs : undefined, + appAccountId: str(data.appAccountId), + }; +} + +// Responses already acted on: the cold-start response is reported both by +// getLastNotificationResponseAsync and, on some platforms, the listener. +const handledResponses = new Set(); + +/** + * Call `open` when the user taps a calendar reminder: the one that launched + * the app and any tapped while it runs. Only reminders scheduled here are + * handled; mail push is posted by the native messaging services and its taps + * arrive through the native tap store (push-notifications), never here. + * Returns the unsubscribe. + */ +export function startCalendarReminderTapHandling( + open: (target: CalendarReminderTarget) => void, +): () => void { + let active = true; + const handle = (response: Notifications.NotificationResponse | null) => { + if (!active || !response) return; + if (response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return; + const { request } = response.notification; + const target = reminderTargetOf(request); + if (!target || handledResponses.has(request.identifier)) return; + handledResponses.add(request.identifier); + // Don't reopen it on the next sign-in or app start. + void Promise.resolve() + .then(() => Notifications.clearLastNotificationResponseAsync()) + .catch(() => undefined); + open(target); + }; + void Notifications.getLastNotificationResponseAsync().then(handle, () => undefined); + const subscription = Notifications.addNotificationResponseReceivedListener(handle); + return () => { + active = false; + subscription.remove(); + }; +} + /** * 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 diff --git a/src/lib/calendar-reminder-open.ts b/src/lib/calendar-reminder-open.ts new file mode 100644 index 00000000..603da499 --- /dev/null +++ b/src/lib/calendar-reminder-open.ts @@ -0,0 +1,141 @@ +import React from 'react'; +import type { CalendarEvent } from '../api/types'; +import { getEvents } from '../api/calendar'; +import { + loadEventsInRange, + mapServerEventToStoreEvent, + useCalendarStore, +} from '../stores/calendar-store'; +import { useLocaleStore } from '../stores/locale-store'; +import { useToastStore } from '../stores/toast-store'; +import { + usePendingCalendarOpen, + type CalendarReminderTarget, +} from '../navigation/pending-calendar-open'; + +// Opening the event or task behind a tapped calendar reminder. The reminder +// was scheduled from its own upcoming window (calendar-notifications), so the +// event is usually not among the ones the Calendar tab has loaded; it is +// looked up by its server identity instead of the store id alone, which for +// an expanded occurrence only lives as long as the loaded window. + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** The raw JMAP id of the stored event behind a store event or occurrence. */ +function serverIdOf(event: CalendarEvent): string { + return event.originalId ?? event.id; +} + +function matchesTarget(event: CalendarEvent, target: CalendarReminderTarget): boolean { + if (event.id === target.eventId) return true; + if (!target.serverId || serverIdOf(event) !== target.serverId) return false; + return (event.accountId ?? undefined) === (target.accountId ?? undefined) + && (event.recurrenceId ?? undefined) === (target.recurrenceId ?? undefined); +} + +/** + * The event (or occurrence) a reminder points at: from the loaded window, + * else from the day around its start, else the stored event itself (a + * series' base event when the occurrence moved away). `null` when it no + * longer exists. + */ +export async function resolveReminderEvent( + target: CalendarReminderTarget, +): Promise { + const store = useCalendarStore.getState(); + const loaded = store.events.find((e) => matchesTarget(e, target)); + if (loaded) return loaded; + + if (store.calendars.length === 0) await store.fetchCalendars(); + const { calendars } = useCalendarStore.getState(); + if (target.startMs !== undefined && calendars.length > 0) { + try { + const around = await loadEventsInRange( + calendars, + calendars.map((c) => c.id), + new Date(target.startMs - DAY_MS).toISOString(), + new Date(target.startMs + DAY_MS).toISOString(), + ); + const found = around.find((e) => matchesTarget(e, target)); + if (found) return found; + } catch { + // Try the event itself below. + } + } + if (!target.serverId) return null; + const [event] = await getEvents([target.serverId], target.accountId); + return event ? mapServerEventToStoreEvent(event, calendars, target.accountId) : null; +} + +/** The task a reminder points at, loading the tasks when they aren't yet. */ +export async function resolveReminderTask( + target: CalendarReminderTarget, +): Promise { + const find = () => useCalendarStore.getState().tasks.find((task) => + task.id === target.eventId + || (!!target.serverId + && serverIdOf(task) === target.serverId + && (task.accountId ?? undefined) === (target.accountId ?? undefined))); + const loaded = find(); + if (loaded) return loaded; + const store = useCalendarStore.getState(); + if (store.calendars.length === 0) await store.fetchCalendars(); + await useCalendarStore.getState().fetchTasks(); + return find() ?? null; +} + +export interface ReminderOpenHandlers { + onEvent: (event: CalendarEvent) => void; + onTask: (taskId: string) => void; +} + +/** Open what the reminder points at; says so when it is gone. */ +export async function openReminderTarget( + target: CalendarReminderTarget, + handlers: ReminderOpenHandlers, +): Promise { + try { + if (target.kind === 'task') { + const task = await resolveReminderTask(target); + if (task) { + handlers.onTask(task.id); + return true; + } + } else { + const event = await resolveReminderEvent(target); + if (event) { + handlers.onEvent(event); + return true; + } + } + } catch { + // Offline or refused: report it like a missing event. + } + const t = useLocaleStore.getState().t; + useToastStore.getState().addToast({ + type: 'error', + title: target.kind === 'task' + ? t('calendar.tasks.not_found', 'This task is no longer available.') + : t('deep_link.event_not_found', 'This event is no longer available.'), + }); + return false; +} + +/** + * For the Calendar tab: open the event or task of a tapped reminder + * (parked in pending-calendar-open by the notification response listener). + */ +export function useCalendarReminderOpen(handlers: ReminderOpenHandlers): void { + const pending = usePendingCalendarOpen((s) => s.target); + const handlersRef = React.useRef(handlers); + handlersRef.current = handlers; + React.useEffect(() => { + if (!pending) return; + const target = usePendingCalendarOpen.getState().consume(); + if (!target) return; + void openReminderTarget(target, { + onEvent: (event) => handlersRef.current.onEvent(event), + onTask: (id) => handlersRef.current.onTask(id), + }); + }, [pending]); +} diff --git a/src/navigation/pending-calendar-open.ts b/src/navigation/pending-calendar-open.ts new file mode 100644 index 00000000..2fb5cace --- /dev/null +++ b/src/navigation/pending-calendar-open.ts @@ -0,0 +1,41 @@ +import { create } from 'zustand'; + +/** The event or task a calendar reminder notification points at. */ +export interface CalendarReminderTarget { + kind: 'event' | 'task'; + /** Store id of the event (or occurrence) when the reminder was scheduled. */ + eventId: string; + /** Raw JMAP id of the stored event; a series' base event for an occurrence. */ + serverId?: string; + /** JMAP account of an event on a calendar shared with the user. */ + accountId?: string; + /** The occurrence of a recurring series. */ + recurrenceId?: string; + /** When the occurrence starts (ms), to find it again on the server. */ + startMs?: number; + /** The signed-in account the reminder was scheduled for. */ + appAccountId?: string; +} + +// A tapped reminder arrives before CalendarScreen is mounted (cold start) or +// while it shows something else. The target is parked here and consumed by +// the screen, like pending-settings-tab. +interface PendingCalendarOpenState { + target: CalendarReminderTarget | null; + set: (target: CalendarReminderTarget | null) => void; + consume: () => CalendarReminderTarget | null; +} + +export const usePendingCalendarOpen = create((set, get) => ({ + target: null, + set: (target) => set({ target }), + consume: () => { + const target = get().target; + if (target) set({ target: null }); + return target; + }, +})); + +export function setPendingCalendarOpen(target: CalendarReminderTarget | null): void { + usePendingCalendarOpen.getState().set(target); +} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 5ebe0998..7a5201d6 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -83,6 +83,7 @@ import { useLocaleStore } from '../stores/locale-store'; import { useUserCalendarAddresses } from '../lib/calendar-user-addresses'; import { useCalendarSubscriptionsStore } from '../stores/calendar-subscriptions-store'; import { startCalendarNotificationSync } from '../lib/calendar-notifications'; +import { useCalendarReminderOpen } from '../lib/calendar-reminder-open'; import { shareEventICS } from '../lib/calendar-ics-export'; import * as Clipboard from 'expo-clipboard'; import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types'; @@ -200,6 +201,11 @@ export default function CalendarScreen() { const deleteTask = useCalendarStore((s) => s.deleteTask); // Task to open in the tasks sheet when a task chip on the grid is tapped. const [tasksInitialId, setTasksInitialId] = React.useState(null); + // A tapped reminder notification opens its event or task here. + useCalendarReminderOpen({ + onEvent: setDetailEvent, + onTask: (id) => { setTasksInitialId(id); setTasksVisible(true); }, + }); const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility); const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar); const createCalendar = useCalendarStore((s) => s.createCalendar); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index d998238d..e39a966c 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -45,7 +45,7 @@ const HIDDEN_CALENDARS_STORAGE_KEY = 'webmail:calendar:hidden:v1'; // visibility filtering and per-calendar colour lookup line up. Events from the // primary account are returned unchanged. Mirrors webmail's // mapServerEventToStoreEvent. -function mapServerEventToStoreEvent( +export function mapServerEventToStoreEvent( event: CalendarEvent, calendars: Calendar[], accountId?: string,