@rapidmx/web-client 0.3.1 → 0.5.0

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 (202) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/branding/index.tsx +39 -404
  3. package/apps/admin/data-requests/index.tsx +480 -485
  4. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  5. package/apps/admin/domains/[uid].tsx +92 -259
  6. package/apps/admin/encryption-policy/index.tsx +19 -0
  7. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  8. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  9. package/apps/admin/index.tsx +118 -82
  10. package/apps/admin/ingest-queue/index.tsx +44 -5
  11. package/apps/admin/mailbox-policy/index.tsx +19 -0
  12. package/apps/admin/mailboxes/[uid].tsx +211 -184
  13. package/apps/admin/mailboxes/new/index.tsx +28 -291
  14. package/apps/admin/plugins/index.tsx +15 -0
  15. package/apps/admin/quarantine/index.tsx +46 -14
  16. package/apps/admin/retention-policy/index.tsx +39 -132
  17. package/apps/admin/setup/index.tsx +15 -0
  18. package/apps/admin/transport-rules/[uid].tsx +30 -1
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  20. package/apps/admin/transport-rules/index.tsx +24 -6
  21. package/apps/admin/transport-rules/new/index.tsx +30 -1
  22. package/apps/escrow/_layout.tsx +2 -3
  23. package/apps/escrow/audit-log/index.tsx +196 -168
  24. package/apps/escrow/matters/[uid].tsx +621 -521
  25. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  26. package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
  27. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  28. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  29. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  30. package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
  31. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
  32. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
  33. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  34. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
  35. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
  36. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
  37. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
  38. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
  39. package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
  40. package/apps/shared/components/admin/signOut.ts +79 -0
  41. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  42. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  43. package/apps/shared/components/calendar/EventModal.tsx +717 -513
  44. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  45. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  46. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  47. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  48. package/apps/shared/components/calendar/allDay.ts +124 -0
  49. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  50. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  51. package/apps/shared/components/contacts/ContactForm.tsx +377 -314
  52. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  53. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  54. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  55. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  56. package/apps/shared/components/layout/AppShell.tsx +270 -169
  57. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  58. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
  59. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  60. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  61. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
  62. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  63. package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
  64. package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
  65. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
  66. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  67. package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
  68. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  69. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  70. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  71. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  72. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  73. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  74. package/apps/shared/mail/listAllPages.ts +39 -0
  75. package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
  76. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  77. package/apps/shared/search/localIndexBuilder.ts +481 -0
  78. package/apps/shared/search/localIndexKey.ts +27 -0
  79. package/apps/shared/search/localIndexRpcClient.ts +316 -0
  80. package/apps/shared/search/localIndexSchema.ts +236 -0
  81. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  82. package/apps/shared/search/localIndexStorage.ts +98 -0
  83. package/apps/shared/search/localIndexVFS.ts +266 -0
  84. package/apps/shared/search/localIndexWorker.ts +926 -0
  85. package/apps/shared/search/searchTier2.ts +79 -0
  86. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  87. package/apps/www/calendar/index.tsx +438 -407
  88. package/apps/www/contacts/[uid].tsx +21 -2
  89. package/apps/www/contacts/index.tsx +148 -17
  90. package/apps/www/index.tsx +1314 -486
  91. package/apps/www/messages/[uid].tsx +23 -7
  92. package/apps/www/settings/auto-reply/index.tsx +134 -129
  93. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  94. package/apps/www/settings/encryption/index.tsx +800 -215
  95. package/apps/www/settings/filters/[uid].tsx +171 -155
  96. package/apps/www/settings/filters/new/index.tsx +8 -1
  97. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  98. package/apps/www/settings/privacy/index.tsx +494 -436
  99. package/apps/www/settings/read-receipts/index.tsx +148 -125
  100. package/apps/www/settings/sharing/index.tsx +274 -0
  101. package/apps/www/tasks/index.tsx +638 -530
  102. package/dist/apps/admin/audit-log/index.js +38 -8
  103. package/dist/apps/admin/branding/index.js +4 -98
  104. package/dist/apps/admin/data-requests/index.js +52 -72
  105. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  106. package/dist/apps/admin/domains/[uid].js +5 -68
  107. package/dist/apps/admin/encryption-policy/index.js +8 -0
  108. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  109. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  110. package/dist/apps/admin/index.js +17 -1
  111. package/dist/apps/admin/ingest-queue/index.js +24 -6
  112. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  113. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  114. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  115. package/dist/apps/admin/plugins/index.js +6 -0
  116. package/dist/apps/admin/quarantine/index.js +20 -11
  117. package/dist/apps/admin/retention-policy/index.js +3 -36
  118. package/dist/apps/admin/setup/index.js +6 -0
  119. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  120. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  121. package/dist/apps/admin/transport-rules/index.js +22 -6
  122. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  123. package/dist/apps/escrow/_layout.js +3 -3
  124. package/dist/apps/escrow/audit-log/index.js +26 -1
  125. package/dist/apps/escrow/matters/[uid].js +77 -42
  126. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  127. package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
  128. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  129. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  130. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  131. package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
  132. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
  133. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
  134. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
  136. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
  137. package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
  138. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
  139. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
  140. package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
  141. package/dist/apps/shared/components/admin/signOut.js +75 -0
  142. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  143. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  144. package/dist/apps/shared/components/calendar/EventModal.js +154 -21
  145. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  146. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  147. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  148. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  149. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  150. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  151. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  152. package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
  153. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  154. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  155. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  156. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  157. package/dist/apps/shared/components/layout/AppShell.js +100 -8
  158. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  159. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
  160. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  161. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  162. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
  163. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  164. package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
  165. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  166. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
  167. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  168. package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
  169. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  170. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  171. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  172. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  173. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  174. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  175. package/dist/apps/shared/mail/listAllPages.js +25 -0
  176. package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
  177. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  178. package/dist/apps/shared/search/localIndexBuilder.js +408 -0
  179. package/dist/apps/shared/search/localIndexKey.js +25 -0
  180. package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
  181. package/dist/apps/shared/search/localIndexSchema.js +185 -0
  182. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  183. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  184. package/dist/apps/shared/search/localIndexVFS.js +258 -0
  185. package/dist/apps/shared/search/localIndexWorker.js +687 -0
  186. package/dist/apps/shared/search/searchTier2.js +38 -0
  187. package/dist/apps/www/calendar/index.js +38 -18
  188. package/dist/apps/www/contacts/[uid].js +13 -2
  189. package/dist/apps/www/contacts/index.js +94 -15
  190. package/dist/apps/www/index.js +770 -137
  191. package/dist/apps/www/messages/[uid].js +23 -7
  192. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  193. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  194. package/dist/apps/www/settings/encryption/index.js +541 -96
  195. package/dist/apps/www/settings/filters/[uid].js +22 -7
  196. package/dist/apps/www/settings/filters/new/index.js +8 -2
  197. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  198. package/dist/apps/www/settings/privacy/index.js +57 -22
  199. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  200. package/dist/apps/www/settings/sharing/index.js +124 -0
  201. package/dist/apps/www/tasks/index.js +76 -14
  202. package/package.json +3 -2
@@ -1,513 +1,717 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { FormEvent, 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
- CalendarEventInput,
14
- RecurrenceRule,
15
- createCalendarEvent,
16
- respondToEvent,
17
- updateCalendarEvent,
18
- } from "@rapidmx/react-shared/calendar/calendarApi.js";
19
- import { deleteEventOccurrence, deleteEventSeries, detachOccurrence, saveEventSeries } from "@rapidmx/react-shared/calendar/calendarMutations.js";
20
- import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
21
- import { Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
22
- import { CalendarOccurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
23
- import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
24
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
25
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
26
- import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
27
- import RecurrenceEditor from "./RecurrenceEditor.js";
28
- import ResourcePicker from "./ResourcePicker.js";
29
-
30
- const INPUT_CLASS =
31
- "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
32
- const SELECT_CLASS =
33
- "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
34
-
35
- const ATTENDEE_ROLES: AttendeeRole[] = ["required", "optional", "resource"];
36
- const BUSY_STATUSES: BusyStatus[] = ["busy", "free", "tentative", "oof"];
37
- const BUSY_STATUS_LABEL: Record<BusyStatus, string> = { busy: "Busy", free: "Free", tentative: "Tentative", oof: "Out of office" };
38
- const RESPONSE_STATUS_LABEL: Record<AttendeeResponseStatus, string> = {
39
- needsAction: "Awaiting response",
40
- accepted: "Accepted",
41
- declined: "Declined",
42
- tentative: "Tentative",
43
- };
44
-
45
- export interface EventModalProps {
46
- open: boolean;
47
- onClose: () => void;
48
- mailboxUid: string;
49
- /** The calendar folder a new event is created into (ignored when editing — an existing event keeps
50
- * its own `folderUid`). When `calendars` names more than one option, a "Calendar" selector lets the
51
- * user override this default before saving. */
52
- folderUid: string;
53
- /** Every calendar the caller could create this event into. Omitted, or a single entry, means "only
54
- * one calendar exists" — no selector is shown and `folderUid` is used as-is, matching this
55
- * component's original single-calendar behavior exactly. */
56
- calendars?: { uid: string; name: string }[];
57
- organizerAddress: string;
58
- /** `null` when creating a new event. */
59
- occurrence: CalendarOccurrence | null;
60
- /** Prefilled start/end for create mode (e.g. the day/slot the user clicked). */
61
- initialStart?: Date;
62
- initialEnd?: Date;
63
- onSaved: () => void;
64
- onDeleted: () => void;
65
- }
66
-
67
- type EditScope = "occurrence" | "series";
68
-
69
- /**
70
- * Create/view/edit/delete for a single calendar event. Editing or deleting a recurring event's
71
- * occurrence offers a choice between "this event" and "the entire series" (see
72
- * `calendarMutations.ts`) — Outlook's own convention for the same ambiguity.
73
- */
74
- export default function EventModal({
75
- open,
76
- onClose,
77
- mailboxUid,
78
- folderUid,
79
- calendars,
80
- organizerAddress,
81
- occurrence,
82
- initialStart,
83
- initialEnd,
84
- onSaved,
85
- onDeleted,
86
- }: EventModalProps) {
87
- const [targetFolderUid, setTargetFolderUid] = useState(folderUid);
88
- const [title, setTitle] = useState(occurrence?.title ?? "");
89
- const [location, setLocation] = useState(occurrence?.location ?? "");
90
- const [start, setStart] = useState(toDatetimeLocal(occurrence?.startDate ?? initialStart?.toISOString() ?? new Date().toISOString()));
91
- const [end, setEnd] = useState(
92
- toDatetimeLocal(occurrence?.endDate ?? initialEnd?.toISOString() ?? new Date(Date.now() + 30 * 60_000).toISOString()),
93
- );
94
- const [allDay, setAllDay] = useState(occurrence?.allDay ?? false);
95
- const [timezone] = useState(occurrence?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone);
96
- const [attendees, setAttendees] = useState<Attendee[]>(occurrence?.attendees ?? []);
97
- const [recurrenceRule, setRecurrenceRule] = useState<RecurrenceRule | null>(occurrence?.recurrenceRule ?? null);
98
- const [reminderMinutes, setReminderMinutes] = useState(occurrence?.reminderMinutesBeforeStart?.toString() ?? "");
99
- const [busyStatus, setBusyStatus] = useState<BusyStatus>(occurrence?.busyStatus ?? "busy");
100
- const [autoReplyEnabled, setAutoReplyEnabled] = useState(occurrence?.autoReplyEnabled ?? false);
101
- const [autoReplyMessage, setAutoReplyMessage] = useState(occurrence?.autoReplyMessage ?? "");
102
- const [editScope, setEditScope] = useState<EditScope>("occurrence");
103
- const [confirmingDelete, setConfirmingDelete] = useState(false);
104
- const [error, setError] = useState<string | null>(null);
105
- const [saving, setSaving] = useState(false);
106
- const [responding, setResponding] = useState(false);
107
- const [resourcePickerOpen, setResourcePickerOpen] = useState(false);
108
- const addResourceButtonRef = useRef<HTMLButtonElement>(null);
109
-
110
- // The viewing mailbox can respond to an *existing* event it's invited to but doesn't organize —
111
- // `organizerAddress` is always this mailbox's own primary SMTP address (see `EventModalProps`), so
112
- // finding it among `attendees` (and not as that attendee's own organizer flag) identifies exactly
113
- // that case. `@rapidmx/restapi`'s `respond()` route has no occurrence-vs-series scope of its own
114
- // (see `calendarApi.ts`'s `respondToEvent` doc comment) — it always acts on `occurrence.uid` as a
115
- // single event document, so this deliberately does not consult `editScope`.
116
- const myAttendeeIndex = occurrence ? attendees.findIndex((a) => a.address.toLowerCase() === organizerAddress.toLowerCase()) : -1;
117
- const canRespond = myAttendeeIndex !== -1 && !attendees[myAttendeeIndex].isOrganizer;
118
-
119
- function updateAttendee(index: number, patch: Partial<Attendee>) {
120
- setAttendees((prev) => prev.map((a, i) => (i === index ? { ...a, ...patch } : a)));
121
- }
122
- function removeAttendee(index: number) {
123
- setAttendees((prev) => prev.filter((_, i) => i !== index));
124
- }
125
- function addResource(mailbox: Mailbox) {
126
- setAttendees((prev) => [
127
- ...prev,
128
- { address: mailbox.primarySmtpAddress, displayName: mailbox.displayName, role: "resource", responseStatus: "needsAction", isOrganizer: false },
129
- ]);
130
- setResourcePickerOpen(false);
131
- }
132
-
133
- async function handleSubmit(e: FormEvent) {
134
- e.preventDefault();
135
- setError(null);
136
-
137
- if (!title.trim()) {
138
- setError("A title is required.");
139
- return;
140
- }
141
- if (new Date(end) <= new Date(start)) {
142
- setError("The end time must be after the start time.");
143
- return;
144
- }
145
-
146
- const fields: Partial<CalendarEventInput> = {
147
- title: title.trim(),
148
- location: location.trim() || undefined,
149
- startDate: new Date(start).toISOString(),
150
- endDate: new Date(end).toISOString(),
151
- allDay,
152
- timezone,
153
- organizer: { address: organizerAddress, type: "to" },
154
- attendees,
155
- recurrenceRule: recurrenceRule ?? undefined,
156
- reminderMinutesBeforeStart: reminderMinutes.trim() ? Number(reminderMinutes) : undefined,
157
- busyStatus,
158
- autoReplyEnabled,
159
- autoReplyMessage: autoReplyEnabled ? autoReplyMessage : undefined,
160
- };
161
-
162
- setSaving(true);
163
- try {
164
- if (!occurrence) {
165
- await createCalendarEvent({ mailboxUid, folderUid: targetFolderUid, ...fields } as CalendarEventInput);
166
- } else if (occurrence.isRecurringOccurrence && editScope === "occurrence") {
167
- await detachOccurrence(occurrence, fields);
168
- } else if (occurrence.isRecurringOccurrence) {
169
- await saveEventSeries(occurrence, fields);
170
- } else {
171
- await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...fields });
172
- }
173
- onSaved();
174
- } catch (err) {
175
- setError(err instanceof ApiRequestError ? err.message : "Could not save this event.");
176
- } finally {
177
- setSaving(false);
178
- }
179
- }
180
-
181
- // Only ever invoked from the Delete button(s) rendered inside the `{occurrence && (...)}` block
182
- // below, so `occurrence` is always non-null here — no defensive null check needed.
183
- async function handleDelete(scope: EditScope) {
184
- setError(null);
185
- setSaving(true);
186
- try {
187
- if (occurrence!.isRecurringOccurrence && scope === "occurrence") {
188
- await deleteEventOccurrence(occurrence!);
189
- } else {
190
- await deleteEventSeries(occurrence!);
191
- }
192
- onDeleted();
193
- } catch (err) {
194
- setError(err instanceof ApiRequestError ? err.message : "Could not delete this event.");
195
- setSaving(false);
196
- }
197
- }
198
-
199
- // Only rendered inside the `{canRespond && (...)}` block below, which already requires `occurrence`
200
- // to be non-null (see `canRespond`'s own definition above) — same established pattern as
201
- // `handleDelete`'s non-null assertion.
202
- async function handleRespond(responseStatus: AttendeeResponseInput) {
203
- setError(null);
204
- setResponding(true);
205
- try {
206
- await respondToEvent(occurrence!.uid, responseStatus);
207
- // A decline soft-deletes the mailbox's own copy server-side — treat it the same as a
208
- // delete rather than a save so the calendar view drops it immediately.
209
- if (responseStatus === "declined") {
210
- onDeleted();
211
- } else {
212
- onSaved();
213
- }
214
- } catch (err) {
215
- setError(err instanceof ApiRequestError ? err.message : "Could not send your response.");
216
- setResponding(false);
217
- }
218
- }
219
-
220
- return (
221
- <Modal open={open} onClose={onClose} title={occurrence ? "Edit event" : "New event"}>
222
- <form onSubmit={handleSubmit} className="flex flex-col gap-1">
223
- {error && <Alert>{error}</Alert>}
224
-
225
- {!occurrence && calendars && calendars.length > 1 && (
226
- <FormField label="Calendar" htmlFor="event-calendar">
227
- <select
228
- id="event-calendar"
229
- className={INPUT_CLASS}
230
- value={targetFolderUid}
231
- onChange={(e) => setTargetFolderUid(e.target.value)}
232
- >
233
- {calendars.map((cal) => (
234
- <option key={cal.uid} value={cal.uid}>
235
- {cal.name}
236
- </option>
237
- ))}
238
- </select>
239
- </FormField>
240
- )}
241
-
242
- <FormField label="Title" htmlFor="event-title">
243
- <input id="event-title" type="text" className={INPUT_CLASS} value={title} onChange={(e) => setTitle(e.target.value)} />
244
- </FormField>
245
-
246
- <FormField label="Location" htmlFor="event-location">
247
- <input id="event-location" type="text" className={INPUT_CLASS} value={location} onChange={(e) => setLocation(e.target.value)} />
248
- </FormField>
249
-
250
- <label className="flex items-center gap-2 text-sm font-medium mb-3">
251
- <input type="checkbox" checked={allDay} onChange={(e) => setAllDay(e.target.checked)} />
252
- All day
253
- </label>
254
-
255
- <div className="grid grid-cols-2 gap-3">
256
- <FormField label="Start" htmlFor="event-start">
257
- <input
258
- id="event-start"
259
- type={allDay ? "date" : "datetime-local"}
260
- className={INPUT_CLASS}
261
- value={allDay ? start.slice(0, 10) : start}
262
- onChange={(e) => setStart(allDay ? `${e.target.value}T00:00` : e.target.value)}
263
- />
264
- </FormField>
265
- <FormField label="End" htmlFor="event-end">
266
- <input
267
- id="event-end"
268
- type={allDay ? "date" : "datetime-local"}
269
- className={INPUT_CLASS}
270
- value={allDay ? end.slice(0, 10) : end}
271
- onChange={(e) => setEnd(allDay ? `${e.target.value}T00:00` : e.target.value)}
272
- />
273
- </FormField>
274
- </div>
275
-
276
- <FormField label="Attendees" htmlFor="event-attendees">
277
- <div id="event-attendees" className="flex flex-col gap-2">
278
- {attendees.map((attendee, i) => (
279
- <div key={i} className="flex gap-2">
280
- <input
281
- type="email"
282
- className={`${INPUT_CLASS} flex-1`}
283
- value={attendee.address}
284
- onChange={(e) => updateAttendee(i, { address: e.target.value })}
285
- aria-label={`Attendee email ${i + 1}`}
286
- />
287
- <select
288
- className={SELECT_CLASS}
289
- value={attendee.role}
290
- onChange={(e) => updateAttendee(i, { role: e.target.value as AttendeeRole })}
291
- aria-label={`Attendee role ${i + 1}`}
292
- >
293
- {ATTENDEE_ROLES.map((role) => (
294
- <option key={role} value={role}>
295
- {role}
296
- </option>
297
- ))}
298
- </select>
299
- <span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt self-center">
300
- {RESPONSE_STATUS_LABEL[attendee.responseStatus]}
301
- </span>
302
- <button
303
- type="button"
304
- onClick={() => removeAttendee(i)}
305
- className="text-sm text-danger px-2"
306
- aria-label={`Remove attendee ${i + 1}`}
307
- >
308
- &times;
309
- </button>
310
- </div>
311
- ))}
312
- <div className="flex gap-3 items-center">
313
- <button
314
- type="button"
315
- onClick={() => setAttendees((prev) => [...prev, { address: "", role: "required", responseStatus: "needsAction", isOrganizer: false }])}
316
- className="self-start text-xs font-medium text-primary-dark hover:underline"
317
- >
318
- + Add attendee
319
- </button>
320
- <button
321
- ref={addResourceButtonRef}
322
- type="button"
323
- onClick={() => setResourcePickerOpen(true)}
324
- className="self-start text-xs font-medium text-primary-dark hover:underline"
325
- >
326
- + Add room/equipment
327
- </button>
328
- </div>
329
- {resourcePickerOpen && (
330
- <ResourcePicker
331
- anchorRef={addResourceButtonRef}
332
- onClose={() => setResourcePickerOpen(false)}
333
- onSelect={addResource}
334
- excludeAddresses={attendees.map((a) => a.address.toLowerCase())}
335
- />
336
- )}
337
- </div>
338
- </FormField>
339
-
340
- {canRespond && (
341
- <div className="flex flex-col gap-2 mb-3 p-3 rounded-sm bg-surface-alt">
342
- <div className="flex items-center justify-between gap-3 flex-wrap">
343
- <span className="text-sm font-medium">Your response</span>
344
- <div className="flex gap-2">
345
- <Button
346
- type="button"
347
- variant="secondary"
348
- className="!w-auto"
349
- disabled={responding}
350
- onClick={() => handleRespond("accepted")}
351
- >
352
- Accept
353
- </Button>
354
- <Button
355
- type="button"
356
- variant="secondary"
357
- className="!w-auto"
358
- disabled={responding}
359
- onClick={() => handleRespond("tentative")}
360
- >
361
- Tentative
362
- </Button>
363
- <Button
364
- type="button"
365
- variant="secondary"
366
- className="!w-auto text-danger"
367
- disabled={responding}
368
- onClick={() => handleRespond("declined")}
369
- >
370
- Decline
371
- </Button>
372
- </div>
373
- </div>
374
- <p className="text-xs text-text-muted">Other attendees&rsquo; responses may take a few minutes to update.</p>
375
- </div>
376
- )}
377
-
378
- <div className="grid grid-cols-2 gap-3">
379
- <FormField label="Busy status" htmlFor="event-busyStatus">
380
- <select
381
- id="event-busyStatus"
382
- className={INPUT_CLASS}
383
- value={busyStatus}
384
- onChange={(e) => setBusyStatus(e.target.value as BusyStatus)}
385
- >
386
- {BUSY_STATUSES.map((status) => (
387
- <option key={status} value={status}>
388
- {BUSY_STATUS_LABEL[status]}
389
- </option>
390
- ))}
391
- </select>
392
- </FormField>
393
- <FormField label="Reminder (minutes before)" htmlFor="event-reminder">
394
- <input
395
- id="event-reminder"
396
- type="number"
397
- min={0}
398
- className={INPUT_CLASS}
399
- value={reminderMinutes}
400
- onChange={(e) => setReminderMinutes(e.target.value)}
401
- placeholder="None"
402
- />
403
- </FormField>
404
- </div>
405
-
406
- <div className="mb-3">
407
- <label className="flex items-center gap-2 text-sm font-medium">
408
- <input
409
- type="checkbox"
410
- checked={autoReplyEnabled}
411
- onChange={(e) => setAutoReplyEnabled(e.target.checked)}
412
- />
413
- Send an automatic reply while this event is happening
414
- </label>
415
- <p className="text-xs text-text-muted mt-1">
416
- Applies in addition to your mailbox&rsquo;s own Automatic Replies setting (see Settings) —
417
- this event&rsquo;s message takes over for its own start/end window.
418
- </p>
419
- {autoReplyEnabled && (
420
- <textarea
421
- aria-label="Automatic reply message"
422
- className={`${INPUT_CLASS} mt-2`}
423
- rows={3}
424
- value={autoReplyMessage}
425
- onChange={(e) => setAutoReplyMessage(e.target.value)}
426
- placeholder="I'm out of office and back on..."
427
- />
428
- )}
429
- </div>
430
-
431
- <FormField label="Recurrence" htmlFor="event-recurrence">
432
- <RecurrenceEditor value={recurrenceRule} onChange={setRecurrenceRule} />
433
- </FormField>
434
-
435
- {occurrence?.isRecurringOccurrence && (
436
- <fieldset className="flex flex-col gap-1.5 text-sm mb-3">
437
- <legend className="text-xs font-bold uppercase tracking-wide text-text-muted mb-1">Apply changes to</legend>
438
- <label className="flex items-center gap-2">
439
- <input
440
- type="radio"
441
- name="event-edit-scope"
442
- checked={editScope === "occurrence"}
443
- onChange={() => setEditScope("occurrence")}
444
- />
445
- This event only
446
- </label>
447
- <label className="flex items-center gap-2">
448
- <input
449
- type="radio"
450
- name="event-edit-scope"
451
- checked={editScope === "series"}
452
- onChange={() => setEditScope("series")}
453
- />
454
- The entire series
455
- </label>
456
- </fieldset>
457
- )}
458
-
459
- <div className="flex gap-3 mt-2">
460
- <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
461
- Save
462
- </Button>
463
- <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
464
- Cancel
465
- </Button>
466
- {occurrence &&
467
- (!occurrence.isRecurringOccurrence ? (
468
- <Button
469
- type="button"
470
- variant="secondary"
471
- className="!w-auto ml-auto text-danger"
472
- disabled={saving}
473
- onClick={() => handleDelete("series")}
474
- >
475
- Delete
476
- </Button>
477
- ) : !confirmingDelete ? (
478
- <Button
479
- type="button"
480
- variant="secondary"
481
- className="!w-auto ml-auto text-danger"
482
- disabled={saving}
483
- onClick={() => setConfirmingDelete(true)}
484
- >
485
- Delete
486
- </Button>
487
- ) : (
488
- <div className="flex gap-2 ml-auto">
489
- <Button
490
- type="button"
491
- variant="secondary"
492
- className="!w-auto text-danger"
493
- disabled={saving}
494
- onClick={() => handleDelete("occurrence")}
495
- >
496
- Delete this event
497
- </Button>
498
- <Button
499
- type="button"
500
- variant="secondary"
501
- className="!w-auto text-danger"
502
- disabled={saving}
503
- onClick={() => handleDelete("series")}
504
- >
505
- Delete series
506
- </Button>
507
- </div>
508
- ))}
509
- </div>
510
- </form>
511
- </Modal>
512
- );
513
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, 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
+ CalendarEventInput,
14
+ RecurrenceRule,
15
+ createCalendarEvent,
16
+ getCalendarEvent,
17
+ respondToEvent,
18
+ updateCalendarEvent,
19
+ } from "@rapidmx/react-shared/calendar/calendarApi.js";
20
+ import { deleteEventOccurrence, deleteEventSeries, detachOccurrence, saveEventSeries } from "@rapidmx/react-shared/calendar/calendarMutations.js";
21
+ import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
22
+ import { Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
23
+ import { CalendarOccurrence, fromEventWallClock, toEventWallClock } from "@rapidmx/react-shared/calendar/recurrence.js";
24
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
25
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
26
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
27
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
28
+ import RecurrenceEditor from "./RecurrenceEditor.js";
29
+ import { addDaysToKey, allDayDateKey, allDayInstant, recurrenceUntilDateKey, recurrenceUntilInstant, startWeekdayCode } from "./allDay.js";
30
+ import ResourcePicker from "./ResourcePicker.js";
31
+
32
+ const INPUT_CLASS =
33
+ "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
34
+ const SELECT_CLASS =
35
+ "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
36
+
37
+ const ATTENDEE_ROLES: AttendeeRole[] = ["required", "optional", "resource"];
38
+ const BUSY_STATUSES: BusyStatus[] = ["busy", "free", "tentative", "oof"];
39
+ const BUSY_STATUS_LABEL: Record<BusyStatus, string> = { busy: "Busy", free: "Free", tentative: "Tentative", oof: "Out of office" };
40
+ const RESPONSE_STATUS_LABEL: Record<AttendeeResponseStatus, string> = {
41
+ needsAction: "Awaiting response",
42
+ accepted: "Accepted",
43
+ declined: "Declined",
44
+ tentative: "Tentative",
45
+ };
46
+
47
+ export interface EventModalProps {
48
+ open: boolean;
49
+ onClose: () => void;
50
+ mailboxUid: string;
51
+ /** The calendar folder a new event is created into (ignored when editing — an existing event keeps
52
+ * its own `folderUid`). When `calendars` names more than one option, a "Calendar" selector lets the
53
+ * user override this default before saving. */
54
+ folderUid: string;
55
+ /** Every calendar the caller could create this event into. Omitted, or a single entry, means "only
56
+ * one calendar exists" — no selector is shown and `folderUid` is used as-is, matching this
57
+ * component's original single-calendar behavior exactly. */
58
+ calendars?: { uid: string; name: string }[];
59
+ /** Every mailbox a new event could be created in, each with its own calendars. With more than one, a
60
+ * "Mailbox" selector (create mode only) chooses the mailbox, which in turn drives the Calendar selector
61
+ * and the organizer address; `mailboxUid`/`folderUid` are the initial selection. */
62
+ mailboxOptions?: { mailbox: Mailbox; calendars: { uid: string; name: string }[] }[];
63
+ organizerAddress: string;
64
+ /** The viewing mailbox's alias addresses - an event it organizes (or an invitation to it) may name any of
65
+ * them instead of `organizerAddress`. Defaults to that mailbox's `aliasAddresses` from `mailboxOptions`. */
66
+ organizerAliases?: string[];
67
+ /** `null` when creating a new event. */
68
+ occurrence: CalendarOccurrence | null;
69
+ /** Prefilled start/end for create mode (e.g. the day/slot the user clicked). */
70
+ initialStart?: Date;
71
+ initialEnd?: Date;
72
+ onSaved: () => void;
73
+ onDeleted: () => void;
74
+ }
75
+
76
+ type EditScope = "occurrence" | "series";
77
+
78
+ /** `CalendarEventInput` fields as sent on save. `null` explicitly clears a field on an update - an
79
+ * omitted (`undefined`) field is dropped by `JSON.stringify` and would leave the stored value in place. */
80
+ type EventFields = { [K in keyof CalendarEventInput]?: CalendarEventInput[K] | null };
81
+
82
+ const MS_PER_MINUTE = 60_000;
83
+ const MS_PER_DAY = 86_400_000;
84
+
85
+ /** Whether two ISO instants fall in the same minute - a stored `...T15:00:00Z` and the form's re-serialized
86
+ * `...T15:00:00.000Z` name the same time without being equal strings. */
87
+ function sameMinute(a: string, b: string): boolean {
88
+ return Math.round(new Date(a).getTime() / MS_PER_MINUTE) === Math.round(new Date(b).getTime() / MS_PER_MINUTE);
89
+ }
90
+
91
+ /** Milliseconds since midnight of an instant's wall clock in `timezone` (a timed event's own zone). */
92
+ function wallTimeOfDayMs(instantMs: number, timezone: string | undefined): number {
93
+ const wall = toEventWallClock(instantMs, timezone, false);
94
+ return ((wall % MS_PER_DAY) + MS_PER_DAY) % MS_PER_DAY;
95
+ }
96
+
97
+ /**
98
+ * Rewrites an edited occurrence's `startDate`/`endDate` for a save to the entire series. The modal only
99
+ * ever shows (and edits) *this occurrence's* dates, but the series is stored as its master record starting
100
+ * at the first occurrence - sending the occurrence's dates as-is would move the whole series' start to
101
+ * this occurrence. So only the change in time-of-day (and the new duration) is applied to the master's own
102
+ * start; the series keeps its original first date. Unchanged times aren't sent at all.
103
+ *
104
+ * Times of day are read on the event's own timezone wall clock (the series expands there), not the
105
+ * browser's, and the change is the smallest signed difference modulo 24h - moving 23:00 to 01:00 is two
106
+ * hours later (into the next day), never 22 hours earlier. The master's new start is converted back from
107
+ * that wall clock, so a master on the other side of a DST change keeps the chosen local time.
108
+ */
109
+ async function toSeriesFields(occurrence: CalendarOccurrence, fields: EventFields): Promise<EventFields> {
110
+ const { startDate, endDate, ...rest } = fields;
111
+ if (sameMinute(startDate as string, occurrence.startDate) && sameMinute(endDate as string, occurrence.endDate)) {
112
+ return rest;
113
+ }
114
+ const master = await getCalendarEvent(occurrence.uid);
115
+ const timezone = fields.timezone as string;
116
+ const newStart = new Date(startDate as string).getTime();
117
+ const durationMs = new Date(endDate as string).getTime() - newStart;
118
+ let masterStart: number;
119
+ if (fields.allDay) {
120
+ // Timed -> all-day keeps the master's own date on its wall clock.
121
+ const masterDateKey = master.allDay
122
+ ? allDayDateKey(master.startDate)
123
+ : new Date(toEventWallClock(new Date(master.startDate).getTime(), timezone, false)).toISOString().slice(0, 10);
124
+ masterStart = new Date(allDayInstant(masterDateKey)).getTime();
125
+ } else if (master.allDay) {
126
+ // All-day -> timed: the master's own date at the newly chosen wall-clock time.
127
+ const masterDateWall = new Date(allDayInstant(allDayDateKey(master.startDate))).getTime();
128
+ masterStart = fromEventWallClock(masterDateWall + wallTimeOfDayMs(newStart, timezone), timezone, false);
129
+ } else {
130
+ const rawDelta = wallTimeOfDayMs(newStart, timezone) - wallTimeOfDayMs(new Date(occurrence.startDate).getTime(), timezone);
131
+ const delta = ((((rawDelta + MS_PER_DAY / 2) % MS_PER_DAY) + MS_PER_DAY) % MS_PER_DAY) - MS_PER_DAY / 2;
132
+ const masterWall = toEventWallClock(new Date(master.startDate).getTime(), timezone, false);
133
+ masterStart = fromEventWallClock(masterWall + delta, timezone, false);
134
+ }
135
+ return {
136
+ ...rest,
137
+ startDate: new Date(masterStart).toISOString(),
138
+ endDate: new Date(masterStart + durationMs).toISOString(),
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Create/view/edit/delete for a single calendar event. Editing or deleting a recurring event's
144
+ * occurrence offers a choice between "this event" and "the entire series" (see
145
+ * `calendarMutations.ts`) — Outlook's own convention for the same ambiguity.
146
+ */
147
+ export default function EventModal({
148
+ open,
149
+ onClose,
150
+ mailboxUid,
151
+ folderUid,
152
+ calendars,
153
+ mailboxOptions,
154
+ organizerAddress,
155
+ organizerAliases,
156
+ occurrence,
157
+ initialStart,
158
+ initialEnd,
159
+ onSaved,
160
+ onDeleted,
161
+ }: EventModalProps) {
162
+ const [targetFolderUid, setTargetFolderUid] = useState(folderUid);
163
+ const [targetMailboxUid, setTargetMailboxUid] = useState(mailboxUid);
164
+ const targetMailboxOption = mailboxOptions?.find((option) => option.mailbox.uid === targetMailboxUid);
165
+ // Create mode follows the chosen mailbox; editing an existing event always keeps the passed-in values.
166
+ const calendarChoices = !occurrence && targetMailboxOption ? targetMailboxOption.calendars : calendars;
167
+ const effectiveOrganizerAddress = !occurrence && targetMailboxOption ? targetMailboxOption.mailbox.primarySmtpAddress : organizerAddress;
168
+ // Every address the viewing mailbox receives at - its primary address and its aliases, case-insensitively.
169
+ const ownAddresses = new Set(
170
+ [organizerAddress, ...(organizerAliases ?? mailboxOptions?.find((option) => option.mailbox.uid === mailboxUid)?.mailbox.aliasAddresses ?? [])].map(
171
+ (address) => address.toLowerCase(),
172
+ ),
173
+ );
174
+ const isOwnAddress = (address: string) => ownAddresses.has(address.toLowerCase());
175
+ // An existing event organized by someone else is the viewing mailbox's copy of an invitation: only the
176
+ // organizer can change it (their next update would overwrite local edits anyway), so it's read-only
177
+ // apart from the RSVP controls.
178
+ const isInvited =
179
+ !!occurrence && !isOwnAddress(occurrence.organizer.address) && !occurrence.attendees.some((a) => a.isOrganizer && isOwnAddress(a.address));
180
+
181
+ function handleMailboxChange(nextMailboxUid: string) {
182
+ setTargetMailboxUid(nextMailboxUid);
183
+ const firstCalendar = mailboxOptions?.find((option) => option.mailbox.uid === nextMailboxUid)?.calendars[0];
184
+ if (firstCalendar) {
185
+ setTargetFolderUid(firstCalendar.uid);
186
+ }
187
+ }
188
+ const [title, setTitle] = useState(occurrence?.title ?? "");
189
+ const [location, setLocation] = useState(occurrence?.location ?? "");
190
+ // An all-day event is stored date-only with an exclusive end (see `allDay.ts`); the form shows its
191
+ // inclusive last day instead.
192
+ const [start, setStart] = useState(() =>
193
+ occurrence?.allDay
194
+ ? `${allDayDateKey(occurrence.startDate)}T00:00`
195
+ : toDatetimeLocal(occurrence?.startDate ?? initialStart?.toISOString() ?? new Date().toISOString()),
196
+ );
197
+ const [end, setEnd] = useState(() => {
198
+ if (occurrence?.allDay) {
199
+ const startKey = allDayDateKey(occurrence.startDate);
200
+ const lastDayKey = addDaysToKey(allDayDateKey(occurrence.endDate), -1);
201
+ return `${lastDayKey < startKey ? startKey : lastDayKey}T00:00`;
202
+ }
203
+ return toDatetimeLocal(occurrence?.endDate ?? initialEnd?.toISOString() ?? new Date(Date.now() + 30 * 60_000).toISOString());
204
+ });
205
+ const [allDay, setAllDay] = useState(occurrence?.allDay ?? false);
206
+ const [timezone] = useState(occurrence?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone);
207
+ const [attendees, setAttendees] = useState<Attendee[]>(occurrence?.attendees ?? []);
208
+ const [recurrenceRule, setRecurrenceRule] = useState<RecurrenceRule | null>(occurrence?.recurrenceRule ?? null);
209
+ const [reminderMinutes, setReminderMinutes] = useState(occurrence?.reminderMinutesBeforeStart?.toString() ?? "");
210
+ const [busyStatus, setBusyStatus] = useState<BusyStatus>(occurrence?.busyStatus ?? "busy");
211
+ const [autoReplyEnabled, setAutoReplyEnabled] = useState(occurrence?.autoReplyEnabled ?? false);
212
+ const [autoReplyMessage, setAutoReplyMessage] = useState(occurrence?.autoReplyMessage ?? "");
213
+ const [editScope, setEditScope] = useState<EditScope>("occurrence");
214
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
215
+ const [error, setError] = useState<string | null>(null);
216
+ const [saving, setSaving] = useState(false);
217
+ // A series save that moved the series but couldn't move every separately edited occurrence with it.
218
+ const [syncWarning, setSyncWarning] = useState(false);
219
+ const [responding, setResponding] = useState(false);
220
+ const [resourcePickerOpen, setResourcePickerOpen] = useState(false);
221
+ const addResourceButtonRef = useRef<HTMLButtonElement>(null);
222
+
223
+ // The viewing mailbox can respond to an *existing* event it's invited to but doesn't organize —
224
+ // `organizerAddress` (plus the aliases) are this mailbox's own addresses (see `EventModalProps`), so
225
+ // finding one among `attendees` (and not as that attendee's own organizer flag) identifies exactly
226
+ // that case. `@rapidmx/restapi`'s `respond()` route has no occurrence-vs-series scope of its own
227
+ // (see `calendarApi.ts`'s `respondToEvent` doc comment) — it always acts on `occurrence.uid` as a
228
+ // single event document, so this deliberately does not consult `editScope`.
229
+ const myAttendeeIndex = occurrence ? attendees.findIndex((a) => isOwnAddress(a.address)) : -1;
230
+ const canRespond = myAttendeeIndex !== -1 && !attendees[myAttendeeIndex].isOrganizer;
231
+ // "This event only" detaches a standalone, non-repeating copy - the series' rule doesn't apply to it.
232
+ const editingSingleOccurrence = !!occurrence?.isRecurringOccurrence && editScope === "occurrence";
233
+
234
+ function updateAttendee(index: number, patch: Partial<Attendee>) {
235
+ setAttendees((prev) => prev.map((a, i) => (i === index ? { ...a, ...patch } : a)));
236
+ }
237
+ function removeAttendee(index: number) {
238
+ setAttendees((prev) => prev.filter((_, i) => i !== index));
239
+ }
240
+ function addResource(mailbox: Mailbox) {
241
+ setAttendees((prev) => [
242
+ ...prev,
243
+ { address: mailbox.primarySmtpAddress, displayName: mailbox.displayName, role: "resource", responseStatus: "needsAction", isOrganizer: false },
244
+ ]);
245
+ setResourcePickerOpen(false);
246
+ }
247
+
248
+ async function handleSubmit(e: FormEvent) {
249
+ e.preventDefault();
250
+ setError(null);
251
+
252
+ if (!title.trim()) {
253
+ setError("A title is required.");
254
+ return;
255
+ }
256
+ let startDate: string;
257
+ let endDate: string;
258
+ if (allDay) {
259
+ const startKey = start.slice(0, 10);
260
+ const lastDayKey = end.slice(0, 10);
261
+ if (lastDayKey < startKey) {
262
+ setError("The end date can't be before the start date.");
263
+ return;
264
+ }
265
+ startDate = allDayInstant(startKey);
266
+ endDate = allDayInstant(addDaysToKey(lastDayKey, 1));
267
+ } else {
268
+ if (new Date(end) <= new Date(start)) {
269
+ setError("The end time must be after the start time.");
270
+ return;
271
+ }
272
+ startDate = new Date(start).toISOString();
273
+ endDate = new Date(end).toISOString();
274
+ }
275
+
276
+ // Only a stored event is updated in place, where a cleared field must be sent as `null`; creating
277
+ // (a new event, or the detached copy of one occurrence) just omits it.
278
+ const updatingInPlace = !!occurrence && !editingSingleOccurrence;
279
+ const cleared = updatingInPlace ? null : undefined;
280
+ const fields: EventFields = {
281
+ title: title.trim(),
282
+ location: location.trim() || cleared,
283
+ startDate,
284
+ endDate,
285
+ allDay,
286
+ timezone,
287
+ attendees,
288
+ recurrenceRule: editingSingleOccurrence ? undefined : (recurrenceRule ?? cleared),
289
+ reminderMinutesBeforeStart: reminderMinutes.trim() ? Number(reminderMinutes) : cleared,
290
+ busyStatus,
291
+ autoReplyEnabled,
292
+ autoReplyMessage: autoReplyEnabled ? autoReplyMessage : cleared,
293
+ };
294
+
295
+ setSaving(true);
296
+ try {
297
+ if (!occurrence) {
298
+ // The organizer is only ever set on create - an edit keeps the event's own organizer.
299
+ await createCalendarEvent({
300
+ mailboxUid: targetMailboxUid,
301
+ folderUid: targetFolderUid,
302
+ ...fields,
303
+ organizer: { address: effectiveOrganizerAddress, type: "to" },
304
+ } as CalendarEventInput);
305
+ } else if (editingSingleOccurrence) {
306
+ await detachOccurrence(occurrence, fields as Partial<CalendarEventInput>);
307
+ } else if (occurrence.isRecurringOccurrence) {
308
+ const saved = await saveEventSeries(occurrence, (await toSeriesFields(occurrence, fields)) as Partial<CalendarEventInput>);
309
+ if (saved.detachedOccurrenceSyncFailed) {
310
+ // The series itself was saved - say so, and leave closing to the user once they've read it.
311
+ setSyncWarning(true);
312
+ return;
313
+ }
314
+ } else {
315
+ await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...(fields as Partial<CalendarEventInput>) });
316
+ }
317
+ onSaved();
318
+ } catch (err) {
319
+ setError(err instanceof ApiRequestError ? err.message : "Could not save this event.");
320
+ } finally {
321
+ setSaving(false);
322
+ }
323
+ }
324
+
325
+ // Only ever invoked from the Delete button(s) rendered inside the `{occurrence && (...)}` block
326
+ // below, so `occurrence` is always non-null here — no defensive null check needed.
327
+ async function handleDelete(scope: EditScope) {
328
+ setError(null);
329
+ setSaving(true);
330
+ try {
331
+ if (occurrence!.isRecurringOccurrence && scope === "occurrence") {
332
+ await deleteEventOccurrence(occurrence!);
333
+ } else {
334
+ await deleteEventSeries(occurrence!);
335
+ }
336
+ onDeleted();
337
+ } catch (err) {
338
+ setError(err instanceof ApiRequestError ? err.message : "Could not delete this event.");
339
+ setSaving(false);
340
+ }
341
+ }
342
+
343
+ // Only rendered inside the `{canRespond && (...)}` block below, which already requires `occurrence`
344
+ // to be non-null (see `canRespond`'s own definition above) — same established pattern as
345
+ // `handleDelete`'s non-null assertion.
346
+ async function handleRespond(responseStatus: AttendeeResponseInput) {
347
+ setError(null);
348
+ setResponding(true);
349
+ try {
350
+ await respondToEvent(occurrence!.uid, responseStatus);
351
+ // A decline soft-deletes the mailbox's own copy server-side — treat it the same as a
352
+ // delete rather than a save so the calendar view drops it immediately.
353
+ if (responseStatus === "declined") {
354
+ onDeleted();
355
+ } else {
356
+ onSaved();
357
+ }
358
+ } catch (err) {
359
+ setError(err instanceof ApiRequestError ? err.message : "Could not send your response.");
360
+ setResponding(false);
361
+ }
362
+ }
363
+
364
+ // The recurrence end date is stored in the frame the series expands in (UTC for all-day, local otherwise),
365
+ // so toggling All day re-stores the same chosen date in the new frame.
366
+ function handleAllDayChange(nextAllDay: boolean) {
367
+ setAllDay(nextAllDay);
368
+ setRecurrenceRule((rule) =>
369
+ rule?.until ? { ...rule, until: recurrenceUntilInstant(recurrenceUntilDateKey(rule.until, allDay), nextAllDay) } : rule,
370
+ );
371
+ }
372
+
373
+ if (syncWarning) {
374
+ return (
375
+ <Modal open={open} onClose={onSaved} title="Series saved">
376
+ <p role="alert" className="text-sm mb-4">
377
+ The series was saved, but some occurrences you had changed individually couldn&rsquo;t be moved with it. They
378
+ may now appear twice - check the calendar and delete any duplicate.
379
+ </p>
380
+ <Button type="button" className="!w-auto" onClick={onSaved}>
381
+ OK
382
+ </Button>
383
+ </Modal>
384
+ );
385
+ }
386
+
387
+ return (
388
+ <Modal open={open} onClose={onClose} title={occurrence ? "Edit event" : "New event"}>
389
+ <form onSubmit={handleSubmit} className="flex flex-col gap-1">
390
+ {error && <Alert>{error}</Alert>}
391
+
392
+ {canRespond && (
393
+ <div className="flex flex-col gap-2 mb-3 p-3 rounded-sm bg-surface-alt">
394
+ <div className="flex items-center justify-between gap-3 flex-wrap">
395
+ <span className="text-sm font-medium">Your response</span>
396
+ <div className="flex gap-2">
397
+ <Button
398
+ type="button"
399
+ variant="secondary"
400
+ className="!w-auto"
401
+ disabled={responding}
402
+ onClick={() => handleRespond("accepted")}
403
+ >
404
+ Accept
405
+ </Button>
406
+ <Button
407
+ type="button"
408
+ variant="secondary"
409
+ className="!w-auto"
410
+ disabled={responding}
411
+ onClick={() => handleRespond("tentative")}
412
+ >
413
+ Tentative
414
+ </Button>
415
+ <Button
416
+ type="button"
417
+ variant="secondary"
418
+ className="!w-auto text-danger"
419
+ disabled={responding}
420
+ onClick={() => handleRespond("declined")}
421
+ >
422
+ Decline
423
+ </Button>
424
+ </div>
425
+ </div>
426
+ <p className="text-xs text-text-muted">Other attendees&rsquo; responses may take a few minutes to update.</p>
427
+ </div>
428
+ )}
429
+
430
+ {isInvited && (
431
+ <p className="text-xs text-text-muted mb-3">
432
+ You were invited to this event. Only the organizer can change its details.
433
+ </p>
434
+ )}
435
+
436
+ <fieldset disabled={isInvited} className="flex flex-col gap-1 min-w-0">
437
+
438
+ {!occurrence && mailboxOptions && mailboxOptions.length > 1 && (
439
+ <FormField label="Mailbox" htmlFor="event-mailbox">
440
+ <select
441
+ id="event-mailbox"
442
+ className={INPUT_CLASS}
443
+ value={targetMailboxUid}
444
+ onChange={(e) => handleMailboxChange(e.target.value)}
445
+ >
446
+ {mailboxOptions.map(({ mailbox }) => (
447
+ <option key={mailbox.uid} value={mailbox.uid}>
448
+ {mailbox.displayName}
449
+ {mailbox.ownerUserUid ? "" : " (shared)"}
450
+ </option>
451
+ ))}
452
+ </select>
453
+ </FormField>
454
+ )}
455
+
456
+ {!occurrence && calendarChoices && calendarChoices.length > 1 && (
457
+ <FormField label="Calendar" htmlFor="event-calendar">
458
+ <select
459
+ id="event-calendar"
460
+ className={INPUT_CLASS}
461
+ value={targetFolderUid}
462
+ onChange={(e) => setTargetFolderUid(e.target.value)}
463
+ >
464
+ {calendarChoices.map((cal) => (
465
+ <option key={cal.uid} value={cal.uid}>
466
+ {cal.name}
467
+ </option>
468
+ ))}
469
+ </select>
470
+ </FormField>
471
+ )}
472
+
473
+ <FormField label="Title" htmlFor="event-title">
474
+ <input id="event-title" type="text" className={INPUT_CLASS} value={title} onChange={(e) => setTitle(e.target.value)} />
475
+ </FormField>
476
+
477
+ <FormField label="Location" htmlFor="event-location">
478
+ <input id="event-location" type="text" className={INPUT_CLASS} value={location} onChange={(e) => setLocation(e.target.value)} />
479
+ </FormField>
480
+
481
+ <label className="flex items-center gap-2 text-sm font-medium mb-3">
482
+ <input type="checkbox" checked={allDay} onChange={(e) => handleAllDayChange(e.target.checked)} />
483
+ All day
484
+ </label>
485
+
486
+ <div className="grid grid-cols-2 gap-3">
487
+ <FormField label="Start" htmlFor="event-start">
488
+ <input
489
+ id="event-start"
490
+ type={allDay ? "date" : "datetime-local"}
491
+ className={INPUT_CLASS}
492
+ value={allDay ? start.slice(0, 10) : start}
493
+ onChange={(e) => setStart(allDay ? `${e.target.value}T00:00` : e.target.value)}
494
+ />
495
+ </FormField>
496
+ <FormField label="End" htmlFor="event-end">
497
+ <input
498
+ id="event-end"
499
+ type={allDay ? "date" : "datetime-local"}
500
+ className={INPUT_CLASS}
501
+ value={allDay ? end.slice(0, 10) : end}
502
+ onChange={(e) => setEnd(allDay ? `${e.target.value}T00:00` : e.target.value)}
503
+ />
504
+ </FormField>
505
+ </div>
506
+
507
+ <FormField label="Attendees" htmlFor="event-attendees">
508
+ <div id="event-attendees" className="flex flex-col gap-2">
509
+ {attendees.map((attendee, i) => (
510
+ <div key={i} className="flex gap-2">
511
+ <input
512
+ type="email"
513
+ className={`${INPUT_CLASS} flex-1`}
514
+ value={attendee.address}
515
+ onChange={(e) => updateAttendee(i, { address: e.target.value })}
516
+ aria-label={`Attendee email ${i + 1}`}
517
+ />
518
+ <select
519
+ className={SELECT_CLASS}
520
+ value={attendee.role}
521
+ onChange={(e) => updateAttendee(i, { role: e.target.value as AttendeeRole })}
522
+ aria-label={`Attendee role ${i + 1}`}
523
+ >
524
+ {ATTENDEE_ROLES.map((role) => (
525
+ <option key={role} value={role}>
526
+ {role}
527
+ </option>
528
+ ))}
529
+ </select>
530
+ <span className="text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt self-center">
531
+ {RESPONSE_STATUS_LABEL[attendee.responseStatus]}
532
+ </span>
533
+ <button
534
+ type="button"
535
+ onClick={() => removeAttendee(i)}
536
+ className="text-sm text-danger px-2"
537
+ aria-label={`Remove attendee ${i + 1}`}
538
+ >
539
+ &times;
540
+ </button>
541
+ </div>
542
+ ))}
543
+ <div className="flex gap-3 items-center">
544
+ <button
545
+ type="button"
546
+ onClick={() => setAttendees((prev) => [...prev, { address: "", role: "required", responseStatus: "needsAction", isOrganizer: false }])}
547
+ className="self-start text-xs font-medium text-primary-dark hover:underline"
548
+ >
549
+ + Add attendee
550
+ </button>
551
+ <button
552
+ ref={addResourceButtonRef}
553
+ type="button"
554
+ onClick={() => setResourcePickerOpen(true)}
555
+ className="self-start text-xs font-medium text-primary-dark hover:underline"
556
+ >
557
+ + Add room/equipment
558
+ </button>
559
+ </div>
560
+ {resourcePickerOpen && (
561
+ <ResourcePicker
562
+ anchorRef={addResourceButtonRef}
563
+ onClose={() => setResourcePickerOpen(false)}
564
+ onSelect={addResource}
565
+ excludeAddresses={attendees.map((a) => a.address.toLowerCase())}
566
+ />
567
+ )}
568
+ </div>
569
+ </FormField>
570
+
571
+ <div className="grid grid-cols-2 gap-3">
572
+ <FormField label="Busy status" htmlFor="event-busyStatus">
573
+ <select
574
+ id="event-busyStatus"
575
+ className={INPUT_CLASS}
576
+ value={busyStatus}
577
+ onChange={(e) => setBusyStatus(e.target.value as BusyStatus)}
578
+ >
579
+ {BUSY_STATUSES.map((status) => (
580
+ <option key={status} value={status}>
581
+ {BUSY_STATUS_LABEL[status]}
582
+ </option>
583
+ ))}
584
+ </select>
585
+ </FormField>
586
+ <FormField label="Reminder (minutes before)" htmlFor="event-reminder">
587
+ <input
588
+ id="event-reminder"
589
+ type="number"
590
+ min={0}
591
+ className={INPUT_CLASS}
592
+ value={reminderMinutes}
593
+ onChange={(e) => setReminderMinutes(e.target.value)}
594
+ placeholder="None"
595
+ />
596
+ </FormField>
597
+ </div>
598
+
599
+ <div className="mb-3">
600
+ <label className="flex items-center gap-2 text-sm font-medium">
601
+ <input
602
+ type="checkbox"
603
+ checked={autoReplyEnabled}
604
+ onChange={(e) => setAutoReplyEnabled(e.target.checked)}
605
+ />
606
+ Send an automatic reply while this event is happening
607
+ </label>
608
+ <p className="text-xs text-text-muted mt-1">
609
+ Applies in addition to your mailbox&rsquo;s own Automatic Replies setting (see Settings) —
610
+ this event&rsquo;s message takes over for its own start/end window.
611
+ </p>
612
+ {autoReplyEnabled && (
613
+ <textarea
614
+ aria-label="Automatic reply message"
615
+ className={`${INPUT_CLASS} mt-2`}
616
+ rows={3}
617
+ value={autoReplyMessage}
618
+ onChange={(e) => setAutoReplyMessage(e.target.value)}
619
+ placeholder="I'm out of office and back on..."
620
+ />
621
+ )}
622
+ </div>
623
+
624
+ {!editingSingleOccurrence && (
625
+ <FormField label="Recurrence" htmlFor="event-recurrence">
626
+ <RecurrenceEditor
627
+ value={recurrenceRule}
628
+ onChange={setRecurrenceRule}
629
+ allDay={allDay}
630
+ startWeekday={startWeekdayCode(start, allDay, timezone)}
631
+ />
632
+ </FormField>
633
+ )}
634
+
635
+ {occurrence?.isRecurringOccurrence && !isInvited && (
636
+ <fieldset className="flex flex-col gap-1.5 text-sm mb-3">
637
+ <legend className="text-xs font-bold uppercase tracking-wide text-text-muted mb-1">Apply changes to</legend>
638
+ <label className="flex items-center gap-2">
639
+ <input
640
+ type="radio"
641
+ name="event-edit-scope"
642
+ checked={editScope === "occurrence"}
643
+ onChange={() => setEditScope("occurrence")}
644
+ />
645
+ This event only
646
+ </label>
647
+ <label className="flex items-center gap-2">
648
+ <input
649
+ type="radio"
650
+ name="event-edit-scope"
651
+ checked={editScope === "series"}
652
+ onChange={() => setEditScope("series")}
653
+ />
654
+ The entire series
655
+ </label>
656
+ </fieldset>
657
+ )}
658
+
659
+ </fieldset>
660
+
661
+ <div className="flex gap-3 mt-2">
662
+ {!isInvited && (
663
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
664
+ Save
665
+ </Button>
666
+ )}
667
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
668
+ Cancel
669
+ </Button>
670
+ {occurrence &&
671
+ (!occurrence.isRecurringOccurrence ? (
672
+ <Button
673
+ type="button"
674
+ variant="secondary"
675
+ className="!w-auto ml-auto text-danger"
676
+ disabled={saving}
677
+ onClick={() => handleDelete("series")}
678
+ >
679
+ Delete
680
+ </Button>
681
+ ) : !confirmingDelete ? (
682
+ <Button
683
+ type="button"
684
+ variant="secondary"
685
+ className="!w-auto ml-auto text-danger"
686
+ disabled={saving}
687
+ onClick={() => setConfirmingDelete(true)}
688
+ >
689
+ Delete
690
+ </Button>
691
+ ) : (
692
+ <div className="flex gap-2 ml-auto">
693
+ <Button
694
+ type="button"
695
+ variant="secondary"
696
+ className="!w-auto text-danger"
697
+ disabled={saving}
698
+ onClick={() => handleDelete("occurrence")}
699
+ >
700
+ Delete this event
701
+ </Button>
702
+ <Button
703
+ type="button"
704
+ variant="secondary"
705
+ className="!w-auto text-danger"
706
+ disabled={saving}
707
+ onClick={() => handleDelete("series")}
708
+ >
709
+ Delete series
710
+ </Button>
711
+ </div>
712
+ ))}
713
+ </div>
714
+ </form>
715
+ </Modal>
716
+ );
717
+ }