@remit/web-client 0.0.282 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.282",
3
+ "version": "0.0.283",
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": {
@@ -109,6 +109,7 @@ import { useIsDesktop } from "@/hooks/useMediaQuery";
109
109
  import { useRefreshControl } from "@/hooks/useRefreshControl";
110
110
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
111
111
  import { useSemanticSearch } from "@/hooks/useSemanticSearch";
112
+ import { useSpamMatchCounts } from "@/hooks/useSpamMatchCounts";
112
113
  import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
113
114
  import { sortAccountsByCreatedAt } from "@/lib/account-order";
114
115
  import {
@@ -128,11 +129,13 @@ import { junkDestination } from "@/lib/junk-destination";
128
129
  import type { ListHeaderChrome } from "@/lib/list-header-chrome";
129
130
  import { useMailContext } from "@/lib/mail-context";
130
131
  import { useMailFreshness } from "@/lib/mail-freshness";
132
+ import { shouldRequestResultCount } from "@/lib/result-count";
133
+ import { junkMailboxIds } from "@/lib/result-folder";
131
134
  import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
132
135
  import { showInlineSearchResults } from "@/lib/search-surface";
133
136
  import { parseSearchTokens } from "@/lib/search-tokens";
134
137
  import { resolveSelectionAccountScope } from "@/lib/selection-account-scope";
135
- import { spamOfferForResults } from "@/lib/spam-offer";
138
+ import { spamOfferFromCounts } from "@/lib/spam-offer";
136
139
  import { dedupeByThread } from "@/lib/starred-rows";
137
140
  import { wizardSelectionFrom } from "@/lib/wizard-selection";
138
141
  import {
@@ -738,6 +741,30 @@ export function DailyBrief({
738
741
  // same, or a committed search surfaces junk mail inline and drops the way back
739
742
  // to it. A bare token query (e.g. `is:unread`) never reaches search mode, so
740
743
  // there is nothing to hold out.
744
+ // What the offer states is the server's count of every junk folder the search
745
+ // reached, one request each, summed — not the junk share of the page below
746
+ // it, which is a page length wearing a folder total's clothes (#313). The
747
+ // page's own junk rows still decide what is held out of the list, and where
748
+ // "Go to Spam" lands whenever no count ranks the folders.
749
+ const junkIds = useMemo(
750
+ () => junkMailboxIds(resultFolderIndex),
751
+ [resultFolderIndex],
752
+ );
753
+ const spamCounts = useSpamMatchCounts({
754
+ criteria: searchCriteria,
755
+ junkMailboxIds: junkIds,
756
+ // The same two gates the mailbox list counts under: criteria the request
757
+ // carried in full, and free text long enough for the index to answer
758
+ // without a folder scan per character.
759
+ enabled:
760
+ counted &&
761
+ shouldRequestResultCount({
762
+ hasSearchQuery: underQuery,
763
+ freeText: sq,
764
+ residualTokenCount: residualTokens.length,
765
+ }),
766
+ });
767
+
741
768
  const { spamIds, briefSpamOffer } = useMemo(() => {
742
769
  const none = { spamIds: undefined, briefSpamOffer: undefined };
743
770
  if (!sq) return none;
@@ -745,12 +772,25 @@ export function DailyBrief({
745
772
  rowToSearchResult(row, resultFolderIndex),
746
773
  );
747
774
  const { spam } = partitionSpamResults(asResults);
748
- if (spam.length === 0) return none;
775
+ // Per folder, not a total: with no count to rank them by, this is the only
776
+ // evidence of which account's Spam holds the mail, and a button landing on
777
+ // the wrong one shows "No matches" over rows this page is holding out.
778
+ const pageRowsByMailbox = new Map<string, number>();
779
+ for (const result of spam) {
780
+ if (!result.mailboxId) continue;
781
+ pageRowsByMailbox.set(
782
+ result.mailboxId,
783
+ (pageRowsByMailbox.get(result.mailboxId) ?? 0) + 1,
784
+ );
785
+ }
786
+ const offer = spamOfferFromCounts(spamCounts, { pageRowsByMailbox });
787
+ if (spam.length === 0 && !offer) return none;
749
788
  return {
750
- spamIds: new Set(spam.map((result) => result.id)),
751
- briefSpamOffer: spamOfferForResults(asResults),
789
+ spamIds:
790
+ spam.length > 0 ? new Set(spam.map((result) => result.id)) : undefined,
791
+ briefSpamOffer: offer,
752
792
  };
753
- }, [filteredRows, sq, resultFolderIndex]);
793
+ }, [filteredRows, sq, resultFolderIndex, spamCounts]);
754
794
 
755
795
  const sections = useMemo<ThreadSection[]>(() => {
756
796
  const keep = (rows: ThreadRowData[]): ThreadRowData[] =>
@@ -1045,6 +1085,10 @@ export function DailyBrief({
1045
1085
  relatedResults,
1046
1086
  relatedLoading,
1047
1087
  onSelectSearchResult: openResult,
1088
+ // The read-only results panel holds spam out of the same
1089
+ // committed search this body does, so it states the same
1090
+ // server-counted number rather than counting its own page.
1091
+ spamOffer: briefSpamOffer,
1048
1092
  // The body already narrows to the committed query (the server
1049
1093
  // `query` on every section request, plus `matchesSearchTokens` for
1050
1094
  // the residue), so a committed search is a selectable list here
@@ -89,7 +89,7 @@ import {
89
89
  removeSearchToken,
90
90
  searchTokenLabel,
91
91
  } from "@/lib/search-tokens";
92
- import { spamOfferForResults } from "@/lib/spam-offer";
92
+ import type { SpamOffer } from "@/lib/spam-offer";
93
93
  import {
94
94
  useBrowsedList,
95
95
  useOpenWizard,
@@ -152,6 +152,13 @@ export interface MailListHeaderProps {
152
152
  * leaves this unset and keeps the panel for every query.
153
153
  */
154
154
  searchResultsInBody?: boolean;
155
+ /**
156
+ * The Spam offer this view is making, if any: where "Go to Spam" lands and
157
+ * how much of the search sits there, counted by the server over every junk
158
+ * folder it reached. Only the global scope holds spam out, so only the brief
159
+ * passes one; every other view leaves it unset and no offer is made.
160
+ */
161
+ spamOffer?: SpamOffer;
155
162
  /**
156
163
  * The refresh control for this view — the mailbox route and the brief pass
157
164
  * one, scoped to the account(s) they show; a view with nothing account-scoped
@@ -230,6 +237,7 @@ export function MailListHeader({
230
237
  searchResultsLabel = "Top matches",
231
238
  relatedResultsLabel = "Related",
232
239
  searchResultsInBody = false,
240
+ spamOffer,
233
241
  refreshControl,
234
242
  }: MailListHeaderProps) {
235
243
  const {
@@ -393,26 +401,23 @@ export function MailListHeader({
393
401
  [browsed, scope, scopedRole],
394
402
  );
395
403
 
396
- // The offer counts results for the *committed* query, so that is the query it
397
- // carries into Spam. The count the banner states is the results list's own,
398
- // over every row it held out; the app supplies only where "Go to Spam" goes.
399
- const spamOffer = useMemo(
400
- () =>
401
- routeScope.kind === "global"
402
- ? spamOfferForResults([...topMatches, ...related])
403
- : undefined,
404
- [routeScope, topMatches, related],
405
- );
404
+ // The offer is made for the *committed* query, so that is the query it carries
405
+ // into Spam. Both halves come from the view that owns the search: the number
406
+ // is the server's count of every junk folder it reached, and the destination
407
+ // the folder holding most of them. Counting the rows this panel happens to
408
+ // hold would count a page and call it a folder (#313).
409
+ const offer = routeScope.kind === "global" ? spamOffer : undefined;
406
410
  const resultsScope = useMemo<SearchScope>(
407
411
  () =>
408
- spamOffer
412
+ offer
409
413
  ? {
410
414
  kind: "global",
415
+ spamCount: offer.count,
411
416
  onScopeToSpam: () =>
412
- scopeSearchToMailbox(spamOffer.mailboxId, searchQuery),
417
+ scopeSearchToMailbox(offer.mailboxId, searchQuery),
413
418
  }
414
419
  : routeScope,
415
- [spamOffer, routeScope, scopeSearchToMailbox, searchQuery],
420
+ [offer, routeScope, scopeSearchToMailbox, searchQuery],
416
421
  );
417
422
 
418
423
  // Make-this-a-filter (#477 clause 1.8): the search is the wizard's second
@@ -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
+ }
@@ -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;
@@ -1,84 +1,192 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
- import type { SearchResult } from "@remit/ui";
4
- import { spamOfferForResults } from "./spam-offer";
5
-
6
- const row = (
7
- id: string,
8
- mailboxId: string,
9
- role?: "junk" | "inbox",
10
- ): SearchResult => ({
11
- id,
12
- sender: "Someone",
13
- subject: "Subject",
14
- snippet: "",
15
- date: "",
3
+ import { type SpamMailboxCount, spamOfferFromCounts } from "./spam-offer";
4
+
5
+ const exact = (mailboxId: string, value: number): SpamMailboxCount => ({
6
+ mailboxId,
7
+ count: { kind: "exact", value },
8
+ failed: false,
9
+ });
10
+
11
+ const uncounted = (mailboxId: string): SpamMailboxCount => ({
12
+ mailboxId,
13
+ count: { kind: "unknown" },
14
+ failed: false,
15
+ });
16
+
17
+ const errored = (mailboxId: string): SpamMailboxCount => ({
16
18
  mailboxId,
17
- ...(role ? { folder: { role } } : {}),
19
+ count: { kind: "unknown" },
20
+ failed: true,
21
+ });
22
+
23
+ /** The loaded page held no junk row of its own. */
24
+ const NO_PAGE_ROWS = { pageRowsByMailbox: new Map<string, number>() };
25
+
26
+ const pageRows = (
27
+ entries: Record<string, number>,
28
+ ): { pageRowsByMailbox: ReadonlyMap<string, number> } => ({
29
+ pageRowsByMailbox: new Map(Object.entries(entries)),
18
30
  });
19
31
 
20
- describe("spamOfferForResults", () => {
21
- it("makes no offer when nothing came from Spam", () => {
32
+ describe("spamOfferFromCounts", () => {
33
+ it("makes no offer when there is no junk folder to count", () => {
34
+ assert.equal(spamOfferFromCounts([], NO_PAGE_ROWS), undefined);
35
+ });
36
+
37
+ it("makes no offer when every junk folder counted zero", () => {
22
38
  assert.equal(
23
- spamOfferForResults([row("1", "mb-inbox", "inbox"), row("2", "mb-a")]),
39
+ spamOfferFromCounts(
40
+ [exact("mb-junk-a", 0), exact("mb-junk-b", 0)],
41
+ NO_PAGE_ROWS,
42
+ ),
24
43
  undefined,
25
44
  );
26
45
  });
27
46
 
28
- it("counts the spam rows and names their mailbox", () => {
47
+ it("states the server's count for the one junk folder", () => {
48
+ assert.deepEqual(
49
+ spamOfferFromCounts([exact("mb-junk", 42)], NO_PAGE_ROWS),
50
+ {
51
+ mailboxId: "mb-junk",
52
+ count: { kind: "exact", value: 42 },
53
+ },
54
+ );
55
+ });
56
+
57
+ it("sums every junk folder, so several accounts read as one number", () => {
58
+ assert.deepEqual(
59
+ spamOfferFromCounts(
60
+ [exact("mb-junk-a", 3), exact("mb-junk-b", 7), exact("mb-junk-c", 2)],
61
+ NO_PAGE_ROWS,
62
+ ),
63
+ { mailboxId: "mb-junk-b", count: { kind: "exact", value: 12 } },
64
+ );
65
+ });
66
+
67
+ it("sends the reader to the junk folder holding the most matches", () => {
68
+ assert.equal(
69
+ spamOfferFromCounts(
70
+ [exact("mb-junk-a", 1), exact("mb-junk-b", 9)],
71
+ NO_PAGE_ROWS,
72
+ )?.mailboxId,
73
+ "mb-junk-b",
74
+ );
75
+ });
76
+
77
+ it("breaks a tie on folder order, so the destination does not flip", () => {
78
+ assert.equal(
79
+ spamOfferFromCounts(
80
+ [exact("mb-junk-a", 4), exact("mb-junk-b", 4)],
81
+ NO_PAGE_ROWS,
82
+ )?.mailboxId,
83
+ "mb-junk-a",
84
+ );
85
+ });
86
+
87
+ it("states no number when one junk folder went uncounted", () => {
88
+ // Summing only the folders that answered would state a figure that is exact
89
+ // in form and short in fact.
29
90
  assert.deepEqual(
30
- spamOfferForResults([
31
- row("1", "mb-inbox", "inbox"),
32
- row("2", "mb-junk", "junk"),
33
- row("3", "mb-junk", "junk"),
34
- ]),
35
- { mailboxId: "mb-junk", count: 2 },
91
+ spamOfferFromCounts(
92
+ [exact("mb-junk-a", 5), uncounted("mb-junk-b")],
93
+ NO_PAGE_ROWS,
94
+ ),
95
+ { mailboxId: "mb-junk-a", count: { kind: "unknown" } },
96
+ );
97
+ });
98
+
99
+ it("stands down when nothing is known and the page held no spam either", () => {
100
+ assert.equal(
101
+ spamOfferFromCounts(
102
+ [uncounted("mb-junk-a"), uncounted("mb-junk-b")],
103
+ NO_PAGE_ROWS,
104
+ ),
105
+ undefined,
36
106
  );
37
107
  });
38
108
 
39
- it("targets the Spam folder with the most matches when accounts compete", () => {
109
+ // A failed count is not a folder saying it holds nothing. Dropping the offer
110
+ // on one would make a lapsed session look exactly like a clean search.
111
+ it("keeps the offer when a count request failed", () => {
40
112
  assert.deepEqual(
41
- spamOfferForResults([
42
- row("1", "mb-junk-a", "junk"),
43
- row("2", "mb-junk-b", "junk"),
44
- row("3", "mb-junk-b", "junk"),
45
- ]),
46
- { mailboxId: "mb-junk-b", count: 2 },
113
+ spamOfferFromCounts(
114
+ [exact("mb-junk-a", 0), errored("mb-junk-b")],
115
+ NO_PAGE_ROWS,
116
+ ),
117
+ { mailboxId: "mb-junk-b", count: { kind: "unknown" } },
47
118
  );
48
119
  });
49
120
 
50
- it("breaks a tie on result order, so the offer does not flip", () => {
121
+ it("does not read an uncounted folder as an empty one", () => {
51
122
  assert.deepEqual(
52
- spamOfferForResults([
53
- row("1", "mb-junk-a", "junk"),
54
- row("2", "mb-junk-b", "junk"),
55
- ]),
56
- { mailboxId: "mb-junk-a", count: 1 },
123
+ spamOfferFromCounts(
124
+ [exact("mb-junk-a", 0), uncounted("mb-junk-b")],
125
+ pageRows({ "mb-junk-b": 1 }),
126
+ ),
127
+ { mailboxId: "mb-junk-b", count: { kind: "unknown" } },
128
+ );
129
+ });
130
+ });
131
+
132
+ describe("spamOfferFromCounts destination", () => {
133
+ // The regression: with counts switched off, every folder is `unknown` and the
134
+ // destination fell to whichever folder happened to be first. Two accounts,
135
+ // every held-out row in B's Spam, and the button opened A's — "No matches"
136
+ // over mail the list was holding out on the very same screen.
137
+ it("follows the page's junk rows when no folder was counted", () => {
138
+ assert.equal(
139
+ spamOfferFromCounts(
140
+ [uncounted("mb-junk-a"), uncounted("mb-junk-b")],
141
+ pageRows({ "mb-junk-b": 3 }),
142
+ )?.mailboxId,
143
+ "mb-junk-b",
144
+ );
145
+ });
146
+
147
+ // An exact zero is the one folder known to hold nothing, so it is the one
148
+ // destination guaranteed to show an empty list — below a folder that simply
149
+ // has not answered.
150
+ it("never sends the reader to a folder that counted zero", () => {
151
+ assert.equal(
152
+ spamOfferFromCounts(
153
+ [exact("mb-junk-a", 0), uncounted("mb-junk-b"), errored("mb-junk-c")],
154
+ NO_PAGE_ROWS,
155
+ )?.mailboxId,
156
+ "mb-junk-b",
57
157
  );
58
158
  });
59
159
 
60
- it("picks a destination without claiming to have counted every account", () => {
61
- // Three spam rows across two accounts: the destination is the bigger folder,
62
- // and its share is 2 — deliberately not the total, which the results list
63
- // states for itself so the banner cannot under-report.
64
- const offer = spamOfferForResults([
65
- row("1", "mb-junk-a", "junk"),
66
- row("2", "mb-junk-b", "junk"),
67
- row("3", "mb-junk-b", "junk"),
68
- ]);
69
- assert.equal(offer?.mailboxId, "mb-junk-b");
70
- assert.equal(offer?.count, 2);
160
+ it("prefers a counted match over the folder the page's rows came from", () => {
161
+ assert.equal(
162
+ spamOfferFromCounts(
163
+ [exact("mb-junk-a", 9), uncounted("mb-junk-b")],
164
+ pageRows({ "mb-junk-b": 4 }),
165
+ )?.mailboxId,
166
+ "mb-junk-a",
167
+ );
168
+ });
169
+
170
+ // The mailbox lists are still loading, or the folder arrived after the count
171
+ // requests went out. Its rows are already held out of the list, so it has to
172
+ // be reachable.
173
+ it("offers a junk folder the page names and the counts do not", () => {
174
+ assert.deepEqual(spamOfferFromCounts([], pageRows({ "mb-junk-late": 2 })), {
175
+ mailboxId: "mb-junk-late",
176
+ count: { kind: "unknown" },
177
+ });
71
178
  });
72
179
 
73
- it("ignores a spam row that carries no mailbox to navigate to", () => {
74
- const orphan: SearchResult = {
75
- id: "1",
76
- sender: "Someone",
77
- subject: "Subject",
78
- snippet: "",
79
- date: "",
80
- folder: { role: "junk" },
81
- };
82
- assert.equal(spamOfferForResults([orphan]), undefined);
180
+ // A count that says nothing over a page that is holding junk rows is stale by
181
+ // up to its half-minute. The rows are on screen; the offer stands and states
182
+ // no figure rather than "0 results from Spam".
183
+ it("states no number when an exact zero contradicts the page's rows", () => {
184
+ assert.deepEqual(
185
+ spamOfferFromCounts(
186
+ [exact("mb-junk-a", 0), exact("mb-junk-b", 0)],
187
+ pageRows({ "mb-junk-b": 2 }),
188
+ ),
189
+ { mailboxId: "mb-junk-b", count: { kind: "unknown" } },
190
+ );
83
191
  });
84
192
  });
@@ -1,37 +1,163 @@
1
1
  /**
2
- * Where taking the Spam offer lands.
2
+ * The Spam offer: how much of a search sits in Spam, and where taking the offer
3
+ * lands.
3
4
  *
4
- * Scoping is a route and a route names one mailbox, so the offer has to pick a
5
- * single Spam folder even when several accounts matched; it picks the one
6
- * holding the most matches. `count` is that folder's share, used only to choose
7
- * between folders — the banner states the full number of rows held out, which
8
- * the results list counts for itself.
5
+ * Both answers come from one server count per junk folder the search reached
6
+ * (`useSpamMatchCounts`). The number is their sum, because a search spans every
7
+ * account and every account has its own Spam folder — "N in Spam" that named
8
+ * one folder's share would be smaller than the mail the phrase describes.
9
+ *
10
+ * The destination is a single folder even so: scoping is a route and a route
11
+ * names one mailbox. It is the folder holding the most matches, which is the
12
+ * best a single click can do, and `SpamResultsOffer` says where the button goes
13
+ * rather than promising to show every match. Where no count ranks the folders —
14
+ * a short query, a residual token, a failed request — the page's own junk rows
15
+ * do, because they are the only evidence left of where the mail is.
16
+ *
17
+ * This used to group the search results the header held and count each group.
18
+ * That array is a page, so the figure was a count of the loaded window offered
19
+ * as a count of a mailbox: a Spam match below the page was missing from it, and
20
+ * the number climbed as further pages loaded (#313).
21
+ *
22
+ * A folder whose count did not arrive makes the total `unknown`. Summing the
23
+ * ones that did would state a figure that is exact in form and short in fact,
24
+ * and nothing distinguishes it from a true one on screen.
9
25
  */
10
- import { partitionSpamResults, type SearchResult } from "@remit/ui";
26
+ import type { ResultCount } from "@remit/ui";
27
+
28
+ export interface SpamMailboxCount {
29
+ mailboxId: string;
30
+ count: ResultCount;
31
+ /**
32
+ * The count request failed. Distinct from `unknown`, which also covers a
33
+ * request still in flight or one nobody made: a failure is a folder that
34
+ * could not answer, and it must never read as a folder holding nothing.
35
+ */
36
+ failed: boolean;
37
+ }
11
38
 
12
39
  export interface SpamOffer {
40
+ /** The junk folder "Go to Spam" scopes the search to. */
41
+ mailboxId: string;
42
+ /** Matches across every junk folder, or `unknown` if any went uncounted. */
43
+ count: ResultCount;
44
+ }
45
+
46
+ export interface SpamOfferEvidence {
47
+ /**
48
+ * Junk rows the loaded page held, per junk folder.
49
+ *
50
+ * Never the number the offer states — a page is a page. It is what says
51
+ * spam was reached at all while the counts are absent, and, crucially, WHICH
52
+ * folder the reader is being sent to: with no count to rank the folders by,
53
+ * the rows on screen are the only evidence of where the mail is, and a
54
+ * "Go to Spam" that lands on the wrong account's folder shows "No matches"
55
+ * over mail this very page is holding out.
56
+ */
57
+ pageRowsByMailbox: ReadonlyMap<string, number>;
58
+ }
59
+
60
+ interface Candidate {
13
61
  mailboxId: string;
14
- count: number;
62
+ count: ResultCount;
63
+ failed: boolean;
64
+ pageRows: number;
15
65
  }
16
66
 
17
- export function spamOfferForResults(
18
- results: readonly SearchResult[],
67
+ /**
68
+ * How good a destination a folder is, before ties are broken.
69
+ *
70
+ * A counted match is the best evidence; junk rows on the page are the next
71
+ * best, and they outrank the folder's own count because the rows are on screen
72
+ * while the count may be half a minute old. A folder that answered zero and
73
+ * shows no row ranks LAST — it is the only one known to hold nothing, so
74
+ * sending the reader there is the one choice guaranteed to show an empty list.
75
+ */
76
+ const tier = (candidate: Candidate): number => {
77
+ if (candidate.count.kind === "exact" && candidate.count.value > 0) return 3;
78
+ if (candidate.pageRows > 0) return 2;
79
+ return candidate.count.kind === "unknown" ? 1 : 0;
80
+ };
81
+
82
+ const exactValue = (candidate: Candidate): number =>
83
+ candidate.count.kind === "exact" ? candidate.count.value : 0;
84
+
85
+ /** Strict, so equal candidates leave the earlier one standing. */
86
+ const isBetterDestination = (
87
+ candidate: Candidate,
88
+ standing: Candidate,
89
+ ): boolean => {
90
+ if (tier(candidate) !== tier(standing)) {
91
+ return tier(candidate) > tier(standing);
92
+ }
93
+ if (exactValue(candidate) !== exactValue(standing)) {
94
+ return exactValue(candidate) > exactValue(standing);
95
+ }
96
+ return candidate.pageRows > standing.pageRows;
97
+ };
98
+
99
+ /**
100
+ * The offer these per-folder counts support, or none.
101
+ *
102
+ * None when every junk folder answered and they sum to zero with no junk row on
103
+ * the page either — the search reaches no spam. Anything else is an offer: rows
104
+ * held out of the list are always offered a way back, because hiding mail
105
+ * without one is worse than hiding a number.
106
+ *
107
+ * The figure is the sum, and only when every folder answered. One folder
108
+ * missing takes it off, because summing the folders that replied states a
109
+ * number exact in form and short in fact. A sum of zero over a page that is
110
+ * holding junk rows takes it off too: the two disagree, so neither is stated.
111
+ */
112
+ export function spamOfferFromCounts(
113
+ counts: readonly SpamMailboxCount[],
114
+ { pageRowsByMailbox }: SpamOfferEvidence,
19
115
  ): SpamOffer | undefined {
20
- const { spam } = partitionSpamResults([...results]);
21
- const countByMailbox = new Map<string, number>();
22
- for (const result of spam) {
23
- if (!result.mailboxId) continue;
24
- countByMailbox.set(
25
- result.mailboxId,
26
- (countByMailbox.get(result.mailboxId) ?? 0) + 1,
27
- );
116
+ const candidates: Candidate[] = counts.map((entry) => ({
117
+ ...entry,
118
+ pageRows: pageRowsByMailbox.get(entry.mailboxId) ?? 0,
119
+ }));
120
+ // A folder the page names but the counts do not — the mailbox lists are still
121
+ // loading, or the folder arrived after the requests went out. Its rows are
122
+ // already being held out of the list, so it has to be reachable.
123
+ for (const [mailboxId, pageRows] of pageRowsByMailbox) {
124
+ if (pageRows <= 0) continue;
125
+ if (candidates.some((entry) => entry.mailboxId === mailboxId)) continue;
126
+ candidates.push({
127
+ mailboxId,
128
+ count: { kind: "unknown" },
129
+ failed: false,
130
+ pageRows,
131
+ });
132
+ }
133
+
134
+ const first = candidates[0];
135
+ if (!first) return undefined;
136
+
137
+ let total = 0;
138
+ let everyFolderAnswered = true;
139
+ let best = first;
140
+ for (const candidate of candidates) {
141
+ if (candidate.count.kind === "unknown") everyFolderAnswered = false;
142
+ else total += candidate.count.value;
143
+ if (isBetterDestination(candidate, best)) best = candidate;
28
144
  }
29
145
 
30
- let offer: SpamOffer | undefined;
31
- // Insertion order is result order, so the first-seen folder wins a tie and
32
- // the offer does not flip between renders of the same results.
33
- for (const [mailboxId, count] of countByMailbox) {
34
- if (!offer || count > offer.count) offer = { mailboxId, count };
146
+ const pageHeldSpam = candidates.some((candidate) => candidate.pageRows > 0);
147
+ const anyFailed = candidates.some((candidate) => candidate.failed);
148
+ if (total === 0 && !pageHeldSpam && (everyFolderAnswered || !anyFailed)) {
149
+ return undefined;
35
150
  }
36
- return offer;
151
+
152
+ // The count and the page disagree: an exact zero under rows the list is
153
+ // holding out. The rows are on screen and the count is up to half a minute
154
+ // old, so the offer stands and states nothing.
155
+ const contradicted = everyFolderAnswered && total === 0 && pageHeldSpam;
156
+ return {
157
+ mailboxId: best.mailboxId,
158
+ count:
159
+ everyFolderAnswered && !contradicted
160
+ ? { kind: "exact", value: total }
161
+ : { kind: "unknown" },
162
+ };
37
163
  }