@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.
@@ -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
+ };