@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,105 @@
1
+ /**
2
+ * Default SMTP defaults applied when auto-filling from IMAP at save time.
3
+ *
4
+ * STARTTLS on port 587 is the standard for almost every provider that
5
+ * exposes both IMAP and SMTP under the same domain (Gmail, Fastmail,
6
+ * Outlook, generic IMAP+SMTP hosts). Users with a different setup can
7
+ * still override before saving — auto-fill never overwrites a value the
8
+ * user already typed.
9
+ */
10
+ const DEFAULT_SMTP_PORT = 587;
11
+ const DEFAULT_SMTP_TLS = false;
12
+ const DEFAULT_SMTP_STARTTLS = true;
13
+
14
+ /**
15
+ * Derive an SMTP hostname from an IMAP hostname using the convention
16
+ * `imap.example.com` -> `smtp.example.com`. If the host does not start
17
+ * with the `imap.` prefix it is returned as-is so the caller can decide
18
+ * whether to use it.
19
+ */
20
+ export const deriveSmtpHostFromImap = (imapHost: string): string =>
21
+ imapHost.replace(/^imap\./i, "smtp.");
22
+
23
+ interface SmtpAutoFillInput {
24
+ imapHost: string;
25
+ smtpHost?: string;
26
+ smtpPort?: number;
27
+ smtpTls?: boolean;
28
+ smtpStartTls?: boolean;
29
+ }
30
+
31
+ interface SmtpAutoFillOutput {
32
+ smtpHost: string;
33
+ smtpPort: number;
34
+ smtpTls: boolean;
35
+ smtpStartTls: boolean;
36
+ }
37
+
38
+ /**
39
+ * Compute the SMTP fields that should replace the user's current values
40
+ * when the SMTP host is blank but IMAP is filled. Returns `null` when
41
+ * no auto-fill should happen — either because the user already provided
42
+ * an SMTP host (we never overwrite explicit input) or because IMAP is
43
+ * also blank (nothing to derive from).
44
+ */
45
+ export const computeSmtpAutoFill = (
46
+ values: SmtpAutoFillInput,
47
+ ): SmtpAutoFillOutput | null => {
48
+ const smtpHostTrimmed = values.smtpHost?.trim() ?? "";
49
+ if (smtpHostTrimmed.length > 0) return null;
50
+
51
+ const imapHostTrimmed = values.imapHost.trim();
52
+ if (imapHostTrimmed.length === 0) return null;
53
+
54
+ return {
55
+ smtpHost: deriveSmtpHostFromImap(imapHostTrimmed),
56
+ smtpPort: DEFAULT_SMTP_PORT,
57
+ smtpTls: DEFAULT_SMTP_TLS,
58
+ smtpStartTls: DEFAULT_SMTP_STARTTLS,
59
+ };
60
+ };
61
+
62
+ /**
63
+ * `true` when a connection-test error message reads as an authentication
64
+ * failure rather than a network/configuration problem. Mirrors the
65
+ * keyword detection in the onboarding wizard so both surfaces agree on
66
+ * what counts as "wrong credentials".
67
+ */
68
+ export const isAuthError = (message: string | undefined): boolean => {
69
+ const m = message?.toLowerCase() ?? "";
70
+ return (
71
+ m.includes("auth") ||
72
+ m.includes("login") ||
73
+ m.includes("535") ||
74
+ m.includes("credential")
75
+ );
76
+ };
77
+
78
+ /**
79
+ * Append the per-provider app-password hint to an auth-failure error.
80
+ * Preset providers reject the normal account password, which surfaces as
81
+ * "wrong password"; the hint tells the user they need an app password.
82
+ * Returns the original message unchanged when it is not an auth error.
83
+ */
84
+ export const appendAppPasswordHint = (
85
+ message: string | undefined,
86
+ hint: string | undefined,
87
+ ): string | undefined => {
88
+ if (!message) return message;
89
+ if (!hint) return message;
90
+ if (!isAuthError(message)) return message;
91
+ return `${message} — ${hint}`;
92
+ };
93
+
94
+ /**
95
+ * `true` when an account row should display the "Can't send mail —
96
+ * configure SMTP" warning. RFC 032 Tier 2: send capability is the explicit
97
+ * `smtpEnabled` marker, not inferred from `smtpHost` presence. Rows written
98
+ * before this field existed coalesce to `false` (treated as missing), so
99
+ * legacy accounts surface the same affordance as new ones.
100
+ */
101
+ export const accountIsMissingSmtp = (account: {
102
+ smtpEnabled?: boolean;
103
+ }): boolean => {
104
+ return account.smtpEnabled !== true;
105
+ };
@@ -0,0 +1,50 @@
1
+ import {
2
+ APP_BUILD_TIME,
3
+ APP_SHORT_SHA,
4
+ GITHUB_COMMIT_URL,
5
+ } from "@/lib/app-info";
6
+
7
+ export interface AppVersionProps {
8
+ /** Override SHA for testing/Storybook. Defaults to the build-time constant. */
9
+ sha?: string;
10
+ /** Override commit URL. Defaults to the GitHub commit link. */
11
+ commitUrl?: string;
12
+ /** Override build time ISO string. Defaults to the build-time constant. */
13
+ buildTime?: string;
14
+ }
15
+
16
+ function formatBuildTime(iso: string): string {
17
+ try {
18
+ return new Date(iso).toLocaleString(undefined, {
19
+ year: "numeric",
20
+ month: "short",
21
+ day: "numeric",
22
+ hour: "2-digit",
23
+ minute: "2-digit",
24
+ });
25
+ } catch {
26
+ return iso;
27
+ }
28
+ }
29
+
30
+ export function AppVersion({
31
+ sha = APP_SHORT_SHA,
32
+ commitUrl = GITHUB_COMMIT_URL,
33
+ buildTime = APP_BUILD_TIME,
34
+ }: AppVersionProps) {
35
+ return (
36
+ <p className="text-xs text-fg-subtle">
37
+ Version{" "}
38
+ <a
39
+ href={commitUrl}
40
+ target="_blank"
41
+ rel="noopener noreferrer"
42
+ className="font-mono hover:text-fg-muted hover:underline"
43
+ >
44
+ {sha}
45
+ </a>
46
+ {" · "}
47
+ <span>Built {formatBuildTime(buildTime)}</span>
48
+ </p>
49
+ );
50
+ }
@@ -0,0 +1,45 @@
1
+ import { Button } from "@remit/ui";
2
+ import { Bug } from "lucide-react";
3
+ import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
4
+
5
+ interface BugReportButtonProps {
6
+ /**
7
+ * `icon` (default) is the compact ghost icon button used in the desktop
8
+ * message toolbar. `drawer` renders a full-width labeled row to sit beside
9
+ * Settings in the mobile drawer footer, where an icon-only control would be
10
+ * unreachable/ambiguous (#685).
11
+ */
12
+ variant?: "icon" | "drawer";
13
+ }
14
+
15
+ const openBugReport = () => {
16
+ const ctx = buildBugReportContext();
17
+ const url = buildGitHubIssueUrl(ctx);
18
+ window.open(url, "_blank", "noopener,noreferrer");
19
+ };
20
+
21
+ export function BugReportButton({ variant = "icon" }: BugReportButtonProps) {
22
+ if (variant === "drawer") {
23
+ return (
24
+ <button
25
+ type="button"
26
+ onClick={openBugReport}
27
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-sm text-fg-muted transition-colors hover:bg-surface hover:text-fg"
28
+ >
29
+ <Bug className="size-4 shrink-0" />
30
+ <span className="flex-1 truncate text-left">Report a bug</span>
31
+ </button>
32
+ );
33
+ }
34
+
35
+ return (
36
+ <Button
37
+ variant="ghost"
38
+ size="sm"
39
+ icon={<Bug className="size-4" />}
40
+ title="Report a bug"
41
+ aria-label="Report a bug"
42
+ onClick={openBugReport}
43
+ />
44
+ );
45
+ }
@@ -0,0 +1,125 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ interface ConfirmDialogProps {
5
+ isOpen: boolean;
6
+ title: string;
7
+ /** Optional supporting line under the title. */
8
+ description?: string;
9
+ confirmLabel: string;
10
+ cancelLabel?: string;
11
+ /** Style the confirm button as a destructive action. */
12
+ destructive?: boolean;
13
+ /** Disable the confirm button (e.g. while a mutation is in flight). */
14
+ isBusy?: boolean;
15
+ onConfirm: () => void;
16
+ onCancel: () => void;
17
+ }
18
+
19
+ /**
20
+ * Minimal accessible confirmation dialog. No existing Dialog/ConfirmDialog
21
+ * primitive ships in the web client (only the bespoke KeyboardShortcutsModal
22
+ * and SlidePanel), so this is a small reusable one matching their Tailwind +
23
+ * overlay conventions. Esc cancels, the backdrop cancels, Cancel is focused on
24
+ * open, and the confirm/cancel pair is the only focusable content so the focus
25
+ * stays within the dialog.
26
+ */
27
+ export const ConfirmDialog = ({
28
+ isOpen,
29
+ title,
30
+ description,
31
+ confirmLabel,
32
+ cancelLabel = "Cancel",
33
+ destructive = false,
34
+ isBusy = false,
35
+ onConfirm,
36
+ onCancel,
37
+ }: ConfirmDialogProps) => {
38
+ const cancelRef = useRef<HTMLButtonElement>(null);
39
+
40
+ const handleKeyDown = useCallback(
41
+ (event: KeyboardEvent) => {
42
+ if (event.key === "Escape") {
43
+ event.preventDefault();
44
+ event.stopPropagation();
45
+ event.stopImmediatePropagation();
46
+ onCancel();
47
+ }
48
+ },
49
+ [onCancel],
50
+ );
51
+
52
+ useEffect(() => {
53
+ if (!isOpen) return;
54
+ // Capture phase so Esc closes the dialog before any list-level Esc
55
+ // handler (e.g. clearSelection) also fires on the same keystroke.
56
+ window.addEventListener("keydown", handleKeyDown, true);
57
+ return () => window.removeEventListener("keydown", handleKeyDown, true);
58
+ }, [isOpen, handleKeyDown]);
59
+
60
+ useEffect(() => {
61
+ if (isOpen) {
62
+ cancelRef.current?.focus();
63
+ }
64
+ }, [isOpen]);
65
+
66
+ if (!isOpen) return null;
67
+
68
+ return (
69
+ <div
70
+ className="fixed inset-0 z-50 flex items-center justify-center"
71
+ aria-hidden="true"
72
+ onClick={onCancel}
73
+ >
74
+ {/* Backdrop */}
75
+ <div className="absolute inset-0 bg-canvas/80 backdrop-blur-sm" />
76
+
77
+ {/* Dialog */}
78
+ <div
79
+ role="dialog"
80
+ aria-modal="true"
81
+ aria-label={title}
82
+ className={cn(
83
+ "relative z-10 w-full max-w-sm",
84
+ "bg-surface border border-line rounded-sm shadow-lg",
85
+ "p-6",
86
+ )}
87
+ onClick={(e) => e.stopPropagation()}
88
+ onKeyDown={(e) => e.stopPropagation()}
89
+ >
90
+ <h2 className="text-lg font-semibold">{title}</h2>
91
+ {description && (
92
+ <p className="mt-2 text-sm text-fg-muted">{description}</p>
93
+ )}
94
+
95
+ <div className="mt-6 flex items-center justify-end gap-2">
96
+ <button
97
+ ref={cancelRef}
98
+ type="button"
99
+ onClick={onCancel}
100
+ className={cn(
101
+ "min-h-11 inline-flex items-center justify-center px-4 rounded text-sm font-medium transition-colors",
102
+ "border border-line hover:bg-surface-raised",
103
+ )}
104
+ >
105
+ {cancelLabel}
106
+ </button>
107
+ <button
108
+ type="button"
109
+ onClick={onConfirm}
110
+ disabled={isBusy}
111
+ className={cn(
112
+ "min-h-11 inline-flex items-center justify-center px-4 rounded text-sm font-medium transition-colors",
113
+ destructive
114
+ ? "bg-danger text-canvas hover:bg-danger/90"
115
+ : "bg-accent text-accent-fg hover:bg-accent-hover",
116
+ "disabled:opacity-50 disabled:cursor-not-allowed",
117
+ )}
118
+ >
119
+ {confirmLabel}
120
+ </button>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ );
125
+ };
@@ -0,0 +1,105 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ interface DropdownMenuProps {
5
+ trigger: React.ReactNode;
6
+ children: React.ReactNode;
7
+ align?: "left" | "right";
8
+ }
9
+
10
+ export const DropdownMenu = ({
11
+ trigger,
12
+ children,
13
+ align = "right",
14
+ }: DropdownMenuProps) => {
15
+ const [isOpen, setIsOpen] = useState(false);
16
+ const menuRef = useRef<HTMLDivElement>(null);
17
+
18
+ const handleClickOutside = useCallback((event: MouseEvent) => {
19
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
20
+ setIsOpen(false);
21
+ }
22
+ }, []);
23
+
24
+ const handleKeyDown = useCallback((event: KeyboardEvent) => {
25
+ if (event.key === "Escape") {
26
+ setIsOpen(false);
27
+ }
28
+ }, []);
29
+
30
+ useEffect(() => {
31
+ if (isOpen) {
32
+ document.addEventListener("mousedown", handleClickOutside);
33
+ document.addEventListener("keydown", handleKeyDown);
34
+ }
35
+ return () => {
36
+ document.removeEventListener("mousedown", handleClickOutside);
37
+ document.removeEventListener("keydown", handleKeyDown);
38
+ };
39
+ }, [isOpen, handleClickOutside, handleKeyDown]);
40
+
41
+ return (
42
+ <div ref={menuRef} className="relative">
43
+ <button
44
+ type="button"
45
+ onClick={(e) => {
46
+ e.stopPropagation();
47
+ setIsOpen(!isOpen);
48
+ }}
49
+ className={cn(
50
+ "p-1.5 min-h-11 min-w-11 inline-flex items-center justify-center rounded-md transition-colors",
51
+ "hover:bg-surface-raised text-fg-muted hover:text-fg",
52
+ )}
53
+ >
54
+ {trigger}
55
+ </button>
56
+ {isOpen && (
57
+ <div
58
+ className={cn(
59
+ "absolute z-50 mt-1 min-w-[180px] py-1",
60
+ "bg-surface border border-line rounded-md shadow-lg",
61
+ align === "right" ? "right-0" : "left-0",
62
+ )}
63
+ >
64
+ {children}
65
+ </div>
66
+ )}
67
+ </div>
68
+ );
69
+ };
70
+
71
+ interface DropdownMenuItemProps {
72
+ onClick: () => void;
73
+ disabled?: boolean;
74
+ destructive?: boolean;
75
+ children: React.ReactNode;
76
+ }
77
+
78
+ export const DropdownMenuItem = ({
79
+ onClick,
80
+ disabled,
81
+ destructive,
82
+ children,
83
+ }: DropdownMenuItemProps) => (
84
+ <button
85
+ type="button"
86
+ onClick={(e) => {
87
+ e.stopPropagation();
88
+ if (!disabled) onClick();
89
+ }}
90
+ disabled={disabled}
91
+ className={cn(
92
+ "w-full px-3 py-2 text-left text-sm transition-colors",
93
+ "flex items-center gap-2",
94
+ disabled && "opacity-50 cursor-not-allowed",
95
+ !disabled && "hover:bg-surface-raised",
96
+ destructive && "text-danger hover:text-danger",
97
+ )}
98
+ >
99
+ {children}
100
+ </button>
101
+ );
102
+
103
+ export const DropdownMenuSeparator = () => (
104
+ <div className="my-1 h-px bg-line" />
105
+ );
@@ -0,0 +1,11 @@
1
+ interface EmptyStateProps {
2
+ message: string;
3
+ icon?: React.ReactNode;
4
+ }
5
+
6
+ export const EmptyState = ({ message, icon }: EmptyStateProps) => (
7
+ <div className="flex flex-col items-center justify-center p-8 text-center">
8
+ {icon && <div className="mb-4 text-fg-muted">{icon}</div>}
9
+ <p className="text-fg-muted">{message}</p>
10
+ </div>
11
+ );
@@ -0,0 +1,88 @@
1
+ import { AlertCircle, AlertTriangle, Info, X } from "lucide-react";
2
+ import { cn } from "../../lib/utils";
3
+ import type { ErrorBannerSeverity } from "./error-banners.js";
4
+
5
+ interface ErrorBannerProps {
6
+ id: string;
7
+ severity: ErrorBannerSeverity;
8
+ title: string;
9
+ detail?: string;
10
+ onDismiss: (id: string) => void;
11
+ }
12
+
13
+ const SEVERITY_STYLES: Record<
14
+ ErrorBannerSeverity,
15
+ { container: string; icon: string; title: string }
16
+ > = {
17
+ error: {
18
+ container: "border-danger/50 bg-danger/10 dark:bg-danger/20",
19
+ icon: "text-danger",
20
+ title: "text-danger",
21
+ },
22
+ warning: {
23
+ container: "border-warning/50 bg-warning/10",
24
+ icon: "text-warning",
25
+ title: "text-warning",
26
+ },
27
+ info: {
28
+ container: "border-accent-2/50 bg-accent-2-soft",
29
+ icon: "text-accent-2",
30
+ title: "text-accent-2",
31
+ },
32
+ };
33
+
34
+ const SEVERITY_ICONS: Record<
35
+ ErrorBannerSeverity,
36
+ typeof AlertCircle | typeof AlertTriangle | typeof Info
37
+ > = {
38
+ error: AlertCircle,
39
+ warning: AlertTriangle,
40
+ info: Info,
41
+ };
42
+
43
+ const SEVERITY_LABEL: Record<ErrorBannerSeverity, string> = {
44
+ error: "Error",
45
+ warning: "Warning",
46
+ info: "Information",
47
+ };
48
+
49
+ export const ErrorBanner = ({
50
+ id,
51
+ severity,
52
+ title,
53
+ detail,
54
+ onDismiss,
55
+ }: ErrorBannerProps) => {
56
+ const styles = SEVERITY_STYLES[severity];
57
+ const Icon = SEVERITY_ICONS[severity];
58
+
59
+ return (
60
+ <div
61
+ role={severity === "error" ? "alert" : "status"}
62
+ aria-live={severity === "error" ? "assertive" : "polite"}
63
+ className={cn(
64
+ "pointer-events-auto flex items-start gap-3 rounded-md border bg-canvas/80 px-3 py-2 shadow-md backdrop-blur",
65
+ styles.container,
66
+ )}
67
+ >
68
+ <Icon
69
+ className={cn("size-5 shrink-0 mt-0.5", styles.icon)}
70
+ aria-hidden="true"
71
+ />
72
+ <div className="flex-1 min-w-0">
73
+ <p className={cn("text-sm font-medium", styles.title)}>{title}</p>
74
+ {detail && (
75
+ <p className="mt-0.5 text-xs text-fg-muted break-words">{detail}</p>
76
+ )}
77
+ </div>
78
+ <button
79
+ type="button"
80
+ onClick={() => onDismiss(id)}
81
+ className="shrink-0 rounded-md p-1 text-fg-muted hover:bg-surface-raised hover:text-fg transition-colors"
82
+ aria-label={`Dismiss ${SEVERITY_LABEL[severity].toLowerCase()}`}
83
+ >
84
+ <X className="size-4" aria-hidden="true" />
85
+ </button>
86
+ </div>
87
+ );
88
+ };
@@ -0,0 +1,77 @@
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ useCallback,
5
+ useContext,
6
+ useMemo,
7
+ useState,
8
+ } from "react";
9
+ import { ErrorBannerStack } from "./ErrorBannerStack.js";
10
+ import {
11
+ appendBanner,
12
+ buildEntry,
13
+ dismissBanner,
14
+ type ErrorBannerEntry,
15
+ type PushErrorInput,
16
+ } from "./error-banners.js";
17
+
18
+ interface ErrorBannerContextValue {
19
+ errors: ErrorBannerEntry[];
20
+ pushError: (input: PushErrorInput) => string;
21
+ dismissError: (id: string) => void;
22
+ clearAll: () => void;
23
+ }
24
+
25
+ const ErrorBannerContext = createContext<ErrorBannerContextValue | undefined>(
26
+ undefined,
27
+ );
28
+
29
+ const generateId = (): string => {
30
+ if (
31
+ typeof globalThis.crypto !== "undefined" &&
32
+ typeof globalThis.crypto.randomUUID === "function"
33
+ ) {
34
+ return globalThis.crypto.randomUUID();
35
+ }
36
+ return `banner-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
37
+ };
38
+
39
+ export const ErrorBannerProvider = ({ children }: { children: ReactNode }) => {
40
+ const [errors, setErrors] = useState<ErrorBannerEntry[]>([]);
41
+
42
+ const pushError = useCallback((input: PushErrorInput): string => {
43
+ const entry = buildEntry(input, generateId(), Date.now());
44
+ setErrors((current) => appendBanner(current, entry));
45
+ return entry.id;
46
+ }, []);
47
+
48
+ const dismissError = useCallback((id: string) => {
49
+ setErrors((current) => dismissBanner(current, id));
50
+ }, []);
51
+
52
+ const clearAll = useCallback(() => {
53
+ setErrors([]);
54
+ }, []);
55
+
56
+ const value = useMemo(
57
+ () => ({ errors, pushError, dismissError, clearAll }),
58
+ [errors, pushError, dismissError, clearAll],
59
+ );
60
+
61
+ return (
62
+ <ErrorBannerContext.Provider value={value}>
63
+ {children}
64
+ <ErrorBannerStack errors={errors} onDismiss={dismissError} />
65
+ </ErrorBannerContext.Provider>
66
+ );
67
+ };
68
+
69
+ export const useErrorBanners = (): ErrorBannerContextValue => {
70
+ const context = useContext(ErrorBannerContext);
71
+ if (!context) {
72
+ throw new Error(
73
+ "useErrorBanners must be used within an ErrorBannerProvider",
74
+ );
75
+ }
76
+ return context;
77
+ };
@@ -0,0 +1,34 @@
1
+ import { ErrorBanner } from "./ErrorBanner.js";
2
+ import type { ErrorBannerEntry } from "./error-banners.js";
3
+
4
+ interface ErrorBannerStackProps {
5
+ errors: ErrorBannerEntry[];
6
+ onDismiss: (id: string) => void;
7
+ }
8
+
9
+ export const ErrorBannerStack = ({
10
+ errors,
11
+ onDismiss,
12
+ }: ErrorBannerStackProps) => {
13
+ if (errors.length === 0) return null;
14
+
15
+ return (
16
+ <section
17
+ aria-label="Notifications"
18
+ className="pointer-events-none fixed inset-x-0 top-0 z-[60] flex flex-col items-center gap-2 px-4 pt-4 sm:items-end sm:pr-6"
19
+ >
20
+ <div className="flex w-full max-w-md flex-col gap-2">
21
+ {errors.map((entry) => (
22
+ <ErrorBanner
23
+ key={entry.id}
24
+ id={entry.id}
25
+ severity={entry.severity}
26
+ title={entry.title}
27
+ detail={entry.detail}
28
+ onDismiss={onDismiss}
29
+ />
30
+ ))}
31
+ </div>
32
+ </section>
33
+ );
34
+ };