Files
native/src/lib/calendar-reminder-open.ts
T
Linus Rath 84548898c1 feat: let the server expand recurring events when it can
Recurring series were always fetched whole and expanded on the device,
the webmail's fallback for Stalwart before 0.16.20. Newer servers
expand them with `expandRecurrences` and accept the synthetic
occurrence ids in `CalendarEvent/set`; the webmail uses that since
`lib/recurrence-instances.ts`.

- `supportsSyntheticCalendarIds` probes once per server and account
  with an update of an id that cannot exist (`notFound` means
  supported); the probe rides along with the first `Calendar/get`
- when supported, a range is loaded with `queryExpandedEvents`, the
  occurrences are fetched with `baseEventId` and hydrated with their
  base event's rules, overrides, all-day flag and duration; duplicate
  listings of one occurrence are collapsed like the webmail does
- a range the server refuses to expand ("expanded recurrences
  exceeds") and older servers keep the device-side expansion, which
  now passes server occurrences through untouched
- changes, deletes and RSVPs resolve a synthetic id to its base event
  (`resolveSeriesTarget`), including a master fetched for an
  occurrence of a shared calendar, and reload the range afterwards
  since synthetic ids reshuffle
- reminder keys use the base event and recurrence id, so a reshuffle
  does not reschedule them
- client-only fields are stripped from `CalendarEvent/set` payloads
2026-09-24 20:10:08 +02:00

138 lines
4.9 KiB
TypeScript

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 { seriesIdOf } from './recurrence-instances';
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;
function matchesTarget(event: CalendarEvent, target: CalendarReminderTarget): boolean {
if (event.id === target.eventId) return true;
if (!target.serverId || seriesIdOf(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
&& seriesIdOf(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]);
}