@rapidmx/web-client 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,6 +35,7 @@ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
35
35
  import RecurrenceEditor from "./RecurrenceEditor.js";
36
36
  import { addDaysToKey, allDayDateKey, allDayInstant, recurrenceUntilDateKey, recurrenceUntilInstant, startWeekdayCode } from "./allDay.js";
37
37
  import ResourcePicker from "./ResourcePicker.js";
38
+ import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
38
39
 
39
40
  const INPUT_CLASS =
40
41
  "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -131,7 +132,11 @@ function wallTimeOfDayMs(instantMs: number, timezone: string | undefined): numbe
131
132
  async function toSeriesFields(occurrence: CalendarOccurrence, fields: EventFields): Promise<EventFields> {
132
133
  const { startDate, endDate, ...rest } = fields;
133
134
  if (sameMinute(startDate as string, occurrence.startDate) && sameMinute(endDate as string, occurrence.endDate)) {
134
- return rest;
135
+ // `saveEventSeries()` treats *any* `timezone`/`allDay` in the update as a possible reinterpretation of
136
+ // the master's instant and fetches the master to check - so when neither differs from the occurrence's
137
+ // own, both are dropped too, or every plain series save (e.g. just a new title) would pay for that GET.
138
+ const { timezone, allDay, ...others } = rest;
139
+ return timezone === occurrence.timezone && allDay === occurrence.allDay ? others : rest;
135
140
  }
136
141
  const master = await getCalendarEvent(occurrence.uid);
137
142
  const timezone = fields.timezone as string;
@@ -318,7 +323,7 @@ export default function EventModal({
318
323
  * theirs and is preserved.
319
324
  *
320
325
  * **Known, deliberate limitation:** an event that already has a meeting is left completely alone, even
321
- * when its attendee list has just changed. `@rapidmx/videoconf-plugin`'s `PUT /mail/video-meetings/:id`
326
+ * when its attendee list has just changed. `@rapidmx/meet-plugin`'s `PUT /mail/video-meetings/:id`
322
327
  * is deliberately minimal (title and cancellation only) and cannot add or remove invitees, and a private
323
328
  * meeting's personal join links are minted once at creation - so there is no way to reconcile an invitee
324
329
  * list, and silently pretending otherwise would leave a new attendee with no link and a removed one with
@@ -333,6 +338,20 @@ export default function EventModal({
333
338
  if (videoEnabled === !!videoMeetingUid) {
334
339
  return { event: saved, failed: false };
335
340
  }
341
+ // Detaching a single occurrence (`detachOccurrence()`, react-shared's `calendarMutations.ts`)
342
+ // deliberately never copies the series' `videoMeetingUid` onto the new standalone event - that
343
+ // shared meeting belongs to the series, not to the one occurrence being split off, and every other
344
+ // occurrence still needs it live. But this component's own `videoMeetingUid` *state* was seeded
345
+ // from the series' occurrence at mount and is untouched by the detach, so unchecking the toggle
346
+ // while detaching would otherwise still look like "off, but state says linked" above and fall into
347
+ // the cancel branch below - cancelling the series' shared meeting for every remaining occurrence,
348
+ // not just this one. `saved.videoMeetingUid` is the actual just-persisted record, so when the
349
+ // toggle is off and that record already has no meeting reference (true for every detach, checked or
350
+ // not - see this function's own doc comment on the still-checked case being a separate, documented
351
+ // gap), there is nothing of *this* record's to cancel.
352
+ if (!videoEnabled && !saved.videoMeetingUid) {
353
+ return { event: saved, failed: false };
354
+ }
336
355
  try {
337
356
  if (videoEnabled) {
338
357
  const organizerAddressLower = saved.organizer.address.toLowerCase();
@@ -374,7 +393,17 @@ export default function EventModal({
374
393
  setLocation(restoredLocation);
375
394
  return { event: patched, failed: false };
376
395
  } catch (err) {
377
- setVideoError(err instanceof ApiRequestError ? err.message : "Could not update this event's video conferencing.");
396
+ // `videoMeetingsApi.ts`'s own doc comment: when the plugin isn't installed at all, its routes
397
+ // simply aren't mounted and every call here fails with a plain 404 - a caller offering video
398
+ // conferencing optionally must treat that as "not available here," not alarm the reader with
399
+ // the raw (often server-generated, unhelpful) 404 body text.
400
+ setVideoError(
401
+ err instanceof ApiRequestError
402
+ ? err.status === 404
403
+ ? "Video conferencing is not available on this server."
404
+ : err.message
405
+ : "Could not update this event's video conferencing.",
406
+ );
378
407
  return { event: saved, failed: true };
379
408
  }
380
409
  }
@@ -536,6 +565,14 @@ export default function EventModal({
536
565
  );
537
566
  }
538
567
 
568
+ // Same scheme allow-list `calendarReminders.ts#joinMeetingUrl()` already applies to a reminder's own
569
+ // `location` field - reused here rather than duplicated so a video-conferencing plugin (or a stray
570
+ // manual edit of `videoMeetingUid`'s otherwise-plugin-owned join link) can't smuggle a `javascript:`/
571
+ // `file:`/other non-http(s) URL into `window.open()`. `undefined` (still loading) and `null` (fetch
572
+ // came back with nothing to join) both fail validation the same way as a bad scheme would - the button
573
+ // stays disabled either way, and the two are told apart below purely from `organizerJoinUrl` itself.
574
+ const validatedJoinUrl = joinMeetingUrl(organizerJoinUrl);
575
+
539
576
  return (
540
577
  <Modal open={open} onClose={onClose} title={occurrence ? "Edit event" : "New event"}>
541
578
  <form onSubmit={handleSubmit} className="flex flex-col gap-1">
@@ -594,14 +631,14 @@ export default function EventModal({
594
631
  type="button"
595
632
  variant="secondary"
596
633
  className="!w-auto"
597
- disabled={!organizerJoinUrl}
598
- onClick={() => window.open(organizerJoinUrl!, "_blank", "noopener,noreferrer")}
634
+ disabled={!validatedJoinUrl}
635
+ onClick={() => window.open(validatedJoinUrl, "_blank", "noopener,noreferrer")}
599
636
  >
600
637
  Join video call
601
638
  </Button>
602
- {!organizerJoinUrl && (
639
+ {!validatedJoinUrl && (
603
640
  <span className="text-xs text-text-muted">
604
- {organizerJoinUrl === null ? "This meeting’s join link isn’t available." : "Loading the join link…"}
641
+ {organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available."}
605
642
  </span>
606
643
  )}
607
644
  </div>
@@ -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;
@@ -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
  }
@@ -378,6 +378,16 @@ const LOAD_MORE_ROOT_MARGIN_PX = 200;
378
378
  * "Load more" button instead. */
379
379
  const MAX_EMPTY_PAGE_CONTINUATIONS = 3;
380
380
 
381
+ /** Hard ceiling on how many rows one list (the plain message list or the conversation list) keeps as live
382
+ * React state/DOM at once. `MAX_EMPTY_PAGE_CONTINUATIONS` above only bounds a streak of *empty* pages, not
383
+ * the total accumulated row count - infinite scroll on its own has no natural stopping point, so a reader
384
+ * who fully scrolls a large mailbox would otherwise keep every row ever fetched mounted forever. Past this
385
+ * cap, `loadMore()` stops fetching further pages and the list shows a "refine your search" banner instead of
386
+ * the load-more sentinel - deliberately not virtualization (a larger, riskier change this bug's own writeup
387
+ * called for only if trivial, which it isn't given this file's existing structure): capping the row count is
388
+ * the same fix's low-risk cousin, since the unbounded-memory failure mode is identical either way. */
389
+ const MAX_LOADED_ROWS = 500;
390
+
381
391
  /** `true` when `more` has at least one row `shown` doesn't - i.e. appending it actually adds rows. Generic
382
392
  * over the row's own identity so both the message list (`uid`) and the conversation list
383
393
  * (`conversationId`) page the same way. */
@@ -397,8 +407,11 @@ function isWithinLoadMoreRange(sentinel: HTMLElement, root: HTMLElement): boolea
397
407
  /** Appends `more` to `shown`, skipping any row already shown - a later page can repeat rows (Tier 1 and
398
408
  * Tier 2/3 cursors advance independently, so the same message can come back from a different tier on a
399
409
  * later page; a plain folder listing's pages shift when new mail arrives between fetches). Generic for the
400
- * same reason `hasUnseenRows()` is. */
401
- function appendUnseenRows<T>(shown: T[], more: T[], idOf: (row: T) => string): T[] {
410
+ * same reason `hasUnseenRows()` is.
411
+ *
412
+ * `cap`, when given, truncates the result to its first `cap` rows (dropping the tail) rather than letting
413
+ * accumulated pages grow without bound - see `MAX_LOADED_ROWS`'s own doc comment for why this exists. */
414
+ function appendUnseenRows<T>(shown: T[], more: T[], idOf: (row: T) => string, cap?: number): T[] {
402
415
  const seen = new Set(shown.map(idOf));
403
416
  const unseen: T[] = [];
404
417
  for (const row of more) {
@@ -407,7 +420,11 @@ function appendUnseenRows<T>(shown: T[], more: T[], idOf: (row: T) => string): T
407
420
  unseen.push(row);
408
421
  }
409
422
  }
410
- return unseen.length === 0 ? shown : [...shown, ...unseen];
423
+ if (unseen.length === 0) {
424
+ return shown;
425
+ }
426
+ const combined = [...shown, ...unseen];
427
+ return cap !== undefined && combined.length > cap ? combined.slice(0, cap) : combined;
411
428
  }
412
429
 
413
430
  const messageUid = (message: Message) => message.uid;
@@ -1209,10 +1226,15 @@ function InboxContent({ userUid }: { userUid?: string }) {
1209
1226
  // has fully finished (the ref may still hold an earlier query's), in which case there's nowhere to
1210
1227
  // continue from yet.
1211
1228
  const searchCursor = decodeCursor(compositeCursorRef.current, fingerprint);
1229
+ // Reads the ref, not `conversations`/`messages` state, so this callback (and the observer effect
1230
+ // watching it) don't need to be recreated every time a page lands - the same reason every other
1231
+ // check below reads a ref rather than closing over state.
1232
+ const atRowCap = (preferences.showAsConversations ? conversationsRef.current.length : messagesRef.current.length) >= MAX_LOADED_ROWS;
1212
1233
  if (
1213
1234
  loadMoreInFlightRef.current ||
1214
1235
  !hasMore ||
1215
1236
  loading ||
1237
+ atRowCap ||
1216
1238
  (!preferences.showAsConversations && !folderUid) ||
1217
1239
  (isSearching && !searchCursor)
1218
1240
  ) {
@@ -1251,7 +1273,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1251
1273
  return;
1252
1274
  }
1253
1275
  const addedRows = hasUnseenRows(conversationsRef.current, more, conversationKey);
1254
- setConversations((prev) => appendUnseenRows(prev, more, conversationKey));
1276
+ setConversations((prev) => appendUnseenRows(prev, more, conversationKey, MAX_LOADED_ROWS));
1255
1277
  setHasMore(more.length === MESSAGE_PAGE_SIZE);
1256
1278
  listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
1257
1279
  notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
@@ -1280,7 +1302,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1280
1302
  return;
1281
1303
  }
1282
1304
  const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
1283
- setMessages((prev) => appendUnseenRows(prev, more, messageUid));
1305
+ setMessages((prev) => appendUnseenRows(prev, more, messageUid, MAX_LOADED_ROWS));
1284
1306
  setSnippets((prev) => ({ ...prev, ...moreSnippets }));
1285
1307
 
1286
1308
  const nextTier3Offset = tier3Offset + tier3Page.length;
@@ -1304,7 +1326,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1304
1326
  return;
1305
1327
  }
1306
1328
  const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
1307
- setMessages((prev) => appendUnseenRows(prev, more, messageUid));
1329
+ setMessages((prev) => appendUnseenRows(prev, more, messageUid, MAX_LOADED_ROWS));
1308
1330
  setHasMore(more.length === MESSAGE_PAGE_SIZE);
1309
1331
  listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
1310
1332
  notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
@@ -1461,6 +1483,15 @@ function InboxContent({ userUid }: { userUid?: string }) {
1461
1483
  * arrived, so paging keeps appending to the same accumulated set. */
1462
1484
  const listedConversations = sortConversations(conversations, preferences.sortBy, preferences.sortOrder);
1463
1485
 
1486
+ /** `true` once whichever list is on screen has hit `MAX_LOADED_ROWS` *and* the server still has more to
1487
+ * give - `appendUnseenRows()` itself already stopped growing the array at that point, and `loadMore()`
1488
+ * refuses to fetch further pages (see its own `atRowCap` check); this just drives the "refine your
1489
+ * search" banner replacing the load-more sentinel below. The `hasMore` half matters at the edge: a
1490
+ * mailbox/search whose true size lands at or near the cap can have its very last page be a partial one,
1491
+ * which correctly turns `hasMore` false - without checking it here, the banner would still claim rows
1492
+ * are being hidden even though every one of them is already on screen. */
1493
+ const rowCapReached = hasMore && (preferences.showAsConversations ? conversations.length : messages.length) >= MAX_LOADED_ROWS;
1494
+
1464
1495
  function leaveSelectMode() {
1465
1496
  setSelectMode(false);
1466
1497
  setSelectedUids(new Set());
@@ -2112,11 +2143,16 @@ function InboxContent({ userUid }: { userUid?: string }) {
2112
2143
  selectedConversationIds={selectedConversationIds}
2113
2144
  onToggleSelected={toggleConversationSelected}
2114
2145
  />
2115
- {hasMore && (
2146
+ {hasMore && !rowCapReached && (
2116
2147
  <div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
2117
2148
  {loadMoreStatus}
2118
2149
  </div>
2119
2150
  )}
2151
+ {rowCapReached && (
2152
+ <p className="p-4 text-center text-xs text-text-muted">
2153
+ Showing the most recent {MAX_LOADED_ROWS} conversations &mdash; refine your search or filters to see more.
2154
+ </p>
2155
+ )}
2120
2156
  </>
2121
2157
  ) : messages.length === 0 ? (
2122
2158
  <>
@@ -2213,11 +2249,16 @@ function InboxContent({ userUid }: { userUid?: string }) {
2213
2249
  </li>
2214
2250
  ))}
2215
2251
  </ul>
2216
- {hasMore && (
2252
+ {hasMore && !rowCapReached && (
2217
2253
  <div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
2218
2254
  {loadMoreStatus}
2219
2255
  </div>
2220
2256
  )}
2257
+ {rowCapReached && (
2258
+ <p className="p-4 text-center text-xs text-text-muted">
2259
+ Showing the most recent {MAX_LOADED_ROWS} messages &mdash; refine your search or filters to see more.
2260
+ </p>
2261
+ )}
2221
2262
  {aggregateFolderType && (
2222
2263
  <p className="p-4 text-center text-xs text-text-muted">
2223
2264
  Showing the most recent mail from each mailbox. Open a specific mailbox&rsquo;s folder to
@@ -17,6 +17,7 @@ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
17
17
  import RecurrenceEditor from "./RecurrenceEditor.js";
18
18
  import { addDaysToKey, allDayDateKey, allDayInstant, recurrenceUntilDateKey, recurrenceUntilInstant, startWeekdayCode } from "./allDay.js";
19
19
  import ResourcePicker from "./ResourcePicker.js";
20
+ import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
20
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";
21
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";
22
23
  const ATTENDEE_ROLES = ["required", "optional", "resource"];
@@ -68,7 +69,11 @@ function wallTimeOfDayMs(instantMs, timezone) {
68
69
  async function toSeriesFields(occurrence, fields) {
69
70
  const { startDate, endDate, ...rest } = fields;
70
71
  if (sameMinute(startDate, occurrence.startDate) && sameMinute(endDate, occurrence.endDate)) {
71
- return rest;
72
+ // `saveEventSeries()` treats *any* `timezone`/`allDay` in the update as a possible reinterpretation of
73
+ // the master's instant and fetches the master to check - so when neither differs from the occurrence's
74
+ // own, both are dropped too, or every plain series save (e.g. just a new title) would pay for that GET.
75
+ const { timezone, allDay, ...others } = rest;
76
+ return timezone === occurrence.timezone && allDay === occurrence.allDay ? others : rest;
72
77
  }
73
78
  const master = await getCalendarEvent(occurrence.uid);
74
79
  const timezone = fields.timezone;
@@ -230,7 +235,7 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
230
235
  * theirs and is preserved.
231
236
  *
232
237
  * **Known, deliberate limitation:** an event that already has a meeting is left completely alone, even
233
- * when its attendee list has just changed. `@rapidmx/videoconf-plugin`'s `PUT /mail/video-meetings/:id`
238
+ * when its attendee list has just changed. `@rapidmx/meet-plugin`'s `PUT /mail/video-meetings/:id`
234
239
  * is deliberately minimal (title and cancellation only) and cannot add or remove invitees, and a private
235
240
  * meeting's personal join links are minted once at creation - so there is no way to reconcile an invitee
236
241
  * list, and silently pretending otherwise would leave a new attendee with no link and a removed one with
@@ -245,6 +250,20 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
245
250
  if (videoEnabled === !!videoMeetingUid) {
246
251
  return { event: saved, failed: false };
247
252
  }
253
+ // Detaching a single occurrence (`detachOccurrence()`, react-shared's `calendarMutations.ts`)
254
+ // deliberately never copies the series' `videoMeetingUid` onto the new standalone event - that
255
+ // shared meeting belongs to the series, not to the one occurrence being split off, and every other
256
+ // occurrence still needs it live. But this component's own `videoMeetingUid` *state* was seeded
257
+ // from the series' occurrence at mount and is untouched by the detach, so unchecking the toggle
258
+ // while detaching would otherwise still look like "off, but state says linked" above and fall into
259
+ // the cancel branch below - cancelling the series' shared meeting for every remaining occurrence,
260
+ // not just this one. `saved.videoMeetingUid` is the actual just-persisted record, so when the
261
+ // toggle is off and that record already has no meeting reference (true for every detach, checked or
262
+ // not - see this function's own doc comment on the still-checked case being a separate, documented
263
+ // gap), there is nothing of *this* record's to cancel.
264
+ if (!videoEnabled && !saved.videoMeetingUid) {
265
+ return { event: saved, failed: false };
266
+ }
248
267
  try {
249
268
  if (videoEnabled) {
250
269
  const organizerAddressLower = saved.organizer.address.toLowerCase();
@@ -287,7 +306,15 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
287
306
  return { event: patched, failed: false };
288
307
  }
289
308
  catch (err) {
290
- setVideoError(err instanceof ApiRequestError ? err.message : "Could not update this event's video conferencing.");
309
+ // `videoMeetingsApi.ts`'s own doc comment: when the plugin isn't installed at all, its routes
310
+ // simply aren't mounted and every call here fails with a plain 404 - a caller offering video
311
+ // conferencing optionally must treat that as "not available here," not alarm the reader with
312
+ // the raw (often server-generated, unhelpful) 404 body text.
313
+ setVideoError(err instanceof ApiRequestError
314
+ ? err.status === 404
315
+ ? "Video conferencing is not available on this server."
316
+ : err.message
317
+ : "Could not update this event's video conferencing.");
291
318
  return { event: saved, failed: true };
292
319
  }
293
320
  }
@@ -439,6 +466,13 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
439
466
  if (syncWarning) {
440
467
  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" })] }));
441
468
  }
442
- 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: !organizerJoinUrl, onClick: () => window.open(organizerJoinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !organizerJoinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: organizerJoinUrl === null ? "This meeting’s join link isn’t available." : "Loading the join link…" }))] })), _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 &&
469
+ // Same scheme allow-list `calendarReminders.ts#joinMeetingUrl()` already applies to a reminder's own
470
+ // `location` field - reused here rather than duplicated so a video-conferencing plugin (or a stray
471
+ // manual edit of `videoMeetingUid`'s otherwise-plugin-owned join link) can't smuggle a `javascript:`/
472
+ // `file:`/other non-http(s) URL into `window.open()`. `undefined` (still loading) and `null` (fetch
473
+ // came back with nothing to join) both fail validation the same way as a bad scheme would - the button
474
+ // stays disabled either way, and the two are told apart below purely from `organizerJoinUrl` itself.
475
+ const validatedJoinUrl = joinMeetingUrl(organizerJoinUrl);
476
+ 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 &&
443
477
  (!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" })] })))] })] }) }));
444
478
  }
@@ -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;
@@ -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
  }
@@ -284,6 +284,15 @@ const LOAD_MORE_ROOT_MARGIN_PX = 200;
284
284
  /** How many full pages in a row that added no new rows the list keeps loading on its own before it shows a
285
285
  * "Load more" button instead. */
286
286
  const MAX_EMPTY_PAGE_CONTINUATIONS = 3;
287
+ /** Hard ceiling on how many rows one list (the plain message list or the conversation list) keeps as live
288
+ * React state/DOM at once. `MAX_EMPTY_PAGE_CONTINUATIONS` above only bounds a streak of *empty* pages, not
289
+ * the total accumulated row count - infinite scroll on its own has no natural stopping point, so a reader
290
+ * who fully scrolls a large mailbox would otherwise keep every row ever fetched mounted forever. Past this
291
+ * cap, `loadMore()` stops fetching further pages and the list shows a "refine your search" banner instead of
292
+ * the load-more sentinel - deliberately not virtualization (a larger, riskier change this bug's own writeup
293
+ * called for only if trivial, which it isn't given this file's existing structure): capping the row count is
294
+ * the same fix's low-risk cousin, since the unbounded-memory failure mode is identical either way. */
295
+ const MAX_LOADED_ROWS = 500;
287
296
  /** `true` when `more` has at least one row `shown` doesn't - i.e. appending it actually adds rows. Generic
288
297
  * over the row's own identity so both the message list (`uid`) and the conversation list
289
298
  * (`conversationId`) page the same way. */
@@ -301,8 +310,11 @@ function isWithinLoadMoreRange(sentinel, root) {
301
310
  /** Appends `more` to `shown`, skipping any row already shown - a later page can repeat rows (Tier 1 and
302
311
  * Tier 2/3 cursors advance independently, so the same message can come back from a different tier on a
303
312
  * later page; a plain folder listing's pages shift when new mail arrives between fetches). Generic for the
304
- * same reason `hasUnseenRows()` is. */
305
- function appendUnseenRows(shown, more, idOf) {
313
+ * same reason `hasUnseenRows()` is.
314
+ *
315
+ * `cap`, when given, truncates the result to its first `cap` rows (dropping the tail) rather than letting
316
+ * accumulated pages grow without bound - see `MAX_LOADED_ROWS`'s own doc comment for why this exists. */
317
+ function appendUnseenRows(shown, more, idOf, cap) {
306
318
  const seen = new Set(shown.map(idOf));
307
319
  const unseen = [];
308
320
  for (const row of more) {
@@ -311,7 +323,11 @@ function appendUnseenRows(shown, more, idOf) {
311
323
  unseen.push(row);
312
324
  }
313
325
  }
314
- return unseen.length === 0 ? shown : [...shown, ...unseen];
326
+ if (unseen.length === 0) {
327
+ return shown;
328
+ }
329
+ const combined = [...shown, ...unseen];
330
+ return cap !== undefined && combined.length > cap ? combined.slice(0, cap) : combined;
315
331
  }
316
332
  const messageUid = (message) => message.uid;
317
333
  const conversationKey = (conversation) => conversation.conversationId;
@@ -1063,9 +1079,14 @@ function InboxContent({ userUid }) {
1063
1079
  // has fully finished (the ref may still hold an earlier query's), in which case there's nowhere to
1064
1080
  // continue from yet.
1065
1081
  const searchCursor = decodeCursor(compositeCursorRef.current, fingerprint);
1082
+ // Reads the ref, not `conversations`/`messages` state, so this callback (and the observer effect
1083
+ // watching it) don't need to be recreated every time a page lands - the same reason every other
1084
+ // check below reads a ref rather than closing over state.
1085
+ const atRowCap = (preferences.showAsConversations ? conversationsRef.current.length : messagesRef.current.length) >= MAX_LOADED_ROWS;
1066
1086
  if (loadMoreInFlightRef.current ||
1067
1087
  !hasMore ||
1068
1088
  loading ||
1089
+ atRowCap ||
1069
1090
  (!preferences.showAsConversations && !folderUid) ||
1070
1091
  (isSearching && !searchCursor)) {
1071
1092
  return;
@@ -1105,7 +1126,7 @@ function InboxContent({ userUid }) {
1105
1126
  return;
1106
1127
  }
1107
1128
  const addedRows = hasUnseenRows(conversationsRef.current, more, conversationKey);
1108
- setConversations((prev) => appendUnseenRows(prev, more, conversationKey));
1129
+ setConversations((prev) => appendUnseenRows(prev, more, conversationKey, MAX_LOADED_ROWS));
1109
1130
  setHasMore(more.length === MESSAGE_PAGE_SIZE);
1110
1131
  listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
1111
1132
  notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
@@ -1133,7 +1154,7 @@ function InboxContent({ userUid }) {
1133
1154
  return;
1134
1155
  }
1135
1156
  const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
1136
- setMessages((prev) => appendUnseenRows(prev, more, messageUid));
1157
+ setMessages((prev) => appendUnseenRows(prev, more, messageUid, MAX_LOADED_ROWS));
1137
1158
  setSnippets((prev) => ({ ...prev, ...moreSnippets }));
1138
1159
  const nextTier3Offset = tier3Offset + tier3Page.length;
1139
1160
  compositeCursorRef.current = {
@@ -1157,7 +1178,7 @@ function InboxContent({ userUid }) {
1157
1178
  return;
1158
1179
  }
1159
1180
  const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
1160
- setMessages((prev) => appendUnseenRows(prev, more, messageUid));
1181
+ setMessages((prev) => appendUnseenRows(prev, more, messageUid, MAX_LOADED_ROWS));
1161
1182
  setHasMore(more.length === MESSAGE_PAGE_SIZE);
1162
1183
  listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
1163
1184
  notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
@@ -1295,6 +1316,14 @@ function InboxContent({ userUid }) {
1295
1316
  * far - which the Sort menu says on screen. `conversations` itself stays in the order the pages
1296
1317
  * arrived, so paging keeps appending to the same accumulated set. */
1297
1318
  const listedConversations = sortConversations(conversations, preferences.sortBy, preferences.sortOrder);
1319
+ /** `true` once whichever list is on screen has hit `MAX_LOADED_ROWS` *and* the server still has more to
1320
+ * give - `appendUnseenRows()` itself already stopped growing the array at that point, and `loadMore()`
1321
+ * refuses to fetch further pages (see its own `atRowCap` check); this just drives the "refine your
1322
+ * search" banner replacing the load-more sentinel below. The `hasMore` half matters at the edge: a
1323
+ * mailbox/search whose true size lands at or near the cap can have its very last page be a partial one,
1324
+ * which correctly turns `hasMore` false - without checking it here, the banner would still claim rows
1325
+ * are being hidden even though every one of them is already on screen. */
1326
+ const rowCapReached = hasMore && (preferences.showAsConversations ? conversations.length : messages.length) >= MAX_LOADED_ROWS;
1298
1327
  function leaveSelectMode() {
1299
1328
  setSelectMode(false);
1300
1329
  setSelectedUids(new Set());
@@ -1674,14 +1703,14 @@ function InboxContent({ userUid }) {
1674
1703
  ? `${messages.length} result${messages.length === 1 ? "" : "s"}`
1675
1704
  : `${messages.length} of ??` }), tier2Done && !searchAllMail && (_jsx("button", { type: "button", onClick: handleSearchAllMail, className: "text-primary-dark hover:underline font-medium shrink-0", children: "Search all mail" }))] })), isSearching && coverage?.indexedFrom && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["Local search covers messages back to ", new Date(coverage.indexedFrom).toLocaleDateString(), coverage.building ? " (still building)" : "", searchAllMail
1676
1705
  ? " - searching everything, not just recent mail."
1677
- : " - older encrypted mail is still searched, just slower."] })), isSearching && !getUnlockedKeys(mailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages in these results"] }) })), !isSearching && !preferences.showAsConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockList, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to show ", undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'", " subject"] }) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })), loading ? (_jsxs("div", { role: "status", "aria-busy": "true", className: "p-4", children: [_jsx("span", { className: "sr-only", children: "Loading\u2026" }), _jsx(SkeletonList, { count: 8 })] })) : preferences.showAsConversations ? (_jsxs(_Fragment, { children: [_jsx(ConversationList, { conversations: listedConversations, newestFirst: preferences.sortBy === "date" && preferences.sortOrder === "desc", mailboxUid: activeMailboxUid, selectedUid: selectedUid, messageOverrides: conversationPatches, onOpenMessage: handleOpenConversation, selectMode: selectMode, selectedConversationIds: selectedConversationIds, onToggleSelected: toggleConversationSelected }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
1706
+ : " - older encrypted mail is still searched, just slower."] })), isSearching && !getUnlockedKeys(mailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages in these results"] }) })), !isSearching && !preferences.showAsConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockList, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to show ", undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'", " subject"] }) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })), loading ? (_jsxs("div", { role: "status", "aria-busy": "true", className: "p-4", children: [_jsx("span", { className: "sr-only", children: "Loading\u2026" }), _jsx(SkeletonList, { count: 8 })] })) : preferences.showAsConversations ? (_jsxs(_Fragment, { children: [_jsx(ConversationList, { conversations: listedConversations, newestFirst: preferences.sortBy === "date" && preferences.sortOrder === "desc", mailboxUid: activeMailboxUid, selectedUid: selectedUid, messageOverrides: conversationPatches, onOpenMessage: handleOpenConversation, selectMode: selectMode, selectedConversationIds: selectedConversationIds, onToggleSelected: toggleConversationSelected }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", MAX_LOADED_ROWS, " conversations \u2014 refine your search or filters to see more."] }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
1678
1707
  ? `No messages match "${searchQuery}".`
1679
1708
  : effectiveFilter === "all"
1680
1709
  ? "No messages in this folder."
1681
1710
  : "No messages here." }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : (_jsxs(_Fragment, { children: [_jsx("ul", { children: messages.map((message) => (_jsxs("li", { "data-message-uid": message.uid, "data-unread": isUnread(message) ? "true" : undefined, className: rowClass({ unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: isUnread(message) }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: selectedUids.has(message.uid), onChange: () => toggleSelected(message.uid), "aria-label": `Select ${rowSubject(message)}`, className: "w-4 h-4 accent-primary" }) })), _jsxs("button", { type: "button", "data-row-open": true, onClick: () => handleSelect(message), className: ["flex-1 min-w-0 text-left px-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: isUnread(message) }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(isUnread(message)) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(isUnread(message))].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), aggregateFolderType && (_jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName })), isSearching && pendingUids.has(message.uid) ? (_jsxs("div", { className: "flex flex-col gap-1.5 py-0.5", children: [_jsx(Skeleton, { height: "h-3.5", className: "w-2/3 rounded-sm" }), _jsx(Skeleton, { height: "h-3", className: "w-full rounded-sm" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: ["text-sm truncate", subjectClass(isUnread(message))].join(" "), children: rowSubject(message) }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: snippets[message.uid] ||
1682
1711
  decryptedRows[message.uid]?.preview ||
1683
1712
  message.bodyPreview ||
1684
- (message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && _jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" }), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] }), isOutbox && _jsx(OutboxRowStatus, { message: message })] }))] })] }, message.uid))) }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), aggregateFolderType && (_jsx("p", { className: "p-4 text-center text-xs text-text-muted", children: "Showing the most recent mail from each mailbox. Open a specific mailbox\u2019s folder to see older mail." }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0 min-h-0", children: preferences.showAsConversations ? (_jsx(LazyConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: activeMailboxUid, folders: currentFolders, labels: mailboxLabels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(LazyMessageDetailPane, { shortcuts: !selectMode, message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
1713
+ (message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && _jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" }), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] }), isOutbox && _jsx(OutboxRowStatus, { message: message })] }))] })] }, message.uid))) }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", MAX_LOADED_ROWS, " messages \u2014 refine your search or filters to see more."] })), aggregateFolderType && (_jsx("p", { className: "p-4 text-center text-xs text-text-muted", children: "Showing the most recent mail from each mailbox. Open a specific mailbox\u2019s folder to see older mail." }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0 min-h-0", children: preferences.showAsConversations ? (_jsx(LazyConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: activeMailboxUid, folders: currentFolders, labels: mailboxLabels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(LazyMessageDetailPane, { shortcuts: !selectMode, message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
1685
1714
  // Same reasoning as onArchived below - a move takes the message out of the folder
1686
1715
  // being listed, so it leaves the list rather than being patched in place.
1687
1716
  removeListedMessage(updated.uid);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/web-client",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "RapidMX's webmail and admin console React UI for web-based clients.",
5
5
  "type": "module",
6
6
  "repository": "https://github.com/rapidmx/web-client",
@@ -17,7 +17,7 @@
17
17
  "./shared/styles/app.css": "./apps/shared/styles/app.css"
18
18
  },
19
19
  "scripts": {
20
- "build": "rimraf dist && tsc -p tsconfig.json && shx mkdir -p dist/apps/shared/styles && shx cp apps/shared/styles/app.css dist/apps/shared/styles/app.css && node scripts/checkDistReferences.mjs",
20
+ "build": "yarn lint && rimraf dist && tsc -p tsconfig.json && shx mkdir -p dist/apps/shared/styles && shx cp apps/shared/styles/app.css dist/apps/shared/styles/app.css && node scripts/checkDistReferences.mjs",
21
21
  "lint": "eslint ./apps ./test",
22
22
  "test": "vitest run --coverage",
23
23
  "test:watch": "vitest --watch --coverage",
@@ -39,7 +39,7 @@
39
39
  "@dnd-kit/core": "^6.3.1",
40
40
  "@journeyapps/wa-sqlite": "^2.0.4",
41
41
  "@peculiar/x509": "^2.1.0",
42
- "@rapidmx/react-shared": "^0.13.0",
42
+ "@rapidmx/react-shared": "^0.15.0",
43
43
  "@tiptap/core": "^3.31.3",
44
44
  "@tiptap/extension-highlight": "^3.31.3",
45
45
  "@tiptap/extension-image": "^3.31.3",