diff --git a/src/api/__tests__/live-stalwart-event-notifications.test.ts b/src/api/__tests__/live-stalwart-event-notifications.test.ts new file mode 100644 index 00000000..6f35a0df --- /dev/null +++ b/src/api/__tests__/live-stalwart-event-notifications.test.ts @@ -0,0 +1,171 @@ +/** + * Live CalendarEventNotification checks against a real Stalwart. + * + * Opt-in like live-stalwart-calendar.test.ts: skipped unless + * `BULWARK_LIVE_JMAP`, `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` (same + * password) are set. The second account invites the first one to an event in + * 2027 and then cancels it; the first one reads the notifications the server + * keeps for that and acknowledges them. Only the notifications of this run + * are destroyed, and the event carries the run's marker and is removed from + * both accounts afterwards. + * + * BULWARK_LIVE_JMAP=http://10.10.10.62:8016 \ + * BULWARK_LIVE_USER=e2e-perf@example.org \ + * BULWARK_LIVE_USER2=e2e-tablet-land@example.org \ + * BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart-event-notifications.test.ts + * + * Verified against Stalwart 0.16.27 and 1.0.1. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { JMAPClient } from '../jmap-client'; +import { CAPABILITIES } from '../types'; +import { + destroyCalendarEventNotifications, + getCalendarEventNotifications, + type CalendarEventNotification, +} from '../calendar-event-notifications'; +import { eventNotificationToast } from '../../lib/calendar-event-notifications'; + +const SERVER = process.env.BULWARK_LIVE_JMAP; +const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; +const USER2 = process.env.BULWARK_LIVE_USER2; +const PASS = process.env.BULWARK_LIVE_PASS ?? ''; +const liveTwo = SERVER && PASS && USER2 ? describe : describe.skip; + +const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]; +const RUN = `live-cen-${Date.now().toString(36)}`; + +type Response = [string, Record, string]; + +async function raw(c: JMAPClient, calls: [string, Record, string][]): Promise { + return (await c.request(calls, USING)).methodResponses as Response[]; +} + +async function waitFor(read: () => Promise, done: (value: T) => boolean, ms = 15_000): Promise { + const until = Date.now() + ms; + let value = await read(); + while (!done(value) && Date.now() < until) { + await new Promise((resolve) => setTimeout(resolve, 500)); + value = await read(); + } + return value; +} + +/** Destroy every event of `c`'s account whose title carries the run's marker. */ +async function removeRunEvents(c: JMAPClient): Promise { + const res = await raw(c, [ + ['CalendarEvent/query', { accountId: c.accountId }, 'q'], + ['CalendarEvent/get', { + accountId: c.accountId, + '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, + properties: ['id', 'title'], + }, 'g'], + ]); + const ids = ((res.find((r) => r[2] === 'g')?.[1].list ?? []) as { id: string; title?: string }[]) + .filter((e) => e.title?.includes(RUN)) + .map((e) => e.id); + if (ids.length > 0) { + await raw(c, [['CalendarEvent/set', { accountId: c.accountId, destroy: ids, sendSchedulingMessages: false }, 'd']]); + } +} + +const t = (_key: string, fallback?: string, params?: Record) => + (fallback ?? '').replace(/\{(\w+)\}/g, (_, k: string) => String(params?.[k] ?? '')); + +liveTwo('live Stalwart calendar event notifications', () => { + let other: JMAPClient; + let before = new Set(); + const ours = new Set(); + let organizerEventId = ''; + let attendeeEventId = ''; + + const fresh = async (): Promise => + (await getCalendarEventNotifications()).filter((n) => !before.has(n.id)); + + beforeAll(async () => { + const mod = await import('../jmap-client'); + const client = new JMAPClient(); + await client.connect(SERVER!, USER, PASS); + (mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot()); + other = new JMAPClient(); + await other.connect(SERVER!, USER2!, PASS); + // Whatever is pending already belongs to someone else's run. + before = new Set((await getCalendarEventNotifications()).map((n) => n.id)); + }, 60_000); + + afterAll(async () => { + const leftover = (await getCalendarEventNotifications().catch(() => [])) + .filter((n) => ours.has(n.id) || (!before.has(n.id) && n.event?.title?.includes(RUN))); + await destroyCalendarEventNotifications(leftover.map((n) => n.id)).catch(() => undefined); + await removeRunEvents(other).catch(() => undefined); + const mod = await import('../jmap-client'); + await removeRunEvents(mod.jmapClient as unknown as JMAPClient).catch(() => undefined); + }, 60_000); + + it('lists the invitation another account sent, with who sent it and the event’s title', async () => { + const cals = await raw(other, [['Calendar/get', { accountId: other.accountId, properties: ['id', 'isDefault'] }, '0']]); + const list = cals[0][1].list as { id: string; isDefault?: boolean }[]; + const calendarId = (list.find((c) => c.isDefault) ?? list[0]).id; + const created = await raw(other, [['CalendarEvent/set', { + accountId: other.accountId, + create: { + inv: { + '@type': 'Event', + title: `${RUN} review`, + calendarIds: { [calendarId]: true }, + start: '2027-06-08T10:00:00', + timeZone: 'Europe/Berlin', + duration: 'PT1H', + organizerCalendarAddress: `mailto:${USER2}`, + participants: { + org: { + '@type': 'Participant', calendarAddress: `mailto:${USER2}`, email: USER2, + roles: { owner: true, attendee: true }, participationStatus: 'accepted', kind: 'individual', + }, + att: { + '@type': 'Participant', calendarAddress: `mailto:${USER}`, email: USER, + roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true, kind: 'individual', + }, + }, + }, + }, + sendSchedulingMessages: true, + }, '0']]); + organizerEventId = created[0][1].created?.inv?.id ?? ''; + expect(organizerEventId, JSON.stringify(created[0][1].notCreated)).toBeTruthy(); + + const notes = await waitFor(fresh, (l) => l.some((n) => n.type === 'created')); + const invite = notes.find((n) => n.type === 'created'); + expect(invite, JSON.stringify(notes)).toBeTruthy(); + ours.add(invite!.id); + attendeeEventId = invite!.calendarEventId; + expect(invite!.changedBy?.email?.toLowerCase()).toBe(USER2!.toLowerCase()); + expect(invite!.isDraft).toBe(false); + // 1.0 returns the event with the notification; 0.16 doesn't, and the + // title is read from the attendee's copy instead. + expect(invite!.event?.title).toBe(`${RUN} review`); + expect(eventNotificationToast(invite!, t)!.title).toContain(`invited you to "${RUN} review"`); + }); + + it('lists the cancellation, and forgets both once acknowledged', async () => { + expect(organizerEventId).toBeTruthy(); + await raw(other, [['CalendarEvent/set', { + accountId: other.accountId, + destroy: [organizerEventId], + sendSchedulingMessages: true, + }, '0']]); + const notes = await waitFor(fresh, (l) => l.some((n) => n.type === 'destroyed' || (n.type === 'updated' && n.calendarEventId === attendeeEventId && !ours.has(n.id)))); + const cancel = notes.find((n) => n.calendarEventId === attendeeEventId && !ours.has(n.id)); + expect(cancel, JSON.stringify(notes)).toBeTruthy(); + ours.add(cancel!.id); + const toast = eventNotificationToast(cancel!, t)!; + if (cancel!.type === 'destroyed') expect(toast.type).toBe('warning'); + + await destroyCalendarEventNotifications([...ours]); + const after = (await getCalendarEventNotifications()).map((n) => n.id); + for (const id of ours) expect(after).not.toContain(id); + // Nobody else's notifications went with them. + for (const id of before) expect(after).toContain(id); + }); +}); diff --git a/src/api/calendar-event-notifications.ts b/src/api/calendar-event-notifications.ts new file mode 100644 index 00000000..deaf3ae3 --- /dev/null +++ b/src/api/calendar-event-notifications.ts @@ -0,0 +1,94 @@ +import { jmapClient } from './jmap-client'; +import { CAPABILITIES } from './types'; +import { batched } from './jmap-result'; + +// CalendarEventNotification (draft-ietf-jmap-calendars §7): the server +// records every invitation, update and cancellation another participant made +// to an event this user takes part in. Ported from the webmail's +// getCalendarEventNotifications / destroyCalendarEventNotifications in +// lib/jmap/client.ts. + +export interface CalendarEventNotification { + id: string; + created: string; + changedBy?: { + name?: string | null; + email?: string | null; + principalId?: string | null; + } | null; + comment?: string | null; + type: 'created' | 'updated' | 'destroyed'; + calendarEventId: string; + isDraft?: boolean; + /** The event as it is now; 1.0 returns it, 0.16 leaves it out. */ + event?: { title?: string | null } | null; +} + +const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]; + +/** + * Pending event notifications, oldest first. `event` is asked for its title + * only; Stalwart 0.16 doesn't return it, so the titles it lacks are read from + * the events themselves (a cancelled event is gone and stays untitled). + * Throws when the server refuses the listing. + */ +export async function getCalendarEventNotifications(): Promise { + const accountId = jmapClient.accountId; + const response = await jmapClient.request([ + ['CalendarEventNotification/query', { + accountId, + sort: [{ property: 'created', isAscending: true }], + }, '0'], + ['CalendarEventNotification/get', { + accountId, + '#ids': { resultOf: '0', name: 'CalendarEventNotification/query', path: '/ids' }, + properties: ['id', 'created', 'changedBy', 'comment', 'type', 'calendarEventId', 'isDraft', 'event'], + }, '1'], + ], USING); + const responses = (response.methodResponses ?? []) as [string, any, string][]; + const getResp = responses.find((r) => r[0] === 'CalendarEventNotification/get'); + if (!getResp) { + const error = responses.find((r) => r[0] === 'error')?.[1] as { description?: string } | undefined; + throw new Error(error?.description || 'Failed to load calendar event notifications'); + } + const list = (getResp[1]?.list ?? []) as CalendarEventNotification[]; + await fillMissingTitles(accountId, list); + return list; +} + +async function fillMissingTitles(accountId: string, list: CalendarEventNotification[]): Promise { + const ids = Array.from(new Set( + list.filter((n) => !n.isDraft && n.type !== 'destroyed' && !n.event?.title && n.calendarEventId) + .map((n) => n.calendarEventId), + )); + if (ids.length === 0) return; + try { + const response = await jmapClient.request( + [['CalendarEvent/get', { accountId, ids, properties: ['id', 'title'] }, '0']], + USING, + ); + const [method, result] = response.methodResponses?.[0] ?? []; + if (method !== 'CalendarEvent/get') return; + const titles = new Map(); + for (const event of (result?.list ?? []) as { id: string; title?: string | null }[]) { + if (event.title) titles.set(event.id, event.title); + } + for (const n of list) { + const title = titles.get(n.calendarEventId); + if (title && !n.event?.title) n.event = { ...(n.event ?? {}), title }; + } + } catch { + // Untitled it is. + } +} + +/** Acknowledges (destroys) event notifications that have been shown. */ +export async function destroyCalendarEventNotifications(ids: string[]): Promise { + if (ids.length === 0) return; + const accountId = jmapClient.accountId; + for (const batch of batched(ids, jmapClient.getMaxObjectsInSet())) { + await jmapClient.request([ + ['CalendarEventNotification/set', { accountId, destroy: batch }, '0'], + ], USING); + } +} diff --git a/src/lib/__tests__/calendar-event-notifications.test.ts b/src/lib/__tests__/calendar-event-notifications.test.ts new file mode 100644 index 00000000..751a3c1d --- /dev/null +++ b/src/lib/__tests__/calendar-event-notifications.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ + client: { isConnected: true, accountId: 'u', currentSession: { apiUrl: 'https://mail.example/jmap/' } as unknown }, + list: vi.fn(), + destroy: vi.fn(async (_ids: string[]) => undefined), + refresh: vi.fn(async () => undefined), + toast: { info: vi.fn(), warning: vi.fn(), success: vi.fn(), error: vi.fn(), dismiss: vi.fn() }, +})); + +vi.mock('react-native', () => ({ AppState: { addEventListener: vi.fn() } })); +vi.mock('../../api/jmap-client', () => ({ jmapClient: h.client })); +vi.mock('../../api/calendar-event-notifications', () => ({ + getCalendarEventNotifications: h.list, + destroyCalendarEventNotifications: h.destroy, +})); +vi.mock('../../stores/auth-store', () => ({ useAuthStore: { subscribe: vi.fn() } })); +vi.mock('../../stores/calendar-store', () => ({ useCalendarStore: { getState: () => ({ refresh: h.refresh }) } })); +vi.mock('../../stores/toast-store', () => ({ toast: h.toast })); +vi.mock('../../stores/locale-store', () => ({ + t: (_key: string, fallback?: string, params?: Record) => + (fallback ?? '').replace(/\{(\w+)\}/g, (_, k: string) => params?.[k] ?? ''), +})); +vi.mock('../capabilities', () => ({ hasCalendarCapability: () => true })); + +import fs from 'node:fs'; +import path from 'node:path'; +import { + eventNotificationToast, + pullCalendarEventNotifications, + resetCalendarEventNotifications, + startCalendarEventNotificationSync, +} from '../calendar-event-notifications'; +import { dispatchStateChange } from '../state-change-bus'; +import type { CalendarEventNotification } from '../../api/calendar-event-notifications'; + +// Ported from the webmail's components/layout/__tests__/calendar-event-notification-toaster.test.tsx +// and lib/__tests__/pending-notification-store.test.ts. + +const t = (_key: string, fallback?: string, params?: Record) => + (fallback ?? '').replace(/\{(\w+)\}/g, (_, k: string) => String(params?.[k] ?? '')); + +function note(id: string, extra: Partial = {}): CalendarEventNotification { + return { + id, + created: '2026-10-31T05:00:00Z', + changedBy: { name: 'Land', email: 'land@example.org', principalId: 't' }, + comment: null, + type: 'updated', + calendarEventId: 'e1', + isDraft: false, + event: { title: 'Planning' }, + ...extra, + }; +} + +beforeEach(() => { + resetCalendarEventNotifications(); + h.client.accountId = 'u'; + h.list.mockReset(); + h.destroy.mockClear(); + h.refresh.mockClear(); + for (const fn of Object.values(h.toast)) fn.mockClear(); +}); + +describe('the toast for a calendar event notification', () => { + it('says who invited, changed or cancelled which event', () => { + expect(eventNotificationToast(note('1', { type: 'created' }), t)).toEqual({ type: 'info', title: 'Land invited you to "Planning"', message: undefined }); + expect(eventNotificationToast(note('2'), t)).toEqual({ type: 'info', title: 'Land updated "Planning"', message: undefined }); + expect(eventNotificationToast(note('3', { type: 'destroyed', comment: 'Sorry, ill' }), t)) + .toEqual({ type: 'warning', title: 'Land cancelled "Planning"', message: 'Sorry, ill' }); + }); + + it('falls back to the address, then "Someone", and to an untitled event', () => { + expect(eventNotificationToast(note('1', { changedBy: { email: 'x@example.org' }, event: null }), t)!.title) + .toBe('x@example.org updated "Untitled event"'); + expect(eventNotificationToast(note('1', { changedBy: null }), t)!.title).toBe('Someone updated "Planning"'); + }); + + it('shows nothing for a draft, the user’s own unsent change', () => { + expect(eventNotificationToast(note('1', { isDraft: true }), t)).toBeNull(); + }); +}); + +describe('pulling calendar event notifications', () => { + it('shows each new one, refreshes the calendar and acknowledges them on the server', async () => { + h.list.mockResolvedValue([note('a', { type: 'created' }), note('b', { type: 'destroyed' }), note('c', { isDraft: true })]); + await pullCalendarEventNotifications(); + expect(h.toast.info).toHaveBeenCalledWith('Land invited you to "Planning"', undefined); + expect(h.toast.warning).toHaveBeenCalledWith('Land cancelled "Planning"', undefined); + expect(h.toast.info).toHaveBeenCalledTimes(1); + expect(h.refresh).toHaveBeenCalledTimes(1); + // Drafts are acknowledged too, as the webmail does. + expect(h.destroy).toHaveBeenCalledWith(['a', 'b', 'c']); + }); + + it('does not show one again when the server still lists it', async () => { + h.list.mockResolvedValue([note('a')]); + await pullCalendarEventNotifications(); + await pullCalendarEventNotifications(); + expect(h.toast.info).toHaveBeenCalledTimes(1); + expect(h.destroy).toHaveBeenCalledTimes(1); + }); + + it('does nothing when there is nothing new', async () => { + h.list.mockResolvedValue([]); + await pullCalendarEventNotifications(); + expect(h.refresh).not.toHaveBeenCalled(); + expect(h.destroy).not.toHaveBeenCalled(); + }); + + it('shows nothing from the previous account after a switch during the fetch', async () => { + h.list.mockImplementation(async () => { + h.client.accountId = 'other'; + return [note('a')]; + }); + await pullCalendarEventNotifications(); + expect(h.toast.info).not.toHaveBeenCalled(); + expect(h.destroy).not.toHaveBeenCalled(); + }); + + it('pulls again when a push arrives during a pull', async () => { + let release!: () => void; + h.list.mockImplementationOnce(() => new Promise((resolve) => { release = () => resolve([note('a')]); })); + h.list.mockResolvedValueOnce([note('a'), note('b')]); + const first = pullCalendarEventNotifications(); + const second = pullCalendarEventNotifications(); + release(); + await Promise.all([first, second]); + expect(h.list).toHaveBeenCalledTimes(2); + expect(h.toast.info).toHaveBeenCalledTimes(2); + }); + + it('keeps quiet when the server refuses the listing', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + h.list.mockRejectedValue(new Error('unknownMethod')); + await expect(pullCalendarEventNotifications()).resolves.toBeUndefined(); + expect(h.toast.info).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +describe('when notifications are pulled', () => { + it('pulls on a CalendarEventNotification push', async () => { + vi.useFakeTimers(); + try { + h.list.mockResolvedValue([]); + startCalendarEventNotificationSync(); + await vi.advanceTimersByTimeAsync(1500); + h.list.mockClear(); + h.list.mockResolvedValue([note('p', { type: 'created' })]); + dispatchStateChange({ '@type': 'StateChange', changed: { u: { CalendarEventNotification: 's2' } } }); + await vi.advanceTimersByTimeAsync(1500); + expect(h.list).toHaveBeenCalledTimes(1); + expect(h.toast.info).toHaveBeenCalledWith('Land invited you to "Planning"', undefined); + } finally { + vi.useRealTimers(); + } + }); + + it('starts with the reminder sync, which the app starts at launch', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../calendar-notifications.ts'), 'utf8'); + const start = source.slice(source.indexOf('export function startCalendarNotificationSync')); + expect(start).toMatch(/syncStarted = true;[\s\S]*startCalendarEventNotificationSync\(\);/); + }); +}); diff --git a/src/lib/calendar-event-notifications.ts b/src/lib/calendar-event-notifications.ts new file mode 100644 index 00000000..b25e7d2b --- /dev/null +++ b/src/lib/calendar-event-notifications.ts @@ -0,0 +1,154 @@ +import { AppState } from 'react-native'; +import { jmapClient } from '../api/jmap-client'; +import { + destroyCalendarEventNotifications, + getCalendarEventNotifications, + type CalendarEventNotification, +} from '../api/calendar-event-notifications'; +import { useAuthStore } from '../stores/auth-store'; +import { useCalendarStore } from '../stores/calendar-store'; +import { t as translate } from '../stores/locale-store'; +import { toast } from '../stores/toast-store'; +import { hasCalendarCapability } from './capabilities'; +import { onStateChangeType } from './state-change-bus'; + +// Someone invited the user, changed or cancelled an event the user takes +// part in: the server keeps a CalendarEventNotification for it. They are +// pulled at sign-in, on every `CalendarEventNotification` push and when the +// app comes back to the foreground, shown as toasts, the events in view are +// refreshed so the change shows, and the notifications are acknowledged, +// which destroys them on the server. Ported from the webmail's +// stores/calendar-event-notification-store.ts, +// lib/pending-notification-store.ts and +// components/layout/calendar-event-notification-toaster.tsx. + +type Translate = (key: string, fallback?: string, params?: Record) => string; + +export interface EventNotificationToast { + type: 'info' | 'warning'; + title: string; + message?: string; +} + +/** The toast for one notification; null for a draft (the user's own unsent change). */ +export function eventNotificationToast(n: CalendarEventNotification, t: Translate): EventNotificationToast | null { + if (n.isDraft) return null; + const kind = n.type === 'created' ? 'invited' : n.type === 'destroyed' ? 'cancelled' : 'updated'; + const params = { + name: n.changedBy?.name || n.changedBy?.email || t('calendar_event_notifications.someone', 'Someone'), + title: n.event?.title || t('calendar_event_notifications.untitled', 'Untitled event'), + }; + const title = kind === 'invited' + ? t('calendar_event_notifications.invited', '{name} invited you to "{title}"', params) + : kind === 'cancelled' + ? t('calendar_event_notifications.cancelled', '{name} cancelled "{title}"', params) + : t('calendar_event_notifications.updated', '{name} updated "{title}"', params); + return { type: kind === 'cancelled' ? 'warning' : 'info', title, message: n.comment || undefined }; +} + +// Ids already shown in this session; a pull racing an acknowledgement must +// not show them again. +const seen = new Set(); +let seenFor: string | null = null; +let inFlight: Promise | null = null; +let again = false; +let syncStarted = false; +let pullTimer: ReturnType | null = null; + +function sessionKey(): string | null { + try { + return `${jmapClient.currentSession?.apiUrl ?? ''}|${jmapClient.accountId}`; + } catch { + return null; + } +} + +/** + * Fetch the pending notifications, show the new ones, refresh the calendar + * and acknowledge them. Concurrent calls coalesce; a push arriving while a + * pull runs pulls again afterwards. Never rejects. + */ +export function pullCalendarEventNotifications(): Promise { + if (inFlight) { + again = true; + return inFlight; + } + inFlight = (async () => { + try { + do { + again = false; + await pullOnce(); + } while (again); + } finally { + inFlight = null; + } + })(); + return inFlight; +} + +async function pullOnce(): Promise { + if (!jmapClient.isConnected || !hasCalendarCapability()) return; + const key = sessionKey(); + if (!key) return; + if (key !== seenFor) { + seen.clear(); + seenFor = key; + } + let list: CalendarEventNotification[]; + try { + list = await getCalendarEventNotifications(); + } catch (error) { + console.warn('[calendar] Failed to fetch calendar event notifications:', error); + return; + } + // Another account by now: its own pull shows its own. + if (sessionKey() !== key) return; + const fresh = list.filter((n) => !seen.has(n.id)); + if (fresh.length === 0) return; + for (const n of fresh) { + seen.add(n.id); + const shown = eventNotificationToast(n, translate as Translate); + if (shown) toast[shown.type](shown.title, shown.message); + } + void useCalendarStore.getState().refresh().catch(() => undefined); + try { + await destroyCalendarEventNotifications(fresh.map((n) => n.id)); + } catch (error) { + // Worst case they are shown again after a restart. + console.warn('[calendar] Failed to acknowledge calendar event notifications:', error); + } +} + +function pullSoon(): void { + if (pullTimer) clearTimeout(pullTimer); + // Sign-in fans out its own requests first; a burst of pushes is one pull. + pullTimer = setTimeout(() => { + pullTimer = null; + void pullCalendarEventNotifications(); + }, 1000); +} + +/** Pull event notifications for the rest of the session. Idempotent. */ +export function startCalendarEventNotificationSync(): void { + if (syncStarted) return; + syncStarted = true; + onStateChangeType('CalendarEventNotification', pullSoon); + useAuthStore.subscribe((state, prev) => { + if (state.session !== prev.session && state.session) pullSoon(); + }); + // Pushes stop while the app is in the background. + AppState.addEventListener('change', (state) => { + if (state === 'active') pullSoon(); + }); + pullSoon(); +} + +/** Forget what was shown (tests). */ +export function resetCalendarEventNotifications(): void { + seen.clear(); + seenFor = null; + inFlight = null; + again = false; + if (pullTimer) clearTimeout(pullTimer); + pullTimer = null; +} diff --git a/src/lib/calendar-notifications.ts b/src/lib/calendar-notifications.ts index b7f4312b..fd7aada6 100644 --- a/src/lib/calendar-notifications.ts +++ b/src/lib/calendar-notifications.ts @@ -11,6 +11,7 @@ import { onStateChangeType } from './state-change-bus'; import { getUpcomingAlerts, type ScheduledAlert } from './calendar-alert-scheduler'; import type { CalendarReminderTarget } from '../navigation/pending-calendar-open'; import { onDeviceRemindersChange, remindedOnDevice } from '../device-sync/app/reminders'; +import { startCalendarEventNotificationSync } from './calendar-event-notifications'; // Local reminders for calendar events and tasks. The webmail polls // `getPendingAlerts` every minute while a tab is open; a phone is mostly @@ -369,6 +370,8 @@ export function startCalendarNotificationSync(options?: { askPermission?: boolea if (syncStarted) return; syncStarted = true; installForegroundHandler(); + // Invitations, changes and cancellations others made (CalendarEventNotification). + startCalendarEventNotificationSync(); // Edits in the app, a refresh after a push, tasks loading. Moving the // calendar to another window changes no reminder. useCalendarStore.subscribe((state, prev) => {