@rapidmx/web-client 0.8.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/elevation.ts +61 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
- package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +87 -24
- 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/BrandingChrome.tsx +3 -2
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -126
- package/apps/shared/components/layout/UserMenu.tsx +303 -150
- package/apps/shared/components/mail/ConversationList.tsx +269 -253
- package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
- package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
- package/apps/shared/components/mail/MailAddress.tsx +88 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
- package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1383
- 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 -1700
- 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/SendFailureAlert.tsx +48 -0
- 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 -445
- 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/mergeFirstPage.ts +47 -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 -0
- 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 -1854
- 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/elevation.d.ts +24 -0
- package/dist/apps/shared/components/admin/elevation.js +56 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
- package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
- 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/BrandingChrome.d.ts +3 -2
- package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
- package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +45 -8
- package/dist/apps/shared/components/layout/UserMenu.d.ts +28 -9
- package/dist/apps/shared/components/layout/UserMenu.js +87 -14
- package/dist/apps/shared/components/mail/ConversationList.js +9 -13
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +53 -28
- 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/MailAddress.d.ts +27 -0
- package/dist/apps/shared/components/mail/MailAddress.js +39 -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 +87 -36
- 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 +122 -27
- 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/SendFailureAlert.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
- 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 +19 -4
- package/dist/apps/shared/components/mail/layout/MailShell.js +85 -85
- 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/mergeFirstPage.d.ts +23 -0
- package/dist/apps/shared/mail/mergeFirstPage.js +30 -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 +55 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.js +182 -0
- 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 +361 -34
- 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
package/dist/apps/www/index.js
CHANGED
|
@@ -3,31 +3,41 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import {
|
|
6
|
+
import { routedPage } from "./_routedPage.js";
|
|
7
|
+
import { useNavigate } from "../shared/navigation/AppRouter.js";
|
|
8
|
+
import { whenIdle } from "../shared/navigation/idle.js";
|
|
9
|
+
import { prefetchComposeWindow } from "../shared/components/mail/compose/ComposeContext.js";
|
|
10
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
7
11
|
import { HiOutlineFlag, HiOutlineLockClosed, HiOutlinePaperClip } from "react-icons/hi2";
|
|
8
12
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
|
-
import { archiveMessage, bulkUpdateMessages, createFolder, getMessage, getMessageRawContent, listMessages, moveMessages, setMessagesFlagged,
|
|
13
|
+
import { archiveMessage, bulkUpdateMessages, createFolder, getMessage, getMessageRawContent, listMessages, moveMessages, setMessagesFlagged, } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
10
14
|
import { listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
11
15
|
import { listConversationMessages, listConversations, } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
12
16
|
import { search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
|
|
13
17
|
import { parseSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
|
|
14
18
|
import { normalizeServerScores } from "@rapidmx/react-shared/search/searchScoring.js";
|
|
15
|
-
import { searchEncryptedCandidates } from "@rapidmx/react-shared/search/searchTier3.js";
|
|
16
19
|
import { searchLocalIndex } from "../shared/search/searchTier2.js";
|
|
17
20
|
import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
18
|
-
import {
|
|
19
|
-
import { useMarkMessageRead, useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
|
|
21
|
+
import { useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
|
|
20
22
|
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
21
23
|
import MailShell, { useMailShell, } from "../shared/components/mail/layout/MailShell.js";
|
|
22
|
-
import
|
|
24
|
+
import MailAddress from "../shared/components/mail/MailAddress.js";
|
|
25
|
+
import { mergeFirstPage } from "../shared/mail/mergeFirstPage.js";
|
|
26
|
+
import { listSnapshotKey, readListSnapshot, saveListScroll, writeListSnapshot } from "../shared/mail/listSnapshots.js";
|
|
27
|
+
import { setReadStateMany } from "../shared/mail/messageReadState.js";
|
|
28
|
+
import { useMarkMessageRead } from "../shared/mail/useMarkMessageRead.js";
|
|
29
|
+
import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "../shared/components/mail/unreadStyle.js";
|
|
30
|
+
import { LazyConversationThreadPane, LazyMessageDetailPane, prefetchReadingPane } from "../shared/components/mail/LazyReadingPane.js";
|
|
23
31
|
import ConversationList from "../shared/components/mail/ConversationList.js";
|
|
24
|
-
import ConversationThreadPane from "../shared/components/mail/ConversationThreadPane.js";
|
|
25
32
|
import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
|
|
26
33
|
import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
|
|
27
34
|
import { CONVERSATION_SORT_NOTE, CONVERSATION_SORT_UNAVAILABLE, MAIL_LIST_CLASSIFICATION_FILTERS, getMailListPreferences, setMailListPreferences, sortConversations, } from "../shared/components/mail/listPreferences.js";
|
|
28
35
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
29
|
-
import Skeleton from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
36
|
+
import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
30
37
|
import { useUnlockPrompt } from "../shared/components/layout/UnlockPromptProvider.js";
|
|
38
|
+
import { SHORTCUTS } from "../shared/keyboard/keymap.js";
|
|
39
|
+
import { isActivatable } from "../shared/keyboard/targets.js";
|
|
40
|
+
import { useShortcut } from "../shared/keyboard/useShortcut.js";
|
|
31
41
|
const MESSAGE_PAGE_SIZE = 50;
|
|
32
42
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
33
43
|
const LIST_PREVIEW_MAX_LENGTH = 160;
|
|
@@ -68,6 +78,9 @@ async function decryptEncryptedRows(messages, unlocked) {
|
|
|
68
78
|
const entries = await Promise.all(encrypted.map(async (message) => {
|
|
69
79
|
try {
|
|
70
80
|
const rawMime = await getMessageRawContent(message.uid);
|
|
81
|
+
// Loaded here, on first use: the S/MIME code (PKI.js, ASN.1, X.509) is over half a megabyte and an inbox with
|
|
82
|
+
// no encrypted mail in it never needs it.
|
|
83
|
+
const { evaluateMessageSecurity } = await import("@rapidmx/react-shared/crypto/messageSecurity.js");
|
|
71
84
|
const security = await evaluateMessageSecurity(rawMime, unlocked);
|
|
72
85
|
if (!security.subject && !security.html) {
|
|
73
86
|
return null;
|
|
@@ -220,6 +233,8 @@ function tier3Windows(parsed, coverage, searchAllMail) {
|
|
|
220
233
|
/** Runs Tier 3 over each window and merges the candidates (a uid can't match in two disjoint windows, but a
|
|
221
234
|
* message whose date sits exactly on a boundary may come back from both). */
|
|
222
235
|
async function searchTier3Windows(windows, unlocked, mailboxUid) {
|
|
236
|
+
// Loaded on first use, with the S/MIME code it decrypts through (see `decryptEncryptedRows()`).
|
|
237
|
+
const { searchEncryptedCandidates } = await import("@rapidmx/react-shared/search/searchTier3.js");
|
|
223
238
|
const pages = await Promise.all(windows.map((window) => searchEncryptedCandidates(window, unlocked, TIER3_CANDIDATE_LIMIT, { mailboxUid })));
|
|
224
239
|
const merged = new Map();
|
|
225
240
|
for (const result of pages.flat()) {
|
|
@@ -332,16 +347,28 @@ async function fetchAggregateMessages(mailboxFolders, type, filter) {
|
|
|
332
347
|
}));
|
|
333
348
|
return mergeInboxMessages(perMailbox);
|
|
334
349
|
}
|
|
335
|
-
|
|
350
|
+
function InboxPage(props) {
|
|
336
351
|
return (_jsx(MailShell, { ...props, children: _jsx(InboxContent, { userUid: props.userUid }) }));
|
|
337
352
|
}
|
|
338
353
|
function InboxContent({ userUid }) {
|
|
339
|
-
const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated } = useMailShell();
|
|
354
|
+
const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, live, trackMessageChange } = useMailShell();
|
|
340
355
|
const isMobile = useIsMobile();
|
|
356
|
+
const navigate = useNavigate();
|
|
341
357
|
const { requestUnlock } = useUnlockPrompt();
|
|
342
358
|
const [messages, setMessages] = useState([]);
|
|
343
359
|
const [conversations, setConversations] = useState([]);
|
|
344
360
|
const [loading, setLoading] = useState(true);
|
|
361
|
+
// Once a list is on screen and the browser has nothing better to do, fetch the code a click on a message (the reading pane)
|
|
362
|
+
// or on Compose/Reply (the compose window, with its editor) would otherwise wait for.
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
if (loading) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
return whenIdle(() => {
|
|
368
|
+
prefetchReadingPane();
|
|
369
|
+
prefetchComposeWindow();
|
|
370
|
+
});
|
|
371
|
+
}, [loading]);
|
|
345
372
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
346
373
|
const [hasMore, setHasMore] = useState(false);
|
|
347
374
|
const [error, setError] = useState(null);
|
|
@@ -479,6 +506,10 @@ function InboxContent({ userUid }) {
|
|
|
479
506
|
// removal steps this back too, or the next page would silently skip a message. See `loadMore()`.
|
|
480
507
|
const listedOffsetRef = useRef(0);
|
|
481
508
|
const scrollContainerRef = useRef(null);
|
|
509
|
+
const searchInputRef = useRef(null);
|
|
510
|
+
// Where the message the keyboard just removed from the list (deleted, archived, moved) was: the next Down/Up continues from that spot, so
|
|
511
|
+
// clearing an inbox from the keyboard walks down it instead of jumping back to the top. Cleared by any selection.
|
|
512
|
+
const removedAnchorRef = useRef(null);
|
|
482
513
|
// State (via a callback ref), not a plain ref: the sentinel mounts and unmounts as the list loads,
|
|
483
514
|
// filters, and empties, and the observer effect below must re-attach to whichever node is current.
|
|
484
515
|
const [sentinel, setSentinel] = useState(null);
|
|
@@ -650,6 +681,48 @@ function InboxContent({ userUid }) {
|
|
|
650
681
|
const handle = setTimeout(() => setSearchQuery(searchInput.trim()), SEARCH_DEBOUNCE_MS);
|
|
651
682
|
return () => clearTimeout(handle);
|
|
652
683
|
}, [searchInput]);
|
|
684
|
+
// Choosing another folder no longer loads a new page, so what was being searched for would follow the reader into it: the
|
|
685
|
+
// search box and its results are cleared instead, as the page load used to. (The first selection resolves after mount - the
|
|
686
|
+
// shell reads the URL and lists the folders in effects - and is not a change of folder.)
|
|
687
|
+
const selectionKey = `${mailboxUid ?? ""}|${folderUid ?? ""}|${aggregateFolderType ?? ""}`;
|
|
688
|
+
const shownSelectionRef = useRef(null);
|
|
689
|
+
useEffect(() => {
|
|
690
|
+
const shown = shownSelectionRef.current;
|
|
691
|
+
if (shown !== null && shown !== selectionKey) {
|
|
692
|
+
setSearchInput("");
|
|
693
|
+
setSearchQuery("");
|
|
694
|
+
}
|
|
695
|
+
if (folderUid || aggregateFolderType) {
|
|
696
|
+
shownSelectionRef.current = selectionKey;
|
|
697
|
+
}
|
|
698
|
+
}, [selectionKey]);
|
|
699
|
+
// Which listing this is, for the short-lived snapshot of each folder that makes going back to one instant (see
|
|
700
|
+
// `listSnapshots.ts`). A search, an aggregate view and a folder that hasn't resolved have no snapshot.
|
|
701
|
+
const listKey = listSnapshotKey({
|
|
702
|
+
mailboxUid: activeMailboxUid,
|
|
703
|
+
folderUid,
|
|
704
|
+
conversations: preferences.showAsConversations,
|
|
705
|
+
filter: effectiveFilter,
|
|
706
|
+
labels: labelFilterKey,
|
|
707
|
+
sort: serverSortKey,
|
|
708
|
+
});
|
|
709
|
+
const snapshotable = !!folderUid && !isSearching && !aggregateFolderType;
|
|
710
|
+
/** The key of the listing `messages`/`conversations` currently hold - not `listKey` while a new folder's rows are still coming. */
|
|
711
|
+
const shownListKeyRef = useRef(null);
|
|
712
|
+
/** A scroll position to put the list back at once its rows are on screen (a snapshot's). */
|
|
713
|
+
const pendingScrollRef = useRef(null);
|
|
714
|
+
useLayoutEffect(() => {
|
|
715
|
+
if (pendingScrollRef.current !== null && !loading && scrollContainerRef.current) {
|
|
716
|
+
scrollContainerRef.current.scrollTop = pendingScrollRef.current;
|
|
717
|
+
pendingScrollRef.current = null;
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
// Keeps the snapshot of the folder on screen current - rows added by "load more" or live updates, a read message, the selection.
|
|
721
|
+
useEffect(() => {
|
|
722
|
+
if (snapshotable && !loading && shownListKeyRef.current === listKey) {
|
|
723
|
+
writeListSnapshot(listKey, { messages, conversations, hasMore, selectedUid });
|
|
724
|
+
}
|
|
725
|
+
}, [snapshotable, loading, listKey, messages, conversations, hasMore, selectedUid]);
|
|
653
726
|
// Loads whichever list the current folder, arrangement and search state call for, and resets every
|
|
654
727
|
// piece of per-listing state (selection, paging, select mode) that a previous listing left behind.
|
|
655
728
|
useEffect(() => {
|
|
@@ -676,6 +749,8 @@ function InboxContent({ userUid }) {
|
|
|
676
749
|
const invalidate = () => {
|
|
677
750
|
searchRunIdRef.current += 1;
|
|
678
751
|
};
|
|
752
|
+
// A folder shown a moment ago is put on screen at once and revalidated behind the rows; one that was not shows a skeleton.
|
|
753
|
+
const snapshot = snapshotable ? readListSnapshot(listKey) : undefined;
|
|
679
754
|
if (!folderUid && !aggregateFolderType) {
|
|
680
755
|
// The shell hasn't resolved this mailbox's folders yet (the render below says so). Listing
|
|
681
756
|
// anything now means a request whose answer is thrown away the moment the Inbox arrives - and
|
|
@@ -700,11 +775,30 @@ function InboxContent({ userUid }) {
|
|
|
700
775
|
// (always set - `MailShell` only renders this component once at least one mailbox exists).
|
|
701
776
|
// They're scoped to the selected folder (`folderUid`, absent only in aggregate mode), so the
|
|
702
777
|
// conversation list matches the folder the sidebar has selected rather than the whole mailbox.
|
|
703
|
-
|
|
778
|
+
if (snapshot) {
|
|
779
|
+
setConversations(snapshot.conversations);
|
|
780
|
+
listedOffsetRef.current = snapshot.conversations.length;
|
|
781
|
+
setHasMore(snapshot.hasMore);
|
|
782
|
+
shownListKeyRef.current = listKey;
|
|
783
|
+
pendingScrollRef.current = snapshot.scrollTop;
|
|
784
|
+
setLoading(false);
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
setLoading(true);
|
|
788
|
+
}
|
|
704
789
|
setError(null);
|
|
705
790
|
listConversations(activeMailboxUid, conversationParams(0))
|
|
706
791
|
.then((result) => {
|
|
707
792
|
if (isCurrentRun()) {
|
|
793
|
+
shownListKeyRef.current = listKey;
|
|
794
|
+
if (snapshot) {
|
|
795
|
+
// Folded into what is already shown, as a live refresh does, so the reader's place is kept.
|
|
796
|
+
const merged = mergeFirstPage(snapshot.conversations, result, conversationKey, MESSAGE_PAGE_SIZE);
|
|
797
|
+
setConversations(merged.rows);
|
|
798
|
+
listedOffsetRef.current = merged.complete ? result.length : snapshot.conversations.length + merged.added;
|
|
799
|
+
setHasMore(!merged.complete);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
708
802
|
setConversations(result);
|
|
709
803
|
listedOffsetRef.current = result.length;
|
|
710
804
|
setHasMore(result.length === MESSAGE_PAGE_SIZE);
|
|
@@ -739,7 +833,7 @@ function InboxContent({ userUid }) {
|
|
|
739
833
|
});
|
|
740
834
|
return invalidate;
|
|
741
835
|
}
|
|
742
|
-
setLoading(
|
|
836
|
+
setLoading(!snapshot);
|
|
743
837
|
setError(null);
|
|
744
838
|
if (isSearching) {
|
|
745
839
|
setCoverage(undefined);
|
|
@@ -827,9 +921,28 @@ function InboxContent({ userUid }) {
|
|
|
827
921
|
}
|
|
828
922
|
// Set: the guard at the top of this effect returned for a view with neither a folder nor an
|
|
829
923
|
// aggregate type, and the aggregate branch above returned for the one with an aggregate type.
|
|
924
|
+
if (snapshot) {
|
|
925
|
+
setMessages(snapshot.messages);
|
|
926
|
+
listedOffsetRef.current = snapshot.messages.length;
|
|
927
|
+
setHasMore(snapshot.hasMore);
|
|
928
|
+
// The selection the reader left the folder with, if that message is still there.
|
|
929
|
+
setSelectedUid(snapshot.messages.some((m) => m.uid === snapshot.selectedUid) ? snapshot.selectedUid : null);
|
|
930
|
+
shownListKeyRef.current = listKey;
|
|
931
|
+
pendingScrollRef.current = snapshot.scrollTop;
|
|
932
|
+
}
|
|
830
933
|
listMessages(folderUid, listParams)
|
|
831
934
|
.then((results) => {
|
|
832
935
|
if (isCurrentRun()) {
|
|
936
|
+
shownListKeyRef.current = listKey;
|
|
937
|
+
if (snapshot) {
|
|
938
|
+
// Folded into what is already shown, as a live refresh does: the reader's place, selection and paged-in
|
|
939
|
+
// rows stay, and a row they changed since (a higher version) is not put back.
|
|
940
|
+
const merged = mergeFirstPage(snapshot.messages, results, messageUid, MESSAGE_PAGE_SIZE, (current, next) => current.version >= next.version ? current : next);
|
|
941
|
+
setMessages(merged.rows);
|
|
942
|
+
listedOffsetRef.current = merged.complete ? results.length : snapshot.messages.length + merged.added;
|
|
943
|
+
setHasMore(!merged.complete);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
833
946
|
setMessages(results);
|
|
834
947
|
listedOffsetRef.current = results.length;
|
|
835
948
|
setHasMore(results.length === MESSAGE_PAGE_SIZE);
|
|
@@ -870,6 +983,67 @@ function InboxContent({ userUid }) {
|
|
|
870
983
|
mailboxFolders,
|
|
871
984
|
activeMailboxUid,
|
|
872
985
|
]);
|
|
986
|
+
// New mail without a reload. `live` is bumped by a push event, a reconnect, the safety-net poll or the tab coming back (see
|
|
987
|
+
// `useMailLiveUpdates()`); this then quietly refetches the first page of whatever is listed and folds it in - unlike the
|
|
988
|
+
// effect above it resets nothing: not the selection, the open thread, select mode, the scroll position or the rows
|
|
989
|
+
// already paged in, and nothing it fetches is marked read. It stands aside for a search (whose rows aren't a folder's),
|
|
990
|
+
// a listing still loading and a "load more" in flight, and for events about folders the list isn't showing - a
|
|
991
|
+
// conversation list, which can span folders, refreshes for any. Any failure is silent: the next tick tries again.
|
|
992
|
+
const liveRunRef = useRef(0);
|
|
993
|
+
useEffect(() => {
|
|
994
|
+
if (live.tick === 0 || isSearching || loading || loadMoreInFlightRef.current || (!folderUid && !aggregateFolderType)) {
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (!preferences.showAsConversations && live.folderUids) {
|
|
998
|
+
const shown = new Set(aggregateFolderType
|
|
999
|
+
? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType).map((f) => f.uid))
|
|
1000
|
+
: [folderUid]);
|
|
1001
|
+
if (![...live.folderUids].some((uid) => shown.has(uid))) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
// Superseded by a newer refresh, or by any reload of the list itself (which bumps the search run id).
|
|
1006
|
+
const listRun = searchRunIdRef.current;
|
|
1007
|
+
const myRun = ++liveRunRef.current;
|
|
1008
|
+
const isCurrent = () => searchRunIdRef.current === listRun && liveRunRef.current === myRun;
|
|
1009
|
+
void (async () => {
|
|
1010
|
+
try {
|
|
1011
|
+
if (preferences.showAsConversations) {
|
|
1012
|
+
const fresh = await listConversations(activeMailboxUid, conversationParams(0));
|
|
1013
|
+
if (!isCurrent()) {
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const merged = mergeFirstPage(conversationsRef.current, fresh, conversationKey, MESSAGE_PAGE_SIZE);
|
|
1017
|
+
setConversations(merged.rows);
|
|
1018
|
+
listedOffsetRef.current = merged.complete ? fresh.length : listedOffsetRef.current + merged.added;
|
|
1019
|
+
setHasMore(!merged.complete);
|
|
1020
|
+
}
|
|
1021
|
+
else if (aggregateFolderType) {
|
|
1022
|
+
// No paging here (see `fetchAggregateMessages()`): the fresh merged first pages are the list.
|
|
1023
|
+
const fresh = await fetchAggregateMessages(mailboxFolders, aggregateFolderType, effectiveFilter);
|
|
1024
|
+
if (isCurrent()) {
|
|
1025
|
+
setMessages(fresh);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
else {
|
|
1029
|
+
const fresh = await listMessages(folderUid, listParams);
|
|
1030
|
+
if (!isCurrent()) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
// A row the reader has just changed (a higher version) is not put back by a fetch that began before.
|
|
1034
|
+
// Equal versions are the same server state, so the row on screen - which may carry an optimistic read/unread
|
|
1035
|
+
// flip the server has not answered yet - wins too.
|
|
1036
|
+
const merged = mergeFirstPage(messagesRef.current, fresh, messageUid, MESSAGE_PAGE_SIZE, (current, next) => current.version >= next.version ? current : next);
|
|
1037
|
+
setMessages(merged.rows);
|
|
1038
|
+
listedOffsetRef.current = merged.complete ? fresh.length : listedOffsetRef.current + merged.added;
|
|
1039
|
+
setHasMore(!merged.complete);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
catch {
|
|
1043
|
+
// Quiet by design - see above.
|
|
1044
|
+
}
|
|
1045
|
+
})();
|
|
1046
|
+
}, [live.tick]);
|
|
873
1047
|
function handleSearchAllMail() {
|
|
874
1048
|
setSearchAllMailKey(searchAllMailScope);
|
|
875
1049
|
}
|
|
@@ -1043,22 +1217,27 @@ function InboxContent({ userUid }) {
|
|
|
1043
1217
|
*
|
|
1044
1218
|
* A conversation's *parent* row has no copy to replace: it is a summary of the whole thread, and its
|
|
1045
1219
|
* "2 unread" chip and bold styling come from a count the server worked out when the list was fetched.
|
|
1046
|
-
* So when
|
|
1047
|
-
* count
|
|
1048
|
-
* read, until the whole list was reloaded.
|
|
1220
|
+
* So when a message changes read state (`previous` and `updated` differ in it - the optimistic copy of a message just
|
|
1221
|
+
* read, or its revert), that count moves by one here - otherwise a conversation kept claiming unread mail the reader
|
|
1222
|
+
* had just read, until the whole list was reloaded.
|
|
1049
1223
|
*/
|
|
1050
1224
|
function patchListedMessage(updated, previous) {
|
|
1051
1225
|
setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)));
|
|
1052
1226
|
// `ConversationList` fetched its own copy of this message when the thread was expanded; hand it the
|
|
1053
1227
|
// newer one so the child row doesn't keep showing a stale read/flag state.
|
|
1054
1228
|
setConversationPatches((prev) => ({ ...prev, [updated.uid]: updated }));
|
|
1055
|
-
|
|
1229
|
+
const unreadChange = previous ? Number(isUnread(updated)) - Number(isUnread(previous)) : 0;
|
|
1230
|
+
if (unreadChange !== 0) {
|
|
1056
1231
|
setConversations((prev) => prev.map((conversation) => conversation.messageUids.includes(updated.uid)
|
|
1057
|
-
? { ...conversation, unreadCount: Math.max(0, conversation.unreadCount
|
|
1232
|
+
? { ...conversation, unreadCount: Math.max(0, conversation.unreadCount + unreadChange) }
|
|
1058
1233
|
: conversation));
|
|
1059
1234
|
}
|
|
1060
1235
|
}
|
|
1061
1236
|
function removeListedMessages(uids) {
|
|
1237
|
+
const selectedIndex = messagesRef.current.findIndex((m) => m.uid === selectedUid);
|
|
1238
|
+
if (selectedUid && uids.has(selectedUid) && selectedIndex !== -1) {
|
|
1239
|
+
removedAnchorRef.current = selectedIndex;
|
|
1240
|
+
}
|
|
1062
1241
|
setMessages((prev) => prev.filter((m) => !uids.has(m.uid)));
|
|
1063
1242
|
listedOffsetRef.current = Math.max(0, listedOffsetRef.current - uids.size);
|
|
1064
1243
|
setSelectedUid((prev) => (prev && uids.has(prev) ? null : prev));
|
|
@@ -1181,13 +1360,26 @@ function InboxContent({ userUid }) {
|
|
|
1181
1360
|
* A bulk update is applied element by element server-side and stops at its first rejection, so a
|
|
1182
1361
|
* failure leaves an unknown prefix of the selection already changed (see `bulkUpdateMessages()`) -
|
|
1183
1362
|
* which is why a failure reloads the list rather than trying to reconcile it, and says so.
|
|
1363
|
+
*
|
|
1364
|
+
* `chosen` is the selection bar's ticked messages unless the caller (a keyboard shortcut acting on the one open message or conversation)
|
|
1365
|
+
* names its own. Resolves whether it succeeded.
|
|
1184
1366
|
*/
|
|
1185
|
-
async function runBulkAction(action, removesRows) {
|
|
1186
|
-
const chosen = selectedMessages;
|
|
1367
|
+
async function runBulkAction(action, removesRows, chosen = selectedMessages) {
|
|
1187
1368
|
setBulkBusy(true);
|
|
1188
1369
|
setBulkError(null);
|
|
1189
1370
|
try {
|
|
1190
1371
|
const updated = await action(chosen);
|
|
1372
|
+
if (removesRows) {
|
|
1373
|
+
// A move (Delete, Archive, Report junk, Move to): the source folders' badges go down, the target's up. Reading
|
|
1374
|
+
// and unreading are tracked by `setReadStateMany()` itself, as they happen.
|
|
1375
|
+
const movedByUid = new Map(updated.map((m) => [m.uid, m]));
|
|
1376
|
+
for (const before of chosen) {
|
|
1377
|
+
const after = movedByUid.get(before.uid);
|
|
1378
|
+
if (after) {
|
|
1379
|
+
trackMessageChange(before, after).settle();
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1191
1383
|
if (preferences.showAsConversations) {
|
|
1192
1384
|
// A conversation row is a summary of its messages - its count, unread count, participants
|
|
1193
1385
|
// and preview all move when a bulk action changes or empties part of it - so the list is
|
|
@@ -1204,6 +1396,7 @@ function InboxContent({ userUid }) {
|
|
|
1204
1396
|
setMessages((prev) => prev.map((m) => byUid.get(m.uid) ?? m));
|
|
1205
1397
|
}
|
|
1206
1398
|
setSelectedUids(new Set());
|
|
1399
|
+
return true;
|
|
1207
1400
|
}
|
|
1208
1401
|
catch (err) {
|
|
1209
1402
|
setBulkError(`${err instanceof ApiRequestError ? err.message : "Those messages couldn't all be updated."} Some of them may already have changed, so the list has been reloaded.`);
|
|
@@ -1211,6 +1404,7 @@ function InboxContent({ userUid }) {
|
|
|
1211
1404
|
setSelectedConversationIds(new Set());
|
|
1212
1405
|
setConversationMessagesById({});
|
|
1213
1406
|
setRefreshKey((n) => n + 1);
|
|
1407
|
+
return false;
|
|
1214
1408
|
}
|
|
1215
1409
|
finally {
|
|
1216
1410
|
setBulkBusy(false);
|
|
@@ -1260,8 +1454,8 @@ function InboxContent({ userUid }) {
|
|
|
1260
1454
|
return { uid: message.uid, version: message.version, labelUids: [...new Set([...labelUids, ...kept])] };
|
|
1261
1455
|
})), false);
|
|
1262
1456
|
}
|
|
1263
|
-
function moveSelectionToType(type, name) {
|
|
1264
|
-
|
|
1457
|
+
function moveSelectionToType(type, name, chosen) {
|
|
1458
|
+
return runBulkAction(async (moving) => moveMessages(moving, await resolveFolderOfType(type, name)), true, chosen);
|
|
1265
1459
|
}
|
|
1266
1460
|
const loadMoreStatus = loadingMore ? ("Loading more\u2026") : loadMoreError ? (_jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { role: "alert", className: "text-danger", children: loadMoreError }), _jsx("button", { type: "button", onClick: () => void loadMoreRef.current(), className: "text-primary-dark hover:underline font-medium", children: "Retry" })] })) : loadMoreStalled ? (_jsx("button", { type: "button", onClick: () => void loadMoreRef.current(), className: "text-primary-dark hover:underline font-medium", children: "Load more" })) : null;
|
|
1267
1461
|
function handleSelect(message) {
|
|
@@ -1270,9 +1464,10 @@ function InboxContent({ userUid }) {
|
|
|
1270
1464
|
return;
|
|
1271
1465
|
}
|
|
1272
1466
|
if (isMobile) {
|
|
1273
|
-
|
|
1467
|
+
navigate(`/messages/${encodeURIComponent(message.uid)}`);
|
|
1274
1468
|
return;
|
|
1275
1469
|
}
|
|
1470
|
+
removedAnchorRef.current = null;
|
|
1276
1471
|
setSelectedUid(message.uid);
|
|
1277
1472
|
}
|
|
1278
1473
|
/** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
|
|
@@ -1287,12 +1482,142 @@ function InboxContent({ userUid }) {
|
|
|
1287
1482
|
if (isMobile) {
|
|
1288
1483
|
// No dedicated mobile thread route yet - the existing single-message detail route already
|
|
1289
1484
|
// handles any message uid regardless of conversation grouping.
|
|
1290
|
-
|
|
1485
|
+
navigate(`/messages/${encodeURIComponent(uid)}`);
|
|
1291
1486
|
return;
|
|
1292
1487
|
}
|
|
1488
|
+
removedAnchorRef.current = null;
|
|
1293
1489
|
setSelectedUid(uid);
|
|
1294
1490
|
setOpenThread({ conversation, uid });
|
|
1295
1491
|
}
|
|
1492
|
+
// ---- Keyboard shortcuts (see `shared/keyboard`). Each is registered only while this view can do it, and calls what the toolbar and
|
|
1493
|
+
// ---- the selection bar call: the same bulk-action path (`runBulkAction()` - so the same optimistic badge tracking, the same rollback,
|
|
1494
|
+
// ---- the same lazily created Deleted Items folder), never a second implementation. Reply, Reply all, Forward, Archive and Move to are
|
|
1495
|
+
// ---- registered by the reading pane itself (`MessageDetailPane`'s `shortcuts`), which owns those handlers.
|
|
1496
|
+
const inConversations = preferences.showAsConversations;
|
|
1497
|
+
/** The selection bar isn't offered in the aggregate view (its rows belong to several mailboxes), and neither are these. */
|
|
1498
|
+
const keyboardActions = !aggregateFolderType;
|
|
1499
|
+
/** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
|
|
1500
|
+
const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : inConversations ? openThread !== null : selected !== null;
|
|
1501
|
+
const rowCount = inConversations ? listedConversations.length : messages.length;
|
|
1502
|
+
const canMoveSelection = !selectMode && !isMobile && !loading && rowCount > 0;
|
|
1503
|
+
function folderTypeOf(folderUidOfMessage) {
|
|
1504
|
+
return currentFolders.find((f) => f.uid === folderUidOfMessage)?.type;
|
|
1505
|
+
}
|
|
1506
|
+
/** Moves the keyboard's focus to a row - and scrolls it into view - once the selection has moved to it. */
|
|
1507
|
+
function focusRow(uid) {
|
|
1508
|
+
// Only called from a key press, when the list is on screen.
|
|
1509
|
+
const row = [...scrollContainerRef.current.querySelectorAll("[data-message-uid]")].find((element) => element.getAttribute("data-message-uid") === uid);
|
|
1510
|
+
row?.scrollIntoView({ block: "nearest" });
|
|
1511
|
+
row?.querySelector("[data-row-open]")?.focus({ preventScroll: true });
|
|
1512
|
+
}
|
|
1513
|
+
/** The index of the selected row: the open message's, or the open conversation's; -1 with nothing selected. */
|
|
1514
|
+
function selectedRowIndex() {
|
|
1515
|
+
if (inConversations) {
|
|
1516
|
+
return openThread ? listedConversations.findIndex((c) => c.conversationId === openThread.conversation.conversationId) : -1;
|
|
1517
|
+
}
|
|
1518
|
+
return messages.findIndex((m) => m.uid === selectedUid);
|
|
1519
|
+
}
|
|
1520
|
+
function selectRow(index) {
|
|
1521
|
+
if (inConversations) {
|
|
1522
|
+
const conversation = listedConversations[index];
|
|
1523
|
+
handleOpenConversation(conversation, conversation.latestMessageUid);
|
|
1524
|
+
focusRow(conversation.latestMessageUid);
|
|
1525
|
+
}
|
|
1526
|
+
else {
|
|
1527
|
+
const message = messages[index];
|
|
1528
|
+
handleSelect(message);
|
|
1529
|
+
focusRow(message.uid);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
function selectNeighbour(step) {
|
|
1533
|
+
const current = selectedRowIndex();
|
|
1534
|
+
// After the selected message left the list (deleted, archived) the next row is the one that slid into its place.
|
|
1535
|
+
const anchor = removedAnchorRef.current;
|
|
1536
|
+
const target = current === -1 && anchor !== null ? (step === 1 ? anchor : anchor - 1) : current + step;
|
|
1537
|
+
selectRow(Math.min(rowCount - 1, Math.max(0, target)));
|
|
1538
|
+
}
|
|
1539
|
+
function selectNextUnread(step) {
|
|
1540
|
+
const isRowUnread = (index) => (inConversations ? listedConversations[index].unreadCount > 0 : isUnread(messages[index]));
|
|
1541
|
+
for (let index = selectedRowIndex() + step; index >= 0 && index < rowCount; index += step) {
|
|
1542
|
+
if (isRowUnread(index)) {
|
|
1543
|
+
selectRow(index);
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
/** The messages of the open conversation that are in the folder being listed - fetched (once) like a ticked conversation's. */
|
|
1549
|
+
async function loadOpenConversation(conversation) {
|
|
1550
|
+
const id = conversation.conversationId;
|
|
1551
|
+
let all = conversationMessagesById[id];
|
|
1552
|
+
if (!all) {
|
|
1553
|
+
all = await listConversationMessages(activeMailboxUid, id);
|
|
1554
|
+
const loaded = all;
|
|
1555
|
+
setConversationMessagesById((prev) => ({ ...prev, [id]: loaded }));
|
|
1556
|
+
}
|
|
1557
|
+
return all.filter((m) => !folderUid || m.folderUid === folderUid);
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Runs a bulk action on what the keyboard acts on - the ticked rows in select mode, else the open message or conversation - keeping only
|
|
1561
|
+
* the messages `applies` accepts (Delete leaves out what is already in Deleted Items, Mark read what is already read: nothing to do
|
|
1562
|
+
* then). Held while another bulk action is on the wire, like the bar's buttons. Closes the open thread after a move away from it.
|
|
1563
|
+
*/
|
|
1564
|
+
async function runOnKeyboardTargets(action, removesRows, applies = () => true) {
|
|
1565
|
+
if (bulkBusy || resolvingSelection > 0) {
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
let chosen;
|
|
1569
|
+
try {
|
|
1570
|
+
chosen = selectMode ? selectedMessages : inConversations ? await loadOpenConversation(openThread.conversation) : [selected];
|
|
1571
|
+
}
|
|
1572
|
+
catch (err) {
|
|
1573
|
+
setBulkError(err instanceof ApiRequestError ? err.message : "Could not load the messages in that conversation.");
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
chosen = chosen.filter(applies);
|
|
1577
|
+
if (chosen.length === 0) {
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
const succeeded = await runBulkAction(action, removesRows, chosen);
|
|
1581
|
+
if (succeeded && removesRows && inConversations && !selectMode) {
|
|
1582
|
+
setOpenThread(null);
|
|
1583
|
+
setSelectedUid(null);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
useShortcut(SHORTCUTS.mail.next, () => selectNeighbour(1), { enabled: canMoveSelection });
|
|
1587
|
+
useShortcut(SHORTCUTS.mail.previous, () => selectNeighbour(-1), { enabled: canMoveSelection });
|
|
1588
|
+
useShortcut(SHORTCUTS.mail.nextUnread, () => selectNextUnread(1), { enabled: canMoveSelection });
|
|
1589
|
+
useShortcut(SHORTCUTS.mail.previousUnread, () => selectNextUnread(-1), { enabled: canMoveSelection });
|
|
1590
|
+
useShortcut(SHORTCUTS.mail.delete, () => void runOnKeyboardTargets(async (chosen) => moveMessages(chosen, await resolveFolderOfType("deleted_items", "Deleted Items")), true, (message) => folderTypeOf(message.folderUid) !== "deleted_items"), { enabled: keyboardActions && keyboardTargetExists });
|
|
1591
|
+
useShortcut(SHORTCUTS.mail.markRead, () => void runOnKeyboardTargets((chosen) => setReadStateMany(chosen, true, { patch: patchListedMessage, track: trackMessageChange }), false, isUnread), { enabled: keyboardActions && keyboardTargetExists });
|
|
1592
|
+
useShortcut(SHORTCUTS.mail.markUnread, () => void runOnKeyboardTargets((chosen) => setReadStateMany(chosen, false, { patch: patchListedMessage, track: trackMessageChange }), false, (message) => !isUnread(message)), { enabled: keyboardActions && keyboardTargetExists });
|
|
1593
|
+
useShortcut(SHORTCUTS.mail.flag, () =>
|
|
1594
|
+
// Flags them all unless every one already is - what the bar's Flag and Unflag would do on this selection.
|
|
1595
|
+
void runOnKeyboardTargets((chosen) => setMessagesFlagged(chosen, !chosen.every((m) => m.flags.flagged)), false), { enabled: keyboardActions && keyboardTargetExists });
|
|
1596
|
+
useShortcut(SHORTCUTS.mail.open, (event) => {
|
|
1597
|
+
// Enter on a row that is not selected yet is the row button's own click (it selects). On the selected one - or with the focus
|
|
1598
|
+
// elsewhere - it opens the message on its own page, as it does in Outlook.
|
|
1599
|
+
const rowUid = event.target.closest("[data-message-uid]")?.getAttribute("data-message-uid");
|
|
1600
|
+
if (rowUid ? rowUid !== selectedUid : isActivatable(event.target)) {
|
|
1601
|
+
return false;
|
|
1602
|
+
}
|
|
1603
|
+
navigate(`/messages/${encodeURIComponent(selectedUid)}`);
|
|
1604
|
+
}, { enabled: !selectMode && selectedUid !== null });
|
|
1605
|
+
useShortcut(SHORTCUTS.mail.close, () => {
|
|
1606
|
+
if (document.activeElement === searchInputRef.current && searchInput !== "") {
|
|
1607
|
+
setSearchInput("");
|
|
1608
|
+
}
|
|
1609
|
+
else if (selectMode) {
|
|
1610
|
+
leaveSelectMode();
|
|
1611
|
+
}
|
|
1612
|
+
else {
|
|
1613
|
+
setSelectedUid(null);
|
|
1614
|
+
setOpenThread(null);
|
|
1615
|
+
}
|
|
1616
|
+
}, { enabled: selectMode || selectedUid !== null || openThread !== null || searchInput !== "" });
|
|
1617
|
+
useShortcut(SHORTCUTS.mail.search, () => {
|
|
1618
|
+
searchInputRef.current?.focus();
|
|
1619
|
+
searchInputRef.current?.select();
|
|
1620
|
+
}, { enabled: !inConversations && !aggregateFolderType });
|
|
1296
1621
|
if (!folderUid && !aggregateFolderType) {
|
|
1297
1622
|
// `MailShell` never renders this component at all until a mailbox is resolved (see its own
|
|
1298
1623
|
// full-screen `MailboxProvisioning` takeover otherwise) — this is purely the brief gap before
|
|
@@ -1301,11 +1626,18 @@ function InboxContent({ userUid }) {
|
|
|
1301
1626
|
// indistinguishable to anything (a test, a user re-reading the screen) that catches this one.
|
|
1302
1627
|
return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Loading your mailbox\u2026" });
|
|
1303
1628
|
}
|
|
1304
|
-
return (_jsxs("div", { className: "flex h-full min-h-0", children: [_jsxs("div", { ref: scrollContainerRef,
|
|
1629
|
+
return (_jsxs("div", { className: "flex h-full min-h-0", children: [_jsxs("div", { ref: scrollContainerRef,
|
|
1630
|
+
// Remembered as it moves (not when the list is left: by then React has already detached the element), so
|
|
1631
|
+
// the folder - or Mail, after another app - comes back where it was.
|
|
1632
|
+
onScroll: (event) => saveListScroll(listKey, event.currentTarget.scrollTop), className: "w-full md:w-96 shrink-0 md:border-r border-border overflow-y-auto", children: [selectMode ? (_jsx(MailSelectionBar, { selected: selectedMessages, listed: messages, totals: preferences.showAsConversations
|
|
1305
1633
|
? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
|
|
1306
1634
|
: undefined, onSelectAll: () => preferences.showAsConversations
|
|
1307
1635
|
? selectAllConversations()
|
|
1308
|
-
: setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () => preferences.showAsConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set()), onCancel: leaveSelectMode, folders: currentFolders, currentFolderUid: folderUid, labels: mailboxLabels, mailboxUid: activeMailboxUid, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated, onApplyLabels: applyLabelsToSelection, onSetRead: (read) => void runBulkAction(
|
|
1636
|
+
: setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () => preferences.showAsConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set()), onCancel: leaveSelectMode, folders: currentFolders, currentFolderUid: folderUid, labels: mailboxLabels, mailboxUid: activeMailboxUid, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated, onApplyLabels: applyLabelsToSelection, onSetRead: (read) => void runBulkAction(
|
|
1637
|
+
// Optimistic: the rows and the folder badges change now, and change back if the server refuses.
|
|
1638
|
+
(chosen) => setReadStateMany(chosen, read, { patch: patchListedMessage, track: trackMessageChange }), false), onSetFlagged: (flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged), false), shortcuts: keyboardActions, onArchive: () => void runBulkAction(bulkArchive, true), onMoveTo: async (targetFolderUid) => {
|
|
1639
|
+
await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid), true);
|
|
1640
|
+
}, onReportJunk: () => void moveSelectionToType("junk", "Junk Email"), onDelete: () => void moveSelectionToType("deleted_items", "Deleted Items"), busy: bulkBusy || resolvingSelection > 0, error: bulkError })) : (_jsx(MailListToolbar, { sortBy: preferences.sortBy, sortOrder: preferences.sortOrder, filter: preferences.filter, labelUids: preferences.labelUids, labels: mailboxLabels, mailboxUid: activeMailboxUid, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onLabelUidsChange: (nextLabelUids) => updatePreferences({ labelUids: nextLabelUids }), showAsConversations: preferences.showAsConversations, onSortChange: (sortBy, sortOrder) => updatePreferences({ sortBy, sortOrder }), onFilterChange: (filter) => updatePreferences({ filter }), onShowAsConversationsChange: (showAsConversations) => updatePreferences({ showAsConversations }), selectMode: selectMode, onSelectModeChange: setSelectMode, offerClassificationFilters: offerClassificationFilters, filterDisabled: isSearching, filterDisabledReason: "Filters don't apply to search results", sortKeysDisabled: isSearching || !!aggregateFolderType,
|
|
1309
1641
|
// A conversation row is a thread summary, so only some of the keys have anything to
|
|
1310
1642
|
// order by - the rest stay pickable and are applied to the rows already fetched.
|
|
1311
1643
|
unavailableSortKeys: preferences.showAsConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined, sortKeysNote: isSearching
|
|
@@ -1318,7 +1650,7 @@ function InboxContent({ userUid }) {
|
|
|
1318
1650
|
? "Open a mailbox's own folder to select messages"
|
|
1319
1651
|
: loading
|
|
1320
1652
|
? "Wait for this folder to finish loading"
|
|
1321
|
-
: "There is nothing here to select" })), !preferences.showAsConversations && (_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { type: "search", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…", "aria-label": "Search all mail", disabled: !!aggregateFolderType, className: "w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface disabled:opacity-55" }) })), offerClassificationFilters && (_jsx("div", { className: "flex border-b border-border text-xs", children: MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (_jsx("button", { type: "button", "aria-pressed": preferences.filter === value, onClick: () => updatePreferences({ filter: value }), className: [
|
|
1653
|
+
: "There is nothing here to select" })), !preferences.showAsConversations && (_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { ref: searchInputRef, type: "search", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…", "aria-label": "Search all mail", disabled: !!aggregateFolderType, className: "w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface disabled:opacity-55" }) })), offerClassificationFilters && (_jsx("div", { className: "flex border-b border-border text-xs", children: MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (_jsx("button", { type: "button", "aria-pressed": preferences.filter === value, onClick: () => updatePreferences({ filter: value }), className: [
|
|
1322
1654
|
"flex-1 py-1.5 font-semibold",
|
|
1323
1655
|
preferences.filter === value
|
|
1324
1656
|
? "text-primary-dark border-b-2 border-primary-dark"
|
|
@@ -1327,17 +1659,11 @@ function InboxContent({ userUid }) {
|
|
|
1327
1659
|
? `${messages.length} result${messages.length === 1 ? "" : "s"}`
|
|
1328
1660
|
: `${messages.length} of ??` }), tier2Done && !searchAllMail && (_jsx("button", { type: "button", onClick: handleSearchAllMail, className: "text-primary-dark hover:underline font-medium shrink-0", children: "Search all mail" }))] })), isSearching && coverage?.indexedFrom && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["Local search covers messages back to ", new Date(coverage.indexedFrom).toLocaleDateString(), coverage.building ? " (still building)" : "", searchAllMail
|
|
1329
1661
|
? " - searching everything, not just recent mail."
|
|
1330
|
-
: " - older encrypted mail is still searched, just slower."] })), isSearching && !getUnlockedKeys(mailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages in these results"] }) })), !isSearching && !preferences.showAsConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockList, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to show ", undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'", " subject"] }) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })),
|
|
1662
|
+
: " - older encrypted mail is still searched, just slower."] })), isSearching && !getUnlockedKeys(mailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages in these results"] }) })), !isSearching && !preferences.showAsConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockList, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to show ", undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'", " subject"] }) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })), !selectMode && bulkError && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: bulkError }) })), loading ? (_jsxs("div", { role: "status", "aria-busy": "true", className: "p-4", children: [_jsx("span", { className: "sr-only", children: "Loading\u2026" }), _jsx(SkeletonList, { count: 8 })] })) : preferences.showAsConversations ? (_jsxs(_Fragment, { children: [_jsx(ConversationList, { conversations: listedConversations, newestFirst: preferences.sortBy === "date" && preferences.sortOrder === "desc", mailboxUid: activeMailboxUid, selectedUid: selectedUid, messageOverrides: conversationPatches, onOpenMessage: handleOpenConversation, selectMode: selectMode, selectedConversationIds: selectedConversationIds, onToggleSelected: toggleConversationSelected }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
|
|
1331
1663
|
? `No messages match "${searchQuery}".`
|
|
1332
1664
|
: effectiveFilter === "all"
|
|
1333
1665
|
? "No messages in this folder."
|
|
1334
|
-
: "No messages here." }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : (_jsxs(_Fragment, { children: [_jsx("ul", { children: messages.map((message) => (_jsxs("li", { className: "flex items-stretch
|
|
1335
|
-
"flex-1 min-w-0 text-left px-4 py-3",
|
|
1336
|
-
message.uid === selectedUid || selectedUids.has(message.uid)
|
|
1337
|
-
? "bg-primary/10"
|
|
1338
|
-
: "hover:bg-surface-alt",
|
|
1339
|
-
message.flags.read ? "" : "font-semibold",
|
|
1340
|
-
].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: message.from.displayName || message.from.address }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), aggregateFolderType && (_jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName })), isSearching && pendingUids.has(message.uid) ? (_jsxs("div", { className: "flex flex-col gap-1.5 py-0.5", children: [_jsx(Skeleton, { height: "h-3.5", className: "w-2/3 rounded-sm" }), _jsx(Skeleton, { height: "h-3", className: "w-full rounded-sm" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "text-sm truncate", children: rowSubject(message) }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: snippets[message.uid] || decryptedRows[message.uid]?.preview || message.bodyPreview }), message.hasAttachments && _jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" }), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }))] })] }, message.uid))) }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), aggregateFolderType && (_jsx("p", { className: "p-4 text-center text-xs text-text-muted", children: "Showing the most recent mail from each mailbox. Open a specific mailbox\u2019s folder to see older mail." }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0 min-h-0", children: preferences.showAsConversations ? (_jsx(ConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: activeMailboxUid, folders: currentFolders, labels: mailboxLabels, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(MessageDetailPane, { message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
|
|
1666
|
+
: "No messages here." }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : (_jsxs(_Fragment, { children: [_jsx("ul", { children: messages.map((message) => (_jsxs("li", { "data-message-uid": message.uid, "data-unread": isUnread(message) ? "true" : undefined, className: rowClass({ unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: isUnread(message) }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: selectedUids.has(message.uid), onChange: () => toggleSelected(message.uid), "aria-label": `Select ${rowSubject(message)}`, className: "w-4 h-4 accent-primary" }) })), _jsxs("button", { type: "button", "data-row-open": true, onClick: () => handleSelect(message), className: ["flex-1 min-w-0 text-left px-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: isUnread(message) }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(isUnread(message)) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(isUnread(message))].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), aggregateFolderType && (_jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName })), isSearching && pendingUids.has(message.uid) ? (_jsxs("div", { className: "flex flex-col gap-1.5 py-0.5", children: [_jsx(Skeleton, { height: "h-3.5", className: "w-2/3 rounded-sm" }), _jsx(Skeleton, { height: "h-3", className: "w-full rounded-sm" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: ["text-sm truncate", subjectClass(isUnread(message))].join(" "), children: rowSubject(message) }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: snippets[message.uid] || decryptedRows[message.uid]?.preview || message.bodyPreview }), message.hasAttachments && _jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" }), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }))] })] }, message.uid))) }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), aggregateFolderType && (_jsx("p", { className: "p-4 text-center text-xs text-text-muted", children: "Showing the most recent mail from each mailbox. Open a specific mailbox\u2019s folder to see older mail." }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0 min-h-0", children: preferences.showAsConversations ? (_jsx(LazyConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: activeMailboxUid, folders: currentFolders, labels: mailboxLabels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(LazyMessageDetailPane, { shortcuts: !selectMode, message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
|
|
1341
1667
|
// Same reasoning as onArchived below - a move takes the message out of the folder
|
|
1342
1668
|
// being listed, so it leaves the list rather than being patched in place.
|
|
1343
1669
|
removeListedMessage(updated.uid);
|
|
@@ -1353,3 +1679,4 @@ function InboxContent({ userUid }) {
|
|
|
1353
1679
|
removeListedMessage(updated.uid);
|
|
1354
1680
|
}, labels: labels, onLabelsChanged: patchListedMessage, onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]) })) })] }));
|
|
1355
1681
|
}
|
|
1682
|
+
export default routedPage("/", InboxPage);
|