@rapidmx/web-client 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/apps/admin/domains/[uid].tsx +73 -2
  2. package/apps/admin/domains/index.tsx +2 -1
  3. package/apps/admin/domains/new/index.tsx +28 -3
  4. package/apps/shared/calendar/calendarReminders.ts +76 -0
  5. package/apps/shared/calendar/useCalendarReminders.ts +91 -0
  6. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +8 -0
  7. package/apps/shared/components/calendar/EventModal.tsx +236 -11
  8. package/apps/shared/components/layout/AppShell.tsx +3 -0
  9. package/apps/shared/components/mail/compose/ComposeWindow.tsx +37 -8
  10. package/apps/shared/notifications/NotificationCenter.tsx +2 -0
  11. package/apps/shared/notifications/NotificationHistoryDialog.tsx +1 -0
  12. package/apps/shared/notifications/store.ts +3 -1
  13. package/apps/shared/search/localIndexRpcClient.ts +33 -2
  14. package/apps/shared/search/searchTier2.ts +34 -16
  15. package/apps/www/index.tsx +49 -8
  16. package/dist/apps/admin/domains/[uid].js +38 -2
  17. package/dist/apps/admin/domains/index.js +1 -1
  18. package/dist/apps/admin/domains/new/index.js +13 -4
  19. package/dist/apps/shared/calendar/calendarReminders.d.ts +28 -0
  20. package/dist/apps/shared/calendar/calendarReminders.js +55 -0
  21. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +23 -0
  22. package/dist/apps/shared/calendar/useCalendarReminders.js +79 -0
  23. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +2 -2
  24. package/dist/apps/shared/components/calendar/EventModal.d.ts +10 -0
  25. package/dist/apps/shared/components/calendar/EventModal.js +186 -12
  26. package/dist/apps/shared/components/layout/AppShell.js +3 -0
  27. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +36 -8
  28. package/dist/apps/shared/notifications/NotificationCenter.js +2 -1
  29. package/dist/apps/shared/notifications/NotificationHistoryDialog.js +1 -0
  30. package/dist/apps/shared/notifications/store.d.ts +1 -1
  31. package/dist/apps/shared/notifications/store.js +2 -0
  32. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -0
  33. package/dist/apps/shared/search/localIndexRpcClient.js +28 -2
  34. package/dist/apps/shared/search/searchTier2.d.ts +7 -1
  35. package/dist/apps/shared/search/searchTier2.js +34 -17
  36. package/dist/apps/www/index.js +37 -8
  37. package/package.json +2 -2
@@ -284,6 +284,15 @@ const LOAD_MORE_ROOT_MARGIN_PX = 200;
284
284
  /** How many full pages in a row that added no new rows the list keeps loading on its own before it shows a
285
285
  * "Load more" button instead. */
286
286
  const MAX_EMPTY_PAGE_CONTINUATIONS = 3;
287
+ /** Hard ceiling on how many rows one list (the plain message list or the conversation list) keeps as live
288
+ * React state/DOM at once. `MAX_EMPTY_PAGE_CONTINUATIONS` above only bounds a streak of *empty* pages, not
289
+ * the total accumulated row count - infinite scroll on its own has no natural stopping point, so a reader
290
+ * who fully scrolls a large mailbox would otherwise keep every row ever fetched mounted forever. Past this
291
+ * cap, `loadMore()` stops fetching further pages and the list shows a "refine your search" banner instead of
292
+ * the load-more sentinel - deliberately not virtualization (a larger, riskier change this bug's own writeup
293
+ * called for only if trivial, which it isn't given this file's existing structure): capping the row count is
294
+ * the same fix's low-risk cousin, since the unbounded-memory failure mode is identical either way. */
295
+ const MAX_LOADED_ROWS = 500;
287
296
  /** `true` when `more` has at least one row `shown` doesn't - i.e. appending it actually adds rows. Generic
288
297
  * over the row's own identity so both the message list (`uid`) and the conversation list
289
298
  * (`conversationId`) page the same way. */
@@ -301,8 +310,11 @@ function isWithinLoadMoreRange(sentinel, root) {
301
310
  /** Appends `more` to `shown`, skipping any row already shown - a later page can repeat rows (Tier 1 and
302
311
  * Tier 2/3 cursors advance independently, so the same message can come back from a different tier on a
303
312
  * later page; a plain folder listing's pages shift when new mail arrives between fetches). Generic for the
304
- * same reason `hasUnseenRows()` is. */
305
- function appendUnseenRows(shown, more, idOf) {
313
+ * same reason `hasUnseenRows()` is.
314
+ *
315
+ * `cap`, when given, truncates the result to its first `cap` rows (dropping the tail) rather than letting
316
+ * accumulated pages grow without bound - see `MAX_LOADED_ROWS`'s own doc comment for why this exists. */
317
+ function appendUnseenRows(shown, more, idOf, cap) {
306
318
  const seen = new Set(shown.map(idOf));
307
319
  const unseen = [];
308
320
  for (const row of more) {
@@ -311,7 +323,11 @@ function appendUnseenRows(shown, more, idOf) {
311
323
  unseen.push(row);
312
324
  }
313
325
  }
314
- return unseen.length === 0 ? shown : [...shown, ...unseen];
326
+ if (unseen.length === 0) {
327
+ return shown;
328
+ }
329
+ const combined = [...shown, ...unseen];
330
+ return cap !== undefined && combined.length > cap ? combined.slice(0, cap) : combined;
315
331
  }
316
332
  const messageUid = (message) => message.uid;
317
333
  const conversationKey = (conversation) => conversation.conversationId;
@@ -1063,9 +1079,14 @@ function InboxContent({ userUid }) {
1063
1079
  // has fully finished (the ref may still hold an earlier query's), in which case there's nowhere to
1064
1080
  // continue from yet.
1065
1081
  const searchCursor = decodeCursor(compositeCursorRef.current, fingerprint);
1082
+ // Reads the ref, not `conversations`/`messages` state, so this callback (and the observer effect
1083
+ // watching it) don't need to be recreated every time a page lands - the same reason every other
1084
+ // check below reads a ref rather than closing over state.
1085
+ const atRowCap = (preferences.showAsConversations ? conversationsRef.current.length : messagesRef.current.length) >= MAX_LOADED_ROWS;
1066
1086
  if (loadMoreInFlightRef.current ||
1067
1087
  !hasMore ||
1068
1088
  loading ||
1089
+ atRowCap ||
1069
1090
  (!preferences.showAsConversations && !folderUid) ||
1070
1091
  (isSearching && !searchCursor)) {
1071
1092
  return;
@@ -1105,7 +1126,7 @@ function InboxContent({ userUid }) {
1105
1126
  return;
1106
1127
  }
1107
1128
  const addedRows = hasUnseenRows(conversationsRef.current, more, conversationKey);
1108
- setConversations((prev) => appendUnseenRows(prev, more, conversationKey));
1129
+ setConversations((prev) => appendUnseenRows(prev, more, conversationKey, MAX_LOADED_ROWS));
1109
1130
  setHasMore(more.length === MESSAGE_PAGE_SIZE);
1110
1131
  listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
1111
1132
  notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
@@ -1133,7 +1154,7 @@ function InboxContent({ userUid }) {
1133
1154
  return;
1134
1155
  }
1135
1156
  const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
1136
- setMessages((prev) => appendUnseenRows(prev, more, messageUid));
1157
+ setMessages((prev) => appendUnseenRows(prev, more, messageUid, MAX_LOADED_ROWS));
1137
1158
  setSnippets((prev) => ({ ...prev, ...moreSnippets }));
1138
1159
  const nextTier3Offset = tier3Offset + tier3Page.length;
1139
1160
  compositeCursorRef.current = {
@@ -1157,7 +1178,7 @@ function InboxContent({ userUid }) {
1157
1178
  return;
1158
1179
  }
1159
1180
  const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
1160
- setMessages((prev) => appendUnseenRows(prev, more, messageUid));
1181
+ setMessages((prev) => appendUnseenRows(prev, more, messageUid, MAX_LOADED_ROWS));
1161
1182
  setHasMore(more.length === MESSAGE_PAGE_SIZE);
1162
1183
  listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
1163
1184
  notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
@@ -1295,6 +1316,14 @@ function InboxContent({ userUid }) {
1295
1316
  * far - which the Sort menu says on screen. `conversations` itself stays in the order the pages
1296
1317
  * arrived, so paging keeps appending to the same accumulated set. */
1297
1318
  const listedConversations = sortConversations(conversations, preferences.sortBy, preferences.sortOrder);
1319
+ /** `true` once whichever list is on screen has hit `MAX_LOADED_ROWS` *and* the server still has more to
1320
+ * give - `appendUnseenRows()` itself already stopped growing the array at that point, and `loadMore()`
1321
+ * refuses to fetch further pages (see its own `atRowCap` check); this just drives the "refine your
1322
+ * search" banner replacing the load-more sentinel below. The `hasMore` half matters at the edge: a
1323
+ * mailbox/search whose true size lands at or near the cap can have its very last page be a partial one,
1324
+ * which correctly turns `hasMore` false - without checking it here, the banner would still claim rows
1325
+ * are being hidden even though every one of them is already on screen. */
1326
+ const rowCapReached = hasMore && (preferences.showAsConversations ? conversations.length : messages.length) >= MAX_LOADED_ROWS;
1298
1327
  function leaveSelectMode() {
1299
1328
  setSelectMode(false);
1300
1329
  setSelectedUids(new Set());
@@ -1674,14 +1703,14 @@ function InboxContent({ userUid }) {
1674
1703
  ? `${messages.length} result${messages.length === 1 ? "" : "s"}`
1675
1704
  : `${messages.length} of ??` }), tier2Done && !searchAllMail && (_jsx("button", { type: "button", onClick: handleSearchAllMail, className: "text-primary-dark hover:underline font-medium shrink-0", children: "Search all mail" }))] })), isSearching && coverage?.indexedFrom && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["Local search covers messages back to ", new Date(coverage.indexedFrom).toLocaleDateString(), coverage.building ? " (still building)" : "", searchAllMail
1676
1705
  ? " - searching everything, not just recent mail."
1677
- : " - older encrypted mail is still searched, just slower."] })), isSearching && !getUnlockedKeys(mailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages in these results"] }) })), !isSearching && !preferences.showAsConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockList, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to show ", undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'", " subject"] }) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })), loading ? (_jsxs("div", { role: "status", "aria-busy": "true", className: "p-4", children: [_jsx("span", { className: "sr-only", children: "Loading\u2026" }), _jsx(SkeletonList, { count: 8 })] })) : preferences.showAsConversations ? (_jsxs(_Fragment, { children: [_jsx(ConversationList, { conversations: listedConversations, newestFirst: preferences.sortBy === "date" && preferences.sortOrder === "desc", mailboxUid: activeMailboxUid, selectedUid: selectedUid, messageOverrides: conversationPatches, onOpenMessage: handleOpenConversation, selectMode: selectMode, selectedConversationIds: selectedConversationIds, onToggleSelected: toggleConversationSelected }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
1706
+ : " - older encrypted mail is still searched, just slower."] })), isSearching && !getUnlockedKeys(mailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages in these results"] }) })), !isSearching && !preferences.showAsConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockList, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to show ", undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'", " subject"] }) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })), loading ? (_jsxs("div", { role: "status", "aria-busy": "true", className: "p-4", children: [_jsx("span", { className: "sr-only", children: "Loading\u2026" }), _jsx(SkeletonList, { count: 8 })] })) : preferences.showAsConversations ? (_jsxs(_Fragment, { children: [_jsx(ConversationList, { conversations: listedConversations, newestFirst: preferences.sortBy === "date" && preferences.sortOrder === "desc", mailboxUid: activeMailboxUid, selectedUid: selectedUid, messageOverrides: conversationPatches, onOpenMessage: handleOpenConversation, selectMode: selectMode, selectedConversationIds: selectedConversationIds, onToggleSelected: toggleConversationSelected }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", MAX_LOADED_ROWS, " conversations \u2014 refine your search or filters to see more."] }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
1678
1707
  ? `No messages match "${searchQuery}".`
1679
1708
  : effectiveFilter === "all"
1680
1709
  ? "No messages in this folder."
1681
1710
  : "No messages here." }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : (_jsxs(_Fragment, { children: [_jsx("ul", { children: messages.map((message) => (_jsxs("li", { "data-message-uid": message.uid, "data-unread": isUnread(message) ? "true" : undefined, className: rowClass({ unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: isUnread(message) }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: selectedUids.has(message.uid), onChange: () => toggleSelected(message.uid), "aria-label": `Select ${rowSubject(message)}`, className: "w-4 h-4 accent-primary" }) })), _jsxs("button", { type: "button", "data-row-open": true, onClick: () => handleSelect(message), className: ["flex-1 min-w-0 text-left px-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: isUnread(message) }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(isUnread(message)) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(isUnread(message))].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), aggregateFolderType && (_jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName })), isSearching && pendingUids.has(message.uid) ? (_jsxs("div", { className: "flex flex-col gap-1.5 py-0.5", children: [_jsx(Skeleton, { height: "h-3.5", className: "w-2/3 rounded-sm" }), _jsx(Skeleton, { height: "h-3", className: "w-full rounded-sm" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: ["text-sm truncate", subjectClass(isUnread(message))].join(" "), children: rowSubject(message) }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: snippets[message.uid] ||
1682
1711
  decryptedRows[message.uid]?.preview ||
1683
1712
  message.bodyPreview ||
1684
- (message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && _jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" }), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] }), isOutbox && _jsx(OutboxRowStatus, { message: message })] }))] })] }, message.uid))) }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), aggregateFolderType && (_jsx("p", { className: "p-4 text-center text-xs text-text-muted", children: "Showing the most recent mail from each mailbox. Open a specific mailbox\u2019s folder to see older mail." }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0 min-h-0", children: preferences.showAsConversations ? (_jsx(LazyConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: activeMailboxUid, folders: currentFolders, labels: mailboxLabels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(LazyMessageDetailPane, { shortcuts: !selectMode, message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
1713
+ (message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && _jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" }), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] }), isOutbox && _jsx(OutboxRowStatus, { message: message })] }))] })] }, message.uid))) }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", MAX_LOADED_ROWS, " messages \u2014 refine your search or filters to see more."] })), aggregateFolderType && (_jsx("p", { className: "p-4 text-center text-xs text-text-muted", children: "Showing the most recent mail from each mailbox. Open a specific mailbox\u2019s folder to see older mail." }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0 min-h-0", children: preferences.showAsConversations ? (_jsx(LazyConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: activeMailboxUid, folders: currentFolders, labels: mailboxLabels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(LazyMessageDetailPane, { shortcuts: !selectMode, message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
1685
1714
  // Same reasoning as onArchived below - a move takes the message out of the folder
1686
1715
  // being listed, so it leaves the list rather than being patched in place.
1687
1716
  removeListedMessage(updated.uid);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/web-client",
3
- "version": "0.12.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.12.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",