@remit/web-client 0.0.322 → 0.0.323

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 (190) hide show
  1. package/package.json +4 -4
  2. package/src/auth/SignInAgain.tsx +18 -0
  3. package/src/auth/amplify-config.test.ts +1 -107
  4. package/src/auth/auth-interceptor.test.ts +0 -6
  5. package/src/auth/cognito/cognito-token.test.ts +2 -4
  6. package/src/components/calendar/AgendaStrip.tsx +1 -1
  7. package/src/components/calendar/CalendarWorkspace.tsx +1 -1
  8. package/src/components/calendar/ChangeCalendarSuggestion.tsx +1 -1
  9. package/src/components/calendar/OpenCalendarEvent.tsx +1 -18
  10. package/src/components/compose/ComposeForm.tsx +11 -6
  11. package/src/components/compose/FullCompose.tsx +1 -1
  12. package/src/components/compose/MobileComposeSheet.tsx +1 -2
  13. package/src/components/compose/compose.stories.tsx +18 -0
  14. package/src/components/compose/draft-discard-failure.render.test.ts +138 -0
  15. package/src/components/config-import/config-import.fixtures.ts +8 -9
  16. package/src/components/config-import/config-import.stories.tsx +2 -2
  17. package/src/components/layout/MailTopBar.tsx +3 -0
  18. package/src/components/mail/BriefPane.tsx +0 -3
  19. package/src/components/mail/ConversationView.tsx +1 -3
  20. package/src/components/mail/DailyBrief.tsx +5 -28
  21. package/src/components/mail/FlaggedList.tsx +3 -4
  22. package/src/components/mail/FlaggedPane.tsx +0 -3
  23. package/src/components/mail/IntelligenceDrawer.tsx +0 -3
  24. package/src/components/mail/IntelligencePane.render.test.ts +0 -1
  25. package/src/components/mail/IntelligencePane.test.ts +1 -95
  26. package/src/components/mail/IntelligencePane.tsx +3 -93
  27. package/src/components/mail/MailListHeader.tsx +12 -43
  28. package/src/components/mail/MailNav.tsx +6 -2
  29. package/src/components/mail/MailViewChrome.tsx +1 -11
  30. package/src/components/mail/MailboxPane.tsx +4 -34
  31. package/src/components/mail/MessageActionMenu.tsx +6 -6
  32. package/src/components/mail/MessageAttachments.tsx +35 -0
  33. package/src/components/mail/MessageBody.tsx +1 -0
  34. package/src/components/mail/MessageBodyErrorBanner.test.ts +0 -30
  35. package/src/components/mail/MessageCard.tsx +8 -3
  36. package/src/components/mail/MessageList.run-reporting.test.ts +2 -46
  37. package/src/components/mail/MessageList.tsx +23 -11
  38. package/src/components/mail/MessageRow.render.test.ts +0 -7
  39. package/src/components/mail/MoveToTrigger.tsx +1 -1
  40. package/src/components/mail/RawMessageView.tsx +1 -1
  41. package/src/components/mail/SelectionWizardHost.search-entry.test.ts +0 -1
  42. package/src/components/mail/SelectionWizardHost.tsx +16 -102
  43. package/src/components/mail/authenticity-warning.stories.tsx +0 -21
  44. package/src/components/mail/daily-brief.stories.tsx +31 -17
  45. package/src/components/mail/enter-cursor-late-open.render.test.ts +197 -0
  46. package/src/components/mail/intelligence-drawer.stories.tsx +0 -2
  47. package/src/components/mail/message-gone.stories.tsx +3 -1
  48. package/src/components/mail/offer-times-reply.render.test.ts +25 -1
  49. package/src/components/mail/reclassify-remove-override.render.test.ts +2 -6
  50. package/src/components/overlay-escape.render.test.ts +7 -3
  51. package/src/components/settings/AccountFormPanel.render.test.ts +26 -3
  52. package/src/components/settings/AccountFormPanel.test.ts +1 -36
  53. package/src/components/settings/AccountFormPanel.tsx +5 -25
  54. package/src/components/settings/AccountServices.tsx +1 -1
  55. package/src/components/settings/AdvancedNavIcon.tsx +3 -3
  56. package/src/components/settings/CalendarFeedCard.tsx +2 -1
  57. package/src/components/settings/CalendarSubscribeCard.tsx +1 -1
  58. package/src/components/settings/CalendarSubscriptionStatus.tsx +1 -2
  59. package/src/components/settings/FilterActionError.tsx +1 -1
  60. package/src/components/settings/FilterEditor.render.test.ts +3 -27
  61. package/src/components/settings/FilterEditor.tsx +4 -24
  62. package/src/components/settings/FilterEditorSurface.tsx +0 -3
  63. package/src/components/settings/FiltersList.render.test.ts +5 -24
  64. package/src/components/settings/FiltersList.stories.tsx +0 -1
  65. package/src/components/settings/FiltersList.tsx +10 -35
  66. package/src/components/settings/WarningsNavIcon.tsx +12 -0
  67. package/src/components/settings/{QuarantinePanel.delimiter.test.ts → WarningsPanel.dom.test.ts} +64 -5
  68. package/src/components/settings/WarningsPanel.render.test.ts +56 -0
  69. package/src/components/settings/WarningsPanel.tsx +244 -0
  70. package/src/components/settings/accounts.stories.tsx +18 -0
  71. package/src/components/settings/advanced.stories.tsx +3 -125
  72. package/src/components/settings/settings-filter.stories.tsx +2 -42
  73. package/src/components/settings/warnings.stories.tsx +290 -0
  74. package/src/components/ui/ErrorBannerProvider.tsx +5 -3
  75. package/src/components/ui/error-banners.test.ts +1 -22
  76. package/src/components/ui/error-banners.ts +5 -20
  77. package/src/hooks/calendar/useCalendarCollectionWrites.ts +2 -1
  78. package/src/hooks/calendar/useCalendarSuggestions.ts +2 -1
  79. package/src/hooks/calendar/useCalendarWrites.ts +2 -1
  80. package/src/hooks/intelligence-calendar.render.test.ts +0 -1
  81. package/src/hooks/useDeleteMessages.test.ts +1 -42
  82. package/src/hooks/useFilters.test.ts +0 -8
  83. package/src/hooks/useFollowFocusOpen.render.test.ts +1 -11
  84. package/src/hooks/useIntelligenceData.test.ts +9 -47
  85. package/src/hooks/useIntelligenceData.ts +7 -100
  86. package/src/hooks/useLayoutTier.test.ts +0 -48
  87. package/src/hooks/useReportSpam.ts +1 -1
  88. package/src/hooks/useRescueCandidates.test.ts +0 -23
  89. package/src/hooks/useRulePreview.ts +8 -7
  90. package/src/hooks/useSearchTokenContext.ts +2 -3
  91. package/src/hooks/useStaleAccountSync.test.ts +0 -17
  92. package/src/hooks/useStarredThreads.test.ts +0 -10
  93. package/src/hooks/useTriggerSync.test.ts +0 -6
  94. package/src/hooks/useVisualViewport.render.test.ts +63 -0
  95. package/src/hooks/useWarningsAttention.ts +37 -0
  96. package/src/lib/account-order.test.ts +0 -10
  97. package/src/lib/adjacent-message.test.ts +0 -9
  98. package/src/lib/app-info.ts +0 -3
  99. package/src/lib/autodiscovery.test.ts +0 -47
  100. package/src/lib/bug-report.test.ts +38 -0
  101. package/src/lib/bug-report.ts +10 -7
  102. package/src/lib/bulk-action-copy.ts +1 -1
  103. package/src/lib/calendar-route.test.ts +0 -5
  104. package/src/lib/config-import.test.ts +3 -39
  105. package/src/lib/dedupe-thread-messages.test.ts +0 -4
  106. package/src/lib/desktop-breakpoint.test.ts +0 -8
  107. package/src/lib/drafts.test.ts +0 -23
  108. package/src/lib/fatal-error.test.ts +0 -19
  109. package/src/lib/folder-roles.test.ts +0 -4
  110. package/src/lib/hot-sync-window.test.ts +0 -5
  111. package/src/lib/list-header-chrome.ts +1 -1
  112. package/src/lib/mail-context.ts +1 -1
  113. package/src/lib/message-attachments.test.ts +77 -0
  114. package/src/lib/message-attachments.ts +31 -15
  115. package/src/lib/message-body-source.test.ts +21 -4
  116. package/src/lib/message-body-source.ts +5 -2
  117. package/src/lib/onboarding-completion.test.ts +0 -16
  118. package/src/lib/organize/filter-edit-model.test.ts +2 -32
  119. package/src/lib/organize/filter-edit-model.ts +6 -16
  120. package/src/lib/organize/organize-model.test.ts +7 -40
  121. package/src/lib/organize/organize-model.ts +16 -18
  122. package/src/lib/organize/rule-model.test.ts +24 -202
  123. package/src/lib/organize/rule-model.ts +19 -166
  124. package/src/lib/organize/search-to-rule.test.ts +2 -36
  125. package/src/lib/organize/search-to-rule.ts +1 -16
  126. package/src/lib/provider-presets.test.ts +1 -77
  127. package/src/lib/query-error-handler.test.ts +0 -61
  128. package/src/lib/query-escalation.integration.test.ts +4 -4
  129. package/src/lib/result-count.ts +1 -1
  130. package/src/lib/result-folder.ts +1 -1
  131. package/src/lib/rum-adapter.test.ts +0 -104
  132. package/src/lib/search-query.test.ts +0 -11
  133. package/src/lib/search-result.test.ts +2 -142
  134. package/src/lib/search-result.ts +3 -73
  135. package/src/lib/search-scope.test.ts +1 -92
  136. package/src/lib/search-scope.ts +0 -29
  137. package/src/lib/search-surface.ts +2 -2
  138. package/src/lib/search-token-index.test.ts +0 -8
  139. package/src/lib/search-tokens.ts +15 -16
  140. package/src/lib/starred-rows.test.ts +0 -4
  141. package/src/lib/sync-warning-entries.ts +33 -0
  142. package/src/lib/wizard-selection.ts +1 -1
  143. package/src/mocks/story-frame/mail-handlers.ts +12 -8
  144. package/src/mocks/story-frame/mail-world.ts +5 -0
  145. package/src/routeTree.gen.ts +21 -0
  146. package/src/routes/calendar/$view.$date/index.tsx +1 -1
  147. package/src/routes/calendar.tsx +2 -1
  148. package/src/routes/mail/brief.tsx +1 -1
  149. package/src/routes/mail/flagged.tsx +1 -1
  150. package/src/routes/mail.tsx +5 -3
  151. package/src/routes/settings/-accounts.test.ts +0 -5
  152. package/src/routes/settings/-filters.test.ts +0 -1
  153. package/src/routes/settings/-shared/help-copy.tsx +15 -0
  154. package/src/routes/settings/accounts.tsx +1 -1
  155. package/src/routes/settings/advanced.tsx +1 -4
  156. package/src/routes/settings/filters.tsx +1 -2
  157. package/src/routes/settings/folders.tsx +1 -1
  158. package/src/routes/settings/labels.tsx +1 -1
  159. package/src/routes/settings/senders.tsx +1 -1
  160. package/src/routes/settings/warnings.tsx +36 -0
  161. package/src/routes/settings.tsx +7 -0
  162. package/src/routing/wizard-history.test.ts +1 -45
  163. package/src/runtime-config.ts +5 -0
  164. package/src/test-support/fixtures.ts +1 -1
  165. package/src/components/compose/draft-discard-banner.test.ts +0 -76
  166. package/src/components/mail/ReadingPaneEmpty.render.test.ts +0 -42
  167. package/src/components/mail/SpamRescue.render.test.ts +0 -35
  168. package/src/components/mail/intelligence-semantic-off.render.test.ts +0 -111
  169. package/src/components/mail/selection-surface-rollcall.test.ts +0 -270
  170. package/src/components/settings/QuarantinePanel.render.test.ts +0 -71
  171. package/src/components/settings/QuarantinePanel.tsx +0 -178
  172. package/src/components/ui/DropdownMenu.tsx +0 -99
  173. package/src/components/ui/EmptyState.tsx +0 -11
  174. package/src/components/ui/ErrorBanner.render.test.ts +0 -104
  175. package/src/components/ui/ErrorBanner.stories.tsx +0 -132
  176. package/src/components/ui/ErrorBanner.tsx +0 -106
  177. package/src/components/ui/ErrorBannerStack.tsx +0 -35
  178. package/src/components/ui/ErrorState.render.test.ts +0 -82
  179. package/src/components/ui/ErrorState.tsx +0 -80
  180. package/src/components/ui/KeyboardShortcutsModal.tsx +0 -113
  181. package/src/hooks/useMoveMessages.test.ts +0 -63
  182. package/src/hooks/useOrganizeWiden.test.ts +0 -217
  183. package/src/hooks/useOrganizeWiden.ts +0 -112
  184. package/src/hooks/useSemanticSearch.ts +0 -132
  185. package/src/hooks/useSemanticSearchEnabled.ts +0 -23
  186. package/src/hooks/useVisualViewport.test.ts +0 -126
  187. package/src/lib/organize/sender-fallback.test.ts +0 -52
  188. package/src/lib/organize/sender-fallback.ts +0 -38
  189. package/src/lib/rescue-telemetry.test.ts +0 -75
  190. package/src/router.test.ts +0 -81
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.322",
3
+ "version": "0.0.323",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -86,10 +86,10 @@
86
86
  "aws-amplify": "^6",
87
87
  "@aws-amplify/ui-react": "^6",
88
88
  "aws-rum-web": "^3",
89
- "better-auth": "^1.6.23",
89
+ "better-auth": "^1.7.12",
90
90
  "i18next": "^24",
91
91
  "i18next-browser-languagedetector": "^8",
92
- "i18next-http-backend": "^3.0.6",
92
+ "i18next-http-backend": "^4.0.2",
93
93
  "lucide-react": "^0.468",
94
94
  "p-map": "*",
95
95
  "react-hook-form": "*",
@@ -115,7 +115,7 @@
115
115
  "react-dom": "^19.2.4",
116
116
  "tailwindcss": "^4",
117
117
  "typescript": "*",
118
- "vite": "^7.3.6"
118
+ "vite": "^7.3.7"
119
119
  },
120
120
  "license": "MIT",
121
121
  "publishConfig": {
@@ -0,0 +1,18 @@
1
+ import { useAuthProvider } from "@/auth/provider";
2
+
3
+ export function SignInAgain() {
4
+ const { Account } = useAuthProvider();
5
+ return (
6
+ <Account>
7
+ {({ signOut }) => (
8
+ <button
9
+ type="button"
10
+ onClick={() => signOut()}
11
+ className="rounded-md border border-line px-2.5 py-1 text-sm font-medium text-fg outline-none hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring"
12
+ >
13
+ Sign in again
14
+ </button>
15
+ )}
16
+ </Account>
17
+ );
18
+ }
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert";
2
- import { beforeEach, describe, test } from "node:test";
2
+ import { describe, test } from "node:test";
3
3
 
4
4
  type TestConfig = {
5
5
  cognito?: {
@@ -10,36 +10,11 @@ type TestConfig = {
10
10
  };
11
11
  appOrigin?: string;
12
12
  };
13
- type AmplifyCall = {
14
- Auth?: {
15
- Cognito?: {
16
- userPoolId?: string;
17
- userPoolClientId?: string;
18
- loginWith?: {
19
- oauth?: {
20
- domain?: string;
21
- redirectSignIn?: string[];
22
- redirectSignOut?: string[];
23
- responseType?: string;
24
- scopes?: string[];
25
- };
26
- };
27
- };
28
- };
29
- };
30
-
31
- declare global {
32
- var __AMPLIFY_MOCKS__: { configureCalls: AmplifyCall[] } | undefined;
33
- var __CACHE_BUST__: number | undefined;
34
- }
35
-
36
13
  let cacheBust = 0;
37
14
 
38
15
  const loadModule = async (config: TestConfig) => {
39
16
  cacheBust += 1;
40
- globalThis.__CACHE_BUST__ = cacheBust;
41
17
  globalThis.__REMIT_CONFIG__ = config;
42
- globalThis.__AMPLIFY_MOCKS__ = { configureCalls: [] };
43
18
  return import(`./amplify-config.ts?v=${cacheBust}`);
44
19
  };
45
20
 
@@ -50,7 +25,6 @@ const fullConfig: TestConfig = {
50
25
  domain: "example.auth.us-east-1.amazoncognito.com",
51
26
  region: "us-east-1",
52
27
  },
53
- appOrigin: "https://app.example.com",
54
28
  };
55
29
 
56
30
  describe("isCognitoConfigured", () => {
@@ -74,83 +48,3 @@ describe("isCognitoConfigured", () => {
74
48
  assert.equal(mod.isCognitoConfigured(), false);
75
49
  });
76
50
  });
77
-
78
- describe("configureAmplify", () => {
79
- let originalWarn: typeof console.warn;
80
- let warnings: unknown[][];
81
-
82
- beforeEach(() => {
83
- originalWarn = console.warn;
84
- warnings = [];
85
- console.warn = (...args: unknown[]) => {
86
- warnings.push(args);
87
- };
88
- });
89
-
90
- test("warns and does not call Amplify.configure when unconfigured", async () => {
91
- const mod = await loadModule({});
92
- mod.configureAmplify();
93
- console.warn = originalWarn;
94
- assert.equal(globalThis.__AMPLIFY_MOCKS__?.configureCalls.length, 0);
95
- assert.equal(warnings.length, 1);
96
- const message = String(warnings[0][0]);
97
- assert.ok(
98
- message.includes("Cognito"),
99
- `expected warning to mention Cognito, got: ${message}`,
100
- );
101
- });
102
-
103
- test("passes userPoolId and clientId to Amplify.configure", async () => {
104
- const mod = await loadModule(fullConfig);
105
- mod.configureAmplify();
106
- console.warn = originalWarn;
107
- const calls = globalThis.__AMPLIFY_MOCKS__?.configureCalls ?? [];
108
- assert.equal(calls.length, 1);
109
- const cognito = calls[0]?.Auth?.Cognito;
110
- assert.equal(cognito?.userPoolId, "us-east-1_pool");
111
- assert.equal(cognito?.userPoolClientId, "client-id");
112
- });
113
-
114
- test("wires OAuth block with appOrigin in redirect arrays when domain set", async () => {
115
- const mod = await loadModule(fullConfig);
116
- mod.configureAmplify();
117
- console.warn = originalWarn;
118
- const oauth =
119
- globalThis.__AMPLIFY_MOCKS__?.configureCalls[0]?.Auth?.Cognito?.loginWith
120
- ?.oauth;
121
- assert.ok(oauth, "expected oauth block to be present");
122
- assert.equal(oauth?.domain, "example.auth.us-east-1.amazoncognito.com");
123
- assert.deepEqual(oauth?.redirectSignIn, ["https://app.example.com"]);
124
- assert.deepEqual(oauth?.redirectSignOut, ["https://app.example.com"]);
125
- assert.equal(oauth?.responseType, "code");
126
- });
127
-
128
- test("omits loginWith block when no domain configured", async () => {
129
- const mod = await loadModule({
130
- cognito: { userPoolId: "pool", clientId: "client" },
131
- });
132
- mod.configureAmplify();
133
- console.warn = originalWarn;
134
- const cognito =
135
- globalThis.__AMPLIFY_MOCKS__?.configureCalls[0]?.Auth?.Cognito;
136
- assert.equal(cognito?.loginWith, undefined);
137
- });
138
-
139
- test("uses empty redirect arrays when domain present but appOrigin missing", async () => {
140
- const mod = await loadModule({
141
- cognito: {
142
- userPoolId: "pool",
143
- clientId: "client",
144
- domain: "example.auth.us-east-1.amazoncognito.com",
145
- },
146
- });
147
- mod.configureAmplify();
148
- console.warn = originalWarn;
149
- const oauth =
150
- globalThis.__AMPLIFY_MOCKS__?.configureCalls[0]?.Auth?.Cognito?.loginWith
151
- ?.oauth;
152
- assert.ok(oauth);
153
- assert.deepEqual(oauth?.redirectSignIn, []);
154
- assert.deepEqual(oauth?.redirectSignOut, []);
155
- });
156
- });
@@ -41,12 +41,6 @@ const sentHeaders = (): Record<string, string> => {
41
41
  };
42
42
 
43
43
  describe("installAuthInterceptor", () => {
44
- test("registers exactly one request interceptor", async () => {
45
- const mod = await loadInterceptor();
46
- mod.installAuthInterceptor(providerWithToken(async () => null));
47
- assert.equal(client.interceptors.request.fns.length, 1);
48
- });
49
-
50
44
  test("is idempotent — subsequent calls do not register additional interceptors", async () => {
51
45
  const mod = await loadInterceptor();
52
46
  const provider = providerWithToken(async () => null);
@@ -9,9 +9,7 @@ type AmplifyAuthMocks = {
9
9
 
10
10
  declare global {
11
11
  var __AMPLIFY_AUTH_MOCKS__: AmplifyAuthMocks | undefined;
12
- var __AMPLIFY_CONFIG_MOCKS__:
13
- | { configured: boolean; configureCalls: number }
14
- | undefined;
12
+ var __AMPLIFY_CONFIG_MOCKS__: { configured: boolean } | undefined;
15
13
  }
16
14
 
17
15
  let cacheBust = 2000;
@@ -32,7 +30,7 @@ const loadCognitoToken = async (
32
30
  ) => {
33
31
  cacheBust += 1;
34
32
  globalThis.__REMIT_CONFIG__ = {};
35
- globalThis.__AMPLIFY_CONFIG_MOCKS__ = { configured, configureCalls: 0 };
33
+ globalThis.__AMPLIFY_CONFIG_MOCKS__ = { configured };
36
34
  globalThis.__AMPLIFY_AUTH_MOCKS__ = {
37
35
  session: { tokens: {} },
38
36
  fetchCalls: 0,
@@ -5,11 +5,11 @@ import {
5
5
  type CalendarDescriptor,
6
6
  type CalendarSlotPick,
7
7
  type Density,
8
+ ErrorState,
8
9
  NextUpCard,
9
10
  readNextUp,
10
11
  } from "@remit/ui";
11
12
  import { useMemo } from "react";
12
- import { ErrorState } from "@/components/ui/ErrorState";
13
13
  import type { FreeLookup } from "@/hooks/calendar";
14
14
  import { agendaDensityOf } from "@/lib/calendar-density";
15
15
 
@@ -7,6 +7,7 @@ import {
7
7
  type CalendarViewId,
8
8
  CalendarViewSwitch,
9
9
  type Density,
10
+ ErrorState,
10
11
  Segmented,
11
12
  type SegmentOption,
12
13
  } from "@remit/ui";
@@ -14,7 +15,6 @@ import { Loader2 } from "lucide-react";
14
15
  import { type ReactNode, useState } from "react";
15
16
  import { CalendarViewPlaceholder } from "@/components/calendar/CalendarViewPlaceholder";
16
17
  import { NavMenuButton } from "@/components/mail/NavMenuButton";
17
- import { ErrorState } from "@/components/ui/ErrorState";
18
18
  import {
19
19
  calendarViewMountsAgenda,
20
20
  calendarViewMountsGrid,
@@ -1,12 +1,12 @@
1
1
  import type { RemitImapCalendarSuggestionResponse } from "@remit/api-http-client/types.gen.ts";
2
2
  import {
3
3
  CalendarFailureNote,
4
+ EmptyState,
4
5
  type EventDraft,
5
6
  EventEditorPane,
6
7
  } from "@remit/ui";
7
8
  import { useEffect, useState } from "react";
8
9
  import { CalendarComposePane } from "@/components/calendar/CalendarComposePane";
9
- import { EmptyState } from "@/components/ui/EmptyState";
10
10
  import {
11
11
  acceptEditedInputFromDrafts,
12
12
  anchorZoneFor,
@@ -5,7 +5,7 @@ import {
5
5
  } from "@remit/ui";
6
6
  import { Navigate } from "@tanstack/react-router";
7
7
  import { useState } from "react";
8
- import { useAuthProvider } from "@/auth/provider";
8
+ import { SignInAgain } from "@/auth/SignInAgain";
9
9
  import { CalendarComposePane } from "@/components/calendar/CalendarComposePane";
10
10
  import {
11
11
  type CalendarEventAbsence,
@@ -66,23 +66,6 @@ const CONFLICT =
66
66
 
67
67
  type Intent = "edit" | "delete";
68
68
 
69
- function SignInAgain() {
70
- const { Account } = useAuthProvider();
71
- return (
72
- <Account>
73
- {({ signOut }) => (
74
- <button
75
- type="button"
76
- onClick={() => signOut()}
77
- className="rounded-md border border-line px-2.5 py-1 text-sm font-medium text-fg outline-none hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring"
78
- >
79
- Sign in again
80
- </button>
81
- )}
82
- </Account>
83
- );
84
- }
85
-
86
69
  const absenceOf = (
87
70
  search: CalendarJumpSearch,
88
71
  loading: boolean,
@@ -185,12 +185,17 @@ const getReferences = (
185
185
  msg: RemitImapDescribeMessageResponse,
186
186
  ): { inReplyTo?: string; references: string[] } => {
187
187
  const messageIdValue = msg.envelope.messageIdValue;
188
- const existingRefs = msg.references
189
- .filter((r) => r.referenceType === "references")
190
- .sort((a, b) => a.referenceOrder - b.referenceOrder)
191
- .map((r) => r.messageIdValue);
192
-
193
- const references = [...existingRefs];
188
+ const ofType = (
189
+ type: RemitImapDescribeMessageResponse["references"][number]["referenceType"],
190
+ ): string[] =>
191
+ msg.references
192
+ .filter((r) => r.referenceType === type)
193
+ .sort((a, b) => a.referenceOrder - b.referenceOrder)
194
+ .map((r) => r.messageIdValue);
195
+ const existingRefs = ofType("references");
196
+
197
+ const references =
198
+ existingRefs.length > 0 ? [...existingRefs] : ofType("in_reply_to");
194
199
  if (messageIdValue && !references.includes(messageIdValue)) {
195
200
  references.push(messageIdValue);
196
201
  }
@@ -1,7 +1,7 @@
1
1
  import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import { ErrorState } from "@remit/ui";
2
3
  import { useQuery } from "@tanstack/react-query";
3
4
  import { X } from "lucide-react";
4
- import { ErrorState } from "@/components/ui/ErrorState";
5
5
  import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
6
6
  import { useIsDesktop } from "@/hooks/useMediaQuery";
7
7
  import {
@@ -1,9 +1,8 @@
1
1
  import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import { ConfirmDialog, useOverlayScope } from "@remit/ui";
2
+ import { ConfirmDialog, ErrorState, useOverlayScope } from "@remit/ui";
3
3
  import { useQuery } from "@tanstack/react-query";
4
4
  import { useCallback, useRef, useState } from "react";
5
5
  import { Drawer } from "vaul";
6
- import { ErrorState } from "@/components/ui/ErrorState";
7
6
  import {
8
7
  useAdoptComposeDraft,
9
8
  useCloseCompose,
@@ -310,6 +310,24 @@ export const SaveFailed: Story = {
310
310
  },
311
311
  };
312
312
 
313
+ export const DiscardFailed: Story = {
314
+ args: { url: `/mail/${INBOX}/compose/out-plain` },
315
+ parameters: {
316
+ msw: { handlers: mailHandlers(world, { draftDiscard: "refused" }) },
317
+ },
318
+ play: async () => {
319
+ await userEvent.click(
320
+ await page().findByRole("button", { name: "Discard" }),
321
+ );
322
+ await expect(
323
+ await page().findByText("Couldn't discard draft"),
324
+ ).toBeVisible();
325
+ await expect(
326
+ await page().findByText("The draft is already gone."),
327
+ ).toBeVisible();
328
+ },
329
+ };
330
+
313
331
  export const Sending: Story = {
314
332
  args: { url: `/mail/${INBOX}/compose/out-misspelt` },
315
333
  parameters: {
@@ -0,0 +1,138 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, it } from "node:test";
3
+ import type {
4
+ RemitImapAccountResponse,
5
+ RemitImapDescribeMessageResponse,
6
+ } from "@remit/api-http-client/types.gen.ts";
7
+ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
8
+ import {
9
+ type AnyRouter,
10
+ createMemoryHistory,
11
+ createRootRoute,
12
+ createRoute,
13
+ createRouter,
14
+ RouterContextProvider,
15
+ } from "@tanstack/react-router";
16
+ import { createElement } from "react";
17
+ import { __resetFatalError } from "../../lib/fatal-error";
18
+ import {
19
+ handleMutationCacheError,
20
+ handleQueryCacheError,
21
+ } from "../../lib/query-error-handler";
22
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
23
+ import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
24
+ import { ComposeForm } from "./ComposeForm";
25
+ import { ComposeProvider } from "./ComposeProvider";
26
+
27
+ const OUTBOX_MESSAGE_ID = "ob-1";
28
+
29
+ const account = {
30
+ accountId: "acc-1",
31
+ email: "me@example.com",
32
+ smtpEnabled: true,
33
+ } as unknown as RemitImapAccountResponse;
34
+
35
+ const sourceMessage = {
36
+ message: { messageId: "msg-1" },
37
+ envelope: {
38
+ subject: "Lunch",
39
+ messageIdValue: "<m1@example.com>",
40
+ from: [{ normalizedEmail: "them@example.com", displayName: "Them" }],
41
+ replyTo: [],
42
+ to: [],
43
+ cc: [],
44
+ },
45
+ references: [],
46
+ bodyParts: [],
47
+ } as unknown as RemitImapDescribeMessageResponse;
48
+
49
+ const outboxEntry = {
50
+ outboxMessageId: OUTBOX_MESSAGE_ID,
51
+ accountId: account.accountId,
52
+ fromAddress: account.email,
53
+ toAddresses: ["them@example.com"],
54
+ attachments: [],
55
+ ccAddresses: [],
56
+ bccAddresses: [],
57
+ references: [],
58
+ subject: "Fwd: Lunch",
59
+ textBody: "here you go",
60
+ status: "draft",
61
+ };
62
+
63
+ let harness: DomHarness | undefined;
64
+ let http: HttpMock | undefined;
65
+
66
+ afterEach(() => {
67
+ harness?.close();
68
+ harness = undefined;
69
+ http?.restore();
70
+ http = undefined;
71
+ __resetFatalError();
72
+ });
73
+
74
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
75
+
76
+ const rootRoute = createRootRoute();
77
+ const mailboxRoute = createRoute({
78
+ getParentRoute: () => rootRoute,
79
+ path: "/mail/$mailboxId",
80
+ validateSearch: (search: Record<string, unknown>) => search,
81
+ });
82
+
83
+ const testRouter = (): AnyRouter =>
84
+ createRouter({
85
+ routeTree: rootRoute.addChildren([mailboxRoute]),
86
+ history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
87
+ }) as unknown as AnyRouter;
88
+
89
+ it("banners a refused draft discard instead of closing as if it worked", async () => {
90
+ http = mockFetch(async (call) => {
91
+ if (call.path.endsWith("/config")) return { accounts: [account] };
92
+ if (call.method === "DELETE") {
93
+ return httpError(409, "the draft is already gone");
94
+ }
95
+ return outboxEntry;
96
+ });
97
+
98
+ const queryClient = new QueryClient({
99
+ queryCache: new QueryCache({ onError: handleQueryCacheError }),
100
+ mutationCache: new MutationCache({ onError: handleMutationCacheError }),
101
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
102
+ });
103
+
104
+ harness = createDomHarness({ queryClient });
105
+ harness.renderApp(
106
+ createElement(RouterContextProvider, {
107
+ router: testRouter(),
108
+ // biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
109
+ children: createElement(
110
+ ComposeProvider,
111
+ null,
112
+ createElement(ComposeForm, {
113
+ mode: "forward",
114
+ account,
115
+ sourceMessage,
116
+ outboxMessageId: OUTBOX_MESSAGE_ID,
117
+ onDraftCreated: () => {},
118
+ onClose: () => {},
119
+ }),
120
+ ),
121
+ }),
122
+ );
123
+ await harness.flush();
124
+ await harness.wait(50);
125
+
126
+ const discard = harness.query('[aria-label="Discard"]');
127
+ if (!discard) throw new Error("the discard button is not mounted");
128
+ harness.click(discard);
129
+ await harness.flush();
130
+ await harness.wait(100);
131
+
132
+ const alerts = harness.queryAll(
133
+ '[aria-label="Notifications"] [role="alert"]',
134
+ );
135
+ assert.equal(alerts.length, 1, "one banner reports the failure");
136
+ assert.match(alerts[0]?.textContent ?? "", /Couldn.t discard draft/);
137
+ assert.match(alerts[0]?.textContent ?? "", /the draft is already gone/);
138
+ });
@@ -174,7 +174,7 @@ export const partialImportReport: RemitImapConfigImportReport = report({
174
174
  {
175
175
  code: "import_write_failed",
176
176
  message:
177
- "the filter store refused the write The items above it were written and remain; the import stopped here.",
177
+ "the filter store refused the write The items above it were written and remain; import the same file again with merge to finish.",
178
178
  details: { section: "filters", key: "Bonnetjes" },
179
179
  },
180
180
  ],
@@ -182,26 +182,25 @@ export const partialImportReport: RemitImapConfigImportReport = report({
182
182
  });
183
183
 
184
184
  /**
185
- * The same stop with no section written: a store that holds the import in one
186
- * transaction undoes the earlier writes, so the report carries no landed item
187
- * and `applied` is false. Warnings go with them.
185
+ * The same stop with no section written: the first write was the one that
186
+ * failed, so the report carries no landed item and `applied` is false.
188
187
  */
189
188
  export const nothingLandedImportReport: RemitImapConfigImportReport = report({
190
189
  applied: false,
191
190
  items: [
192
191
  item(
193
- "filters",
194
- "Bonnetjes",
192
+ "accounts",
193
+ "matthijs@ischen.nl",
195
194
  "rejected",
196
- "the filter store refused the write",
195
+ "the account store refused the write",
197
196
  ),
198
197
  ],
199
198
  errors: [
200
199
  {
201
200
  code: "import_write_failed",
202
201
  message:
203
- "the filter store refused the write Nothing was written; the import stopped here.",
204
- details: { section: "filters", key: "Bonnetjes" },
202
+ "the account store refused the write The items above it were written and remain; import the same file again with merge to finish.",
203
+ details: { section: "accounts", key: "matthijs@ischen.nl" },
205
204
  },
206
205
  ],
207
206
  warnings: [],
@@ -436,8 +436,8 @@ export const PartiallyLandedImport: Story = {
436
436
  };
437
437
 
438
438
  /**
439
- * The same stop with no section written — a transaction undid the earlier
440
- * writes, or the first write was the one that failed. No section may carry a
439
+ * The same stop with no section written — the first write was the one that
440
+ * failed. No section may carry a
441
441
  * tick, and the screen counts nothing as landed.
442
442
  */
443
443
  export const NothingLandedImport: Story = {
@@ -13,6 +13,7 @@ import { useMemo } from "react";
13
13
  import { AccountMenu } from "@/auth/AccountMenu";
14
14
  import { useRefreshControl } from "@/hooks/useRefreshControl";
15
15
  import { useSearchScope } from "@/hooks/useSearchScope";
16
+ import { useWarningsAttention } from "@/hooks/useWarningsAttention";
16
17
  import { openBugReport } from "@/lib/bug-report";
17
18
  import { useMailContext } from "@/lib/mail-context";
18
19
  import { useMailFreshness } from "@/lib/mail-freshness";
@@ -38,6 +39,7 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
38
39
  const goToSection = useGoToSection();
39
40
  const compose = useOpenCompose();
40
41
  const { scope, clearScope } = useSearchScope(accounts);
42
+ const settingsAttention = useWarningsAttention();
41
43
  const chips =
42
44
  scope.kind === "scoped"
43
45
  ? [{ id: scope.chip.id, label: scope.chip.label, tone: "scope" as const }]
@@ -80,6 +82,7 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
80
82
  onCompose={compose}
81
83
  onReportBug={openBugReport}
82
84
  onOpenSettings={() => goToSection("accounts")}
85
+ settingsAttention={settingsAttention}
83
86
  composeShortcut={shortcutHintForAction("compose")}
84
87
  refreshControl={
85
88
  <RefreshButton
@@ -374,7 +374,6 @@ function BriefReading() {
374
374
  isOpen={intelligence.drawerOpen}
375
375
  onClose={intelligence.closeDrawer}
376
376
  thread={selectedThread}
377
- mailboxId={selectedThread?.mailboxId}
378
377
  accountId={selectedThread?.accountId}
379
378
  onAfterOptimisticRemove={handleDeselectIfRemoved}
380
379
  onReplyWithText={replyWithText}
@@ -398,7 +397,6 @@ function BriefIntelligence() {
398
397
  <IntelligencePane
399
398
  onClose={onToggleIntelligence}
400
399
  thread={selectedThread}
401
- mailboxId={selectedThread?.mailboxId}
402
400
  accountId={selectedThread?.accountId}
403
401
  onAfterOptimisticRemove={handleDeselectIfRemoved}
404
402
  onReplyWithText={replyWithText}
@@ -444,7 +442,6 @@ function BriefPhone() {
444
442
  isOpen={drawer.isOpen}
445
443
  onClose={drawer.close}
446
444
  thread={selectedThread}
447
- mailboxId={selectedThread?.mailboxId}
448
445
  accountId={selectedThread?.accountId}
449
446
  onAfterOptimisticRemove={handleDeselectIfRemoved}
450
447
  onReplyWithText={replyWithText}
@@ -1,11 +1,9 @@
1
1
  import { threadDetailOperationsListThreadMessagesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
2
  import type { RemitImapMessageAuthenticity } from "@remit/api-http-client/types.gen.ts";
3
- import { MobileReadingPane } from "@remit/ui";
3
+ import { EmptyState, ErrorState, MobileReadingPane } from "@remit/ui";
4
4
  import { useQuery } from "@tanstack/react-query";
5
5
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
6
  import { ConversationCompose } from "@/components/compose/ConversationCompose";
7
- import { EmptyState } from "@/components/ui/EmptyState";
8
- import { ErrorState } from "@/components/ui/ErrorState";
9
7
  import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
10
8
  import { useMailboxAccount } from "@/hooks/useMailboxAccount";
11
9
  import { useMarkAsRead } from "@/hooks/useMarkAsRead";