mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
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:
@@ -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<RecurrenceEditScope>('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({
|
||||
<Text style={styles.title}>
|
||||
{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')}
|
||||
</Text>
|
||||
<Text style={styles.description}>
|
||||
{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?',
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.options}>
|
||||
{OPTIONS.map((opt) => {
|
||||
{options.map((opt) => {
|
||||
const active = selected === opt.value;
|
||||
return (
|
||||
<Pressable
|
||||
@@ -93,7 +104,9 @@ export function RecurrenceScopeDialog({
|
||||
>
|
||||
{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')}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -458,3 +458,34 @@ export function withNewOverrideDetails(
|
||||
}
|
||||
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>);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<RecurrenceScopeDialog
|
||||
visible={!!pendingAction}
|
||||
actionType={pendingAction?.kind === 'delete' ? 'delete' : 'edit'}
|
||||
actionType={pendingAction?.kind ?? 'edit'}
|
||||
onSelect={handleScopeSelect}
|
||||
onClose={() => setPendingAction(null)}
|
||||
/>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
buildFallbackExcludePatch,
|
||||
buildFallbackOverridePatch,
|
||||
buildOccurrencePatch,
|
||||
buildOccurrenceRsvpPatch,
|
||||
isBrowserExpandedOccurrence,
|
||||
isServerRecurrenceInstance,
|
||||
isSyntheticIdMutationUnsupported,
|
||||
@@ -238,6 +239,9 @@ export interface CalendarState {
|
||||
replyTo?: Record<string, string> | 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<void>;
|
||||
// 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<CalendarState>()(
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user