@remit/web-client 0.0.239 → 0.0.240
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.delimiter.test.ts +112 -0
- package/src/components/settings/QuarantinePanel.tsx +38 -15
- package/src/hooks/useResultFolderIndex.ts +39 -9
- package/src/lib/quarantine-entries.ts +5 -3
- package/src/routes/mail.tsx +1 -1
- package/src/hooks/useAccountDelimiters.ts +0 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.240",
|
|
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,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A quarantine row names the folder its message sits in, which means cutting a
|
|
3
|
+
* provider path into a leaf — and that cut is only correct on the account's own
|
|
4
|
+
* hierarchy separator. The separator comes from a second read (the account's
|
|
5
|
+
* mailbox list), so the pane must never paint a row before that read lands:
|
|
6
|
+
* `INBOX.Projects.Q3` cut on a slash is one segment, and the row would show the
|
|
7
|
+
* whole path as if it were a folder name. See #900.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { afterEach, describe, it } from "node:test";
|
|
12
|
+
import { createElement } from "react";
|
|
13
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
14
|
+
import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
|
|
15
|
+
import { QuarantinePanel } from "./QuarantinePanel";
|
|
16
|
+
|
|
17
|
+
const ACCOUNT_ID = "acct-1";
|
|
18
|
+
const MAILBOX_ID = "mbx-q3";
|
|
19
|
+
const FULL_PATH = "INBOX.Projects.Q3";
|
|
20
|
+
|
|
21
|
+
const CONFIG = {
|
|
22
|
+
accounts: [{ accountId: ACCOUNT_ID, folderAppointments: [] }],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const MAILBOXES = {
|
|
26
|
+
items: [
|
|
27
|
+
{
|
|
28
|
+
mailboxId: MAILBOX_ID,
|
|
29
|
+
fullPath: FULL_PATH,
|
|
30
|
+
hierarchyDelimiter: ".",
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const QUARANTINE = {
|
|
36
|
+
entries: [
|
|
37
|
+
{
|
|
38
|
+
quarantineId: "q-1",
|
|
39
|
+
accountId: ACCOUNT_ID,
|
|
40
|
+
mailboxId: MAILBOX_ID,
|
|
41
|
+
uidValidity: 1_712_000_000,
|
|
42
|
+
uid: 40251,
|
|
43
|
+
mailboxPath: FULL_PATH,
|
|
44
|
+
failureStage: "BodyParse",
|
|
45
|
+
failureCode: "UnreadableBody",
|
|
46
|
+
failureMessage: "stream ended before the declared body length",
|
|
47
|
+
quarantinedAt: Date.parse("2026-07-19T06:03:00Z"),
|
|
48
|
+
attempts: 1,
|
|
49
|
+
sizeBytes: 2_140,
|
|
50
|
+
structure: [],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let harness: DomHarness | undefined;
|
|
56
|
+
let http: HttpMock | undefined;
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
harness?.close();
|
|
60
|
+
harness = undefined;
|
|
61
|
+
http?.restore();
|
|
62
|
+
http = undefined;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const mount = async (mailboxes: () => unknown): Promise<DomHarness> => {
|
|
66
|
+
http = mockFetch((call) => {
|
|
67
|
+
if (call.path.endsWith("/config")) return CONFIG;
|
|
68
|
+
if (call.path.endsWith("/mailboxes")) return mailboxes();
|
|
69
|
+
if (call.path.endsWith("/quarantine")) return QUARANTINE;
|
|
70
|
+
return {};
|
|
71
|
+
});
|
|
72
|
+
harness = createDomHarness();
|
|
73
|
+
harness.renderApp(createElement(QuarantinePanel));
|
|
74
|
+
// Two waves: the mailbox-list fan-out is keyed on the accounts `/config`
|
|
75
|
+
// returns, so it cannot even start until that first read has settled.
|
|
76
|
+
await harness.flush();
|
|
77
|
+
await harness.wait(0);
|
|
78
|
+
await harness.flush();
|
|
79
|
+
return harness;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
describe("QuarantinePanel folder names", () => {
|
|
83
|
+
it("waits for the account's delimiter rather than cutting on a guess", async () => {
|
|
84
|
+
const view = await mount(() => new Promise(() => {}));
|
|
85
|
+
|
|
86
|
+
assert.match(view.text(), /Checking for messages set aside/);
|
|
87
|
+
assert.doesNotMatch(
|
|
88
|
+
view.text(),
|
|
89
|
+
new RegExp(FULL_PATH.replace(/\./g, "\\.")),
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("names the folder by its leaf once the delimiter has arrived", async () => {
|
|
94
|
+
const view = await mount(() => MAILBOXES);
|
|
95
|
+
|
|
96
|
+
assert.match(view.text(), /Q3/);
|
|
97
|
+
assert.doesNotMatch(
|
|
98
|
+
view.text(),
|
|
99
|
+
new RegExp(FULL_PATH.replace(/\./g, "\\.")),
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("reports a failed mailbox read instead of naming folders on a guess", async () => {
|
|
104
|
+
const view = await mount(() => httpError(500));
|
|
105
|
+
|
|
106
|
+
assert.match(view.html(), /role="alert"/);
|
|
107
|
+
assert.doesNotMatch(
|
|
108
|
+
view.text(),
|
|
109
|
+
new RegExp(FULL_PATH.replace(/\./g, "\\.")),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* visible: the list, the reassurance that there is nothing in it, and the
|
|
7
7
|
* failure to read it at all. None of them is an empty panel.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
configOperationsGetConfigOptions,
|
|
11
|
+
meOperationsListQuarantineOptions,
|
|
12
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
10
13
|
import type { RemitImapQuarantineResponse } from "@remit/api-http-client/types.gen.ts";
|
|
11
14
|
import {
|
|
12
15
|
Button,
|
|
@@ -20,9 +23,10 @@ import { useQuery } from "@tanstack/react-query";
|
|
|
20
23
|
import { Bug } from "lucide-react";
|
|
21
24
|
import { useMemo, useState } from "react";
|
|
22
25
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
23
|
-
import {
|
|
26
|
+
import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
|
|
24
27
|
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
25
28
|
import { toQuarantineEntry } from "@/lib/quarantine-entries";
|
|
29
|
+
import type { ResultFolderIndex } from "@/lib/result-folder";
|
|
26
30
|
|
|
27
31
|
/** Stable identity, so an empty list does not rebuild the memos below. */
|
|
28
32
|
const EMPTY_WIRE_ENTRIES: readonly RemitImapQuarantineResponse[] = [];
|
|
@@ -126,30 +130,49 @@ export function QuarantinePanelView({
|
|
|
126
130
|
);
|
|
127
131
|
}
|
|
128
132
|
|
|
133
|
+
/**
|
|
134
|
+
* A row names the folder it came from by the leaf of its path, which cannot be
|
|
135
|
+
* cut until that account's own delimiter has arrived. A mailbox the account no
|
|
136
|
+
* longer lists never gets one, and keeps its whole path — `folderLeaf` on an
|
|
137
|
+
* empty delimiter is the path itself.
|
|
138
|
+
*/
|
|
139
|
+
function delimiterForMailbox(
|
|
140
|
+
folders: ResultFolderIndex,
|
|
141
|
+
mailboxId: string,
|
|
142
|
+
): string {
|
|
143
|
+
return folders.get(mailboxId)?.hierarchyDelimiter ?? "";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The folder names come from the account's mailbox lists, which are a second
|
|
148
|
+
* read — started alongside the quarantine list, never behind it. Until both have
|
|
149
|
+
* landed the pane says it is still reading, because a path cut on a delimiter
|
|
150
|
+
* that has not arrived is a wrong folder name rather than a late one, and a
|
|
151
|
+
* failure on either read is reported rather than papered over with a guess.
|
|
152
|
+
*/
|
|
129
153
|
export function QuarantinePanel() {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
);
|
|
154
|
+
const quarantine = useQuery(meOperationsListQuarantineOptions());
|
|
155
|
+
const config = useQuery(configOperationsGetConfigOptions());
|
|
156
|
+
const accounts = useMemo(() => config.data?.accounts ?? [], [config.data]);
|
|
157
|
+
const folders = useResultFolderIndex(accounts);
|
|
133
158
|
|
|
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);
|
|
159
|
+
const wireEntries = quarantine.data?.entries ?? EMPTY_WIRE_ENTRIES;
|
|
140
160
|
const entries = useMemo(
|
|
141
161
|
() =>
|
|
142
162
|
wireEntries.map((entry) =>
|
|
143
|
-
toQuarantineEntry(
|
|
163
|
+
toQuarantineEntry(
|
|
164
|
+
entry,
|
|
165
|
+
delimiterForMailbox(folders.index, entry.mailboxId),
|
|
166
|
+
),
|
|
144
167
|
),
|
|
145
|
-
[wireEntries,
|
|
168
|
+
[wireEntries, folders.index],
|
|
146
169
|
);
|
|
147
170
|
|
|
148
171
|
return (
|
|
149
172
|
<QuarantinePanelView
|
|
150
173
|
entries={entries}
|
|
151
|
-
isPending={isPending}
|
|
152
|
-
error={error}
|
|
174
|
+
isPending={quarantine.isPending || config.isPending || folders.isPending}
|
|
175
|
+
error={quarantine.error ?? config.error ?? folders.error}
|
|
153
176
|
/>
|
|
154
177
|
);
|
|
155
178
|
}
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
* and `useMailboxNameIndex` run — cached forever, react-query dedupes the
|
|
4
4
|
* identical key across call sites) and reduces it to the mailboxId → folder map
|
|
5
5
|
* search results resolve their provenance against. See `lib/result-folder.ts`.
|
|
6
|
+
*
|
|
7
|
+
* The read state travels with the index: a folder label cut on a delimiter that
|
|
8
|
+
* has not arrived is wrong rather than late, so a surface that names folders can
|
|
9
|
+
* wait for this, and say so when it fails.
|
|
6
10
|
*/
|
|
7
11
|
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
8
12
|
import type {
|
|
@@ -18,37 +22,63 @@ import {
|
|
|
18
22
|
|
|
19
23
|
type MailboxItems = RemitImapMailboxResponse[];
|
|
20
24
|
|
|
25
|
+
interface MailboxListsState {
|
|
26
|
+
items: MailboxItems[];
|
|
27
|
+
isPending: boolean;
|
|
28
|
+
error: Error | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ResultFolderIndexState {
|
|
32
|
+
index: ResultFolderIndex;
|
|
33
|
+
isPending: boolean;
|
|
34
|
+
error: Error | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
/**
|
|
22
38
|
* Hoisted, not inline: `useQueries` skips re-running `combine` when it is given
|
|
23
39
|
* the same function, so a stable reference is what makes this a memo rather than
|
|
24
40
|
* a per-render reduce. Without it the index — and every search-result memo keyed
|
|
25
41
|
* on it — rebuilds on every render.
|
|
26
42
|
*/
|
|
27
|
-
const
|
|
28
|
-
results: {
|
|
29
|
-
|
|
43
|
+
const combineMailboxLists = (
|
|
44
|
+
results: {
|
|
45
|
+
data?: { items: MailboxItems };
|
|
46
|
+
isPending: boolean;
|
|
47
|
+
error: Error | null;
|
|
48
|
+
}[],
|
|
49
|
+
): MailboxListsState => ({
|
|
50
|
+
items: results.map((result) => result.data?.items ?? []),
|
|
51
|
+
isPending: results.some((result) => result.isPending),
|
|
52
|
+
error: results.find((result) => result.error)?.error ?? null,
|
|
53
|
+
});
|
|
30
54
|
|
|
31
55
|
export function useResultFolderIndex(
|
|
32
56
|
accounts: RemitImapAccountResponse[],
|
|
33
|
-
):
|
|
34
|
-
const
|
|
57
|
+
): ResultFolderIndexState {
|
|
58
|
+
const mailboxLists = useQueries({
|
|
35
59
|
queries: accounts.map((account) => ({
|
|
36
60
|
...mailboxOperationsListMailboxesOptions({
|
|
37
61
|
path: { accountId: account.accountId },
|
|
38
62
|
}),
|
|
39
63
|
staleTime: Infinity,
|
|
40
64
|
})),
|
|
41
|
-
combine:
|
|
65
|
+
combine: combineMailboxLists,
|
|
42
66
|
});
|
|
43
67
|
|
|
44
|
-
|
|
68
|
+
const index = useMemo(
|
|
45
69
|
() =>
|
|
46
70
|
buildResultFolderIndex(
|
|
47
71
|
accounts.map((account, i) => ({
|
|
48
72
|
folderAppointments: account.folderAppointments,
|
|
49
|
-
mailboxes:
|
|
73
|
+
mailboxes: mailboxLists.items[i] ?? [],
|
|
50
74
|
})),
|
|
51
75
|
),
|
|
52
|
-
[accounts,
|
|
76
|
+
[accounts, mailboxLists],
|
|
53
77
|
);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
index,
|
|
81
|
+
isPending: mailboxLists.isPending,
|
|
82
|
+
error: mailboxLists.error,
|
|
83
|
+
};
|
|
54
84
|
}
|
|
@@ -8,9 +8,11 @@ import { CANONICAL_TO_NAV_ROLE } from "./folder-roles";
|
|
|
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
10
|
*
|
|
11
|
-
* The record carries no hierarchy delimiter either, so the caller
|
|
12
|
-
* account's own
|
|
13
|
-
* name, and a path cannot be cut into one without it.
|
|
11
|
+
* The record carries no hierarchy delimiter either, so the caller joins the
|
|
12
|
+
* mailbox id against the account's own mailbox list and hands the delimiter in
|
|
13
|
+
* — the row shows a folder name, and a path cannot be cut into one without it.
|
|
14
|
+
* An empty delimiter means no account lists that mailbox, and the row keeps its
|
|
15
|
+
* whole path rather than being cut on a guess.
|
|
14
16
|
*/
|
|
15
17
|
export function toQuarantineEntry(
|
|
16
18
|
wire: RemitImapQuarantineResponse,
|
package/src/routes/mail.tsx
CHANGED
|
@@ -173,7 +173,7 @@ function MailLayout() {
|
|
|
173
173
|
[accounts],
|
|
174
174
|
);
|
|
175
175
|
const mailboxNameIndex = useMailboxNameIndex(accounts);
|
|
176
|
-
const resultFolderIndex = useResultFolderIndex(accounts);
|
|
176
|
+
const resultFolderIndex = useResultFolderIndex(accounts).index;
|
|
177
177
|
const accountNameIndex = useMemo(
|
|
178
178
|
() => buildAccountNameIndex(accounts),
|
|
179
179
|
[accounts],
|
|
@@ -1,41 +0,0 @@
|
|
|
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
|
-
}
|