@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 +1 -1
- package/src/components/compose/ComposeProvider.tsx +7 -1
- package/src/components/mail/OutboxPane.tsx +8 -3
- package/src/components/ui/ErrorBanner.stories.tsx +23 -6
- package/src/components/ui/ErrorBannerProvider.tsx +38 -1
- package/src/components/ui/banner-way-out.test.ts +71 -0
- package/src/hooks/useReportSpam.ts +1 -1
- package/src/lib/app-info.ts +10 -2
- package/src/lib/outbox-status.test.ts +25 -10
- package/src/lib/outbox-status.ts +9 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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 (
|
|
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,
|
|
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 =
|
|
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
|
-
{
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* hrefs below stand in for the real report URL, which the app
|
|
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(
|
|
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
|
package/src/lib/app-info.ts
CHANGED
|
@@ -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 =
|
|
8
|
-
|
|
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
|
-
|
|
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("
|
|
37
|
-
test("sent must NOT
|
|
38
|
-
assert.equal(
|
|
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
|
|
42
|
-
assert.equal(
|
|
43
|
-
assert.equal(
|
|
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("
|
|
47
|
-
assert.equal(
|
|
48
|
-
|
|
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
|
});
|
package/src/lib/outbox-status.ts
CHANGED
|
@@ -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
|
|
62
|
-
status
|
|
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,
|