@rapidmx/web-client 0.14.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +373 -373
- package/apps/admin/branding/index.tsx +39 -39
- package/apps/admin/data-requests/index.tsx +482 -480
- package/apps/admin/domains/[uid].tsx +164 -163
- package/apps/admin/escrow-scopes/[uid].tsx +348 -348
- package/apps/admin/index.tsx +127 -124
- package/apps/admin/mailboxes/[uid].tsx +269 -224
- package/apps/admin/mailboxes/new/index.tsx +28 -28
- package/apps/admin/plugins/index.tsx +15 -15
- package/apps/admin/retention-policy/index.tsx +39 -39
- package/apps/admin/signing-certificates/index.tsx +343 -343
- package/apps/escrow/audit-log/index.tsx +196 -196
- package/apps/escrow/matters/[uid].tsx +621 -621
- package/apps/shared/appearance/AppearanceForm.tsx +9 -1
- package/apps/shared/auth/adminAccess.ts +99 -99
- package/apps/shared/calendar/bookingPlugin.ts +100 -0
- package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +378 -346
- 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 +53 -4
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
- package/apps/shared/components/admin/settings/PluginsManager.tsx +64 -10
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
- package/apps/shared/components/admin/setup/SetupWizard.tsx +444 -444
- package/apps/shared/components/admin/usePagedList.tsx +129 -129
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
- package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
- package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
- package/apps/shared/components/calendar/EventDetails.tsx +401 -0
- package/apps/shared/components/calendar/EventEditor.tsx +635 -0
- package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
- package/apps/shared/components/calendar/EventFormParts.tsx +591 -0
- package/apps/shared/components/calendar/EventModal.tsx +206 -946
- package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
- package/apps/shared/components/calendar/EventShell.tsx +202 -0
- package/apps/shared/components/calendar/FindATime.tsx +379 -0
- package/apps/shared/components/calendar/GuestInput.tsx +100 -0
- package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
- package/apps/shared/components/calendar/MonthView.tsx +185 -173
- package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
- package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
- package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -222
- package/apps/shared/components/calendar/RequestChangeForm.tsx +240 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +144 -143
- package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +246 -244
- package/apps/shared/components/calendar/allDay.ts +124 -124
- package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
- package/apps/shared/components/calendar/eventForm.ts +108 -0
- package/apps/shared/components/calendar/eventFormat.ts +210 -0
- package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
- package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
- package/apps/shared/components/contacts/ContactForm.tsx +383 -383
- package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
- package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +217 -216
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
- package/apps/shared/components/layout/AppShell.tsx +480 -480
- package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
- package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
- package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
- package/apps/shared/components/layout/UserMenu.tsx +439 -372
- package/apps/shared/components/mail/ConversationList.tsx +323 -271
- package/apps/shared/components/mail/ConversationThreadPane.tsx +130 -5
- package/apps/shared/components/mail/InviteCard.tsx +387 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +236 -227
- package/apps/shared/components/mail/MessageDetailPane.tsx +1559 -1537
- package/apps/shared/components/mail/SwipeRow.tsx +120 -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 -1673
- 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/invite/InviteRowChip.tsx +104 -0
- package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
- package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
- package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
- package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
- package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +546 -490
- package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
- package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -125
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
- package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
- package/apps/shared/components/settings/layout/SettingsShell.tsx +255 -253
- package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
- package/apps/shared/components/tasks/layout/TasksShell.tsx +218 -217
- package/apps/shared/gestures/useEnterSlide.ts +54 -0
- package/apps/shared/gestures/useSwipe.ts +114 -0
- package/apps/shared/gestures/useSwipeSlide.ts +113 -0
- package/apps/shared/gestures/useWheelPaging.ts +107 -0
- package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
- package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
- package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
- package/apps/shared/keyboard/dispatch.ts +124 -124
- package/apps/shared/keyboard/format.ts +89 -89
- package/apps/shared/keyboard/keymap.ts +114 -114
- package/apps/shared/keyboard/registry.ts +65 -65
- package/apps/shared/keyboard/targets.ts +79 -79
- package/apps/shared/mail/folderCounts.ts +17 -0
- package/apps/shared/mail/folderTree.ts +143 -143
- package/apps/shared/mail/listAllPages.ts +39 -39
- package/apps/shared/mail/newMailNotifications.ts +3 -15
- package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
- package/apps/shared/mail/outbox/sendJob.ts +19 -1
- package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
- package/apps/shared/mail/primaryMailbox.ts +71 -0
- package/apps/shared/mail/useCollapsedSections.ts +146 -0
- package/apps/shared/mail/useMailConnection.ts +205 -203
- package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
- package/apps/shared/mail/useMarkMessageRead.ts +47 -47
- package/apps/shared/mail/useNewMailNotifications.ts +2 -2
- package/apps/shared/navigation/AppRouter.tsx +300 -300
- package/apps/shared/navigation/routerContext.tsx +83 -83
- package/apps/shared/notifications/preferences.ts +36 -0
- package/apps/shared/notifications/store.ts +9 -0
- 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/_routes.ts +1 -0
- package/apps/www/calendar/index.tsx +578 -469
- package/apps/www/contacts/[uid].tsx +109 -109
- package/apps/www/contacts/index.tsx +6 -0
- package/apps/www/index.tsx +2847 -2332
- package/apps/www/messages/[uid].tsx +135 -108
- package/apps/www/settings/auto-reply/index.tsx +133 -133
- package/apps/www/settings/encryption/index.tsx +1287 -1287
- package/apps/www/settings/filters/[uid].tsx +176 -176
- package/apps/www/settings/filters/index.tsx +102 -102
- package/apps/www/settings/filters/new/index.tsx +140 -140
- package/apps/www/settings/labels/index.tsx +204 -204
- package/apps/www/settings/privacy/index.tsx +492 -492
- package/apps/www/settings/profile/index.tsx +248 -0
- package/apps/www/settings/read-receipts/index.tsx +147 -147
- package/apps/www/settings/sharing/index.tsx +256 -256
- package/apps/www/settings/signatures/[uid].tsx +172 -172
- package/apps/www/settings/signatures/index.tsx +88 -88
- package/apps/www/settings/signatures/new/index.tsx +135 -135
- package/apps/www/tasks/index.tsx +649 -649
- package/dist/apps/admin/data-requests/index.js +1 -1
- package/dist/apps/admin/domains/[uid].js +4 -2
- package/dist/apps/admin/index.js +2 -1
- package/dist/apps/admin/mailboxes/[uid].js +12 -2
- package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
- package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
- package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -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 +28 -4
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
- package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
- package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
- package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
- package/dist/apps/shared/components/calendar/EventEditor.js +544 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +70 -0
- package/dist/apps/shared/components/calendar/EventFormParts.js +191 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
- package/dist/apps/shared/components/calendar/EventModal.js +57 -459
- package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
- package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
- package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
- package/dist/apps/shared/components/calendar/EventShell.js +134 -0
- package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
- package/dist/apps/shared/components/calendar/FindATime.js +190 -0
- 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/LazyDescriptionEditor.d.ts +8 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
- package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
- package/dist/apps/shared/components/calendar/MonthView.js +11 -5
- package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
- package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +127 -0
- package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
- package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
- package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
- package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
- package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
- package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
- package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
- package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
- package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
- package/dist/apps/shared/components/calendar/eventForm.js +1 -0
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +80 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +190 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -2
- package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -3
- package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
- package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +14 -9
- package/dist/apps/shared/components/layout/UserMenu.js +74 -26
- package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
- package/dist/apps/shared/components/mail/ConversationList.js +21 -4
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
- package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
- package/dist/apps/shared/components/mail/InviteCard.js +150 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +3 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
- package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
- package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +16 -7
- 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/invite/InviteRowChip.d.ts +23 -0
- package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
- 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 +10 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +76 -46
- package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
- package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +7 -5
- package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +4 -3
- package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
- package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
- package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
- package/dist/apps/shared/gestures/useSwipe.js +90 -0
- package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
- package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
- package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
- package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
- package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
- package/dist/apps/shared/mail/folderCounts.js +12 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
- package/dist/apps/shared/mail/newMailNotifications.js +3 -12
- package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
- package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
- package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
- package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
- package/dist/apps/shared/mail/primaryMailbox.js +61 -0
- package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
- package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
- package/dist/apps/shared/mail/useMailConnection.js +3 -1
- package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
- package/dist/apps/shared/notifications/preferences.d.ts +13 -0
- package/dist/apps/shared/notifications/preferences.js +36 -0
- package/dist/apps/shared/notifications/store.d.ts +1 -0
- package/dist/apps/shared/notifications/store.js +7 -23
- 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/_routes.js +1 -0
- package/dist/apps/www/calendar/index.js +78 -7
- package/dist/apps/www/contacts/index.js +3 -1
- package/dist/apps/www/index.js +548 -210
- package/dist/apps/www/messages/[uid].js +13 -5
- package/dist/apps/www/settings/profile/index.d.ts +4 -0
- package/dist/apps/www/settings/profile/index.js +124 -0
- package/package.json +2 -2
package/dist/apps/www/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { useNavigate } from "../shared/navigation/AppRouter.js";
|
|
|
8
8
|
import { whenIdle } from "../shared/navigation/idle.js";
|
|
9
9
|
import { prefetchComposeWindow } from "../shared/components/mail/compose/ComposeContext.js";
|
|
10
10
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
11
|
+
import { createPortal } from "react-dom";
|
|
11
12
|
import { HiOutlineFlag, HiOutlineLockClosed, HiOutlinePaperClip } from "react-icons/hi2";
|
|
12
13
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
13
14
|
import { archiveMessage, bulkUpdateMessages, createFolder, getMessage, getMessageRawContent, listFolders, listMessages, moveMessages, setMessagesFlagged, } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
@@ -15,8 +16,8 @@ import { listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
|
15
16
|
import { listConversationMessages, listConversations, } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
16
17
|
import { search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
|
|
17
18
|
import { parseSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
|
|
18
|
-
import { normalizeServerScores } from "@rapidmx/react-shared/search/searchScoring.js";
|
|
19
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";
|
|
20
21
|
import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
21
22
|
import { useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
|
|
22
23
|
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
@@ -24,12 +25,16 @@ import MailShell, { useMailShell, } from "../shared/components/mail/layout/MailS
|
|
|
24
25
|
import MailAddress from "../shared/components/mail/MailAddress.js";
|
|
25
26
|
import OutboxRowStatus from "../shared/components/mail/OutboxRowStatus.js";
|
|
26
27
|
import { mergeFirstPage } from "../shared/mail/mergeFirstPage.js";
|
|
28
|
+
import { isOwnMailbox, orderMailboxes, primaryMailboxUid } from "../shared/mail/primaryMailbox.js";
|
|
27
29
|
import { listSnapshotKey, readListSnapshot, saveListScroll, writeListSnapshot } from "../shared/mail/listSnapshots.js";
|
|
28
30
|
import { setReadStateMany } from "../shared/mail/messageReadState.js";
|
|
29
31
|
import { useMarkMessageRead } from "../shared/mail/useMarkMessageRead.js";
|
|
30
32
|
import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "../shared/components/mail/unreadStyle.js";
|
|
31
33
|
import { LazyConversationThreadPane, LazyMessageDetailPane, prefetchReadingPane } from "../shared/components/mail/LazyReadingPane.js";
|
|
32
34
|
import ConversationList from "../shared/components/mail/ConversationList.js";
|
|
35
|
+
import SwipeRow from "../shared/components/mail/SwipeRow.js";
|
|
36
|
+
import InviteRowChip from "../shared/components/mail/invite/InviteRowChip.js";
|
|
37
|
+
import MoveToFolderDialog from "../shared/components/mail/MoveToFolderDialog.js";
|
|
33
38
|
import { EncryptedPreview } from "../shared/components/mail/reading/EncryptedPreview.js";
|
|
34
39
|
import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
|
|
35
40
|
import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
|
|
@@ -78,12 +83,14 @@ function stripHtmlToText(html) {
|
|
|
78
83
|
*/
|
|
79
84
|
async function decryptEncryptedRows(messages, unlocked) {
|
|
80
85
|
const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER);
|
|
86
|
+
// Imported once for all the rows, by whichever needs it first.
|
|
87
|
+
let securityModule;
|
|
81
88
|
const entries = await Promise.all(encrypted.map(async (message) => {
|
|
82
89
|
try {
|
|
83
90
|
const rawMime = await getMessageRawContent(message.uid);
|
|
84
91
|
// Loaded here, on first use: the S/MIME code (PKI.js, ASN.1, X.509) is over half a megabyte and an inbox with
|
|
85
92
|
// no encrypted mail in it never needs it.
|
|
86
|
-
const { evaluateMessageSecurity } = await import("@rapidmx/react-shared/crypto/messageSecurity.js");
|
|
93
|
+
const { evaluateMessageSecurity } = await (securityModule ?? (securityModule = import("@rapidmx/react-shared/crypto/messageSecurity.js")));
|
|
87
94
|
const security = await evaluateMessageSecurity(rawMime, unlocked);
|
|
88
95
|
if (!security.subject && !security.html) {
|
|
89
96
|
return null;
|
|
@@ -103,53 +110,20 @@ async function decryptEncryptedRows(messages, unlocked) {
|
|
|
103
110
|
}
|
|
104
111
|
return result;
|
|
105
112
|
}
|
|
106
|
-
/** Merges Tier 1 (server, possibly `metadataOnly` for an encrypted message), Tier 2 (local index, fully
|
|
107
|
-
* decrypted and re-scored), and Tier 3 (server-narrowed candidates, decrypted and re-scored) results into
|
|
108
|
-
* one ranked list, per `specs/search.md` §7's "client MUST re-score all results it can see... normalise
|
|
109
|
-
* into the same space rather than interleaving raw scores": each tier is normalized independently via
|
|
110
|
-
* `normalizeServerScores()` before merging, since a Postgres/OpenSearch score, a local `bm25()` score,
|
|
111
|
-
* and this module's own Tier 3 term-count score all occupy unrelated ranges. A uid present in more than
|
|
112
|
-
* one list keeps only the last-inserted entry (Tier 2 wins over Tier 3 wins over Tier 1) - Tier 2 and
|
|
113
|
-
* Tier 3 both represent genuine, content-verified scores for the same message, so which one "wins" on
|
|
114
|
-
* overlap doesn't change correctness, only which of two equally-valid scores is shown; either supersedes
|
|
115
|
-
* Tier 1's metadata-only guess for the same uid.
|
|
116
|
-
*
|
|
117
|
-
* Called progressively - once per tier as it resolves, each time with whatever tiers have reported so
|
|
118
|
-
* far (an empty array for the rest) - by `InboxContent`'s own search orchestration below, per §_Progressive
|
|
119
|
-
* Results_' "reordering is permitted and preferred over appending." This function itself stays pure and
|
|
120
|
-
* stateless; it has no notion of "in progress" versus "final." */
|
|
121
|
-
function mergeSearchResults(tier1, tier2, tier3) {
|
|
122
|
-
const normalizedTier1 = normalizeServerScores(tier1);
|
|
123
|
-
const normalizedTier2 = normalizeServerScores(tier2);
|
|
124
|
-
const normalizedTier3 = normalizeServerScores(tier3);
|
|
125
|
-
const merged = new Map();
|
|
126
|
-
for (const entry of normalizedTier1) {
|
|
127
|
-
merged.set(entry.result.entityUid, entry);
|
|
128
|
-
}
|
|
129
|
-
for (const entry of normalizedTier3) {
|
|
130
|
-
merged.set(entry.result.entityUid, entry);
|
|
131
|
-
}
|
|
132
|
-
for (const entry of normalizedTier2) {
|
|
133
|
-
merged.set(entry.result.entityUid, entry);
|
|
134
|
-
}
|
|
135
|
-
return Array.from(merged.values())
|
|
136
|
-
.sort((a, b) => b.normalizedScore - a.normalizedScore)
|
|
137
|
-
.map((entry) => entry.result);
|
|
138
|
-
}
|
|
139
113
|
/** `type:` narrows `entityTypes`; when absent this still defaults to `["message"]` — a non-message hit
|
|
140
114
|
* (contact/calendarEvent/note/task) has no `Message` to resolve via `getMessage()` below and is simply
|
|
141
115
|
* dropped by the same eventually-consistent-index fallback that already existed, rather than rendered
|
|
142
116
|
* (this inbox list only ever shows message rows; a real multi-entity-type results view is a separate,
|
|
143
117
|
* larger UI project outside this pass). Shared by both the fresh-search orchestration and `loadMore()`
|
|
144
118
|
* below, which each build this from the same `ParsedSearchQuery` differently only in `cursor`. */
|
|
145
|
-
function tier1SearchParams(parsed, cursor, mailboxUid) {
|
|
119
|
+
function tier1SearchParams(parsed, cursor, mailboxUid, limit) {
|
|
146
120
|
return {
|
|
147
|
-
// The mailbox
|
|
148
|
-
// wrong one whenever a shared mailbox's folder is being viewed.
|
|
121
|
+
// The mailbox being searched - omitted, the server searches the caller's *own* mailbox, which is the
|
|
122
|
+
// wrong one whenever a shared mailbox's folder is being viewed (or several mailboxes are searched at once).
|
|
149
123
|
mailboxUid,
|
|
150
124
|
types: parsed.entityTypes ?? ["message"],
|
|
151
125
|
cursor,
|
|
152
|
-
limit
|
|
126
|
+
limit,
|
|
153
127
|
from: parsed.from,
|
|
154
128
|
to: parsed.to,
|
|
155
129
|
cc: parsed.cc,
|
|
@@ -235,9 +209,10 @@ function tier3Windows(parsed, coverage, searchAllMail) {
|
|
|
235
209
|
}
|
|
236
210
|
/** Runs Tier 3 over each window and merges the candidates (a uid can't match in two disjoint windows, but a
|
|
237
211
|
* message whose date sits exactly on a boundary may come back from both). */
|
|
238
|
-
async function searchTier3Windows(windows, unlocked, mailboxUid) {
|
|
239
|
-
// Loaded on first use, with the S/MIME code it decrypts through (see `decryptEncryptedRows()`)
|
|
240
|
-
|
|
212
|
+
async function searchTier3Windows(windows, unlocked, mailboxUid, loadTier3) {
|
|
213
|
+
// Loaded on first use, with the S/MIME code it decrypts through (see `decryptEncryptedRows()`) - once for however many mailboxes a search
|
|
214
|
+
// covers: `loadTier3` hands every one of them the same import.
|
|
215
|
+
const { searchEncryptedCandidates } = await loadTier3();
|
|
241
216
|
const pages = await Promise.all(windows.map((window) => searchEncryptedCandidates(window, unlocked, TIER3_CANDIDATE_LIMIT, { mailboxUid })));
|
|
242
217
|
const merged = new Map();
|
|
243
218
|
for (const result of pages.flat()) {
|
|
@@ -252,6 +227,10 @@ async function searchTier3Windows(windows, unlocked, mailboxUid) {
|
|
|
252
227
|
function decodeCursor(raw, fingerprint) {
|
|
253
228
|
return raw?.fingerprint === fingerprint ? raw : undefined;
|
|
254
229
|
}
|
|
230
|
+
/** The mailboxes a search's `cursor` can still be paged for. */
|
|
231
|
+
function mailboxesWithMore(cursor) {
|
|
232
|
+
return [...cursor.perMailbox].filter(([, mailboxCursor]) => mailboxCursor.more).map(([uid]) => uid);
|
|
233
|
+
}
|
|
255
234
|
/** Resolves every hit's `entityUid` to a full `Message` via `getMessage()`, reusing `cache` across
|
|
256
235
|
* repeated calls within the same search pass - `InboxContent`'s own search orchestration below re-merges
|
|
257
236
|
* and re-resolves the *entire* current hit set each time a tier resolves, so without this cache every
|
|
@@ -366,11 +345,27 @@ async function fetchAggregateMessages(mailboxFolders, type, filter) {
|
|
|
366
345
|
}));
|
|
367
346
|
return mergeInboxMessages(perMailbox);
|
|
368
347
|
}
|
|
348
|
+
/** Groups search results by the conversation each message belongs to - within its own mailbox - the groups in the order of their first result. */
|
|
349
|
+
function groupByConversation(results) {
|
|
350
|
+
const groups = new Map();
|
|
351
|
+
for (const message of results) {
|
|
352
|
+
const id = message.conversationId ?? message.uid;
|
|
353
|
+
const key = `${message.mailboxUid}\u0000${id}`;
|
|
354
|
+
const group = groups.get(key);
|
|
355
|
+
if (group) {
|
|
356
|
+
group.messages.push(message);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
groups.set(key, { id, mailboxUid: message.mailboxUid, messages: [message] });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return [...groups.values()];
|
|
363
|
+
}
|
|
369
364
|
function InboxPage(props) {
|
|
370
365
|
return (_jsx(MailShell, { ...props, children: _jsx(InboxContent, { userUid: props.userUid }) }));
|
|
371
366
|
}
|
|
372
367
|
function InboxContent({ userUid }) {
|
|
373
|
-
const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange } = useMailShell();
|
|
368
|
+
const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot } = useMailShell();
|
|
374
369
|
const isMobile = useIsMobile();
|
|
375
370
|
const navigate = useNavigate();
|
|
376
371
|
const { requestUnlock } = useUnlockPrompt();
|
|
@@ -394,6 +389,7 @@ function InboxContent({ userUid }) {
|
|
|
394
389
|
const [selectedUid, setSelectedUid] = useState(null);
|
|
395
390
|
// The thread the conversation list opened, and which of its messages was picked - the reading pane
|
|
396
391
|
// shows the whole conversation, positioned at that message (see `ConversationThreadPane`).
|
|
392
|
+
// The mailbox is the open thread's own: a search over several mailboxes can open one that is not the page's mailbox.
|
|
397
393
|
const [openThread, setOpenThread] = useState(null);
|
|
398
394
|
// Messages the page has a newer copy of than `ConversationList` fetched (so far only the one the reading
|
|
399
395
|
// pane just marked read), applied over its own child rows so they don't stay bold after being read.
|
|
@@ -426,9 +422,15 @@ function InboxContent({ userUid }) {
|
|
|
426
422
|
// search and aggregate views it can be, and its labels must not be offered as a filter for this one.
|
|
427
423
|
// Empty (and never fetched) otherwise - see `labels` below.
|
|
428
424
|
const [otherMailboxLabels, setOtherMailboxLabels] = useState([]);
|
|
429
|
-
// Tier 2's own reported window coverage for the current search -
|
|
430
|
-
//
|
|
431
|
-
const [
|
|
425
|
+
// Tier 2's own reported window coverage for each mailbox the current search covers - empty outside a search, and
|
|
426
|
+
// 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).
|
|
427
|
+
const [coverages, setCoverages] = useState({});
|
|
428
|
+
// The mailboxes the current search could not (fully) search, and why - only a search over several mailboxes carries on past a
|
|
429
|
+
// failed one (a search of one mailbox fails as a whole, as it always has).
|
|
430
|
+
const [searchFailures, setSearchFailures] = useState([]);
|
|
431
|
+
// "Search all mailboxes" from a mailbox's own folder: the search is widened to every mailbox the reader can read. Reset with the search
|
|
432
|
+
// (see the effects below), and by leaving the folder.
|
|
433
|
+
const [searchAllMailboxes, setSearchAllMailboxes] = useState(false);
|
|
432
434
|
// Keyed by message uid - see decryptEncryptedRows(). Never cleared on folder/search switches (a
|
|
433
435
|
// decrypted row stays decrypted while its keys stay unlocked; re-decrypting on every navigation would
|
|
434
436
|
// waste work for no benefit) - only cleared when the mailbox's keys are locked (see the
|
|
@@ -462,7 +464,8 @@ function InboxContent({ userUid }) {
|
|
|
462
464
|
// aggregate-view row from a *different*, not-yet-unlocked mailbox stays locked until that mailbox's
|
|
463
465
|
// own folder view is opened directly - an accepted limitation, not a bug (see `MailShell`'s own doc
|
|
464
466
|
// comment on the same tradeoff for its `LocalIndexLifecycle`/`KeyEnrollmentGate` wiring).
|
|
465
|
-
|
|
467
|
+
// Always set once this page renders: a caller with no mailbox at all gets `MailboxProvisioning` from the shell instead.
|
|
468
|
+
const activeMailboxUid = (mailboxUid ?? primaryMailboxUid(mailboxes, userUid));
|
|
466
469
|
const mailboxKeys = mailboxes.find((mb) => mb.uid === activeMailboxUid)?.keys ?? [];
|
|
467
470
|
// The Sort/Filter menus' and "Show as conversations"' current settings, remembered per mailbox across
|
|
468
471
|
// reloads (`listPreferences.ts`). Read during render, not in an effect, so the very first listing
|
|
@@ -477,16 +480,42 @@ function InboxContent({ userUid }) {
|
|
|
477
480
|
setPreferencesByMailbox((prev) => ({ ...prev, [activeMailboxUid]: next }));
|
|
478
481
|
setMailListPreferences(activeMailboxUid, next);
|
|
479
482
|
}
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
|
|
483
|
+
// 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
|
|
484
|
+
// 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
|
|
485
|
+
// 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
|
|
486
|
+
// mailbox-scoped, so a search over several is the same per-mailbox search once for each, merged (see the search effect below). Own
|
|
487
|
+
// mailboxes come first, so the cap on how many are searched at once leaves out shared ones before the reader's own.
|
|
488
|
+
const readableMailboxes = mailboxFolders.filter((mf) => !mf.error).map((mf) => mf.mailbox);
|
|
489
|
+
const crossMailbox = !!aggregateFolderType || searchAllMailboxes;
|
|
490
|
+
// `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
|
|
491
|
+
// them has is left to the server to say so of every mailbox).
|
|
492
|
+
const inFolderUid = crossMailbox ? parseSearchQuery(searchQuery).folderUid : undefined;
|
|
493
|
+
const mailboxesWithFolder = inFolderUid
|
|
494
|
+
? mailboxFolders.filter((mf) => !mf.error && mf.folders.some((f) => f.uid === inFolderUid)).map((mf) => mf.mailbox)
|
|
495
|
+
: [];
|
|
496
|
+
const searchableMailboxes = mailboxesWithFolder.length > 0 ? mailboxesWithFolder : readableMailboxes;
|
|
497
|
+
const searchedMailboxes = crossMailbox ? orderMailboxes(searchableMailboxes, userUid).slice(0, SEARCH_LIMITS.maxMailboxes) : [];
|
|
498
|
+
// A single mailbox's own folder always has one; a cross-mailbox search has whichever it covers.
|
|
499
|
+
const searchTargetUids = crossMailbox ? searchedMailboxes.map((mb) => mb.uid) : [mailboxUid];
|
|
500
|
+
// A dependency of the search effect, which can't take the array itself.
|
|
501
|
+
const searchTargetKey = searchTargetUids.join(",");
|
|
502
|
+
// Search works whichever way the list is arranged. Its results are messages, so while a query is held the list is fetched as
|
|
503
|
+
// messages (`asConversations` is about the list's data and is false then), and when the reader arranges by conversation they are
|
|
504
|
+
// shown grouped under their conversation - only the messages that match - with the whole thread in the reading pane
|
|
505
|
+
// (`threadPane`, which follows the arrangement the reader chose and not the query).
|
|
506
|
+
const isSearching = searchQuery.length > 0;
|
|
507
|
+
/** 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). */
|
|
508
|
+
const rowCap = isSearching ? SEARCH_LIMITS.maxRows : MAX_LOADED_ROWS;
|
|
509
|
+
const asConversations = preferences.showAsConversations && !isSearching;
|
|
510
|
+
const threadPane = preferences.showAsConversations;
|
|
511
|
+
const groupedResults = isSearching && preferences.showAsConversations;
|
|
484
512
|
// Focused/Other is an Inbox-only concept (`FocusedInboxUtils.classifyMessage()` short-circuits to
|
|
485
513
|
// Focused for every other folder), and
|
|
486
514
|
// search results are ranked across folders rather than listed from one, so neither tab is offered
|
|
487
515
|
// there. Not offered for an aggregate view either (each mailbox classifies independently; merging
|
|
488
516
|
// that is out of scope).
|
|
489
|
-
const
|
|
517
|
+
const foldersOf = (ofMailbox) => mailboxFolders.find((mf) => mf.mailbox.uid === ofMailbox)?.folders ?? [];
|
|
518
|
+
const currentFolders = foldersOf(activeMailboxUid);
|
|
490
519
|
const currentFolderIsInbox = currentFolders.find((f) => f.uid === folderUid)?.type === "inbox";
|
|
491
520
|
const offerClassificationFilters = currentFolderIsInbox && !isSearching && !aggregateFolderType;
|
|
492
521
|
// A remembered Focused/Other filter must not silently narrow a folder that has no Focused Inbox to
|
|
@@ -513,7 +542,7 @@ function InboxContent({ userUid }) {
|
|
|
513
542
|
* shown: `GET /mail/messages/conversations` takes no sort parameters at all, so those rows are ordered
|
|
514
543
|
* in the browser (`sortConversations()`) and rearranging them must not refetch the identical page -
|
|
515
544
|
* which would also collapse whichever conversations the reader had expanded. */
|
|
516
|
-
const serverSortKey =
|
|
545
|
+
const serverSortKey = asConversations ? "" : `${preferences.sortBy}:${preferences.sortOrder}`;
|
|
517
546
|
/** The conversation list's own equivalent of `listParams` - a function because the page differs. */
|
|
518
547
|
function conversationParams(page) {
|
|
519
548
|
return { folderUid, filter: effectiveFilter, ...labelFilter, page, limit: MESSAGE_PAGE_SIZE };
|
|
@@ -555,7 +584,7 @@ function InboxContent({ userUid }) {
|
|
|
555
584
|
const resolvedMessageCacheRef = useRef(new Map());
|
|
556
585
|
// Session-scoped, never explicitly cleared - see Tier3Cache's own doc comment above.
|
|
557
586
|
const tier3CacheRef = useRef(new Map());
|
|
558
|
-
// The latest
|
|
587
|
+
// The latest cursor this search pass has reached (one composite cursor per mailbox searched) - read by loadMore(), written at the end
|
|
559
588
|
// of both the fresh-search orchestration and loadMore() itself. Not React state: it never drives a
|
|
560
589
|
// render on its own, only what loadMore() does with it later.
|
|
561
590
|
const compositeCursorRef = useRef(undefined);
|
|
@@ -573,33 +602,34 @@ function InboxContent({ userUid }) {
|
|
|
573
602
|
// state update elsewhere). New rows (a fresh page load, load-more, or a completed search) each
|
|
574
603
|
// re-trigger this the normal way, by changing `messages` itself.
|
|
575
604
|
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
// own doc comment above)
|
|
579
|
-
|
|
580
|
-
const undecryptedEncryptedUids =
|
|
581
|
-
.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && !decryptedRows[m.uid] && m.mailboxUid === activeMailboxUid)
|
|
582
|
-
.map((m) => m.uid);
|
|
605
|
+
// The auto-decrypt below covers every mailbox whose keys are unlocked - in aggregate mode and in a search over several mailboxes `messages`
|
|
606
|
+
// spans them, and a mailbox unlocked from the search's own banner has rows to show. The manual unlock banner (`handleUnlockList()`) is
|
|
607
|
+
// still about `activeMailboxUid` only (see its own doc comment above): a row of a locked mailbox stays "Encrypted message".
|
|
608
|
+
const undecryptedEncrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && !decryptedRows[m.uid]);
|
|
609
|
+
const undecryptedEncryptedUids = undecryptedEncrypted.filter((m) => m.mailboxUid === activeMailboxUid).map((m) => m.uid);
|
|
583
610
|
// Once unlocked, silently decrypt this page's own encrypted rows to show their real subject/preview -
|
|
584
611
|
// no prompt needed here, the same way searchEncryptedCandidates() already auto-includes decrypted
|
|
585
612
|
// matches once unlocked without asking again. Only the *first* unlock (or a fresh page of messages
|
|
586
613
|
// arriving) needs this; `handleUnlockList()` below covers the not-yet-unlocked case explicitly.
|
|
587
614
|
useEffect(() => {
|
|
588
|
-
if (undecryptedEncryptedUids.length === 0 || !activeMailboxUid) {
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
const unlocked = getUnlockedKeys(activeMailboxUid);
|
|
592
|
-
if (!unlocked) {
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
615
|
let cancelled = false;
|
|
596
616
|
const lockGeneration = lockGenerationRef.current;
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
617
|
+
const rowsByMailbox = new Map();
|
|
618
|
+
for (const message of undecryptedEncrypted) {
|
|
619
|
+
rowsByMailbox.set(message.mailboxUid, [...(rowsByMailbox.get(message.mailboxUid) ?? []), message]);
|
|
620
|
+
}
|
|
621
|
+
for (const [rowsMailboxUid, rows] of rowsByMailbox) {
|
|
622
|
+
const unlocked = getUnlockedKeys(rowsMailboxUid);
|
|
623
|
+
if (!unlocked) {
|
|
624
|
+
continue;
|
|
601
625
|
}
|
|
602
|
-
|
|
626
|
+
void decryptEncryptedRows(rows, unlocked).then((decrypted) => {
|
|
627
|
+
// A lock while this was in flight already cleared `decryptedRows` - never put them back.
|
|
628
|
+
if (!cancelled && lockGeneration === lockGenerationRef.current && Object.keys(decrypted).length > 0) {
|
|
629
|
+
setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
}
|
|
603
633
|
return () => {
|
|
604
634
|
cancelled = true;
|
|
605
635
|
};
|
|
@@ -609,11 +639,13 @@ function InboxContent({ userUid }) {
|
|
|
609
639
|
// snippets (Tier 2/3 snippets are decrypted content), and the already-decrypted Tier 3 candidates. A
|
|
610
640
|
// live search re-runs, so Tier 2/3 contribute nothing again until the user unlocks. Read through a ref
|
|
611
641
|
// so the subscription itself doesn't churn on every render.
|
|
612
|
-
const keyLockStateRef = useRef({ activeMailboxUid, isSearching });
|
|
613
|
-
keyLockStateRef.current = { activeMailboxUid, isSearching };
|
|
642
|
+
const keyLockStateRef = useRef({ activeMailboxUid, isSearching, searchTargetUids });
|
|
643
|
+
keyLockStateRef.current = { activeMailboxUid, isSearching, searchTargetUids };
|
|
614
644
|
useEffect(() => subscribeKeySession(({ mailboxUid: changedMailboxUid, state }) => {
|
|
615
645
|
const current = keyLockStateRef.current;
|
|
616
|
-
|
|
646
|
+
// The open mailbox's, or - while searching - any mailbox the search covers: their decrypted hits are on screen too.
|
|
647
|
+
const relevant = changedMailboxUid === current.activeMailboxUid || (current.isSearching && current.searchTargetUids.includes(changedMailboxUid));
|
|
648
|
+
if (state !== "locked" || !relevant) {
|
|
617
649
|
return;
|
|
618
650
|
}
|
|
619
651
|
lockGenerationRef.current += 1;
|
|
@@ -637,8 +669,30 @@ function InboxContent({ userUid }) {
|
|
|
637
669
|
// User dismissed the unlock dialog - rows stay exactly as they were.
|
|
638
670
|
}
|
|
639
671
|
}
|
|
672
|
+
/** The mailboxes a search over several could include encrypted mail from if they were unlocked: the ones with keys that are locked right now.
|
|
673
|
+
* `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. */
|
|
674
|
+
const lockedSearchMailboxes = crossMailbox ? searchedMailboxes.filter((mb) => (mb.keys?.length ?? 0) > 0 && !getUnlockedKeys(mb.uid)) : [];
|
|
675
|
+
const unlockableSearchMailboxes = lockedSearchMailboxes.filter((mb) => isOwnMailbox(mb, userUid));
|
|
676
|
+
const sharedLockedSearchMailboxes = lockedSearchMailboxes.filter((mb) => !isOwnMailbox(mb, userUid));
|
|
677
|
+
/** 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
|
|
678
|
+
* only Tier 3 - server-narrowed, bounded - reaches their encrypted mail. */
|
|
679
|
+
const serverOnlyMailboxes = crossMailbox
|
|
680
|
+
? searchedMailboxes.filter((mb) => (mb.keys?.length ?? 0) > 0 && !!getUnlockedKeys(mb.uid) && !coverages[mb.uid]?.indexedFrom)
|
|
681
|
+
: [];
|
|
682
|
+
/** The one mailbox's own coverage, for a search of one. */
|
|
683
|
+
const coverage = coverages[mailboxUid ?? ""];
|
|
684
|
+
/** A folder's search can be widened to every mailbox, when there is more than one to widen it to. */
|
|
685
|
+
const widenable = !aggregateFolderType && readableMailboxes.length > 1;
|
|
640
686
|
async function handleUnlockSearch() {
|
|
641
687
|
try {
|
|
688
|
+
if (crossMailbox) {
|
|
689
|
+
// One prompt per locked mailbox of the reader's own, in turn; a dismissed one stops the rest.
|
|
690
|
+
for (const mailbox of unlockableSearchMailboxes) {
|
|
691
|
+
await requestUnlock(mailbox.uid, mailbox.keys);
|
|
692
|
+
setUnlockRefresh((n) => n + 1);
|
|
693
|
+
}
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
642
696
|
await requestUnlock(activeMailboxUid, mailboxKeys);
|
|
643
697
|
setUnlockRefresh((n) => n + 1);
|
|
644
698
|
}
|
|
@@ -653,7 +707,7 @@ function InboxContent({ userUid }) {
|
|
|
653
707
|
// `activeMailboxUid`, and offering that mailbox's labels would let a label from the wrong mailbox be
|
|
654
708
|
// applied. The conversation view stays scoped to `activeMailboxUid`, the mailbox its threads come from.
|
|
655
709
|
const selectedMessageMailboxUid = messages.find((m) => m.uid === selectedUid)?.mailboxUid;
|
|
656
|
-
const labelsMailboxUid =
|
|
710
|
+
const labelsMailboxUid = threadPane ? (openThread?.mailboxUid ?? activeMailboxUid) : (selectedMessageMailboxUid ?? activeMailboxUid);
|
|
657
711
|
// Almost always the open mailbox's own labels, already fetched below - so `labels` reuses that list
|
|
658
712
|
// rather than asking for the identical one a second time (which is what this page did on every single
|
|
659
713
|
// view). Only a selected message from *another* mailbox - a search hit or an aggregate-view row - needs
|
|
@@ -709,17 +763,30 @@ function InboxContent({ userUid }) {
|
|
|
709
763
|
if (shown !== null && shown !== selectionKey) {
|
|
710
764
|
setSearchInput("");
|
|
711
765
|
setSearchQuery("");
|
|
766
|
+
setSearchAllMailboxes(false);
|
|
712
767
|
}
|
|
713
768
|
if (folderUid || aggregateFolderType) {
|
|
714
769
|
shownSelectionRef.current = selectionKey;
|
|
715
770
|
}
|
|
716
771
|
}, [selectionKey]);
|
|
772
|
+
// 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.
|
|
773
|
+
useEffect(() => {
|
|
774
|
+
if (searchQuery === "") {
|
|
775
|
+
setSearchAllMailboxes(false);
|
|
776
|
+
}
|
|
777
|
+
}, [searchQuery]);
|
|
778
|
+
// 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.
|
|
779
|
+
useEffect(() => {
|
|
780
|
+
if (aggregateFolderType && !isSearching) {
|
|
781
|
+
setSelectMode(false);
|
|
782
|
+
}
|
|
783
|
+
}, [aggregateFolderType, isSearching]);
|
|
717
784
|
// Which listing this is, for the short-lived snapshot of each folder that makes going back to one instant (see
|
|
718
785
|
// `listSnapshots.ts`). A search, an aggregate view and a folder that hasn't resolved have no snapshot.
|
|
719
786
|
const listKey = listSnapshotKey({
|
|
720
787
|
mailboxUid: activeMailboxUid,
|
|
721
788
|
folderUid,
|
|
722
|
-
conversations:
|
|
789
|
+
conversations: asConversations,
|
|
723
790
|
filter: effectiveFilter,
|
|
724
791
|
labels: labelFilterKey,
|
|
725
792
|
sort: serverSortKey,
|
|
@@ -787,7 +854,7 @@ function InboxContent({ userUid }) {
|
|
|
787
854
|
setLoading(true);
|
|
788
855
|
return;
|
|
789
856
|
}
|
|
790
|
-
if (
|
|
857
|
+
if (asConversations) {
|
|
791
858
|
// Conversations stay single-mailbox (not aggregated across mailboxes in this pass) - in
|
|
792
859
|
// aggregate mode this falls back to `activeMailboxUid`, the same mailbox unlock/labels use
|
|
793
860
|
// (always set - `MailShell` only renders this component once at least one mailbox exists).
|
|
@@ -834,7 +901,7 @@ function InboxContent({ userUid }) {
|
|
|
834
901
|
});
|
|
835
902
|
return invalidate;
|
|
836
903
|
}
|
|
837
|
-
if (aggregateFolderType) {
|
|
904
|
+
if (aggregateFolderType && !isSearching) {
|
|
838
905
|
setLoading(true);
|
|
839
906
|
setError(null);
|
|
840
907
|
// hasMore stays false (set above) - see fetchAggregateMessages()'s own pagination scope trim.
|
|
@@ -854,82 +921,183 @@ function InboxContent({ userUid }) {
|
|
|
854
921
|
setLoading(!snapshot);
|
|
855
922
|
setError(null);
|
|
856
923
|
if (isSearching) {
|
|
857
|
-
|
|
924
|
+
setCoverages({});
|
|
925
|
+
setSearchFailures([]);
|
|
858
926
|
setPendingUids(new Set());
|
|
859
927
|
setTier1Done(false);
|
|
860
928
|
setTier2Done(false);
|
|
861
929
|
setTier3Done(false);
|
|
862
930
|
resolvedMessageCacheRef.current = new Map();
|
|
863
931
|
const parsed = parseSearchQuery(searchQuery);
|
|
864
|
-
const unlocked = getUnlockedKeys(mailboxUid);
|
|
865
932
|
const fingerprint = queryFingerprint(parsed);
|
|
866
|
-
//
|
|
933
|
+
// What each mailbox is asked for per page: the whole page for one mailbox, a share of it for several.
|
|
934
|
+
const pageSize = searchPageSize(searchTargetUids.length, MESSAGE_PAGE_SIZE);
|
|
935
|
+
// 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.
|
|
936
|
+
const tier1TimeoutMs = crossMailbox ? SEARCH_LIMITS.tier1TimeoutMs : Infinity;
|
|
937
|
+
const tier3TimeoutMs = crossMailbox ? SEARCH_LIMITS.tier3TimeoutMs : Infinity;
|
|
938
|
+
const mailboxNames = new Map(searchedMailboxes.map((mb) => [mb.uid, mb.displayName]));
|
|
939
|
+
const isSuperseded = () => searchRunIdRef.current !== myRunId;
|
|
940
|
+
// Tier 3's code, imported when the first mailbox needs it and shared by the rest.
|
|
941
|
+
let tier3Module;
|
|
942
|
+
const loadTier3 = () => (tier3Module ?? (tier3Module = import("@rapidmx/react-shared/search/searchTier3.js")));
|
|
943
|
+
const failures = [];
|
|
944
|
+
/** The failed mailbox's notice entry. A search of one mailbox has none: its failure fails the search. */
|
|
945
|
+
const noteFailure = (uid, encrypted, err) => {
|
|
946
|
+
failures.push({ mailboxUid: uid, mailboxName: mailboxNames.get(uid), encrypted, reason: classifyFailure(err) });
|
|
947
|
+
};
|
|
948
|
+
// One entry per mailbox searched, in the order they are shown in - which is also the order ties are ranked in.
|
|
949
|
+
const runs = new Map(searchTargetUids.map((uid) => [
|
|
950
|
+
uid,
|
|
951
|
+
{
|
|
952
|
+
mailboxUid: uid,
|
|
953
|
+
unlocked: getUnlockedKeys(uid),
|
|
954
|
+
tier1: [],
|
|
955
|
+
tier2: [],
|
|
956
|
+
tier2More: false,
|
|
957
|
+
tier3: [],
|
|
958
|
+
tier3Total: 0,
|
|
959
|
+
tier3Key: "",
|
|
960
|
+
tier1Settled: false,
|
|
961
|
+
tier3Settled: false,
|
|
962
|
+
failed: false,
|
|
963
|
+
},
|
|
964
|
+
]));
|
|
965
|
+
const runList = [...runs.values()];
|
|
966
|
+
// A reveal that started before another one but finished after it must not put its older, smaller picture back.
|
|
967
|
+
let revealsStarted = 0;
|
|
968
|
+
let revealsApplied = 0;
|
|
969
|
+
// Recomputes and re-renders the merged list from whatever tiers have reported so far, across every mailbox -
|
|
867
970
|
// called once after Tier 1+2 (interim: unconfirmed Tier 1 metadataOnly hits render as
|
|
868
971
|
// skeletons) and again after Tier 3 (final: anything still unconfirmed is pruned instead -
|
|
869
|
-
// §_Progressive Results_' "Skeletons resolve or disappear")
|
|
972
|
+
// §_Progressive Results_' "Skeletons resolve or disappear"); a search over several mailboxes also calls it as each one
|
|
973
|
+
// reports, and a mailbox is "final" for its own hits as soon as its own Tier 3 has. Bails out if a
|
|
870
974
|
// newer search pass has since started.
|
|
871
|
-
async function reveal(
|
|
872
|
-
const
|
|
873
|
-
const
|
|
874
|
-
const
|
|
875
|
-
const
|
|
975
|
+
async function reveal() {
|
|
976
|
+
const revealId = ++revealsStarted;
|
|
977
|
+
const capped = capSkeletons(runList.flatMap((run) => run.tier1));
|
|
978
|
+
const tier2Hits = runList.flatMap((run) => run.tier2);
|
|
979
|
+
const tier3Hits = runList.flatMap((run) => run.tier3);
|
|
980
|
+
const confirmed = new Set([...tier2Hits, ...tier3Hits].map(hitKey));
|
|
981
|
+
const isUnconfirmed = (hit) => !!hit.metadataOnly && !confirmed.has(hitKey(hit));
|
|
982
|
+
const effectiveTier1 = capped.filter((hit) => !runs.get(hit.mailboxUid).tier3Settled || !isUnconfirmed(hit));
|
|
983
|
+
const merged = mergeSearchResults(effectiveTier1, tier2Hits, tier3Hits).slice(0, rowCap);
|
|
876
984
|
const { messages: resolved, snippets: resolvedSnippets } = await resolveHitsToMessages(merged, resolvedMessageCacheRef.current);
|
|
877
|
-
if (
|
|
985
|
+
if (isSuperseded() || revealId < revealsApplied) {
|
|
878
986
|
return;
|
|
879
987
|
}
|
|
988
|
+
revealsApplied = revealId;
|
|
880
989
|
setMessages(resolved);
|
|
881
990
|
setSnippets(resolvedSnippets);
|
|
882
|
-
if (
|
|
883
|
-
|
|
991
|
+
if (resolved.length > 0) {
|
|
992
|
+
// The first mailbox to answer with anything replaces the loading skeleton.
|
|
993
|
+
setLoading(false);
|
|
884
994
|
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
pending.add(hit.entityUid);
|
|
890
|
-
}
|
|
995
|
+
const pending = new Set();
|
|
996
|
+
for (const hit of capped) {
|
|
997
|
+
if (!runs.get(hit.mailboxUid).tier3Settled && isUnconfirmed(hit)) {
|
|
998
|
+
pending.add(hit.entityUid);
|
|
891
999
|
}
|
|
892
|
-
setPendingUids(pending);
|
|
893
1000
|
}
|
|
1001
|
+
setPendingUids(pending);
|
|
894
1002
|
}
|
|
895
1003
|
void (async () => {
|
|
896
1004
|
try {
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
1005
|
+
// Tier 1 (server) and Tier 2 (local index) of every mailbox, a few at a time.
|
|
1006
|
+
await runLimited(searchTargetUids, SEARCH_LIMITS.concurrency, async (uid) => {
|
|
1007
|
+
const run = runs.get(uid);
|
|
1008
|
+
try {
|
|
1009
|
+
const [tier1Page, tier2Page] = await Promise.all([
|
|
1010
|
+
withTimeout(searchMailbox(parsed.text, tier1SearchParams(parsed, undefined, uid, pageSize)), tier1TimeoutMs),
|
|
1011
|
+
searchLocalIndex(uid, parsed, run.unlocked, pageSize, 0),
|
|
1012
|
+
]);
|
|
1013
|
+
if (isSuperseded()) {
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
run.tier1 = tagHits(uid, tier1Page.results);
|
|
1017
|
+
run.tier1Cursor = tier1Page.nextCursor;
|
|
1018
|
+
run.tier2 = tagHits(uid, tier2Page.results);
|
|
1019
|
+
run.tier2More = tier2Page.hasMore;
|
|
1020
|
+
run.coverage = tier2Page.coverage;
|
|
1021
|
+
}
|
|
1022
|
+
catch (err) {
|
|
1023
|
+
if (!crossMailbox) {
|
|
1024
|
+
throw err;
|
|
1025
|
+
}
|
|
1026
|
+
run.failed = true;
|
|
1027
|
+
noteFailure(uid, false, err);
|
|
1028
|
+
}
|
|
1029
|
+
run.tier1Settled = true;
|
|
1030
|
+
if (crossMailbox && runList.some((other) => !other.tier1Settled)) {
|
|
1031
|
+
void reveal();
|
|
1032
|
+
}
|
|
1033
|
+
}, isSuperseded);
|
|
1034
|
+
if (isSuperseded()) {
|
|
902
1035
|
return;
|
|
903
1036
|
}
|
|
904
1037
|
setTier1Done(true);
|
|
905
1038
|
setTier2Done(true);
|
|
906
|
-
|
|
1039
|
+
setCoverages(Object.fromEntries(runList.map((run) => [run.mailboxUid, run.coverage])));
|
|
1040
|
+
setSearchFailures([...failures]);
|
|
907
1041
|
setLoading(false);
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
1042
|
+
if (failures.length === runList.length) {
|
|
1043
|
+
// Not one mailbox could be searched (or there were none to search): that is a failed search, not missing results.
|
|
1044
|
+
setError("Search failed.");
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
await reveal();
|
|
1048
|
+
// Tier 3 (server-narrowed encrypted candidates) of every mailbox that answered, each over the window its own local index
|
|
1049
|
+
// leaves uncovered - a mailbox with no local index is searched over the query's whole range.
|
|
1050
|
+
await runLimited(runList.filter((run) => !run.failed), SEARCH_LIMITS.concurrency, async (run) => {
|
|
1051
|
+
try {
|
|
1052
|
+
const windows = tier3Windows(parsed, run.coverage, searchAllMail);
|
|
1053
|
+
const cacheKey = tier3CacheKey(run.mailboxUid, windows, !!run.unlocked);
|
|
1054
|
+
let tier3Full = tier3CacheRef.current.get(cacheKey);
|
|
1055
|
+
if (!tier3Full) {
|
|
1056
|
+
tier3Full = await withTimeout(searchTier3Windows(windows, run.unlocked, run.mailboxUid, loadTier3), tier3TimeoutMs);
|
|
1057
|
+
if (isSuperseded()) {
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
tier3CacheRef.current.set(cacheKey, tier3Full);
|
|
1061
|
+
}
|
|
1062
|
+
run.tier3 = tagHits(run.mailboxUid, tier3Full.slice(0, pageSize));
|
|
1063
|
+
run.tier3Total = tier3Full.length;
|
|
1064
|
+
run.tier3Key = cacheKey;
|
|
1065
|
+
}
|
|
1066
|
+
catch (err) {
|
|
1067
|
+
if (!crossMailbox) {
|
|
1068
|
+
throw err;
|
|
1069
|
+
}
|
|
1070
|
+
noteFailure(run.mailboxUid, true, err);
|
|
1071
|
+
}
|
|
1072
|
+
run.tier3Settled = true;
|
|
1073
|
+
if (crossMailbox && runList.some((other) => !other.failed && !other.tier3Settled)) {
|
|
1074
|
+
void reveal();
|
|
916
1075
|
}
|
|
917
|
-
|
|
1076
|
+
}, isSuperseded);
|
|
1077
|
+
if (isSuperseded()) {
|
|
1078
|
+
return;
|
|
918
1079
|
}
|
|
919
|
-
const tier3Page = tier3Full.slice(0, MESSAGE_PAGE_SIZE);
|
|
920
1080
|
setTier3Done(true);
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1081
|
+
setSearchFailures([...failures]);
|
|
1082
|
+
const cursor = { fingerprint, pageSize, perMailbox: new Map() };
|
|
1083
|
+
for (const run of runList) {
|
|
1084
|
+
if (!run.failed) {
|
|
1085
|
+
cursor.perMailbox.set(run.mailboxUid, {
|
|
1086
|
+
tier1Cursor: run.tier1Cursor,
|
|
1087
|
+
tier2Offset: run.tier2.length,
|
|
1088
|
+
tier3Offset: run.tier3.length,
|
|
1089
|
+
tier3Total: run.tier3Total,
|
|
1090
|
+
tier3Key: run.tier3Key,
|
|
1091
|
+
more: !!run.tier1Cursor || run.tier2More || run.tier3.length < run.tier3Total,
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
compositeCursorRef.current = cursor;
|
|
1096
|
+
setHasMore(mailboxesWithMore(cursor).length > 0);
|
|
1097
|
+
await reveal();
|
|
930
1098
|
}
|
|
931
1099
|
catch (err) {
|
|
932
|
-
if (
|
|
1100
|
+
if (!isSuperseded()) {
|
|
933
1101
|
setError(err instanceof ApiRequestError ? err.message : "Search failed.");
|
|
934
1102
|
setLoading(false);
|
|
935
1103
|
}
|
|
@@ -986,7 +1154,7 @@ function InboxContent({ userUid }) {
|
|
|
986
1154
|
// `refreshKey` is a dependency for the same reason: a failed bulk action bumps it to refetch,
|
|
987
1155
|
// since a bulk update applies until its first rejection rather than all-or-nothing.
|
|
988
1156
|
}, [
|
|
989
|
-
|
|
1157
|
+
asConversations,
|
|
990
1158
|
serverSortKey,
|
|
991
1159
|
effectiveFilter,
|
|
992
1160
|
labelFilterKey,
|
|
@@ -998,9 +1166,12 @@ function InboxContent({ userUid }) {
|
|
|
998
1166
|
unlockRefresh,
|
|
999
1167
|
searchAllMail,
|
|
1000
1168
|
aggregateFolderType,
|
|
1001
|
-
// Only a merged
|
|
1002
|
-
// the list on screen and forget its selection.
|
|
1003
|
-
|
|
1169
|
+
// 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
|
|
1170
|
+
// the list on screen and forget its selection. A search over several mailboxes is built from the *mailboxes* instead (`searchTargetKey`), so a
|
|
1171
|
+
// folder appearing while it runs does not restart it either.
|
|
1172
|
+
aggregateFolderType && !isSearching ? mailboxFolders : null,
|
|
1173
|
+
crossMailbox,
|
|
1174
|
+
searchTargetKey,
|
|
1004
1175
|
activeMailboxUid,
|
|
1005
1176
|
]);
|
|
1006
1177
|
// 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 -
|
|
@@ -1019,7 +1190,7 @@ function InboxContent({ userUid }) {
|
|
|
1019
1190
|
if (live.tick === 0 || isSearching || loading || loadMoreInFlightRef.current || (!folderUid && !aggregateFolderType)) {
|
|
1020
1191
|
return;
|
|
1021
1192
|
}
|
|
1022
|
-
if (!
|
|
1193
|
+
if (!asConversations && live.folderUids) {
|
|
1023
1194
|
const shown = new Set(aggregateFolderType
|
|
1024
1195
|
? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType).map((f) => f.uid))
|
|
1025
1196
|
: [folderUid]);
|
|
@@ -1033,7 +1204,7 @@ function InboxContent({ userUid }) {
|
|
|
1033
1204
|
const isCurrent = () => searchRunIdRef.current === listRun && liveRunRef.current === myRun;
|
|
1034
1205
|
void (async () => {
|
|
1035
1206
|
try {
|
|
1036
|
-
if (
|
|
1207
|
+
if (asConversations) {
|
|
1037
1208
|
const fresh = await listConversations(activeMailboxUid, conversationParams(0));
|
|
1038
1209
|
if (!isCurrent()) {
|
|
1039
1210
|
return;
|
|
@@ -1082,12 +1253,13 @@ function InboxContent({ userUid }) {
|
|
|
1082
1253
|
// Reads the ref, not `conversations`/`messages` state, so this callback (and the observer effect
|
|
1083
1254
|
// watching it) don't need to be recreated every time a page lands - the same reason every other
|
|
1084
1255
|
// check below reads a ref rather than closing over state.
|
|
1085
|
-
const atRowCap = (
|
|
1256
|
+
const atRowCap = (asConversations ? conversationsRef.current.length : messagesRef.current.length) >= rowCap;
|
|
1086
1257
|
if (loadMoreInFlightRef.current ||
|
|
1087
1258
|
!hasMore ||
|
|
1088
1259
|
loading ||
|
|
1089
1260
|
atRowCap ||
|
|
1090
|
-
(
|
|
1261
|
+
// A search over every mailbox has no folder of its own (nor does a listing that has not resolved one).
|
|
1262
|
+
(!asConversations && !folderUid && !isSearching) ||
|
|
1091
1263
|
(isSearching && !searchCursor)) {
|
|
1092
1264
|
return;
|
|
1093
1265
|
}
|
|
@@ -1119,52 +1291,103 @@ function InboxContent({ userUid }) {
|
|
|
1119
1291
|
const myRunId = searchRunIdRef.current;
|
|
1120
1292
|
const isCurrentRun = () => searchRunIdRef.current === myRunId;
|
|
1121
1293
|
try {
|
|
1122
|
-
if (
|
|
1294
|
+
if (asConversations) {
|
|
1123
1295
|
const page = Math.floor(listedOffsetRef.current / MESSAGE_PAGE_SIZE);
|
|
1124
1296
|
const more = await listConversations(activeMailboxUid, conversationParams(page));
|
|
1125
1297
|
if (!isCurrentRun()) {
|
|
1126
1298
|
return;
|
|
1127
1299
|
}
|
|
1128
1300
|
const addedRows = hasUnseenRows(conversationsRef.current, more, conversationKey);
|
|
1129
|
-
setConversations((prev) => appendUnseenRows(prev, more, conversationKey,
|
|
1301
|
+
setConversations((prev) => appendUnseenRows(prev, more, conversationKey, rowCap));
|
|
1130
1302
|
setHasMore(more.length === MESSAGE_PAGE_SIZE);
|
|
1131
1303
|
listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
|
|
1132
1304
|
notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
|
|
1133
1305
|
}
|
|
1134
1306
|
else if (isSearching) {
|
|
1135
|
-
const unlocked = getUnlockedKeys(mailboxUid);
|
|
1136
1307
|
const cursor = searchCursor;
|
|
1137
|
-
const
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
const
|
|
1143
|
-
|
|
1144
|
-
|
|
1308
|
+
const tier1TimeoutMs = crossMailbox ? SEARCH_LIMITS.tier1TimeoutMs : Infinity;
|
|
1309
|
+
// Every mailbox with more to give is asked for its own next page - the others (exhausted, or given up on) stay as they are.
|
|
1310
|
+
const attempted = mailboxesWithMore(cursor);
|
|
1311
|
+
const pages = new Map();
|
|
1312
|
+
const failedMailboxes = [];
|
|
1313
|
+
const failedErrors = [];
|
|
1314
|
+
await runLimited(attempted, SEARCH_LIMITS.concurrency, async (uid) => {
|
|
1315
|
+
const mailboxCursor = cursor.perMailbox.get(uid);
|
|
1316
|
+
try {
|
|
1317
|
+
const [tier1Page, tier2Page] = await Promise.all([
|
|
1318
|
+
withTimeout(searchMailbox(parsed.text, tier1SearchParams(parsed, mailboxCursor.tier1Cursor, uid, cursor.pageSize)), tier1TimeoutMs),
|
|
1319
|
+
searchLocalIndex(uid, parsed, getUnlockedKeys(uid), cursor.pageSize, mailboxCursor.tier2Offset),
|
|
1320
|
+
]);
|
|
1321
|
+
// The first page cached this pass under `tier3Key` before it created the cursor - unless Tier 3 had nothing to give.
|
|
1322
|
+
const tier3Page = mailboxCursor.tier3Offset < mailboxCursor.tier3Total
|
|
1323
|
+
? tier3CacheRef.current
|
|
1324
|
+
.get(mailboxCursor.tier3Key)
|
|
1325
|
+
.slice(mailboxCursor.tier3Offset, mailboxCursor.tier3Offset + cursor.pageSize)
|
|
1326
|
+
: [];
|
|
1327
|
+
pages.set(uid, {
|
|
1328
|
+
tier1: tagHits(uid, tier1Page.results),
|
|
1329
|
+
tier1Cursor: tier1Page.nextCursor,
|
|
1330
|
+
tier2: tagHits(uid, tier2Page.results),
|
|
1331
|
+
tier2More: tier2Page.hasMore,
|
|
1332
|
+
tier3: tagHits(uid, tier3Page),
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
catch (err) {
|
|
1336
|
+
// One mailbox failing to give its next page must not stop the others' (a search of one has nothing else to show).
|
|
1337
|
+
if (!crossMailbox) {
|
|
1338
|
+
throw err;
|
|
1339
|
+
}
|
|
1340
|
+
failedErrors.push(err);
|
|
1341
|
+
failedMailboxes.push({
|
|
1342
|
+
mailboxUid: uid,
|
|
1343
|
+
mailboxName: searchedMailboxes.find((mb) => mb.uid === uid).displayName,
|
|
1344
|
+
encrypted: false,
|
|
1345
|
+
reason: classifyFailure(err),
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
}, () => !isCurrentRun());
|
|
1349
|
+
if (failedErrors.length === attempted.length) {
|
|
1350
|
+
// No mailbox answered: that is a failed page (with Retry), not a page with fewer rows.
|
|
1351
|
+
throw failedErrors[0];
|
|
1352
|
+
}
|
|
1353
|
+
const answered = [...pages.values()];
|
|
1145
1354
|
// Unlike the fresh-search pass above, a load-more page is resolved and appended in one
|
|
1146
1355
|
// shot rather than progressively revealed - rows already on screen shouldn't reorder or
|
|
1147
1356
|
// grow skeletons out from under a reader who has since scrolled past them. Any Tier 1
|
|
1148
1357
|
// metadataOnly hit this page that neither Tier 2 nor Tier 3 (both already awaited above)
|
|
1149
1358
|
// confirms simply keeps showing its existing placeholder text rather than a live skeleton
|
|
1150
1359
|
// - a deliberate, documented scope trim of progressive reveal to the first page only.
|
|
1151
|
-
const merged = mergeSearchResults(capSkeletons(
|
|
1360
|
+
const merged = mergeSearchResults(capSkeletons(answered.flatMap((page) => page.tier1)), answered.flatMap((page) => page.tier2), answered.flatMap((page) => page.tier3));
|
|
1152
1361
|
const { messages: more, snippets: moreSnippets } = await resolveHitsToMessages(merged, resolvedMessageCacheRef.current);
|
|
1153
1362
|
if (!isCurrentRun()) {
|
|
1154
1363
|
return;
|
|
1155
1364
|
}
|
|
1156
1365
|
const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
|
|
1157
|
-
setMessages((prev) => appendUnseenRows(prev, more, messageUid,
|
|
1366
|
+
setMessages((prev) => appendUnseenRows(prev, more, messageUid, rowCap));
|
|
1158
1367
|
setSnippets((prev) => ({ ...prev, ...moreSnippets }));
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1368
|
+
setSearchFailures((prev) => [...prev, ...failedMailboxes]);
|
|
1369
|
+
const perMailbox = new Map(cursor.perMailbox);
|
|
1370
|
+
for (const uid of attempted) {
|
|
1371
|
+
const previous = cursor.perMailbox.get(uid);
|
|
1372
|
+
const page = pages.get(uid);
|
|
1373
|
+
if (!page) {
|
|
1374
|
+
// Failed just now: given up on for the rest of this search (the notice says so).
|
|
1375
|
+
perMailbox.set(uid, { ...previous, more: false });
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
1378
|
+
const nextTier3Offset = previous.tier3Offset + page.tier3.length;
|
|
1379
|
+
perMailbox.set(uid, {
|
|
1380
|
+
tier1Cursor: page.tier1Cursor,
|
|
1381
|
+
tier2Offset: previous.tier2Offset + page.tier2.length,
|
|
1382
|
+
tier3Offset: nextTier3Offset,
|
|
1383
|
+
tier3Total: previous.tier3Total,
|
|
1384
|
+
tier3Key: previous.tier3Key,
|
|
1385
|
+
more: !!page.tier1Cursor || page.tier2More || nextTier3Offset < previous.tier3Total,
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
const nextCursor = { ...cursor, perMailbox };
|
|
1389
|
+
compositeCursorRef.current = nextCursor;
|
|
1390
|
+
const moreRemain = mailboxesWithMore(nextCursor).length > 0;
|
|
1168
1391
|
setHasMore(moreRemain);
|
|
1169
1392
|
notePageLanded(addedRows, moreRemain);
|
|
1170
1393
|
}
|
|
@@ -1178,7 +1401,7 @@ function InboxContent({ userUid }) {
|
|
|
1178
1401
|
return;
|
|
1179
1402
|
}
|
|
1180
1403
|
const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
|
|
1181
|
-
setMessages((prev) => appendUnseenRows(prev, more, messageUid,
|
|
1404
|
+
setMessages((prev) => appendUnseenRows(prev, more, messageUid, rowCap));
|
|
1182
1405
|
setHasMore(more.length === MESSAGE_PAGE_SIZE);
|
|
1183
1406
|
listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
|
|
1184
1407
|
notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
|
|
@@ -1198,7 +1421,7 @@ function InboxContent({ userUid }) {
|
|
|
1198
1421
|
}, [
|
|
1199
1422
|
hasMore,
|
|
1200
1423
|
loading,
|
|
1201
|
-
|
|
1424
|
+
asConversations,
|
|
1202
1425
|
serverSortKey,
|
|
1203
1426
|
effectiveFilter,
|
|
1204
1427
|
labelFilterKey,
|
|
@@ -1207,6 +1430,8 @@ function InboxContent({ userUid }) {
|
|
|
1207
1430
|
searchQuery,
|
|
1208
1431
|
mailboxUid,
|
|
1209
1432
|
activeMailboxUid,
|
|
1433
|
+
crossMailbox,
|
|
1434
|
+
searchTargetKey,
|
|
1210
1435
|
]);
|
|
1211
1436
|
// Always calls the latest `loadMore` closure so the effect below doesn't need `loadMore` itself in its
|
|
1212
1437
|
// dependency array (it changes on every keystroke/page load, which would otherwise mean nothing here).
|
|
@@ -1277,7 +1502,7 @@ function InboxContent({ userUid }) {
|
|
|
1277
1502
|
}
|
|
1278
1503
|
// The conversation list opens a whole thread in `ConversationThreadPane`, which loads and marks read
|
|
1279
1504
|
// its own messages; only the flat list feeds the single-message pane below.
|
|
1280
|
-
const selected =
|
|
1505
|
+
const selected = threadPane ? null : (messages.find((m) => m.uid === selectedUid) ?? null);
|
|
1281
1506
|
const attachments = useMessageAttachments(selected);
|
|
1282
1507
|
useMarkMessageRead(selected, patchListedMessage);
|
|
1283
1508
|
// Search results can span every folder in the mailbox, not just the one selected in the sidebar - a
|
|
@@ -1286,7 +1511,7 @@ function InboxContent({ userUid }) {
|
|
|
1286
1511
|
// this falls back to the sidebar selection unchanged). Aggregate views span every *mailbox* too, so
|
|
1287
1512
|
// the folder list consulted is the selected message's own mailbox's, not the shell's ambient one. A
|
|
1288
1513
|
// conversation spans folders for the same reason (an Inbox message and the Sent Items copy of its reply).
|
|
1289
|
-
const spansFolders = isSearching || !!aggregateFolderType ||
|
|
1514
|
+
const spansFolders = isSearching || !!aggregateFolderType || threadPane;
|
|
1290
1515
|
const selectedFolderUid = spansFolders ? (selected?.folderUid ?? folderUid) : folderUid;
|
|
1291
1516
|
const selectedMailboxUid = spansFolders ? (selected?.mailboxUid ?? activeMailboxUid) : mailboxUid;
|
|
1292
1517
|
const folders = mailboxFolders.find((mf) => mf.mailbox.uid === selectedMailboxUid)?.folders ?? [];
|
|
@@ -1305,12 +1530,12 @@ function InboxContent({ userUid }) {
|
|
|
1305
1530
|
* Items copy of its reply), and the list only ever showed this folder's half of it, so a bulk action
|
|
1306
1531
|
* from here must not reach into the other folders' copies either. */
|
|
1307
1532
|
const selectedConversationMessages = [...selectedConversationIds].flatMap((id) => (conversationMessagesById[id] ?? []).filter((m) => !folderUid || m.folderUid === folderUid));
|
|
1308
|
-
const selectedMessages =
|
|
1533
|
+
const selectedMessages = asConversations
|
|
1309
1534
|
? selectedConversationMessages
|
|
1310
1535
|
: messages.filter((m) => selectedUids.has(m.uid));
|
|
1311
1536
|
/** How many rows the list is actually showing - conversations or messages, whichever it lists. What the
|
|
1312
1537
|
* Select toggle is enabled by: there is nothing to select in a list with no rows. */
|
|
1313
|
-
const listedRowCount =
|
|
1538
|
+
const listedRowCount = asConversations ? conversations.length : messages.length;
|
|
1314
1539
|
/** The conversation rows in the order the reader arranged them. The endpoint takes no sort parameters
|
|
1315
1540
|
* of its own (it pages by latest activity), so the arrangement is applied here, to the rows fetched so
|
|
1316
1541
|
* far - which the Sort menu says on screen. `conversations` itself stays in the order the pages
|
|
@@ -1323,7 +1548,7 @@ function InboxContent({ userUid }) {
|
|
|
1323
1548
|
* mailbox/search whose true size lands at or near the cap can have its very last page be a partial one,
|
|
1324
1549
|
* which correctly turns `hasMore` false - without checking it here, the banner would still claim rows
|
|
1325
1550
|
* are being hidden even though every one of them is already on screen. */
|
|
1326
|
-
const rowCapReached = hasMore && (
|
|
1551
|
+
const rowCapReached = hasMore && (asConversations ? conversations.length : messages.length) >= rowCap;
|
|
1327
1552
|
function leaveSelectMode() {
|
|
1328
1553
|
setSelectMode(false);
|
|
1329
1554
|
setSelectedUids(new Set());
|
|
@@ -1417,7 +1642,7 @@ function InboxContent({ userUid }) {
|
|
|
1417
1642
|
}
|
|
1418
1643
|
}
|
|
1419
1644
|
}
|
|
1420
|
-
if (
|
|
1645
|
+
if (asConversations) {
|
|
1421
1646
|
// A conversation row is a summary of its messages - its count, unread count, participants
|
|
1422
1647
|
// and preview all move when a bulk action changes or empties part of it - so the list is
|
|
1423
1648
|
// reloaded rather than patched row by row.
|
|
@@ -1451,13 +1676,27 @@ function InboxContent({ userUid }) {
|
|
|
1451
1676
|
* first single-message archive, so that call both creates the folder and archives the first message,
|
|
1452
1677
|
* and the rest of the selection is then moved into the folder it reports. */
|
|
1453
1678
|
async function bulkArchive(chosen) {
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1679
|
+
return inEachMailbox(chosen, async (ofMailbox, group) => {
|
|
1680
|
+
const archiveFolderUid = foldersOf(ofMailbox).find((f) => f.type === "archive")?.uid;
|
|
1681
|
+
if (archiveFolderUid) {
|
|
1682
|
+
return moveMessages(group, archiveFolderUid);
|
|
1683
|
+
}
|
|
1684
|
+
const first = await archiveMessage(group[0].uid);
|
|
1685
|
+
const rest = group.slice(1);
|
|
1686
|
+
return rest.length === 0 ? [first] : [first, ...(await moveMessages(rest, first.folderUid))];
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
/**
|
|
1690
|
+
* Runs `action` once for every mailbox `chosen` has messages in and gathers what each returns. Folders belong to a mailbox, so anything that
|
|
1691
|
+
* 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
|
|
1692
|
+
* every other list holds one mailbox's messages and this is one call, as it always was.
|
|
1693
|
+
*/
|
|
1694
|
+
async function inEachMailbox(chosen, action) {
|
|
1695
|
+
const groups = new Map();
|
|
1696
|
+
for (const message of chosen) {
|
|
1697
|
+
groups.set(message.mailboxUid, [...(groups.get(message.mailboxUid) ?? []), message]);
|
|
1457
1698
|
}
|
|
1458
|
-
|
|
1459
|
-
const rest = chosen.slice(1);
|
|
1460
|
-
return rest.length === 0 ? [first] : [first, ...(await moveMessages(rest, first.folderUid))];
|
|
1699
|
+
return (await Promise.all([...groups].map(([ofMailbox, group]) => action(ofMailbox, group)))).flat();
|
|
1461
1700
|
}
|
|
1462
1701
|
/**
|
|
1463
1702
|
* 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
|
|
@@ -1468,22 +1707,26 @@ function InboxContent({ userUid }) {
|
|
|
1468
1707
|
* 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
|
|
1469
1708
|
* reloads; otherwise a second Delete would ask again.
|
|
1470
1709
|
*/
|
|
1471
|
-
async function resolveFolderOfType(type, name) {
|
|
1472
|
-
const key = `${
|
|
1473
|
-
const known =
|
|
1710
|
+
async function resolveFolderOfType(type, name, ofMailbox) {
|
|
1711
|
+
const key = `${ofMailbox}:${type}`;
|
|
1712
|
+
const known = foldersOf(ofMailbox).find((f) => f.type === type)?.uid ?? lazyFoldersRef.current.get(key);
|
|
1474
1713
|
if (known) {
|
|
1475
1714
|
return known;
|
|
1476
1715
|
}
|
|
1477
|
-
const listed = (await listFolders(
|
|
1716
|
+
const listed = (await listFolders(ofMailbox)).find((f) => f.type === type);
|
|
1478
1717
|
if (listed) {
|
|
1479
1718
|
onFolderCreated(listed);
|
|
1480
1719
|
lazyFoldersRef.current.set(key, listed.uid);
|
|
1481
1720
|
return listed.uid;
|
|
1482
1721
|
}
|
|
1483
|
-
const created = await createFolder({ mailboxUid:
|
|
1722
|
+
const created = await createFolder({ mailboxUid: ofMailbox, name, type });
|
|
1484
1723
|
lazyFoldersRef.current.set(key, created.uid);
|
|
1485
1724
|
return created.uid;
|
|
1486
1725
|
}
|
|
1726
|
+
/** Moves `chosen` into each of its mailbox's own folder of `type` (Deleted Items, Junk), making it where the server has none yet. */
|
|
1727
|
+
function moveToFolderOfType(chosen, type, name) {
|
|
1728
|
+
return inEachMailbox(chosen, async (ofMailbox, group) => moveMessages(group, await resolveFolderOfType(type, name, ofMailbox)));
|
|
1729
|
+
}
|
|
1487
1730
|
/**
|
|
1488
1731
|
* Sets every selected message's labels in one bulk update: each ends up with `labelUids`, plus the
|
|
1489
1732
|
* ones left partially applied (`keepPartial`) that it already had, plus any label this mailbox no
|
|
@@ -1499,14 +1742,71 @@ function InboxContent({ userUid }) {
|
|
|
1499
1742
|
})), false);
|
|
1500
1743
|
}
|
|
1501
1744
|
function moveSelectionToType(type, name, chosen) {
|
|
1502
|
-
return runBulkAction(
|
|
1745
|
+
return runBulkAction((moving) => moveToFolderOfType(moving, type, name), true, chosen);
|
|
1746
|
+
}
|
|
1747
|
+
// ---- Swiping a row on a phone (see `SwipeRow`): right to left archives, left to right asks for a folder. Both go through the same
|
|
1748
|
+
// ---- bulk path as the selection bar's Archive and Move to, so the badges, the optimistic bookkeeping and the failure pop-up are one
|
|
1749
|
+
// ---- implementation. Not offered where the actions aren't (an aggregate view spans mailboxes, search results are a mixed bag, the
|
|
1750
|
+
// ---- Outbox is the server's send queue) or while rows are being ticked.
|
|
1751
|
+
const listingOutbox = currentFolders.find((f) => f.uid === folderUid)?.type === "outbox";
|
|
1752
|
+
const swipeEnabled = isMobile && !selectMode && !aggregateFolderType && !isSearching && !listingOutbox;
|
|
1753
|
+
/** What a swipe's Move to is about, while its folder prompt is open: one message, or every message of a conversation in this folder. */
|
|
1754
|
+
const [moveDialog, setMoveDialog] = useState(null);
|
|
1755
|
+
/** The messages a swiped conversation stands for (the ones in the folder being listed), or `null` after saying why they couldn't be loaded. */
|
|
1756
|
+
async function swipedConversationMessages(conversation) {
|
|
1757
|
+
try {
|
|
1758
|
+
return await loadOpenConversation(conversation);
|
|
1759
|
+
}
|
|
1760
|
+
catch (err) {
|
|
1761
|
+
notifyApiError(err, "Couldn't load the messages in that conversation");
|
|
1762
|
+
return null;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
/** Archives what a swipe took away. Resolves whether it went through, so the row comes back when it didn't. */
|
|
1766
|
+
async function swipeArchive(target) {
|
|
1767
|
+
if (bulkBusy || resolvingSelection > 0) {
|
|
1768
|
+
return false;
|
|
1769
|
+
}
|
|
1770
|
+
const chosen = Array.isArray(target) ? target : await swipedConversationMessages(target);
|
|
1771
|
+
if (!chosen || chosen.length === 0) {
|
|
1772
|
+
return false;
|
|
1773
|
+
}
|
|
1774
|
+
return runBulkAction(bulkArchive, true, chosen);
|
|
1775
|
+
}
|
|
1776
|
+
async function swipeMove(folderUidToMoveTo) {
|
|
1777
|
+
// Only ever asked by the folder prompt, which is only open while `moveDialog` is set.
|
|
1778
|
+
const target = moveDialog;
|
|
1779
|
+
const chosen = "messages" in target ? target.messages : await swipedConversationMessages(target.conversation);
|
|
1780
|
+
if (!chosen || chosen.length === 0) {
|
|
1781
|
+
setMoveDialog(null);
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
await runBulkAction((moving) => moveMessages(moving, folderUidToMoveTo), true, chosen);
|
|
1785
|
+
setMoveDialog(null);
|
|
1503
1786
|
}
|
|
1504
1787
|
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;
|
|
1788
|
+
/** One message's row of the flat list - and, in search results grouped by conversation, of a group. */
|
|
1789
|
+
function renderMessageRow(message) {
|
|
1790
|
+
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] ||
|
|
1791
|
+
decryptedRows[message.uid]?.preview ||
|
|
1792
|
+
message.bodyPreview ||
|
|
1793
|
+
(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));
|
|
1794
|
+
}
|
|
1795
|
+
/** Search results arranged by conversation: the messages that matched, grouped under the conversation each belongs to, in the order of the
|
|
1796
|
+
* first match of each - the order the results are ranked in. `null` when the list is not showing grouped results. */
|
|
1797
|
+
const resultGroups = groupedResults ? groupByConversation(messages) : null;
|
|
1798
|
+
/** The search box: in the list on a desktop (flat lists only), in the shell's header beside the folders button on a phone. */
|
|
1799
|
+
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(" ") }));
|
|
1505
1800
|
function handleSelect(message) {
|
|
1506
1801
|
if (selectMode) {
|
|
1507
1802
|
toggleSelected(message.uid);
|
|
1508
1803
|
return;
|
|
1509
1804
|
}
|
|
1805
|
+
if (preferences.showAsConversations) {
|
|
1806
|
+
// A search result of a list arranged by conversation: the reading pane is the whole thread, opened at this message.
|
|
1807
|
+
openThreadOf(message);
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1510
1810
|
if (isMobile) {
|
|
1511
1811
|
navigate(`/messages/${encodeURIComponent(message.uid)}`);
|
|
1512
1812
|
return;
|
|
@@ -1514,6 +1814,22 @@ function InboxContent({ userUid }) {
|
|
|
1514
1814
|
removedAnchorRef.current = null;
|
|
1515
1815
|
setSelectedUid(message.uid);
|
|
1516
1816
|
}
|
|
1817
|
+
/** A message of a conversation, as the thread pane names it: the conversation's id (a message with no thread is its own) and the
|
|
1818
|
+
* subject to head it with while the thread loads. */
|
|
1819
|
+
function threadHeadOf(message) {
|
|
1820
|
+
return { conversationId: message.conversationId ?? message.uid, subject: message.subject ?? "", messageCount: 1 };
|
|
1821
|
+
}
|
|
1822
|
+
/** Opens the whole thread a message belongs to, positioned at it - the phone's message page, or the reading pane. */
|
|
1823
|
+
function openThreadOf(message) {
|
|
1824
|
+
const conversation = threadHeadOf(message);
|
|
1825
|
+
if (isMobile) {
|
|
1826
|
+
navigate(`/messages/${encodeURIComponent(message.uid)}?conversation=${encodeURIComponent(conversation.conversationId)}`);
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
removedAnchorRef.current = null;
|
|
1830
|
+
setSelectedUid(message.uid);
|
|
1831
|
+
setOpenThread({ conversation, uid: message.uid, mailboxUid: message.mailboxUid });
|
|
1832
|
+
}
|
|
1517
1833
|
/** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
|
|
1518
1834
|
* stands for, or the latest for a parent row. The thread pane loads the thread itself. */
|
|
1519
1835
|
function handleOpenConversation(conversation, uid) {
|
|
@@ -1524,28 +1840,29 @@ function InboxContent({ userUid }) {
|
|
|
1524
1840
|
return;
|
|
1525
1841
|
}
|
|
1526
1842
|
if (isMobile) {
|
|
1527
|
-
//
|
|
1528
|
-
|
|
1529
|
-
navigate(`/messages/${encodeURIComponent(uid)}`);
|
|
1843
|
+
// The message page shows the whole conversation, positioned at this message, when it is given the conversation.
|
|
1844
|
+
navigate(`/messages/${encodeURIComponent(uid)}?conversation=${encodeURIComponent(conversation.conversationId)}`);
|
|
1530
1845
|
return;
|
|
1531
1846
|
}
|
|
1532
1847
|
removedAnchorRef.current = null;
|
|
1533
1848
|
setSelectedUid(uid);
|
|
1534
|
-
setOpenThread({ conversation, uid });
|
|
1849
|
+
setOpenThread({ conversation, uid, mailboxUid: activeMailboxUid });
|
|
1535
1850
|
}
|
|
1536
1851
|
// ---- Keyboard shortcuts (see `shared/keyboard`). Each is registered only while this view can do it, and calls what the toolbar and
|
|
1537
1852
|
// ---- the selection bar call: the same bulk-action path (`runBulkAction()` - so the same optimistic badge tracking, the same rollback,
|
|
1538
1853
|
// ---- the same lazily created Deleted Items folder), never a second implementation. Reply, Reply all, Forward, Archive and Move to are
|
|
1539
1854
|
// ---- registered by the reading pane itself (`MessageDetailPane`'s `shortcuts`), which owns those handlers.
|
|
1540
|
-
const inConversations =
|
|
1541
|
-
/** The selection bar isn't offered in the aggregate
|
|
1542
|
-
|
|
1855
|
+
const inConversations = asConversations;
|
|
1856
|
+
/** 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
|
|
1857
|
+
* search over them are, each hit acted on in its own mailbox (see `inEachMailbox()`). */
|
|
1858
|
+
const keyboardActions = !aggregateFolderType || isSearching;
|
|
1543
1859
|
/** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
|
|
1544
|
-
const keyboardTargetExists = selectMode ? selectedMessages.length > 0 :
|
|
1860
|
+
const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : threadPane ? openThread !== null : selected !== null;
|
|
1545
1861
|
const rowCount = inConversations ? listedConversations.length : messages.length;
|
|
1546
1862
|
const canMoveSelection = !selectMode && !isMobile && !loading && rowCount > 0;
|
|
1863
|
+
/** The type of a message's folder - looked for in every mailbox's, as a search over several has messages from each. */
|
|
1547
1864
|
function folderTypeOf(folderUidOfMessage) {
|
|
1548
|
-
return
|
|
1865
|
+
return mailboxFolders.flatMap((mf) => mf.folders).find((f) => f.uid === folderUidOfMessage)?.type;
|
|
1549
1866
|
}
|
|
1550
1867
|
/** Moves the keyboard's focus to a row - and scrolls it into view - once the selection has moved to it. */
|
|
1551
1868
|
function focusRow(uid) {
|
|
@@ -1589,16 +1906,19 @@ function InboxContent({ userUid }) {
|
|
|
1589
1906
|
}
|
|
1590
1907
|
}
|
|
1591
1908
|
}
|
|
1592
|
-
/** The messages of the open conversation that are in the folder being listed - fetched (once) like a ticked conversation's.
|
|
1593
|
-
|
|
1909
|
+
/** The messages of the open conversation that are in the folder being listed - fetched (once) like a ticked conversation's. `ofMailbox` is the
|
|
1910
|
+
* 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. */
|
|
1911
|
+
async function loadOpenConversation(conversation, ofMailbox = activeMailboxUid) {
|
|
1594
1912
|
const id = conversation.conversationId;
|
|
1595
|
-
|
|
1913
|
+
const cacheKey = ofMailbox === activeMailboxUid ? id : `${ofMailbox}\u0000${id}`;
|
|
1914
|
+
let all = conversationMessagesById[cacheKey];
|
|
1596
1915
|
if (!all) {
|
|
1597
|
-
all = await listConversationMessages(
|
|
1916
|
+
all = await listConversationMessages(ofMailbox, id);
|
|
1598
1917
|
const loaded = all;
|
|
1599
|
-
setConversationMessagesById((prev) => ({ ...prev, [
|
|
1918
|
+
setConversationMessagesById((prev) => ({ ...prev, [cacheKey]: loaded }));
|
|
1600
1919
|
}
|
|
1601
|
-
|
|
1920
|
+
// What a search over several mailboxes finds is in every folder of them, not just the one open.
|
|
1921
|
+
return all.filter((m) => !folderUid || crossMailbox || m.folderUid === folderUid);
|
|
1602
1922
|
}
|
|
1603
1923
|
/**
|
|
1604
1924
|
* Runs a bulk action on what the keyboard acts on - the ticked rows in select mode, else the open message or conversation - keeping only
|
|
@@ -1611,7 +1931,11 @@ function InboxContent({ userUid }) {
|
|
|
1611
1931
|
}
|
|
1612
1932
|
let chosen;
|
|
1613
1933
|
try {
|
|
1614
|
-
chosen = selectMode
|
|
1934
|
+
chosen = selectMode
|
|
1935
|
+
? selectedMessages
|
|
1936
|
+
: threadPane
|
|
1937
|
+
? await loadOpenConversation(openThread.conversation, openThread.mailboxUid)
|
|
1938
|
+
: [selected];
|
|
1615
1939
|
}
|
|
1616
1940
|
catch (err) {
|
|
1617
1941
|
notifyApiError(err, "Couldn't load the messages in that conversation");
|
|
@@ -1622,7 +1946,7 @@ function InboxContent({ userUid }) {
|
|
|
1622
1946
|
return;
|
|
1623
1947
|
}
|
|
1624
1948
|
const succeeded = await runBulkAction(action, removesRows, chosen);
|
|
1625
|
-
if (succeeded && removesRows &&
|
|
1949
|
+
if (succeeded && removesRows && threadPane && !selectMode) {
|
|
1626
1950
|
setOpenThread(null);
|
|
1627
1951
|
setSelectedUid(null);
|
|
1628
1952
|
}
|
|
@@ -1631,7 +1955,7 @@ function InboxContent({ userUid }) {
|
|
|
1631
1955
|
useShortcut(SHORTCUTS.mail.previous, () => selectNeighbour(-1), { enabled: canMoveSelection });
|
|
1632
1956
|
useShortcut(SHORTCUTS.mail.nextUnread, () => selectNextUnread(1), { enabled: canMoveSelection });
|
|
1633
1957
|
useShortcut(SHORTCUTS.mail.previousUnread, () => selectNextUnread(-1), { enabled: canMoveSelection });
|
|
1634
|
-
useShortcut(SHORTCUTS.mail.delete, () => void runOnKeyboardTargets(
|
|
1958
|
+
useShortcut(SHORTCUTS.mail.delete, () => void runOnKeyboardTargets((chosen) => moveToFolderOfType(chosen, "deleted_items", "Deleted Items"), true, (message) => folderTypeOf(message.folderUid) !== "deleted_items"), { enabled: keyboardActions && keyboardTargetExists });
|
|
1635
1959
|
useShortcut(SHORTCUTS.mail.markRead, () => void runOnKeyboardTargets((chosen) => setReadStateMany(chosen, true, { patch: patchListedMessage, track: trackMessageChange }), false, isUnread), { enabled: keyboardActions && keyboardTargetExists });
|
|
1636
1960
|
useShortcut(SHORTCUTS.mail.markUnread, () => void runOnKeyboardTargets((chosen) => setReadStateMany(chosen, false, { patch: patchListedMessage, track: trackMessageChange }), false, (message) => !isUnread(message)), { enabled: keyboardActions && keyboardTargetExists });
|
|
1637
1961
|
useShortcut(SHORTCUTS.mail.flag, () =>
|
|
@@ -1661,7 +1985,16 @@ function InboxContent({ userUid }) {
|
|
|
1661
1985
|
useShortcut(SHORTCUTS.mail.search, () => {
|
|
1662
1986
|
searchInputRef.current?.focus();
|
|
1663
1987
|
searchInputRef.current?.select();
|
|
1664
|
-
}
|
|
1988
|
+
});
|
|
1989
|
+
// 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
|
|
1990
|
+
// (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
|
|
1991
|
+
// Report junk go to each hit's own mailbox's folders (`inEachMailbox()`), so they need nothing of the sort.
|
|
1992
|
+
const selectedMailboxUids = new Set(selectedMessages.map((m) => m.mailboxUid));
|
|
1993
|
+
const selectionMailboxUid = crossMailbox && selectedMailboxUids.size === 1 ? [...selectedMailboxUids][0] : activeMailboxUid;
|
|
1994
|
+
const moveDisabledReason = crossMailbox && selectedMailboxUids.size > 1 ? "The selected messages are in different mailboxes - select messages from one mailbox to move them" : undefined;
|
|
1995
|
+
const labelsDisabledReason = crossMailbox && selectedMessages.some((m) => m.mailboxUid !== activeMailboxUid)
|
|
1996
|
+
? "Labels can only be applied here to messages in the open mailbox"
|
|
1997
|
+
: undefined;
|
|
1665
1998
|
if (!folderUid && !aggregateFolderType) {
|
|
1666
1999
|
// `MailShell` never renders this component at all until a mailbox is resolved (see its own
|
|
1667
2000
|
// full-screen `MailboxProvisioning` takeover otherwise) — this is purely the brief gap before
|
|
@@ -1673,44 +2006,49 @@ function InboxContent({ userUid }) {
|
|
|
1673
2006
|
return (_jsxs("div", { className: "flex h-full min-h-0", children: [_jsxs("div", { ref: scrollContainerRef,
|
|
1674
2007
|
// Remembered as it moves (not when the list is left: by then React has already detached the element), so
|
|
1675
2008
|
// the folder - or Mail, after another app - comes back where it was.
|
|
1676
|
-
onScroll: (event) => saveListScroll(listKey, event.currentTarget.scrollTop), className: "w-full md:w-96 shrink-0 md:border-r border-border overflow-y-auto", children: [selectMode ? (_jsx(MailSelectionBar, { selected: selectedMessages, listed: messages, totals:
|
|
2009
|
+
onScroll: (event) => saveListScroll(listKey, event.currentTarget.scrollTop), className: "w-full md:w-96 shrink-0 md:border-r border-border overflow-y-auto", children: [selectMode ? (_jsx(MailSelectionBar, { selected: selectedMessages, listed: messages, totals: asConversations
|
|
1677
2010
|
? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
|
|
1678
|
-
: undefined, onSelectAll: () =>
|
|
2011
|
+
: undefined, onSelectAll: () => asConversations
|
|
1679
2012
|
? selectAllConversations()
|
|
1680
|
-
: setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () =>
|
|
2013
|
+
: 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(
|
|
1681
2014
|
// Optimistic: the rows and the folder badges change now, and change back if the server refuses.
|
|
1682
2015
|
(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) => {
|
|
1683
2016
|
await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid), true);
|
|
1684
2017
|
}, onReportJunk: () => void moveSelectionToType("junk", "Junk Email"), onDelete: () => void moveSelectionToType("deleted_items", "Deleted Items"), busy: bulkBusy || resolvingSelection > 0 })) : (_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,
|
|
1685
2018
|
// A conversation row is a thread summary, so only some of the keys have anything to
|
|
1686
2019
|
// order by - the rest stay pickable and are applied to the rows already fetched.
|
|
1687
|
-
unavailableSortKeys:
|
|
2020
|
+
unavailableSortKeys: asConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined, sortKeysNote: isSearching
|
|
1688
2021
|
? "Search results are ranked by relevance rather than sorted."
|
|
1689
2022
|
: aggregateFolderType
|
|
1690
2023
|
? "This view merges the newest mail from every mailbox and is always listed by date."
|
|
1691
|
-
:
|
|
2024
|
+
: asConversations
|
|
1692
2025
|
? CONVERSATION_SORT_NOTE
|
|
1693
|
-
: undefined, selectDisabled: !!aggregateFolderType || loading || listedRowCount === 0, selectDisabledReason: aggregateFolderType
|
|
2026
|
+
: undefined, selectDisabled: (!!aggregateFolderType && !isSearching) || loading || listedRowCount === 0, selectDisabledReason: aggregateFolderType && !isSearching
|
|
1694
2027
|
? "Open a mailbox's own folder to select messages"
|
|
1695
2028
|
: loading
|
|
1696
2029
|
? "Wait for this folder to finish loading"
|
|
1697
|
-
: "There is nothing here to select" })), !
|
|
2030
|
+
: "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: [
|
|
1698
2031
|
"flex-1 py-1.5 font-semibold",
|
|
1699
2032
|
preferences.filter === value
|
|
1700
2033
|
? "text-primary-dark border-b-2 border-primary-dark"
|
|
1701
2034
|
: "text-text-muted",
|
|
1702
|
-
].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
|
|
1703
|
-
|
|
1704
|
-
|
|
2035
|
+
].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
|
|
2036
|
+
? `${messages.length} result${messages.length === 1 ? "" : "s"}`
|
|
2037
|
+
: `${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
|
|
2038
|
+
? "Also search encrypted mail from before each mailbox's local index begins"
|
|
2039
|
+
: 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
|
|
1705
2040
|
? " - searching everything, not just recent mail."
|
|
1706
|
-
: " - 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 && !
|
|
2041
|
+
: " - 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: {
|
|
2042
|
+
enabled: swipeEnabled,
|
|
2043
|
+
onArchive: swipeArchive,
|
|
2044
|
+
onMove: (conversation) => setMoveDialog({ conversation }),
|
|
2045
|
+
} }), 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
|
|
1707
2046
|
? `No messages match "${searchQuery}".`
|
|
1708
2047
|
: effectiveFilter === "all"
|
|
1709
2048
|
? "No messages in this folder."
|
|
1710
|
-
: "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", {
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
(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 })] }))] })] }, message.uid))) }), 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 ", MAX_LOADED_ROWS, " messages \u2014 refine your search or filters to see more."] })), aggregateFolderType && (_jsx("p", { className: "p-4 text-center text-xs text-text-muted", children: "Showing the most recent mail from each mailbox. Open a specific mailbox\u2019s folder to see older mail." }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0 min-h-0", children: preferences.showAsConversations ? (_jsx(LazyConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: activeMailboxUid, folders: currentFolders, labels: mailboxLabels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(LazyMessageDetailPane, { shortcuts: !selectMode, message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
|
|
2049
|
+
: "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
|
|
2050
|
+
? 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}`)))
|
|
2051
|
+
: 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) => {
|
|
1714
2052
|
// Same reasoning as onArchived below - a move takes the message out of the folder
|
|
1715
2053
|
// being listed, so it leaves the list rather than being patched in place.
|
|
1716
2054
|
removeListedMessage(updated.uid);
|
|
@@ -1724,6 +2062,6 @@ function InboxContent({ userUid }) {
|
|
|
1724
2062
|
// whichever folder is currently being viewed (into Archive), so it's removed
|
|
1725
2063
|
// from the list rather than patched in place.
|
|
1726
2064
|
removeListedMessage(updated.uid);
|
|
1727
|
-
}, labels: labels, onLabelsChanged: patchListedMessage, onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]) })) })] }));
|
|
2065
|
+
}, 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 })] }));
|
|
1728
2066
|
}
|
|
1729
2067
|
export default routedPage("/", InboxPage);
|