@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,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get the user's preferred locale from browser settings.
|
|
3
|
+
* Falls back to 'en-US' if unavailable.
|
|
4
|
+
*/
|
|
5
|
+
const getLocale = (): string => {
|
|
6
|
+
if (typeof navigator !== "undefined") {
|
|
7
|
+
return navigator.language || navigator.languages?.[0] || "en-US";
|
|
8
|
+
}
|
|
9
|
+
return "en-US";
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Format a number according to user's locale.
|
|
14
|
+
*/
|
|
15
|
+
export const formatNumber = (
|
|
16
|
+
value: number,
|
|
17
|
+
options?: Intl.NumberFormatOptions,
|
|
18
|
+
): string => {
|
|
19
|
+
return new Intl.NumberFormat(getLocale(), options).format(value);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Format a number as compact (1.2K, 3.4M, etc.)
|
|
24
|
+
*/
|
|
25
|
+
export const formatCompactNumber = (value: number): string => {
|
|
26
|
+
return formatNumber(value, { notation: "compact", compactDisplay: "short" });
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const EPOCH_STRING = /^-?\d+$/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Coerce any accepted date input into a Date. An all-digit string is treated as
|
|
33
|
+
* an epoch (the Postgres read path serializes int64 columns as numeric strings);
|
|
34
|
+
* any other string goes through the normal Date parser.
|
|
35
|
+
*/
|
|
36
|
+
export const toDate = (value: Date | string | number): Date => {
|
|
37
|
+
if (value instanceof Date) return value;
|
|
38
|
+
if (typeof value === "number") return new Date(value);
|
|
39
|
+
if (typeof value === "string") {
|
|
40
|
+
const trimmed = value.trim();
|
|
41
|
+
if (EPOCH_STRING.test(trimmed)) return new Date(Number(trimmed));
|
|
42
|
+
return new Date(trimmed);
|
|
43
|
+
}
|
|
44
|
+
return new Date(Number.NaN);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const isValidDate = (date: Date): boolean => Number.isFinite(date.getTime());
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Format a date according to user's locale. A missing or unparseable date
|
|
51
|
+
* renders as an empty string instead of throwing — display must never crash the
|
|
52
|
+
* view over one bad row.
|
|
53
|
+
*/
|
|
54
|
+
export const formatDate = (
|
|
55
|
+
date: Date | string | number,
|
|
56
|
+
options?: Intl.DateTimeFormatOptions,
|
|
57
|
+
): string => {
|
|
58
|
+
const d = toDate(date);
|
|
59
|
+
if (!isValidDate(d)) return "";
|
|
60
|
+
return new Intl.DateTimeFormat(getLocale(), options).format(d);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Format date with common presets.
|
|
65
|
+
*/
|
|
66
|
+
export const formatDatePreset = (
|
|
67
|
+
date: Date | string | number,
|
|
68
|
+
preset: "short" | "medium" | "long" | "full" | "time" | "datetime",
|
|
69
|
+
): string => {
|
|
70
|
+
const presets: Record<typeof preset, Intl.DateTimeFormatOptions> = {
|
|
71
|
+
short: { month: "short", day: "numeric" },
|
|
72
|
+
medium: { month: "short", day: "numeric", year: "numeric" },
|
|
73
|
+
long: { month: "long", day: "numeric", year: "numeric" },
|
|
74
|
+
full: { weekday: "long", month: "long", day: "numeric", year: "numeric" },
|
|
75
|
+
time: { hour: "numeric", minute: "numeric" },
|
|
76
|
+
datetime: {
|
|
77
|
+
month: "short",
|
|
78
|
+
day: "numeric",
|
|
79
|
+
hour: "numeric",
|
|
80
|
+
minute: "numeric",
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
return formatDate(date, presets[preset]);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Format relative time (e.g., "2 hours ago", "in 3 days").
|
|
88
|
+
*/
|
|
89
|
+
export const formatRelativeTime = (date: Date | string | number): string => {
|
|
90
|
+
const d = toDate(date);
|
|
91
|
+
if (!isValidDate(d)) return "";
|
|
92
|
+
const rtf = new Intl.RelativeTimeFormat(getLocale(), { numeric: "auto" });
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
const diff = d.getTime() - now;
|
|
95
|
+
const diffSeconds = Math.round(diff / 1000);
|
|
96
|
+
const diffMinutes = Math.round(diff / (1000 * 60));
|
|
97
|
+
const diffHours = Math.round(diff / (1000 * 60 * 60));
|
|
98
|
+
const diffDays = Math.round(diff / (1000 * 60 * 60 * 24));
|
|
99
|
+
const diffWeeks = Math.round(diff / (1000 * 60 * 60 * 24 * 7));
|
|
100
|
+
|
|
101
|
+
if (Math.abs(diffSeconds) < 60) {
|
|
102
|
+
return rtf.format(diffSeconds, "second");
|
|
103
|
+
}
|
|
104
|
+
if (Math.abs(diffMinutes) < 60) {
|
|
105
|
+
return rtf.format(diffMinutes, "minute");
|
|
106
|
+
}
|
|
107
|
+
if (Math.abs(diffHours) < 24) {
|
|
108
|
+
return rtf.format(diffHours, "hour");
|
|
109
|
+
}
|
|
110
|
+
if (Math.abs(diffDays) < 7) {
|
|
111
|
+
return rtf.format(diffDays, "day");
|
|
112
|
+
}
|
|
113
|
+
if (Math.abs(diffWeeks) < 4) {
|
|
114
|
+
return rtf.format(diffWeeks, "week");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return formatDatePreset(d, "medium");
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Format email date intelligently based on age.
|
|
122
|
+
* - Today: "10:42" (time only)
|
|
123
|
+
* - Yesterday: "Yesterday"
|
|
124
|
+
* - This week: "Tuesday" (day name)
|
|
125
|
+
* - Older: "Jan 17" (or "Jan 17, 2023" if different year)
|
|
126
|
+
*/
|
|
127
|
+
export const formatEmailDate = (date: Date | string | number): string => {
|
|
128
|
+
const d = toDate(date);
|
|
129
|
+
if (!isValidDate(d)) return "";
|
|
130
|
+
const now = new Date();
|
|
131
|
+
|
|
132
|
+
const isToday = d.toDateString() === now.toDateString();
|
|
133
|
+
|
|
134
|
+
const yesterday = new Date(now);
|
|
135
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
136
|
+
const isYesterday = d.toDateString() === yesterday.toDateString();
|
|
137
|
+
|
|
138
|
+
const sixDaysAgo = new Date(now);
|
|
139
|
+
sixDaysAgo.setDate(sixDaysAgo.getDate() - 6);
|
|
140
|
+
const isThisWeek = d >= sixDaysAgo && d < now;
|
|
141
|
+
|
|
142
|
+
const isThisYear = d.getFullYear() === now.getFullYear();
|
|
143
|
+
|
|
144
|
+
if (isToday) {
|
|
145
|
+
return formatDate(d, { hour: "numeric", minute: "numeric" });
|
|
146
|
+
}
|
|
147
|
+
if (isYesterday) {
|
|
148
|
+
return "Yesterday";
|
|
149
|
+
}
|
|
150
|
+
if (isThisWeek) {
|
|
151
|
+
return formatDate(d, { weekday: "long" });
|
|
152
|
+
}
|
|
153
|
+
if (isThisYear) {
|
|
154
|
+
return formatDate(d, { month: "short", day: "numeric" });
|
|
155
|
+
}
|
|
156
|
+
return formatDate(d, { month: "short", day: "numeric", year: "numeric" });
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Format file size in human-readable format.
|
|
161
|
+
*/
|
|
162
|
+
export const formatFileSize = (bytes: number): string => {
|
|
163
|
+
const units = ["byte", "kilobyte", "megabyte", "gigabyte"] as const;
|
|
164
|
+
let unitIndex = 0;
|
|
165
|
+
let size = bytes;
|
|
166
|
+
|
|
167
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
168
|
+
size /= 1024;
|
|
169
|
+
unitIndex++;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return formatNumber(size, {
|
|
173
|
+
style: "unit",
|
|
174
|
+
unit: units[unitIndex],
|
|
175
|
+
unitDisplay: "short",
|
|
176
|
+
maximumFractionDigits: 1,
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Format a list of items (e.g., "Alice, Bob, and Carol").
|
|
182
|
+
*/
|
|
183
|
+
export const formatList = (
|
|
184
|
+
items: string[],
|
|
185
|
+
type: "conjunction" | "disjunction" = "conjunction",
|
|
186
|
+
): string => {
|
|
187
|
+
return new Intl.ListFormat(getLocale(), {
|
|
188
|
+
style: "long",
|
|
189
|
+
type,
|
|
190
|
+
}).format(items);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Confirmation title for the move-to-Trash delete flow. Reflects that delete
|
|
195
|
+
* moves messages to Trash (not a permanent delete) and pluralizes on count.
|
|
196
|
+
*/
|
|
197
|
+
export const formatDeleteToTrashTitle = (count: number): string =>
|
|
198
|
+
count === 1 ? "Move 1 message to Trash?" : `Move ${count} messages to Trash?`;
|
package/src/lib/i18n.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import i18n from "i18next";
|
|
2
|
+
import LanguageDetector from "i18next-browser-languagedetector";
|
|
3
|
+
import Backend from "i18next-http-backend";
|
|
4
|
+
import { initReactI18next } from "react-i18next";
|
|
5
|
+
|
|
6
|
+
i18n
|
|
7
|
+
.use(Backend)
|
|
8
|
+
.use(LanguageDetector)
|
|
9
|
+
.use(initReactI18next)
|
|
10
|
+
.init({
|
|
11
|
+
fallbackLng: "en",
|
|
12
|
+
supportedLngs: ["en", "nl", "de", "fr"],
|
|
13
|
+
|
|
14
|
+
ns: ["common", "mail", "settings", "errors"],
|
|
15
|
+
defaultNS: "common",
|
|
16
|
+
|
|
17
|
+
backend: {
|
|
18
|
+
loadPath: "/locales/{{lng}}/{{ns}}.json",
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
detection: {
|
|
22
|
+
order: ["navigator", "htmlTag"],
|
|
23
|
+
caches: [],
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
interpolation: {
|
|
27
|
+
escapeValue: false,
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
react: {
|
|
31
|
+
useSuspense: true,
|
|
32
|
+
bindI18n: "languageChanged loaded",
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export default i18n;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
readIntelligencePref,
|
|
5
|
+
writeIntelligencePref,
|
|
6
|
+
} from "./intelligence-pref.js";
|
|
7
|
+
|
|
8
|
+
const installMemoryStorage = (): void => {
|
|
9
|
+
const store = new Map<string, string>();
|
|
10
|
+
(globalThis as { localStorage?: Storage }).localStorage = {
|
|
11
|
+
getItem: (key: string) => store.get(key) ?? null,
|
|
12
|
+
setItem: (key: string, value: string) => {
|
|
13
|
+
store.set(key, value);
|
|
14
|
+
},
|
|
15
|
+
removeItem: (key: string) => {
|
|
16
|
+
store.delete(key);
|
|
17
|
+
},
|
|
18
|
+
clear: () => {
|
|
19
|
+
store.clear();
|
|
20
|
+
},
|
|
21
|
+
key: (index: number) => Array.from(store.keys())[index] ?? null,
|
|
22
|
+
get length() {
|
|
23
|
+
return store.size;
|
|
24
|
+
},
|
|
25
|
+
} as Storage;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("intelligence-pref (#782)", () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
installMemoryStorage();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
(globalThis as { localStorage?: Storage }).localStorage = undefined;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("defaults to open when nothing is stored", () => {
|
|
38
|
+
assert.equal(readIntelligencePref(), true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("honours a stored collapse", () => {
|
|
42
|
+
writeIntelligencePref(false);
|
|
43
|
+
assert.equal(readIntelligencePref(), false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("round-trips an explicit open", () => {
|
|
47
|
+
writeIntelligencePref(false);
|
|
48
|
+
writeIntelligencePref(true);
|
|
49
|
+
assert.equal(readIntelligencePref(), true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("falls back to open when storage is unavailable", () => {
|
|
53
|
+
(globalThis as { localStorage?: Storage }).localStorage = undefined;
|
|
54
|
+
assert.equal(readIntelligencePref(), true);
|
|
55
|
+
assert.doesNotThrow(() => writeIntelligencePref(false));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence for the intelligence-pane open/closed preference (#782).
|
|
3
|
+
*
|
|
4
|
+
* The pane opens with the thread by default; a manual collapse sticks across
|
|
5
|
+
* sessions. Storage failures (private mode / quota) fall back to the default
|
|
6
|
+
* (open) rather than crashing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const INTELLIGENCE_PREF_KEY = "remit:intelligence-open";
|
|
10
|
+
|
|
11
|
+
export function readIntelligencePref(): boolean {
|
|
12
|
+
try {
|
|
13
|
+
return localStorage.getItem(INTELLIGENCE_PREF_KEY) !== "closed";
|
|
14
|
+
} catch {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function writeIntelligencePref(open: boolean): void {
|
|
20
|
+
try {
|
|
21
|
+
localStorage.setItem(INTELLIGENCE_PREF_KEY, open ? "open" : "closed");
|
|
22
|
+
} catch {
|
|
23
|
+
// Storage unavailable — the in-memory default stands.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
dispatchKey,
|
|
5
|
+
type KeyStroke,
|
|
6
|
+
type SequencePrefix,
|
|
7
|
+
} from "./keymap-dispatch.ts";
|
|
8
|
+
|
|
9
|
+
const stroke = (partial: Partial<KeyStroke> & { key: string }): KeyStroke => ({
|
|
10
|
+
shiftKey: false,
|
|
11
|
+
metaKey: false,
|
|
12
|
+
ctrlKey: false,
|
|
13
|
+
altKey: false,
|
|
14
|
+
inEditable: false,
|
|
15
|
+
...partial,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const run = (
|
|
19
|
+
s: Partial<KeyStroke> & { key: string },
|
|
20
|
+
prefix: SequencePrefix = null,
|
|
21
|
+
) => dispatchKey(stroke(s), prefix);
|
|
22
|
+
|
|
23
|
+
describe("dispatchKey — plain bindings", () => {
|
|
24
|
+
const cases: Array<[string, string]> = [
|
|
25
|
+
["j", "focusNext"],
|
|
26
|
+
["k", "focusPrevious"],
|
|
27
|
+
["Enter", "openFocused"],
|
|
28
|
+
["u", "toggleRead"],
|
|
29
|
+
["x", "toggleSelect"],
|
|
30
|
+
["r", "reply"],
|
|
31
|
+
["a", "replyAll"],
|
|
32
|
+
["f", "forward"],
|
|
33
|
+
["#", "delete"],
|
|
34
|
+
["s", "toggleStar"],
|
|
35
|
+
["m", "muteSender"],
|
|
36
|
+
["b", "blockSender"],
|
|
37
|
+
["v", "vipSender"],
|
|
38
|
+
["!", "markJunk"],
|
|
39
|
+
["i", "toggleIntelligence"],
|
|
40
|
+
["d", "toggleDensity"],
|
|
41
|
+
["/", "focusSearch"],
|
|
42
|
+
["c", "compose"],
|
|
43
|
+
["?", "help"],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
for (const [key, action] of cases) {
|
|
47
|
+
test(`'${key}' → ${action}`, () => {
|
|
48
|
+
const result = run({ key });
|
|
49
|
+
assert.strictEqual(result.action, action);
|
|
50
|
+
assert.strictEqual(result.nextPrefix, null);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test("uppercase letters match case-insensitively", () => {
|
|
55
|
+
assert.strictEqual(run({ key: "R", shiftKey: true }).action, null);
|
|
56
|
+
assert.strictEqual(run({ key: "R" }).action, "reply");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("unknown key maps to nothing", () => {
|
|
60
|
+
assert.strictEqual(run({ key: "q" }).action, null);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("dispatchKey — input suppression", () => {
|
|
65
|
+
test("plain keys are inert in an editable surface", () => {
|
|
66
|
+
assert.strictEqual(run({ key: "j", inEditable: true }).action, null);
|
|
67
|
+
assert.strictEqual(run({ key: "e", inEditable: true }).action, null);
|
|
68
|
+
assert.strictEqual(run({ key: "c", inEditable: true }).action, null);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("Esc is inert in an editable surface (field owns its own Esc)", () => {
|
|
72
|
+
// The layer must NOT emit `back` for Esc-while-typing: that would
|
|
73
|
+
// double-fire with SearchBar's own Esc (clear query AND close thread).
|
|
74
|
+
const result = run({ key: "Escape", inEditable: true });
|
|
75
|
+
assert.strictEqual(result.action, null);
|
|
76
|
+
assert.strictEqual(result.preventDefault, false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("Esc in an input clears a pending g prefix", () => {
|
|
80
|
+
const result = run({ key: "Escape", inEditable: true }, "g");
|
|
81
|
+
assert.strictEqual(result.action, null);
|
|
82
|
+
assert.strictEqual(result.nextPrefix, null);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("any key in an editable surface clears a pending g prefix", () => {
|
|
86
|
+
// You can't be mid-`g`-sequence while typing in a field; entering an
|
|
87
|
+
// editable surface drops any stale prefix so it can't leak back out.
|
|
88
|
+
const result = run({ key: "x", inEditable: true }, "g");
|
|
89
|
+
assert.strictEqual(result.action, null);
|
|
90
|
+
assert.strictEqual(result.nextPrefix, null);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("dispatchKey — modifiers", () => {
|
|
95
|
+
test("⌘N / Ctrl+N → compose", () => {
|
|
96
|
+
assert.strictEqual(run({ key: "n", metaKey: true }).action, "compose");
|
|
97
|
+
assert.strictEqual(run({ key: "n", ctrlKey: true }).action, "compose");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("other meta combos are left to the browser", () => {
|
|
101
|
+
assert.strictEqual(run({ key: "a", metaKey: true }).action, null);
|
|
102
|
+
assert.strictEqual(run({ key: "c", metaKey: true }).action, null);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("Shift+J / Shift+K extend the selection", () => {
|
|
106
|
+
assert.strictEqual(
|
|
107
|
+
run({ key: "j", shiftKey: true }).action,
|
|
108
|
+
"extendSelectDown",
|
|
109
|
+
);
|
|
110
|
+
assert.strictEqual(
|
|
111
|
+
run({ key: "k", shiftKey: true }).action,
|
|
112
|
+
"extendSelectUp",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("Shift on a letter verb suppresses it (no Shift+R reply)", () => {
|
|
117
|
+
assert.strictEqual(run({ key: "r", shiftKey: true }).action, null);
|
|
118
|
+
assert.strictEqual(run({ key: "e", shiftKey: true }).action, null);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("Shift on a shifted-punctuation binding still fires (#, !, ?)", () => {
|
|
122
|
+
assert.strictEqual(run({ key: "#", shiftKey: true }).action, "delete");
|
|
123
|
+
assert.strictEqual(run({ key: "!", shiftKey: true }).action, "markJunk");
|
|
124
|
+
assert.strictEqual(run({ key: "?", shiftKey: true }).action, "help");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("dispatchKey — g … sequences", () => {
|
|
129
|
+
test("g arms the prefix without an action", () => {
|
|
130
|
+
const result = run({ key: "g" });
|
|
131
|
+
assert.strictEqual(result.action, null);
|
|
132
|
+
assert.strictEqual(result.nextPrefix, "g");
|
|
133
|
+
assert.strictEqual(result.preventDefault, true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const seq: Array<[string, string]> = [
|
|
137
|
+
["b", "goBrief"],
|
|
138
|
+
["i", "goInbox"],
|
|
139
|
+
["s", "goSent"],
|
|
140
|
+
["f", "goFlagged"],
|
|
141
|
+
[",", "goSettings"],
|
|
142
|
+
];
|
|
143
|
+
for (const [key, action] of seq) {
|
|
144
|
+
test(`g then '${key}' → ${action}`, () => {
|
|
145
|
+
const result = run({ key }, "g");
|
|
146
|
+
assert.strictEqual(result.action, action);
|
|
147
|
+
assert.strictEqual(result.nextPrefix, null);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
test("g then an unmapped key cancels the prefix and is inert", () => {
|
|
152
|
+
const result = run({ key: "q" }, "g");
|
|
153
|
+
assert.strictEqual(result.action, null);
|
|
154
|
+
assert.strictEqual(result.nextPrefix, null);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("Shift+G does not arm the prefix", () => {
|
|
158
|
+
assert.strictEqual(run({ key: "g", shiftKey: true }).nextPrefix, null);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("after a sequence resolves, the next key is a plain binding again", () => {
|
|
162
|
+
const first = run({ key: "g" });
|
|
163
|
+
assert.strictEqual(first.nextPrefix, "g");
|
|
164
|
+
const second = run({ key: "j" }, first.nextPrefix);
|
|
165
|
+
// 'j' is not a go-to key → cancels, inert.
|
|
166
|
+
assert.strictEqual(second.action, null);
|
|
167
|
+
const third = run({ key: "j" }, second.nextPrefix);
|
|
168
|
+
assert.strictEqual(third.action, "focusNext");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("dispatchKey — escape", () => {
|
|
173
|
+
test("Esc maps to back and clears any prefix", () => {
|
|
174
|
+
const result = run({ key: "Escape" }, "g");
|
|
175
|
+
assert.strictEqual(result.action, "back");
|
|
176
|
+
assert.strictEqual(result.nextPrefix, null);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { TriageAction } from "./keymap.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure dispatch core for the global triage keyboard layer (#429).
|
|
5
|
+
*
|
|
6
|
+
* The React hook (`useTriageKeyboard`) owns DOM wiring and the handler table;
|
|
7
|
+
* this module owns the *decision* — given a normalized keystroke and the
|
|
8
|
+
* current sequence-prefix state, which {@link TriageAction} (if any) fires, and
|
|
9
|
+
* what the next sequence-prefix state is. Keeping it pure makes the routing,
|
|
10
|
+
* input suppression and `g`-prefix sequencing unit-testable without JSDOM.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The subset of a KeyboardEvent the dispatcher reads. */
|
|
14
|
+
export interface KeyStroke {
|
|
15
|
+
key: string;
|
|
16
|
+
shiftKey: boolean;
|
|
17
|
+
metaKey: boolean;
|
|
18
|
+
ctrlKey: boolean;
|
|
19
|
+
altKey: boolean;
|
|
20
|
+
/** Whether the event originated inside an editable surface. */
|
|
21
|
+
inEditable: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Pending sequence-prefix state. `"g"` means a `g` was pressed recently. */
|
|
25
|
+
export type SequencePrefix = null | "g";
|
|
26
|
+
|
|
27
|
+
export interface DispatchResult {
|
|
28
|
+
/** The action to run, or null when the stroke maps to nothing actionable. */
|
|
29
|
+
action: TriageAction | null;
|
|
30
|
+
/** The sequence-prefix state to carry into the next stroke. */
|
|
31
|
+
nextPrefix: SequencePrefix;
|
|
32
|
+
/** Whether the host should preventDefault on this stroke. */
|
|
33
|
+
preventDefault: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const NONE: DispatchResult = {
|
|
37
|
+
action: null,
|
|
38
|
+
nextPrefix: null,
|
|
39
|
+
preventDefault: false,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Second key of a `g …` go-to sequence → action. Keys are lowercased; `,`
|
|
44
|
+
* stays as-is.
|
|
45
|
+
*/
|
|
46
|
+
const GO_TO_SEQUENCE: Record<string, TriageAction> = {
|
|
47
|
+
b: "goBrief",
|
|
48
|
+
i: "goInbox",
|
|
49
|
+
s: "goSent",
|
|
50
|
+
f: "goFlagged",
|
|
51
|
+
",": "goSettings",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Plain single-key bindings (no meta, no `g` prefix). `shift` here means the
|
|
56
|
+
* binding *requires* shift; absence means shift must be absent.
|
|
57
|
+
*/
|
|
58
|
+
interface PlainBinding {
|
|
59
|
+
action: TriageAction;
|
|
60
|
+
requireShift?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Lowercased key → binding. `event.key` is lowercased before lookup, so the
|
|
65
|
+
* shifted variants (`#`, `!`, `?`) are matched by their produced character, and
|
|
66
|
+
* letter keys ignore caps. Shift-j/k extend selection and are handled before
|
|
67
|
+
* this table.
|
|
68
|
+
*/
|
|
69
|
+
const PLAIN_BINDINGS: Record<string, PlainBinding> = {
|
|
70
|
+
j: { action: "focusNext" },
|
|
71
|
+
k: { action: "focusPrevious" },
|
|
72
|
+
enter: { action: "openFocused" },
|
|
73
|
+
u: { action: "toggleRead" },
|
|
74
|
+
x: { action: "toggleSelect" },
|
|
75
|
+
r: { action: "reply" },
|
|
76
|
+
a: { action: "replyAll" },
|
|
77
|
+
f: { action: "forward" },
|
|
78
|
+
"#": { action: "delete" },
|
|
79
|
+
s: { action: "toggleStar" },
|
|
80
|
+
m: { action: "muteSender" },
|
|
81
|
+
b: { action: "blockSender" },
|
|
82
|
+
v: { action: "vipSender" },
|
|
83
|
+
"!": { action: "markJunk" },
|
|
84
|
+
i: { action: "toggleIntelligence" },
|
|
85
|
+
d: { action: "toggleDensity" },
|
|
86
|
+
"/": { action: "focusSearch" },
|
|
87
|
+
c: { action: "compose" },
|
|
88
|
+
"?": { action: "help" },
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve a keystroke into an action and the next sequence-prefix state. Pure:
|
|
93
|
+
* no DOM, no side effects.
|
|
94
|
+
*
|
|
95
|
+
* Rules:
|
|
96
|
+
* - In an editable surface only `Esc` fires (clears any pending prefix).
|
|
97
|
+
* - `⌘N` / `Ctrl+N` → compose (the only meta combo we own).
|
|
98
|
+
* - A pending `g` prefix consumes the next key as a go-to sequence; an
|
|
99
|
+
* unmatched second key cancels the prefix and is otherwise inert.
|
|
100
|
+
* - `g` (no modifiers) arms the prefix.
|
|
101
|
+
* - `Shift+J` / `Shift+K` extend the selection.
|
|
102
|
+
* - Otherwise a plain single-key binding fires.
|
|
103
|
+
* - Any non-Esc keystroke clears a stale prefix.
|
|
104
|
+
*/
|
|
105
|
+
export function dispatchKey(
|
|
106
|
+
stroke: KeyStroke,
|
|
107
|
+
prefix: SequencePrefix,
|
|
108
|
+
): DispatchResult {
|
|
109
|
+
const lower = stroke.key.toLowerCase();
|
|
110
|
+
const meta = stroke.metaKey || stroke.ctrlKey;
|
|
111
|
+
|
|
112
|
+
// Editable surfaces: the layer is fully inert. Even Esc is left to the
|
|
113
|
+
// focused field's own handler (SearchBar clears the query / blurs on Esc);
|
|
114
|
+
// emitting `back` here would double-fire — clearing search AND closing the
|
|
115
|
+
// open thread on one keypress. We still clear any pending `g` prefix so a
|
|
116
|
+
// stray sequence can't leak across a focus change into the field.
|
|
117
|
+
if (stroke.inEditable) {
|
|
118
|
+
return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ⌘N / Ctrl+N → compose. Checked before the prefix/plain tables so the
|
|
122
|
+
// browser's "new window" is the only thing we intercept among meta combos.
|
|
123
|
+
if (meta && lower === "n") {
|
|
124
|
+
return { action: "compose", nextPrefix: null, preventDefault: true };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Any other meta/ctrl combo is left to the browser/OS.
|
|
128
|
+
if (meta) return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
|
|
129
|
+
|
|
130
|
+
// Esc: back/close. Always clears the prefix.
|
|
131
|
+
if (lower === "escape") {
|
|
132
|
+
return { action: "back", nextPrefix: null, preventDefault: false };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Resolve a pending `g …` sequence.
|
|
136
|
+
if (prefix === "g") {
|
|
137
|
+
const seqKey = lower === "," ? "," : lower;
|
|
138
|
+
const action = GO_TO_SEQUENCE[seqKey] ?? null;
|
|
139
|
+
// Consume the second key whether or not it matched; the prefix resets.
|
|
140
|
+
return { action, nextPrefix: null, preventDefault: action !== null };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Arm the `g` prefix (no shift, no modifiers).
|
|
144
|
+
if (lower === "g" && !stroke.shiftKey) {
|
|
145
|
+
return { action: null, nextPrefix: "g", preventDefault: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Shift+J / Shift+K extend the selection.
|
|
149
|
+
if (stroke.shiftKey && (lower === "j" || lower === "k")) {
|
|
150
|
+
return {
|
|
151
|
+
action: lower === "j" ? "extendSelectDown" : "extendSelectUp",
|
|
152
|
+
nextPrefix: null,
|
|
153
|
+
preventDefault: true,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Plain single-key bindings.
|
|
158
|
+
const binding = PLAIN_BINDINGS[lower];
|
|
159
|
+
if (binding) {
|
|
160
|
+
const needsShift = binding.requireShift === true;
|
|
161
|
+
// `?`, `#`, `!` are produced with Shift on most layouts; their entries
|
|
162
|
+
// are keyed by the produced character so we don't gate on shift here.
|
|
163
|
+
// Plain letter bindings must NOT fire when shift is held (e.g. Shift+R).
|
|
164
|
+
const isPunctuation = lower.length === 1 && !/[a-z0-9]/.test(lower);
|
|
165
|
+
if (!needsShift && stroke.shiftKey && !isPunctuation) {
|
|
166
|
+
return NONE;
|
|
167
|
+
}
|
|
168
|
+
return { action: binding.action, nextPrefix: null, preventDefault: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return NONE;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Whether a DOM event target is an editable surface (input/textarea/select/
|
|
176
|
+
* contenteditable). Exposed so the hook and tests share one definition.
|
|
177
|
+
*/
|
|
178
|
+
export function isEditableTarget(target: EventTarget | null): boolean {
|
|
179
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
180
|
+
const tag = target.tagName;
|
|
181
|
+
return (
|
|
182
|
+
tag === "INPUT" ||
|
|
183
|
+
tag === "TEXTAREA" ||
|
|
184
|
+
tag === "SELECT" ||
|
|
185
|
+
target.isContentEditable
|
|
186
|
+
);
|
|
187
|
+
}
|