@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,11 +1,5 @@
1
1
  import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
2
- import {
3
- Badge,
4
- Button,
5
- LabelChip,
6
- type LabelOption,
7
- WidenChip,
8
- } from "@remit/ui";
2
+ import { Badge, Button, LabelChip, type LabelOption } from "@remit/ui";
9
3
  import { Trash2 } from "lucide-react";
10
4
  import type { FilterRunStatus } from "@/lib/organize/filter-run";
11
5
  import {
@@ -47,12 +41,6 @@ interface FiltersListProps {
47
41
  runFilterId?: string;
48
42
  /** How that run stands; a failure is reported above the list. */
49
43
  runStatus?: FilterRunStatus;
50
- /**
51
- * This deployment ships no vector pipeline (RFC 038 D4). A filter carrying a
52
- * semantic anchor lists with its widen chip inactive — it matches by its
53
- * literal clauses only.
54
- */
55
- semanticUnavailable?: boolean;
56
44
  /** Injected for deterministic status in tests; defaults to now. */
57
45
  now?: number;
58
46
  }
@@ -60,9 +48,7 @@ interface FiltersListProps {
60
48
  /**
61
49
  * The account's standing filters, each opening its rule in the editor (RFC 038
62
50
  * D6). Expired temporary filters stay listed and are marked Expired distinctly
63
- * rather than hidden (RFC 034 Decision 1.2). A filter with a semantic anchor
64
- * shows the widen chip — inactive where this deployment cannot evaluate it
65
- * (D4), so the list says honestly that it matches by literal clauses only.
51
+ * rather than hidden (RFC 034 Decision 1.2).
66
52
  */
67
53
  export function FiltersList({
68
54
  filters,
@@ -76,7 +62,6 @@ export function FiltersList({
76
62
  onRunNow,
77
63
  runFilterId,
78
64
  runStatus,
79
- semanticUnavailable = false,
80
65
  now = Date.now(),
81
66
  }: FiltersListProps) {
82
67
  if (filters.length === 0) {
@@ -156,25 +141,15 @@ export function FiltersList({
156
141
  {runLine}
157
142
  </p>
158
143
  )}
159
- {(filter.hasAnchor || label) && (
144
+ {label && (
160
145
  <div className="mt-1.5 flex flex-wrap gap-1.5">
161
- {filter.hasAnchor && (
162
- <WidenChip
163
- widen={{
164
- anchorCount: 1,
165
- ...(semanticUnavailable ? { inactive: true } : {}),
166
- }}
167
- />
168
- )}
169
- {label && (
170
- <LabelChip
171
- label={{
172
- labelId: label.id,
173
- name: label.name,
174
- color: label.color,
175
- }}
176
- />
177
- )}
146
+ <LabelChip
147
+ label={{
148
+ labelId: label.id,
149
+ name: label.name,
150
+ color: label.color,
151
+ }}
152
+ />
178
153
  </div>
179
154
  )}
180
155
  </button>
@@ -0,0 +1,12 @@
1
+ import { AttentionDot } from "@remit/ui";
2
+ import { TriangleAlert } from "lucide-react";
3
+ import { useWarningsAttention } from "@/hooks/useWarningsAttention";
4
+
5
+ export function WarningsNavIcon() {
6
+ const attention = useWarningsAttention();
7
+ return (
8
+ <AttentionDot label={attention}>
9
+ <TriangleAlert className="size-4" />
10
+ </AttentionDot>
11
+ );
12
+ }
@@ -13,7 +13,7 @@ import { meOperationsListQuarantineOptions } from "@remit/api-http-client/@tanst
13
13
  import { createElement } from "react";
14
14
  import { createDomHarness, type DomHarness } from "../../test-support/dom";
15
15
  import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
16
- import { QuarantinePanel } from "./QuarantinePanel";
16
+ import { WarningsPanel } from "./WarningsPanel";
17
17
 
18
18
  const ACCOUNT_ID = "acct-1";
19
19
  const MAILBOX_ID = "mbx-q3";
@@ -51,6 +51,8 @@ const QUARANTINE = {
51
51
  structure: [],
52
52
  },
53
53
  ],
54
+ warnings: [],
55
+ warningsTotal: 0,
54
56
  };
55
57
 
56
58
  let harness: DomHarness | undefined;
@@ -76,16 +78,17 @@ const mount = async (
76
78
  mailboxes: () => unknown,
77
79
  settled: (view: DomHarness) => boolean,
78
80
  description: string,
81
+ list: () => unknown = () => QUARANTINE,
79
82
  ): Promise<DomHarness> => {
80
83
  http = mockFetch((call) => {
81
84
  if (call.path.endsWith("/config")) return CONFIG;
82
85
  if (call.path.endsWith("/mailboxes")) return mailboxes();
83
- if (call.path.endsWith("/quarantine")) return QUARANTINE;
86
+ if (call.path.endsWith("/quarantine")) return list();
84
87
  return {};
85
88
  });
86
89
  const view = createDomHarness();
87
90
  harness = view;
88
- view.renderApp(createElement(QuarantinePanel));
91
+ view.renderApp(createElement(WarningsPanel));
89
92
  // Two waves: the mailbox-list fan-out is keyed on the accounts `/config`
90
93
  // returns, so it cannot even start until that first read has settled. How
91
94
  // many turns those waves take is the runner's business, so each case names
@@ -94,7 +97,63 @@ const mount = async (
94
97
  return view;
95
98
  };
96
99
 
97
- describe("QuarantinePanel folder names", () => {
100
+ describe("WarningsPanel reads", () => {
101
+ it("lists a body that gave up and a header still retrying beside the set-aside message", async () => {
102
+ const view = await mount(
103
+ () => MAILBOXES,
104
+ (pane) => /Gave up/.test(pane.text()),
105
+ "the warning to be listed",
106
+ () => ({
107
+ ...QUARANTINE,
108
+ warnings: [
109
+ {
110
+ syncWarningId: "w-1",
111
+ accountConfigId: "cfg-1",
112
+ accountId: ACCOUNT_ID,
113
+ mailboxId: MAILBOX_ID,
114
+ uidValidity: 1_712_000_000,
115
+ uid: 51002,
116
+ stage: "Body",
117
+ attempts: 3,
118
+ createdAt: Date.parse("2026-10-17T08:12:00Z"),
119
+ },
120
+ {
121
+ syncWarningId: "w-2",
122
+ accountConfigId: "cfg-1",
123
+ accountId: ACCOUNT_ID,
124
+ mailboxId: MAILBOX_ID,
125
+ uidValidity: 1_712_000_000,
126
+ uid: 50874,
127
+ stage: "Header",
128
+ attempts: 5,
129
+ createdAt: Date.parse("2026-10-16T21:40:00Z"),
130
+ },
131
+ ],
132
+ warningsTotal: 2,
133
+ }),
134
+ );
135
+
136
+ assert.match(view.text(), /uid 51002/);
137
+ assert.match(view.text(), /Retrying/);
138
+ assert.match(view.text(), /uid 50874/);
139
+ assert.match(view.text(), /Messages set aside/);
140
+ assert.match(view.text(), /uid 40251/);
141
+ });
142
+
143
+ it("shows a 401 on the list as signed out, never as an empty list", async () => {
144
+ const view = await mount(
145
+ () => MAILBOXES,
146
+ (pane) => /Your session has ended/.test(pane.text()),
147
+ "the signed-out notice",
148
+ () => httpError(401),
149
+ );
150
+
151
+ assert.match(view.html(), /role="alert"/);
152
+ assert.doesNotMatch(view.text(), /Nothing has failed|Nothing is set aside/);
153
+ });
154
+ });
155
+
156
+ describe("WarningsPanel folder names", () => {
98
157
  it("waits for the account's delimiter rather than cutting on a guess", async () => {
99
158
  // The mailbox read never resolves here, so settled means every read that
100
159
  // can land has: the entries are in hand and only the delimiter is missing.
@@ -105,7 +164,7 @@ describe("QuarantinePanel folder names", () => {
105
164
  "the quarantine list to land while the mailbox read hangs",
106
165
  );
107
166
 
108
- assert.match(view.text(), /Checking for messages set aside/);
167
+ assert.match(view.text(), /Checking for warnings/);
109
168
  assert.doesNotMatch(
110
169
  view.text(),
111
170
  new RegExp(FULL_PATH.replace(/\./g, "\\.")),
@@ -0,0 +1,56 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import React, { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import { WarningsPanelView, type WarningsRead } from "./WarningsPanel";
6
+
7
+ // See MessageToolbar.render.test.ts: the SSR test loader transpiles remit-ui
8
+ // `.tsx` with the classic JSX runtime, which needs a global `React`.
9
+ (globalThis as { React?: typeof React }).React = React;
10
+
11
+ const render = (read: WarningsRead): string =>
12
+ renderToString(
13
+ createElement(WarningsPanelView, {
14
+ read,
15
+ signIn: createElement("button", { type: "button" }, "Sign in again"),
16
+ }) as never,
17
+ );
18
+
19
+ const ready = (
20
+ over: Partial<Extract<WarningsRead, { kind: "Ready" }>>,
21
+ ): WarningsRead => ({
22
+ kind: "Ready",
23
+ quarantine: [],
24
+ warnings: [],
25
+ warningsTotal: 0,
26
+ ...over,
27
+ });
28
+
29
+ describe("WarningsPanelView", () => {
30
+ it("says nothing failed and nothing is set aside rather than rendering nothing", () => {
31
+ const html = render(ready({}));
32
+ assert.match(html, /Messages that failed to sync/);
33
+ assert.match(html, /Nothing has failed/);
34
+ assert.match(html, /Messages set aside/);
35
+ assert.match(html, /Nothing is set aside/);
36
+ });
37
+
38
+ it("reports a failed read in the pane, with a way to file it", () => {
39
+ const html = render({
40
+ kind: "Failed",
41
+ error: new Error("Failed to fetch"),
42
+ });
43
+ assert.match(html, /role="alert"/);
44
+ assert.match(html, /could not be read/);
45
+ assert.match(html, /Failed to fetch/);
46
+ assert.match(html, /Report this/);
47
+ assert.doesNotMatch(html, /Nothing has failed/);
48
+ assert.doesNotMatch(html, /Nothing is set aside/);
49
+ });
50
+
51
+ it("says it is still reading rather than claiming an empty list", () => {
52
+ const html = render({ kind: "Pending" });
53
+ assert.match(html, /Checking for warnings/);
54
+ assert.doesNotMatch(html, /Nothing has failed/);
55
+ });
56
+ });
@@ -0,0 +1,244 @@
1
+ import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type {
3
+ RemitImapQuarantineResponse,
4
+ RemitImapSyncWarningResponse,
5
+ } from "@remit/api-http-client/types.gen.ts";
6
+ import {
7
+ Button,
8
+ formatErrorMessage,
9
+ formatQuarantineReport,
10
+ formatSyncWarningReport,
11
+ MessageReportDialog,
12
+ type QuarantineEntry,
13
+ QuarantineSection,
14
+ quarantineIssueTitle,
15
+ quarantineReportSections,
16
+ type SyncWarningEntry,
17
+ SyncWarningSection,
18
+ syncWarningIssueTitle,
19
+ } from "@remit/ui";
20
+ import { useQuery } from "@tanstack/react-query";
21
+ import { Bug } from "lucide-react";
22
+ import { type ReactNode, useMemo, useState } from "react";
23
+ import { SignInAgain } from "@/auth/SignInAgain";
24
+ import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
25
+ import { useWarningsList } from "@/hooks/useWarningsAttention";
26
+ import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
27
+ import { isUnauthenticated } from "@/lib/error-classifier";
28
+ import { toQuarantineEntry } from "@/lib/quarantine-entries";
29
+ import type { ResultFolderIndex } from "@/lib/result-folder";
30
+ import { toSyncWarningEntry } from "@/lib/sync-warning-entries";
31
+
32
+ const EMPTY_QUARANTINE: readonly RemitImapQuarantineResponse[] = [];
33
+ const EMPTY_WARNINGS: readonly RemitImapSyncWarningResponse[] = [];
34
+
35
+ interface Reporting {
36
+ report: string;
37
+ issueUrl: string;
38
+ }
39
+
40
+ function openIssue(url: string): void {
41
+ window.open(url, "_blank", "noopener,noreferrer");
42
+ }
43
+
44
+ function quarantineReporting(entry: QuarantineEntry): Reporting {
45
+ return {
46
+ report: formatQuarantineReport(entry),
47
+ issueUrl: buildGitHubIssueUrl(
48
+ buildBugReportContext({
49
+ quarantine: quarantineReportSections(entry),
50
+ title: quarantineIssueTitle(entry),
51
+ }),
52
+ ),
53
+ };
54
+ }
55
+
56
+ function syncWarningReporting(entry: SyncWarningEntry): Reporting {
57
+ const report = formatSyncWarningReport(entry);
58
+ return {
59
+ report,
60
+ issueUrl: buildGitHubIssueUrl(
61
+ buildBugReportContext({
62
+ syncWarning: report,
63
+ title: syncWarningIssueTitle(entry),
64
+ }),
65
+ ),
66
+ };
67
+ }
68
+
69
+ function WarningsSignedOut({ signIn }: { signIn: ReactNode }) {
70
+ return (
71
+ <section role="alert" className="space-y-3">
72
+ <div className="space-y-2 rounded-sm border border-danger/40 bg-danger-soft px-row-inset py-3">
73
+ <p className="text-sm font-medium text-danger">
74
+ Your session has ended.
75
+ </p>
76
+ <p className="text-sm text-fg-muted">
77
+ Sign in again to see the messages that failed to sync or were set
78
+ aside.
79
+ </p>
80
+ {signIn}
81
+ </div>
82
+ </section>
83
+ );
84
+ }
85
+
86
+ function WarningsReadFailure({ error }: { error: unknown }) {
87
+ return (
88
+ <section role="alert" className="space-y-3">
89
+ <div className="space-y-2 rounded-sm border border-danger/40 bg-danger-soft px-row-inset py-3">
90
+ <p className="text-sm font-medium text-danger">
91
+ The warnings could not be read.
92
+ </p>
93
+ <p className="break-words text-sm text-fg-muted">
94
+ {formatErrorMessage(error)}
95
+ </p>
96
+ <p className="text-xs text-fg-muted">
97
+ Mail may still be failing to sync or have been set aside — this page
98
+ cannot say either way until it can read the list. Reporting this gets
99
+ it fixed.
100
+ </p>
101
+ <Button
102
+ variant="secondary"
103
+ size="sm"
104
+ icon={<Bug className="size-3.5" />}
105
+ onClick={() =>
106
+ openIssue(buildGitHubIssueUrl(buildBugReportContext()))
107
+ }
108
+ >
109
+ Report this
110
+ </Button>
111
+ </div>
112
+ </section>
113
+ );
114
+ }
115
+
116
+ export type WarningsRead =
117
+ | { kind: "Pending" }
118
+ | { kind: "SignedOut" }
119
+ | { kind: "Failed"; error: unknown }
120
+ | {
121
+ kind: "Ready";
122
+ quarantine: readonly QuarantineEntry[];
123
+ warnings: readonly SyncWarningEntry[];
124
+ warningsTotal: number;
125
+ };
126
+
127
+ export interface WarningsPanelViewProps {
128
+ read: WarningsRead;
129
+ signIn: ReactNode;
130
+ }
131
+
132
+ export function WarningsPanelView({ read, signIn }: WarningsPanelViewProps) {
133
+ const [reporting, setReporting] = useState<Reporting | null>(null);
134
+ const [copyFailed, setCopyFailed] = useState(false);
135
+
136
+ if (read.kind === "Pending") {
137
+ return (
138
+ <p className="animate-pulse text-sm text-fg-subtle">
139
+ Checking for warnings…
140
+ </p>
141
+ );
142
+ }
143
+
144
+ if (read.kind === "SignedOut") return <WarningsSignedOut signIn={signIn} />;
145
+
146
+ if (read.kind === "Failed") return <WarningsReadFailure error={read.error} />;
147
+
148
+ const handleCopy = (report: string) => {
149
+ navigator.clipboard.writeText(report).then(
150
+ () => {
151
+ setCopyFailed(false);
152
+ setReporting(null);
153
+ },
154
+ () => {
155
+ setCopyFailed(true);
156
+ setReporting(null);
157
+ },
158
+ );
159
+ };
160
+
161
+ return (
162
+ <div className="space-y-6">
163
+ {copyFailed && (
164
+ <p role="alert" className="text-sm text-danger">
165
+ The report was not copied — this browser refused clipboard access. Cut
166
+ the bug again and use Open on GitHub, which needs no clipboard.
167
+ </p>
168
+ )}
169
+ <SyncWarningSection
170
+ entries={read.warnings}
171
+ total={read.warningsTotal}
172
+ onReport={(entry) => setReporting(syncWarningReporting(entry))}
173
+ />
174
+ <QuarantineSection
175
+ entries={read.quarantine}
176
+ onCutBug={(entry) => setReporting(quarantineReporting(entry))}
177
+ />
178
+ <MessageReportDialog
179
+ report={reporting?.report ?? null}
180
+ issueUrl={reporting?.issueUrl ?? ""}
181
+ onClose={() => setReporting(null)}
182
+ onCopy={handleCopy}
183
+ />
184
+ </div>
185
+ );
186
+ }
187
+
188
+ function delimiterForMailbox(
189
+ folders: ResultFolderIndex,
190
+ mailboxId: string,
191
+ ): string {
192
+ return folders.get(mailboxId)?.hierarchyDelimiter ?? "";
193
+ }
194
+
195
+ export function WarningsPanel() {
196
+ const list = useWarningsList();
197
+ const config = useQuery(configOperationsGetConfigOptions());
198
+ const accounts = useMemo(() => config.data?.accounts ?? [], [config.data]);
199
+ const folders = useResultFolderIndex(accounts);
200
+
201
+ const wireQuarantine = list.data?.entries ?? EMPTY_QUARANTINE;
202
+ const wireWarnings = list.data?.warnings ?? EMPTY_WARNINGS;
203
+ const quarantine = useMemo(
204
+ () =>
205
+ wireQuarantine.map((entry) =>
206
+ toQuarantineEntry(
207
+ entry,
208
+ delimiterForMailbox(folders.index, entry.mailboxId),
209
+ ),
210
+ ),
211
+ [wireQuarantine, folders.index],
212
+ );
213
+ const warnings = useMemo(
214
+ () =>
215
+ wireWarnings.map((warning) =>
216
+ toSyncWarningEntry(warning, folders.index.get(warning.mailboxId)),
217
+ ),
218
+ [wireWarnings, folders.index],
219
+ );
220
+
221
+ const read = readOf(
222
+ list.error ?? config.error ?? folders.error,
223
+ list.isPending || config.isPending || folders.isPending,
224
+ {
225
+ kind: "Ready",
226
+ quarantine,
227
+ warnings,
228
+ warningsTotal: list.data?.warningsTotal ?? 0,
229
+ },
230
+ );
231
+
232
+ return <WarningsPanelView read={read} signIn={<SignInAgain />} />;
233
+ }
234
+
235
+ function readOf(
236
+ error: unknown,
237
+ isPending: boolean,
238
+ ready: WarningsRead,
239
+ ): WarningsRead {
240
+ if (isUnauthenticated(error)) return { kind: "SignedOut" };
241
+ if (error) return { kind: "Failed", error };
242
+ if (isPending) return { kind: "Pending" };
243
+ return ready;
244
+ }
@@ -253,6 +253,24 @@ export const AccountEdit: Story = {
253
253
  },
254
254
  };
255
255
 
256
+ export const AccountEditRevealPassword: Story = {
257
+ name: "Accounts — edit, reveal password",
258
+ globals: desktop,
259
+ play: async ({ canvasElement }) => {
260
+ const panel = within(await openEditPanel(canvasElement, 0));
261
+ const password = panel.getByLabelText("Password");
262
+ await expect(password).toHaveValue("");
263
+ await expect(password).toHaveAttribute(
264
+ "placeholder",
265
+ "Leave empty to keep current",
266
+ );
267
+ await userEvent.type(password, "new-app-password");
268
+ await userEvent.click(panel.getByRole("button", { name: "Show password" }));
269
+ await expect(password).toHaveAttribute("type", "text");
270
+ await expect(password).toHaveValue("new-app-password");
271
+ },
272
+ };
273
+
256
274
  export const AccountEditClosed: Story = {
257
275
  name: "Accounts — edit panel closed",
258
276
  globals: desktop,
@@ -1,57 +1,10 @@
1
- import type { RemitImapQuarantineResponse } from "@remit/api-http-client/types.gen.ts";
2
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
3
- import { HttpResponse, http } from "msw";
4
2
  import { expect, within } from "storybook/test";
5
3
  import { AppStory } from "@/mocks/story-frame/AppStory";
6
4
  import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
7
- import {
8
- mailboxIdFor,
9
- mailWorld,
10
- NOW,
11
- PERSONAL,
12
- } from "@/mocks/story-frame/mail-world";
5
+ import { mailWorld } from "@/mocks/story-frame/mail-world";
13
6
  import { withRuntimeConfig } from "@/mocks/story-frame/session";
14
7
 
15
- const setAside = (
16
- id: string,
17
- role: "Inbox" | "Archive",
18
- failureCode: RemitImapQuarantineResponse["failureCode"],
19
- failureMessage: string,
20
- ): RemitImapQuarantineResponse => ({
21
- quarantineId: `q-${id}`,
22
- accountConfigId: "cfg-1",
23
- accountId: PERSONAL,
24
- mailboxId: mailboxIdFor(PERSONAL, role),
25
- uidValidity: 1_712_000_000,
26
- uid: 40_217,
27
- mailboxRole: role,
28
- mailboxPath: role === "Inbox" ? "INBOX" : "Archive",
29
- quarantinedAt: NOW,
30
- attempts: 3,
31
- failureStage: "BodyParse",
32
- failureCode,
33
- failureMessage,
34
- workerVersion: "worker 1.0.0",
35
- contentType: "multipart/mixed",
36
- sizeBytes: 184_233,
37
- structure: [{ depth: 0, contentType: "multipart/mixed" }],
38
- createdAt: NOW,
39
- updatedAt: NOW,
40
- });
41
-
42
- const unreadableBody = setAside(
43
- "1",
44
- "Inbox",
45
- "UnreadableBody",
46
- "multipart boundary was never closed",
47
- );
48
- const unknownCharset = setAside(
49
- "2",
50
- "Archive",
51
- "UnknownCharset",
52
- "declared charset is not a known encoding",
53
- );
54
-
55
8
  const meta = {
56
9
  title: "Playground/Shipped/Settings/Advanced/Overview",
57
10
  component: AppStory,
@@ -68,89 +21,14 @@ type Story = StoryObj<typeof meta>;
68
21
  export const Advanced: Story = {
69
22
  play: async ({ canvasElement }) => {
70
23
  const canvas = within(canvasElement);
71
- await expect(
72
- await canvas.findByText(/Every message has been read successfully/),
73
- ).toBeVisible();
74
- await expect(canvas.getByText("About")).toBeVisible();
24
+ await expect(await canvas.findByText("About")).toBeVisible();
25
+ await expect(canvas.queryByText("Messages set aside")).toBeNull();
75
26
  await expect(
76
27
  canvas.queryByRole("link", { name: "Download root certificate" }),
77
28
  ).toBeNull();
78
29
  },
79
30
  };
80
31
 
81
- export const AdvancedOneQuarantined: Story = {
82
- parameters: {
83
- msw: {
84
- handlers: mailHandlers(mailWorld({ quarantine: [unreadableBody] })),
85
- },
86
- },
87
- play: async ({ canvasElement }) => {
88
- const canvas = within(canvasElement);
89
- await expect(
90
- await canvas.findByText(/multipart boundary was never closed/),
91
- ).toBeVisible();
92
- await expect(canvas.queryByText(/messages could not be read/)).toBeNull();
93
- },
94
- };
95
-
96
- export const AdvancedQuarantineAlert: Story = {
97
- parameters: {
98
- msw: {
99
- handlers: mailHandlers(
100
- mailWorld({ quarantine: [unreadableBody, unknownCharset] }),
101
- ),
102
- },
103
- },
104
- play: async ({ canvasElement }) => {
105
- const canvas = within(canvasElement);
106
- await expect(
107
- await canvas.findByText("2 messages could not be read."),
108
- ).toBeVisible();
109
- },
110
- };
111
-
112
- export const AdvancedQuarantineLoading: Story = {
113
- parameters: {
114
- msw: {
115
- handlers: [
116
- http.get(
117
- "/api/me/quarantine",
118
- () => new Promise<never>(() => undefined),
119
- ),
120
- ...mailHandlers(mailWorld()),
121
- ],
122
- },
123
- },
124
- play: async ({ canvasElement }) => {
125
- const canvas = within(canvasElement);
126
- await expect(
127
- await canvas.findByText("Checking for messages set aside…"),
128
- ).toBeVisible();
129
- },
130
- };
131
-
132
- export const AdvancedQuarantineUnreadable: Story = {
133
- parameters: {
134
- msw: {
135
- handlers: [
136
- http.get("/api/me/quarantine", () => HttpResponse.error()),
137
- ...mailHandlers(mailWorld()),
138
- ],
139
- },
140
- },
141
- play: async ({ canvasElement }) => {
142
- const canvas = within(canvasElement);
143
- await expect(
144
- await canvas.findByText(
145
- "The list of set-aside messages could not be read.",
146
- ),
147
- ).toBeVisible();
148
- await expect(
149
- canvas.getByRole("button", { name: "Report this" }),
150
- ).toBeVisible();
151
- },
152
- };
153
-
154
32
  export const AdvancedTlsInternal: Story = {
155
33
  beforeEach: withRuntimeConfig({ tlsMode: "internal" }),
156
34
  play: async ({ canvasElement }) => {