@remit/web-client 0.0.279 → 0.0.281
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/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/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/thread-search-tokens.ts +4 -3
- package/src/test-support/fixtures.ts +1 -0
|
@@ -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));
|
package/src/lib/brief.test.ts
CHANGED
|
@@ -11,11 +11,7 @@ import {
|
|
|
11
11
|
BRIEF_CATEGORIES,
|
|
12
12
|
type BriefCategoryResult,
|
|
13
13
|
briefSections,
|
|
14
|
-
briefSectionTotal,
|
|
15
|
-
excludeMutedSenders,
|
|
16
|
-
matchesBriefSearch,
|
|
17
14
|
matchesSearchTokens,
|
|
18
|
-
mergeSearchRows,
|
|
19
15
|
toThreadRowData,
|
|
20
16
|
} from "./brief.js";
|
|
21
17
|
import type { SearchToken } from "./search-tokens.js";
|
|
@@ -288,157 +284,6 @@ describe("briefSections", () => {
|
|
|
288
284
|
});
|
|
289
285
|
});
|
|
290
286
|
|
|
291
|
-
// issue #301: `Address.flags.muted` is denormalized onto each row as
|
|
292
|
-
// `muted`; the brief excludes those rows before grouping into sections.
|
|
293
|
-
describe("excludeMutedSenders", () => {
|
|
294
|
-
test("drops a row whose sender is muted", () => {
|
|
295
|
-
const kept = threadResponse({ threadMessageId: "keep" });
|
|
296
|
-
const muted = threadResponse({ threadMessageId: "mute-me", muted: true });
|
|
297
|
-
const result = excludeMutedSenders([kept, muted]);
|
|
298
|
-
assert.deepStrictEqual(
|
|
299
|
-
result.map((t) => t.threadMessageId),
|
|
300
|
-
["keep"],
|
|
301
|
-
);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
test("keeps rows whose sender is not muted", () => {
|
|
305
|
-
const rows = [
|
|
306
|
-
threadResponse({ threadMessageId: "a", muted: false }),
|
|
307
|
-
threadResponse({ threadMessageId: "b" }),
|
|
308
|
-
];
|
|
309
|
-
assert.strictEqual(excludeMutedSenders(rows).length, 2);
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
test("returns an empty array when every sender is muted", () => {
|
|
313
|
-
const rows = [
|
|
314
|
-
threadResponse({ threadMessageId: "a", muted: true }),
|
|
315
|
-
threadResponse({ threadMessageId: "b", muted: true }),
|
|
316
|
-
];
|
|
317
|
-
assert.deepStrictEqual(excludeMutedSenders(rows), []);
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
test("a muted sender's message is excluded from every section, not folded into uncategorized", () => {
|
|
321
|
-
const rows = [
|
|
322
|
-
threadResponse({
|
|
323
|
-
threadMessageId: "muted-personal",
|
|
324
|
-
messageId: "muted-personal",
|
|
325
|
-
category: "personal",
|
|
326
|
-
muted: true,
|
|
327
|
-
}),
|
|
328
|
-
threadResponse({
|
|
329
|
-
threadMessageId: "kept-personal",
|
|
330
|
-
messageId: "kept-personal",
|
|
331
|
-
category: "personal",
|
|
332
|
-
}),
|
|
333
|
-
];
|
|
334
|
-
const kept = excludeMutedSenders(rows).map(toThreadRowData);
|
|
335
|
-
const sections = briefSections([
|
|
336
|
-
{
|
|
337
|
-
category: "personal",
|
|
338
|
-
rows: kept,
|
|
339
|
-
total: { kind: "exact", value: kept.length },
|
|
340
|
-
atCap: false,
|
|
341
|
-
loading: false,
|
|
342
|
-
failed: false,
|
|
343
|
-
},
|
|
344
|
-
]);
|
|
345
|
-
const allIds = sections.flatMap((s) => s.threads.map((t) => t.id));
|
|
346
|
-
assert.deepStrictEqual(allIds, ["kept-personal"]);
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
test("muting every candidate row leaves no sections (brief empty state)", () => {
|
|
350
|
-
const rows = [
|
|
351
|
-
threadResponse({
|
|
352
|
-
threadMessageId: "a",
|
|
353
|
-
messageId: "a",
|
|
354
|
-
category: "personal",
|
|
355
|
-
muted: true,
|
|
356
|
-
}),
|
|
357
|
-
threadResponse({
|
|
358
|
-
threadMessageId: "b",
|
|
359
|
-
messageId: "b",
|
|
360
|
-
category: "newsletter",
|
|
361
|
-
muted: true,
|
|
362
|
-
}),
|
|
363
|
-
];
|
|
364
|
-
const kept = excludeMutedSenders(rows).map(toThreadRowData);
|
|
365
|
-
const sections = briefSections([
|
|
366
|
-
{
|
|
367
|
-
category: "personal",
|
|
368
|
-
rows: kept,
|
|
369
|
-
total: { kind: "exact", value: 0 },
|
|
370
|
-
atCap: false,
|
|
371
|
-
loading: false,
|
|
372
|
-
failed: false,
|
|
373
|
-
},
|
|
374
|
-
]);
|
|
375
|
-
assert.deepStrictEqual(sections, []);
|
|
376
|
-
});
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
// The count is the server's, taken over a scope that includes senders the reader
|
|
380
|
-
// muted — `listAllThreads` has no `muted` parameter. The list drops their mail,
|
|
381
|
-
// so the number would overstate what is on screen.
|
|
382
|
-
describe("briefSectionTotal", () => {
|
|
383
|
-
const counted: ResultCount = { kind: "exact", value: 4753 };
|
|
384
|
-
|
|
385
|
-
test("keeps the count when no sender in the rows is muted", () => {
|
|
386
|
-
assert.deepStrictEqual(
|
|
387
|
-
briefSectionTotal(counted, [
|
|
388
|
-
threadResponse({ threadMessageId: "a" }),
|
|
389
|
-
threadResponse({ threadMessageId: "b", muted: false }),
|
|
390
|
-
]),
|
|
391
|
-
counted,
|
|
392
|
-
);
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
test("withholds the count when the rows show a muted sender", () => {
|
|
396
|
-
assert.deepStrictEqual(
|
|
397
|
-
briefSectionTotal(counted, [
|
|
398
|
-
threadResponse({ threadMessageId: "a" }),
|
|
399
|
-
threadResponse({ threadMessageId: "b", muted: true }),
|
|
400
|
-
]),
|
|
401
|
-
{ kind: "unknown" },
|
|
402
|
-
);
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
test("a count nobody took stays absent", () => {
|
|
406
|
-
assert.deepStrictEqual(briefSectionTotal({ kind: "unknown" }, []), {
|
|
407
|
-
kind: "unknown",
|
|
408
|
-
});
|
|
409
|
-
});
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
describe("matchesBriefSearch", () => {
|
|
413
|
-
const r = row({
|
|
414
|
-
id: "1",
|
|
415
|
-
fromName: "Alice Tan",
|
|
416
|
-
fromEmail: "alice@example.com",
|
|
417
|
-
subject: "Q3 roadmap",
|
|
418
|
-
snippet: "See the attached deck",
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
test("matches on fromName (case-insensitive)", () => {
|
|
422
|
-
assert.strictEqual(matchesBriefSearch(r, "alice"), true);
|
|
423
|
-
});
|
|
424
|
-
|
|
425
|
-
test("matches on fromEmail", () => {
|
|
426
|
-
assert.strictEqual(matchesBriefSearch(r, "alice@example"), true);
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
test("matches on subject", () => {
|
|
430
|
-
assert.strictEqual(matchesBriefSearch(r, "roadmap"), true);
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
test("matches on snippet", () => {
|
|
434
|
-
assert.strictEqual(matchesBriefSearch(r, "deck"), true);
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
test("returns false when query matches nothing", () => {
|
|
438
|
-
assert.strictEqual(matchesBriefSearch(r, "zyxwvuts"), false);
|
|
439
|
-
});
|
|
440
|
-
});
|
|
441
|
-
|
|
442
287
|
describe("matchesSearchTokens", () => {
|
|
443
288
|
const from = (value: string): SearchToken => ({
|
|
444
289
|
type: "from",
|
|
@@ -646,82 +491,3 @@ describe("matchesSearchTokens", () => {
|
|
|
646
491
|
);
|
|
647
492
|
});
|
|
648
493
|
});
|
|
649
|
-
|
|
650
|
-
// #49: a listing complete under its own criteria still cannot see a snippet
|
|
651
|
-
// match, and the server's cross-folder search cannot see one either. The two
|
|
652
|
-
// halves are merged. The brief no longer uses this — its sections are pages, and
|
|
653
|
-
// merging a page with a search window orders two truncated lists (#312).
|
|
654
|
-
describe("mergeSearchRows", () => {
|
|
655
|
-
test("keeps rows the server found in other folders", () => {
|
|
656
|
-
const merged = mergeSearchRows(
|
|
657
|
-
[row({ id: "inbox-hit", sentDate: 300 })],
|
|
658
|
-
[
|
|
659
|
-
row({ id: "archive-hit", sentDate: 200 }),
|
|
660
|
-
row({ id: "spam-hit", sentDate: 100 }),
|
|
661
|
-
],
|
|
662
|
-
);
|
|
663
|
-
|
|
664
|
-
assert.deepEqual(
|
|
665
|
-
merged.map((r) => r.id),
|
|
666
|
-
["inbox-hit", "archive-hit", "spam-hit"],
|
|
667
|
-
);
|
|
668
|
-
});
|
|
669
|
-
|
|
670
|
-
test("keeps a snippet-only match the server cannot see", () => {
|
|
671
|
-
const merged = mergeSearchRows(
|
|
672
|
-
[row({ id: "snippet-only", sentDate: 100 })],
|
|
673
|
-
[],
|
|
674
|
-
);
|
|
675
|
-
|
|
676
|
-
assert.deepEqual(
|
|
677
|
-
merged.map((r) => r.id),
|
|
678
|
-
["snippet-only"],
|
|
679
|
-
);
|
|
680
|
-
});
|
|
681
|
-
|
|
682
|
-
test("the two sources overlap on INBOX, so rows are deduped", () => {
|
|
683
|
-
const merged = mergeSearchRows(
|
|
684
|
-
[row({ id: "shared", sentDate: 200, subject: "from the brief" })],
|
|
685
|
-
[
|
|
686
|
-
row({ id: "shared", sentDate: 200, subject: "from the search" }),
|
|
687
|
-
row({ id: "archive-hit", sentDate: 100 }),
|
|
688
|
-
],
|
|
689
|
-
);
|
|
690
|
-
|
|
691
|
-
assert.deepEqual(
|
|
692
|
-
merged.map((r) => r.id),
|
|
693
|
-
["shared", "archive-hit"],
|
|
694
|
-
);
|
|
695
|
-
assert.equal(merged[0].subject, "from the brief");
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
test("the union reads newest first, interleaving both sources", () => {
|
|
699
|
-
const merged = mergeSearchRows(
|
|
700
|
-
[
|
|
701
|
-
row({ id: "brief-new", sentDate: 400 }),
|
|
702
|
-
row({ id: "brief-old", sentDate: 200 }),
|
|
703
|
-
],
|
|
704
|
-
[
|
|
705
|
-
row({ id: "search-mid", sentDate: 300 }),
|
|
706
|
-
row({ id: "search-oldest", sentDate: 100 }),
|
|
707
|
-
],
|
|
708
|
-
);
|
|
709
|
-
|
|
710
|
-
assert.deepEqual(
|
|
711
|
-
merged.map((r) => r.id),
|
|
712
|
-
["brief-new", "search-mid", "brief-old", "search-oldest"],
|
|
713
|
-
);
|
|
714
|
-
});
|
|
715
|
-
|
|
716
|
-
test("a row without a sentDate sorts last", () => {
|
|
717
|
-
const merged = mergeSearchRows(
|
|
718
|
-
[row({ id: "undated" })],
|
|
719
|
-
[row({ id: "dated", sentDate: 100 })],
|
|
720
|
-
);
|
|
721
|
-
|
|
722
|
-
assert.deepEqual(
|
|
723
|
-
merged.map((r) => r.id),
|
|
724
|
-
["dated", "undated"],
|
|
725
|
-
);
|
|
726
|
-
});
|
|
727
|
-
});
|