@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
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { FormEvent, useRef, useState } from "react";
5
+ import React, { FormEvent, useEffect, useRef, useState } from "react";
6
6
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
7
  import {
8
8
  Attendee,
@@ -10,6 +10,7 @@ import {
10
10
  AttendeeResponseStatus,
11
11
  AttendeeRole,
12
12
  BusyStatus,
13
+ CalendarEvent,
13
14
  CalendarEventInput,
14
15
  RecurrenceRule,
15
16
  createCalendarEvent,
@@ -17,6 +18,12 @@ import {
17
18
  respondToEvent,
18
19
  updateCalendarEvent,
19
20
  } from "@rapidmx/react-shared/calendar/calendarApi.js";
21
+ import {
22
+ VideoMeetingInvitee,
23
+ createVideoMeeting,
24
+ getVideoMeeting,
25
+ updateVideoMeeting,
26
+ } from "@rapidmx/react-shared/videoconf/videoMeetingsApi.js";
20
27
  import { deleteEventOccurrence, deleteEventSeries, detachOccurrence, saveEventSeries } from "@rapidmx/react-shared/calendar/calendarMutations.js";
21
28
  import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
22
29
  import { Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
@@ -28,6 +35,7 @@ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
28
35
  import RecurrenceEditor from "./RecurrenceEditor.js";
29
36
  import { addDaysToKey, allDayDateKey, allDayInstant, recurrenceUntilDateKey, recurrenceUntilInstant, startWeekdayCode } from "./allDay.js";
30
37
  import ResourcePicker from "./ResourcePicker.js";
38
+ import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
31
39
 
32
40
  const INPUT_CLASS =
33
41
  "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -44,6 +52,21 @@ const RESPONSE_STATUS_LABEL: Record<AttendeeResponseStatus, string> = {
44
52
  tentative: "Tentative",
45
53
  };
46
54
 
55
+ /**
56
+ * The Location text this modal writes when it mints a video meeting for an event, and the one value it will
57
+ * clear again when video conferencing is turned back off (see `applyVideoConferencing()`).
58
+ *
59
+ * Deliberately generic and identical for everyone: each attendee's own personal join link is substituted into
60
+ * their own copy of the invitation server-side (`@rapidmx/restapi`'s `MeetingSchedulingJob`), so no link -
61
+ * personal or otherwise - may be stored on the shared event itself, where every attendee would read the same
62
+ * one.
63
+ */
64
+ export const VIDEO_LOCATION_PLACEHOLDER = "Video call — link in this invitation";
65
+
66
+ /** The plugin's own title bound (`BaseVideoMeetingRoute`'s `MAX_TITLE_LENGTH`) - a longer title is a 400, so
67
+ * an over-long event title is trimmed to fit rather than failing the whole meeting. */
68
+ const MAX_MEETING_TITLE_LENGTH = 200;
69
+
47
70
  export interface EventModalProps {
48
71
  open: boolean;
49
72
  onClose: () => void;
@@ -211,6 +234,17 @@ export default function EventModal({
211
234
  const [autoReplyEnabled, setAutoReplyEnabled] = useState(occurrence?.autoReplyEnabled ?? false);
212
235
  const [autoReplyMessage, setAutoReplyMessage] = useState(occurrence?.autoReplyMessage ?? "");
213
236
  const [editScope, setEditScope] = useState<EditScope>("occurrence");
237
+ // Video conferencing: the toggle, the meeting this event is linked to (if any), the organizer's own join
238
+ // link for it, and whatever went wrong with the last attempt to mint/cancel one.
239
+ const [videoEnabled, setVideoEnabled] = useState(!!occurrence?.videoMeetingUid);
240
+ const [videoMeetingUid, setVideoMeetingUid] = useState<string | undefined>(occurrence?.videoMeetingUid);
241
+ // `undefined` while the link is still being fetched, `null` once it is known there is none to open.
242
+ const [organizerJoinUrl, setOrganizerJoinUrl] = useState<string | null | undefined>(undefined);
243
+ const [videoError, setVideoError] = useState<string | null>(null);
244
+ // The event as this modal last persisted it. Set only when a save got the event stored but could not
245
+ // finish the video-conferencing step after it: the modal then stays open on that error, and the retry
246
+ // must update *that* record (its uid and its new version), not re-create it or reuse the stale prop.
247
+ const [savedEvent, setSavedEvent] = useState<CalendarEvent | null>(null);
214
248
  const [confirmingDelete, setConfirmingDelete] = useState(false);
215
249
  const [error, setError] = useState<string | null>(null);
216
250
  const [saving, setSaving] = useState(false);
@@ -220,6 +254,31 @@ export default function EventModal({
220
254
  const [resourcePickerOpen, setResourcePickerOpen] = useState(false);
221
255
  const addResourceButtonRef = useRef<HTMLButtonElement>(null);
222
256
 
257
+ // An event that already carried a meeting when it was opened (e.g. after a page reload) knows only that
258
+ // meeting's uid - the organizer's own join link lives on the meeting, so it is fetched once here. A
259
+ // meeting minted in this session already has its link from the create call (see
260
+ // `applyVideoConferencing()`), which is why this deliberately reads the *prop*, not the state: it must
261
+ // not re-fetch what was just handed to us. A failure and a meeting with no link at all are the same
262
+ // thing to this UI - there is nothing to open either way - so both settle on `null`.
263
+ useEffect(() => {
264
+ const uid = occurrence?.videoMeetingUid;
265
+ if (!uid) {
266
+ return;
267
+ }
268
+ let cancelled = false;
269
+ void getVideoMeeting(uid)
270
+ .then((meeting) => meeting.organizerJoinUrl ?? null)
271
+ .catch(() => null)
272
+ .then((url) => {
273
+ if (!cancelled) {
274
+ setOrganizerJoinUrl(url);
275
+ }
276
+ });
277
+ return () => {
278
+ cancelled = true;
279
+ };
280
+ }, [occurrence?.videoMeetingUid]);
281
+
223
282
  // The viewing mailbox can respond to an *existing* event it's invited to but doesn't organize —
224
283
  // `organizerAddress` (plus the aliases) are this mailbox's own addresses (see `EventModalProps`), so
225
284
  // finding one among `attendees` (and not as that attendee's own organizer flag) identifies exactly
@@ -245,9 +304,110 @@ export default function EventModal({
245
304
  setResourcePickerOpen(false);
246
305
  }
247
306
 
307
+ /**
308
+ * Brings the event's video meeting in line with the toggle, once the event itself is saved (so the
309
+ * attendee list a new meeting's invitees are built from is the one that was just stored).
310
+ *
311
+ * Turning it **on** mints a meeting for the saved event's attendees - every one of them except the
312
+ * organizer, who reaches their own meeting through ownership (`organizerJoinUrl`), not as a guest - and
313
+ * then patches the event with the meeting's uid and the generic `VIDEO_LOCATION_PLACEHOLDER` location.
314
+ * No join link is written onto the event: each attendee's own link is substituted into their own copy of
315
+ * the invitation server-side.
316
+ *
317
+ * Turning it **off** cancels the meeting and clears the link, restoring Location to `""` only when it
318
+ * still holds exactly the placeholder this modal wrote - anything the user has typed there since is
319
+ * theirs and is preserved.
320
+ *
321
+ * **Known, deliberate limitation:** an event that already has a meeting is left completely alone, even
322
+ * when its attendee list has just changed. `@rapidmx/meet-plugin`'s `PUT /mail/video-meetings/:id`
323
+ * is deliberately minimal (title and cancellation only) and cannot add or remove invitees, and a private
324
+ * meeting's personal join links are minted once at creation - so there is no way to reconcile an invitee
325
+ * list, and silently pretending otherwise would leave a new attendee with no link and a removed one with
326
+ * a working one. The helper text under the checkbox says so, and turning the toggle off and on again
327
+ * reissues links for the current attendee list.
328
+ *
329
+ * Never throws: the event is already stored by the time this runs, so a failure here is reported inline
330
+ * next to the checkbox and left retryable, with `videoMeetingUid` still whatever it was before.
331
+ */
332
+ async function applyVideoConferencing(saved: CalendarEvent): Promise<{ event: CalendarEvent; failed: boolean }> {
333
+ // Off and never on, or on and already linked (see the limitation above) - nothing to do either way.
334
+ if (videoEnabled === !!videoMeetingUid) {
335
+ return { event: saved, failed: false };
336
+ }
337
+ // Detaching a single occurrence (`detachOccurrence()`, react-shared's `calendarMutations.ts`)
338
+ // deliberately never copies the series' `videoMeetingUid` onto the new standalone event - that
339
+ // shared meeting belongs to the series, not to the one occurrence being split off, and every other
340
+ // occurrence still needs it live. But this component's own `videoMeetingUid` *state* was seeded
341
+ // from the series' occurrence at mount and is untouched by the detach, so unchecking the toggle
342
+ // while detaching would otherwise still look like "off, but state says linked" above and fall into
343
+ // the cancel branch below - cancelling the series' shared meeting for every remaining occurrence,
344
+ // not just this one. `saved.videoMeetingUid` is the actual just-persisted record, so when the
345
+ // toggle is off and that record already has no meeting reference (true for every detach, checked or
346
+ // not - see this function's own doc comment on the still-checked case being a separate, documented
347
+ // gap), there is nothing of *this* record's to cancel.
348
+ if (!videoEnabled && !saved.videoMeetingUid) {
349
+ return { event: saved, failed: false };
350
+ }
351
+ try {
352
+ if (videoEnabled) {
353
+ const organizerAddressLower = saved.organizer.address.toLowerCase();
354
+ const invitees: VideoMeetingInvitee[] = attendees
355
+ .filter((a) => !a.isOrganizer && !!a.address.trim() && a.address.trim().toLowerCase() !== organizerAddressLower)
356
+ .map((a) => ({ email: a.address.trim(), displayName: a.displayName }));
357
+ if (invitees.length === 0) {
358
+ setVideoError("Add at least one attendee other than yourself to add video conferencing.");
359
+ return { event: saved, failed: true };
360
+ }
361
+ const result = await createVideoMeeting({
362
+ mailboxUid: saved.mailboxUid,
363
+ title: saved.title.slice(0, MAX_MEETING_TITLE_LENGTH),
364
+ visibility: "private",
365
+ calendarEventUid: saved.uid,
366
+ startTime: saved.startDate,
367
+ endTime: saved.endDate,
368
+ invitees,
369
+ });
370
+ const patched = await updateCalendarEvent({
371
+ uid: saved.uid,
372
+ version: saved.version,
373
+ location: VIDEO_LOCATION_PLACEHOLDER,
374
+ videoMeetingUid: result.meeting.uid,
375
+ });
376
+ setVideoMeetingUid(result.meeting.uid);
377
+ setOrganizerJoinUrl(result.organizerJoinUrl ?? null);
378
+ setLocation(VIDEO_LOCATION_PLACEHOLDER);
379
+ return { event: patched, failed: false };
380
+ }
381
+ await updateVideoMeeting(videoMeetingUid!, { status: "cancelled" });
382
+ const restoredLocation = location.trim() === VIDEO_LOCATION_PLACEHOLDER ? "" : location.trim();
383
+ // A stored event clears a field with an explicit `null` (see `EventFields`) - an omitted one would
384
+ // leave the placeholder (and the link to the now-cancelled meeting) in place.
385
+ const patch: EventFields = { location: restoredLocation || null, videoMeetingUid: null };
386
+ const patched = await updateCalendarEvent({ uid: saved.uid, version: saved.version, ...(patch as Partial<CalendarEventInput>) });
387
+ setVideoMeetingUid(undefined);
388
+ setOrganizerJoinUrl(undefined);
389
+ setLocation(restoredLocation);
390
+ return { event: patched, failed: false };
391
+ } catch (err) {
392
+ // `videoMeetingsApi.ts`'s own doc comment: when the plugin isn't installed at all, its routes
393
+ // simply aren't mounted and every call here fails with a plain 404 - a caller offering video
394
+ // conferencing optionally must treat that as "not available here," not alarm the reader with
395
+ // the raw (often server-generated, unhelpful) 404 body text.
396
+ setVideoError(
397
+ err instanceof ApiRequestError
398
+ ? err.status === 404
399
+ ? "Video conferencing is not available on this server."
400
+ : err.message
401
+ : "Could not update this event's video conferencing.",
402
+ );
403
+ return { event: saved, failed: true };
404
+ }
405
+ }
406
+
248
407
  async function handleSubmit(e: FormEvent) {
249
408
  e.preventDefault();
250
409
  setError(null);
410
+ setVideoError(null);
251
411
 
252
412
  if (!title.trim()) {
253
413
  setError("A title is required.");
@@ -294,25 +454,42 @@ export default function EventModal({
294
454
 
295
455
  setSaving(true);
296
456
  try {
297
- if (!occurrence) {
457
+ let saved: CalendarEvent;
458
+ let detachedSyncFailed = false;
459
+ if (savedEvent) {
460
+ // A retry after the event was stored but its video meeting couldn't be (see `savedEvent`):
461
+ // update exactly the record the first attempt left behind, whichever branch below created it.
462
+ // A series' own exception/detached-occurrence re-pointing already ran on that attempt and has
463
+ // nothing left to shift - the dates being sent again are the ones it moved everything to.
464
+ saved = await updateCalendarEvent({ uid: savedEvent.uid, version: savedEvent.version, ...(fields as Partial<CalendarEventInput>) });
465
+ } else if (!occurrence) {
298
466
  // The organizer is only ever set on create - an edit keeps the event's own organizer.
299
- await createCalendarEvent({
467
+ saved = await createCalendarEvent({
300
468
  mailboxUid: targetMailboxUid,
301
469
  folderUid: targetFolderUid,
302
470
  ...fields,
303
471
  organizer: { address: effectiveOrganizerAddress, type: "to" },
304
472
  } as CalendarEventInput);
305
473
  } else if (editingSingleOccurrence) {
306
- await detachOccurrence(occurrence, fields as Partial<CalendarEventInput>);
474
+ saved = await detachOccurrence(occurrence, fields as Partial<CalendarEventInput>);
307
475
  } 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
- }
476
+ const series = await saveEventSeries(occurrence, (await toSeriesFields(occurrence, fields)) as Partial<CalendarEventInput>);
477
+ saved = series;
478
+ detachedSyncFailed = !!series.detachedOccurrenceSyncFailed;
314
479
  } else {
315
- await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...(fields as Partial<CalendarEventInput>) });
480
+ saved = await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...(fields as Partial<CalendarEventInput>) });
481
+ }
482
+ const video = await applyVideoConferencing(saved);
483
+ if (video.failed) {
484
+ // The event itself is saved - keep the modal open on the inline error so the video meeting
485
+ // can be retried (or the toggle put back) rather than closing over it.
486
+ setSavedEvent(video.event);
487
+ return;
488
+ }
489
+ if (detachedSyncFailed) {
490
+ // The series itself was saved - say so, and leave closing to the user once they've read it.
491
+ setSyncWarning(true);
492
+ return;
316
493
  }
317
494
  onSaved();
318
495
  } catch (err) {
@@ -384,6 +561,14 @@ export default function EventModal({
384
561
  );
385
562
  }
386
563
 
564
+ // Same scheme allow-list `calendarReminders.ts#joinMeetingUrl()` already applies to a reminder's own
565
+ // `location` field - reused here rather than duplicated so a video-conferencing plugin (or a stray
566
+ // manual edit of `videoMeetingUid`'s otherwise-plugin-owned join link) can't smuggle a `javascript:`/
567
+ // `file:`/other non-http(s) URL into `window.open()`. `undefined` (still loading) and `null` (fetch
568
+ // came back with nothing to join) both fail validation the same way as a bad scheme would - the button
569
+ // stays disabled either way, and the two are told apart below purely from `organizerJoinUrl` itself.
570
+ const validatedJoinUrl = joinMeetingUrl(organizerJoinUrl);
571
+
387
572
  return (
388
573
  <Modal open={open} onClose={onClose} title={occurrence ? "Edit event" : "New event"}>
389
574
  <form onSubmit={handleSubmit} className="flex flex-col gap-1">
@@ -433,6 +618,28 @@ export default function EventModal({
433
618
  </p>
434
619
  )}
435
620
 
621
+ {/* Outside the `disabled` fieldset below: joining a call is not editing the event, so it stays
622
+ available on an invitation's read-only copy too (where the link, like the meeting itself,
623
+ is only reachable by whoever holds access to its mailbox). */}
624
+ {videoMeetingUid && (
625
+ <div className="flex items-center gap-3 mb-3">
626
+ <Button
627
+ type="button"
628
+ variant="secondary"
629
+ className="!w-auto"
630
+ disabled={!validatedJoinUrl}
631
+ onClick={() => window.open(validatedJoinUrl!, "_blank", "noopener,noreferrer")}
632
+ >
633
+ Join video call
634
+ </Button>
635
+ {!validatedJoinUrl && (
636
+ <span className="text-xs text-text-muted">
637
+ {organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available."}
638
+ </span>
639
+ )}
640
+ </div>
641
+ )}
642
+
436
643
  <fieldset disabled={isInvited} className="flex flex-col gap-1 min-w-0">
437
644
 
438
645
  {!occurrence && mailboxOptions && mailboxOptions.length > 1 && (
@@ -478,6 +685,24 @@ export default function EventModal({
478
685
  <input id="event-location" type="text" className={INPUT_CLASS} value={location} onChange={(e) => setLocation(e.target.value)} />
479
686
  </FormField>
480
687
 
688
+ <div className="mb-3">
689
+ <label className="flex items-center gap-2 text-sm font-medium">
690
+ <input type="checkbox" checked={videoEnabled} onChange={(e) => setVideoEnabled(e.target.checked)} />
691
+ Add video conferencing
692
+ </label>
693
+ {videoEnabled && (
694
+ <p className="text-xs text-text-muted mt-1">
695
+ Changing attendees after enabling video conferencing won&rsquo;t update meeting invitees &mdash;
696
+ turn this off and back on to reissue links to the current attendee list.
697
+ </p>
698
+ )}
699
+ {videoError && (
700
+ <p role="alert" className="text-xs text-danger mt-1">
701
+ {videoError}
702
+ </p>
703
+ )}
704
+ </div>
705
+
481
706
  <label className="flex items-center gap-2 text-sm font-medium mb-3">
482
707
  <input type="checkbox" checked={allDay} onChange={(e) => handleAllDayChange(e.target.checked)} />
483
708
  All day
@@ -48,6 +48,7 @@ import NotificationHistoryDialog from "../../notifications/NotificationHistoryDi
48
48
  import { notifySessionExpired, setSignInUrl } from "../../notifications/apiErrors.js";
49
49
  import { useUnseenErrorCount } from "../../notifications/useNotifications.js";
50
50
  import { useSigningEnrollmentWatcher } from "../../signing/useSigningEnrollmentWatcher.js";
51
+ import { useCalendarReminders } from "../../calendar/useCalendarReminders.js";
51
52
 
52
53
  /** How long sign-out waits for auth-server's logout before navigating anyway. */
53
54
  export const LOGOUT_TIMEOUT_MS = 3_000;
@@ -200,6 +201,8 @@ export function AppChrome({
200
201
  const mail = useMailConnection({ userUid, enabled: inFrame, open: navigate });
201
202
  // A signing certificate the user asked for is announced when it is issued (or fails), on whichever page they are - see the hook.
202
203
  useSigningEnrollmentWatcher({ userUid, mailboxes: mail.mailboxes, enabled: inFrame });
204
+ // A meeting reminder pops up on whichever page they are - see the hook.
205
+ useCalendarReminders({ userUid, enabled: inFrame });
203
206
 
204
207
  useRedirectIfUnauthenticated(userUid, authServerUrl);
205
208
  // Any request this app makes that the server answers with a 401 - the session ended - raises one "Your session expired" pop-up with a
@@ -126,6 +126,20 @@ function parseAddresses(value: string): ComposeRecipientInput[] {
126
126
  return parseRecipientList(value);
127
127
  }
128
128
 
129
+ /**
130
+ * Normalizes an address into the key `recipientStatuses`/the in-flight and failure trackers below use -
131
+ * matching `classifyRecipientTier()`'s own lowercase-domain comparison (`@rapidmx/react-shared`'s
132
+ * `composeSecurity.ts`), but applied to the whole address rather than just the domain. Without this, the
133
+ * same mailbox typed with different casing across To/Cc/Bcc (plausible via autocomplete vs. a pasted
134
+ * signature block) is tracked as two independent recipients: if one lookup lags or fails while the other
135
+ * succeeds, `decideMessageEncryption()`'s all-or-nothing check sees a spurious unresolved recipient and
136
+ * denies auto-encryption even though every *real* recipient resolved fine. Only ever used as an internal
137
+ * cache key - `ComposeRecipientInput.address` itself (what is actually sent as the recipient) is untouched.
138
+ */
139
+ function addressCacheKey(address: string): string {
140
+ return address.trim().toLowerCase();
141
+ }
142
+
129
143
  function HeaderButton({
130
144
  label,
131
145
  onClick,
@@ -411,26 +425,27 @@ export default function ComposeWindow({
411
425
  const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
412
426
  const generation = discoveryGenerationRef.current;
413
427
  const inFlight = lookupsInFlightRef.current;
414
- const unchecked = addresses.filter((r) => !(r.address in recipientStatuses) && !inFlight.has(r.address));
428
+ const unchecked = addresses.filter((r) => !(addressCacheKey(r.address) in recipientStatuses) && !inFlight.has(addressCacheKey(r.address)));
415
429
  for (const recipient of unchecked) {
416
- inFlight.add(recipient.address);
430
+ const key = addressCacheKey(recipient.address);
431
+ inFlight.add(key);
417
432
  void lookupKeys(mailbox.uid, recipient.address)
418
433
  .then(
419
434
  (lookup) => ({ lookup }),
420
435
  () => null,
421
436
  )
422
437
  .then((result) => {
423
- inFlight.delete(recipient.address);
438
+ inFlight.delete(key);
424
439
  if (generation !== discoveryGenerationRef.current) {
425
440
  return;
426
441
  }
427
442
  if (!result) {
428
- noteLookupFailed(recipient.address);
443
+ noteLookupFailed(key);
429
444
  return;
430
445
  }
431
- lookupFailuresRef.current.delete(recipient.address);
446
+ lookupFailuresRef.current.delete(key);
432
447
  const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, result.lookup);
433
- setRecipientStatuses((prev) => ({ ...prev, [recipient.address]: status }));
448
+ setRecipientStatuses((prev) => ({ ...prev, [key]: status }));
434
449
  });
435
450
  }
436
451
  }
@@ -1070,7 +1085,7 @@ export default function ComposeWindow({
1070
1085
  // (4) Send encrypts (or is refused, with the override, when it can't) - never silently plaintext. And back: when it stops being encrypted
1071
1086
  // (a recipient removed) autosave simply resumes.
1072
1087
  const currentAddresses = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)].map((r) => r.address);
1073
- const currentStatuses = currentAddresses.map((address) => recipientStatuses[address]);
1088
+ const currentStatuses = currentAddresses.map((address) => recipientStatuses[addressCacheKey(address)]);
1074
1089
  const hasEncryptionKey = !!unlockedKeys?.encryptionPrivateKey || hasEnrolledEncryptionKey || offeredCryptoRef.current.encrypt;
1075
1090
  const requirement = evaluateEncryptionRequirement({
1076
1091
  encryptRequested,
@@ -1480,8 +1495,22 @@ export default function ComposeWindow({
1480
1495
  )}
1481
1496
 
1482
1497
  {(() => {
1498
+ // De-duplicated by the same normalized key `recipientStatuses` itself is keyed by - two
1499
+ // fields (or one field twice) naming the same mailbox in different casing now resolve to
1500
+ // one shared status object (see `addressCacheKey()`'s own doc comment), so without this
1501
+ // the list below would render - and React would warn about - two `<li key=...>`s sharing
1502
+ // the exact same key.
1503
+ const seenKeys = new Set<string>();
1483
1504
  const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
1484
- .map((r) => recipientStatuses[r.address])
1505
+ .filter((r) => {
1506
+ const key = addressCacheKey(r.address);
1507
+ if (seenKeys.has(key)) {
1508
+ return false;
1509
+ }
1510
+ seenKeys.add(key);
1511
+ return true;
1512
+ })
1513
+ .map((r) => recipientStatuses[addressCacheKey(r.address)])
1485
1514
  .filter((status): status is RecipientEncryptionStatus => !!status);
1486
1515
  if (knownRecipients.length === 0) {
1487
1516
  return null;
@@ -5,6 +5,7 @@
5
5
  import React, { useEffect } from "react";
6
6
  import type { IconType } from "react-icons";
7
7
  import {
8
+ HiOutlineBell,
8
9
  HiOutlineCheckCircle,
9
10
  HiOutlineEnvelope,
10
11
  HiOutlineExclamationCircle,
@@ -19,6 +20,7 @@ import { useSystemErrorNotifications } from "./systemErrors.js";
19
20
 
20
21
  const KIND_STYLE: Record<NotificationKind, { border: string; icon: string; Icon: IconType; label: string }> = {
21
22
  mail: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineEnvelope, label: "New message" },
23
+ calendar: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineBell, label: "Reminder" },
22
24
  info: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineInformationCircle, label: "Information" },
23
25
  success: { border: "border-l-success", icon: "text-success", Icon: HiOutlineCheckCircle, label: "Success" },
24
26
  warning: { border: "border-l-warning", icon: "text-warning", Icon: HiOutlineExclamationTriangle, label: "Warning" },
@@ -10,6 +10,7 @@ import { useNotifications } from "./useNotifications.js";
10
10
 
11
11
  const KIND_LABEL: Record<NotificationKind, string> = {
12
12
  mail: "New message",
13
+ calendar: "Reminder",
13
14
  info: "Information",
14
15
  success: "Done",
15
16
  warning: "Warning",
@@ -26,7 +26,7 @@
26
26
  * `notify()` only hands back an id.
27
27
  */
28
28
 
29
- export type NotificationKind = "mail" | "info" | "success" | "warning" | "error";
29
+ export type NotificationKind = "mail" | "calendar" | "info" | "success" | "warning" | "error";
30
30
 
31
31
  export interface NotificationAction {
32
32
  label: string;
@@ -119,6 +119,8 @@ export const MAX_DETAIL_LINE_LENGTH = 500;
119
119
  /** How long a non-sticky notification stays, by kind. Errors are sticky, so theirs is only for an explicit `sticky: false`. */
120
120
  export const DEFAULT_TIMEOUT_MS: Record<NotificationKind, number> = {
121
121
  mail: 8_000,
122
+ // Irrelevant in practice - a reminder always carries actions, which makes it sticky (see `applyInput()`) regardless of this value.
123
+ calendar: 8_000,
122
124
  info: 6_000,
123
125
  success: 5_000,
124
126
  warning: 10_000,
@@ -20,6 +20,11 @@
20
20
  *
21
21
  * **Failed deletions are retried**: a destroy that couldn't remove a directory (another tab held it, the page
22
22
  * navigated away first) is recorded in `localStorage` and retried before this tab's first `init`.
23
+ *
24
+ * **A crashed Worker never leaves a caller hanging**: the `error` listener registered in `getWorker()` rejects
25
+ * every pending request and drops the module-level `worker` reference so the next `call()` spawns a
26
+ * replacement. `searchTier2.ts` additionally races its own calls against a timeout, since a Worker that's
27
+ * merely stuck (not crashed) posts no `error` event at all.
23
28
  */
24
29
  import type { ParsedSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
25
30
  import type {
@@ -71,8 +76,12 @@ function getWorker(): Worker {
71
76
  if (!worker) {
72
77
  // Named by its compiled `.js` file, like every other relative import: tsc copies the literal into
73
78
  // `dist` unchanged, where only the `.js` file exists, and Vite maps it back to the `.ts` source.
74
- worker = new Worker(new URL("./localIndexWorker.js", import.meta.url), { type: "module" });
75
- worker.addEventListener("message", (event: MessageEvent<LocalIndexResponse>) => {
79
+ // Captured in its own `created` const (not just read back from the module-level `worker` variable)
80
+ // so the `error` listener below can tell whether it still refers to *this* Worker - see that
81
+ // listener's own comment for why that guard matters.
82
+ const created = new Worker(new URL("./localIndexWorker.js", import.meta.url), { type: "module" });
83
+ worker = created;
84
+ created.addEventListener("message", (event: MessageEvent<LocalIndexResponse>) => {
76
85
  const entry = pending.get(event.data.id);
77
86
  if (!entry) {
78
87
  return;
@@ -84,6 +93,28 @@ function getWorker(): Worker {
84
93
  entry.reject(new Error(event.data.error));
85
94
  }
86
95
  });
96
+ // A Worker that crashes (an uncaught exception, the WASM module aborting) never posts a response to
97
+ // its in-flight requests - without this, every pending call() promise would sit unresolved forever,
98
+ // and searchTier2.ts's try/catch (which can only catch a *rejection*) couldn't rescue it. Reject
99
+ // everything outstanding and drop the reference so the next call() spawns a fresh Worker instead of
100
+ // continuing to postMessage into a dead one.
101
+ //
102
+ // Scoped to `created`, this listener's own Worker, via the `worker !== created` check below - not
103
+ // scoped, a very unlikely double-error (this same Worker firing a second `error` event after the
104
+ // first already replaced `worker` with a healthy new instance) would reject that new instance's own
105
+ // in-flight requests and terminate it too, for no reason of its own.
106
+ created.addEventListener("error", (event: ErrorEvent) => {
107
+ if (worker !== created) {
108
+ return;
109
+ }
110
+ const err = new Error(`Local search Worker crashed: ${event.message || "unknown error"}`);
111
+ for (const entry of pending.values()) {
112
+ entry.reject(err);
113
+ }
114
+ pending.clear();
115
+ created.terminate();
116
+ worker = undefined;
117
+ });
87
118
  // Only a tab with a Worker has connections to close when another tab signs out.
88
119
  listenForSignOut();
89
120
  }
@@ -44,36 +44,54 @@ async function ensureInitialized(mailboxUid: string, unlocked: UnlockedKeys): Pr
44
44
  await initLocalIndex({ mailboxUid, indexKey });
45
45
  }
46
46
 
47
+ /** How long a Tier 2 search may run before this function gives up on it and degrades to "no local results" -
48
+ * `localIndexRpcClient.ts`'s own `error` listener rescues a Worker that *crashes*, but a Worker that's merely
49
+ * wedged (an infinite loop in the WASM module, a stuck OPFS lock) posts no `error` event and no response
50
+ * either; without this, the caller's `Promise.all()` (`apps/www/index.tsx`) would wait on this tier forever
51
+ * even though Tier 1's results already arrived. */
52
+ export const TIER2_SEARCH_TIMEOUT_MS = 5_000;
53
+
47
54
  export async function searchLocalIndex(
48
55
  mailboxUid: string,
49
56
  parsed: ParsedSearchQuery,
50
57
  unlocked: UnlockedKeys | undefined,
51
58
  limit = 50,
52
59
  offset = 0,
60
+ timeoutMs = TIER2_SEARCH_TIMEOUT_MS,
53
61
  ): Promise<Tier2SearchOutcome> {
54
62
  if (!unlocked) {
55
63
  return { results: [], hasMore: false };
56
64
  }
65
+ let timer: ReturnType<typeof setTimeout> | undefined;
57
66
  try {
58
- await ensureInitialized(mailboxUid, unlocked);
59
- const [page, coverage] = await Promise.all([searchLocal(mailboxUid, parsed, limit, offset), getLocalCoverage(mailboxUid)]);
60
- const results: SearchResult[] = page.hits.map((hit) => ({
61
- entityType: "message",
62
- entityUid: hit.entityUid,
63
- // Negated: bm25()'s own convention is "more negative is a better match" (SQLite), the
64
- // opposite of every other score this codebase merges (Postgres ts_rank, OpenSearch _score,
65
- // and normalizeServerScores() itself all treat "higher is better"). Negating here, once,
66
- // keeps that convention uniform for the caller (apps/www/index.tsx's merge layer) - it never
67
- // needs to know this tier's underlying scoring function works backwards from the others.
68
- score: -hit.score,
69
- snippet: hit.snippet,
70
- source: "local",
71
- metadataOnly: false,
72
- }));
73
- return { results, coverage, hasMore: page.hasMore };
67
+ return await Promise.race([
68
+ (async (): Promise<Tier2SearchOutcome> => {
69
+ await ensureInitialized(mailboxUid, unlocked);
70
+ const [page, coverage] = await Promise.all([searchLocal(mailboxUid, parsed, limit, offset), getLocalCoverage(mailboxUid)]);
71
+ const results: SearchResult[] = page.hits.map((hit) => ({
72
+ entityType: "message",
73
+ entityUid: hit.entityUid,
74
+ // Negated: bm25()'s own convention is "more negative is a better match" (SQLite), the
75
+ // opposite of every other score this codebase merges (Postgres ts_rank, OpenSearch _score,
76
+ // and normalizeServerScores() itself all treat "higher is better"). Negating here, once,
77
+ // keeps that convention uniform for the caller (apps/www/index.tsx's merge layer) - it never
78
+ // needs to know this tier's underlying scoring function works backwards from the others.
79
+ score: -hit.score,
80
+ snippet: hit.snippet,
81
+ source: "local",
82
+ metadataOnly: false,
83
+ }));
84
+ return { results, coverage, hasMore: page.hasMore };
85
+ })(),
86
+ new Promise<Tier2SearchOutcome>((resolve) => {
87
+ timer = setTimeout(() => resolve({ results: [], hasMore: false }), timeoutMs);
88
+ }),
89
+ ]);
74
90
  } catch {
75
91
  // See this function's own doc comment - a broken local index degrades to "nothing to contribute
76
92
  // this time," never a rejected search.
77
93
  return { results: [], hasMore: false };
94
+ } finally {
95
+ clearTimeout(timer);
78
96
  }
79
97
  }