@rapidmx/web-client 0.15.1 → 0.17.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 +381 -373
- package/apps/admin/branding/index.tsx +39 -39
- package/apps/admin/data-requests/index.tsx +482 -480
- package/apps/admin/distribution-lists/new/index.tsx +3 -1
- package/apps/admin/domains/[uid].tsx +166 -164
- package/apps/admin/domains/new/index.tsx +3 -1
- package/apps/admin/escrow-scopes/[uid].tsx +350 -348
- package/apps/admin/escrow-scopes/new/index.tsx +3 -1
- package/apps/admin/index.tsx +129 -124
- package/apps/admin/mailboxes/[uid].tsx +271 -224
- package/apps/admin/mailboxes/new/index.tsx +30 -28
- package/apps/admin/plugins/index.tsx +15 -15
- package/apps/admin/retention-policy/index.tsx +39 -39
- package/apps/admin/signing-certificates/index.tsx +343 -343
- package/apps/admin/transport-rules/new/index.tsx +3 -1
- package/apps/escrow/audit-log/index.tsx +196 -196
- package/apps/escrow/matters/[uid].tsx +621 -621
- package/apps/escrow/matters/new/index.tsx +3 -1
- package/apps/shared/auth/adminAccess.ts +99 -99
- package/apps/shared/components/admin/layout/AdminShell.tsx +381 -378
- package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
- package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
- package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +50 -3
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1545
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
- package/apps/shared/components/admin/setup/SetupWizard.tsx +446 -444
- package/apps/shared/components/admin/usePagedList.tsx +129 -129
- package/apps/shared/components/calendar/EventEditor.tsx +5 -10
- package/apps/shared/components/calendar/EventFormParts.tsx +16 -47
- package/apps/shared/components/calendar/EventModal.tsx +206 -206
- package/apps/shared/components/calendar/GuestInput.tsx +100 -0
- package/apps/shared/components/calendar/MonthView.tsx +185 -185
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
- package/apps/shared/components/calendar/RequestChangeForm.tsx +23 -54
- package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
- package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
- package/apps/shared/components/calendar/allDay.ts +124 -124
- package/apps/shared/components/calendar/eventForm.ts +2 -2
- package/apps/shared/components/calendar/eventFormat.ts +49 -22
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
- package/apps/shared/components/contacts/ContactForm.tsx +383 -383
- package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
- package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +218 -216
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
- package/apps/shared/components/layout/AppShell.tsx +461 -480
- package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
- package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
- package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
- package/apps/shared/components/layout/UserMenu.tsx +439 -424
- package/apps/shared/components/mail/ConversationList.tsx +323 -323
- package/apps/shared/components/mail/ConversationThreadPane.tsx +613 -491
- package/apps/shared/components/mail/EmptyFolderBar.tsx +41 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +240 -227
- package/apps/shared/components/mail/MenuButton.tsx +31 -11
- package/apps/shared/components/mail/MessageDetailPane.tsx +1757 -1559
- package/apps/shared/components/mail/PermanentDeleteDialog.tsx +47 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1677
- package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
- package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
- package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +161 -134
- package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
- package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -0
- package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -0
- package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
- package/apps/shared/components/mail/reading/messageExport.ts +59 -0
- package/apps/shared/components/mail/reading/printMessage.ts +99 -0
- package/apps/shared/components/mail/reading/useMessageActions.ts +443 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -125
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
- package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
- package/apps/shared/components/settings/layout/SettingsShell.tsx +257 -254
- package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
- package/apps/shared/components/tasks/layout/TasksShell.tsx +219 -217
- package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
- package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
- package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
- package/apps/shared/keyboard/dispatch.ts +124 -124
- package/apps/shared/keyboard/format.ts +89 -89
- package/apps/shared/keyboard/keymap.ts +114 -114
- package/apps/shared/keyboard/registry.ts +65 -65
- package/apps/shared/keyboard/targets.ts +79 -79
- package/apps/shared/mail/folderCounts.ts +17 -0
- package/apps/shared/mail/folderOfType.ts +49 -0
- package/apps/shared/mail/folderTree.ts +143 -143
- package/apps/shared/mail/listAllPages.ts +39 -39
- package/apps/shared/mail/newMailNotifications.ts +171 -171
- package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
- package/apps/shared/mail/outbox/sendJob.ts +19 -1
- package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
- package/apps/shared/mail/permanentDelete.ts +160 -0
- package/apps/shared/mail/primaryMailbox.ts +71 -0
- package/apps/shared/mail/reportNotices.ts +66 -0
- package/apps/shared/mail/senderBlocking.ts +141 -0
- package/apps/shared/mail/useCollapsedSections.ts +146 -0
- package/apps/shared/mail/useMailConnection.ts +205 -203
- package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
- package/apps/shared/mail/useMailboxUpdateAccess.ts +60 -0
- package/apps/shared/mail/useMarkMessageRead.ts +47 -47
- package/apps/shared/mail/useNewMailNotifications.ts +178 -178
- package/apps/shared/mail/usePermanentDelete.tsx +160 -0
- package/apps/shared/navigation/frameContext.tsx +1 -1
- package/apps/shared/navigation/index.ts +43 -0
- package/apps/shared/navigation/pageTitle.ts +19 -0
- package/apps/shared/notifications/store.ts +560 -560
- package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
- package/apps/shared/search/crossMailboxSearch.ts +169 -0
- package/apps/shared/search/localIndexBuilder.ts +481 -481
- package/apps/shared/signing/enrollmentStorage.ts +33 -33
- package/apps/shared/signing/enrollmentTracker.ts +385 -385
- package/apps/shared/signing/enrollmentView.ts +251 -251
- package/apps/shared/signing/useNow.ts +19 -19
- package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
- package/apps/shared/styles/app.css +396 -386
- package/apps/www/_shell.tsx +82 -0
- package/apps/www/calendar/index.tsx +581 -571
- package/apps/www/contacts/[uid].tsx +112 -109
- package/apps/www/contacts/index.tsx +14 -3
- package/apps/www/index.tsx +2962 -2479
- package/apps/www/messages/[uid].tsx +139 -135
- package/apps/www/settings/appearance/index.tsx +5 -2
- package/apps/www/settings/auto-reply/index.tsx +136 -133
- package/apps/www/settings/blocked-senders/index.tsx +303 -0
- package/apps/www/settings/encryption/index.tsx +1290 -1287
- package/apps/www/settings/filters/[uid].tsx +179 -176
- package/apps/www/settings/filters/index.tsx +105 -102
- package/apps/www/settings/filters/new/index.tsx +165 -140
- package/apps/www/settings/labels/index.tsx +207 -204
- package/apps/www/settings/privacy/index.tsx +495 -492
- package/apps/www/settings/profile/index.tsx +251 -248
- package/apps/www/settings/read-receipts/index.tsx +150 -147
- package/apps/www/settings/sharing/index.tsx +259 -256
- package/apps/www/settings/signatures/[uid].tsx +175 -172
- package/apps/www/settings/signatures/index.tsx +91 -88
- package/apps/www/settings/signatures/new/index.tsx +138 -135
- package/apps/www/tasks/index.tsx +654 -649
- package/dist/apps/admin/data-requests/index.js +1 -1
- package/dist/apps/admin/distribution-lists/new/index.js +3 -1
- package/dist/apps/admin/domains/[uid].js +3 -1
- package/dist/apps/admin/domains/new/index.js +3 -1
- package/dist/apps/admin/escrow-scopes/[uid].js +3 -1
- package/dist/apps/admin/escrow-scopes/new/index.js +3 -1
- package/dist/apps/admin/index.js +5 -2
- package/dist/apps/admin/mailboxes/[uid].js +15 -3
- package/dist/apps/admin/mailboxes/new/index.js +3 -1
- package/dist/apps/admin/transport-rules/new/index.js +3 -1
- package/dist/apps/escrow/matters/new/index.js +3 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +6 -3
- package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
- package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +25 -3
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +3 -1
- package/dist/apps/shared/components/calendar/EventEditor.js +5 -10
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -2
- package/dist/apps/shared/components/calendar/EventFormParts.js +5 -16
- package/dist/apps/shared/components/calendar/GuestInput.d.ts +45 -0
- package/dist/apps/shared/components/calendar/GuestInput.js +30 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +11 -22
- package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -2
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +21 -8
- package/dist/apps/shared/components/calendar/eventFormat.js +50 -23
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +5 -4
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +7 -5
- package/dist/apps/shared/components/layout/AppShell.d.ts +8 -11
- package/dist/apps/shared/components/layout/AppShell.js +24 -33
- package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
- package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +4 -1
- package/dist/apps/shared/components/layout/UserMenu.js +6 -5
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
- package/dist/apps/shared/components/mail/EmptyFolderBar.d.ts +18 -0
- package/dist/apps/shared/components/mail/EmptyFolderBar.js +10 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +14 -4
- package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -13
- package/dist/apps/shared/components/mail/MenuButton.d.ts +9 -2
- package/dist/apps/shared/components/mail/MenuButton.js +15 -11
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +5 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +147 -7
- package/dist/apps/shared/components/mail/PermanentDeleteDialog.d.ts +19 -0
- package/dist/apps/shared/components/mail/PermanentDeleteDialog.js +11 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -4
- package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
- package/dist/apps/shared/components/mail/layout/FolderBadgeChip.d.ts +6 -0
- package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +16 -7
- package/dist/apps/shared/components/mail/layout/MailShell.js +78 -65
- package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
- package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
- package/dist/apps/shared/components/mail/reading/MessageMoreMenu.d.ts +56 -0
- package/dist/apps/shared/components/mail/reading/MessageMoreMenu.js +153 -0
- package/dist/apps/shared/components/mail/reading/MessageSourceDialog.d.ts +27 -0
- package/dist/apps/shared/components/mail/reading/MessageSourceDialog.js +27 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
- package/dist/apps/shared/components/mail/reading/messageExport.d.ts +19 -0
- package/dist/apps/shared/components/mail/reading/messageExport.js +51 -0
- package/dist/apps/shared/components/mail/reading/printMessage.d.ts +30 -0
- package/dist/apps/shared/components/mail/reading/printMessage.js +70 -0
- package/dist/apps/shared/components/mail/reading/useMessageActions.d.ts +57 -0
- package/dist/apps/shared/components/mail/reading/useMessageActions.js +366 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +10 -7
- package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +7 -5
- package/dist/apps/shared/keyboard/GlobalShortcuts.js +1 -1
- package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
- package/dist/apps/shared/mail/folderCounts.js +12 -0
- package/dist/apps/shared/mail/folderOfType.d.ts +13 -0
- package/dist/apps/shared/mail/folderOfType.js +40 -0
- package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
- package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
- package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
- package/dist/apps/shared/mail/permanentDelete.d.ts +62 -0
- package/dist/apps/shared/mail/permanentDelete.js +127 -0
- package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
- package/dist/apps/shared/mail/primaryMailbox.js +61 -0
- package/dist/apps/shared/mail/reportNotices.d.ts +21 -0
- package/dist/apps/shared/mail/reportNotices.js +47 -0
- package/dist/apps/shared/mail/senderBlocking.d.ts +26 -0
- package/dist/apps/shared/mail/senderBlocking.js +92 -0
- package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
- package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
- package/dist/apps/shared/mail/useMailConnection.js +3 -1
- package/dist/apps/shared/mail/useMailboxUpdateAccess.d.ts +11 -0
- package/dist/apps/shared/mail/useMailboxUpdateAccess.js +52 -0
- package/dist/apps/shared/mail/usePermanentDelete.d.ts +35 -0
- package/dist/apps/shared/mail/usePermanentDelete.js +96 -0
- package/dist/apps/shared/navigation/frameContext.d.ts +1 -1
- package/dist/apps/shared/navigation/index.d.ts +16 -0
- package/dist/apps/shared/navigation/index.js +34 -0
- package/dist/apps/shared/navigation/pageTitle.d.ts +11 -0
- package/dist/apps/shared/navigation/pageTitle.js +12 -0
- package/dist/apps/shared/search/crossMailboxSearch.d.ts +72 -0
- package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
- package/dist/apps/shared/styles/app.css +396 -386
- package/dist/apps/www/_shell.d.ts +26 -0
- package/dist/apps/www/_shell.js +31 -0
- package/dist/apps/www/calendar/index.d.ts +7 -2
- package/dist/apps/www/calendar/index.js +7 -4
- package/dist/apps/www/contacts/[uid].d.ts +14 -3
- package/dist/apps/www/contacts/[uid].js +5 -3
- package/dist/apps/www/contacts/index.d.ts +7 -2
- package/dist/apps/www/contacts/index.js +8 -4
- package/dist/apps/www/index.d.ts +7 -2
- package/dist/apps/www/index.js +542 -207
- package/dist/apps/www/messages/[uid].d.ts +14 -3
- package/dist/apps/www/messages/[uid].js +7 -5
- package/dist/apps/www/settings/appearance/index.d.ts +8 -2
- package/dist/apps/www/settings/appearance/index.js +4 -2
- package/dist/apps/www/settings/auto-reply/index.d.ts +7 -2
- package/dist/apps/www/settings/auto-reply/index.js +4 -2
- package/dist/apps/www/settings/blocked-senders/index.d.ts +9 -0
- package/dist/apps/www/settings/blocked-senders/index.js +134 -0
- package/dist/apps/www/settings/encryption/index.d.ts +7 -2
- package/dist/apps/www/settings/encryption/index.js +4 -2
- package/dist/apps/www/settings/filters/[uid].d.ts +7 -2
- package/dist/apps/www/settings/filters/[uid].js +4 -2
- package/dist/apps/www/settings/filters/index.d.ts +7 -2
- package/dist/apps/www/settings/filters/index.js +4 -2
- package/dist/apps/www/settings/filters/new/index.d.ts +7 -2
- package/dist/apps/www/settings/filters/new/index.js +28 -5
- package/dist/apps/www/settings/labels/index.d.ts +7 -2
- package/dist/apps/www/settings/labels/index.js +4 -2
- package/dist/apps/www/settings/privacy/index.d.ts +7 -2
- package/dist/apps/www/settings/privacy/index.js +4 -2
- package/dist/apps/www/settings/profile/index.d.ts +7 -2
- package/dist/apps/www/settings/profile/index.js +4 -2
- package/dist/apps/www/settings/read-receipts/index.d.ts +7 -2
- package/dist/apps/www/settings/read-receipts/index.js +4 -2
- package/dist/apps/www/settings/sharing/index.d.ts +7 -2
- package/dist/apps/www/settings/sharing/index.js +4 -2
- package/dist/apps/www/settings/signatures/[uid].d.ts +7 -2
- package/dist/apps/www/settings/signatures/[uid].js +4 -2
- package/dist/apps/www/settings/signatures/index.d.ts +7 -2
- package/dist/apps/www/settings/signatures/index.js +4 -2
- package/dist/apps/www/settings/signatures/new/index.d.ts +7 -2
- package/dist/apps/www/settings/signatures/new/index.js +5 -3
- package/dist/apps/www/tasks/index.d.ts +7 -2
- package/dist/apps/www/tasks/index.js +5 -3
- package/package.json +4 -2
- package/apps/shared/navigation/AppRouter.tsx +0 -300
- package/apps/shared/navigation/idle.ts +0 -45
- package/apps/shared/navigation/routerContext.tsx +0 -83
- package/apps/shared/navigation/routes.ts +0 -72
- package/apps/www/_routedPage.tsx +0 -24
- package/apps/www/_routes.ts +0 -37
- package/dist/apps/shared/navigation/AppRouter.d.ts +0 -52
- package/dist/apps/shared/navigation/AppRouter.js +0 -242
- package/dist/apps/shared/navigation/idle.d.ts +0 -14
- package/dist/apps/shared/navigation/idle.js +0 -43
- package/dist/apps/shared/navigation/routerContext.d.ts +0 -37
- package/dist/apps/shared/navigation/routerContext.js +0 -56
- package/dist/apps/shared/navigation/routes.d.ts +0 -32
- package/dist/apps/shared/navigation/routes.js +0 -37
- package/dist/apps/www/_routedPage.d.ts +0 -12
- package/dist/apps/www/_routedPage.js +0 -19
- package/dist/apps/www/_routes.d.ts +0 -11
- package/dist/apps/www/_routes.js +0 -31
package/dist/apps/www/index.js
CHANGED
|
@@ -3,9 +3,9 @@ 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 {
|
|
7
|
-
import { useNavigate } from "../shared/navigation/
|
|
8
|
-
import { whenIdle } from "
|
|
6
|
+
import { pageTitle } from "../shared/navigation/pageTitle.js";
|
|
7
|
+
import { useNavigate } from "../shared/navigation/index.js";
|
|
8
|
+
import { whenIdle } from "@rapidrest/react/client";
|
|
9
9
|
import { prefetchComposeWindow } from "../shared/components/mail/compose/ComposeContext.js";
|
|
10
10
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
11
11
|
import { createPortal } from "react-dom";
|
|
@@ -16,8 +16,8 @@ import { listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
|
16
16
|
import { listConversationMessages, listConversations, } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
17
17
|
import { search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
|
|
18
18
|
import { parseSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
|
|
19
|
-
import { normalizeServerScores } from "@rapidmx/react-shared/search/searchScoring.js";
|
|
20
19
|
import { searchLocalIndex } from "../shared/search/searchTier2.js";
|
|
20
|
+
import { SEARCH_LIMITS, classifyFailure, describeFailures, hitKey, mergeSearchResults, runLimited, searchPageSize, tagHits, withTimeout, } from "../shared/search/crossMailboxSearch.js";
|
|
21
21
|
import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
22
22
|
import { useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
|
|
23
23
|
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
@@ -25,6 +25,7 @@ import MailShell, { useMailShell, } from "../shared/components/mail/layout/MailS
|
|
|
25
25
|
import MailAddress from "../shared/components/mail/MailAddress.js";
|
|
26
26
|
import OutboxRowStatus from "../shared/components/mail/OutboxRowStatus.js";
|
|
27
27
|
import { mergeFirstPage } from "../shared/mail/mergeFirstPage.js";
|
|
28
|
+
import { isOwnMailbox, orderMailboxes, primaryMailboxUid } from "../shared/mail/primaryMailbox.js";
|
|
28
29
|
import { listSnapshotKey, readListSnapshot, saveListScroll, writeListSnapshot } from "../shared/mail/listSnapshots.js";
|
|
29
30
|
import { setReadStateMany } from "../shared/mail/messageReadState.js";
|
|
30
31
|
import { useMarkMessageRead } from "../shared/mail/useMarkMessageRead.js";
|
|
@@ -37,6 +38,10 @@ import MoveToFolderDialog from "../shared/components/mail/MoveToFolderDialog.js"
|
|
|
37
38
|
import { EncryptedPreview } from "../shared/components/mail/reading/EncryptedPreview.js";
|
|
38
39
|
import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
|
|
39
40
|
import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
|
|
41
|
+
import EmptyFolderBar from "../shared/components/mail/EmptyFolderBar.js";
|
|
42
|
+
import { EMPTIABLE_FOLDER_TYPES } from "../shared/mail/permanentDelete.js";
|
|
43
|
+
import { usePermanentDelete } from "../shared/mail/usePermanentDelete.js";
|
|
44
|
+
import { useMailboxUpdateAccess } from "../shared/mail/useMailboxUpdateAccess.js";
|
|
40
45
|
import { CONVERSATION_SORT_NOTE, CONVERSATION_SORT_UNAVAILABLE, MAIL_LIST_CLASSIFICATION_FILTERS, getMailListPreferences, setMailListPreferences, sortConversations, } from "../shared/components/mail/listPreferences.js";
|
|
41
46
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
42
47
|
import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
@@ -82,12 +87,14 @@ function stripHtmlToText(html) {
|
|
|
82
87
|
*/
|
|
83
88
|
async function decryptEncryptedRows(messages, unlocked) {
|
|
84
89
|
const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER);
|
|
90
|
+
// Imported once for all the rows, by whichever needs it first.
|
|
91
|
+
let securityModule;
|
|
85
92
|
const entries = await Promise.all(encrypted.map(async (message) => {
|
|
86
93
|
try {
|
|
87
94
|
const rawMime = await getMessageRawContent(message.uid);
|
|
88
95
|
// Loaded here, on first use: the S/MIME code (PKI.js, ASN.1, X.509) is over half a megabyte and an inbox with
|
|
89
96
|
// no encrypted mail in it never needs it.
|
|
90
|
-
const { evaluateMessageSecurity } = await import("@rapidmx/react-shared/crypto/messageSecurity.js");
|
|
97
|
+
const { evaluateMessageSecurity } = await (securityModule ?? (securityModule = import("@rapidmx/react-shared/crypto/messageSecurity.js")));
|
|
91
98
|
const security = await evaluateMessageSecurity(rawMime, unlocked);
|
|
92
99
|
if (!security.subject && !security.html) {
|
|
93
100
|
return null;
|
|
@@ -107,53 +114,20 @@ async function decryptEncryptedRows(messages, unlocked) {
|
|
|
107
114
|
}
|
|
108
115
|
return result;
|
|
109
116
|
}
|
|
110
|
-
/** Merges Tier 1 (server, possibly `metadataOnly` for an encrypted message), Tier 2 (local index, fully
|
|
111
|
-
* decrypted and re-scored), and Tier 3 (server-narrowed candidates, decrypted and re-scored) results into
|
|
112
|
-
* one ranked list, per `specs/search.md` §7's "client MUST re-score all results it can see... normalise
|
|
113
|
-
* into the same space rather than interleaving raw scores": each tier is normalized independently via
|
|
114
|
-
* `normalizeServerScores()` before merging, since a Postgres/OpenSearch score, a local `bm25()` score,
|
|
115
|
-
* and this module's own Tier 3 term-count score all occupy unrelated ranges. A uid present in more than
|
|
116
|
-
* one list keeps only the last-inserted entry (Tier 2 wins over Tier 3 wins over Tier 1) - Tier 2 and
|
|
117
|
-
* Tier 3 both represent genuine, content-verified scores for the same message, so which one "wins" on
|
|
118
|
-
* overlap doesn't change correctness, only which of two equally-valid scores is shown; either supersedes
|
|
119
|
-
* Tier 1's metadata-only guess for the same uid.
|
|
120
|
-
*
|
|
121
|
-
* Called progressively - once per tier as it resolves, each time with whatever tiers have reported so
|
|
122
|
-
* far (an empty array for the rest) - by `InboxContent`'s own search orchestration below, per §_Progressive
|
|
123
|
-
* Results_' "reordering is permitted and preferred over appending." This function itself stays pure and
|
|
124
|
-
* stateless; it has no notion of "in progress" versus "final." */
|
|
125
|
-
function mergeSearchResults(tier1, tier2, tier3) {
|
|
126
|
-
const normalizedTier1 = normalizeServerScores(tier1);
|
|
127
|
-
const normalizedTier2 = normalizeServerScores(tier2);
|
|
128
|
-
const normalizedTier3 = normalizeServerScores(tier3);
|
|
129
|
-
const merged = new Map();
|
|
130
|
-
for (const entry of normalizedTier1) {
|
|
131
|
-
merged.set(entry.result.entityUid, entry);
|
|
132
|
-
}
|
|
133
|
-
for (const entry of normalizedTier3) {
|
|
134
|
-
merged.set(entry.result.entityUid, entry);
|
|
135
|
-
}
|
|
136
|
-
for (const entry of normalizedTier2) {
|
|
137
|
-
merged.set(entry.result.entityUid, entry);
|
|
138
|
-
}
|
|
139
|
-
return Array.from(merged.values())
|
|
140
|
-
.sort((a, b) => b.normalizedScore - a.normalizedScore)
|
|
141
|
-
.map((entry) => entry.result);
|
|
142
|
-
}
|
|
143
117
|
/** `type:` narrows `entityTypes`; when absent this still defaults to `["message"]` — a non-message hit
|
|
144
118
|
* (contact/calendarEvent/note/task) has no `Message` to resolve via `getMessage()` below and is simply
|
|
145
119
|
* dropped by the same eventually-consistent-index fallback that already existed, rather than rendered
|
|
146
120
|
* (this inbox list only ever shows message rows; a real multi-entity-type results view is a separate,
|
|
147
121
|
* larger UI project outside this pass). Shared by both the fresh-search orchestration and `loadMore()`
|
|
148
122
|
* below, which each build this from the same `ParsedSearchQuery` differently only in `cursor`. */
|
|
149
|
-
function tier1SearchParams(parsed, cursor, mailboxUid) {
|
|
123
|
+
function tier1SearchParams(parsed, cursor, mailboxUid, limit) {
|
|
150
124
|
return {
|
|
151
|
-
// The mailbox
|
|
152
|
-
// wrong one whenever a shared mailbox's folder is being viewed.
|
|
125
|
+
// The mailbox being searched - omitted, the server searches the caller's *own* mailbox, which is the
|
|
126
|
+
// wrong one whenever a shared mailbox's folder is being viewed (or several mailboxes are searched at once).
|
|
153
127
|
mailboxUid,
|
|
154
128
|
types: parsed.entityTypes ?? ["message"],
|
|
155
129
|
cursor,
|
|
156
|
-
limit
|
|
130
|
+
limit,
|
|
157
131
|
from: parsed.from,
|
|
158
132
|
to: parsed.to,
|
|
159
133
|
cc: parsed.cc,
|
|
@@ -239,9 +213,10 @@ function tier3Windows(parsed, coverage, searchAllMail) {
|
|
|
239
213
|
}
|
|
240
214
|
/** Runs Tier 3 over each window and merges the candidates (a uid can't match in two disjoint windows, but a
|
|
241
215
|
* message whose date sits exactly on a boundary may come back from both). */
|
|
242
|
-
async function searchTier3Windows(windows, unlocked, mailboxUid) {
|
|
243
|
-
// Loaded on first use, with the S/MIME code it decrypts through (see `decryptEncryptedRows()`)
|
|
244
|
-
|
|
216
|
+
async function searchTier3Windows(windows, unlocked, mailboxUid, loadTier3) {
|
|
217
|
+
// Loaded on first use, with the S/MIME code it decrypts through (see `decryptEncryptedRows()`) - once for however many mailboxes a search
|
|
218
|
+
// covers: `loadTier3` hands every one of them the same import.
|
|
219
|
+
const { searchEncryptedCandidates } = await loadTier3();
|
|
245
220
|
const pages = await Promise.all(windows.map((window) => searchEncryptedCandidates(window, unlocked, TIER3_CANDIDATE_LIMIT, { mailboxUid })));
|
|
246
221
|
const merged = new Map();
|
|
247
222
|
for (const result of pages.flat()) {
|
|
@@ -256,6 +231,10 @@ async function searchTier3Windows(windows, unlocked, mailboxUid) {
|
|
|
256
231
|
function decodeCursor(raw, fingerprint) {
|
|
257
232
|
return raw?.fingerprint === fingerprint ? raw : undefined;
|
|
258
233
|
}
|
|
234
|
+
/** The mailboxes a search's `cursor` can still be paged for. */
|
|
235
|
+
function mailboxesWithMore(cursor) {
|
|
236
|
+
return [...cursor.perMailbox].filter(([, mailboxCursor]) => mailboxCursor.more).map(([uid]) => uid);
|
|
237
|
+
}
|
|
259
238
|
/** Resolves every hit's `entityUid` to a full `Message` via `getMessage()`, reusing `cache` across
|
|
260
239
|
* repeated calls within the same search pass - `InboxContent`'s own search orchestration below re-merges
|
|
261
240
|
* and re-resolves the *entire* current hit set each time a tier resolves, so without this cache every
|
|
@@ -370,17 +349,18 @@ async function fetchAggregateMessages(mailboxFolders, type, filter) {
|
|
|
370
349
|
}));
|
|
371
350
|
return mergeInboxMessages(perMailbox);
|
|
372
351
|
}
|
|
373
|
-
/** Groups search results by the conversation each message belongs to
|
|
352
|
+
/** Groups search results by the conversation each message belongs to - within its own mailbox - the groups in the order of their first result. */
|
|
374
353
|
function groupByConversation(results) {
|
|
375
354
|
const groups = new Map();
|
|
376
355
|
for (const message of results) {
|
|
377
356
|
const id = message.conversationId ?? message.uid;
|
|
378
|
-
const
|
|
357
|
+
const key = `${message.mailboxUid}\u0000${id}`;
|
|
358
|
+
const group = groups.get(key);
|
|
379
359
|
if (group) {
|
|
380
360
|
group.messages.push(message);
|
|
381
361
|
}
|
|
382
362
|
else {
|
|
383
|
-
groups.set(
|
|
363
|
+
groups.set(key, { id, mailboxUid: message.mailboxUid, messages: [message] });
|
|
384
364
|
}
|
|
385
365
|
}
|
|
386
366
|
return [...groups.values()];
|
|
@@ -389,7 +369,7 @@ function InboxPage(props) {
|
|
|
389
369
|
return (_jsx(MailShell, { ...props, children: _jsx(InboxContent, { userUid: props.userUid }) }));
|
|
390
370
|
}
|
|
391
371
|
function InboxContent({ userUid }) {
|
|
392
|
-
const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot } = useMailShell();
|
|
372
|
+
const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange, folderCountOf, mobileSearchSlot } = useMailShell();
|
|
393
373
|
const isMobile = useIsMobile();
|
|
394
374
|
const navigate = useNavigate();
|
|
395
375
|
const { requestUnlock } = useUnlockPrompt();
|
|
@@ -413,6 +393,7 @@ function InboxContent({ userUid }) {
|
|
|
413
393
|
const [selectedUid, setSelectedUid] = useState(null);
|
|
414
394
|
// The thread the conversation list opened, and which of its messages was picked - the reading pane
|
|
415
395
|
// shows the whole conversation, positioned at that message (see `ConversationThreadPane`).
|
|
396
|
+
// The mailbox is the open thread's own: a search over several mailboxes can open one that is not the page's mailbox.
|
|
416
397
|
const [openThread, setOpenThread] = useState(null);
|
|
417
398
|
// Messages the page has a newer copy of than `ConversationList` fetched (so far only the one the reading
|
|
418
399
|
// pane just marked read), applied over its own child rows so they don't stay bold after being read.
|
|
@@ -431,6 +412,8 @@ function InboxContent({ userUid }) {
|
|
|
431
412
|
// or it would act on a selection that is still arriving.
|
|
432
413
|
const [resolvingSelection, setResolvingSelection] = useState(0);
|
|
433
414
|
const [bulkBusy, setBulkBusy] = useState(false);
|
|
415
|
+
// Delete on what is already in Deleted Items, and Empty folder: confirmed, then done for good (see `usePermanentDelete()`).
|
|
416
|
+
const permanent = usePermanentDelete();
|
|
434
417
|
// Bumped to force the list effect below to re-run - a bulk update is deliberately neither atomic nor
|
|
435
418
|
// all-or-nothing (see `bulkUpdateMessages()`), so a rejection means refetching rather than guessing
|
|
436
419
|
// which half of the selection actually landed.
|
|
@@ -445,9 +428,15 @@ function InboxContent({ userUid }) {
|
|
|
445
428
|
// search and aggregate views it can be, and its labels must not be offered as a filter for this one.
|
|
446
429
|
// Empty (and never fetched) otherwise - see `labels` below.
|
|
447
430
|
const [otherMailboxLabels, setOtherMailboxLabels] = useState([]);
|
|
448
|
-
// Tier 2's own reported window coverage for the current search -
|
|
449
|
-
//
|
|
450
|
-
const [
|
|
431
|
+
// Tier 2's own reported window coverage for each mailbox the current search covers - empty outside a search, and
|
|
432
|
+
// holding nothing for a mailbox until Tier 2 has resolved for it in this search pass (or at all, for one without a local index).
|
|
433
|
+
const [coverages, setCoverages] = useState({});
|
|
434
|
+
// The mailboxes the current search could not (fully) search, and why - only a search over several mailboxes carries on past a
|
|
435
|
+
// failed one (a search of one mailbox fails as a whole, as it always has).
|
|
436
|
+
const [searchFailures, setSearchFailures] = useState([]);
|
|
437
|
+
// "Search all mailboxes" from a mailbox's own folder: the search is widened to every mailbox the reader can read. Reset with the search
|
|
438
|
+
// (see the effects below), and by leaving the folder.
|
|
439
|
+
const [searchAllMailboxes, setSearchAllMailboxes] = useState(false);
|
|
451
440
|
// Keyed by message uid - see decryptEncryptedRows(). Never cleared on folder/search switches (a
|
|
452
441
|
// decrypted row stays decrypted while its keys stay unlocked; re-decrypting on every navigation would
|
|
453
442
|
// waste work for no benefit) - only cleared when the mailbox's keys are locked (see the
|
|
@@ -481,8 +470,11 @@ function InboxContent({ userUid }) {
|
|
|
481
470
|
// aggregate-view row from a *different*, not-yet-unlocked mailbox stays locked until that mailbox's
|
|
482
471
|
// own folder view is opened directly - an accepted limitation, not a bug (see `MailShell`'s own doc
|
|
483
472
|
// comment on the same tradeoff for its `LocalIndexLifecycle`/`KeyEnrollmentGate` wiring).
|
|
484
|
-
|
|
473
|
+
// Always set once this page renders: a caller with no mailbox at all gets `MailboxProvisioning` from the shell instead.
|
|
474
|
+
const activeMailboxUid = (mailboxUid ?? primaryMailboxUid(mailboxes, userUid));
|
|
485
475
|
const mailboxKeys = mailboxes.find((mb) => mb.uid === activeMailboxUid)?.keys ?? [];
|
|
476
|
+
// False for a mailbox shared with the reader view-only: Empty folder is not offered there (the server would refuse it).
|
|
477
|
+
const mailboxWritable = useMailboxUpdateAccess(mailboxes.find((mb) => mb.uid === activeMailboxUid));
|
|
486
478
|
// The Sort/Filter menus' and "Show as conversations"' current settings, remembered per mailbox across
|
|
487
479
|
// reloads (`listPreferences.ts`). Read during render, not in an effect, so the very first listing
|
|
488
480
|
// already uses the remembered arrangement rather than fetching the default one and immediately
|
|
@@ -496,14 +488,32 @@ function InboxContent({ userUid }) {
|
|
|
496
488
|
setPreferencesByMailbox((prev) => ({ ...prev, [activeMailboxUid]: next }));
|
|
497
489
|
setMailListPreferences(activeMailboxUid, next);
|
|
498
490
|
}
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
491
|
+
// What a search covers. In a mailbox's own folder it is that mailbox (all of its folders, as it always was); in an "All mailboxes" view - or
|
|
492
|
+
// once the reader has widened it from a folder - it is every mailbox they can read, the very set those views aggregate: the ones whose
|
|
493
|
+
// folders could be listed (a mailbox whose listing failed, say a delegate grant without mail access, is not readable). Tier 1/2/3 are all
|
|
494
|
+
// mailbox-scoped, so a search over several is the same per-mailbox search once for each, merged (see the search effect below). Own
|
|
495
|
+
// mailboxes come first, so the cap on how many are searched at once leaves out shared ones before the reader's own.
|
|
496
|
+
const readableMailboxes = mailboxFolders.filter((mf) => !mf.error).map((mf) => mf.mailbox);
|
|
497
|
+
const crossMailbox = !!aggregateFolderType || searchAllMailboxes;
|
|
498
|
+
// `in:<folder>` names a folder, and a folder belongs to one mailbox: only that mailbox can have results, so only it is searched (a folder none of
|
|
499
|
+
// them has is left to the server to say so of every mailbox).
|
|
500
|
+
const inFolderUid = crossMailbox ? parseSearchQuery(searchQuery).folderUid : undefined;
|
|
501
|
+
const mailboxesWithFolder = inFolderUid
|
|
502
|
+
? mailboxFolders.filter((mf) => !mf.error && mf.folders.some((f) => f.uid === inFolderUid)).map((mf) => mf.mailbox)
|
|
503
|
+
: [];
|
|
504
|
+
const searchableMailboxes = mailboxesWithFolder.length > 0 ? mailboxesWithFolder : readableMailboxes;
|
|
505
|
+
const searchedMailboxes = crossMailbox ? orderMailboxes(searchableMailboxes, userUid).slice(0, SEARCH_LIMITS.maxMailboxes) : [];
|
|
506
|
+
// A single mailbox's own folder always has one; a cross-mailbox search has whichever it covers.
|
|
507
|
+
const searchTargetUids = crossMailbox ? searchedMailboxes.map((mb) => mb.uid) : [mailboxUid];
|
|
508
|
+
// A dependency of the search effect, which can't take the array itself.
|
|
509
|
+
const searchTargetKey = searchTargetUids.join(",");
|
|
502
510
|
// Search works whichever way the list is arranged. Its results are messages, so while a query is held the list is fetched as
|
|
503
511
|
// messages (`asConversations` is about the list's data and is false then), and when the reader arranges by conversation they are
|
|
504
512
|
// shown grouped under their conversation - only the messages that match - with the whole thread in the reading pane
|
|
505
513
|
// (`threadPane`, which follows the arrangement the reader chose and not the query).
|
|
506
|
-
const isSearching = searchQuery.length > 0
|
|
514
|
+
const isSearching = searchQuery.length > 0;
|
|
515
|
+
/** How many rows the list on screen keeps: the same 500 for a search as for a listing (`SEARCH_LIMITS.maxRows`, which a test can shrink). */
|
|
516
|
+
const rowCap = isSearching ? SEARCH_LIMITS.maxRows : MAX_LOADED_ROWS;
|
|
507
517
|
const asConversations = preferences.showAsConversations && !isSearching;
|
|
508
518
|
const threadPane = preferences.showAsConversations;
|
|
509
519
|
const groupedResults = isSearching && preferences.showAsConversations;
|
|
@@ -512,7 +522,8 @@ function InboxContent({ userUid }) {
|
|
|
512
522
|
// search results are ranked across folders rather than listed from one, so neither tab is offered
|
|
513
523
|
// there. Not offered for an aggregate view either (each mailbox classifies independently; merging
|
|
514
524
|
// that is out of scope).
|
|
515
|
-
const
|
|
525
|
+
const foldersOf = (ofMailbox) => mailboxFolders.find((mf) => mf.mailbox.uid === ofMailbox)?.folders ?? [];
|
|
526
|
+
const currentFolders = foldersOf(activeMailboxUid);
|
|
516
527
|
const currentFolderIsInbox = currentFolders.find((f) => f.uid === folderUid)?.type === "inbox";
|
|
517
528
|
const offerClassificationFilters = currentFolderIsInbox && !isSearching && !aggregateFolderType;
|
|
518
529
|
// A remembered Focused/Other filter must not silently narrow a folder that has no Focused Inbox to
|
|
@@ -581,7 +592,7 @@ function InboxContent({ userUid }) {
|
|
|
581
592
|
const resolvedMessageCacheRef = useRef(new Map());
|
|
582
593
|
// Session-scoped, never explicitly cleared - see Tier3Cache's own doc comment above.
|
|
583
594
|
const tier3CacheRef = useRef(new Map());
|
|
584
|
-
// The latest
|
|
595
|
+
// The latest cursor this search pass has reached (one composite cursor per mailbox searched) - read by loadMore(), written at the end
|
|
585
596
|
// of both the fresh-search orchestration and loadMore() itself. Not React state: it never drives a
|
|
586
597
|
// render on its own, only what loadMore() does with it later.
|
|
587
598
|
const compositeCursorRef = useRef(undefined);
|
|
@@ -599,33 +610,34 @@ function InboxContent({ userUid }) {
|
|
|
599
610
|
// state update elsewhere). New rows (a fresh page load, load-more, or a completed search) each
|
|
600
611
|
// re-trigger this the normal way, by changing `messages` itself.
|
|
601
612
|
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
// own doc comment above)
|
|
605
|
-
|
|
606
|
-
const undecryptedEncryptedUids =
|
|
607
|
-
.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && !decryptedRows[m.uid] && m.mailboxUid === activeMailboxUid)
|
|
608
|
-
.map((m) => m.uid);
|
|
613
|
+
// The auto-decrypt below covers every mailbox whose keys are unlocked - in aggregate mode and in a search over several mailboxes `messages`
|
|
614
|
+
// spans them, and a mailbox unlocked from the search's own banner has rows to show. The manual unlock banner (`handleUnlockList()`) is
|
|
615
|
+
// still about `activeMailboxUid` only (see its own doc comment above): a row of a locked mailbox stays "Encrypted message".
|
|
616
|
+
const undecryptedEncrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && !decryptedRows[m.uid]);
|
|
617
|
+
const undecryptedEncryptedUids = undecryptedEncrypted.filter((m) => m.mailboxUid === activeMailboxUid).map((m) => m.uid);
|
|
609
618
|
// Once unlocked, silently decrypt this page's own encrypted rows to show their real subject/preview -
|
|
610
619
|
// no prompt needed here, the same way searchEncryptedCandidates() already auto-includes decrypted
|
|
611
620
|
// matches once unlocked without asking again. Only the *first* unlock (or a fresh page of messages
|
|
612
621
|
// arriving) needs this; `handleUnlockList()` below covers the not-yet-unlocked case explicitly.
|
|
613
622
|
useEffect(() => {
|
|
614
|
-
if (undecryptedEncryptedUids.length === 0 || !activeMailboxUid) {
|
|
615
|
-
return;
|
|
616
|
-
}
|
|
617
|
-
const unlocked = getUnlockedKeys(activeMailboxUid);
|
|
618
|
-
if (!unlocked) {
|
|
619
|
-
return;
|
|
620
|
-
}
|
|
621
623
|
let cancelled = false;
|
|
622
624
|
const lockGeneration = lockGenerationRef.current;
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
625
|
+
const rowsByMailbox = new Map();
|
|
626
|
+
for (const message of undecryptedEncrypted) {
|
|
627
|
+
rowsByMailbox.set(message.mailboxUid, [...(rowsByMailbox.get(message.mailboxUid) ?? []), message]);
|
|
628
|
+
}
|
|
629
|
+
for (const [rowsMailboxUid, rows] of rowsByMailbox) {
|
|
630
|
+
const unlocked = getUnlockedKeys(rowsMailboxUid);
|
|
631
|
+
if (!unlocked) {
|
|
632
|
+
continue;
|
|
627
633
|
}
|
|
628
|
-
|
|
634
|
+
void decryptEncryptedRows(rows, unlocked).then((decrypted) => {
|
|
635
|
+
// A lock while this was in flight already cleared `decryptedRows` - never put them back.
|
|
636
|
+
if (!cancelled && lockGeneration === lockGenerationRef.current && Object.keys(decrypted).length > 0) {
|
|
637
|
+
setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
}
|
|
629
641
|
return () => {
|
|
630
642
|
cancelled = true;
|
|
631
643
|
};
|
|
@@ -635,11 +647,13 @@ function InboxContent({ userUid }) {
|
|
|
635
647
|
// snippets (Tier 2/3 snippets are decrypted content), and the already-decrypted Tier 3 candidates. A
|
|
636
648
|
// live search re-runs, so Tier 2/3 contribute nothing again until the user unlocks. Read through a ref
|
|
637
649
|
// so the subscription itself doesn't churn on every render.
|
|
638
|
-
const keyLockStateRef = useRef({ activeMailboxUid, isSearching });
|
|
639
|
-
keyLockStateRef.current = { activeMailboxUid, isSearching };
|
|
650
|
+
const keyLockStateRef = useRef({ activeMailboxUid, isSearching, searchTargetUids });
|
|
651
|
+
keyLockStateRef.current = { activeMailboxUid, isSearching, searchTargetUids };
|
|
640
652
|
useEffect(() => subscribeKeySession(({ mailboxUid: changedMailboxUid, state }) => {
|
|
641
653
|
const current = keyLockStateRef.current;
|
|
642
|
-
|
|
654
|
+
// The open mailbox's, or - while searching - any mailbox the search covers: their decrypted hits are on screen too.
|
|
655
|
+
const relevant = changedMailboxUid === current.activeMailboxUid || (current.isSearching && current.searchTargetUids.includes(changedMailboxUid));
|
|
656
|
+
if (state !== "locked" || !relevant) {
|
|
643
657
|
return;
|
|
644
658
|
}
|
|
645
659
|
lockGenerationRef.current += 1;
|
|
@@ -663,8 +677,30 @@ function InboxContent({ userUid }) {
|
|
|
663
677
|
// User dismissed the unlock dialog - rows stay exactly as they were.
|
|
664
678
|
}
|
|
665
679
|
}
|
|
680
|
+
/** The mailboxes a search over several could include encrypted mail from if they were unlocked: the ones with keys that are locked right now.
|
|
681
|
+
* `unlockable` are the reader's own - a shared mailbox's keys belong to its owner, and unlocking them is not something this page can ask for. */
|
|
682
|
+
const lockedSearchMailboxes = crossMailbox ? searchedMailboxes.filter((mb) => (mb.keys?.length ?? 0) > 0 && !getUnlockedKeys(mb.uid)) : [];
|
|
683
|
+
const unlockableSearchMailboxes = lockedSearchMailboxes.filter((mb) => isOwnMailbox(mb, userUid));
|
|
684
|
+
const sharedLockedSearchMailboxes = lockedSearchMailboxes.filter((mb) => !isOwnMailbox(mb, userUid));
|
|
685
|
+
/** Unlocked mailboxes with encrypted mail to search that have no local index on this device (the index is only built for the open mailbox), so
|
|
686
|
+
* only Tier 3 - server-narrowed, bounded - reaches their encrypted mail. */
|
|
687
|
+
const serverOnlyMailboxes = crossMailbox
|
|
688
|
+
? searchedMailboxes.filter((mb) => (mb.keys?.length ?? 0) > 0 && !!getUnlockedKeys(mb.uid) && !coverages[mb.uid]?.indexedFrom)
|
|
689
|
+
: [];
|
|
690
|
+
/** The one mailbox's own coverage, for a search of one. */
|
|
691
|
+
const coverage = coverages[mailboxUid ?? ""];
|
|
692
|
+
/** A folder's search can be widened to every mailbox, when there is more than one to widen it to. */
|
|
693
|
+
const widenable = !aggregateFolderType && readableMailboxes.length > 1;
|
|
666
694
|
async function handleUnlockSearch() {
|
|
667
695
|
try {
|
|
696
|
+
if (crossMailbox) {
|
|
697
|
+
// One prompt per locked mailbox of the reader's own, in turn; a dismissed one stops the rest.
|
|
698
|
+
for (const mailbox of unlockableSearchMailboxes) {
|
|
699
|
+
await requestUnlock(mailbox.uid, mailbox.keys);
|
|
700
|
+
setUnlockRefresh((n) => n + 1);
|
|
701
|
+
}
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
668
704
|
await requestUnlock(activeMailboxUid, mailboxKeys);
|
|
669
705
|
setUnlockRefresh((n) => n + 1);
|
|
670
706
|
}
|
|
@@ -679,7 +715,7 @@ function InboxContent({ userUid }) {
|
|
|
679
715
|
// `activeMailboxUid`, and offering that mailbox's labels would let a label from the wrong mailbox be
|
|
680
716
|
// applied. The conversation view stays scoped to `activeMailboxUid`, the mailbox its threads come from.
|
|
681
717
|
const selectedMessageMailboxUid = messages.find((m) => m.uid === selectedUid)?.mailboxUid;
|
|
682
|
-
const labelsMailboxUid = threadPane ? activeMailboxUid : (selectedMessageMailboxUid ?? activeMailboxUid);
|
|
718
|
+
const labelsMailboxUid = threadPane ? (openThread?.mailboxUid ?? activeMailboxUid) : (selectedMessageMailboxUid ?? activeMailboxUid);
|
|
683
719
|
// Almost always the open mailbox's own labels, already fetched below - so `labels` reuses that list
|
|
684
720
|
// rather than asking for the identical one a second time (which is what this page did on every single
|
|
685
721
|
// view). Only a selected message from *another* mailbox - a search hit or an aggregate-view row - needs
|
|
@@ -735,11 +771,24 @@ function InboxContent({ userUid }) {
|
|
|
735
771
|
if (shown !== null && shown !== selectionKey) {
|
|
736
772
|
setSearchInput("");
|
|
737
773
|
setSearchQuery("");
|
|
774
|
+
setSearchAllMailboxes(false);
|
|
738
775
|
}
|
|
739
776
|
if (folderUid || aggregateFolderType) {
|
|
740
777
|
shownSelectionRef.current = selectionKey;
|
|
741
778
|
}
|
|
742
779
|
}, [selectionKey]);
|
|
780
|
+
// A search widened to every mailbox lasts for that search: once the box is emptied the next one starts in the open folder's mailbox again.
|
|
781
|
+
useEffect(() => {
|
|
782
|
+
if (searchQuery === "") {
|
|
783
|
+
setSearchAllMailboxes(false);
|
|
784
|
+
}
|
|
785
|
+
}, [searchQuery]);
|
|
786
|
+
// Rows can be ticked in the results of a search over every mailbox, but not in the listing they came out of: clearing the search leaves select mode.
|
|
787
|
+
useEffect(() => {
|
|
788
|
+
if (aggregateFolderType && !isSearching) {
|
|
789
|
+
setSelectMode(false);
|
|
790
|
+
}
|
|
791
|
+
}, [aggregateFolderType, isSearching]);
|
|
743
792
|
// Which listing this is, for the short-lived snapshot of each folder that makes going back to one instant (see
|
|
744
793
|
// `listSnapshots.ts`). A search, an aggregate view and a folder that hasn't resolved have no snapshot.
|
|
745
794
|
const listKey = listSnapshotKey({
|
|
@@ -860,7 +909,7 @@ function InboxContent({ userUid }) {
|
|
|
860
909
|
});
|
|
861
910
|
return invalidate;
|
|
862
911
|
}
|
|
863
|
-
if (aggregateFolderType) {
|
|
912
|
+
if (aggregateFolderType && !isSearching) {
|
|
864
913
|
setLoading(true);
|
|
865
914
|
setError(null);
|
|
866
915
|
// hasMore stays false (set above) - see fetchAggregateMessages()'s own pagination scope trim.
|
|
@@ -880,82 +929,183 @@ function InboxContent({ userUid }) {
|
|
|
880
929
|
setLoading(!snapshot);
|
|
881
930
|
setError(null);
|
|
882
931
|
if (isSearching) {
|
|
883
|
-
|
|
932
|
+
setCoverages({});
|
|
933
|
+
setSearchFailures([]);
|
|
884
934
|
setPendingUids(new Set());
|
|
885
935
|
setTier1Done(false);
|
|
886
936
|
setTier2Done(false);
|
|
887
937
|
setTier3Done(false);
|
|
888
938
|
resolvedMessageCacheRef.current = new Map();
|
|
889
939
|
const parsed = parseSearchQuery(searchQuery);
|
|
890
|
-
const unlocked = getUnlockedKeys(mailboxUid);
|
|
891
940
|
const fingerprint = queryFingerprint(parsed);
|
|
892
|
-
//
|
|
941
|
+
// What each mailbox is asked for per page: the whole page for one mailbox, a share of it for several.
|
|
942
|
+
const pageSize = searchPageSize(searchTargetUids.length, MESSAGE_PAGE_SIZE);
|
|
943
|
+
// Only a search over several mailboxes gives up on a slow one - a search of one is left to take as long as it takes.
|
|
944
|
+
const tier1TimeoutMs = crossMailbox ? SEARCH_LIMITS.tier1TimeoutMs : Infinity;
|
|
945
|
+
const tier3TimeoutMs = crossMailbox ? SEARCH_LIMITS.tier3TimeoutMs : Infinity;
|
|
946
|
+
const mailboxNames = new Map(searchedMailboxes.map((mb) => [mb.uid, mb.displayName]));
|
|
947
|
+
const isSuperseded = () => searchRunIdRef.current !== myRunId;
|
|
948
|
+
// Tier 3's code, imported when the first mailbox needs it and shared by the rest.
|
|
949
|
+
let tier3Module;
|
|
950
|
+
const loadTier3 = () => (tier3Module ?? (tier3Module = import("@rapidmx/react-shared/search/searchTier3.js")));
|
|
951
|
+
const failures = [];
|
|
952
|
+
/** The failed mailbox's notice entry. A search of one mailbox has none: its failure fails the search. */
|
|
953
|
+
const noteFailure = (uid, encrypted, err) => {
|
|
954
|
+
failures.push({ mailboxUid: uid, mailboxName: mailboxNames.get(uid), encrypted, reason: classifyFailure(err) });
|
|
955
|
+
};
|
|
956
|
+
// One entry per mailbox searched, in the order they are shown in - which is also the order ties are ranked in.
|
|
957
|
+
const runs = new Map(searchTargetUids.map((uid) => [
|
|
958
|
+
uid,
|
|
959
|
+
{
|
|
960
|
+
mailboxUid: uid,
|
|
961
|
+
unlocked: getUnlockedKeys(uid),
|
|
962
|
+
tier1: [],
|
|
963
|
+
tier2: [],
|
|
964
|
+
tier2More: false,
|
|
965
|
+
tier3: [],
|
|
966
|
+
tier3Total: 0,
|
|
967
|
+
tier3Key: "",
|
|
968
|
+
tier1Settled: false,
|
|
969
|
+
tier3Settled: false,
|
|
970
|
+
failed: false,
|
|
971
|
+
},
|
|
972
|
+
]));
|
|
973
|
+
const runList = [...runs.values()];
|
|
974
|
+
// A reveal that started before another one but finished after it must not put its older, smaller picture back.
|
|
975
|
+
let revealsStarted = 0;
|
|
976
|
+
let revealsApplied = 0;
|
|
977
|
+
// Recomputes and re-renders the merged list from whatever tiers have reported so far, across every mailbox -
|
|
893
978
|
// called once after Tier 1+2 (interim: unconfirmed Tier 1 metadataOnly hits render as
|
|
894
979
|
// skeletons) and again after Tier 3 (final: anything still unconfirmed is pruned instead -
|
|
895
|
-
// §_Progressive Results_' "Skeletons resolve or disappear")
|
|
980
|
+
// §_Progressive Results_' "Skeletons resolve or disappear"); a search over several mailboxes also calls it as each one
|
|
981
|
+
// reports, and a mailbox is "final" for its own hits as soon as its own Tier 3 has. Bails out if a
|
|
896
982
|
// newer search pass has since started.
|
|
897
|
-
async function reveal(
|
|
898
|
-
const
|
|
899
|
-
const
|
|
900
|
-
const
|
|
901
|
-
const
|
|
983
|
+
async function reveal() {
|
|
984
|
+
const revealId = ++revealsStarted;
|
|
985
|
+
const capped = capSkeletons(runList.flatMap((run) => run.tier1));
|
|
986
|
+
const tier2Hits = runList.flatMap((run) => run.tier2);
|
|
987
|
+
const tier3Hits = runList.flatMap((run) => run.tier3);
|
|
988
|
+
const confirmed = new Set([...tier2Hits, ...tier3Hits].map(hitKey));
|
|
989
|
+
const isUnconfirmed = (hit) => !!hit.metadataOnly && !confirmed.has(hitKey(hit));
|
|
990
|
+
const effectiveTier1 = capped.filter((hit) => !runs.get(hit.mailboxUid).tier3Settled || !isUnconfirmed(hit));
|
|
991
|
+
const merged = mergeSearchResults(effectiveTier1, tier2Hits, tier3Hits).slice(0, rowCap);
|
|
902
992
|
const { messages: resolved, snippets: resolvedSnippets } = await resolveHitsToMessages(merged, resolvedMessageCacheRef.current);
|
|
903
|
-
if (
|
|
993
|
+
if (isSuperseded() || revealId < revealsApplied) {
|
|
904
994
|
return;
|
|
905
995
|
}
|
|
996
|
+
revealsApplied = revealId;
|
|
906
997
|
setMessages(resolved);
|
|
907
998
|
setSnippets(resolvedSnippets);
|
|
908
|
-
if (
|
|
909
|
-
|
|
999
|
+
if (resolved.length > 0) {
|
|
1000
|
+
// The first mailbox to answer with anything replaces the loading skeleton.
|
|
1001
|
+
setLoading(false);
|
|
910
1002
|
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
pending.add(hit.entityUid);
|
|
916
|
-
}
|
|
1003
|
+
const pending = new Set();
|
|
1004
|
+
for (const hit of capped) {
|
|
1005
|
+
if (!runs.get(hit.mailboxUid).tier3Settled && isUnconfirmed(hit)) {
|
|
1006
|
+
pending.add(hit.entityUid);
|
|
917
1007
|
}
|
|
918
|
-
setPendingUids(pending);
|
|
919
1008
|
}
|
|
1009
|
+
setPendingUids(pending);
|
|
920
1010
|
}
|
|
921
1011
|
void (async () => {
|
|
922
1012
|
try {
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1013
|
+
// Tier 1 (server) and Tier 2 (local index) of every mailbox, a few at a time.
|
|
1014
|
+
await runLimited(searchTargetUids, SEARCH_LIMITS.concurrency, async (uid) => {
|
|
1015
|
+
const run = runs.get(uid);
|
|
1016
|
+
try {
|
|
1017
|
+
const [tier1Page, tier2Page] = await Promise.all([
|
|
1018
|
+
withTimeout(searchMailbox(parsed.text, tier1SearchParams(parsed, undefined, uid, pageSize)), tier1TimeoutMs),
|
|
1019
|
+
searchLocalIndex(uid, parsed, run.unlocked, pageSize, 0),
|
|
1020
|
+
]);
|
|
1021
|
+
if (isSuperseded()) {
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
run.tier1 = tagHits(uid, tier1Page.results);
|
|
1025
|
+
run.tier1Cursor = tier1Page.nextCursor;
|
|
1026
|
+
run.tier2 = tagHits(uid, tier2Page.results);
|
|
1027
|
+
run.tier2More = tier2Page.hasMore;
|
|
1028
|
+
run.coverage = tier2Page.coverage;
|
|
1029
|
+
}
|
|
1030
|
+
catch (err) {
|
|
1031
|
+
if (!crossMailbox) {
|
|
1032
|
+
throw err;
|
|
1033
|
+
}
|
|
1034
|
+
run.failed = true;
|
|
1035
|
+
noteFailure(uid, false, err);
|
|
1036
|
+
}
|
|
1037
|
+
run.tier1Settled = true;
|
|
1038
|
+
if (crossMailbox && runList.some((other) => !other.tier1Settled)) {
|
|
1039
|
+
void reveal();
|
|
1040
|
+
}
|
|
1041
|
+
}, isSuperseded);
|
|
1042
|
+
if (isSuperseded()) {
|
|
928
1043
|
return;
|
|
929
1044
|
}
|
|
930
1045
|
setTier1Done(true);
|
|
931
1046
|
setTier2Done(true);
|
|
932
|
-
|
|
1047
|
+
setCoverages(Object.fromEntries(runList.map((run) => [run.mailboxUid, run.coverage])));
|
|
1048
|
+
setSearchFailures([...failures]);
|
|
933
1049
|
setLoading(false);
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1050
|
+
if (failures.length === runList.length) {
|
|
1051
|
+
// Not one mailbox could be searched (or there were none to search): that is a failed search, not missing results.
|
|
1052
|
+
setError("Search failed.");
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
await reveal();
|
|
1056
|
+
// Tier 3 (server-narrowed encrypted candidates) of every mailbox that answered, each over the window its own local index
|
|
1057
|
+
// leaves uncovered - a mailbox with no local index is searched over the query's whole range.
|
|
1058
|
+
await runLimited(runList.filter((run) => !run.failed), SEARCH_LIMITS.concurrency, async (run) => {
|
|
1059
|
+
try {
|
|
1060
|
+
const windows = tier3Windows(parsed, run.coverage, searchAllMail);
|
|
1061
|
+
const cacheKey = tier3CacheKey(run.mailboxUid, windows, !!run.unlocked);
|
|
1062
|
+
let tier3Full = tier3CacheRef.current.get(cacheKey);
|
|
1063
|
+
if (!tier3Full) {
|
|
1064
|
+
tier3Full = await withTimeout(searchTier3Windows(windows, run.unlocked, run.mailboxUid, loadTier3), tier3TimeoutMs);
|
|
1065
|
+
if (isSuperseded()) {
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
tier3CacheRef.current.set(cacheKey, tier3Full);
|
|
1069
|
+
}
|
|
1070
|
+
run.tier3 = tagHits(run.mailboxUid, tier3Full.slice(0, pageSize));
|
|
1071
|
+
run.tier3Total = tier3Full.length;
|
|
1072
|
+
run.tier3Key = cacheKey;
|
|
1073
|
+
}
|
|
1074
|
+
catch (err) {
|
|
1075
|
+
if (!crossMailbox) {
|
|
1076
|
+
throw err;
|
|
1077
|
+
}
|
|
1078
|
+
noteFailure(run.mailboxUid, true, err);
|
|
942
1079
|
}
|
|
943
|
-
|
|
1080
|
+
run.tier3Settled = true;
|
|
1081
|
+
if (crossMailbox && runList.some((other) => !other.failed && !other.tier3Settled)) {
|
|
1082
|
+
void reveal();
|
|
1083
|
+
}
|
|
1084
|
+
}, isSuperseded);
|
|
1085
|
+
if (isSuperseded()) {
|
|
1086
|
+
return;
|
|
944
1087
|
}
|
|
945
|
-
const tier3Page = tier3Full.slice(0, MESSAGE_PAGE_SIZE);
|
|
946
1088
|
setTier3Done(true);
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1089
|
+
setSearchFailures([...failures]);
|
|
1090
|
+
const cursor = { fingerprint, pageSize, perMailbox: new Map() };
|
|
1091
|
+
for (const run of runList) {
|
|
1092
|
+
if (!run.failed) {
|
|
1093
|
+
cursor.perMailbox.set(run.mailboxUid, {
|
|
1094
|
+
tier1Cursor: run.tier1Cursor,
|
|
1095
|
+
tier2Offset: run.tier2.length,
|
|
1096
|
+
tier3Offset: run.tier3.length,
|
|
1097
|
+
tier3Total: run.tier3Total,
|
|
1098
|
+
tier3Key: run.tier3Key,
|
|
1099
|
+
more: !!run.tier1Cursor || run.tier2More || run.tier3.length < run.tier3Total,
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
compositeCursorRef.current = cursor;
|
|
1104
|
+
setHasMore(mailboxesWithMore(cursor).length > 0);
|
|
1105
|
+
await reveal();
|
|
956
1106
|
}
|
|
957
1107
|
catch (err) {
|
|
958
|
-
if (
|
|
1108
|
+
if (!isSuperseded()) {
|
|
959
1109
|
setError(err instanceof ApiRequestError ? err.message : "Search failed.");
|
|
960
1110
|
setLoading(false);
|
|
961
1111
|
}
|
|
@@ -1024,9 +1174,12 @@ function InboxContent({ userUid }) {
|
|
|
1024
1174
|
unlockRefresh,
|
|
1025
1175
|
searchAllMail,
|
|
1026
1176
|
aggregateFolderType,
|
|
1027
|
-
// Only a merged
|
|
1028
|
-
// the list on screen and forget its selection.
|
|
1029
|
-
|
|
1177
|
+
// Only a merged listing is built from the folder tree; for a single folder a folder appearing in the sidebar (the Outbox, Sent Items) must not reload
|
|
1178
|
+
// the list on screen and forget its selection. A search over several mailboxes is built from the *mailboxes* instead (`searchTargetKey`), so a
|
|
1179
|
+
// folder appearing while it runs does not restart it either.
|
|
1180
|
+
aggregateFolderType && !isSearching ? mailboxFolders : null,
|
|
1181
|
+
crossMailbox,
|
|
1182
|
+
searchTargetKey,
|
|
1030
1183
|
activeMailboxUid,
|
|
1031
1184
|
]);
|
|
1032
1185
|
// Which folders the messages on screen live in: one the sidebar does not know (the Outbox and Sent Items are made by the server on first use) is looked for -
|
|
@@ -1108,12 +1261,13 @@ function InboxContent({ userUid }) {
|
|
|
1108
1261
|
// Reads the ref, not `conversations`/`messages` state, so this callback (and the observer effect
|
|
1109
1262
|
// watching it) don't need to be recreated every time a page lands - the same reason every other
|
|
1110
1263
|
// check below reads a ref rather than closing over state.
|
|
1111
|
-
const atRowCap = (asConversations ? conversationsRef.current.length : messagesRef.current.length) >=
|
|
1264
|
+
const atRowCap = (asConversations ? conversationsRef.current.length : messagesRef.current.length) >= rowCap;
|
|
1112
1265
|
if (loadMoreInFlightRef.current ||
|
|
1113
1266
|
!hasMore ||
|
|
1114
1267
|
loading ||
|
|
1115
1268
|
atRowCap ||
|
|
1116
|
-
(
|
|
1269
|
+
// A search over every mailbox has no folder of its own (nor does a listing that has not resolved one).
|
|
1270
|
+
(!asConversations && !folderUid && !isSearching) ||
|
|
1117
1271
|
(isSearching && !searchCursor)) {
|
|
1118
1272
|
return;
|
|
1119
1273
|
}
|
|
@@ -1152,45 +1306,96 @@ function InboxContent({ userUid }) {
|
|
|
1152
1306
|
return;
|
|
1153
1307
|
}
|
|
1154
1308
|
const addedRows = hasUnseenRows(conversationsRef.current, more, conversationKey);
|
|
1155
|
-
setConversations((prev) => appendUnseenRows(prev, more, conversationKey,
|
|
1309
|
+
setConversations((prev) => appendUnseenRows(prev, more, conversationKey, rowCap));
|
|
1156
1310
|
setHasMore(more.length === MESSAGE_PAGE_SIZE);
|
|
1157
1311
|
listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
|
|
1158
1312
|
notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
|
|
1159
1313
|
}
|
|
1160
1314
|
else if (isSearching) {
|
|
1161
|
-
const unlocked = getUnlockedKeys(mailboxUid);
|
|
1162
1315
|
const cursor = searchCursor;
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
const
|
|
1169
|
-
|
|
1170
|
-
|
|
1316
|
+
const tier1TimeoutMs = crossMailbox ? SEARCH_LIMITS.tier1TimeoutMs : Infinity;
|
|
1317
|
+
// Every mailbox with more to give is asked for its own next page - the others (exhausted, or given up on) stay as they are.
|
|
1318
|
+
const attempted = mailboxesWithMore(cursor);
|
|
1319
|
+
const pages = new Map();
|
|
1320
|
+
const failedMailboxes = [];
|
|
1321
|
+
const failedErrors = [];
|
|
1322
|
+
await runLimited(attempted, SEARCH_LIMITS.concurrency, async (uid) => {
|
|
1323
|
+
const mailboxCursor = cursor.perMailbox.get(uid);
|
|
1324
|
+
try {
|
|
1325
|
+
const [tier1Page, tier2Page] = await Promise.all([
|
|
1326
|
+
withTimeout(searchMailbox(parsed.text, tier1SearchParams(parsed, mailboxCursor.tier1Cursor, uid, cursor.pageSize)), tier1TimeoutMs),
|
|
1327
|
+
searchLocalIndex(uid, parsed, getUnlockedKeys(uid), cursor.pageSize, mailboxCursor.tier2Offset),
|
|
1328
|
+
]);
|
|
1329
|
+
// The first page cached this pass under `tier3Key` before it created the cursor - unless Tier 3 had nothing to give.
|
|
1330
|
+
const tier3Page = mailboxCursor.tier3Offset < mailboxCursor.tier3Total
|
|
1331
|
+
? tier3CacheRef.current
|
|
1332
|
+
.get(mailboxCursor.tier3Key)
|
|
1333
|
+
.slice(mailboxCursor.tier3Offset, mailboxCursor.tier3Offset + cursor.pageSize)
|
|
1334
|
+
: [];
|
|
1335
|
+
pages.set(uid, {
|
|
1336
|
+
tier1: tagHits(uid, tier1Page.results),
|
|
1337
|
+
tier1Cursor: tier1Page.nextCursor,
|
|
1338
|
+
tier2: tagHits(uid, tier2Page.results),
|
|
1339
|
+
tier2More: tier2Page.hasMore,
|
|
1340
|
+
tier3: tagHits(uid, tier3Page),
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
catch (err) {
|
|
1344
|
+
// One mailbox failing to give its next page must not stop the others' (a search of one has nothing else to show).
|
|
1345
|
+
if (!crossMailbox) {
|
|
1346
|
+
throw err;
|
|
1347
|
+
}
|
|
1348
|
+
failedErrors.push(err);
|
|
1349
|
+
failedMailboxes.push({
|
|
1350
|
+
mailboxUid: uid,
|
|
1351
|
+
mailboxName: searchedMailboxes.find((mb) => mb.uid === uid).displayName,
|
|
1352
|
+
encrypted: false,
|
|
1353
|
+
reason: classifyFailure(err),
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
}, () => !isCurrentRun());
|
|
1357
|
+
if (failedErrors.length === attempted.length) {
|
|
1358
|
+
// No mailbox answered: that is a failed page (with Retry), not a page with fewer rows.
|
|
1359
|
+
throw failedErrors[0];
|
|
1360
|
+
}
|
|
1361
|
+
const answered = [...pages.values()];
|
|
1171
1362
|
// Unlike the fresh-search pass above, a load-more page is resolved and appended in one
|
|
1172
1363
|
// shot rather than progressively revealed - rows already on screen shouldn't reorder or
|
|
1173
1364
|
// grow skeletons out from under a reader who has since scrolled past them. Any Tier 1
|
|
1174
1365
|
// metadataOnly hit this page that neither Tier 2 nor Tier 3 (both already awaited above)
|
|
1175
1366
|
// confirms simply keeps showing its existing placeholder text rather than a live skeleton
|
|
1176
1367
|
// - a deliberate, documented scope trim of progressive reveal to the first page only.
|
|
1177
|
-
const merged = mergeSearchResults(capSkeletons(
|
|
1368
|
+
const merged = mergeSearchResults(capSkeletons(answered.flatMap((page) => page.tier1)), answered.flatMap((page) => page.tier2), answered.flatMap((page) => page.tier3));
|
|
1178
1369
|
const { messages: more, snippets: moreSnippets } = await resolveHitsToMessages(merged, resolvedMessageCacheRef.current);
|
|
1179
1370
|
if (!isCurrentRun()) {
|
|
1180
1371
|
return;
|
|
1181
1372
|
}
|
|
1182
1373
|
const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
|
|
1183
|
-
setMessages((prev) => appendUnseenRows(prev, more, messageUid,
|
|
1374
|
+
setMessages((prev) => appendUnseenRows(prev, more, messageUid, rowCap));
|
|
1184
1375
|
setSnippets((prev) => ({ ...prev, ...moreSnippets }));
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1376
|
+
setSearchFailures((prev) => [...prev, ...failedMailboxes]);
|
|
1377
|
+
const perMailbox = new Map(cursor.perMailbox);
|
|
1378
|
+
for (const uid of attempted) {
|
|
1379
|
+
const previous = cursor.perMailbox.get(uid);
|
|
1380
|
+
const page = pages.get(uid);
|
|
1381
|
+
if (!page) {
|
|
1382
|
+
// Failed just now: given up on for the rest of this search (the notice says so).
|
|
1383
|
+
perMailbox.set(uid, { ...previous, more: false });
|
|
1384
|
+
continue;
|
|
1385
|
+
}
|
|
1386
|
+
const nextTier3Offset = previous.tier3Offset + page.tier3.length;
|
|
1387
|
+
perMailbox.set(uid, {
|
|
1388
|
+
tier1Cursor: page.tier1Cursor,
|
|
1389
|
+
tier2Offset: previous.tier2Offset + page.tier2.length,
|
|
1390
|
+
tier3Offset: nextTier3Offset,
|
|
1391
|
+
tier3Total: previous.tier3Total,
|
|
1392
|
+
tier3Key: previous.tier3Key,
|
|
1393
|
+
more: !!page.tier1Cursor || page.tier2More || nextTier3Offset < previous.tier3Total,
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
const nextCursor = { ...cursor, perMailbox };
|
|
1397
|
+
compositeCursorRef.current = nextCursor;
|
|
1398
|
+
const moreRemain = mailboxesWithMore(nextCursor).length > 0;
|
|
1194
1399
|
setHasMore(moreRemain);
|
|
1195
1400
|
notePageLanded(addedRows, moreRemain);
|
|
1196
1401
|
}
|
|
@@ -1204,7 +1409,7 @@ function InboxContent({ userUid }) {
|
|
|
1204
1409
|
return;
|
|
1205
1410
|
}
|
|
1206
1411
|
const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
|
|
1207
|
-
setMessages((prev) => appendUnseenRows(prev, more, messageUid,
|
|
1412
|
+
setMessages((prev) => appendUnseenRows(prev, more, messageUid, rowCap));
|
|
1208
1413
|
setHasMore(more.length === MESSAGE_PAGE_SIZE);
|
|
1209
1414
|
listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
|
|
1210
1415
|
notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
|
|
@@ -1233,6 +1438,8 @@ function InboxContent({ userUid }) {
|
|
|
1233
1438
|
searchQuery,
|
|
1234
1439
|
mailboxUid,
|
|
1235
1440
|
activeMailboxUid,
|
|
1441
|
+
crossMailbox,
|
|
1442
|
+
searchTargetKey,
|
|
1236
1443
|
]);
|
|
1237
1444
|
// Always calls the latest `loadMore` closure so the effect below doesn't need `loadMore` itself in its
|
|
1238
1445
|
// dependency array (it changes on every keystroke/page load, which would otherwise mean nothing here).
|
|
@@ -1349,7 +1556,7 @@ function InboxContent({ userUid }) {
|
|
|
1349
1556
|
* mailbox/search whose true size lands at or near the cap can have its very last page be a partial one,
|
|
1350
1557
|
* which correctly turns `hasMore` false - without checking it here, the banner would still claim rows
|
|
1351
1558
|
* are being hidden even though every one of them is already on screen. */
|
|
1352
|
-
const rowCapReached = hasMore && (asConversations ? conversations.length : messages.length) >=
|
|
1559
|
+
const rowCapReached = hasMore && (asConversations ? conversations.length : messages.length) >= rowCap;
|
|
1353
1560
|
function leaveSelectMode() {
|
|
1354
1561
|
setSelectMode(false);
|
|
1355
1562
|
setSelectedUids(new Set());
|
|
@@ -1477,13 +1684,27 @@ function InboxContent({ userUid }) {
|
|
|
1477
1684
|
* first single-message archive, so that call both creates the folder and archives the first message,
|
|
1478
1685
|
* and the rest of the selection is then moved into the folder it reports. */
|
|
1479
1686
|
async function bulkArchive(chosen) {
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1687
|
+
return inEachMailbox(chosen, async (ofMailbox, group) => {
|
|
1688
|
+
const archiveFolderUid = foldersOf(ofMailbox).find((f) => f.type === "archive")?.uid;
|
|
1689
|
+
if (archiveFolderUid) {
|
|
1690
|
+
return moveMessages(group, archiveFolderUid);
|
|
1691
|
+
}
|
|
1692
|
+
const first = await archiveMessage(group[0].uid);
|
|
1693
|
+
const rest = group.slice(1);
|
|
1694
|
+
return rest.length === 0 ? [first] : [first, ...(await moveMessages(rest, first.folderUid))];
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* Runs `action` once for every mailbox `chosen` has messages in and gathers what each returns. Folders belong to a mailbox, so anything that
|
|
1699
|
+
* moves messages into "the" Archive or Deleted Items has to do so per mailbox: a search over several mailboxes lists hits from more than one, while
|
|
1700
|
+
* every other list holds one mailbox's messages and this is one call, as it always was.
|
|
1701
|
+
*/
|
|
1702
|
+
async function inEachMailbox(chosen, action) {
|
|
1703
|
+
const groups = new Map();
|
|
1704
|
+
for (const message of chosen) {
|
|
1705
|
+
groups.set(message.mailboxUid, [...(groups.get(message.mailboxUid) ?? []), message]);
|
|
1483
1706
|
}
|
|
1484
|
-
|
|
1485
|
-
const rest = chosen.slice(1);
|
|
1486
|
-
return rest.length === 0 ? [first] : [first, ...(await moveMessages(rest, first.folderUid))];
|
|
1707
|
+
return (await Promise.all([...groups].map(([ofMailbox, group]) => action(ofMailbox, group)))).flat();
|
|
1487
1708
|
}
|
|
1488
1709
|
/**
|
|
1489
1710
|
* This mailbox's folder of `type`. The server makes every well-known folder itself (when the mailbox is made, and again when the folders are listed
|
|
@@ -1494,22 +1715,26 @@ function InboxContent({ userUid }) {
|
|
|
1494
1715
|
* A folder found or created here may not be in `mailboxFolders` yet, so it is filed there and remembered per mailbox and type until the page
|
|
1495
1716
|
* reloads; otherwise a second Delete would ask again.
|
|
1496
1717
|
*/
|
|
1497
|
-
async function resolveFolderOfType(type, name) {
|
|
1498
|
-
const key = `${
|
|
1499
|
-
const known =
|
|
1718
|
+
async function resolveFolderOfType(type, name, ofMailbox) {
|
|
1719
|
+
const key = `${ofMailbox}:${type}`;
|
|
1720
|
+
const known = foldersOf(ofMailbox).find((f) => f.type === type)?.uid ?? lazyFoldersRef.current.get(key);
|
|
1500
1721
|
if (known) {
|
|
1501
1722
|
return known;
|
|
1502
1723
|
}
|
|
1503
|
-
const listed = (await listFolders(
|
|
1724
|
+
const listed = (await listFolders(ofMailbox)).find((f) => f.type === type);
|
|
1504
1725
|
if (listed) {
|
|
1505
1726
|
onFolderCreated(listed);
|
|
1506
1727
|
lazyFoldersRef.current.set(key, listed.uid);
|
|
1507
1728
|
return listed.uid;
|
|
1508
1729
|
}
|
|
1509
|
-
const created = await createFolder({ mailboxUid:
|
|
1730
|
+
const created = await createFolder({ mailboxUid: ofMailbox, name, type });
|
|
1510
1731
|
lazyFoldersRef.current.set(key, created.uid);
|
|
1511
1732
|
return created.uid;
|
|
1512
1733
|
}
|
|
1734
|
+
/** Moves `chosen` into each of its mailbox's own folder of `type` (Deleted Items, Junk), making it where the server has none yet. */
|
|
1735
|
+
function moveToFolderOfType(chosen, type, name) {
|
|
1736
|
+
return inEachMailbox(chosen, async (ofMailbox, group) => moveMessages(group, await resolveFolderOfType(type, name, ofMailbox)));
|
|
1737
|
+
}
|
|
1513
1738
|
/**
|
|
1514
1739
|
* Sets every selected message's labels in one bulk update: each ends up with `labelUids`, plus the
|
|
1515
1740
|
* ones left partially applied (`keepPartial`) that it already had, plus any label this mailbox no
|
|
@@ -1525,7 +1750,50 @@ function InboxContent({ userUid }) {
|
|
|
1525
1750
|
})), false);
|
|
1526
1751
|
}
|
|
1527
1752
|
function moveSelectionToType(type, name, chosen) {
|
|
1528
|
-
return runBulkAction(
|
|
1753
|
+
return runBulkAction((moving) => moveToFolderOfType(moving, type, name), true, chosen);
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* Permanently deletes `chosen` - messages that are already in Deleted Items - once the reader has confirmed it (`usePermanentDelete()`
|
|
1757
|
+
* asks, deletes, tells the badges and says what happened). The rows that went leave the list, and selection and advance behave as for
|
|
1758
|
+
* Archive; a conversation list is reloaded, as after every bulk action on it, and so is a flat one when the server refused some
|
|
1759
|
+
* (those stay listed, and the reload shows what is really there). Resolves whether anything was deleted.
|
|
1760
|
+
*/
|
|
1761
|
+
async function purgeChosen(chosen) {
|
|
1762
|
+
const outcome = await permanent.requestPermanentDelete(chosen);
|
|
1763
|
+
if (!outcome) {
|
|
1764
|
+
return false;
|
|
1765
|
+
}
|
|
1766
|
+
if (asConversations) {
|
|
1767
|
+
setSelectedConversationIds(new Set());
|
|
1768
|
+
setConversationMessagesById({});
|
|
1769
|
+
setRefreshKey((n) => n + 1);
|
|
1770
|
+
}
|
|
1771
|
+
else {
|
|
1772
|
+
removeListedMessages(new Set(outcome.deleted.map((m) => m.uid)));
|
|
1773
|
+
if (outcome.failed.length > 0) {
|
|
1774
|
+
setRefreshKey((n) => n + 1);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
setSelectedUids(new Set());
|
|
1778
|
+
return outcome.deleted.length > 0;
|
|
1779
|
+
}
|
|
1780
|
+
/** Empty folder: permanently deletes everything in the folder being listed (Deleted Items or Junk Email) after the reader has confirmed. */
|
|
1781
|
+
async function emptyListedFolder(folder, count) {
|
|
1782
|
+
const outcome = await permanent.requestEmptyFolder({ uid: folder.uid, name: folder.name }, count);
|
|
1783
|
+
if (!outcome) {
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
if (outcome.emptied) {
|
|
1787
|
+
setMessages([]);
|
|
1788
|
+
setConversations([]);
|
|
1789
|
+
}
|
|
1790
|
+
setSelectedUid(null);
|
|
1791
|
+
setOpenThread(null);
|
|
1792
|
+
setSelectedUids(new Set());
|
|
1793
|
+
setSelectedConversationIds(new Set());
|
|
1794
|
+
setConversationMessagesById({});
|
|
1795
|
+
// What is really left - after a partial delete, or one that failed outright - is whatever the server lists.
|
|
1796
|
+
setRefreshKey((n) => n + 1);
|
|
1529
1797
|
}
|
|
1530
1798
|
// ---- Swiping a row on a phone (see `SwipeRow`): right to left archives, left to right asks for a folder. Both go through the same
|
|
1531
1799
|
// ---- bulk path as the selection bar's Archive and Move to, so the badges, the optimistic bookkeeping and the failure pop-up are one
|
|
@@ -1570,7 +1838,7 @@ function InboxContent({ userUid }) {
|
|
|
1570
1838
|
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;
|
|
1571
1839
|
/** One message's row of the flat list - and, in search results grouped by conversation, of a group. */
|
|
1572
1840
|
function renderMessageRow(message) {
|
|
1573
|
-
return (_jsxs(SwipeRow, { as: "li", enabled: swipeEnabled, onArchive: () => swipeArchive([message]), onMove: () => setMoveDialog({ messages: [message] }), "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("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: () => handleSelect(message), className: ["w-full 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() })] }),
|
|
1841
|
+
return (_jsxs(SwipeRow, { as: "li", enabled: swipeEnabled, onArchive: () => swipeArchive([message]), onMove: () => setMoveDialog({ messages: [message] }), "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("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: () => handleSelect(message), className: ["w-full 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() })] }), crossMailbox && (_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] ||
|
|
1574
1842
|
decryptedRows[message.uid]?.preview ||
|
|
1575
1843
|
message.bodyPreview ||
|
|
1576
1844
|
(message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && _jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" }), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] }), isOutbox && _jsx(OutboxRowStatus, { message: message })] }))] }), _jsx(InviteRowChip, { message: message, onResponded: (updated) => patchListedMessage(updated) })] })] }, message.uid));
|
|
@@ -1579,7 +1847,7 @@ function InboxContent({ userUid }) {
|
|
|
1579
1847
|
* first match of each - the order the results are ranked in. `null` when the list is not showing grouped results. */
|
|
1580
1848
|
const resultGroups = groupedResults ? groupByConversation(messages) : null;
|
|
1581
1849
|
/** The search box: in the list on a desktop (flat lists only), in the shell's header beside the folders button on a phone. */
|
|
1582
|
-
const searchField = (_jsx("input", { ref: searchInputRef, type: "search", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder:
|
|
1850
|
+
const searchField = (_jsx("input", { ref: searchInputRef, type: "search", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: "Search all mail\u2026", "aria-label": "Search all mail", className: ["w-full text-sm px-3 rounded-md border border-border bg-surface", isMobile ? "py-2" : "py-1.5"].join(" ") }));
|
|
1583
1851
|
function handleSelect(message) {
|
|
1584
1852
|
if (selectMode) {
|
|
1585
1853
|
toggleSelected(message.uid);
|
|
@@ -1611,7 +1879,7 @@ function InboxContent({ userUid }) {
|
|
|
1611
1879
|
}
|
|
1612
1880
|
removedAnchorRef.current = null;
|
|
1613
1881
|
setSelectedUid(message.uid);
|
|
1614
|
-
setOpenThread({ conversation, uid: message.uid });
|
|
1882
|
+
setOpenThread({ conversation, uid: message.uid, mailboxUid: message.mailboxUid });
|
|
1615
1883
|
}
|
|
1616
1884
|
/** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
|
|
1617
1885
|
* stands for, or the latest for a parent row. The thread pane loads the thread itself. */
|
|
@@ -1629,21 +1897,27 @@ function InboxContent({ userUid }) {
|
|
|
1629
1897
|
}
|
|
1630
1898
|
removedAnchorRef.current = null;
|
|
1631
1899
|
setSelectedUid(uid);
|
|
1632
|
-
setOpenThread({ conversation, uid });
|
|
1900
|
+
setOpenThread({ conversation, uid, mailboxUid: activeMailboxUid });
|
|
1633
1901
|
}
|
|
1634
1902
|
// ---- Keyboard shortcuts (see `shared/keyboard`). Each is registered only while this view can do it, and calls what the toolbar and
|
|
1635
1903
|
// ---- the selection bar call: the same bulk-action path (`runBulkAction()` - so the same optimistic badge tracking, the same rollback,
|
|
1636
1904
|
// ---- the same lazily created Deleted Items folder), never a second implementation. Reply, Reply all, Forward, Archive and Move to are
|
|
1637
1905
|
// ---- registered by the reading pane itself (`MessageDetailPane`'s `shortcuts`), which owns those handlers.
|
|
1638
1906
|
const inConversations = asConversations;
|
|
1639
|
-
/** The selection bar isn't offered in the aggregate
|
|
1640
|
-
|
|
1907
|
+
/** The selection bar isn't offered in the aggregate listing (its rows belong to several mailboxes), and neither are these - but the results of a
|
|
1908
|
+
* search over them are, each hit acted on in its own mailbox (see `inEachMailbox()`). */
|
|
1909
|
+
const keyboardActions = !aggregateFolderType || isSearching;
|
|
1641
1910
|
/** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
|
|
1642
1911
|
const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : threadPane ? openThread !== null : selected !== null;
|
|
1643
1912
|
const rowCount = inConversations ? listedConversations.length : messages.length;
|
|
1644
1913
|
const canMoveSelection = !selectMode && !isMobile && !loading && rowCount > 0;
|
|
1914
|
+
/** The type of a message's folder - looked for in every mailbox's, as a search over several has messages from each. */
|
|
1645
1915
|
function folderTypeOf(folderUidOfMessage) {
|
|
1646
|
-
return
|
|
1916
|
+
return mailboxFolders.flatMap((mf) => mf.folders).find((f) => f.uid === folderUidOfMessage)?.type;
|
|
1917
|
+
}
|
|
1918
|
+
/** Whether a message is in its mailbox's Deleted Items - where Delete is the permanent one. */
|
|
1919
|
+
function inDeletedItems(message) {
|
|
1920
|
+
return folderTypeOf(message.folderUid) === "deleted_items";
|
|
1647
1921
|
}
|
|
1648
1922
|
/** Moves the keyboard's focus to a row - and scrolls it into view - once the selection has moved to it. */
|
|
1649
1923
|
function focusRow(uid) {
|
|
@@ -1687,49 +1961,81 @@ function InboxContent({ userUid }) {
|
|
|
1687
1961
|
}
|
|
1688
1962
|
}
|
|
1689
1963
|
}
|
|
1690
|
-
/** The messages of the open conversation that are in the folder being listed - fetched (once) like a ticked conversation's.
|
|
1691
|
-
|
|
1964
|
+
/** The messages of the open conversation that are in the folder being listed - fetched (once) like a ticked conversation's. `ofMailbox` is the
|
|
1965
|
+
* conversation's mailbox: a search over several mailboxes can open one that is not the page's, and the same conversation id can exist in two. */
|
|
1966
|
+
async function loadOpenConversation(conversation, ofMailbox = activeMailboxUid) {
|
|
1692
1967
|
const id = conversation.conversationId;
|
|
1693
|
-
|
|
1968
|
+
const cacheKey = ofMailbox === activeMailboxUid ? id : `${ofMailbox}\u0000${id}`;
|
|
1969
|
+
let all = conversationMessagesById[cacheKey];
|
|
1694
1970
|
if (!all) {
|
|
1695
|
-
all = await listConversationMessages(
|
|
1971
|
+
all = await listConversationMessages(ofMailbox, id);
|
|
1696
1972
|
const loaded = all;
|
|
1697
|
-
setConversationMessagesById((prev) => ({ ...prev, [
|
|
1973
|
+
setConversationMessagesById((prev) => ({ ...prev, [cacheKey]: loaded }));
|
|
1698
1974
|
}
|
|
1699
|
-
|
|
1975
|
+
// What a search over several mailboxes finds is in every folder of them, not just the one open.
|
|
1976
|
+
return all.filter((m) => !folderUid || crossMailbox || m.folderUid === folderUid);
|
|
1700
1977
|
}
|
|
1701
1978
|
/**
|
|
1702
|
-
*
|
|
1703
|
-
*
|
|
1704
|
-
*
|
|
1979
|
+
* What the keyboard acts on - the ticked rows in select mode, else the open message or conversation - keeping only the messages `applies`
|
|
1980
|
+
* accepts (Mark read leaves out what is already read: nothing to do then). Nothing (an empty list) while another bulk action or a
|
|
1981
|
+
* permanent delete is on the wire, like the bar's buttons, or when the open conversation could not be loaded.
|
|
1705
1982
|
*/
|
|
1706
|
-
async function
|
|
1707
|
-
if (bulkBusy || resolvingSelection > 0) {
|
|
1708
|
-
return;
|
|
1983
|
+
async function keyboardTargets(applies = () => true) {
|
|
1984
|
+
if (bulkBusy || resolvingSelection > 0 || permanent.busy) {
|
|
1985
|
+
return [];
|
|
1709
1986
|
}
|
|
1710
1987
|
let chosen;
|
|
1711
1988
|
try {
|
|
1712
|
-
chosen = selectMode
|
|
1989
|
+
chosen = selectMode
|
|
1990
|
+
? selectedMessages
|
|
1991
|
+
: threadPane
|
|
1992
|
+
? await loadOpenConversation(openThread.conversation, openThread.mailboxUid)
|
|
1993
|
+
: [selected];
|
|
1713
1994
|
}
|
|
1714
1995
|
catch (err) {
|
|
1715
1996
|
notifyApiError(err, "Couldn't load the messages in that conversation");
|
|
1716
|
-
return;
|
|
1997
|
+
return [];
|
|
1717
1998
|
}
|
|
1718
|
-
|
|
1999
|
+
return chosen.filter(applies);
|
|
2000
|
+
}
|
|
2001
|
+
/** Closes the open thread (and the selection in it) - what a delete of the whole conversation leaves nothing to show of. */
|
|
2002
|
+
function closeOpenThread() {
|
|
2003
|
+
setOpenThread(null);
|
|
2004
|
+
setSelectedUid(null);
|
|
2005
|
+
}
|
|
2006
|
+
/** Runs a bulk action on `chosen` (from `keyboardTargets()`), closing the open thread after a move away from it. */
|
|
2007
|
+
async function actOnKeyboardTargets(chosen, action, removesRows) {
|
|
1719
2008
|
if (chosen.length === 0) {
|
|
1720
2009
|
return;
|
|
1721
2010
|
}
|
|
1722
2011
|
const succeeded = await runBulkAction(action, removesRows, chosen);
|
|
1723
2012
|
if (succeeded && removesRows && threadPane && !selectMode) {
|
|
1724
|
-
|
|
1725
|
-
setSelectedUid(null);
|
|
2013
|
+
closeOpenThread();
|
|
1726
2014
|
}
|
|
1727
2015
|
}
|
|
2016
|
+
/** Runs a bulk action on what the keyboard acts on (`keyboardTargets()`). */
|
|
2017
|
+
async function runOnKeyboardTargets(action, removesRows, applies = () => true) {
|
|
2018
|
+
await actOnKeyboardTargets(await keyboardTargets(applies), action, removesRows);
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Delete, from the keyboard: what is in Deleted Items is permanently deleted (after a confirmation), anything else moves to its mailbox's
|
|
2022
|
+
* Deleted Items - as the selection bar's Delete does. A mix (search results from several folders) moves only what is not in Deleted Items yet.
|
|
2023
|
+
*/
|
|
2024
|
+
async function deleteFromKeyboard() {
|
|
2025
|
+
const targets = await keyboardTargets();
|
|
2026
|
+
if (targets.length > 0 && targets.every(inDeletedItems)) {
|
|
2027
|
+
if ((await purgeChosen(targets)) && threadPane && !selectMode) {
|
|
2028
|
+
closeOpenThread();
|
|
2029
|
+
}
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
2032
|
+
await actOnKeyboardTargets(targets.filter((message) => !inDeletedItems(message)), (chosen) => moveToFolderOfType(chosen, "deleted_items", "Deleted Items"), true);
|
|
2033
|
+
}
|
|
1728
2034
|
useShortcut(SHORTCUTS.mail.next, () => selectNeighbour(1), { enabled: canMoveSelection });
|
|
1729
2035
|
useShortcut(SHORTCUTS.mail.previous, () => selectNeighbour(-1), { enabled: canMoveSelection });
|
|
1730
2036
|
useShortcut(SHORTCUTS.mail.nextUnread, () => selectNextUnread(1), { enabled: canMoveSelection });
|
|
1731
2037
|
useShortcut(SHORTCUTS.mail.previousUnread, () => selectNextUnread(-1), { enabled: canMoveSelection });
|
|
1732
|
-
useShortcut(SHORTCUTS.mail.delete, () => void
|
|
2038
|
+
useShortcut(SHORTCUTS.mail.delete, () => void deleteFromKeyboard(), { enabled: keyboardActions && keyboardTargetExists });
|
|
1733
2039
|
useShortcut(SHORTCUTS.mail.markRead, () => void runOnKeyboardTargets((chosen) => setReadStateMany(chosen, true, { patch: patchListedMessage, track: trackMessageChange }), false, isUnread), { enabled: keyboardActions && keyboardTargetExists });
|
|
1734
2040
|
useShortcut(SHORTCUTS.mail.markUnread, () => void runOnKeyboardTargets((chosen) => setReadStateMany(chosen, false, { patch: patchListedMessage, track: trackMessageChange }), false, (message) => !isUnread(message)), { enabled: keyboardActions && keyboardTargetExists });
|
|
1735
2041
|
useShortcut(SHORTCUTS.mail.flag, () =>
|
|
@@ -1759,7 +2065,29 @@ function InboxContent({ userUid }) {
|
|
|
1759
2065
|
useShortcut(SHORTCUTS.mail.search, () => {
|
|
1760
2066
|
searchInputRef.current?.focus();
|
|
1761
2067
|
searchInputRef.current?.select();
|
|
1762
|
-
}
|
|
2068
|
+
});
|
|
2069
|
+
// Folders and labels belong to one mailbox. A search over several mailboxes can have a selection in more than one: Move to needs it to be in one
|
|
2070
|
+
// (and offers that mailbox's folders), and Apply label needs it to be in the open mailbox, whose labels are the ones loaded. Archive, Delete and
|
|
2071
|
+
// Report junk go to each hit's own mailbox's folders (`inEachMailbox()`), so they need nothing of the sort.
|
|
2072
|
+
const selectedMailboxUids = new Set(selectedMessages.map((m) => m.mailboxUid));
|
|
2073
|
+
const selectionMailboxUid = crossMailbox && selectedMailboxUids.size === 1 ? [...selectedMailboxUids][0] : activeMailboxUid;
|
|
2074
|
+
const moveDisabledReason = crossMailbox && selectedMailboxUids.size > 1 ? "The selected messages are in different mailboxes - select messages from one mailbox to move them" : undefined;
|
|
2075
|
+
const labelsDisabledReason = crossMailbox && selectedMessages.some((m) => m.mailboxUid !== activeMailboxUid)
|
|
2076
|
+
? "Labels can only be applied here to messages in the open mailbox"
|
|
2077
|
+
: undefined;
|
|
2078
|
+
// Delete is the permanent one for what is in Deleted Items: for the selection when there is one (search results can come from any folder),
|
|
2079
|
+
// else for the folder being viewed, so the button says so before anything is ticked.
|
|
2080
|
+
const deletesPermanently = selectedMessages.length > 0 ? selectedMessages.every(inDeletedItems) : currentFolders.find((f) => f.uid === folderUid)?.type === "deleted_items";
|
|
2081
|
+
// Empty folder is offered at the top of the list of a mailbox's own Deleted Items or Junk Email - not for search results, nor for the "All
|
|
2082
|
+
// mailboxes" views, which have no one folder to empty.
|
|
2083
|
+
const emptiableFolder = !isSearching && !aggregateFolderType ? currentFolders.find((f) => f.uid === folderUid && EMPTIABLE_FOLDER_TYPES.includes(f.type)) : undefined;
|
|
2084
|
+
// How many it holds as the sidebar's counts have it (kept right as messages leave), unless that says none while the list shows some.
|
|
2085
|
+
const emptiableCount = emptiableFolder && folderCountOf(emptiableFolder).total > 0 ? folderCountOf(emptiableFolder).total : undefined;
|
|
2086
|
+
const emptyDisabledReason = !mailboxWritable
|
|
2087
|
+
? "This mailbox is shared with you view-only"
|
|
2088
|
+
: listedRowCount === 0 && emptiableCount === undefined
|
|
2089
|
+
? "This folder is already empty"
|
|
2090
|
+
: undefined;
|
|
1763
2091
|
if (!folderUid && !aggregateFolderType) {
|
|
1764
2092
|
// `MailShell` never renders this component at all until a mailbox is resolved (see its own
|
|
1765
2093
|
// full-screen `MailboxProvisioning` takeover otherwise) — this is purely the brief gap before
|
|
@@ -1775,11 +2103,14 @@ function InboxContent({ userUid }) {
|
|
|
1775
2103
|
? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
|
|
1776
2104
|
: undefined, onSelectAll: () => asConversations
|
|
1777
2105
|
? selectAllConversations()
|
|
1778
|
-
: setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () => asConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set()), onCancel: leaveSelectMode, folders:
|
|
2106
|
+
: setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () => asConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set()), onCancel: leaveSelectMode, folders: foldersOf(selectionMailboxUid), currentFolderUid: folderUid, labels: mailboxLabels, mailboxUid: selectionMailboxUid, moveDisabledReason: moveDisabledReason, labelsDisabledReason: labelsDisabledReason, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated, onApplyLabels: applyLabelsToSelection, onSetRead: (read) => void runBulkAction(
|
|
1779
2107
|
// Optimistic: the rows and the folder badges change now, and change back if the server refuses.
|
|
1780
2108
|
(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) => {
|
|
1781
2109
|
await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid), true);
|
|
1782
|
-
}, onReportJunk: () => void moveSelectionToType("junk", "Junk Email"), onDelete: () => void
|
|
2110
|
+
}, onReportJunk: () => void moveSelectionToType("junk", "Junk Email"), onDelete: () => void (deletesPermanently
|
|
2111
|
+
? purgeChosen(selectedMessages)
|
|
2112
|
+
: // A mix (search results from several folders) moves only what is not in Deleted Items yet.
|
|
2113
|
+
moveSelectionToType("deleted_items", "Deleted Items", selectedMessages.filter((m) => !inDeletedItems(m)))), deletesPermanently: deletesPermanently, busy: bulkBusy || resolvingSelection > 0 || permanent.busy })) : (_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,
|
|
1783
2114
|
// A conversation row is a thread summary, so only some of the keys have anything to
|
|
1784
2115
|
// order by - the rest stay pickable and are applied to the rows already fetched.
|
|
1785
2116
|
unavailableSortKeys: asConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined, sortKeysNote: isSearching
|
|
@@ -1788,30 +2119,32 @@ function InboxContent({ userUid }) {
|
|
|
1788
2119
|
? "This view merges the newest mail from every mailbox and is always listed by date."
|
|
1789
2120
|
: asConversations
|
|
1790
2121
|
? CONVERSATION_SORT_NOTE
|
|
1791
|
-
: undefined, selectDisabled: !!aggregateFolderType || loading || listedRowCount === 0, selectDisabledReason: aggregateFolderType
|
|
2122
|
+
: undefined, selectDisabled: (!!aggregateFolderType && !isSearching) || loading || listedRowCount === 0, selectDisabledReason: aggregateFolderType && !isSearching
|
|
1792
2123
|
? "Open a mailbox's own folder to select messages"
|
|
1793
2124
|
: loading
|
|
1794
2125
|
? "Wait for this folder to finish loading"
|
|
1795
|
-
: "There is nothing here to select" })), !isMobile && _jsx("div", { className: "p-2 border-b border-border", children: searchField }), isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot), 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: [
|
|
2126
|
+
: "There is nothing here to select" })), !isMobile && _jsx("div", { className: "p-2 border-b border-border", children: searchField }), isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot), emptiableFolder && !selectMode && (_jsx(EmptyFolderBar, { folderName: emptiableFolder.name, count: emptiableCount, disabled: !!emptyDisabledReason || permanent.busy || bulkBusy, disabledReason: emptyDisabledReason, onEmpty: () => void emptyListedFolder(emptiableFolder, emptiableCount) })), permanent.dialog, 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: [
|
|
1796
2127
|
"flex-1 py-1.5 font-semibold",
|
|
1797
2128
|
preferences.filter === value
|
|
1798
2129
|
? "text-primary-dark border-b-2 border-primary-dark"
|
|
1799
2130
|
: "text-text-muted",
|
|
1800
|
-
].join(" "), children: label }, value))) })), isSearching && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border flex items-center justify-between gap-2", children: [_jsx("span", { children: tier1Done && tier2Done && tier3Done
|
|
1801
|
-
|
|
1802
|
-
|
|
2131
|
+
].join(" "), children: label }, value))) })), isSearching && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border flex items-center justify-between gap-2", children: [_jsxs("span", { className: "flex flex-col", children: [_jsx("span", { children: tier1Done && tier2Done && tier3Done
|
|
2132
|
+
? `${messages.length} result${messages.length === 1 ? "" : "s"}`
|
|
2133
|
+
: `${messages.length} of ??` }), crossMailbox && _jsx("span", { "data-testid": "search-scope", children: "All mailboxes" })] }), _jsxs("span", { className: "flex flex-col items-end gap-0.5", children: [tier2Done && !searchAllMail && (_jsx("button", { type: "button", onClick: handleSearchAllMail, title: crossMailbox
|
|
2134
|
+
? "Also search encrypted mail from before each mailbox's local index begins"
|
|
2135
|
+
: undefined, className: "text-primary-dark hover:underline font-medium shrink-0", children: crossMailbox ? "Search older encrypted mail" : "Search all mail" })), widenable && (_jsx("button", { type: "button", onClick: () => setSearchAllMailboxes(!searchAllMailboxes), className: "text-primary-dark hover:underline font-medium shrink-0", children: searchAllMailboxes ? "Search this mailbox only" : "Search all mailboxes" }))] })] })), crossMailbox && isSearching && searchableMailboxes.length > searchedMailboxes.length && (_jsxs("div", { role: "status", className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["Searched ", searchedMailboxes.length, " of ", searchableMailboxes.length, " mailboxes - the most that are searched at once."] })), crossMailbox && isSearching && searchFailures.length > 0 && (_jsxs("div", { role: "status", className: "px-4 py-1.5 text-xs text-text-muted border-b border-border bg-surface-alt", children: ["Some results may be missing: ", describeFailures(searchFailures)] })), isSearching && !crossMailbox && 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
|
|
1803
2136
|
? " - searching everything, not just recent mail."
|
|
1804
|
-
: " - 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 && !asConversations && 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 }) })), loading ? (_jsxs("div", { role: "status", "aria-busy": "true", className: "p-4", children: [_jsx("span", { className: "sr-only", children: "Loading\u2026" }), _jsx(SkeletonList, { count: 8 })] })) : asConversations ? (_jsxs(_Fragment, { children: [_jsx(ConversationList, { conversations: listedConversations, newestFirst: preferences.sortBy === "date" && preferences.sortOrder === "desc", mailboxUid: activeMailboxUid, selectedUid: selectedUid, messageOverrides: conversationPatches, onMeetingResponded: patchListedMessage, onOpenMessage: handleOpenConversation, selectMode: selectMode, selectedConversationIds: selectedConversationIds, onToggleSelected: toggleConversationSelected, swipe: {
|
|
2137
|
+
: " - older encrypted mail is still searched, just slower."] })), isSearching && crossMailbox && tier2Done && serverOnlyMailboxes.length > 0 && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["There is no local index on this device for ", serverOnlyMailboxes.map((mb) => mb.displayName).join(", "), ": its encrypted mail is searched through the server instead, which is slower and looks at a limited number of messages."] })), isSearching && crossMailbox && lockedSearchMailboxes.length > 0 && (_jsxs("div", { className: "px-4 py-2 border-b border-border bg-surface-alt flex flex-col gap-1", children: [unlockableSearchMailboxes.length > 0 && (_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 from ", unlockableSearchMailboxes.map((mb) => mb.displayName).join(", ")] })), sharedLockedSearchMailboxes.length > 0 && (_jsxs("p", { className: "text-xs text-text-muted", children: ["Encrypted messages in ", sharedLockedSearchMailboxes.map((mb) => mb.displayName).join(", "), " are not included: only the mailbox\u2019s owner can unlock them."] }))] })), isSearching && !crossMailbox && !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 && !asConversations && 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 }) })), loading ? (_jsxs("div", { role: "status", "aria-busy": "true", className: "p-4", children: [_jsx("span", { className: "sr-only", children: "Loading\u2026" }), _jsx(SkeletonList, { count: 8 })] })) : asConversations ? (_jsxs(_Fragment, { children: [_jsx(ConversationList, { conversations: listedConversations, newestFirst: preferences.sortBy === "date" && preferences.sortOrder === "desc", mailboxUid: activeMailboxUid, selectedUid: selectedUid, messageOverrides: conversationPatches, onMeetingResponded: patchListedMessage, onOpenMessage: handleOpenConversation, selectMode: selectMode, selectedConversationIds: selectedConversationIds, onToggleSelected: toggleConversationSelected, swipe: {
|
|
1805
2138
|
enabled: swipeEnabled,
|
|
1806
2139
|
onArchive: swipeArchive,
|
|
1807
2140
|
onMove: (conversation) => setMoveDialog({ conversation }),
|
|
1808
|
-
} }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ",
|
|
2141
|
+
} }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", rowCap, " conversations \u2014 refine your search or filters to see more."] }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
|
|
1809
2142
|
? `No messages match "${searchQuery}".`
|
|
1810
2143
|
: effectiveFilter === "all"
|
|
1811
2144
|
? "No messages in this folder."
|
|
1812
2145
|
: "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", { className: swipeEnabled ? "overflow-x-clip" : undefined, children: resultGroups
|
|
1813
|
-
? resultGroups.map((group) => (_jsxs("li", { "data-search-group": group.id, children: [_jsxs("div", { className: "px-4 py-1.5 bg-surface-alt border-b border-border text-xs text-text-muted flex items-center justify-between gap-2", children: [_jsx("span", { className: "truncate font-semibold", children: rowSubject(group.messages[0]) }), _jsxs("span", { className: "shrink-0", children: [group.messages.length, " matching message", group.messages.length === 1 ? "" : "s"] })] }), _jsx("ul", { children: group.messages.map(renderMessageRow) })] }, group.id)))
|
|
1814
|
-
: messages.map(renderMessageRow) }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ",
|
|
2146
|
+
? resultGroups.map((group) => (_jsxs("li", { "data-search-group": group.id, children: [_jsxs("div", { className: "px-4 py-1.5 bg-surface-alt border-b border-border text-xs text-text-muted flex items-center justify-between gap-2", children: [_jsx("span", { className: "truncate font-semibold", children: rowSubject(group.messages[0]) }), _jsxs("span", { className: "shrink-0", children: [group.messages.length, " matching message", group.messages.length === 1 ? "" : "s"] })] }), _jsx("ul", { children: group.messages.map(renderMessageRow) })] }, `${group.mailboxUid}\u0000${group.id}`)))
|
|
2147
|
+
: messages.map(renderMessageRow) }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", rowCap, " messages \u2014 refine your search or filters to see more."] })), aggregateFolderType && !isSearching && (_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: threadPane ? (_jsx(LazyConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: labelsMailboxUid, folders: foldersOf(labelsMailboxUid), labels: labels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : 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) => {
|
|
1815
2148
|
// Same reasoning as onArchived below - a move takes the message out of the folder
|
|
1816
2149
|
// being listed, so it leaves the list rather than being patched in place.
|
|
1817
2150
|
removeListedMessage(updated.uid);
|
|
@@ -1825,6 +2158,8 @@ function InboxContent({ userUid }) {
|
|
|
1825
2158
|
// whichever folder is currently being viewed (into Archive), so it's removed
|
|
1826
2159
|
// from the list rather than patched in place.
|
|
1827
2160
|
removeListedMessage(updated.uid);
|
|
1828
|
-
}, labels: labels, onLabelsChanged: patchListedMessage, onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]) })) }), _jsx(MoveToFolderDialog, { open: moveDialog !== null, onClose: () => setMoveDialog(null), mailboxUid: activeMailboxUid, folders: currentFolders, currentFolderUid: folderUid, count: moveDialog === null ? 1 : "messages" in moveDialog ? moveDialog.messages.length : moveDialog.conversation.messageCount, onMove: swipeMove, onFolderCreated: onFolderCreated })] }));
|
|
2161
|
+
}, onChanged: patchListedMessage, labels: labels, onLabelsChanged: patchListedMessage, onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]) })) }), _jsx(MoveToFolderDialog, { open: moveDialog !== null, onClose: () => setMoveDialog(null), mailboxUid: activeMailboxUid, folders: currentFolders, currentFolderUid: folderUid, count: moveDialog === null ? 1 : "messages" in moveDialog ? moveDialog.messages.length : moveDialog.conversation.messageCount, onMove: swipeMove, onFolderCreated: onFolderCreated })] }));
|
|
1829
2162
|
}
|
|
1830
|
-
export default
|
|
2163
|
+
export default InboxPage;
|
|
2164
|
+
/** The tab's title: `Brand: Mail` (see `pageTitle()`). */
|
|
2165
|
+
export const title = pageTitle("Mail");
|