@remit/web-client 0.0.321 → 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 +16 -4
  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 +44 -0
  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 +9 -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
@@ -10,9 +10,7 @@ import { FilterEditor } from "./FilterEditor";
10
10
  * `Flows/Settings Filters` — editing a standing filter in the same chip editor
11
11
  * the Organize surface uses (RFC 038 D6), rendered from the real web-client
12
12
  * component the settings page mounts. Each story opens a persisted filter into
13
- * the editor: a literal rule, an anchored rule with its widen chip, an
14
- * until-a-date rule, and the degraded case where the deployment cannot evaluate
15
- * the anchor and the widen lists inactive (D4).
13
+ * the editor: a literal rule and an until-a-date rule.
16
14
  */
17
15
 
18
16
  const ACCOUNT_ID = "acc-1";
@@ -47,7 +45,6 @@ const makeFilter = (
47
45
  scope: "Standing",
48
46
  state: "Active",
49
47
  disabledReason: "None",
50
- hasAnchor: false,
51
48
  ruleChangedAt: 0,
52
49
  actionChangedAt: 0,
53
50
  matchOperator: "And",
@@ -65,7 +62,7 @@ const makeFilter = (
65
62
  /**
66
63
  * A bare client with no backend. The editor opens on `loading` and its preview
67
64
  * settles to the count region's error state — these stories exercise the editor
68
- * chrome (chips, read-only lifecycle, the widen chip), not the live count.
65
+ * chrome (chips, read-only lifecycle), not the live count.
69
66
  */
70
67
  function seededClient(): QueryClient {
71
68
  return new QueryClient({
@@ -127,22 +124,6 @@ export const EditStandingRule: Story = {
127
124
  ),
128
125
  };
129
126
 
130
- /** A filter with a semantic anchor — the widen lists as an active chip. */
131
- export const EditAnchoredRule: Story = {
132
- name: "Edit Anchored Rule",
133
- render: () => (
134
- <SheetStage>
135
- <FilterEditor
136
- accountId={ACCOUNT_ID}
137
- filter={makeFilter({ hasAnchor: true, name: "GitHub" })}
138
- folders={FOLDERS}
139
- labels={LABELS}
140
- onClose={() => undefined}
141
- />
142
- </SheetStage>
143
- ),
144
- };
145
-
146
127
  /** A temporary filter — the scope loads as until-a-date with the stored date. */
147
128
  export const EditUntilADate: Story = {
148
129
  name: "Edit Until A Date",
@@ -162,24 +143,3 @@ export const EditUntilADate: Story = {
162
143
  </SheetStage>
163
144
  ),
164
145
  };
165
-
166
- /**
167
- * The deployment cannot evaluate the filter's anchor (RFC 038 D4): the widen
168
- * chip lists inactive, the rule matches by its literal clauses only, and the
169
- * dead anchor is removable.
170
- */
171
- export const DegradedAnchor: Story = {
172
- name: "Degraded Anchor",
173
- render: () => (
174
- <SheetStage>
175
- <FilterEditor
176
- accountId={ACCOUNT_ID}
177
- filter={makeFilter({ hasAnchor: true, name: "Newsletters" })}
178
- folders={FOLDERS}
179
- labels={LABELS}
180
- semanticUnavailable
181
- onClose={() => undefined}
182
- />
183
- </SheetStage>
184
- ),
185
- };
@@ -0,0 +1,290 @@
1
+ import type {
2
+ RemitImapQuarantineResponse,
3
+ RemitImapSyncWarningResponse,
4
+ } from "@remit/api-http-client/types.gen.ts";
5
+ import type { Meta, StoryObj } from "@storybook/react-vite";
6
+ import { HttpResponse, http } from "msw";
7
+ import { expect, userEvent, within } from "storybook/test";
8
+ import { AppStory } from "@/mocks/story-frame/AppStory";
9
+ import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
10
+ import {
11
+ MINUTE,
12
+ mailboxIdFor,
13
+ mailWorld,
14
+ NOW,
15
+ PERSONAL,
16
+ } from "@/mocks/story-frame/mail-world";
17
+
18
+ const setAside = (
19
+ id: string,
20
+ role: "Inbox" | "Archive",
21
+ failureCode: RemitImapQuarantineResponse["failureCode"],
22
+ failureMessage: string,
23
+ ): RemitImapQuarantineResponse => ({
24
+ quarantineId: `q-${id}`,
25
+ accountConfigId: "cfg-1",
26
+ accountId: PERSONAL,
27
+ mailboxId: mailboxIdFor(PERSONAL, role),
28
+ uidValidity: 1_712_000_000,
29
+ uid: 40_217,
30
+ mailboxRole: role,
31
+ mailboxPath: role === "Inbox" ? "INBOX" : "Archive",
32
+ quarantinedAt: NOW,
33
+ attempts: 3,
34
+ failureStage: "BodyParse",
35
+ failureCode,
36
+ failureMessage,
37
+ workerVersion: "worker 1.0.0",
38
+ contentType: "multipart/mixed",
39
+ sizeBytes: 184_233,
40
+ structure: [{ depth: 0, contentType: "multipart/mixed" }],
41
+ createdAt: NOW,
42
+ updatedAt: NOW,
43
+ });
44
+
45
+ const failing = (
46
+ uid: number,
47
+ role: "Inbox" | "Archive",
48
+ stage: RemitImapSyncWarningResponse["stage"],
49
+ attempts: number,
50
+ ): RemitImapSyncWarningResponse => ({
51
+ syncWarningId: `w-${uid}`,
52
+ accountConfigId: "cfg-1",
53
+ accountId: PERSONAL,
54
+ mailboxId: mailboxIdFor(PERSONAL, role),
55
+ uidValidity: 1_712_000_000,
56
+ uid,
57
+ stage,
58
+ attempts,
59
+ createdAt: NOW - uid * MINUTE,
60
+ });
61
+
62
+ const unreadableBody = setAside(
63
+ "1",
64
+ "Inbox",
65
+ "UnreadableBody",
66
+ "multipart boundary was never closed",
67
+ );
68
+ const unknownCharset = setAside(
69
+ "2",
70
+ "Archive",
71
+ "UnknownCharset",
72
+ "declared charset is not a known encoding",
73
+ );
74
+
75
+ const bodyGaveUp = failing(3, "Inbox", "Body", 3);
76
+ const headerRetrying = failing(5, "Archive", "Header", 6);
77
+
78
+ const meta = {
79
+ title: "Playground/Shipped/Settings/Warnings",
80
+ component: AppStory,
81
+ args: { url: "/settings/warnings" },
82
+ parameters: {
83
+ layout: "fullscreen",
84
+ msw: { handlers: mailHandlers(mailWorld()) },
85
+ },
86
+ } satisfies Meta<typeof AppStory>;
87
+ export default meta;
88
+
89
+ type Story = StoryObj<typeof meta>;
90
+
91
+ export const Empty: Story = {
92
+ play: async ({ canvasElement }) => {
93
+ const canvas = within(canvasElement);
94
+ await expect(
95
+ await canvas.findByText(/Every message is syncing/),
96
+ ).toBeVisible();
97
+ await expect(
98
+ canvas.getByText(/Every message has been read successfully/),
99
+ ).toBeVisible();
100
+ },
101
+ };
102
+
103
+ export const Mixed: Story = {
104
+ parameters: {
105
+ msw: {
106
+ handlers: mailHandlers(
107
+ mailWorld({
108
+ syncWarnings: [bodyGaveUp, headerRetrying],
109
+ syncWarningsTotal: 2,
110
+ quarantine: [unreadableBody],
111
+ }),
112
+ ),
113
+ },
114
+ },
115
+ play: async ({ canvasElement }) => {
116
+ const canvas = within(canvasElement);
117
+ await expect(await canvas.findByText("Gave up")).toBeVisible();
118
+ await expect(canvas.getByText("Retrying")).toBeVisible();
119
+ await expect(
120
+ canvas.getByText(/multipart boundary was never closed/),
121
+ ).toBeVisible();
122
+ },
123
+ };
124
+
125
+ export const OverCap: Story = {
126
+ parameters: {
127
+ msw: {
128
+ handlers: mailHandlers(
129
+ mailWorld({
130
+ syncWarnings: [bodyGaveUp, headerRetrying],
131
+ syncWarningsTotal: 4_812,
132
+ }),
133
+ ),
134
+ },
135
+ },
136
+ play: async ({ canvasElement }) => {
137
+ const canvas = within(canvasElement);
138
+ await expect(
139
+ await canvas.findByText(/Showing the 2 newest of 4812/),
140
+ ).toBeVisible();
141
+ },
142
+ };
143
+
144
+ export const QuarantineAlert: Story = {
145
+ parameters: {
146
+ msw: {
147
+ handlers: mailHandlers(
148
+ mailWorld({ quarantine: [unreadableBody, unknownCharset] }),
149
+ ),
150
+ },
151
+ },
152
+ play: async ({ canvasElement }) => {
153
+ const canvas = within(canvasElement);
154
+ await expect(
155
+ await canvas.findByText("2 messages could not be read."),
156
+ ).toBeVisible();
157
+ },
158
+ };
159
+
160
+ export const Loading: Story = {
161
+ parameters: {
162
+ msw: {
163
+ handlers: [
164
+ http.get(
165
+ "/api/me/quarantine",
166
+ () => new Promise<never>(() => undefined),
167
+ ),
168
+ ...mailHandlers(mailWorld()),
169
+ ],
170
+ },
171
+ },
172
+ play: async ({ canvasElement }) => {
173
+ const canvas = within(canvasElement);
174
+ await expect(
175
+ await canvas.findByText("Checking for warnings…"),
176
+ ).toBeVisible();
177
+ },
178
+ };
179
+
180
+ export const ReadFailure: Story = {
181
+ parameters: {
182
+ msw: {
183
+ handlers: [
184
+ http.get("/api/me/quarantine", () => HttpResponse.error()),
185
+ ...mailHandlers(mailWorld()),
186
+ ],
187
+ },
188
+ },
189
+ play: async ({ canvasElement }) => {
190
+ const canvas = within(canvasElement);
191
+ await expect(
192
+ await canvas.findByText("The warnings could not be read."),
193
+ ).toBeVisible();
194
+ await expect(
195
+ canvas.getByRole("button", { name: "Report this" }),
196
+ ).toBeVisible();
197
+ },
198
+ };
199
+
200
+ export const SignedOut: Story = {
201
+ parameters: {
202
+ msw: {
203
+ handlers: [
204
+ http.get("/api/me/quarantine", () =>
205
+ HttpResponse.json(
206
+ { status: 401, message: "Unauthorized" },
207
+ { status: 401 },
208
+ ),
209
+ ),
210
+ ...mailHandlers(mailWorld()),
211
+ ],
212
+ },
213
+ },
214
+ play: async ({ canvasElement }) => {
215
+ const canvas = within(canvasElement);
216
+ await expect(
217
+ await canvas.findByText("Your session has ended."),
218
+ ).toBeVisible();
219
+ await expect(canvas.queryByText(/Every message is syncing/)).toBeNull();
220
+ },
221
+ };
222
+
223
+ const failingWorld = () =>
224
+ mailHandlers(mailWorld({ syncWarnings: [bodyGaveUp], syncWarningsTotal: 1 }));
225
+
226
+ export const NavDot: Story = {
227
+ parameters: { msw: { handlers: failingWorld() } },
228
+ play: async ({ canvasElement }) => {
229
+ const canvas = within(canvasElement);
230
+ await expect(await canvas.findByText("Sync warnings")).toBeInTheDocument();
231
+ },
232
+ };
233
+
234
+ export const DotOnMail: Story = {
235
+ args: { url: "/mail/flagged" },
236
+ parameters: { msw: { handlers: failingWorld() } },
237
+ play: async ({ canvasElement }) => {
238
+ const canvas = within(canvasElement);
239
+ await expect(
240
+ await canvas.findByRole("button", { name: "Settings — Sync warnings" }),
241
+ ).toBeVisible();
242
+ },
243
+ };
244
+
245
+ export const NoDotOnMail: Story = {
246
+ args: { url: "/mail/flagged" },
247
+ play: async ({ canvasElement }) => {
248
+ const canvas = within(canvasElement);
249
+ await expect(
250
+ await canvas.findByRole("button", { name: "Settings" }),
251
+ ).toBeVisible();
252
+ await expect(canvas.queryByText("Sync warnings")).toBeNull();
253
+ },
254
+ };
255
+
256
+ export const DotOnPhone: Story = {
257
+ args: { url: "/mail/flagged" },
258
+ globals: { viewport: { value: "mobile", isRotated: false } },
259
+ parameters: { msw: { handlers: failingWorld() } },
260
+ play: async ({ canvasElement }) => {
261
+ const canvas = within(canvasElement);
262
+ const [menu] = await canvas.findAllByRole("button", { name: "Menu" });
263
+ await userEvent.click(menu);
264
+ const page = within(canvasElement.ownerDocument.body);
265
+ await expect(await page.findByText("Sync warnings")).toBeInTheDocument();
266
+ },
267
+ };
268
+
269
+ export const DotOnSignedOut: Story = {
270
+ args: { url: "/mail/flagged" },
271
+ parameters: {
272
+ msw: {
273
+ handlers: [
274
+ http.get("/api/me/quarantine", () =>
275
+ HttpResponse.json(
276
+ { status: 401, message: "Unauthorized" },
277
+ { status: 401 },
278
+ ),
279
+ ),
280
+ ...mailHandlers(mailWorld()),
281
+ ],
282
+ },
283
+ },
284
+ play: async ({ canvasElement }) => {
285
+ const canvas = within(canvasElement);
286
+ await expect(
287
+ await canvas.findByRole("button", { name: "Settings — Session ended" }),
288
+ ).toBeVisible();
289
+ },
290
+ };
@@ -1,3 +1,8 @@
1
+ import {
2
+ type ErrorBannerAction,
3
+ type ErrorBannerEntry,
4
+ ErrorBannerStack,
5
+ } from "@remit/ui";
1
6
  import {
2
7
  createContext,
3
8
  type ReactNode,
@@ -9,13 +14,10 @@ import {
9
14
  import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
10
15
  import { isAlwaysFatal } from "@/lib/error-classifier";
11
16
  import { reportFatalError } from "@/lib/fatal-error";
12
- import { ErrorBannerStack } from "./ErrorBannerStack.js";
13
17
  import {
14
18
  appendBanner,
15
19
  buildEntry,
16
20
  dismissBanner,
17
- type ErrorBannerAction,
18
- type ErrorBannerEntry,
19
21
  type PushErrorInput,
20
22
  } from "./error-banners.js";
21
23
 
@@ -1,11 +1,10 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
+ import type { ErrorBannerEntry } from "@remit/ui";
3
4
  import {
4
5
  appendBanner,
5
- buildEntry,
6
6
  buildMutationErrorBanner,
7
7
  dismissBanner,
8
- type ErrorBannerEntry,
9
8
  formatErrorDetail,
10
9
  isMessageNotFoundError,
11
10
  } from "./error-banners.js";
@@ -20,26 +19,6 @@ const make = (
20
19
  ...overrides,
21
20
  });
22
21
 
23
- describe("buildEntry", () => {
24
- test("defaults to error severity", () => {
25
- const entry = buildEntry({ title: "boom" }, "id-1", 1000);
26
- assert.equal(entry.severity, "error");
27
- assert.equal(entry.title, "boom");
28
- assert.equal(entry.id, "id-1");
29
- assert.equal(entry.createdAt, 1000);
30
- });
31
-
32
- test("preserves explicit severity and detail", () => {
33
- const entry = buildEntry(
34
- { severity: "warning", title: "watch", detail: "context" },
35
- "id-2",
36
- 2000,
37
- );
38
- assert.equal(entry.severity, "warning");
39
- assert.equal(entry.detail, "context");
40
- });
41
- });
42
-
43
22
  describe("appendBanner", () => {
44
23
  test("adds an entry at the end of the stack", () => {
45
24
  const a = make({ id: "1", title: "first" });
@@ -1,23 +1,9 @@
1
- export type ErrorBannerSeverity = "error" | "warning" | "info";
2
-
3
- /**
4
- * A way out of the banner. A failure the user cannot act on is still theirs to
5
- * report, and a link they have to assemble themselves is one nobody follows.
6
- */
7
- export interface ErrorBannerAction {
8
- label: string;
9
- href: string;
10
- }
11
-
12
- export interface ErrorBannerEntry {
13
- id: string;
14
- severity: ErrorBannerSeverity;
15
- title: string;
16
- detail?: string;
17
- action?: ErrorBannerAction;
18
- createdAt: number;
19
- }
20
-
1
+ import type {
2
+ ErrorBannerAction,
3
+ ErrorBannerEntry,
4
+ ErrorBannerSeverity,
5
+ } from "@remit/ui";
6
+ import { codedApiErrorBody } from "@/lib/api";
21
7
  export interface PushErrorInput {
22
8
  severity?: ErrorBannerSeverity;
23
9
  title: string;
@@ -67,6 +53,9 @@ export const formatErrorDetail = (error: unknown): string | undefined => {
67
53
  return undefined;
68
54
  };
69
55
 
56
+ export const isMessageGoneFromServerError = (error: unknown): boolean =>
57
+ codedApiErrorBody(error)?.code === "message_gone_from_server";
58
+
70
59
  /**
71
60
  * Detect a "describeMessage hit a deleted/missing row" error.
72
61
  *
@@ -10,6 +10,7 @@
10
10
  * editor's picker and the address filter all read. A delete or a rezone also
11
11
  * moves or removes occurrences, so the event reads are invalidated with it.
12
12
  */
13
+
13
14
  import {
14
15
  calendarDetailOperationsDeleteCalendarMutation,
15
16
  calendarDetailOperationsUpdateCalendarMutation,
@@ -20,9 +21,9 @@ import type {
20
21
  RemitImapCreateCalendarInput,
21
22
  RemitImapUpdateCalendarInput,
22
23
  } from "@remit/api-http-client/types.gen.ts";
24
+ import { formatErrorMessage } from "@remit/ui";
23
25
  import { useMutation, useQueryClient } from "@tanstack/react-query";
24
26
  import { useCallback, useMemo } from "react";
25
- import { formatErrorMessage } from "@/components/ui/ErrorState";
26
27
  import { softErrorStatuses } from "@/lib/error-classifier";
27
28
  import { useInvalidateCalendarReads } from "./useCalendarWrites";
28
29
 
@@ -11,6 +11,7 @@
11
11
  * must state from a fault it must not swallow. A lapsed session is neither, and
12
12
  * escalates the way it does everywhere else.
13
13
  */
14
+
14
15
  import {
15
16
  calendarSuggestionActionOperationsAcceptCalendarSuggestionMutation,
16
17
  calendarSuggestionActionOperationsDeclineCalendarSuggestionMutation,
@@ -24,6 +25,7 @@ import type {
24
25
  RemitImapAcceptCalendarSuggestionInput,
25
26
  RemitImapCalendarSuggestionResponse,
26
27
  } from "@remit/api-http-client/types.gen.ts";
28
+ import { formatErrorMessage } from "@remit/ui";
27
29
  import {
28
30
  type QueryClient,
29
31
  useMutation,
@@ -31,7 +33,6 @@ import {
31
33
  useQueryClient,
32
34
  } from "@tanstack/react-query";
33
35
  import { useCallback, useMemo } from "react";
34
- import { formatErrorMessage } from "@/components/ui/ErrorState";
35
36
  import { softErrorStatuses } from "@/lib/error-classifier";
36
37
 
37
38
  const SUGGESTION_META = softErrorStatuses(400, 404);
@@ -13,6 +13,7 @@
13
13
  * statuses this surface owns, so a lapsed session takes the screen the way it
14
14
  * does everywhere else.
15
15
  */
16
+
16
17
  import {
17
18
  calendarEventDetailOperationsDeleteCalendarEventMutation,
18
19
  calendarEventDetailOperationsUpdateCalendarEventMutation,
@@ -24,9 +25,9 @@ import type {
24
25
  RemitImapUpdateCalendarEventInput,
25
26
  } from "@remit/api-http-client/types.gen.ts";
26
27
  import type { RecurrenceScope } from "@remit/ui";
28
+ import { formatErrorMessage } from "@remit/ui";
27
29
  import { useMutation, useQueryClient } from "@tanstack/react-query";
28
30
  import { useCallback, useMemo } from "react";
29
- import { formatErrorMessage } from "@/components/ui/ErrorState";
30
31
  import { getErrorStatus, softErrorStatuses } from "@/lib/error-classifier";
31
32
 
32
33
  /** The refusals a calendar surface renders itself. A 401 is not among them. */
@@ -53,7 +53,6 @@ const sender: IntelligenceData = {
53
53
  summary: "Signed by example.test.",
54
54
  },
55
55
  category: { value: "personal" },
56
- similar: [],
57
56
  };
58
57
 
59
58
  const calendars = [
@@ -1,10 +1,7 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
3
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
4
- import {
5
- dropDeletedThreads,
6
- removeMessagesFromItems,
7
- } from "./useDeleteMessages.js";
4
+ import { dropDeletedThreads } from "./useDeleteMessages.js";
8
5
 
9
6
  const make = (
10
7
  overrides: Partial<RemitImapThreadMessageResponse> & {
@@ -26,44 +23,6 @@ const make = (
26
23
  ...overrides,
27
24
  }) as RemitImapThreadMessageResponse;
28
25
 
29
- describe("removeMessagesFromItems", () => {
30
- test("drops items whose messageId is in the set", () => {
31
- const items = [
32
- make({ messageId: "m1", threadMessageId: "tm1" }),
33
- make({ messageId: "m2", threadMessageId: "tm2" }),
34
- make({ messageId: "m3", threadMessageId: "tm3" }),
35
- ];
36
- const got = removeMessagesFromItems(items, new Set(["m2"]));
37
- assert.deepStrictEqual(
38
- got.map((i) => i.messageId),
39
- ["m1", "m3"],
40
- );
41
- });
42
-
43
- test("returns the input unchanged when nothing matches", () => {
44
- const items = [
45
- make({ messageId: "m1", threadMessageId: "tm1" }),
46
- make({ messageId: "m2", threadMessageId: "tm2" }),
47
- ];
48
- const got = removeMessagesFromItems(items, new Set(["does-not-exist"]));
49
- assert.equal(got.length, 2);
50
- });
51
-
52
- test("returns an empty array when all items match", () => {
53
- const items = [
54
- make({ messageId: "m1", threadMessageId: "tm1" }),
55
- make({ messageId: "m2", threadMessageId: "tm2" }),
56
- ];
57
- const got = removeMessagesFromItems(items, new Set(["m1", "m2"]));
58
- assert.deepStrictEqual(got, []);
59
- });
60
-
61
- test("returns an empty array for empty input", () => {
62
- const got = removeMessagesFromItems([], new Set(["m1"]));
63
- assert.deepStrictEqual(got, []);
64
- });
65
- });
66
-
67
26
  describe("dropDeletedThreads (#212)", () => {
68
27
  // Belt-and-braces UI filter for issue #212. If the backend regresses and
69
28
  // returns a soft-deleted row, the inbox list must still hide it. The
@@ -32,7 +32,6 @@ const filter = (
32
32
  name: "Travel",
33
33
  scope: "Standing",
34
34
  state: "Active",
35
- hasAnchor: false,
36
35
  ruleChangedAt: 0,
37
36
  actionChangedAt: 0,
38
37
  matchOperator: "And",
@@ -79,13 +78,6 @@ describe("buildFilterListKey", () => {
79
78
  filterOperationsListFiltersQueryKey({ path: { accountId: ACCOUNT_ID } }),
80
79
  );
81
80
  });
82
-
83
- test("is distinct per account, so one account's list is not invalidated by another's write", () => {
84
- assert.notDeepEqual(
85
- buildFilterListKey("acc-1"),
86
- buildFilterListKey("acc-2"),
87
- );
88
- });
89
81
  });
90
82
 
91
83
  describe("useFilterList", () => {
@@ -12,10 +12,7 @@ import assert from "node:assert/strict";
12
12
  import { afterEach, describe, it } from "node:test";
13
13
  import { createElement } from "react";
14
14
  import { createDomHarness, type DomHarness } from "../test-support/dom";
15
- import {
16
- FOLLOW_FOCUS_DELAY_MS,
17
- useFollowFocusOpen,
18
- } from "./useFollowFocusOpen";
15
+ import { useFollowFocusOpen } from "./useFollowFocusOpen";
19
16
 
20
17
  let harness: DomHarness | undefined;
21
18
 
@@ -146,10 +143,3 @@ describe("useFollowFocusOpen", () => {
146
143
  assert.deepEqual(driver.opened, []);
147
144
  });
148
145
  });
149
-
150
- describe("FOLLOW_FOCUS_DELAY_MS", () => {
151
- it("sits above key repeat and below what reads as a wait", () => {
152
- assert.ok(FOLLOW_FOCUS_DELAY_MS >= 60);
153
- assert.ok(FOLLOW_FOCUS_DELAY_MS <= 250);
154
- });
155
- });