diff --git a/components/calendar/__tests__/event-modal-organizer.test.tsx b/components/calendar/__tests__/event-modal-organizer.test.tsx index 5105ad1dc..46590fd68 100644 --- a/components/calendar/__tests__/event-modal-organizer.test.tsx +++ b/components/calendar/__tests__/event-modal-organizer.test.tsx @@ -232,6 +232,80 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal answers after a }); }); +// userc shares a calendar with the user (read & write) and organizes a +// meeting in it. Stalwart sends a change to it from userc, the owner of the +// account (isOrigin: true, live on both versions). The editor treated the +// user as an attendee there: no participants written, no "Send +// invitations", the attendees never told of a move. +describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal on the owner\'s meeting in a shared calendar on Stalwart %s', (version) => { + const shared = [{ + id: 'f:bz', originalId: 'bz', name: 'Team', color: '#3366cc', isShared: true, accountId: 'f', + myRights: { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: false, + }, + }] as unknown as Calendar[]; + // The user (userb) attends the meeting as well. + const inShared = (organizer: string, isOrigin: boolean) => ({ + ...meeting(organizer, { + owner: participant(version, organizer, true, 'accepted'), + anna: participant(version, 'usera@example.org', false, 'accepted'), + bea: participant(version, 'userb@example.org', false, 'tentative'), + }), + id: 'f:fj', calendarIds: { 'f:bz': true }, isShared: true, accountId: 'f', isOrigin, + }) as CalendarEvent; + + async function saveAs(event: CalendarEvent, edit: (container: HTMLElement) => void = () => {}) { + const onSave = vi.fn().mockResolvedValue(undefined); + const { container } = render( + , + ); + fireEvent.click(screen.getByText('events.edit')); + edit(container); + fireEvent.click(screen.getByText('form.save')); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + return { data: onSave.mock.calls[0][0] as Partial, send: onSave.mock.calls[0][1] }; + } + + it('writes the owner\'s participants, the user among them, and sends the change', async () => { + const { data, send } = await saveAs(inShared('userc@example.org', true)); + + const participants = data.participants as Record; + expect(participants.owner).toMatchObject({ calendarAddress: 'mailto:userc@example.org', roles: { owner: true } }); + expect(Object.keys(participants).sort()).toEqual(['anna', 'bea', 'owner']); + expect(participants.bea.participationStatus).toBe('tentative'); + expect(data).not.toHaveProperty('organizerCalendarAddress'); + expect(send).toBe(true); + }); + + it('offers invitations and attendee changes', () => { + render(); + fireEvent.click(screen.getByText('events.edit')); + + expect(screen.getByText('participants.send_invitations')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('email_placeholder')).toBeEnabled(); + }); + + it('asks the attendees again when the user moves it', async () => { + const { data } = await saveAs(inShared('userc@example.org', true), (container) => { + const [startTime] = container.querySelectorAll('input[type="time"]'); + fireEvent.change(startTime, { target: { value: '11:00' } }); + }); + + const participants = data.participants as Record; + expect(participants.owner.participationStatus).toBe('accepted'); + expect(participants.anna.participationStatus).toBe('needs-action'); + expect(participants.bea.participationStatus).toBe('needs-action'); + }); + + it('leaves an invitation the owner got alone', async () => { + const { data, send } = await saveAs(inShared('boss@example.org', false)); + + expect(data).not.toHaveProperty('participants'); + expect(send).toBe(false); + }); +}); + // A save for every occurrence of a series applies what the user changed on // the occurrence the editor shows (lib/recurrence-scope-edit.ts): the save // carries the attendees the editor opened and saved with, and its dates. diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index e8e226873..54ae0fe93 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -16,6 +16,7 @@ import { createRecurrenceRule, sameRecurrenceRules } from "@/lib/recurrence-rule import { ParticipantInput, type ParticipantInputHandle } from "./participant-input"; import { ParticipantAvailability } from "./participant-availability"; import { + eventOrganizer, getUserParticipantId, getUserStatus, getParticipantList, @@ -320,7 +321,8 @@ export function EventModal({ // 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. + // sends no messages. The owner's meeting in a calendar shared with the + // user is the organizer's copy (userOrganizes). const userIsOrganizer = useMemo(() => { if (!event?.participants) return true; return userOrganizes(event, currentUserEmails); @@ -504,10 +506,14 @@ export function EventModal({ if (!event?.participants) return []; // Never seed the invite list with the organizer or with a repeated address: // handleSave writes the organizer separately, so either would round-trip - // into a duplicate entry every time the event is saved (#731). + // into a duplicate entry every time the event is saved (#731). The + // organizer is the event's own, or the user for an event without one - + // not the user as such: a sharee who attends the owner's meeting stays + // on its list. const excluded = new Set(); - if (userIsOrganizer && currentUserEmails[0]) { - excluded.add(currentUserEmails[0].toLowerCase()); + const organizerAddress = (event && eventOrganizer(event)?.email) || (userIsOrganizer ? currentUserEmails[0] : ''); + if (organizerAddress) { + excluded.add(organizerAddress.toLowerCase()); } return existingParticipants .filter(p => !p.isOrganizer) diff --git a/hooks/__tests__/use-time-grid-interactions.test.ts b/hooks/__tests__/use-time-grid-interactions.test.ts index 692413075..1af53ec72 100644 --- a/hooks/__tests__/use-time-grid-interactions.test.ts +++ b/hooks/__tests__/use-time-grid-interactions.test.ts @@ -80,11 +80,14 @@ describe.each<["0.16" | "1.0"]>([["0.16"], ["1.0"]])("useTimeGridInteractions mo const original = useCalendarStore.getState().updateEvent; let updateEvent: ReturnType; - function meeting(organizer: string): CalendarEvent { + // isOrigin as the server says it: whether the organizer is an address of + // the account the event is in. + function meeting(organizer: string, extra: Partial = {}): 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}`, + organizerCalendarAddress: `mailto:${organizer}`, isOrigin: organizer === "me@example.com", + ...extra, participants: { org: { "@type": "Participant", calendarAddress: `mailto:${organizer}`, roles: { owner: true }, participationStatus: "accepted" }, bob: { @@ -144,6 +147,22 @@ describe.each<["0.16" | "1.0"]>([["0.16"], ["1.0"]])("useTimeGridInteractions mo expect(send).toBeUndefined(); }); + // userc's meeting in userc's calendar, shared with the user read & write: + // Stalwart sends the move from userc. It went out to nobody and the + // attendees kept the old time (live on 1.0 and 0.16.23). + it("asks the attendees again when the user drags the owner's meeting in a shared calendar", async () => { + useCalendarStore.setState({ events: [meeting("userc@example.org", { id: "f:e1", isOrigin: true, isShared: true, accountId: "f" })] }); + const result = interactions(); + + await act(() => result.current.handleColumnDrop(drop("f:e1"), new Date(2026, 8, 3))); + + const [, id, updates, send] = updateEvent.mock.calls[0]; + expect(id).toBe("f:e1"); + expect(updates.participants.org.participationStatus).toBe("accepted"); + expect(updates.participants.bob.participationStatus).toBe("needs-action"); + expect(send).toBe(true); + }); + it("asks the attendees again when the user resizes their meeting", async () => { useCalendarStore.setState({ events: [meeting("me@example.com")] }); const result = interactions(); diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts index 2a93dff1c..00494f260 100644 --- a/lib/__tests__/calendar-participants.test.ts +++ b/lib/__tests__/calendar-participants.test.ts @@ -794,17 +794,35 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('participantFieldsToSave fo }); it('never asks again for an event someone else organizes', () => { + // The user's copy of an invitation: not the organizer's (isOrigin). const invitation = makeEvent({ boss: organizer('boss@example.org'), mine: attendee('me@example.com', 'accepted'), bob: attendee('bob@example.org', 'declined'), - }, { organizerCalendarAddress: 'mailto:boss@example.org' }); + }, { organizerCalendarAddress: 'mailto:boss@example.org', isOrigin: false }); const fields = participantFieldsToSave(invitation, rows('me@example.com', 'bob@example.org'), { ...me, rescheduled: true }); expect(fields.participants!.mine.participationStatus).toBe('accepted'); expect(fields.participants!.bob.participationStatus).toBe('declined'); }); + + // userc shares a calendar with the user (read & write): userc's meeting in + // it is the organizer's copy, and a move there goes out from userc. + it('asks again when the user moves the owner\'s meeting in a calendar shared with them', () => { + const shared = makeEvent({ + owner: organizer('userc@example.org'), + anna: attendee('usera@example.org', 'accepted'), + mine: attendee('me@example.com', 'tentative'), + }, { organizerCalendarAddress: 'mailto:userc@example.org', isOrigin: true, isShared: true, accountId: 'f' }); + + const fields = participantFieldsToSave(shared, rows('usera@example.org', 'me@example.com'), { ...me, rescheduled: true }); + + expect(fields.participants!.owner).toMatchObject({ calendarAddress: 'mailto:userc@example.org', participationStatus: 'accepted' }); + expect(fields.participants!.anna.participationStatus).toBe('needs-action'); + expect(fields.participants!.mine.participationStatus).toBe('needs-action'); + expect(fields).not.toHaveProperty('organizerCalendarAddress'); + }); }); // What Stalwart sends: a new event, and an occurrence that gets its own @@ -862,11 +880,13 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('movedEventUpdates on Stalw '@type': 'Participant', calendarAddress: `mailto:${address}`, email: address, participationStatus, ...(version === '1.0' ? { roles: { attendee: true } } : {}), }) as Partial; - const meeting = (organizer: string) => makeEvent({ + // isOrigin as the server says it: whether the organizer is an address of + // the account the event is in. + const meeting = (organizer: string, extra: Partial = {}) => 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}` }); + }, { organizerCalendarAddress: `mailto:${organizer}`, isOrigin: organizer.toLowerCase() === 'me@example.com', ...extra }); const MOVE = { start: '2026-03-01T11:00:00' }; it('asks the attendees again and tells them when the user organizes the event', () => { @@ -883,6 +903,25 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('movedEventUpdates on Stalw .toEqual({ updates: MOVE, sendSchedulingMessages: undefined }); }); + // userc's meeting in userc's calendar, shared with the user read & write: + // Stalwart sends the move from userc. It went out to nobody, and the + // attendees kept the old time (live on 1.0 and 0.16.23). + it('asks the attendees again and tells them when the user moves the owner\'s meeting in a shared calendar', () => { + const { updates, sendSchedulingMessages } = movedEventUpdates( + meeting('userc@example.org', { isOrigin: true, isShared: true, accountId: 'f' }), MOVE, ['me@example.com'], + ); + + expect(sendSchedulingMessages).toBe(true); + expect(updates.participants!.org.participationStatus).toBe('accepted'); + expect(updates.participants!.bob.participationStatus).toBe('needs-action'); + expect(updates.participants!.me.participationStatus).toBe('needs-action'); + }); + + it('moves the owner\'s copy of someone else\'s meeting in a shared calendar quietly', () => { + expect(movedEventUpdates(meeting('boss@example.org', { isShared: true, accountId: 'f' }), 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 }); @@ -900,10 +939,31 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('movedEventUpdates on Stalw 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 } } }); + const own = makeEvent({ org: { '@type': 'Participant', calendarAddress: 'mailto:Alias@Example.com', roles: { owner: true } } }, { isOrigin: false }); 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); + expect(userOrganizes(makeEvent({ bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.org' } }, { isOrigin: false }), ['me@example.com'])).toBe(true); + }); + + // What userb sees in userc's calendar, shared read & write, as each version + // returns it (live): isOrigin is true where userc organizes, false for an + // invitation userc got, for the event and for each expanded occurrence. + describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('in a calendar shared with the user on Stalwart %s', (version) => { + const inShared = (organizer: string, isOrigin: boolean) => makeEvent({ + org: { '@type': 'Participant', calendarAddress: `mailto:${organizer}`, roles: { owner: true }, participationStatus: 'accepted' }, + b: { + '@type': 'Participant', calendarAddress: 'mailto:userb@example.org', participationStatus: 'accepted', + ...(version === '1.0' ? { roles: { attendee: true } } : {}), + }, + }, { organizerCalendarAddress: `mailto:${organizer}`, isOrigin, isShared: true, accountId: 'f' }); + + it('is the owner\'s meeting: the server schedules it from the owner', () => { + expect(userOrganizes(inShared('userc@example.org', true), ['userb@example.org'])).toBe(true); + }); + + it('is not an invitation the owner got', () => { + expect(userOrganizes(inShared('usera@example.org', false), ['userb@example.org'])).toBe(false); + }); }); }); diff --git a/lib/__tests__/recurrence-scope-edit.test.ts b/lib/__tests__/recurrence-scope-edit.test.ts index 291a70b6d..83295eb6b 100644 --- a/lib/__tests__/recurrence-scope-edit.test.ts +++ b/lib/__tests__/recurrence-scope-edit.test.ts @@ -24,6 +24,8 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface'; type Version = '0.16' | '1.0'; const ORGANIZER = 'userc@example.org'; const ATTENDEE = 'userb@example.org'; +// Someone userc shares the calendar with, read & write; not invited. +const SHAREE = 'usera@example.org'; // Participants as each version stores them: 0.16.23 drops an attendee's // roles, 1.0 keeps them. @@ -222,7 +224,7 @@ describe.each(occurrences)('changing every occurrence from one of them on Stalwa 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 }), + occurrence, { ...series(version), isOrigin: false }, editorUpdates(occurrence, { participants: undefined }), editorOn(occurrence, { when: { allDay: false, start: addHours(getEventStartDate(occurrence), 1), end: addHours(getEventEndDate(occurrence), 1) } }), { userEmail: ATTENDEE, userEmails: [ATTENDEE] }, ); @@ -230,6 +232,22 @@ describe.each(occurrences)('changing every occurrence from one of them on Stalwa expect(updates.start).toBe('2026-10-05T11:00:00'); expect(updates).not.toHaveProperty('participants'); }); + + // The series in userc's calendar, shared with usera read & write: Stalwart + // sends usera's change from userc (isOrigin: true). + it('asks again when the user moves the owner\'s series in a calendar shared with them', () => { + const occurrence = make(); + const updates = seriesUpdatesFromOccurrence( + occurrence, { ...series(version), isOrigin: true, isShared: true, accountId: 'f' }, editorUpdates(occurrence), + editorOn(occurrence, { when: { allDay: false, start: addHours(getEventStartDate(occurrence), 1), end: addHours(getEventEndDate(occurrence), 1) } }), + { userEmail: SHAREE, userEmails: [SHAREE] }, + ); + + expect(updates.start).toBe('2026-10-05T11:00:00'); + expect(statusOf(updates.participants, ORGANIZER)).toBe('accepted'); + expect(statusOf(updates.participants, ATTENDEE)).toBe('needs-action'); + expect(statusOf(updates.participants, SHAREE)).toBeUndefined(); + }); }); describe('changing every occurrence of a series in another time zone', () => { @@ -347,7 +365,7 @@ describe.each(occurrences)('saving this and the following occurrences on Stalwar it('ends an attendee\'s own copy quietly', async () => { const occurrence = make(); - const deps = actions(series(version)); + const deps = actions({ ...series(version), isOrigin: false }); await saveOccurrenceEdit('this_and_future', { event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), sendScheduling: true, userEmails: [ATTENDEE], @@ -355,6 +373,25 @@ describe.each(occurrences)('saving this and the following occurrences on Stalwar expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', { recurrenceRules: truncated }, undefined); }); + + // The series in userc's calendar, shared with usera read & write: the + // split ended it without a word, and the rest had no attendees - the + // editor wrote none for someone it took for an attendee. + it('tells the attendees and asks them to the new series when the user splits the owner\'s series in a shared calendar', async () => { + const occurrence = make(); + const deps = actions({ ...series(version), isOrigin: true, isShared: true, accountId: 'f' }); + + await saveOccurrenceEdit('this_and_future', { + event: occurrence, updates: editorUpdates(occurrence, { title: 'Planning, room 2' }), editor: editorOn(occurrence), + sendScheduling: true, userEmails: [SHAREE], + }, deps); + + expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', { recurrenceRules: truncated }, true); + const [, created, send] = deps.createEvent.mock.calls[0]; + expect(send).toBe(true); + expect(statusOf(created.participants, ORGANIZER)).toBe('accepted'); + expect(statusOf(created.participants, ATTENDEE)).toBe('needs-action'); + }); }); describe('the new series from this and the following occurrences', () => { diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts index c4d1cb2dc..c0be48dc1 100644 --- a/lib/calendar-participants.ts +++ b/lib/calendar-participants.ts @@ -383,7 +383,7 @@ export function buildParticipantMap( * `organizerCalendarAddress` or the retired `replyTo`. Null for an event * without one. */ -function eventOrganizer(event: CalendarEvent): { name: string; email: string } | null { +export function eventOrganizer(event: CalendarEvent): { name: string; email: string } | null { const participant = getParticipantList(event).find((p) => p.isOrganizer && p.email.trim()); if (participant) return { name: participant.name, email: participant.email.trim() }; const address = event.organizerCalendarAddress || Object.values(event.replyTo ?? {})[0]; @@ -392,20 +392,29 @@ function eventOrganizer(event: CalendarEvent): { name: string; email: string } | } /** - * 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. + * Whether the user writes `event` as its organizer: they organize it under + * any of `userEmails`, or would on saving it with attendees (an event that + * names no organizer gets the user, participantFieldsToSave), or the event + * is its organizer's own copy in someone else's account the user writes to - + * a calendar shared with them, a group's. Stalwart schedules a change from + * the addresses of the account the event is in, and `isOrigin` is its word + * that the event's organizer is one of them (0.16 and 1.0 alike): a + * sharee's move of the owner's meeting goes out from the owner. An + * invitation in that account is `isOrigin: false` and stays the account's + * own answer to make. Decides whether a change of when the event happens + * asks the attendees again, and whether it is sent, in the editor and + * elsewhere. */ export function userOrganizes(event: CalendarEvent, userEmails: string[]): boolean { - return !eventOrganizer(event) || isOrganizer(event, userEmails); + return !eventOrganizer(event) || isOrganizer(event, userEmails) || event.isOrigin === true; } /** * 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 + * organizes the event (userOrganizes: the owner's copy in a calendar shared + * with the user counts), 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 diff --git a/lib/demo/fixtures/calendars.ts b/lib/demo/fixtures/calendars.ts index 68f493512..cc368ac1f 100644 --- a/lib/demo/fixtures/calendars.ts +++ b/lib/demo/fixtures/calendars.ts @@ -181,6 +181,8 @@ export function createDemoCalendarEvents(): CalendarEvent[] { id: 'demo-event-5', calendarIds: { 'demo-calendar-work': true }, uid: 'demo-event-5@example.com', + // Alice's invitation: the demo user's copy is not the organizer's. + isOrigin: false, title: 'Quarterly Review', description: 'Q4 performance review and planning session', created: demoDate(-14),