@remit/web-client 0.0.33 → 0.0.35
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/bulk-delete.test.ts +6 -48
- package/src/lib/bulk-delete.ts +22 -18
- 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.35",
|
|
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
|
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
BULK_DELETE_CHUNK_SIZE,
|
|
5
5
|
chunkIds,
|
|
6
6
|
countMatches,
|
|
7
|
-
type DeleteBatchResult,
|
|
8
7
|
type FetchIdsPageResult,
|
|
9
8
|
honestProgress,
|
|
10
9
|
resolveSelectionAfterDelete,
|
|
@@ -90,36 +89,16 @@ describe("runChunkedDelete", () => {
|
|
|
90
89
|
assert.deepEqual(outcome.failedIds, []);
|
|
91
90
|
});
|
|
92
91
|
|
|
93
|
-
test("
|
|
92
|
+
test("a returned batch counts every id in it as accepted", async () => {
|
|
94
93
|
const input = ids(5);
|
|
95
94
|
const outcome = await runChunkedDelete(
|
|
96
95
|
input,
|
|
97
|
-
async (chunk) => ({
|
|
98
|
-
successCount: chunk.length - 2,
|
|
99
|
-
failureCount: 2,
|
|
100
|
-
failedIds: chunk.slice(0, 2),
|
|
101
|
-
}),
|
|
102
|
-
noopProgress,
|
|
103
|
-
neverCancelled,
|
|
104
|
-
);
|
|
105
|
-
assert.equal(outcome.done, 3);
|
|
106
|
-
assert.deepEqual(outcome.failedIds, input.slice(0, 2));
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test("partial failure: failed ids are reported, not rolled into done", async () => {
|
|
110
|
-
const input = ids(3);
|
|
111
|
-
const outcome = await runChunkedDelete(
|
|
112
|
-
input,
|
|
113
|
-
async (chunk) => ({
|
|
114
|
-
successCount: 2,
|
|
115
|
-
failureCount: 1,
|
|
116
|
-
failedIds: [chunk[1]],
|
|
117
|
-
}),
|
|
96
|
+
async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
|
|
118
97
|
noopProgress,
|
|
119
98
|
neverCancelled,
|
|
120
99
|
);
|
|
121
|
-
assert.equal(outcome.done,
|
|
122
|
-
assert.deepEqual(outcome.failedIds, [
|
|
100
|
+
assert.equal(outcome.done, input.length);
|
|
101
|
+
assert.deepEqual(outcome.failedIds, []);
|
|
123
102
|
});
|
|
124
103
|
|
|
125
104
|
test("cancelling mid-run folds every unreached chunk into failedIds", async () => {
|
|
@@ -264,27 +243,6 @@ describe("runPredicateDelete", () => {
|
|
|
264
243
|
assert.equal(outcome.done, 4);
|
|
265
244
|
});
|
|
266
245
|
|
|
267
|
-
test("partial failure across pages accumulates failedIds without rolling into done", async () => {
|
|
268
|
-
const fetch = pagedFetcher([
|
|
269
|
-
{ ids: ["a", "b"], continuationToken: "t1" },
|
|
270
|
-
{ ids: ["c"] },
|
|
271
|
-
]);
|
|
272
|
-
const outcome = await runPredicateDelete(
|
|
273
|
-
fetch,
|
|
274
|
-
3,
|
|
275
|
-
async (chunk): Promise<DeleteBatchResult> => {
|
|
276
|
-
if (chunk.includes("b")) {
|
|
277
|
-
return { successCount: 1, failureCount: 1, failedIds: ["b"] };
|
|
278
|
-
}
|
|
279
|
-
return { successCount: chunk.length, failureCount: 0 };
|
|
280
|
-
},
|
|
281
|
-
noopProgress,
|
|
282
|
-
neverCancelled,
|
|
283
|
-
);
|
|
284
|
-
assert.equal(outcome.done, 2);
|
|
285
|
-
assert.deepEqual(outcome.failedIds, ["b"]);
|
|
286
|
-
});
|
|
287
|
-
|
|
288
246
|
test("cancelling mid-delete stops paging without inventing failedIds for unfetched pages", async () => {
|
|
289
247
|
let fetchCalls = 0;
|
|
290
248
|
let cancelled = false;
|
|
@@ -409,7 +367,7 @@ describe("resolveSelectionAfterDelete", () => {
|
|
|
409
367
|
);
|
|
410
368
|
});
|
|
411
369
|
|
|
412
|
-
test("
|
|
370
|
+
test("unreached ids stay selected for a precise retry, even alongside a clean stop", () => {
|
|
413
371
|
assert.deepEqual(
|
|
414
372
|
resolveSelectionAfterDelete({
|
|
415
373
|
done: 3072,
|
|
@@ -431,7 +389,7 @@ describe("resolveSelectionAfterDelete", () => {
|
|
|
431
389
|
);
|
|
432
390
|
});
|
|
433
391
|
|
|
434
|
-
test("an infra failure with
|
|
392
|
+
test("an infra failure with nothing left unreached still keeps selection mode open", () => {
|
|
435
393
|
assert.deepEqual(
|
|
436
394
|
resolveSelectionAfterDelete({
|
|
437
395
|
done: 0,
|
package/src/lib/bulk-delete.ts
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The bulk endpoint caps a call at 100 ids (`BulkMessageInput.messageIds`,
|
|
5
5
|
* `@maxItems(100)`); a delete over a search result can run to thousands. These
|
|
6
|
-
* helpers sequence the calls and tally
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* helpers sequence the calls and tally which ids the run actually reached, so a
|
|
7
|
+
* caller can always ask "what's still not deleted" instead of trusting the
|
|
8
|
+
* request it sent.
|
|
9
|
+
*
|
|
10
|
+
* The endpoint enqueues the IMAP delete and returns; it does not apply it. So a
|
|
11
|
+
* returned call means every id in it was accepted for deletion, not that the
|
|
12
|
+
* mail server removed it — there is no per-id success/failure in the response
|
|
13
|
+
* to read. The only failure this layer can observe is a thrown call: an
|
|
14
|
+
* infrastructure failure (auth, the write, or the enqueue) that takes out the
|
|
15
|
+
* whole batch and stops the run.
|
|
9
16
|
*
|
|
10
17
|
* Pure, framework-agnostic, and independently testable — no React, no fetch.
|
|
11
18
|
* `useEscalatedDelete.ts` supplies the real `DeleteBatch`/`FetchIdsPage`
|
|
@@ -46,7 +53,6 @@ export const chunkIds = (
|
|
|
46
53
|
export interface DeleteBatchResult {
|
|
47
54
|
successCount: number;
|
|
48
55
|
failureCount: number;
|
|
49
|
-
failedIds?: string[];
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
/** Sends one bulk-delete call for the given ids (≤100). */
|
|
@@ -64,9 +70,12 @@ export interface BulkDeleteOutcome {
|
|
|
64
70
|
/** Ids confirmed deleted (server-reported success, not merely "sent"). */
|
|
65
71
|
done: number;
|
|
66
72
|
/**
|
|
67
|
-
* Ids not
|
|
68
|
-
*
|
|
69
|
-
*
|
|
73
|
+
* Ids the run did not reach and are therefore not confirmed deleted: on
|
|
74
|
+
* cancellation or a thrown error, the chunks the bounded run never attempted
|
|
75
|
+
* (see `runChunkedDelete`). Empty in the predicate case, which re-resolves on
|
|
76
|
+
* every run rather than handing back a remainder (see `runPredicateDelete`).
|
|
77
|
+
* There is no per-id failure source: a returned batch call counts every id in
|
|
78
|
+
* it as accepted (see the module header).
|
|
70
79
|
*/
|
|
71
80
|
failedIds: string[];
|
|
72
81
|
cancelled: boolean;
|
|
@@ -106,9 +115,7 @@ export const runChunkedDelete = async (
|
|
|
106
115
|
onProgress({ done, total });
|
|
107
116
|
return { done, failedIds, cancelled: false, error: attempted.error };
|
|
108
117
|
}
|
|
109
|
-
|
|
110
|
-
done += chunk.length - failedInChunk.size;
|
|
111
|
-
failedIds.push(...chunk.filter((id) => failedInChunk.has(id)));
|
|
118
|
+
done += chunk.length;
|
|
112
119
|
onProgress({ done, total });
|
|
113
120
|
}
|
|
114
121
|
|
|
@@ -165,9 +172,7 @@ export const runPredicateDelete = async (
|
|
|
165
172
|
if (!attempted.ok) {
|
|
166
173
|
return { done, failedIds, cancelled: false, error: attempted.error };
|
|
167
174
|
}
|
|
168
|
-
|
|
169
|
-
done += page.ids.length - failedInPage.size;
|
|
170
|
-
failedIds.push(...page.ids.filter((id) => failedInPage.has(id)));
|
|
175
|
+
done += page.ids.length;
|
|
171
176
|
onProgress({ done, total });
|
|
172
177
|
}
|
|
173
178
|
|
|
@@ -250,11 +255,10 @@ export interface SelectionAfterDelete {
|
|
|
250
255
|
|
|
251
256
|
/**
|
|
252
257
|
* What a caller does with selection once a run ends, for any reason. Every id
|
|
253
|
-
* not confirmed deleted —
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
* requirement 8).
|
|
258
|
+
* not confirmed deleted — a chunk the bounded run never reached because it was
|
|
259
|
+
* stopped or errored — belongs in `retryIds`: it is exactly what Retry should
|
|
260
|
+
* resend, and it is what stays selected so the count on screen never claims
|
|
261
|
+
* more was deleted than actually was (#92 requirement 8).
|
|
258
262
|
*/
|
|
259
263
|
export const resolveSelectionAfterDelete = (
|
|
260
264
|
outcome: DeleteRunOutcome,
|
|
@@ -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
|
+
};
|