@rapidmx/web-client 0.5.0 → 0.7.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 +88 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +25 -8
- package/apps/shared/components/admin/settings/BrandingForm.tsx +398 -385
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -112
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +159 -152
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1288 -1235
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +9 -1
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -290
- package/apps/shared/components/admin/setup/SetupWizard.tsx +443 -416
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +194 -192
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +199 -197
- package/apps/shared/components/layout/AppShell.tsx +288 -270
- package/apps/shared/components/mail/ConversationList.tsx +225 -33
- package/apps/shared/components/mail/ConversationThreadPane.tsx +434 -216
- package/apps/shared/components/mail/MailListToolbar.tsx +265 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +222 -0
- package/apps/shared/components/mail/MenuButton.tsx +384 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +280 -215
- package/apps/shared/components/mail/MoveToFolderDialog.tsx +263 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +29 -2
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1700 -1652
- package/apps/shared/components/mail/compose/RecipientInput.tsx +356 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +38 -3
- package/apps/shared/components/mail/compose/quotedBody.ts +100 -0
- package/apps/shared/components/mail/compose/recipients.ts +104 -0
- package/apps/shared/components/mail/labelMenu.tsx +278 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +31 -4
- package/apps/shared/components/mail/listPreferences.ts +228 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +23 -10
- package/apps/shared/components/tasks/layout/TasksShell.tsx +201 -199
- package/apps/shared/plugins/pluginNav.ts +73 -0
- package/apps/shared/search/localIndexRpcClient.ts +3 -1
- package/apps/shared/styles/app.css +28 -5
- package/apps/www/index.tsx +1854 -1314
- package/apps/www/messages/[uid].tsx +5 -4
- package/dist/apps/admin/_404.d.ts +2 -0
- package/dist/apps/admin/_500.d.ts +2 -0
- package/dist/apps/admin/_layout.d.ts +8 -0
- package/dist/apps/admin/audit-log/index.d.ts +11 -0
- package/dist/apps/admin/branding/index.d.ts +3 -0
- package/dist/apps/admin/data-requests/index.d.ts +3 -0
- package/dist/apps/admin/distribution-lists/[uid].d.ts +7 -0
- package/dist/apps/admin/distribution-lists/index.d.ts +3 -0
- package/dist/apps/admin/distribution-lists/new/index.d.ts +3 -0
- package/dist/apps/admin/domains/[uid].d.ts +7 -0
- package/dist/apps/admin/domains/index.d.ts +3 -0
- package/dist/apps/admin/domains/new/index.d.ts +3 -0
- package/dist/apps/admin/encryption-policy/index.d.ts +3 -0
- package/dist/apps/admin/escrow-scopes/[uid].d.ts +21 -0
- package/dist/apps/admin/escrow-scopes/index.d.ts +3 -0
- package/dist/apps/admin/escrow-scopes/new/index.d.ts +3 -0
- package/dist/apps/admin/index.d.ts +3 -0
- package/dist/apps/admin/ingest-queue/index.d.ts +4 -0
- package/dist/apps/admin/mailbox-policy/index.d.ts +3 -0
- package/dist/apps/admin/mailboxes/[uid].d.ts +7 -0
- package/dist/apps/admin/mailboxes/new/index.d.ts +3 -0
- package/dist/apps/admin/plugins/index.d.ts +3 -0
- package/dist/apps/admin/quarantine/index.d.ts +6 -0
- package/dist/apps/admin/retention-policy/index.d.ts +3 -0
- package/dist/apps/admin/setup/index.d.ts +3 -0
- package/dist/apps/admin/transport-rules/[uid].d.ts +7 -0
- package/dist/apps/admin/transport-rules/_transportRuleConfig.d.ts +21 -0
- package/dist/apps/admin/transport-rules/index.d.ts +3 -0
- package/dist/apps/admin/transport-rules/new/index.d.ts +3 -0
- package/dist/apps/escrow/_layout.d.ts +10 -0
- package/dist/apps/escrow/audit-log/index.d.ts +9 -0
- package/dist/apps/escrow/index.d.ts +3 -0
- package/dist/apps/escrow/matters/[uid].d.ts +7 -0
- package/dist/apps/escrow/matters/new/index.d.ts +3 -0
- package/dist/apps/shared/components/admin/distributionLists/MemberListCard.d.ts +17 -0
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.d.ts +32 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +32 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +12 -5
- package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.d.ts +12 -0
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.d.ts +6 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.d.ts +19 -0
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +12 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +13 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +6 -5
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.d.ts +12 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +15 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +2 -2
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.d.ts +9 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.d.ts +19 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +13 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +2 -2
- package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +11 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +33 -14
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +12 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +2 -2
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.d.ts +14 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +1 -1
- package/dist/apps/shared/components/admin/setup/SetupWizard.d.ts +36 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +14 -10
- package/dist/apps/shared/components/admin/signOut.d.ts +23 -0
- package/dist/apps/shared/components/admin/usePagedList.d.ts +39 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.d.ts +26 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +46 -0
- package/dist/apps/shared/components/calendar/MonthView.d.ts +15 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +20 -0
- package/dist/apps/shared/components/calendar/ResourcePicker.d.ts +19 -0
- package/dist/apps/shared/components/calendar/SplitDayView.d.ts +28 -0
- package/dist/apps/shared/components/calendar/TimeGridView.d.ts +17 -0
- package/dist/apps/shared/components/calendar/allDay.d.ts +51 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +40 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +2 -2
- package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +23 -0
- package/dist/apps/shared/components/contacts/ContactForm.d.ts +20 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +40 -0
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +25 -0
- package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +40 -0
- package/dist/apps/shared/components/contacts/contactKeys.d.ts +29 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +21 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +2 -2
- package/dist/apps/shared/components/escrow/layout/EscrowShell.d.ts +25 -0
- package/dist/apps/shared/components/forms/StringListField.d.ts +22 -0
- package/dist/apps/shared/components/layout/AppShell.d.ts +49 -0
- package/dist/apps/shared/components/layout/AppShell.js +15 -13
- package/dist/apps/shared/components/layout/BrandingChrome.d.ts +18 -0
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.d.ts +51 -0
- package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +13 -0
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +66 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +45 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +23 -0
- package/dist/apps/shared/components/mail/ConversationList.d.ts +47 -0
- package/dist/apps/shared/components/mail/ConversationList.js +84 -10
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +62 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +217 -60
- package/dist/apps/shared/components/mail/MailListToolbar.d.ts +55 -0
- package/dist/apps/shared/components/mail/MailListToolbar.js +149 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +59 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.js +48 -0
- package/dist/apps/shared/components/mail/MenuButton.d.ts +71 -0
- package/dist/apps/shared/components/mail/MenuButton.js +211 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +101 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +126 -129
- package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +57 -0
- package/dist/apps/shared/components/mail/MoveToFolderDialog.js +138 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +73 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +3 -1
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.d.ts +15 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +37 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +52 -20
- package/dist/apps/shared/components/mail/compose/EmojiPicker.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/GifPicker.d.ts +13 -0
- package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +36 -0
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +203 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +43 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +21 -3
- package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.d.ts +16 -0
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.d.ts +27 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +37 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +85 -0
- package/dist/apps/shared/components/mail/compose/recipients.d.ts +24 -0
- package/dist/apps/shared/components/mail/compose/recipients.js +87 -0
- package/dist/apps/shared/components/mail/labelMenu.d.ts +97 -0
- package/dist/apps/shared/components/mail/labelMenu.js +134 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +65 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +17 -5
- package/dist/apps/shared/components/mail/listPreferences.d.ts +101 -0
- package/dist/apps/shared/components/mail/listPreferences.js +186 -0
- package/dist/apps/shared/components/mail/pinnedSigners.d.ts +33 -0
- package/dist/apps/shared/components/mail/verificationSeals.d.ts +22 -0
- package/dist/apps/shared/components/mail/writableMailboxes.d.ts +15 -0
- package/dist/apps/shared/components/rules/RuleBuilder.d.ts +70 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +41 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +19 -10
- package/dist/apps/shared/components/tasks/TasksSidebar.d.ts +41 -0
- package/dist/apps/shared/components/tasks/TasksToolbar.d.ts +14 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +24 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +2 -2
- package/dist/apps/shared/mail/findWellKnownFolderUid.d.ts +5 -0
- package/dist/apps/shared/mail/listAllPages.d.ts +17 -0
- package/dist/apps/shared/plugins/pluginNav.d.ts +39 -0
- package/dist/apps/shared/plugins/pluginNav.js +33 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +13 -0
- package/dist/apps/shared/search/localIndexBlockCipher.d.ts +36 -0
- package/dist/apps/shared/search/localIndexBuilder.d.ts +85 -0
- package/dist/apps/shared/search/localIndexKey.d.ts +4 -0
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +71 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +3 -1
- package/dist/apps/shared/search/localIndexSchema.d.ts +111 -0
- package/dist/apps/shared/search/localIndexSizePreference.d.ts +27 -0
- package/dist/apps/shared/search/localIndexStorage.d.ts +28 -0
- package/dist/apps/shared/search/localIndexVFS.d.ts +101 -0
- package/dist/apps/shared/search/localIndexWorker.d.ts +145 -0
- package/dist/apps/shared/search/searchTier2.d.ts +30 -0
- package/dist/apps/shared/styles/app.css +28 -5
- package/dist/apps/www/_404.d.ts +2 -0
- package/dist/apps/www/_500.d.ts +2 -0
- package/dist/apps/www/_layout.d.ts +14 -0
- package/dist/apps/www/calendar/index.d.ts +3 -0
- package/dist/apps/www/contacts/[uid].d.ts +13 -0
- package/dist/apps/www/contacts/index.d.ts +3 -0
- package/dist/apps/www/index.d.ts +3 -0
- package/dist/apps/www/index.js +506 -112
- package/dist/apps/www/messages/[uid].d.ts +11 -0
- package/dist/apps/www/messages/[uid].js +2 -3
- package/dist/apps/www/settings/auto-reply/index.d.ts +4 -0
- package/dist/apps/www/settings/encryption/index.d.ts +7 -0
- package/dist/apps/www/settings/filters/[uid].d.ts +8 -0
- package/dist/apps/www/settings/filters/_mailFilterRuleConfig.d.ts +11 -0
- package/dist/apps/www/settings/filters/index.d.ts +4 -0
- package/dist/apps/www/settings/filters/new/index.d.ts +4 -0
- package/dist/apps/www/settings/labels/index.d.ts +4 -0
- package/dist/apps/www/settings/privacy/index.d.ts +4 -0
- package/dist/apps/www/settings/read-receipts/index.d.ts +4 -0
- package/dist/apps/www/settings/sharing/index.d.ts +4 -0
- package/dist/apps/www/settings/signatures/[uid].d.ts +8 -0
- package/dist/apps/www/settings/signatures/index.d.ts +4 -0
- package/dist/apps/www/settings/signatures/new/index.d.ts +4 -0
- package/dist/apps/www/settings/signatures/signatureDefaults.d.ts +10 -0
- package/dist/apps/www/tasks/index.d.ts +3 -0
- package/package.json +3 -3
- package/apps/shared/components/booking/AvailabilityEditor.tsx +0 -118
- package/apps/www/settings/booking-types/[uid].tsx +0 -306
- package/apps/www/settings/booking-types/index.tsx +0 -98
- package/apps/www/settings/booking-types/new/index.tsx +0 -212
- package/apps/www/settings/focused-inbox/index.tsx +0 -150
- package/dist/apps/shared/components/booking/AvailabilityEditor.js +0 -45
- package/dist/apps/www/settings/booking-types/[uid].js +0 -134
- package/dist/apps/www/settings/booking-types/index.js +0 -29
- package/dist/apps/www/settings/booking-types/new/index.js +0 -86
- package/dist/apps/www/settings/focused-inbox/index.js +0 -71
|
@@ -3,56 +3,145 @@ 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, useRef, useState } from "react";
|
|
6
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
8
|
+
import { listAttachments, setMessageRead } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
|
+
import { listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
10
10
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
11
|
+
import MessageDetailPane from "./MessageDetailPane.js";
|
|
12
|
+
/** One request's worth of the thread. The server's own default for `listConversationMessages()`. */
|
|
13
|
+
export const THREAD_PAGE_SIZE = 100;
|
|
14
|
+
/**
|
|
15
|
+
* How many of a conversation's messages this pane will load. The server reads at most
|
|
16
|
+
* `CONVERSATION_SCAN_LIMIT` (500) messages when it groups conversations, so a `ConversationSummary` never
|
|
17
|
+
* describes more than this many anyway; the cap is here so a thread that somehow reports more can't turn
|
|
18
|
+
* into an unbounded run of requests. Past it the pane says so and the rest stay readable from the list.
|
|
19
|
+
*/
|
|
20
|
+
export const THREAD_MESSAGE_LIMIT = 500;
|
|
11
21
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* a long thread up front would be wasteful.
|
|
22
|
+
* Every message from `selectedUid` through to the newest - the run the reader is reading. Anything older
|
|
23
|
+
* stays collapsed to its one-line summary. Selecting the newest message therefore expands just that one.
|
|
24
|
+
*
|
|
25
|
+
* The pane lists newest first (see `loadThread()`), so that run is the opened message together with
|
|
26
|
+
* everything *above* it, and the opened message is the set's **last** entry rather than its first.
|
|
18
27
|
*/
|
|
19
|
-
|
|
20
|
-
const
|
|
28
|
+
function expandedFrom(messages, selectedUid) {
|
|
29
|
+
const index = messages.findIndex((message) => message.uid === selectedUid);
|
|
30
|
+
// `messages` is never empty here (the callers below check), so -1 means "not in this thread" and the
|
|
31
|
+
// newest message - the first entry - is the anchor, exactly as a parent row's own click means.
|
|
32
|
+
const anchor = index === -1 ? 0 : index;
|
|
33
|
+
return new Set(messages.slice(0, anchor + 1).map((message) => message.uid));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The element a message of this thread actually scrolls inside. The pane is not itself the scroll
|
|
37
|
+
* container in the mail shell - `MailShell`'s own `<main>` is - so an adjustment has to be applied where
|
|
38
|
+
* the scrolling really happens, which is whichever ancestor is both scrollable and overflowing.
|
|
39
|
+
*/
|
|
40
|
+
function scrollingAncestor(node) {
|
|
41
|
+
for (let el = node.parentElement; el; el = el.parentElement) {
|
|
42
|
+
const overflowY = getComputedStyle(el).overflowY;
|
|
43
|
+
if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight) {
|
|
44
|
+
return el;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Nothing between the row and the root scrolls, so the page itself does - which in standards mode is
|
|
48
|
+
// `documentElement`, the same element `document.scrollingElement` names there.
|
|
49
|
+
return document.documentElement;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The thread's messages, **newest first**, in pages of `THREAD_PAGE_SIZE` up to `THREAD_MESSAGE_LIMIT`.
|
|
53
|
+
*
|
|
54
|
+
* `listConversationMessages()` pages oldest first and has no order of its own to ask for, so the pages are
|
|
55
|
+
* collected in that order - which is also the order the `THREAD_MESSAGE_LIMIT` cap has to apply in, since it
|
|
56
|
+
* is the oldest messages that are dropped when a thread is too long to load - and reversed once at the end.
|
|
57
|
+
* The pane reads newest first always, whatever the *list* is sorted by: it is the reading order for mail,
|
|
58
|
+
* and it means the message a conversation row stands for is the entry at the top.
|
|
59
|
+
*/
|
|
60
|
+
async function loadThread(mailboxUid, conversationId) {
|
|
61
|
+
const messages = [];
|
|
62
|
+
let more = true;
|
|
63
|
+
while (more && messages.length < THREAD_MESSAGE_LIMIT) {
|
|
64
|
+
const page = await listConversationMessages(mailboxUid, conversationId, {
|
|
65
|
+
page: messages.length / THREAD_PAGE_SIZE,
|
|
66
|
+
limit: THREAD_PAGE_SIZE,
|
|
67
|
+
});
|
|
68
|
+
messages.push(...page);
|
|
69
|
+
// A short page is the last one; a full page means asking for another.
|
|
70
|
+
more = page.length === THREAD_PAGE_SIZE;
|
|
71
|
+
}
|
|
72
|
+
messages.reverse();
|
|
73
|
+
return { messages, truncated: more };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The reading pane for the conversation list: the whole thread, **newest at the top**, opened at the message
|
|
77
|
+
* the reader picked. Every message from that one through to the newest is expanded - which in this order is
|
|
78
|
+
* the opened message and the entries above it - and the older ones, below it, are collapsed to a one-line
|
|
79
|
+
* summary (sender, date, preview) that expands on click or Enter. So opening the newest message shows just
|
|
80
|
+
* the top entry expanded, and opening 5 of 10 expands 10 down to 5.
|
|
81
|
+
*
|
|
82
|
+
* Newest first regardless of how the *list* is sorted: the list's own order arranges rows to pick from,
|
|
83
|
+
* while this is one conversation being read, and the entry a conversation row stands for - its latest
|
|
84
|
+
* message - is the one that should be at the top of the pane every time it is opened.
|
|
85
|
+
*
|
|
86
|
+
* Each *expanded* message is a `MessageDetailPane` of its own rather than a reimplementation of it, so the
|
|
87
|
+
* signature and verification badges, the verification-seal and decryption behaviour, the labels chips and
|
|
88
|
+
* menu, the attachments and Reply/Reply All/Forward/Archive all behave exactly as they do in the
|
|
89
|
+
* single-message pane, and each acts on the message it belongs to. A collapsed message mounts none of
|
|
90
|
+
* that - mounting a body iframe per message up front would be wasteful in a long thread.
|
|
91
|
+
*/
|
|
92
|
+
export default function ConversationThreadPane({ conversation, mailboxUid, selectedUid, folders, labels, onMessagePatched, onMessageRemoved, onLabelCreated, onFolderCreated, }) {
|
|
93
|
+
const [messages, setMessages] = useState([]);
|
|
21
94
|
const [attachmentsByUid, setAttachmentsByUid] = useState({});
|
|
22
95
|
const [expandedUids, setExpandedUids] = useState(new Set());
|
|
96
|
+
const [truncated, setTruncated] = useState(false);
|
|
23
97
|
const [loading, setLoading] = useState(false);
|
|
24
98
|
const [error, setError] = useState(null);
|
|
25
|
-
|
|
99
|
+
/** The message to scroll to and focus once it has rendered, or `null` once that has happened. */
|
|
100
|
+
const [pendingFocusUid, setPendingFocusUid] = useState(null);
|
|
101
|
+
// Bumped on every conversation switch - an in-flight load/attachments/mark-read response carrying an
|
|
26
102
|
// older generation belongs to a superseded conversation and is dropped rather than applied.
|
|
27
103
|
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
104
|
const markReadRequestedRef = useRef(new Set());
|
|
32
105
|
const attachmentsRequestedRef = useRef(new Set());
|
|
106
|
+
/** The `conversationId:selectedUid` the expansion run below has already been applied for, so patching
|
|
107
|
+
* a message (which changes `messages`) doesn't re-expand what the reader has since collapsed. */
|
|
108
|
+
const appliedSelectionRef = useRef(null);
|
|
109
|
+
/** Which conversation the messages currently in state belong to. A render with a new conversation and
|
|
110
|
+
* the previous one's messages still in state happens before the load effect has cleared them, and the
|
|
111
|
+
* expansion run below must sit that render out rather than anchor on a message from another thread. */
|
|
112
|
+
const loadedIdRef = useRef(null);
|
|
113
|
+
const rowRefs = useRef({});
|
|
114
|
+
const headerRefs = useRef({});
|
|
115
|
+
/** Where the toggled message's header sat in the viewport before it expanded, so the run below can put
|
|
116
|
+
* it back there - expanding a message above the one being read must not shove that one off-screen. */
|
|
117
|
+
const anchorRef = useRef(null);
|
|
118
|
+
const conversationId = conversation?.conversationId;
|
|
33
119
|
useEffect(() => {
|
|
34
120
|
const generation = ++generationRef.current;
|
|
35
121
|
markReadRequestedRef.current = new Set();
|
|
36
122
|
attachmentsRequestedRef.current = new Set();
|
|
37
|
-
|
|
123
|
+
appliedSelectionRef.current = null;
|
|
124
|
+
loadedIdRef.current = null;
|
|
125
|
+
setMessages([]);
|
|
38
126
|
setAttachmentsByUid({});
|
|
39
127
|
setExpandedUids(new Set());
|
|
40
|
-
|
|
128
|
+
setTruncated(false);
|
|
129
|
+
setError(null);
|
|
130
|
+
// The message the previous conversation was to be scrolled to went with it; leaving it set would
|
|
131
|
+
// point the run below at a row that is no longer rendered.
|
|
132
|
+
setPendingFocusUid(null);
|
|
133
|
+
if (!conversationId) {
|
|
41
134
|
setLoading(false);
|
|
42
135
|
return;
|
|
43
136
|
}
|
|
44
137
|
setLoading(true);
|
|
45
|
-
|
|
46
|
-
Promise.all(conversation.messageUids.map((uid) => getMessage(uid)))
|
|
138
|
+
loadThread(mailboxUid, conversationId)
|
|
47
139
|
.then((loaded) => {
|
|
48
140
|
if (generation !== generationRef.current)
|
|
49
141
|
return;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// `BaseMessageRoute.conversations()`).
|
|
54
|
-
const latestUid = conversation.messageUids[conversation.messageUids.length - 1];
|
|
55
|
-
setExpandedUids(new Set([latestUid]));
|
|
142
|
+
loadedIdRef.current = conversationId;
|
|
143
|
+
setMessages(loaded.messages);
|
|
144
|
+
setTruncated(loaded.truncated);
|
|
56
145
|
})
|
|
57
146
|
.catch((err) => {
|
|
58
147
|
if (generation !== generationRef.current)
|
|
@@ -63,16 +152,68 @@ export default function ConversationThreadPane({ conversation, folders, labels }
|
|
|
63
152
|
if (generation === generationRef.current)
|
|
64
153
|
setLoading(false);
|
|
65
154
|
});
|
|
66
|
-
}, [
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
155
|
+
}, [conversationId, mailboxUid]);
|
|
156
|
+
// Opening the thread, and opening a different message of the same thread, both set the run of expanded
|
|
157
|
+
// messages and ask for that message to be scrolled to. `messages` is a dependency because the thread's
|
|
158
|
+
// messages arrive after the click that selected one of them; the ref guard keeps a later change to
|
|
159
|
+
// `messages` (a patched copy) from re-running it.
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (messages.length === 0 || loadedIdRef.current !== conversationId)
|
|
162
|
+
return;
|
|
163
|
+
const key = `${conversationId}:${selectedUid}`;
|
|
164
|
+
if (appliedSelectionRef.current === key)
|
|
165
|
+
return;
|
|
166
|
+
appliedSelectionRef.current = key;
|
|
167
|
+
const expanded = expandedFrom(messages, selectedUid);
|
|
168
|
+
setExpandedUids(expanded);
|
|
169
|
+
// The oldest expanded message is the one that was opened - `expandedFrom()`'s own anchor - which in
|
|
170
|
+
// this newest-first order is the *last* of the run, not the first.
|
|
171
|
+
setPendingFocusUid([...expanded][expanded.size - 1]);
|
|
172
|
+
}, [conversationId, selectedUid, messages]);
|
|
173
|
+
useLayoutEffect(() => {
|
|
174
|
+
if (!pendingFocusUid)
|
|
175
|
+
return;
|
|
176
|
+
// The row is always rendered by now: this runs after the DOM update that added the message it
|
|
177
|
+
// names, and that message came out of `messages` in the first place.
|
|
178
|
+
//
|
|
179
|
+
// Scrolled inside the thread's own list and nowhere else. `scrollIntoView()` scrolls *every*
|
|
180
|
+
// scrollable ancestor, the window included, which with a run of full-height messages expanded
|
|
181
|
+
// took the app header and the folder rail off the screen - so the adjustment goes on whichever
|
|
182
|
+
// ancestor actually scrolls, exactly as the toggle below already does it. When that is the page
|
|
183
|
+
// itself, nothing inside the pane scrolls and the row is already in view, so it is left alone.
|
|
184
|
+
const row = rowRefs.current[pendingFocusUid];
|
|
185
|
+
const scroller = scrollingAncestor(row);
|
|
186
|
+
if (scroller !== document.documentElement) {
|
|
187
|
+
const rect = row.getBoundingClientRect();
|
|
188
|
+
const top = rect.top - scroller.getBoundingClientRect().top;
|
|
189
|
+
// "nearest": the least that brings it into view, and nothing at all when it is already there.
|
|
190
|
+
if (top < 0 || top + rect.height > scroller.clientHeight) {
|
|
191
|
+
scroller.scrollTop += top;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// `preventScroll` so focusing doesn't scroll it somewhere else again.
|
|
195
|
+
headerRefs.current[pendingFocusUid].focus({ preventScroll: true });
|
|
196
|
+
setPendingFocusUid(null);
|
|
197
|
+
}, [pendingFocusUid, expandedUids]);
|
|
198
|
+
// Keeps the message whose header was just clicked where it was on screen. Expanding one above the
|
|
199
|
+
// message being read otherwise pushes everything below it down by however tall the new body is.
|
|
200
|
+
useLayoutEffect(() => {
|
|
201
|
+
const anchor = anchorRef.current;
|
|
202
|
+
if (!anchor)
|
|
203
|
+
return;
|
|
204
|
+
anchorRef.current = null;
|
|
205
|
+
// The row is still there: the anchor was taken from a rendered row, and toggling never removes one.
|
|
206
|
+
const row = rowRefs.current[anchor.uid];
|
|
207
|
+
scrollingAncestor(row).scrollTop += row.getBoundingClientRect().top - anchor.top;
|
|
208
|
+
}, [expandedUids]);
|
|
209
|
+
// Attachments and mark-as-read, for expanded messages only - mirrors `mailDetailHooks.ts`'s
|
|
210
|
+
// `useMessageAttachments`/`useMarkMessageRead`, reimplemented here (rather than called in a loop, which
|
|
211
|
+
// the rules of hooks don't allow) because a thread expands several messages at once.
|
|
71
212
|
useEffect(() => {
|
|
72
213
|
const generation = generationRef.current;
|
|
73
|
-
for (const
|
|
74
|
-
const
|
|
75
|
-
if (!
|
|
214
|
+
for (const message of messages) {
|
|
215
|
+
const uid = message.uid;
|
|
216
|
+
if (!expandedUids.has(uid))
|
|
76
217
|
continue;
|
|
77
218
|
if (message.hasAttachments && !attachmentsRequestedRef.current.has(uid)) {
|
|
78
219
|
attachmentsRequestedRef.current.add(uid);
|
|
@@ -83,31 +224,43 @@ export default function ConversationThreadPane({ conversation, folders, labels }
|
|
|
83
224
|
}
|
|
84
225
|
})
|
|
85
226
|
.catch(() => {
|
|
86
|
-
// Best-effort,
|
|
87
|
-
//
|
|
88
|
-
// request just lets a later re-expand retry it.
|
|
227
|
+
// Best-effort, as in `useMessageAttachments`: no attachments render meanwhile, and
|
|
228
|
+
// forgetting the request lets a later re-expand retry it.
|
|
89
229
|
attachmentsRequestedRef.current.delete(uid);
|
|
90
230
|
});
|
|
91
231
|
}
|
|
92
|
-
// `message` is this render's latest copy, so the request carries its current `version`.
|
|
93
232
|
if (!message.flags.read && !markReadRequestedRef.current.has(uid)) {
|
|
94
233
|
markReadRequestedRef.current.add(uid);
|
|
234
|
+
// `message` is this render's copy, so the request carries its current `version`.
|
|
95
235
|
setMessageRead(message, true)
|
|
96
236
|
.then((updated) => {
|
|
97
237
|
if (generation !== generationRef.current)
|
|
98
238
|
return;
|
|
99
|
-
//
|
|
100
|
-
|
|
239
|
+
// `message` is the unread copy this pane just replaced - what tells the list's own
|
|
240
|
+
// conversation row that its unread count has gone down by one.
|
|
241
|
+
patchMessage(updated, message);
|
|
101
242
|
})
|
|
102
243
|
.catch(() => {
|
|
103
|
-
// Best-effort
|
|
104
|
-
// request lets a later effect run (e.g. a re-expand) retry with a fresh copy.
|
|
244
|
+
// Best-effort, as in `useMarkMessageRead`.
|
|
105
245
|
markReadRequestedRef.current.delete(uid);
|
|
106
246
|
});
|
|
107
247
|
}
|
|
108
248
|
}
|
|
109
249
|
}, [expandedUids, messages]);
|
|
250
|
+
/** A newer copy of one of the thread's messages, kept here and handed to the list - with the copy it
|
|
251
|
+
* replaces where the caller was given one, so a conversation row can tell what actually changed. */
|
|
252
|
+
function patchMessage(updated, previous) {
|
|
253
|
+
setMessages((prev) => prev.map((message) => (message.uid === updated.uid ? updated : message)));
|
|
254
|
+
onMessagePatched(updated, previous);
|
|
255
|
+
}
|
|
256
|
+
/** A message that left the folder being listed - it leaves the thread too, as it left the list. */
|
|
257
|
+
function removeMessage(updated) {
|
|
258
|
+
setMessages((prev) => prev.filter((message) => message.uid !== updated.uid));
|
|
259
|
+
onMessageRemoved(updated);
|
|
260
|
+
}
|
|
110
261
|
function toggleExpanded(uid) {
|
|
262
|
+
// The row this button lives in has rendered, so its ref is set.
|
|
263
|
+
anchorRef.current = { uid, top: rowRefs.current[uid].getBoundingClientRect().top };
|
|
111
264
|
setExpandedUids((prev) => {
|
|
112
265
|
const next = new Set(prev);
|
|
113
266
|
if (next.has(uid)) {
|
|
@@ -119,6 +272,9 @@ export default function ConversationThreadPane({ conversation, folders, labels }
|
|
|
119
272
|
return next;
|
|
120
273
|
});
|
|
121
274
|
}
|
|
275
|
+
function folderTypeOf(message) {
|
|
276
|
+
return folders.find((folder) => folder.uid === message.folderUid)?.type;
|
|
277
|
+
}
|
|
122
278
|
if (!conversation) {
|
|
123
279
|
return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Select a conversation to read it." });
|
|
124
280
|
}
|
|
@@ -128,23 +284,24 @@ export default function ConversationThreadPane({ conversation, folders, labels }
|
|
|
128
284
|
if (error) {
|
|
129
285
|
return (_jsx("div", { className: "p-4 flex-1", children: _jsx(Alert, { children: error }) }));
|
|
130
286
|
}
|
|
131
|
-
return (_jsxs("div", { className: "flex-1 min-w-0 flex flex-col
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
287
|
+
return (_jsxs("div", { className: "flex-1 min-w-0 min-h-0 flex flex-col", children: [_jsxs("div", { className: "shrink-0 border-b border-border p-4", children: [_jsx("h1", { className: "text-lg font-bold tracking-tight", children: conversation.subject || "(no subject)" }), _jsxs("p", { className: "text-sm text-text-muted mt-1", children: [messages.length, " message", messages.length === 1 ? "" : "s"] }), truncated && (_jsxs("p", { className: "text-xs text-text-muted mt-1", children: ["Only the oldest ", THREAD_MESSAGE_LIMIT, " messages of this conversation are shown here. The rest are still in the message list."] }))] }), _jsx("ul", { className: "flex-1 min-h-0 overflow-y-auto", children: messages.map((message) => {
|
|
288
|
+
const uid = message.uid;
|
|
289
|
+
const expanded = expandedUids.has(uid);
|
|
290
|
+
const bodyId = `thread-message-${uid}`;
|
|
291
|
+
const sender = message.from.displayName || message.from.address;
|
|
292
|
+
return (_jsxs("li", { ref: (node) => {
|
|
293
|
+
rowRefs.current[uid] = node;
|
|
294
|
+
},
|
|
295
|
+
// An expanded message is as tall as the list it scrolls in - `min-h-full`
|
|
296
|
+
// against the `<ul>`'s own resolved height - so its `MessageDetailPane` below
|
|
297
|
+
// has a full pane of height to fill and its body reaches the bottom of the
|
|
298
|
+
// window whatever that window's size is. `min-` rather than `h-`: a message
|
|
299
|
+
// whose header alone is taller than the pane still gets the room it needs.
|
|
300
|
+
className: ["border-b border-border", expanded ? "flex flex-col min-h-full" : ""].join(" "), children: [_jsx("h2", { className: "shrink-0", children: _jsxs("button", { type: "button", ref: (node) => {
|
|
301
|
+
headerRefs.current[uid] = node;
|
|
302
|
+
}, onClick: () => toggleExpanded(uid), "aria-expanded": expanded, "aria-controls": bodyId, className: [
|
|
303
|
+
"w-full text-left px-4 py-3 hover:bg-surface-alt",
|
|
304
|
+
message.flags.read ? "" : "font-semibold",
|
|
305
|
+
].join(" "), children: [_jsxs("span", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: sender }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleString() })] }), !expanded && (_jsx("span", { className: "block text-xs text-text-muted truncate font-normal", children: message.bodyPreview }))] }) }), _jsx("div", { id: bodyId, hidden: !expanded, className: expanded ? "flex-1 min-h-0 flex" : undefined, children: expanded && (_jsx(MessageDetailPane, { inThread: true, message: message, attachments: attachmentsByUid[uid] ?? [], isSentItems: folderTypeOf(message) === "sent_items", isOutbox: folderTypeOf(message) === "outbox", draftsFolderUid: folders.find((folder) => folder.type === "drafts")?.uid, folders: folders, onMoved: removeMessage, onFolderCreated: onFolderCreated, onRecalled: patchMessage, onReceiptHandled: patchMessage, onScheduledSendCanceled: removeMessage, onArchived: removeMessage, labels: labels, onLabelsChanged: patchMessage, onLabelCreated: onLabelCreated })) })] }, uid));
|
|
306
|
+
}) })] }));
|
|
150
307
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { MessageListFilter, MessageListSort, MessageSortOrder } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
3
|
+
import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
4
|
+
export interface MailListToolbarProps {
|
|
5
|
+
sortBy: MessageListSort;
|
|
6
|
+
sortOrder: MessageSortOrder;
|
|
7
|
+
filter: MessageListFilter;
|
|
8
|
+
/** The labels the list is currently narrowed to - any of them, not all. */
|
|
9
|
+
labelUids: string[];
|
|
10
|
+
/** Every label this mailbox has, for the Filter menu's own Labels submenu. */
|
|
11
|
+
labels: Label[];
|
|
12
|
+
onLabelUidsChange: (labelUids: string[]) => void;
|
|
13
|
+
/** The mailbox a label created from the Filter menu belongs to. */
|
|
14
|
+
mailboxUid: string;
|
|
15
|
+
/** Handed a label just created from the Filter menu, for the caller to add to `labels`. */
|
|
16
|
+
onLabelCreated: (label: Label) => void;
|
|
17
|
+
showAsConversations: boolean;
|
|
18
|
+
onSortChange: (sortBy: MessageListSort, sortOrder: MessageSortOrder) => void;
|
|
19
|
+
onFilterChange: (filter: MessageListFilter) => void;
|
|
20
|
+
onShowAsConversationsChange: (showAsConversations: boolean) => void;
|
|
21
|
+
selectMode: boolean;
|
|
22
|
+
onSelectModeChange: (selectMode: boolean) => void;
|
|
23
|
+
/** Offers the Focused/Other half of the filter vocabulary - an Inbox-only concept. */
|
|
24
|
+
offerClassificationFilters: boolean;
|
|
25
|
+
/** Search results are ranked by relevance across folders rather than listed from one, so the named
|
|
26
|
+
* filters don't apply to them. */
|
|
27
|
+
filterDisabled?: boolean;
|
|
28
|
+
filterDisabledReason?: string;
|
|
29
|
+
/** Greys out the sort keys and order while something else decides the order entirely: a search (ranked
|
|
30
|
+
* by relevance) or an aggregate view (one page merged from each mailbox). The Sort button itself stays
|
|
31
|
+
* open, since "Show as conversations" lives in it. */
|
|
32
|
+
sortKeysDisabled?: boolean;
|
|
33
|
+
/** Says why, under the sort keys. */
|
|
34
|
+
sortKeysNote?: string;
|
|
35
|
+
/** Individual sort keys the current arrangement can't order by, each with the reason shown beside it -
|
|
36
|
+
* the conversation list's own case, where the rows are thread summaries carrying no value for some of
|
|
37
|
+
* these (see `CONVERSATION_SORT_UNAVAILABLE`). The rest stay pickable. */
|
|
38
|
+
unavailableSortKeys?: Partial<Record<MessageListSort, string>>;
|
|
39
|
+
/** Select mode acts on the messages of one mailbox - so it is offered over both the message list and
|
|
40
|
+
* the conversation list (where a ticked row means every message of that conversation in this folder),
|
|
41
|
+
* but not over an aggregate view, whose rows come from several mailboxes whose folders a single Move
|
|
42
|
+
* couldn't name, nor over a list that is still loading or has no rows to tick. */
|
|
43
|
+
selectDisabled?: boolean;
|
|
44
|
+
selectDisabledReason?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The mail list's Outlook-style toolbar: a Filter menu, a Sort menu (which also carries "Show as
|
|
48
|
+
* conversations", Outlook's own home for it - it arranges the list rather than acting on a message, and
|
|
49
|
+
* keeping it there leaves the toolbar three controls wide, which is what fits a 400px phone) and a Select
|
|
50
|
+
* toggle that turns the list into a multi-select with bulk actions.
|
|
51
|
+
*
|
|
52
|
+
* Both menus drive server-side parameters (`listMessages()`'s `sortBy`/`sortOrder`/`filter`), so they sort
|
|
53
|
+
* and filter the whole folder rather than the page that happens to be loaded.
|
|
54
|
+
*/
|
|
55
|
+
export default function MailListToolbar({ sortBy, sortOrder, filter, labelUids, labels, onLabelUidsChange, mailboxUid, onLabelCreated, showAsConversations, onSortChange, onFilterChange, onShowAsConversationsChange, selectMode, onSelectModeChange, offerClassificationFilters, filterDisabled, filterDisabledReason, sortKeysDisabled, sortKeysNote, unavailableSortKeys, selectDisabled, selectDisabledReason, }: MailListToolbarProps): React.JSX.Element;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { HiOutlineAdjustmentsHorizontal, HiOutlineBarsArrowDown, HiOutlineStop } from "react-icons/hi2";
|
|
8
|
+
import MenuButton from "./MenuButton.js";
|
|
9
|
+
import { NewLabelDialog, labelSections, useLabelDraft } from "./labelMenu.js";
|
|
10
|
+
import { MAIL_LIST_CLASSIFICATION_FILTERS, MAIL_LIST_FILTERS, MAIL_LIST_SORTS, SORT_ORDER_LABELS, defaultSortOrder, } from "./listPreferences.js";
|
|
11
|
+
/** The label for the filter currently in force, or `undefined` for "All" (which isn't worth naming on the
|
|
12
|
+
* button). */
|
|
13
|
+
function filterLabel(filter) {
|
|
14
|
+
return [...MAIL_LIST_FILTERS, ...MAIL_LIST_CLASSIFICATION_FILTERS].find((entry) => entry.value === filter && entry.value !== "all")
|
|
15
|
+
?.label;
|
|
16
|
+
}
|
|
17
|
+
function sortLabel(sortBy) {
|
|
18
|
+
// Every `MessageListSort` has an entry, so this never falls through.
|
|
19
|
+
return MAIL_LIST_SORTS.find((entry) => entry.value === sortBy).label;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The mail list's Outlook-style toolbar: a Filter menu, a Sort menu (which also carries "Show as
|
|
23
|
+
* conversations", Outlook's own home for it - it arranges the list rather than acting on a message, and
|
|
24
|
+
* keeping it there leaves the toolbar three controls wide, which is what fits a 400px phone) and a Select
|
|
25
|
+
* toggle that turns the list into a multi-select with bulk actions.
|
|
26
|
+
*
|
|
27
|
+
* Both menus drive server-side parameters (`listMessages()`'s `sortBy`/`sortOrder`/`filter`), so they sort
|
|
28
|
+
* and filter the whole folder rather than the page that happens to be loaded.
|
|
29
|
+
*/
|
|
30
|
+
export default function MailListToolbar({ sortBy, sortOrder, filter, labelUids, labels, onLabelUidsChange, mailboxUid, onLabelCreated, showAsConversations, onSortChange, onFilterChange, onShowAsConversationsChange, selectMode, onSelectModeChange, offerClassificationFilters, filterDisabled, filterDisabledReason, sortKeysDisabled, sortKeysNote, unavailableSortKeys, selectDisabled, selectDisabledReason, }) {
|
|
31
|
+
const activeFilter = filterLabel(filter);
|
|
32
|
+
const orderLabels = SORT_ORDER_LABELS[sortBy];
|
|
33
|
+
// The label picks are drafted while the Filter menu is open and applied in one go, so narrowing to
|
|
34
|
+
// three labels is one refetch rather than three.
|
|
35
|
+
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
|
|
36
|
+
const [creatingLabel, setCreatingLabel] = useState(false);
|
|
37
|
+
const labelDraft = useLabelDraft(labelUids, [], filterMenuOpen);
|
|
38
|
+
const chosenLabelName = labels.find((l) => l.uid === labelUids[0])?.name;
|
|
39
|
+
// What the button says it is set to: the named filter, the labels, or both.
|
|
40
|
+
const activeParts = [
|
|
41
|
+
...(activeFilter ? [activeFilter] : []),
|
|
42
|
+
...(labelUids.length === 1 ? [chosenLabelName ?? "1 label"] : labelUids.length > 1 ? [`${labelUids.length} labels`] : []),
|
|
43
|
+
];
|
|
44
|
+
const filterButtonLabel = activeParts.length > 0 ? `Filter: ${activeParts.join(", ")}` : "Filter";
|
|
45
|
+
const filterSections = [
|
|
46
|
+
{
|
|
47
|
+
key: "filter",
|
|
48
|
+
label: "Filter",
|
|
49
|
+
items: MAIL_LIST_FILTERS.map((entry) => ({
|
|
50
|
+
key: entry.value,
|
|
51
|
+
label: entry.label,
|
|
52
|
+
role: "menuitemradio",
|
|
53
|
+
checked: filter === entry.value,
|
|
54
|
+
onSelect: () => onFilterChange(entry.value),
|
|
55
|
+
})),
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
if (offerClassificationFilters) {
|
|
59
|
+
filterSections.push({
|
|
60
|
+
key: "classification",
|
|
61
|
+
label: "Focused Inbox",
|
|
62
|
+
note: "One filter at a time: picking Focused or Other replaces the filter above.",
|
|
63
|
+
items: MAIL_LIST_CLASSIFICATION_FILTERS.map((entry) => ({
|
|
64
|
+
key: entry.value,
|
|
65
|
+
label: entry.label,
|
|
66
|
+
role: "menuitemradio",
|
|
67
|
+
checked: filter === entry.value,
|
|
68
|
+
onSelect: () => onFilterChange(entry.value),
|
|
69
|
+
})),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
filterSections.push({
|
|
73
|
+
key: "labels",
|
|
74
|
+
items: [
|
|
75
|
+
{
|
|
76
|
+
key: "labels",
|
|
77
|
+
label: labelUids.length > 0 ? `Labels (${labelUids.length})` : "Labels",
|
|
78
|
+
description: "Show only messages with the labels you pick",
|
|
79
|
+
// A submenu rather than another group: a mailbox can have far more labels than the rest of
|
|
80
|
+
// this menu has rows, and they are picked several at a time rather than one instead of another.
|
|
81
|
+
submenu: labelSections({
|
|
82
|
+
labels,
|
|
83
|
+
state: labelDraft,
|
|
84
|
+
note: "Shows messages with any of the ticked labels.",
|
|
85
|
+
emptyNote: "This mailbox has no labels yet.",
|
|
86
|
+
commit: { label: "Apply labels", onSelect: () => onLabelUidsChange(labelDraft.draft), disabled: !labelDraft.dirty },
|
|
87
|
+
onCreate: () => setCreatingLabel(true),
|
|
88
|
+
// Clearing the label filter is worth doing in one step, so this applies straight away.
|
|
89
|
+
clear: { label: "Clear labels", keepOpen: false, disabled: labelUids.length === 0, onSelect: () => onLabelUidsChange([]) },
|
|
90
|
+
}),
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
const sortSections = [
|
|
95
|
+
{
|
|
96
|
+
key: "sortBy",
|
|
97
|
+
label: "Sort by",
|
|
98
|
+
note: sortKeysNote,
|
|
99
|
+
items: MAIL_LIST_SORTS.map((entry) => ({
|
|
100
|
+
key: entry.value,
|
|
101
|
+
label: entry.label,
|
|
102
|
+
// The reason a key is unavailable is shown on the row itself rather than folded into the
|
|
103
|
+
// group's note, so it sits beside the one key it explains.
|
|
104
|
+
description: unavailableSortKeys?.[entry.value],
|
|
105
|
+
role: "menuitemradio",
|
|
106
|
+
checked: sortBy === entry.value,
|
|
107
|
+
disabled: sortKeysDisabled || entry.value in (unavailableSortKeys ?? {}),
|
|
108
|
+
// Switching field resets the direction to the one that reads naturally for it, rather than
|
|
109
|
+
// carrying Date's newest-first over to Subject as a surprising Z-A.
|
|
110
|
+
onSelect: () => onSortChange(entry.value, defaultSortOrder(entry.value)),
|
|
111
|
+
})),
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
key: "sortOrder",
|
|
115
|
+
label: "Order",
|
|
116
|
+
items: ["desc", "asc"].map((order) => ({
|
|
117
|
+
key: order,
|
|
118
|
+
label: orderLabels[order],
|
|
119
|
+
role: "menuitemradio",
|
|
120
|
+
checked: sortOrder === order,
|
|
121
|
+
disabled: sortKeysDisabled,
|
|
122
|
+
onSelect: () => onSortChange(sortBy, order),
|
|
123
|
+
})),
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
key: "arrange",
|
|
127
|
+
items: [
|
|
128
|
+
{
|
|
129
|
+
key: "conversations",
|
|
130
|
+
label: "Show as conversations",
|
|
131
|
+
description: "Group replies into one row",
|
|
132
|
+
role: "menuitemcheckbox",
|
|
133
|
+
checked: showAsConversations,
|
|
134
|
+
onSelect: () => onShowAsConversationsChange(!showAsConversations),
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
return (_jsxs("div", { className: "flex items-center gap-0.5 px-1.5 py-1 border-b border-border", children: [_jsx(NewLabelDialog, { open: creatingLabel, onClose: () => setCreatingLabel(false), mailboxUid: mailboxUid, onCreated: onLabelCreated }), _jsx(MenuButton, { "aria-label": filterButtonLabel, label: filterButtonLabel, icon: _jsx(HiOutlineAdjustmentsHorizontal, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), onOpenChange: setFilterMenuOpen, sections: filterSections, disabled: filterDisabled, title: filterDisabled ? filterDisabledReason : undefined }), _jsx(MenuButton, { "aria-label": `Sort: ${sortLabel(sortBy)}`, label: `Sort: ${sortLabel(sortBy)}`, icon: _jsx(HiOutlineBarsArrowDown, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), sections: sortSections }), _jsx("button", { type: "button", "aria-pressed": selectMode, disabled: selectDisabled,
|
|
140
|
+
// Icon-only, so the tooltip is the only thing naming it on screen - replaced by the reason
|
|
141
|
+
// while it is unavailable, which is the more useful thing to read at that moment.
|
|
142
|
+
title: selectDisabled ? selectDisabledReason : "Select", onClick: () => onSelectModeChange(!selectMode), "aria-label": "Select", className: [
|
|
143
|
+
// Icon-only, like Outlook's own "select items" command: the glyph carries it and the
|
|
144
|
+
// name lives in `aria-label`/`title`. The pressed state is a filled, outlined box
|
|
145
|
+
// rather than only a colour change, so it stays legible next to a disabled one.
|
|
146
|
+
"ml-auto shrink-0 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent",
|
|
147
|
+
selectMode ? "bg-primary/10 text-primary-dark" : "text-text-muted",
|
|
148
|
+
].join(" "), children: _jsx(HiOutlineStop, { size: 18, "aria-hidden": "true", className: "shrink-0" }) })] }));
|
|
149
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Folder, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
3
|
+
import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
4
|
+
export interface MailSelectionBarProps {
|
|
5
|
+
selected: Message[];
|
|
6
|
+
/** Every message currently listed - what "Select all" selects and what `allSelected` is measured against. */
|
|
7
|
+
listed: Message[];
|
|
8
|
+
/** What the bar *counts*, when the rows being ticked aren't messages themselves: the conversation list
|
|
9
|
+
* ticks conversations, each standing for several messages, so "3 conversations selected" is what the
|
|
10
|
+
* reader picked while `selected` stays the messages every action below acts on. Left out by the flat
|
|
11
|
+
* message list, which counts `selected`/`listed` directly. */
|
|
12
|
+
totals?: {
|
|
13
|
+
selected: number;
|
|
14
|
+
listed: number;
|
|
15
|
+
noun: string;
|
|
16
|
+
};
|
|
17
|
+
onSelectAll: () => void;
|
|
18
|
+
onClearSelection: () => void;
|
|
19
|
+
/** Leaves select mode entirely (and clears the selection). */
|
|
20
|
+
onCancel: () => void;
|
|
21
|
+
folders: Folder[];
|
|
22
|
+
currentFolderUid?: string;
|
|
23
|
+
/** Every label this mailbox has, for the Apply label action. */
|
|
24
|
+
labels: Label[];
|
|
25
|
+
/** The mailbox a label created from Apply label belongs to. */
|
|
26
|
+
mailboxUid: string;
|
|
27
|
+
onLabelCreated: (label: Label) => void;
|
|
28
|
+
/** A folder created from the Move to prompt, for the caller's own folder list and the sidebar. */
|
|
29
|
+
onFolderCreated: (folder: Folder) => void;
|
|
30
|
+
/** Sets the selection's labels: every message ends up with `labelUids`, plus whichever of
|
|
31
|
+
* `keepPartial` it already had (those rows were left partially applied, so each message keeps what
|
|
32
|
+
* it has). */
|
|
33
|
+
onApplyLabels: (labelUids: string[], keepPartial: string[]) => void;
|
|
34
|
+
onSetRead: (read: boolean) => void;
|
|
35
|
+
onSetFlagged: (flagged: boolean) => void;
|
|
36
|
+
onArchive: () => void;
|
|
37
|
+
/** Moves the selection into `folderUid`, resolving once the bulk update has settled. It resolves
|
|
38
|
+
* whether or not that update was rejected: a bulk update is applied element by element, so a failure
|
|
39
|
+
* is explained in this bar (along with the reload it triggers) rather than in the prompt, which would
|
|
40
|
+
* be claiming the move simply didn't happen. */
|
|
41
|
+
onMoveTo: (folderUid: string) => Promise<void>;
|
|
42
|
+
onReportJunk: () => void;
|
|
43
|
+
onDelete: () => void;
|
|
44
|
+
/** A bulk action is in flight - every action is held until it settles, since the next one would send
|
|
45
|
+
* `version`s the first has already superseded. */
|
|
46
|
+
busy: boolean;
|
|
47
|
+
error: string | null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The header that replaces the list's own toolbar while select mode is on: how many rows are selected,
|
|
51
|
+
* select-all/clear, and the bulk actions Outlook offers for a multi-selection - mark read/unread, flag,
|
|
52
|
+
* archive, move, report junk and delete.
|
|
53
|
+
*
|
|
54
|
+
* "Delete" means *move to Deleted Items*, never a permanent erase: there is no bulk permanent-delete API,
|
|
55
|
+
* and the collection-level DELETE truncates the folder rather than removing a selection. An action whose
|
|
56
|
+
* target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
|
|
57
|
+
* of actions doesn't shift around between folders.
|
|
58
|
+
*/
|
|
59
|
+
export default function MailSelectionBar({ selected, listed, totals, onSelectAll, onClearSelection, onCancel, folders, currentFolderUid, labels, mailboxUid, onLabelCreated, onFolderCreated, onApplyLabels, onSetRead, onSetFlagged, onArchive, onMoveTo, onReportJunk, onDelete, busy, error, }: MailSelectionBarProps): React.JSX.Element;
|