@rapidmx/web-client 0.19.0 → 0.21.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 (32) hide show
  1. package/README.md +1 -1
  2. package/apps/shared/components/admin/diagnostics/HostCard.tsx +2 -0
  3. package/apps/shared/components/admin/diagnostics/PressureTiles.tsx +108 -0
  4. package/apps/shared/components/admin/diagnostics/PvcTable.tsx +20 -6
  5. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +21 -2
  6. package/apps/shared/components/admin/layout/AdminShell.tsx +2 -2
  7. package/apps/shared/components/admin/settings/PluginsManager.tsx +508 -35
  8. package/apps/shared/components/admin/settings/pluginPreferences.ts +34 -0
  9. package/apps/shared/components/escrow/layout/EscrowShell.tsx +2 -2
  10. package/apps/shared/components/layout/AppShell.tsx +4 -2
  11. package/apps/shared/components/mail/ConversationList.tsx +18 -9
  12. package/apps/shared/components/mail/compose/ComposeWindow.tsx +24 -0
  13. package/apps/shared/components/mail/listPreferences.ts +3 -3
  14. package/apps/www/index.tsx +62 -12
  15. package/dist/apps/shared/components/admin/diagnostics/HostCard.js +2 -1
  16. package/dist/apps/shared/components/admin/diagnostics/PressureTiles.d.ts +22 -0
  17. package/dist/apps/shared/components/admin/diagnostics/PressureTiles.js +52 -0
  18. package/dist/apps/shared/components/admin/diagnostics/PvcTable.js +9 -4
  19. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +35 -2
  20. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
  21. package/dist/apps/shared/components/admin/settings/PluginsManager.js +267 -35
  22. package/dist/apps/shared/components/admin/settings/pluginPreferences.d.ts +2 -0
  23. package/dist/apps/shared/components/admin/settings/pluginPreferences.js +35 -0
  24. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +2 -2
  25. package/dist/apps/shared/components/layout/AppShell.js +4 -2
  26. package/dist/apps/shared/components/mail/ConversationList.d.ts +10 -3
  27. package/dist/apps/shared/components/mail/ConversationList.js +9 -5
  28. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +7 -1
  29. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +20 -1
  30. package/dist/apps/shared/components/mail/listPreferences.d.ts +1 -1
  31. package/dist/apps/www/index.js +51 -10
  32. package/package.json +2 -2
@@ -1,9 +1,16 @@
1
1
  import React from "react";
2
2
  import { Message } from "@rapidmx/react-shared/mail/mailApi.js";
3
3
  import { ConversationSummary } from "@rapidmx/react-shared/mail/conversationsApi.js";
4
+ /** A conversation row of the list. `mailboxUid` is set only by a view that merges several mailboxes ("All Mailboxes"), whose rows are
5
+ * each from a different one; a list of one mailbox's own leaves it out and every row is that mailbox's. */
6
+ export type ListedConversation = ConversationSummary & {
7
+ mailboxUid?: string;
8
+ };
9
+ /** What tells a row apart from the others: the same conversation id in two mailboxes (a message sent to both) is two rows. */
10
+ export declare function conversationRowKey(conversation: ListedConversation): string;
4
11
  export interface ConversationListProps {
5
- conversations: ConversationSummary[];
6
- /** The mailbox the conversations were listed from - `listConversationMessages()` is mailbox-scoped. */
12
+ conversations: ListedConversation[];
13
+ /** The mailbox the conversations were listed from - `listConversationMessages()` is mailbox-scoped. A row that names its own mailbox uses that. */
7
14
  mailboxUid: string;
8
15
  /** The message currently open in the reading pane, so the row standing for it can be marked. */
9
16
  selectedUid: string | null;
@@ -12,7 +19,7 @@ export interface ConversationListProps {
12
19
  * stands for, or the conversation's own `latestMessageUid` for a parent row. The conversation goes
13
20
  * with the uid because the pane shows the whole thread, not just that message.
14
21
  */
15
- onOpenMessage: (conversation: ConversationSummary, uid: string) => void;
22
+ onOpenMessage: (conversation: ListedConversation, uid: string) => void;
16
23
  /** Newer copies of messages this list already fetched - the reading pane marks the message it opens as
17
24
  * read, which this list would otherwise keep showing as unread until the thread is collapsed and
18
25
  * expanded again. */
@@ -13,6 +13,10 @@ import { listConversationMessages } from "@rapidmx/react-shared/mail/conversatio
13
13
  import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "./unreadStyle.js";
14
14
  import SwipeRow from "./SwipeRow.js";
15
15
  import InviteRowChip from "./invite/InviteRowChip.js";
16
+ /** What tells a row apart from the others: the same conversation id in two mailboxes (a message sent to both) is two rows. */
17
+ export function conversationRowKey(conversation) {
18
+ return conversation.mailboxUid ? `${conversation.mailboxUid}\u0000${conversation.conversationId}` : conversation.conversationId;
19
+ }
16
20
  /** Every participant as `Name <address>`, for the tooltip and the accessible text of the "+N" a crowded row folds them into. */
17
21
  function participantList(conversation) {
18
22
  return conversation.participants.map((participant) => formatMailAddress(participant)).join(", ");
@@ -41,7 +45,7 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
41
45
  setAnswers((prev) => ({ ...prev, [updated.uid]: updated.meetingResponse }));
42
46
  }
43
47
  function toggle(conversation) {
44
- const id = conversation.conversationId;
48
+ const id = conversationRowKey(conversation);
45
49
  setExpanded((prev) => {
46
50
  const next = new Set(prev);
47
51
  if (next.has(id)) {
@@ -61,7 +65,7 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
61
65
  delete next[id];
62
66
  return next;
63
67
  });
64
- listConversationMessages(mailboxUid, id)
68
+ listConversationMessages(conversation.mailboxUid ?? mailboxUid, conversation.conversationId)
65
69
  .then((loaded) => setMessagesById((prev) => ({ ...prev, [id]: loaded })))
66
70
  .catch((err) => setErrorsById((prev) => ({
67
71
  ...prev,
@@ -77,15 +81,15 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
77
81
  return _jsx("p", { className: "p-4 text-sm text-text-muted", children: "No conversations in this folder." });
78
82
  }
79
83
  return (_jsx("ul", { className: swipe?.enabled ? "overflow-x-clip" : undefined, children: conversations.map((conversation) => {
80
- const id = conversation.conversationId;
84
+ const id = conversationRowKey(conversation);
81
85
  const isExpanded = expanded.has(id);
82
86
  const unread = conversation.unreadCount > 0;
83
87
  const loaded = messagesById[id];
84
88
  // `listConversationMessages()` answers oldest first; the rows read in whichever sense the
85
89
  // list itself is arranged in. Copied before reversing - the fetched array is cached.
86
90
  const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
87
- const panelId = `conversation-messages-${id}`;
88
- const ticked = selectedConversationIds?.has(id) ?? false;
91
+ const panelId = `conversation-messages-${conversation.mailboxUid ? `${conversation.mailboxUid}-` : ""}${conversation.conversationId}`;
92
+ const ticked = selectedConversationIds?.has(conversation.conversationId) ?? false;
89
93
  return (_jsxs("li", { children: [_jsxs(SwipeRow, { as: "div", enabled: !!swipe?.enabled && !selectMode, onArchive: () => swipe.onArchive(conversation), onMove: () => swipe.onMove(conversation), "data-message-uid": conversation.latestMessageUid, "data-unread": unread ? "true" : undefined, className: rowClass({ unread, selected: conversation.latestMessageUid === selectedUid || ticked }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: unread }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: ["shrink-0 px-2 text-text-muted hover:text-text", ROW_FOCUS_CLASS].join(" "), children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["w-full text-left pr-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: unread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: conversation.participants[0] ?? conversation.latestFrom, className: ["flex-1", senderClass(unread)].join(" ") }), conversation.participants.length > 1 && (_jsxs("span", { className: "text-xs text-text-muted shrink-0 font-normal", title: participantList(conversation), children: ["+", conversation.participants.length - 1, _jsxs("span", { className: "sr-only", children: [" more: ", participantList(conversation)] })] })), _jsx("span", { className: ["text-xs shrink-0", dateClass(unread)].join(" "), children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: ["text-sm truncate", subjectClass(unread)].join(" "), children: conversation.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversationLooksEncrypted(conversation) ? _jsx(EncryptedPreview, {}) : conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/15 text-primary-dark font-bold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] }), _jsx(InviteRowChip, { message: {
90
94
  uid: conversation.latestMessageUid,
91
95
  meetingMethod: conversation.latestMeetingMethod ?? undefined,
@@ -12,12 +12,18 @@ export interface ComposeWindowProps {
12
12
  /** How long (ms) after the last edit the draft is autosaved. Defaults to `DEFAULT_AUTOSAVE_DELAY_MS`;
13
13
  * only overridden by tests. */
14
14
  autosaveDelayMs?: number;
15
+ /** The longest (ms) an unsaved edit waits before the draft is saved even though typing hasn't paused - so a
16
+ * message written without a 2-second break is still saved regularly. Defaults to `DEFAULT_AUTOSAVE_MAX_WAIT_MS`
17
+ * and is never shorter than `autosaveDelayMs`; only overridden by tests. */
18
+ autosaveMaxWaitMs?: number;
15
19
  /** Backoff (ms) between automatic retries of a failed mailbox/encryption-policy load, one entry per retry.
16
20
  * Defaults to `DEFAULT_CRYPTO_RETRY_DELAYS_MS`; only overridden by tests. */
17
21
  cryptoRetryDelaysMs?: number[];
18
22
  }
19
23
  /** Debounce between the last edit and the draft autosave. */
20
24
  export declare const DEFAULT_AUTOSAVE_DELAY_MS = 2000;
25
+ /** The longest an edit stays unsaved while the user keeps typing (the debounce above restarts on every keystroke, so it alone never fires then). */
26
+ export declare const DEFAULT_AUTOSAVE_MAX_WAIT_MS = 30000;
21
27
  /** Automatic, silent retries of a failed mailbox/encryption-policy (or recipient key) load, one entry per retry - a flapping gateway is waited out
22
28
  * in the background, without a word to the user. */
23
29
  export declare const DEFAULT_CRYPTO_RETRY_DELAYS_MS: number[];
@@ -35,4 +41,4 @@ export declare const DEFAULT_CRYPTO_RETRY_DELAYS_MS: number[];
35
41
  * see `ComposeContext.tsx`'s own doc comment for how that interacts with several sessions being open at
36
42
  * once.
37
43
  */
38
- export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid, trusted, autosaveDelayMs, cryptoRetryDelaysMs, }: ComposeWindowProps): React.JSX.Element;
44
+ export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid, trusted, autosaveDelayMs, autosaveMaxWaitMs, cryptoRetryDelaysMs, }: ComposeWindowProps): React.JSX.Element;
@@ -36,6 +36,8 @@ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
36
36
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
37
37
  /** Debounce between the last edit and the draft autosave. */
38
38
  export const DEFAULT_AUTOSAVE_DELAY_MS = 2000;
39
+ /** The longest an edit stays unsaved while the user keeps typing (the debounce above restarts on every keystroke, so it alone never fires then). */
40
+ export const DEFAULT_AUTOSAVE_MAX_WAIT_MS = 30000;
39
41
  /** Automatic, silent retries of a failed mailbox/encryption-policy (or recipient key) load, one entry per retry - a flapping gateway is waited out
40
42
  * in the background, without a word to the user. */
41
43
  export const DEFAULT_CRYPTO_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 15000, 30000, 60000];
@@ -96,7 +98,7 @@ function HeaderButton({ label, onClick, icon: Icon, disabled, shortcut, }) {
96
98
  * see `ComposeContext.tsx`'s own doc comment for how that interacts with several sessions being open at
97
99
  * once.
98
100
  */
99
- export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid, trusted, autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS, cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS, }) {
101
+ export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid, trusted, autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS, autosaveMaxWaitMs = DEFAULT_AUTOSAVE_MAX_WAIT_MS, cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS, }) {
100
102
  const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late, resume } = session;
101
103
  // What a reply worked out after this window opened (see `OpenComposeInput.pending`): better recipients, applied below
102
104
  // only to fields still exactly as they opened - so these, not `initialTo`/`initialCc`, are what "untouched" is measured against.
@@ -197,6 +199,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
197
199
  const lastSavedRef = useRef(null);
198
200
  /** An edit is waiting on the autosave debounce - flushed if the window unmounts first. */
199
201
  const pendingSaveRef = useRef(false);
202
+ /** Fires the save when an edit has waited `autosaveMaxWaitMs` without the debounce ever getting a pause to fire in. */
203
+ const maxWaitTimerRef = useRef(undefined);
200
204
  /** Send was pressed and is being validated/handed over: a second press (a double click, the shortcut held or repeated) does nothing. */
201
205
  const submittingRef = useRef(false);
202
206
  /** Attachment/inline-image uploads on the wire, and who is waiting for them to finish (Send pressed meanwhile). */
@@ -751,6 +755,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
751
755
  const target = latestRef.current.draft;
752
756
  const previous = saveInFlightRef.current;
753
757
  pendingSaveRef.current = false;
758
+ clearTimeout(maxWaitTimerRef.current);
759
+ maxWaitTimerRef.current = undefined;
754
760
  setSaveStatus("saving");
755
761
  const outcome = (async () => {
756
762
  const before = await previous;
@@ -1022,14 +1028,27 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1022
1028
  lastSavedRef.current !== `${draft.uid}:${contentKey}`;
1023
1029
  pendingSaveRef.current = due;
1024
1030
  if (!due) {
1031
+ clearTimeout(maxWaitTimerRef.current);
1032
+ maxWaitTimerRef.current = undefined;
1025
1033
  return;
1026
1034
  }
1035
+ // The debounce below restarts on every edit, so steady typing never lets it fire: this timer, started by the first unsaved edit and stopped
1036
+ // by the save that follows, is what saves such a message at a regular interval.
1037
+ if (maxWaitTimerRef.current === undefined) {
1038
+ maxWaitTimerRef.current = setTimeout(() => {
1039
+ maxWaitTimerRef.current = undefined;
1040
+ if (pendingSaveRef.current && !finishedRef.current) {
1041
+ void saveDraftNow();
1042
+ }
1043
+ }, Math.max(autosaveMaxWaitMs, autosaveDelayMs));
1044
+ }
1027
1045
  const timer = setTimeout(() => void saveDraftNow(), autosaveDelayMs);
1028
1046
  return () => clearTimeout(timer);
1029
1047
  }, [draft, contentReady, autosaveSuppressed, hasUserContent, contentKey]);
1030
1048
  // Unmounting with an edit still waiting on the debounce (e.g. the whole app shell unmounting) saves it
1031
1049
  // right away rather than losing it.
1032
1050
  useEffect(() => () => {
1051
+ clearTimeout(maxWaitTimerRef.current);
1033
1052
  if (pendingSaveRef.current && !finishedRef.current) {
1034
1053
  void saveDraftNow();
1035
1054
  }
@@ -89,7 +89,7 @@ export declare const CONVERSATION_SORT_NOTE = "Conversations are ordered within
89
89
  * order that would mean nothing. Ties break newest first whichever direction is in force, so two rows the
90
90
  * key can't tell apart still read in a stable, familiar order.
91
91
  */
92
- export declare function sortConversations(conversations: ConversationSummary[], sortBy: MessageListSort, sortOrder: MessageSortOrder): ConversationSummary[];
92
+ export declare function sortConversations<T extends ConversationSummary>(conversations: T[], sortBy: MessageListSort, sortOrder: MessageSortOrder): T[];
93
93
  /** The direction the server itself would pick for a key if none were sent (see `MessageSortOrder`) - what
94
94
  * this list switches to when the reader picks a different field, so changing "Date" to "Subject" reads A-Z
95
95
  * rather than inheriting Date's newest-first as a surprising Z-A. */
@@ -31,7 +31,7 @@ import { setReadStateMany } from "../shared/mail/messageReadState.js";
31
31
  import { useMarkMessageRead } from "../shared/mail/useMarkMessageRead.js";
32
32
  import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "../shared/components/mail/unreadStyle.js";
33
33
  import { LazyConversationThreadPane, LazyMessageDetailPane, prefetchReadingPane } from "../shared/components/mail/LazyReadingPane.js";
34
- import ConversationList from "../shared/components/mail/ConversationList.js";
34
+ import ConversationList, { conversationRowKey } from "../shared/components/mail/ConversationList.js";
35
35
  import SwipeRow from "../shared/components/mail/SwipeRow.js";
36
36
  import InviteRowChip from "../shared/components/mail/invite/InviteRowChip.js";
37
37
  import MoveToFolderDialog from "../shared/components/mail/MoveToFolderDialog.js";
@@ -313,7 +313,7 @@ function appendUnseenRows(shown, more, idOf, cap) {
313
313
  return cap !== undefined && combined.length > cap ? combined.slice(0, cap) : combined;
314
314
  }
315
315
  const messageUid = (message) => message.uid;
316
- const conversationKey = (conversation) => conversation.conversationId;
316
+ const conversationKey = conversationRowKey;
317
317
  /** Flattens and sorts a per-mailbox fetch into one merged, newest-first list - the aggregate ("All
318
318
  * Inboxes" etc.) equivalent of `mergeSearchResults()` above, but simpler: an aggregated message has no
319
319
  * natural relevance score to normalize, so this only ever sorts by `receivedDate`. */
@@ -349,6 +349,23 @@ async function fetchAggregateMessages(mailboxFolders, type, filter) {
349
349
  }));
350
350
  return mergeInboxMessages(perMailbox);
351
351
  }
352
+ /**
353
+ * The conversation-list counterpart of `fetchAggregateMessages()`: one `listConversations()` per accessible mailbox that has a folder of
354
+ * `type`, each scoped to *that folder* (so "All Inboxes" groups only Inbox mail - not the Sent Items copy of a reply, which a mailbox-wide
355
+ * grouping pulls in) and each row tagged with its mailbox, then merged newest activity first. Same first-page-only scope, and the same
356
+ * rule that a mailbox that has no such folder or fails to answer contributes nothing rather than blanking the rest.
357
+ */
358
+ async function fetchAggregateConversations(mailboxFolders, type, params) {
359
+ const perMailbox = await Promise.all(mailboxFolders.map(async ({ mailbox, folders }) => {
360
+ const folder = folders.find((f) => f.type === type);
361
+ if (!folder) {
362
+ return [];
363
+ }
364
+ const rows = await listConversations(mailbox.uid, { ...params, folderUid: folder.uid, page: 0 }).catch(() => []);
365
+ return rows.map((row) => ({ ...row, mailboxUid: mailbox.uid }));
366
+ }));
367
+ return perMailbox.flat().sort((a, b) => Date.parse(b.latestDate) - Date.parse(a.latestDate));
368
+ }
352
369
  /** Groups search results by the conversation each message belongs to - within its own mailbox - the groups in the order of their first result. */
353
370
  function groupByConversation(results) {
354
371
  const groups = new Map();
@@ -862,12 +879,27 @@ function InboxContent({ userUid }) {
862
879
  setLoading(true);
863
880
  return;
864
881
  }
882
+ if (asConversations && aggregateFolderType) {
883
+ // Grouped per mailbox, inside that mailbox's folder of this type, and merged - first pages only, like the message listing.
884
+ setLoading(true);
885
+ setError(null);
886
+ setHasMore(false);
887
+ void fetchAggregateConversations(mailboxFolders, aggregateFolderType, conversationParams(0))
888
+ .then((results) => {
889
+ if (isCurrentRun()) {
890
+ setConversations(results);
891
+ }
892
+ })
893
+ .finally(() => {
894
+ if (isCurrentRun()) {
895
+ setLoading(false);
896
+ }
897
+ });
898
+ return invalidate;
899
+ }
865
900
  if (asConversations) {
866
- // Conversations stay single-mailbox (not aggregated across mailboxes in this pass) - in
867
- // aggregate mode this falls back to `activeMailboxUid`, the same mailbox unlock/labels use
868
- // (always set - `MailShell` only renders this component once at least one mailbox exists).
869
- // They're scoped to the selected folder (`folderUid`, absent only in aggregate mode), so the
870
- // conversation list matches the folder the sidebar has selected rather than the whole mailbox.
901
+ // A single mailbox's own folder (`folderUid`), so the conversation list matches the folder the sidebar has selected rather
902
+ // than the whole mailbox.
871
903
  if (snapshot) {
872
904
  setConversations(snapshot.conversations);
873
905
  listedOffsetRef.current = snapshot.conversations.length;
@@ -1212,7 +1244,14 @@ function InboxContent({ userUid }) {
1212
1244
  const isCurrent = () => searchRunIdRef.current === listRun && liveRunRef.current === myRun;
1213
1245
  void (async () => {
1214
1246
  try {
1215
- if (asConversations) {
1247
+ if (asConversations && aggregateFolderType) {
1248
+ // No paging here either: the fresh merged first pages are the list.
1249
+ const fresh = await fetchAggregateConversations(mailboxFolders, aggregateFolderType, conversationParams(0));
1250
+ if (isCurrent()) {
1251
+ setConversations(fresh);
1252
+ }
1253
+ }
1254
+ else if (asConversations) {
1216
1255
  const fresh = await listConversations(activeMailboxUid, conversationParams(0));
1217
1256
  if (!isCurrent()) {
1218
1257
  return;
@@ -1897,7 +1936,7 @@ function InboxContent({ userUid }) {
1897
1936
  }
1898
1937
  removedAnchorRef.current = null;
1899
1938
  setSelectedUid(uid);
1900
- setOpenThread({ conversation, uid, mailboxUid: activeMailboxUid });
1939
+ setOpenThread({ conversation, uid, mailboxUid: conversation.mailboxUid ?? activeMailboxUid });
1901
1940
  }
1902
1941
  // ---- Keyboard shortcuts (see `shared/keyboard`). Each is registered only while this view can do it, and calls what the toolbar and
1903
1942
  // ---- the selection bar call: the same bulk-action path (`runBulkAction()` - so the same optimistic badge tracking, the same rollback,
@@ -1929,7 +1968,9 @@ function InboxContent({ userUid }) {
1929
1968
  /** The index of the selected row: the open message's, or the open conversation's; -1 with nothing selected. */
1930
1969
  function selectedRowIndex() {
1931
1970
  if (inConversations) {
1932
- return openThread ? listedConversations.findIndex((c) => c.conversationId === openThread.conversation.conversationId) : -1;
1971
+ return openThread
1972
+ ? listedConversations.findIndex((c) => c.conversationId === openThread.conversation.conversationId && (!c.mailboxUid || c.mailboxUid === openThread.mailboxUid))
1973
+ : -1;
1933
1974
  }
1934
1975
  return messages.findIndex((m) => m.uid === selectedUid);
1935
1976
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/web-client",
3
- "version": "0.19.0",
3
+ "version": "0.21.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",
@@ -40,7 +40,7 @@
40
40
  "@dnd-kit/core": "^6.3.1",
41
41
  "@journeyapps/wa-sqlite": "^2.0.4",
42
42
  "@peculiar/x509": "^2.1.0",
43
- "@rapidmx/react-shared": "^0.20.0",
43
+ "@rapidmx/react-shared": "^0.22.0",
44
44
  "@tiptap/core": "^3.31.3",
45
45
  "@tiptap/extension-highlight": "^3.31.3",
46
46
  "@tiptap/extension-image": "^3.31.3",