@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,13 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
// Search schema: `selectedOutboxMessageId` is read by `OutboxPane` in the
|
|
5
|
+
// parent `/mail` shell via `useSearch({ strict: false })`.
|
|
6
|
+
const outboxSearchSchema = z.object({
|
|
7
|
+
selectedOutboxMessageId: z.string().optional(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const Route = createFileRoute("/mail/outbox")({
|
|
11
|
+
component: () => null,
|
|
12
|
+
validateSearch: outboxSearchSchema,
|
|
13
|
+
});
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import {
|
|
2
|
+
configOperationsGetConfigOptions,
|
|
3
|
+
unifiedThreadOperationsListAllThreadsOptions,
|
|
4
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
|
+
import { AppShellSlotted } from "@remit/ui";
|
|
6
|
+
import { useQuery } from "@tanstack/react-query";
|
|
7
|
+
import {
|
|
8
|
+
createFileRoute,
|
|
9
|
+
Outlet,
|
|
10
|
+
useNavigate,
|
|
11
|
+
useRouterState,
|
|
12
|
+
useSearch,
|
|
13
|
+
} from "@tanstack/react-router";
|
|
14
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
|
|
17
|
+
import { ComposeFab } from "@/components/layout/ComposeFab";
|
|
18
|
+
import { BriefPane } from "@/components/mail/BriefPane";
|
|
19
|
+
import { FlaggedPane } from "@/components/mail/FlaggedPane";
|
|
20
|
+
import { MailboxPane } from "@/components/mail/MailboxPane";
|
|
21
|
+
import { MailNav } from "@/components/mail/MailNav";
|
|
22
|
+
import { OutboxPane } from "@/components/mail/OutboxPane";
|
|
23
|
+
import { ErrorState } from "@/components/ui/ErrorState";
|
|
24
|
+
import { KeyboardShortcutsModal } from "@/components/ui/KeyboardShortcutsModal";
|
|
25
|
+
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
|
26
|
+
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
|
27
|
+
import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
|
|
28
|
+
import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
|
|
29
|
+
import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
|
|
30
|
+
import { writeIntelligencePref } from "@/lib/intelligence-pref";
|
|
31
|
+
import { MailContext } from "@/lib/mail-context";
|
|
32
|
+
import { isBriefRoute, isFlaggedRoute, isOutboxRoute } from "@/lib/mail-route";
|
|
33
|
+
import { buildAccountNameIndex } from "@/lib/search-token-index";
|
|
34
|
+
import "@/lib/client";
|
|
35
|
+
|
|
36
|
+
// `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
|
|
37
|
+
// here and the child-route consumers resolve to a single module instance — see
|
|
38
|
+
// that file for why the alias-vs-relative route-tree import split otherwise
|
|
39
|
+
// breaks context.
|
|
40
|
+
export { useMailContext } from "@/lib/mail-context";
|
|
41
|
+
|
|
42
|
+
const mailSearchSchema = z.object({
|
|
43
|
+
q: z.string().optional(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const Route = createFileRoute("/mail")({
|
|
47
|
+
// Kick off config and the cross-account thread list together, without
|
|
48
|
+
// awaiting either — the route paints its skeleton shell immediately and the
|
|
49
|
+
// queries resolve in parallel rather than serialized behind config. The
|
|
50
|
+
// zero-account onboarding redirect moved into the component (see
|
|
51
|
+
// MailLayout), so a cold load no longer blocks first paint on the network.
|
|
52
|
+
loader: ({ context: { queryClient } }) => {
|
|
53
|
+
void queryClient.prefetchQuery(configOperationsGetConfigOptions());
|
|
54
|
+
void queryClient.prefetchQuery({
|
|
55
|
+
...unifiedThreadOperationsListAllThreadsOptions(),
|
|
56
|
+
staleTime: 60_000,
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
component: MailLayout,
|
|
60
|
+
validateSearch: mailSearchSchema,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function MailLayout() {
|
|
64
|
+
const { q: searchQuery = "" } = useSearch({ from: "/mail" });
|
|
65
|
+
const navigate = useNavigate();
|
|
66
|
+
const tier = useLayoutTier();
|
|
67
|
+
// Below the reading boundary (phone AND tablet) AppShellSlotted shows a
|
|
68
|
+
// SINGLE pane — there is no reading pane to host the thread or the compose
|
|
69
|
+
// surface. So both tiers use the single-pane view, which swaps the pane in
|
|
70
|
+
// place between list, open thread, and compose. Keying this off "phone"
|
|
71
|
+
// alone left tablet with no compose surface (compose lives in the reading
|
|
72
|
+
// pane, which tablet doesn't mount) — the "c" shortcut / FAB opened nothing.
|
|
73
|
+
const isSinglePane = isSinglePaneTier(tier);
|
|
74
|
+
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
75
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
76
|
+
// Pane 4 / the mobile details drawer share this toggle. It starts closed so
|
|
77
|
+
// the phone never slams a full-screen intelligence drawer over a freshly
|
|
78
|
+
// opened thread; the DESKTOP route opens it by default with the thread (the
|
|
79
|
+
// intelligence rail is the product's core value) and honours the persisted
|
|
80
|
+
// collapse preference there (#782). DKIM-mismatch auto-open still fires on
|
|
81
|
+
// every tier. Explicit toggles persist the user's choice.
|
|
82
|
+
const [intelligenceOpen, setIntelligenceOpen] = useState(false);
|
|
83
|
+
const handleSetIntelligenceOpen = useCallback((open: boolean) => {
|
|
84
|
+
setIntelligenceOpen(open);
|
|
85
|
+
writeIntelligencePref(open);
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
// URL `q` is a load-once seed for the input and a one-directional write
|
|
89
|
+
// target. The debounced local value is the source of truth and drives the
|
|
90
|
+
// search API; it is mirrored back to the URL but the URL is never read into
|
|
91
|
+
// state after mount.
|
|
92
|
+
const [searchInput, setSearchInput] = useState(searchQuery);
|
|
93
|
+
const debouncedSearchInput = useDebouncedValue(searchInput, 200);
|
|
94
|
+
|
|
95
|
+
const searchQueryRef = useRef(searchQuery);
|
|
96
|
+
searchQueryRef.current = searchQuery;
|
|
97
|
+
|
|
98
|
+
// Mirror the debounced search into the URL so links are shareable and a
|
|
99
|
+
// refresh restores the query. One-directional: the URL is never read back
|
|
100
|
+
// into state, so there is no sync loop.
|
|
101
|
+
// When a query *goes* active, also strip selectedMessageId so the reading
|
|
102
|
+
// pane closes (#539): an open message from the pre-search list is not
|
|
103
|
+
// meaningful in the search result set. Only strip on that transition though —
|
|
104
|
+
// tapping a search result commits the same `q` with the selection, so when
|
|
105
|
+
// `prev.q` already equals the query the result was opened under it (not a
|
|
106
|
+
// pre-search leftover) and must survive. The strip otherwise raced the tap:
|
|
107
|
+
// the row shows before the debounce settles, so this mirror can land just
|
|
108
|
+
// after the open and close it again.
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (debouncedSearchInput === searchQueryRef.current) return;
|
|
111
|
+
navigate({
|
|
112
|
+
to: ".",
|
|
113
|
+
search: (prev) => {
|
|
114
|
+
const queryAlreadyActive =
|
|
115
|
+
(prev as { q?: string }).q === debouncedSearchInput;
|
|
116
|
+
return {
|
|
117
|
+
...prev,
|
|
118
|
+
q: debouncedSearchInput || undefined,
|
|
119
|
+
...(debouncedSearchInput && !queryAlreadyActive
|
|
120
|
+
? {
|
|
121
|
+
selectedMessageId: undefined,
|
|
122
|
+
selectedThreadId: undefined,
|
|
123
|
+
selectedMailboxId: undefined,
|
|
124
|
+
}
|
|
125
|
+
: {}),
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
replace: true,
|
|
129
|
+
});
|
|
130
|
+
}, [debouncedSearchInput, navigate]);
|
|
131
|
+
|
|
132
|
+
const {
|
|
133
|
+
data: config,
|
|
134
|
+
isLoading,
|
|
135
|
+
isError: isConfigError,
|
|
136
|
+
error: configError,
|
|
137
|
+
refetch: refetchConfig,
|
|
138
|
+
} = useQuery({
|
|
139
|
+
...configOperationsGetConfigOptions(),
|
|
140
|
+
// Config only changes when accounts are added/edited/removed; those
|
|
141
|
+
// mutations explicitly invalidate this query.
|
|
142
|
+
staleTime: Infinity,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// First-run guard: a zero-account user goes to the onboarding wizard. This
|
|
146
|
+
// lives here (not a blocking beforeLoad) so a cold load paints the skeleton
|
|
147
|
+
// shell immediately; the redirect fires once config arrives. It guards the
|
|
148
|
+
// /mail parent so it holds for every child route — index redirect, daily
|
|
149
|
+
// brief (#426/#484), etc.
|
|
150
|
+
const hasNoAccounts = Boolean(config && (config.accounts?.length ?? 0) === 0);
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!hasNoAccounts) return;
|
|
153
|
+
navigate({ to: "/onboarding", replace: true });
|
|
154
|
+
}, [hasNoAccounts, navigate]);
|
|
155
|
+
|
|
156
|
+
// Global keyboard shortcut for help
|
|
157
|
+
useKeyboardNavigation({
|
|
158
|
+
enabled: !showShortcuts,
|
|
159
|
+
bindings: [
|
|
160
|
+
{
|
|
161
|
+
key: "?",
|
|
162
|
+
handler: () => setShowShortcuts(true),
|
|
163
|
+
noModifiers: false, // Allow shift+/
|
|
164
|
+
preventDefault: true,
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const handleSearchChange = useCallback((query: string) => {
|
|
170
|
+
setSearchInput(query);
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
// Clears the search field; the write effect drops `q` from the URL after
|
|
174
|
+
// the debounce settles.
|
|
175
|
+
const handleSearchClear = useCallback(() => {
|
|
176
|
+
setSearchInput("");
|
|
177
|
+
}, []);
|
|
178
|
+
|
|
179
|
+
// Esc inside the search field clears only the query (#489).
|
|
180
|
+
const handleSearchClearQuery = useCallback(() => {
|
|
181
|
+
setSearchInput("");
|
|
182
|
+
}, []);
|
|
183
|
+
|
|
184
|
+
const handleToggleIntelligence = useCallback(() => {
|
|
185
|
+
setIntelligenceOpen((open) => {
|
|
186
|
+
const next = !open;
|
|
187
|
+
writeIntelligencePref(next);
|
|
188
|
+
return next;
|
|
189
|
+
});
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
const accounts = config?.accounts ?? [];
|
|
193
|
+
const mailboxNameIndex = useMailboxNameIndex(accounts);
|
|
194
|
+
const accountNameIndex = useMemo(
|
|
195
|
+
() => buildAccountNameIndex(accounts),
|
|
196
|
+
[accounts],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Read the current mailbox params and selected message (if any) from the
|
|
200
|
+
// child route so the parent shell can mount the right pane (brief / mailbox /
|
|
201
|
+
// outbox) and forward the open thread into it.
|
|
202
|
+
//
|
|
203
|
+
// `useParams({ strict: false })` resolves against the *nearest* route match
|
|
204
|
+
// in the React component tree — which for this parent layout is /mail (no
|
|
205
|
+
// path params). The child's $mailboxId param never appears there, so
|
|
206
|
+
// mobileMailboxId would always be undefined and the navigate guard would
|
|
207
|
+
// short-circuit every tap. Use `useRouterState` instead: it exposes all
|
|
208
|
+
// currently matched routes, letting us find the one that carries mailboxId.
|
|
209
|
+
const mobileMailboxId = useRouterState({
|
|
210
|
+
select: (s) => {
|
|
211
|
+
const match = s.matches.find(
|
|
212
|
+
(m): m is typeof m & { params: { mailboxId: string } } =>
|
|
213
|
+
"mailboxId" in m.params,
|
|
214
|
+
);
|
|
215
|
+
return match?.params.mailboxId;
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
const { selectedMessageId: mobileSelectedMessageId } = useSearch({
|
|
219
|
+
strict: false,
|
|
220
|
+
}) as { selectedMessageId?: string };
|
|
221
|
+
|
|
222
|
+
// Detect which leaf route is active by its own routeId — never the parent
|
|
223
|
+
// /mail layout's pathname (which is "/mail" on EVERY child route and would
|
|
224
|
+
// wrongly route every mailbox through the brief pane, dropping message rows).
|
|
225
|
+
// See lib/mail-route.ts for the contract + regression test.
|
|
226
|
+
const onBriefRoute = useRouterState({
|
|
227
|
+
select: (s) => isBriefRoute(s.matches),
|
|
228
|
+
});
|
|
229
|
+
const onFlaggedRoute = useRouterState({
|
|
230
|
+
select: (s) => isFlaggedRoute(s.matches),
|
|
231
|
+
});
|
|
232
|
+
const onOutboxRoute = useRouterState({
|
|
233
|
+
select: (s) => isOutboxRoute(s.matches),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Auto-trigger a mailbox-list sync for any account whose lastSyncAt is
|
|
237
|
+
// older than 15 minutes (or unset). Fires once per accountId per session
|
|
238
|
+
// — re-mounts of MailLayout will not retrigger. See #205.
|
|
239
|
+
useStaleAccountSync(accounts);
|
|
240
|
+
|
|
241
|
+
const handleMailboxSelect = useCallback(() => {
|
|
242
|
+
// Auto-collapse the mobile drawer after the user picks an inbox
|
|
243
|
+
// from the sidebar (#199). Desktop sidebar isn't a drawer so the
|
|
244
|
+
// noop is fine there.
|
|
245
|
+
setDrawerOpen(false);
|
|
246
|
+
}, []);
|
|
247
|
+
|
|
248
|
+
const mailContextValue = {
|
|
249
|
+
accounts,
|
|
250
|
+
mailboxNameIndex,
|
|
251
|
+
accountNameIndex,
|
|
252
|
+
// Debounced local value is the source of truth for search; it is
|
|
253
|
+
// mirrored to the URL one-directionally for shareable links.
|
|
254
|
+
searchQuery: debouncedSearchInput,
|
|
255
|
+
searchInput,
|
|
256
|
+
onSearchChange: handleSearchChange,
|
|
257
|
+
onSearchClear: handleSearchClear,
|
|
258
|
+
onSearchClearQuery: handleSearchClearQuery,
|
|
259
|
+
intelligenceOpen,
|
|
260
|
+
onToggleIntelligence: handleToggleIntelligence,
|
|
261
|
+
onSetIntelligenceOpen: handleSetIntelligenceOpen,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// Single nav node: the kit renders it as a pane (≥1024px) or inside its
|
|
265
|
+
// own slide-over Dialog (narrow), never both — so there is exactly one
|
|
266
|
+
// "Mailboxes" nav landmark at any width. `MailNav` adds the mobile
|
|
267
|
+
// Settings + bug-report footer when it detects the slide-over context.
|
|
268
|
+
const navContent = (
|
|
269
|
+
<MailNav accounts={accounts} onMailboxSelect={handleMailboxSelect} />
|
|
270
|
+
);
|
|
271
|
+
const overlayContent = <ComposeFab />;
|
|
272
|
+
const navSlideOver = {
|
|
273
|
+
navOpen: drawerOpen,
|
|
274
|
+
onOpenNav: () => setDrawerOpen(true),
|
|
275
|
+
onCloseNav: () => setDrawerOpen(false),
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
return (
|
|
279
|
+
<MailContext.Provider value={mailContextValue}>
|
|
280
|
+
{isConfigError ? (
|
|
281
|
+
<div className="flex h-full items-center justify-center bg-canvas p-4">
|
|
282
|
+
<ErrorState
|
|
283
|
+
title="Couldn't load your account"
|
|
284
|
+
error={configError}
|
|
285
|
+
onRetry={() => {
|
|
286
|
+
refetchConfig();
|
|
287
|
+
}}
|
|
288
|
+
/>
|
|
289
|
+
</div>
|
|
290
|
+
) : onBriefRoute ? (
|
|
291
|
+
// Daily brief (/mail/) — no mailboxId param; 2-pane layout (no intelligence).
|
|
292
|
+
<BriefPane selectedMessageId={mobileSelectedMessageId}>
|
|
293
|
+
{isSinglePane ? (
|
|
294
|
+
<AppShellSlotted
|
|
295
|
+
nav={navContent}
|
|
296
|
+
list={<BriefPane.Phone />}
|
|
297
|
+
intelligenceOpen={intelligenceOpen}
|
|
298
|
+
overlay={overlayContent}
|
|
299
|
+
skeleton={<AppShellSkeleton />}
|
|
300
|
+
isLoading={isLoading || hasNoAccounts}
|
|
301
|
+
{...navSlideOver}
|
|
302
|
+
/>
|
|
303
|
+
) : (
|
|
304
|
+
<AppShellSlotted
|
|
305
|
+
nav={navContent}
|
|
306
|
+
list={<BriefPane.List />}
|
|
307
|
+
reading={<BriefPane.Reading />}
|
|
308
|
+
intelligenceOpen={intelligenceOpen}
|
|
309
|
+
overlay={overlayContent}
|
|
310
|
+
skeleton={<AppShellSkeleton />}
|
|
311
|
+
isLoading={isLoading || hasNoAccounts}
|
|
312
|
+
{...navSlideOver}
|
|
313
|
+
/>
|
|
314
|
+
)}
|
|
315
|
+
</BriefPane>
|
|
316
|
+
) : onFlaggedRoute ? (
|
|
317
|
+
// Flagged virtual mailbox (/mail/flagged) — flat starred list across
|
|
318
|
+
// accounts; 2-pane layout (no intelligence), like the brief.
|
|
319
|
+
<FlaggedPane selectedMessageId={mobileSelectedMessageId}>
|
|
320
|
+
{isSinglePane ? (
|
|
321
|
+
<AppShellSlotted
|
|
322
|
+
nav={navContent}
|
|
323
|
+
list={<FlaggedPane.Phone />}
|
|
324
|
+
intelligenceOpen={intelligenceOpen}
|
|
325
|
+
overlay={overlayContent}
|
|
326
|
+
skeleton={<AppShellSkeleton />}
|
|
327
|
+
isLoading={isLoading || hasNoAccounts}
|
|
328
|
+
{...navSlideOver}
|
|
329
|
+
/>
|
|
330
|
+
) : (
|
|
331
|
+
<AppShellSlotted
|
|
332
|
+
nav={navContent}
|
|
333
|
+
list={<FlaggedPane.List />}
|
|
334
|
+
reading={<FlaggedPane.Reading />}
|
|
335
|
+
intelligenceOpen={intelligenceOpen}
|
|
336
|
+
overlay={overlayContent}
|
|
337
|
+
skeleton={<AppShellSkeleton />}
|
|
338
|
+
isLoading={isLoading || hasNoAccounts}
|
|
339
|
+
{...navSlideOver}
|
|
340
|
+
/>
|
|
341
|
+
)}
|
|
342
|
+
</FlaggedPane>
|
|
343
|
+
) : mobileMailboxId ? (
|
|
344
|
+
// Mailbox view (/mail/$mailboxId) — full 4-pane layout.
|
|
345
|
+
<MailboxPane
|
|
346
|
+
mailboxId={mobileMailboxId}
|
|
347
|
+
selectedMessageId={mobileSelectedMessageId}
|
|
348
|
+
>
|
|
349
|
+
{isSinglePane ? (
|
|
350
|
+
<AppShellSlotted
|
|
351
|
+
nav={navContent}
|
|
352
|
+
list={<MailboxPane.Phone />}
|
|
353
|
+
intelligenceOpen={intelligenceOpen}
|
|
354
|
+
overlay={overlayContent}
|
|
355
|
+
skeleton={<AppShellSkeleton />}
|
|
356
|
+
isLoading={isLoading || hasNoAccounts}
|
|
357
|
+
{...navSlideOver}
|
|
358
|
+
/>
|
|
359
|
+
) : (
|
|
360
|
+
<AppShellSlotted
|
|
361
|
+
nav={navContent}
|
|
362
|
+
list={<MailboxPane.List />}
|
|
363
|
+
reading={<MailboxPane.Reading />}
|
|
364
|
+
intelligence={<MailboxPane.Intelligence />}
|
|
365
|
+
intelligenceOpen={intelligenceOpen}
|
|
366
|
+
overlay={overlayContent}
|
|
367
|
+
skeleton={<AppShellSkeleton />}
|
|
368
|
+
isLoading={isLoading || hasNoAccounts}
|
|
369
|
+
{...navSlideOver}
|
|
370
|
+
/>
|
|
371
|
+
)}
|
|
372
|
+
</MailboxPane>
|
|
373
|
+
) : onOutboxRoute ? (
|
|
374
|
+
// Outbox — 2-pane layout (list + reading, no intelligence).
|
|
375
|
+
<OutboxPane>
|
|
376
|
+
{isSinglePane ? (
|
|
377
|
+
<AppShellSlotted
|
|
378
|
+
nav={navContent}
|
|
379
|
+
list={<OutboxPane.Phone />}
|
|
380
|
+
intelligenceOpen={false}
|
|
381
|
+
overlay={overlayContent}
|
|
382
|
+
skeleton={<AppShellSkeleton />}
|
|
383
|
+
isLoading={isLoading || hasNoAccounts}
|
|
384
|
+
{...navSlideOver}
|
|
385
|
+
/>
|
|
386
|
+
) : (
|
|
387
|
+
<AppShellSlotted
|
|
388
|
+
nav={navContent}
|
|
389
|
+
list={<OutboxPane.List />}
|
|
390
|
+
reading={<OutboxPane.Reading />}
|
|
391
|
+
intelligenceOpen={false}
|
|
392
|
+
overlay={overlayContent}
|
|
393
|
+
skeleton={<AppShellSkeleton />}
|
|
394
|
+
isLoading={isLoading || hasNoAccounts}
|
|
395
|
+
{...navSlideOver}
|
|
396
|
+
/>
|
|
397
|
+
)}
|
|
398
|
+
</OutboxPane>
|
|
399
|
+
) : (
|
|
400
|
+
// Fallback: transitioning or unrecognized route — show skeleton.
|
|
401
|
+
<AppShellSkeleton />
|
|
402
|
+
)}
|
|
403
|
+
<KeyboardShortcutsModal
|
|
404
|
+
isOpen={showShortcuts}
|
|
405
|
+
onClose={() => setShowShortcuts(false)}
|
|
406
|
+
/>
|
|
407
|
+
{/* Outlet is required for TanStack Router to activate child routes.
|
|
408
|
+
Routes that manage their own rendering (brief, mailbox, outbox) return
|
|
409
|
+
null from their component — the parent shell owns the layout. */}
|
|
410
|
+
<Outlet />
|
|
411
|
+
</MailContext.Provider>
|
|
412
|
+
);
|
|
413
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
3
|
+
import { useCallback } from "react";
|
|
4
|
+
import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
|
|
5
|
+
import { completeOnboarding } from "@/lib/onboarding-completion";
|
|
6
|
+
import { useTelemetry } from "@/lib/telemetry-context";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* /onboarding — full-screen wizard for first-run users (zero accounts).
|
|
10
|
+
*
|
|
11
|
+
* Redirected to automatically by the /mail route when config.accounts is empty.
|
|
12
|
+
* After completing the wizard the user is sent to /mail (inbox).
|
|
13
|
+
*/
|
|
14
|
+
export const Route = createFileRoute("/onboarding")({
|
|
15
|
+
component: OnboardingPage,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function OnboardingPage() {
|
|
19
|
+
const navigate = useNavigate();
|
|
20
|
+
const telemetry = useTelemetry();
|
|
21
|
+
const queryClient = useQueryClient();
|
|
22
|
+
|
|
23
|
+
const handleComplete = useCallback(
|
|
24
|
+
(_accountId: string) => {
|
|
25
|
+
void completeOnboarding({
|
|
26
|
+
queryClient,
|
|
27
|
+
recordCompleted: () => telemetry.recordEvent("onboarding.completed"),
|
|
28
|
+
navigateToInbox: () => void navigate({ to: "/mail" }),
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
[navigate, telemetry, queryClient],
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return <OnboardingWizard skipWelcome={false} onComplete={handleComplete} />;
|
|
35
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { describe, test } from "node:test";
|
|
4
|
+
import { mapOauthError } from "./accounts.tsx";
|
|
5
|
+
|
|
6
|
+
describe("mapOauthError", () => {
|
|
7
|
+
test("access_denied returns cancellation message", () => {
|
|
8
|
+
assert.strictEqual(
|
|
9
|
+
mapOauthError("access_denied"),
|
|
10
|
+
"You cancelled the sign-in.",
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("ACCESS_DENIED is case-insensitive", () => {
|
|
15
|
+
assert.strictEqual(
|
|
16
|
+
mapOauthError("ACCESS_DENIED"),
|
|
17
|
+
"You cancelled the sign-in.",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("consent_required returns admin consent message", () => {
|
|
22
|
+
const result = mapOauthError("consent_required");
|
|
23
|
+
assert.ok(
|
|
24
|
+
result.toLowerCase().includes("admin"),
|
|
25
|
+
`Expected admin hint, got: ${result}`,
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("admin_consent_required returns admin consent message", () => {
|
|
30
|
+
const result = mapOauthError("admin_consent_required");
|
|
31
|
+
assert.ok(
|
|
32
|
+
result.toLowerCase().includes("admin"),
|
|
33
|
+
`Expected admin hint, got: ${result}`,
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("interaction_required returns admin consent message", () => {
|
|
38
|
+
const result = mapOauthError("interaction_required");
|
|
39
|
+
assert.ok(
|
|
40
|
+
result.toLowerCase().includes("admin"),
|
|
41
|
+
`Expected admin hint, got: ${result}`,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("imap_disabled returns IMAP hint", () => {
|
|
46
|
+
const result = mapOauthError("imap_disabled");
|
|
47
|
+
assert.ok(
|
|
48
|
+
result.toLowerCase().includes("imap"),
|
|
49
|
+
`Expected IMAP hint, got: ${result}`,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("unknown code returns generic fallback with the code", () => {
|
|
54
|
+
const result = mapOauthError("some_random_error");
|
|
55
|
+
assert.ok(
|
|
56
|
+
result.includes("some_random_error"),
|
|
57
|
+
`Expected code in message, got: ${result}`,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("empty string returns generic fallback", () => {
|
|
62
|
+
const result = mapOauthError("");
|
|
63
|
+
assert.ok(typeof result === "string" && result.length > 0);
|
|
64
|
+
});
|
|
65
|
+
});
|