mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +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 {
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user