@remit/web-client 0.0.239 → 0.0.241
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/auth/auth-interceptor.test.ts +41 -38
- package/src/components/compose/ComposeForm.tsx +51 -2
- package/src/components/compose/attachment-only-source.render.test.ts +233 -0
- package/src/components/mail/RoleAppointmentPromptProvider.tsx +7 -3
- package/src/components/settings/QuarantinePanel.delimiter.test.ts +112 -0
- package/src/components/settings/QuarantinePanel.tsx +38 -15
- package/src/components/ui/folder-role-refusal.test.ts +56 -26
- package/src/components/ui/folder-role-refusal.ts +6 -18
- package/src/components/ui/placement-refusal.test.ts +14 -6
- package/src/components/ui/placement-refusal.ts +5 -6
- package/src/hooks/refusal-escalation.render.test.ts +187 -0
- package/src/hooks/use-system-update.test.ts +104 -0
- package/src/hooks/use-system-update.ts +6 -0
- package/src/hooks/useDeleteMessages.ts +5 -0
- package/src/hooks/useEmptyTrash.ts +4 -0
- package/src/hooks/useMessageBodyContent.ts +7 -0
- package/src/hooks/useResultFolderIndex.ts +39 -9
- package/src/lib/api.ts +42 -5
- package/src/lib/client.test.ts +68 -0
- package/src/lib/client.ts +2 -8
- package/src/lib/quarantine-entries.ts +5 -3
- package/src/routes/mail.tsx +1 -1
- package/src/routes/settings/folders.tsx +4 -0
- package/src/test-support/http.ts +3 -2
- package/src/hooks/useAccountDelimiters.ts +0 -41
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type FolderRoleRefusal,
|
|
23
23
|
isFolderRoleRefusal,
|
|
24
24
|
} from "@/components/ui/folder-role-refusal";
|
|
25
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
25
26
|
import {
|
|
26
27
|
invalidateThreadListQueries,
|
|
27
28
|
threadListCacheKeys,
|
|
@@ -109,6 +110,9 @@ export const useEmptyTrash = ({
|
|
|
109
110
|
|
|
110
111
|
const { mutateAsync, isPending } = useMutation({
|
|
111
112
|
...trashOperationsEmptyTrashMutation(),
|
|
113
|
+
// The refusal is rendered in the pane, so it must not also take the whole
|
|
114
|
+
// screen. Only 409 — every other status is still the fatal page's (#1059).
|
|
115
|
+
meta: softErrorStatuses(409),
|
|
112
116
|
onMutate: (): EmptyTrashContext => {
|
|
113
117
|
inFlight.current = scope;
|
|
114
118
|
return { scope, listMailboxId: listMailboxIdNow() };
|
|
@@ -351,6 +351,12 @@ export const useMessageBodyContent = ({
|
|
|
351
351
|
|
|
352
352
|
const isLoading = query.isLoading && !!picked;
|
|
353
353
|
|
|
354
|
+
// `picked` is null for two different reasons, and a caller that treats them
|
|
355
|
+
// alike is wrong about one of them: the message has not arrived yet, or it
|
|
356
|
+
// arrived carrying nothing renderable. Only the second is a fact about the
|
|
357
|
+
// message, and only it may be stated to the reader (#1030).
|
|
358
|
+
const hasNoRenderablePart = bodyParts !== undefined && picked === null;
|
|
359
|
+
|
|
354
360
|
useEffect(() => {
|
|
355
361
|
if (isLoading) {
|
|
356
362
|
loadStartRef.current = performance.now();
|
|
@@ -374,6 +380,7 @@ export const useMessageBodyContent = ({
|
|
|
374
380
|
picked,
|
|
375
381
|
data,
|
|
376
382
|
isLoading,
|
|
383
|
+
hasNoRenderablePart,
|
|
377
384
|
isError: query.isError,
|
|
378
385
|
error: query.error,
|
|
379
386
|
refetch: query.refetch,
|
|
@@ -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
|
}
|
package/src/lib/api.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ApiError as ApiErrorWireBody } from "@remit/api-http-client/types.gen.ts";
|
|
1
2
|
import { taggedFetch } from "./network-error";
|
|
2
3
|
|
|
3
4
|
export class ApiError extends Error {
|
|
@@ -11,6 +12,46 @@ export class ApiError extends Error {
|
|
|
11
12
|
}
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The wire body's coded half, re-checked at runtime. `ApiErrorWireBody` is the
|
|
17
|
+
* generated flat shape the API sends; `ApiError` is the class every HTTP
|
|
18
|
+
* failure arrives in, carrying that body at `.body`. Reading `code` off the
|
|
19
|
+
* class is the bug #1004 is about, so nothing outside this module reaches for
|
|
20
|
+
* either shape directly.
|
|
21
|
+
*/
|
|
22
|
+
export interface CodedApiErrorBody {
|
|
23
|
+
code: ApiErrorWireBody["code"];
|
|
24
|
+
details: Record<string, unknown> | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
28
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
|
|
30
|
+
export const codedApiErrorBody = (
|
|
31
|
+
error: unknown,
|
|
32
|
+
): CodedApiErrorBody | undefined => {
|
|
33
|
+
const body = error instanceof ApiError ? error.body : error;
|
|
34
|
+
if (!isRecord(body)) return undefined;
|
|
35
|
+
const { code, details } = body;
|
|
36
|
+
if (typeof code !== "string") return undefined;
|
|
37
|
+
return { code, details: isRecord(details) ? details : undefined };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const failureMessage = (body: unknown, status: number): string => {
|
|
41
|
+
if (isRecord(body) && body.message !== undefined && body.message !== null) {
|
|
42
|
+
const message = String(body.message);
|
|
43
|
+
if (message) return message;
|
|
44
|
+
}
|
|
45
|
+
return `Request failed with status ${status}`;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The one place an HTTP failure becomes an `ApiError`, so a test can hand a
|
|
50
|
+
* consumer the exact value the error interceptor produces.
|
|
51
|
+
*/
|
|
52
|
+
export const wrapHttpFailure = (body: unknown, status: number): ApiError =>
|
|
53
|
+
new ApiError(failureMessage(body, status), status, body);
|
|
54
|
+
|
|
14
55
|
interface RequestOptions extends Omit<RequestInit, "body"> {
|
|
15
56
|
params?: Record<string, string | number | boolean | undefined>;
|
|
16
57
|
body?: unknown;
|
|
@@ -52,11 +93,7 @@ const request = async <T>(
|
|
|
52
93
|
|
|
53
94
|
if (!response.ok) {
|
|
54
95
|
const errorBody = await response.json().catch(() => undefined);
|
|
55
|
-
throw
|
|
56
|
-
errorBody?.message || `Request failed with status ${response.status}`,
|
|
57
|
-
response.status,
|
|
58
|
-
errorBody,
|
|
59
|
-
);
|
|
96
|
+
throw wrapHttpFailure(errorBody, response.status);
|
|
60
97
|
}
|
|
61
98
|
|
|
62
99
|
if (response.status === 204) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
|
+
import { trashOperationsEmptyTrash } from "@remit/api-http-client/sdk.gen.ts";
|
|
4
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
5
|
+
import { ApiError } from "./api";
|
|
6
|
+
import "./client";
|
|
7
|
+
|
|
8
|
+
let http: HttpMock | undefined;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
http?.restore();
|
|
12
|
+
http = undefined;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const answer = (status: number, body: unknown): void => {
|
|
16
|
+
http = mockFetch(
|
|
17
|
+
() =>
|
|
18
|
+
new Response(JSON.stringify(body), {
|
|
19
|
+
status,
|
|
20
|
+
headers: { "content-type": "application/json" },
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const empty = (): Promise<unknown> =>
|
|
26
|
+
trashOperationsEmptyTrash({
|
|
27
|
+
path: { accountId: "acc-1" },
|
|
28
|
+
throwOnError: true,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("the error interceptor", () => {
|
|
32
|
+
it("hands the app an ApiError carrying the status and the wire body", async () => {
|
|
33
|
+
answer(409, {
|
|
34
|
+
code: "folder_role_unresolved",
|
|
35
|
+
message: "Nobody has confirmed which folder is this account's Trash",
|
|
36
|
+
details: { reason: "unconfirmed", role: "Trash", accountId: "acc-1" },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const error = await empty().then(
|
|
40
|
+
() => undefined,
|
|
41
|
+
(reason: unknown) => reason,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
assert.ok(error instanceof ApiError);
|
|
45
|
+
assert.equal(error.status, 409);
|
|
46
|
+
assert.equal(
|
|
47
|
+
error.message,
|
|
48
|
+
"Nobody has confirmed which folder is this account's Trash",
|
|
49
|
+
);
|
|
50
|
+
assert.deepEqual(error.body, {
|
|
51
|
+
code: "folder_role_unresolved",
|
|
52
|
+
message: "Nobody has confirmed which folder is this account's Trash",
|
|
53
|
+
details: { reason: "unconfirmed", role: "Trash", accountId: "acc-1" },
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("falls back to the status when the body states no message", async () => {
|
|
58
|
+
answer(503, {});
|
|
59
|
+
|
|
60
|
+
const error = await empty().then(
|
|
61
|
+
() => undefined,
|
|
62
|
+
(reason: unknown) => reason,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
assert.ok(error instanceof ApiError);
|
|
66
|
+
assert.equal(error.message, "Request failed with status 503");
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/lib/client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { client } from "@remit/api-http-client/client.gen.ts";
|
|
2
2
|
import { getRuntimeConfig } from "../runtime-config";
|
|
3
|
-
import { ApiError } from "./api";
|
|
3
|
+
import { ApiError, wrapHttpFailure } from "./api";
|
|
4
4
|
import { taggedFetch } from "./network-error";
|
|
5
5
|
|
|
6
6
|
// In production, the config.js apiUrl points at the deployed API Gateway.
|
|
@@ -27,13 +27,7 @@ client.interceptors.error.use((error, response) => {
|
|
|
27
27
|
if (error instanceof ApiError) return error;
|
|
28
28
|
if (!response) return error;
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
const message =
|
|
32
|
-
body && typeof body === "object" && "message" in body
|
|
33
|
-
? String((body as { message: unknown }).message)
|
|
34
|
-
: `Request failed with status ${response.status}`;
|
|
35
|
-
|
|
36
|
-
return new ApiError(message, response.status, body);
|
|
30
|
+
return wrapHttpFailure(error, response.status);
|
|
37
31
|
});
|
|
38
32
|
|
|
39
33
|
export { client };
|
|
@@ -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],
|
|
@@ -31,6 +31,7 @@ import { isMailboxNotSettledRefusal } from "@/components/ui/folder-role-refusal"
|
|
|
31
31
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
32
32
|
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
33
33
|
import { guardFolderDeletion } from "@/lib/delete-folder";
|
|
34
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
34
35
|
import {
|
|
35
36
|
buildMailboxRoleMap,
|
|
36
37
|
CANONICAL_TO_NAV_ROLE,
|
|
@@ -57,6 +58,9 @@ function AccountFolders({ account }: { account: RemitImapAccountResponse }) {
|
|
|
57
58
|
|
|
58
59
|
const appointMutation = useMutation({
|
|
59
60
|
...folderRoleOperationsAppointFolderRoleMutation(),
|
|
61
|
+
// The pane states a still-settling mailbox inline; see the same meta on
|
|
62
|
+
// the prompt's own write.
|
|
63
|
+
meta: softErrorStatuses(409),
|
|
60
64
|
onSuccess: () => {
|
|
61
65
|
queryClient.invalidateQueries({
|
|
62
66
|
queryKey: configOperationsGetConfigQueryKey(),
|
package/src/test-support/http.ts
CHANGED
|
@@ -71,8 +71,9 @@ export const mockFetch = (responder: Responder = () => ({})): HttpMock => {
|
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
73
|
* A failed request. The status travels in the body as well as on the response:
|
|
74
|
-
* the generated client throws the parsed body,
|
|
75
|
-
*
|
|
74
|
+
* the generated client throws the parsed body, which the error interceptor
|
|
75
|
+
* re-wraps as an `ApiError` carrying the response status. Anything read off the
|
|
76
|
+
* body — a `code`, its `details` — sits at `.body`, never at the top level.
|
|
76
77
|
*/
|
|
77
78
|
export const httpError = (status: number, message = "boom"): Response =>
|
|
78
79
|
new Response(JSON.stringify({ status, message }), {
|
|
@@ -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
|
-
}
|