@remit/web-client 0.0.278 → 0.0.280
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 +27 -22
- package/src/components/mail/FlaggedList.tsx +26 -34
- package/src/components/mail/SelectionWizardHost.tsx +8 -6
- package/src/components/mail/brief-server-sections.render.test.ts +28 -23
- package/src/components/mail/flagged-search-reach.render.test.ts +272 -0
- package/src/hooks/useBriefSections.ts +13 -4
- package/src/hooks/useOrganizeWiden.test.ts +71 -5
- package/src/hooks/useOrganizeWiden.ts +24 -15
- package/src/hooks/useStarredThreads.ts +13 -36
- package/src/lib/brief-criteria.test.ts +64 -22
- package/src/lib/brief-criteria.ts +36 -17
- package/src/lib/brief.test.ts +0 -234
- package/src/lib/brief.ts +11 -87
- package/src/lib/flagged-criteria.test.ts +19 -4
- package/src/lib/flagged-criteria.ts +16 -15
- package/src/lib/inbox-filters.ts +18 -1
- package/src/lib/organize/sender-fallback.test.ts +31 -2
- package/src/lib/organize/sender-fallback.ts +12 -8
- package/src/lib/thread-search-tokens.ts +4 -3
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* The widen step's two-phase preview. On a semantic-capable deployment it runs
|
|
3
3
|
* the anchor preview once and stops. On a deployment that reports
|
|
4
4
|
* `semanticUnavailable` (no vector pipeline — semantic-capability.ts) it
|
|
5
|
-
* re-previews with
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* re-previews with what the whole selection agrees on (#458) — one `From` or
|
|
6
|
+
* `FromDomain` clause when senders agree, the shared subject when they don't,
|
|
7
|
+
* combined with `Or`, no anchor — and reports the literal match count, so
|
|
8
|
+
* every commit scope acts on the same set the fallback derived.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import assert from "node:assert/strict";
|
|
@@ -29,12 +30,16 @@ type Responder = Parameters<typeof mockFetch>[0];
|
|
|
29
30
|
|
|
30
31
|
const PREVIEW_PATH = "/organize/preview";
|
|
31
32
|
|
|
32
|
-
const mount = (
|
|
33
|
+
const mount = (
|
|
34
|
+
senders: string[],
|
|
35
|
+
responder: Responder,
|
|
36
|
+
subjects: string[] = [],
|
|
37
|
+
) => {
|
|
33
38
|
http = mockFetch(responder);
|
|
34
39
|
harness = createDomHarness();
|
|
35
40
|
const holder: { current: Widen | undefined } = { current: undefined };
|
|
36
41
|
function Probe() {
|
|
37
|
-
const widen = useOrganizeWiden("acc-1", "msg-1", senders);
|
|
42
|
+
const widen = useOrganizeWiden("acc-1", "msg-1", senders, subjects);
|
|
38
43
|
holder.current = widen;
|
|
39
44
|
const { preview } = widen;
|
|
40
45
|
useEffect(() => {
|
|
@@ -130,6 +135,39 @@ describe("useOrganizeWiden — no vector pipeline, senders present", () => {
|
|
|
130
135
|
});
|
|
131
136
|
});
|
|
132
137
|
|
|
138
|
+
describe("useOrganizeWiden — no vector pipeline, mixed senders sharing a subject", () => {
|
|
139
|
+
it("re-previews with the shared subject rather than one From clause per sender (#458)", async () => {
|
|
140
|
+
const holder = mount(
|
|
141
|
+
["billing@acme.test", "accounts@globex.test"],
|
|
142
|
+
(call) =>
|
|
143
|
+
call.body?.anchorMessageId
|
|
144
|
+
? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
|
|
145
|
+
: {
|
|
146
|
+
matchedCount: 64,
|
|
147
|
+
messageIds: ["m"],
|
|
148
|
+
semanticUnavailable: false,
|
|
149
|
+
},
|
|
150
|
+
["Invoice 1841", "Invoice 1902"],
|
|
151
|
+
);
|
|
152
|
+
await settle(2);
|
|
153
|
+
|
|
154
|
+
const calls = http?.to(PREVIEW_PATH) ?? [];
|
|
155
|
+
assert.equal(calls.length, 2);
|
|
156
|
+
const second = calls[1];
|
|
157
|
+
assert.equal(second.body?.anchorMessageId, undefined);
|
|
158
|
+
assert.equal(second.body?.matchOperator, "Or");
|
|
159
|
+
assert.deepEqual(second.body?.literalClauses, [
|
|
160
|
+
{ field: "Subject", value: "Invoice" },
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
const widen = holder.current;
|
|
164
|
+
assert.deepEqual(widen?.matchPredicate, {
|
|
165
|
+
matchOperator: "Or",
|
|
166
|
+
literalClauses: [{ field: "Subject", value: "Invoice" }],
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
133
171
|
describe("useOrganizeWiden — no vector pipeline, no senders", () => {
|
|
134
172
|
it("does not re-preview and stays capability-absent with the anchor predicate", async () => {
|
|
135
173
|
const holder = mount([], () => ({
|
|
@@ -148,4 +186,32 @@ describe("useOrganizeWiden — no vector pipeline, no senders", () => {
|
|
|
148
186
|
assert.deepEqual(widen?.senders, []);
|
|
149
187
|
assert.equal(widen?.matchPredicate.anchorMessageId, "msg-1");
|
|
150
188
|
});
|
|
189
|
+
|
|
190
|
+
it("still re-previews on a shared subject alone (#458)", async () => {
|
|
191
|
+
const holder = mount(
|
|
192
|
+
[],
|
|
193
|
+
(call) =>
|
|
194
|
+
call.body?.anchorMessageId
|
|
195
|
+
? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
|
|
196
|
+
: {
|
|
197
|
+
matchedCount: 12,
|
|
198
|
+
messageIds: ["m"],
|
|
199
|
+
semanticUnavailable: false,
|
|
200
|
+
},
|
|
201
|
+
["Invoice 1841", "Invoice 1902"],
|
|
202
|
+
);
|
|
203
|
+
await settle(2);
|
|
204
|
+
|
|
205
|
+
const calls = http?.to(PREVIEW_PATH) ?? [];
|
|
206
|
+
assert.equal(calls.length, 2);
|
|
207
|
+
const second = calls[1];
|
|
208
|
+
assert.equal(second.body?.anchorMessageId, undefined);
|
|
209
|
+
assert.deepEqual(second.body?.literalClauses, [
|
|
210
|
+
{ field: "Subject", value: "Invoice" },
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
const widen = holder.current;
|
|
214
|
+
assert.equal(widen?.semanticUnavailable, true);
|
|
215
|
+
assert.equal(widen?.senderFallback, true);
|
|
216
|
+
});
|
|
151
217
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { derivePropertyClauses, distinctSenders } from "@remit/ui";
|
|
3
3
|
import { useMutation } from "@tanstack/react-query";
|
|
4
4
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
5
5
|
import { buildOrganizeInput } from "@/lib/organize/organize-model";
|
|
@@ -9,16 +9,17 @@ import {
|
|
|
9
9
|
} from "@/lib/organize/sender-fallback";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* "Select similar messages" (the widen step) with a
|
|
13
|
-
* deployments without the vector pipeline.
|
|
12
|
+
* "Select similar messages" (the widen step) with a selection-agreement
|
|
13
|
+
* fallback for deployments without the vector pipeline.
|
|
14
14
|
*
|
|
15
15
|
* The first preview runs the semantic anchor (POST /organize/preview). When the
|
|
16
16
|
* server reports `semanticUnavailable` — no vector pipeline on this deployment
|
|
17
17
|
* (semantic-capability.ts) — the widen re-previews with the literal vocabulary
|
|
18
|
-
* that matches vector-free:
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* that matches vector-free: what the whole selection agrees on (#458) — one
|
|
19
|
+
* sender/domain clause, or the subject the selection shares when senders don't
|
|
20
|
+
* — combined with `Or`, no anchor. The counts shown, the one-time back-apply,
|
|
21
|
+
* and any standing filter then all carry that same literal predicate, so the
|
|
22
|
+
* previewed set equals the set every commit scope acts on.
|
|
22
23
|
*
|
|
23
24
|
* A semantic-capable deployment never fires the second preview and keeps exactly
|
|
24
25
|
* the anchor behaviour.
|
|
@@ -27,12 +28,17 @@ export const useOrganizeWiden = (
|
|
|
27
28
|
accountId: string | undefined,
|
|
28
29
|
anchorMessageId: string | undefined,
|
|
29
30
|
senders: readonly string[],
|
|
31
|
+
subjects: readonly string[],
|
|
30
32
|
) => {
|
|
31
33
|
const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
|
|
32
34
|
const { mutate, reset: mutationReset } = mutation;
|
|
33
35
|
const [fellBack, setFellBack] = useState(false);
|
|
34
36
|
|
|
35
37
|
const distinct = useMemo(() => distinctSenders(senders), [senders]);
|
|
38
|
+
const agreementClauses = useMemo(
|
|
39
|
+
() => derivePropertyClauses(distinct, subjects),
|
|
40
|
+
[distinct, subjects],
|
|
41
|
+
);
|
|
36
42
|
|
|
37
43
|
const preview = useCallback(() => {
|
|
38
44
|
if (!accountId || !anchorMessageId) return;
|
|
@@ -53,21 +59,24 @@ export const useOrganizeWiden = (
|
|
|
53
59
|
}, [mutationReset]);
|
|
54
60
|
|
|
55
61
|
const data = mutation.data;
|
|
56
|
-
// The first preview came back capability-absent and
|
|
57
|
-
// match on
|
|
58
|
-
//
|
|
59
|
-
//
|
|
62
|
+
// The first preview came back capability-absent and the selection agrees on
|
|
63
|
+
// something to match on — a sender, a domain, or just a shared subject, not
|
|
64
|
+
// senders alone (#458): re-preview with the literal clauses before showing
|
|
65
|
+
// any count, so the count the user sees is the agreement's match count,
|
|
66
|
+
// never a flash of the empty semantic result.
|
|
60
67
|
const willFallBack =
|
|
61
|
-
data?.semanticUnavailable === true &&
|
|
68
|
+
data?.semanticUnavailable === true &&
|
|
69
|
+
!fellBack &&
|
|
70
|
+
agreementClauses.length > 0;
|
|
62
71
|
|
|
63
72
|
useEffect(() => {
|
|
64
73
|
if (!willFallBack || !accountId) return;
|
|
65
74
|
setFellBack(true);
|
|
66
75
|
mutate({
|
|
67
76
|
path: { accountId },
|
|
68
|
-
body: buildOrganizeInput(buildSenderFallbackDraft(distinct)),
|
|
77
|
+
body: buildOrganizeInput(buildSenderFallbackDraft(distinct, subjects)),
|
|
69
78
|
});
|
|
70
|
-
}, [willFallBack, accountId, distinct, mutate]);
|
|
79
|
+
}, [willFallBack, accountId, distinct, subjects, mutate]);
|
|
71
80
|
|
|
72
81
|
// The literal re-preview has no anchor, so its response reports
|
|
73
82
|
// `semanticUnavailable: false`; `fellBack` is what remembers the capability
|
|
@@ -76,7 +85,7 @@ export const useOrganizeWiden = (
|
|
|
76
85
|
const isPending = mutation.isPending || willFallBack;
|
|
77
86
|
|
|
78
87
|
const matchPredicate: OrganizeMatchPredicate = fellBack
|
|
79
|
-
? { matchOperator: "Or", literalClauses:
|
|
88
|
+
? { matchOperator: "Or", literalClauses: agreementClauses }
|
|
80
89
|
: {
|
|
81
90
|
...(anchorMessageId ? { anchorMessageId } : {}),
|
|
82
91
|
matchOperator: "And",
|
|
@@ -14,20 +14,28 @@
|
|
|
14
14
|
* below the newest page rendered an empty list however much of it the
|
|
15
15
|
* collection held (#308); the server answers the predicate over the whole
|
|
16
16
|
* collection instead, and a page comes back full of matches.
|
|
17
|
+
*
|
|
18
|
+
* Free text is one of those parameters, so a search is this same paginated
|
|
19
|
+
* query rather than a second, single-page one beside it. The separate one had
|
|
20
|
+
* no error state of its own — an expired session read as "nothing matched" —
|
|
21
|
+
* no continuation, so "Load more" was a dead button under a query, and its own
|
|
22
|
+
* loading state, so a cached listing flashed the empty state while it was in
|
|
23
|
+
* flight.
|
|
17
24
|
*/
|
|
18
25
|
import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
19
26
|
import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
|
|
20
27
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
21
28
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
|
22
29
|
import { useMemo } from "react";
|
|
23
|
-
import type {
|
|
30
|
+
import type { ThreadSearchTokenParams } from "@/lib/thread-search-tokens";
|
|
24
31
|
|
|
25
32
|
/** The criteria the Flagged view narrows its listing by, all server-applied. */
|
|
26
|
-
export interface StarredCriteria extends
|
|
27
|
-
/**
|
|
33
|
+
export interface StarredCriteria extends ThreadSearchTokenParams {
|
|
34
|
+
/**
|
|
35
|
+
* Free text, matched against subject, From and the body preview over the
|
|
36
|
+
* whole collection.
|
|
37
|
+
*/
|
|
28
38
|
query?: string;
|
|
29
|
-
/** Page size, set only by the single-page text search. */
|
|
30
|
-
limit?: number;
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
// `starred` and `order` are the view itself, not a criterion, so they are set
|
|
@@ -101,37 +109,6 @@ export function useStarredThreads(
|
|
|
101
109
|
};
|
|
102
110
|
}
|
|
103
111
|
|
|
104
|
-
/**
|
|
105
|
-
* The server's own text match over the whole starred collection, as one page.
|
|
106
|
-
*
|
|
107
|
-
* The free-text filter has two halves that no single request covers: the server
|
|
108
|
-
* matches subject and From over every starred message, and the client can also
|
|
109
|
-
* match a snippet, but only on rows it has already loaded. The server's set is
|
|
110
|
-
* the filter — it is the half that sees the mail below the newest page — and
|
|
111
|
-
* the snippet pass complements it (#308). Same shape as the daily brief's
|
|
112
|
-
* unscoped search, which merges the same two halves.
|
|
113
|
-
*/
|
|
114
|
-
export function useStarredTextSearch(
|
|
115
|
-
criteria: StarredCriteria,
|
|
116
|
-
limit: number,
|
|
117
|
-
): RemitImapThreadMessageResponse[] {
|
|
118
|
-
const text = criteria.query ?? "";
|
|
119
|
-
const query = { ...starredQuery(criteria), limit };
|
|
120
|
-
const { data } = useQuery({
|
|
121
|
-
queryKey: unifiedThreadOperationsListAllThreadsQueryKey({ query }),
|
|
122
|
-
queryFn: async () => {
|
|
123
|
-
const { data: page } = await unifiedThreadOperationsListAllThreads({
|
|
124
|
-
query,
|
|
125
|
-
throwOnError: true,
|
|
126
|
-
});
|
|
127
|
-
return page;
|
|
128
|
-
},
|
|
129
|
-
enabled: text.length > 0,
|
|
130
|
-
staleTime: 30_000,
|
|
131
|
-
});
|
|
132
|
-
return useMemo(() => data?.items ?? [], [data]);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
112
|
/**
|
|
136
113
|
* How many of the matching starred messages are unread, counted by the server.
|
|
137
114
|
*
|
|
@@ -21,8 +21,13 @@ const tokensOf = (query: string): SearchToken[] =>
|
|
|
21
21
|
mailboxesByName: new Map([["sent", "mb2"]]),
|
|
22
22
|
}).tokens;
|
|
23
23
|
|
|
24
|
-
const criteriaOf = (
|
|
25
|
-
|
|
24
|
+
const criteriaOf = (
|
|
25
|
+
query: string,
|
|
26
|
+
attributes: string[] = [],
|
|
27
|
+
accountId?: string,
|
|
28
|
+
) =>
|
|
29
|
+
briefCriteria("all", new Set(attributes), tokensOf(query), accountId)
|
|
30
|
+
.criteria;
|
|
26
31
|
|
|
27
32
|
const residualTypes = (query: string, attributes: string[] = []) =>
|
|
28
33
|
briefCriteria("all", new Set(attributes), tokensOf(query)).residual.map(
|
|
@@ -30,13 +35,16 @@ const residualTypes = (query: string, attributes: string[] = []) =>
|
|
|
30
35
|
);
|
|
31
36
|
|
|
32
37
|
describe("briefCriteria", () => {
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
// The brief renders no muted sender's mail, so every request it makes says
|
|
39
|
+
// so — the alternative is a header counting mail the list drops (#1137).
|
|
40
|
+
test("an unnarrowed brief still refuses muted senders", () => {
|
|
41
|
+
assert.deepEqual(criteriaOf(""), { muted: false });
|
|
35
42
|
assert.deepEqual(residualTypes(""), []);
|
|
36
43
|
});
|
|
37
44
|
|
|
38
45
|
test("the chips travel as parameters", () => {
|
|
39
46
|
assert.deepEqual(criteriaOf("", ["unread", "attachment"]), {
|
|
47
|
+
muted: false,
|
|
40
48
|
unread: true,
|
|
41
49
|
attachments: true,
|
|
42
50
|
});
|
|
@@ -44,6 +52,7 @@ describe("briefCriteria", () => {
|
|
|
44
52
|
|
|
45
53
|
test("a typed token asks for exactly what its chip asks for", () => {
|
|
46
54
|
assert.deepEqual(criteriaOf("is:unread has:attachment"), {
|
|
55
|
+
muted: false,
|
|
47
56
|
unread: true,
|
|
48
57
|
attachments: true,
|
|
49
58
|
});
|
|
@@ -51,20 +60,36 @@ describe("briefCriteria", () => {
|
|
|
51
60
|
});
|
|
52
61
|
|
|
53
62
|
test("is:read narrows the request rather than the rows", () => {
|
|
54
|
-
assert.deepEqual(criteriaOf("is:read"), { unread: false });
|
|
63
|
+
assert.deepEqual(criteriaOf("is:read"), { muted: false, unread: false });
|
|
55
64
|
assert.deepEqual(residualTypes("is:read"), []);
|
|
56
65
|
});
|
|
57
66
|
|
|
58
67
|
test("is:starred travels too", () => {
|
|
59
|
-
assert.deepEqual(criteriaOf("is:starred"), { starred: true });
|
|
68
|
+
assert.deepEqual(criteriaOf("is:starred"), { muted: false, starred: true });
|
|
60
69
|
assert.deepEqual(residualTypes("is:starred"), []);
|
|
61
70
|
});
|
|
62
71
|
|
|
72
|
+
// #1136: the pill used to narrow the rows after they arrived, so a count
|
|
73
|
+
// taken over every account was not the size of the list showing one.
|
|
74
|
+
test("the account pill travels as a parameter", () => {
|
|
75
|
+
assert.deepEqual(criteriaOf("", [], "acc_1"), {
|
|
76
|
+
muted: false,
|
|
77
|
+
accountId: "acc_1",
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("the cross-account brief names no account", () => {
|
|
82
|
+
assert.equal(Object.hasOwn(criteriaOf(""), "accountId"), false);
|
|
83
|
+
});
|
|
84
|
+
|
|
63
85
|
// The section supplies its own category, so the shared criteria must not
|
|
64
86
|
// carry one — two categories on one request is a request for neither.
|
|
65
87
|
test("the category never travels in the shared criteria", () => {
|
|
66
|
-
assert.deepEqual(criteriaOf("category:personal"), {});
|
|
67
|
-
assert.deepEqual(criteriaOf("", ["unread"]), {
|
|
88
|
+
assert.deepEqual(criteriaOf("category:personal"), { muted: false });
|
|
89
|
+
assert.deepEqual(criteriaOf("", ["unread"]), {
|
|
90
|
+
muted: false,
|
|
91
|
+
unread: true,
|
|
92
|
+
});
|
|
68
93
|
assert.equal(
|
|
69
94
|
Object.hasOwn(
|
|
70
95
|
briefCriteria("personal", new Set(), []).criteria,
|
|
@@ -74,13 +99,32 @@ describe("briefCriteria", () => {
|
|
|
74
99
|
);
|
|
75
100
|
});
|
|
76
101
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
assert.deepEqual(
|
|
82
|
-
|
|
83
|
-
|
|
102
|
+
// #1128: the free-text parameter matches subject and From at once, so
|
|
103
|
+
// neither token could be asked for on its own and both were applied over the
|
|
104
|
+
// rows a section had fetched. Each has its own parameter now.
|
|
105
|
+
test("from: and subject: travel as parameters", () => {
|
|
106
|
+
assert.deepEqual(criteriaOf("from:alice"), {
|
|
107
|
+
muted: false,
|
|
108
|
+
from: "alice",
|
|
109
|
+
});
|
|
110
|
+
assert.deepEqual(residualTypes("from:alice"), []);
|
|
111
|
+
assert.deepEqual(criteriaOf("subject:invoice"), {
|
|
112
|
+
muted: false,
|
|
113
|
+
subject: "invoice",
|
|
114
|
+
});
|
|
115
|
+
assert.deepEqual(residualTypes("subject:invoice"), []);
|
|
116
|
+
assert.equal(BRIEF_TOKEN_PARAMS.includes("from"), true);
|
|
117
|
+
assert.equal(BRIEF_TOKEN_PARAMS.includes("subject"), true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// One parameter per field, so a second value cannot be expressed; it drops
|
|
121
|
+
// to the residue rather than being silently lost.
|
|
122
|
+
test("a second from: stays residue", () => {
|
|
123
|
+
assert.deepEqual(criteriaOf("from:alice from:bob"), {
|
|
124
|
+
muted: false,
|
|
125
|
+
from: "alice",
|
|
126
|
+
});
|
|
127
|
+
assert.deepEqual(residualTypes("from:alice from:bob"), ["from"]);
|
|
84
128
|
});
|
|
85
129
|
|
|
86
130
|
test("dates, the mailbox and the account stay residue", () => {
|
|
@@ -92,7 +136,10 @@ describe("briefCriteria", () => {
|
|
|
92
136
|
});
|
|
93
137
|
|
|
94
138
|
test("a chip beats the token that contradicts it", () => {
|
|
95
|
-
assert.deepEqual(criteriaOf("is:read", ["unread"]), {
|
|
139
|
+
assert.deepEqual(criteriaOf("is:read", ["unread"]), {
|
|
140
|
+
muted: false,
|
|
141
|
+
unread: true,
|
|
142
|
+
});
|
|
96
143
|
assert.deepEqual(residualTypes("is:read", ["unread"]), ["isRead"]);
|
|
97
144
|
});
|
|
98
145
|
});
|
|
@@ -102,7 +149,6 @@ describe("briefCountsMatchRows", () => {
|
|
|
102
149
|
briefCountsMatchRows({
|
|
103
150
|
residual: [],
|
|
104
151
|
attributes: new Set<string>(),
|
|
105
|
-
accountScoped: false,
|
|
106
152
|
...over,
|
|
107
153
|
});
|
|
108
154
|
|
|
@@ -112,11 +158,7 @@ describe("briefCountsMatchRows", () => {
|
|
|
112
158
|
});
|
|
113
159
|
|
|
114
160
|
test("a residual token makes the count wider than the list", () => {
|
|
115
|
-
assert.equal(reach({ residual: tokensOf("
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test("the account pills scope the rows and nothing else", () => {
|
|
119
|
-
assert.equal(reach({ accountScoped: true }), false);
|
|
161
|
+
assert.equal(reach({ residual: tokensOf("before:2024-01-01") }), false);
|
|
120
162
|
});
|
|
121
163
|
|
|
122
164
|
test("a chip with no parameter makes the count wider than the list", () => {
|
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
* The brief's category scope is not a criterion here: every section is its own
|
|
5
5
|
* category-scoped request, so the category travels per section rather than in
|
|
6
6
|
* the shared criteria. What is shared is the attribute chips and the tokens
|
|
7
|
-
* typed in the field — `is:unread`, `has:attachment`,
|
|
8
|
-
*
|
|
9
|
-
* over the whole scope instead of as a pass over the
|
|
10
|
-
* (#312).
|
|
7
|
+
* typed in the field — `from:`, `subject:`, `is:unread`, `has:attachment`,
|
|
8
|
+
* `is:starred` — all of them fields on the ThreadMessage row, so all of them
|
|
9
|
+
* travel as query parameters over the whole scope instead of as a pass over the
|
|
10
|
+
* rows already fetched (#312, #1128).
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* The account pill and mute travel with them. Both used to narrow the rows after
|
|
13
|
+
* they arrived, which is what took the number off every section header: a count
|
|
14
|
+
* over every account, muted senders included, is not the size of a list showing
|
|
15
|
+
* one account without them (#1136, #1137).
|
|
14
16
|
*
|
|
15
17
|
* Two chips have no parameter anywhere — "From contacts" reads `senderTrust`,
|
|
16
18
|
* which is off-row (design D7), and "Today" is a date, which no listing
|
|
@@ -24,11 +26,14 @@ import { inboxFilterParams } from "./inbox-filters.js";
|
|
|
24
26
|
import type { SearchToken } from "./search-tokens.js";
|
|
25
27
|
import {
|
|
26
28
|
type ThreadSearchTokenParamName,
|
|
29
|
+
type ThreadSearchTokenParams,
|
|
27
30
|
threadSearchTokens,
|
|
28
31
|
} from "./thread-search-tokens.js";
|
|
29
32
|
|
|
30
33
|
/** The token parameters `listAllThreads` carries. */
|
|
31
34
|
export const BRIEF_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] = [
|
|
35
|
+
"from",
|
|
36
|
+
"subject",
|
|
32
37
|
"category",
|
|
33
38
|
"unread",
|
|
34
39
|
"starred",
|
|
@@ -53,7 +58,12 @@ export const briefClientOnlyFilters = (
|
|
|
53
58
|
new Set(BRIEF_CLIENT_ONLY_FILTERS.filter((id) => attributes.has(id)));
|
|
54
59
|
|
|
55
60
|
/** Everything a section request narrows by except its own category. */
|
|
56
|
-
export type BriefSectionParams = Omit<
|
|
61
|
+
export type BriefSectionParams = Omit<ThreadSearchTokenParams, "category"> & {
|
|
62
|
+
/** The account the pills narrow to, or absent for the whole config. */
|
|
63
|
+
accountId?: string;
|
|
64
|
+
/** Always `false` here: the brief renders no muted sender's mail. */
|
|
65
|
+
muted?: boolean;
|
|
66
|
+
};
|
|
57
67
|
|
|
58
68
|
export interface BriefCriteria {
|
|
59
69
|
/** What every section request carries, chips and carried tokens together. */
|
|
@@ -66,19 +76,29 @@ export const briefCriteria = (
|
|
|
66
76
|
category: string,
|
|
67
77
|
attributes: ReadonlySet<string>,
|
|
68
78
|
tokens: readonly SearchToken[],
|
|
79
|
+
accountId?: string,
|
|
69
80
|
): BriefCriteria => {
|
|
70
|
-
const chipParams = inboxFilterParams({
|
|
81
|
+
const chipParams: InboxFilterParams = inboxFilterParams({
|
|
82
|
+
category,
|
|
83
|
+
attributes,
|
|
84
|
+
});
|
|
71
85
|
const { params, residual } = threadSearchTokens(
|
|
72
86
|
tokens,
|
|
73
87
|
chipParams,
|
|
74
88
|
BRIEF_TOKEN_PARAMS,
|
|
75
89
|
);
|
|
76
90
|
const request = { ...params, ...chipParams };
|
|
77
|
-
|
|
91
|
+
// Mute is not a control the reader sets: the brief is the view that hides
|
|
92
|
+
// muted senders, so every request it makes says so and every count it shows
|
|
93
|
+
// is a count of what it renders.
|
|
94
|
+
const criteria: BriefSectionParams = { muted: false };
|
|
95
|
+
if (request.from !== undefined) criteria.from = request.from;
|
|
96
|
+
if (request.subject !== undefined) criteria.subject = request.subject;
|
|
78
97
|
if (request.unread !== undefined) criteria.unread = request.unread;
|
|
79
98
|
if (request.starred !== undefined) criteria.starred = request.starred;
|
|
80
99
|
if (request.attachments !== undefined)
|
|
81
100
|
criteria.attachments = request.attachments;
|
|
101
|
+
if (accountId !== undefined) criteria.accountId = accountId;
|
|
82
102
|
return { criteria, residual };
|
|
83
103
|
};
|
|
84
104
|
|
|
@@ -87,24 +107,23 @@ export interface BriefCountReach {
|
|
|
87
107
|
residual: readonly SearchToken[];
|
|
88
108
|
/** Attribute chips currently on. */
|
|
89
109
|
attributes: ReadonlySet<string>;
|
|
90
|
-
/** True while the reader has narrowed the brief to one account. */
|
|
91
|
-
accountScoped: boolean;
|
|
92
110
|
}
|
|
93
111
|
|
|
94
112
|
/**
|
|
95
113
|
* Whether a section's server count is a count of what the section shows.
|
|
96
114
|
*
|
|
97
115
|
* The count answers the request. Anything narrowing the rows after they arrive —
|
|
98
|
-
* a residual token, a chip with no parameter
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
116
|
+
* a residual token, or a chip with no parameter — makes the count the size of a
|
|
117
|
+
* wider set than the list. A wider number rendered as the section's size is a
|
|
118
|
+
* worse reading than no number, so the caller asks for none.
|
|
119
|
+
*
|
|
120
|
+
* The account pills are not among them any more, and neither is mute: both are
|
|
121
|
+
* parameters the request carries, so the count narrows exactly as the list does
|
|
122
|
+
* (#1136, #1137).
|
|
102
123
|
*/
|
|
103
124
|
export const briefCountsMatchRows = ({
|
|
104
125
|
residual,
|
|
105
126
|
attributes,
|
|
106
|
-
accountScoped,
|
|
107
127
|
}: BriefCountReach): boolean =>
|
|
108
128
|
residual.length === 0 &&
|
|
109
|
-
!accountScoped &&
|
|
110
129
|
!BRIEF_CLIENT_ONLY_FILTERS.some((id) => attributes.has(id));
|