fix: say cancelled when the organizer cancels an event

This commit is contained in:
Linus Rath
2026-10-31 20:12:12 +01:00
parent d88d3dfbaf
commit 818c45c280
4 changed files with 35 additions and 5 deletions
@@ -159,8 +159,11 @@ liveTwo('live Stalwart calendar event notifications', () => {
const cancel = notes.find((n) => n.calendarEventId === attendeeEventId && !ours.has(n.id));
expect(cancel, JSON.stringify(notes)).toBeTruthy();
ours.add(cancel!.id);
// Both servers send it as an update that sets the status to cancelled
// (the attendee's copy stays, cancelled); it must read as a cancellation.
const toast = eventNotificationToast(cancel!, t)!;
if (cancel!.type === 'destroyed') expect(toast.type).toBe('warning');
expect(toast.type, JSON.stringify(cancel)).toBe('warning');
expect(toast.title).toContain(`cancelled "${RUN} review"`);
await destroyCalendarEventNotifications([...ours]);
const after = (await getCalendarEventNotifications()).map((n) => n.id);
+14 -2
View File
@@ -20,8 +20,20 @@ export interface CalendarEventNotification {
type: 'created' | 'updated' | 'destroyed';
calendarEventId: string;
isDraft?: boolean;
/** The event as it is now; 1.0 returns it, 0.16 leaves it out. */
/** The event before the change on 1.0 (as created for an invitation); 0.16 leaves it out. */
event?: { title?: string | null } | null;
/**
* What changed, for an `updated` one: a JSCalendar patch on 1.0, the whole
* event as it is now on 0.16. An organizer's cancellation reaches the
* attendee as an update that sets `status` to `cancelled` (iTIP CANCEL
* keeps the attendee's copy), never as `destroyed`.
*/
eventPatch?: { status?: string | null; title?: string | null } | null;
}
/** An organizer cancelled the event: the change sets its status to cancelled. */
export function isCancellation(n: Pick<CalendarEventNotification, 'type' | 'eventPatch'>): boolean {
return n.type === 'destroyed' || (n.type === 'updated' && n.eventPatch?.status === 'cancelled');
}
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
@@ -42,7 +54,7 @@ export async function getCalendarEventNotifications(): Promise<CalendarEventNoti
['CalendarEventNotification/get', {
accountId,
'#ids': { resultOf: '0', name: 'CalendarEventNotification/query', path: '/ids' },
properties: ['id', 'created', 'changedBy', 'comment', 'type', 'calendarEventId', 'isDraft', 'event'],
properties: ['id', 'created', 'changedBy', 'comment', 'type', 'calendarEventId', 'isDraft', 'event', 'eventPatch'],
}, '1'],
], USING);
const responses = (response.methodResponses ?? []) as [string, any, string][];
@@ -10,7 +10,8 @@ const h = vi.hoisted(() => ({
vi.mock('react-native', () => ({ AppState: { addEventListener: vi.fn() } }));
vi.mock('../../api/jmap-client', () => ({ jmapClient: h.client }));
vi.mock('../../api/calendar-event-notifications', () => ({
vi.mock('../../api/calendar-event-notifications', async (importOriginal) => ({
...(await importOriginal<typeof import('../../api/calendar-event-notifications')>()),
getCalendarEventNotifications: h.list,
destroyCalendarEventNotifications: h.destroy,
}));
@@ -71,6 +72,17 @@ describe('the toast for a calendar event notification', () => {
.toEqual({ type: 'warning', title: 'Land cancelled "Planning"', message: 'Sorry, ill' });
});
it('says cancelled, as a warning, when the organizer cancels (an update setting status cancelled)', () => {
// What Stalwart 1.0.1 sends the attendee after CalendarEvent/set destroy
// with sendSchedulingMessages; 0.16.27 sends the whole event as the patch.
expect(eventNotificationToast(note('4', { eventPatch: { sequence: null, status: 'cancelled' } as never }), t))
.toEqual({ type: 'warning', title: 'Land cancelled "Planning"', message: undefined });
expect(eventNotificationToast(note('5', { event: null, eventPatch: { title: 'Planning', status: 'cancelled' } }), t)!.type)
.toBe('warning');
// Any other change stays an update.
expect(eventNotificationToast(note('6', { eventPatch: { title: 'Planning v2' } }), t)!.type).toBe('info');
});
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"');
+4 -1
View File
@@ -3,6 +3,7 @@ import { jmapClient } from '../api/jmap-client';
import {
destroyCalendarEventNotifications,
getCalendarEventNotifications,
isCancellation,
type CalendarEventNotification,
} from '../api/calendar-event-notifications';
import { useAuthStore } from '../stores/auth-store';
@@ -33,7 +34,9 @@ export interface EventNotificationToast {
/** 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';
// Stalwart sends an organizer's cancellation as an update setting the
// status to cancelled (the webmail reads only `type` and says "updated").
const kind = n.type === 'created' ? 'invited' : isCancellation(n) ? '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'),