mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
fix: tell attendees when someone with write access changes the owner's meeting
Someone who writes to a calendar shared with them (read & write) changes the owner's meeting there on the owner's behalf: Stalwart sends the change from the addresses of the account the event is in. Since6d4b6954Bulwark decided who organizes by the user's own addresses only, so it took the sharee for an attendee (live on 1.0 and 0.16.23): - a drag, a resize or a month-view drop wrote the new time without scheduling messages: the attendees kept the old time and their answers while the owner's copy moved; before6d4b6954they got "Updated invitation" - the editor wrote no participants, hid "Send invitations", showed the attendees read-only and sent nothing - "All events" asked nobody again, and "This and following events" ended the series quietly and made the rest a new series without attendees `userOrganizes` now also counts the organizer's own copy in another account: `isOrigin`, which both versions compute from the addresses of the account the event is in, true for the owner's meeting and every expanded occurrence of it, false for an invitation the owner got. Such an invitation is still changed without messages. The editor no longer drops the user's own address from the attendees it opens with, only the organizer's, so a sharee who attends the owner's meeting stays on it. The demo's invitation from Alice now says it is not the demo user's copy.
This commit is contained in:
@@ -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(
|
||||
<EventModal event={event} calendars={shared} onSave={onSave} onClose={vi.fn()} currentUserEmails={['userb@example.org']} />,
|
||||
);
|
||||
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<CalendarEvent>, 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<string, CalendarParticipant>;
|
||||
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(<EventModal event={inShared('userc@example.org', true)} calendars={shared} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['userb@example.org']} />);
|
||||
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<string, CalendarParticipant>;
|
||||
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.
|
||||
|
||||
@@ -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<string>();
|
||||
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)
|
||||
|
||||
@@ -80,11 +80,14 @@ describe.each<["0.16" | "1.0"]>([["0.16"], ["1.0"]])("useTimeGridInteractions mo
|
||||
const original = useCalendarStore.getState().updateEvent;
|
||||
let updateEvent: ReturnType<typeof vi.fn>;
|
||||
|
||||
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> = {}): 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();
|
||||
|
||||
@@ -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<CalendarParticipant>;
|
||||
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<CalendarEvent> = {}) => 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user