@remit/web-client 0.0.171 → 0.0.173

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.171",
3
+ "version": "0.0.173",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -59,7 +59,12 @@ export const ComposeProvider = ({
59
59
  enabled: !!pollingMessageId,
60
60
  refetchInterval: (query) => {
61
61
  const status = query.state.data?.status;
62
- if (status === "sent" || status === "failed" || status === "blocked")
62
+ if (
63
+ status === "sent" ||
64
+ status === "unfiled" ||
65
+ status === "failed" ||
66
+ status === "blocked"
67
+ )
63
68
  return false;
64
69
  if (Date.now() - startedAtRef.current > MAX_POLL_DURATION_MS)
65
70
  return false;
@@ -72,6 +77,7 @@ export const ComposeProvider = ({
72
77
 
73
78
  const terminal =
74
79
  polledMessage.status === "sent" ||
80
+ polledMessage.status === "unfiled" ||
75
81
  polledMessage.status === "failed" ||
76
82
  polledMessage.status === "blocked";
77
83
 
@@ -59,7 +59,7 @@ import {
59
59
  matchesOutboxSearch,
60
60
  outboxQueryIsUnsupported,
61
61
  } from "@/lib/outbox-search";
62
- import { isOutboxListRow, isUnsendableStatus } from "@/lib/outbox-status";
62
+ import { isOutboxListRow, showsLastError } from "@/lib/outbox-status";
63
63
  import { normalizeSearchQuery } from "@/lib/search-query";
64
64
  import { parseSearchTokens } from "@/lib/search-tokens";
65
65
  import { useEditDraft, useRetainOpenPanels } from "@/routing";
@@ -87,6 +87,11 @@ const STATUS_CONFIG: Record<
87
87
  label: "Sent",
88
88
  className: "text-positive",
89
89
  },
90
+ unfiled: {
91
+ icon: AlertTriangle,
92
+ label: "Sent, not filed",
93
+ className: "text-warning",
94
+ },
90
95
  failed: {
91
96
  icon: AlertCircle,
92
97
  label: "Failed",
@@ -310,7 +315,7 @@ function OutboxMessageRow({
310
315
  >;
311
316
  if (!STATUS_CONFIG[status]) return null;
312
317
 
313
- const showError = isUnsendableStatus(message.status);
318
+ const showError = showsLastError(message.status);
314
319
 
315
320
  return (
316
321
  <OutboxRow
@@ -414,7 +419,7 @@ function OutboxMessageDetail({ message, onBack }: OutboxMessageDetailProps) {
414
419
  <span className={cn("text-xs font-medium", config.className)}>
415
420
  {config.label}
416
421
  </span>
417
- {isUnsendableStatus(message.status) && message.lastError && (
422
+ {showsLastError(message.status) && message.lastError && (
418
423
  <span className="text-xs text-fg-muted">
419
424
  — {message.lastError}
420
425
  </span>
@@ -6,10 +6,10 @@ import { ErrorBanner } from "./ErrorBanner";
6
6
  * message list, so it is opaque and it names its own severity out loud rather
7
7
  * than leaving colour to carry the meaning.
8
8
  *
9
- * A failure the user cannot act on still gets an action link, prefilled, so
10
- * reporting it is one click instead of a form they have to assemble. The
11
- * hrefs below stand in for the real report URL, which the app builds from
12
- * build-time constants Storybook has no `define` for.
9
+ * Every error banner carries a prefilled report link, so reporting a failure
10
+ * the user cannot act on is one click instead of a form they have to
11
+ * assemble. The hrefs below stand in for the real report URL, which the app
12
+ * builds from build-time constants Storybook has no `define` for.
13
13
  */
14
14
  const meta: Meta<typeof ErrorBanner> = {
15
15
  title: "Components/ErrorBanner",
@@ -27,21 +27,38 @@ type Story = StoryObj<typeof ErrorBanner>;
27
27
  const REPORT_URL =
28
28
  "https://github.com/remit-mail/reader/issues/new?title=Spellcheck+stopped";
29
29
 
30
- /** A mutation that failed, with the reason underneath. */
30
+ /** A mutation that failed, with the reason underneath and a way out. */
31
31
  export const Failed: Story = {
32
32
  name: "Error",
33
33
  args: {
34
34
  severity: "error",
35
35
  title: "Couldn't move message",
36
36
  detail: "Connection reset by peer",
37
+ action: { label: "Report an issue", href: REPORT_URL },
37
38
  },
38
39
  };
39
40
 
40
- /** Nothing more to say than the title. */
41
+ /** Nothing more to say than the title — the report link still stands. */
41
42
  export const NoDetail: Story = {
42
43
  args: {
43
44
  severity: "error",
44
45
  title: "Couldn't move message",
46
+ action: { label: "Report an issue", href: REPORT_URL },
47
+ },
48
+ };
49
+
50
+ /**
51
+ * Report spam failing because the account has nowhere to file it. The reason
52
+ * names the folder and the fix, so this one is actionable without the report
53
+ * link — which is offered anyway, because the user should not have to decide.
54
+ */
55
+ export const NoJunkFolder: Story = {
56
+ args: {
57
+ severity: "error",
58
+ title: "Couldn't report this message as spam",
59
+ detail:
60
+ "This account has no Junk folder. Create one named Junk or Spam in your mail provider, then report this message again.",
61
+ action: { label: "Report an issue", href: REPORT_URL },
45
62
  },
46
63
  };
47
64
 
@@ -6,6 +6,7 @@ import {
6
6
  useMemo,
7
7
  useState,
8
8
  } from "react";
9
+ import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
9
10
  import { isAlwaysFatal } from "@/lib/error-classifier";
10
11
  import { reportFatalError } from "@/lib/fatal-error";
11
12
  import { ErrorBannerStack } from "./ErrorBannerStack.js";
@@ -13,6 +14,7 @@ import {
13
14
  appendBanner,
14
15
  buildEntry,
15
16
  dismissBanner,
17
+ type ErrorBannerAction,
16
18
  type ErrorBannerEntry,
17
19
  type PushErrorInput,
18
20
  } from "./error-banners.js";
@@ -28,6 +30,37 @@ const ErrorBannerContext = createContext<ErrorBannerContextValue | undefined>(
28
30
  undefined,
29
31
  );
30
32
 
33
+ /**
34
+ * A way out of every failure banner. A soft failure is still a failure the
35
+ * user could not prevent and cannot diagnose, and a banner offering only
36
+ * "Dismiss" is a dead end — the fatal screen has carried a prefilled report
37
+ * since issue #55 and the soft surface never did. The report is seeded from
38
+ * the banner it belongs to, so it names the failure instead of asking the
39
+ * user to describe it, and it carries the same breadcrumbs — failing request,
40
+ * navigation, console — the fatal report does.
41
+ *
42
+ * A caller that already has a better way out keeps it, and a warning or an
43
+ * informational banner gets none: those are not failures to report.
44
+ */
45
+ export const bannerWayOut = (
46
+ input: PushErrorInput,
47
+ ): ErrorBannerAction | undefined => {
48
+ if (input.action) return input.action;
49
+ if ((input.severity ?? "error") !== "error") return undefined;
50
+ return {
51
+ label: "Report an issue",
52
+ href: buildGitHubIssueUrl(
53
+ buildBugReportContext({
54
+ title: `Bug: ${input.title}`,
55
+ errorMessage: input.detail
56
+ ? `${input.title} — ${input.detail}`
57
+ : input.title,
58
+ stack: input.error instanceof Error ? input.error.stack : undefined,
59
+ }),
60
+ ),
61
+ };
62
+ };
63
+
31
64
  const generateId = (): string => {
32
65
  if (
33
66
  typeof globalThis.crypto !== "undefined" &&
@@ -58,7 +91,11 @@ export const ErrorBannerProvider = ({ children }: { children: ReactNode }) => {
58
91
  if (input.error != null && isAlwaysFatal(input.error)) {
59
92
  return reportFatalError(input.error).correlationId;
60
93
  }
61
- const entry = buildEntry(input, generateId(), Date.now());
94
+ const entry = buildEntry(
95
+ { ...input, action: bannerWayOut(input) },
96
+ generateId(),
97
+ Date.now(),
98
+ );
62
99
  setErrors((current) => appendBanner(current, entry));
63
100
  return entry.id;
64
101
  }, []);
@@ -0,0 +1,71 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { ApiError } from "@/lib/api";
4
+ import { bannerWayOut } from "./ErrorBannerProvider";
5
+
6
+ const issueBody = (href: string): string =>
7
+ new URL(href).searchParams.get("body") ?? "";
8
+
9
+ const issueTitle = (href: string): string =>
10
+ new URL(href).searchParams.get("title") ?? "";
11
+
12
+ describe("bannerWayOut — no failure banner is a dead end", () => {
13
+ it("offers a prefilled report on a failure the user cannot act on", () => {
14
+ // The spam report that failed on test.remit.email offered nothing but
15
+ // Dismiss: no reason, no fix, no way to report it.
16
+ const action = bannerWayOut({
17
+ title: "Couldn't report this message as spam",
18
+ detail: "This message could not be processed.",
19
+ error: new ApiError("This message could not be processed.", 422),
20
+ });
21
+
22
+ assert.equal(action?.label, "Report an issue");
23
+ assert.ok(
24
+ action?.href.startsWith(
25
+ "https://github.com/remit-mail/reader/issues/new?",
26
+ ),
27
+ );
28
+ assert.match(
29
+ issueTitle(action.href),
30
+ /Couldn't report this message as spam/,
31
+ );
32
+ assert.match(
33
+ issueBody(action.href),
34
+ /Couldn't report this message as spam/,
35
+ );
36
+ });
37
+
38
+ it("carries the stack of the error that caused the banner", () => {
39
+ const error = new Error("Connection reset by peer");
40
+ const action = bannerWayOut({
41
+ title: "Couldn't move this message",
42
+ error,
43
+ });
44
+
45
+ assert.ok(action !== undefined);
46
+ assert.match(issueBody(action.href), /Stacktrace/);
47
+ });
48
+
49
+ it("keeps an action the call site already chose", () => {
50
+ const action = bannerWayOut({
51
+ title: "Spellcheck stopped",
52
+ action: { label: "Report this", href: "https://example.invalid/report" },
53
+ });
54
+
55
+ assert.deepEqual(action, {
56
+ label: "Report this",
57
+ href: "https://example.invalid/report",
58
+ });
59
+ });
60
+
61
+ it("offers none on a warning or a statement of fact", () => {
62
+ assert.equal(
63
+ bannerWayOut({ severity: "warning", title: "Draft saved locally" }),
64
+ undefined,
65
+ );
66
+ assert.equal(
67
+ bannerWayOut({ severity: "info", title: "Sync finished" }),
68
+ undefined,
69
+ );
70
+ });
71
+ });
@@ -51,7 +51,7 @@ interface UseReportSpamOptions {
51
51
  * despite a non-zero `failureCount`.
52
52
  */
53
53
  export const GENERIC_SPAM_ACTION_FAILURE =
54
- "This message could not be processed. Please try again.";
54
+ "This message could not be processed. Please try again. If it keeps failing, report it.";
55
55
 
56
56
  /**
57
57
  * The server's designed failure text names the message by embedding its raw
@@ -2,10 +2,18 @@
2
2
  * Build-time constants injected by vite.config.ts via `define`.
3
3
  * __APP_SHA__ is the full git SHA (or "dev" in local builds without git).
4
4
  * __APP_BUILD_TIME__ is an ISO timestamp.
5
+ *
6
+ * Read through `typeof` because not every host that renders this app's
7
+ * components applies the `define` — Storybook's test runner mounts them
8
+ * without it — and a bare reference is a ReferenceError at module scope that
9
+ * takes the importing story down with it. An unknown build is the same
10
+ * "dev" the git-less local build already resolves to.
5
11
  */
6
12
 
7
- export const APP_SHA: string = __APP_SHA__;
8
- export const APP_BUILD_TIME: string = __APP_BUILD_TIME__;
13
+ export const APP_SHA: string =
14
+ typeof __APP_SHA__ === "undefined" ? "dev" : __APP_SHA__;
15
+ export const APP_BUILD_TIME: string =
16
+ typeof __APP_BUILD_TIME__ === "undefined" ? "unknown" : __APP_BUILD_TIME__;
9
17
 
10
18
  /** First 7 characters of the SHA, matching git's default short form. */
11
19
  export const APP_SHORT_SHA: string = APP_SHA.slice(0, 7);
@@ -3,7 +3,7 @@ import { describe, test } from "node:test";
3
3
  import {
4
4
  describeOutboxStatus,
5
5
  isOutboxListRow,
6
- isUnsendableStatus,
6
+ showsLastError,
7
7
  } from "./outbox-status.js";
8
8
 
9
9
  describe("describeOutboxStatus", () => {
@@ -25,6 +25,13 @@ describe("describeOutboxStatus", () => {
25
25
  assert.equal(desc.label, "Sent");
26
26
  });
27
27
 
28
+ test("unfiled says the message was sent but not filed", () => {
29
+ const desc = describeOutboxStatus("unfiled");
30
+ assert.ok(desc);
31
+ assert.equal(desc.tone, "warning");
32
+ assert.match(desc.label, /not filed/);
33
+ });
34
+
28
35
  test("failed is error tone (distinct from blocked)", () => {
29
36
  const desc = describeOutboxStatus("failed");
30
37
  assert.ok(desc);
@@ -33,19 +40,23 @@ describe("describeOutboxStatus", () => {
33
40
  });
34
41
  });
35
42
 
36
- describe("isUnsendableStatus", () => {
37
- test("sent must NOT be flagged as unsendable — never show error subtitle on success (issue #192)", () => {
38
- assert.equal(isUnsendableStatus("sent"), false);
43
+ describe("showsLastError", () => {
44
+ test("sent must NOT surface an error — never show an error subtitle on success (issue #192)", () => {
45
+ assert.equal(showsLastError("sent"), false);
39
46
  });
40
47
 
41
- test("failed and blocked are unsendable", () => {
42
- assert.equal(isUnsendableStatus("failed"), true);
43
- assert.equal(isUnsendableStatus("blocked"), true);
48
+ test("failed and blocked surface their error", () => {
49
+ assert.equal(showsLastError("failed"), true);
50
+ assert.equal(showsLastError("blocked"), true);
44
51
  });
45
52
 
46
- test("queued and sending are not unsendable", () => {
47
- assert.equal(isUnsendableStatus("queued"), false);
48
- assert.equal(isUnsendableStatus("sending"), false);
53
+ test("unfiled surfaces its reason the row exists only to carry it", () => {
54
+ assert.equal(showsLastError("unfiled"), true);
55
+ });
56
+
57
+ test("queued and sending carry no error", () => {
58
+ assert.equal(showsLastError("queued"), false);
59
+ assert.equal(showsLastError("sending"), false);
49
60
  });
50
61
  });
51
62
 
@@ -61,4 +72,8 @@ describe("isOutboxListRow", () => {
61
72
  assert.equal(isOutboxListRow("failed"), true);
62
73
  assert.equal(isOutboxListRow("blocked"), true);
63
74
  });
75
+
76
+ test("keeps an unfiled row listed — it is the only copy the user has", () => {
77
+ assert.equal(isOutboxListRow("unfiled"), true);
78
+ });
64
79
  });
@@ -29,6 +29,7 @@ const STATUS_DESCRIPTORS: Record<OutboxDisplayStatus, OutboxStatusDescriptor> =
29
29
  queued: { label: "Queued", tone: "neutral" },
30
30
  sending: { label: "Sending…", tone: "info" },
31
31
  sent: { label: "Sent", tone: "success" },
32
+ unfiled: { label: "Sent, not filed", tone: "warning" },
32
33
  failed: { label: "Failed", tone: "error" },
33
34
  blocked: { label: "Blocked", tone: "warning" },
34
35
  };
@@ -56,11 +57,12 @@ export const describeOutboxStatus = (
56
57
  *
57
58
  * Critically: `sent` is NOT included. A successful send must never display
58
59
  * a stale lastError — that is the contradictory "green check + 'SMTP not
59
- * configured'" UI bug from issue #192.
60
+ * configured'" UI bug from issue #192. `unfiled` is: the message was
61
+ * delivered but could not be filed, and the reason is the whole point of
62
+ * the row still being there.
60
63
  */
61
- export const isUnsendableStatus = (
62
- status: RemitImapOutboxMessageStatus,
63
- ): boolean => status === "failed" || status === "blocked";
64
+ export const showsLastError = (status: RemitImapOutboxMessageStatus): boolean =>
65
+ status === "failed" || status === "blocked" || status === "unfiled";
64
66
 
65
67
  /**
66
68
  * Whether the outbox list view should render this row.
@@ -68,7 +70,9 @@ export const isUnsendableStatus = (
68
70
  * `draft` belongs in the Drafts view. `sent` rows are deleted by the IMAP
69
71
  * APPEND handler (issue #178) — but until that happens we hide them from
70
72
  * the Outbox list so a successfully-sent message never appears in two
71
- * places at once (issue #193).
73
+ * places at once (issue #193). `unfiled` is the settled failure of that
74
+ * APPEND: the message is on no server folder, so the Outbox row is the only
75
+ * copy the user has and it stays listed.
72
76
  */
73
77
  export const isOutboxListRow = (
74
78
  status: RemitImapOutboxMessageStatus,