@remit/web-client 0.0.224 → 0.0.226
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/settings/QuarantinePanel.tsx +21 -2
- package/src/hooks/useAccountDelimiters.ts +41 -0
- package/src/lib/organize/rule-model.ts +2 -2
- package/src/lib/quarantine-entries.test.ts +9 -4
- package/src/lib/quarantine-entries.ts +6 -0
- package/src/lib/result-folder.test.ts +21 -3
- package/src/lib/result-folder.ts +9 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.226",
|
|
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": {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* failure to read it at all. None of them is an empty panel.
|
|
8
8
|
*/
|
|
9
9
|
import { meOperationsListQuarantineOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
10
|
+
import type { RemitImapQuarantineResponse } from "@remit/api-http-client/types.gen.ts";
|
|
10
11
|
import {
|
|
11
12
|
Button,
|
|
12
13
|
QuarantineBugDialog,
|
|
@@ -17,11 +18,15 @@ import {
|
|
|
17
18
|
} from "@remit/ui";
|
|
18
19
|
import { useQuery } from "@tanstack/react-query";
|
|
19
20
|
import { Bug } from "lucide-react";
|
|
20
|
-
import { useState } from "react";
|
|
21
|
+
import { useMemo, useState } from "react";
|
|
21
22
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
23
|
+
import { useAccountDelimiters } from "@/hooks/useAccountDelimiters";
|
|
22
24
|
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
23
25
|
import { toQuarantineEntry } from "@/lib/quarantine-entries";
|
|
24
26
|
|
|
27
|
+
/** Stable identity, so an empty list does not rebuild the memos below. */
|
|
28
|
+
const EMPTY_WIRE_ENTRIES: readonly RemitImapQuarantineResponse[] = [];
|
|
29
|
+
|
|
25
30
|
function openIssue(url: string): void {
|
|
26
31
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
27
32
|
}
|
|
@@ -126,9 +131,23 @@ export function QuarantinePanel() {
|
|
|
126
131
|
meOperationsListQuarantineOptions(),
|
|
127
132
|
);
|
|
128
133
|
|
|
134
|
+
const wireEntries = data?.entries ?? EMPTY_WIRE_ENTRIES;
|
|
135
|
+
const accountIds = useMemo(
|
|
136
|
+
() => [...new Set(wireEntries.map((entry) => entry.accountId))],
|
|
137
|
+
[wireEntries],
|
|
138
|
+
);
|
|
139
|
+
const delimiters = useAccountDelimiters(accountIds);
|
|
140
|
+
const entries = useMemo(
|
|
141
|
+
() =>
|
|
142
|
+
wireEntries.map((entry) =>
|
|
143
|
+
toQuarantineEntry(entry, delimiters.get(entry.accountId) ?? "/"),
|
|
144
|
+
),
|
|
145
|
+
[wireEntries, delimiters],
|
|
146
|
+
);
|
|
147
|
+
|
|
129
148
|
return (
|
|
130
149
|
<QuarantinePanelView
|
|
131
|
-
entries={
|
|
150
|
+
entries={entries}
|
|
132
151
|
isPending={isPending}
|
|
133
152
|
error={error}
|
|
134
153
|
/>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hierarchy separator each account's own server reports, for surfaces that
|
|
3
|
+
* hold a provider path but not the mailbox row it came from — quarantine, so
|
|
4
|
+
* far. Fans out the same per-account mailbox-list query as
|
|
5
|
+
* `useResultFolderIndex` (cached forever, react-query dedupes the identical key
|
|
6
|
+
* across call sites) and keeps only the delimiter.
|
|
7
|
+
*/
|
|
8
|
+
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
9
|
+
import type { RemitImapMailboxResponse } from "@remit/api-http-client/types.gen.ts";
|
|
10
|
+
import { useQueries } from "@tanstack/react-query";
|
|
11
|
+
import { useMemo } from "react";
|
|
12
|
+
import { folderDelimiter } from "@/lib/move-options";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Hoisted so `useQueries` treats this as a memo rather than a per-render
|
|
16
|
+
* reduce — see `useResultFolderIndex`.
|
|
17
|
+
*/
|
|
18
|
+
const combineDelimiters = (
|
|
19
|
+
results: { data?: { items: RemitImapMailboxResponse[] } }[],
|
|
20
|
+
): string[] =>
|
|
21
|
+
results.map((result) => folderDelimiter(result.data?.items ?? []));
|
|
22
|
+
|
|
23
|
+
export function useAccountDelimiters(
|
|
24
|
+
accountIds: readonly string[],
|
|
25
|
+
): ReadonlyMap<string, string> {
|
|
26
|
+
const delimiters = useQueries({
|
|
27
|
+
queries: accountIds.map((accountId) => ({
|
|
28
|
+
...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
29
|
+
staleTime: Infinity,
|
|
30
|
+
})),
|
|
31
|
+
combine: combineDelimiters,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return useMemo(
|
|
35
|
+
() =>
|
|
36
|
+
new Map(
|
|
37
|
+
accountIds.map((accountId, i) => [accountId, delimiters[i] ?? "/"]),
|
|
38
|
+
),
|
|
39
|
+
[accountIds, delimiters],
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -199,8 +199,8 @@ export const rulePredicate = (
|
|
|
199
199
|
* all. Without an anchor it takes the vector-free literal arm, which reads no
|
|
200
200
|
* message body and rejects a `HasWords` clause rather than narrowing the match
|
|
201
201
|
* silently (`bodyContentRejection`, backend/service/organize.ts). Asking it
|
|
202
|
-
* anyway is refused
|
|
203
|
-
*
|
|
202
|
+
* anyway is refused with a 400, on preview and on a back-apply alike, so the
|
|
203
|
+
* caller must not ask.
|
|
204
204
|
*/
|
|
205
205
|
export const isEvaluablePredicate = (
|
|
206
206
|
predicate: OrganizeMatchPredicate,
|
|
@@ -34,16 +34,16 @@ const wire: RemitImapQuarantineResponse = {
|
|
|
34
34
|
|
|
35
35
|
describe("toQuarantineEntry", () => {
|
|
36
36
|
it("spells the folder role the way the kit does", () => {
|
|
37
|
-
assert.equal(toQuarantineEntry(wire).mailboxRole, "junk");
|
|
37
|
+
assert.equal(toQuarantineEntry(wire, "/").mailboxRole, "junk");
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
it("leaves the role absent when the folder has none appointed", () => {
|
|
41
41
|
const { mailboxRole: _dropped, ...roleless } = wire;
|
|
42
|
-
assert.equal(toQuarantineEntry(roleless).mailboxRole, undefined);
|
|
42
|
+
assert.equal(toQuarantineEntry(roleless, "/").mailboxRole, undefined);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
it("carries every field the report and the row are built from", () => {
|
|
46
|
-
const entry = toQuarantineEntry(wire);
|
|
46
|
+
const entry = toQuarantineEntry(wire, "/");
|
|
47
47
|
assert.equal(entry.quarantineId, wire.quarantineId);
|
|
48
48
|
assert.equal(entry.uid, wire.uid);
|
|
49
49
|
assert.equal(entry.uidValidity, wire.uidValidity);
|
|
@@ -63,8 +63,13 @@ describe("toQuarantineEntry", () => {
|
|
|
63
63
|
assert.deepEqual(entry.structure, wire.structure);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
it("carries the delimiter the caller resolved, which the wire has not", () => {
|
|
67
|
+
assert.equal("mailboxDelimiter" in wire, false);
|
|
68
|
+
assert.equal(toQuarantineEntry(wire, ".").mailboxDelimiter, ".");
|
|
69
|
+
});
|
|
70
|
+
|
|
66
71
|
it("drops the fields the settings surface has no use for", () => {
|
|
67
|
-
const entry = toQuarantineEntry(wire);
|
|
72
|
+
const entry = toQuarantineEntry(wire, "/");
|
|
68
73
|
assert.equal("accountConfigId" in entry, false);
|
|
69
74
|
assert.equal("createdAt" in entry, false);
|
|
70
75
|
assert.equal("updatedAt" in entry, false);
|
|
@@ -7,9 +7,14 @@ import { CANONICAL_TO_NAV_ROLE } from "./folder-roles";
|
|
|
7
7
|
* the settings surface has no use for, and spells the folder role in the
|
|
8
8
|
* canonical PascalCase the API uses everywhere. Both differences are resolved
|
|
9
9
|
* here rather than by widening the kit's entry type.
|
|
10
|
+
*
|
|
11
|
+
* The record carries no hierarchy delimiter either, so the caller resolves the
|
|
12
|
+
* account's own from its mailbox list and hands it in — the row shows a folder
|
|
13
|
+
* name, and a path cannot be cut into one without it.
|
|
10
14
|
*/
|
|
11
15
|
export function toQuarantineEntry(
|
|
12
16
|
wire: RemitImapQuarantineResponse,
|
|
17
|
+
mailboxDelimiter: string,
|
|
13
18
|
): QuarantineEntry {
|
|
14
19
|
return {
|
|
15
20
|
quarantineId: wire.quarantineId,
|
|
@@ -21,6 +26,7 @@ export function toQuarantineEntry(
|
|
|
21
26
|
? CANONICAL_TO_NAV_ROLE[wire.mailboxRole]
|
|
22
27
|
: undefined,
|
|
23
28
|
mailboxPath: wire.mailboxPath,
|
|
29
|
+
mailboxDelimiter,
|
|
24
30
|
failureStage: wire.failureStage,
|
|
25
31
|
failureCode: wire.failureCode,
|
|
26
32
|
failureMessage: wire.failureMessage,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import { provenanceFolderLabel } from "@remit/ui";
|
|
3
4
|
import {
|
|
4
5
|
type AccountMailboxes,
|
|
5
6
|
buildResultFolderIndex,
|
|
@@ -9,10 +10,11 @@ import {
|
|
|
9
10
|
const account = (
|
|
10
11
|
appointments: { role: string; mailboxId: string }[],
|
|
11
12
|
mailboxes: { mailboxId: string; fullPath: string }[],
|
|
13
|
+
hierarchyDelimiter = "/",
|
|
12
14
|
): AccountMailboxes =>
|
|
13
15
|
({
|
|
14
16
|
folderAppointments: appointments,
|
|
15
|
-
mailboxes,
|
|
17
|
+
mailboxes: mailboxes.map((mailbox) => ({ ...mailbox, hierarchyDelimiter })),
|
|
16
18
|
}) as unknown as AccountMailboxes;
|
|
17
19
|
|
|
18
20
|
describe("buildResultFolderIndex", () => {
|
|
@@ -30,9 +32,11 @@ describe("buildResultFolderIndex", () => {
|
|
|
30
32
|
assert.deepEqual(index.get("mb-junk"), {
|
|
31
33
|
role: "junk",
|
|
32
34
|
providerPath: "Bulk Mail",
|
|
35
|
+
hierarchyDelimiter: "/",
|
|
33
36
|
});
|
|
34
37
|
assert.deepEqual(index.get("mb-work"), {
|
|
35
38
|
providerPath: "Projects/Bookkeeping",
|
|
39
|
+
hierarchyDelimiter: "/",
|
|
36
40
|
});
|
|
37
41
|
});
|
|
38
42
|
|
|
@@ -72,7 +76,7 @@ describe("resolveResultFolder", () => {
|
|
|
72
76
|
it("skips a virtual folder for one that names a real place", () => {
|
|
73
77
|
assert.deepEqual(resolveResultFolder(index, ["mb-all", "mb-inbox"]), {
|
|
74
78
|
mailboxId: "mb-inbox",
|
|
75
|
-
folder: { role: "inbox", providerPath: "INBOX" },
|
|
79
|
+
folder: { role: "inbox", providerPath: "INBOX", hierarchyDelimiter: "/" },
|
|
76
80
|
});
|
|
77
81
|
});
|
|
78
82
|
|
|
@@ -83,7 +87,11 @@ describe("resolveResultFolder", () => {
|
|
|
83
87
|
it("falls back to the first id when none names a real place", () => {
|
|
84
88
|
assert.deepEqual(resolveResultFolder(index, ["mb-all"]), {
|
|
85
89
|
mailboxId: "mb-all",
|
|
86
|
-
folder: {
|
|
90
|
+
folder: {
|
|
91
|
+
role: "all",
|
|
92
|
+
providerPath: "[Gmail]/All Mail",
|
|
93
|
+
hierarchyDelimiter: "/",
|
|
94
|
+
},
|
|
87
95
|
});
|
|
88
96
|
});
|
|
89
97
|
|
|
@@ -113,3 +121,13 @@ describe("buildResultFolderIndex before the mailbox list arrives", () => {
|
|
|
113
121
|
assert.deepEqual(index.get("mb-junk"), { role: "junk" });
|
|
114
122
|
});
|
|
115
123
|
});
|
|
124
|
+
|
|
125
|
+
describe("buildResultFolderIndex on a dotted server", () => {
|
|
126
|
+
it("carries the delimiter the account reports, so the chip reads the leaf", () => {
|
|
127
|
+
const index = buildResultFolderIndex([
|
|
128
|
+
account([], [{ mailboxId: "mb-q3", fullPath: "INBOX.Projects.Q3" }], "."),
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
assert.equal(provenanceFolderLabel(index.get("mb-q3") ?? {}), "Q3");
|
|
132
|
+
});
|
|
133
|
+
});
|
package/src/lib/result-folder.ts
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* sidebar already loads: the id → folder map here is the only thing either
|
|
8
8
|
* needs, so neither the search API nor the semantic index has to carry a folder.
|
|
9
9
|
*
|
|
10
|
+
* The mailbox row carries the hierarchy delimiter its own server reports, and it
|
|
11
|
+
* is kept alongside the path: a chip labelled by path shows the folder's own
|
|
12
|
+
* name, which on a `.`-delimited account cannot be cut on a slash.
|
|
13
|
+
*
|
|
10
14
|
* The role comes from the account's `folderAppointments` map (RFC 032), which
|
|
11
15
|
* the server resolves from the IMAP SPECIAL-USE attributes — so `junk` means the
|
|
12
16
|
* folder the account advertises as `\Junk`, whatever it is called. Names are
|
|
@@ -27,7 +31,7 @@ export interface AccountMailboxes {
|
|
|
27
31
|
folderAppointments: readonly RemitImapFolderAppointment[];
|
|
28
32
|
mailboxes: readonly Pick<
|
|
29
33
|
RemitImapMailboxResponse,
|
|
30
|
-
"mailboxId" | "fullPath"
|
|
34
|
+
"mailboxId" | "fullPath" | "hierarchyDelimiter"
|
|
31
35
|
>[];
|
|
32
36
|
}
|
|
33
37
|
|
|
@@ -46,10 +50,11 @@ export function buildResultFolderIndex(
|
|
|
46
50
|
for (const [mailboxId, role] of roles) index.set(mailboxId, { role });
|
|
47
51
|
for (const mailbox of account.mailboxes) {
|
|
48
52
|
const role = roles.get(mailbox.mailboxId);
|
|
49
|
-
|
|
50
|
-
...(role ? { role } : {}),
|
|
53
|
+
const path = {
|
|
51
54
|
providerPath: mailbox.fullPath,
|
|
52
|
-
|
|
55
|
+
hierarchyDelimiter: mailbox.hierarchyDelimiter,
|
|
56
|
+
};
|
|
57
|
+
index.set(mailbox.mailboxId, role ? { role, ...path } : path);
|
|
53
58
|
}
|
|
54
59
|
}
|
|
55
60
|
return index;
|