@rapidmx/web-client 0.14.1 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/apps/admin/domains/[uid].tsx +3 -2
  2. package/apps/shared/appearance/AppearanceForm.tsx +9 -1
  3. package/apps/shared/calendar/bookingPlugin.ts +100 -0
  4. package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
  5. package/apps/shared/components/admin/layout/AdminShell.tsx +37 -5
  6. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +3 -1
  7. package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1491
  8. package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
  9. package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
  10. package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
  11. package/apps/shared/components/calendar/EventDetails.tsx +401 -0
  12. package/apps/shared/components/calendar/EventEditor.tsx +640 -0
  13. package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
  14. package/apps/shared/components/calendar/EventFormParts.tsx +622 -0
  15. package/apps/shared/components/calendar/EventModal.tsx +126 -866
  16. package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
  17. package/apps/shared/components/calendar/EventShell.tsx +202 -0
  18. package/apps/shared/components/calendar/FindATime.tsx +379 -0
  19. package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
  20. package/apps/shared/components/calendar/MonthView.tsx +15 -3
  21. package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
  22. package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
  23. package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
  24. package/apps/shared/components/calendar/RecurrenceEditor.tsx +10 -5
  25. package/apps/shared/components/calendar/RequestChangeForm.tsx +271 -0
  26. package/apps/shared/components/calendar/SplitDayView.tsx +6 -5
  27. package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
  28. package/apps/shared/components/calendar/TimeGridView.tsx +8 -6
  29. package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
  30. package/apps/shared/components/calendar/eventForm.ts +108 -0
  31. package/apps/shared/components/calendar/eventFormat.ts +183 -0
  32. package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
  33. package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
  34. package/apps/shared/components/layout/UserMenu.tsx +80 -28
  35. package/apps/shared/components/mail/ConversationList.tsx +100 -48
  36. package/apps/shared/components/mail/ConversationThreadPane.tsx +490 -486
  37. package/apps/shared/components/mail/InviteCard.tsx +387 -0
  38. package/apps/shared/components/mail/MessageDetailPane.tsx +24 -2
  39. package/apps/shared/components/mail/SwipeRow.tsx +120 -0
  40. package/apps/shared/components/mail/compose/ComposeWindow.tsx +7 -3
  41. package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
  42. package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
  43. package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
  44. package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
  45. package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
  46. package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
  47. package/apps/shared/components/mail/layout/MailShell.tsx +522 -490
  48. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  49. package/apps/shared/gestures/useEnterSlide.ts +54 -0
  50. package/apps/shared/gestures/useSwipe.ts +114 -0
  51. package/apps/shared/gestures/useSwipeSlide.ts +113 -0
  52. package/apps/shared/gestures/useWheelPaging.ts +107 -0
  53. package/apps/shared/mail/newMailNotifications.ts +171 -183
  54. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  55. package/apps/shared/notifications/preferences.ts +36 -0
  56. package/apps/shared/notifications/store.ts +560 -551
  57. package/apps/www/_routes.ts +1 -0
  58. package/apps/www/calendar/index.tsx +139 -37
  59. package/apps/www/index.tsx +273 -126
  60. package/apps/www/messages/[uid].tsx +31 -4
  61. package/apps/www/settings/profile/index.tsx +248 -0
  62. package/dist/apps/admin/domains/[uid].js +4 -2
  63. package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
  64. package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
  65. package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
  66. package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
  67. package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
  68. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  69. package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
  70. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +3 -1
  71. package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
  72. package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
  73. package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
  74. package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
  75. package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
  76. package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
  77. package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
  78. package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
  79. package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
  80. package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
  81. package/dist/apps/shared/components/calendar/EventEditor.js +549 -0
  82. package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
  83. package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
  84. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +69 -0
  85. package/dist/apps/shared/components/calendar/EventFormParts.js +202 -0
  86. package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
  87. package/dist/apps/shared/components/calendar/EventModal.js +57 -459
  88. package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
  89. package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
  90. package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
  91. package/dist/apps/shared/components/calendar/EventShell.js +134 -0
  92. package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
  93. package/dist/apps/shared/components/calendar/FindATime.js +190 -0
  94. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
  95. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
  96. package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
  97. package/dist/apps/shared/components/calendar/MonthView.js +11 -5
  98. package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
  99. package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
  100. package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
  101. package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
  102. package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
  103. package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
  104. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
  105. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
  106. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
  107. package/dist/apps/shared/components/calendar/RequestChangeForm.js +138 -0
  108. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
  109. package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
  110. package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
  111. package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
  112. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
  113. package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
  114. package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
  115. package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
  116. package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
  117. package/dist/apps/shared/components/calendar/eventForm.js +1 -0
  118. package/dist/apps/shared/components/calendar/eventFormat.d.ts +67 -0
  119. package/dist/apps/shared/components/calendar/eventFormat.js +163 -0
  120. package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
  121. package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
  122. package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
  123. package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
  124. package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -8
  125. package/dist/apps/shared/components/layout/UserMenu.js +70 -23
  126. package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
  127. package/dist/apps/shared/components/mail/ConversationList.js +21 -4
  128. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  129. package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
  130. package/dist/apps/shared/components/mail/InviteCard.js +150 -0
  131. package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
  132. package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
  133. package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
  134. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -3
  135. package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
  136. package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
  137. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
  138. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
  139. package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
  140. package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
  141. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
  142. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
  143. package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
  144. package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
  145. package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
  146. package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
  147. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
  148. package/dist/apps/shared/components/mail/layout/MailShell.js +14 -3
  149. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  150. package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
  151. package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
  152. package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
  153. package/dist/apps/shared/gestures/useSwipe.js +90 -0
  154. package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
  155. package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
  156. package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
  157. package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
  158. package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
  159. package/dist/apps/shared/mail/newMailNotifications.js +3 -12
  160. package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
  161. package/dist/apps/shared/notifications/preferences.d.ts +13 -0
  162. package/dist/apps/shared/notifications/preferences.js +36 -0
  163. package/dist/apps/shared/notifications/store.d.ts +1 -0
  164. package/dist/apps/shared/notifications/store.js +7 -23
  165. package/dist/apps/www/_routes.js +1 -0
  166. package/dist/apps/www/calendar/index.js +76 -6
  167. package/dist/apps/www/index.js +140 -39
  168. package/dist/apps/www/messages/[uid].js +13 -5
  169. package/dist/apps/www/settings/profile/index.d.ts +4 -0
  170. package/dist/apps/www/settings/profile/index.js +124 -0
  171. package/package.json +2 -2
@@ -2,70 +2,19 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { FormEvent, useEffect, useRef, useState } from "react";
6
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import {
8
- Attendee,
9
- AttendeeResponseInput,
10
- AttendeeResponseStatus,
11
- AttendeeRole,
12
- BusyStatus,
13
- CalendarEvent,
14
- CalendarEventInput,
15
- RecurrenceRule,
16
- createCalendarEvent,
17
- getCalendarEvent,
18
- respondToEvent,
19
- updateCalendarEvent,
20
- } from "@rapidmx/react-shared/calendar/calendarApi.js";
21
- import {
22
- VideoMeetingInvitee,
23
- createVideoMeeting,
24
- getVideoMeeting,
25
- updateVideoMeeting,
26
- } from "@rapidmx/react-shared/videoconf/videoMeetingsApi.js";
27
- import { deleteEventOccurrence, deleteEventSeries, detachOccurrence, saveEventSeries } from "@rapidmx/react-shared/calendar/calendarMutations.js";
28
- import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
5
+ import React, { useRef, useState } from "react";
29
6
  import { Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
30
- import { CalendarOccurrence, fromEventWallClock, toEventWallClock } from "@rapidmx/react-shared/calendar/recurrence.js";
7
+ import { CalendarOccurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
31
8
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
32
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
33
9
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
34
- import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
35
- import RecurrenceEditor from "./RecurrenceEditor.js";
36
- import { addDaysToKey, allDayDateKey, allDayInstant, recurrenceUntilDateKey, recurrenceUntilInstant, startWeekdayCode } from "./allDay.js";
37
- import ResourcePicker from "./ResourcePicker.js";
38
- import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
10
+ import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
11
+ import EventDetails from "./EventDetails.js";
12
+ import EventEditor from "./EventEditor.js";
13
+ import EventShell, { EventAnchor, EventShellVariant } from "./EventShell.js";
14
+ import { QuickTab } from "./QuickCreateTabs.js";
39
15
 
40
- const INPUT_CLASS =
41
- "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
42
- const SELECT_CLASS =
43
- "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
44
-
45
- const ATTENDEE_ROLES: AttendeeRole[] = ["required", "optional", "resource"];
46
- const BUSY_STATUSES: BusyStatus[] = ["busy", "free", "tentative", "oof"];
47
- const BUSY_STATUS_LABEL: Record<BusyStatus, string> = { busy: "Busy", free: "Free", tentative: "Tentative", oof: "Out of office" };
48
- const RESPONSE_STATUS_LABEL: Record<AttendeeResponseStatus, string> = {
49
- needsAction: "Awaiting response",
50
- accepted: "Accepted",
51
- declined: "Declined",
52
- tentative: "Tentative",
53
- };
54
-
55
- /**
56
- * The Location text this modal writes when it mints a video meeting for an event, and the one value it will
57
- * clear again when video conferencing is turned back off (see `applyVideoConferencing()`).
58
- *
59
- * Deliberately generic and identical for everyone: each attendee's own personal join link is substituted into
60
- * their own copy of the invitation server-side (`@rapidmx/restapi`'s `MeetingSchedulingJob`), so no link -
61
- * personal or otherwise - may be stored on the shared event itself, where every attendee would read the same
62
- * one.
63
- */
64
- export const VIDEO_LOCATION_PLACEHOLDER = "Video call — link in this invitation";
65
-
66
- /** The plugin's own title bound (`BaseVideoMeetingRoute`'s `MAX_TITLE_LENGTH`) - a longer title is a 400, so
67
- * an over-long event title is trimmed to fit rather than failing the whole meeting. */
68
- const MAX_MEETING_TITLE_LENGTH = 200;
16
+ export { VIDEO_LOCATION_PLACEHOLDER } from "./EventEditor.js";
17
+ export type { EventAnchor } from "./EventShell.js";
69
18
 
70
19
  export interface EventModalProps {
71
20
  open: boolean;
@@ -83,6 +32,8 @@ export interface EventModalProps {
83
32
  * "Mailbox" selector (create mode only) chooses the mailbox, which in turn drives the Calendar selector
84
33
  * and the organizer address; `mailboxUid`/`folderUid` are the initial selection. */
85
34
  mailboxOptions?: { mailbox: Mailbox; calendars: { uid: string; name: string }[] }[];
35
+ /** Each calendar's colour, keyed by folder uid: the dot next to the calendar's name. */
36
+ folderColors?: Record<string, string>;
86
37
  organizerAddress: string;
87
38
  /** The viewing mailbox's alias addresses - an event it organizes (or an invitation to it) may name any of
88
39
  * them instead of `organizerAddress`. Defaults to that mailbox's `aliasAddresses` from `mailboxOptions`. */
@@ -92,83 +43,37 @@ export interface EventModalProps {
92
43
  /** Prefilled start/end for create mode (e.g. the day/slot the user clicked). */
93
44
  initialStart?: Date;
94
45
  initialEnd?: Date;
46
+ /** Create mode: start as an all-day event on `initialStart`'s day (a day of the month view was clicked). */
47
+ initialAllDay?: boolean;
48
+ /** Create mode: what was clicked (a slot, a day, the New event button), which the quick-create popover opens beside. Without one it
49
+ * opens centered near the top of the window. */
50
+ anchor?: EventAnchor;
51
+ /** Where the booking plugin's Settings pages are (`/settings/booking-types`) when it is running: a new event then has an Appointment
52
+ * schedule tab, and its More options opens the plugin's own new-link page. Without it there are only the Event and Task tabs. */
53
+ bookingHref?: string;
95
54
  onSaved: () => void;
96
55
  onDeleted: () => void;
97
56
  }
98
57
 
99
- type EditScope = "occurrence" | "series";
100
-
101
- /** `CalendarEventInput` fields as sent on save. `null` explicitly clears a field on an update - an
102
- * omitted (`undefined`) field is dropped by `JSON.stringify` and would leave the stored value in place. */
103
- type EventFields = { [K in keyof CalendarEventInput]?: CalendarEventInput[K] | null };
104
-
105
- const MS_PER_MINUTE = 60_000;
106
- const MS_PER_DAY = 86_400_000;
107
-
108
- /** Whether two ISO instants fall in the same minute - a stored `...T15:00:00Z` and the form's re-serialized
109
- * `...T15:00:00.000Z` name the same time without being equal strings. */
110
- function sameMinute(a: string, b: string): boolean {
111
- return Math.round(new Date(a).getTime() / MS_PER_MINUTE) === Math.round(new Date(b).getTime() / MS_PER_MINUTE);
112
- }
113
-
114
- /** Milliseconds since midnight of an instant's wall clock in `timezone` (a timed event's own zone). */
115
- function wallTimeOfDayMs(instantMs: number, timezone: string | undefined): number {
116
- const wall = toEventWallClock(instantMs, timezone, false);
117
- return ((wall % MS_PER_DAY) + MS_PER_DAY) % MS_PER_DAY;
118
- }
58
+ /** How wide each face of the dialog gets, in px. */
59
+ const QUICK_WIDTH = 450;
60
+ const DETAILS_WIDTH = 480;
61
+ const FORM_WIDTH = 880;
62
+ const TASK_FORM_WIDTH = 560;
119
63
 
120
64
  /**
121
- * Rewrites an edited occurrence's `startDate`/`endDate` for a save to the entire series. The modal only
122
- * ever shows (and edits) *this occurrence's* dates, but the series is stored as its master record starting
123
- * at the first occurrence - sending the occurrence's dates as-is would move the whole series' start to
124
- * this occurrence. So only the change in time-of-day (and the new duration) is applied to the master's own
125
- * start; the series keeps its original first date. Unchanged times aren't sent at all.
65
+ * The calendar's event dialog, in three faces drawn through one frame (`EventShell`):
126
66
  *
127
- * Times of day are read on the event's own timezone wall clock (the series expands there), not the
128
- * browser's, and the change is the smallest signed difference modulo 24h - moving 23:00 to 01:00 is two
129
- * hours later (into the next day), never 22 hours earlier. The master's new start is converted back from
130
- * that wall clock, so a master on the other side of a DST change keeps the chosen local time.
131
- */
132
- async function toSeriesFields(occurrence: CalendarOccurrence, fields: EventFields): Promise<EventFields> {
133
- const { startDate, endDate, ...rest } = fields;
134
- if (sameMinute(startDate as string, occurrence.startDate) && sameMinute(endDate as string, occurrence.endDate)) {
135
- // `saveEventSeries()` treats *any* `timezone`/`allDay` in the update as a possible reinterpretation of
136
- // the master's instant and fetches the master to check - so when neither differs from the occurrence's
137
- // own, both are dropped too, or every plain series save (e.g. just a new title) would pay for that GET.
138
- const { timezone, allDay, ...others } = rest;
139
- return timezone === occurrence.timezone && allDay === occurrence.allDay ? others : rest;
140
- }
141
- const master = await getCalendarEvent(occurrence.uid);
142
- const timezone = fields.timezone as string;
143
- const newStart = new Date(startDate as string).getTime();
144
- const durationMs = new Date(endDate as string).getTime() - newStart;
145
- let masterStart: number;
146
- if (fields.allDay) {
147
- // Timed -> all-day keeps the master's own date on its wall clock.
148
- const masterDateKey = master.allDay
149
- ? allDayDateKey(master.startDate)
150
- : new Date(toEventWallClock(new Date(master.startDate).getTime(), timezone, false)).toISOString().slice(0, 10);
151
- masterStart = new Date(allDayInstant(masterDateKey)).getTime();
152
- } else if (master.allDay) {
153
- // All-day -> timed: the master's own date at the newly chosen wall-clock time.
154
- const masterDateWall = new Date(allDayInstant(allDayDateKey(master.startDate))).getTime();
155
- masterStart = fromEventWallClock(masterDateWall + wallTimeOfDayMs(newStart, timezone), timezone, false);
156
- } else {
157
- const rawDelta = wallTimeOfDayMs(newStart, timezone) - wallTimeOfDayMs(new Date(occurrence.startDate).getTime(), timezone);
158
- const delta = ((((rawDelta + MS_PER_DAY / 2) % MS_PER_DAY) + MS_PER_DAY) % MS_PER_DAY) - MS_PER_DAY / 2;
159
- const masterWall = toEventWallClock(new Date(master.startDate).getTime(), timezone, false);
160
- masterStart = fromEventWallClock(masterWall + delta, timezone, false);
161
- }
162
- return {
163
- ...rest,
164
- startDate: new Date(masterStart).toISOString(),
165
- endDate: new Date(masterStart + durationMs).toISOString(),
166
- };
167
- }
168
-
169
- /**
170
- * Create/view/edit/delete for a single calendar event. Editing or deleting a recurring event's
171
- * occurrence offers a choice between "this event" and "the entire series" (see
67
+ * - **A new event** opens as a quick-create popover (`EventQuickForm`, beside what was clicked; a bottom sheet on a phone). "More options"
68
+ * grows it into the full card (`EventExpandedForm`) with everything typed so far still in it.
69
+ * The popover has tabs (`QuickCreateTabs`): Event, Task, and - when the booking plugin is running (`bookingHref`) - Appointment schedule.
70
+ * The Task and Appointment faces are drawn by `QuickCreateFaces`, share the Event tab's title, mailbox and calendar, and only ever
71
+ * create a task or a booking type; the Event tab is the form it always was.
72
+ * - **An existing event** opens read-only (`EventDetails`): what the event says, the invited reader's Accept / Tentative / Decline,
73
+ * and Delete. The reader who organized it also gets Modify, which turns the same card into the full form; closing that form returns
74
+ * to the details, discarding the edits (a save closes everything, as it always did).
75
+ *
76
+ * Editing or deleting a recurring event's occurrence offers a choice between "this event" and "the entire series" (see
172
77
  * `calendarMutations.ts`) — Outlook's own convention for the same ambiguity.
173
78
  */
174
79
  export default function EventModal({
@@ -178,377 +83,30 @@ export default function EventModal({
178
83
  folderUid,
179
84
  calendars,
180
85
  mailboxOptions,
86
+ folderColors,
181
87
  organizerAddress,
182
88
  organizerAliases,
183
89
  occurrence,
184
90
  initialStart,
185
91
  initialEnd,
92
+ initialAllDay,
93
+ anchor,
94
+ bookingHref,
186
95
  onSaved,
187
96
  onDeleted,
188
97
  }: EventModalProps) {
189
- const [targetFolderUid, setTargetFolderUid] = useState(folderUid);
190
- const [targetMailboxUid, setTargetMailboxUid] = useState(mailboxUid);
191
- const targetMailboxOption = mailboxOptions?.find((option) => option.mailbox.uid === targetMailboxUid);
192
- // Create mode follows the chosen mailbox; editing an existing event always keeps the passed-in values.
193
- const calendarChoices = !occurrence && targetMailboxOption ? targetMailboxOption.calendars : calendars;
194
- const effectiveOrganizerAddress = !occurrence && targetMailboxOption ? targetMailboxOption.mailbox.primarySmtpAddress : organizerAddress;
195
- // Every address the viewing mailbox receives at - its primary address and its aliases, case-insensitively.
196
- const ownAddresses = new Set(
197
- [organizerAddress, ...(organizerAliases ?? mailboxOptions?.find((option) => option.mailbox.uid === mailboxUid)?.mailbox.aliasAddresses ?? [])].map(
198
- (address) => address.toLowerCase(),
199
- ),
200
- );
201
- const isOwnAddress = (address: string) => ownAddresses.has(address.toLowerCase());
202
- // An existing event organized by someone else is the viewing mailbox's copy of an invitation: only the
203
- // organizer can change it (their next update would overwrite local edits anyway), so it's read-only
204
- // apart from the RSVP controls.
205
- const isInvited =
206
- !!occurrence && !isOwnAddress(occurrence.organizer.address) && !occurrence.attendees.some((a) => a.isOrganizer && isOwnAddress(a.address));
207
-
208
- function handleMailboxChange(nextMailboxUid: string) {
209
- setTargetMailboxUid(nextMailboxUid);
210
- const firstCalendar = mailboxOptions?.find((option) => option.mailbox.uid === nextMailboxUid)?.calendars[0];
211
- if (firstCalendar) {
212
- setTargetFolderUid(firstCalendar.uid);
213
- }
214
- }
215
- const [title, setTitle] = useState(occurrence?.title ?? "");
216
- const [location, setLocation] = useState(occurrence?.location ?? "");
217
- // An all-day event is stored date-only with an exclusive end (see `allDay.ts`); the form shows its
218
- // inclusive last day instead.
219
- const [start, setStart] = useState(() =>
220
- occurrence?.allDay
221
- ? `${allDayDateKey(occurrence.startDate)}T00:00`
222
- : toDatetimeLocal(occurrence?.startDate ?? initialStart?.toISOString() ?? new Date().toISOString()),
223
- );
224
- const [end, setEnd] = useState(() => {
225
- if (occurrence?.allDay) {
226
- const startKey = allDayDateKey(occurrence.startDate);
227
- const lastDayKey = addDaysToKey(allDayDateKey(occurrence.endDate), -1);
228
- return `${lastDayKey < startKey ? startKey : lastDayKey}T00:00`;
229
- }
230
- return toDatetimeLocal(occurrence?.endDate ?? initialEnd?.toISOString() ?? new Date(Date.now() + 30 * 60_000).toISOString());
231
- });
232
- const [allDay, setAllDay] = useState(occurrence?.allDay ?? false);
233
- const [timezone] = useState(occurrence?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone);
234
- const [attendees, setAttendees] = useState<Attendee[]>(occurrence?.attendees ?? []);
235
- const [recurrenceRule, setRecurrenceRule] = useState<RecurrenceRule | null>(occurrence?.recurrenceRule ?? null);
236
- const [reminderMinutes, setReminderMinutes] = useState(occurrence?.reminderMinutesBeforeStart?.toString() ?? "");
237
- const [busyStatus, setBusyStatus] = useState<BusyStatus>(occurrence?.busyStatus ?? "busy");
238
- const [autoReplyEnabled, setAutoReplyEnabled] = useState(occurrence?.autoReplyEnabled ?? false);
239
- const [autoReplyMessage, setAutoReplyMessage] = useState(occurrence?.autoReplyMessage ?? "");
240
- const [editScope, setEditScope] = useState<EditScope>("occurrence");
241
- // Video conferencing: the toggle, the meeting this event is linked to (if any), the organizer's own join
242
- // link for it, and whatever went wrong with the last attempt to mint/cancel one.
243
- const [videoEnabled, setVideoEnabled] = useState(!!occurrence?.videoMeetingUid);
244
- const [videoMeetingUid, setVideoMeetingUid] = useState<string | undefined>(occurrence?.videoMeetingUid);
245
- // `undefined` while the link is still being fetched, `null` once it is known there is none to open.
246
- const [organizerJoinUrl, setOrganizerJoinUrl] = useState<string | null | undefined>(undefined);
247
- const [videoError, setVideoError] = useState<string | null>(null);
248
- // The event as this modal last persisted it. Set only when a save got the event stored but could not
249
- // finish the video-conferencing step after it: the modal then stays open on that error, and the retry
250
- // must update *that* record (its uid and its new version), not re-create it or reuse the stale prop.
251
- const [savedEvent, setSavedEvent] = useState<CalendarEvent | null>(null);
252
- const [confirmingDelete, setConfirmingDelete] = useState(false);
253
- const [error, setError] = useState<string | null>(null);
254
- const [saving, setSaving] = useState(false);
255
- // A series save that moved the series but couldn't move every separately edited occurrence with it.
98
+ const isMobile = useIsMobile();
99
+ // An existing event: Modify was pressed. A new event: More options was.
100
+ const [editing, setEditing] = useState(false);
101
+ const [expanded, setExpanded] = useState(false);
102
+ // A new event: what the popover creates (an event, a task or an appointment schedule). More options only ever grows the tab it was pressed on.
103
+ const [tab, setTab] = useState<QuickTab>("event");
256
104
  const [syncWarning, setSyncWarning] = useState(false);
257
- const [responding, setResponding] = useState(false);
258
- const [resourcePickerOpen, setResourcePickerOpen] = useState(false);
259
- const addResourceButtonRef = useRef<HTMLButtonElement>(null);
260
-
261
- // An event that already carried a meeting when it was opened (e.g. after a page reload) knows only that
262
- // meeting's uid - the organizer's own join link lives on the meeting, so it is fetched once here. A
263
- // meeting minted in this session already has its link from the create call (see
264
- // `applyVideoConferencing()`), which is why this deliberately reads the *prop*, not the state: it must
265
- // not re-fetch what was just handed to us. A failure and a meeting with no link at all are the same
266
- // thing to this UI - there is nothing to open either way - so both settle on `null`.
267
- useEffect(() => {
268
- const uid = occurrence?.videoMeetingUid;
269
- if (!uid) {
270
- return;
271
- }
272
- let cancelled = false;
273
- void getVideoMeeting(uid)
274
- .then((meeting) => meeting.organizerJoinUrl ?? null)
275
- .catch(() => null)
276
- .then((url) => {
277
- if (!cancelled) {
278
- setOrganizerJoinUrl(url);
279
- }
280
- });
281
- return () => {
282
- cancelled = true;
283
- };
284
- }, [occurrence?.videoMeetingUid]);
285
-
286
- // The viewing mailbox can respond to an *existing* event it's invited to but doesn't organize —
287
- // `organizerAddress` (plus the aliases) are this mailbox's own addresses (see `EventModalProps`), so
288
- // finding one among `attendees` (and not as that attendee's own organizer flag) identifies exactly
289
- // that case. `@rapidmx/restapi`'s `respond()` route has no occurrence-vs-series scope of its own
290
- // (see `calendarApi.ts`'s `respondToEvent` doc comment) — it always acts on `occurrence.uid` as a
291
- // single event document, so this deliberately does not consult `editScope`.
292
- const myAttendeeIndex = occurrence ? attendees.findIndex((a) => isOwnAddress(a.address)) : -1;
293
- const canRespond = myAttendeeIndex !== -1 && !attendees[myAttendeeIndex].isOrganizer;
294
- // "This event only" detaches a standalone, non-repeating copy - the series' rule doesn't apply to it.
295
- const editingSingleOccurrence = !!occurrence?.isRecurringOccurrence && editScope === "occurrence";
296
-
297
- function updateAttendee(index: number, patch: Partial<Attendee>) {
298
- setAttendees((prev) => prev.map((a, i) => (i === index ? { ...a, ...patch } : a)));
299
- }
300
- function removeAttendee(index: number) {
301
- setAttendees((prev) => prev.filter((_, i) => i !== index));
302
- }
303
- function addResource(mailbox: Mailbox) {
304
- setAttendees((prev) => [
305
- ...prev,
306
- { address: mailbox.primarySmtpAddress, displayName: mailbox.displayName, role: "resource", responseStatus: "needsAction", isOrganizer: false },
307
- ]);
308
- setResourcePickerOpen(false);
309
- }
310
-
311
- /**
312
- * Brings the event's video meeting in line with the toggle, once the event itself is saved (so the
313
- * attendee list a new meeting's invitees are built from is the one that was just stored).
314
- *
315
- * Turning it **on** mints a meeting for the saved event's attendees - every one of them except the
316
- * organizer, who reaches their own meeting through ownership (`organizerJoinUrl`), not as a guest - and
317
- * then patches the event with the meeting's uid and the generic `VIDEO_LOCATION_PLACEHOLDER` location.
318
- * No join link is written onto the event: each attendee's own link is substituted into their own copy of
319
- * the invitation server-side.
320
- *
321
- * Turning it **off** cancels the meeting and clears the link, restoring Location to `""` only when it
322
- * still holds exactly the placeholder this modal wrote - anything the user has typed there since is
323
- * theirs and is preserved.
324
- *
325
- * **Known, deliberate limitation:** an event that already has a meeting is left completely alone, even
326
- * when its attendee list has just changed. `@rapidmx/meet-plugin`'s `PUT /mail/video-meetings/:id`
327
- * is deliberately minimal (title and cancellation only) and cannot add or remove invitees, and a private
328
- * meeting's personal join links are minted once at creation - so there is no way to reconcile an invitee
329
- * list, and silently pretending otherwise would leave a new attendee with no link and a removed one with
330
- * a working one. The helper text under the checkbox says so, and turning the toggle off and on again
331
- * reissues links for the current attendee list.
332
- *
333
- * Never throws: the event is already stored by the time this runs, so a failure here is reported inline
334
- * next to the checkbox and left retryable, with `videoMeetingUid` still whatever it was before.
335
- */
336
- async function applyVideoConferencing(saved: CalendarEvent): Promise<{ event: CalendarEvent; failed: boolean }> {
337
- // Off and never on, or on and already linked (see the limitation above) - nothing to do either way.
338
- if (videoEnabled === !!videoMeetingUid) {
339
- return { event: saved, failed: false };
340
- }
341
- // Detaching a single occurrence (`detachOccurrence()`, react-shared's `calendarMutations.ts`)
342
- // deliberately never copies the series' `videoMeetingUid` onto the new standalone event - that
343
- // shared meeting belongs to the series, not to the one occurrence being split off, and every other
344
- // occurrence still needs it live. But this component's own `videoMeetingUid` *state* was seeded
345
- // from the series' occurrence at mount and is untouched by the detach, so unchecking the toggle
346
- // while detaching would otherwise still look like "off, but state says linked" above and fall into
347
- // the cancel branch below - cancelling the series' shared meeting for every remaining occurrence,
348
- // not just this one. `saved.videoMeetingUid` is the actual just-persisted record, so when the
349
- // toggle is off and that record already has no meeting reference (true for every detach, checked or
350
- // not - see this function's own doc comment on the still-checked case being a separate, documented
351
- // gap), there is nothing of *this* record's to cancel.
352
- if (!videoEnabled && !saved.videoMeetingUid) {
353
- return { event: saved, failed: false };
354
- }
355
- try {
356
- if (videoEnabled) {
357
- const organizerAddressLower = saved.organizer.address.toLowerCase();
358
- const invitees: VideoMeetingInvitee[] = attendees
359
- .filter((a) => !a.isOrganizer && !!a.address.trim() && a.address.trim().toLowerCase() !== organizerAddressLower)
360
- .map((a) => ({ email: a.address.trim(), displayName: a.displayName }));
361
- if (invitees.length === 0) {
362
- setVideoError("Add at least one attendee other than yourself to add video conferencing.");
363
- return { event: saved, failed: true };
364
- }
365
- const result = await createVideoMeeting({
366
- mailboxUid: saved.mailboxUid,
367
- title: saved.title.slice(0, MAX_MEETING_TITLE_LENGTH),
368
- visibility: "private",
369
- calendarEventUid: saved.uid,
370
- startTime: saved.startDate,
371
- endTime: saved.endDate,
372
- invitees,
373
- });
374
- const patched = await updateCalendarEvent({
375
- uid: saved.uid,
376
- version: saved.version,
377
- location: VIDEO_LOCATION_PLACEHOLDER,
378
- videoMeetingUid: result.meeting.uid,
379
- });
380
- setVideoMeetingUid(result.meeting.uid);
381
- setOrganizerJoinUrl(result.organizerJoinUrl ?? null);
382
- setLocation(VIDEO_LOCATION_PLACEHOLDER);
383
- return { event: patched, failed: false };
384
- }
385
- await updateVideoMeeting(videoMeetingUid!, { status: "cancelled" });
386
- const restoredLocation = location.trim() === VIDEO_LOCATION_PLACEHOLDER ? "" : location.trim();
387
- // A stored event clears a field with an explicit `null` (see `EventFields`) - an omitted one would
388
- // leave the placeholder (and the link to the now-cancelled meeting) in place.
389
- const patch: EventFields = { location: restoredLocation || null, videoMeetingUid: null };
390
- const patched = await updateCalendarEvent({ uid: saved.uid, version: saved.version, ...(patch as Partial<CalendarEventInput>) });
391
- setVideoMeetingUid(undefined);
392
- setOrganizerJoinUrl(undefined);
393
- setLocation(restoredLocation);
394
- return { event: patched, failed: false };
395
- } catch (err) {
396
- // `videoMeetingsApi.ts`'s own doc comment: when the plugin isn't installed at all, its routes
397
- // simply aren't mounted and every call here fails with a plain 404 - a caller offering video
398
- // conferencing optionally must treat that as "not available here," not alarm the reader with
399
- // the raw (often server-generated, unhelpful) 404 body text.
400
- setVideoError(
401
- err instanceof ApiRequestError
402
- ? err.status === 404
403
- ? "Video conferencing is not available on this server."
404
- : err.message
405
- : "Could not update this event's video conferencing.",
406
- );
407
- return { event: saved, failed: true };
408
- }
409
- }
410
-
411
- async function handleSubmit(e: FormEvent) {
412
- e.preventDefault();
413
- setError(null);
414
- setVideoError(null);
415
-
416
- if (!title.trim()) {
417
- setError("A title is required.");
418
- return;
419
- }
420
- let startDate: string;
421
- let endDate: string;
422
- if (allDay) {
423
- const startKey = start.slice(0, 10);
424
- const lastDayKey = end.slice(0, 10);
425
- if (lastDayKey < startKey) {
426
- setError("The end date can't be before the start date.");
427
- return;
428
- }
429
- startDate = allDayInstant(startKey);
430
- endDate = allDayInstant(addDaysToKey(lastDayKey, 1));
431
- } else {
432
- if (new Date(end) <= new Date(start)) {
433
- setError("The end time must be after the start time.");
434
- return;
435
- }
436
- startDate = new Date(start).toISOString();
437
- endDate = new Date(end).toISOString();
438
- }
439
-
440
- // Only a stored event is updated in place, where a cleared field must be sent as `null`; creating
441
- // (a new event, or the detached copy of one occurrence) just omits it.
442
- const updatingInPlace = !!occurrence && !editingSingleOccurrence;
443
- const cleared = updatingInPlace ? null : undefined;
444
- const fields: EventFields = {
445
- title: title.trim(),
446
- location: location.trim() || cleared,
447
- startDate,
448
- endDate,
449
- allDay,
450
- timezone,
451
- attendees,
452
- recurrenceRule: editingSingleOccurrence ? undefined : (recurrenceRule ?? cleared),
453
- reminderMinutesBeforeStart: reminderMinutes.trim() ? Number(reminderMinutes) : cleared,
454
- busyStatus,
455
- autoReplyEnabled,
456
- autoReplyMessage: autoReplyEnabled ? autoReplyMessage : cleared,
457
- };
458
-
459
- setSaving(true);
460
- try {
461
- let saved: CalendarEvent;
462
- let detachedSyncFailed = false;
463
- if (savedEvent) {
464
- // A retry after the event was stored but its video meeting couldn't be (see `savedEvent`):
465
- // update exactly the record the first attempt left behind, whichever branch below created it.
466
- // A series' own exception/detached-occurrence re-pointing already ran on that attempt and has
467
- // nothing left to shift - the dates being sent again are the ones it moved everything to.
468
- saved = await updateCalendarEvent({ uid: savedEvent.uid, version: savedEvent.version, ...(fields as Partial<CalendarEventInput>) });
469
- } else if (!occurrence) {
470
- // The organizer is only ever set on create - an edit keeps the event's own organizer.
471
- saved = await createCalendarEvent({
472
- mailboxUid: targetMailboxUid,
473
- folderUid: targetFolderUid,
474
- ...fields,
475
- organizer: { address: effectiveOrganizerAddress, type: "to" },
476
- } as CalendarEventInput);
477
- } else if (editingSingleOccurrence) {
478
- saved = await detachOccurrence(occurrence, fields as Partial<CalendarEventInput>);
479
- } else if (occurrence.isRecurringOccurrence) {
480
- const series = await saveEventSeries(occurrence, (await toSeriesFields(occurrence, fields)) as Partial<CalendarEventInput>);
481
- saved = series;
482
- detachedSyncFailed = !!series.detachedOccurrenceSyncFailed;
483
- } else {
484
- saved = await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...(fields as Partial<CalendarEventInput>) });
485
- }
486
- const video = await applyVideoConferencing(saved);
487
- if (video.failed) {
488
- // The event itself is saved - keep the modal open on the inline error so the video meeting
489
- // can be retried (or the toggle put back) rather than closing over it.
490
- setSavedEvent(video.event);
491
- return;
492
- }
493
- if (detachedSyncFailed) {
494
- // The series itself was saved - say so, and leave closing to the user once they've read it.
495
- setSyncWarning(true);
496
- return;
497
- }
498
- onSaved();
499
- } catch (err) {
500
- setError(err instanceof ApiRequestError ? err.message : "Could not save this event.");
501
- } finally {
502
- setSaving(false);
503
- }
504
- }
505
-
506
- // Only ever invoked from the Delete button(s) rendered inside the `{occurrence && (...)}` block
507
- // below, so `occurrence` is always non-null here — no defensive null check needed.
508
- async function handleDelete(scope: EditScope) {
509
- setError(null);
510
- setSaving(true);
511
- try {
512
- if (occurrence!.isRecurringOccurrence && scope === "occurrence") {
513
- await deleteEventOccurrence(occurrence!);
514
- } else {
515
- await deleteEventSeries(occurrence!);
516
- }
517
- onDeleted();
518
- } catch (err) {
519
- setError(err instanceof ApiRequestError ? err.message : "Could not delete this event.");
520
- setSaving(false);
521
- }
522
- }
523
-
524
- // Only rendered inside the `{canRespond && (...)}` block below, which already requires `occurrence`
525
- // to be non-null (see `canRespond`'s own definition above) — same established pattern as
526
- // `handleDelete`'s non-null assertion.
527
- async function handleRespond(responseStatus: AttendeeResponseInput) {
528
- setError(null);
529
- setResponding(true);
530
- try {
531
- await respondToEvent(occurrence!.uid, responseStatus);
532
- // A decline soft-deletes the mailbox's own copy server-side — treat it the same as a
533
- // delete rather than a save so the calendar view drops it immediately.
534
- if (responseStatus === "declined") {
535
- onDeleted();
536
- } else {
537
- onSaved();
538
- }
539
- } catch (err) {
540
- setError(err instanceof ApiRequestError ? err.message : "Could not send your response.");
541
- setResponding(false);
542
- }
543
- }
105
+ // Whether the form holds anything a click on the backdrop would throw away (kept up to date by the editor).
106
+ const dirtyRef = useRef(false);
544
107
 
545
- // The recurrence end date is stored in the frame the series expands in (UTC for all-day, local otherwise),
546
- // so toggling All day re-stores the same chosen date in the new frame.
547
- function handleAllDayChange(nextAllDay: boolean) {
548
- setAllDay(nextAllDay);
549
- setRecurrenceRule((rule) =>
550
- rule?.until ? { ...rule, until: recurrenceUntilInstant(recurrenceUntilDateKey(rule.until, allDay), nextAllDay) } : rule,
551
- );
108
+ if (!open) {
109
+ return null;
552
110
  }
553
111
 
554
112
  if (syncWarning) {
@@ -565,382 +123,84 @@ export default function EventModal({
565
123
  );
566
124
  }
567
125
 
568
- // Same scheme allow-list `calendarReminders.ts#joinMeetingUrl()` already applies to a reminder's own
569
- // `location` field - reused here rather than duplicated so a video-conferencing plugin (or a stray
570
- // manual edit of `videoMeetingUid`'s otherwise-plugin-owned join link) can't smuggle a `javascript:`/
571
- // `file:`/other non-http(s) URL into `window.open()`. `undefined` (still loading) and `null` (fetch
572
- // came back with nothing to join) both fail validation the same way as a bad scheme would - the button
573
- // stays disabled either way, and the two are told apart below purely from `organizerJoinUrl` itself.
574
- const validatedJoinUrl = joinMeetingUrl(organizerJoinUrl);
126
+ // Every address the viewing mailbox receives at - its primary address and its aliases, case-insensitively.
127
+ const ownAddresses = new Set(
128
+ [organizerAddress, ...(organizerAliases ?? mailboxOptions?.find((option) => option.mailbox.uid === mailboxUid)?.mailbox.aliasAddresses ?? [])].map(
129
+ (address) => address.toLowerCase(),
130
+ ),
131
+ );
132
+ const isOwnAddress = (address: string) => ownAddresses.has(address.toLowerCase());
133
+ // An existing event organized by someone else is the viewing mailbox's copy of an invitation: only the
134
+ // organizer can change it (their next update would overwrite local edits anyway), so it can't be modified.
135
+ const isInvited =
136
+ !!occurrence && !isOwnAddress(occurrence.organizer.address) && !occurrence.attendees.some((a) => a.isOrganizer && isOwnAddress(a.address));
137
+ // The viewing mailbox can respond to an *existing* event it's invited to but doesn't organize - finding one of its own
138
+ // addresses among the attendees (and not as that attendee's own organizer flag) identifies exactly that case.
139
+ const myAttendee = occurrence?.attendees.find((a) => isOwnAddress(a.address));
140
+ const canRespond = !!myAttendee && !myAttendee.isOrganizer;
141
+
142
+ const isForm = !occurrence || editing;
143
+ const layout = occurrence || expanded ? "expanded" : "quick";
144
+ const variant: EventShellVariant = !isForm || layout === "expanded" ? "card" : isMobile ? "sheet" : "popover";
145
+ const width = !isForm ? DETAILS_WIDTH : layout === "expanded" ? (tab === "event" || occurrence ? FORM_WIDTH : TASK_FORM_WIDTH) : QUICK_WIDTH;
146
+ // Leaving the form: an existing event goes back to its details, a new one is done.
147
+ const leaveForm = occurrence ? () => setEditing(false) : onClose;
575
148
 
576
149
  return (
577
- <Modal open={open} onClose={onClose} title={occurrence ? "Edit event" : "New event"}>
578
- <form onSubmit={handleSubmit} className="flex flex-col gap-1">
579
- {error && <Alert>{error}</Alert>}
580
-
581
- {canRespond && (
582
- <div className="flex flex-col gap-2 mb-3 p-3 rounded-sm bg-surface-alt">
583
- <div className="flex items-center justify-between gap-3 flex-wrap">
584
- <span className="text-sm font-medium">Your response</span>
585
- <div className="flex gap-2">
586
- <Button
587
- type="button"
588
- variant="secondary"
589
- className="!w-auto"
590
- disabled={responding}
591
- onClick={() => handleRespond("accepted")}
592
- >
593
- Accept
594
- </Button>
595
- <Button
596
- type="button"
597
- variant="secondary"
598
- className="!w-auto"
599
- disabled={responding}
600
- onClick={() => handleRespond("tentative")}
601
- >
602
- Tentative
603
- </Button>
604
- <Button
605
- type="button"
606
- variant="secondary"
607
- className="!w-auto text-danger"
608
- disabled={responding}
609
- onClick={() => handleRespond("declined")}
610
- >
611
- Decline
612
- </Button>
613
- </div>
614
- </div>
615
- <p className="text-xs text-text-muted">Other attendees&rsquo; responses may take a few minutes to update.</p>
616
- </div>
617
- )}
618
-
619
- {isInvited && (
620
- <p className="text-xs text-text-muted mb-3">
621
- You were invited to this event. Only the organizer can change its details.
622
- </p>
623
- )}
624
-
625
- {/* Outside the `disabled` fieldset below: joining a call is not editing the event, so it stays
626
- available on an invitation's read-only copy too (where the link, like the meeting itself,
627
- is only reachable by whoever holds access to its mailbox). */}
628
- {videoMeetingUid && (
629
- <div className="flex items-center gap-3 mb-3">
630
- <Button
631
- type="button"
632
- variant="secondary"
633
- className="!w-auto"
634
- disabled={!validatedJoinUrl}
635
- onClick={() => window.open(validatedJoinUrl, "_blank", "noopener,noreferrer")}
636
- >
637
- Join video call
638
- </Button>
639
- {!validatedJoinUrl && (
640
- <span className="text-xs text-text-muted">
641
- {organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available."}
642
- </span>
643
- )}
644
- </div>
645
- )}
646
-
647
- <fieldset disabled={isInvited} className="flex flex-col gap-1 min-w-0">
648
-
649
- {!occurrence && mailboxOptions && mailboxOptions.length > 1 && (
650
- <FormField label="Mailbox" htmlFor="event-mailbox">
651
- <select
652
- id="event-mailbox"
653
- className={INPUT_CLASS}
654
- value={targetMailboxUid}
655
- onChange={(e) => handleMailboxChange(e.target.value)}
656
- >
657
- {mailboxOptions.map(({ mailbox }) => (
658
- <option key={mailbox.uid} value={mailbox.uid}>
659
- {mailbox.displayName}
660
- {mailbox.ownerUserUid ? "" : " (shared)"}
661
- </option>
662
- ))}
663
- </select>
664
- </FormField>
665
- )}
666
-
667
- {!occurrence && calendarChoices && calendarChoices.length > 1 && (
668
- <FormField label="Calendar" htmlFor="event-calendar">
669
- <select
670
- id="event-calendar"
671
- className={INPUT_CLASS}
672
- value={targetFolderUid}
673
- onChange={(e) => setTargetFolderUid(e.target.value)}
674
- >
675
- {calendarChoices.map((cal) => (
676
- <option key={cal.uid} value={cal.uid}>
677
- {cal.name}
678
- </option>
679
- ))}
680
- </select>
681
- </FormField>
682
- )}
683
-
684
- <FormField label="Title" htmlFor="event-title">
685
- <input id="event-title" type="text" className={INPUT_CLASS} value={title} onChange={(e) => setTitle(e.target.value)} />
686
- </FormField>
687
-
688
- <FormField label="Location" htmlFor="event-location">
689
- <input id="event-location" type="text" className={INPUT_CLASS} value={location} onChange={(e) => setLocation(e.target.value)} />
690
- </FormField>
691
-
692
- <div className="mb-3">
693
- <label className="flex items-center gap-2 text-sm font-medium">
694
- <input type="checkbox" checked={videoEnabled} onChange={(e) => setVideoEnabled(e.target.checked)} />
695
- Add video conferencing
696
- </label>
697
- {videoEnabled && (
698
- <p className="text-xs text-text-muted mt-1">
699
- Changing attendees after enabling video conferencing won&rsquo;t update meeting invitees &mdash;
700
- turn this off and back on to reissue links to the current attendee list.
701
- </p>
702
- )}
703
- {videoError && (
704
- <p role="alert" className="text-xs text-danger mt-1">
705
- {videoError}
706
- </p>
707
- )}
708
- </div>
709
-
710
- <label className="flex items-center gap-2 text-sm font-medium mb-3">
711
- <input type="checkbox" checked={allDay} onChange={(e) => handleAllDayChange(e.target.checked)} />
712
- All day
713
- </label>
714
-
715
- <div className="grid grid-cols-2 gap-3">
716
- <FormField label="Start" htmlFor="event-start">
717
- <input
718
- id="event-start"
719
- type={allDay ? "date" : "datetime-local"}
720
- className={INPUT_CLASS}
721
- value={allDay ? start.slice(0, 10) : start}
722
- onChange={(e) => setStart(allDay ? `${e.target.value}T00:00` : e.target.value)}
723
- />
724
- </FormField>
725
- <FormField label="End" htmlFor="event-end">
726
- <input
727
- id="event-end"
728
- type={allDay ? "date" : "datetime-local"}
729
- className={INPUT_CLASS}
730
- value={allDay ? end.slice(0, 10) : end}
731
- onChange={(e) => setEnd(allDay ? `${e.target.value}T00:00` : e.target.value)}
732
- />
733
- </FormField>
734
- </div>
735
-
736
- <FormField label="Attendees" htmlFor="event-attendees">
737
- <div id="event-attendees" className="flex flex-col gap-2">
738
- {attendees.map((attendee, i) => (
739
- <div key={i} className="flex gap-2">
740
- <input
741
- type="email"
742
- className={`${INPUT_CLASS} flex-1`}
743
- value={attendee.address}
744
- onChange={(e) => updateAttendee(i, { address: e.target.value })}
745
- aria-label={`Attendee email ${i + 1}`}
746
- />
747
- <select
748
- className={SELECT_CLASS}
749
- value={attendee.role}
750
- onChange={(e) => updateAttendee(i, { role: e.target.value as AttendeeRole })}
751
- aria-label={`Attendee role ${i + 1}`}
752
- >
753
- {ATTENDEE_ROLES.map((role) => (
754
- <option key={role} value={role}>
755
- {role}
756
- </option>
757
- ))}
758
- </select>
759
- <span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt self-center">
760
- {RESPONSE_STATUS_LABEL[attendee.responseStatus]}
761
- </span>
762
- <button
763
- type="button"
764
- onClick={() => removeAttendee(i)}
765
- className="text-sm text-danger px-2"
766
- aria-label={`Remove attendee ${i + 1}`}
767
- >
768
- &times;
769
- </button>
770
- </div>
771
- ))}
772
- <div className="flex gap-3 items-center">
773
- <button
774
- type="button"
775
- onClick={() => setAttendees((prev) => [...prev, { address: "", role: "required", responseStatus: "needsAction", isOrganizer: false }])}
776
- className="self-start text-xs font-medium text-primary-dark hover:underline"
777
- >
778
- + Add attendee
779
- </button>
780
- <button
781
- ref={addResourceButtonRef}
782
- type="button"
783
- onClick={() => setResourcePickerOpen(true)}
784
- className="self-start text-xs font-medium text-primary-dark hover:underline"
785
- >
786
- + Add room/equipment
787
- </button>
788
- </div>
789
- {resourcePickerOpen && (
790
- <ResourcePicker
791
- anchorRef={addResourceButtonRef}
792
- onClose={() => setResourcePickerOpen(false)}
793
- onSelect={addResource}
794
- excludeAddresses={attendees.map((a) => a.address.toLowerCase())}
795
- />
796
- )}
797
- </div>
798
- </FormField>
799
-
800
- <div className="grid grid-cols-2 gap-3">
801
- <FormField label="Busy status" htmlFor="event-busyStatus">
802
- <select
803
- id="event-busyStatus"
804
- className={INPUT_CLASS}
805
- value={busyStatus}
806
- onChange={(e) => setBusyStatus(e.target.value as BusyStatus)}
807
- >
808
- {BUSY_STATUSES.map((status) => (
809
- <option key={status} value={status}>
810
- {BUSY_STATUS_LABEL[status]}
811
- </option>
812
- ))}
813
- </select>
814
- </FormField>
815
- <FormField label="Reminder (minutes before)" htmlFor="event-reminder">
816
- <input
817
- id="event-reminder"
818
- type="number"
819
- min={0}
820
- className={INPUT_CLASS}
821
- value={reminderMinutes}
822
- onChange={(e) => setReminderMinutes(e.target.value)}
823
- placeholder="None"
824
- />
825
- </FormField>
826
- </div>
827
-
828
- <div className="mb-3">
829
- <label className="flex items-center gap-2 text-sm font-medium">
830
- <input
831
- type="checkbox"
832
- checked={autoReplyEnabled}
833
- onChange={(e) => setAutoReplyEnabled(e.target.checked)}
834
- />
835
- Send an automatic reply while this event is happening
836
- </label>
837
- <p className="text-xs text-text-muted mt-1">
838
- Applies in addition to your mailbox&rsquo;s own Automatic Replies setting (see Settings) —
839
- this event&rsquo;s message takes over for its own start/end window.
840
- </p>
841
- {autoReplyEnabled && (
842
- <textarea
843
- aria-label="Automatic reply message"
844
- className={`${INPUT_CLASS} mt-2`}
845
- rows={3}
846
- value={autoReplyMessage}
847
- onChange={(e) => setAutoReplyMessage(e.target.value)}
848
- placeholder="I'm out of office and back on..."
849
- />
850
- )}
851
- </div>
852
-
853
- {!editingSingleOccurrence && (
854
- <FormField label="Recurrence" htmlFor="event-recurrence">
855
- <RecurrenceEditor
856
- value={recurrenceRule}
857
- onChange={setRecurrenceRule}
858
- allDay={allDay}
859
- startWeekday={startWeekdayCode(start, allDay, timezone)}
860
- />
861
- </FormField>
862
- )}
863
-
864
- {occurrence?.isRecurringOccurrence && !isInvited && (
865
- <fieldset className="flex flex-col gap-1.5 text-sm mb-3">
866
- <legend className="text-xs font-bold uppercase tracking-wide text-text-muted mb-1">Apply changes to</legend>
867
- <label className="flex items-center gap-2">
868
- <input
869
- type="radio"
870
- name="event-edit-scope"
871
- checked={editScope === "occurrence"}
872
- onChange={() => setEditScope("occurrence")}
873
- />
874
- This event only
875
- </label>
876
- <label className="flex items-center gap-2">
877
- <input
878
- type="radio"
879
- name="event-edit-scope"
880
- checked={editScope === "series"}
881
- onChange={() => setEditScope("series")}
882
- />
883
- The entire series
884
- </label>
885
- </fieldset>
886
- )}
887
-
888
- </fieldset>
889
-
890
- <div className="flex gap-3 mt-2">
891
- {!isInvited && (
892
- <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
893
- Save
894
- </Button>
895
- )}
896
- <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
897
- Cancel
898
- </Button>
899
- {occurrence &&
900
- (!occurrence.isRecurringOccurrence ? (
901
- <Button
902
- type="button"
903
- variant="secondary"
904
- className="!w-auto ml-auto text-danger"
905
- disabled={saving}
906
- onClick={() => handleDelete("series")}
907
- >
908
- Delete
909
- </Button>
910
- ) : !confirmingDelete ? (
911
- <Button
912
- type="button"
913
- variant="secondary"
914
- className="!w-auto ml-auto text-danger"
915
- disabled={saving}
916
- onClick={() => setConfirmingDelete(true)}
917
- >
918
- Delete
919
- </Button>
920
- ) : (
921
- <div className="flex gap-2 ml-auto">
922
- <Button
923
- type="button"
924
- variant="secondary"
925
- className="!w-auto text-danger"
926
- disabled={saving}
927
- onClick={() => handleDelete("occurrence")}
928
- >
929
- Delete this event
930
- </Button>
931
- <Button
932
- type="button"
933
- variant="secondary"
934
- className="!w-auto text-danger"
935
- disabled={saving}
936
- onClick={() => handleDelete("series")}
937
- >
938
- Delete series
939
- </Button>
940
- </div>
941
- ))}
942
- </div>
943
- </form>
944
- </Modal>
150
+ <EventShell
151
+ variant={variant}
152
+ label={!occurrence ? "New event" : editing ? "Edit event" : "Event details"}
153
+ focusKey={isForm ? layout : "details"}
154
+ anchor={anchor}
155
+ width={width}
156
+ onClose={isForm ? leaveForm : onClose}
157
+ onBackdropPress={isForm ? () => !dirtyRef.current && leaveForm() : undefined}
158
+ >
159
+ {isForm ? (
160
+ <EventEditor
161
+ layout={layout}
162
+ onExpand={() => setExpanded(true)}
163
+ onCancel={leaveForm}
164
+ onSaved={onSaved}
165
+ onSyncWarning={() => setSyncWarning(true)}
166
+ dirtyRef={dirtyRef}
167
+ mailboxUid={mailboxUid}
168
+ folderUid={folderUid}
169
+ calendars={calendars}
170
+ mailboxOptions={mailboxOptions}
171
+ folderColors={folderColors}
172
+ organizerAddress={organizerAddress}
173
+ occurrence={occurrence}
174
+ initialStart={initialStart}
175
+ initialEnd={initialEnd}
176
+ initialAllDay={initialAllDay}
177
+ quickCreate={
178
+ occurrence
179
+ ? undefined
180
+ : {
181
+ tab,
182
+ onTabChange: (next) => {
183
+ setTab(next);
184
+ setExpanded(false);
185
+ },
186
+ bookingHref,
187
+ }
188
+ }
189
+ />
190
+ ) : (
191
+ <EventDetails
192
+ occurrence={occurrence}
193
+ isInvited={isInvited}
194
+ myResponse={canRespond ? myAttendee.responseStatus : undefined}
195
+ isOwnAddress={isOwnAddress}
196
+ calendarName={calendars?.find((cal) => cal.uid === occurrence.folderUid)?.name}
197
+ calendarColor={folderColors?.[occurrence.folderUid]}
198
+ onClose={onClose}
199
+ onModify={() => setEditing(true)}
200
+ onSaved={onSaved}
201
+ onDeleted={onDeleted}
202
+ />
203
+ )}
204
+ </EventShell>
945
205
  );
946
206
  }