@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,284 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addressOperationsSearchAddressesOptions,
|
|
3
|
+
semanticSearchOperationsSemanticSearchOptions,
|
|
4
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
|
+
import type {
|
|
6
|
+
RemitImapAddressResponse,
|
|
7
|
+
RemitImapThreadMessageResponse,
|
|
8
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
9
|
+
import type {
|
|
10
|
+
AuthenticityIntel,
|
|
11
|
+
IntelligenceData,
|
|
12
|
+
MatchedChunk,
|
|
13
|
+
SenderFlagsIntel,
|
|
14
|
+
SenderIntel,
|
|
15
|
+
SimilarMessageIntel,
|
|
16
|
+
} from "@remit/ui";
|
|
17
|
+
import { useQuery } from "@tanstack/react-query";
|
|
18
|
+
import { useMemo } from "react";
|
|
19
|
+
import { isServerError } from "@/lib/error-classifier";
|
|
20
|
+
import { formatDate, formatEmailDate } from "@/lib/format";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Format a creation timestamp as a human-readable "first seen" label.
|
|
24
|
+
* - Today: "today"
|
|
25
|
+
* - Otherwise: "Jan 2025"
|
|
26
|
+
*/
|
|
27
|
+
function formatFirstSeenLabel(createdAtMs: number): string {
|
|
28
|
+
const d = new Date(createdAtMs);
|
|
29
|
+
const now = new Date();
|
|
30
|
+
if (d.toDateString() === now.toDateString()) return "today";
|
|
31
|
+
return formatDate(d, { month: "short", year: "numeric" });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A sender address we cannot read: empty, missing the `@`, or whose domain
|
|
36
|
+
* part carries no dot (catches placeholders like `missing_domain` and other
|
|
37
|
+
* junk). Structural — never a hard-coded literal.
|
|
38
|
+
*/
|
|
39
|
+
export function isSenderAddressUnverifiable(
|
|
40
|
+
fromEmail: string | undefined,
|
|
41
|
+
): boolean {
|
|
42
|
+
if (!fromEmail) return true;
|
|
43
|
+
const [, domain] = fromEmail.split("@");
|
|
44
|
+
if (!domain) return true;
|
|
45
|
+
return !domain.includes(".");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const NO_SIGNAL_SUMMARY =
|
|
49
|
+
"We can't verify the sender of this email, which could mean it's from an insecure source.";
|
|
50
|
+
|
|
51
|
+
const UNREADABLE_SENDER_SUMMARY =
|
|
52
|
+
"We couldn't read this sender's address, so we can't confirm who really sent this message.";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build sender intel from the thread message and address lookup.
|
|
56
|
+
*
|
|
57
|
+
* Engagement counters (`inboundCount`/`replyCount`) are passed through
|
|
58
|
+
* as-is from the address response. When absent (`undefined`) they remain
|
|
59
|
+
* `undefined` so the `SenderCard` engagement clause is suppressed rather
|
|
60
|
+
* than rendering a misleading "0 received" next to an earned trust badge.
|
|
61
|
+
*/
|
|
62
|
+
export function buildSenderIntel(
|
|
63
|
+
thread: RemitImapThreadMessageResponse,
|
|
64
|
+
address: RemitImapAddressResponse | undefined,
|
|
65
|
+
): SenderIntel {
|
|
66
|
+
return {
|
|
67
|
+
name: thread.fromName ?? thread.fromEmail ?? "Unknown",
|
|
68
|
+
email: thread.fromEmail ?? "",
|
|
69
|
+
trust: thread.senderTrust,
|
|
70
|
+
firstSeenLabel: address
|
|
71
|
+
? formatFirstSeenLabel(address.createdAt)
|
|
72
|
+
: "unknown",
|
|
73
|
+
// Pass counters through as-is. undefined stays undefined so the panel
|
|
74
|
+
// suppresses the engagement clause when data is absent.
|
|
75
|
+
inboundCount: address?.inboundCount,
|
|
76
|
+
replyCount: address?.replyCount,
|
|
77
|
+
addressUnverified: isSenderAddressUnverifiable(thread.fromEmail),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build sender flags from the address response.
|
|
83
|
+
*/
|
|
84
|
+
function buildSenderFlags(
|
|
85
|
+
address: RemitImapAddressResponse | undefined,
|
|
86
|
+
): SenderFlagsIntel {
|
|
87
|
+
if (!address?.flags) return {};
|
|
88
|
+
return {
|
|
89
|
+
vip: address.flags.vip?.value === true,
|
|
90
|
+
muted: address.flags.muted?.value === true,
|
|
91
|
+
blocked: address.flags.blocked?.value === true,
|
|
92
|
+
unsubscribed: address.flags.unsubscribed?.value === true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build authenticity intel from the thread message's authenticity field.
|
|
98
|
+
*
|
|
99
|
+
* `similarCount` is the number of semantic-search lookalikes; the panel uses
|
|
100
|
+
* it to render the "N similar messages" campaign-reveal CTA inside the
|
|
101
|
+
* danger panel. Passed in because it is derived from the semantic-search
|
|
102
|
+
* result, not the thread row.
|
|
103
|
+
*/
|
|
104
|
+
export function buildAuthenticityIntel(
|
|
105
|
+
thread: RemitImapThreadMessageResponse,
|
|
106
|
+
similarCount: number,
|
|
107
|
+
): AuthenticityIntel {
|
|
108
|
+
const surfacedSimilar = similarCount > 0 ? similarCount : undefined;
|
|
109
|
+
|
|
110
|
+
if (isSenderAddressUnverifiable(thread.fromEmail)) {
|
|
111
|
+
return {
|
|
112
|
+
verdict: "mismatch",
|
|
113
|
+
fromDomain: "",
|
|
114
|
+
addressUnreadable: true,
|
|
115
|
+
summary: UNREADABLE_SENDER_SUMMARY,
|
|
116
|
+
similarCount: surfacedSimilar,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const auth = thread.authenticity;
|
|
121
|
+
if (!auth) {
|
|
122
|
+
return {
|
|
123
|
+
verdict: "caution",
|
|
124
|
+
fromDomain: thread.fromEmail?.split("@")[1] ?? "",
|
|
125
|
+
summary: NO_SIGNAL_SUMMARY,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (!auth.dkimMismatch) {
|
|
129
|
+
return {
|
|
130
|
+
verdict: "aligned",
|
|
131
|
+
fromDomain: auth.fromDomain,
|
|
132
|
+
dkimDomain: auth.dkimDomain,
|
|
133
|
+
summary: auth.dkimDomain
|
|
134
|
+
? `We verified this message was really sent by ${auth.fromDomain}.`
|
|
135
|
+
: `Nothing looks unusual about this sender.`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const fromDomain = auth.fromDomain;
|
|
140
|
+
const dkimDomain = auth.dkimDomain;
|
|
141
|
+
const claimedBrand =
|
|
142
|
+
thread.fromName && thread.fromName !== thread.fromEmail
|
|
143
|
+
? thread.fromName
|
|
144
|
+
: undefined;
|
|
145
|
+
const summary = claimedBrand
|
|
146
|
+
? `The display name claims "${claimedBrand}", but this message was actually sent from ${dkimDomain ?? "another sender"} — not ${fromDomain}. Real senders use their own address.`
|
|
147
|
+
: `This message claims to be from ${fromDomain}, but it was actually sent from ${dkimDomain ?? "a different sender"}.`;
|
|
148
|
+
return {
|
|
149
|
+
verdict: "mismatch",
|
|
150
|
+
fromDomain,
|
|
151
|
+
dkimDomain,
|
|
152
|
+
claimedBrand,
|
|
153
|
+
summary,
|
|
154
|
+
// Surface the lookalike count so the panel renders the "N similar
|
|
155
|
+
// messages" campaign-reveal button.
|
|
156
|
+
similarCount: surfacedSimilar,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface UseIntelligenceDataResult {
|
|
161
|
+
data: IntelligenceData | null;
|
|
162
|
+
/**
|
|
163
|
+
* True while similar-messages fetch is in flight. The rest of the data
|
|
164
|
+
* (sender, authenticity, category, flags) arrives synchronously from the
|
|
165
|
+
* already-loaded thread row.
|
|
166
|
+
*/
|
|
167
|
+
isSimilarLoading: boolean;
|
|
168
|
+
/** Non-null when the similar-messages fetch failed. */
|
|
169
|
+
similarError: unknown;
|
|
170
|
+
/**
|
|
171
|
+
* True when the similar-messages failure is a fatal first-party 5xx. The
|
|
172
|
+
* global escalation overlay handles it; the panel must NOT degrade it to the
|
|
173
|
+
* benign grey "Similarity search unavailable" label. A soft (non-fatal)
|
|
174
|
+
* error or an empty result keeps the panel quiet.
|
|
175
|
+
*/
|
|
176
|
+
similarErrorIsFatal: boolean;
|
|
177
|
+
/**
|
|
178
|
+
* True when the address lookup failed with a fatal first-party 5xx. The panel
|
|
179
|
+
* must not silently render the sender's flags (VIP/muted/blocked) as absent —
|
|
180
|
+
* that misrepresents the sender's real state. Escalation covers it.
|
|
181
|
+
*/
|
|
182
|
+
addressErrorIsFatal: boolean;
|
|
183
|
+
/** Address id for the sender — needed for PATCH /addresses/{id} mutations. */
|
|
184
|
+
addressId: string | undefined;
|
|
185
|
+
/**
|
|
186
|
+
* Raw address response for the sender — forwarded to the quick-action
|
|
187
|
+
* mutation hooks so they can optimistically patch the cache.
|
|
188
|
+
*/
|
|
189
|
+
address: RemitImapAddressResponse | undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Maps a `RemitImapThreadMessageResponse` into `IntelligenceData` for the
|
|
194
|
+
* `IntelligencePanel` component. Three data sources:
|
|
195
|
+
*
|
|
196
|
+
* 1. The thread row itself (already loaded) → sender basics, trust, authenticity, category.
|
|
197
|
+
* 2. `GET /addresses/search?q=<email>` → address flags (VIP/muted/blocked/etc.)
|
|
198
|
+
* and first-seen timestamp. One round-trip per unique sender.
|
|
199
|
+
* 3. `GET /search/semantic` → similar messages. Last to arrive; sidebar still
|
|
200
|
+
* renders if this fails.
|
|
201
|
+
*/
|
|
202
|
+
export function useIntelligenceData(
|
|
203
|
+
thread: RemitImapThreadMessageResponse | null | undefined,
|
|
204
|
+
currentMailboxId?: string,
|
|
205
|
+
): UseIntelligenceDataResult {
|
|
206
|
+
const senderEmail = thread?.fromEmail ?? null;
|
|
207
|
+
const subject = thread?.subject ?? "";
|
|
208
|
+
|
|
209
|
+
// --- Address lookup: flags + first-seen timestamp ---
|
|
210
|
+
const { data: addressSearchResult, error: addressError } = useQuery({
|
|
211
|
+
...addressOperationsSearchAddressesOptions({
|
|
212
|
+
query: { q: senderEmail ?? "", limit: 1 },
|
|
213
|
+
}),
|
|
214
|
+
enabled: Boolean(senderEmail),
|
|
215
|
+
staleTime: 30_000,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const address = addressSearchResult?.items?.[0];
|
|
219
|
+
|
|
220
|
+
// --- Semantic search: similar messages ---
|
|
221
|
+
const semanticQuery = [subject, senderEmail].filter(Boolean).join(" ");
|
|
222
|
+
const {
|
|
223
|
+
data: semanticResult,
|
|
224
|
+
isLoading: isSimilarLoading,
|
|
225
|
+
error: similarError,
|
|
226
|
+
} = useQuery({
|
|
227
|
+
...semanticSearchOperationsSemanticSearchOptions({
|
|
228
|
+
query: { query: semanticQuery, limit: 5 },
|
|
229
|
+
}),
|
|
230
|
+
enabled: Boolean(thread && semanticQuery.length > 3),
|
|
231
|
+
staleTime: 60_000,
|
|
232
|
+
// A non-fatal failure (e.g. 404, no index yet) is acceptable — the sidebar
|
|
233
|
+
// degrades gracefully. A 5xx is NOT acceptable: it escalates globally and
|
|
234
|
+
// the panel must not present it as a benign "unavailable" state.
|
|
235
|
+
retry: 1,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const data = useMemo((): IntelligenceData | null => {
|
|
239
|
+
if (!thread) return null;
|
|
240
|
+
|
|
241
|
+
const similar: SimilarMessageIntel[] = (semanticResult?.items ?? [])
|
|
242
|
+
// Exclude the current message itself
|
|
243
|
+
.filter((r) => r.messageId !== thread.messageId)
|
|
244
|
+
.map((r) => ({
|
|
245
|
+
id: r.messageId,
|
|
246
|
+
// Prefer the mailbox the user is already viewing when the message also
|
|
247
|
+
// lives there, so opening a similar message stays in the same folder;
|
|
248
|
+
// otherwise fall back to the first mailbox it lives in.
|
|
249
|
+
mailboxId:
|
|
250
|
+
currentMailboxId != null && r.mailboxIds.includes(currentMailboxId)
|
|
251
|
+
? currentMailboxId
|
|
252
|
+
: (r.mailboxIds[0] ?? ""),
|
|
253
|
+
fromName: r.fromName ?? "",
|
|
254
|
+
subject: r.subject ?? "(No subject)",
|
|
255
|
+
timeLabel: r.sentDate != null ? formatEmailDate(r.sentDate * 1000) : "",
|
|
256
|
+
matched: r.matchedChunkType as MatchedChunk,
|
|
257
|
+
}));
|
|
258
|
+
|
|
259
|
+
const sender = buildSenderIntel(thread, address);
|
|
260
|
+
|
|
261
|
+
const authenticity = buildAuthenticityIntel(thread, similar.length);
|
|
262
|
+
|
|
263
|
+
const category = {
|
|
264
|
+
value: thread.category ?? "personal",
|
|
265
|
+
overridden:
|
|
266
|
+
address?.flags?.category?.value != null &&
|
|
267
|
+
address.flags.category.value !== thread.category,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const flags = buildSenderFlags(address);
|
|
271
|
+
|
|
272
|
+
return { sender, authenticity, category, flags, similar };
|
|
273
|
+
}, [thread, address, semanticResult, currentMailboxId]);
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
data,
|
|
277
|
+
isSimilarLoading,
|
|
278
|
+
similarError,
|
|
279
|
+
similarErrorIsFatal: isServerError(similarError),
|
|
280
|
+
addressErrorIsFatal: isServerError(addressError),
|
|
281
|
+
addressId: address?.addressId,
|
|
282
|
+
address,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns true when the app is in dark mode.
|
|
5
|
+
*
|
|
6
|
+
* Observes the `dark` class on `document.documentElement` — the same
|
|
7
|
+
* mechanism Tailwind's `class` darkMode strategy uses. The initial value
|
|
8
|
+
* is read synchronously so there's no flash on first render.
|
|
9
|
+
*/
|
|
10
|
+
export const useIsDark = (): boolean => {
|
|
11
|
+
const [isDark, setIsDark] = useState(
|
|
12
|
+
() =>
|
|
13
|
+
typeof document !== "undefined" &&
|
|
14
|
+
document.documentElement.classList.contains("dark"),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const el = document.documentElement;
|
|
19
|
+
const observer = new MutationObserver(() => {
|
|
20
|
+
setIsDark(el.classList.contains("dark"));
|
|
21
|
+
});
|
|
22
|
+
observer.observe(el, { attributes: true, attributeFilter: ["class"] });
|
|
23
|
+
return () => observer.disconnect();
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
return isDark;
|
|
27
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { useCallback, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
type KeyHandler = (event: KeyboardEvent) => void;
|
|
4
|
+
|
|
5
|
+
interface KeyBinding {
|
|
6
|
+
key: string;
|
|
7
|
+
handler: KeyHandler;
|
|
8
|
+
/** If true, only trigger when no modifier keys are pressed */
|
|
9
|
+
noModifiers?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Shift requirement. `undefined` (default) ignores shift. `true` requires
|
|
12
|
+
* shift to be held; `false` requires it to be absent. Use this to bind a
|
|
13
|
+
* plain key and its shift-variant to different handlers (e.g. ArrowDown vs
|
|
14
|
+
* Shift+ArrowDown) without them firing each other.
|
|
15
|
+
*/
|
|
16
|
+
requireShift?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Meta/Ctrl requirement. `undefined` (default) follows `noModifiers`.
|
|
19
|
+
* `true` requires Cmd (mac) or Ctrl to be held — used for Cmd/Ctrl+A.
|
|
20
|
+
*/
|
|
21
|
+
requireMeta?: boolean;
|
|
22
|
+
/** If true, prevent default browser behavior */
|
|
23
|
+
preventDefault?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* If true, stop the event from reaching any other keydown listener once
|
|
26
|
+
* this binding handles it (calls stopPropagation + stopImmediatePropagation).
|
|
27
|
+
* Combine with `capture` to win precedence over other window-level
|
|
28
|
+
* listeners on the same keypress (e.g. Esc clearing a selection must
|
|
29
|
+
* pre-empt a route-level Esc that would also navigate).
|
|
30
|
+
*/
|
|
31
|
+
stopPropagation?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface UseKeyboardNavigationOptions {
|
|
35
|
+
/** Whether the keyboard navigation is enabled */
|
|
36
|
+
enabled?: boolean;
|
|
37
|
+
/** Key bindings to register */
|
|
38
|
+
bindings: KeyBinding[];
|
|
39
|
+
/**
|
|
40
|
+
* Register the listener on the capture phase instead of the bubble phase.
|
|
41
|
+
* A capture-phase listener runs before bubble-phase listeners on the same
|
|
42
|
+
* target, so paired with a binding's `stopPropagation` it can consume a key
|
|
43
|
+
* before other handlers see it.
|
|
44
|
+
*/
|
|
45
|
+
capture?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hook for handling keyboard navigation.
|
|
50
|
+
* Registers global keyboard event listeners and calls handlers for matching keys.
|
|
51
|
+
*/
|
|
52
|
+
export const useKeyboardNavigation = ({
|
|
53
|
+
enabled = true,
|
|
54
|
+
bindings,
|
|
55
|
+
capture = false,
|
|
56
|
+
}: UseKeyboardNavigationOptions) => {
|
|
57
|
+
const handleKeyDown = useCallback(
|
|
58
|
+
(event: KeyboardEvent) => {
|
|
59
|
+
// Skip if focus is in an input/textarea
|
|
60
|
+
const target = event.target as HTMLElement;
|
|
61
|
+
if (
|
|
62
|
+
target.tagName === "INPUT" ||
|
|
63
|
+
target.tagName === "TEXTAREA" ||
|
|
64
|
+
target.isContentEditable
|
|
65
|
+
) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const binding of bindings) {
|
|
70
|
+
const keyMatches =
|
|
71
|
+
event.key.toLowerCase() === binding.key.toLowerCase();
|
|
72
|
+
if (!keyMatches) continue;
|
|
73
|
+
|
|
74
|
+
// Shift gate: when a binding pins shift on/off, it only matches that
|
|
75
|
+
// exact state so plain and shift variants stay distinct.
|
|
76
|
+
if (
|
|
77
|
+
binding.requireShift !== undefined &&
|
|
78
|
+
binding.requireShift !== event.shiftKey
|
|
79
|
+
) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const metaHeld = event.ctrlKey || event.metaKey;
|
|
84
|
+
|
|
85
|
+
// Meta gate: requireMeta bindings (Cmd/Ctrl+A) need meta/ctrl held.
|
|
86
|
+
if (binding.requireMeta !== undefined) {
|
|
87
|
+
if (binding.requireMeta !== metaHeld) continue;
|
|
88
|
+
} else {
|
|
89
|
+
const noModifiersRequired = binding.noModifiers ?? true;
|
|
90
|
+
// Treat shift as a modifier for the no-modifier guard only when
|
|
91
|
+
// the binding hasn't opted into an explicit shift requirement.
|
|
92
|
+
const hasModifiers =
|
|
93
|
+
metaHeld ||
|
|
94
|
+
event.altKey ||
|
|
95
|
+
(binding.requireShift === undefined && event.shiftKey);
|
|
96
|
+
if (noModifiersRequired && hasModifiers) continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (binding.preventDefault) {
|
|
100
|
+
event.preventDefault();
|
|
101
|
+
}
|
|
102
|
+
if (binding.stopPropagation) {
|
|
103
|
+
event.stopPropagation();
|
|
104
|
+
event.stopImmediatePropagation();
|
|
105
|
+
}
|
|
106
|
+
binding.handler(event);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
[bindings],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!enabled) return;
|
|
115
|
+
|
|
116
|
+
window.addEventListener("keydown", handleKeyDown, capture);
|
|
117
|
+
return () => window.removeEventListener("keydown", handleKeyDown, capture);
|
|
118
|
+
}, [enabled, handleKeyDown, capture]);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Simple hook for list navigation with j/k keys.
|
|
123
|
+
*/
|
|
124
|
+
export const useListNavigation = <T extends { id: string }>({
|
|
125
|
+
items,
|
|
126
|
+
selectedId,
|
|
127
|
+
onSelect,
|
|
128
|
+
enabled = true,
|
|
129
|
+
}: {
|
|
130
|
+
items: T[];
|
|
131
|
+
selectedId: string | undefined;
|
|
132
|
+
onSelect: (id: string) => void;
|
|
133
|
+
enabled?: boolean;
|
|
134
|
+
}) => {
|
|
135
|
+
const currentIndex = selectedId
|
|
136
|
+
? items.findIndex((item) => item.id === selectedId)
|
|
137
|
+
: -1;
|
|
138
|
+
|
|
139
|
+
const selectNext = useCallback(() => {
|
|
140
|
+
if (items.length === 0) return;
|
|
141
|
+
const nextIndex =
|
|
142
|
+
currentIndex < items.length - 1 ? currentIndex + 1 : currentIndex;
|
|
143
|
+
if (nextIndex >= 0 && nextIndex < items.length) {
|
|
144
|
+
onSelect(items[nextIndex].id);
|
|
145
|
+
}
|
|
146
|
+
}, [items, currentIndex, onSelect]);
|
|
147
|
+
|
|
148
|
+
const selectPrevious = useCallback(() => {
|
|
149
|
+
if (items.length === 0) return;
|
|
150
|
+
const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0;
|
|
151
|
+
if (prevIndex >= 0 && prevIndex < items.length) {
|
|
152
|
+
onSelect(items[prevIndex].id);
|
|
153
|
+
}
|
|
154
|
+
}, [items, currentIndex, onSelect]);
|
|
155
|
+
|
|
156
|
+
const selectFirst = useCallback(() => {
|
|
157
|
+
if (items.length > 0) {
|
|
158
|
+
onSelect(items[0].id);
|
|
159
|
+
}
|
|
160
|
+
}, [items, onSelect]);
|
|
161
|
+
|
|
162
|
+
const selectLast = useCallback(() => {
|
|
163
|
+
if (items.length > 0) {
|
|
164
|
+
onSelect(items[items.length - 1].id);
|
|
165
|
+
}
|
|
166
|
+
}, [items, onSelect]);
|
|
167
|
+
|
|
168
|
+
useKeyboardNavigation({
|
|
169
|
+
enabled,
|
|
170
|
+
bindings: [
|
|
171
|
+
{ key: "j", handler: selectNext, preventDefault: true },
|
|
172
|
+
{ key: "ArrowDown", handler: selectNext, preventDefault: true },
|
|
173
|
+
{ key: "k", handler: selectPrevious, preventDefault: true },
|
|
174
|
+
{ key: "ArrowUp", handler: selectPrevious, preventDefault: true },
|
|
175
|
+
{ key: "g", handler: selectFirst, preventDefault: true },
|
|
176
|
+
{
|
|
177
|
+
key: "G",
|
|
178
|
+
handler: selectLast,
|
|
179
|
+
noModifiers: false,
|
|
180
|
+
preventDefault: true,
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
currentIndex,
|
|
187
|
+
selectNext,
|
|
188
|
+
selectPrevious,
|
|
189
|
+
selectFirst,
|
|
190
|
+
selectLast,
|
|
191
|
+
};
|
|
192
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { isSinglePaneTier, resolveLayoutTier } from "./useLayoutTier";
|
|
4
|
+
|
|
5
|
+
describe("resolveLayoutTier breakpoints (#784)", () => {
|
|
6
|
+
it("is phone below 768", () => {
|
|
7
|
+
assert.equal(resolveLayoutTier(767), "phone");
|
|
8
|
+
assert.equal(resolveLayoutTier(390), "phone");
|
|
9
|
+
assert.equal(resolveLayoutTier(0), "phone");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("is tablet from 768 up to 1023", () => {
|
|
13
|
+
assert.equal(resolveLayoutTier(768), "tablet");
|
|
14
|
+
assert.equal(resolveLayoutTier(900), "tablet");
|
|
15
|
+
assert.equal(resolveLayoutTier(1023), "tablet");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("is desktop from 1024 up", () => {
|
|
19
|
+
assert.equal(resolveLayoutTier(1024), "desktop");
|
|
20
|
+
assert.equal(resolveLayoutTier(1440), "desktop");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("isSinglePaneTier — compose surface must mount below desktop", () => {
|
|
25
|
+
// Regression: AppShellSlotted mounts the reading pane only at desktop, and
|
|
26
|
+
// the compose surface lives in the single pane below it. Tablet (768–1023)
|
|
27
|
+
// must be single-pane so "c" / the FAB can open compose — keying the pane
|
|
28
|
+
// choice off "phone" alone left tablet with no compose surface.
|
|
29
|
+
it("is single-pane at phone", () => {
|
|
30
|
+
assert.equal(isSinglePaneTier("phone"), true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("is single-pane at tablet (the regression tier)", () => {
|
|
34
|
+
assert.equal(isSinglePaneTier("tablet"), true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("is NOT single-pane at desktop (reading pane hosts compose)", () => {
|
|
38
|
+
assert.equal(isSinglePaneTier("desktop"), false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("treats every below-desktop width as single-pane via resolveLayoutTier", () => {
|
|
42
|
+
assert.equal(isSinglePaneTier(resolveLayoutTier(390)), true);
|
|
43
|
+
assert.equal(isSinglePaneTier(resolveLayoutTier(768)), true);
|
|
44
|
+
assert.equal(isSinglePaneTier(resolveLayoutTier(1023)), true);
|
|
45
|
+
assert.equal(isSinglePaneTier(resolveLayoutTier(1024)), false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useMediaQuery } from "./useMediaQuery";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Responsive layout tiers for the mail shell:
|
|
5
|
+
* - `phone` (< 768px) single pane: list → reading swaps in place.
|
|
6
|
+
* - `tablet` (768–1023px) two panes: list + reading, nav rail is drawer-backed.
|
|
7
|
+
* - `desktop` (>= 1024px) full four-pane shell (nav · list · reading · pane 4).
|
|
8
|
+
*
|
|
9
|
+
* Pane 4 (intelligence) is desktop-only; `useIsDesktop` stays the pane-4 gate.
|
|
10
|
+
*/
|
|
11
|
+
export type LayoutTier = "phone" | "tablet" | "desktop";
|
|
12
|
+
|
|
13
|
+
export const TABLET_MIN_WIDTH = 768;
|
|
14
|
+
export const DESKTOP_MIN_WIDTH = 1024;
|
|
15
|
+
|
|
16
|
+
/** Pure breakpoint resolution, so the tier logic is testable without a DOM. */
|
|
17
|
+
export const resolveLayoutTier = (width: number): LayoutTier => {
|
|
18
|
+
if (width >= DESKTOP_MIN_WIDTH) return "desktop";
|
|
19
|
+
if (width >= TABLET_MIN_WIDTH) return "tablet";
|
|
20
|
+
return "phone";
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether the mail shell shows a SINGLE pane (no reading pane) at this tier.
|
|
25
|
+
* AppShellSlotted mounts the reading pane only at desktop (≥1024px), so both
|
|
26
|
+
* phone AND tablet are single-pane: the one pane swaps in place between the
|
|
27
|
+
* list, an open thread, and the compose surface. Compose therefore must mount
|
|
28
|
+
* via the single-pane view at tablet too — keying the pane choice off "phone"
|
|
29
|
+
* alone left tablet with no compose surface ("c" / FAB opened nothing).
|
|
30
|
+
*/
|
|
31
|
+
export const isSinglePaneTier = (tier: LayoutTier): boolean =>
|
|
32
|
+
tier !== "desktop";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Returns the current layout tier. SSR-safe (falls back to `phone` before
|
|
36
|
+
* hydration, matching `useMediaQuery`'s server default).
|
|
37
|
+
*/
|
|
38
|
+
export const useLayoutTier = (): LayoutTier => {
|
|
39
|
+
const isTabletUp = useMediaQuery(`(min-width: ${TABLET_MIN_WIDTH}px)`);
|
|
40
|
+
const isDesktop = useMediaQuery(`(min-width: ${DESKTOP_MIN_WIDTH}px)`);
|
|
41
|
+
if (isDesktop) return "desktop";
|
|
42
|
+
if (isTabletUp) return "tablet";
|
|
43
|
+
return "phone";
|
|
44
|
+
};
|