@rapidmx/web-client 0.15.1 → 0.16.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 +373 -373
- package/apps/admin/branding/index.tsx +39 -39
- package/apps/admin/data-requests/index.tsx +482 -480
- package/apps/admin/domains/[uid].tsx +164 -164
- package/apps/admin/escrow-scopes/[uid].tsx +348 -348
- package/apps/admin/index.tsx +127 -124
- package/apps/admin/mailboxes/[uid].tsx +269 -224
- package/apps/admin/mailboxes/new/index.tsx +28 -28
- package/apps/admin/plugins/index.tsx +15 -15
- package/apps/admin/retention-policy/index.tsx +39 -39
- package/apps/admin/signing-certificates/index.tsx +343 -343
- package/apps/escrow/audit-log/index.tsx +196 -196
- package/apps/escrow/matters/[uid].tsx +621 -621
- package/apps/shared/auth/adminAccess.ts +99 -99
- package/apps/shared/components/admin/layout/AdminShell.tsx +378 -378
- package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
- package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
- package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +50 -3
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1545
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
- package/apps/shared/components/admin/setup/SetupWizard.tsx +444 -444
- package/apps/shared/components/admin/usePagedList.tsx +129 -129
- package/apps/shared/components/calendar/EventEditor.tsx +5 -10
- package/apps/shared/components/calendar/EventFormParts.tsx +16 -47
- package/apps/shared/components/calendar/EventModal.tsx +206 -206
- package/apps/shared/components/calendar/GuestInput.tsx +100 -0
- package/apps/shared/components/calendar/MonthView.tsx +185 -185
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
- package/apps/shared/components/calendar/RequestChangeForm.tsx +23 -54
- package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
- package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
- package/apps/shared/components/calendar/allDay.ts +124 -124
- package/apps/shared/components/calendar/eventForm.ts +2 -2
- package/apps/shared/components/calendar/eventFormat.ts +49 -22
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
- package/apps/shared/components/contacts/ContactForm.tsx +383 -383
- package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
- package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +217 -216
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
- package/apps/shared/components/layout/AppShell.tsx +480 -480
- package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
- package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
- package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
- package/apps/shared/components/layout/UserMenu.tsx +439 -424
- package/apps/shared/components/mail/ConversationList.tsx +323 -323
- package/apps/shared/components/mail/ConversationThreadPane.tsx +612 -491
- package/apps/shared/components/mail/MailSelectionBar.tsx +236 -227
- package/apps/shared/components/mail/MessageDetailPane.tsx +1559 -1559
- package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1677
- package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
- package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
- package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +137 -113
- package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
- package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -125
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
- package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
- package/apps/shared/components/settings/layout/SettingsShell.tsx +255 -254
- package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
- package/apps/shared/components/tasks/layout/TasksShell.tsx +218 -217
- package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
- package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
- package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
- package/apps/shared/keyboard/dispatch.ts +124 -124
- package/apps/shared/keyboard/format.ts +89 -89
- package/apps/shared/keyboard/keymap.ts +114 -114
- package/apps/shared/keyboard/registry.ts +65 -65
- package/apps/shared/keyboard/targets.ts +79 -79
- package/apps/shared/mail/folderCounts.ts +17 -0
- package/apps/shared/mail/folderTree.ts +143 -143
- package/apps/shared/mail/listAllPages.ts +39 -39
- package/apps/shared/mail/newMailNotifications.ts +171 -171
- package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
- package/apps/shared/mail/outbox/sendJob.ts +19 -1
- package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
- package/apps/shared/mail/primaryMailbox.ts +71 -0
- package/apps/shared/mail/useCollapsedSections.ts +146 -0
- package/apps/shared/mail/useMailConnection.ts +205 -203
- package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
- package/apps/shared/mail/useMarkMessageRead.ts +47 -47
- package/apps/shared/mail/useNewMailNotifications.ts +178 -178
- package/apps/shared/navigation/AppRouter.tsx +300 -300
- package/apps/shared/navigation/routerContext.tsx +83 -83
- package/apps/shared/notifications/store.ts +560 -560
- package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
- package/apps/shared/search/crossMailboxSearch.ts +169 -0
- package/apps/shared/search/localIndexBuilder.ts +481 -481
- package/apps/shared/signing/enrollmentStorage.ts +33 -33
- package/apps/shared/signing/enrollmentTracker.ts +385 -385
- package/apps/shared/signing/enrollmentView.ts +251 -251
- package/apps/shared/signing/useNow.ts +19 -19
- package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
- package/apps/shared/styles/app.css +396 -386
- package/apps/www/calendar/index.tsx +578 -571
- package/apps/www/contacts/[uid].tsx +109 -109
- package/apps/www/contacts/index.tsx +6 -0
- package/apps/www/index.tsx +2847 -2479
- package/apps/www/messages/[uid].tsx +135 -135
- package/apps/www/settings/auto-reply/index.tsx +133 -133
- package/apps/www/settings/encryption/index.tsx +1287 -1287
- package/apps/www/settings/filters/[uid].tsx +176 -176
- package/apps/www/settings/filters/index.tsx +102 -102
- package/apps/www/settings/filters/new/index.tsx +140 -140
- package/apps/www/settings/labels/index.tsx +204 -204
- package/apps/www/settings/privacy/index.tsx +492 -492
- package/apps/www/settings/profile/index.tsx +248 -248
- package/apps/www/settings/read-receipts/index.tsx +147 -147
- package/apps/www/settings/sharing/index.tsx +256 -256
- package/apps/www/settings/signatures/[uid].tsx +172 -172
- package/apps/www/settings/signatures/index.tsx +88 -88
- package/apps/www/settings/signatures/new/index.tsx +135 -135
- package/apps/www/tasks/index.tsx +649 -649
- package/dist/apps/admin/data-requests/index.js +1 -1
- package/dist/apps/admin/index.js +2 -1
- package/dist/apps/admin/mailboxes/[uid].js +12 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
- package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
- package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +25 -3
- package/dist/apps/shared/components/calendar/EventEditor.js +5 -10
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -2
- package/dist/apps/shared/components/calendar/EventFormParts.js +5 -16
- package/dist/apps/shared/components/calendar/GuestInput.d.ts +45 -0
- package/dist/apps/shared/components/calendar/GuestInput.js +30 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +11 -22
- package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -2
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +21 -8
- package/dist/apps/shared/components/calendar/eventFormat.js +50 -23
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -2
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -3
- package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
- package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +4 -1
- package/dist/apps/shared/components/layout/UserMenu.js +6 -5
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +3 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -4
- package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
- package/dist/apps/shared/components/mail/layout/FolderBadgeChip.d.ts +6 -0
- package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +64 -45
- package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
- package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +6 -5
- package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +4 -3
- package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
- package/dist/apps/shared/mail/folderCounts.js +12 -0
- package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
- package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
- package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
- package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
- package/dist/apps/shared/mail/primaryMailbox.js +61 -0
- package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
- package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
- package/dist/apps/shared/mail/useMailConnection.js +3 -1
- package/dist/apps/shared/search/crossMailboxSearch.d.ts +72 -0
- package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
- package/dist/apps/shared/styles/app.css +396 -386
- package/dist/apps/www/calendar/index.js +3 -2
- package/dist/apps/www/contacts/index.js +3 -1
- package/dist/apps/www/index.js +426 -189
- package/package.json +2 -2
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useSyncExternalStore } from "react";
|
|
6
|
+
/** The most entries kept; the oldest go first. Every entry is a message this tab sent, so this is generous. */
|
|
7
|
+
const MAX_TRACKED = 25;
|
|
8
|
+
/** A sent message that a read of its conversation has not found this many times is not part of that conversation (the server filed it elsewhere). */
|
|
9
|
+
export const MAX_SETTLE_MISSES = 2;
|
|
10
|
+
const NO_REPLIES = [];
|
|
11
|
+
let replies = [];
|
|
12
|
+
let snapshot = replies;
|
|
13
|
+
const listeners = new Set();
|
|
14
|
+
function commit(next) {
|
|
15
|
+
replies = next;
|
|
16
|
+
snapshot = next;
|
|
17
|
+
for (const listener of [...listeners]) {
|
|
18
|
+
listener();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function update(uid, patch) {
|
|
22
|
+
if (replies.some((reply) => reply.uid === uid)) {
|
|
23
|
+
commit(replies.map((reply) => (reply.uid === uid ? { ...reply, ...patch } : reply)));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Starts showing `request`'s message in the conversation it replies to - or does nothing for a message that replies to nothing (a new message
|
|
28
|
+
* starts a conversation of its own, it is never part of an open one) and for a send-later message (it is not sent yet). Called again for the same
|
|
29
|
+
* message - a Retry - it starts over as `sending`.
|
|
30
|
+
*
|
|
31
|
+
* The thread is `request.threading` (the compose session's), else what the draft itself records: a draft opened again from "Open draft" is a
|
|
32
|
+
* session with no threading of its own, but the server kept it on the message.
|
|
33
|
+
*/
|
|
34
|
+
export function trackOutgoing(request) {
|
|
35
|
+
const source = request.threading ?? request.draft;
|
|
36
|
+
const references = source.references ?? [];
|
|
37
|
+
if (request.scheduledSendTime || (!source.inReplyTo && references.length === 0)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const { mailbox } = request;
|
|
41
|
+
const reply = {
|
|
42
|
+
uid: request.draft.uid,
|
|
43
|
+
mailboxUid: request.mailboxUid,
|
|
44
|
+
inReplyTo: source.inReplyTo,
|
|
45
|
+
references,
|
|
46
|
+
// The mailbox is only unknown when the window's own load of it had not succeeded: "Me" is what a sender is called then.
|
|
47
|
+
sender: mailbox ? { address: mailbox.primarySmtpAddress, displayName: mailbox.displayName } : { address: "Me" },
|
|
48
|
+
to: request.to,
|
|
49
|
+
cc: request.cc,
|
|
50
|
+
bcc: request.bcc,
|
|
51
|
+
subject: request.subject,
|
|
52
|
+
html: request.html,
|
|
53
|
+
startedAt: Date.now(),
|
|
54
|
+
state: "sending",
|
|
55
|
+
actions: [],
|
|
56
|
+
misses: 0,
|
|
57
|
+
};
|
|
58
|
+
commit([...replies.filter((existing) => existing.uid !== reply.uid), reply].slice(-MAX_TRACKED));
|
|
59
|
+
}
|
|
60
|
+
/** The send did not go: `failure` is why, `actions` what can be done (Retry, Open draft ...). */
|
|
61
|
+
export function failOutgoing(uid, failure, actions) {
|
|
62
|
+
update(uid, { state: "failed", failure, actions });
|
|
63
|
+
}
|
|
64
|
+
/** A failed send is being tried again. */
|
|
65
|
+
export function markOutgoingSending(uid) {
|
|
66
|
+
update(uid, { state: "sending", failure: undefined, actions: [] });
|
|
67
|
+
}
|
|
68
|
+
/** The server has relayed the message: its Sent Items copy is what the conversation shows next. */
|
|
69
|
+
export function markOutgoingSent(uid) {
|
|
70
|
+
update(uid, { state: "sent" });
|
|
71
|
+
}
|
|
72
|
+
/** Stops showing a message - it went back to Drafts to be edited. */
|
|
73
|
+
export function forgetOutgoing(uid) {
|
|
74
|
+
if (replies.some((reply) => reply.uid === uid)) {
|
|
75
|
+
commit(replies.filter((reply) => reply.uid !== uid));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Whether `reply` continues the conversation `messages` are: it answers one of them, or its `References` chain runs through one. */
|
|
79
|
+
export function belongsToThread(reply, messages) {
|
|
80
|
+
return messages.some((message) => !!message.messageId && (message.messageId === reply.inReplyTo || reply.references.includes(message.messageId)));
|
|
81
|
+
}
|
|
82
|
+
/** The uids of the messages sent from here whose real copy is in `messages`, the thread as it was just read. */
|
|
83
|
+
export function adoptedOutgoing(mailboxUid, messages) {
|
|
84
|
+
return replies.filter((reply) => reply.mailboxUid === mailboxUid && messages.some((message) => message.uid === reply.uid)).map((reply) => reply.uid);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Called with a mailbox's conversation as it was just read, `messages` being the whole thread: forgets the messages whose real copy is now in it
|
|
88
|
+
* (`adoptedOutgoing()`), and gives up on a *sent* message that belongs to this thread but that the read did not find `MAX_SETTLE_MISSES` times in a
|
|
89
|
+
* row - the server filed it in a conversation of its own, and it is not to be shown here. A message of another conversation is left alone.
|
|
90
|
+
*
|
|
91
|
+
* A pane draws the messages it has read *before* it calls this, so that there is never a moment with neither the pending card nor the real one.
|
|
92
|
+
*/
|
|
93
|
+
export function settleOutgoing(mailboxUid, messages) {
|
|
94
|
+
const adopted = new Set(adoptedOutgoing(mailboxUid, messages));
|
|
95
|
+
const next = [];
|
|
96
|
+
let changed = adopted.size > 0;
|
|
97
|
+
for (const reply of replies) {
|
|
98
|
+
if (adopted.has(reply.uid)) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (reply.mailboxUid === mailboxUid && reply.state === "sent" && belongsToThread(reply, messages)) {
|
|
102
|
+
changed = true;
|
|
103
|
+
if (reply.misses + 1 < MAX_SETTLE_MISSES) {
|
|
104
|
+
next.push({ ...reply, misses: reply.misses + 1 });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
next.push(reply);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (changed) {
|
|
112
|
+
commit(next);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export function getOutgoingReplies() {
|
|
116
|
+
return snapshot;
|
|
117
|
+
}
|
|
118
|
+
function subscribe(listener) {
|
|
119
|
+
listeners.add(listener);
|
|
120
|
+
return () => {
|
|
121
|
+
listeners.delete(listener);
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** The messages this tab has sent that a conversation may still need to show, oldest first. */
|
|
125
|
+
export function useOutgoingReplies() {
|
|
126
|
+
return useSyncExternalStore(subscribe, getOutgoingReplies, () => NO_REPLIES);
|
|
127
|
+
}
|
|
128
|
+
/** Forgets everything - for a test. */
|
|
129
|
+
export function resetOutgoingReplies() {
|
|
130
|
+
commit([]);
|
|
131
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Attachment, ComposeRecipientInput, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
1
|
+
import { Attachment, ComposeRecipientInput, DraftThreading, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
2
2
|
import { RecipientEncryptionStatus } from "@rapidmx/react-shared/crypto/composeSecurity.js";
|
|
3
3
|
import { EncryptionPolicy } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
4
4
|
import { SendDecisionInput } from "./sendDecision.js";
|
|
@@ -34,6 +34,10 @@ export interface SendRequest {
|
|
|
34
34
|
forcePlaintext: boolean;
|
|
35
35
|
/** Settles when the window's own autosaves have landed, so none can overwrite the assembly below. */
|
|
36
36
|
saved?: Promise<unknown>;
|
|
37
|
+
/** The thread this message continues (a reply or forward), as the compose session was opened with it - what lets the conversation on screen
|
|
38
|
+
* show the message as soon as it is sent (see `outgoingReplies.ts`). Absent for a new message, and for a draft opened again, which the draft's own
|
|
39
|
+
* `inReplyTo`/`references` stand in for. */
|
|
40
|
+
threading?: DraftThreading;
|
|
37
41
|
}
|
|
38
42
|
/** How long a background send waits for the mailbox / encryption policy it did not have, before it goes ahead treating them as unknown. Nobody waits for it. */
|
|
39
43
|
export declare const CONTEXT_GRACE_MS = 4000;
|
|
@@ -13,6 +13,7 @@ import { policyCanAutoEncrypt } from "../../components/mail/compose/encryptionRe
|
|
|
13
13
|
import { dismiss, notify } from "../../notifications/store.js";
|
|
14
14
|
import { INLINE_IMAGE_PATTERN, decideSend } from "./sendDecision.js";
|
|
15
15
|
import { beginPendingSend, finishPendingSend, setPendingStage } from "./pendingSends.js";
|
|
16
|
+
import { failOutgoing, forgetOutgoing, markOutgoingSending, markOutgoingSent, trackOutgoing } from "./outgoingReplies.js";
|
|
16
17
|
import { openComposeFromOutside, requestUnlockFromOutside } from "./composeBridge.js";
|
|
17
18
|
import { sendState } from "./sendState.js";
|
|
18
19
|
/** How long a background send waits for the mailbox / encryption policy it did not have, before it goes ahead treating them as unknown. Nobody waits for it. */
|
|
@@ -209,7 +210,7 @@ export function describeMessage(subject, recipients) {
|
|
|
209
210
|
}
|
|
210
211
|
/** Re-opens the compose window around a request's message, with everything as it was typed. */
|
|
211
212
|
export function openDraftFromRequest(request) {
|
|
212
|
-
|
|
213
|
+
const opened = openComposeFromOutside({
|
|
213
214
|
mailboxUid: request.mailboxUid,
|
|
214
215
|
resume: {
|
|
215
216
|
draft: request.draft,
|
|
@@ -225,6 +226,11 @@ export function openDraftFromRequest(request) {
|
|
|
225
226
|
encryptRequested: request.encryptRequested,
|
|
226
227
|
},
|
|
227
228
|
});
|
|
229
|
+
// Being edited again, it is no longer a message on its way in the conversation.
|
|
230
|
+
if (opened) {
|
|
231
|
+
forgetOutgoing(request.draft.uid);
|
|
232
|
+
}
|
|
233
|
+
return opened;
|
|
228
234
|
}
|
|
229
235
|
function failureNotificationId(draftUid) {
|
|
230
236
|
return `send-failed:${draftUid}`;
|
|
@@ -257,6 +263,8 @@ function notifyFailure(request, failure) {
|
|
|
257
263
|
details: failure.details,
|
|
258
264
|
actions,
|
|
259
265
|
});
|
|
266
|
+
// The conversation on screen shows the same failure under the message, with the same ways out.
|
|
267
|
+
failOutgoing(uid, failure.message, actions);
|
|
260
268
|
}
|
|
261
269
|
const SENT_BURST_MS = 6000;
|
|
262
270
|
/** The short "Message sent" confirmation - several sent close together are one pop-up counting up, never a stack of them. */
|
|
@@ -280,6 +288,8 @@ export function startSend(request) {
|
|
|
280
288
|
}
|
|
281
289
|
// The failure of an earlier attempt is resolved by trying again.
|
|
282
290
|
dismiss(failureNotificationId(uid));
|
|
291
|
+
// A reply or forward is on screen in its conversation from this moment, before any of the work below.
|
|
292
|
+
trackOutgoing(request);
|
|
283
293
|
const tracker = sendState.countTracker?.(request.mailboxUid);
|
|
284
294
|
void attempt(request).then((outcome) => {
|
|
285
295
|
finishPendingSend(uid);
|
|
@@ -294,6 +304,8 @@ export function startSend(request) {
|
|
|
294
304
|
sendState.queuedListener?.(request.mailboxUid);
|
|
295
305
|
}
|
|
296
306
|
else if (outcome.kind === "sent") {
|
|
307
|
+
// A server with no queue relayed it before answering: the Sent Items copy exists already.
|
|
308
|
+
markOutgoingSent(uid);
|
|
297
309
|
notifySent();
|
|
298
310
|
}
|
|
299
311
|
else {
|
|
@@ -312,6 +324,7 @@ export function startSend(request) {
|
|
|
312
324
|
export async function retrySend(request) {
|
|
313
325
|
if (await alreadyAccepted(request)) {
|
|
314
326
|
dismiss(failureNotificationId(request.draft.uid));
|
|
327
|
+
markOutgoingSending(request.draft.uid);
|
|
315
328
|
notify({ id: `already-sent:${request.draft.uid}`, kind: "info", title: "This message is already on its way", timeoutMs: 5000 });
|
|
316
329
|
return;
|
|
317
330
|
}
|
|
@@ -9,6 +9,7 @@ import { loadOriginalMessage } from "../../components/mail/compose/quotedBody.js
|
|
|
9
9
|
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
10
10
|
import { dismiss, notify } from "../../notifications/store.js";
|
|
11
11
|
import { beginPendingSend, finishPendingSend, setPendingStage } from "./pendingSends.js";
|
|
12
|
+
import { failOutgoing, forgetOutgoing, markOutgoingSending, markOutgoingSent } from "./outgoingReplies.js";
|
|
12
13
|
import { openComposeFromOutside } from "./composeBridge.js";
|
|
13
14
|
import { describeMessage, forgetRetainedRequest, notifySent, openDraftFromRequest, retainedRequest } from "./sendJob.js";
|
|
14
15
|
/**
|
|
@@ -39,6 +40,8 @@ function escapeHtml(text) {
|
|
|
39
40
|
async function openFailedDraft(event) {
|
|
40
41
|
try {
|
|
41
42
|
const moved = await moveBackToDrafts(event.uid, event.mailboxUid ?? "");
|
|
43
|
+
// Back in Drafts it is no longer a message on its way in its conversation.
|
|
44
|
+
forgetOutgoing(event.uid);
|
|
42
45
|
const request = retainedRequest(event.uid);
|
|
43
46
|
if (request) {
|
|
44
47
|
openDraftFromRequest({ ...request, draft: moved });
|
|
@@ -84,6 +87,7 @@ async function retryFailed(event) {
|
|
|
84
87
|
return;
|
|
85
88
|
}
|
|
86
89
|
dismiss(failedId(event.uid));
|
|
90
|
+
markOutgoingSending(event.uid);
|
|
87
91
|
try {
|
|
88
92
|
setPendingStage(event.uid, "queuing");
|
|
89
93
|
const result = await queueMessageSend(event.uid);
|
|
@@ -103,6 +107,8 @@ export function handleSendEvent(event) {
|
|
|
103
107
|
if (event.action === "send-succeeded") {
|
|
104
108
|
dismiss(retryId(event.uid));
|
|
105
109
|
dismiss(failedId(event.uid));
|
|
110
|
+
// The conversation on screen swaps the message it drew for the Sent Items copy (a message this tab did not send has nothing drawn).
|
|
111
|
+
markOutgoingSent(event.uid);
|
|
106
112
|
// The server reports every message it relays, scheduled ones from long ago included: only a message this tab queued in the background is
|
|
107
113
|
// worth a confirmation (several are one pop-up counting up). Anything else - a send-later message going out, another tab's send - is quiet.
|
|
108
114
|
if (retainedRequest(event.uid)) {
|
|
@@ -127,15 +133,18 @@ export function handleSendEvent(event) {
|
|
|
127
133
|
return;
|
|
128
134
|
}
|
|
129
135
|
dismiss(retryId(event.uid));
|
|
136
|
+
const actions = [
|
|
137
|
+
{ label: "Retry", onClick: () => void retryFailed(event) },
|
|
138
|
+
{ label: "Open draft", onClick: () => void openFailedDraft(event) },
|
|
139
|
+
];
|
|
130
140
|
notify({
|
|
131
141
|
id: failedId(event.uid),
|
|
132
142
|
kind: "error",
|
|
133
143
|
title: "This message wasn't sent",
|
|
134
144
|
message: `${what}: ${failure.message}`,
|
|
135
145
|
details: failure.lines,
|
|
136
|
-
actions
|
|
137
|
-
{ label: "Retry", onClick: () => void retryFailed(event) },
|
|
138
|
-
{ label: "Open draft", onClick: () => void openFailedDraft(event) },
|
|
139
|
-
],
|
|
146
|
+
actions,
|
|
140
147
|
});
|
|
148
|
+
// The conversation on screen shows the same failure under the message, with the same ways out.
|
|
149
|
+
failOutgoing(event.uid, failure.message, actions);
|
|
141
150
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of the caller's mailboxes is "theirs", and the order every list of them is shown in - the one definition the mail, calendar,
|
|
3
|
+
* contacts, tasks and settings shells, the compose window's From picker and the account menu all use, so a shared mailbox that happens
|
|
4
|
+
* to sort first (or that the server lists first) is never the one an app opens on or a picker defaults to.
|
|
5
|
+
*
|
|
6
|
+
* An **own** mailbox is one whose `ownerUserUid` is the signed-in user.
|
|
7
|
+
*
|
|
8
|
+
* A **shared** mailbox is any other one the caller merely has access to: an ownerless (shared/organization) mailbox, or a delegate grant on
|
|
9
|
+
* somebody else's (`isSharedWithMe()`).
|
|
10
|
+
*
|
|
11
|
+
* The **primary** mailbox is the caller's own mailbox - the earliest created, when they own several - else the first mailbox that isn't
|
|
12
|
+
* shared, else the first one there is.
|
|
13
|
+
*
|
|
14
|
+
* `orderMailboxes()` puts the primary mailbox first, then the caller's other mailboxes, then the shared ones, each group alphabetical.
|
|
15
|
+
*/
|
|
16
|
+
import { type Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
17
|
+
/** Whether `mailbox` is the signed-in user's own. */
|
|
18
|
+
export declare function isOwnMailbox(mailbox: Mailbox, userUid: string | undefined): boolean;
|
|
19
|
+
/** Whether `mailbox` is one the caller only has access to (an ownerless one, or somebody else's shared with them). */
|
|
20
|
+
export declare function isSharedMailbox(mailbox: Mailbox, userUid: string | undefined): boolean;
|
|
21
|
+
/** The caller's own mailboxes, earliest created first (the given order among ones created together). */
|
|
22
|
+
export declare function ownMailboxes(mailboxes: readonly Mailbox[], userUid: string | undefined): Mailbox[];
|
|
23
|
+
/** The mailbox to open and to default pickers to: see the rules at the top of this file. `undefined` only for no mailboxes at all. */
|
|
24
|
+
export declare function primaryMailbox(mailboxes: readonly Mailbox[], userUid: string | undefined): Mailbox | undefined;
|
|
25
|
+
/** `primaryMailbox()`'s uid. */
|
|
26
|
+
export declare function primaryMailboxUid(mailboxes: readonly Mailbox[], userUid: string | undefined): string | undefined;
|
|
27
|
+
/** `mailboxes` in display order: the primary mailbox, then the caller's other mailboxes, then the shared ones - each group by name. Never
|
|
28
|
+
* changes the list it is given. */
|
|
29
|
+
export declare function orderMailboxes(mailboxes: readonly Mailbox[], userUid: string | undefined): Mailbox[];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Which of the caller's mailboxes is "theirs", and the order every list of them is shown in - the one definition the mail, calendar,
|
|
7
|
+
* contacts, tasks and settings shells, the compose window's From picker and the account menu all use, so a shared mailbox that happens
|
|
8
|
+
* to sort first (or that the server lists first) is never the one an app opens on or a picker defaults to.
|
|
9
|
+
*
|
|
10
|
+
* An **own** mailbox is one whose `ownerUserUid` is the signed-in user.
|
|
11
|
+
*
|
|
12
|
+
* A **shared** mailbox is any other one the caller merely has access to: an ownerless (shared/organization) mailbox, or a delegate grant on
|
|
13
|
+
* somebody else's (`isSharedWithMe()`).
|
|
14
|
+
*
|
|
15
|
+
* The **primary** mailbox is the caller's own mailbox - the earliest created, when they own several - else the first mailbox that isn't
|
|
16
|
+
* shared, else the first one there is.
|
|
17
|
+
*
|
|
18
|
+
* `orderMailboxes()` puts the primary mailbox first, then the caller's other mailboxes, then the shared ones, each group alphabetical.
|
|
19
|
+
*/
|
|
20
|
+
import { isSharedWithMe } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
21
|
+
/** Whether `mailbox` is the signed-in user's own. */
|
|
22
|
+
export function isOwnMailbox(mailbox, userUid) {
|
|
23
|
+
return !!userUid && mailbox.ownerUserUid === userUid;
|
|
24
|
+
}
|
|
25
|
+
/** Whether `mailbox` is one the caller only has access to (an ownerless one, or somebody else's shared with them). */
|
|
26
|
+
export function isSharedMailbox(mailbox, userUid) {
|
|
27
|
+
if (isOwnMailbox(mailbox, userUid)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
// The server says how the caller reaches a mailbox (`accessRole`); an older one doesn't, and then a mailbox that isn't the known
|
|
31
|
+
// caller's own is somebody else's.
|
|
32
|
+
return mailbox.accessRole ? isSharedWithMe(mailbox) : !!userUid || isSharedWithMe(mailbox);
|
|
33
|
+
}
|
|
34
|
+
function createdAt(mailbox) {
|
|
35
|
+
const time = Date.parse(mailbox.dateCreated);
|
|
36
|
+
return Number.isNaN(time) ? Infinity : time;
|
|
37
|
+
}
|
|
38
|
+
/** The caller's own mailboxes, earliest created first (the given order among ones created together). */
|
|
39
|
+
export function ownMailboxes(mailboxes, userUid) {
|
|
40
|
+
return mailboxes.filter((mailbox) => isOwnMailbox(mailbox, userUid)).sort((a, b) => createdAt(a) - createdAt(b));
|
|
41
|
+
}
|
|
42
|
+
/** The mailbox to open and to default pickers to: see the rules at the top of this file. `undefined` only for no mailboxes at all. */
|
|
43
|
+
export function primaryMailbox(mailboxes, userUid) {
|
|
44
|
+
return ownMailboxes(mailboxes, userUid)[0] ?? mailboxes.find((mailbox) => !isSharedMailbox(mailbox, userUid)) ?? mailboxes[0];
|
|
45
|
+
}
|
|
46
|
+
/** `primaryMailbox()`'s uid. */
|
|
47
|
+
export function primaryMailboxUid(mailboxes, userUid) {
|
|
48
|
+
return primaryMailbox(mailboxes, userUid)?.uid;
|
|
49
|
+
}
|
|
50
|
+
/** `mailboxes` in display order: the primary mailbox, then the caller's other mailboxes, then the shared ones - each group by name. Never
|
|
51
|
+
* changes the list it is given. */
|
|
52
|
+
export function orderMailboxes(mailboxes, userUid) {
|
|
53
|
+
const primary = primaryMailbox(mailboxes, userUid);
|
|
54
|
+
const rank = (mailbox) => (mailbox === primary ? 0 : isSharedMailbox(mailbox, userUid) ? 2 : 1);
|
|
55
|
+
// Tolerates a mailbox that says nothing of its name or address (an older server's, or a test fixture's).
|
|
56
|
+
const name = (mailbox) => mailbox.displayName ?? "";
|
|
57
|
+
const address = (mailbox) => mailbox.primarySmtpAddress ?? "";
|
|
58
|
+
return [...mailboxes].sort((a, b) => rank(a) - rank(b) ||
|
|
59
|
+
name(a).localeCompare(name(b), undefined, { sensitivity: "base" }) ||
|
|
60
|
+
address(a).localeCompare(address(b)));
|
|
61
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** The id of the "All mailboxes" section; every mailbox's section is its uid. */
|
|
2
|
+
export declare const ALL_MAILBOXES_SECTION = "all";
|
|
3
|
+
/** What the reader chose per section: `true` collapsed, `false` open. A section that is not there follows the default rule. */
|
|
4
|
+
export type SectionChoices = Record<string, boolean>;
|
|
5
|
+
/** The `localStorage` key one user's choices are kept under. */
|
|
6
|
+
export declare function collapsedSectionsKey(userUid: string): string;
|
|
7
|
+
/** Reads a user's stored choices - none for an unknown user, a missing entry, unparsable JSON, something that is not an object, or a store that throws. */
|
|
8
|
+
export declare function readSectionChoices(userUid: string | undefined): SectionChoices;
|
|
9
|
+
/** Stores a user's choices. Best-effort - see this module's own doc comment. */
|
|
10
|
+
export declare function writeSectionChoices(userUid: string | undefined, choices: SectionChoices): void;
|
|
11
|
+
/** Whether a section is open by the reader's choice, or by the default rule where they have made none (a navigation's override not considered). */
|
|
12
|
+
export declare function sectionChosenOpen(id: string, choices: SectionChoices, primaryUid: string | undefined): boolean;
|
|
13
|
+
export interface SidebarSectionsOptions {
|
|
14
|
+
/** The signed-in user, whose choices these are; `undefined` keeps them for this page view only. */
|
|
15
|
+
userUid: string | undefined;
|
|
16
|
+
/** The user's primary mailbox, which - with "All mailboxes" - is open until they choose otherwise. */
|
|
17
|
+
primaryUid: string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* The section holding the folder that is open: `ALL_MAILBOXES_SECTION` for an aggregate view, else that mailbox's uid; `undefined`
|
|
20
|
+
* while that is not known yet (the address not read, the mailboxes not loaded). Moving from one section to another opens the new one
|
|
21
|
+
* (see this module's doc comment); the first section that becomes known does not.
|
|
22
|
+
*/
|
|
23
|
+
activeId: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
export interface SidebarSections {
|
|
26
|
+
/** Whether the section shows its folders. */
|
|
27
|
+
isExpanded: (id: string) => boolean;
|
|
28
|
+
/** Opens a collapsed section, collapses an open one, and remembers it as the reader's choice. */
|
|
29
|
+
toggle: (id: string) => void;
|
|
30
|
+
}
|
|
31
|
+
/** See this module's doc comment. */
|
|
32
|
+
export declare function useSidebarSections({ userUid, primaryUid, activeId }: SidebarSectionsOptions): SidebarSections;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Which of the Mail sidebar's sections - "All mailboxes" and one per mailbox - are open, and the reader's own choices about it.
|
|
7
|
+
*
|
|
8
|
+
* A section the reader has never touched follows the **default rule**: "All mailboxes" and the signed-in user's primary mailbox
|
|
9
|
+
* (`primaryMailboxUid()`) are open, every other mailbox is collapsed - so a mailbox that is newly shared with them arrives closed.
|
|
10
|
+
* Once they toggle a section the choice is remembered for that section, replacing the default, whatever the default later says.
|
|
11
|
+
*
|
|
12
|
+
* **Every** section can be collapsed, the one holding the open folder and the primary mailbox included, and a collapsed one stays collapsed
|
|
13
|
+
* across reloads even while its own folder is the selected one: the toggle always toggles and always remembers.
|
|
14
|
+
*
|
|
15
|
+
* The one exception is *navigation*: when the section holding the open folder (`activeId`) **changes** - the reader picks a folder in
|
|
16
|
+
* another section, or a search result or a link lands in one - that section is shown open, so the folder just highlighted is not hidden
|
|
17
|
+
* from them. That is an in-memory override of this page view, never written as the reader's choice: it does not apply to the first
|
|
18
|
+
* section the page resolves (a reload lands on whatever was chosen), and it ends when they navigate to yet another section. Toggling
|
|
19
|
+
* the section it opened forgets the override and remembers the reader's new choice (the opposite of what was showing).
|
|
20
|
+
*
|
|
21
|
+
* The choices are kept per signed-in user, per device, in `localStorage` (`rapidmx:mail-sidebar-collapsed:<userUid>`), the way
|
|
22
|
+
* `listPreferences.ts` keeps the list arrangement: `{ "<section id>": <collapsed> }`. Every read tolerates a missing, corrupt or
|
|
23
|
+
* blocked store (and keeps only the booleans of a partly-valid one), every write swallows its failure - a blocked store only means the
|
|
24
|
+
* choices do not survive a reload.
|
|
25
|
+
*/
|
|
26
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
27
|
+
const STORAGE_KEY_PREFIX = "rapidmx:mail-sidebar-collapsed:";
|
|
28
|
+
/** The id of the "All mailboxes" section; every mailbox's section is its uid. */
|
|
29
|
+
export const ALL_MAILBOXES_SECTION = "all";
|
|
30
|
+
/** The `localStorage` key one user's choices are kept under. */
|
|
31
|
+
export function collapsedSectionsKey(userUid) {
|
|
32
|
+
return `${STORAGE_KEY_PREFIX}${userUid}`;
|
|
33
|
+
}
|
|
34
|
+
/** Reads a user's stored choices - none for an unknown user, a missing entry, unparsable JSON, something that is not an object, or a store that throws. */
|
|
35
|
+
export function readSectionChoices(userUid) {
|
|
36
|
+
if (!userUid) {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
let stored;
|
|
40
|
+
try {
|
|
41
|
+
const raw = localStorage.getItem(collapsedSectionsKey(userUid));
|
|
42
|
+
stored = raw === null ? null : JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
if (!stored || typeof stored !== "object" || Array.isArray(stored)) {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
// Only what is a real choice: a value that is not a boolean falls back to the default for its own section, not for all of them.
|
|
51
|
+
return Object.fromEntries(Object.entries(stored).filter((entry) => typeof entry[1] === "boolean"));
|
|
52
|
+
}
|
|
53
|
+
/** Stores a user's choices. Best-effort - see this module's own doc comment. */
|
|
54
|
+
export function writeSectionChoices(userUid, choices) {
|
|
55
|
+
if (!userUid) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
localStorage.setItem(collapsedSectionsKey(userUid), JSON.stringify(choices));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Best-effort - see this module's own doc comment.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Whether a section is open by the reader's choice, or by the default rule where they have made none (a navigation's override not considered). */
|
|
66
|
+
export function sectionChosenOpen(id, choices, primaryUid) {
|
|
67
|
+
const collapsed = choices[id];
|
|
68
|
+
return collapsed === undefined ? id === ALL_MAILBOXES_SECTION || id === primaryUid : !collapsed;
|
|
69
|
+
}
|
|
70
|
+
/** See this module's doc comment. */
|
|
71
|
+
export function useSidebarSections({ userUid, primaryUid, activeId }) {
|
|
72
|
+
const [loaded, setLoaded] = useState(() => ({ userUid, choices: readSectionChoices(userUid) }));
|
|
73
|
+
let current = loaded;
|
|
74
|
+
if (loaded.userUid !== userUid) {
|
|
75
|
+
// Another user signed in on this page: theirs are not the last one's choices.
|
|
76
|
+
current = { userUid, choices: readSectionChoices(userUid) };
|
|
77
|
+
setLoaded(current);
|
|
78
|
+
}
|
|
79
|
+
// The section the last navigation opened, if any: derived while rendering from the change of `activeId` (no effect, so the section is
|
|
80
|
+
// already open in the render that highlights its folder). A first `activeId` (from undefined) is not a navigation.
|
|
81
|
+
const [nav, setNav] = useState({ activeId, opened: undefined });
|
|
82
|
+
let currentNav = nav;
|
|
83
|
+
if (nav.activeId !== activeId) {
|
|
84
|
+
currentNav = { activeId, opened: nav.activeId === undefined ? undefined : activeId };
|
|
85
|
+
setNav(currentNav);
|
|
86
|
+
}
|
|
87
|
+
// Kept in step with what was last rendered, and moved on by a toggle at once, so two toggles before a re-render both count.
|
|
88
|
+
const choicesRef = useRef(current.choices);
|
|
89
|
+
choicesRef.current = current.choices;
|
|
90
|
+
const openedRef = useRef(currentNav.opened);
|
|
91
|
+
openedRef.current = currentNav.opened;
|
|
92
|
+
const toggle = useCallback((id) => {
|
|
93
|
+
const expanded = openedRef.current === id || sectionChosenOpen(id, choicesRef.current, primaryUid);
|
|
94
|
+
const next = { ...choicesRef.current, [id]: expanded };
|
|
95
|
+
choicesRef.current = next;
|
|
96
|
+
setLoaded({ userUid, choices: next });
|
|
97
|
+
writeSectionChoices(userUid, next);
|
|
98
|
+
if (openedRef.current === id) {
|
|
99
|
+
// The reader's own choice replaces the override that had opened it.
|
|
100
|
+
openedRef.current = undefined;
|
|
101
|
+
setNav({ activeId, opened: undefined });
|
|
102
|
+
}
|
|
103
|
+
}, [activeId, primaryUid, userUid]);
|
|
104
|
+
const choices = current.choices;
|
|
105
|
+
const opened = currentNav.opened;
|
|
106
|
+
return useMemo(() => ({
|
|
107
|
+
isExpanded: (id) => id === opened || sectionChosenOpen(id, choices, primaryUid),
|
|
108
|
+
toggle,
|
|
109
|
+
}), [opened, choices, primaryUid, toggle]);
|
|
110
|
+
}
|
|
@@ -6,6 +6,7 @@ import { createContext, useCallback, useEffect, useRef, useState } from "react";
|
|
|
6
6
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
7
|
import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
8
|
import { FOLDER_ORDER, MAIL_FOLDER_TYPES, isMailFolder, knownFolderUids, reconcileFolders, removeFolder, upsertFolder } from "./folderTree.js";
|
|
9
|
+
import { orderMailboxes } from "./primaryMailbox.js";
|
|
9
10
|
import { useMailLiveUpdates } from "./useMailLiveUpdates.js";
|
|
10
11
|
import { useNewMailNotifications } from "./useNewMailNotifications.js";
|
|
11
12
|
import { usePushConnectionNotice } from "../notifications/pushStatus.js";
|
|
@@ -38,7 +39,8 @@ export function useMailConnection({ userUid, enabled, open }) {
|
|
|
38
39
|
}
|
|
39
40
|
listMailboxes({ limit: MAILBOX_LIST_LIMIT })
|
|
40
41
|
.then((result) => {
|
|
41
|
-
|
|
42
|
+
// The caller's own mailbox first, then the others: every sidebar, picker and default below follows this order.
|
|
43
|
+
setMailboxes(orderMailboxes(result, userUid));
|
|
42
44
|
setStatus("ready");
|
|
43
45
|
})
|
|
44
46
|
.catch((err) => {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { SearchResult } from "@rapidmx/react-shared/search/searchApi.js";
|
|
2
|
+
/**
|
|
3
|
+
* How far a cross-mailbox search fans out. Exported (and mutable) so a test can shrink them instead of building 26 mailboxes or waiting a
|
|
4
|
+
* minute for a timeout; nothing else writes to it.
|
|
5
|
+
*/
|
|
6
|
+
export declare const SEARCH_LIMITS: {
|
|
7
|
+
/** How many mailboxes are worked on at once. */
|
|
8
|
+
concurrency: number;
|
|
9
|
+
/** The most mailboxes one search covers - the caller's own first, then the shared ones (`orderMailboxes()`); a note says how many were left out. */
|
|
10
|
+
maxMailboxes: number;
|
|
11
|
+
/** How long one mailbox's Tier 1 (server) request may take before that mailbox is given up on, so one wedged mailbox cannot hold the search. */
|
|
12
|
+
tier1TimeoutMs: number;
|
|
13
|
+
/** The same for a mailbox's Tier 3 pass (fetching and decrypting up to a couple of hundred candidates, so much longer). */
|
|
14
|
+
tier3TimeoutMs: number;
|
|
15
|
+
/** The most result rows a search keeps on screen (the page's own cap for a list, `MAX_LOADED_ROWS`, applies to a search too). */
|
|
16
|
+
maxRows: number;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* How many results each mailbox contributes to one page. One mailbox gives the whole page; with several the page is shared out between them
|
|
20
|
+
* (never below `MIN_MAILBOX_PAGE_SIZE`), so the merged first page stays about one page long instead of growing by the number of mailboxes.
|
|
21
|
+
*/
|
|
22
|
+
export declare function searchPageSize(mailboxCount: number, pageSize: number): number;
|
|
23
|
+
/** A search hit that remembers which mailbox it was found in. */
|
|
24
|
+
export interface MailboxHit extends SearchResult {
|
|
25
|
+
mailboxUid: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function tagHits(mailboxUid: string, hits: SearchResult[]): MailboxHit[];
|
|
28
|
+
/** One hit's identity across mailboxes: the same entity uid found in two mailboxes is two hits. */
|
|
29
|
+
export declare function hitKey(hit: {
|
|
30
|
+
mailboxUid: string;
|
|
31
|
+
entityUid: string;
|
|
32
|
+
}): string;
|
|
33
|
+
/**
|
|
34
|
+
* Merges Tier 1 (server, possibly `metadataOnly` for an encrypted message), Tier 2 (local index, fully decrypted and re-scored) and Tier 3
|
|
35
|
+
* (server-narrowed candidates, decrypted and re-scored) results into one ranked list, per `specs/search.md` §7's "client MUST re-score all
|
|
36
|
+
* results it can see... normalise into the same space rather than interleaving raw scores": each tier is normalized independently via
|
|
37
|
+
* `normalizeServerScores()` before merging, since a Postgres/OpenSearch score, a local `bm25()` score, and Tier 3's own term-count score all
|
|
38
|
+
* occupy unrelated ranges. A hit present in more than one list keeps only the last-inserted entry (Tier 2 wins over Tier 3 wins over Tier 1) -
|
|
39
|
+
* Tier 2 and Tier 3 both represent genuine, content-verified scores for the same message, so which one "wins" on overlap doesn't change
|
|
40
|
+
* correctness, only which of two equally-valid scores is shown; either supersedes Tier 1's metadata-only guess for the same message.
|
|
41
|
+
*
|
|
42
|
+
* Each tier's list is pooled across every mailbox searched before it is normalized, so one mailbox's best hit is not automatically "1.0"
|
|
43
|
+
* next to another's: the ranking is the same rule as for one mailbox, applied to the merged lists. A hit's identity is its mailbox and its
|
|
44
|
+
* entity uid (`hitKey()`), so the same uid can only collapse within a mailbox. Ties keep the order the lists were given in (a stable sort), which
|
|
45
|
+
* the caller makes the mailboxes' display order.
|
|
46
|
+
*
|
|
47
|
+
* Called progressively - each time with whatever tiers have reported so far (an empty array for the rest). Pure and stateless.
|
|
48
|
+
*/
|
|
49
|
+
export declare function mergeSearchResults(tier1: MailboxHit[], tier2: MailboxHit[], tier3: MailboxHit[]): MailboxHit[];
|
|
50
|
+
/** Raised by `withTimeout()`. */
|
|
51
|
+
export declare class SearchTimeoutError extends Error {
|
|
52
|
+
constructor();
|
|
53
|
+
}
|
|
54
|
+
/** `promise`, or a rejection with `SearchTimeoutError` if it has not settled after `ms` (never, for a non-finite `ms`). The work is not aborted - a late answer is just dropped. */
|
|
55
|
+
export declare function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T>;
|
|
56
|
+
/**
|
|
57
|
+
* Runs `task` for every item, at most `concurrency` at a time, starting them in order. Stops starting new ones once `isCancelled()` (the search
|
|
58
|
+
* was superseded) or once one has rejected - which is then this function's own rejection, after the ones already running have settled.
|
|
59
|
+
*/
|
|
60
|
+
export declare function runLimited<T>(items: readonly T[], concurrency: number, task: (item: T) => Promise<void>, isCancelled: () => boolean): Promise<void>;
|
|
61
|
+
/** Why one mailbox's search failed, as a short phrase for the notice: the class of failure, never the server's own text. */
|
|
62
|
+
export declare function classifyFailure(err: unknown): string;
|
|
63
|
+
/** A mailbox that could not be (fully) searched. `encrypted` is set when only its Tier 3 pass (the encrypted mail) failed - its other results are in. */
|
|
64
|
+
export interface MailboxFailure {
|
|
65
|
+
mailboxUid: string;
|
|
66
|
+
/** The mailbox's display name, as it was when the search ran. */
|
|
67
|
+
mailboxName: string;
|
|
68
|
+
encrypted: boolean;
|
|
69
|
+
reason: string;
|
|
70
|
+
}
|
|
71
|
+
/** The body of the "Some results may be missing" notice: which mailboxes, and why each. */
|
|
72
|
+
export declare function describeFailures(failures: readonly MailboxFailure[]): string;
|