@remit/web-client 0.0.281 → 0.0.283
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/auth/BetterAuthShell.tsx +39 -4
- package/src/auth/SessionRateLimited.stories.tsx +26 -0
- package/src/auth/SessionRateLimited.tsx +39 -0
- package/src/auth/auth-error.test.ts +20 -0
- package/src/auth/auth-error.ts +15 -2
- package/src/auth/session-gate.render.test.ts +77 -0
- package/src/auth/session-state.test.ts +66 -0
- package/src/auth/session-state.ts +41 -0
- package/src/components/mail/DailyBrief.tsx +49 -5
- package/src/components/mail/MailListHeader.tsx +19 -14
- package/src/components/mail/spam-offer-server-count.render.test.ts +349 -0
- package/src/hooks/useSpamMatchCounts.ts +102 -0
- package/src/lib/result-folder.ts +16 -0
- package/src/lib/spam-offer.test.ts +166 -58
- package/src/lib/spam-offer.ts +150 -24
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Spam offer's number comes from the server, one count per junk folder,
|
|
3
|
+
* summed (#313).
|
|
4
|
+
*
|
|
5
|
+
* The offer used to group the search rows the client had loaded, count the ones
|
|
6
|
+
* whose folder is `\Junk`, and state that figure as "N in Spam". The rows are a
|
|
7
|
+
* page, so the number was a count of a window offered as a count of a mailbox:
|
|
8
|
+
* a Spam match older than the page was missing from it, and the figure grew as
|
|
9
|
+
* further pages arrived.
|
|
10
|
+
*
|
|
11
|
+
* The seed here is that page. Every Spam match sits below the window the search
|
|
12
|
+
* request answers with, so the loaded rows hold no junk at all — a client that
|
|
13
|
+
* counts what it holds finds nothing and makes no offer. A client that asks each
|
|
14
|
+
* junk folder finds them, and with two accounts it has to add the two answers
|
|
15
|
+
* up: one number is stated, and each account has a Spam folder of its own.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { afterEach, describe, it } from "node:test";
|
|
20
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
21
|
+
import type { ResultFolder } from "@remit/ui";
|
|
22
|
+
import {
|
|
23
|
+
type AnyRouter,
|
|
24
|
+
createMemoryHistory,
|
|
25
|
+
createRootRoute,
|
|
26
|
+
createRoute,
|
|
27
|
+
createRouter,
|
|
28
|
+
Outlet,
|
|
29
|
+
RouterProvider,
|
|
30
|
+
} from "@tanstack/react-router";
|
|
31
|
+
import { createElement, type ReactNode } from "react";
|
|
32
|
+
import { ComposeProvider } from "@/components/compose/ComposeProvider";
|
|
33
|
+
import { MailContext, type MailContextValue } from "@/lib/mail-context";
|
|
34
|
+
import { MailFreshnessProvider } from "@/lib/mail-freshness";
|
|
35
|
+
import type { ResultFolderIndex } from "@/lib/result-folder";
|
|
36
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
37
|
+
import { makeAccount, makeThreadMessage } from "@/test-support/fixtures";
|
|
38
|
+
import { type HttpCall, type HttpMock, mockFetch } from "@/test-support/http";
|
|
39
|
+
import { DailyBrief } from "./DailyBrief";
|
|
40
|
+
|
|
41
|
+
const ACCOUNT_A = "acc-a";
|
|
42
|
+
const ACCOUNT_B = "acc-b";
|
|
43
|
+
const JUNK_A = "mbx-junk-a";
|
|
44
|
+
const JUNK_B = "mbx-junk-b";
|
|
45
|
+
const INBOX_A = "mbx-inbox-a";
|
|
46
|
+
|
|
47
|
+
const accounts = [
|
|
48
|
+
makeAccount({ accountId: ACCOUNT_A }),
|
|
49
|
+
makeAccount({ accountId: ACCOUNT_B }),
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** What the search says: a word the seeded rows carry. */
|
|
53
|
+
const QUERY = "invoice";
|
|
54
|
+
|
|
55
|
+
/** Matches in Spam, per account. Deliberately no round sum. */
|
|
56
|
+
const SPAM_IN_A = 5;
|
|
57
|
+
const SPAM_IN_B = 3;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The page the search request answers with: matches from the INBOX only. Every
|
|
61
|
+
* Spam match is older than these, so none of them is here — which is exactly
|
|
62
|
+
* the case the old count could not see.
|
|
63
|
+
*/
|
|
64
|
+
const PAGE: RemitImapThreadMessageResponse[] = Array.from(
|
|
65
|
+
{ length: 4 },
|
|
66
|
+
(_, index) =>
|
|
67
|
+
makeThreadMessage({
|
|
68
|
+
messageId: `inbox-${index}`,
|
|
69
|
+
threadId: `thread-inbox-${index}`,
|
|
70
|
+
accountId: ACCOUNT_A,
|
|
71
|
+
mailboxId: INBOX_A,
|
|
72
|
+
subject: `Invoice ${index} from the office`,
|
|
73
|
+
sentDate: 1_767_225_600_000 - index * 1_000,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
/** The `\Junk` appointment is what holds a row out of a search and counts it. */
|
|
78
|
+
const FOLDERS: ResultFolderIndex = new Map<string, ResultFolder>([
|
|
79
|
+
[INBOX_A, { role: "inbox" }],
|
|
80
|
+
[JUNK_A, { role: "junk" }],
|
|
81
|
+
[JUNK_B, { role: "junk" }],
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
let harness: DomHarness | undefined;
|
|
85
|
+
let http: HttpMock | undefined;
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
harness?.close();
|
|
89
|
+
harness = undefined;
|
|
90
|
+
http?.restore();
|
|
91
|
+
http = undefined;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
95
|
+
// `window`.
|
|
96
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
97
|
+
|
|
98
|
+
const paramsOf = (call: HttpCall): URLSearchParams =>
|
|
99
|
+
new URL(call.url, "http://localhost").searchParams;
|
|
100
|
+
|
|
101
|
+
const threadCalls = (): HttpCall[] =>
|
|
102
|
+
(http?.calls ?? []).filter(
|
|
103
|
+
(call) => new URL(call.url, "http://localhost").pathname === "/threads",
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Params that say WHICH mail is being asked about, with everything about paging
|
|
108
|
+
* and response shape taken out. A count is right only if these match the rows
|
|
109
|
+
* request's exactly — same query, same account, same chips — because a count
|
|
110
|
+
* over a wider set than the list shows is a wrong number, not a rounded one.
|
|
111
|
+
*/
|
|
112
|
+
const CRITERIA_FREE_PARAMS = new Set([
|
|
113
|
+
"limit",
|
|
114
|
+
"continuationToken",
|
|
115
|
+
"mailboxId",
|
|
116
|
+
"count",
|
|
117
|
+
"results",
|
|
118
|
+
"order",
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
const criteriaOf = (call: HttpCall): string => {
|
|
122
|
+
const criteria = [...paramsOf(call).entries()]
|
|
123
|
+
.filter(([name]) => !CRITERIA_FREE_PARAMS.has(name))
|
|
124
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
125
|
+
.sort();
|
|
126
|
+
return criteria.join("&");
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** The count requests this render issued, by the folder each one scoped to. */
|
|
130
|
+
const spamCountRequests = (): HttpCall[] =>
|
|
131
|
+
threadCalls()
|
|
132
|
+
.filter((call) => paramsOf(call).get("count") === "true")
|
|
133
|
+
.filter((call) => {
|
|
134
|
+
const mailboxId = paramsOf(call).get("mailboxId");
|
|
135
|
+
return mailboxId === JUNK_A || mailboxId === JUNK_B;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const spamCountFolders = (): string[] =>
|
|
139
|
+
spamCountRequests().map((call) => paramsOf(call).get("mailboxId") ?? "");
|
|
140
|
+
|
|
141
|
+
/** The request that fetched the rows the offer sits above. */
|
|
142
|
+
const searchRowsRequest = (): HttpCall | undefined =>
|
|
143
|
+
threadCalls().find(
|
|
144
|
+
(call) =>
|
|
145
|
+
paramsOf(call).get("count") !== "true" &&
|
|
146
|
+
paramsOf(call).get("query") !== null,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const context = (): MailContextValue => ({
|
|
150
|
+
accounts,
|
|
151
|
+
mailboxNameIndex: new Map(),
|
|
152
|
+
accountNameIndex: new Map(),
|
|
153
|
+
resultFolderIndex: FOLDERS,
|
|
154
|
+
searchQuery: QUERY,
|
|
155
|
+
searchInput: QUERY,
|
|
156
|
+
searchViewKey: "brief",
|
|
157
|
+
onSearchChange: () => undefined,
|
|
158
|
+
onSearchClear: () => undefined,
|
|
159
|
+
onSearchClearQuery: () => undefined,
|
|
160
|
+
intelligenceOpen: false,
|
|
161
|
+
onToggleIntelligence: () => undefined,
|
|
162
|
+
onRaiseIntelligence: () => undefined,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const brief = (): ReactNode =>
|
|
166
|
+
createElement(MailFreshnessProvider, {
|
|
167
|
+
accountIds: [ACCOUNT_A, ACCOUNT_B],
|
|
168
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
169
|
+
children: createElement(
|
|
170
|
+
MailContext.Provider,
|
|
171
|
+
{ value: context() },
|
|
172
|
+
createElement(DailyBrief, {
|
|
173
|
+
accounts,
|
|
174
|
+
onDeleteMessages: () => undefined,
|
|
175
|
+
}),
|
|
176
|
+
),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const testRouter = (): AnyRouter => {
|
|
180
|
+
const rootRoute = createRootRoute({
|
|
181
|
+
component: () =>
|
|
182
|
+
createElement(ComposeProvider, null, createElement(Outlet)),
|
|
183
|
+
});
|
|
184
|
+
const mailRoute = createRoute({
|
|
185
|
+
getParentRoute: () => rootRoute,
|
|
186
|
+
path: "/mail",
|
|
187
|
+
validateSearch: (query: Record<string, unknown>) => query,
|
|
188
|
+
component: Outlet,
|
|
189
|
+
});
|
|
190
|
+
const mailboxRoute = createRoute({
|
|
191
|
+
getParentRoute: () => mailRoute,
|
|
192
|
+
path: "/$mailboxId",
|
|
193
|
+
component: Outlet,
|
|
194
|
+
});
|
|
195
|
+
const briefRoute = createRoute({
|
|
196
|
+
getParentRoute: () => mailRoute,
|
|
197
|
+
path: "/brief",
|
|
198
|
+
component: brief,
|
|
199
|
+
});
|
|
200
|
+
const threadRoute = createRoute({
|
|
201
|
+
getParentRoute: () => briefRoute,
|
|
202
|
+
path: "$threadId",
|
|
203
|
+
component: Outlet,
|
|
204
|
+
});
|
|
205
|
+
const messageRoute = createRoute({
|
|
206
|
+
getParentRoute: () => threadRoute,
|
|
207
|
+
path: "$messageId",
|
|
208
|
+
component: Outlet,
|
|
209
|
+
});
|
|
210
|
+
const routeTree = rootRoute.addChildren([
|
|
211
|
+
mailRoute.addChildren([
|
|
212
|
+
mailboxRoute,
|
|
213
|
+
briefRoute.addChildren([threadRoute.addChildren([messageRoute])]),
|
|
214
|
+
]),
|
|
215
|
+
]);
|
|
216
|
+
return createRouter({
|
|
217
|
+
routeTree,
|
|
218
|
+
history: createMemoryHistory({ initialEntries: ["/mail/brief"] }),
|
|
219
|
+
}) as unknown as AnyRouter;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** What each junk folder answers `count: true` with; `undefined` counts nothing. */
|
|
223
|
+
type JunkCounts = Record<string, number | undefined>;
|
|
224
|
+
|
|
225
|
+
const mount = async (junk: JunkCounts): Promise<DomHarness> => {
|
|
226
|
+
http = mockFetch((call) => {
|
|
227
|
+
const url = new URL(call.url, "http://localhost");
|
|
228
|
+
if (url.pathname.endsWith("/config")) return { accounts };
|
|
229
|
+
if (url.pathname !== "/threads") return { items: [] };
|
|
230
|
+
const params = url.searchParams;
|
|
231
|
+
if (params.get("count") === "true") {
|
|
232
|
+
const mailboxId = params.get("mailboxId");
|
|
233
|
+
if (mailboxId === null) return { count: 0 };
|
|
234
|
+
// The server counts what it was asked about. A count request carrying
|
|
235
|
+
// different criteria from the rows request is answering a different
|
|
236
|
+
// question, so this one answers nothing rather than lending it a number
|
|
237
|
+
// — which is what fails the assertions below if the criteria stop being
|
|
238
|
+
// forwarded.
|
|
239
|
+
if (params.get("query") !== QUERY) return {};
|
|
240
|
+
const count = junk[mailboxId];
|
|
241
|
+
// A folder the server declines to count answers with no `count` at all,
|
|
242
|
+
// never with a zero — the distinction the offer has to keep.
|
|
243
|
+
return count === undefined ? {} : { count };
|
|
244
|
+
}
|
|
245
|
+
// The search answers with the page above. Its Spam matches are older than
|
|
246
|
+
// every row in it, so no junk row reaches the client.
|
|
247
|
+
if (params.get("query")) return { items: PAGE };
|
|
248
|
+
return { items: [] };
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const router = testRouter();
|
|
252
|
+
await router.load();
|
|
253
|
+
const dom = createDomHarness({ viewportWidth: 1400 });
|
|
254
|
+
harness = dom;
|
|
255
|
+
dom.renderApp(createElement(RouterProvider, { router }));
|
|
256
|
+
await dom.flush();
|
|
257
|
+
await dom.wait(20);
|
|
258
|
+
await dom.flush();
|
|
259
|
+
await dom.waitFor(
|
|
260
|
+
() => dom.text().includes("from Spam") || dom.text().includes("Invoice 0"),
|
|
261
|
+
"the brief to render its matches",
|
|
262
|
+
);
|
|
263
|
+
return dom;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
describe("the Spam offer's count comes from the server (#313)", () => {
|
|
267
|
+
// The regression. Every Spam match sits below the page the search returned,
|
|
268
|
+
// so a client counting its own rows offers nothing at all.
|
|
269
|
+
it("counts Spam matches the loaded page never held", async () => {
|
|
270
|
+
const mounted = await mount({ [JUNK_A]: SPAM_IN_A, [JUNK_B]: 0 });
|
|
271
|
+
await mounted.waitFor(
|
|
272
|
+
() => mounted.text().includes("from Spam"),
|
|
273
|
+
"the Spam offer to state a count",
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
assert.ok(
|
|
277
|
+
PAGE.every((row) => row.mailboxId !== JUNK_A && row.mailboxId !== JUNK_B),
|
|
278
|
+
"the page under test stopped being junk-free",
|
|
279
|
+
);
|
|
280
|
+
assert.match(
|
|
281
|
+
mounted.text(),
|
|
282
|
+
new RegExp(`${SPAM_IN_A}\\s*results from Spam`),
|
|
283
|
+
);
|
|
284
|
+
assert.match(mounted.text(), /Go to Spam/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("sums one count per junk folder, so several accounts read as one number", async () => {
|
|
288
|
+
const mounted = await mount({ [JUNK_A]: SPAM_IN_A, [JUNK_B]: SPAM_IN_B });
|
|
289
|
+
await mounted.waitFor(
|
|
290
|
+
() => mounted.text().includes("from Spam"),
|
|
291
|
+
"the Spam offer to state a count",
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
assert.deepEqual(
|
|
295
|
+
[...new Set(spamCountFolders())].sort(),
|
|
296
|
+
[JUNK_A, JUNK_B].sort(),
|
|
297
|
+
"the offer did not ask every junk folder for its own count",
|
|
298
|
+
);
|
|
299
|
+
// The invariant the whole fix rests on: each folder is counted under the
|
|
300
|
+
// search's own criteria, so the number is the size of what the reader is
|
|
301
|
+
// looking at rather than of every message that folder holds.
|
|
302
|
+
const rows = searchRowsRequest();
|
|
303
|
+
assert.ok(rows, "the brief never asked for the search's rows");
|
|
304
|
+
for (const call of spamCountRequests()) {
|
|
305
|
+
assert.equal(
|
|
306
|
+
criteriaOf(call),
|
|
307
|
+
criteriaOf(rows),
|
|
308
|
+
"a count asked about different mail from the list it sits above",
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
assert.match(
|
|
312
|
+
mounted.text(),
|
|
313
|
+
new RegExp(`${SPAM_IN_A + SPAM_IN_B}\\s*results from Spam`),
|
|
314
|
+
"the offer stated one account's share instead of the total",
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("makes no offer when every junk folder counted zero", async () => {
|
|
319
|
+
const mounted = await mount({ [JUNK_A]: 0, [JUNK_B]: 0 });
|
|
320
|
+
await mounted.waitFor(
|
|
321
|
+
() => mounted.text().includes("Invoice 0"),
|
|
322
|
+
"the brief to render its matches",
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
assert.ok(
|
|
326
|
+
!mounted.text().includes("from Spam"),
|
|
327
|
+
"the offer stood over a search that reaches no spam",
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Summing only the folders that answered would state a figure that is exact
|
|
332
|
+
// in form and short in fact, and nothing on screen tells the two apart.
|
|
333
|
+
it("states no number when a junk folder went uncounted", async () => {
|
|
334
|
+
const mounted = await mount({
|
|
335
|
+
[JUNK_A]: SPAM_IN_A,
|
|
336
|
+
[JUNK_B]: undefined,
|
|
337
|
+
});
|
|
338
|
+
await mounted.waitFor(
|
|
339
|
+
() => mounted.text().includes("from Spam"),
|
|
340
|
+
"the Spam offer to stand",
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
assert.match(mounted.text(), /Results from Spam/);
|
|
344
|
+
assert.doesNotMatch(
|
|
345
|
+
mounted.text(),
|
|
346
|
+
new RegExp(`${SPAM_IN_A}\\s*results from Spam`),
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How much of a search sits in Spam: one `listAllThreads` count per junk folder
|
|
3
|
+
* the search reached, under the search's own criteria.
|
|
4
|
+
*
|
|
5
|
+
* One request per folder rather than one for all of them, because the scope
|
|
6
|
+
* parameter names a single mailbox — and because the offer needs the folders
|
|
7
|
+
* apart as well as together: the sum is what it states, the largest is where its
|
|
8
|
+
* button goes. Accounts have one junk folder each, so this is one request per
|
|
9
|
+
* account and it is issued when the committed criteria change, never on a
|
|
10
|
+
* keystroke.
|
|
11
|
+
*
|
|
12
|
+
* `accountId` rides along on the criteria whenever the account pill is set, and
|
|
13
|
+
* the server intersects the two scopes: a junk folder of another account counts
|
|
14
|
+
* nothing. That is what keeps the sum the size of the list the reader is looking
|
|
15
|
+
* at rather than of every account they have.
|
|
16
|
+
*
|
|
17
|
+
* A count that has not arrived, failed, or came back absent is `unknown` for
|
|
18
|
+
* that folder, and `spamOfferFromCounts` turns any one of those into an unknown
|
|
19
|
+
* total. Nothing here substitutes a page length for a count the server did not
|
|
20
|
+
* give (#313).
|
|
21
|
+
*
|
|
22
|
+
* A failure is reported apart from the rest, because the three are not the same
|
|
23
|
+
* answer: a folder that could not be counted has not said it holds nothing, and
|
|
24
|
+
* an offer that vanished on a failed request is indistinguishable from a search
|
|
25
|
+
* that reaches no spam. These queries carry no `meta.softError` and must not:
|
|
26
|
+
* a 401 or 403 here escalates on the fail-fast contract (`shouldEscalate`,
|
|
27
|
+
* #1059) and puts the reader in front of the signed-out surface, which is the
|
|
28
|
+
* one thing a read must never answer with an empty result.
|
|
29
|
+
*/
|
|
30
|
+
import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
31
|
+
import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
|
|
32
|
+
import { useQueries } from "@tanstack/react-query";
|
|
33
|
+
import { useMemo } from "react";
|
|
34
|
+
import type { BriefSearchCriteria } from "@/hooks/useBriefSections";
|
|
35
|
+
import { toResultCount } from "@/lib/result-count";
|
|
36
|
+
import type { SpamMailboxCount } from "@/lib/spam-offer";
|
|
37
|
+
|
|
38
|
+
/** Half a minute, matching the searched brief's own rows. */
|
|
39
|
+
const SPAM_COUNT_STALE_TIME_MS = 30_000;
|
|
40
|
+
|
|
41
|
+
const UNCOUNTED: SpamMailboxCount["count"] = { kind: "unknown" };
|
|
42
|
+
|
|
43
|
+
export interface UseSpamMatchCountsOptions {
|
|
44
|
+
/** The committed search, exactly as the rows beneath the offer were asked for. */
|
|
45
|
+
criteria: BriefSearchCriteria;
|
|
46
|
+
/** Every junk folder in the search's scope, in a stable order. */
|
|
47
|
+
junkMailboxIds: readonly string[];
|
|
48
|
+
/**
|
|
49
|
+
* Whether a count is worth asking for. False while the view carries a term
|
|
50
|
+
* the request cannot express — the server would count a wider set than the
|
|
51
|
+
* list shows — and every folder reports no number.
|
|
52
|
+
*/
|
|
53
|
+
enabled: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// No `limit` and no cursor: the count is a property of the criteria, so no page
|
|
57
|
+
// of mail is paid for and no expansion can ask for it again.
|
|
58
|
+
const spamCountQuery = (criteria: BriefSearchCriteria, mailboxId: string) => ({
|
|
59
|
+
...criteria,
|
|
60
|
+
mailboxId,
|
|
61
|
+
order: "desc" as const,
|
|
62
|
+
count: true,
|
|
63
|
+
results: false,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export function useSpamMatchCounts({
|
|
67
|
+
criteria,
|
|
68
|
+
junkMailboxIds,
|
|
69
|
+
enabled,
|
|
70
|
+
}: UseSpamMatchCountsOptions): SpamMailboxCount[] {
|
|
71
|
+
const countQueries = useQueries({
|
|
72
|
+
queries: junkMailboxIds.map((mailboxId) => {
|
|
73
|
+
const query = spamCountQuery(criteria, mailboxId);
|
|
74
|
+
return {
|
|
75
|
+
queryKey: unifiedThreadOperationsListAllThreadsQueryKey({ query }),
|
|
76
|
+
queryFn: async () => {
|
|
77
|
+
const { data } = await unifiedThreadOperationsListAllThreads({
|
|
78
|
+
query,
|
|
79
|
+
throwOnError: true,
|
|
80
|
+
});
|
|
81
|
+
return data;
|
|
82
|
+
},
|
|
83
|
+
enabled,
|
|
84
|
+
staleTime: SPAM_COUNT_STALE_TIME_MS,
|
|
85
|
+
};
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return useMemo(
|
|
90
|
+
() =>
|
|
91
|
+
junkMailboxIds.map((mailboxId, index) => ({
|
|
92
|
+
mailboxId,
|
|
93
|
+
// `enabled` gates the request, not the cache: an uncounted view must
|
|
94
|
+
// not state the number an earlier, differently-scoped one left behind.
|
|
95
|
+
count: enabled
|
|
96
|
+
? toResultCount(countQueries[index]?.data?.count)
|
|
97
|
+
: UNCOUNTED,
|
|
98
|
+
failed: enabled ? (countQueries[index]?.isError ?? false) : false,
|
|
99
|
+
})),
|
|
100
|
+
[junkMailboxIds, countQueries, enabled],
|
|
101
|
+
);
|
|
102
|
+
}
|
package/src/lib/result-folder.ts
CHANGED
|
@@ -60,6 +60,22 @@ export function buildResultFolderIndex(
|
|
|
60
60
|
return index;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Every junk folder the index knows, in index order.
|
|
65
|
+
*
|
|
66
|
+
* The same `\Junk` appointment that holds a row out of a global search, read
|
|
67
|
+
* the other way round: these are the folders whose contents the Spam offer
|
|
68
|
+
* counts. Order is the accounts' own, so the offer's destination does not move
|
|
69
|
+
* between renders (#313).
|
|
70
|
+
*/
|
|
71
|
+
export function junkMailboxIds(folders: ResultFolderIndex): string[] {
|
|
72
|
+
const ids: string[] = [];
|
|
73
|
+
for (const [mailboxId, folder] of folders) {
|
|
74
|
+
if (folder.role === "junk") ids.push(mailboxId);
|
|
75
|
+
}
|
|
76
|
+
return ids;
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
export interface ResolvedResultFolder {
|
|
64
80
|
mailboxId?: string;
|
|
65
81
|
folder?: ResultFolder;
|