@remit/web-client 0.0.159 → 0.0.161
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/mail/SelectionWizardHost.tsx +5 -3
- package/src/components/settings/QuarantinePanel.render.test.ts +71 -0
- package/src/components/settings/QuarantinePanel.tsx +136 -0
- package/src/lib/quarantine-entries.test.ts +72 -0
- package/src/lib/quarantine-entries.ts +38 -0
- package/src/routes/settings/advanced.tsx +17 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.161",
|
|
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": {
|
|
@@ -449,9 +449,10 @@ function SelectionWizardSession({
|
|
|
449
449
|
// is how a footer comes to name a screen nobody is looking at.
|
|
450
450
|
const current = steps[stepIndex(steps, step)];
|
|
451
451
|
|
|
452
|
-
// A filter
|
|
453
|
-
//
|
|
454
|
-
//
|
|
452
|
+
// A filter, a folder, and the preview that counts a widened door all belong to
|
|
453
|
+
// one account, so a selection spanning accounts reaches none of them and is
|
|
454
|
+
// told so on the step that asks (#477 5.5, #523). The one-off scope and the
|
|
455
|
+
// ticked rows act on the messages themselves and are unaffected.
|
|
455
456
|
const accountScoped = !crossAccount && !!accountId;
|
|
456
457
|
const ruleRestriction = accountScoped ? undefined : crossAccountRuleReason;
|
|
457
458
|
const folderRestriction = accountScoped
|
|
@@ -939,6 +940,7 @@ function SelectionWizardSession({
|
|
|
939
940
|
match={{
|
|
940
941
|
selectedCount: selection.length,
|
|
941
942
|
mode,
|
|
943
|
+
accountId: accountScoped ? accountId : undefined,
|
|
942
944
|
onModeChange: changeMode,
|
|
943
945
|
semanticUnavailable,
|
|
944
946
|
semanticErrorDetail:
|
|
@@ -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
|
|
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
|
|
22
|
-
|
|
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
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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>
|