diff --git a/components/calendar/__tests__/event-modal-organizer.test.tsx b/components/calendar/__tests__/event-modal-organizer.test.tsx index 55796a8a4..5105ad1dc 100644 --- a/components/calendar/__tests__/event-modal-organizer.test.tsx +++ b/components/calendar/__tests__/event-modal-organizer.test.tsx @@ -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): Cale } as unknown as CalendarEvent; } -async function saveUnchanged(event: CalendarEvent): Promise> { - const onSave = vi.fn().mockResolvedValue(undefined); +async function saveUnchanged(event: CalendarEvent, onSave = vi.fn().mockResolvedValue(undefined)): Promise> { render( ([['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; - 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(); + 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(); + 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, extra: Partial = {}) => ({ + ...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' }); }); }); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 1dd710e12..95fd12b3e 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -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); }} /> diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index 688cfdf86..f9dcaff45 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -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, }); diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index db192e6ac..411fc9f6f 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -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 (
diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 472309cdd..583c18ec0 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -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, }); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 175fefc97..e8e226873 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -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("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")} + {/* The organizer decides who is invited; an attendee's save + keeps the list as it came. */} {/* 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({
- {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 && (
{ }); 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(); + 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 diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index b3d72fd4f..92e90a222 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -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); diff --git a/hooks/__tests__/use-time-grid-interactions.test.ts b/hooks/__tests__/use-time-grid-interactions.test.ts index a0f7526c2..692413075 100644 --- a/hooks/__tests__/use-time-grid-interactions.test.ts +++ b/hooks/__tests__/use-time-grid-interactions.test.ts @@ -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; + + 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; + + 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); + }); +}); diff --git a/hooks/use-time-grid-interactions.ts b/hooks/use-time-grid-interactions.ts index 8ecbb15ba..2381a45df 100644 --- a/hooks/use-time-grid-interactions.ts +++ b/hooks/use-time-grid-interactions.ts @@ -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 | 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, diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts index ffbe7af59..2a93dff1c 100644 --- a/lib/__tests__/calendar-participants.test.ts +++ b/lib/__tests__/calendar-participants.test.ts @@ -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; + 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.', { diff --git a/lib/__tests__/recurrence-instances.test.ts b/lib/__tests__/recurrence-instances.test.ts index 0b84f20b8..174c3092b 100644 --- a/lib/__tests__/recurrence-instances.test.ts +++ b/lib/__tests__/recurrence-instances.test.ts @@ -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) => ({ + 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' }); + }); +}); diff --git a/lib/__tests__/recurrence-rule.test.ts b/lib/__tests__/recurrence-rule.test.ts index d1da93e4f..35d2ab784 100644 --- a/lib/__tests__/recurrence-rule.test.ts +++ b/lib/__tests__/recurrence-rule.test.ts @@ -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 = {}) => 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); + }); +}); diff --git a/lib/__tests__/recurrence-scope-edit.test.ts b/lib/__tests__/recurrence-scope-edit.test.ts index 0b95abc0c..dd56167c8 100644 --- a/lib/__tests__/recurrence-scope-edit.test.ts +++ b/lib/__tests__/recurrence-scope-edit.test.ts @@ -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', () => { diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts index e07d304f5..c4d1cb2dc 100644 --- a/lib/calendar-participants.ts +++ b/lib/calendar-participants.ts @@ -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>( + event: CalendarEvent | undefined, + updates: T, + userEmails: string[], +): { updates: T & Partial>; 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> = { participants: buildParticipantMap( // A new event has no participants yet, so it would store an empty diff --git a/lib/recurrence-instances.ts b/lib/recurrence-instances.ts index c58866560..3a4e0f28e 100644 --- a/lib/recurrence-instances.ts +++ b/lib/recurrence-instances.ts @@ -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, patch: Partial): Partial { + 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, patch: Partial, options: OverridePatchOptions = {}, ): Partial { - 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 = {}; for (const name of names) { diff --git a/lib/recurrence-rule.ts b/lib/recurrence-rule.ts index 607edf156..cf5c79586 100644 --- a/lib/recurrence-rule.ts +++ b/lib/recurrence-rule.ts @@ -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 = { interval: 1, firstDayOfWeek: 'mo', rscale: 'gregorian', skip: 'omit' }; + +function ruleKey(rule: CalendarRecurrenceRule): string { + const parts = Object.entries(rule as unknown as Record) + .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)); +} diff --git a/lib/recurrence-scope-edit.ts b/lib/recurrence-scope-edit.ts index 9c59f31f7..6ee341090 100644 --- a/lib/recurrence-scope-edit.ts +++ b/lib/recurrence-scope-edit.ts @@ -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> { + 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);