@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
@@ -1,270 +0,0 @@
1
- /**
2
- * The roll-call no run can take: which surfaces exist, and which function each
3
- * one's verbs name.
4
- *
5
- * Everything a selection surface *does* — the review screen naming what a verb
6
- * covers, a run reaching the mail server — is driven end to end by
7
- * `packages/e2e`. What no run observes is a surface that was never wired: a
8
- * fourth pane growing its own direct handler while every other route was moved,
9
- * a fourth list rendering the make-filter row with no wizard behind it, or a
10
- * verb on a bar nobody presses in a spec quietly keeping a direct call. The
11
- * suite has locators for Delete, Move and Organize and none for Junk or
12
- * Mark-read, so three of the five are reachable only from here.
13
- *
14
- * So this reads the source, and reads it for the census: who raises a bar, what
15
- * each of its verbs is wired to, who declares a keyboard verb over a selection,
16
- * who renders the make-filter row. Every census is discovered from the
17
- * directory rather than listed, so a fourth surface is a failure here until it
18
- * is wired like the other three.
19
- */
20
-
21
- import assert from "node:assert/strict";
22
- import { readdirSync, readFileSync } from "node:fs";
23
- import { dirname, resolve } from "node:path";
24
- import { describe, it } from "node:test";
25
- import { fileURLToPath } from "node:url";
26
-
27
- const here = dirname(fileURLToPath(import.meta.url));
28
- const read = (file: string): string =>
29
- readFileSync(resolve(here, file), "utf8");
30
-
31
- const sourceFiles = (): string[] =>
32
- readdirSync(here).filter((file) => file.endsWith(".tsx"));
33
-
34
- const BAR_VERB_PROPS = [
35
- "onDelete",
36
- "onMove",
37
- "onOrganize",
38
- "onJunk",
39
- "onMarkRead",
40
- ] as const;
41
-
42
- /**
43
- * How a surface may answer a verb prop. `startWizard`/`wizard.start`/
44
- * `startSelectionVerb` open the wizard on the verb; `organizeSelection` and
45
- * `startFromSearch` open it on the search entry, which is the same wizard
46
- * reached by its other door (#477 1.8). Anything else routes around the review
47
- * screen that names what the verb covers (#477 1.4).
48
- */
49
- const OPENS_THE_WIZARD =
50
- /^(startWizard|wizard\.start|startSelectionVerb|organizeSelection|startFromSearch)\b/;
51
-
52
- const bracedFrom = (source: string, open: number): string => {
53
- let depth = 0;
54
- for (let i = open; i < source.length; i++) {
55
- if (source[i] === "{") depth++;
56
- else if (source[i] === "}") {
57
- depth--;
58
- if (depth === 0) return source.slice(open, i + 1);
59
- }
60
- }
61
- return source.slice(open);
62
- };
63
-
64
- const namedCallbackBody = (source: string, name: string): string => {
65
- const at = source.indexOf(`const ${name} = useCallback(`);
66
- if (at === -1) return "";
67
- const open = source.indexOf("{", at);
68
- return open === -1 ? "" : bracedFrom(source, open);
69
- };
70
-
71
- /**
72
- * The bar element as written, to its own closing tag. Found by matching tags
73
- * rather than by stopping at the first `/>` on a line: a nested self-closing
74
- * element matches that too, so the scan would end early and silently skip
75
- * whatever a surface happened to write after it.
76
- */
77
- const barMarkup = (source: string): string => {
78
- const start = source.indexOf("<SelectionTopBar");
79
- if (start === -1) return "";
80
- let depth = 0;
81
- for (let i = start; i < source.length; i++) {
82
- if (source[i] === "<" && /[A-Za-z]/.test(source[i + 1] ?? "")) depth++;
83
- else if (source.startsWith("/>", i)) {
84
- depth--;
85
- if (depth === 0) return source.slice(start, i + 2);
86
- i++;
87
- } else if (source.startsWith("</", i)) depth--;
88
- }
89
- return "";
90
- };
91
-
92
- /**
93
- * What a verb prop is wired to, with the whitespace, the guard a verb is
94
- * offered behind, and the arrow taken off — what is left is the call itself.
95
- */
96
- const verbHandler = (bar: string, prop: string): string | undefined => {
97
- const assigned = bar.match(new RegExp(`${prop}=\\{([\\s\\S]*?)\\}\\n`))?.[1];
98
- if (assigned === undefined) return undefined;
99
- return assigned
100
- .replace(/\s+/g, " ")
101
- .replace(/^.*? \? /, "")
102
- .replace(/ : undefined$/, "")
103
- .replace(/^\(\)\s*=>\s*/, "")
104
- .trim();
105
- };
106
-
107
- const declaredHandlers = (source: string): string[] => {
108
- const at = source.indexOf("handlers: {");
109
- if (at === -1) return [];
110
- return Array.from(
111
- bracedFrom(source, source.indexOf("{", at)).matchAll(/\n\t{3}(\w+): /g),
112
- (match) => match[1],
113
- );
114
- };
115
-
116
- /**
117
- * What a pane does for one keyboard verb: the handler it wired into the triage
118
- * layer, plus any callback that handler defers to for its target. A handler
119
- * that offers the list the press and then falls through to a helper picking the
120
- * selection is exactly as wrong as one picking it inline, so both are read.
121
- */
122
- const paneVerbBody = (source: string, handler: string): string => {
123
- const at = source.search(new RegExp(`\\n\\t{3}${handler}: `));
124
- if (at === -1) return "";
125
- const value =
126
- source.slice(at).match(new RegExp(`${handler}: (.*)`))?.[1] ?? "";
127
- const body = value.startsWith("(")
128
- ? bracedFrom(source, source.indexOf("{", at))
129
- : namedCallbackBody(source, value.replace(/[,\s].*$/, ""));
130
- const helpers = Array.from(body.matchAll(/\b(\w+MessageIds)\(\)/g), (match) =>
131
- namedCallbackBody(source, match[1]),
132
- );
133
- return [body, ...helpers].join("\n");
134
- };
135
-
136
- describe("the surfaces that raise a selection bar", () => {
137
- const surfaces = (): string[] =>
138
- sourceFiles()
139
- .filter((file) => /<SelectionTopBar/.test(read(file)))
140
- .sort();
141
-
142
- it("are these three and no others", () => {
143
- assert.deepEqual(surfaces(), [
144
- "DailyBrief.tsx",
145
- "MessageList.tsx",
146
- "ThreadListInteraction.tsx",
147
- ]);
148
- });
149
-
150
- for (const file of surfaces()) {
151
- it(`${file} routes every verb it offers through the wizard`, () => {
152
- const bar = barMarkup(read(file));
153
- assert.notEqual(bar, "", `${file} renders a SelectionTopBar`);
154
- const offered = BAR_VERB_PROPS.filter((prop) => bar.includes(`${prop}=`));
155
- assert.ok(offered.length > 0, `${file} offers at least one verb`);
156
- for (const prop of offered) {
157
- const handler = verbHandler(bar, prop);
158
- assert.ok(handler, `${file}'s ${prop} is wired to something`);
159
- assert.match(
160
- handler,
161
- OPENS_THE_WIZARD,
162
- `${file}'s ${prop} must open the wizard, not run the verb`,
163
- );
164
- }
165
- });
166
- }
167
-
168
- for (const file of ["DailyBrief.tsx", "MessageList.tsx"]) {
169
- it(`${file} drops none of the five verbs from the bar it raises`, () => {
170
- const bar = barMarkup(read(file));
171
- for (const prop of BAR_VERB_PROPS) {
172
- assert.match(bar, new RegExp(`${prop}=`), `${file} offers ${prop}`);
173
- }
174
- });
175
- }
176
- });
177
-
178
- // A pane that runs a verb over `selectedIds` without offering the list the
179
- // press first is the hole no run can see: the pane's fallback and the list's
180
- // claim are indistinguishable from outside until a selection outlives the
181
- // list's commands. That the list claims the press is rendered in
182
- // `./ThreadListInteraction.test.ts`.
183
- describe("the panes that answer a keyboard verb over a selection", () => {
184
- const PANE_VERB_ROUTE: Record<string, string> = {
185
- delete: "delete",
186
- toggleRead: "markRead",
187
- markJunk: "junk",
188
- };
189
-
190
- const panes = (): string[] =>
191
- sourceFiles()
192
- .filter((file) => read(file).includes("handlers: {"))
193
- .sort();
194
-
195
- const routed = (): string[] =>
196
- panes()
197
- .flatMap((file) =>
198
- declaredHandlers(read(file))
199
- .filter((handler) => handler in PANE_VERB_ROUTE)
200
- .map((handler) => `${file}:${handler}`),
201
- )
202
- .sort();
203
-
204
- it("are these three panes and no others", () => {
205
- assert.deepEqual(panes(), [
206
- "BriefPane.tsx",
207
- "FlaggedPane.tsx",
208
- "MailboxPane.tsx",
209
- ]);
210
- });
211
-
212
- it("declare these seven routed handlers and no others", () => {
213
- assert.deepEqual(routed(), [
214
- "BriefPane.tsx:delete",
215
- "BriefPane.tsx:toggleRead",
216
- "FlaggedPane.tsx:delete",
217
- "FlaggedPane.tsx:toggleRead",
218
- "MailboxPane.tsx:delete",
219
- "MailboxPane.tsx:markJunk",
220
- "MailboxPane.tsx:toggleRead",
221
- ]);
222
- });
223
-
224
- for (const entry of routed()) {
225
- const [file, handler] = entry.split(":");
226
- const verb = PANE_VERB_ROUTE[handler];
227
-
228
- it(`${entry} offers the list ${verb} before running anything`, () => {
229
- const body = paneVerbBody(read(file), handler);
230
- assert.notEqual(body, "", `${handler}'s body was found`);
231
- assert.match(body, new RegExp(`requestVerb\\("${verb}"\\)`));
232
- });
233
-
234
- it(`${entry} falls back to the focused row, never to a selection`, () => {
235
- const body = paneVerbBody(read(file), handler);
236
- assert.notEqual(body, "", `${handler}'s body was found`);
237
- assert.doesNotMatch(body, /selectedIds/i);
238
- });
239
- }
240
- });
241
-
242
- // The row is built once by `MailListHeader` and handed down as `makeFilterSlot`,
243
- // but the wizard answering the step it pushes is mounted per surface. A surface
244
- // that renders the row without one leaves the press on a URL nothing answers.
245
- describe("the surfaces that render the make-filter row", () => {
246
- const ANSWERED_BY: Record<string, string> = {
247
- "DailyBrief.tsx": "DailyBrief.tsx",
248
- "MessageList.tsx": "MessageList.tsx",
249
- "ThreadListInteraction.tsx": "FlaggedList.tsx",
250
- };
251
-
252
- it("are these three and no others", () => {
253
- assert.deepEqual(
254
- sourceFiles()
255
- .filter((file) => /\.makeFilterSlot/.test(read(file)))
256
- .sort(),
257
- Object.keys(ANSWERED_BY).sort(),
258
- );
259
- });
260
-
261
- it("each name a file that mounts a wizard to answer the step", () => {
262
- for (const [surface, host] of Object.entries(ANSWERED_BY)) {
263
- assert.match(
264
- read(host),
265
- /<SelectionWizardHost/,
266
- `${surface} is answered by ${host}`,
267
- );
268
- }
269
- });
270
- });
@@ -1,71 +0,0 @@
1
- /**
2
- * The pane in Settings › Advanced that is the only place a quarantined message
3
- * is ever mentioned. Every read state has to say something: an empty list is
4
- * the normal one, and a failed read is never a blank panel.
5
- */
6
- import assert from "node:assert/strict";
7
- import { describe, it } from "node:test";
8
- import { type QuarantineEntry, quarantineDemoEntries } from "@remit/ui";
9
- import React, { createElement } from "react";
10
- import { renderToString } from "react-dom/server";
11
- import { QuarantinePanelView } from "./QuarantinePanel";
12
-
13
- // See MessageToolbar.render.test.ts: the SSR test loader transpiles remit-ui
14
- // `.tsx` with the classic JSX runtime, which needs a global `React`.
15
- (globalThis as { React?: typeof React }).React = React;
16
-
17
- const render = (props: {
18
- entries: readonly QuarantineEntry[];
19
- isPending: boolean;
20
- error: unknown;
21
- }): string =>
22
- renderToString(createElement(QuarantinePanelView, props) as never);
23
-
24
- describe("QuarantinePanelView", () => {
25
- it("says nothing was set aside rather than rendering nothing", () => {
26
- const html = render({ entries: [], isPending: false, error: null });
27
- assert.match(html, /Messages set aside/);
28
- assert.match(html, /Nothing is set aside/);
29
- });
30
-
31
- it("lists a set-aside message with a way to report it", () => {
32
- const html = render({
33
- entries: quarantineDemoEntries.slice(0, 1),
34
- isPending: false,
35
- error: null,
36
- });
37
- assert.match(html, /Cut a bug/);
38
- assert.match(html, /could not read/i);
39
- });
40
-
41
- it("calls more than one set aside a pattern, not a list", () => {
42
- const html = render({
43
- entries: quarantineDemoEntries,
44
- isPending: false,
45
- error: null,
46
- });
47
- assert.match(
48
- html,
49
- new RegExp(`${quarantineDemoEntries.length} messages could not be read`),
50
- );
51
- });
52
-
53
- it("reports a failed read in the pane, with a way to file it", () => {
54
- const html = render({
55
- entries: [],
56
- isPending: false,
57
- error: new Error("Failed to fetch"),
58
- });
59
- assert.match(html, /role="alert"/);
60
- assert.match(html, /could not be read/);
61
- assert.match(html, /Failed to fetch/);
62
- assert.match(html, /Report this/);
63
- assert.doesNotMatch(html, /Nothing is set aside/);
64
- });
65
-
66
- it("says it is still reading rather than claiming an empty list", () => {
67
- const html = render({ entries: [], isPending: true, error: null });
68
- assert.match(html, /Checking for messages set aside/);
69
- assert.doesNotMatch(html, /Nothing is set aside/);
70
- });
71
- });
@@ -1,178 +0,0 @@
1
- /**
2
- * Settings › Advanced — the messages sync set aside.
3
- *
4
- * A quarantined message exists on the server and nowhere else in the app, so
5
- * this pane is the only place it is ever mentioned. Three states are all
6
- * visible: the list, the reassurance that there is nothing in it, and the
7
- * failure to read it at all. None of them is an empty panel.
8
- */
9
- import {
10
- configOperationsGetConfigOptions,
11
- meOperationsListQuarantineOptions,
12
- } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
13
- import type { RemitImapQuarantineResponse } from "@remit/api-http-client/types.gen.ts";
14
- import {
15
- Button,
16
- QuarantineBugDialog,
17
- type QuarantineEntry,
18
- QuarantineSection,
19
- quarantineIssueTitle,
20
- quarantineReportSections,
21
- } from "@remit/ui";
22
- import { useQuery } from "@tanstack/react-query";
23
- import { Bug } from "lucide-react";
24
- import { useMemo, useState } from "react";
25
- import { formatErrorMessage } from "@/components/ui/ErrorState";
26
- import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
27
- import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
28
- import { toQuarantineEntry } from "@/lib/quarantine-entries";
29
- import type { ResultFolderIndex } from "@/lib/result-folder";
30
-
31
- /** Stable identity, so an empty list does not rebuild the memos below. */
32
- const EMPTY_WIRE_ENTRIES: readonly RemitImapQuarantineResponse[] = [];
33
-
34
- function openIssue(url: string): void {
35
- window.open(url, "_blank", "noopener,noreferrer");
36
- }
37
-
38
- function issueUrlFor(entry: QuarantineEntry): string {
39
- return buildGitHubIssueUrl(
40
- buildBugReportContext({
41
- quarantine: quarantineReportSections(entry),
42
- title: quarantineIssueTitle(entry),
43
- }),
44
- );
45
- }
46
-
47
- function QuarantineReadFailure({ error }: { error: unknown }) {
48
- return (
49
- <section role="alert" className="space-y-3">
50
- <h2 className="text-sm font-semibold text-fg">Messages set aside</h2>
51
- <div className="space-y-2 rounded-sm border border-danger/40 bg-danger-soft px-row-inset py-3">
52
- <p className="text-sm font-medium text-danger">
53
- The list of set-aside messages could not be read.
54
- </p>
55
- <p className="break-words text-sm text-fg-muted">
56
- {formatErrorMessage(error)}
57
- </p>
58
- <p className="text-xs text-fg-muted">
59
- Mail may still have been set aside — this page cannot say either way
60
- until it can read the list. Reporting this gets it fixed.
61
- </p>
62
- <Button
63
- variant="secondary"
64
- size="sm"
65
- icon={<Bug className="size-3.5" />}
66
- onClick={() =>
67
- openIssue(buildGitHubIssueUrl(buildBugReportContext()))
68
- }
69
- >
70
- Report this
71
- </Button>
72
- </div>
73
- </section>
74
- );
75
- }
76
-
77
- export interface QuarantinePanelViewProps {
78
- entries: readonly QuarantineEntry[];
79
- isPending: boolean;
80
- error: unknown;
81
- }
82
-
83
- export function QuarantinePanelView({
84
- entries,
85
- isPending,
86
- error,
87
- }: QuarantinePanelViewProps) {
88
- const [reporting, setReporting] = useState<QuarantineEntry | null>(null);
89
- const [copyFailed, setCopyFailed] = useState(false);
90
-
91
- if (isPending) {
92
- return (
93
- <p className="animate-pulse text-sm text-fg-subtle">
94
- Checking for messages set aside…
95
- </p>
96
- );
97
- }
98
-
99
- if (error) return <QuarantineReadFailure error={error} />;
100
-
101
- const handleCopy = (report: string) => {
102
- navigator.clipboard.writeText(report).then(
103
- () => {
104
- setCopyFailed(false);
105
- setReporting(null);
106
- },
107
- () => {
108
- setCopyFailed(true);
109
- setReporting(null);
110
- },
111
- );
112
- };
113
-
114
- return (
115
- <>
116
- {copyFailed && (
117
- <p role="alert" className="mb-3 text-sm text-danger">
118
- The report was not copied — this browser refused clipboard access. Cut
119
- the bug again and use Open on GitHub, which needs no clipboard.
120
- </p>
121
- )}
122
- <QuarantineSection entries={entries} onCutBug={setReporting} />
123
- <QuarantineBugDialog
124
- entry={reporting}
125
- issueUrl={reporting ? issueUrlFor(reporting) : ""}
126
- onClose={() => setReporting(null)}
127
- onCopy={handleCopy}
128
- />
129
- </>
130
- );
131
- }
132
-
133
- /**
134
- * A row names the folder it came from by the leaf of its path, which cannot be
135
- * cut until that account's own delimiter has arrived. A mailbox the account no
136
- * longer lists never gets one, and keeps its whole path — `folderLeaf` on an
137
- * empty delimiter is the path itself.
138
- */
139
- function delimiterForMailbox(
140
- folders: ResultFolderIndex,
141
- mailboxId: string,
142
- ): string {
143
- return folders.get(mailboxId)?.hierarchyDelimiter ?? "";
144
- }
145
-
146
- /**
147
- * The folder names come from the account's mailbox lists, which are a second
148
- * read — started alongside the quarantine list, never behind it. Until both have
149
- * landed the pane says it is still reading, because a path cut on a delimiter
150
- * that has not arrived is a wrong folder name rather than a late one, and a
151
- * failure on either read is reported rather than papered over with a guess.
152
- */
153
- export function QuarantinePanel() {
154
- const quarantine = useQuery(meOperationsListQuarantineOptions());
155
- const config = useQuery(configOperationsGetConfigOptions());
156
- const accounts = useMemo(() => config.data?.accounts ?? [], [config.data]);
157
- const folders = useResultFolderIndex(accounts);
158
-
159
- const wireEntries = quarantine.data?.entries ?? EMPTY_WIRE_ENTRIES;
160
- const entries = useMemo(
161
- () =>
162
- wireEntries.map((entry) =>
163
- toQuarantineEntry(
164
- entry,
165
- delimiterForMailbox(folders.index, entry.mailboxId),
166
- ),
167
- ),
168
- [wireEntries, folders.index],
169
- );
170
-
171
- return (
172
- <QuarantinePanelView
173
- entries={entries}
174
- isPending={quarantine.isPending || config.isPending || folders.isPending}
175
- error={quarantine.error ?? config.error ?? folders.error}
176
- />
177
- );
178
- }
@@ -1,99 +0,0 @@
1
- import { cn, useOverlayScope } from "@remit/ui";
2
- import { useCallback, useEffect, useRef, useState } from "react";
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
- useOverlayScope({
25
- id: "dropdown-menu",
26
- open: isOpen,
27
- answers: { back: () => setIsOpen(false) },
28
- });
29
-
30
- useEffect(() => {
31
- if (isOpen) document.addEventListener("mousedown", handleClickOutside);
32
- return () => document.removeEventListener("mousedown", handleClickOutside);
33
- }, [isOpen, handleClickOutside]);
34
-
35
- return (
36
- <div ref={menuRef} className="relative">
37
- <button
38
- type="button"
39
- onClick={(e) => {
40
- e.stopPropagation();
41
- setIsOpen(!isOpen);
42
- }}
43
- className={cn(
44
- "p-1.5 min-h-11 min-w-11 inline-flex items-center justify-center rounded-md transition-colors",
45
- "hover:bg-surface-raised text-fg-muted hover:text-fg",
46
- )}
47
- >
48
- {trigger}
49
- </button>
50
- {isOpen && (
51
- <div
52
- className={cn(
53
- "absolute z-50 mt-1 min-w-[180px] py-1",
54
- "bg-surface border border-line rounded-md shadow-lg",
55
- align === "right" ? "right-0" : "left-0",
56
- )}
57
- >
58
- {children}
59
- </div>
60
- )}
61
- </div>
62
- );
63
- };
64
-
65
- interface DropdownMenuItemProps {
66
- onClick: () => void;
67
- disabled?: boolean;
68
- destructive?: boolean;
69
- children: React.ReactNode;
70
- }
71
-
72
- export const DropdownMenuItem = ({
73
- onClick,
74
- disabled,
75
- destructive,
76
- children,
77
- }: DropdownMenuItemProps) => (
78
- <button
79
- type="button"
80
- onClick={(e) => {
81
- e.stopPropagation();
82
- if (!disabled) onClick();
83
- }}
84
- disabled={disabled}
85
- className={cn(
86
- "w-full px-3 py-2 text-left text-sm transition-colors",
87
- "flex items-center gap-2",
88
- disabled && "opacity-50 cursor-not-allowed",
89
- !disabled && "hover:bg-surface-raised",
90
- destructive && "text-danger hover:text-danger",
91
- )}
92
- >
93
- {children}
94
- </button>
95
- );
96
-
97
- export const DropdownMenuSeparator = () => (
98
- <div className="my-1 h-px bg-line" />
99
- );
@@ -1,11 +0,0 @@
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
- );