@rapidmx/web-client 0.9.0 → 0.10.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 +192 -88
- package/apps/shared/auth/accountUrl.ts +9 -0
- package/apps/shared/auth/adminAccess.ts +99 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +2 -1
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
- package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
- package/apps/shared/components/layout/AppShell.tsx +411 -288
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
- package/apps/shared/components/layout/UserMenu.tsx +303 -185
- package/apps/shared/components/mail/ConversationList.tsx +269 -262
- package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
- package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
- package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1387
- package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1736
- package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
- package/apps/shared/components/mail/compose/composePerf.ts +46 -0
- package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
- package/apps/shared/components/mail/layout/MailShell.tsx +476 -472
- package/apps/shared/components/mail/unreadStyle.tsx +75 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
- package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
- package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
- package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
- package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
- package/apps/shared/keyboard/dispatch.ts +124 -0
- package/apps/shared/keyboard/format.ts +89 -0
- package/apps/shared/keyboard/keymap.ts +114 -0
- package/apps/shared/keyboard/match.ts +44 -0
- package/apps/shared/keyboard/parse.ts +136 -0
- package/apps/shared/keyboard/platform.ts +34 -0
- package/apps/shared/keyboard/registry.ts +65 -0
- package/apps/shared/keyboard/targets.ts +79 -0
- package/apps/shared/keyboard/useShortcut.ts +50 -0
- package/apps/shared/keyboard/useShortcutProps.ts +17 -0
- package/apps/shared/mail/folderCounts.ts +302 -0
- package/apps/shared/mail/listSnapshots.ts +87 -0
- package/apps/shared/mail/messageReadState.ts +85 -0
- package/apps/shared/mail/newMailNotifications.ts +183 -0
- package/apps/shared/mail/useMailConnection.ts +150 -0
- package/apps/shared/mail/useMailLiveUpdates.ts +232 -224
- package/apps/shared/mail/useMarkMessageRead.ts +47 -0
- package/apps/shared/mail/useNewMailNotifications.ts +163 -0
- package/apps/shared/mail/useUnreadTitle.ts +42 -0
- package/apps/shared/navigation/AppRouter.tsx +300 -0
- package/apps/shared/navigation/appHrefs.ts +23 -0
- package/apps/shared/navigation/frameContext.tsx +35 -0
- package/apps/shared/navigation/idle.ts +45 -0
- package/apps/shared/navigation/routerContext.tsx +83 -0
- package/apps/shared/navigation/routes.ts +72 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
- package/apps/shared/styles/app.css +28 -10
- package/apps/www/_routedPage.tsx +24 -0
- package/apps/www/_routes.ts +35 -0
- package/apps/www/calendar/index.tsx +55 -19
- package/apps/www/contacts/[uid].tsx +112 -107
- package/apps/www/contacts/index.tsx +584 -567
- package/apps/www/index.tsx +2280 -1917
- package/apps/www/messages/[uid].tsx +106 -101
- package/apps/www/settings/auto-reply/index.tsx +4 -1
- package/apps/www/settings/encryption/index.tsx +1252 -1249
- package/apps/www/settings/filters/[uid].tsx +4 -1
- package/apps/www/settings/filters/index.tsx +102 -99
- package/apps/www/settings/filters/new/index.tsx +138 -133
- package/apps/www/settings/labels/index.tsx +204 -201
- package/apps/www/settings/privacy/index.tsx +4 -1
- package/apps/www/settings/read-receipts/index.tsx +4 -1
- package/apps/www/settings/sharing/index.tsx +277 -274
- package/apps/www/settings/signatures/[uid].tsx +170 -167
- package/apps/www/settings/signatures/index.tsx +88 -85
- package/apps/www/settings/signatures/new/index.tsx +134 -129
- package/apps/www/tasks/index.tsx +18 -2
- package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
- package/dist/apps/shared/auth/accountUrl.js +8 -0
- package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
- package/dist/apps/shared/auth/adminAccess.js +89 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
- package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
- package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
- package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
- package/dist/apps/shared/components/layout/AppShell.js +73 -14
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
- package/dist/apps/shared/components/layout/UserMenu.d.ts +19 -3
- package/dist/apps/shared/components/layout/UserMenu.js +52 -5
- package/dist/apps/shared/components/mail/ConversationList.js +4 -11
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +52 -27
- package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
- package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +83 -34
- package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
- package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +98 -22
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
- package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
- package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +12 -4
- package/dist/apps/shared/components/mail/layout/MailShell.js +76 -96
- package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
- package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
- package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
- package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
- package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
- package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
- package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
- package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
- package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
- package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
- package/dist/apps/shared/keyboard/dispatch.js +109 -0
- package/dist/apps/shared/keyboard/format.d.ts +12 -0
- package/dist/apps/shared/keyboard/format.js +74 -0
- package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
- package/dist/apps/shared/keyboard/keymap.js +84 -0
- package/dist/apps/shared/keyboard/match.d.ts +20 -0
- package/dist/apps/shared/keyboard/match.js +31 -0
- package/dist/apps/shared/keyboard/parse.d.ts +31 -0
- package/dist/apps/shared/keyboard/parse.js +107 -0
- package/dist/apps/shared/keyboard/platform.d.ts +15 -0
- package/dist/apps/shared/keyboard/platform.js +22 -0
- package/dist/apps/shared/keyboard/registry.d.ts +39 -0
- package/dist/apps/shared/keyboard/registry.js +33 -0
- package/dist/apps/shared/keyboard/targets.d.ts +14 -0
- package/dist/apps/shared/keyboard/targets.js +67 -0
- package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
- package/dist/apps/shared/keyboard/useShortcut.js +35 -0
- package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
- package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
- package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
- package/dist/apps/shared/mail/folderCounts.js +212 -0
- package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
- package/dist/apps/shared/mail/listSnapshots.js +43 -0
- package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
- package/dist/apps/shared/mail/messageReadState.js +63 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
- package/dist/apps/shared/mail/newMailNotifications.js +138 -0
- package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
- package/dist/apps/shared/mail/useMailConnection.js +96 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +13 -6
- package/dist/apps/shared/mail/useMailLiveUpdates.js +28 -26
- package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
- package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
- package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
- package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
- package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
- package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
- package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
- package/dist/apps/shared/navigation/AppRouter.js +242 -0
- package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
- package/dist/apps/shared/navigation/appHrefs.js +20 -0
- package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
- package/dist/apps/shared/navigation/frameContext.js +24 -0
- package/dist/apps/shared/navigation/idle.d.ts +14 -0
- package/dist/apps/shared/navigation/idle.js +43 -0
- package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
- package/dist/apps/shared/navigation/routerContext.js +56 -0
- package/dist/apps/shared/navigation/routes.d.ts +32 -0
- package/dist/apps/shared/navigation/routes.js +37 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
- package/dist/apps/shared/styles/app.css +28 -10
- package/dist/apps/www/_routedPage.d.ts +12 -0
- package/dist/apps/www/_routedPage.js +19 -0
- package/dist/apps/www/_routes.d.ts +11 -0
- package/dist/apps/www/_routes.js +29 -0
- package/dist/apps/www/calendar/index.d.ts +2 -2
- package/dist/apps/www/calendar/index.js +39 -8
- package/dist/apps/www/contacts/[uid].d.ts +3 -9
- package/dist/apps/www/contacts/[uid].js +6 -2
- package/dist/apps/www/contacts/index.d.ts +2 -2
- package/dist/apps/www/contacts/index.js +17 -4
- package/dist/apps/www/index.d.ts +2 -2
- package/dist/apps/www/index.js +301 -35
- package/dist/apps/www/messages/[uid].d.ts +3 -7
- package/dist/apps/www/messages/[uid].js +7 -4
- package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
- package/dist/apps/www/settings/auto-reply/index.js +3 -1
- package/dist/apps/www/settings/encryption/index.d.ts +2 -2
- package/dist/apps/www/settings/encryption/index.js +3 -1
- package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
- package/dist/apps/www/settings/filters/[uid].js +3 -1
- package/dist/apps/www/settings/filters/index.d.ts +2 -2
- package/dist/apps/www/settings/filters/index.js +3 -1
- package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
- package/dist/apps/www/settings/filters/new/index.js +6 -2
- package/dist/apps/www/settings/labels/index.d.ts +2 -2
- package/dist/apps/www/settings/labels/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.d.ts +2 -2
- package/dist/apps/www/settings/privacy/index.js +3 -1
- package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
- package/dist/apps/www/settings/read-receipts/index.js +3 -1
- package/dist/apps/www/settings/sharing/index.d.ts +2 -2
- package/dist/apps/www/settings/sharing/index.js +3 -1
- package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
- package/dist/apps/www/settings/signatures/[uid].js +3 -1
- package/dist/apps/www/settings/signatures/index.d.ts +2 -2
- package/dist/apps/www/settings/signatures/index.js +3 -1
- package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
- package/dist/apps/www/settings/signatures/new/index.js +6 -2
- package/dist/apps/www/tasks/index.d.ts +2 -2
- package/dist/apps/www/tasks/index.js +15 -3
- package/package.json +2 -2
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { ComponentType, useEffect, useState } from "react";
|
|
6
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
7
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
8
|
+
import Skeleton from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
9
|
+
import type MessageDetailPaneType from "./MessageDetailPane.js";
|
|
10
|
+
import type ConversationThreadPaneType from "./ConversationThreadPane.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The reading pane (`MessageDetailPane`, and the thread pane that stacks it) is only rendered once a message is selected, and
|
|
14
|
+
* it is the heaviest piece of the inbox after the compose window - message security, the body sanitizer, the labels and move
|
|
15
|
+
* menus - so it is a chunk of its own, downloaded when the browser is idle after the list has loaded (`prefetchReadingPane()`)
|
|
16
|
+
* and, at the latest, on the first selection. Nothing about the pane changes: these wrap the same components, with a skeleton
|
|
17
|
+
* while the chunk loads and a reload prompt if it can't.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately not `React.lazy()` + `<Suspense>`: a boundary that has shown its fallback holds the content back for up to 300 ms
|
|
20
|
+
* after the code arrives (React's fallback throttle), which would put a fifth of a second between a click and a pane whose
|
|
21
|
+
* code was already fetched. This reads the loaded component synchronously when there is one, so an already-loaded pane renders
|
|
22
|
+
* on the click's own frame, and it can retry a failed download.
|
|
23
|
+
*/
|
|
24
|
+
function lazyComponent<P extends object>(load: () => Promise<{ default: ComponentType<P> }>) {
|
|
25
|
+
let loaded: ComponentType<P> | undefined;
|
|
26
|
+
let loading: Promise<ComponentType<P>> | undefined;
|
|
27
|
+
|
|
28
|
+
/** Starts (once) loading the component's code. Rejects if it can't be downloaded - and then a later call tries again. */
|
|
29
|
+
function start(): Promise<ComponentType<P>> {
|
|
30
|
+
loading ??= load().then(
|
|
31
|
+
(module) => (loaded = module.default),
|
|
32
|
+
(err) => {
|
|
33
|
+
loading = undefined;
|
|
34
|
+
throw err;
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
return loading;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Lazy(props: P) {
|
|
41
|
+
const [, setLoadedNow] = useState(0);
|
|
42
|
+
const [failed, setFailed] = useState(false);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (loaded) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
let cancelled = false;
|
|
48
|
+
start().then(
|
|
49
|
+
() => !cancelled && setLoadedNow((n) => n + 1),
|
|
50
|
+
() => !cancelled && setFailed(true),
|
|
51
|
+
);
|
|
52
|
+
return () => {
|
|
53
|
+
cancelled = true;
|
|
54
|
+
};
|
|
55
|
+
}, []);
|
|
56
|
+
if (loaded) {
|
|
57
|
+
const Component = loaded;
|
|
58
|
+
return <Component {...props} />;
|
|
59
|
+
}
|
|
60
|
+
return failed ? <ChunkFailed /> : <PaneSkeleton />;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { Lazy, start };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function PaneSkeleton() {
|
|
67
|
+
return (
|
|
68
|
+
<div role="status" aria-busy="true" className="flex-1 p-6 flex flex-col gap-3">
|
|
69
|
+
<span className="sr-only">Loading the message</span>
|
|
70
|
+
<Skeleton width="w-2/3" height="h-6" />
|
|
71
|
+
<Skeleton width="w-1/3" />
|
|
72
|
+
<Skeleton height="h-40" />
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A chunk that can't be downloaded (offline, or replaced by a deploy since this page loaded) is the one failure a reading
|
|
78
|
+
* pane can't recover from by itself: say so, with the fix, rather than leaving a blank pane. */
|
|
79
|
+
function ChunkFailed() {
|
|
80
|
+
return (
|
|
81
|
+
<div className="flex-1 p-6">
|
|
82
|
+
<Alert>
|
|
83
|
+
<p className="mb-2">This part of the page couldn’t be loaded. Check your connection, then reload.</p>
|
|
84
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={() => window.location.reload()}>
|
|
85
|
+
Reload
|
|
86
|
+
</Button>
|
|
87
|
+
</Alert>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const messageDetailPane = lazyComponent<React.ComponentProps<typeof MessageDetailPaneType>>(() => import("./MessageDetailPane.js"));
|
|
93
|
+
const conversationThreadPane = lazyComponent<React.ComponentProps<typeof ConversationThreadPaneType>>(() => import("./ConversationThreadPane.js"));
|
|
94
|
+
|
|
95
|
+
/** Starts downloading the reading pane's code, for the page to call when it has nothing better to do. Never rejects. */
|
|
96
|
+
export function prefetchReadingPane(): void {
|
|
97
|
+
messageDetailPane.start().catch(() => undefined);
|
|
98
|
+
conversationThreadPane.start().catch(() => undefined);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** With nothing selected the pane is only its empty-state line, which needs none of the pane's code - so the chunk isn't
|
|
102
|
+
* requested until something is (or the page prefetches it). */
|
|
103
|
+
export function LazyMessageDetailPane(props: React.ComponentProps<typeof MessageDetailPaneType>) {
|
|
104
|
+
if (!props.message) {
|
|
105
|
+
return <p className="p-8 text-sm text-text-muted">Select a message to read it.</p>;
|
|
106
|
+
}
|
|
107
|
+
return <messageDetailPane.Lazy {...props} />;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function LazyConversationThreadPane(props: React.ComponentProps<typeof ConversationThreadPaneType>) {
|
|
111
|
+
if (!props.conversation) {
|
|
112
|
+
return <p className="p-8 text-sm text-text-muted">Select a conversation to read it.</p>;
|
|
113
|
+
}
|
|
114
|
+
return <conversationThreadPane.Lazy {...props} />;
|
|
115
|
+
}
|
|
@@ -1,222 +1,235 @@
|
|
|
1
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
-
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React, { useState } from "react";
|
|
6
|
-
import { Folder, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
7
|
-
import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
8
|
-
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
9
|
-
import LabelMenuButton from "./labelMenu.js";
|
|
10
|
-
import MoveToFolderDialog, { MOVE_TARGET_TYPES } from "./MoveToFolderDialog.js";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
>
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
disabled={none || busy}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useState } from "react";
|
|
6
|
+
import { Folder, Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
7
|
+
import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
8
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
9
|
+
import LabelMenuButton from "./labelMenu.js";
|
|
10
|
+
import MoveToFolderDialog, { MOVE_TARGET_TYPES } from "./MoveToFolderDialog.js";
|
|
11
|
+
import { ariaKeyShortcuts, withHint } from "../../keyboard/format.js";
|
|
12
|
+
import { SHORTCUTS, ShortcutDef } from "../../keyboard/keymap.js";
|
|
13
|
+
import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
|
|
14
|
+
|
|
15
|
+
export interface MailSelectionBarProps {
|
|
16
|
+
selected: Message[];
|
|
17
|
+
/** Every message currently listed - what "Select all" selects and what `allSelected` is measured against. */
|
|
18
|
+
listed: Message[];
|
|
19
|
+
/** What the bar *counts*, when the rows being ticked aren't messages themselves: the conversation list
|
|
20
|
+
* ticks conversations, each standing for several messages, so "3 conversations selected" is what the
|
|
21
|
+
* reader picked while `selected` stays the messages every action below acts on. Left out by the flat
|
|
22
|
+
* message list, which counts `selected`/`listed` directly. */
|
|
23
|
+
totals?: { selected: number; listed: number; noun: string };
|
|
24
|
+
onSelectAll: () => void;
|
|
25
|
+
onClearSelection: () => void;
|
|
26
|
+
/** Leaves select mode entirely (and clears the selection). */
|
|
27
|
+
onCancel: () => void;
|
|
28
|
+
folders: Folder[];
|
|
29
|
+
currentFolderUid?: string;
|
|
30
|
+
/** Every label this mailbox has, for the Apply label action. */
|
|
31
|
+
labels: Label[];
|
|
32
|
+
/** The mailbox a label created from Apply label belongs to. */
|
|
33
|
+
mailboxUid: string;
|
|
34
|
+
onLabelCreated: (label: Label) => void;
|
|
35
|
+
/** A folder created from the Move to prompt, for the caller's own folder list and the sidebar. */
|
|
36
|
+
onFolderCreated: (folder: Folder) => void;
|
|
37
|
+
/** Sets the selection's labels: every message ends up with `labelUids`, plus whichever of
|
|
38
|
+
* `keepPartial` it already had (those rows were left partially applied, so each message keeps what
|
|
39
|
+
* it has). */
|
|
40
|
+
onApplyLabels: (labelUids: string[], keepPartial: string[]) => void;
|
|
41
|
+
onSetRead: (read: boolean) => void;
|
|
42
|
+
onSetFlagged: (flagged: boolean) => void;
|
|
43
|
+
onArchive: () => void;
|
|
44
|
+
/** Moves the selection into `folderUid`, resolving once the bulk update has settled. It resolves
|
|
45
|
+
* whether or not that update was rejected: a bulk update is applied element by element, so a failure
|
|
46
|
+
* is explained in this bar (along with the reload it triggers) rather than in the prompt, which would
|
|
47
|
+
* be claiming the move simply didn't happen. */
|
|
48
|
+
onMoveTo: (folderUid: string) => Promise<void>;
|
|
49
|
+
onReportJunk: () => void;
|
|
50
|
+
onDelete: () => void;
|
|
51
|
+
/** A bulk action is in flight - every action is held until it settles, since the next one would send
|
|
52
|
+
* `version`s the first has already superseded. */
|
|
53
|
+
busy: boolean;
|
|
54
|
+
error: string | null;
|
|
55
|
+
/** The keyboard acts on this selection (Delete, Ctrl+Q, Ctrl+U, Insert - registered by the page): Mark read, Mark unread, Flag and
|
|
56
|
+
* Delete name their shortcut in the tooltip and `aria-keyshortcuts`. */
|
|
57
|
+
shortcuts?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function actionClassName(): string {
|
|
61
|
+
return "px-2 py-1 rounded-md text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The header that replaces the list's own toolbar while select mode is on: how many rows are selected,
|
|
66
|
+
* select-all/clear, and the bulk actions Outlook offers for a multi-selection - mark read/unread, flag,
|
|
67
|
+
* archive, move, report junk and delete.
|
|
68
|
+
*
|
|
69
|
+
* "Delete" means *move to Deleted Items*, never a permanent erase: there is no bulk permanent-delete API,
|
|
70
|
+
* and the collection-level DELETE truncates the folder rather than removing a selection. An action whose
|
|
71
|
+
* target folder is the one already being viewed is disabled with a reason rather than hidden, so the set
|
|
72
|
+
* of actions doesn't shift around between folders.
|
|
73
|
+
*/
|
|
74
|
+
export default function MailSelectionBar({
|
|
75
|
+
selected,
|
|
76
|
+
listed,
|
|
77
|
+
totals,
|
|
78
|
+
onSelectAll,
|
|
79
|
+
onClearSelection,
|
|
80
|
+
onCancel,
|
|
81
|
+
folders,
|
|
82
|
+
currentFolderUid,
|
|
83
|
+
labels,
|
|
84
|
+
mailboxUid,
|
|
85
|
+
onLabelCreated,
|
|
86
|
+
onFolderCreated,
|
|
87
|
+
onApplyLabels,
|
|
88
|
+
onSetRead,
|
|
89
|
+
onSetFlagged,
|
|
90
|
+
onArchive,
|
|
91
|
+
onMoveTo,
|
|
92
|
+
onReportJunk,
|
|
93
|
+
onDelete,
|
|
94
|
+
busy,
|
|
95
|
+
error,
|
|
96
|
+
shortcuts,
|
|
97
|
+
}: MailSelectionBarProps) {
|
|
98
|
+
const env = useKeyEnvironment();
|
|
99
|
+
/** `title` and `aria-keyshortcuts` for an action the keyboard also does; `reason` (why it is disabled) wins the tooltip. */
|
|
100
|
+
const hint = (label: string, shortcut: ShortcutDef, reason?: string) =>
|
|
101
|
+
shortcuts
|
|
102
|
+
? { title: reason ?? withHint(label, shortcut, env), "aria-keyshortcuts": ariaKeyShortcuts(shortcut, env) }
|
|
103
|
+
: { title: reason };
|
|
104
|
+
const selectedCount = totals?.selected ?? selected.length;
|
|
105
|
+
const listedCount = totals?.listed ?? listed.length;
|
|
106
|
+
// Counted on the rows that were ticked, but *emptied* on the messages: a ticked conversation whose
|
|
107
|
+
// messages are still being fetched has nothing for an action to act on yet.
|
|
108
|
+
const none = selectedCount === 0 || selected.length === 0;
|
|
109
|
+
const allSelected = listedCount > 0 && selectedCount === listedCount;
|
|
110
|
+
const currentType = folders.find((folder) => folder.uid === currentFolderUid)?.type;
|
|
111
|
+
const moveTargets = folders.filter((folder) => MOVE_TARGET_TYPES.has(folder.type) && folder.uid !== currentFolderUid);
|
|
112
|
+
// The same prompt the reading pane's own Move to opens, so one list of destinations and one way to
|
|
113
|
+
// create a folder serve both.
|
|
114
|
+
const [movePrompt, setMovePrompt] = useState(false);
|
|
115
|
+
|
|
116
|
+
// None of the three needs its folder to exist first - the caller creates or lazily provisions it (see
|
|
117
|
+
// `resolveFolderOfType()` and `bulkArchive()` in `apps/www/index.tsx`) - so the only thing that disables
|
|
118
|
+
// them is already being in the folder they would move to.
|
|
119
|
+
const archiveReason = currentType === "archive" ? "These messages are already in Archive" : undefined;
|
|
120
|
+
const junkReason = currentType === "junk" ? "These messages are already in Junk" : undefined;
|
|
121
|
+
const deleteReason = currentType === "deleted_items" ? "These messages are already in Deleted Items" : undefined;
|
|
122
|
+
|
|
123
|
+
// A label every selected message already carries starts ticked; one only some of them carry starts
|
|
124
|
+
// partially applied, and is left exactly as it is unless the reader touches that row.
|
|
125
|
+
const appliedToAll = labels.filter((label) => !none && selected.every((m) => m.labelUids?.includes(label.uid)));
|
|
126
|
+
const appliedToSome = labels.filter(
|
|
127
|
+
(label) => selected.some((m) => m.labelUids?.includes(label.uid)) && !appliedToAll.includes(label),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div className="border-b border-border">
|
|
133
|
+
<MoveToFolderDialog
|
|
134
|
+
open={movePrompt}
|
|
135
|
+
onClose={() => setMovePrompt(false)}
|
|
136
|
+
mailboxUid={mailboxUid}
|
|
137
|
+
folders={folders}
|
|
138
|
+
currentFolderUid={currentFolderUid}
|
|
139
|
+
count={selected.length}
|
|
140
|
+
onMove={onMoveTo}
|
|
141
|
+
onFolderCreated={onFolderCreated}
|
|
142
|
+
/>
|
|
143
|
+
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface-alt">
|
|
144
|
+
<span aria-live="polite" className="text-sm font-semibold text-text">
|
|
145
|
+
{selectedCount}
|
|
146
|
+
{totals ? ` ${totals.noun}${selectedCount === 1 ? "" : "s"}` : ""} selected
|
|
147
|
+
</span>
|
|
148
|
+
<button type="button" onClick={onSelectAll} disabled={allSelected || listedCount === 0} className={actionClassName()}>
|
|
149
|
+
Select all
|
|
150
|
+
</button>
|
|
151
|
+
<button type="button" onClick={onClearSelection} disabled={selectedCount === 0} className={actionClassName()}>
|
|
152
|
+
Clear
|
|
153
|
+
</button>
|
|
154
|
+
<button type="button" onClick={onCancel} className={`${actionClassName()} ml-auto`}>
|
|
155
|
+
Cancel
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
<div className="flex flex-wrap items-center gap-0.5 px-1.5 py-1">
|
|
159
|
+
<button type="button" onClick={() => onSetRead(true)} disabled={none || busy} className={actionClassName()} {...hint("Mark read", SHORTCUTS.mail.markRead)}>
|
|
160
|
+
Mark read
|
|
161
|
+
</button>
|
|
162
|
+
<button type="button" onClick={() => onSetRead(false)} disabled={none || busy} className={actionClassName()} {...hint("Mark unread", SHORTCUTS.mail.markUnread)}>
|
|
163
|
+
Mark unread
|
|
164
|
+
</button>
|
|
165
|
+
<button type="button" onClick={() => onSetFlagged(true)} disabled={none || busy} className={actionClassName()} {...hint("Flag", SHORTCUTS.mail.flag)}>
|
|
166
|
+
Flag
|
|
167
|
+
</button>
|
|
168
|
+
<button type="button" onClick={() => onSetFlagged(false)} disabled={none || busy} className={actionClassName()}>
|
|
169
|
+
Unflag
|
|
170
|
+
</button>
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
onClick={onArchive}
|
|
174
|
+
disabled={none || busy || !!archiveReason}
|
|
175
|
+
title={archiveReason}
|
|
176
|
+
className={actionClassName()}
|
|
177
|
+
>
|
|
178
|
+
Archive
|
|
179
|
+
</button>
|
|
180
|
+
<LabelMenuButton
|
|
181
|
+
aria-label="Apply label"
|
|
182
|
+
label="Apply label"
|
|
183
|
+
className="text-sm"
|
|
184
|
+
labels={labels}
|
|
185
|
+
mailboxUid={mailboxUid}
|
|
186
|
+
onLabelCreated={onLabelCreated}
|
|
187
|
+
applied={appliedToAll.map((label) => label.uid)}
|
|
188
|
+
partial={appliedToSome.map((label) => label.uid)}
|
|
189
|
+
onCommit={onApplyLabels}
|
|
190
|
+
busy={busy}
|
|
191
|
+
disabled={none || busy}
|
|
192
|
+
note={
|
|
193
|
+
selected.length === 1
|
|
194
|
+
? "Ticked labels are applied, unticked ones removed."
|
|
195
|
+
: "Ticked labels are applied to every selected message and unticked ones removed from all of them; a dash means only some have that label, and leaving it alone keeps it that way."
|
|
196
|
+
}
|
|
197
|
+
emptyNote="This mailbox has no labels yet."
|
|
198
|
+
commit={{ label: "Apply" }}
|
|
199
|
+
clear={{ label: "Remove all labels" }}
|
|
200
|
+
/>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={() => setMovePrompt(true)}
|
|
204
|
+
disabled={none || busy}
|
|
205
|
+
className={actionClassName()}
|
|
206
|
+
>
|
|
207
|
+
Move to
|
|
208
|
+
</button>
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
onClick={onReportJunk}
|
|
212
|
+
disabled={none || busy || !!junkReason}
|
|
213
|
+
title={junkReason}
|
|
214
|
+
className={actionClassName()}
|
|
215
|
+
>
|
|
216
|
+
Report junk
|
|
217
|
+
</button>
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
onClick={onDelete}
|
|
221
|
+
disabled={none || busy || !!deleteReason}
|
|
222
|
+
className={actionClassName()}
|
|
223
|
+
{...hint("Delete", SHORTCUTS.mail.delete, deleteReason)}
|
|
224
|
+
>
|
|
225
|
+
Delete
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
{error && (
|
|
229
|
+
<div className="px-3 pb-2">
|
|
230
|
+
<Alert>{error}</Alert>
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|