@remit/web-client 0.0.249 → 0.0.251
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 +244 -100
- package/src/components/mail/brief-server-sections.render.test.ts +655 -0
- package/src/hooks/calendar/agenda-window.ts +2 -15
- package/src/hooks/useBriefSections.ts +310 -0
- package/src/lib/brief-criteria.test.ts +126 -0
- package/src/lib/brief-criteria.ts +98 -0
- package/src/lib/brief.test.ts +215 -148
- package/src/lib/brief.ts +116 -53
- package/src/lib/drafts.ts +1 -1
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daily brief's rows: one `listAllThreads` request per section it shows,
|
|
3
|
+
* scoped to that section's category, plus one count of the whole category.
|
|
4
|
+
*
|
|
5
|
+
* The brief used to be a single non-paginated page of the newest 50 unified
|
|
6
|
+
* rows, and every section was "the Marketing rows among those 50" presented as
|
|
7
|
+
* Marketing. On a mailbox whose Marketing mail is all older than that window the
|
|
8
|
+
* section rendered empty above a header reading zero (#312). A category-scoped
|
|
9
|
+
* request answers over the whole scope, so a section holds that category's
|
|
10
|
+
* newest mail however far down the unified order it sits.
|
|
11
|
+
*
|
|
12
|
+
* The brief does not paginate as a whole; it paginates by section. Each section
|
|
13
|
+
* takes its newest {@link SECTION_ROW_CAP} rows and states its real size, and
|
|
14
|
+
* the way to the rest is the brief's own filtered list for that category — no
|
|
15
|
+
* cursor here, and no request that walks a mailbox.
|
|
16
|
+
*
|
|
17
|
+
* The counts are the second read. One per displayed section, keyed on the
|
|
18
|
+
* criteria alone so no page, cursor or expansion can trigger another, and held
|
|
19
|
+
* for a minute. The criteria are the caller's committed state — the query as it
|
|
20
|
+
* was submitted, and the chips as that query and the panel leave them — so
|
|
21
|
+
* nothing here is on a keystroke path.
|
|
22
|
+
*
|
|
23
|
+
* Each section loads and fails on its own. Seven requests are seven answers, and
|
|
24
|
+
* one category's failed request is not a reason to blank the six that came back:
|
|
25
|
+
* a section carries its own pending and error state, and the brief renders what
|
|
26
|
+
* it has. What reaches that state is a transport failure — a request that never
|
|
27
|
+
* got an answer. A 5xx is the API breaking rather than this section failing, and
|
|
28
|
+
* escalates globally on the fail-fast contract (`shouldEscalate`, #1059); there
|
|
29
|
+
* is no `meta` opt-out here and there must not be one.
|
|
30
|
+
*
|
|
31
|
+
* A refetch under the same predicate keeps the rows already on screen. Under a
|
|
32
|
+
* different one it does not — the previous chip's mail under the new chip is the
|
|
33
|
+
* defect `sameInboxFilter` exists to stop — so only a change of free text holds
|
|
34
|
+
* the previous answer while the next one is in flight.
|
|
35
|
+
*
|
|
36
|
+
* Sections are the unsearched brief only. Under a query the brief is one list in
|
|
37
|
+
* one order — see `useBriefSearchRows`.
|
|
38
|
+
*/
|
|
39
|
+
import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
40
|
+
import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
|
|
41
|
+
import type {
|
|
42
|
+
RemitImapMessageCategory,
|
|
43
|
+
RemitImapThreadMessageResponse,
|
|
44
|
+
RemitImapThreadSearchResponse,
|
|
45
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
46
|
+
import type { ResultCount } from "@remit/ui";
|
|
47
|
+
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
48
|
+
import { useCallback, useMemo } from "react";
|
|
49
|
+
import type { BriefSectionParams } from "@/lib/brief-criteria";
|
|
50
|
+
import { type InboxFilterParams, sameInboxFilter } from "@/lib/inbox-filters";
|
|
51
|
+
import { toResultCount } from "@/lib/result-count";
|
|
52
|
+
|
|
53
|
+
/** A minute: a brief reopened straight away costs nothing. */
|
|
54
|
+
const BRIEF_STALE_TIME_MS = 60_000;
|
|
55
|
+
|
|
56
|
+
/** Half a minute: a query being refined re-asks sooner than the brief does. */
|
|
57
|
+
const SEARCH_STALE_TIME_MS = 30_000;
|
|
58
|
+
|
|
59
|
+
/** No number at all, which is what a section shows when nobody counted it. */
|
|
60
|
+
const UNCOUNTED: ResultCount = { kind: "unknown" };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Hold the previous answer while the next one is in flight, but only where the
|
|
64
|
+
* predicate is unchanged. A chip that changes what is being asked for takes its
|
|
65
|
+
* rows with it: the previous chip's mail under the new chip, for one round trip,
|
|
66
|
+
* is what `sameInboxFilter` was written to stop (design D18).
|
|
67
|
+
*/
|
|
68
|
+
const keepUnderSameFilter =
|
|
69
|
+
(filter: InboxFilterParams) =>
|
|
70
|
+
(
|
|
71
|
+
previous: RemitImapThreadSearchResponse | undefined,
|
|
72
|
+
previousQuery: { queryKey: readonly unknown[] } | undefined,
|
|
73
|
+
): RemitImapThreadSearchResponse | undefined =>
|
|
74
|
+
sameInboxFilter(previousQuery?.queryKey, filter) ? previous : undefined;
|
|
75
|
+
|
|
76
|
+
/** The criteria every section request carries, its own category aside. */
|
|
77
|
+
export interface BriefSectionsCriteria extends BriefSectionParams {
|
|
78
|
+
/** Committed free text, matched against subject and From across folders. */
|
|
79
|
+
query?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const sectionRowsQuery = (
|
|
83
|
+
criteria: BriefSectionsCriteria,
|
|
84
|
+
category: RemitImapMessageCategory,
|
|
85
|
+
limit: number,
|
|
86
|
+
) => ({
|
|
87
|
+
...criteria,
|
|
88
|
+
category: [category],
|
|
89
|
+
limit,
|
|
90
|
+
order: "desc" as const,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// No `limit` and no cursor: the count is a property of the criteria, so growing
|
|
94
|
+
// the page cannot change it and cannot ask for it again.
|
|
95
|
+
const sectionCountQuery = (
|
|
96
|
+
criteria: BriefSectionsCriteria,
|
|
97
|
+
category: RemitImapMessageCategory,
|
|
98
|
+
) => ({
|
|
99
|
+
...criteria,
|
|
100
|
+
category: [category],
|
|
101
|
+
order: "desc" as const,
|
|
102
|
+
count: true,
|
|
103
|
+
results: false,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* What a request under these criteria asks for, as `sameInboxFilter` reads it.
|
|
108
|
+
* Free text is deliberately not part of it: a query being refined is the one
|
|
109
|
+
* change that may keep the previous answer on screen.
|
|
110
|
+
*/
|
|
111
|
+
const sectionFilter = (
|
|
112
|
+
criteria: BriefSectionsCriteria,
|
|
113
|
+
category: RemitImapMessageCategory,
|
|
114
|
+
): InboxFilterParams => ({
|
|
115
|
+
category: [category],
|
|
116
|
+
...(criteria.unread !== undefined ? { unread: criteria.unread } : {}),
|
|
117
|
+
...(criteria.starred !== undefined ? { starred: criteria.starred } : {}),
|
|
118
|
+
...(criteria.attachments !== undefined
|
|
119
|
+
? { attachments: criteria.attachments }
|
|
120
|
+
: {}),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export interface BriefSectionRows {
|
|
124
|
+
category: RemitImapMessageCategory;
|
|
125
|
+
rows: RemitImapThreadMessageResponse[];
|
|
126
|
+
total: ResultCount;
|
|
127
|
+
/** The request came back full, so the category holds more than these rows. */
|
|
128
|
+
atCap: boolean;
|
|
129
|
+
/** Nothing has arrived for this section yet. */
|
|
130
|
+
loading: boolean;
|
|
131
|
+
/** This section's own request failed; the others are unaffected. */
|
|
132
|
+
failed: boolean;
|
|
133
|
+
/** Ask this one section again. */
|
|
134
|
+
retry: () => void;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface UseBriefSectionsOptions {
|
|
138
|
+
/** The categories the brief is showing, in display order. */
|
|
139
|
+
categories: readonly RemitImapMessageCategory[];
|
|
140
|
+
criteria: BriefSectionsCriteria;
|
|
141
|
+
/** Rows per section. */
|
|
142
|
+
limit: number;
|
|
143
|
+
/**
|
|
144
|
+
* Whether the request saw every criterion on screen. False, no count is asked
|
|
145
|
+
* for and every section reports no number — see `briefCountsMatchRows`.
|
|
146
|
+
*/
|
|
147
|
+
counted: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface UseBriefSections {
|
|
151
|
+
sections: BriefSectionRows[];
|
|
152
|
+
/** Nothing has arrived for any section — the brief has nothing to render. */
|
|
153
|
+
isLoading: boolean;
|
|
154
|
+
/** Every section failed. One failing section is the section's own state. */
|
|
155
|
+
isError: boolean;
|
|
156
|
+
refetch: () => void;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function useBriefSections({
|
|
160
|
+
categories,
|
|
161
|
+
criteria,
|
|
162
|
+
limit,
|
|
163
|
+
counted,
|
|
164
|
+
}: UseBriefSectionsOptions): UseBriefSections {
|
|
165
|
+
const rowQueries = useQueries({
|
|
166
|
+
queries: categories.map((category) => {
|
|
167
|
+
const query = sectionRowsQuery(criteria, category, limit);
|
|
168
|
+
const filter = sectionFilter(criteria, category);
|
|
169
|
+
return {
|
|
170
|
+
queryKey: unifiedThreadOperationsListAllThreadsQueryKey({ query }),
|
|
171
|
+
queryFn: async () => {
|
|
172
|
+
const { data } = await unifiedThreadOperationsListAllThreads({
|
|
173
|
+
query,
|
|
174
|
+
throwOnError: true,
|
|
175
|
+
});
|
|
176
|
+
return data;
|
|
177
|
+
},
|
|
178
|
+
placeholderData: keepUnderSameFilter(filter),
|
|
179
|
+
staleTime: BRIEF_STALE_TIME_MS,
|
|
180
|
+
};
|
|
181
|
+
}),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const countQueries = useQueries({
|
|
185
|
+
queries: categories.map((category) => {
|
|
186
|
+
const query = sectionCountQuery(criteria, category);
|
|
187
|
+
const filter = sectionFilter(criteria, category);
|
|
188
|
+
return {
|
|
189
|
+
queryKey: unifiedThreadOperationsListAllThreadsQueryKey({ query }),
|
|
190
|
+
queryFn: async () => {
|
|
191
|
+
const { data } = await unifiedThreadOperationsListAllThreads({
|
|
192
|
+
query,
|
|
193
|
+
throwOnError: true,
|
|
194
|
+
});
|
|
195
|
+
return data;
|
|
196
|
+
},
|
|
197
|
+
enabled: counted,
|
|
198
|
+
placeholderData: keepUnderSameFilter(filter),
|
|
199
|
+
staleTime: BRIEF_STALE_TIME_MS,
|
|
200
|
+
};
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const sections = useMemo<BriefSectionRows[]>(
|
|
205
|
+
() =>
|
|
206
|
+
categories.map((category, index) => {
|
|
207
|
+
const rows = rowQueries[index];
|
|
208
|
+
const count = countQueries[index];
|
|
209
|
+
const items = rows?.data?.items ?? [];
|
|
210
|
+
return {
|
|
211
|
+
category,
|
|
212
|
+
rows: items,
|
|
213
|
+
// Read off the page rather than the rendered rows: muting and the
|
|
214
|
+
// thread collapse both shrink what is rendered, and neither means the
|
|
215
|
+
// category holds no more.
|
|
216
|
+
atCap: items.length >= limit,
|
|
217
|
+
// `enabled` gates the request, not the cache: a brief that asks for
|
|
218
|
+
// no count must not render the one an earlier, narrower brief left
|
|
219
|
+
// behind.
|
|
220
|
+
total: counted ? toResultCount(count?.data?.count) : UNCOUNTED,
|
|
221
|
+
// Pending, not fetching: a section holding the previous query's rows
|
|
222
|
+
// has something to render, and replacing it with a skeleton takes the
|
|
223
|
+
// brief off screen every time the search field is cleared.
|
|
224
|
+
loading: rows?.isPending ?? true,
|
|
225
|
+
failed: rows?.isError ?? false,
|
|
226
|
+
retry: () => {
|
|
227
|
+
rows?.refetch();
|
|
228
|
+
// `refetch` ignores `enabled`, so an uncounted brief would fetch a
|
|
229
|
+
// count it has already decided not to render.
|
|
230
|
+
if (counted) count?.refetch();
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}),
|
|
234
|
+
[categories, rowQueries, countQueries, counted, limit],
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const refetch = useCallback(() => {
|
|
238
|
+
for (const query of rowQueries) query.refetch();
|
|
239
|
+
for (const query of countQueries) query.refetch();
|
|
240
|
+
}, [rowQueries, countQueries]);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
sections,
|
|
244
|
+
// The whole-body states are the ones where there is genuinely nothing to
|
|
245
|
+
// show. A section that is still loading, or that failed on its own, says so
|
|
246
|
+
// in its own place while the rest of the brief stands.
|
|
247
|
+
isLoading:
|
|
248
|
+
rowQueries.length > 0 && rowQueries.every((query) => query.isPending),
|
|
249
|
+
isError:
|
|
250
|
+
rowQueries.length > 0 && rowQueries.every((query) => query.isError),
|
|
251
|
+
refetch,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** What a searched brief holds before the reader is told to narrow it. */
|
|
256
|
+
export interface BriefSearchCriteria extends BriefSectionsCriteria {
|
|
257
|
+
/** A category the chips or a `category:` term narrowed the search to. */
|
|
258
|
+
category?: RemitImapMessageCategory[];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface UseBriefSearchRows {
|
|
262
|
+
/** Every match the request returned, in the order the server returned them. */
|
|
263
|
+
rows: RemitImapThreadMessageResponse[];
|
|
264
|
+
isLoading: boolean;
|
|
265
|
+
isError: boolean;
|
|
266
|
+
refetch: () => void;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The brief under a search: one request, one globally newest-first list.
|
|
271
|
+
*
|
|
272
|
+
* Not seven category queries stitched back together. A match's category must not
|
|
273
|
+
* lift it above a newer match, and section headers between the rows would do
|
|
274
|
+
* exactly that — an old newsletter above a mail that arrived this morning. The
|
|
275
|
+
* server orders the whole match set; the client renders it in that order (#312).
|
|
276
|
+
*
|
|
277
|
+
* Search mode also widens the scope past INBOX to every non-muted folder of every
|
|
278
|
+
* account, so this is the only request that reaches Archive, Sent and Spam.
|
|
279
|
+
*/
|
|
280
|
+
export function useBriefSearchRows(
|
|
281
|
+
criteria: BriefSearchCriteria,
|
|
282
|
+
limit: number,
|
|
283
|
+
enabled: boolean,
|
|
284
|
+
): UseBriefSearchRows {
|
|
285
|
+
const query = { ...criteria, limit, order: "desc" as const };
|
|
286
|
+
const { data, isLoading, isError, refetch } = useQuery({
|
|
287
|
+
queryKey: unifiedThreadOperationsListAllThreadsQueryKey({ query }),
|
|
288
|
+
queryFn: async () => {
|
|
289
|
+
const { data: page } = await unifiedThreadOperationsListAllThreads({
|
|
290
|
+
query,
|
|
291
|
+
throwOnError: true,
|
|
292
|
+
});
|
|
293
|
+
return page;
|
|
294
|
+
},
|
|
295
|
+
enabled,
|
|
296
|
+
staleTime: SEARCH_STALE_TIME_MS,
|
|
297
|
+
});
|
|
298
|
+
const rows = useMemo(
|
|
299
|
+
() => (enabled ? (data?.items ?? []) : []),
|
|
300
|
+
[data, enabled],
|
|
301
|
+
);
|
|
302
|
+
return {
|
|
303
|
+
rows,
|
|
304
|
+
isLoading: enabled && isLoading,
|
|
305
|
+
isError: enabled && isError,
|
|
306
|
+
refetch: useCallback(() => {
|
|
307
|
+
refetch();
|
|
308
|
+
}, [refetch]),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each brief section asks the server for.
|
|
3
|
+
*
|
|
4
|
+
* #312: every section is its own category-scoped request, so the chips and the
|
|
5
|
+
* tokens that ask for the same thing have to reach it as parameters. A criterion
|
|
6
|
+
* evaluated over the ten rows a section fetched would mean "among the newest ten
|
|
7
|
+
* Marketing messages", which is the reading the whole issue removes.
|
|
8
|
+
*/
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { describe, test } from "node:test";
|
|
11
|
+
import {
|
|
12
|
+
BRIEF_TOKEN_PARAMS,
|
|
13
|
+
briefCountsMatchRows,
|
|
14
|
+
briefCriteria,
|
|
15
|
+
} from "./brief-criteria.js";
|
|
16
|
+
import { parseSearchTokens, type SearchToken } from "./search-tokens.js";
|
|
17
|
+
|
|
18
|
+
const tokensOf = (query: string): SearchToken[] =>
|
|
19
|
+
parseSearchTokens(query, {
|
|
20
|
+
accountsByName: new Map([["alice", "acc_1"]]),
|
|
21
|
+
mailboxesByName: new Map([["sent", "mb2"]]),
|
|
22
|
+
}).tokens;
|
|
23
|
+
|
|
24
|
+
const criteriaOf = (query: string, attributes: string[] = []) =>
|
|
25
|
+
briefCriteria("all", new Set(attributes), tokensOf(query)).criteria;
|
|
26
|
+
|
|
27
|
+
const residualTypes = (query: string, attributes: string[] = []) =>
|
|
28
|
+
briefCriteria("all", new Set(attributes), tokensOf(query)).residual.map(
|
|
29
|
+
(t) => t.type,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
describe("briefCriteria", () => {
|
|
33
|
+
test("an unnarrowed brief asks for nothing in particular", () => {
|
|
34
|
+
assert.deepEqual(criteriaOf(""), {});
|
|
35
|
+
assert.deepEqual(residualTypes(""), []);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the chips travel as parameters", () => {
|
|
39
|
+
assert.deepEqual(criteriaOf("", ["unread", "attachment"]), {
|
|
40
|
+
unread: true,
|
|
41
|
+
attachments: true,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a typed token asks for exactly what its chip asks for", () => {
|
|
46
|
+
assert.deepEqual(criteriaOf("is:unread has:attachment"), {
|
|
47
|
+
unread: true,
|
|
48
|
+
attachments: true,
|
|
49
|
+
});
|
|
50
|
+
assert.deepEqual(residualTypes("is:unread has:attachment"), []);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("is:read narrows the request rather than the rows", () => {
|
|
54
|
+
assert.deepEqual(criteriaOf("is:read"), { unread: false });
|
|
55
|
+
assert.deepEqual(residualTypes("is:read"), []);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("is:starred travels too", () => {
|
|
59
|
+
assert.deepEqual(criteriaOf("is:starred"), { starred: true });
|
|
60
|
+
assert.deepEqual(residualTypes("is:starred"), []);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// The section supplies its own category, so the shared criteria must not
|
|
64
|
+
// carry one — two categories on one request is a request for neither.
|
|
65
|
+
test("the category never travels in the shared criteria", () => {
|
|
66
|
+
assert.deepEqual(criteriaOf("category:personal"), {});
|
|
67
|
+
assert.deepEqual(criteriaOf("", ["unread"]), { unread: true });
|
|
68
|
+
assert.equal(
|
|
69
|
+
Object.hasOwn(
|
|
70
|
+
briefCriteria("personal", new Set(), []).criteria,
|
|
71
|
+
"category",
|
|
72
|
+
),
|
|
73
|
+
false,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// `listAllThreads` has one text parameter matching subject and From at once,
|
|
78
|
+
// so neither token can be asked for on its own and both stay residue.
|
|
79
|
+
test("from: and subject: have no parameter here and come back as residue", () => {
|
|
80
|
+
assert.deepEqual(criteriaOf("from:alice"), {});
|
|
81
|
+
assert.deepEqual(residualTypes("from:alice"), ["from"]);
|
|
82
|
+
assert.deepEqual(residualTypes("subject:invoice"), ["subject"]);
|
|
83
|
+
assert.deepEqual(BRIEF_TOKEN_PARAMS.includes("from"), false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("dates, the mailbox and the account stay residue", () => {
|
|
87
|
+
assert.deepEqual(residualTypes("before:2024-01-01 in:sent account:alice"), [
|
|
88
|
+
"before",
|
|
89
|
+
"in",
|
|
90
|
+
"account",
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a chip beats the token that contradicts it", () => {
|
|
95
|
+
assert.deepEqual(criteriaOf("is:read", ["unread"]), { unread: true });
|
|
96
|
+
assert.deepEqual(residualTypes("is:read", ["unread"]), ["isRead"]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("briefCountsMatchRows", () => {
|
|
101
|
+
const reach = (over: Partial<Parameters<typeof briefCountsMatchRows>[0]>) =>
|
|
102
|
+
briefCountsMatchRows({
|
|
103
|
+
residual: [],
|
|
104
|
+
attributes: new Set<string>(),
|
|
105
|
+
accountScoped: false,
|
|
106
|
+
...over,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a request carrying every criterion on screen counts what it shows", () => {
|
|
110
|
+
assert.equal(reach({}), true);
|
|
111
|
+
assert.equal(reach({ attributes: new Set(["unread"]) }), true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a residual token makes the count wider than the list", () => {
|
|
115
|
+
assert.equal(reach({ residual: tokensOf("from:alice") }), false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("the account pills scope the rows and nothing else", () => {
|
|
119
|
+
assert.equal(reach({ accountScoped: true }), false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a chip with no parameter makes the count wider than the list", () => {
|
|
123
|
+
assert.equal(reach({ attributes: new Set(["contacts"]) }), false);
|
|
124
|
+
assert.equal(reach({ attributes: new Set(["today"]) }), false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each daily-brief section asks `listAllThreads` for, and what is left over.
|
|
3
|
+
*
|
|
4
|
+
* The brief's category scope is not a criterion here: every section is its own
|
|
5
|
+
* category-scoped request, so the category travels per section rather than in
|
|
6
|
+
* the shared criteria. What is shared is the attribute chips and the tokens
|
|
7
|
+
* typed in the field — `is:unread`, `has:attachment`, `is:starred` — all of them
|
|
8
|
+
* columns on the ThreadMessage row, so all of them travel as query parameters
|
|
9
|
+
* over the whole scope instead of as a pass over the rows already fetched
|
|
10
|
+
* (#312).
|
|
11
|
+
*
|
|
12
|
+
* `from:` and `subject:` stay residue: `listAllThreads` has one text parameter
|
|
13
|
+
* that matches both at once, so neither can be asked for on its own.
|
|
14
|
+
*
|
|
15
|
+
* Two chips have no parameter anywhere — "From contacts" reads `senderTrust`,
|
|
16
|
+
* which is off-row (design D7), and "Today" is a date, which no listing
|
|
17
|
+
* endpoint takes. They narrow the rows the kit renders, so while either is on
|
|
18
|
+
* the request is wider than the list and the section totals are not the list's
|
|
19
|
+
* size. `briefCountsMatchRows` is how a caller knows to show no number instead.
|
|
20
|
+
*/
|
|
21
|
+
import type { InboxFilterParams } from "./inbox-filters.js";
|
|
22
|
+
import { inboxFilterParams } from "./inbox-filters.js";
|
|
23
|
+
import type { SearchToken } from "./search-tokens.js";
|
|
24
|
+
import {
|
|
25
|
+
type ThreadSearchTokenParamName,
|
|
26
|
+
threadSearchTokens,
|
|
27
|
+
} from "./thread-search-tokens.js";
|
|
28
|
+
|
|
29
|
+
/** The token parameters `listAllThreads` carries. */
|
|
30
|
+
export const BRIEF_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] = [
|
|
31
|
+
"category",
|
|
32
|
+
"unread",
|
|
33
|
+
"starred",
|
|
34
|
+
"attachments",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** Chips the request cannot express, so the kit applies them over the rows. */
|
|
38
|
+
export const BRIEF_CLIENT_ONLY_FILTERS: readonly string[] = [
|
|
39
|
+
"contacts",
|
|
40
|
+
"today",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Everything a section request narrows by except its own category. */
|
|
44
|
+
export type BriefSectionParams = Omit<InboxFilterParams, "category">;
|
|
45
|
+
|
|
46
|
+
export interface BriefCriteria {
|
|
47
|
+
/** What every section request carries, chips and carried tokens together. */
|
|
48
|
+
criteria: BriefSectionParams;
|
|
49
|
+
/** The tokens no parameter carries; apply these over the returned rows. */
|
|
50
|
+
residual: SearchToken[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const briefCriteria = (
|
|
54
|
+
category: string,
|
|
55
|
+
attributes: ReadonlySet<string>,
|
|
56
|
+
tokens: readonly SearchToken[],
|
|
57
|
+
): BriefCriteria => {
|
|
58
|
+
const chipParams = inboxFilterParams({ category, attributes });
|
|
59
|
+
const { params, residual } = threadSearchTokens(
|
|
60
|
+
tokens,
|
|
61
|
+
chipParams,
|
|
62
|
+
BRIEF_TOKEN_PARAMS,
|
|
63
|
+
);
|
|
64
|
+
const request = { ...params, ...chipParams };
|
|
65
|
+
const criteria: BriefSectionParams = {};
|
|
66
|
+
if (request.unread !== undefined) criteria.unread = request.unread;
|
|
67
|
+
if (request.starred !== undefined) criteria.starred = request.starred;
|
|
68
|
+
if (request.attachments !== undefined)
|
|
69
|
+
criteria.attachments = request.attachments;
|
|
70
|
+
return { criteria, residual };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export interface BriefCountReach {
|
|
74
|
+
/** Tokens the request did not carry, applied over the rows afterwards. */
|
|
75
|
+
residual: readonly SearchToken[];
|
|
76
|
+
/** Attribute chips currently on. */
|
|
77
|
+
attributes: ReadonlySet<string>;
|
|
78
|
+
/** True while the reader has narrowed the brief to one account. */
|
|
79
|
+
accountScoped: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether a section's server count is a count of what the section shows.
|
|
84
|
+
*
|
|
85
|
+
* The count answers the request. Anything narrowing the rows after they arrive —
|
|
86
|
+
* a residual token, a chip with no parameter, the account pills, which no
|
|
87
|
+
* listing endpoint scopes by — makes the count the size of a wider set than the
|
|
88
|
+
* list. A wider number rendered as the section's size is a worse reading than no
|
|
89
|
+
* number, so the caller asks for none.
|
|
90
|
+
*/
|
|
91
|
+
export const briefCountsMatchRows = ({
|
|
92
|
+
residual,
|
|
93
|
+
attributes,
|
|
94
|
+
accountScoped,
|
|
95
|
+
}: BriefCountReach): boolean =>
|
|
96
|
+
residual.length === 0 &&
|
|
97
|
+
!accountScoped &&
|
|
98
|
+
!BRIEF_CLIENT_ONLY_FILTERS.some((id) => attributes.has(id));
|