@remit/web-client 0.0.44 → 0.0.46

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.44",
3
+ "version": "0.0.46",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -1,8 +1,6 @@
1
1
  import {
2
2
  messageBulkOperationsUpdateFlagsMutation,
3
3
  threadDetailOperationsListThreadMessagesQueryKey,
4
- threadOperationsListThreadsQueryKey,
5
- threadOperationsSearchThreadsQueryKey,
6
4
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
7
5
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
8
6
  import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -25,9 +23,18 @@ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
25
23
  import { ErrorState } from "@/components/ui/ErrorState";
26
24
  import { formatErrorDetail } from "@/components/ui/error-banners";
27
25
  import { useDeleteMessages } from "@/hooks/useDeleteMessages";
26
+ import { setReadOnItems } from "@/hooks/useMarkAsRead";
28
27
  import { useMoveMessages } from "@/hooks/useMoveMessages";
29
28
  import { useToggleTrusted } from "@/hooks/useToggleTrusted";
30
- import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
29
+ import {
30
+ cancelThreadListQueries,
31
+ invalidateThreadListQueries,
32
+ patchThreadListQueries,
33
+ restoreThreadListQueries,
34
+ snapshotThreadListQueries,
35
+ type ThreadListSnapshotEntry,
36
+ threadListCacheKeys,
37
+ } from "@/lib/thread-list-cache";
31
38
  import { MoveToTrigger } from "./MoveToTrigger";
32
39
 
33
40
  interface ThreadMessagesData {
@@ -35,13 +42,6 @@ interface ThreadMessagesData {
35
42
  [key: string]: unknown;
36
43
  }
37
44
 
38
- /**
39
- * Either shape a thread list/search query caches — the infinite mailbox list
40
- * or a single-shot page. `setQueriesData` matches by key prefix, so the
41
- * optimistic updater sees both.
42
- */
43
- type ThreadsListData = ThreadListCache;
44
-
45
45
  interface SnapshotEntry<T> {
46
46
  queryKey: readonly unknown[];
47
47
  data: T;
@@ -49,10 +49,9 @@ interface SnapshotEntry<T> {
49
49
 
50
50
  interface MarkUnreadContext {
51
51
  threadMessagesPrefix: readonly unknown[];
52
- threadsListPrefix: readonly unknown[];
53
- threadsSearchPrefix: readonly unknown[];
52
+ listPrefixes: ReadonlyArray<readonly unknown[]>;
54
53
  previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
55
- previousThreadsList: SnapshotEntry<ThreadsListData>[];
54
+ previousThreadsList: ThreadListSnapshotEntry[];
56
55
  }
57
56
 
58
57
  interface MessageActionMenuProps {
@@ -118,17 +117,11 @@ export const MessageActionMenu = ({
118
117
  threadDetailOperationsListThreadMessagesQueryKey({
119
118
  path: { threadId },
120
119
  });
121
- const threadsListPrefix = threadOperationsListThreadsQueryKey({
122
- path: { mailboxId },
123
- });
124
- const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
125
- path: { mailboxId },
126
- });
120
+ const listPrefixes = threadListCacheKeys([mailboxId]);
127
121
 
128
122
  await Promise.all([
129
123
  queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
130
- queryClient.cancelQueries({ queryKey: threadsListPrefix }),
131
- queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
124
+ cancelThreadListQueries(queryClient, listPrefixes),
132
125
  ]);
133
126
 
134
127
  const previousThreadMessages = queryClient
@@ -139,18 +132,10 @@ export const MessageActionMenu = ({
139
132
  )
140
133
  .map(([queryKey, data]) => ({ queryKey, data }));
141
134
 
142
- const previousThreadsList = queryClient
143
- .getQueriesData<ThreadsListData>({ queryKey: threadsListPrefix })
144
- .concat(
145
- queryClient.getQueriesData<ThreadsListData>({
146
- queryKey: threadsSearchPrefix,
147
- }),
148
- )
149
- .filter(
150
- (entry): entry is [readonly unknown[], ThreadsListData] =>
151
- entry[1] !== undefined,
152
- )
153
- .map(([queryKey, data]) => ({ queryKey, data }));
135
+ const previousThreadsList = snapshotThreadListQueries(
136
+ queryClient,
137
+ listPrefixes,
138
+ );
154
139
 
155
140
  queryClient.setQueriesData<ThreadMessagesData>(
156
141
  { queryKey: threadMessagesPrefix },
@@ -158,37 +143,18 @@ export const MessageActionMenu = ({
158
143
  if (!old) return old;
159
144
  return {
160
145
  ...old,
161
- items: old.items.map((item) =>
162
- targetIds.has(item.messageId)
163
- ? { ...item, isRead: isReadNext }
164
- : item,
165
- ),
146
+ items: setReadOnItems(old.items, targetIds, isReadNext),
166
147
  };
167
148
  },
168
149
  );
169
150
 
170
- const patchListData = (old: unknown) =>
171
- patchThreadListCache(old, (items) =>
172
- items.map((item) =>
173
- targetIds.has(item.messageId)
174
- ? { ...item, isRead: isReadNext }
175
- : item,
176
- ),
177
- );
178
-
179
- queryClient.setQueriesData(
180
- { queryKey: threadsListPrefix },
181
- patchListData,
182
- );
183
- queryClient.setQueriesData(
184
- { queryKey: threadsSearchPrefix },
185
- patchListData,
151
+ patchThreadListQueries(queryClient, listPrefixes, (items) =>
152
+ setReadOnItems(items, targetIds, isReadNext),
186
153
  );
187
154
 
188
155
  return {
189
156
  threadMessagesPrefix,
190
- threadsListPrefix,
191
- threadsSearchPrefix,
157
+ listPrefixes,
192
158
  previousThreadMessages,
193
159
  previousThreadsList,
194
160
  };
@@ -198,9 +164,7 @@ export const MessageActionMenu = ({
198
164
  for (const entry of context.previousThreadMessages) {
199
165
  queryClient.setQueryData(entry.queryKey, entry.data);
200
166
  }
201
- for (const entry of context.previousThreadsList) {
202
- queryClient.setQueryData(entry.queryKey, entry.data);
203
- }
167
+ restoreThreadListQueries(queryClient, context.previousThreadsList);
204
168
  }
205
169
  const isReadNext = variables.body.isRead ?? true;
206
170
  pushError({
@@ -214,10 +178,7 @@ export const MessageActionMenu = ({
214
178
  queryClient.invalidateQueries({
215
179
  queryKey: context.threadMessagesPrefix,
216
180
  });
217
- queryClient.invalidateQueries({ queryKey: context.threadsListPrefix });
218
- queryClient.invalidateQueries({
219
- queryKey: context.threadsSearchPrefix,
220
- });
181
+ invalidateThreadListQueries(queryClient, context.listPrefixes);
221
182
  },
222
183
  });
223
184
 
@@ -67,10 +67,9 @@ interface MessageBodyProps {
67
67
  */
68
68
  category?: EmailRenderCategory;
69
69
  /**
70
- * Extra classes for the `.message-body` wrapper. The single-message
71
- * reading view (`MessageDetail`) passes `px-4` here so the body shares
72
- * the header's horizontal inset (#729). `MessageCard` provides its own
73
- * `px-5` inset via the surrounding card and leaves this unset.
70
+ * Extra classes for the `.message-body` wrapper. `MessageCard` leaves this
71
+ * unset: the kit's `ExpandedMessage` runs the body edge to edge on a phone
72
+ * and supplies the desktop inset itself.
74
73
  */
75
74
  className?: string;
76
75
  }
@@ -1,8 +1,6 @@
1
1
  import {
2
2
  messageBulkOperationsUpdateFlagsMutation,
3
3
  threadDetailOperationsListThreadMessagesQueryKey,
4
- threadOperationsListThreadsQueryKey,
5
- threadOperationsSearchThreadsQueryKey,
6
4
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
7
5
  import { MobileMessageActionBar, type PopoverMenuItem } from "@remit/ui";
8
6
  import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -11,6 +9,10 @@ import { useCallback } from "react";
11
9
  import { useDeleteMessages } from "@/hooks/useDeleteMessages";
12
10
  import { useMoveMessages } from "@/hooks/useMoveMessages";
13
11
  import { useToggleTrusted } from "@/hooks/useToggleTrusted";
12
+ import {
13
+ invalidateThreadListQueries,
14
+ threadListCacheKeys,
15
+ } from "@/lib/thread-list-cache";
14
16
  import { MoveToTrigger } from "./MoveToTrigger";
15
17
 
16
18
  interface MobileMessageBarProps {
@@ -67,14 +69,10 @@ export const MobileMessageBar = ({
67
69
  path: { threadId },
68
70
  }),
69
71
  });
70
- queryClient.invalidateQueries({
71
- queryKey: threadOperationsListThreadsQueryKey({ path: { mailboxId } }),
72
- });
73
- queryClient.invalidateQueries({
74
- queryKey: threadOperationsSearchThreadsQueryKey({
75
- path: { mailboxId },
76
- }),
77
- });
72
+ invalidateThreadListQueries(
73
+ queryClient,
74
+ threadListCacheKeys([mailboxId]),
75
+ );
78
76
  },
79
77
  });
80
78
 
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
4
+ import { threadListCacheKeys } from "@/lib/thread-list-cache";
5
+ import { mailboxesTouchedBy } from "./useEscalatedActions.js";
6
+
7
+ describe("mailboxesTouchedBy", () => {
8
+ test("a delete or mark-read run touches only the mailbox it ran over", () => {
9
+ assert.deepStrictEqual(mailboxesTouchedBy({ kind: "delete" }, "mb1"), [
10
+ "mb1",
11
+ ]);
12
+ assert.deepStrictEqual(mailboxesTouchedBy({ kind: "markRead" }, "mb1"), [
13
+ "mb1",
14
+ ]);
15
+ });
16
+
17
+ test("a move run also touches the destination", () => {
18
+ assert.deepStrictEqual(
19
+ mailboxesTouchedBy({ kind: "move", destinationMailboxId: "mb2" }, "mb1"),
20
+ ["mb1", "mb2"],
21
+ );
22
+ });
23
+
24
+ test("its cache keys reach the unified listing the daily brief reads", () => {
25
+ const keys = threadListCacheKeys(
26
+ mailboxesTouchedBy({ kind: "delete" }, "mb1"),
27
+ );
28
+ assert.ok(
29
+ keys.some(
30
+ (key) =>
31
+ JSON.stringify(key) ===
32
+ JSON.stringify(unifiedThreadOperationsListAllThreadsQueryKey()),
33
+ ),
34
+ );
35
+ });
36
+ });
@@ -1,8 +1,4 @@
1
- import {
2
- mailboxOperationsListMailboxesQueryKey,
3
- threadOperationsListThreadsQueryKey,
4
- threadOperationsSearchThreadsQueryKey,
5
- } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
1
+ import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
6
2
  import {
7
3
  messageBulkOperationsDeleteMessages,
8
4
  messageBulkOperationsMoveMessages,
@@ -28,6 +24,10 @@ import {
28
24
  runChunkedAction,
29
25
  runPredicateAction,
30
26
  } from "@/lib/bulk-actions";
27
+ import {
28
+ invalidateThreadListQueries,
29
+ threadListCacheKeys,
30
+ } from "@/lib/thread-list-cache";
31
31
 
32
32
  /** The predicate a search-scoped run re-issues on every page — the same
33
33
  * filters the visible list is searching with, minus pagination/count knobs. */
@@ -102,6 +102,18 @@ export interface UseEscalatedActionsResult {
102
102
  ) => Promise<BulkRunOutcome>;
103
103
  }
104
104
 
105
+ /**
106
+ * The mailboxes whose cached listings a bulk run affects: the mailbox it ran
107
+ * over, plus a move's destination, which gains the messages the source loses.
108
+ */
109
+ export const mailboxesTouchedBy = (
110
+ action: EscalatedAction,
111
+ mailboxId: string,
112
+ ): string[] =>
113
+ action.kind === "move"
114
+ ? [mailboxId, action.destinationMailboxId]
115
+ : [mailboxId];
116
+
105
117
  export const useEscalatedActions = ({
106
118
  mailboxId,
107
119
  accountId,
@@ -182,21 +194,10 @@ export const useEscalatedActions = ({
182
194
 
183
195
  const invalidateAfterRun = useCallback(
184
196
  (action: EscalatedAction) => {
185
- queryClient.invalidateQueries({
186
- queryKey: threadOperationsListThreadsQueryKey({ path: { mailboxId } }),
187
- });
188
- queryClient.invalidateQueries({
189
- queryKey: threadOperationsSearchThreadsQueryKey({
190
- path: { mailboxId },
191
- }),
192
- });
193
- if (action.kind === "move") {
194
- queryClient.invalidateQueries({
195
- queryKey: threadOperationsListThreadsQueryKey({
196
- path: { mailboxId: action.destinationMailboxId },
197
- }),
198
- });
199
- }
197
+ invalidateThreadListQueries(
198
+ queryClient,
199
+ threadListCacheKeys(mailboxesTouchedBy(action, mailboxId)),
200
+ );
200
201
  if (accountId) {
201
202
  queryClient.invalidateQueries({
202
203
  queryKey: mailboxOperationsListMailboxesQueryKey({
@@ -80,10 +80,14 @@ export const useMoveMessages = ({
80
80
  path: { threadId },
81
81
  })
82
82
  : [];
83
- // The source mailbox's lists plus the unified cross-account listing that
84
- // backs the daily brief — moving from the brief has to remove the row
85
- // there too, not only from the per-mailbox lists (#140, part of #149).
86
- const listPrefixes = threadListCacheKeys([mailboxId]);
83
+ // The source and destination mailboxes' lists plus the unified
84
+ // cross-account listing that backs the daily brief — moving from the
85
+ // brief has to remove the row there too, not only from the per-mailbox
86
+ // lists (#140, part of #149).
87
+ const listPrefixes = threadListCacheKeys([
88
+ mailboxId,
89
+ variables.body.destinationMailboxId,
90
+ ]);
87
91
 
88
92
  // Only cancel the thread-messages query when we actually have a
89
93
  // threadId — passing an empty queryKey here would match every
@@ -5,7 +5,15 @@ import {
5
5
  threadOperationsSearchThreadsQueryKey,
6
6
  unifiedThreadOperationsListAllThreadsQueryKey,
7
7
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
- import { threadListCacheKeys } from "./thread-list-cache.js";
8
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
9
+ import { QueryClient } from "@tanstack/react-query";
10
+ import {
11
+ invalidateThreadListQueries,
12
+ patchThreadListQueries,
13
+ restoreThreadListQueries,
14
+ snapshotThreadListQueries,
15
+ threadListCacheKeys,
16
+ } from "./thread-list-cache.js";
9
17
 
10
18
  describe("threadListCacheKeys", () => {
11
19
  test("resolves each mailbox's list and search caches plus the unified one", () => {
@@ -42,3 +50,98 @@ describe("threadListCacheKeys", () => {
42
50
  assert.strictEqual(keys.length, 3);
43
51
  });
44
52
  });
53
+
54
+ const thread = (
55
+ overrides: Partial<RemitImapThreadMessageResponse> & { messageId: string },
56
+ ): RemitImapThreadMessageResponse =>
57
+ ({
58
+ threadId: "t1",
59
+ threadMessageId: `tm-${overrides.messageId}`,
60
+ mailboxId: "mb1",
61
+ subject: "s",
62
+ fromName: "n",
63
+ fromEmail: "e",
64
+ sentDate: "2025-01-01T00:00:00Z",
65
+ snippet: "",
66
+ hasAttachment: false,
67
+ hasStars: false,
68
+ isRead: false,
69
+ ...overrides,
70
+ }) as RemitImapThreadMessageResponse;
71
+
72
+ /** The single-shot page the daily brief and Flagged cache under the unified key. */
73
+ const briefKey = unifiedThreadOperationsListAllThreadsQueryKey();
74
+
75
+ /** The brief's search variant caches under the same key plus query options. */
76
+ const briefSearchKey = unifiedThreadOperationsListAllThreadsQueryKey({
77
+ query: { query: "invoice", limit: 50 },
78
+ });
79
+
80
+ /** The infinite shape a mailbox list caches. */
81
+ const mailboxKey = threadOperationsListThreadsQueryKey({
82
+ path: { mailboxId: "mb1" },
83
+ query: { order: "desc" },
84
+ });
85
+
86
+ const seed = () => {
87
+ const queryClient = new QueryClient();
88
+ queryClient.setQueryData(briefKey, { items: [thread({ messageId: "m1" })] });
89
+ queryClient.setQueryData(briefSearchKey, {
90
+ items: [thread({ messageId: "m1" })],
91
+ });
92
+ queryClient.setQueryData(mailboxKey, {
93
+ pages: [{ items: [thread({ messageId: "m1" })] }],
94
+ pageParams: [undefined],
95
+ });
96
+ return queryClient;
97
+ };
98
+
99
+ const briefItems = (queryClient: QueryClient, key: readonly unknown[]) =>
100
+ queryClient.getQueryData<{ items: RemitImapThreadMessageResponse[] }>(key)
101
+ ?.items ?? [];
102
+
103
+ describe("mutating from the daily brief", () => {
104
+ test("patches the unified listing the brief reads, not only the mailbox list", () => {
105
+ const queryClient = seed();
106
+ patchThreadListQueries(queryClient, threadListCacheKeys(["mb1"]), (items) =>
107
+ items.map((item) => ({ ...item, isRead: true })),
108
+ );
109
+ assert.strictEqual(briefItems(queryClient, briefKey)[0].isRead, true);
110
+ assert.strictEqual(briefItems(queryClient, briefSearchKey)[0].isRead, true);
111
+ const pages = queryClient.getQueryData<{
112
+ pages: Array<{ items: RemitImapThreadMessageResponse[] }>;
113
+ }>(mailboxKey);
114
+ assert.strictEqual(pages?.pages[0].items[0].isRead, true);
115
+ });
116
+
117
+ test("removes a deleted row from the brief's page", () => {
118
+ const queryClient = seed();
119
+ patchThreadListQueries(queryClient, threadListCacheKeys(["mb1"]), (items) =>
120
+ items.filter((item) => item.messageId !== "m1"),
121
+ );
122
+ assert.deepStrictEqual(briefItems(queryClient, briefKey), []);
123
+ });
124
+
125
+ test("restores the brief's page when the mutation fails", () => {
126
+ const queryClient = seed();
127
+ const prefixes = threadListCacheKeys(["mb1"]);
128
+ const snapshot = snapshotThreadListQueries(queryClient, prefixes);
129
+ patchThreadListQueries(queryClient, prefixes, () => []);
130
+ restoreThreadListQueries(queryClient, snapshot);
131
+ assert.strictEqual(briefItems(queryClient, briefKey).length, 1);
132
+ assert.strictEqual(briefItems(queryClient, briefSearchKey).length, 1);
133
+ });
134
+
135
+ test("invalidates the brief's listing so the next read refetches", () => {
136
+ const queryClient = seed();
137
+ invalidateThreadListQueries(queryClient, threadListCacheKeys(["mb1"]));
138
+ assert.strictEqual(
139
+ queryClient.getQueryState(briefKey)?.isInvalidated,
140
+ true,
141
+ );
142
+ assert.strictEqual(
143
+ queryClient.getQueryState(mailboxKey)?.isInvalidated,
144
+ true,
145
+ );
146
+ });
147
+ });