@remit/web-client 0.0.12 → 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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/auth/better-auth-config.ts +4 -1
  3. package/src/components/compose/ComposeForm.tsx +3 -0
  4. package/src/components/layout/ComposeFab.tsx +19 -34
  5. package/src/components/layout/MailTopBar.tsx +64 -0
  6. package/src/components/mail/BriefPane.tsx +1 -12
  7. package/src/components/mail/FlaggedPane.tsx +1 -12
  8. package/src/components/mail/IntelligencePane.tsx +10 -8
  9. package/src/components/mail/MailListHeader.tsx +9 -5
  10. package/src/components/mail/MailboxPane.tsx +0 -10
  11. package/src/components/mail/MessageActionMenu.tsx +18 -25
  12. package/src/components/mail/MessageDetail.tsx +1 -0
  13. package/src/components/mail/MessageToolbar.render.test.ts +39 -0
  14. package/src/components/mail/MessageToolbar.tsx +8 -48
  15. package/src/components/ui/ErrorBanner.tsx +6 -3
  16. package/src/components/ui/ErrorBannerProvider.render.test.ts +85 -0
  17. package/src/components/ui/ErrorBannerProvider.tsx +18 -0
  18. package/src/components/ui/error-banners.ts +9 -0
  19. package/src/hooks/useComposeTarget.ts +92 -0
  20. package/src/hooks/useDeleteMessages.ts +14 -21
  21. package/src/hooks/useIntelligenceData.ts +14 -3
  22. package/src/hooks/useLayoutTier.test.ts +26 -0
  23. package/src/hooks/useMarkAsRead.ts +14 -23
  24. package/src/hooks/useMessageBodyContent.ts +2 -1
  25. package/src/hooks/useMoveMessages.ts +14 -21
  26. package/src/hooks/useStaleAccountSync.test.ts +22 -1
  27. package/src/hooks/useToggleStar.test.ts +31 -0
  28. package/src/hooks/useToggleStar.ts +27 -31
  29. package/src/hooks/useUpdateAddressFlags.ts +3 -1
  30. package/src/lib/api.ts +3 -1
  31. package/src/lib/client.ts +3 -0
  32. package/src/lib/compose-routes.test.ts +44 -0
  33. package/src/lib/compose-routes.ts +25 -0
  34. package/src/lib/error-classifier.test.ts +156 -8
  35. package/src/lib/error-classifier.ts +49 -9
  36. package/src/lib/network-error.ts +58 -0
  37. package/src/lib/query-error-handler.test.ts +6 -2
  38. package/src/lib/query-escalation.integration.test.ts +3 -2
  39. package/src/lib/route-search-query.test.ts +48 -0
  40. package/src/lib/sender-address.test.ts +60 -0
  41. package/src/lib/sender-address.ts +37 -0
  42. package/src/lib/thread-cache.test.ts +65 -0
  43. package/src/lib/thread-cache.ts +76 -0
  44. package/src/routes/mail/outbox.tsx +5 -0
  45. package/src/routes/mail.tsx +15 -1
@@ -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
+ };
@@ -3,8 +3,13 @@ import { z } from "zod";
3
3
 
4
4
  // Search schema: `selectedOutboxMessageId` is read by `OutboxPane` in the
5
5
  // parent `/mail` shell via `useSearch({ strict: false })`.
6
+ //
7
+ // `q` is inherited from the parent /mail route; re-declared here so it survives
8
+ // this route's own search validation and isn't dropped when navigating with a
9
+ // functional search updater.
6
10
  const outboxSearchSchema = z.object({
7
11
  selectedOutboxMessageId: z.string().optional(),
12
+ q: z.string().optional(),
8
13
  });
9
14
 
10
15
  export const Route = createFileRoute("/mail/outbox")({
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
15
15
  import { z } from "zod";
16
16
  import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
17
17
  import { ComposeFab } from "@/components/layout/ComposeFab";
18
+ import { MailTopBar } from "@/components/layout/MailTopBar";
18
19
  import { BriefPane } from "@/components/mail/BriefPane";
19
20
  import { FlaggedPane } from "@/components/mail/FlaggedPane";
20
21
  import { MailboxPane } from "@/components/mail/MailboxPane";
@@ -268,7 +269,16 @@ function MailLayout() {
268
269
  const navContent = (
269
270
  <MailNav accounts={accounts} onMailboxSelect={handleMailboxSelect} />
270
271
  );
271
- const overlayContent = <ComposeFab />;
272
+ // Single-pane only, where the FAB is the compose entry point. Above it the
273
+ // top bar owns compose, and mounting the FAB there would resolve the same
274
+ // compose target twice for a button CSS keeps hidden.
275
+ const overlayContent = isSinglePane ? (
276
+ <ComposeFab accounts={accounts} />
277
+ ) : undefined;
278
+ // Desktop only. Below 1024px the single pane keeps its own header search and
279
+ // the phone takeover; there is no room for a bar spanning panes that do not
280
+ // exist side by side.
281
+ const topBar = isSinglePane ? undefined : <MailTopBar accounts={accounts} />;
272
282
  const navSlideOver = {
273
283
  navOpen: drawerOpen,
274
284
  onOpenNav: () => setDrawerOpen(true),
@@ -303,6 +313,7 @@ function MailLayout() {
303
313
  ) : (
304
314
  <AppShellSlotted
305
315
  nav={navContent}
316
+ topBar={topBar}
306
317
  list={<BriefPane.List />}
307
318
  reading={<BriefPane.Reading />}
308
319
  intelligenceOpen={intelligenceOpen}
@@ -330,6 +341,7 @@ function MailLayout() {
330
341
  ) : (
331
342
  <AppShellSlotted
332
343
  nav={navContent}
344
+ topBar={topBar}
333
345
  list={<FlaggedPane.List />}
334
346
  reading={<FlaggedPane.Reading />}
335
347
  intelligenceOpen={intelligenceOpen}
@@ -359,6 +371,7 @@ function MailLayout() {
359
371
  ) : (
360
372
  <AppShellSlotted
361
373
  nav={navContent}
374
+ topBar={topBar}
362
375
  list={<MailboxPane.List />}
363
376
  reading={<MailboxPane.Reading />}
364
377
  intelligence={<MailboxPane.Intelligence />}
@@ -386,6 +399,7 @@ function MailLayout() {
386
399
  ) : (
387
400
  <AppShellSlotted
388
401
  nav={navContent}
402
+ topBar={topBar}
389
403
  list={<OutboxPane.List />}
390
404
  reading={<OutboxPane.Reading />}
391
405
  intelligenceOpen={false}