@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 +1 -1
- package/src/hooks/useDeleteMessages.ts +25 -52
- package/src/hooks/useMarkAsRead.test.ts +91 -1
- package/src/hooks/useMarkAsRead.ts +63 -81
- package/src/hooks/useMoveMessages.ts +26 -54
- package/src/hooks/useToggleStar.ts +32 -97
- package/src/lib/thread-list-cache.test.ts +44 -0
- package/src/lib/thread-list-cache.ts +97 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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 {
|
|
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
|
-
|
|
47
|
-
threadsSearchPrefix: readonly unknown[];
|
|
45
|
+
listPrefixes: ReadonlyArray<readonly unknown[]>;
|
|
48
46
|
previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
|
|
49
|
-
previousThreadsList:
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
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
|
|
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 =
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
43
|
-
threadsSearchPrefixes: ReadonlyArray<readonly unknown[]>;
|
|
41
|
+
listPrefixes: ReadonlyArray<readonly unknown[]>;
|
|
44
42
|
previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
|
|
45
|
-
previousThreadsList:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
useEffect(
|
|
283
|
-
|
|
284
|
-
markMessagesRead
|
|
285
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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 {
|
|
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
|
-
|
|
47
|
-
threadsSearchPrefix: readonly unknown[];
|
|
45
|
+
listPrefixes: ReadonlyArray<readonly unknown[]>;
|
|
48
46
|
previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
|
|
49
|
-
previousThreadsList:
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
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
|
|
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
|
|
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 =
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
65
|
-
threadsSearchPrefix: readonly unknown[];
|
|
66
|
-
unifiedThreadsPrefix: readonly unknown[];
|
|
63
|
+
listPrefixes: ReadonlyArray<readonly unknown[]>;
|
|
67
64
|
previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
|
|
68
|
-
previousThreadsList:
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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 =
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
patchListData,
|
|
132
|
+
patchThreadListQueries(queryClient, listPrefixes, (items) =>
|
|
133
|
+
toggleStarsInItems(items, messageId, nextStarred),
|
|
189
134
|
);
|
|
190
135
|
|
|
191
136
|
return {
|
|
192
137
|
threadMessagesPrefix,
|
|
193
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
};
|