@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 +1 -1
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
751
|
-
|
|
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 {
|
|
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
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
412
|
+
offer
|
|
409
413
|
? {
|
|
410
414
|
kind: "global",
|
|
415
|
+
spamCount: offer.count,
|
|
411
416
|
onScopeToSpam: () =>
|
|
412
|
-
scopeSearchToMailbox(
|
|
417
|
+
scopeSearchToMailbox(offer.mailboxId, searchQuery),
|
|
413
418
|
}
|
|
414
419
|
: routeScope,
|
|
415
|
-
[
|
|
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
|
+
}
|
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;
|
|
@@ -1,84 +1,192 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import type
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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("
|
|
21
|
-
it("makes no offer when
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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("
|
|
121
|
+
it("does not read an uncounted folder as an empty one", () => {
|
|
51
122
|
assert.deepEqual(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
{ mailboxId: "mb-junk-
|
|
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("
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
});
|
package/src/lib/spam-offer.ts
CHANGED
|
@@ -1,37 +1,163 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The Spam offer: how much of a search sits in Spam, and where taking the offer
|
|
3
|
+
* lands.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
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:
|
|
62
|
+
count: ResultCount;
|
|
63
|
+
failed: boolean;
|
|
64
|
+
pageRows: number;
|
|
15
65
|
}
|
|
16
66
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
}
|