diff --git a/src/api/__tests__/live-stalwart-event-notifications.test.ts b/src/api/__tests__/live-stalwart-event-notifications.test.ts index 6f35a0df..fdc8366c 100644 --- a/src/api/__tests__/live-stalwart-event-notifications.test.ts +++ b/src/api/__tests__/live-stalwart-event-notifications.test.ts @@ -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); diff --git a/src/api/calendar-event-notifications.ts b/src/api/calendar-event-notifications.ts index deaf3ae3..6ba2bf35 100644 --- a/src/api/calendar-event-notifications.ts +++ b/src/api/calendar-event-notifications.ts @@ -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): 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 ({ 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()), 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"'); diff --git a/src/lib/calendar-event-notifications.ts b/src/lib/calendar-event-notifications.ts index b25e7d2b..8f21ca1b 100644 --- a/src/lib/calendar-event-notifications.ts +++ b/src/lib/calendar-event-notifications.ts @@ -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'),