@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.
- package/apps/shared/components/calendar/EventModal.tsx +44 -7
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +37 -8
- package/apps/shared/search/localIndexRpcClient.ts +33 -2
- package/apps/shared/search/searchTier2.ts +34 -16
- package/apps/www/index.tsx +49 -8
- package/dist/apps/shared/components/calendar/EventModal.js +38 -4
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +36 -8
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +28 -2
- package/dist/apps/shared/search/searchTier2.d.ts +7 -1
- package/dist/apps/shared/search/searchTier2.js +34 -17
- package/dist/apps/www/index.js +37 -8
- package/package.json +3 -3
|
@@ -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
|
-
|
|
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/
|
|
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
|
-
|
|
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={!
|
|
598
|
-
onClick={() => window.open(
|
|
634
|
+
disabled={!validatedJoinUrl}
|
|
635
|
+
onClick={() => window.open(validatedJoinUrl, "_blank", "noopener,noreferrer")}
|
|
599
636
|
>
|
|
600
637
|
Join video call
|
|
601
638
|
</Button>
|
|
602
|
-
{!
|
|
639
|
+
{!validatedJoinUrl && (
|
|
603
640
|
<span className="text-xs text-text-muted">
|
|
604
|
-
{organizerJoinUrl ===
|
|
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
|
-
|
|
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(
|
|
438
|
+
inFlight.delete(key);
|
|
424
439
|
if (generation !== discoveryGenerationRef.current) {
|
|
425
440
|
return;
|
|
426
441
|
}
|
|
427
442
|
if (!result) {
|
|
428
|
-
noteLookupFailed(
|
|
443
|
+
noteLookupFailed(key);
|
|
429
444
|
return;
|
|
430
445
|
}
|
|
431
|
-
lookupFailuresRef.current.delete(
|
|
446
|
+
lookupFailuresRef.current.delete(key);
|
|
432
447
|
const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, result.lookup);
|
|
433
|
-
setRecipientStatuses((prev) => ({ ...prev, [
|
|
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
|
-
.
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
}
|
package/apps/www/index.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 — 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 — 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’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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
325
|
+
inFlight.delete(key);
|
|
312
326
|
if (generation !== discoveryGenerationRef.current) {
|
|
313
327
|
return;
|
|
314
328
|
}
|
|
315
329
|
if (!result) {
|
|
316
|
-
noteLookupFailed(
|
|
330
|
+
noteLookupFailed(key);
|
|
317
331
|
return;
|
|
318
332
|
}
|
|
319
|
-
lookupFailuresRef.current.delete(
|
|
333
|
+
lookupFailuresRef.current.delete(key);
|
|
320
334
|
const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, result.lookup);
|
|
321
|
-
setRecipientStatuses((prev) => ({ ...prev, [
|
|
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
|
-
.
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
}
|
package/dist/apps/www/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|