@remit/web-client 0.0.33 → 0.0.34

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.33",
3
+ "version": "0.0.34",
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": {
@@ -2,15 +2,21 @@ import {
2
2
  mailboxOperationsListMailboxesQueryKey,
3
3
  messageBulkOperationsDeleteMessagesMutation,
4
4
  threadDetailOperationsListThreadMessagesQueryKey,
5
- threadOperationsListThreadsQueryKey,
6
- threadOperationsSearchThreadsQueryKey,
7
5
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
6
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
9
7
  import { useMutation, useQueryClient } from "@tanstack/react-query";
10
8
  import { useCallback } from "react";
11
9
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
12
10
  import { formatErrorDetail } from "@/components/ui/error-banners";
13
- import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
11
+ import {
12
+ cancelThreadListQueries,
13
+ invalidateThreadListQueries,
14
+ patchThreadListQueries,
15
+ restoreThreadListQueries,
16
+ snapshotThreadListQueries,
17
+ type ThreadListSnapshotEntry,
18
+ threadListCacheKeys,
19
+ } from "@/lib/thread-list-cache";
14
20
 
15
21
  interface UseDeleteMessagesOptions {
16
22
  mailboxId: string;
@@ -29,13 +35,6 @@ interface ThreadMessagesData {
29
35
  [key: string]: unknown;
30
36
  }
31
37
 
32
- /**
33
- * Either shape a thread list/search query caches — the infinite mailbox list
34
- * or a single-shot page. `setQueriesData` matches by key prefix, so the
35
- * optimistic updater sees both.
36
- */
37
- type ThreadsListData = ThreadListCache;
38
-
39
38
  interface SnapshotEntry<T> {
40
39
  queryKey: readonly unknown[];
41
40
  data: T;
@@ -43,10 +42,9 @@ interface SnapshotEntry<T> {
43
42
 
44
43
  interface DeleteContext {
45
44
  threadMessagesPrefix: readonly unknown[];
46
- threadsListPrefix: readonly unknown[];
47
- threadsSearchPrefix: readonly unknown[];
45
+ listPrefixes: ReadonlyArray<readonly unknown[]>;
48
46
  previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
49
- previousThreadsList: SnapshotEntry<ThreadsListData>[];
47
+ previousThreadsList: ThreadListSnapshotEntry[];
50
48
  }
51
49
 
52
50
  /**
@@ -94,17 +92,14 @@ export const useDeleteMessages = ({
94
92
  path: { threadId },
95
93
  })
96
94
  : [];
97
- const threadsListPrefix = threadOperationsListThreadsQueryKey({
98
- path: { mailboxId },
99
- });
100
- const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
101
- path: { mailboxId },
102
- });
95
+ // The browsed mailbox's lists plus the unified cross-account listing
96
+ // that backs the daily brief — deleting from the brief has to remove the
97
+ // row there too, not only from the per-mailbox lists (#140, part of #149).
98
+ const listPrefixes = threadListCacheKeys([mailboxId]);
103
99
 
104
100
  await Promise.all([
105
101
  queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
106
- queryClient.cancelQueries({ queryKey: threadsListPrefix }),
107
- queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
102
+ cancelThreadListQueries(queryClient, listPrefixes),
108
103
  ]);
109
104
 
110
105
  const previousThreadMessages = threadId
@@ -119,18 +114,10 @@ export const useDeleteMessages = ({
119
114
  .map(([queryKey, data]) => ({ queryKey, data }))
120
115
  : [];
121
116
 
122
- const previousThreadsList = queryClient
123
- .getQueriesData<ThreadsListData>({ queryKey: threadsListPrefix })
124
- .concat(
125
- queryClient.getQueriesData<ThreadsListData>({
126
- queryKey: threadsSearchPrefix,
127
- }),
128
- )
129
- .filter(
130
- (entry): entry is [readonly unknown[], ThreadsListData] =>
131
- entry[1] !== undefined,
132
- )
133
- .map(([queryKey, data]) => ({ queryKey, data }));
117
+ const previousThreadsList = snapshotThreadListQueries(
118
+ queryClient,
119
+ listPrefixes,
120
+ );
134
121
 
135
122
  if (threadId) {
136
123
  queryClient.setQueriesData<ThreadMessagesData>(
@@ -145,26 +132,15 @@ export const useDeleteMessages = ({
145
132
  );
146
133
  }
147
134
 
148
- const patchListData = (old: unknown) =>
149
- patchThreadListCache(old, (items) =>
150
- removeMessagesFromItems(items, messageIds),
151
- );
152
-
153
- queryClient.setQueriesData(
154
- { queryKey: threadsListPrefix },
155
- patchListData,
156
- );
157
- queryClient.setQueriesData(
158
- { queryKey: threadsSearchPrefix },
159
- patchListData,
135
+ patchThreadListQueries(queryClient, listPrefixes, (items) =>
136
+ removeMessagesFromItems(items, messageIds),
160
137
  );
161
138
 
162
139
  onAfterOptimisticRemove?.(Array.from(messageIds));
163
140
 
164
141
  return {
165
142
  threadMessagesPrefix,
166
- threadsListPrefix,
167
- threadsSearchPrefix,
143
+ listPrefixes,
168
144
  previousThreadMessages,
169
145
  previousThreadsList,
170
146
  };
@@ -174,9 +150,7 @@ export const useDeleteMessages = ({
174
150
  for (const entry of context.previousThreadMessages) {
175
151
  queryClient.setQueryData(entry.queryKey, entry.data);
176
152
  }
177
- for (const entry of context.previousThreadsList) {
178
- queryClient.setQueryData(entry.queryKey, entry.data);
179
- }
153
+ restoreThreadListQueries(queryClient, context.previousThreadsList);
180
154
  }
181
155
  const count = vars.body.messageIds?.length ?? 0;
182
156
  pushError({
@@ -195,8 +169,7 @@ export const useDeleteMessages = ({
195
169
  queryKey: context.threadMessagesPrefix,
196
170
  });
197
171
  }
198
- queryClient.invalidateQueries({ queryKey: context.threadsListPrefix });
199
- queryClient.invalidateQueries({ queryKey: context.threadsSearchPrefix });
172
+ invalidateThreadListQueries(queryClient, context.listPrefixes);
200
173
  if (accountId) {
201
174
  queryClient.invalidateQueries({
202
175
  queryKey: mailboxOperationsListMailboxesQueryKey({
@@ -1,9 +1,13 @@
1
1
  import assert from "node:assert";
2
- import { describe, test } from "node:test";
2
+ import { afterEach, beforeEach, describe, mock, test } from "node:test";
3
3
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
4
+ import { patchThreadListCache } from "../lib/thread-cache.js";
4
5
  import {
6
+ MARK_READ_DELAY_MS,
5
7
  resolveMailboxesForMessages,
8
+ scheduleMarkRead,
6
9
  selectMessagesToMarkRead,
10
+ setReadOnItems,
7
11
  } from "./useMarkAsRead.js";
8
12
 
9
13
  const make = (
@@ -167,6 +171,92 @@ describe("selectMessagesToMarkRead", () => {
167
171
  });
168
172
  });
169
173
 
174
+ describe("marking read across the unified-threads cache shapes", () => {
175
+ // The daily brief reads from the unified cross-account listing, a different
176
+ // cache than the per-mailbox lists the regular inboxes render. Marking a
177
+ // message read has to patch that cache too, or the brief keeps its unread
178
+ // dot until reload (#140). The unified prefix carries both cache shapes — the
179
+ // brief's single page and the Flagged view's infinite query — so the patch
180
+ // has to survive whichever it is handed.
181
+ const patch = (old: unknown) =>
182
+ patchThreadListCache(old, (items) =>
183
+ setReadOnItems(items, new Set(["m1"]), true),
184
+ );
185
+
186
+ test("patches the brief's single-shot page", () => {
187
+ const patched = patch({
188
+ items: [
189
+ make({ messageId: "m1", threadMessageId: "tm1", isRead: false }),
190
+ make({ messageId: "m2", threadMessageId: "tm2", isRead: false }),
191
+ ],
192
+ }) as { items: RemitImapThreadMessageResponse[] };
193
+ assert.equal(patched.items[0].isRead, true);
194
+ assert.equal(patched.items[1].isRead, false);
195
+ });
196
+
197
+ test("patches the Flagged view's infinite query instead of throwing", () => {
198
+ const patched = patch({
199
+ pages: [
200
+ {
201
+ items: [
202
+ make({ messageId: "m1", threadMessageId: "tm1", isRead: false }),
203
+ ],
204
+ },
205
+ {
206
+ items: [
207
+ make({ messageId: "m2", threadMessageId: "tm2", isRead: false }),
208
+ ],
209
+ },
210
+ ],
211
+ pageParams: [undefined, "next"],
212
+ }) as { pages: Array<{ items: RemitImapThreadMessageResponse[] }> };
213
+ assert.equal(patched.pages[0].items[0].isRead, true);
214
+ assert.equal(patched.pages[1].items[0].isRead, false);
215
+ });
216
+ });
217
+
218
+ describe("scheduleMarkRead", () => {
219
+ beforeEach(() => {
220
+ mock.timers.enable({ apis: ["setTimeout"] });
221
+ });
222
+ afterEach(() => {
223
+ mock.timers.reset();
224
+ });
225
+
226
+ test("marks read once the dwell elapses", () => {
227
+ const marked: string[][] = [];
228
+ scheduleMarkRead(["m1"], MARK_READ_DELAY_MS, (ids) => marked.push(ids));
229
+ mock.timers.tick(MARK_READ_DELAY_MS - 1);
230
+ assert.deepStrictEqual(marked, []);
231
+ mock.timers.tick(1);
232
+ assert.deepStrictEqual(marked, [["m1"]]);
233
+ });
234
+
235
+ test("does not mark when the selection changes before the dwell", () => {
236
+ const marked: string[][] = [];
237
+ const cancel = scheduleMarkRead(["m1"], MARK_READ_DELAY_MS, (ids) =>
238
+ marked.push(ids),
239
+ );
240
+ mock.timers.tick(2000);
241
+ cancel();
242
+ mock.timers.tick(MARK_READ_DELAY_MS);
243
+ assert.deepStrictEqual(marked, []);
244
+ });
245
+
246
+ test("schedules nothing for an already-read (empty) selection", () => {
247
+ const marked: string[][] = [];
248
+ scheduleMarkRead([], MARK_READ_DELAY_MS, (ids) => marked.push(ids));
249
+ mock.timers.tick(MARK_READ_DELAY_MS);
250
+ assert.deepStrictEqual(marked, []);
251
+ });
252
+
253
+ test("marks at once when the delay is not positive", () => {
254
+ const marked: string[][] = [];
255
+ scheduleMarkRead(["m1"], 0, (ids) => marked.push(ids));
256
+ assert.deepStrictEqual(marked, [["m1"]]);
257
+ });
258
+ });
259
+
170
260
  describe("resolveMailboxesForMessages", () => {
171
261
  const messages = [
172
262
  make({
@@ -2,15 +2,21 @@ import {
2
2
  mailboxOperationsListMailboxesQueryKey,
3
3
  messageBulkOperationsUpdateFlagsMutation,
4
4
  threadDetailOperationsListThreadMessagesQueryKey,
5
- threadOperationsListThreadsQueryKey,
6
- threadOperationsSearchThreadsQueryKey,
7
5
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
6
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
9
7
  import { useMutation, useQueryClient } from "@tanstack/react-query";
10
8
  import { useCallback, useEffect, useMemo, useRef } from "react";
11
9
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
12
10
  import { formatErrorDetail } from "@/components/ui/error-banners";
13
- import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
11
+ import {
12
+ cancelThreadListQueries,
13
+ invalidateThreadListQueries,
14
+ patchThreadListQueries,
15
+ restoreThreadListQueries,
16
+ snapshotThreadListQueries,
17
+ type ThreadListSnapshotEntry,
18
+ threadListCacheKeys,
19
+ } from "@/lib/thread-list-cache";
14
20
 
15
21
  interface UseMarkAsReadOptions {
16
22
  messages: RemitImapThreadMessageResponse[];
@@ -25,13 +31,6 @@ interface ThreadMessagesData {
25
31
  [key: string]: unknown;
26
32
  }
27
33
 
28
- /**
29
- * Either shape a thread list/search query caches — the infinite mailbox list
30
- * or a single-shot page. `setQueriesData` matches by key prefix, so the
31
- * optimistic updater sees both.
32
- */
33
- type ThreadsListData = ThreadListCache;
34
-
35
34
  interface SnapshotEntry<T> {
36
35
  queryKey: readonly unknown[];
37
36
  data: T;
@@ -39,12 +38,38 @@ interface SnapshotEntry<T> {
39
38
 
40
39
  interface MarkAsReadContext {
41
40
  threadMessagesPrefix: readonly unknown[];
42
- threadsListPrefixes: ReadonlyArray<readonly unknown[]>;
43
- threadsSearchPrefixes: ReadonlyArray<readonly unknown[]>;
41
+ listPrefixes: ReadonlyArray<readonly unknown[]>;
44
42
  previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
45
- previousThreadsList: SnapshotEntry<ThreadsListData>[];
43
+ previousThreadsList: ThreadListSnapshotEntry[];
46
44
  }
47
45
 
46
+ /**
47
+ * Dwell before a message the user is viewing is marked read. A glance closed
48
+ * within this window leaves it unread (#140). Applied by the single shared
49
+ * trigger below, so the daily brief and the mailbox thread views agree.
50
+ */
51
+ export const MARK_READ_DELAY_MS = 3000;
52
+
53
+ /**
54
+ * Fire `markRead` for `messageIds` after `delayMs`. Returns a canceller the
55
+ * caller runs on unmount or when the selection changes before the dwell
56
+ * elapses; an empty list schedules nothing, and a non-positive delay fires at
57
+ * once. Framework-agnostic so the timing can be exercised with fake timers.
58
+ */
59
+ export const scheduleMarkRead = (
60
+ messageIds: string[],
61
+ delayMs: number,
62
+ markRead: (ids: string[]) => void,
63
+ ): (() => void) => {
64
+ if (messageIds.length === 0) return () => {};
65
+ if (delayMs <= 0) {
66
+ markRead(messageIds);
67
+ return () => {};
68
+ }
69
+ const timer = setTimeout(() => markRead(messageIds), delayMs);
70
+ return () => clearTimeout(timer);
71
+ };
72
+
48
73
  /**
49
74
  * The mailboxes whose cached listings a batch of message mutations affects.
50
75
  *
@@ -68,7 +93,7 @@ export const resolveMailboxesForMessages = (
68
93
  return [...mailboxIds];
69
94
  };
70
95
 
71
- const setReadOnItems = (
96
+ export const setReadOnItems = (
72
97
  items: RemitImapThreadMessageResponse[],
73
98
  messageIds: Set<string>,
74
99
  isRead: boolean,
@@ -122,26 +147,13 @@ export const useMarkAsRead = ({
122
147
  threadDetailOperationsListThreadMessagesQueryKey({
123
148
  path: { threadId },
124
149
  });
125
- const affectedMailboxIds = resolveMailboxesForMessages(
126
- messageIds,
127
- messages,
128
- mailboxId,
129
- );
130
- const threadsListPrefixes = affectedMailboxIds.map((id) =>
131
- threadOperationsListThreadsQueryKey({ path: { mailboxId: id } }),
132
- );
133
- const threadsSearchPrefixes = affectedMailboxIds.map((id) =>
134
- threadOperationsSearchThreadsQueryKey({ path: { mailboxId: id } }),
150
+ const listPrefixes = threadListCacheKeys(
151
+ resolveMailboxesForMessages(messageIds, messages, mailboxId),
135
152
  );
136
153
 
137
154
  await Promise.all([
138
155
  queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
139
- ...threadsListPrefixes.map((queryKey) =>
140
- queryClient.cancelQueries({ queryKey }),
141
- ),
142
- ...threadsSearchPrefixes.map((queryKey) =>
143
- queryClient.cancelQueries({ queryKey }),
144
- ),
156
+ cancelThreadListQueries(queryClient, listPrefixes),
145
157
  ]);
146
158
 
147
159
  const previousThreadMessages = queryClient
@@ -152,18 +164,10 @@ export const useMarkAsRead = ({
152
164
  )
153
165
  .map(([queryKey, data]) => ({ queryKey, data }));
154
166
 
155
- const previousThreadsList = [
156
- ...threadsListPrefixes,
157
- ...threadsSearchPrefixes,
158
- ]
159
- .flatMap((queryKey) =>
160
- queryClient.getQueriesData<ThreadsListData>({ queryKey }),
161
- )
162
- .filter(
163
- (entry): entry is [readonly unknown[], ThreadsListData] =>
164
- entry[1] !== undefined,
165
- )
166
- .map(([queryKey, data]) => ({ queryKey, data }));
167
+ const previousThreadsList = snapshotThreadListQueries(
168
+ queryClient,
169
+ listPrefixes,
170
+ );
167
171
 
168
172
  queryClient.setQueriesData<ThreadMessagesData>(
169
173
  { queryKey: threadMessagesPrefix },
@@ -176,22 +180,13 @@ export const useMarkAsRead = ({
176
180
  },
177
181
  );
178
182
 
179
- const patchListData = (old: unknown) =>
180
- patchThreadListCache(old, (items) =>
181
- setReadOnItems(items, messageIds, isRead),
182
- );
183
-
184
- for (const queryKey of [
185
- ...threadsListPrefixes,
186
- ...threadsSearchPrefixes,
187
- ]) {
188
- queryClient.setQueriesData({ queryKey }, patchListData);
189
- }
183
+ patchThreadListQueries(queryClient, listPrefixes, (items) =>
184
+ setReadOnItems(items, messageIds, isRead),
185
+ );
190
186
 
191
187
  return {
192
188
  threadMessagesPrefix,
193
- threadsListPrefixes,
194
- threadsSearchPrefixes,
189
+ listPrefixes,
195
190
  previousThreadMessages,
196
191
  previousThreadsList,
197
192
  };
@@ -212,9 +207,7 @@ export const useMarkAsRead = ({
212
207
  for (const entry of context.previousThreadMessages) {
213
208
  queryClient.setQueryData(entry.queryKey, entry.data);
214
209
  }
215
- for (const entry of context.previousThreadsList) {
216
- queryClient.setQueryData(entry.queryKey, entry.data);
217
- }
210
+ restoreThreadListQueries(queryClient, context.previousThreadsList);
218
211
  }
219
212
  const isRead = variables.body.isRead ?? true;
220
213
  pushError({
@@ -226,12 +219,7 @@ export const useMarkAsRead = ({
226
219
  onSettled: (_data, _err, _vars, context) => {
227
220
  if (!context) return;
228
221
  queryClient.invalidateQueries({ queryKey: context.threadMessagesPrefix });
229
- for (const queryKey of [
230
- ...context.threadsListPrefixes,
231
- ...context.threadsSearchPrefixes,
232
- ]) {
233
- queryClient.invalidateQueries({ queryKey });
234
- }
222
+ invalidateThreadListQueries(queryClient, context.listPrefixes);
235
223
  // Refresh the sidebar mailbox list so the unread badge picks up the
236
224
  // next backend `unseenCount` (which is owned by IMAP sync).
237
225
  if (accountId) {
@@ -276,13 +264,13 @@ export const useMarkAsRead = ({
276
264
  [messages, expandedIds],
277
265
  );
278
266
 
279
- // Mark as read immediately when a message is expanded match Gmail
280
- // behaviour. The previous implementation used a 10s delay plus an
281
- // "all expanded" gate that meant most reads silently never fired.
282
- useEffect(() => {
283
- if (eligibleIds.length === 0) return;
284
- markMessagesRead(eligibleIds);
285
- }, [eligibleIds, markMessagesRead]);
267
+ // Mark as read after a short dwell on the open message. Changing the
268
+ // selection or leaving the view before the dwell elapses cancels the pending
269
+ // mark, so a glance stays unread (#140).
270
+ useEffect(
271
+ () => scheduleMarkRead(eligibleIds, MARK_READ_DELAY_MS, markMessagesRead),
272
+ [eligibleIds, markMessagesRead],
273
+ );
286
274
 
287
275
  // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally clears state on threadId change; adding threadId to deps causes stale closure issues
288
276
  useEffect(() => {
@@ -315,16 +303,10 @@ export const useToggleReadFor = (options: {
315
303
  });
316
304
  },
317
305
  onSettled: () => {
318
- queryClient.invalidateQueries({
319
- queryKey: threadOperationsListThreadsQueryKey({
320
- path: { mailboxId },
321
- }),
322
- });
323
- queryClient.invalidateQueries({
324
- queryKey: threadOperationsSearchThreadsQueryKey({
325
- path: { mailboxId },
326
- }),
327
- });
306
+ invalidateThreadListQueries(
307
+ queryClient,
308
+ threadListCacheKeys([mailboxId]),
309
+ );
328
310
  if (accountId) {
329
311
  queryClient.invalidateQueries({
330
312
  queryKey: mailboxOperationsListMailboxesQueryKey({
@@ -2,15 +2,21 @@ import {
2
2
  mailboxOperationsListMailboxesQueryKey,
3
3
  messageBulkOperationsMoveMessagesMutation,
4
4
  threadDetailOperationsListThreadMessagesQueryKey,
5
- threadOperationsListThreadsQueryKey,
6
- threadOperationsSearchThreadsQueryKey,
7
5
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
6
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
9
7
  import { useMutation, useQueryClient } from "@tanstack/react-query";
10
8
  import { useCallback } from "react";
11
9
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
12
10
  import { formatErrorDetail } from "@/components/ui/error-banners";
13
- import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
11
+ import {
12
+ cancelThreadListQueries,
13
+ invalidateThreadListQueries,
14
+ patchThreadListQueries,
15
+ restoreThreadListQueries,
16
+ snapshotThreadListQueries,
17
+ type ThreadListSnapshotEntry,
18
+ threadListCacheKeys,
19
+ } from "@/lib/thread-list-cache";
14
20
 
15
21
  interface UseMoveMessagesOptions {
16
22
  mailboxId: string;
@@ -29,13 +35,6 @@ interface ThreadMessagesData {
29
35
  [key: string]: unknown;
30
36
  }
31
37
 
32
- /**
33
- * Either shape a thread list/search query caches — the infinite mailbox list
34
- * or a single-shot page. `setQueriesData` matches by key prefix, so the
35
- * optimistic updater sees both.
36
- */
37
- type ThreadsListData = ThreadListCache;
38
-
39
38
  interface SnapshotEntry<T> {
40
39
  queryKey: readonly unknown[];
41
40
  data: T;
@@ -43,10 +42,9 @@ interface SnapshotEntry<T> {
43
42
 
44
43
  interface MoveContext {
45
44
  threadMessagesPrefix: readonly unknown[];
46
- threadsListPrefix: readonly unknown[];
47
- threadsSearchPrefix: readonly unknown[];
45
+ listPrefixes: ReadonlyArray<readonly unknown[]>;
48
46
  previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
49
- previousThreadsList: SnapshotEntry<ThreadsListData>[];
47
+ previousThreadsList: ThreadListSnapshotEntry[];
50
48
  }
51
49
 
52
50
  /**
@@ -82,24 +80,20 @@ export const useMoveMessages = ({
82
80
  path: { threadId },
83
81
  })
84
82
  : [];
85
- const threadsListPrefix = threadOperationsListThreadsQueryKey({
86
- path: { mailboxId },
87
- });
88
- const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
89
- path: { mailboxId },
90
- });
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]);
91
87
 
92
88
  // Only cancel the thread-messages query when we actually have a
93
89
  // threadId — passing an empty queryKey here would match every
94
90
  // query in the cache and cancel unrelated work (Copilot review,
95
- // PR #287). The list/search prefixes are always scoped to the
96
- // current mailbox so they're safe to cancel as-is.
91
+ // PR #287). The list prefixes are always scoped so they're safe.
97
92
  await Promise.all([
98
93
  ...(threadId
99
94
  ? [queryClient.cancelQueries({ queryKey: threadMessagesPrefix })]
100
95
  : []),
101
- queryClient.cancelQueries({ queryKey: threadsListPrefix }),
102
- queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
96
+ cancelThreadListQueries(queryClient, listPrefixes),
103
97
  ]);
104
98
 
105
99
  const previousThreadMessages = threadId
@@ -114,18 +108,10 @@ export const useMoveMessages = ({
114
108
  .map(([queryKey, data]) => ({ queryKey, data }))
115
109
  : [];
116
110
 
117
- const previousThreadsList = queryClient
118
- .getQueriesData<ThreadsListData>({ queryKey: threadsListPrefix })
119
- .concat(
120
- queryClient.getQueriesData<ThreadsListData>({
121
- queryKey: threadsSearchPrefix,
122
- }),
123
- )
124
- .filter(
125
- (entry): entry is [readonly unknown[], ThreadsListData] =>
126
- entry[1] !== undefined,
127
- )
128
- .map(([queryKey, data]) => ({ queryKey, data }));
111
+ const previousThreadsList = snapshotThreadListQueries(
112
+ queryClient,
113
+ listPrefixes,
114
+ );
129
115
 
130
116
  if (threadId) {
131
117
  queryClient.setQueriesData<ThreadMessagesData>(
@@ -140,26 +126,15 @@ export const useMoveMessages = ({
140
126
  );
141
127
  }
142
128
 
143
- const patchListData = (old: unknown) =>
144
- patchThreadListCache(old, (items) =>
145
- removeMovedMessagesFromItems(items, messageIds),
146
- );
147
-
148
- queryClient.setQueriesData(
149
- { queryKey: threadsListPrefix },
150
- patchListData,
151
- );
152
- queryClient.setQueriesData(
153
- { queryKey: threadsSearchPrefix },
154
- patchListData,
129
+ patchThreadListQueries(queryClient, listPrefixes, (items) =>
130
+ removeMovedMessagesFromItems(items, messageIds),
155
131
  );
156
132
 
157
133
  onAfterOptimisticRemove?.(Array.from(messageIds));
158
134
 
159
135
  return {
160
136
  threadMessagesPrefix,
161
- threadsListPrefix,
162
- threadsSearchPrefix,
137
+ listPrefixes,
163
138
  previousThreadMessages,
164
139
  previousThreadsList,
165
140
  };
@@ -169,9 +144,7 @@ export const useMoveMessages = ({
169
144
  for (const entry of context.previousThreadMessages) {
170
145
  queryClient.setQueryData(entry.queryKey, entry.data);
171
146
  }
172
- for (const entry of context.previousThreadsList) {
173
- queryClient.setQueryData(entry.queryKey, entry.data);
174
- }
147
+ restoreThreadListQueries(queryClient, context.previousThreadsList);
175
148
  }
176
149
  const count = vars.body.messageIds?.length ?? 0;
177
150
  pushError({
@@ -190,8 +163,7 @@ export const useMoveMessages = ({
190
163
  queryKey: context.threadMessagesPrefix,
191
164
  });
192
165
  }
193
- queryClient.invalidateQueries({ queryKey: context.threadsListPrefix });
194
- queryClient.invalidateQueries({ queryKey: context.threadsSearchPrefix });
166
+ invalidateThreadListQueries(queryClient, context.listPrefixes);
195
167
  if (accountId) {
196
168
  queryClient.invalidateQueries({
197
169
  queryKey: mailboxOperationsListMailboxesQueryKey({
@@ -1,15 +1,21 @@
1
1
  import {
2
2
  messageOperationsUpdateMessageFlagsMutation,
3
3
  threadDetailOperationsListThreadMessagesQueryKey,
4
- threadOperationsListThreadsQueryKey,
5
- threadOperationsSearchThreadsQueryKey,
6
- unifiedThreadOperationsListAllThreadsQueryKey,
7
4
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
5
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
9
6
  import { useMutation, useQueryClient } from "@tanstack/react-query";
10
7
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
11
8
  import { formatErrorDetail } from "@/components/ui/error-banners";
12
- import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
9
+ import { patchThreadListCache } from "@/lib/thread-cache";
10
+ import {
11
+ cancelThreadListQueries,
12
+ invalidateThreadListQueries,
13
+ patchThreadListQueries,
14
+ restoreThreadListQueries,
15
+ snapshotThreadListQueries,
16
+ type ThreadListSnapshotEntry,
17
+ threadListCacheKeys,
18
+ } from "@/lib/thread-list-cache";
13
19
 
14
20
  interface UseToggleStarOptions {
15
21
  threadId: string;
@@ -47,13 +53,6 @@ interface ThreadMessagesData {
47
53
  [key: string]: unknown;
48
54
  }
49
55
 
50
- /**
51
- * Either shape a thread list/search query caches — the infinite mailbox list
52
- * or a single-shot page. `setQueriesData` matches by key prefix, so the
53
- * optimistic updater sees both.
54
- */
55
- type ThreadsListData = ThreadListCache;
56
-
57
56
  interface SnapshotEntry<T> {
58
57
  queryKey: readonly unknown[];
59
58
  data: T;
@@ -61,12 +60,9 @@ interface SnapshotEntry<T> {
61
60
 
62
61
  interface ToggleStarContext {
63
62
  threadMessagesPrefix: readonly unknown[];
64
- threadsListPrefix: readonly unknown[];
65
- threadsSearchPrefix: readonly unknown[];
66
- unifiedThreadsPrefix: readonly unknown[];
63
+ listPrefixes: ReadonlyArray<readonly unknown[]>;
67
64
  previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
68
- previousThreadsList: SnapshotEntry<ThreadsListData>[];
69
- previousUnifiedThreads: SnapshotEntry<ThreadMessagesData>[];
65
+ previousThreadsList: ThreadListSnapshotEntry[];
70
66
  }
71
67
 
72
68
  export const toggleStarsInItems = (
@@ -100,29 +96,17 @@ export const useToggleStar = ({
100
96
  threadDetailOperationsListThreadMessagesQueryKey({
101
97
  path: { threadId },
102
98
  });
103
- const affectedMailboxId = resolveMailboxForMessage(
104
- messageId,
105
- messages,
106
- mailboxId,
107
- );
108
- const threadsListPrefix = threadOperationsListThreadsQueryKey({
109
- path: { mailboxId: affectedMailboxId },
110
- });
111
- const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
112
- path: { mailboxId: affectedMailboxId },
113
- });
114
- // The unified cross-account listing backs the daily brief and the
115
- // Starred mailbox, so a star toggled from an inbox has to land there
116
- // too — without this the starred view keeps serving a stale page for
117
- // its whole staleTime and the message never appears.
118
- const unifiedThreadsPrefix =
119
- unifiedThreadOperationsListAllThreadsQueryKey();
99
+ // The list caches to patch: the message's own mailbox plus the unified
100
+ // cross-account listing that backs the daily brief and the Flagged view.
101
+ // Resolved from the shared source so this hook can never fall behind the
102
+ // others on which listings exist (#149).
103
+ const listPrefixes = threadListCacheKeys([
104
+ resolveMailboxForMessage(messageId, messages, mailboxId),
105
+ ]);
120
106
 
121
107
  await Promise.all([
122
108
  queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
123
- queryClient.cancelQueries({ queryKey: threadsListPrefix }),
124
- queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
125
- queryClient.cancelQueries({ queryKey: unifiedThreadsPrefix }),
109
+ cancelThreadListQueries(queryClient, listPrefixes),
126
110
  ]);
127
111
 
128
112
  const previousThreadMessages = queryClient
@@ -133,69 +117,27 @@ export const useToggleStar = ({
133
117
  )
134
118
  .map(([queryKey, data]) => ({ queryKey, data }));
135
119
 
136
- const previousThreadsList = queryClient
137
- .getQueriesData<ThreadsListData>({ queryKey: threadsListPrefix })
138
- .concat(
139
- queryClient.getQueriesData<ThreadsListData>({
140
- queryKey: threadsSearchPrefix,
141
- }),
142
- )
143
- .filter(
144
- (entry): entry is [readonly unknown[], ThreadsListData] =>
145
- entry[1] !== undefined,
146
- )
147
- .map(([queryKey, data]) => ({ queryKey, data }));
148
-
149
- const previousUnifiedThreads = queryClient
150
- .getQueriesData<ThreadMessagesData>({ queryKey: unifiedThreadsPrefix })
151
- .filter(
152
- (entry): entry is [readonly unknown[], ThreadMessagesData] =>
153
- entry[1] !== undefined,
154
- )
155
- .map(([queryKey, data]) => ({ queryKey, data }));
156
-
157
- // The unified-threads prefix carries both shapes too: the Flagged view
158
- // runs an infinite query on `listAllThreads({ starred: true })`, which
159
- // sits under the same prefix as the single-shot readers. Patching it as
160
- // a plain `{ items }` threw on that entry and failed the star before it
161
- // was sent — the same defect as the mailbox list (issues #51, #55), so
162
- // it goes through the same helper.
163
- const patchItemsData = (old: unknown) =>
164
- patchThreadListCache(old, (items) =>
165
- toggleStarsInItems(items, messageId, nextStarred),
166
- );
167
-
168
- queryClient.setQueriesData(
169
- { queryKey: threadMessagesPrefix },
170
- patchItemsData,
171
- );
172
- queryClient.setQueriesData(
173
- { queryKey: unifiedThreadsPrefix },
174
- patchItemsData,
120
+ const previousThreadsList = snapshotThreadListQueries(
121
+ queryClient,
122
+ listPrefixes,
175
123
  );
176
124
 
177
- const patchListData = (old: unknown) =>
125
+ // The thread-detail cache carries the `{ items }` shape too, so it goes
126
+ // through the same shape-tolerant helper as the listings.
127
+ queryClient.setQueriesData({ queryKey: threadMessagesPrefix }, (old) =>
178
128
  patchThreadListCache(old, (items) =>
179
129
  toggleStarsInItems(items, messageId, nextStarred),
180
- );
181
-
182
- queryClient.setQueriesData(
183
- { queryKey: threadsListPrefix },
184
- patchListData,
130
+ ),
185
131
  );
186
- queryClient.setQueriesData(
187
- { queryKey: threadsSearchPrefix },
188
- patchListData,
132
+ patchThreadListQueries(queryClient, listPrefixes, (items) =>
133
+ toggleStarsInItems(items, messageId, nextStarred),
189
134
  );
190
135
 
191
136
  return {
192
137
  threadMessagesPrefix,
193
- threadsListPrefix,
194
- threadsSearchPrefix,
195
- unifiedThreadsPrefix,
138
+ listPrefixes,
196
139
  previousThreadMessages,
197
140
  previousThreadsList,
198
- previousUnifiedThreads,
199
141
  };
200
142
  },
201
143
  onError: (err, vars, context) => {
@@ -203,12 +145,7 @@ export const useToggleStar = ({
203
145
  for (const entry of context.previousThreadMessages) {
204
146
  queryClient.setQueryData(entry.queryKey, entry.data);
205
147
  }
206
- for (const entry of context.previousThreadsList) {
207
- queryClient.setQueryData(entry.queryKey, entry.data);
208
- }
209
- for (const entry of context.previousUnifiedThreads) {
210
- queryClient.setQueryData(entry.queryKey, entry.data);
211
- }
148
+ restoreThreadListQueries(queryClient, context.previousThreadsList);
212
149
  }
213
150
  const nextStarred = vars.body.isStarred ?? false;
214
151
  pushError({
@@ -222,9 +159,7 @@ export const useToggleStar = ({
222
159
  onSettled: (_data, _err, _vars, context) => {
223
160
  if (!context) return;
224
161
  queryClient.invalidateQueries({ queryKey: context.threadMessagesPrefix });
225
- queryClient.invalidateQueries({ queryKey: context.threadsListPrefix });
226
- queryClient.invalidateQueries({ queryKey: context.threadsSearchPrefix });
227
- queryClient.invalidateQueries({ queryKey: context.unifiedThreadsPrefix });
162
+ invalidateThreadListQueries(queryClient, context.listPrefixes);
228
163
  },
229
164
  });
230
165
 
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import {
4
+ threadOperationsListThreadsQueryKey,
5
+ threadOperationsSearchThreadsQueryKey,
6
+ unifiedThreadOperationsListAllThreadsQueryKey,
7
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
+ import { threadListCacheKeys } from "./thread-list-cache.js";
9
+
10
+ describe("threadListCacheKeys", () => {
11
+ test("resolves each mailbox's list and search caches plus the unified one", () => {
12
+ const keys = threadListCacheKeys(["mb1"]);
13
+ assert.deepStrictEqual(keys, [
14
+ threadOperationsListThreadsQueryKey({ path: { mailboxId: "mb1" } }),
15
+ threadOperationsSearchThreadsQueryKey({ path: { mailboxId: "mb1" } }),
16
+ unifiedThreadOperationsListAllThreadsQueryKey(),
17
+ ]);
18
+ });
19
+
20
+ test("always includes the unified cross-account listing the daily brief reads", () => {
21
+ // #140: the brief kept its unread dot because the read mutation never
22
+ // reached this key. Every caller of the shared source now does.
23
+ const keys = threadListCacheKeys(["mb1", "mb2"]);
24
+ assert.ok(
25
+ keys.some(
26
+ (key) =>
27
+ JSON.stringify(key) ===
28
+ JSON.stringify(unifiedThreadOperationsListAllThreadsQueryKey()),
29
+ ),
30
+ );
31
+ });
32
+
33
+ test("emits one list and one search key per distinct mailbox", () => {
34
+ const keys = threadListCacheKeys(["mb1", "mb2"]);
35
+ // two mailboxes -> two list + two search + one unified
36
+ assert.strictEqual(keys.length, 5);
37
+ });
38
+
39
+ test("dedupes repeated mailbox ids", () => {
40
+ const keys = threadListCacheKeys(["mb1", "mb1"]);
41
+ // one mailbox -> one list + one search + one unified
42
+ assert.strictEqual(keys.length, 3);
43
+ });
44
+ });
@@ -0,0 +1,97 @@
1
+ import {
2
+ threadOperationsListThreadsQueryKey,
3
+ threadOperationsSearchThreadsQueryKey,
4
+ unifiedThreadOperationsListAllThreadsQueryKey,
5
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
6
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
7
+ import type { QueryClient } from "@tanstack/react-query";
8
+ import { patchThreadListCache, type ThreadListCache } from "./thread-cache.js";
9
+
10
+ export interface ThreadListSnapshotEntry {
11
+ queryKey: readonly unknown[];
12
+ data: ThreadListCache;
13
+ }
14
+
15
+ /**
16
+ * Every cached thread listing the given mailboxes feed, as query-key prefixes:
17
+ * each mailbox's list and search caches, plus the unified cross-account listing
18
+ * that backs the daily brief and the Flagged view.
19
+ *
20
+ * A thread-list mutation (mark read, star, delete, move) has to reach all of
21
+ * them. When a hook hand-copies a subset, a view reading from a listing it
22
+ * forgot serves a stale page until reload: the daily brief kept its unread dot
23
+ * after a message was read because the read mutation patched the per-mailbox
24
+ * caches but not the unified one (#140). This is the single source every
25
+ * mutation hook resolves its keys from, so the set can never drift between them
26
+ * (part of #149). React Query matches by prefix, so each key also covers its
27
+ * option variants (order, pagination) without enumerating them.
28
+ */
29
+ export const threadListCacheKeys = (
30
+ mailboxIds: Iterable<string>,
31
+ ): ReadonlyArray<readonly unknown[]> => {
32
+ const keys: Array<readonly unknown[]> = [];
33
+ for (const mailboxId of new Set(mailboxIds)) {
34
+ keys.push(threadOperationsListThreadsQueryKey({ path: { mailboxId } }));
35
+ keys.push(threadOperationsSearchThreadsQueryKey({ path: { mailboxId } }));
36
+ }
37
+ keys.push(unifiedThreadOperationsListAllThreadsQueryKey());
38
+ return keys;
39
+ };
40
+
41
+ export const cancelThreadListQueries = (
42
+ queryClient: QueryClient,
43
+ prefixes: ReadonlyArray<readonly unknown[]>,
44
+ ): Promise<unknown> =>
45
+ Promise.all(
46
+ prefixes.map((queryKey) => queryClient.cancelQueries({ queryKey })),
47
+ );
48
+
49
+ export const snapshotThreadListQueries = (
50
+ queryClient: QueryClient,
51
+ prefixes: ReadonlyArray<readonly unknown[]>,
52
+ ): ThreadListSnapshotEntry[] =>
53
+ prefixes
54
+ .flatMap((queryKey) =>
55
+ queryClient.getQueriesData<ThreadListCache>({ queryKey }),
56
+ )
57
+ .filter(
58
+ (entry): entry is [readonly unknown[], ThreadListCache] =>
59
+ entry[1] !== undefined,
60
+ )
61
+ .map(([queryKey, data]) => ({ queryKey, data }));
62
+
63
+ /**
64
+ * Run `patchItems` over every cached listing under `prefixes`, whichever shape
65
+ * each holds — the shape-tolerant `patchThreadListCache` covers both the
66
+ * single-shot page (brief) and the infinite query (Flagged, mailbox list).
67
+ */
68
+ export const patchThreadListQueries = (
69
+ queryClient: QueryClient,
70
+ prefixes: ReadonlyArray<readonly unknown[]>,
71
+ patchItems: (
72
+ items: RemitImapThreadMessageResponse[],
73
+ ) => RemitImapThreadMessageResponse[],
74
+ ): void => {
75
+ const updater = (old: unknown) => patchThreadListCache(old, patchItems);
76
+ for (const queryKey of prefixes) {
77
+ queryClient.setQueriesData({ queryKey }, updater);
78
+ }
79
+ };
80
+
81
+ export const restoreThreadListQueries = (
82
+ queryClient: QueryClient,
83
+ snapshot: ThreadListSnapshotEntry[],
84
+ ): void => {
85
+ for (const entry of snapshot) {
86
+ queryClient.setQueryData(entry.queryKey, entry.data);
87
+ }
88
+ };
89
+
90
+ export const invalidateThreadListQueries = (
91
+ queryClient: QueryClient,
92
+ prefixes: ReadonlyArray<readonly unknown[]>,
93
+ ): void => {
94
+ for (const queryKey of prefixes) {
95
+ queryClient.invalidateQueries({ queryKey });
96
+ }
97
+ };