diff --git a/src/components/calendar/RecurrenceScopeDialog.tsx b/src/components/calendar/RecurrenceScopeDialog.tsx index 509c6a01..bea77d1a 100644 --- a/src/components/calendar/RecurrenceScopeDialog.tsx +++ b/src/components/calendar/RecurrenceScopeDialog.tsx @@ -10,7 +10,7 @@ export type RecurrenceEditScope = 'this' | 'this_and_future' | 'all'; interface RecurrenceScopeDialogProps { visible: boolean; - actionType: 'edit' | 'delete'; + actionType: 'edit' | 'delete' | 'rsvp'; onSelect: (scope: RecurrenceEditScope) => void; onClose: () => void; } @@ -32,6 +32,10 @@ export function RecurrenceScopeDialog({ const t = useLocaleStore((s) => s.t); const [selected, setSelected] = React.useState('this'); const isDelete = actionType === 'delete'; + const isRsvp = actionType === 'rsvp'; + // An attendee cannot split the organizer's series, so an answer covers + // one occurrence or all of them (webmail #1086). + const options = isRsvp ? OPTIONS.filter((opt) => opt.value !== 'this_and_future') : OPTIONS; React.useEffect(() => { if (visible) setSelected('this'); @@ -53,19 +57,26 @@ export function RecurrenceScopeDialog({ {isDelete ? t('calendar.recurrence_scope.delete_title', 'Delete recurring event') - : t('calendar.recurrence_scope.edit_title', 'Edit recurring event')} + : isRsvp + ? t('calendar.recurrence_scope.rsvp_title', 'Respond to recurring event') + : t('calendar.recurrence_scope.edit_title', 'Edit recurring event')} - {t( - 'calendar.recurrence_scope.description', - 'This is a recurring event. Which events would you like to modify?', - )} + {isRsvp + ? t( + 'calendar.recurrence_scope.rsvp_description', + 'This is a recurring event. Which events does your response apply to?', + ) + : t( + 'calendar.recurrence_scope.description', + 'This is a recurring event. Which events would you like to modify?', + )} - {OPTIONS.map((opt) => { + {options.map((opt) => { const active = selected === opt.value; return ( {isDelete ? t('calendar.recurrence_scope.delete', 'Delete') - : t('calendar.recurrence_scope.save', 'Save')} + : isRsvp + ? t('calendar.recurrence_scope.respond', 'Respond') + : t('calendar.recurrence_scope.save', 'Save')} diff --git a/src/lib/__tests__/recurrence-instances.test.ts b/src/lib/__tests__/recurrence-instances.test.ts index 4c5a6f5e..19059d14 100644 --- a/src/lib/__tests__/recurrence-instances.test.ts +++ b/src/lib/__tests__/recurrence-instances.test.ts @@ -4,6 +4,7 @@ import { buildFallbackExcludePatch, buildFallbackOverridePatch, buildOccurrencePatch, + buildOccurrenceRsvpPatch, hydrateRecurrenceInstances, isBrowserExpandedOccurrence, isServerRecurrenceInstance, @@ -229,4 +230,26 @@ describe('changing one occurrence', () => { 'recurrenceOverrides/2026-03-02T09:00:00': { excluded: true }, }); }); + + it('answers one occurrence with the whole participant map and the organizer', () => { + const occurrence = ev({ + id: 's1', baseEventId: 'm', recurrenceId: '2026-03-02T09:00:00', title: 'Standup', + organizerCalendarAddress: 'mailto:org@x', sequence: 1, + recurrenceOverrides: { '2026-03-02T09:00:00': {} }, + participants: { + org: { calendarAddress: 'mailto:org@x', participationStatus: 'accepted' }, + me: { calendarAddress: 'mailto:me@x', participationStatus: 'needs-action' }, + } as any, + }); + + expect(buildOccurrenceRsvpPatch(occurrence, 'me', 'accepted')).toEqual({ + sequence: 1, + organizerCalendarAddress: 'mailto:org@x', + participants: { + org: { calendarAddress: 'mailto:org@x', participationStatus: 'accepted' }, + me: { calendarAddress: 'mailto:me@x', participationStatus: 'accepted' }, + }, + }); + expect(buildOccurrenceRsvpPatch(occurrence, 'nobody', 'accepted')).toBeNull(); + }); }); diff --git a/src/lib/recurrence-instances.ts b/src/lib/recurrence-instances.ts index 62e2015a..a03b9c25 100644 --- a/src/lib/recurrence-instances.ts +++ b/src/lib/recurrence-instances.ts @@ -458,3 +458,34 @@ export function withNewOverrideDetails( } return { ...details, ...patch } as Partial; } + +/** + * The patch that answers one occurrence as `participantId`. + * + * - The whole participants map, with only that participant's status changed. + * Stalwart rejects a `participants//participationStatus` pointer on a + * single occurrence ("Multiple organizers found in iCalendar object", + * 0.16.23), and a pointer inside an override keeps just the patched + * participant, dropping the organizer and everyone else from it. + * - The organizer: 0.16.19 rejects the override the same way without it. + * - The sequence (see NEW_OVERRIDE_COPIED_KEYS). + * - For a new override, the occurrence's details (`withNewOverrideDetails`). + */ +export function buildOccurrenceRsvpPatch( + occurrence: Partial, + participantId: string, + status: Participant['participationStatus'], +): Partial | null { + const participant = occurrence.participants?.[participantId]; + if (!participant) return null; + const patch: Record = {}; + if (occurrence.sequence != null) patch.sequence = occurrence.sequence; + if (occurrence.organizerCalendarAddress) { + patch.organizerCalendarAddress = occurrence.organizerCalendarAddress; + } + patch.participants = { + ...occurrence.participants, + [participantId]: { ...participant, participationStatus: status }, + }; + return withNewOverrideDetails(occurrence, patch as Partial); +} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 828ef0e6..6db70c35 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -97,6 +97,12 @@ type PendingAction = sendScheduling?: boolean; } | { kind: 'delete'; event: CalendarEvent } + | { + kind: 'rsvp'; + event: CalendarEvent; + participantId: string; + status: 'accepted' | 'declined' | 'tentative'; + } | null; const AGENDA_DAYS = 30; @@ -479,12 +485,48 @@ export default function CalendarScreen() { [getMasterEvent, updateEvent], ); + // Send an answer and show it in the open detail sheet. 'occurrence' + // answers just that occurrence of a series. + const submitRsvp = React.useCallback( + async ( + ev: CalendarEvent, + participantId: string, + status: 'accepted' | 'declined' | 'tentative', + scope: 'occurrence' | 'series', + ) => { + await rsvpEvent(ev.id, participantId, status, buildReplyTo(ev), undefined, scope); + setDetailEvent((cur) => + cur && cur.id === ev.id && cur.participants?.[participantId] + ? { + ...cur, + participants: { + ...cur.participants, + [participantId]: { ...cur.participants[participantId], participationStatus: status }, + }, + } + : cur, + ); + }, + [rsvpEvent], + ); + const handleScopeSelect = React.useCallback( async (scope: RecurrenceEditScope) => { const action = pendingAction; setPendingAction(null); if (!action) return; const { event } = action; + if (action.kind === 'rsvp') { + try { + await submitRsvp(event, action.participantId, action.status, scope === 'this' ? 'occurrence' : 'series'); + } catch (err) { + Alert.alert( + t('calendar.notifications.rsvp_error', 'Failed to update response'), + err instanceof Error ? err.message : undefined, + ); + } + return; + } try { if (action.kind === 'edit') { const { updates } = action; @@ -558,7 +600,7 @@ export default function CalendarScreen() { // Best effort — the next navigation refetches anyway. } }, - [pendingAction, updateEvent, deleteEvent, createEvent, getMasterEvent, truncateRecurrenceAtEvent, refresh, t], + [pendingAction, updateEvent, deleteEvent, createEvent, getMasterEvent, truncateRecurrenceAtEvent, refresh, submitRsvp, t], ); const handleSave = React.useCallback( @@ -903,18 +945,13 @@ export default function CalendarScreen() { onExport={handleExportFromDetail} onCopyLink={handleCopyLink} onRsvp={async (ev, participantId, status) => { - await rsvpEvent(ev.id, participantId, status, buildReplyTo(ev)); - setDetailEvent((cur) => - cur && cur.id === ev.id && cur.participants?.[participantId] - ? { - ...cur, - participants: { - ...cur.participants, - [participantId]: { ...cur.participants[participantId], participationStatus: status }, - }, - } - : cur, - ); + // An answer on one occurrence of a series asks whether it covers + // just that occurrence or the whole series (webmail #1086). + if (ev.recurrenceId) { + setPendingAction({ kind: 'rsvp', event: ev, participantId, status }); + return; + } + await submitRsvp(ev, participantId, status, 'series'); }} /> @@ -932,7 +969,7 @@ export default function CalendarScreen() { setPendingAction(null)} /> diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index 3ca6f561..69be0890 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -907,6 +907,63 @@ describe('calendar-store', () => { expect(mockRsvpEvent).toHaveBeenCalledWith('base', 'me', 'accepted', undefined, undefined); }); + + it('answers just one server occurrence through its synthetic id', async () => { + const participants = { + org: { calendarAddress: 'mailto:org@x', roles: { owner: true }, participationStatus: 'accepted' }, + me: { calendarAddress: 'mailto:me@x', participationStatus: 'needs-action' }, + }; + useCalendarStore.setState({ + events: [{ + id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', recurrenceRules: weekly, + recurrenceOverrides: { '2026-03-02T09:00:00': { title: 'Standup' } }, title: 'Standup', + organizerCalendarAddress: 'mailto:org@x', sequence: 4, participants, + } as any], + }); + mockUpdateEvent.mockResolvedValue(undefined); + + await useCalendarStore.getState().rsvpEvent('s1', 'me', 'declined', undefined, undefined, 'occurrence'); + + expect(mockRsvpEvent).not.toHaveBeenCalled(); + // The whole participants map with the organizer, as Stalwart needs it + // on one occurrence; sent with scheduling so the organizer hears back. + expect(mockUpdateEvent).toHaveBeenCalledWith('s1', { + sequence: 4, + organizerCalendarAddress: 'mailto:org@x', + participants: { ...participants, me: { ...participants.me, participationStatus: 'declined' } }, + }, true, undefined); + expect(useCalendarStore.getState().events[0].participants?.me.participationStatus).toBe('declined'); + }); + }); + + describe('answering one occurrence expanded on the device', () => { + it('writes the answer as an override on the master', async () => { + useCalendarStore.setState({ + events: [{ + id: 'ev1:2026-03-02T09:00:00', originalId: 'ev1', recurrenceId: '2026-03-02T09:00:00', + start: '2026-03-02T09:00:00', duration: 'PT30M', title: 'Standup', + recurrenceRules: [{ frequency: 'weekly' }], organizerCalendarAddress: 'mailto:org@x', + participants: { me: { calendarAddress: 'mailto:me@x', participationStatus: 'needs-action' } }, + } as any], + }); + mockUpdateEvent.mockResolvedValue(undefined); + + await useCalendarStore.getState().rsvpEvent( + 'ev1:2026-03-02T09:00:00', 'me', 'tentative', undefined, undefined, 'occurrence', + ); + + expect(mockUpdateEvent).toHaveBeenCalledWith('ev1', { + recurrenceOverrides: { + '2026-03-02T09:00:00': { + start: '2026-03-02T09:00:00', + duration: 'PT30M', + title: 'Standup', + organizerCalendarAddress: 'mailto:org@x', + participants: { me: { calendarAddress: 'mailto:me@x', participationStatus: 'tentative' } }, + }, + }, + }, true, undefined); + }); }); describe('recurring series mutations', () => { diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 589baaf8..f03833dc 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -37,6 +37,7 @@ import { buildFallbackExcludePatch, buildFallbackOverridePatch, buildOccurrencePatch, + buildOccurrenceRsvpPatch, isBrowserExpandedOccurrence, isServerRecurrenceInstance, isSyntheticIdMutationUnsupported, @@ -238,6 +239,9 @@ export interface CalendarState { replyTo?: Record | null, // The event itself, for one that isn't in the loaded window. event?: CalendarEvent, + // 'occurrence': answer just this occurrence of a series (the server + // stores it as an override and replies with its RECURRENCE-ID). + scope?: 'occurrence' | 'series', ) => Promise; // Resolves with what got in and what the server refused; rejects with an // ImportRefusedError when nothing got in because everything was refused. @@ -814,28 +818,42 @@ export const useCalendarStore = create()( return mapped; }, - rsvpEvent: async (eventId, participantId, status, replyTo, event) => { + rsvpEvent: async (eventId, participantId, status, replyTo, event, scope = 'series') => { // JMAP participant ids are opaque strings (they can contain @, ., :, /); // the api layer RFC 6901-escapes them, so only reject empty values. if (!participantId) { throw new Error('Invalid participant ID'); } // An event outside the loaded window (an invitation looked up by UID) - // isn't in the store; the caller hands it over instead. An occurrence - // answers for its whole series: the stored event is updated. - const target = resolveMutationTarget(get().events, eventId, 'series'); + // isn't in the store; the caller hands it over instead. With scope + // 'series' an occurrence answers for its whole series: the stored event + // is updated. + const target = resolveMutationTarget(get().events, eventId, scope); const storeEvent = target.storeEvent ?? event; const realId = target.storeEvent || !event ? target.realId : seriesIdOf(event); const accountId = target.storeEvent ? target.accountId : event?.accountId ?? target.accountId; + const occurrence = scope === 'occurrence' && (target.isOccurrence || target.isBrowserOccurrence) + ? target.storeEvent ?? null + : null; const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent)) - || hasServerOccurrencesOf(get().events, realId, accountId); - // Repair events that are missing the organizer (e.g. imported ones) so - // Stalwart can route the REPLY; never touch an existing one. - const repair = - replyTo?.imip && storeEvent && !storeEvent.organizerCalendarAddress - ? replyTo.imip - : undefined; - await apiRsvpEvent(realId, participantId, status, repair, accountId); + || hasServerOccurrencesOf(get().events, seriesIdOf(storeEvent ?? { id: realId }), accountId); + if (occurrence) { + const patch = buildOccurrenceRsvpPatch(occurrence, participantId, status); + if (!patch) throw new Error('Participant not found on this occurrence'); + if (target.isOccurrence) { + await updateOccurrence(occurrence, target.realId, patch, true, accountId); + } else { + await updateBrowserOccurrence(target, patch, true); + } + } else { + // Repair events that are missing the organizer (e.g. imported ones) so + // Stalwart can route the REPLY; never touch an existing one. + const repair = + replyTo?.imip && storeEvent && !storeEvent.organizerCalendarAddress + ? replyTo.imip + : undefined; + await apiRsvpEvent(realId, participantId, status, repair, accountId); + } set({ events: get().events.map((e) => { if (e.id !== eventId || !e.participants?.[participantId]) return e;