@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.159",
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 and a folder both belong to one account, so a selection spanning
453
- // accounts reaches neither and is told so on the step that asks (#477 5.5).
454
- // The one-off scope acts on the messages themselves and is unaffected.
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. 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>