@rapidmx/web-client 0.12.0 → 0.14.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 (37) hide show
  1. package/apps/admin/domains/[uid].tsx +73 -2
  2. package/apps/admin/domains/index.tsx +2 -1
  3. package/apps/admin/domains/new/index.tsx +28 -3
  4. package/apps/shared/calendar/calendarReminders.ts +76 -0
  5. package/apps/shared/calendar/useCalendarReminders.ts +91 -0
  6. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +8 -0
  7. package/apps/shared/components/calendar/EventModal.tsx +236 -11
  8. package/apps/shared/components/layout/AppShell.tsx +3 -0
  9. package/apps/shared/components/mail/compose/ComposeWindow.tsx +37 -8
  10. package/apps/shared/notifications/NotificationCenter.tsx +2 -0
  11. package/apps/shared/notifications/NotificationHistoryDialog.tsx +1 -0
  12. package/apps/shared/notifications/store.ts +3 -1
  13. package/apps/shared/search/localIndexRpcClient.ts +33 -2
  14. package/apps/shared/search/searchTier2.ts +34 -16
  15. package/apps/www/index.tsx +49 -8
  16. package/dist/apps/admin/domains/[uid].js +38 -2
  17. package/dist/apps/admin/domains/index.js +1 -1
  18. package/dist/apps/admin/domains/new/index.js +13 -4
  19. package/dist/apps/shared/calendar/calendarReminders.d.ts +28 -0
  20. package/dist/apps/shared/calendar/calendarReminders.js +55 -0
  21. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +23 -0
  22. package/dist/apps/shared/calendar/useCalendarReminders.js +79 -0
  23. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +2 -2
  24. package/dist/apps/shared/components/calendar/EventModal.d.ts +10 -0
  25. package/dist/apps/shared/components/calendar/EventModal.js +186 -12
  26. package/dist/apps/shared/components/layout/AppShell.js +3 -0
  27. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +36 -8
  28. package/dist/apps/shared/notifications/NotificationCenter.js +2 -1
  29. package/dist/apps/shared/notifications/NotificationHistoryDialog.js +1 -0
  30. package/dist/apps/shared/notifications/store.d.ts +1 -1
  31. package/dist/apps/shared/notifications/store.js +2 -0
  32. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -0
  33. package/dist/apps/shared/search/localIndexRpcClient.js +28 -2
  34. package/dist/apps/shared/search/searchTier2.d.ts +7 -1
  35. package/dist/apps/shared/search/searchTier2.js +34 -17
  36. package/dist/apps/www/index.js +37 -8
  37. package/package.json +2 -2
@@ -3,9 +3,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useRef, useState } from "react";
6
+ import { useEffect, useRef, useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { createCalendarEvent, getCalendarEvent, respondToEvent, updateCalendarEvent, } from "@rapidmx/react-shared/calendar/calendarApi.js";
9
+ import { createVideoMeeting, getVideoMeeting, updateVideoMeeting, } from "@rapidmx/react-shared/videoconf/videoMeetingsApi.js";
9
10
  import { deleteEventOccurrence, deleteEventSeries, detachOccurrence, saveEventSeries } from "@rapidmx/react-shared/calendar/calendarMutations.js";
10
11
  import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
11
12
  import { fromEventWallClock, toEventWallClock } from "@rapidmx/react-shared/calendar/recurrence.js";
@@ -16,6 +17,7 @@ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
16
17
  import RecurrenceEditor from "./RecurrenceEditor.js";
17
18
  import { addDaysToKey, allDayDateKey, allDayInstant, recurrenceUntilDateKey, recurrenceUntilInstant, startWeekdayCode } from "./allDay.js";
18
19
  import ResourcePicker from "./ResourcePicker.js";
20
+ import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
19
21
  const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
20
22
  const SELECT_CLASS = "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
21
23
  const ATTENDEE_ROLES = ["required", "optional", "resource"];
@@ -27,6 +29,19 @@ const RESPONSE_STATUS_LABEL = {
27
29
  declined: "Declined",
28
30
  tentative: "Tentative",
29
31
  };
32
+ /**
33
+ * The Location text this modal writes when it mints a video meeting for an event, and the one value it will
34
+ * clear again when video conferencing is turned back off (see `applyVideoConferencing()`).
35
+ *
36
+ * Deliberately generic and identical for everyone: each attendee's own personal join link is substituted into
37
+ * their own copy of the invitation server-side (`@rapidmx/restapi`'s `MeetingSchedulingJob`), so no link -
38
+ * personal or otherwise - may be stored on the shared event itself, where every attendee would read the same
39
+ * one.
40
+ */
41
+ export const VIDEO_LOCATION_PLACEHOLDER = "Video call — link in this invitation";
42
+ /** The plugin's own title bound (`BaseVideoMeetingRoute`'s `MAX_TITLE_LENGTH`) - a longer title is a 400, so
43
+ * an over-long event title is trimmed to fit rather than failing the whole meeting. */
44
+ const MAX_MEETING_TITLE_LENGTH = 200;
30
45
  const MS_PER_MINUTE = 60000;
31
46
  const MS_PER_DAY = 86400000;
32
47
  /** Whether two ISO instants fall in the same minute - a stored `...T15:00:00Z` and the form's re-serialized
@@ -135,6 +150,17 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
135
150
  const [autoReplyEnabled, setAutoReplyEnabled] = useState(occurrence?.autoReplyEnabled ?? false);
136
151
  const [autoReplyMessage, setAutoReplyMessage] = useState(occurrence?.autoReplyMessage ?? "");
137
152
  const [editScope, setEditScope] = useState("occurrence");
153
+ // Video conferencing: the toggle, the meeting this event is linked to (if any), the organizer's own join
154
+ // link for it, and whatever went wrong with the last attempt to mint/cancel one.
155
+ const [videoEnabled, setVideoEnabled] = useState(!!occurrence?.videoMeetingUid);
156
+ const [videoMeetingUid, setVideoMeetingUid] = useState(occurrence?.videoMeetingUid);
157
+ // `undefined` while the link is still being fetched, `null` once it is known there is none to open.
158
+ const [organizerJoinUrl, setOrganizerJoinUrl] = useState(undefined);
159
+ const [videoError, setVideoError] = useState(null);
160
+ // The event as this modal last persisted it. Set only when a save got the event stored but could not
161
+ // finish the video-conferencing step after it: the modal then stays open on that error, and the retry
162
+ // must update *that* record (its uid and its new version), not re-create it or reuse the stale prop.
163
+ const [savedEvent, setSavedEvent] = useState(null);
138
164
  const [confirmingDelete, setConfirmingDelete] = useState(false);
139
165
  const [error, setError] = useState(null);
140
166
  const [saving, setSaving] = useState(false);
@@ -143,6 +169,30 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
143
169
  const [responding, setResponding] = useState(false);
144
170
  const [resourcePickerOpen, setResourcePickerOpen] = useState(false);
145
171
  const addResourceButtonRef = useRef(null);
172
+ // An event that already carried a meeting when it was opened (e.g. after a page reload) knows only that
173
+ // meeting's uid - the organizer's own join link lives on the meeting, so it is fetched once here. A
174
+ // meeting minted in this session already has its link from the create call (see
175
+ // `applyVideoConferencing()`), which is why this deliberately reads the *prop*, not the state: it must
176
+ // not re-fetch what was just handed to us. A failure and a meeting with no link at all are the same
177
+ // thing to this UI - there is nothing to open either way - so both settle on `null`.
178
+ useEffect(() => {
179
+ const uid = occurrence?.videoMeetingUid;
180
+ if (!uid) {
181
+ return;
182
+ }
183
+ let cancelled = false;
184
+ void getVideoMeeting(uid)
185
+ .then((meeting) => meeting.organizerJoinUrl ?? null)
186
+ .catch(() => null)
187
+ .then((url) => {
188
+ if (!cancelled) {
189
+ setOrganizerJoinUrl(url);
190
+ }
191
+ });
192
+ return () => {
193
+ cancelled = true;
194
+ };
195
+ }, [occurrence?.videoMeetingUid]);
146
196
  // The viewing mailbox can respond to an *existing* event it's invited to but doesn't organize —
147
197
  // `organizerAddress` (plus the aliases) are this mailbox's own addresses (see `EventModalProps`), so
148
198
  // finding one among `attendees` (and not as that attendee's own organizer flag) identifies exactly
@@ -166,9 +216,108 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
166
216
  ]);
167
217
  setResourcePickerOpen(false);
168
218
  }
219
+ /**
220
+ * Brings the event's video meeting in line with the toggle, once the event itself is saved (so the
221
+ * attendee list a new meeting's invitees are built from is the one that was just stored).
222
+ *
223
+ * Turning it **on** mints a meeting for the saved event's attendees - every one of them except the
224
+ * organizer, who reaches their own meeting through ownership (`organizerJoinUrl`), not as a guest - and
225
+ * then patches the event with the meeting's uid and the generic `VIDEO_LOCATION_PLACEHOLDER` location.
226
+ * No join link is written onto the event: each attendee's own link is substituted into their own copy of
227
+ * the invitation server-side.
228
+ *
229
+ * Turning it **off** cancels the meeting and clears the link, restoring Location to `""` only when it
230
+ * still holds exactly the placeholder this modal wrote - anything the user has typed there since is
231
+ * theirs and is preserved.
232
+ *
233
+ * **Known, deliberate limitation:** an event that already has a meeting is left completely alone, even
234
+ * when its attendee list has just changed. `@rapidmx/meet-plugin`'s `PUT /mail/video-meetings/:id`
235
+ * is deliberately minimal (title and cancellation only) and cannot add or remove invitees, and a private
236
+ * meeting's personal join links are minted once at creation - so there is no way to reconcile an invitee
237
+ * list, and silently pretending otherwise would leave a new attendee with no link and a removed one with
238
+ * a working one. The helper text under the checkbox says so, and turning the toggle off and on again
239
+ * reissues links for the current attendee list.
240
+ *
241
+ * Never throws: the event is already stored by the time this runs, so a failure here is reported inline
242
+ * next to the checkbox and left retryable, with `videoMeetingUid` still whatever it was before.
243
+ */
244
+ async function applyVideoConferencing(saved) {
245
+ // Off and never on, or on and already linked (see the limitation above) - nothing to do either way.
246
+ if (videoEnabled === !!videoMeetingUid) {
247
+ return { event: saved, failed: false };
248
+ }
249
+ // Detaching a single occurrence (`detachOccurrence()`, react-shared's `calendarMutations.ts`)
250
+ // deliberately never copies the series' `videoMeetingUid` onto the new standalone event - that
251
+ // shared meeting belongs to the series, not to the one occurrence being split off, and every other
252
+ // occurrence still needs it live. But this component's own `videoMeetingUid` *state* was seeded
253
+ // from the series' occurrence at mount and is untouched by the detach, so unchecking the toggle
254
+ // while detaching would otherwise still look like "off, but state says linked" above and fall into
255
+ // the cancel branch below - cancelling the series' shared meeting for every remaining occurrence,
256
+ // not just this one. `saved.videoMeetingUid` is the actual just-persisted record, so when the
257
+ // toggle is off and that record already has no meeting reference (true for every detach, checked or
258
+ // not - see this function's own doc comment on the still-checked case being a separate, documented
259
+ // gap), there is nothing of *this* record's to cancel.
260
+ if (!videoEnabled && !saved.videoMeetingUid) {
261
+ return { event: saved, failed: false };
262
+ }
263
+ try {
264
+ if (videoEnabled) {
265
+ const organizerAddressLower = saved.organizer.address.toLowerCase();
266
+ const invitees = attendees
267
+ .filter((a) => !a.isOrganizer && !!a.address.trim() && a.address.trim().toLowerCase() !== organizerAddressLower)
268
+ .map((a) => ({ email: a.address.trim(), displayName: a.displayName }));
269
+ if (invitees.length === 0) {
270
+ setVideoError("Add at least one attendee other than yourself to add video conferencing.");
271
+ return { event: saved, failed: true };
272
+ }
273
+ const result = await createVideoMeeting({
274
+ mailboxUid: saved.mailboxUid,
275
+ title: saved.title.slice(0, MAX_MEETING_TITLE_LENGTH),
276
+ visibility: "private",
277
+ calendarEventUid: saved.uid,
278
+ startTime: saved.startDate,
279
+ endTime: saved.endDate,
280
+ invitees,
281
+ });
282
+ const patched = await updateCalendarEvent({
283
+ uid: saved.uid,
284
+ version: saved.version,
285
+ location: VIDEO_LOCATION_PLACEHOLDER,
286
+ videoMeetingUid: result.meeting.uid,
287
+ });
288
+ setVideoMeetingUid(result.meeting.uid);
289
+ setOrganizerJoinUrl(result.organizerJoinUrl ?? null);
290
+ setLocation(VIDEO_LOCATION_PLACEHOLDER);
291
+ return { event: patched, failed: false };
292
+ }
293
+ await updateVideoMeeting(videoMeetingUid, { status: "cancelled" });
294
+ const restoredLocation = location.trim() === VIDEO_LOCATION_PLACEHOLDER ? "" : location.trim();
295
+ // A stored event clears a field with an explicit `null` (see `EventFields`) - an omitted one would
296
+ // leave the placeholder (and the link to the now-cancelled meeting) in place.
297
+ const patch = { location: restoredLocation || null, videoMeetingUid: null };
298
+ const patched = await updateCalendarEvent({ uid: saved.uid, version: saved.version, ...patch });
299
+ setVideoMeetingUid(undefined);
300
+ setOrganizerJoinUrl(undefined);
301
+ setLocation(restoredLocation);
302
+ return { event: patched, failed: false };
303
+ }
304
+ catch (err) {
305
+ // `videoMeetingsApi.ts`'s own doc comment: when the plugin isn't installed at all, its routes
306
+ // simply aren't mounted and every call here fails with a plain 404 - a caller offering video
307
+ // conferencing optionally must treat that as "not available here," not alarm the reader with
308
+ // the raw (often server-generated, unhelpful) 404 body text.
309
+ setVideoError(err instanceof ApiRequestError
310
+ ? err.status === 404
311
+ ? "Video conferencing is not available on this server."
312
+ : err.message
313
+ : "Could not update this event's video conferencing.");
314
+ return { event: saved, failed: true };
315
+ }
316
+ }
169
317
  async function handleSubmit(e) {
170
318
  e.preventDefault();
171
319
  setError(null);
320
+ setVideoError(null);
172
321
  if (!title.trim()) {
173
322
  setError("A title is required.");
174
323
  return;
@@ -213,9 +362,18 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
213
362
  };
214
363
  setSaving(true);
215
364
  try {
216
- if (!occurrence) {
365
+ let saved;
366
+ let detachedSyncFailed = false;
367
+ if (savedEvent) {
368
+ // A retry after the event was stored but its video meeting couldn't be (see `savedEvent`):
369
+ // update exactly the record the first attempt left behind, whichever branch below created it.
370
+ // A series' own exception/detached-occurrence re-pointing already ran on that attempt and has
371
+ // nothing left to shift - the dates being sent again are the ones it moved everything to.
372
+ saved = await updateCalendarEvent({ uid: savedEvent.uid, version: savedEvent.version, ...fields });
373
+ }
374
+ else if (!occurrence) {
217
375
  // The organizer is only ever set on create - an edit keeps the event's own organizer.
218
- await createCalendarEvent({
376
+ saved = await createCalendarEvent({
219
377
  mailboxUid: targetMailboxUid,
220
378
  folderUid: targetFolderUid,
221
379
  ...fields,
@@ -223,18 +381,27 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
223
381
  });
224
382
  }
225
383
  else if (editingSingleOccurrence) {
226
- await detachOccurrence(occurrence, fields);
384
+ saved = await detachOccurrence(occurrence, fields);
227
385
  }
228
386
  else if (occurrence.isRecurringOccurrence) {
229
- const saved = await saveEventSeries(occurrence, (await toSeriesFields(occurrence, fields)));
230
- if (saved.detachedOccurrenceSyncFailed) {
231
- // The series itself was saved - say so, and leave closing to the user once they've read it.
232
- setSyncWarning(true);
233
- return;
234
- }
387
+ const series = await saveEventSeries(occurrence, (await toSeriesFields(occurrence, fields)));
388
+ saved = series;
389
+ detachedSyncFailed = !!series.detachedOccurrenceSyncFailed;
235
390
  }
236
391
  else {
237
- await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...fields });
392
+ saved = await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...fields });
393
+ }
394
+ const video = await applyVideoConferencing(saved);
395
+ if (video.failed) {
396
+ // The event itself is saved - keep the modal open on the inline error so the video meeting
397
+ // can be retried (or the toggle put back) rather than closing over it.
398
+ setSavedEvent(video.event);
399
+ return;
400
+ }
401
+ if (detachedSyncFailed) {
402
+ // The series itself was saved - say so, and leave closing to the user once they've read it.
403
+ setSyncWarning(true);
404
+ return;
238
405
  }
239
406
  onSaved();
240
407
  }
@@ -295,6 +462,13 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
295
462
  if (syncWarning) {
296
463
  return (_jsxs(Modal, { open: open, onClose: onSaved, title: "Series saved", children: [_jsx("p", { role: "alert", className: "text-sm mb-4", children: "The series was saved, but some occurrences you had changed individually couldn\u2019t be moved with it. They may now appear twice - check the calendar and delete any duplicate." }), _jsx(Button, { type: "button", className: "!w-auto", onClick: onSaved, children: "OK" })] }));
297
464
  }
298
- return (_jsx(Modal, { open: open, onClose: onClose, title: occurrence ? "Edit event" : "New event", children: _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-1", children: [error && _jsx(Alert, { children: error }), canRespond && (_jsxs("div", { className: "flex flex-col gap-2 mb-3 p-3 rounded-sm bg-surface-alt", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 flex-wrap", children: [_jsx("span", { className: "text-sm font-medium", children: "Your response" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("accepted"), children: "Accept" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("tentative"), children: "Tentative" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: responding, onClick: () => handleRespond("declined"), children: "Decline" })] })] }), _jsx("p", { className: "text-xs text-text-muted", children: "Other attendees\u2019 responses may take a few minutes to update." })] })), isInvited && (_jsx("p", { className: "text-xs text-text-muted mb-3", children: "You were invited to this event. Only the organizer can change its details." })), _jsxs("fieldset", { disabled: isInvited, className: "flex flex-col gap-1 min-w-0", children: [!occurrence && mailboxOptions && mailboxOptions.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "event-mailbox", children: _jsx("select", { id: "event-mailbox", className: INPUT_CLASS, value: targetMailboxUid, onChange: (e) => handleMailboxChange(e.target.value), children: mailboxOptions.map(({ mailbox }) => (_jsxs("option", { value: mailbox.uid, children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }, mailbox.uid))) }) })), !occurrence && calendarChoices && calendarChoices.length > 1 && (_jsx(FormField, { label: "Calendar", htmlFor: "event-calendar", children: _jsx("select", { id: "event-calendar", className: INPUT_CLASS, value: targetFolderUid, onChange: (e) => setTargetFolderUid(e.target.value), children: calendarChoices.map((cal) => (_jsx("option", { value: cal.uid, children: cal.name }, cal.uid))) }) })), _jsx(FormField, { label: "Title", htmlFor: "event-title", children: _jsx("input", { id: "event-title", type: "text", className: INPUT_CLASS, value: title, onChange: (e) => setTitle(e.target.value) }) }), _jsx(FormField, { label: "Location", htmlFor: "event-location", children: _jsx("input", { id: "event-location", type: "text", className: INPUT_CLASS, value: location, onChange: (e) => setLocation(e.target.value) }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm font-medium mb-3", children: [_jsx("input", { type: "checkbox", checked: allDay, onChange: (e) => handleAllDayChange(e.target.checked) }), "All day"] }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Start", htmlFor: "event-start", children: _jsx("input", { id: "event-start", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? start.slice(0, 10) : start, onChange: (e) => setStart(allDay ? `${e.target.value}T00:00` : e.target.value) }) }), _jsx(FormField, { label: "End", htmlFor: "event-end", children: _jsx("input", { id: "event-end", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? end.slice(0, 10) : end, onChange: (e) => setEnd(allDay ? `${e.target.value}T00:00` : e.target.value) }) })] }), _jsx(FormField, { label: "Attendees", htmlFor: "event-attendees", children: _jsxs("div", { id: "event-attendees", className: "flex flex-col gap-2", children: [attendees.map((attendee, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: attendee.address, onChange: (e) => updateAttendee(i, { address: e.target.value }), "aria-label": `Attendee email ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: attendee.role, onChange: (e) => updateAttendee(i, { role: e.target.value }), "aria-label": `Attendee role ${i + 1}`, children: ATTENDEE_ROLES.map((role) => (_jsx("option", { value: role, children: role }, role))) }), _jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt self-center", children: RESPONSE_STATUS_LABEL[attendee.responseStatus] }), _jsx("button", { type: "button", onClick: () => removeAttendee(i), className: "text-sm text-danger px-2", "aria-label": `Remove attendee ${i + 1}`, children: "\u00D7" })] }, i))), _jsxs("div", { className: "flex gap-3 items-center", children: [_jsx("button", { type: "button", onClick: () => setAttendees((prev) => [...prev, { address: "", role: "required", responseStatus: "needsAction", isOrganizer: false }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add attendee" }), _jsx("button", { ref: addResourceButtonRef, type: "button", onClick: () => setResourcePickerOpen(true), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add room/equipment" })] }), resourcePickerOpen && (_jsx(ResourcePicker, { anchorRef: addResourceButtonRef, onClose: () => setResourcePickerOpen(false), onSelect: addResource, excludeAddresses: attendees.map((a) => a.address.toLowerCase()) }))] }) }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Busy status", htmlFor: "event-busyStatus", children: _jsx("select", { id: "event-busyStatus", className: INPUT_CLASS, value: busyStatus, onChange: (e) => setBusyStatus(e.target.value), children: BUSY_STATUSES.map((status) => (_jsx("option", { value: status, children: BUSY_STATUS_LABEL[status] }, status))) }) }), _jsx(FormField, { label: "Reminder (minutes before)", htmlFor: "event-reminder", children: _jsx("input", { id: "event-reminder", type: "number", min: 0, className: INPUT_CLASS, value: reminderMinutes, onChange: (e) => setReminderMinutes(e.target.value), placeholder: "None" }) })] }), _jsxs("div", { className: "mb-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: autoReplyEnabled, onChange: (e) => setAutoReplyEnabled(e.target.checked) }), "Send an automatic reply while this event is happening"] }), _jsx("p", { className: "text-xs text-text-muted mt-1", children: "Applies in addition to your mailbox\u2019s own Automatic Replies setting (see Settings) \u2014 this event\u2019s message takes over for its own start/end window." }), autoReplyEnabled && (_jsx("textarea", { "aria-label": "Automatic reply message", className: `${INPUT_CLASS} mt-2`, rows: 3, value: autoReplyMessage, onChange: (e) => setAutoReplyMessage(e.target.value), placeholder: "I'm out of office and back on..." }))] }), !editingSingleOccurrence && (_jsx(FormField, { label: "Recurrence", htmlFor: "event-recurrence", children: _jsx(RecurrenceEditor, { value: recurrenceRule, onChange: setRecurrenceRule, allDay: allDay, startWeekday: startWeekdayCode(start, allDay, timezone) }) })), occurrence?.isRecurringOccurrence && !isInvited && (_jsxs("fieldset", { className: "flex flex-col gap-1.5 text-sm mb-3", children: [_jsx("legend", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1", children: "Apply changes to" }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "occurrence", onChange: () => setEditScope("occurrence") }), "This event only"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "series", onChange: () => setEditScope("series") }), "The entire series"] })] }))] }), _jsxs("div", { className: "flex gap-3 mt-2", children: [!isInvited && (_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), occurrence &&
465
+ // Same scheme allow-list `calendarReminders.ts#joinMeetingUrl()` already applies to a reminder's own
466
+ // `location` field - reused here rather than duplicated so a video-conferencing plugin (or a stray
467
+ // manual edit of `videoMeetingUid`'s otherwise-plugin-owned join link) can't smuggle a `javascript:`/
468
+ // `file:`/other non-http(s) URL into `window.open()`. `undefined` (still loading) and `null` (fetch
469
+ // came back with nothing to join) both fail validation the same way as a bad scheme would - the button
470
+ // stays disabled either way, and the two are told apart below purely from `organizerJoinUrl` itself.
471
+ const validatedJoinUrl = joinMeetingUrl(organizerJoinUrl);
472
+ return (_jsx(Modal, { open: open, onClose: onClose, title: occurrence ? "Edit event" : "New event", children: _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-1", children: [error && _jsx(Alert, { children: error }), canRespond && (_jsxs("div", { className: "flex flex-col gap-2 mb-3 p-3 rounded-sm bg-surface-alt", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 flex-wrap", children: [_jsx("span", { className: "text-sm font-medium", children: "Your response" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("accepted"), children: "Accept" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("tentative"), children: "Tentative" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: responding, onClick: () => handleRespond("declined"), children: "Decline" })] })] }), _jsx("p", { className: "text-xs text-text-muted", children: "Other attendees\u2019 responses may take a few minutes to update." })] })), isInvited && (_jsx("p", { className: "text-xs text-text-muted mb-3", children: "You were invited to this event. Only the organizer can change its details." })), videoMeetingUid && (_jsxs("div", { className: "flex items-center gap-3 mb-3", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !validatedJoinUrl, onClick: () => window.open(validatedJoinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !validatedJoinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] })), _jsxs("fieldset", { disabled: isInvited, className: "flex flex-col gap-1 min-w-0", children: [!occurrence && mailboxOptions && mailboxOptions.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "event-mailbox", children: _jsx("select", { id: "event-mailbox", className: INPUT_CLASS, value: targetMailboxUid, onChange: (e) => handleMailboxChange(e.target.value), children: mailboxOptions.map(({ mailbox }) => (_jsxs("option", { value: mailbox.uid, children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }, mailbox.uid))) }) })), !occurrence && calendarChoices && calendarChoices.length > 1 && (_jsx(FormField, { label: "Calendar", htmlFor: "event-calendar", children: _jsx("select", { id: "event-calendar", className: INPUT_CLASS, value: targetFolderUid, onChange: (e) => setTargetFolderUid(e.target.value), children: calendarChoices.map((cal) => (_jsx("option", { value: cal.uid, children: cal.name }, cal.uid))) }) })), _jsx(FormField, { label: "Title", htmlFor: "event-title", children: _jsx("input", { id: "event-title", type: "text", className: INPUT_CLASS, value: title, onChange: (e) => setTitle(e.target.value) }) }), _jsx(FormField, { label: "Location", htmlFor: "event-location", children: _jsx("input", { id: "event-location", type: "text", className: INPUT_CLASS, value: location, onChange: (e) => setLocation(e.target.value) }) }), _jsxs("div", { className: "mb-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: videoEnabled, onChange: (e) => setVideoEnabled(e.target.checked) }), "Add video conferencing"] }), videoEnabled && (_jsx("p", { className: "text-xs text-text-muted mt-1", children: "Changing attendees after enabling video conferencing won\u2019t update meeting invitees \u2014 turn this off and back on to reissue links to the current attendee list." })), videoError && (_jsx("p", { role: "alert", className: "text-xs text-danger mt-1", children: videoError }))] }), _jsxs("label", { className: "flex items-center gap-2 text-sm font-medium mb-3", children: [_jsx("input", { type: "checkbox", checked: allDay, onChange: (e) => handleAllDayChange(e.target.checked) }), "All day"] }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Start", htmlFor: "event-start", children: _jsx("input", { id: "event-start", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? start.slice(0, 10) : start, onChange: (e) => setStart(allDay ? `${e.target.value}T00:00` : e.target.value) }) }), _jsx(FormField, { label: "End", htmlFor: "event-end", children: _jsx("input", { id: "event-end", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? end.slice(0, 10) : end, onChange: (e) => setEnd(allDay ? `${e.target.value}T00:00` : e.target.value) }) })] }), _jsx(FormField, { label: "Attendees", htmlFor: "event-attendees", children: _jsxs("div", { id: "event-attendees", className: "flex flex-col gap-2", children: [attendees.map((attendee, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: attendee.address, onChange: (e) => updateAttendee(i, { address: e.target.value }), "aria-label": `Attendee email ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: attendee.role, onChange: (e) => updateAttendee(i, { role: e.target.value }), "aria-label": `Attendee role ${i + 1}`, children: ATTENDEE_ROLES.map((role) => (_jsx("option", { value: role, children: role }, role))) }), _jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt self-center", children: RESPONSE_STATUS_LABEL[attendee.responseStatus] }), _jsx("button", { type: "button", onClick: () => removeAttendee(i), className: "text-sm text-danger px-2", "aria-label": `Remove attendee ${i + 1}`, children: "\u00D7" })] }, i))), _jsxs("div", { className: "flex gap-3 items-center", children: [_jsx("button", { type: "button", onClick: () => setAttendees((prev) => [...prev, { address: "", role: "required", responseStatus: "needsAction", isOrganizer: false }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add attendee" }), _jsx("button", { ref: addResourceButtonRef, type: "button", onClick: () => setResourcePickerOpen(true), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add room/equipment" })] }), resourcePickerOpen && (_jsx(ResourcePicker, { anchorRef: addResourceButtonRef, onClose: () => setResourcePickerOpen(false), onSelect: addResource, excludeAddresses: attendees.map((a) => a.address.toLowerCase()) }))] }) }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Busy status", htmlFor: "event-busyStatus", children: _jsx("select", { id: "event-busyStatus", className: INPUT_CLASS, value: busyStatus, onChange: (e) => setBusyStatus(e.target.value), children: BUSY_STATUSES.map((status) => (_jsx("option", { value: status, children: BUSY_STATUS_LABEL[status] }, status))) }) }), _jsx(FormField, { label: "Reminder (minutes before)", htmlFor: "event-reminder", children: _jsx("input", { id: "event-reminder", type: "number", min: 0, className: INPUT_CLASS, value: reminderMinutes, onChange: (e) => setReminderMinutes(e.target.value), placeholder: "None" }) })] }), _jsxs("div", { className: "mb-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: autoReplyEnabled, onChange: (e) => setAutoReplyEnabled(e.target.checked) }), "Send an automatic reply while this event is happening"] }), _jsx("p", { className: "text-xs text-text-muted mt-1", children: "Applies in addition to your mailbox\u2019s own Automatic Replies setting (see Settings) \u2014 this event\u2019s message takes over for its own start/end window." }), autoReplyEnabled && (_jsx("textarea", { "aria-label": "Automatic reply message", className: `${INPUT_CLASS} mt-2`, rows: 3, value: autoReplyMessage, onChange: (e) => setAutoReplyMessage(e.target.value), placeholder: "I'm out of office and back on..." }))] }), !editingSingleOccurrence && (_jsx(FormField, { label: "Recurrence", htmlFor: "event-recurrence", children: _jsx(RecurrenceEditor, { value: recurrenceRule, onChange: setRecurrenceRule, allDay: allDay, startWeekday: startWeekdayCode(start, allDay, timezone) }) })), occurrence?.isRecurringOccurrence && !isInvited && (_jsxs("fieldset", { className: "flex flex-col gap-1.5 text-sm mb-3", children: [_jsx("legend", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1", children: "Apply changes to" }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "occurrence", onChange: () => setEditScope("occurrence") }), "This event only"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "series", onChange: () => setEditScope("series") }), "The entire series"] })] }))] }), _jsxs("div", { className: "flex gap-3 mt-2", children: [!isInvited && (_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), occurrence &&
299
473
  (!occurrence.isRecurringOccurrence ? (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto ml-auto text-danger", disabled: saving, onClick: () => handleDelete("series"), children: "Delete" })) : !confirmingDelete ? (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto ml-auto text-danger", disabled: saving, onClick: () => setConfirmingDelete(true), children: "Delete" })) : (_jsxs("div", { className: "flex gap-2 ml-auto", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: saving, onClick: () => handleDelete("occurrence"), children: "Delete this event" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: saving, onClick: () => handleDelete("series"), children: "Delete series" })] })))] })] }) }));
300
474
  }
@@ -41,6 +41,7 @@ import NotificationHistoryDialog from "../../notifications/NotificationHistoryDi
41
41
  import { notifySessionExpired, setSignInUrl } from "../../notifications/apiErrors.js";
42
42
  import { useUnseenErrorCount } from "../../notifications/useNotifications.js";
43
43
  import { useSigningEnrollmentWatcher } from "../../signing/useSigningEnrollmentWatcher.js";
44
+ import { useCalendarReminders } from "../../calendar/useCalendarReminders.js";
44
45
  /** How long sign-out waits for auth-server's logout before navigating anyway. */
45
46
  export const LOGOUT_TIMEOUT_MS = 3000;
46
47
  /**
@@ -113,6 +114,8 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
113
114
  const mail = useMailConnection({ userUid, enabled: inFrame, open: navigate });
114
115
  // A signing certificate the user asked for is announced when it is issued (or fails), on whichever page they are - see the hook.
115
116
  useSigningEnrollmentWatcher({ userUid, mailboxes: mail.mailboxes, enabled: inFrame });
117
+ // A meeting reminder pops up on whichever page they are - see the hook.
118
+ useCalendarReminders({ userUid, enabled: inFrame });
116
119
  useRedirectIfUnauthenticated(userUid, authServerUrl);
117
120
  // Any request this app makes that the server answers with a 401 - the session ended - raises one "Your session expired" pop-up with a
118
121
  // Sign in action (see `notifySessionExpired()`), whichever request noticed first, a background refresh included.
@@ -64,6 +64,19 @@ const VIEWPORT_MARGIN = 24;
64
64
  function parseAddresses(value) {
65
65
  return parseRecipientList(value);
66
66
  }
67
+ /**
68
+ * Normalizes an address into the key `recipientStatuses`/the in-flight and failure trackers below use -
69
+ * matching `classifyRecipientTier()`'s own lowercase-domain comparison (`@rapidmx/react-shared`'s
70
+ * `composeSecurity.ts`), but applied to the whole address rather than just the domain. Without this, the
71
+ * same mailbox typed with different casing across To/Cc/Bcc (plausible via autocomplete vs. a pasted
72
+ * signature block) is tracked as two independent recipients: if one lookup lags or fails while the other
73
+ * succeeds, `decideMessageEncryption()`'s all-or-nothing check sees a spurious unresolved recipient and
74
+ * denies auto-encryption even though every *real* recipient resolved fine. Only ever used as an internal
75
+ * cache key - `ComposeRecipientInput.address` itself (what is actually sent as the recipient) is untouched.
76
+ */
77
+ function addressCacheKey(address) {
78
+ return address.trim().toLowerCase();
79
+ }
67
80
  function HeaderButton({ label, onClick, icon: Icon, disabled, shortcut, }) {
68
81
  const env = useKeyEnvironment();
69
82
  return (_jsx("button", { type: "button", "aria-label": label, title: shortcut ? withHint(label, shortcut, env) : label, "aria-keyshortcuts": shortcut ? ariaKeyShortcuts(shortcut, env) : undefined, onClick: onClick, disabled: disabled, className: "w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(Icon, { size: 14 }) }));
@@ -302,23 +315,24 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
302
315
  const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
303
316
  const generation = discoveryGenerationRef.current;
304
317
  const inFlight = lookupsInFlightRef.current;
305
- const unchecked = addresses.filter((r) => !(r.address in recipientStatuses) && !inFlight.has(r.address));
318
+ const unchecked = addresses.filter((r) => !(addressCacheKey(r.address) in recipientStatuses) && !inFlight.has(addressCacheKey(r.address)));
306
319
  for (const recipient of unchecked) {
307
- inFlight.add(recipient.address);
320
+ const key = addressCacheKey(recipient.address);
321
+ inFlight.add(key);
308
322
  void lookupKeys(mailbox.uid, recipient.address)
309
323
  .then((lookup) => ({ lookup }), () => null)
310
324
  .then((result) => {
311
- inFlight.delete(recipient.address);
325
+ inFlight.delete(key);
312
326
  if (generation !== discoveryGenerationRef.current) {
313
327
  return;
314
328
  }
315
329
  if (!result) {
316
- noteLookupFailed(recipient.address);
330
+ noteLookupFailed(key);
317
331
  return;
318
332
  }
319
- lookupFailuresRef.current.delete(recipient.address);
333
+ lookupFailuresRef.current.delete(key);
320
334
  const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, result.lookup);
321
- setRecipientStatuses((prev) => ({ ...prev, [recipient.address]: status }));
335
+ setRecipientStatuses((prev) => ({ ...prev, [key]: status }));
322
336
  });
323
337
  }
324
338
  }
@@ -932,7 +946,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
932
946
  // (4) Send encrypts (or is refused, with the override, when it can't) - never silently plaintext. And back: when it stops being encrypted
933
947
  // (a recipient removed) autosave simply resumes.
934
948
  const currentAddresses = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)].map((r) => r.address);
935
- const currentStatuses = currentAddresses.map((address) => recipientStatuses[address]);
949
+ const currentStatuses = currentAddresses.map((address) => recipientStatuses[addressCacheKey(address)]);
936
950
  const hasEncryptionKey = !!unlockedKeys?.encryptionPrivateKey || hasEnrolledEncryptionKey || offeredCryptoRef.current.encrypt;
937
951
  const requirement = evaluateEncryptionRequirement({
938
952
  encryptRequested,
@@ -1132,8 +1146,22 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1132
1146
  ? "fixed inset-0 w-full h-full rounded-none border-0"
1133
1147
  : ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
1134
1148
  ].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: closing, shortcut: SHORTCUTS.compose.close })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(Alert, { children: sendError }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`, value: mailboxUid ?? "", disabled: hasUploads, title: hasUploads ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
1149
+ // De-duplicated by the same normalized key `recipientStatuses` itself is keyed by - two
1150
+ // fields (or one field twice) naming the same mailbox in different casing now resolve to
1151
+ // one shared status object (see `addressCacheKey()`'s own doc comment), so without this
1152
+ // the list below would render - and React would warn about - two `<li key=...>`s sharing
1153
+ // the exact same key.
1154
+ const seenKeys = new Set();
1135
1155
  const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
1136
- .map((r) => recipientStatuses[r.address])
1156
+ .filter((r) => {
1157
+ const key = addressCacheKey(r.address);
1158
+ if (seenKeys.has(key)) {
1159
+ return false;
1160
+ }
1161
+ seenKeys.add(key);
1162
+ return true;
1163
+ })
1164
+ .map((r) => recipientStatuses[addressCacheKey(r.address)])
1137
1165
  .filter((status) => !!status);
1138
1166
  if (knownRecipients.length === 0) {
1139
1167
  return null;
@@ -4,13 +4,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useEffect } from "react";
7
- import { HiOutlineCheckCircle, HiOutlineEnvelope, HiOutlineExclamationCircle, HiOutlineExclamationTriangle, HiOutlineInformationCircle, HiOutlineXMark, } from "react-icons/hi2";
7
+ import { HiOutlineBell, HiOutlineCheckCircle, HiOutlineEnvelope, HiOutlineExclamationCircle, HiOutlineExclamationTriangle, HiOutlineInformationCircle, HiOutlineXMark, } from "react-icons/hi2";
8
8
  import CopyButton from "@rapidmx/react-shared/components/buttons/CopyButton.js";
9
9
  import { dismiss, setAllPaused, setPaused } from "./store.js";
10
10
  import { useNotifications } from "./useNotifications.js";
11
11
  import { useSystemErrorNotifications } from "./systemErrors.js";
12
12
  const KIND_STYLE = {
13
13
  mail: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineEnvelope, label: "New message" },
14
+ calendar: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineBell, label: "Reminder" },
14
15
  info: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineInformationCircle, label: "Information" },
15
16
  success: { border: "border-l-success", icon: "text-success", Icon: HiOutlineCheckCircle, label: "Success" },
16
17
  warning: { border: "border-l-warning", icon: "text-warning", Icon: HiOutlineExclamationTriangle, label: "Warning" },
@@ -9,6 +9,7 @@ import CopyButton from "@rapidmx/react-shared/components/buttons/CopyButton.js";
9
9
  import { useNotifications } from "./useNotifications.js";
10
10
  const KIND_LABEL = {
11
11
  mail: "New message",
12
+ calendar: "Reminder",
12
13
  info: "Information",
13
14
  success: "Done",
14
15
  warning: "Warning",
@@ -21,7 +21,7 @@
21
21
  * Nothing is stored where there is no `window` (server-side rendering): the module state is shared between requests there, so
22
22
  * `notify()` only hands back an id.
23
23
  */
24
- export type NotificationKind = "mail" | "info" | "success" | "warning" | "error";
24
+ export type NotificationKind = "mail" | "calendar" | "info" | "success" | "warning" | "error";
25
25
  export interface NotificationAction {
26
26
  label: string;
27
27
  /** Runs when the action is used. */
@@ -36,6 +36,8 @@ export const MAX_DETAIL_LINE_LENGTH = 500;
36
36
  /** How long a non-sticky notification stays, by kind. Errors are sticky, so theirs is only for an explicit `sticky: false`. */
37
37
  export const DEFAULT_TIMEOUT_MS = {
38
38
  mail: 8000,
39
+ // Irrelevant in practice - a reminder always carries actions, which makes it sticky (see `applyInput()`) regardless of this value.
40
+ calendar: 8000,
39
41
  info: 6000,
40
42
  success: 5000,
41
43
  warning: 10000,
@@ -16,6 +16,11 @@
16
16
  *
17
17
  * **Failed deletions are retried**: a destroy that couldn't remove a directory (another tab held it, the page
18
18
  * navigated away first) is recorded in `localStorage` and retried before this tab's first `init`.
19
+ *
20
+ * **A crashed Worker never leaves a caller hanging**: the `error` listener registered in `getWorker()` rejects
21
+ * every pending request and drops the module-level `worker` reference so the next `call()` spawns a
22
+ * replacement. `searchTier2.ts` additionally races its own calls against a timeout, since a Worker that's
23
+ * merely stuck (not crashed) posts no `error` event at all.
19
24
  */
20
25
  import type { ParsedSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
21
26
  import type { Coverage, IndexEntitiesResult, InitParams, LocalSearchPage, WindowState } from "./localIndexWorker.js";
@@ -20,8 +20,12 @@ function getWorker() {
20
20
  if (!worker) {
21
21
  // Named by its compiled `.js` file, like every other relative import: tsc copies the literal into
22
22
  // `dist` unchanged, where only the `.js` file exists, and Vite maps it back to the `.ts` source.
23
- worker = new Worker(new URL("./localIndexWorker.js", import.meta.url), { type: "module" });
24
- worker.addEventListener("message", (event) => {
23
+ // Captured in its own `created` const (not just read back from the module-level `worker` variable)
24
+ // so the `error` listener below can tell whether it still refers to *this* Worker - see that
25
+ // listener's own comment for why that guard matters.
26
+ const created = new Worker(new URL("./localIndexWorker.js", import.meta.url), { type: "module" });
27
+ worker = created;
28
+ created.addEventListener("message", (event) => {
25
29
  const entry = pending.get(event.data.id);
26
30
  if (!entry) {
27
31
  return;
@@ -34,6 +38,28 @@ function getWorker() {
34
38
  entry.reject(new Error(event.data.error));
35
39
  }
36
40
  });
41
+ // A Worker that crashes (an uncaught exception, the WASM module aborting) never posts a response to
42
+ // its in-flight requests - without this, every pending call() promise would sit unresolved forever,
43
+ // and searchTier2.ts's try/catch (which can only catch a *rejection*) couldn't rescue it. Reject
44
+ // everything outstanding and drop the reference so the next call() spawns a fresh Worker instead of
45
+ // continuing to postMessage into a dead one.
46
+ //
47
+ // Scoped to `created`, this listener's own Worker, via the `worker !== created` check below - not
48
+ // scoped, a very unlikely double-error (this same Worker firing a second `error` event after the
49
+ // first already replaced `worker` with a healthy new instance) would reject that new instance's own
50
+ // in-flight requests and terminate it too, for no reason of its own.
51
+ created.addEventListener("error", (event) => {
52
+ if (worker !== created) {
53
+ return;
54
+ }
55
+ const err = new Error(`Local search Worker crashed: ${event.message || "unknown error"}`);
56
+ for (const entry of pending.values()) {
57
+ entry.reject(err);
58
+ }
59
+ pending.clear();
60
+ created.terminate();
61
+ worker = undefined;
62
+ });
37
63
  // Only a tab with a Worker has connections to close when another tab signs out.
38
64
  listenForSignOut();
39
65
  }
@@ -27,4 +27,10 @@ export interface Tier2SearchOutcome {
27
27
  * degradations below - there is nothing more to page through either way. */
28
28
  hasMore: boolean;
29
29
  }
30
- export declare function searchLocalIndex(mailboxUid: string, parsed: ParsedSearchQuery, unlocked: UnlockedKeys | undefined, limit?: number, offset?: number): Promise<Tier2SearchOutcome>;
30
+ /** How long a Tier 2 search may run before this function gives up on it and degrades to "no local results" -
31
+ * `localIndexRpcClient.ts`'s own `error` listener rescues a Worker that *crashes*, but a Worker that's merely
32
+ * wedged (an infinite loop in the WASM module, a stuck OPFS lock) posts no `error` event and no response
33
+ * either; without this, the caller's `Promise.all()` (`apps/www/index.tsx`) would wait on this tier forever
34
+ * even though Tier 1's results already arrived. */
35
+ export declare const TIER2_SEARCH_TIMEOUT_MS = 5000;
36
+ export declare function searchLocalIndex(mailboxUid: string, parsed: ParsedSearchQuery, unlocked: UnlockedKeys | undefined, limit?: number, offset?: number, timeoutMs?: number): Promise<Tier2SearchOutcome>;
@@ -8,31 +8,48 @@ async function ensureInitialized(mailboxUid, unlocked) {
8
8
  const indexKey = await deriveLocalIndexKey(unlocked.masterKey, mailboxUid);
9
9
  await initLocalIndex({ mailboxUid, indexKey });
10
10
  }
11
- export async function searchLocalIndex(mailboxUid, parsed, unlocked, limit = 50, offset = 0) {
11
+ /** How long a Tier 2 search may run before this function gives up on it and degrades to "no local results" -
12
+ * `localIndexRpcClient.ts`'s own `error` listener rescues a Worker that *crashes*, but a Worker that's merely
13
+ * wedged (an infinite loop in the WASM module, a stuck OPFS lock) posts no `error` event and no response
14
+ * either; without this, the caller's `Promise.all()` (`apps/www/index.tsx`) would wait on this tier forever
15
+ * even though Tier 1's results already arrived. */
16
+ export const TIER2_SEARCH_TIMEOUT_MS = 5000;
17
+ export async function searchLocalIndex(mailboxUid, parsed, unlocked, limit = 50, offset = 0, timeoutMs = TIER2_SEARCH_TIMEOUT_MS) {
12
18
  if (!unlocked) {
13
19
  return { results: [], hasMore: false };
14
20
  }
21
+ let timer;
15
22
  try {
16
- await ensureInitialized(mailboxUid, unlocked);
17
- const [page, coverage] = await Promise.all([searchLocal(mailboxUid, parsed, limit, offset), getLocalCoverage(mailboxUid)]);
18
- const results = page.hits.map((hit) => ({
19
- entityType: "message",
20
- entityUid: hit.entityUid,
21
- // Negated: bm25()'s own convention is "more negative is a better match" (SQLite), the
22
- // opposite of every other score this codebase merges (Postgres ts_rank, OpenSearch _score,
23
- // and normalizeServerScores() itself all treat "higher is better"). Negating here, once,
24
- // keeps that convention uniform for the caller (apps/www/index.tsx's merge layer) - it never
25
- // needs to know this tier's underlying scoring function works backwards from the others.
26
- score: -hit.score,
27
- snippet: hit.snippet,
28
- source: "local",
29
- metadataOnly: false,
30
- }));
31
- return { results, coverage, hasMore: page.hasMore };
23
+ return await Promise.race([
24
+ (async () => {
25
+ await ensureInitialized(mailboxUid, unlocked);
26
+ const [page, coverage] = await Promise.all([searchLocal(mailboxUid, parsed, limit, offset), getLocalCoverage(mailboxUid)]);
27
+ const results = page.hits.map((hit) => ({
28
+ entityType: "message",
29
+ entityUid: hit.entityUid,
30
+ // Negated: bm25()'s own convention is "more negative is a better match" (SQLite), the
31
+ // opposite of every other score this codebase merges (Postgres ts_rank, OpenSearch _score,
32
+ // and normalizeServerScores() itself all treat "higher is better"). Negating here, once,
33
+ // keeps that convention uniform for the caller (apps/www/index.tsx's merge layer) - it never
34
+ // needs to know this tier's underlying scoring function works backwards from the others.
35
+ score: -hit.score,
36
+ snippet: hit.snippet,
37
+ source: "local",
38
+ metadataOnly: false,
39
+ }));
40
+ return { results, coverage, hasMore: page.hasMore };
41
+ })(),
42
+ new Promise((resolve) => {
43
+ timer = setTimeout(() => resolve({ results: [], hasMore: false }), timeoutMs);
44
+ }),
45
+ ]);
32
46
  }
33
47
  catch {
34
48
  // See this function's own doc comment - a broken local index degrades to "nothing to contribute
35
49
  // this time," never a rejected search.
36
50
  return { results: [], hasMore: false };
37
51
  }
52
+ finally {
53
+ clearTimeout(timer);
54
+ }
38
55
  }