@remit/web-client 0.0.1
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/harness/build.mjs +84 -0
- package/harness/index.html +37 -0
- package/harness/vite-preset.ts +29 -0
- package/index.html +47 -0
- package/package.json +147 -0
- package/public/config.js +5 -0
- package/public/locales/de/common.json +1 -0
- package/public/locales/de/errors.json +1 -0
- package/public/locales/de/mail.json +13 -0
- package/public/locales/de/settings.json +1 -0
- package/public/locales/en/common.json +26 -0
- package/public/locales/en/errors.json +1 -0
- package/public/locales/en/mail.json +69 -0
- package/public/locales/en/settings.json +1 -0
- package/public/locales/fr/common.json +1 -0
- package/public/locales/fr/errors.json +1 -0
- package/public/locales/fr/mail.json +13 -0
- package/public/locales/fr/settings.json +1 -0
- package/public/locales/nl/common.json +1 -0
- package/public/locales/nl/errors.json +1 -0
- package/public/locales/nl/mail.json +13 -0
- package/public/locales/nl/settings.json +1 -0
- package/src/auth/AccountMenu.tsx +58 -0
- package/src/auth/AccountSession.tsx +19 -0
- package/src/auth/BetterAuthShell.tsx +198 -0
- package/src/auth/account-menu-mode.test.ts +64 -0
- package/src/auth/account-menu-mode.ts +28 -0
- package/src/auth/amplify-config.test.ts +158 -0
- package/src/auth/amplify-config.ts +60 -0
- package/src/auth/auth-error.test.ts +109 -0
- package/src/auth/auth-error.ts +107 -0
- package/src/auth/auth-interceptor.test.ts +86 -0
- package/src/auth/auth-interceptor.ts +23 -0
- package/src/auth/auth.css +334 -0
- package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
- package/src/auth/better-auth-config.ts +85 -0
- package/src/auth/better-auth-provider.tsx +20 -0
- package/src/auth/cognito/CognitoAccount.tsx +48 -0
- package/src/auth/cognito/CognitoShell.tsx +266 -0
- package/src/auth/cognito/cognito-token.test.ts +110 -0
- package/src/auth/cognito/cognito-token.ts +47 -0
- package/src/auth/cognito/cognito.css +21 -0
- package/src/auth/cognito-provider.tsx +22 -0
- package/src/auth/combined-provider.tsx +42 -0
- package/src/auth/composition.test.ts +136 -0
- package/src/auth/provider.tsx +63 -0
- package/src/auth/sign-out-visibility.test.ts +40 -0
- package/src/auth/sign-out-visibility.ts +10 -0
- package/src/auth/sign-up-disabled.test.ts +23 -0
- package/src/components/compose/AddressField.tsx +237 -0
- package/src/components/compose/ComposeBody.tsx +36 -0
- package/src/components/compose/ComposeForm.tsx +676 -0
- package/src/components/compose/ComposeProvider.tsx +133 -0
- package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
- package/src/components/compose/FromSelector.tsx +67 -0
- package/src/components/compose/FullCompose.tsx +82 -0
- package/src/components/compose/InlineCompose.tsx +29 -0
- package/src/components/compose/MobileComposeSheet.tsx +152 -0
- package/src/components/compose/PlateEditor.tsx +86 -0
- package/src/components/compose/PlateToolbar.tsx +110 -0
- package/src/components/compose/SubjectField.tsx +19 -0
- package/src/components/compose/draft-discard-banner.test.ts +76 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/sanitize-quote-html.ts +23 -0
- package/src/components/layout/AppShellSkeleton.tsx +53 -0
- package/src/components/layout/ComposeFab.tsx +86 -0
- package/src/components/layout/Drawer.tsx +105 -0
- package/src/components/mail/AutoMovedIndicator.tsx +48 -0
- package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
- package/src/components/mail/BlockedImagesNotice.tsx +41 -0
- package/src/components/mail/BriefPane.tsx +289 -0
- package/src/components/mail/ConversationView.tsx +462 -0
- package/src/components/mail/DailyBrief.tsx +451 -0
- package/src/components/mail/DraftsView.tsx +279 -0
- package/src/components/mail/FlaggedList.tsx +170 -0
- package/src/components/mail/FlaggedPane.tsx +217 -0
- package/src/components/mail/IntelligencePane.test.ts +123 -0
- package/src/components/mail/IntelligencePane.tsx +441 -0
- package/src/components/mail/MailListHeader.tsx +187 -0
- package/src/components/mail/MailNav.tsx +70 -0
- package/src/components/mail/MailSidebarAdapter.tsx +351 -0
- package/src/components/mail/MailViewChrome.tsx +95 -0
- package/src/components/mail/MailboxPane.tsx +1219 -0
- package/src/components/mail/MessageActionMenu.tsx +368 -0
- package/src/components/mail/MessageBody.tsx +207 -0
- package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
- package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
- package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
- package/src/components/mail/MessageCard.tsx +401 -0
- package/src/components/mail/MessageDetail.tsx +153 -0
- package/src/components/mail/MessageList.tsx +804 -0
- package/src/components/mail/MessageListItem.tsx +267 -0
- package/src/components/mail/MessageToolbar.render.test.ts +57 -0
- package/src/components/mail/MessageToolbar.tsx +166 -0
- package/src/components/mail/MobileMessageBar.tsx +142 -0
- package/src/components/mail/MoveToTrigger.tsx +250 -0
- package/src/components/mail/NavMenuButton.tsx +25 -0
- package/src/components/mail/OutboxPane.tsx +531 -0
- package/src/components/mail/PullToRefresh.tsx +24 -0
- package/src/components/mail/RawMessageView.tsx +84 -0
- package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
- package/src/components/mail/SelectionToolbar.tsx +139 -0
- package/src/components/mail/SpamRescue.render.test.ts +35 -0
- package/src/components/mail/SpamRescue.tsx +138 -0
- package/src/components/mail/SwipeableMessageRow.tsx +148 -0
- package/src/components/mail/message-body-error-banner-content.ts +87 -0
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
- package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
- package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
- package/src/components/mail/organize/OrganizePanel.tsx +344 -0
- package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
- package/src/components/settings/AccountFormPanel.test.ts +72 -0
- package/src/components/settings/AccountFormPanel.tsx +942 -0
- package/src/components/settings/DangerZone.tsx +30 -0
- package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
- package/src/components/settings/DeleteAccountDialog.tsx +224 -0
- package/src/components/settings/FiltersList.render.test.ts +71 -0
- package/src/components/settings/FiltersList.tsx +93 -0
- package/src/components/settings/account-form-helpers.test.ts +172 -0
- package/src/components/settings/account-form-helpers.ts +105 -0
- package/src/components/ui/AppVersion.tsx +50 -0
- package/src/components/ui/BugReportButton.tsx +45 -0
- package/src/components/ui/ConfirmDialog.tsx +125 -0
- package/src/components/ui/DropdownMenu.tsx +105 -0
- package/src/components/ui/EmptyState.tsx +11 -0
- package/src/components/ui/ErrorBanner.tsx +88 -0
- package/src/components/ui/ErrorBannerProvider.tsx +77 -0
- package/src/components/ui/ErrorBannerStack.tsx +34 -0
- package/src/components/ui/ErrorState.tsx +80 -0
- package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
- package/src/components/ui/FatalErrorOverlay.tsx +151 -0
- package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
- package/src/components/ui/SlidePanel.tsx +72 -0
- package/src/components/ui/error-banners.test.ts +207 -0
- package/src/components/ui/error-banners.ts +111 -0
- package/src/hooks/queries/keys.ts +38 -0
- package/src/hooks/useArchiveMailbox.ts +104 -0
- package/src/hooks/useAutoMovedBadge.ts +77 -0
- package/src/hooks/useCurrentMailboxName.ts +99 -0
- package/src/hooks/useDebouncedValue.ts +12 -0
- package/src/hooks/useDeleteMessages.test.ts +103 -0
- package/src/hooks/useDeleteMessages.ts +226 -0
- package/src/hooks/useFilters.ts +112 -0
- package/src/hooks/useIntelligenceData.test.ts +269 -0
- package/src/hooks/useIntelligenceData.ts +284 -0
- package/src/hooks/useIsDark.ts +27 -0
- package/src/hooks/useKeyboardNavigation.ts +192 -0
- package/src/hooks/useLayoutTier.test.ts +47 -0
- package/src/hooks/useLayoutTier.ts +44 -0
- package/src/hooks/useLongPress.test.ts +291 -0
- package/src/hooks/useLongPress.ts +87 -0
- package/src/hooks/useMailboxAccount.ts +62 -0
- package/src/hooks/useMailboxNameIndex.ts +32 -0
- package/src/hooks/useMarkAsRead.test.ts +165 -0
- package/src/hooks/useMarkAsRead.ts +319 -0
- package/src/hooks/useMediaQuery.ts +39 -0
- package/src/hooks/useMessageBodyContent.test.ts +370 -0
- package/src/hooks/useMessageBodyContent.ts +380 -0
- package/src/hooks/useMoveMessages.test.ts +63 -0
- package/src/hooks/useMoveMessages.ts +221 -0
- package/src/hooks/useOrganizeJob.ts +90 -0
- package/src/hooks/useOrganizePreview.ts +40 -0
- package/src/hooks/useRescueCandidates.test.ts +217 -0
- package/src/hooks/useRescueCandidates.ts +48 -0
- package/src/hooks/useSaveDraft.ts +118 -0
- package/src/hooks/useSelection.test.ts +75 -0
- package/src/hooks/useSelection.ts +211 -0
- package/src/hooks/useSemanticSearch.ts +94 -0
- package/src/hooks/useSignature.ts +69 -0
- package/src/hooks/useStaleAccountSync.test.ts +211 -0
- package/src/hooks/useStaleAccountSync.ts +246 -0
- package/src/hooks/useSwipeNavigation.test.ts +35 -0
- package/src/hooks/useSwipeNavigation.ts +99 -0
- package/src/hooks/useToggleStar.ts +187 -0
- package/src/hooks/useToggleTrusted.test.ts +102 -0
- package/src/hooks/useToggleTrusted.ts +161 -0
- package/src/hooks/useTriageKeyboard.ts +103 -0
- package/src/hooks/useTriggerSync.test.ts +24 -0
- package/src/hooks/useTriggerSync.ts +58 -0
- package/src/hooks/useUpdateAddressFlags.ts +125 -0
- package/src/hooks/useVisualViewport.test.ts +127 -0
- package/src/hooks/useVisualViewport.ts +51 -0
- package/src/index.css +19 -0
- package/src/lib/account-order.test.ts +84 -0
- package/src/lib/account-order.ts +13 -0
- package/src/lib/adjacent-message.test.ts +43 -0
- package/src/lib/adjacent-message.ts +20 -0
- package/src/lib/api.ts +82 -0
- package/src/lib/app-info.ts +15 -0
- package/src/lib/auto-moved.test.ts +140 -0
- package/src/lib/auto-moved.ts +68 -0
- package/src/lib/autodiscovery.test.ts +94 -0
- package/src/lib/autodiscovery.ts +354 -0
- package/src/lib/brief.test.ts +366 -0
- package/src/lib/brief.ts +180 -0
- package/src/lib/bug-report.test.ts +213 -0
- package/src/lib/bug-report.ts +176 -0
- package/src/lib/client.ts +36 -0
- package/src/lib/console-errors.test.ts +64 -0
- package/src/lib/console-errors.ts +92 -0
- package/src/lib/conversation-target.test.ts +68 -0
- package/src/lib/conversation-target.ts +59 -0
- package/src/lib/display-category.ts +20 -0
- package/src/lib/drafts.test.ts +241 -0
- package/src/lib/drafts.ts +121 -0
- package/src/lib/error-classifier.test.ts +133 -0
- package/src/lib/error-classifier.ts +96 -0
- package/src/lib/fatal-error.test.ts +97 -0
- package/src/lib/fatal-error.ts +165 -0
- package/src/lib/folder-roles.test.ts +113 -0
- package/src/lib/folder-roles.ts +112 -0
- package/src/lib/format.test.ts +115 -0
- package/src/lib/format.ts +198 -0
- package/src/lib/i18n.ts +36 -0
- package/src/lib/intelligence-pref.test.ts +57 -0
- package/src/lib/intelligence-pref.ts +25 -0
- package/src/lib/keymap-dispatch.test.ts +178 -0
- package/src/lib/keymap-dispatch.ts +187 -0
- package/src/lib/keymap.test.ts +49 -0
- package/src/lib/keymap.ts +178 -0
- package/src/lib/mail-context.ts +64 -0
- package/src/lib/mail-route.test.ts +80 -0
- package/src/lib/mail-route.ts +44 -0
- package/src/lib/message-body-source.test.ts +98 -0
- package/src/lib/message-body-source.ts +48 -0
- package/src/lib/move-targets.test.ts +195 -0
- package/src/lib/move-targets.ts +74 -0
- package/src/lib/onboarding-completion.test.ts +64 -0
- package/src/lib/onboarding-completion.ts +23 -0
- package/src/lib/organize/filter-status.test.ts +99 -0
- package/src/lib/organize/filter-status.ts +86 -0
- package/src/lib/organize/organize-copy.test.ts +93 -0
- package/src/lib/organize/organize-copy.ts +61 -0
- package/src/lib/organize/organize-model.test.ts +104 -0
- package/src/lib/organize/organize-model.ts +105 -0
- package/src/lib/organize/organize-poll.test.ts +37 -0
- package/src/lib/organize/organize-poll.ts +28 -0
- package/src/lib/outbox-status.test.ts +64 -0
- package/src/lib/outbox-status.ts +75 -0
- package/src/lib/plate-serializer.ts +164 -0
- package/src/lib/provider-presets.test.ts +96 -0
- package/src/lib/provider-presets.ts +81 -0
- package/src/lib/query-error-handler.test.ts +144 -0
- package/src/lib/query-error-handler.ts +34 -0
- package/src/lib/query-escalation.integration.test.ts +138 -0
- package/src/lib/recent-searches.test.ts +53 -0
- package/src/lib/recent-searches.ts +34 -0
- package/src/lib/rescue-candidates.test.ts +94 -0
- package/src/lib/rescue-candidates.ts +35 -0
- package/src/lib/rescue-telemetry.test.ts +75 -0
- package/src/lib/rescue-telemetry.ts +55 -0
- package/src/lib/rum-adapter.test.ts +299 -0
- package/src/lib/rum-adapter.ts +107 -0
- package/src/lib/saved-searches.test.ts +80 -0
- package/src/lib/saved-searches.ts +52 -0
- package/src/lib/search-pending.test.ts +61 -0
- package/src/lib/search-pending.ts +24 -0
- package/src/lib/search-query.test.ts +35 -0
- package/src/lib/search-query.ts +11 -0
- package/src/lib/search-result.test.ts +160 -0
- package/src/lib/search-result.ts +102 -0
- package/src/lib/search-schemas.ts +34 -0
- package/src/lib/search-token-index.test.ts +112 -0
- package/src/lib/search-token-index.ts +56 -0
- package/src/lib/search-tokens.test.ts +218 -0
- package/src/lib/search-tokens.ts +133 -0
- package/src/lib/telemetry-context.tsx +9 -0
- package/src/lib/telemetry.ts +17 -0
- package/src/lib/theme-preference.ts +46 -0
- package/src/lib/theme.ts +33 -0
- package/src/lib/utils.ts +4 -0
- package/src/main.tsx +10 -0
- package/src/routeTree.gen.ts +392 -0
- package/src/router.test.ts +81 -0
- package/src/router.tsx +31 -0
- package/src/routes/__root.tsx +69 -0
- package/src/routes/index.tsx +7 -0
- package/src/routes/mail/$mailboxId.tsx +27 -0
- package/src/routes/mail/flagged.tsx +37 -0
- package/src/routes/mail/index.tsx +41 -0
- package/src/routes/mail/outbox.tsx +13 -0
- package/src/routes/mail.tsx +413 -0
- package/src/routes/onboarding.tsx +35 -0
- package/src/routes/settings/-accounts.test.ts +65 -0
- package/src/routes/settings/accounts.tsx +542 -0
- package/src/routes/settings/advanced.tsx +55 -0
- package/src/routes/settings/appearance.tsx +140 -0
- package/src/routes/settings/filters.tsx +141 -0
- package/src/routes/settings/folders.tsx +216 -0
- package/src/routes/settings/index.tsx +5 -0
- package/src/routes/settings/senders.tsx +466 -0
- package/src/routes/settings/suggested-vips.tsx +10 -0
- package/src/routes/settings.tsx +66 -0
- package/src/runtime-config.ts +75 -0
- package/src/shell/index.tsx +98 -0
- package/src/types/index.ts +73 -0
- package/src/vite-env.d.ts +4 -0
- package/vite.base.ts +43 -0
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import {
|
|
3
|
+
type Density,
|
|
4
|
+
MessageListPane,
|
|
5
|
+
SelectionTopBar,
|
|
6
|
+
} from "@remit/ui";
|
|
7
|
+
import { useNavigate } from "@tanstack/react-router";
|
|
8
|
+
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
9
|
+
import { Search, Sparkles } from "lucide-react";
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
11
|
+
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
12
|
+
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
13
|
+
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
|
14
|
+
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
15
|
+
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
16
|
+
import {
|
|
17
|
+
nextFocusId,
|
|
18
|
+
type SelectionModifiers,
|
|
19
|
+
useSelection,
|
|
20
|
+
} from "@/hooks/useSelection";
|
|
21
|
+
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
22
|
+
import { formatDeleteToTrashTitle } from "@/lib/format";
|
|
23
|
+
import { MoveToTrigger } from "./MoveToTrigger";
|
|
24
|
+
import { OrganizeDialog } from "./organize/OrganizeDialog";
|
|
25
|
+
import { SelectionToolbar } from "./SelectionToolbar";
|
|
26
|
+
import { SwipeableMessageRow } from "./SwipeableMessageRow";
|
|
27
|
+
|
|
28
|
+
interface MessageListProps {
|
|
29
|
+
mailboxId: string;
|
|
30
|
+
threads: RemitImapThreadMessageResponse[];
|
|
31
|
+
selectedMessageId?: string;
|
|
32
|
+
isLoading: boolean;
|
|
33
|
+
isError?: boolean;
|
|
34
|
+
error?: unknown;
|
|
35
|
+
onRetry?: () => void;
|
|
36
|
+
searchQuery?: string;
|
|
37
|
+
onDeleteMessages?: (messageIds: string[]) => void;
|
|
38
|
+
onMarkAsRead?: (messageIds: string[]) => void;
|
|
39
|
+
onMoveMessages?: (messageIds: string[], destinationMailboxId: string) => void;
|
|
40
|
+
isDeleting?: boolean;
|
|
41
|
+
isMoving?: boolean;
|
|
42
|
+
onLoadMore?: () => void;
|
|
43
|
+
hasMore?: boolean;
|
|
44
|
+
isLoadingMore?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Owning account for the current mailbox view. Required for the Move
|
|
47
|
+
* action — when missing or when the selection spans multiple accounts
|
|
48
|
+
* the toolbar disables Move and surfaces an inline hint.
|
|
49
|
+
*/
|
|
50
|
+
accountId?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Mailbox display title shown in the list pane header.
|
|
53
|
+
* The parent route owns the title; the list pane renders it.
|
|
54
|
+
*/
|
|
55
|
+
listTitle: string;
|
|
56
|
+
/**
|
|
57
|
+
* Optional subtitle (e.g. "3 unread") shown alongside the title.
|
|
58
|
+
*/
|
|
59
|
+
listMeta?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Triage-layer context bridge (#429). The roving focus cursor and the
|
|
62
|
+
* multi-selection live here; the parent route's global keyboard dispatcher
|
|
63
|
+
* needs them to target the action verbs (reply/star/…) at the
|
|
64
|
+
* focused row, or the selection when one exists. Called whenever either
|
|
65
|
+
* changes. `focusedMessageId` is the keyboard cursor (distinct from the
|
|
66
|
+
* open/selected thread in the URL); `selectedIds` is the checkbox set.
|
|
67
|
+
*/
|
|
68
|
+
onTriageContextChange?: (context: {
|
|
69
|
+
focusedMessageId: string | undefined;
|
|
70
|
+
selectedIds: string[];
|
|
71
|
+
}) => void;
|
|
72
|
+
/**
|
|
73
|
+
* Suppress the pane's built-in title header — the shared `MailHeader` above
|
|
74
|
+
* the list owns it (the inbox renders inside `MailViewChrome`).
|
|
75
|
+
*/
|
|
76
|
+
hideHeader?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const COMFORTABLE_ITEM_HEIGHT = 72;
|
|
80
|
+
const COMPACT_ITEM_HEIGHT = 32;
|
|
81
|
+
const OVERSCAN_COUNT = 5;
|
|
82
|
+
const DENSITY_STORAGE_KEY = "remit:list-density";
|
|
83
|
+
|
|
84
|
+
const readStoredDensity = (): Density => {
|
|
85
|
+
try {
|
|
86
|
+
const stored = localStorage.getItem(DENSITY_STORAGE_KEY);
|
|
87
|
+
if (stored === "compact" || stored === "comfortable") return stored;
|
|
88
|
+
} catch {
|
|
89
|
+
// localStorage unavailable (SSR, privacy mode) — fall through
|
|
90
|
+
}
|
|
91
|
+
return "comfortable";
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const SearchResultsHeader = ({
|
|
95
|
+
query,
|
|
96
|
+
count,
|
|
97
|
+
}: {
|
|
98
|
+
query: string;
|
|
99
|
+
count: number;
|
|
100
|
+
}) => (
|
|
101
|
+
<div className="flex items-center gap-2 px-3 py-2 border-b border-line bg-surface-sunken/30">
|
|
102
|
+
<Search className="size-4 text-fg-muted" />
|
|
103
|
+
<span className="text-sm text-fg-muted">
|
|
104
|
+
{count} {count === 1 ? "result" : "results"} for “{query}”
|
|
105
|
+
</span>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
export const MessageList = ({
|
|
110
|
+
mailboxId,
|
|
111
|
+
threads,
|
|
112
|
+
selectedMessageId,
|
|
113
|
+
isLoading,
|
|
114
|
+
isError = false,
|
|
115
|
+
error,
|
|
116
|
+
onRetry,
|
|
117
|
+
searchQuery,
|
|
118
|
+
onDeleteMessages,
|
|
119
|
+
onMarkAsRead,
|
|
120
|
+
onMoveMessages,
|
|
121
|
+
isDeleting = false,
|
|
122
|
+
isMoving = false,
|
|
123
|
+
onLoadMore,
|
|
124
|
+
hasMore = false,
|
|
125
|
+
isLoadingMore = false,
|
|
126
|
+
accountId,
|
|
127
|
+
listTitle,
|
|
128
|
+
listMeta,
|
|
129
|
+
onTriageContextChange,
|
|
130
|
+
hideHeader = false,
|
|
131
|
+
}: MessageListProps) => {
|
|
132
|
+
const parentRef = useRef<HTMLDivElement>(null);
|
|
133
|
+
const navigate = useNavigate();
|
|
134
|
+
const isDesktop = useIsDesktop();
|
|
135
|
+
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
|
136
|
+
const [organizeOpen, setOrganizeOpen] = useState(false);
|
|
137
|
+
|
|
138
|
+
// Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
|
|
139
|
+
// from the open thread (`selectedMessageId` in the URL). j/k move this
|
|
140
|
+
// cursor without opening; Enter opens the focused row → sets selected. It
|
|
141
|
+
// seeds from the open thread so opening a message also focuses its row, and
|
|
142
|
+
// click-to-open keeps working unchanged (the route still navigates).
|
|
143
|
+
const [focusedMessageId, setFocusedMessageId] = useState<string | undefined>(
|
|
144
|
+
selectedMessageId,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// Density toggle: comfortable (default) or compact (mutt mode).
|
|
148
|
+
// Persisted to localStorage so the choice survives reloads.
|
|
149
|
+
const [density, setDensity] = useState<Density>(readStoredDensity);
|
|
150
|
+
const toggleDensity = useCallback(() => {
|
|
151
|
+
setDensity((prev) => {
|
|
152
|
+
const next: Density = prev === "comfortable" ? "compact" : "comfortable";
|
|
153
|
+
try {
|
|
154
|
+
localStorage.setItem(DENSITY_STORAGE_KEY, next);
|
|
155
|
+
} catch {
|
|
156
|
+
// ignore
|
|
157
|
+
}
|
|
158
|
+
return next;
|
|
159
|
+
});
|
|
160
|
+
}, []);
|
|
161
|
+
|
|
162
|
+
// Swipe-to-read toggle hook
|
|
163
|
+
const { toggleReadFor } = useToggleReadFor({ mailboxId });
|
|
164
|
+
|
|
165
|
+
// Selection state
|
|
166
|
+
const {
|
|
167
|
+
selectedIds,
|
|
168
|
+
selectedCount,
|
|
169
|
+
hasSelection,
|
|
170
|
+
isSelected: isChecked,
|
|
171
|
+
toggle: toggleCheck,
|
|
172
|
+
select,
|
|
173
|
+
clearSelection,
|
|
174
|
+
selectRange,
|
|
175
|
+
setAnchor,
|
|
176
|
+
selectAll,
|
|
177
|
+
} = useSelection();
|
|
178
|
+
|
|
179
|
+
// Ids queued for deletion, awaiting confirmation. `null` means the dialog
|
|
180
|
+
// is closed. Snapshotted at request time so a selection change behind the
|
|
181
|
+
// dialog can't retarget the delete.
|
|
182
|
+
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[] | null>(
|
|
183
|
+
null,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Auto-exit multi-select when selection becomes empty
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (isMultiSelectMode && selectedCount === 0) {
|
|
189
|
+
setIsMultiSelectMode(false);
|
|
190
|
+
}
|
|
191
|
+
}, [isMultiSelectMode, selectedCount]);
|
|
192
|
+
|
|
193
|
+
const virtualizer = useVirtualizer({
|
|
194
|
+
count: threads.length,
|
|
195
|
+
getScrollElement: () => parentRef.current,
|
|
196
|
+
estimateSize: () =>
|
|
197
|
+
density === "compact" ? COMPACT_ITEM_HEIGHT : COMFORTABLE_ITEM_HEIGHT,
|
|
198
|
+
overscan: OVERSCAN_COUNT,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Index of the open thread (for scroll-into-view of the reading pane target).
|
|
202
|
+
const currentIndex = selectedMessageId
|
|
203
|
+
? threads.findIndex((t) => t.messageId === selectedMessageId)
|
|
204
|
+
: -1;
|
|
205
|
+
|
|
206
|
+
// Index of the roving focus cursor — what j/k move.
|
|
207
|
+
const focusIndex = focusedMessageId
|
|
208
|
+
? threads.findIndex((t) => t.messageId === focusedMessageId)
|
|
209
|
+
: -1;
|
|
210
|
+
|
|
211
|
+
// Move the focus cursor by index. In multi-select mode (mobile) j/k still
|
|
212
|
+
// toggle selection rather than moving a cursor, preserving prior behavior.
|
|
213
|
+
const moveFocusToIndex = useCallback(
|
|
214
|
+
(index: number) => {
|
|
215
|
+
if (index < 0 || index >= threads.length) return;
|
|
216
|
+
const thread = threads[index];
|
|
217
|
+
if (isMultiSelectMode) {
|
|
218
|
+
toggleCheck(thread.messageId);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
setFocusedMessageId(thread.messageId);
|
|
222
|
+
},
|
|
223
|
+
[threads, isMultiSelectMode, toggleCheck],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// j / ArrowDown: move focus to the next row (no open). Starts at the top
|
|
227
|
+
// when nothing is focused yet.
|
|
228
|
+
const focusNext = useCallback(() => {
|
|
229
|
+
if (threads.length === 0) return;
|
|
230
|
+
const nextIndex =
|
|
231
|
+
focusIndex < 0 ? 0 : Math.min(focusIndex + 1, threads.length - 1);
|
|
232
|
+
moveFocusToIndex(nextIndex);
|
|
233
|
+
}, [threads.length, focusIndex, moveFocusToIndex]);
|
|
234
|
+
|
|
235
|
+
// k / ArrowUp: move focus to the previous row.
|
|
236
|
+
const focusPrevious = useCallback(() => {
|
|
237
|
+
if (threads.length === 0) return;
|
|
238
|
+
const prevIndex = focusIndex <= 0 ? 0 : focusIndex - 1;
|
|
239
|
+
moveFocusToIndex(prevIndex);
|
|
240
|
+
}, [threads.length, focusIndex, moveFocusToIndex]);
|
|
241
|
+
|
|
242
|
+
// Toggle selection on the focused row with x.
|
|
243
|
+
const toggleFocusedSelection = useCallback(() => {
|
|
244
|
+
if (focusedMessageId) {
|
|
245
|
+
toggleCheck(focusedMessageId);
|
|
246
|
+
}
|
|
247
|
+
}, [focusedMessageId, toggleCheck]);
|
|
248
|
+
|
|
249
|
+
// Desktop mouse selection semantics (Apple Mail / Gmail model). Called by a
|
|
250
|
+
// row's onClick with the click modifiers. Returns true when selection
|
|
251
|
+
// handled the click (caller should preventDefault and skip navigation);
|
|
252
|
+
// false for a plain click (caller lets the Link navigate).
|
|
253
|
+
const orderedIds = useMemo(() => threads.map((t) => t.messageId), [threads]);
|
|
254
|
+
const handleRowSelect = useCallback(
|
|
255
|
+
(messageId: string, modifiers: SelectionModifiers): boolean => {
|
|
256
|
+
if (modifiers.shiftKey) {
|
|
257
|
+
selectRange(orderedIds, messageId);
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
if (modifiers.metaKey || modifiers.ctrlKey) {
|
|
261
|
+
// Toggle membership and re-anchor on the clicked row.
|
|
262
|
+
toggleCheck(messageId);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
// Plain click: collapse any multi-selection and let navigation proceed.
|
|
266
|
+
// The clicked row becomes the next anchor for a subsequent shift-click,
|
|
267
|
+
// but is NOT added to the checkbox set (no toolbar on a plain open).
|
|
268
|
+
clearSelection();
|
|
269
|
+
setAnchor(messageId);
|
|
270
|
+
return false;
|
|
271
|
+
},
|
|
272
|
+
[orderedIds, selectRange, toggleCheck, clearSelection, setAnchor],
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
// Open the delete confirmation for an explicit set of ids. All delete
|
|
276
|
+
// entry points (toolbar Trash2, Delete/Backspace key) funnel through here
|
|
277
|
+
// so the move-to-Trash confirmation is consistent.
|
|
278
|
+
const requestDelete = useCallback(
|
|
279
|
+
(ids: string[]) => {
|
|
280
|
+
if (!onDeleteMessages || ids.length === 0) return;
|
|
281
|
+
setPendingDeleteIds(ids);
|
|
282
|
+
},
|
|
283
|
+
[onDeleteMessages],
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// Keyboard shift-arrow range extend: move focus one row in `direction` and
|
|
287
|
+
// extend the selection range from the existing anchor to the new focus —
|
|
288
|
+
// the keyboard equivalent of shift-click. The anchor stays fixed across
|
|
289
|
+
// consecutive shift-arrows (selectRange seeds it only when unset), so
|
|
290
|
+
// moving back toward the anchor shrinks the range.
|
|
291
|
+
const extendRange = useCallback(
|
|
292
|
+
(direction: -1 | 1) => {
|
|
293
|
+
const target = nextFocusId(orderedIds, focusedMessageId, direction);
|
|
294
|
+
if (target === undefined) return;
|
|
295
|
+
selectRange(orderedIds, target);
|
|
296
|
+
// Shift+arrow moves the focus cursor (not the open thread) and grows
|
|
297
|
+
// the selection from the anchor — the keyboard equivalent of
|
|
298
|
+
// shift-click.
|
|
299
|
+
setFocusedMessageId(target);
|
|
300
|
+
},
|
|
301
|
+
[orderedIds, focusedMessageId, selectRange],
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const extendRangeUp = useCallback(() => extendRange(-1), [extendRange]);
|
|
305
|
+
const extendRangeDown = useCallback(() => extendRange(1), [extendRange]);
|
|
306
|
+
|
|
307
|
+
// Cmd/Ctrl+A: select every currently loaded row.
|
|
308
|
+
const handleSelectAll = useCallback(() => {
|
|
309
|
+
if (orderedIds.length > 0) {
|
|
310
|
+
selectAll(orderedIds);
|
|
311
|
+
}
|
|
312
|
+
}, [orderedIds, selectAll]);
|
|
313
|
+
|
|
314
|
+
// Delete / Backspace: confirm-delete the selection, or the focused row when
|
|
315
|
+
// nothing is selected.
|
|
316
|
+
const handleDeleteKey = useCallback(() => {
|
|
317
|
+
if (selectedCount > 0) {
|
|
318
|
+
requestDelete(Array.from(selectedIds));
|
|
319
|
+
} else if (focusedMessageId) {
|
|
320
|
+
requestDelete([focusedMessageId]);
|
|
321
|
+
}
|
|
322
|
+
}, [selectedCount, selectedIds, focusedMessageId, requestDelete]);
|
|
323
|
+
|
|
324
|
+
// Enter: open the focused row in the reading pane (sets selected/URL). This
|
|
325
|
+
// is the focus→open transition of the 2-state model.
|
|
326
|
+
const handleOpenFocused = useCallback(() => {
|
|
327
|
+
if (!focusedMessageId) return;
|
|
328
|
+
navigate({
|
|
329
|
+
to: "/mail/$mailboxId",
|
|
330
|
+
params: { mailboxId },
|
|
331
|
+
search: (prev) => ({ ...prev, selectedMessageId: focusedMessageId }),
|
|
332
|
+
});
|
|
333
|
+
}, [focusedMessageId, navigate, mailboxId]);
|
|
334
|
+
|
|
335
|
+
// Toolbar Trash2: confirm-delete the current selection.
|
|
336
|
+
const handleDelete = useCallback(() => {
|
|
337
|
+
if (selectedCount > 0) {
|
|
338
|
+
requestDelete(Array.from(selectedIds));
|
|
339
|
+
}
|
|
340
|
+
}, [requestDelete, selectedCount, selectedIds]);
|
|
341
|
+
|
|
342
|
+
// Confirm handler: run the actual bulk delete, then clear selection and
|
|
343
|
+
// move focus to a sensible neighbor (the row after the first deleted one).
|
|
344
|
+
const handleConfirmDelete = useCallback(() => {
|
|
345
|
+
if (!pendingDeleteIds || pendingDeleteIds.length === 0) {
|
|
346
|
+
setPendingDeleteIds(null);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const deletedSet = new Set(pendingDeleteIds);
|
|
350
|
+
const firstDeletedIndex = threads.findIndex((t) =>
|
|
351
|
+
deletedSet.has(t.messageId),
|
|
352
|
+
);
|
|
353
|
+
// Next surviving row at or after the first deleted row, else the one
|
|
354
|
+
// before it. Computed against the pre-delete order.
|
|
355
|
+
let nextFocus: string | undefined;
|
|
356
|
+
for (let i = firstDeletedIndex + 1; i < threads.length; i++) {
|
|
357
|
+
if (!deletedSet.has(threads[i].messageId)) {
|
|
358
|
+
nextFocus = threads[i].messageId;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (nextFocus === undefined) {
|
|
363
|
+
for (let i = firstDeletedIndex - 1; i >= 0; i--) {
|
|
364
|
+
if (!deletedSet.has(threads[i].messageId)) {
|
|
365
|
+
nextFocus = threads[i].messageId;
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
onDeleteMessages?.(pendingDeleteIds);
|
|
372
|
+
clearSelection();
|
|
373
|
+
setPendingDeleteIds(null);
|
|
374
|
+
|
|
375
|
+
if (nextFocus !== undefined) {
|
|
376
|
+
navigate({
|
|
377
|
+
to: "/mail/$mailboxId",
|
|
378
|
+
params: { mailboxId },
|
|
379
|
+
search: (prev) => ({ ...prev, selectedMessageId: nextFocus }),
|
|
380
|
+
replace: true,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}, [
|
|
384
|
+
pendingDeleteIds,
|
|
385
|
+
threads,
|
|
386
|
+
onDeleteMessages,
|
|
387
|
+
clearSelection,
|
|
388
|
+
navigate,
|
|
389
|
+
mailboxId,
|
|
390
|
+
]);
|
|
391
|
+
|
|
392
|
+
const handleCancelDelete = useCallback(() => {
|
|
393
|
+
setPendingDeleteIds(null);
|
|
394
|
+
}, []);
|
|
395
|
+
|
|
396
|
+
// Handle mark as read
|
|
397
|
+
const handleMarkAsRead = useCallback(() => {
|
|
398
|
+
if (onMarkAsRead && selectedCount > 0) {
|
|
399
|
+
onMarkAsRead(Array.from(selectedIds));
|
|
400
|
+
}
|
|
401
|
+
}, [onMarkAsRead, selectedCount, selectedIds]);
|
|
402
|
+
|
|
403
|
+
// Handle move
|
|
404
|
+
const handleMoveSelected = useCallback(
|
|
405
|
+
(destinationMailboxId: string) => {
|
|
406
|
+
if (!onMoveMessages || selectedCount === 0) return;
|
|
407
|
+
onMoveMessages(Array.from(selectedIds), destinationMailboxId);
|
|
408
|
+
clearSelection();
|
|
409
|
+
},
|
|
410
|
+
[onMoveMessages, selectedCount, selectedIds, clearSelection],
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
// Cross-account guard: every selected thread row must belong to the
|
|
414
|
+
// same account as the current mailbox. The list is already scoped to
|
|
415
|
+
// one mailbox so in practice this is always single-account, but we
|
|
416
|
+
// detect drift defensively (e.g. a future global selection mode) and
|
|
417
|
+
// disable Move with an inline hint rather than silently aggregating.
|
|
418
|
+
//
|
|
419
|
+
// `MessageList` re-renders on every virtualizer scroll tick. Memoize
|
|
420
|
+
// the guard so we only walk the selected slice when selection or
|
|
421
|
+
// thread identity actually changes.
|
|
422
|
+
const moveDisabledHint = useMemo(() => {
|
|
423
|
+
if (selectedCount === 0) return undefined;
|
|
424
|
+
const selectedAccountConfigIds = new Set<string>();
|
|
425
|
+
for (const thread of threads) {
|
|
426
|
+
if (selectedIds.has(thread.messageId)) {
|
|
427
|
+
selectedAccountConfigIds.add(thread.accountConfigId);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (selectedAccountConfigIds.size > 1) {
|
|
431
|
+
return "Move only works within one account — clear selection or pick messages from a single account";
|
|
432
|
+
}
|
|
433
|
+
return undefined;
|
|
434
|
+
}, [selectedCount, selectedIds, threads]);
|
|
435
|
+
|
|
436
|
+
// Swipe-to-delete single message
|
|
437
|
+
const handleSwipeDelete = useCallback(
|
|
438
|
+
(messageId: string) => {
|
|
439
|
+
onDeleteMessages?.([messageId]);
|
|
440
|
+
},
|
|
441
|
+
[onDeleteMessages],
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
// Swipe-to-toggle-read single message
|
|
445
|
+
const handleSwipeToggleRead = useCallback(
|
|
446
|
+
(messageId: string, currentIsRead: boolean) => {
|
|
447
|
+
toggleReadFor([messageId], !currentIsRead);
|
|
448
|
+
},
|
|
449
|
+
[toggleReadFor],
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
// Mobile: Enter multi-select mode on long press
|
|
453
|
+
const handleLongPress = useCallback(
|
|
454
|
+
(messageId: string) => {
|
|
455
|
+
if (!isDesktop) {
|
|
456
|
+
setIsMultiSelectMode(true);
|
|
457
|
+
select(messageId);
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
[isDesktop, select],
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
// Cancel multi-select mode
|
|
464
|
+
const handleCancelMultiSelect = useCallback(() => {
|
|
465
|
+
setIsMultiSelectMode(false);
|
|
466
|
+
clearSelection();
|
|
467
|
+
}, [clearSelection]);
|
|
468
|
+
|
|
469
|
+
// Scroll the roving focus cursor into view as it moves (j/k). Falls back to
|
|
470
|
+
// the open thread when nothing is focused yet.
|
|
471
|
+
useEffect(() => {
|
|
472
|
+
// On single-pane tiers, opening a thread swaps this list out for the
|
|
473
|
+
// conversation. Scrolling the list as it unmounts is both pointless (it's
|
|
474
|
+
// no longer visible) and unsafe: @tanstack/react-virtual's scrollToIndex
|
|
475
|
+
// schedules a requestAnimationFrame retry chain on the scroll element's
|
|
476
|
+
// window, which throws once that element (and its window) are gone. Only
|
|
477
|
+
// auto-scroll while the list stays mounted alongside the reading pane.
|
|
478
|
+
if (!isDesktop && selectedMessageId) return;
|
|
479
|
+
const target = focusIndex >= 0 ? focusIndex : currentIndex;
|
|
480
|
+
if (target >= 0) {
|
|
481
|
+
virtualizer.scrollToIndex(target, { align: "auto" });
|
|
482
|
+
}
|
|
483
|
+
}, [focusIndex, currentIndex, virtualizer, isDesktop, selectedMessageId]);
|
|
484
|
+
|
|
485
|
+
// Opening a thread (click or Enter, anywhere) seeds the focus cursor onto it
|
|
486
|
+
// so subsequent j/k continue from the open row — focus and open stay in
|
|
487
|
+
// sync on open while remaining independent during scanning.
|
|
488
|
+
useEffect(() => {
|
|
489
|
+
if (selectedMessageId) {
|
|
490
|
+
setFocusedMessageId(selectedMessageId);
|
|
491
|
+
}
|
|
492
|
+
}, [selectedMessageId]);
|
|
493
|
+
|
|
494
|
+
// Keep the focus cursor valid as the thread list changes (after delete /
|
|
495
|
+
// move / refetch). If the focused row vanished, snap to the nearest
|
|
496
|
+
// surviving row so j/k never dead-ends.
|
|
497
|
+
useEffect(() => {
|
|
498
|
+
if (!focusedMessageId) return;
|
|
499
|
+
if (threads.some((t) => t.messageId === focusedMessageId)) return;
|
|
500
|
+
setFocusedMessageId(threads[0]?.messageId);
|
|
501
|
+
}, [threads, focusedMessageId]);
|
|
502
|
+
|
|
503
|
+
// Bridge the roving cursor + selection up to the route's global keyboard
|
|
504
|
+
// dispatcher (#429) so the action verbs can target the focused row, or the
|
|
505
|
+
// selection when one exists.
|
|
506
|
+
useEffect(() => {
|
|
507
|
+
onTriageContextChange?.({
|
|
508
|
+
focusedMessageId,
|
|
509
|
+
selectedIds: Array.from(selectedIds),
|
|
510
|
+
});
|
|
511
|
+
}, [focusedMessageId, selectedIds, onTriageContextChange]);
|
|
512
|
+
|
|
513
|
+
// Clear selection when threads change (e.g., after delete)
|
|
514
|
+
useEffect(() => {
|
|
515
|
+
const threadIds = new Set(threads.map((t) => t.messageId));
|
|
516
|
+
const hasOrphanedSelection = Array.from(selectedIds).some(
|
|
517
|
+
(id) => !threadIds.has(id),
|
|
518
|
+
);
|
|
519
|
+
if (hasOrphanedSelection) {
|
|
520
|
+
clearSelection();
|
|
521
|
+
}
|
|
522
|
+
}, [threads, selectedIds, clearSelection]);
|
|
523
|
+
|
|
524
|
+
// Load more when scrolling near the bottom
|
|
525
|
+
useEffect(() => {
|
|
526
|
+
const scrollElement = parentRef.current;
|
|
527
|
+
if (!scrollElement || !hasMore || !onLoadMore) return;
|
|
528
|
+
|
|
529
|
+
const handleScroll = () => {
|
|
530
|
+
if (isLoadingMore) return;
|
|
531
|
+
|
|
532
|
+
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
|
533
|
+
// Trigger when within 200px of the bottom
|
|
534
|
+
const nearBottom = scrollTop + clientHeight >= scrollHeight - 200;
|
|
535
|
+
|
|
536
|
+
if (nearBottom) {
|
|
537
|
+
onLoadMore();
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
scrollElement.addEventListener("scroll", handleScroll, { passive: true });
|
|
542
|
+
// Also check immediately in case we're already at the bottom
|
|
543
|
+
handleScroll();
|
|
544
|
+
|
|
545
|
+
return () => scrollElement.removeEventListener("scroll", handleScroll);
|
|
546
|
+
}, [hasMore, isLoadingMore, onLoadMore]);
|
|
547
|
+
|
|
548
|
+
// Keyboard navigation. The dialog owns the keyboard while open, so list
|
|
549
|
+
// shortcuts pause to avoid double-handling (e.g. a second Delete press).
|
|
550
|
+
useKeyboardNavigation({
|
|
551
|
+
enabled: !isLoading && threads.length > 0 && pendingDeleteIds === null,
|
|
552
|
+
bindings: [
|
|
553
|
+
// Focus movement (plain). requireShift:false so Shift+Arrow falls
|
|
554
|
+
// through to the range-extend bindings below instead of also moving
|
|
555
|
+
// focus without selecting. j/k move the roving cursor WITHOUT opening
|
|
556
|
+
// the thread (#429) — only Enter / click open.
|
|
557
|
+
{ key: "j", handler: focusNext, preventDefault: true },
|
|
558
|
+
{
|
|
559
|
+
key: "ArrowDown",
|
|
560
|
+
handler: focusNext,
|
|
561
|
+
preventDefault: true,
|
|
562
|
+
requireShift: false,
|
|
563
|
+
},
|
|
564
|
+
{ key: "k", handler: focusPrevious, preventDefault: true },
|
|
565
|
+
{
|
|
566
|
+
key: "ArrowUp",
|
|
567
|
+
handler: focusPrevious,
|
|
568
|
+
preventDefault: true,
|
|
569
|
+
requireShift: false,
|
|
570
|
+
},
|
|
571
|
+
// Shift+Arrow: extend the selection range.
|
|
572
|
+
{
|
|
573
|
+
key: "ArrowDown",
|
|
574
|
+
handler: extendRangeDown,
|
|
575
|
+
preventDefault: true,
|
|
576
|
+
requireShift: true,
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
key: "ArrowUp",
|
|
580
|
+
handler: extendRangeUp,
|
|
581
|
+
preventDefault: true,
|
|
582
|
+
requireShift: true,
|
|
583
|
+
},
|
|
584
|
+
// Toggle focused row (x or Space) and re-anchor.
|
|
585
|
+
{ key: "x", handler: toggleFocusedSelection, preventDefault: true },
|
|
586
|
+
{ key: " ", handler: toggleFocusedSelection, preventDefault: true },
|
|
587
|
+
// Cmd/Ctrl+A select all loaded rows.
|
|
588
|
+
{
|
|
589
|
+
key: "a",
|
|
590
|
+
handler: handleSelectAll,
|
|
591
|
+
preventDefault: true,
|
|
592
|
+
requireMeta: true,
|
|
593
|
+
},
|
|
594
|
+
// Enter opens the focused message.
|
|
595
|
+
{ key: "Enter", handler: handleOpenFocused, preventDefault: true },
|
|
596
|
+
// Delete / Backspace: confirm-delete selection or focused row.
|
|
597
|
+
{ key: "Delete", handler: handleDeleteKey, preventDefault: true },
|
|
598
|
+
{ key: "Backspace", handler: handleDeleteKey, preventDefault: true },
|
|
599
|
+
// d: toggle density (comfortable / compact).
|
|
600
|
+
{ key: "d", handler: toggleDensity, preventDefault: true },
|
|
601
|
+
],
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// Esc-to-clear-selection on the capture phase, enabled only when a
|
|
605
|
+
// selection exists. Capture + stopPropagation makes a single Esc clear the
|
|
606
|
+
// selection and consume the keypress, so the route-level Esc (go back) does
|
|
607
|
+
// NOT also fire. With no selection this listener is disabled and Esc falls
|
|
608
|
+
// through to the route as before. The ConfirmDialog (when open) registers
|
|
609
|
+
// its own capture-phase Esc and pauses these list shortcuts, so its cancel
|
|
610
|
+
// still wins first.
|
|
611
|
+
useKeyboardNavigation({
|
|
612
|
+
enabled: hasSelection && pendingDeleteIds === null,
|
|
613
|
+
capture: true,
|
|
614
|
+
bindings: [
|
|
615
|
+
{
|
|
616
|
+
key: "Escape",
|
|
617
|
+
handler: clearSelection,
|
|
618
|
+
preventDefault: true,
|
|
619
|
+
stopPropagation: true,
|
|
620
|
+
},
|
|
621
|
+
],
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// Derive the MessageListPane listState from the loading/error/empty signals.
|
|
625
|
+
const listState = isLoading
|
|
626
|
+
? "loading"
|
|
627
|
+
: isError
|
|
628
|
+
? "error"
|
|
629
|
+
: threads.length === 0
|
|
630
|
+
? "empty"
|
|
631
|
+
: "ready";
|
|
632
|
+
|
|
633
|
+
// Fail-loud (ux.md): surface the real failure under the error headline, and
|
|
634
|
+
// give it a place to go — the same GitHub-issue path the bug-report button
|
|
635
|
+
// uses, so the report carries app version, console errors and the URL.
|
|
636
|
+
const errorMessage = isError ? formatErrorMessage(error) : undefined;
|
|
637
|
+
const handleReportError = useCallback(() => {
|
|
638
|
+
const url = buildGitHubIssueUrl(buildBugReportContext());
|
|
639
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
640
|
+
}, []);
|
|
641
|
+
|
|
642
|
+
// Single flat section — the mailbox view doesn't group by date.
|
|
643
|
+
const sections = [{ id: "inbox", threads: [] }];
|
|
644
|
+
|
|
645
|
+
// Desktop selection toolbar replaces the pane header when any rows are selected.
|
|
646
|
+
const desktopSelectionBar =
|
|
647
|
+
hasSelection && isDesktop ? (
|
|
648
|
+
<SelectionToolbar
|
|
649
|
+
selectedCount={selectedCount}
|
|
650
|
+
onDelete={handleDelete}
|
|
651
|
+
onClearSelection={clearSelection}
|
|
652
|
+
onMarkAsRead={onMarkAsRead ? handleMarkAsRead : undefined}
|
|
653
|
+
onMove={onMoveMessages ? handleMoveSelected : undefined}
|
|
654
|
+
onOrganize={() => setOrganizeOpen(true)}
|
|
655
|
+
isDeleting={isDeleting}
|
|
656
|
+
isMoving={isMoving}
|
|
657
|
+
accountId={accountId}
|
|
658
|
+
currentMailboxId={mailboxId}
|
|
659
|
+
moveDisabledHint={moveDisabledHint}
|
|
660
|
+
/>
|
|
661
|
+
) : undefined;
|
|
662
|
+
|
|
663
|
+
// Mobile multi-select bar replaces the pane header during selection mode.
|
|
664
|
+
const mobileSelectionBar =
|
|
665
|
+
isMultiSelectMode && !isDesktop ? (
|
|
666
|
+
<SelectionTopBar
|
|
667
|
+
count={selectedCount}
|
|
668
|
+
onCancel={handleCancelMultiSelect}
|
|
669
|
+
onDelete={handleDelete}
|
|
670
|
+
onMarkRead={onMarkAsRead ? handleMarkAsRead : undefined}
|
|
671
|
+
isBusy={isDeleting || isMoving}
|
|
672
|
+
moveDisabledHint={moveDisabledHint}
|
|
673
|
+
moveSlot={
|
|
674
|
+
onMoveMessages && accountId && mailboxId ? (
|
|
675
|
+
<>
|
|
676
|
+
{!moveDisabledHint && (
|
|
677
|
+
<button
|
|
678
|
+
type="button"
|
|
679
|
+
onClick={() => setOrganizeOpen(true)}
|
|
680
|
+
className="min-h-11 min-w-11 inline-flex shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-raised"
|
|
681
|
+
aria-label="Organize similar messages"
|
|
682
|
+
>
|
|
683
|
+
<Sparkles className="size-4" />
|
|
684
|
+
</button>
|
|
685
|
+
)}
|
|
686
|
+
<MoveToTrigger
|
|
687
|
+
accountId={accountId}
|
|
688
|
+
currentMailboxId={mailboxId}
|
|
689
|
+
onMove={isDeleting || isMoving ? () => {} : handleMoveSelected}
|
|
690
|
+
disabledHint={moveDisabledHint}
|
|
691
|
+
label="Move selected messages"
|
|
692
|
+
/>
|
|
693
|
+
</>
|
|
694
|
+
) : undefined
|
|
695
|
+
}
|
|
696
|
+
/>
|
|
697
|
+
) : undefined;
|
|
698
|
+
|
|
699
|
+
const activeSelectionBar = desktopSelectionBar ?? mobileSelectionBar;
|
|
700
|
+
|
|
701
|
+
const isSearching = !!searchQuery?.trim();
|
|
702
|
+
|
|
703
|
+
// The virtualized list body: rows + search header + load-more indicator.
|
|
704
|
+
// Passed to MessageListPane as `listBody` so the kit provides the chrome
|
|
705
|
+
// (pane header, loading / empty / error states, keyboard hints) while we
|
|
706
|
+
// keep the @tanstack/react-virtual row recycling.
|
|
707
|
+
const virtualBody = (
|
|
708
|
+
<>
|
|
709
|
+
{isSearching && searchQuery && (
|
|
710
|
+
<SearchResultsHeader query={searchQuery} count={threads.length} />
|
|
711
|
+
)}
|
|
712
|
+
<div ref={parentRef} className="flex-1 overflow-y-auto">
|
|
713
|
+
<div
|
|
714
|
+
className="relative w-full"
|
|
715
|
+
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
|
716
|
+
>
|
|
717
|
+
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
718
|
+
const thread = threads[virtualRow.index];
|
|
719
|
+
return (
|
|
720
|
+
<div
|
|
721
|
+
key={virtualRow.key}
|
|
722
|
+
data-index={virtualRow.index}
|
|
723
|
+
ref={virtualizer.measureElement}
|
|
724
|
+
className="absolute left-0 top-0 w-full border-b border-line"
|
|
725
|
+
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
|
726
|
+
>
|
|
727
|
+
<SwipeableMessageRow
|
|
728
|
+
thread={thread}
|
|
729
|
+
mailboxId={mailboxId}
|
|
730
|
+
accountId={accountId}
|
|
731
|
+
isSelected={selectedMessageId === thread.messageId}
|
|
732
|
+
isFocused={focusedMessageId === thread.messageId}
|
|
733
|
+
isChecked={isChecked(thread.messageId)}
|
|
734
|
+
onToggleCheck={toggleCheck}
|
|
735
|
+
onRowSelect={handleRowSelect}
|
|
736
|
+
isMultiSelectMode={isMultiSelectMode}
|
|
737
|
+
onLongPress={handleLongPress}
|
|
738
|
+
isDesktop={isDesktop}
|
|
739
|
+
onDelete={handleSwipeDelete}
|
|
740
|
+
onToggleRead={handleSwipeToggleRead}
|
|
741
|
+
density={density}
|
|
742
|
+
/>
|
|
743
|
+
</div>
|
|
744
|
+
);
|
|
745
|
+
})}
|
|
746
|
+
</div>
|
|
747
|
+
{isLoadingMore && (
|
|
748
|
+
<div className="flex justify-center py-4">
|
|
749
|
+
<div className="h-5 w-5 animate-spin rounded-full border-2 border-fg-muted border-t-transparent" />
|
|
750
|
+
</div>
|
|
751
|
+
)}
|
|
752
|
+
</div>
|
|
753
|
+
</>
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
return (
|
|
757
|
+
<>
|
|
758
|
+
<MessageListPane
|
|
759
|
+
listTitle={listTitle}
|
|
760
|
+
listMeta={listMeta}
|
|
761
|
+
sections={sections}
|
|
762
|
+
flatList
|
|
763
|
+
listState={listState}
|
|
764
|
+
searchQuery={isSearching ? searchQuery : undefined}
|
|
765
|
+
errorMessage={errorMessage}
|
|
766
|
+
onRetry={onRetry}
|
|
767
|
+
onReportError={handleReportError}
|
|
768
|
+
density={density}
|
|
769
|
+
selectedThreadId={selectedMessageId}
|
|
770
|
+
onSelectThread={(id) =>
|
|
771
|
+
navigate({
|
|
772
|
+
to: "/mail/$mailboxId",
|
|
773
|
+
params: { mailboxId },
|
|
774
|
+
search: (prev) => ({ ...prev, selectedMessageId: id }),
|
|
775
|
+
})
|
|
776
|
+
}
|
|
777
|
+
onSelectBriefCategory={() => undefined}
|
|
778
|
+
isDesktop={isDesktop}
|
|
779
|
+
hideHeader={hideHeader}
|
|
780
|
+
selectionBar={activeSelectionBar}
|
|
781
|
+
listBody={listState === "ready" ? virtualBody : undefined}
|
|
782
|
+
/>
|
|
783
|
+
<ConfirmDialog
|
|
784
|
+
isOpen={pendingDeleteIds !== null}
|
|
785
|
+
title={formatDeleteToTrashTitle(pendingDeleteIds?.length ?? 0)}
|
|
786
|
+
description="You can restore them from Trash later."
|
|
787
|
+
confirmLabel="Move to Trash"
|
|
788
|
+
destructive
|
|
789
|
+
isBusy={isDeleting}
|
|
790
|
+
onConfirm={handleConfirmDelete}
|
|
791
|
+
onCancel={handleCancelDelete}
|
|
792
|
+
/>
|
|
793
|
+
{organizeOpen && accountId && (
|
|
794
|
+
<OrganizeDialog
|
|
795
|
+
open={organizeOpen}
|
|
796
|
+
accountId={accountId}
|
|
797
|
+
mailboxId={mailboxId}
|
|
798
|
+
selectedMessageIds={Array.from(selectedIds)}
|
|
799
|
+
onClose={() => setOrganizeOpen(false)}
|
|
800
|
+
/>
|
|
801
|
+
)}
|
|
802
|
+
</>
|
|
803
|
+
);
|
|
804
|
+
};
|