@rapidmx/web-client 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/admin/domains/[uid].tsx +73 -2
- package/apps/admin/domains/index.tsx +2 -1
- package/apps/admin/domains/new/index.tsx +28 -3
- package/apps/shared/calendar/calendarReminders.ts +76 -0
- package/apps/shared/calendar/useCalendarReminders.ts +91 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +8 -0
- package/apps/shared/components/calendar/EventModal.tsx +236 -11
- package/apps/shared/components/layout/AppShell.tsx +3 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +37 -8
- package/apps/shared/notifications/NotificationCenter.tsx +2 -0
- package/apps/shared/notifications/NotificationHistoryDialog.tsx +1 -0
- package/apps/shared/notifications/store.ts +3 -1
- 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/admin/domains/[uid].js +38 -2
- package/dist/apps/admin/domains/index.js +1 -1
- package/dist/apps/admin/domains/new/index.js +13 -4
- package/dist/apps/shared/calendar/calendarReminders.d.ts +28 -0
- package/dist/apps/shared/calendar/calendarReminders.js +55 -0
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +23 -0
- package/dist/apps/shared/calendar/useCalendarReminders.js +79 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +2 -2
- package/dist/apps/shared/components/calendar/EventModal.d.ts +10 -0
- package/dist/apps/shared/components/calendar/EventModal.js +186 -12
- package/dist/apps/shared/components/layout/AppShell.js +3 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +36 -8
- package/dist/apps/shared/notifications/NotificationCenter.js +2 -1
- package/dist/apps/shared/notifications/NotificationHistoryDialog.js +1 -0
- package/dist/apps/shared/notifications/store.d.ts +1 -1
- package/dist/apps/shared/notifications/store.js +2 -0
- 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 +2 -2
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.0",
|
|
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",
|
|
@@ -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",
|