@remit/web-client 0.0.159 → 0.0.160

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.159",
3
+ "version": "0.0.160",
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": {
@@ -0,0 +1,71 @@
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: Error | null;
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
+ });
@@ -0,0 +1,136 @@
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 { meOperationsListQuarantineOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
10
+ import {
11
+ Button,
12
+ QuarantineBugDialog,
13
+ type QuarantineEntry,
14
+ QuarantineSection,
15
+ quarantineIssueTitle,
16
+ quarantineReportSections,
17
+ } from "@remit/ui";
18
+ import { useQuery } from "@tanstack/react-query";
19
+ import { Bug } from "lucide-react";
20
+ import { useState } from "react";
21
+ import { formatErrorMessage } from "@/components/ui/ErrorState";
22
+ import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
23
+ import { toQuarantineEntry } from "@/lib/quarantine-entries";
24
+
25
+ function openIssue(url: string): void {
26
+ window.open(url, "_blank", "noopener,noreferrer");
27
+ }
28
+
29
+ function issueUrlFor(entry: QuarantineEntry): string {
30
+ return buildGitHubIssueUrl(
31
+ buildBugReportContext({
32
+ quarantine: quarantineReportSections(entry),
33
+ title: quarantineIssueTitle(entry),
34
+ }),
35
+ );
36
+ }
37
+
38
+ function QuarantineReadFailure({ error }: { error: Error }) {
39
+ return (
40
+ <section role="alert" className="space-y-3">
41
+ <h2 className="text-sm font-semibold text-fg">Messages set aside</h2>
42
+ <div className="space-y-2 rounded-sm border border-danger/40 bg-danger-soft px-row-inset py-3">
43
+ <p className="text-sm font-medium text-danger">
44
+ The list of set-aside messages could not be read.
45
+ </p>
46
+ <p className="break-words text-sm text-fg-muted">
47
+ {formatErrorMessage(error)}
48
+ </p>
49
+ <p className="text-xs text-fg-muted">
50
+ Mail may still have been set aside — this page cannot say either way
51
+ until it can read the list. Reporting this gets it fixed.
52
+ </p>
53
+ <Button
54
+ variant="secondary"
55
+ size="sm"
56
+ icon={<Bug className="size-3.5" />}
57
+ onClick={() =>
58
+ openIssue(buildGitHubIssueUrl(buildBugReportContext()))
59
+ }
60
+ >
61
+ Report this
62
+ </Button>
63
+ </div>
64
+ </section>
65
+ );
66
+ }
67
+
68
+ export interface QuarantinePanelViewProps {
69
+ entries: readonly QuarantineEntry[];
70
+ isPending: boolean;
71
+ error: Error | null;
72
+ }
73
+
74
+ export function QuarantinePanelView({
75
+ entries,
76
+ isPending,
77
+ error,
78
+ }: QuarantinePanelViewProps) {
79
+ const [reporting, setReporting] = useState<QuarantineEntry | null>(null);
80
+ const [copyFailed, setCopyFailed] = useState(false);
81
+
82
+ if (isPending) {
83
+ return (
84
+ <p className="animate-pulse text-sm text-fg-subtle">
85
+ Checking for messages set aside…
86
+ </p>
87
+ );
88
+ }
89
+
90
+ if (error) return <QuarantineReadFailure error={error} />;
91
+
92
+ const handleCopy = (report: string) => {
93
+ navigator.clipboard.writeText(report).then(
94
+ () => {
95
+ setCopyFailed(false);
96
+ setReporting(null);
97
+ },
98
+ () => {
99
+ setCopyFailed(true);
100
+ setReporting(null);
101
+ },
102
+ );
103
+ };
104
+
105
+ return (
106
+ <>
107
+ {copyFailed && (
108
+ <p role="alert" className="mb-3 text-sm text-danger">
109
+ The report was not copied — this browser refused clipboard access. Cut
110
+ the bug again and use Open on GitHub, which needs no clipboard.
111
+ </p>
112
+ )}
113
+ <QuarantineSection entries={entries} onCutBug={setReporting} />
114
+ <QuarantineBugDialog
115
+ entry={reporting}
116
+ issueUrl={reporting ? issueUrlFor(reporting) : ""}
117
+ onClose={() => setReporting(null)}
118
+ onCopy={handleCopy}
119
+ />
120
+ </>
121
+ );
122
+ }
123
+
124
+ export function QuarantinePanel() {
125
+ const { data, isPending, error } = useQuery(
126
+ meOperationsListQuarantineOptions(),
127
+ );
128
+
129
+ return (
130
+ <QuarantinePanelView
131
+ entries={(data?.entries ?? []).map(toQuarantineEntry)}
132
+ isPending={isPending}
133
+ error={error}
134
+ />
135
+ );
136
+ }
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { RemitImapQuarantineResponse } from "@remit/api-http-client/types.gen.ts";
4
+ import { toQuarantineEntry } from "./quarantine-entries";
5
+
6
+ const wire: RemitImapQuarantineResponse = {
7
+ quarantineId: "8b1e0c2a-0000-4000-8000-000000000001",
8
+ accountConfigId: "8b1e0c2a-0000-4000-8000-000000000002",
9
+ accountId: "8b1e0c2a-0000-4000-8000-000000000003",
10
+ mailboxId: "8b1e0c2a-0000-4000-8000-000000000004",
11
+ uidValidity: 1717171717,
12
+ uid: 4211,
13
+ mailboxRole: "Junk",
14
+ mailboxPath: "INBOX/Spam",
15
+ quarantinedAt: 1_750_000_000_000,
16
+ attempts: 3,
17
+ failureStage: "BodyParse",
18
+ failureCode: "UnknownCharset",
19
+ failureMessage: 'unknown charset "x-mac-roman"',
20
+ failurePartPath: "1.2",
21
+ workerVersion: "imap-worker@1.4.0",
22
+ contentType: "multipart/mixed",
23
+ transferEncoding: "base64",
24
+ charset: "x-mac-roman",
25
+ sizeBytes: 91_204,
26
+ structure: [
27
+ { depth: 0, contentType: "multipart/mixed" },
28
+ { depth: 1, contentType: "text/plain" },
29
+ ],
30
+ messageIdHash: "sha256:abc",
31
+ createdAt: 1_750_000_000_000,
32
+ updatedAt: 1_750_000_000_000,
33
+ };
34
+
35
+ describe("toQuarantineEntry", () => {
36
+ it("spells the folder role the way the kit does", () => {
37
+ assert.equal(toQuarantineEntry(wire).mailboxRole, "junk");
38
+ });
39
+
40
+ it("leaves the role absent when the folder has none appointed", () => {
41
+ const { mailboxRole: _dropped, ...roleless } = wire;
42
+ assert.equal(toQuarantineEntry(roleless).mailboxRole, undefined);
43
+ });
44
+
45
+ it("carries every field the report and the row are built from", () => {
46
+ const entry = toQuarantineEntry(wire);
47
+ assert.equal(entry.quarantineId, wire.quarantineId);
48
+ assert.equal(entry.uid, wire.uid);
49
+ assert.equal(entry.uidValidity, wire.uidValidity);
50
+ assert.equal(entry.mailboxPath, wire.mailboxPath);
51
+ assert.equal(entry.failureCode, wire.failureCode);
52
+ assert.equal(entry.failureStage, wire.failureStage);
53
+ assert.equal(entry.failureMessage, wire.failureMessage);
54
+ assert.equal(entry.failurePartPath, wire.failurePartPath);
55
+ assert.equal(entry.attempts, wire.attempts);
56
+ assert.equal(entry.quarantinedAt, wire.quarantinedAt);
57
+ assert.equal(entry.workerVersion, wire.workerVersion);
58
+ assert.equal(entry.contentType, wire.contentType);
59
+ assert.equal(entry.transferEncoding, wire.transferEncoding);
60
+ assert.equal(entry.charset, wire.charset);
61
+ assert.equal(entry.sizeBytes, wire.sizeBytes);
62
+ assert.equal(entry.messageIdHash, wire.messageIdHash);
63
+ assert.deepEqual(entry.structure, wire.structure);
64
+ });
65
+
66
+ it("drops the fields the settings surface has no use for", () => {
67
+ const entry = toQuarantineEntry(wire);
68
+ assert.equal("accountConfigId" in entry, false);
69
+ assert.equal("createdAt" in entry, false);
70
+ assert.equal("updatedAt" in entry, false);
71
+ });
72
+ });
@@ -0,0 +1,38 @@
1
+ import type { RemitImapQuarantineResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import type { QuarantineEntry } from "@remit/ui";
3
+ import { CANONICAL_TO_NAV_ROLE } from "./folder-roles";
4
+
5
+ /**
6
+ * The wire record carries `accountConfigId`, `createdAt` and `updatedAt`, which
7
+ * the settings surface has no use for, and spells the folder role in the
8
+ * canonical PascalCase the API uses everywhere. Both differences are resolved
9
+ * here rather than by widening the kit's entry type.
10
+ */
11
+ export function toQuarantineEntry(
12
+ wire: RemitImapQuarantineResponse,
13
+ ): QuarantineEntry {
14
+ return {
15
+ quarantineId: wire.quarantineId,
16
+ accountId: wire.accountId,
17
+ mailboxId: wire.mailboxId,
18
+ uidValidity: wire.uidValidity,
19
+ uid: wire.uid,
20
+ mailboxRole: wire.mailboxRole
21
+ ? CANONICAL_TO_NAV_ROLE[wire.mailboxRole]
22
+ : undefined,
23
+ mailboxPath: wire.mailboxPath,
24
+ failureStage: wire.failureStage,
25
+ failureCode: wire.failureCode,
26
+ failureMessage: wire.failureMessage,
27
+ failurePartPath: wire.failurePartPath,
28
+ quarantinedAt: wire.quarantinedAt,
29
+ attempts: wire.attempts,
30
+ sizeBytes: wire.sizeBytes,
31
+ contentType: wire.contentType,
32
+ transferEncoding: wire.transferEncoding,
33
+ charset: wire.charset,
34
+ structure: wire.structure,
35
+ messageIdHash: wire.messageIdHash,
36
+ workerVersion: wire.workerVersion,
37
+ };
38
+ }
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Advanced settings. Updates live here — and only here — with a full pane in
3
- * Settings › Advanced. Notification rules, export, and diagnostics are future
4
- * scope.
3
+ * Settings › Advanced, alongside the messages sync could not read. Notification
4
+ * rules and export are future scope.
5
5
  */
6
6
  import { SettingsShell } from "@remit/ui";
7
7
  import { createFileRoute, useNavigate } from "@tanstack/react-router";
8
8
  import { useState } from "react";
9
+ import { QuarantinePanel } from "@/components/settings/QuarantinePanel";
9
10
  import { SelfUpdatePanel } from "@/components/settings/SelfUpdatePanel";
10
11
  import { TlsRootCaDownload } from "@/components/settings/TlsRootCaDownload";
11
12
  import { AppVersion } from "@/components/ui/AppVersion";
@@ -18,8 +19,14 @@ export const Route = createFileRoute("/settings/advanced")({
18
19
  const advancedHelp = (
19
20
  <div className="space-y-3">
20
21
  <p>
21
- <strong className="text-fg">Notification rules</strong>, data export, and
22
- per-account diagnostics are coming in a future release.
22
+ <strong className="text-fg">Notification rules</strong> and data export
23
+ are coming in a future release.
24
+ </p>
25
+ <p>
26
+ A message Remit cannot read is{" "}
27
+ <strong className="text-fg">set aside</strong> rather than skipped, and
28
+ the folder keeps syncing. Setting one aside is a defect in Remit, so every
29
+ entry can be reported with the diagnostics already attached.
23
30
  </p>
24
31
  </div>
25
32
  );
@@ -46,10 +53,12 @@ function AdvancedSettings() {
46
53
  onBackToMail={() => void navigate({ to: "/mail" })}
47
54
  >
48
55
  <SelfUpdatePanel />
49
- <p className="text-sm text-fg-muted">
50
- Notification rules, data export, and raw sync diagnostics are coming in
51
- a future release.
52
- </p>
56
+ <QuarantinePanel />
57
+ <div className="mt-6 border-t border-line pt-4">
58
+ <p className="text-sm text-fg-muted">
59
+ Notification rules and data export are coming in a future release.
60
+ </p>
61
+ </div>
53
62
  <TlsRootCaDownload />
54
63
  <div className="border-t border-line pt-4 mt-4">
55
64
  <p className="text-sm font-medium text-fg mb-1">About</p>