@remit/web-client 0.0.13 → 0.0.14
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/MessageActionMenu.tsx +18 -25
- package/src/components/mail/MessageDetail.tsx +1 -0
- 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/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/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,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
|
+
};
|