@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.
- package/README.md +1 -1
- package/apps/shared/components/admin/diagnostics/HostCard.tsx +2 -0
- package/apps/shared/components/admin/diagnostics/PressureTiles.tsx +108 -0
- package/apps/shared/components/admin/diagnostics/PvcTable.tsx +20 -6
- package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +21 -2
- package/apps/shared/components/admin/layout/AdminShell.tsx +2 -2
- package/apps/shared/components/admin/settings/PluginsManager.tsx +508 -35
- package/apps/shared/components/admin/settings/pluginPreferences.ts +34 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +2 -2
- package/apps/shared/components/layout/AppShell.tsx +4 -2
- package/apps/shared/components/mail/ConversationList.tsx +18 -9
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +24 -0
- package/apps/shared/components/mail/listPreferences.ts +3 -3
- package/apps/www/index.tsx +62 -12
- package/dist/apps/shared/components/admin/diagnostics/HostCard.js +2 -1
- package/dist/apps/shared/components/admin/diagnostics/PressureTiles.d.ts +22 -0
- package/dist/apps/shared/components/admin/diagnostics/PressureTiles.js +52 -0
- package/dist/apps/shared/components/admin/diagnostics/PvcTable.js +9 -4
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +35 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +267 -35
- package/dist/apps/shared/components/admin/settings/pluginPreferences.d.ts +2 -0
- package/dist/apps/shared/components/admin/settings/pluginPreferences.js +35 -0
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +2 -2
- package/dist/apps/shared/components/layout/AppShell.js +4 -2
- package/dist/apps/shared/components/mail/ConversationList.d.ts +10 -3
- package/dist/apps/shared/components/mail/ConversationList.js +9 -5
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +7 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +20 -1
- package/dist/apps/shared/components/mail/listPreferences.d.ts +1 -1
- package/dist/apps/www/index.js +51 -10
- 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:
|
|
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:
|
|
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
|
|
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,
|
|
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
|
|
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-${
|
|
88
|
-
const ticked = selectedConversationIds?.has(
|
|
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:
|
|
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. */
|
package/dist/apps/www/index.js
CHANGED
|
@@ -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 =
|
|
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
|
-
//
|
|
867
|
-
//
|
|
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
|
|
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.
|
|
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.
|
|
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",
|