feat: let attendees answer a single occurrence of a recurring event

An answer to an invitation always went to the whole series, even when
given on one occurrence. The webmail asks which occurrences it covers
(#1086); the app now does too.

- answering on an occurrence opens the scope dialog with "This event
  only" and "All events" (an attendee cannot split the organizer's
  series, so "This and following" is not offered)
- "This event only" writes the answer to that occurrence: through its
  synthetic id, or as an override on the base event for one expanded
  on the device, with the whole participant map, the organizer and
  the sequence (`buildOccurrenceRsvpPatch`), so Stalwart replies with
  the occurrence's RECURRENCE-ID
- "All events" and answers on single events work as before
This commit is contained in:
Linus Rath
2026-09-24 20:10:08 +02:00
parent f397ee3e1b
commit 96c5a3f046
6 changed files with 213 additions and 34 deletions
@@ -10,7 +10,7 @@ export type RecurrenceEditScope = 'this' | 'this_and_future' | 'all';
interface RecurrenceScopeDialogProps { interface RecurrenceScopeDialogProps {
visible: boolean; visible: boolean;
actionType: 'edit' | 'delete'; actionType: 'edit' | 'delete' | 'rsvp';
onSelect: (scope: RecurrenceEditScope) => void; onSelect: (scope: RecurrenceEditScope) => void;
onClose: () => void; onClose: () => void;
} }
@@ -32,6 +32,10 @@ export function RecurrenceScopeDialog({
const t = useLocaleStore((s) => s.t); const t = useLocaleStore((s) => s.t);
const [selected, setSelected] = React.useState<RecurrenceEditScope>('this'); const [selected, setSelected] = React.useState<RecurrenceEditScope>('this');
const isDelete = actionType === 'delete'; 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(() => { React.useEffect(() => {
if (visible) setSelected('this'); if (visible) setSelected('this');
@@ -53,19 +57,26 @@ export function RecurrenceScopeDialog({
<Text style={styles.title}> <Text style={styles.title}>
{isDelete {isDelete
? t('calendar.recurrence_scope.delete_title', 'Delete recurring event') ? 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')}
</Text> </Text>
<Text style={styles.description}> <Text style={styles.description}>
{t( {isRsvp
'calendar.recurrence_scope.description', ? t(
'This is a recurring event. Which events would you like to modify?', '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?',
)}
</Text> </Text>
</View> </View>
</View> </View>
<View style={styles.options}> <View style={styles.options}>
{OPTIONS.map((opt) => { {options.map((opt) => {
const active = selected === opt.value; const active = selected === opt.value;
return ( return (
<Pressable <Pressable
@@ -93,7 +104,9 @@ export function RecurrenceScopeDialog({
> >
{isDelete {isDelete
? t('calendar.recurrence_scope.delete', 'Delete') ? 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')}
</Button> </Button>
</View> </View>
</View> </View>
@@ -4,6 +4,7 @@ import {
buildFallbackExcludePatch, buildFallbackExcludePatch,
buildFallbackOverridePatch, buildFallbackOverridePatch,
buildOccurrencePatch, buildOccurrencePatch,
buildOccurrenceRsvpPatch,
hydrateRecurrenceInstances, hydrateRecurrenceInstances,
isBrowserExpandedOccurrence, isBrowserExpandedOccurrence,
isServerRecurrenceInstance, isServerRecurrenceInstance,
@@ -229,4 +230,26 @@ describe('changing one occurrence', () => {
'recurrenceOverrides/2026-03-02T09:00:00': { excluded: true }, '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();
});
}); });
+31
View File
@@ -458,3 +458,34 @@ export function withNewOverrideDetails(
} }
return { ...details, ...patch } as Partial<CalendarEvent>; return { ...details, ...patch } as Partial<CalendarEvent>;
} }
/**
* The patch that answers one occurrence as `participantId`.
*
* - The whole participants map, with only that participant's status changed.
* Stalwart rejects a `participants/<id>/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<CalendarEvent>,
participantId: string,
status: Participant['participationStatus'],
): Partial<CalendarEvent> | null {
const participant = occurrence.participants?.[participantId];
if (!participant) return null;
const patch: Record<string, unknown> = {};
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<CalendarEvent>);
}
+51 -14
View File
@@ -97,6 +97,12 @@ type PendingAction =
sendScheduling?: boolean; sendScheduling?: boolean;
} }
| { kind: 'delete'; event: CalendarEvent } | { kind: 'delete'; event: CalendarEvent }
| {
kind: 'rsvp';
event: CalendarEvent;
participantId: string;
status: 'accepted' | 'declined' | 'tentative';
}
| null; | null;
const AGENDA_DAYS = 30; const AGENDA_DAYS = 30;
@@ -479,12 +485,48 @@ export default function CalendarScreen() {
[getMasterEvent, updateEvent], [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( const handleScopeSelect = React.useCallback(
async (scope: RecurrenceEditScope) => { async (scope: RecurrenceEditScope) => {
const action = pendingAction; const action = pendingAction;
setPendingAction(null); setPendingAction(null);
if (!action) return; if (!action) return;
const { event } = action; 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 { try {
if (action.kind === 'edit') { if (action.kind === 'edit') {
const { updates } = action; const { updates } = action;
@@ -558,7 +600,7 @@ export default function CalendarScreen() {
// Best effort — the next navigation refetches anyway. // 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( const handleSave = React.useCallback(
@@ -903,18 +945,13 @@ export default function CalendarScreen() {
onExport={handleExportFromDetail} onExport={handleExportFromDetail}
onCopyLink={handleCopyLink} onCopyLink={handleCopyLink}
onRsvp={async (ev, participantId, status) => { onRsvp={async (ev, participantId, status) => {
await rsvpEvent(ev.id, participantId, status, buildReplyTo(ev)); // An answer on one occurrence of a series asks whether it covers
setDetailEvent((cur) => // just that occurrence or the whole series (webmail #1086).
cur && cur.id === ev.id && cur.participants?.[participantId] if (ev.recurrenceId) {
? { setPendingAction({ kind: 'rsvp', event: ev, participantId, status });
...cur, return;
participants: { }
...cur.participants, await submitRsvp(ev, participantId, status, 'series');
[participantId]: { ...cur.participants[participantId], participationStatus: status },
},
}
: cur,
);
}} }}
/> />
@@ -932,7 +969,7 @@ export default function CalendarScreen() {
<RecurrenceScopeDialog <RecurrenceScopeDialog
visible={!!pendingAction} visible={!!pendingAction}
actionType={pendingAction?.kind === 'delete' ? 'delete' : 'edit'} actionType={pendingAction?.kind ?? 'edit'}
onSelect={handleScopeSelect} onSelect={handleScopeSelect}
onClose={() => setPendingAction(null)} onClose={() => setPendingAction(null)}
/> />
@@ -907,6 +907,63 @@ describe('calendar-store', () => {
expect(mockRsvpEvent).toHaveBeenCalledWith('base', 'me', 'accepted', undefined, undefined); 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', () => { describe('recurring series mutations', () => {
+30 -12
View File
@@ -37,6 +37,7 @@ import {
buildFallbackExcludePatch, buildFallbackExcludePatch,
buildFallbackOverridePatch, buildFallbackOverridePatch,
buildOccurrencePatch, buildOccurrencePatch,
buildOccurrenceRsvpPatch,
isBrowserExpandedOccurrence, isBrowserExpandedOccurrence,
isServerRecurrenceInstance, isServerRecurrenceInstance,
isSyntheticIdMutationUnsupported, isSyntheticIdMutationUnsupported,
@@ -238,6 +239,9 @@ export interface CalendarState {
replyTo?: Record<string, string> | null, replyTo?: Record<string, string> | null,
// The event itself, for one that isn't in the loaded window. // The event itself, for one that isn't in the loaded window.
event?: CalendarEvent, 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<void>; ) => Promise<void>;
// Resolves with what got in and what the server refused; rejects with an // Resolves with what got in and what the server refused; rejects with an
// ImportRefusedError when nothing got in because everything was refused. // ImportRefusedError when nothing got in because everything was refused.
@@ -814,28 +818,42 @@ export const useCalendarStore = create<CalendarState>()(
return mapped; 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 @, ., :, /); // JMAP participant ids are opaque strings (they can contain @, ., :, /);
// the api layer RFC 6901-escapes them, so only reject empty values. // the api layer RFC 6901-escapes them, so only reject empty values.
if (!participantId) { if (!participantId) {
throw new Error('Invalid participant ID'); throw new Error('Invalid participant ID');
} }
// An event outside the loaded window (an invitation looked up by UID) // 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 // isn't in the store; the caller hands it over instead. With scope
// answers for its whole series: the stored event is updated. // 'series' an occurrence answers for its whole series: the stored event
const target = resolveMutationTarget(get().events, eventId, 'series'); // is updated.
const target = resolveMutationTarget(get().events, eventId, scope);
const storeEvent = target.storeEvent ?? event; const storeEvent = target.storeEvent ?? event;
const realId = target.storeEvent || !event ? target.realId : seriesIdOf(event); const realId = target.storeEvent || !event ? target.realId : seriesIdOf(event);
const accountId = target.storeEvent ? target.accountId : event?.accountId ?? target.accountId; 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)) const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|| hasServerOccurrencesOf(get().events, realId, accountId); || hasServerOccurrencesOf(get().events, seriesIdOf(storeEvent ?? { id: realId }), accountId);
// Repair events that are missing the organizer (e.g. imported ones) so if (occurrence) {
// Stalwart can route the REPLY; never touch an existing one. const patch = buildOccurrenceRsvpPatch(occurrence, participantId, status);
const repair = if (!patch) throw new Error('Participant not found on this occurrence');
replyTo?.imip && storeEvent && !storeEvent.organizerCalendarAddress if (target.isOccurrence) {
? replyTo.imip await updateOccurrence(occurrence, target.realId, patch, true, accountId);
: undefined; } else {
await apiRsvpEvent(realId, participantId, status, repair, accountId); 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({ set({
events: get().events.map((e) => { events: get().events.map((e) => {
if (e.id !== eventId || !e.participants?.[participantId]) return e; if (e.id !== eventId || !e.participants?.[participantId]) return e;