mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
fix: ask attendees again when their organizer drags a meeting
A move outside the editor kept every answer. A drag or resize in the
week and day views, a drop in the month view and an applied counter
proposal wrote the new time with the attendees' stored answers:
Stalwart's update then said "accepted" for a time nobody had accepted,
and a dragged occurrence, which gets its own override, reached no
attendee who had answered (live on 1.0 and 0.16.23). When the user
organizes the meeting these moves now ask the attendees again, as the
editor does; one helper (`userOrganizes`) decides for both.
Also:
- A changed recurrence rule counts as a move for the series: a custom
rule moved from Monday to Tuesday asks again. A rule the user did not
touch is no change however the server spells it, and on one
occurrence, where no rule applies, it is none.
- An event that lasts no time (a deadline) keeps its zero length on
save instead of becoming an hour long and asking everyone again.
- An attendee's save of their own copy - an alert on one occurrence, a
drag - wrote the participants again and sent the organizer a
"Reply:" mail. It now leaves the participants alone and sends
nothing. The editor shows someone else's attendee list read-only and
no "Send invitations" box, which only ever controlled the organizer's
messages; answers go out through the answer buttons.
- An existing override written on the base event (0.16 before 0.16.20,
and ranges the browser expands) gets the series' organizer again when
it has none, as every save wrote it before 4c20b09c: 0.16.19 refuses
an answer to such an occurrence. 0.16.23 takes the key and drops it;
1.0 refuses it inside an override once, and the store writes the
override again without it (both live).
- The comment on `organizerCalendarAddress` said Stalwart takes the
ORGANIZER solely from it. Both versions take it from the owner
participant when it is missing (live).
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EventModal } from '../event-modal';
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
@@ -40,8 +40,7 @@ function meeting(organizer: string, participants: Record<string, unknown>): Cale
|
||||
} as unknown as CalendarEvent;
|
||||
}
|
||||
|
||||
async function saveUnchanged(event: CalendarEvent): Promise<Partial<CalendarEvent>> {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
async function saveUnchanged(event: CalendarEvent, onSave = vi.fn().mockResolvedValue(undefined)): Promise<Partial<CalendarEvent>> {
|
||||
render(
|
||||
<EventModal
|
||||
event={event}
|
||||
@@ -58,19 +57,41 @@ async function saveUnchanged(event: CalendarEvent): Promise<Partial<CalendarEven
|
||||
}
|
||||
|
||||
describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal organizer and answers on Stalwart %s', (version) => {
|
||||
it('keeps the sender of an invitation and the user\'s answer', async () => {
|
||||
const data = await saveUnchanged(meeting('boss@example.org', {
|
||||
boss: participant(version, 'boss@example.org', true, 'accepted'),
|
||||
mine: participant(version, 'me@example.com', false, 'accepted'),
|
||||
bob: participant(version, 'bob@example.org', false, 'declined'),
|
||||
}));
|
||||
const invitation = () => meeting('boss@example.org', {
|
||||
boss: participant(version, 'boss@example.org', true, 'accepted'),
|
||||
mine: participant(version, 'me@example.com', false, 'accepted'),
|
||||
bob: participant(version, 'bob@example.org', false, 'declined'),
|
||||
});
|
||||
|
||||
// The user's own copy of someone else's meeting: a save (an alert, a note)
|
||||
// used to rewrite the participants and send the organizer a reply.
|
||||
it('leaves the participants of an invitation alone and sends no messages', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const data = await saveUnchanged(invitation(), onSave);
|
||||
|
||||
expect(data).not.toHaveProperty('participants');
|
||||
expect(data).not.toHaveProperty('organizerCalendarAddress');
|
||||
const participants = data.participants as Record<string, CalendarParticipant>;
|
||||
expect(Object.keys(participants).sort()).toEqual(['bob', 'boss', 'mine']);
|
||||
expect(participants.boss).toMatchObject({ calendarAddress: 'mailto:boss@example.org', roles: { owner: true } });
|
||||
expect(participants.mine.participationStatus).toBe('accepted');
|
||||
expect(participants.bob.participationStatus).toBe('declined');
|
||||
expect(onSave.mock.calls[0][1]).toBe(false);
|
||||
});
|
||||
|
||||
it('offers no invitations and no attendee changes on an invitation', () => {
|
||||
render(<EventModal event={invitation()} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['me@example.com']} />);
|
||||
fireEvent.click(screen.getByText('events.edit'));
|
||||
|
||||
expect(screen.queryByText('participants.send_invitations')).toBeNull();
|
||||
expect(screen.getByPlaceholderText('email_placeholder')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('offers both on the user\'s own meeting', () => {
|
||||
const own = meeting('me@example.com', {
|
||||
org: participant(version, 'me@example.com', true, 'accepted'),
|
||||
bob: participant(version, 'bob@example.org', false, 'declined'),
|
||||
});
|
||||
render(<EventModal event={own} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['me@example.com']} />);
|
||||
fireEvent.click(screen.getByText('events.edit'));
|
||||
|
||||
expect(screen.getByText('participants.send_invitations')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('email_placeholder')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('keeps the letter case a series is organized under', async () => {
|
||||
@@ -160,8 +181,54 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal answers after a
|
||||
|
||||
const data = await save(invitation, moveStartTo('22:00'));
|
||||
|
||||
// The user's own copy moves; the organizer's participants stay as they are.
|
||||
expect(data.start).toBe('2026-10-12T22:00:00');
|
||||
expect(statuses(data)).toEqual({ boss: 'accepted', mine: 'accepted', bob: 'declined' });
|
||||
expect(data).not.toHaveProperty('participants');
|
||||
});
|
||||
|
||||
// A series that happens on other days is a move as well.
|
||||
const weekly = (rule: Record<string, unknown>, extra: Partial<CalendarEvent> = {}) => ({
|
||||
...own(), recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', ...rule }], ...extra,
|
||||
}) as CalendarEvent;
|
||||
// The recurrence dropdown, found by the option it shows.
|
||||
const pickRecurrence = (from: string, to: string) => (container: HTMLElement) => {
|
||||
const select = Array.from(container.querySelectorAll('select')).find((element) => element.value === from)!;
|
||||
fireEvent.change(select, { target: { value: to } });
|
||||
};
|
||||
|
||||
it('asks the attendees again when the user gives their series another rule', async () => {
|
||||
const data = await save(weekly({}), pickRecurrence('weekly', 'daily'));
|
||||
|
||||
expect(statuses(data)).toEqual({ org: 'accepted', bob: 'needs-action', carol: 'needs-action' });
|
||||
});
|
||||
|
||||
it('keeps the answers when the user leaves a custom rule alone', async () => {
|
||||
// A custom rule as 1.0 returns it: typed NDay, no interval.
|
||||
const custom = weekly({ byDay: [{ '@type': 'NDay', day: 'mo' }], count: 5 });
|
||||
const touched = await save(custom, pickRecurrence('custom', 'weekly'));
|
||||
cleanup();
|
||||
const untouched = await save(custom, () => {});
|
||||
|
||||
// Switched to a plain weekly rule: a change.
|
||||
expect(statuses(touched)).toEqual({ org: 'accepted', bob: 'needs-action', carol: 'needs-action' });
|
||||
expect(statuses(untouched)).toEqual({ org: 'accepted', bob: 'accepted', carol: 'declined' });
|
||||
});
|
||||
|
||||
it('keeps the answers when a rule is changed on one occurrence, where it does not apply', async () => {
|
||||
const occurrence = weekly({}, { recurrenceId: '2026-10-12T08:00:00' });
|
||||
|
||||
const data = await save(occurrence, pickRecurrence('weekly', 'daily'));
|
||||
|
||||
expect(statuses(data)).toEqual({ org: 'accepted', bob: 'accepted', carol: 'declined' });
|
||||
});
|
||||
|
||||
it('keeps an event that lasts no time as it is, answers included', async () => {
|
||||
const deadline = { ...own(), duration: 'PT0M', utcEnd: '2026-10-12T12:00:00Z' } as CalendarEvent;
|
||||
|
||||
const data = await save(deadline, () => {});
|
||||
|
||||
expect(data.duration).toBe('PT0M');
|
||||
expect(statuses(data)).toEqual({ org: 'accepted', bob: 'accepted', carol: 'declined' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1402,6 +1402,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
isMobile={isMobile}
|
||||
pendingPreview={pendingPreview}
|
||||
currentUserEmails={currentUserEmails}
|
||||
/>
|
||||
);
|
||||
case "week":
|
||||
@@ -1422,6 +1423,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
timeFormat={timeFormat}
|
||||
isMobile={isMobile}
|
||||
pendingPreview={pendingPreview}
|
||||
currentUserEmails={currentUserEmails}
|
||||
tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined}
|
||||
onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
|
||||
/>
|
||||
@@ -1442,6 +1444,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
timeFormat={timeFormat}
|
||||
isMobile={isMobile}
|
||||
pendingPreview={pendingPreview}
|
||||
currentUserEmails={currentUserEmails}
|
||||
tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined}
|
||||
onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
|
||||
/>
|
||||
|
||||
@@ -30,6 +30,8 @@ interface CalendarDayViewProps extends ScrollWindowViewProps {
|
||||
timeFormat?: "12h" | "24h";
|
||||
isMobile?: boolean;
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
/** The user's calendar addresses: moving an event they organize asks its attendees again. */
|
||||
currentUserEmails?: string[];
|
||||
tasks?: CalendarTask[];
|
||||
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||
}
|
||||
@@ -59,6 +61,7 @@ export function CalendarDayView({
|
||||
timeFormat = "24h",
|
||||
isMobile,
|
||||
pendingPreview,
|
||||
currentUserEmails,
|
||||
tasks,
|
||||
onToggleTaskComplete,
|
||||
}: CalendarDayViewProps) {
|
||||
@@ -271,6 +274,7 @@ export function CalendarDayView({
|
||||
error: t("notifications.event_error"),
|
||||
},
|
||||
describeError,
|
||||
currentUserEmails,
|
||||
isMobile,
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/li
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { movedEventUpdates } from "@/lib/calendar-participants";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -31,6 +32,8 @@ interface CalendarMonthViewProps extends ScrollWindowViewProps {
|
||||
firstDayOfWeek?: number;
|
||||
isMobile?: boolean;
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
/** The user's calendar addresses: moving an event they organize asks its attendees again. */
|
||||
currentUserEmails?: string[];
|
||||
}
|
||||
|
||||
/** Fraction of the viewport height at which the "current month" is sampled. */
|
||||
@@ -57,6 +60,7 @@ export function CalendarMonthView({
|
||||
onCreateAtTime,
|
||||
isMobile,
|
||||
pendingPreview,
|
||||
currentUserEmails,
|
||||
}: CalendarMonthViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const describeError = useCalendarErrorMessage();
|
||||
@@ -234,12 +238,12 @@ export function CalendarMonthView({
|
||||
if (newStartISO === data.originalStart) return;
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) return;
|
||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
||||
const moved = movedEventUpdates(event, { start: newStartISO }, currentUserEmails ?? []);
|
||||
await useCalendarStore.getState().updateEvent(client, data.eventId, moved.updates, moved.sendSchedulingMessages);
|
||||
} catch (error) {
|
||||
toast.error(describeError(error, t("notifications.event_move_error")));
|
||||
}
|
||||
}, [t, describeError]);
|
||||
}, [t, describeError, currentUserEmails]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={formatFullDate(selectedDate)}>
|
||||
|
||||
@@ -34,6 +34,8 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps {
|
||||
timeFormat?: "12h" | "24h";
|
||||
isMobile?: boolean;
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
/** The user's calendar addresses: moving an event they organize asks its attendees again. */
|
||||
currentUserEmails?: string[];
|
||||
tasks?: CalendarTask[];
|
||||
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||
}
|
||||
@@ -66,6 +68,7 @@ export function CalendarWeekView({
|
||||
timeFormat = "24h",
|
||||
isMobile,
|
||||
pendingPreview,
|
||||
currentUserEmails,
|
||||
tasks,
|
||||
onToggleTaskComplete,
|
||||
}: CalendarWeekViewProps) {
|
||||
@@ -304,6 +307,7 @@ export function CalendarWeekView({
|
||||
error: t("notifications.event_error"),
|
||||
},
|
||||
describeError,
|
||||
currentUserEmails,
|
||||
isMobile,
|
||||
});
|
||||
|
||||
|
||||
@@ -12,16 +12,16 @@ import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { displayNow, getEffectiveTimeZone } from "@/lib/timezone";
|
||||
import { createRecurrenceRule } from "@/lib/recurrence-rule";
|
||||
import { createRecurrenceRule, sameRecurrenceRules } from "@/lib/recurrence-rule";
|
||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||
import { ParticipantAvailability } from "./participant-availability";
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
getUserStatus,
|
||||
getParticipantList,
|
||||
getStatusCounts,
|
||||
participantFieldsToSave,
|
||||
userOrganizes,
|
||||
} from "@/lib/calendar-participants";
|
||||
import { canUserRsvp, getEventEditability, canCreateEventsIn } from "@/lib/calendar-editability";
|
||||
import { withOwnAlertIds } from "@/lib/calendar-alerts";
|
||||
@@ -318,10 +318,12 @@ export function EventModal({
|
||||
const formatEventDate = useFormatEventDate();
|
||||
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
|
||||
|
||||
// Only the organizer invites: someone else's invitation keeps its
|
||||
// attendees as the organizer sent them, and a save of the user's own copy
|
||||
// sends no messages.
|
||||
const userIsOrganizer = useMemo(() => {
|
||||
if (!event) return true;
|
||||
if (!event.participants) return true;
|
||||
return isOrganizer(event, currentUserEmails);
|
||||
if (!event?.participants) return true;
|
||||
return userOrganizes(event, currentUserEmails);
|
||||
}, [event, currentUserEmails]);
|
||||
|
||||
// Gate affordances on calendar rights, not identity (see calendar-editability).
|
||||
@@ -452,6 +454,9 @@ export function EventModal({
|
||||
const rule = event.recurrenceRules[0];
|
||||
return isSimpleRecurrenceRule(rule) ? null : rule;
|
||||
});
|
||||
// The recurrence the editor opened with: a rule the user did not touch is
|
||||
// no change, however the server spells it.
|
||||
const initialRecurrenceRef = useRef({ recurrence, customRule });
|
||||
const [showRecurrenceEditor, setShowRecurrenceEditor] = useState(false);
|
||||
// Dropdown value to restore when the custom editor is cancelled without a saved rule.
|
||||
const recurrenceBeforeCustomRef = useRef<RecurrenceOption>("none");
|
||||
@@ -608,7 +613,11 @@ export function EventModal({
|
||||
} else {
|
||||
const endStr = `${endDate}T${endTime}:00`;
|
||||
end = new Date(endStr);
|
||||
if (end <= start) {
|
||||
// An end at or before the start becomes an hour - but an event that
|
||||
// lasts no time (a deadline, a reminder) keeps doing so.
|
||||
const lastedNoTime = !!event && !event.showWithoutTime
|
||||
&& getEventEndDate(event).getTime() === getEventStartDate(event).getTime();
|
||||
if (end < start || (end.getTime() === start.getTime() && !lastedNoTime)) {
|
||||
end = new Date(start.getTime() + 3600000);
|
||||
}
|
||||
duration = buildDuration(start, end);
|
||||
@@ -679,31 +688,42 @@ export function EventModal({
|
||||
.flatMap((c) => [...Object.keys(c.defaultAlertsWithTime ?? {}), ...Object.keys(c.defaultAlertsWithoutTime ?? {})]);
|
||||
Object.assign(data, buildAlertsPatch(event, alertRows, preservedAlertsRef.current, defaultAlertIds));
|
||||
|
||||
// A rule the user did not touch is no change, however the server spells it.
|
||||
const initial = initialRecurrenceRef.current;
|
||||
const ruleChanged = !!event && (recurrence !== initial.recurrence || customRule !== initial.customRule)
|
||||
&& !sameRecurrenceRules(event.recurrenceRules, data.recurrenceRules === undefined ? event.recurrenceRules : data.recurrenceRules);
|
||||
|
||||
// Keeps the event's own organizer - an invitation's sender, the exact
|
||||
// address a series is organized under - and the answers of attendees who
|
||||
// stay; the user organizes an event that has no organizer yet. Moved by
|
||||
// the user as its organizer, the others are asked again. The dates are
|
||||
// those of the event the editor was opened on - one occurrence of a
|
||||
// series - so a move applies to whichever occurrences the save covers.
|
||||
Object.assign(data, participantFieldsToSave(event, effectiveAttendees, {
|
||||
userEmail: currentUserEmails[0],
|
||||
userEmails: currentUserEmails,
|
||||
resolveName: resolveContactName,
|
||||
rescheduled: !!event && changesWhen(event, { allDay, start, end }),
|
||||
}));
|
||||
// the user as its organizer, or given another recurrence, the others are
|
||||
// asked again. The dates are those of the event the editor was opened on
|
||||
// - one occurrence of a series - so a move applies to whichever
|
||||
// occurrences the save covers; a rule changes only with the series, so
|
||||
// on one occurrence it is no move. Someone else's event keeps its
|
||||
// participants as its organizer sent them.
|
||||
if (userIsOrganizer) {
|
||||
Object.assign(data, participantFieldsToSave(event, effectiveAttendees, {
|
||||
userEmail: currentUserEmails[0],
|
||||
userEmails: currentUserEmails,
|
||||
resolveName: resolveContactName,
|
||||
rescheduled: !!event && (changesWhen(event, { allDay, start, end }) || (ruleChanged && !event.recurrenceId)),
|
||||
}));
|
||||
}
|
||||
|
||||
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||
const shouldSendScheduling = userIsOrganizer && effectiveAttendees.length > 0 && sendInvitations;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(data, shouldSendScheduling, {
|
||||
initialAttendees,
|
||||
attendees: effectiveAttendees,
|
||||
when: { allDay, start, end },
|
||||
ruleChanged,
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, alertRows, attendees, initialAttendees, sendInvitations, currentUserEmails, resolveContactName, event, onSave, isSaving]);
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, alertRows, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, onSave, isSaving]);
|
||||
|
||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||
if (!event || !userParticipantId || !onRsvp) return;
|
||||
@@ -1195,11 +1215,14 @@ export function EventModal({
|
||||
{t("participants.title")}
|
||||
</span>
|
||||
</label>
|
||||
{/* The organizer decides who is invited; an attendee's save
|
||||
keeps the list as it came. */}
|
||||
<ParticipantInput
|
||||
ref={participantInputRef}
|
||||
participants={attendees}
|
||||
onAdd={handleAddAttendee}
|
||||
onRemove={handleRemoveAttendee}
|
||||
disabled={!userIsOrganizer}
|
||||
/>
|
||||
{/* Only a warning: 0.16 advertises the limit without enforcing
|
||||
it, Stalwart 1.0 refuses to save more participants - the
|
||||
@@ -1427,7 +1450,9 @@ export function EventModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{attendees.length > 0 && (
|
||||
{/* Only the organizer's messages: the editor never changes the
|
||||
user's own answer, which the answer buttons send. */}
|
||||
{attendees.length > 0 && userIsOrganizer && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -749,6 +749,35 @@ describe('CalendarInvitationBanner', () => {
|
||||
});
|
||||
|
||||
expect(screen.getByText('Proposed changes applied.')).toBeInTheDocument();
|
||||
// Another time: the attendees are asked again, as for any move by the
|
||||
// organizer; the organizer keeps their answer.
|
||||
const [, , patch, send] = mocks.calendarState.updateEvent.mock.calls[0];
|
||||
expect(patch).toMatchObject({ start: '2026-03-27T10:00:00Z' });
|
||||
expect(patch.participants.organizer.participationStatus).toBe('accepted');
|
||||
expect(patch.participants.attendee.participationStatus).toBe('needs-action');
|
||||
expect(send).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the answers when an applied proposal does not change the time', async () => {
|
||||
mocks.calendarState.updateEvent = vi.fn().mockResolvedValue(undefined);
|
||||
const event = (room: string) => ({
|
||||
uid: 'uid-6', title: 'Sync', start: '2026-03-27T09:00:00Z', duration: 'PT1H',
|
||||
locations: { room: { '@type': 'Location', name: room } },
|
||||
participants: {
|
||||
organizer: { '@type': 'Participant', email: 'user@example.com', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
attendee: { '@type': 'Participant', email: 'alice@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
|
||||
},
|
||||
});
|
||||
mocks.calendarState.events = [{ id: 'event-6', ...event('Room A') }];
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([event('Room B')]);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-6', attachments: [{ partId: '1', blobId: 'blob-6', size: 256, type: 'text/calendar; method=COUNTER', name: 'counter.ics' }] })} />);
|
||||
await waitFor(() => expect(screen.getByText('Proposed changes')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Apply proposed changes'));
|
||||
await waitFor(() => expect(mocks.calendarState.updateEvent).toHaveBeenCalledTimes(1));
|
||||
|
||||
const [, , patch] = mocks.calendarState.updateEvent.mock.calls[0];
|
||||
expect(patch).toEqual({ locations: { room: { '@type': 'Location', name: 'Room B' } } });
|
||||
});
|
||||
|
||||
// Stalwart 1.0 refuses an event with more participants than
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type InvitationMethod,
|
||||
type InvitationTrustAssessment,
|
||||
} from '@/lib/calendar-invitation';
|
||||
import { participantsAskedAgain } from '@/lib/calendar-participants';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeColor } from '@/components/calendar/event-card';
|
||||
import { RecipientPopover } from './recipient-popover';
|
||||
@@ -744,7 +745,11 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
await updateEvent(client, existingEvent.id, proposalPatch, true);
|
||||
// A proposal for another time moves the event: its attendees are asked
|
||||
// again, as the editor and a drag ask them (participantsAskedAgain).
|
||||
const moves = (['start', 'duration', 'timeZone', 'showWithoutTime'] as const).some((key) => key in proposalPatch);
|
||||
const participants = moves ? participantsAskedAgain(existingEvent) : null;
|
||||
await updateEvent(client, existingEvent.id, participants ? { ...proposalPatch, participants } : proposalPatch, true);
|
||||
setActionNotice(t('proposal_applied'));
|
||||
setState('parsed');
|
||||
setRsvpStatus(null);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DragEvent, PointerEvent } from "react";
|
||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
|
||||
const errorMessages = { resize: "resize", move: "move", created: "created", error: "error" };
|
||||
|
||||
@@ -64,3 +68,95 @@ describe("useTimeGridInteractions slot clicks (#435)", () => {
|
||||
expect(end).toEqual(new Date(2026, 8, 4, 0, 0, 0, 0));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// A drag or a resize of an event the user organizes asks its attendees
|
||||
// again and tells them, as the editor does for a move; anyone else moves
|
||||
// their own copy without messages. Live on 1.0 and 0.16.23 a dragged event
|
||||
// kept "accepted" for the new time, and a dragged occurrence reached no
|
||||
// attendee who had answered.
|
||||
describe.each<["0.16" | "1.0"]>([["0.16"], ["1.0"]])("useTimeGridInteractions moves on Stalwart %s", (version) => {
|
||||
const client = { id: "client" };
|
||||
const original = useCalendarStore.getState().updateEvent;
|
||||
let updateEvent: ReturnType<typeof vi.fn>;
|
||||
|
||||
function meeting(organizer: string): CalendarEvent {
|
||||
return {
|
||||
id: "e1", uid: "u1", title: "Planning", start: "2026-09-03T10:00:00", duration: "PT1H",
|
||||
timeZone: "Europe/Berlin", showWithoutTime: false, calendarIds: { cal: true },
|
||||
organizerCalendarAddress: `mailto:${organizer}`,
|
||||
participants: {
|
||||
org: { "@type": "Participant", calendarAddress: `mailto:${organizer}`, roles: { owner: true }, participationStatus: "accepted" },
|
||||
bob: {
|
||||
"@type": "Participant", calendarAddress: "mailto:bob@example.org", participationStatus: "accepted",
|
||||
// 0.16.23 drops an attendee's roles, 1.0 keeps them.
|
||||
...(version === "1.0" ? { roles: { attendee: true } } : {}),
|
||||
},
|
||||
},
|
||||
} as unknown as CalendarEvent;
|
||||
}
|
||||
|
||||
function interactions() {
|
||||
return renderHook(() => useTimeGridInteractions({
|
||||
hourHeight: 60, calendars: [], onCreateRange: vi.fn(), errorMessages, currentUserEmails: ["me@example.com"],
|
||||
})).result;
|
||||
}
|
||||
|
||||
const drop = (eventId: string) => ({
|
||||
preventDefault: () => {},
|
||||
dataTransfer: { getData: () => JSON.stringify({ eventId, originalStart: "2026-09-03T10:00:00" }) },
|
||||
currentTarget: { getBoundingClientRect: () => ({ top: 0 }) },
|
||||
clientY: 11 * 60,
|
||||
}) as unknown as DragEvent<HTMLDivElement>;
|
||||
|
||||
beforeEach(() => {
|
||||
updateEvent = vi.fn().mockResolvedValue(undefined);
|
||||
useCalendarStore.setState({ updateEvent: updateEvent as never });
|
||||
useAuthStore.setState({ client: client as never });
|
||||
});
|
||||
afterEach(() => {
|
||||
useCalendarStore.setState({ events: [], updateEvent: original });
|
||||
useAuthStore.setState({ client: null });
|
||||
});
|
||||
|
||||
it("asks the attendees again when the user drags their meeting", async () => {
|
||||
useCalendarStore.setState({ events: [meeting("me@example.com")] });
|
||||
const result = interactions();
|
||||
|
||||
await act(() => result.current.handleColumnDrop(drop("e1"), new Date(2026, 8, 3)));
|
||||
|
||||
const [, id, updates, send] = updateEvent.mock.calls[0];
|
||||
expect(id).toBe("e1");
|
||||
expect(updates.start).toMatch(/^2026-09-03T/);
|
||||
expect(updates.participants.org.participationStatus).toBe("accepted");
|
||||
expect(updates.participants.bob.participationStatus).toBe("needs-action");
|
||||
expect(send).toBe(true);
|
||||
});
|
||||
|
||||
it("moves someone else's meeting quietly", async () => {
|
||||
useCalendarStore.setState({ events: [meeting("boss@example.org")] });
|
||||
const result = interactions();
|
||||
|
||||
await act(() => result.current.handleColumnDrop(drop("e1"), new Date(2026, 8, 3)));
|
||||
|
||||
const [, , updates, send] = updateEvent.mock.calls[0];
|
||||
expect(updates).not.toHaveProperty("participants");
|
||||
expect(send).toBeUndefined();
|
||||
});
|
||||
|
||||
it("asks the attendees again when the user resizes their meeting", async () => {
|
||||
useCalendarStore.setState({ events: [meeting("me@example.com")] });
|
||||
const result = interactions();
|
||||
const target = { setPointerCapture: () => {}, releasePointerCapture: () => {} };
|
||||
|
||||
act(() => result.current.handleResizePointerDown("e1", "bottom", 600, 60, {
|
||||
stopPropagation: () => {}, preventDefault: () => {}, clientY: 0, pointerId: 1, target,
|
||||
} as unknown as PointerEvent));
|
||||
await act(() => result.current.handleResizePointerUp({ clientY: 30, target } as unknown as PointerEvent));
|
||||
|
||||
const [, , updates, send] = updateEvent.mock.calls[0];
|
||||
expect(updates.duration).toBe("PT1H30M");
|
||||
expect(updates.participants.bob.participationStatus).toBe("needs-action");
|
||||
expect(send).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toast } from "@/stores/toast-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { formatIsoInTimeZone } from "@/lib/calendar-utils";
|
||||
import { fromDisplayDate, getEffectiveTimeZone } from "@/lib/timezone";
|
||||
import { movedEventUpdates } from "@/lib/calendar-participants";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
|
||||
interface DragCreateState {
|
||||
@@ -48,6 +49,8 @@ interface UseTimeGridInteractionsOptions {
|
||||
};
|
||||
/** The message for a refused move or resize, `fallback` when there is nothing more to say. */
|
||||
describeError?: (error: unknown, fallback: string) => string;
|
||||
/** The user's calendar addresses: moving an event they organize asks its attendees again. */
|
||||
currentUserEmails?: string[];
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
@@ -57,6 +60,7 @@ export function useTimeGridInteractions({
|
||||
onCreateRange,
|
||||
errorMessages,
|
||||
describeError,
|
||||
currentUserEmails,
|
||||
isMobile,
|
||||
}: UseTimeGridInteractionsOptions) {
|
||||
const snapToMinutes = useCallback((clientY: number, containerTop: number): number => {
|
||||
@@ -239,7 +243,6 @@ export function useTimeGridInteractions({
|
||||
|
||||
try {
|
||||
const event = useCalendarStore.getState().events.find(ev => ev.id === resize.eventId);
|
||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||
const updates: { start?: string; duration: string } = { duration: dur };
|
||||
if (startChanged && event?.start) {
|
||||
// Shift the event's floating `start` wall-clock by the delta (preserves event.timeZone).
|
||||
@@ -247,14 +250,15 @@ export function useTimeGridInteractions({
|
||||
const shifted = new Date(parseISO(event.start).getTime() + deltaMinutes * 60000);
|
||||
updates.start = format(shifted, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
}
|
||||
await useCalendarStore.getState().updateEvent(client, resize.eventId, updates, hasParticipants || undefined);
|
||||
const moved = movedEventUpdates(event, updates, currentUserEmails ?? []);
|
||||
await useCalendarStore.getState().updateEvent(client, resize.eventId, moved.updates, moved.sendSchedulingMessages);
|
||||
} catch (error) {
|
||||
debug.error("Failed to resize event:", resize.eventId, error);
|
||||
toast.error(describeError ? describeError(error, errorMessages.resize) : errorMessages.resize);
|
||||
} finally {
|
||||
setResizeVisual(null);
|
||||
}
|
||||
}, [computeResize, errorMessages.resize, describeError]);
|
||||
}, [computeResize, errorMessages.resize, describeError, currentUserEmails]);
|
||||
|
||||
// --- Click / Double-click / Quick-create ---
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -381,12 +385,12 @@ export function useTimeGridInteractions({
|
||||
toast.error(errorMessages.move);
|
||||
return;
|
||||
}
|
||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
||||
const moved = movedEventUpdates(event, { start: newStartISO }, currentUserEmails ?? []);
|
||||
await useCalendarStore.getState().updateEvent(client, data.eventId, moved.updates, moved.sendSchedulingMessages);
|
||||
} catch (error) {
|
||||
toast.error(describeError ? describeError(error, errorMessages.move) : errorMessages.move);
|
||||
}
|
||||
}, [snapDragMinutes, errorMessages.move, describeError]);
|
||||
}, [snapDragMinutes, errorMessages.move, describeError, currentUserEmails]);
|
||||
|
||||
return {
|
||||
dragCreate,
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
participantLimitOf,
|
||||
participantsAskedAgain,
|
||||
hasAnsweredAttendees,
|
||||
movedEventUpdates,
|
||||
userOrganizes,
|
||||
} from '@/lib/calendar-participants';
|
||||
import { jmapError, jmapSetError } from '@/lib/jmap/jmap-error';
|
||||
|
||||
@@ -851,6 +853,60 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('asking the attendees again
|
||||
});
|
||||
});
|
||||
|
||||
// A drag or a resize saved the way the editor saves a move: the organizer
|
||||
// asks the attendees again and tells them; anyone else moves their own copy
|
||||
// quietly. Live on 1.0 and 0.16.23 a dragged event stayed "accepted" for
|
||||
// the new time, and a dragged occurrence reached no one who had answered.
|
||||
describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('movedEventUpdates on Stalwart %s', (version) => {
|
||||
const attendee = (address: string, participationStatus: CalendarParticipant['participationStatus']) => ({
|
||||
'@type': 'Participant', calendarAddress: `mailto:${address}`, email: address, participationStatus,
|
||||
...(version === '1.0' ? { roles: { attendee: true } } : {}),
|
||||
}) as Partial<CalendarParticipant>;
|
||||
const meeting = (organizer: string) => makeEvent({
|
||||
org: { '@type': 'Participant', calendarAddress: `mailto:${organizer}`, roles: { owner: true }, participationStatus: 'accepted' },
|
||||
bob: attendee('bob@example.org', 'accepted'),
|
||||
me: attendee('me@example.com', 'tentative'),
|
||||
}, { organizerCalendarAddress: `mailto:${organizer}` });
|
||||
const MOVE = { start: '2026-03-01T11:00:00' };
|
||||
|
||||
it('asks the attendees again and tells them when the user organizes the event', () => {
|
||||
const { updates, sendSchedulingMessages } = movedEventUpdates(meeting('ME@example.com'), MOVE, ['me@example.com']);
|
||||
|
||||
expect(sendSchedulingMessages).toBe(true);
|
||||
expect(updates.start).toBe(MOVE.start);
|
||||
expect(updates.participants!.org.participationStatus).toBe('accepted');
|
||||
expect(updates.participants!.bob.participationStatus).toBe('needs-action');
|
||||
});
|
||||
|
||||
it('moves someone else\'s event quietly, answers as they are', () => {
|
||||
expect(movedEventUpdates(meeting('boss@example.org'), MOVE, ['me@example.com']))
|
||||
.toEqual({ updates: MOVE, sendSchedulingMessages: undefined });
|
||||
});
|
||||
|
||||
it('moves an event without participants quietly', () => {
|
||||
expect(movedEventUpdates(makeEvent(null), MOVE, ['me@example.com'])).toEqual({ updates: MOVE, sendSchedulingMessages: undefined });
|
||||
expect(movedEventUpdates(undefined, MOVE, ['me@example.com'])).toEqual({ updates: MOVE, sendSchedulingMessages: undefined });
|
||||
});
|
||||
|
||||
it('tells, and changes no answer, when nobody has answered yet', () => {
|
||||
const fresh = makeEvent({
|
||||
org: { '@type': 'Participant', calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
bob: attendee('bob@example.org', 'needs-action'),
|
||||
}, { organizerCalendarAddress: 'mailto:me@example.com' });
|
||||
|
||||
expect(movedEventUpdates(fresh, MOVE, ['me@example.com'])).toEqual({ updates: MOVE, sendSchedulingMessages: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('userOrganizes', () => {
|
||||
it('is the user under any of their addresses, or anyone for an event without an organizer', () => {
|
||||
const own = makeEvent({ org: { '@type': 'Participant', calendarAddress: 'mailto:Alias@Example.com', roles: { owner: true } } });
|
||||
expect(userOrganizes(own, ['me@example.com', 'alias@example.com'])).toBe(true);
|
||||
expect(userOrganizes(own, ['me@example.com'])).toBe(false);
|
||||
expect(userOrganizes(makeEvent({ bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.org' } }), ['me@example.com'])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('participantLimitOf', () => {
|
||||
it('reads the limit from Stalwart 1.0’s refusal', () => {
|
||||
const refusal = jmapSetError('An event cannot have more than 20 participants.', {
|
||||
|
||||
@@ -640,3 +640,33 @@ describe('hydrateRecurrenceInstances with an occurrence listed twice', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Until 4c20b09c every editor save wrote the organizer into the override it
|
||||
// rewrote; 0.16.19 refuses an answer to an occurrence whose override names
|
||||
// none. An override on the base event gets the series' organizer back when
|
||||
// it has none; through a synthetic id it is never sent.
|
||||
describe('the organizer of an existing override written on the base event', () => {
|
||||
const occurrence = (entry: Record<string, unknown>) => ({
|
||||
id: 'b:2026-09-08T10:00:00', originalId: 'b', start: '2026-09-08T10:00:00', recurrenceId: '2026-09-08T10:00:00',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' }],
|
||||
organizerCalendarAddress: 'mailto:org@example.com',
|
||||
recurrenceOverrides: { '2026-09-08T10:00:00': entry },
|
||||
}) as unknown as CalendarEvent;
|
||||
|
||||
it('adds it to an override that has none, as 0.16 before 0.16.20 stores it', () => {
|
||||
expect(withNewOverrideDetails(occurrence({ title: 'Moved' }), { title: 'Renamed' }, { seriesKeys: true }))
|
||||
.toEqual({ title: 'Renamed', organizerCalendarAddress: 'mailto:org@example.com' });
|
||||
});
|
||||
|
||||
it('leaves an override that has one, and a patch that sets one, alone', () => {
|
||||
const named = occurrence({ title: 'Moved', organizerCalendarAddress: 'mailto:org@example.com' });
|
||||
expect(withNewOverrideDetails(named, { title: 'Renamed' }, { seriesKeys: true })).toEqual({ title: 'Renamed' });
|
||||
expect(withNewOverrideDetails(occurrence({}), { organizerCalendarAddress: null } as never, { seriesKeys: true }))
|
||||
.toEqual({ organizerCalendarAddress: null });
|
||||
});
|
||||
|
||||
it('sends nothing of the series through a synthetic id', () => {
|
||||
expect(withNewOverrideDetails(occurrence({ title: 'Moved' }), { title: 'Renamed' })).toEqual({ title: 'Renamed' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRecurrenceRule } from '@/lib/recurrence-rule';
|
||||
import { createRecurrenceRule, sameRecurrenceRules } from '@/lib/recurrence-rule';
|
||||
import type { CalendarNDay, CalendarRecurrenceRule } from '@/lib/jmap/types';
|
||||
|
||||
describe('createRecurrenceRule (#805 — no default SKIP=OMIT)', () => {
|
||||
it('does not emit rscale or skip (which serialise to RSCALE=GREGORIAN;SKIP=OMIT)', () => {
|
||||
@@ -38,3 +39,33 @@ describe('createRecurrenceRule (#805 — no default SKIP=OMIT)', () => {
|
||||
expect('skip' in rule).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sameRecurrenceRules', () => {
|
||||
const weekly = (overrides: Partial<CalendarRecurrenceRule> = {}) => createRecurrenceRule('weekly', overrides);
|
||||
|
||||
it('matches a rule the editor wrote with its defaults to the server\'s copy without them', () => {
|
||||
const fromServer = { '@type': 'RecurrenceRule', frequency: 'weekly' } as CalendarRecurrenceRule;
|
||||
expect(sameRecurrenceRules([weekly()], [fromServer])).toBe(true);
|
||||
expect(sameRecurrenceRules(null, [])).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores the order of by-lists', () => {
|
||||
// As 1.0 returns an NDay: typed, nthOfPeriod left out.
|
||||
const nday = (day: string) => ({ '@type': 'NDay', day }) as CalendarNDay;
|
||||
expect(sameRecurrenceRules(
|
||||
[weekly({ byDay: [{ day: 'mo' }, { day: 'we' }] })],
|
||||
[weekly({ byDay: [nday('we'), nday('mo')] })],
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('tells a custom rule moved from Monday to Tuesday apart', () => {
|
||||
expect(sameRecurrenceRules([weekly({ byDay: [{ day: 'mo' }] })], [weekly({ byDay: [{ day: 'tu' }] })])).toBe(false);
|
||||
});
|
||||
|
||||
it('tells other frequencies, intervals, ends and a removed rule apart', () => {
|
||||
expect(sameRecurrenceRules([weekly()], [createRecurrenceRule('daily')])).toBe(false);
|
||||
expect(sameRecurrenceRules([weekly()], [weekly({ interval: 2 })])).toBe(false);
|
||||
expect(sameRecurrenceRules([weekly({ count: 5 })], [weekly({ count: 6 })])).toBe(false);
|
||||
expect(sameRecurrenceRules([weekly()], null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,6 +205,30 @@ describe.each(occurrences)('changing every occurrence from one of them on Stalwa
|
||||
expect(updates.participants).toBeNull();
|
||||
expect(updates.organizerCalendarAddress).toBeNull();
|
||||
});
|
||||
|
||||
it('asks the attendees again when the series gets another rule', () => {
|
||||
const occurrence = make();
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, series(version),
|
||||
editorUpdates(occurrence, { recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 } as never] }),
|
||||
editorOn(occurrence, { ruleChanged: true }), context,
|
||||
);
|
||||
|
||||
expect(updates).not.toHaveProperty('start');
|
||||
expect(statusOf(updates.participants, ATTENDEE)).toBe('needs-action');
|
||||
});
|
||||
|
||||
it('moves an attendee\'s own copy of the series and leaves its participants alone', () => {
|
||||
const occurrence = make();
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, series(version), editorUpdates(occurrence, { participants: undefined }),
|
||||
editorOn(occurrence, { when: { allDay: false, start: addHours(getEventStartDate(occurrence), 1), end: addHours(getEventEndDate(occurrence), 1) } }),
|
||||
{ userEmail: ATTENDEE, userEmails: [ATTENDEE] },
|
||||
);
|
||||
|
||||
expect(updates.start).toBe('2026-10-05T11:00:00');
|
||||
expect(updates).not.toHaveProperty('participants');
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing every occurrence of a series in another time zone', () => {
|
||||
|
||||
@@ -391,6 +391,38 @@ function eventOrganizer(event: CalendarEvent): { name: string; email: string } |
|
||||
return email ? { name: '', email } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user, under any of `userEmails`, organizes `event`, or would
|
||||
* on saving it with attendees: an event that names no organizer gets the
|
||||
* user (participantFieldsToSave). Decides whether a change of when the
|
||||
* event happens asks the attendees again, in the editor and elsewhere.
|
||||
*/
|
||||
export function userOrganizes(event: CalendarEvent, userEmails: string[]): boolean {
|
||||
return !eventOrganizer(event) || isOrganizer(event, userEmails);
|
||||
}
|
||||
|
||||
/**
|
||||
* A move that does not go through the editor - a drag, a resize - saved
|
||||
* the way the editor saves one (participantFieldsToSave): when the user
|
||||
* organizes the event, its attendees are asked again, and the move goes out
|
||||
* to them; anyone else moves their own copy without messages. Stalwart's
|
||||
* update carries each attendee's stored answer, and one occurrence that
|
||||
* gets its own override reaches only attendees without an answer: a dragged
|
||||
* occurrence reached no attendee who had answered, a dragged event kept
|
||||
* their answers for the new time.
|
||||
*/
|
||||
export function movedEventUpdates<T extends Partial<CalendarEvent>>(
|
||||
event: CalendarEvent | undefined,
|
||||
updates: T,
|
||||
userEmails: string[],
|
||||
): { updates: T & Partial<Pick<CalendarEvent, 'participants'>>; sendSchedulingMessages: true | undefined } {
|
||||
if (!event?.participants || Object.keys(event.participants).length === 0 || !userOrganizes(event, userEmails)) {
|
||||
return { updates, sendSchedulingMessages: undefined };
|
||||
}
|
||||
const participants = participantsAskedAgain(event);
|
||||
return { updates: participants ? { ...updates, participants } : updates, sendSchedulingMessages: true };
|
||||
}
|
||||
|
||||
export interface ParticipantSaveContext {
|
||||
/** The user's first calendar address: the organizer of an event that has none yet. */
|
||||
userEmail: string | undefined;
|
||||
@@ -398,7 +430,10 @@ export interface ParticipantSaveContext {
|
||||
userEmails?: string[];
|
||||
/** A name for an organizer the event data carries none for (the contact card). */
|
||||
resolveName?: (email: string) => string | undefined;
|
||||
/** The save changes when the event happens: its start or end instant, or whether it is all-day. */
|
||||
/**
|
||||
* The save changes when the event happens: its start or end instant,
|
||||
* whether it is all-day, or the recurrence of a series.
|
||||
*/
|
||||
rescheduled?: boolean;
|
||||
}
|
||||
|
||||
@@ -409,13 +444,14 @@ export interface ParticipantSaveContext {
|
||||
* organizer: an invitation keeps whoever sent it, and a series the exact
|
||||
* address it is organized under (Stalwart 1.0 checks it on single
|
||||
* occurrences). Only an event without an organizer gets the user, and only
|
||||
* then is `organizerCalendarAddress` written: Stalwart derives the
|
||||
* iCalendar ORGANIZER solely from it, so without one no invitations go out.
|
||||
* Participants that stay keep their id and answer (buildParticipantMap),
|
||||
* unless the user as the organizer moves the event: then the other
|
||||
* attendees are asked again, as RFC 5546 has an organizer do when an
|
||||
* event is rescheduled, and the server's update asks them with
|
||||
* needs-action.
|
||||
* then is `organizerCalendarAddress` written. Stalwart takes the iCalendar
|
||||
* ORGANIZER from it, and from the owner participant when it is not set
|
||||
* (0.16 and 1.0 alike), so an event that has an organizer keeps it either
|
||||
* way. Participants that stay keep their id and answer
|
||||
* (buildParticipantMap), unless the user as the organizer moves the event
|
||||
* or changes its recurrence: then the other attendees are asked again, as
|
||||
* RFC 5546 has an organizer do when an event is rescheduled, and the
|
||||
* server's update asks them with needs-action.
|
||||
*
|
||||
* Without attendees, an event that had participants loses them, its
|
||||
* organizer and the retired `replyTo` older releases wrote.
|
||||
@@ -428,8 +464,7 @@ export function participantFieldsToSave(
|
||||
const { userEmail, resolveName } = context;
|
||||
if (attendees.length > 0 && userEmail) {
|
||||
const organizer = (event ? eventOrganizer(event) : null) ?? { name: '', email: userEmail };
|
||||
const userAddresses = [userEmail, ...(context.userEmails ?? [])].map((address) => address.trim().toLowerCase());
|
||||
const askAgain = !!context.rescheduled && userAddresses.includes(organizer.email.toLowerCase());
|
||||
const askAgain = !!context.rescheduled && (!event || userOrganizes(event, [userEmail, ...(context.userEmails ?? [])]));
|
||||
const fields: Partial<Pick<CalendarEvent, 'participants' | 'organizerCalendarAddress'>> = {
|
||||
participants: buildParticipantMap(
|
||||
// A new event has no participants yet, so it would store an empty
|
||||
|
||||
@@ -628,18 +628,37 @@ export interface OverridePatchOptions {
|
||||
seriesKeys?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `patch` for an occurrence's existing override on the base event, with the
|
||||
* series' organizer when that override names none - as every editor save
|
||||
* wrote it before 4c20b09c: 0.16.19 refuses an answer to one occurrence
|
||||
* whose override has no organizer (see buildOccurrenceRsvpPatch). Stalwart
|
||||
* 1.0 refuses the key inside an override, and the store then writes the
|
||||
* override once more without series keys; through a synthetic id it is
|
||||
* never sent.
|
||||
*/
|
||||
function withOverrideOrganizer(occurrence: Partial<CalendarEvent>, patch: Partial<CalendarEvent>): Partial<CalendarEvent> {
|
||||
if (!occurrence.organizerCalendarAddress || 'organizerCalendarAddress' in patch || !occurrence.recurrenceId) return patch;
|
||||
const overrides = knownOverrides(occurrence);
|
||||
const key = resolveOverrideKey({ start: occurrence.start ?? '', recurrenceId: occurrence.recurrenceId }, overrides);
|
||||
const entry = key ? overrides?.[key] : undefined;
|
||||
if (!entry || 'organizerCalendarAddress' in entry) return patch;
|
||||
return { ...patch, organizerCalendarAddress: occurrence.organizerCalendarAddress };
|
||||
}
|
||||
|
||||
/**
|
||||
* `patch` plus the occurrence details a new override needs
|
||||
* (NEW_OVERRIDE_COPIED_KEYS). Nothing is added when the occurrence already
|
||||
* has an override - an attendee may not add most of these to the organizer's
|
||||
* - or when that is unknown.
|
||||
* - or when that is unknown; an existing override written on the base event
|
||||
* gets the series' organizer if it has none (withOverrideOrganizer).
|
||||
*/
|
||||
export function withNewOverrideDetails(
|
||||
occurrence: Partial<CalendarEvent>,
|
||||
patch: Partial<CalendarEvent>,
|
||||
options: OverridePatchOptions = {},
|
||||
): Partial<CalendarEvent> {
|
||||
if (!createsOverride(occurrence)) return patch;
|
||||
if (!createsOverride(occurrence)) return options.seriesKeys ? withOverrideOrganizer(occurrence, patch) : patch;
|
||||
const names = options.seriesKeys ? [...NEW_OVERRIDE_COPIED_KEYS, ...NEW_OVERRIDE_SERIES_KEYS] : NEW_OVERRIDE_COPIED_KEYS;
|
||||
const details: Record<string, unknown> = {};
|
||||
for (const name of names) {
|
||||
|
||||
@@ -31,3 +31,35 @@ export function createRecurrenceRule(
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// What a rule means when a part is left out (RFC 8984 4.3.3).
|
||||
const RULE_DEFAULTS: Record<string, unknown> = { interval: 1, firstDayOfWeek: 'mo', rscale: 'gregorian', skip: 'omit' };
|
||||
|
||||
function ruleKey(rule: CalendarRecurrenceRule): string {
|
||||
const parts = Object.entries(rule as unknown as Record<string, unknown>)
|
||||
.filter(([name, value]) => name !== '@type' && value != null
|
||||
&& !(Array.isArray(value) && value.length === 0) && RULE_DEFAULTS[name] !== value)
|
||||
.map(([name, value]) => {
|
||||
if (!Array.isArray(value)) return [name, value];
|
||||
const items = value.map((item) => (item && typeof item === 'object'
|
||||
? JSON.stringify([(item as { day?: string }).day, (item as { nthOfPeriod?: number | null }).nthOfPeriod ?? null])
|
||||
: JSON.stringify(item)));
|
||||
return [name, items.sort()];
|
||||
})
|
||||
.sort(([a], [b]) => (String(a) < String(b) ? -1 : 1));
|
||||
return JSON.stringify(parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two sets of recurrence rules describe the same occurrences as far
|
||||
* as their parts go: parts left out and parts set to their default are the
|
||||
* same, and so are by-lists in another order. A server returns a rule with
|
||||
* its defaults dropped that the editor wrote with them.
|
||||
*/
|
||||
export function sameRecurrenceRules(
|
||||
a: CalendarRecurrenceRule[] | null | undefined,
|
||||
b: CalendarRecurrenceRule[] | null | undefined,
|
||||
): boolean {
|
||||
const keys = (rules: CalendarRecurrenceRule[] | null | undefined) => (rules ?? []).map(ruleKey).sort();
|
||||
return JSON.stringify(keys(a)) === JSON.stringify(keys(b));
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from '@/li
|
||||
import { parseDurationSeconds } from '@/lib/calendar-event-normalization';
|
||||
import {
|
||||
getParticipantList,
|
||||
isOrganizer,
|
||||
participantFieldsToSave,
|
||||
participantsAskedAgain,
|
||||
userOrganizes,
|
||||
type ParticipantSaveContext,
|
||||
} from '@/lib/calendar-participants';
|
||||
import {
|
||||
@@ -45,6 +45,8 @@ export interface EventEditorState {
|
||||
initialAttendees: EditorAttendee[];
|
||||
attendees: EditorAttendee[];
|
||||
when: EventWhen;
|
||||
/** The user changed the recurrence rule: a series then happens on other days. */
|
||||
ruleChanged?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,13 +140,16 @@ function attendeesOf(event: CalendarEvent): EditorAttendee[] {
|
||||
* The series' participants after the editor changed one occurrence's: the
|
||||
* series' own, with the attendees the user added in the editor or removed
|
||||
* from it. Nothing when neither happened and the series does not move -
|
||||
* the series keeps its participants and their answers as they are.
|
||||
* the series keeps its participants and their answers as they are - and
|
||||
* nothing when someone else organizes the series.
|
||||
*/
|
||||
function seriesParticipants(
|
||||
master: CalendarEvent,
|
||||
editor: EventEditorState,
|
||||
context: ParticipantSaveContext,
|
||||
): Partial<Pick<CalendarEvent, 'participants' | 'organizerCalendarAddress' | 'replyTo'>> {
|
||||
const userEmails = [context.userEmail ?? '', ...(context.userEmails ?? [])].filter(Boolean);
|
||||
if (!userOrganizes(master, userEmails)) return {};
|
||||
const key = (email: string) => email.trim().toLowerCase();
|
||||
const before = new Set(editor.initialAttendees.map((a) => key(a.email)));
|
||||
const after = new Set(editor.attendees.map((a) => key(a.email)));
|
||||
@@ -185,7 +190,7 @@ export function seriesUpdatesFromOccurrence(
|
||||
Object.assign(series, when);
|
||||
Object.assign(series, seriesParticipants(master, editor, {
|
||||
...context,
|
||||
rescheduled: Object.keys(when).length > 0,
|
||||
rescheduled: Object.keys(when).length > 0 || !!editor.ruleChanged,
|
||||
}));
|
||||
return series;
|
||||
}
|
||||
@@ -256,7 +261,7 @@ export async function truncateSeriesAt(
|
||||
until,
|
||||
count: null,
|
||||
}));
|
||||
const announced = scheduling.sendScheduling && isOrganizer(master, scheduling.userEmails ?? []) ? true : undefined;
|
||||
const announced = scheduling.sendScheduling && userOrganizes(master, scheduling.userEmails ?? []) ? true : undefined;
|
||||
await actions.updateEvent(actions.client, master.id, { recurrenceRules: truncatedRules }, announced);
|
||||
return { master, originalRules, announced };
|
||||
}
|
||||
@@ -317,7 +322,7 @@ export async function saveOccurrenceEdit(
|
||||
if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(event)) {
|
||||
// The store writes one occurrence through its own (synthetic) id, or
|
||||
// as a recurrence override on the base event it was expanded from.
|
||||
const asOrganizer = isOrganizer({
|
||||
const asOrganizer = userOrganizes({
|
||||
...event,
|
||||
participants: updates.participants === undefined ? event.participants : updates.participants,
|
||||
}, edit.userEmails);
|
||||
|
||||
Reference in New Issue
Block a user