@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.
Files changed (298) hide show
  1. package/harness/build.mjs +84 -0
  2. package/harness/index.html +37 -0
  3. package/harness/vite-preset.ts +29 -0
  4. package/index.html +47 -0
  5. package/package.json +147 -0
  6. package/public/config.js +5 -0
  7. package/public/locales/de/common.json +1 -0
  8. package/public/locales/de/errors.json +1 -0
  9. package/public/locales/de/mail.json +13 -0
  10. package/public/locales/de/settings.json +1 -0
  11. package/public/locales/en/common.json +26 -0
  12. package/public/locales/en/errors.json +1 -0
  13. package/public/locales/en/mail.json +69 -0
  14. package/public/locales/en/settings.json +1 -0
  15. package/public/locales/fr/common.json +1 -0
  16. package/public/locales/fr/errors.json +1 -0
  17. package/public/locales/fr/mail.json +13 -0
  18. package/public/locales/fr/settings.json +1 -0
  19. package/public/locales/nl/common.json +1 -0
  20. package/public/locales/nl/errors.json +1 -0
  21. package/public/locales/nl/mail.json +13 -0
  22. package/public/locales/nl/settings.json +1 -0
  23. package/src/auth/AccountMenu.tsx +58 -0
  24. package/src/auth/AccountSession.tsx +19 -0
  25. package/src/auth/BetterAuthShell.tsx +198 -0
  26. package/src/auth/account-menu-mode.test.ts +64 -0
  27. package/src/auth/account-menu-mode.ts +28 -0
  28. package/src/auth/amplify-config.test.ts +158 -0
  29. package/src/auth/amplify-config.ts +60 -0
  30. package/src/auth/auth-error.test.ts +109 -0
  31. package/src/auth/auth-error.ts +107 -0
  32. package/src/auth/auth-interceptor.test.ts +86 -0
  33. package/src/auth/auth-interceptor.ts +23 -0
  34. package/src/auth/auth.css +334 -0
  35. package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
  36. package/src/auth/better-auth-config.ts +85 -0
  37. package/src/auth/better-auth-provider.tsx +20 -0
  38. package/src/auth/cognito/CognitoAccount.tsx +48 -0
  39. package/src/auth/cognito/CognitoShell.tsx +266 -0
  40. package/src/auth/cognito/cognito-token.test.ts +110 -0
  41. package/src/auth/cognito/cognito-token.ts +47 -0
  42. package/src/auth/cognito/cognito.css +21 -0
  43. package/src/auth/cognito-provider.tsx +22 -0
  44. package/src/auth/combined-provider.tsx +42 -0
  45. package/src/auth/composition.test.ts +136 -0
  46. package/src/auth/provider.tsx +63 -0
  47. package/src/auth/sign-out-visibility.test.ts +40 -0
  48. package/src/auth/sign-out-visibility.ts +10 -0
  49. package/src/auth/sign-up-disabled.test.ts +23 -0
  50. package/src/components/compose/AddressField.tsx +237 -0
  51. package/src/components/compose/ComposeBody.tsx +36 -0
  52. package/src/components/compose/ComposeForm.tsx +676 -0
  53. package/src/components/compose/ComposeProvider.tsx +133 -0
  54. package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
  55. package/src/components/compose/FromSelector.tsx +67 -0
  56. package/src/components/compose/FullCompose.tsx +82 -0
  57. package/src/components/compose/InlineCompose.tsx +29 -0
  58. package/src/components/compose/MobileComposeSheet.tsx +152 -0
  59. package/src/components/compose/PlateEditor.tsx +86 -0
  60. package/src/components/compose/PlateToolbar.tsx +110 -0
  61. package/src/components/compose/SubjectField.tsx +19 -0
  62. package/src/components/compose/draft-discard-banner.test.ts +76 -0
  63. package/src/components/compose/index.ts +6 -0
  64. package/src/components/compose/sanitize-quote-html.ts +23 -0
  65. package/src/components/layout/AppShellSkeleton.tsx +53 -0
  66. package/src/components/layout/ComposeFab.tsx +86 -0
  67. package/src/components/layout/Drawer.tsx +105 -0
  68. package/src/components/mail/AutoMovedIndicator.tsx +48 -0
  69. package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
  70. package/src/components/mail/BlockedImagesNotice.tsx +41 -0
  71. package/src/components/mail/BriefPane.tsx +289 -0
  72. package/src/components/mail/ConversationView.tsx +462 -0
  73. package/src/components/mail/DailyBrief.tsx +451 -0
  74. package/src/components/mail/DraftsView.tsx +279 -0
  75. package/src/components/mail/FlaggedList.tsx +170 -0
  76. package/src/components/mail/FlaggedPane.tsx +217 -0
  77. package/src/components/mail/IntelligencePane.test.ts +123 -0
  78. package/src/components/mail/IntelligencePane.tsx +441 -0
  79. package/src/components/mail/MailListHeader.tsx +187 -0
  80. package/src/components/mail/MailNav.tsx +70 -0
  81. package/src/components/mail/MailSidebarAdapter.tsx +351 -0
  82. package/src/components/mail/MailViewChrome.tsx +95 -0
  83. package/src/components/mail/MailboxPane.tsx +1219 -0
  84. package/src/components/mail/MessageActionMenu.tsx +368 -0
  85. package/src/components/mail/MessageBody.tsx +207 -0
  86. package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
  87. package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
  88. package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
  89. package/src/components/mail/MessageCard.tsx +401 -0
  90. package/src/components/mail/MessageDetail.tsx +153 -0
  91. package/src/components/mail/MessageList.tsx +804 -0
  92. package/src/components/mail/MessageListItem.tsx +267 -0
  93. package/src/components/mail/MessageToolbar.render.test.ts +57 -0
  94. package/src/components/mail/MessageToolbar.tsx +166 -0
  95. package/src/components/mail/MobileMessageBar.tsx +142 -0
  96. package/src/components/mail/MoveToTrigger.tsx +250 -0
  97. package/src/components/mail/NavMenuButton.tsx +25 -0
  98. package/src/components/mail/OutboxPane.tsx +531 -0
  99. package/src/components/mail/PullToRefresh.tsx +24 -0
  100. package/src/components/mail/RawMessageView.tsx +84 -0
  101. package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
  102. package/src/components/mail/SelectionToolbar.tsx +139 -0
  103. package/src/components/mail/SpamRescue.render.test.ts +35 -0
  104. package/src/components/mail/SpamRescue.tsx +138 -0
  105. package/src/components/mail/SwipeableMessageRow.tsx +148 -0
  106. package/src/components/mail/message-body-error-banner-content.ts +87 -0
  107. package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
  108. package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
  109. package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
  110. package/src/components/mail/organize/OrganizePanel.tsx +344 -0
  111. package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
  112. package/src/components/settings/AccountFormPanel.test.ts +72 -0
  113. package/src/components/settings/AccountFormPanel.tsx +942 -0
  114. package/src/components/settings/DangerZone.tsx +30 -0
  115. package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
  116. package/src/components/settings/DeleteAccountDialog.tsx +224 -0
  117. package/src/components/settings/FiltersList.render.test.ts +71 -0
  118. package/src/components/settings/FiltersList.tsx +93 -0
  119. package/src/components/settings/account-form-helpers.test.ts +172 -0
  120. package/src/components/settings/account-form-helpers.ts +105 -0
  121. package/src/components/ui/AppVersion.tsx +50 -0
  122. package/src/components/ui/BugReportButton.tsx +45 -0
  123. package/src/components/ui/ConfirmDialog.tsx +125 -0
  124. package/src/components/ui/DropdownMenu.tsx +105 -0
  125. package/src/components/ui/EmptyState.tsx +11 -0
  126. package/src/components/ui/ErrorBanner.tsx +88 -0
  127. package/src/components/ui/ErrorBannerProvider.tsx +77 -0
  128. package/src/components/ui/ErrorBannerStack.tsx +34 -0
  129. package/src/components/ui/ErrorState.tsx +80 -0
  130. package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
  131. package/src/components/ui/FatalErrorOverlay.tsx +151 -0
  132. package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
  133. package/src/components/ui/SlidePanel.tsx +72 -0
  134. package/src/components/ui/error-banners.test.ts +207 -0
  135. package/src/components/ui/error-banners.ts +111 -0
  136. package/src/hooks/queries/keys.ts +38 -0
  137. package/src/hooks/useArchiveMailbox.ts +104 -0
  138. package/src/hooks/useAutoMovedBadge.ts +77 -0
  139. package/src/hooks/useCurrentMailboxName.ts +99 -0
  140. package/src/hooks/useDebouncedValue.ts +12 -0
  141. package/src/hooks/useDeleteMessages.test.ts +103 -0
  142. package/src/hooks/useDeleteMessages.ts +226 -0
  143. package/src/hooks/useFilters.ts +112 -0
  144. package/src/hooks/useIntelligenceData.test.ts +269 -0
  145. package/src/hooks/useIntelligenceData.ts +284 -0
  146. package/src/hooks/useIsDark.ts +27 -0
  147. package/src/hooks/useKeyboardNavigation.ts +192 -0
  148. package/src/hooks/useLayoutTier.test.ts +47 -0
  149. package/src/hooks/useLayoutTier.ts +44 -0
  150. package/src/hooks/useLongPress.test.ts +291 -0
  151. package/src/hooks/useLongPress.ts +87 -0
  152. package/src/hooks/useMailboxAccount.ts +62 -0
  153. package/src/hooks/useMailboxNameIndex.ts +32 -0
  154. package/src/hooks/useMarkAsRead.test.ts +165 -0
  155. package/src/hooks/useMarkAsRead.ts +319 -0
  156. package/src/hooks/useMediaQuery.ts +39 -0
  157. package/src/hooks/useMessageBodyContent.test.ts +370 -0
  158. package/src/hooks/useMessageBodyContent.ts +380 -0
  159. package/src/hooks/useMoveMessages.test.ts +63 -0
  160. package/src/hooks/useMoveMessages.ts +221 -0
  161. package/src/hooks/useOrganizeJob.ts +90 -0
  162. package/src/hooks/useOrganizePreview.ts +40 -0
  163. package/src/hooks/useRescueCandidates.test.ts +217 -0
  164. package/src/hooks/useRescueCandidates.ts +48 -0
  165. package/src/hooks/useSaveDraft.ts +118 -0
  166. package/src/hooks/useSelection.test.ts +75 -0
  167. package/src/hooks/useSelection.ts +211 -0
  168. package/src/hooks/useSemanticSearch.ts +94 -0
  169. package/src/hooks/useSignature.ts +69 -0
  170. package/src/hooks/useStaleAccountSync.test.ts +211 -0
  171. package/src/hooks/useStaleAccountSync.ts +246 -0
  172. package/src/hooks/useSwipeNavigation.test.ts +35 -0
  173. package/src/hooks/useSwipeNavigation.ts +99 -0
  174. package/src/hooks/useToggleStar.ts +187 -0
  175. package/src/hooks/useToggleTrusted.test.ts +102 -0
  176. package/src/hooks/useToggleTrusted.ts +161 -0
  177. package/src/hooks/useTriageKeyboard.ts +103 -0
  178. package/src/hooks/useTriggerSync.test.ts +24 -0
  179. package/src/hooks/useTriggerSync.ts +58 -0
  180. package/src/hooks/useUpdateAddressFlags.ts +125 -0
  181. package/src/hooks/useVisualViewport.test.ts +127 -0
  182. package/src/hooks/useVisualViewport.ts +51 -0
  183. package/src/index.css +19 -0
  184. package/src/lib/account-order.test.ts +84 -0
  185. package/src/lib/account-order.ts +13 -0
  186. package/src/lib/adjacent-message.test.ts +43 -0
  187. package/src/lib/adjacent-message.ts +20 -0
  188. package/src/lib/api.ts +82 -0
  189. package/src/lib/app-info.ts +15 -0
  190. package/src/lib/auto-moved.test.ts +140 -0
  191. package/src/lib/auto-moved.ts +68 -0
  192. package/src/lib/autodiscovery.test.ts +94 -0
  193. package/src/lib/autodiscovery.ts +354 -0
  194. package/src/lib/brief.test.ts +366 -0
  195. package/src/lib/brief.ts +180 -0
  196. package/src/lib/bug-report.test.ts +213 -0
  197. package/src/lib/bug-report.ts +176 -0
  198. package/src/lib/client.ts +36 -0
  199. package/src/lib/console-errors.test.ts +64 -0
  200. package/src/lib/console-errors.ts +92 -0
  201. package/src/lib/conversation-target.test.ts +68 -0
  202. package/src/lib/conversation-target.ts +59 -0
  203. package/src/lib/display-category.ts +20 -0
  204. package/src/lib/drafts.test.ts +241 -0
  205. package/src/lib/drafts.ts +121 -0
  206. package/src/lib/error-classifier.test.ts +133 -0
  207. package/src/lib/error-classifier.ts +96 -0
  208. package/src/lib/fatal-error.test.ts +97 -0
  209. package/src/lib/fatal-error.ts +165 -0
  210. package/src/lib/folder-roles.test.ts +113 -0
  211. package/src/lib/folder-roles.ts +112 -0
  212. package/src/lib/format.test.ts +115 -0
  213. package/src/lib/format.ts +198 -0
  214. package/src/lib/i18n.ts +36 -0
  215. package/src/lib/intelligence-pref.test.ts +57 -0
  216. package/src/lib/intelligence-pref.ts +25 -0
  217. package/src/lib/keymap-dispatch.test.ts +178 -0
  218. package/src/lib/keymap-dispatch.ts +187 -0
  219. package/src/lib/keymap.test.ts +49 -0
  220. package/src/lib/keymap.ts +178 -0
  221. package/src/lib/mail-context.ts +64 -0
  222. package/src/lib/mail-route.test.ts +80 -0
  223. package/src/lib/mail-route.ts +44 -0
  224. package/src/lib/message-body-source.test.ts +98 -0
  225. package/src/lib/message-body-source.ts +48 -0
  226. package/src/lib/move-targets.test.ts +195 -0
  227. package/src/lib/move-targets.ts +74 -0
  228. package/src/lib/onboarding-completion.test.ts +64 -0
  229. package/src/lib/onboarding-completion.ts +23 -0
  230. package/src/lib/organize/filter-status.test.ts +99 -0
  231. package/src/lib/organize/filter-status.ts +86 -0
  232. package/src/lib/organize/organize-copy.test.ts +93 -0
  233. package/src/lib/organize/organize-copy.ts +61 -0
  234. package/src/lib/organize/organize-model.test.ts +104 -0
  235. package/src/lib/organize/organize-model.ts +105 -0
  236. package/src/lib/organize/organize-poll.test.ts +37 -0
  237. package/src/lib/organize/organize-poll.ts +28 -0
  238. package/src/lib/outbox-status.test.ts +64 -0
  239. package/src/lib/outbox-status.ts +75 -0
  240. package/src/lib/plate-serializer.ts +164 -0
  241. package/src/lib/provider-presets.test.ts +96 -0
  242. package/src/lib/provider-presets.ts +81 -0
  243. package/src/lib/query-error-handler.test.ts +144 -0
  244. package/src/lib/query-error-handler.ts +34 -0
  245. package/src/lib/query-escalation.integration.test.ts +138 -0
  246. package/src/lib/recent-searches.test.ts +53 -0
  247. package/src/lib/recent-searches.ts +34 -0
  248. package/src/lib/rescue-candidates.test.ts +94 -0
  249. package/src/lib/rescue-candidates.ts +35 -0
  250. package/src/lib/rescue-telemetry.test.ts +75 -0
  251. package/src/lib/rescue-telemetry.ts +55 -0
  252. package/src/lib/rum-adapter.test.ts +299 -0
  253. package/src/lib/rum-adapter.ts +107 -0
  254. package/src/lib/saved-searches.test.ts +80 -0
  255. package/src/lib/saved-searches.ts +52 -0
  256. package/src/lib/search-pending.test.ts +61 -0
  257. package/src/lib/search-pending.ts +24 -0
  258. package/src/lib/search-query.test.ts +35 -0
  259. package/src/lib/search-query.ts +11 -0
  260. package/src/lib/search-result.test.ts +160 -0
  261. package/src/lib/search-result.ts +102 -0
  262. package/src/lib/search-schemas.ts +34 -0
  263. package/src/lib/search-token-index.test.ts +112 -0
  264. package/src/lib/search-token-index.ts +56 -0
  265. package/src/lib/search-tokens.test.ts +218 -0
  266. package/src/lib/search-tokens.ts +133 -0
  267. package/src/lib/telemetry-context.tsx +9 -0
  268. package/src/lib/telemetry.ts +17 -0
  269. package/src/lib/theme-preference.ts +46 -0
  270. package/src/lib/theme.ts +33 -0
  271. package/src/lib/utils.ts +4 -0
  272. package/src/main.tsx +10 -0
  273. package/src/routeTree.gen.ts +392 -0
  274. package/src/router.test.ts +81 -0
  275. package/src/router.tsx +31 -0
  276. package/src/routes/__root.tsx +69 -0
  277. package/src/routes/index.tsx +7 -0
  278. package/src/routes/mail/$mailboxId.tsx +27 -0
  279. package/src/routes/mail/flagged.tsx +37 -0
  280. package/src/routes/mail/index.tsx +41 -0
  281. package/src/routes/mail/outbox.tsx +13 -0
  282. package/src/routes/mail.tsx +413 -0
  283. package/src/routes/onboarding.tsx +35 -0
  284. package/src/routes/settings/-accounts.test.ts +65 -0
  285. package/src/routes/settings/accounts.tsx +542 -0
  286. package/src/routes/settings/advanced.tsx +55 -0
  287. package/src/routes/settings/appearance.tsx +140 -0
  288. package/src/routes/settings/filters.tsx +141 -0
  289. package/src/routes/settings/folders.tsx +216 -0
  290. package/src/routes/settings/index.tsx +5 -0
  291. package/src/routes/settings/senders.tsx +466 -0
  292. package/src/routes/settings/suggested-vips.tsx +10 -0
  293. package/src/routes/settings.tsx +66 -0
  294. package/src/runtime-config.ts +75 -0
  295. package/src/shell/index.tsx +98 -0
  296. package/src/types/index.ts +73 -0
  297. package/src/vite-env.d.ts +4 -0
  298. package/vite.base.ts +43 -0
@@ -0,0 +1,49 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import { KEY_HINT_GROUPS, keysForAction, tooltipForAction } from "./keymap.ts";
4
+
5
+ describe("keymap module", () => {
6
+ test("exposes the documented groups in reading order", () => {
7
+ const titles = KEY_HINT_GROUPS.map((g) => g.title);
8
+ assert.deepStrictEqual(titles, [
9
+ "Navigation",
10
+ "Selection",
11
+ "Actions",
12
+ "Sender",
13
+ "Go to",
14
+ "View & global",
15
+ ]);
16
+ });
17
+
18
+ test("keysForAction returns the first hint's tokens", () => {
19
+ assert.deepStrictEqual(keysForAction("reply"), ["r"]);
20
+ assert.deepStrictEqual(keysForAction("goBrief"), ["g", "b"]);
21
+ });
22
+
23
+ test("keysForAction is undefined for an action with no hint", () => {
24
+ // `back` has a hint; a contrived missing lookup returns undefined.
25
+ assert.strictEqual(
26
+ keysForAction("totallyMissing" as Parameters<typeof keysForAction>[0]),
27
+ undefined,
28
+ );
29
+ });
30
+
31
+ test("tooltipForAction renders single keys, sequences and combos", () => {
32
+ assert.strictEqual(tooltipForAction("reply"), "(r)");
33
+ assert.strictEqual(tooltipForAction("goBrief"), "(g then b)");
34
+ // compose's first hint is the single 'c' key.
35
+ assert.strictEqual(tooltipForAction("compose"), "(c)");
36
+ });
37
+
38
+ test("every hint's action is a non-empty key list", () => {
39
+ for (const group of KEY_HINT_GROUPS) {
40
+ for (const hint of group.hints) {
41
+ assert.ok(hint.keys.length > 0, `${hint.action} has keys`);
42
+ assert.ok(
43
+ hint.description.length > 0,
44
+ `${hint.action} has a description`,
45
+ );
46
+ }
47
+ }
48
+ });
49
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Single source of truth for the global keyboard triage layer (#429).
3
+ *
4
+ * Every shortcut the app responds to is declared here once. The help overlay
5
+ * (`KeyboardShortcutsModal`), the list footer hints and the toolbar tooltips
6
+ * all read from this module so the displayed bindings can never drift from the
7
+ * keys the dispatcher actually routes. The spec lives in
8
+ * `doc/design/flows/04-triage.md`.
9
+ */
10
+
11
+ /** A logical action a key (or key sequence) maps to. */
12
+ export type TriageAction =
13
+ // list navigation / focus model
14
+ | "focusNext"
15
+ | "focusPrevious"
16
+ | "openFocused"
17
+ | "back"
18
+ // selection
19
+ | "toggleSelect"
20
+ | "extendSelectDown"
21
+ | "extendSelectUp"
22
+ // message verbs (focused row / selection)
23
+ | "reply"
24
+ | "replyAll"
25
+ | "forward"
26
+ | "delete"
27
+ | "toggleStar"
28
+ | "toggleRead"
29
+ // sender verbs
30
+ | "muteSender"
31
+ | "blockSender"
32
+ | "vipSender"
33
+ | "markJunk"
34
+ // view
35
+ | "toggleIntelligence"
36
+ | "toggleDensity"
37
+ // global
38
+ | "focusSearch"
39
+ | "compose"
40
+ | "help"
41
+ // go-to sequences (g then …)
42
+ | "goBrief"
43
+ | "goInbox"
44
+ | "goSent"
45
+ | "goFlagged"
46
+ | "goSettings";
47
+
48
+ /**
49
+ * One displayed binding row in the help overlay. `keys` is the human-readable
50
+ * sequence (e.g. ["g", "b"] or ["⌘", "N"]); `display` overrides the rendered
51
+ * label when a single token reads better (e.g. "Esc", "↵").
52
+ */
53
+ export interface KeyHint {
54
+ action: TriageAction;
55
+ /** Tokens rendered as individual <Kbd> chips, in order. */
56
+ keys: string[];
57
+ description: string;
58
+ }
59
+
60
+ export interface KeyHintGroup {
61
+ title: string;
62
+ hints: KeyHint[];
63
+ }
64
+
65
+ /**
66
+ * The full key map, grouped by area, exactly as it appears in the `?` overlay.
67
+ * Order is intentional (matches 04-triage.md reading order).
68
+ */
69
+ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
70
+ {
71
+ title: "Navigation",
72
+ hints: [
73
+ { action: "focusNext", keys: ["j"], description: "Focus next message" },
74
+ {
75
+ action: "focusPrevious",
76
+ keys: ["k"],
77
+ description: "Focus previous message",
78
+ },
79
+ {
80
+ action: "openFocused",
81
+ keys: ["Enter"],
82
+ description: "Open focused thread",
83
+ },
84
+ { action: "back", keys: ["Esc"], description: "Back / close overlay" },
85
+ ],
86
+ },
87
+ {
88
+ title: "Selection",
89
+ hints: [
90
+ { action: "toggleSelect", keys: ["x"], description: "Toggle select" },
91
+ {
92
+ action: "extendSelectDown",
93
+ keys: ["Shift", "j"],
94
+ description: "Extend selection down",
95
+ },
96
+ {
97
+ action: "extendSelectUp",
98
+ keys: ["Shift", "k"],
99
+ description: "Extend selection up",
100
+ },
101
+ ],
102
+ },
103
+ {
104
+ title: "Actions",
105
+ hints: [
106
+ { action: "reply", keys: ["r"], description: "Reply" },
107
+ { action: "replyAll", keys: ["a"], description: "Reply all" },
108
+ { action: "forward", keys: ["f"], description: "Forward" },
109
+ { action: "delete", keys: ["#"], description: "Delete" },
110
+ { action: "toggleStar", keys: ["s"], description: "Star / unstar" },
111
+ {
112
+ action: "toggleRead",
113
+ keys: ["u"],
114
+ description: "Toggle read / unread",
115
+ },
116
+ ],
117
+ },
118
+ {
119
+ title: "Sender",
120
+ hints: [
121
+ { action: "muteSender", keys: ["m"], description: "Mute sender" },
122
+ { action: "blockSender", keys: ["b"], description: "Block sender" },
123
+ { action: "vipSender", keys: ["v"], description: "VIP sender" },
124
+ { action: "markJunk", keys: ["!"], description: "Mark junk" },
125
+ ],
126
+ },
127
+ {
128
+ title: "Go to",
129
+ hints: [
130
+ { action: "goBrief", keys: ["g", "b"], description: "Daily brief" },
131
+ { action: "goInbox", keys: ["g", "i"], description: "Inbox" },
132
+ { action: "goSent", keys: ["g", "s"], description: "Sent" },
133
+ { action: "goFlagged", keys: ["g", "f"], description: "Flagged" },
134
+ { action: "goSettings", keys: ["g", ","], description: "Settings" },
135
+ ],
136
+ },
137
+ {
138
+ title: "View & global",
139
+ hints: [
140
+ {
141
+ action: "toggleIntelligence",
142
+ keys: ["i"],
143
+ description: "Toggle intelligence",
144
+ },
145
+ { action: "toggleDensity", keys: ["d"], description: "Toggle density" },
146
+ { action: "focusSearch", keys: ["/"], description: "Focus search" },
147
+ { action: "compose", keys: ["c"], description: "Compose" },
148
+ { action: "compose", keys: ["⌘", "N"], description: "Compose" },
149
+ { action: "help", keys: ["?"], description: "Shortcut help" },
150
+ ],
151
+ },
152
+ ];
153
+
154
+ /**
155
+ * Look up the displayed key tokens for an action (first matching hint). Used by
156
+ * toolbar tooltips so a single declaration drives both the overlay and the
157
+ * button titles. Returns `undefined` for actions with no hint.
158
+ */
159
+ export function keysForAction(action: TriageAction): string[] | undefined {
160
+ for (const group of KEY_HINT_GROUPS) {
161
+ const hint = group.hints.find((h) => h.action === action);
162
+ if (hint) return hint.keys;
163
+ }
164
+ return undefined;
165
+ }
166
+
167
+ /**
168
+ * Render an action's binding as a compact tooltip suffix, e.g. "(r)" or
169
+ * "(g then b)". Returns "" when the action has no hint.
170
+ */
171
+ export function tooltipForAction(action: TriageAction): string {
172
+ const keys = keysForAction(action);
173
+ if (!keys || keys.length === 0) return "";
174
+ if (keys.length === 1) return `(${keys[0]})`;
175
+ // Sequence (go-to) keys read as "g then b"; modifier combos as "⌘N".
176
+ if (keys[0] === "g") return `(${keys.join(" then ")})`;
177
+ return `(${keys.join("")})`;
178
+ }
@@ -0,0 +1,64 @@
1
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import { createContext, useContext } from "react";
3
+
4
+ /**
5
+ * Shared mail-layout context. Lives in `lib/` — NOT in `routes/mail.tsx` — on
6
+ * purpose: the generated route tree imports the mail route via a relative
7
+ * specifier (`./routes/mail`) while feature code imports via the `@/` alias.
8
+ * Vite can resolve those to two distinct module instances, which would give the
9
+ * `MailLayout` provider and a child-route consumer two different `MailContext`
10
+ * objects — the consumer then silently falls back to the defaults below (this
11
+ * bit the intelligence toggle: clicks reached a no-op handler). Keeping the
12
+ * context in a module that is only ever imported through the `@/` alias
13
+ * guarantees a single instance shared by provider and consumers.
14
+ */
15
+ export interface MailContextValue {
16
+ accounts: RemitImapAccountResponse[];
17
+ /**
18
+ * Name indexes `parseSearchTokens` resolves `in:`/`account:` chips against
19
+ * (#428 follow-up). Computed once here so every consumer — chip rendering,
20
+ * the brief/flagged token filters, `useSemanticSearch` — agrees on the same
21
+ * resolution. See `lib/search-token-index.ts`.
22
+ */
23
+ mailboxNameIndex: ReadonlyMap<string, string>;
24
+ accountNameIndex: ReadonlyMap<string, string>;
25
+ searchQuery: string;
26
+ /** Live (pre-debounce) search input — the toolbar's search field binds this. */
27
+ searchInput: string;
28
+ onSearchChange: (query: string) => void;
29
+ /** Full clear (X button): drops the query and any selected thread (#538). */
30
+ onSearchClear: () => void;
31
+ /** Query-only clear (Esc): drops the query, keeps the thread open (#489). */
32
+ onSearchClearQuery: () => void;
33
+ /** Pane 4 (intelligence) visibility. The shared toggle starts closed; the
34
+ * desktop route opens it by default with the thread, honouring the stored
35
+ * preference (#782). */
36
+ intelligenceOpen: boolean;
37
+ onToggleIntelligence: () => void;
38
+ /** Set the pane open/closed and persist the choice (desktop default-open). */
39
+ onSetIntelligenceOpen: (open: boolean) => void;
40
+ }
41
+
42
+ export const MailContext = createContext<MailContextValue | null>(null);
43
+
44
+ export const useMailContext = (): MailContextValue => {
45
+ const context = useContext(MailContext);
46
+ // Return default values if context not yet available (e.g., during loading)
47
+ // This can happen when TanStack Router renders child routes before parent
48
+ // finishes.
49
+ return (
50
+ context ?? {
51
+ accounts: [],
52
+ mailboxNameIndex: new Map(),
53
+ accountNameIndex: new Map(),
54
+ searchQuery: "",
55
+ searchInput: "",
56
+ onSearchChange: () => {},
57
+ onSearchClear: () => {},
58
+ onSearchClearQuery: () => {},
59
+ intelligenceOpen: false,
60
+ onToggleIntelligence: () => {},
61
+ onSetIntelligenceOpen: () => {},
62
+ }
63
+ );
64
+ };
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Regression: the /mail shell must mount the MAILBOX pane (MessageList →
3
+ * SwipeableMessageRow → `a[href*=selectedMessageId]`) on /mail/$mailboxId,
4
+ * NOT the brief pane. The first slotted-shell cut keyed brief detection off
5
+ * the parent /mail layout's matched pathname ("/mail"), which is present on
6
+ * EVERY child route — so every mailbox rendered the unified DailyBrief and
7
+ * the message-row anchors disappeared (e2e sync-flow + smoke both failed on
8
+ * `a[href*='selectedMessageId']`). These tests pin detection to each leaf
9
+ * route's own routeId so that can't regress.
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import {
14
+ isBriefRoute,
15
+ isFlaggedRoute,
16
+ isMailboxRoute,
17
+ isOutboxRoute,
18
+ type MailRouteMatch,
19
+ } from "./mail-route.js";
20
+
21
+ /** Matches as TanStack Router reports them: parent /mail layout + the leaf. */
22
+ const briefMatches: MailRouteMatch[] = [
23
+ { routeId: "__root__" },
24
+ { routeId: "/mail" },
25
+ { routeId: "/mail/" },
26
+ ];
27
+ const mailboxMatches: MailRouteMatch[] = [
28
+ { routeId: "__root__" },
29
+ { routeId: "/mail" },
30
+ { routeId: "/mail/$mailboxId" },
31
+ ];
32
+ const outboxMatches: MailRouteMatch[] = [
33
+ { routeId: "__root__" },
34
+ { routeId: "/mail" },
35
+ { routeId: "/mail/outbox" },
36
+ ];
37
+ const flaggedMatches: MailRouteMatch[] = [
38
+ { routeId: "__root__" },
39
+ { routeId: "/mail" },
40
+ { routeId: "/mail/flagged" },
41
+ ];
42
+
43
+ describe("mail route pane detection", () => {
44
+ it("classifies the brief index route as brief only", () => {
45
+ assert.equal(isBriefRoute(briefMatches), true);
46
+ assert.equal(isMailboxRoute(briefMatches), false);
47
+ assert.equal(isOutboxRoute(briefMatches), false);
48
+ });
49
+
50
+ it("classifies a mailbox route as mailbox, NOT brief (the regression)", () => {
51
+ // The bug: the parent /mail layout match made isBriefRoute true here,
52
+ // routing the mailbox through the brief pane and dropping message rows.
53
+ assert.equal(isBriefRoute(mailboxMatches), false);
54
+ assert.equal(isMailboxRoute(mailboxMatches), true);
55
+ assert.equal(isOutboxRoute(mailboxMatches), false);
56
+ });
57
+
58
+ it("classifies the outbox route as outbox only", () => {
59
+ assert.equal(isBriefRoute(outboxMatches), false);
60
+ assert.equal(isMailboxRoute(outboxMatches), false);
61
+ assert.equal(isOutboxRoute(outboxMatches), true);
62
+ });
63
+
64
+ it("classifies the flagged route as flagged only", () => {
65
+ assert.equal(isFlaggedRoute(flaggedMatches), true);
66
+ assert.equal(isBriefRoute(flaggedMatches), false);
67
+ assert.equal(isMailboxRoute(flaggedMatches), false);
68
+ assert.equal(isOutboxRoute(flaggedMatches), false);
69
+ });
70
+
71
+ it("never treats the parent /mail layout match as the brief route", () => {
72
+ // Every child route carries the parent /mail layout match. Brief
73
+ // detection must ignore it entirely.
74
+ const parentOnly: MailRouteMatch[] = [
75
+ { routeId: "__root__" },
76
+ { routeId: "/mail" },
77
+ ];
78
+ assert.equal(isBriefRoute(parentOnly), false);
79
+ });
80
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Route detection for the /mail shell.
3
+ *
4
+ * `mail.tsx` mounts the right pane (brief, mailbox, or outbox) into
5
+ * `AppShellSlotted` by inspecting the router's matched routes. The parent
6
+ * `/mail` layout route is matched on EVERY child route, and its matched
7
+ * `pathname` is "/mail" everywhere — so detection MUST key off each leaf
8
+ * route's own `routeId`, never the parent pathname. Keying off "/mail"
9
+ * pathname routed every mailbox through the brief pane, which renders the
10
+ * unified DailyBrief instead of the mailbox MessageList — so the message
11
+ * rows (the `a[href*=selectedMessageId]` anchors) vanished. These pure
12
+ * predicates pin that contract.
13
+ */
14
+
15
+ /** A matched route, minimal shape needed for pane detection. */
16
+ export interface MailRouteMatch {
17
+ routeId: string;
18
+ }
19
+
20
+ /** The leaf route ids the /mail shell branches on. */
21
+ export const MAIL_BRIEF_ROUTE_ID = "/mail/" as const;
22
+ export const MAIL_MAILBOX_ROUTE_ID = "/mail/$mailboxId" as const;
23
+ export const MAIL_OUTBOX_ROUTE_ID = "/mail/outbox" as const;
24
+ export const MAIL_FLAGGED_ROUTE_ID = "/mail/flagged" as const;
25
+
26
+ /** True only on the brief index route (/mail/), never on a mailbox/outbox. */
27
+ export function isBriefRoute(matches: readonly MailRouteMatch[]): boolean {
28
+ return matches.some((m) => m.routeId === MAIL_BRIEF_ROUTE_ID);
29
+ }
30
+
31
+ /** True only on the flagged virtual-mailbox route (/mail/flagged). */
32
+ export function isFlaggedRoute(matches: readonly MailRouteMatch[]): boolean {
33
+ return matches.some((m) => m.routeId === MAIL_FLAGGED_ROUTE_ID);
34
+ }
35
+
36
+ /** True only on the outbox route. */
37
+ export function isOutboxRoute(matches: readonly MailRouteMatch[]): boolean {
38
+ return matches.some((m) => m.routeId === MAIL_OUTBOX_ROUTE_ID);
39
+ }
40
+
41
+ /** True only on a mailbox route (/mail/$mailboxId). */
42
+ export function isMailboxRoute(matches: readonly MailRouteMatch[]): boolean {
43
+ return matches.some((m) => m.routeId === MAIL_MAILBOX_ROUTE_ID);
44
+ }
@@ -0,0 +1,98 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ pickRenderablePart,
5
+ type RenderableBodyPart,
6
+ } from "./message-body-source";
7
+
8
+ const make = (overrides: Partial<RenderableBodyPart>): RenderableBodyPart => ({
9
+ mediaType: "TEXT",
10
+ mediaSubtype: "PLAIN",
11
+ contentUrl: "https://cdn.test/content/x",
12
+ isMultipart: false,
13
+ ...overrides,
14
+ });
15
+
16
+ describe("pickRenderablePart", () => {
17
+ it("prefers the first text/html part over text/plain", () => {
18
+ const html = make({
19
+ mediaSubtype: "HTML",
20
+ contentUrl: "https://cdn.test/html",
21
+ });
22
+ const text = make({
23
+ mediaSubtype: "PLAIN",
24
+ contentUrl: "https://cdn.test/text",
25
+ });
26
+ const picked = pickRenderablePart([text, html]);
27
+ assert.deepEqual(picked, {
28
+ kind: "html",
29
+ contentUrl: "https://cdn.test/html",
30
+ });
31
+ });
32
+
33
+ it("falls back to text/plain when no html part exists", () => {
34
+ const text = make({
35
+ mediaSubtype: "PLAIN",
36
+ contentUrl: "https://cdn.test/text",
37
+ });
38
+ const picked = pickRenderablePart([text]);
39
+ assert.deepEqual(picked, {
40
+ kind: "text",
41
+ contentUrl: "https://cdn.test/text",
42
+ });
43
+ });
44
+
45
+ it("ignores multipart container rows", () => {
46
+ const container = make({
47
+ mediaType: "MULTIPART",
48
+ mediaSubtype: "ALTERNATIVE",
49
+ isMultipart: true,
50
+ });
51
+ const text = make({ mediaSubtype: "PLAIN" });
52
+ const picked = pickRenderablePart([container, text]);
53
+ assert.equal(picked?.kind, "text");
54
+ });
55
+
56
+ it("ignores attachment-disposition parts so a PDF doesn't shadow the body", () => {
57
+ const attachment = make({
58
+ mediaSubtype: "HTML",
59
+ disposition: "attachment",
60
+ contentUrl: "https://cdn.test/attachment.html",
61
+ });
62
+ const text = make({
63
+ mediaSubtype: "PLAIN",
64
+ contentUrl: "https://cdn.test/text",
65
+ });
66
+ const picked = pickRenderablePart([attachment, text]);
67
+ assert.deepEqual(picked, {
68
+ kind: "text",
69
+ contentUrl: "https://cdn.test/text",
70
+ });
71
+ });
72
+
73
+ it("returns null when nothing renderable is present", () => {
74
+ const image = make({ mediaType: "IMAGE", mediaSubtype: "PNG" });
75
+ assert.equal(pickRenderablePart([image]), null);
76
+ });
77
+
78
+ it("returns null on an empty list", () => {
79
+ assert.equal(pickRenderablePart([]), null);
80
+ });
81
+
82
+ it("treats casing variants of the subtype as equivalent (mailers are inconsistent)", () => {
83
+ const html = make({
84
+ mediaSubtype: "html",
85
+ contentUrl: "https://cdn.test/lower",
86
+ });
87
+ const picked = pickRenderablePart([html]);
88
+ assert.deepEqual(picked, {
89
+ kind: "html",
90
+ contentUrl: "https://cdn.test/lower",
91
+ });
92
+ });
93
+
94
+ it("skips parts with an empty contentUrl so the fetch never fires against the API root", () => {
95
+ const broken = make({ mediaSubtype: "HTML", contentUrl: "" });
96
+ assert.equal(pickRenderablePart([broken]), null);
97
+ });
98
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Pick the renderable body part from a `describeMessage` BodyPartResponse list
3
+ * and fetch its content via CloudFront. The legacy inlined `bodyText` /
4
+ * `bodyHtml` fields were removed in #224 PR 3 — body content lives at the
5
+ * per-part `contentUrl`, JWT-authorized at the edge.
6
+ *
7
+ * Pure helpers (no React) so they can be unit-tested against the generated
8
+ * type without standing up jsdom or DOMPurify.
9
+ */
10
+
11
+ export type BodyContentKind = "html" | "text";
12
+
13
+ export interface RenderableBodyPart {
14
+ mediaType: string;
15
+ mediaSubtype: string;
16
+ disposition?: string;
17
+ contentUrl: string;
18
+ isMultipart: boolean;
19
+ }
20
+
21
+ export interface PickedBodyPart {
22
+ kind: BodyContentKind;
23
+ contentUrl: string;
24
+ }
25
+
26
+ /**
27
+ * Pick the best body part to render: prefer the first inline `text/html`,
28
+ * fall back to the first inline `text/plain`. Multipart container rows and
29
+ * attachment-disposition parts are ignored — they carry no renderable
30
+ * payload at this position. Returns `null` when nothing renderable is
31
+ * available so callers can show an empty-body affordance.
32
+ */
33
+ export const pickRenderablePart = (
34
+ parts: readonly RenderableBodyPart[],
35
+ ): PickedBodyPart | null => {
36
+ const renderable = parts.filter(
37
+ (p) =>
38
+ !p.isMultipart &&
39
+ p.disposition !== "attachment" &&
40
+ p.mediaType === "TEXT" &&
41
+ p.contentUrl.length > 0,
42
+ );
43
+ const html = renderable.find((p) => p.mediaSubtype.toUpperCase() === "HTML");
44
+ if (html) return { kind: "html", contentUrl: html.contentUrl };
45
+ const text = renderable.find((p) => p.mediaSubtype.toUpperCase() === "PLAIN");
46
+ if (text) return { kind: "text", contentUrl: text.contentUrl };
47
+ return null;
48
+ };