@rapidmx/web-client 0.4.0 → 0.5.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/audit-log/index.tsx +35 -8
- package/apps/admin/data-requests/index.tsx +480 -485
- package/apps/admin/distribution-lists/[uid].tsx +19 -1
- package/apps/admin/escrow-scopes/[uid].tsx +348 -237
- package/apps/admin/escrow-scopes/new/index.tsx +8 -2
- package/apps/admin/index.tsx +19 -1
- package/apps/admin/ingest-queue/index.tsx +44 -5
- package/apps/admin/mailboxes/[uid].tsx +211 -184
- package/apps/admin/quarantine/index.tsx +46 -14
- package/apps/admin/transport-rules/[uid].tsx +30 -1
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
- package/apps/admin/transport-rules/index.tsx +24 -6
- package/apps/admin/transport-rules/new/index.tsx +30 -1
- package/apps/escrow/_layout.tsx +2 -3
- package/apps/escrow/audit-log/index.tsx +196 -168
- package/apps/escrow/matters/[uid].tsx +621 -521
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +3 -1
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
- package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
- package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
- package/apps/shared/components/admin/signOut.ts +79 -0
- package/apps/shared/components/admin/usePagedList.tsx +129 -0
- package/apps/shared/components/calendar/EventModal.tsx +717 -549
- package/apps/shared/components/calendar/MonthView.tsx +173 -139
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
- package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
- package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
- package/apps/shared/components/calendar/allDay.ts +124 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
- package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
- package/apps/shared/components/contacts/ContactForm.tsx +377 -348
- package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
- package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
- package/apps/shared/components/contacts/contactKeys.ts +78 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
- package/apps/shared/components/layout/AppShell.tsx +87 -15
- package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
- package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
- package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
- package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
- package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
- package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +12 -2
- package/apps/shared/components/mail/pinnedSigners.ts +124 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -0
- package/apps/shared/components/mail/writableMailboxes.ts +101 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
- package/apps/shared/mail/listAllPages.ts +39 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
- package/apps/shared/search/localIndexBuilder.ts +481 -179
- package/apps/shared/search/localIndexRpcClient.ts +228 -28
- package/apps/shared/search/localIndexSchema.ts +15 -6
- package/apps/shared/search/localIndexStorage.ts +98 -0
- package/apps/shared/search/localIndexVFS.ts +32 -1
- package/apps/shared/search/localIndexWorker.ts +648 -186
- package/apps/www/calendar/index.tsx +438 -417
- package/apps/www/contacts/[uid].tsx +21 -2
- package/apps/www/contacts/index.tsx +116 -12
- package/apps/www/index.tsx +1314 -1041
- package/apps/www/messages/[uid].tsx +18 -6
- package/apps/www/settings/auto-reply/index.tsx +134 -129
- package/apps/www/settings/booking-types/[uid].tsx +306 -300
- package/apps/www/settings/encryption/index.tsx +739 -216
- package/apps/www/settings/filters/[uid].tsx +171 -155
- package/apps/www/settings/filters/new/index.tsx +8 -1
- package/apps/www/settings/focused-inbox/index.tsx +150 -148
- package/apps/www/settings/privacy/index.tsx +494 -436
- package/apps/www/settings/read-receipts/index.tsx +148 -125
- package/apps/www/settings/sharing/index.tsx +17 -14
- package/apps/www/tasks/index.tsx +638 -579
- package/dist/apps/admin/audit-log/index.js +38 -8
- package/dist/apps/admin/data-requests/index.js +52 -72
- package/dist/apps/admin/distribution-lists/[uid].js +1 -1
- package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
- package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
- package/dist/apps/admin/index.js +4 -1
- package/dist/apps/admin/ingest-queue/index.js +24 -6
- package/dist/apps/admin/mailboxes/[uid].js +20 -4
- package/dist/apps/admin/quarantine/index.js +20 -11
- package/dist/apps/admin/transport-rules/[uid].js +18 -2
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
- package/dist/apps/admin/transport-rules/index.js +22 -6
- package/dist/apps/admin/transport-rules/new/index.js +18 -2
- package/dist/apps/escrow/_layout.js +3 -3
- package/dist/apps/escrow/audit-log/index.js +26 -1
- package/dist/apps/escrow/matters/[uid].js +77 -42
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -1
- package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
- package/dist/apps/shared/components/admin/signOut.js +75 -0
- package/dist/apps/shared/components/admin/usePagedList.js +99 -0
- package/dist/apps/shared/components/calendar/EventModal.js +142 -21
- package/dist/apps/shared/components/calendar/MonthView.js +12 -5
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
- package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
- package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
- package/dist/apps/shared/components/calendar/allDay.js +111 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
- package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
- package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
- package/dist/apps/shared/components/layout/AppShell.js +83 -14
- package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
- package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +12 -3
- package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
- package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
- package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
- package/dist/apps/shared/mail/listAllPages.js +25 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
- package/dist/apps/shared/search/localIndexBuilder.js +292 -37
- package/dist/apps/shared/search/localIndexRpcClient.js +188 -25
- package/dist/apps/shared/search/localIndexSchema.js +12 -6
- package/dist/apps/shared/search/localIndexStorage.js +86 -0
- package/dist/apps/shared/search/localIndexVFS.js +68 -40
- package/dist/apps/shared/search/localIndexWorker.js +519 -173
- package/dist/apps/www/calendar/index.js +28 -10
- package/dist/apps/www/contacts/[uid].js +13 -2
- package/dist/apps/www/contacts/index.js +80 -10
- package/dist/apps/www/index.js +339 -108
- package/dist/apps/www/messages/[uid].js +18 -6
- package/dist/apps/www/settings/auto-reply/index.js +9 -4
- package/dist/apps/www/settings/booking-types/[uid].js +17 -12
- package/dist/apps/www/settings/encryption/index.js +514 -97
- package/dist/apps/www/settings/filters/[uid].js +22 -7
- package/dist/apps/www/settings/filters/new/index.js +8 -2
- package/dist/apps/www/settings/focused-inbox/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +57 -22
- package/dist/apps/www/settings/read-receipts/index.js +10 -3
- package/dist/apps/www/settings/sharing/index.js +7 -11
- package/dist/apps/www/tasks/index.js +50 -10
- package/package.json +2 -2
|
@@ -1,14 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
///////////////////////////////////////////////////////////////////////////////
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { createContext, useCallback, useContext, useState } from "react";
|
|
7
|
-
import { getUnlockedKeys, unlockWithPassword } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
6
|
+
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
|
7
|
+
import { getUnlockedKeys, unlockWithPassword, UnopenableEncryptionKeyError, } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
8
8
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
9
9
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
10
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
11
11
|
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
12
|
+
import { RECOVERY_CODE_ERROR, RecoveryFollowUpModal, UnlockModeToggle, startRecoveryUnlock, } from "./RecoveryCodeUnlock.js";
|
|
13
|
+
/** Shown instead of "Incorrect password" when the password was right but the mailbox's encryption key itself
|
|
14
|
+
* won't open (`UnopenableEncryptionKeyError`) - retrying can't fix that, so it must not read as a typo. */
|
|
15
|
+
export const UNOPENABLE_ENCRYPTION_KEY_MESSAGE = "Your password is correct, but one of your keys couldn't be opened, so encrypted mail can't be read or sent from this mailbox. Trying again won't help - contact support.";
|
|
16
|
+
/** `UNOPENABLE_ENCRYPTION_KEY_MESSAGE`'s counterpart for a recovery-code unlock. */
|
|
17
|
+
export const UNOPENABLE_ENCRYPTION_KEY_RECOVERY_MESSAGE = "Your recovery code is correct, but one of your keys couldn't be opened, so encrypted mail can't be read or sent from this mailbox. Trying again won't help - contact support.";
|
|
18
|
+
/** The error text for a failed `unlockWithPassword()` (or, with `method` `"recovery"`, `unlockWithRecoveryCode()`). */
|
|
19
|
+
export function unlockErrorMessage(err, method = "password") {
|
|
20
|
+
// Deliberately generic otherwise - see `unlockWithPassword()`'s own doc comment: a wrong password and a
|
|
21
|
+
// missing password wrap fail the same way, and telling them apart would help someone guessing passwords.
|
|
22
|
+
if (method === "recovery") {
|
|
23
|
+
return err instanceof UnopenableEncryptionKeyError ? UNOPENABLE_ENCRYPTION_KEY_RECOVERY_MESSAGE : RECOVERY_CODE_ERROR;
|
|
24
|
+
}
|
|
25
|
+
return err instanceof UnopenableEncryptionKeyError ? UNOPENABLE_ENCRYPTION_KEY_MESSAGE : "Incorrect password.";
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A non-blocking notice after an unlock that succeeded without some signing keys (`UnlockResult.unopenableKeys`):
|
|
29
|
+
* mail can still be read, but not signed with those keys on this device.
|
|
30
|
+
*/
|
|
31
|
+
export function UnopenableKeysNotice({ fingerprints, onDismiss }) {
|
|
32
|
+
return (_jsxs("div", { role: "status", className: "fixed bottom-4 right-4 z-50 max-w-sm bg-surface border border-border rounded-md p-3 text-sm shadow-lg flex flex-col gap-2", children: [_jsxs("p", { children: ["Unlocked, but ", fingerprints.length === 1 ? "one of your signing keys" : `${fingerprints.length} of your signing keys`, " ", "couldn\u2019t be opened, so mail can\u2019t be signed with ", fingerprints.length === 1 ? "it" : "them", ". Contact support."] }), _jsx("p", { className: "font-mono text-xs break-all text-text-muted", children: fingerprints.join(", ") }), _jsx("div", { children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onDismiss, children: "Dismiss" }) })] }));
|
|
33
|
+
}
|
|
12
34
|
const UnlockPromptContext = createContext(null);
|
|
13
35
|
/**
|
|
14
36
|
* The on-demand counterpart to `KeyEnrollmentGate`'s blocking, first-sign-in-only provisioning flow.
|
|
@@ -23,23 +45,45 @@ const UnlockPromptContext = createContext(null);
|
|
|
23
45
|
* `false` for exactly this reason.
|
|
24
46
|
*/
|
|
25
47
|
export function UnlockPromptProvider({ children }) {
|
|
26
|
-
const [pending,
|
|
48
|
+
const [pending, setPendingState] = useState(null);
|
|
49
|
+
// Mirrors `pending` synchronously, so two requests issued in the same tick see each other.
|
|
50
|
+
const pendingRef = useRef(null);
|
|
27
51
|
const [password, setPassword] = useState("");
|
|
28
52
|
const [error, setError] = useState(null);
|
|
29
53
|
const [unlocking, setUnlocking] = useState(false);
|
|
54
|
+
const [unopenableKeys, setUnopenableKeys] = useState(null);
|
|
55
|
+
const [mode, setMode] = useState("password");
|
|
56
|
+
const [code, setCode] = useState("");
|
|
57
|
+
// The steps after a recovery-code unlock (new password, using up the code). Shown only once the waiters are
|
|
58
|
+
// already settled - see `RecoveryCodeUnlock.tsx`.
|
|
59
|
+
const [followUp, setFollowUp] = useState(null);
|
|
60
|
+
function setPending(next) {
|
|
61
|
+
pendingRef.current = next;
|
|
62
|
+
setPendingState(next);
|
|
63
|
+
}
|
|
30
64
|
const requestUnlock = useCallback((mailboxUid, mailboxKeys) => {
|
|
31
65
|
const existing = getUnlockedKeys(mailboxUid);
|
|
32
66
|
if (existing) {
|
|
33
67
|
return Promise.resolve(existing);
|
|
34
68
|
}
|
|
35
69
|
return new Promise((resolve, reject) => {
|
|
70
|
+
const current = pendingRef.current;
|
|
71
|
+
if (current?.mailboxUid === mailboxUid) {
|
|
72
|
+
current.waiters.push({ resolve, reject });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// A different mailbox's dialog is replacing this one - settle its callers rather than leaving
|
|
76
|
+
// their promises hanging forever.
|
|
77
|
+
current?.waiters.forEach((waiter) => waiter.reject(new Error("Unlock superseded by another request.")));
|
|
36
78
|
setPassword("");
|
|
79
|
+
setCode("");
|
|
80
|
+
setMode("password");
|
|
37
81
|
setError(null);
|
|
38
|
-
setPending({ mailboxUid, mailboxKeys, resolve, reject });
|
|
82
|
+
setPending({ mailboxUid, mailboxKeys, waiters: [{ resolve, reject }] });
|
|
39
83
|
});
|
|
40
84
|
}, []);
|
|
41
85
|
function handleCancel() {
|
|
42
|
-
|
|
86
|
+
pendingRef.current?.waiters.forEach((waiter) => waiter.reject(new Error("Unlock cancelled.")));
|
|
43
87
|
setPending(null);
|
|
44
88
|
}
|
|
45
89
|
async function handleSubmit(e) {
|
|
@@ -49,22 +93,37 @@ export function UnlockPromptProvider({ children }) {
|
|
|
49
93
|
setError(null);
|
|
50
94
|
setUnlocking(true);
|
|
51
95
|
try {
|
|
52
|
-
await
|
|
53
|
-
|
|
96
|
+
const result = await (mode === "recovery"
|
|
97
|
+
? startRecoveryUnlock(request.mailboxUid, request.mailboxKeys, code)
|
|
98
|
+
: unlockWithPassword(request.mailboxUid, request.mailboxKeys, password));
|
|
99
|
+
const recovery = mode === "recovery" ? result : null;
|
|
100
|
+
// Always reachable: the unlock just populated keySession.ts's session store for
|
|
54
101
|
// this exact mailboxUid, or threw before we get here.
|
|
55
102
|
const unlocked = getUnlockedKeys(request.mailboxUid);
|
|
56
|
-
request.resolve(unlocked);
|
|
57
|
-
|
|
103
|
+
request.waiters.forEach((waiter) => waiter.resolve(unlocked));
|
|
104
|
+
if (pendingRef.current === request) {
|
|
105
|
+
setPending(null);
|
|
106
|
+
}
|
|
107
|
+
if (result.unopenableKeys.length > 0) {
|
|
108
|
+
setUnopenableKeys(result.unopenableKeys);
|
|
109
|
+
}
|
|
110
|
+
setCode("");
|
|
111
|
+
setFollowUp(recovery);
|
|
58
112
|
}
|
|
59
|
-
catch {
|
|
60
|
-
|
|
61
|
-
setError("Incorrect password.");
|
|
113
|
+
catch (err) {
|
|
114
|
+
setError(unlockErrorMessage(err, mode));
|
|
62
115
|
}
|
|
63
116
|
finally {
|
|
64
117
|
setUnlocking(false);
|
|
65
118
|
}
|
|
66
119
|
}
|
|
67
|
-
|
|
120
|
+
function handleModeChange(next) {
|
|
121
|
+
setMode(next);
|
|
122
|
+
setError(null);
|
|
123
|
+
}
|
|
124
|
+
return (_jsxs(UnlockPromptContext.Provider, { value: { requestUnlock }, children: [children, unopenableKeys && _jsx(UnopenableKeysNotice, { fingerprints: unopenableKeys, onDismiss: () => setUnopenableKeys(null) }), followUp && (_jsx(RecoveryFollowUpModal, { followUp: followUp, open: !pending, onDone: () => setFollowUp(null) }, `${followUp.mailboxUid}:${followUp.recoveryMethodId}`)), _jsxs(Modal, { open: !!pending, onClose: handleCancel, title: "Unlock your mailbox", children: [_jsx("p", { className: "text-sm text-text-muted mb-5", children: mode === "password"
|
|
125
|
+
? "Enter your encryption password to continue."
|
|
126
|
+
: "Enter one of your recovery codes to continue. Each code works once." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, children: [mode === "password" ? (_jsx(FormField, { label: "Encryption password", htmlFor: "unlock-prompt-password", children: _jsx("input", { id: "unlock-prompt-password", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: password, onChange: (e) => setPassword(e.target.value), disabled: unlocking, autoComplete: "current-password", autoFocus: true }) })) : (_jsx(FormField, { label: "Recovery code", htmlFor: "unlock-prompt-recovery-code", children: _jsx("input", { id: "unlock-prompt-recovery-code", type: "text", className: "w-full text-sm font-mono border border-border rounded-sm py-1.5 px-2 bg-surface", value: code, onChange: (e) => setCode(e.target.value), disabled: unlocking, autoComplete: "off", autoCapitalize: "characters", spellCheck: false, autoFocus: true }) })), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "submit", loading: unlocking, disabled: unlocking, children: "Unlock" }), _jsx(Button, { type: "button", variant: "secondary", onClick: handleCancel, disabled: unlocking, children: "Cancel" })] })] }), _jsx("div", { className: "mt-3", children: _jsx(UnlockModeToggle, { mode: mode, disabled: unlocking, onChange: handleModeChange }) })] })] }));
|
|
68
127
|
}
|
|
69
128
|
/** Throws outside an `<UnlockPromptProvider>` - every app shell mounts one (see `AppShell.tsx`), so
|
|
70
129
|
* reaching this from any real page is a wiring bug, not a runtime condition to degrade gracefully from. */
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { useEffect, useState } from "react";
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
8
|
import { getMessage, listAttachments, setMessageRead } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
9
|
import MessageDetailPane from "./MessageDetailPane.js";
|
|
@@ -22,16 +22,31 @@ export default function ConversationThreadPane({ conversation, folders, labels }
|
|
|
22
22
|
const [expandedUids, setExpandedUids] = useState(new Set());
|
|
23
23
|
const [loading, setLoading] = useState(false);
|
|
24
24
|
const [error, setError] = useState(null);
|
|
25
|
+
// Bumped on every conversation switch - any in-flight load/attachments/mark-read response carrying an
|
|
26
|
+
// older generation belongs to a superseded conversation and is dropped rather than applied.
|
|
27
|
+
const generationRef = useRef(0);
|
|
28
|
+
// Message uids a mark-read / attachments request has already been issued for in the current
|
|
29
|
+
// generation, so re-running the effect below (every `messages`/`expandedUids` change) while one is
|
|
30
|
+
// still in flight never fires a duplicate - which for mark-read would also carry a stale `version`.
|
|
31
|
+
const markReadRequestedRef = useRef(new Set());
|
|
32
|
+
const attachmentsRequestedRef = useRef(new Set());
|
|
25
33
|
useEffect(() => {
|
|
34
|
+
const generation = ++generationRef.current;
|
|
35
|
+
markReadRequestedRef.current = new Set();
|
|
36
|
+
attachmentsRequestedRef.current = new Set();
|
|
37
|
+
setMessages({});
|
|
38
|
+
setAttachmentsByUid({});
|
|
39
|
+
setExpandedUids(new Set());
|
|
26
40
|
if (!conversation) {
|
|
27
|
-
|
|
28
|
-
setExpandedUids(new Set());
|
|
41
|
+
setLoading(false);
|
|
29
42
|
return;
|
|
30
43
|
}
|
|
31
44
|
setLoading(true);
|
|
32
45
|
setError(null);
|
|
33
46
|
Promise.all(conversation.messageUids.map((uid) => getMessage(uid)))
|
|
34
47
|
.then((loaded) => {
|
|
48
|
+
if (generation !== generationRef.current)
|
|
49
|
+
return;
|
|
35
50
|
setMessages(Object.fromEntries(loaded.map((m) => [m.uid, m])));
|
|
36
51
|
// `messageUids` always has at least one entry — a `ConversationSummary` only ever
|
|
37
52
|
// exists because it was grouped from real messages (see
|
|
@@ -39,32 +54,55 @@ export default function ConversationThreadPane({ conversation, folders, labels }
|
|
|
39
54
|
const latestUid = conversation.messageUids[conversation.messageUids.length - 1];
|
|
40
55
|
setExpandedUids(new Set([latestUid]));
|
|
41
56
|
})
|
|
42
|
-
.catch((err) =>
|
|
43
|
-
|
|
57
|
+
.catch((err) => {
|
|
58
|
+
if (generation !== generationRef.current)
|
|
59
|
+
return;
|
|
60
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not load this conversation.");
|
|
61
|
+
})
|
|
62
|
+
.finally(() => {
|
|
63
|
+
if (generation === generationRef.current)
|
|
64
|
+
setLoading(false);
|
|
65
|
+
});
|
|
44
66
|
}, [conversation?.conversationId]);
|
|
45
67
|
// Lazily loads attachments and marks-as-read only for messages the reader has actually expanded —
|
|
46
68
|
// mirrors `mailDetailHooks.ts`'s `useMessageAttachments`/`useMarkMessageRead`, reimplemented here
|
|
47
69
|
// (rather than called in a loop, which the rules of hooks don't allow) since a thread can expand
|
|
48
70
|
// more than one message at once, unlike the single-selected-message case those hooks were built for.
|
|
49
71
|
useEffect(() => {
|
|
72
|
+
const generation = generationRef.current;
|
|
50
73
|
for (const uid of expandedUids) {
|
|
51
74
|
const message = messages[uid];
|
|
52
75
|
if (!message)
|
|
53
76
|
continue;
|
|
54
|
-
if (message.hasAttachments && !
|
|
77
|
+
if (message.hasAttachments && !attachmentsRequestedRef.current.has(uid)) {
|
|
78
|
+
attachmentsRequestedRef.current.add(uid);
|
|
55
79
|
listAttachments(message.folderUid, uid)
|
|
56
|
-
.then((loaded) =>
|
|
80
|
+
.then((loaded) => {
|
|
81
|
+
if (generation === generationRef.current) {
|
|
82
|
+
setAttachmentsByUid((prev) => ({ ...prev, [uid]: loaded }));
|
|
83
|
+
}
|
|
84
|
+
})
|
|
57
85
|
.catch(() => {
|
|
58
86
|
// Best-effort, same as the mark-as-read catch below — `attachmentsByUid[uid] ??
|
|
59
|
-
// []` already renders no attachments while this stays unset
|
|
60
|
-
//
|
|
87
|
+
// []` already renders no attachments while this stays unset; forgetting the
|
|
88
|
+
// request just lets a later re-expand retry it.
|
|
89
|
+
attachmentsRequestedRef.current.delete(uid);
|
|
61
90
|
});
|
|
62
91
|
}
|
|
63
|
-
|
|
92
|
+
// `message` is this render's latest copy, so the request carries its current `version`.
|
|
93
|
+
if (!message.flags.read && !markReadRequestedRef.current.has(uid)) {
|
|
94
|
+
markReadRequestedRef.current.add(uid);
|
|
64
95
|
setMessageRead(message, true)
|
|
65
|
-
.then((updated) =>
|
|
96
|
+
.then((updated) => {
|
|
97
|
+
if (generation !== generationRef.current)
|
|
98
|
+
return;
|
|
99
|
+
// Never clobber a newer copy another action (label/classify/...) already patched in.
|
|
100
|
+
setMessages((prev) => (prev[uid].version > updated.version ? prev : { ...prev, [uid]: updated }));
|
|
101
|
+
})
|
|
66
102
|
.catch(() => {
|
|
67
|
-
// Best-effort — matches `useMarkMessageRead`'s own doc comment.
|
|
103
|
+
// Best-effort — matches `useMarkMessageRead`'s own doc comment. Forgetting the
|
|
104
|
+
// request lets a later effect run (e.g. a re-expand) retry with a fresh copy.
|
|
105
|
+
markReadRequestedRef.current.delete(uid);
|
|
68
106
|
});
|
|
69
107
|
}
|
|
70
108
|
}
|