mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: open the event when a calendar reminder is tapped
Tapping a reminder notification only brought the app to the front: nothing listened for expo-notifications responses, so the event never opened. - reminders now carry the event's server id, account, occurrence, start and the signed-in account alongside the store id - `startCalendarReminderTapHandling` picks up the reminder that launched the app and any tapped while it runs; it only acts on reminders it scheduled, and mail push taps keep coming through the native tap store - the app switches to the reminder's account if needed, parks the target in `pending-calendar-open` and opens the Calendar tab, which shows the event in the detail sheet or the task in the tasks sheet - the event is looked up by its server identity, first in the loaded window, then in the day around its start, then as the stored event, so an occurrence outside the visible month still opens; a deleted one says "This event is no longer available."
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ type Listener = (state: Record<string, unknown>, prev: Record<string, unknown>)
|
||||
const h = vi.hoisted(() => ({
|
||||
pending: [] as Array<{ identifier: string; content: { data: Record<string, unknown> } }>,
|
||||
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<string, unknown>, 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>, _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.' });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
|
||||
async function scheduleOne(alert: ScheduledAlert): Promise<void> {
|
||||
const data: Record<string, unknown> = {
|
||||
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<string, unknown>;
|
||||
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<string>();
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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<CalendarEvent | null> {
|
||||
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<CalendarEvent | null> {
|
||||
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<boolean> {
|
||||
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]);
|
||||
}
|
||||
@@ -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<PendingCalendarOpenState>((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);
|
||||
}
|
||||
@@ -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<string | null>(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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user