mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
feat: show when someone invites you to, changes or cancels a calendar event
This commit is contained in:
@@ -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, any>, string];
|
||||
|
||||
async function raw(c: JMAPClient, calls: [string, Record<string, unknown>, string][]): Promise<Response[]> {
|
||||
return (await c.request(calls, USING)).methodResponses as Response[];
|
||||
}
|
||||
|
||||
async function waitFor<T>(read: () => Promise<T>, done: (value: T) => boolean, ms = 15_000): Promise<T> {
|
||||
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<void> {
|
||||
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<string, string | number>) =>
|
||||
(fallback ?? '').replace(/\{(\w+)\}/g, (_, k: string) => String(params?.[k] ?? ''));
|
||||
|
||||
liveTwo('live Stalwart calendar event notifications', () => {
|
||||
let other: JMAPClient;
|
||||
let before = new Set<string>();
|
||||
const ours = new Set<string>();
|
||||
let organizerEventId = '';
|
||||
let attendeeEventId = '';
|
||||
|
||||
const fresh = async (): Promise<CalendarEventNotification[]> =>
|
||||
(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);
|
||||
});
|
||||
});
|
||||
@@ -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<CalendarEventNotification[]> {
|
||||
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<void> {
|
||||
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<string, string>();
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>) =>
|
||||
(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<string, string | number>) =>
|
||||
(fallback ?? '').replace(/\{(\w+)\}/g, (_, k: string) => String(params?.[k] ?? ''));
|
||||
|
||||
function note(id: string, extra: Partial<CalendarEventNotification> = {}): 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\(\);/);
|
||||
});
|
||||
});
|
||||
@@ -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, string | number>) => 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<string>();
|
||||
let seenFor: string | null = null;
|
||||
let inFlight: Promise<void> | null = null;
|
||||
let again = false;
|
||||
let syncStarted = false;
|
||||
let pullTimer: ReturnType<typeof setTimeout> | 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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user