@remit/web-client 0.0.13 → 0.0.15
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/better-auth-config.ts +4 -1
- package/src/components/compose/ComposeForm.tsx +3 -0
- package/src/components/mail/IntelligencePane.tsx +10 -8
- package/src/components/mail/MailboxPane.tsx +53 -2
- package/src/components/mail/MessageActionMenu.tsx +18 -25
- package/src/components/mail/MessageDetail.tsx +1 -0
- package/src/components/mail/MessageList.tsx +196 -78
- package/src/components/mail/MessageListItem.tsx +60 -13
- package/src/components/mail/SwipeableMessageRow.tsx +8 -0
- package/src/components/ui/ConfirmDialog.tsx +9 -7
- package/src/components/ui/ErrorBanner.tsx +6 -3
- package/src/components/ui/ErrorBannerProvider.render.test.ts +85 -0
- package/src/components/ui/ErrorBannerProvider.tsx +18 -0
- package/src/components/ui/error-banners.ts +9 -0
- package/src/hooks/useDeleteMessages.ts +14 -21
- package/src/hooks/useIntelligenceData.ts +14 -3
- package/src/hooks/useMarkAsRead.ts +14 -23
- package/src/hooks/useMessageBodyContent.ts +2 -1
- package/src/hooks/useMoveMessages.ts +14 -21
- package/src/hooks/useStaleAccountSync.test.ts +22 -1
- package/src/hooks/useToggleStar.test.ts +31 -0
- package/src/hooks/useToggleStar.ts +27 -31
- package/src/hooks/useTriageKeyboard.ts +13 -8
- package/src/hooks/useUpdateAddressFlags.ts +3 -1
- package/src/lib/api.ts +3 -1
- package/src/lib/client.ts +3 -0
- package/src/lib/error-classifier.test.ts +156 -8
- package/src/lib/error-classifier.ts +49 -9
- package/src/lib/keymap-dispatch.test.ts +78 -1
- package/src/lib/keymap-dispatch.ts +64 -7
- package/src/lib/keymap.ts +23 -0
- package/src/lib/list-focus.test.ts +25 -0
- package/src/lib/list-focus.ts +24 -0
- package/src/lib/network-error.ts +58 -0
- package/src/lib/query-error-handler.test.ts +6 -2
- package/src/lib/query-escalation.integration.test.ts +3 -2
- package/src/lib/sender-address.test.ts +60 -0
- package/src/lib/sender-address.ts +37 -0
- package/src/lib/thread-cache.test.ts +65 -0
- package/src/lib/thread-cache.ts +76 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport failures are identified where they happen, not guessed at later.
|
|
3
|
+
*
|
|
4
|
+
* `fetch()` rejects for exactly two reasons: the request never completed at the
|
|
5
|
+
* transport level (DNS, TLS, connection lost, captive portal, timeout), or it
|
|
6
|
+
* was aborted. It never rejects for an HTTP status. So the one place that knows
|
|
7
|
+
* for certain that an error is a network error is the call site that wrapped
|
|
8
|
+
* `fetch` — everywhere downstream is guessing.
|
|
9
|
+
*
|
|
10
|
+
* Guessing is what the classifier used to do, first by treating every error
|
|
11
|
+
* without an HTTP status as a network blip (which swallowed our own exceptions),
|
|
12
|
+
* then by matching browser failure strings (which is unbounded: WebKit alone
|
|
13
|
+
* says "Failed to fetch", "Load failed", "The network connection was lost." and
|
|
14
|
+
* "The request timed out.", and undici says "fetch failed"). Tagging at the
|
|
15
|
+
* boundary makes the question decidable and the browser's wording irrelevant.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A request that never reached a server. Always soft — never escalated. */
|
|
19
|
+
export class NetworkError extends Error {
|
|
20
|
+
constructor(cause: unknown) {
|
|
21
|
+
super(
|
|
22
|
+
cause instanceof Error && cause.message
|
|
23
|
+
? cause.message
|
|
24
|
+
: "The request could not be completed.",
|
|
25
|
+
{ cause },
|
|
26
|
+
);
|
|
27
|
+
this.name = "NetworkError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A deliberate cancellation — a route change, a React Query cancellation, a
|
|
33
|
+
* caller's own `AbortController`. Its own category: not a failure at all, so it
|
|
34
|
+
* passes through untagged.
|
|
35
|
+
*
|
|
36
|
+
* `AbortSignal.timeout()` rejects with a `TimeoutError` rather than an
|
|
37
|
+
* `AbortError`, and a timeout IS a transport failure, so it is deliberately not
|
|
38
|
+
* matched here and gets tagged like any other.
|
|
39
|
+
*/
|
|
40
|
+
const isDeliberateAbort = (error: unknown): boolean =>
|
|
41
|
+
typeof error === "object" &&
|
|
42
|
+
error !== null &&
|
|
43
|
+
"name" in error &&
|
|
44
|
+
(error as { name?: unknown }).name === "AbortError";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `fetch`, with transport failures tagged. Every app-owned request goes through
|
|
48
|
+
* this — the generated client is configured with it, and the hand-written
|
|
49
|
+
* wrapper calls it — so a `NetworkError` downstream is a fact, not an inference.
|
|
50
|
+
*/
|
|
51
|
+
export const taggedFetch: typeof fetch = async (input, init) => {
|
|
52
|
+
try {
|
|
53
|
+
return await fetch(input, init);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (isDeliberateAbort(error)) throw error;
|
|
56
|
+
throw new NetworkError(error);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
@@ -3,6 +3,7 @@ import { afterEach, describe, it } from "node:test";
|
|
|
3
3
|
import type { Mutation, Query } from "@tanstack/react-query";
|
|
4
4
|
import { ApiError } from "./api";
|
|
5
5
|
import { __resetFatalError, subscribeFatalError } from "./fatal-error";
|
|
6
|
+
import { NetworkError } from "./network-error";
|
|
6
7
|
import {
|
|
7
8
|
handleMutationCacheError,
|
|
8
9
|
handleQueryCacheError,
|
|
@@ -95,12 +96,15 @@ describe("handleQueryCacheError (fail-fast contract #1059)", () => {
|
|
|
95
96
|
assert.equal(escalated, false);
|
|
96
97
|
});
|
|
97
98
|
|
|
98
|
-
it("does NOT escalate a
|
|
99
|
+
it("does NOT escalate a network blip tagged at the fetch boundary", () => {
|
|
99
100
|
let escalated = false;
|
|
100
101
|
subscribeFatalError(() => {
|
|
101
102
|
escalated = true;
|
|
102
103
|
});
|
|
103
|
-
handleQueryCacheError(
|
|
104
|
+
handleQueryCacheError(
|
|
105
|
+
new NetworkError(new TypeError("Failed to fetch")),
|
|
106
|
+
fakeQuery(),
|
|
107
|
+
);
|
|
104
108
|
assert.equal(escalated, false);
|
|
105
109
|
});
|
|
106
110
|
});
|
|
@@ -11,6 +11,7 @@ import { afterEach, describe, it } from "node:test";
|
|
|
11
11
|
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
|
12
12
|
import { ApiError } from "./api";
|
|
13
13
|
import { __resetFatalError, subscribeFatalError } from "./fatal-error";
|
|
14
|
+
import { NetworkError } from "./network-error";
|
|
14
15
|
import {
|
|
15
16
|
handleMutationCacheError,
|
|
16
17
|
handleQueryCacheError,
|
|
@@ -117,7 +118,7 @@ describe("global query/mutation escalation", () => {
|
|
|
117
118
|
assert.deepEqual(seen, ["not found"]);
|
|
118
119
|
});
|
|
119
120
|
|
|
120
|
-
it("
|
|
121
|
+
it("an offline blip tagged at the fetch boundary does NOT escalate", async () => {
|
|
121
122
|
let escalated = false;
|
|
122
123
|
subscribeFatalError(() => {
|
|
123
124
|
escalated = true;
|
|
@@ -128,7 +129,7 @@ describe("global query/mutation escalation", () => {
|
|
|
128
129
|
.fetchQuery({
|
|
129
130
|
queryKey: ["offline"],
|
|
130
131
|
queryFn: async () => {
|
|
131
|
-
throw new TypeError("Failed to fetch");
|
|
132
|
+
throw new NetworkError(new TypeError("Failed to fetch"));
|
|
132
133
|
},
|
|
133
134
|
})
|
|
134
135
|
.catch(() => {});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { RemitImapAddressResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import {
|
|
5
|
+
pickSenderAddress,
|
|
6
|
+
SENDER_ADDRESS_SEARCH_LIMIT,
|
|
7
|
+
senderAddressSearchQuery,
|
|
8
|
+
} from "./sender-address";
|
|
9
|
+
|
|
10
|
+
const address = (
|
|
11
|
+
addressId: string,
|
|
12
|
+
normalizedEmail: string,
|
|
13
|
+
): RemitImapAddressResponse =>
|
|
14
|
+
({ addressId, normalizedEmail }) as RemitImapAddressResponse;
|
|
15
|
+
|
|
16
|
+
describe("senderAddressSearchQuery", () => {
|
|
17
|
+
it("lowercases the address and asks for a window, not one row", () => {
|
|
18
|
+
assert.deepEqual(senderAddressSearchQuery("Support@NPMJS.com"), {
|
|
19
|
+
q: "support@npmjs.com",
|
|
20
|
+
limit: SENDER_ADDRESS_SEARCH_LIMIT,
|
|
21
|
+
});
|
|
22
|
+
assert.ok(SENDER_ADDRESS_SEARCH_LIMIT > 1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("is stable for a missing sender, so the cache key stays valid", () => {
|
|
26
|
+
assert.deepEqual(senderAddressSearchQuery(undefined), {
|
|
27
|
+
q: "",
|
|
28
|
+
limit: SENDER_ADDRESS_SEARCH_LIMIT,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("pickSenderAddress", () => {
|
|
34
|
+
it("picks the exact address, not the first prefix match", () => {
|
|
35
|
+
const items = [
|
|
36
|
+
address("other", "sup@npmjs.com"),
|
|
37
|
+
address("wanted", "support@npmjs.com"),
|
|
38
|
+
];
|
|
39
|
+
assert.equal(
|
|
40
|
+
pickSenderAddress(items, "support@npmjs.com")?.addressId,
|
|
41
|
+
"wanted",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("matches case-insensitively", () => {
|
|
46
|
+
const items = [address("wanted", "support@npmjs.com")];
|
|
47
|
+
assert.equal(
|
|
48
|
+
pickSenderAddress(items, "Support@NPMJS.com")?.addressId,
|
|
49
|
+
"wanted",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("resolves to undefined rather than the wrong sender", () => {
|
|
54
|
+
const items = [address("other", "sup@npmjs.com")];
|
|
55
|
+
assert.equal(pickSenderAddress(items, "support@npmjs.com"), undefined);
|
|
56
|
+
assert.equal(pickSenderAddress([], "support@npmjs.com"), undefined);
|
|
57
|
+
assert.equal(pickSenderAddress(undefined, "support@npmjs.com"), undefined);
|
|
58
|
+
assert.equal(pickSenderAddress(items, undefined), undefined);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { RemitImapAddressResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `GET /addresses/search` is a prefix search over both the display-name
|
|
5
|
+
* compound and the normalized email, so a query for one sender's address can
|
|
6
|
+
* legitimately return several rows (`sup@x.com` also prefixes `support@x.com`,
|
|
7
|
+
* and any display name starting with the same characters matches too).
|
|
8
|
+
*
|
|
9
|
+
* Asking for a single row and taking `items[0]` is therefore wrong twice over:
|
|
10
|
+
* the row it returns may belong to a different sender, and the row we actually
|
|
11
|
+
* want may be past the cut. Fetch a small window instead and pick the exact
|
|
12
|
+
* address out of it.
|
|
13
|
+
*/
|
|
14
|
+
export const SENDER_ADDRESS_SEARCH_LIMIT = 10;
|
|
15
|
+
|
|
16
|
+
/** Query params for the sender-address lookup. One shape, used by every caller. */
|
|
17
|
+
export const senderAddressSearchQuery = (
|
|
18
|
+
senderEmail: string | undefined,
|
|
19
|
+
): { q: string; limit: number } => ({
|
|
20
|
+
q: senderEmail?.toLowerCase() ?? "",
|
|
21
|
+
limit: SENDER_ADDRESS_SEARCH_LIMIT,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Select the address row for exactly this sender. A prefix match on another
|
|
26
|
+
* sender is not this sender, so it resolves to `undefined` rather than the
|
|
27
|
+
* wrong address — silently flagging the wrong sender is worse than not
|
|
28
|
+
* resolving.
|
|
29
|
+
*/
|
|
30
|
+
export const pickSenderAddress = (
|
|
31
|
+
items: RemitImapAddressResponse[] | undefined,
|
|
32
|
+
senderEmail: string | undefined,
|
|
33
|
+
): RemitImapAddressResponse | undefined => {
|
|
34
|
+
if (!senderEmail) return undefined;
|
|
35
|
+
const normalized = senderEmail.toLowerCase();
|
|
36
|
+
return items?.find((item) => item.normalizedEmail === normalized);
|
|
37
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import {
|
|
5
|
+
isInfiniteThreadData,
|
|
6
|
+
patchThreadListCache,
|
|
7
|
+
type ThreadListCache,
|
|
8
|
+
} from "./thread-cache";
|
|
9
|
+
|
|
10
|
+
const message = (messageId: string): RemitImapThreadMessageResponse =>
|
|
11
|
+
({ messageId }) as RemitImapThreadMessageResponse;
|
|
12
|
+
|
|
13
|
+
const drop = (id: string) => (items: RemitImapThreadMessageResponse[]) =>
|
|
14
|
+
items.filter((item) => item.messageId !== id);
|
|
15
|
+
|
|
16
|
+
describe("isInfiniteThreadData", () => {
|
|
17
|
+
it("recognises only the paged shape", () => {
|
|
18
|
+
assert.equal(isInfiniteThreadData({ pages: [], pageParams: [] }), true);
|
|
19
|
+
assert.equal(isInfiniteThreadData({ items: [] }), false);
|
|
20
|
+
assert.equal(isInfiniteThreadData(undefined), false);
|
|
21
|
+
assert.equal(isInfiniteThreadData(null), false);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("patchThreadListCache", () => {
|
|
26
|
+
it("patches every page of an infinite query", () => {
|
|
27
|
+
const cache: ThreadListCache = {
|
|
28
|
+
pages: [
|
|
29
|
+
{ items: [message("a"), message("b")] },
|
|
30
|
+
{ items: [message("c")] },
|
|
31
|
+
],
|
|
32
|
+
pageParams: [undefined, "next"],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const patched = patchThreadListCache(cache, drop("b"));
|
|
36
|
+
|
|
37
|
+
assert.deepEqual(patched, {
|
|
38
|
+
pages: [{ items: [message("a")] }, { items: [message("c")] }],
|
|
39
|
+
pageParams: [undefined, "next"],
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("patches a single-shot page, which shares the query-key prefix", () => {
|
|
44
|
+
// The rescue-candidate search caches this shape under the same prefix the
|
|
45
|
+
// mailbox list uses. An updater that assumed `pages` threw here, failing
|
|
46
|
+
// the whole mutation before it ever reached the server (issues #51, #55).
|
|
47
|
+
const cache: ThreadListCache = {
|
|
48
|
+
items: [message("a"), message("b")],
|
|
49
|
+
continuationToken: "t",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const patched = patchThreadListCache(cache, drop("a"));
|
|
53
|
+
|
|
54
|
+
assert.deepEqual(patched, {
|
|
55
|
+
items: [message("b")],
|
|
56
|
+
continuationToken: "t",
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("leaves an unrecognised entry alone instead of throwing", () => {
|
|
61
|
+
const cache = { total: 3 } as unknown as ThreadListCache;
|
|
62
|
+
assert.equal(patchThreadListCache(cache, drop("a")), cache);
|
|
63
|
+
assert.equal(patchThreadListCache(undefined, drop("a")), undefined);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Optimistic patching of the thread caches.
|
|
5
|
+
*
|
|
6
|
+
* `queryClient.setQueriesData({ queryKey: prefix }, …)` matches by key *prefix*,
|
|
7
|
+
* so an updater written for the mailbox list also runs against every other
|
|
8
|
+
* cached query sharing that prefix. Those do not all have the same shape: the
|
|
9
|
+
* mailbox list is an infinite query (`{ pages: [{ items }] }`) while
|
|
10
|
+
* single-shot readers of the same endpoint — the rescue-candidate search, for
|
|
11
|
+
* one — cache a plain page (`{ items }`). An updater that assumed the infinite
|
|
12
|
+
* shape threw `pages is undefined` inside `onMutate`, which React Query reports
|
|
13
|
+
* as a failed mutation: the move never fired and the user got an error toast
|
|
14
|
+
* for an action that was never attempted (issues #51, #55).
|
|
15
|
+
*
|
|
16
|
+
* `patchThreadListCache` handles both shapes and leaves anything else it does
|
|
17
|
+
* not recognise untouched.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface ThreadItemsPage {
|
|
21
|
+
items: RemitImapThreadMessageResponse[];
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface InfiniteThreadData {
|
|
26
|
+
pages: ThreadItemsPage[];
|
|
27
|
+
pageParams: Array<string | undefined>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Either cached shape served by the thread list/search endpoints. */
|
|
31
|
+
export type ThreadListCache = InfiniteThreadData | ThreadItemsPage;
|
|
32
|
+
|
|
33
|
+
export const isInfiniteThreadData = (
|
|
34
|
+
data: unknown,
|
|
35
|
+
): data is InfiniteThreadData =>
|
|
36
|
+
typeof data === "object" &&
|
|
37
|
+
data !== null &&
|
|
38
|
+
Array.isArray((data as { pages?: unknown }).pages);
|
|
39
|
+
|
|
40
|
+
const isThreadItemsPage = (data: unknown): data is ThreadItemsPage =>
|
|
41
|
+
typeof data === "object" &&
|
|
42
|
+
data !== null &&
|
|
43
|
+
Array.isArray((data as { items?: unknown }).items);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Apply `patchItems` to every list of thread messages inside a cache entry,
|
|
47
|
+
* whichever shape it holds.
|
|
48
|
+
*
|
|
49
|
+
* `old` is `unknown` because that is the truth: a prefix match hands this
|
|
50
|
+
* whatever happens to be cached under that prefix, and the two shapes below are
|
|
51
|
+
* the ones we know about today, not the ones we are guaranteed. Declaring a
|
|
52
|
+
* narrower parameter type is what produced the original bug — the type asserted
|
|
53
|
+
* a shape the runtime never promised, and the first entry that disagreed threw.
|
|
54
|
+
* Anything unrecognised is returned untouched.
|
|
55
|
+
*/
|
|
56
|
+
export const patchThreadListCache = (
|
|
57
|
+
old: unknown,
|
|
58
|
+
patchItems: (
|
|
59
|
+
items: RemitImapThreadMessageResponse[],
|
|
60
|
+
) => RemitImapThreadMessageResponse[],
|
|
61
|
+
): unknown => {
|
|
62
|
+
if (old === undefined) return old;
|
|
63
|
+
if (isInfiniteThreadData(old)) {
|
|
64
|
+
return {
|
|
65
|
+
...old,
|
|
66
|
+
pages: old.pages.map((page) => ({
|
|
67
|
+
...page,
|
|
68
|
+
items: patchItems(page.items),
|
|
69
|
+
})),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (isThreadItemsPage(old)) {
|
|
73
|
+
return { ...old, items: patchItems(old.items) };
|
|
74
|
+
}
|
|
75
|
+
return old;
|
|
76
|
+
};
|