@remit/web-client 0.0.248 → 0.0.250
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/MailboxPane.tsx +28 -4
- package/src/components/mail/MessageList.tsx +10 -17
- package/src/components/mail/brief-server-sections.render.test.ts +655 -0
- package/src/hooks/useBriefSections.ts +310 -0
- package/src/hooks/useResultCount.render.test.ts +161 -0
- package/src/hooks/useResultCount.ts +68 -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
- package/src/lib/result-count.test.ts +97 -0
- package/src/lib/result-count.ts +57 -0
package/src/lib/brief.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Daily brief
|
|
2
|
+
* Daily brief section assembly.
|
|
3
3
|
*
|
|
4
|
-
* Pure function: takes
|
|
4
|
+
* Pure function: takes one complete per-category server response and returns one
|
|
5
5
|
* section per message category, in a fixed display order:
|
|
6
6
|
*
|
|
7
7
|
* 1. Personal
|
|
@@ -12,11 +12,15 @@
|
|
|
12
12
|
* 6. Automated
|
|
13
13
|
* 7. Unclassified
|
|
14
14
|
*
|
|
15
|
-
* Each
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* Each section is its own server query, scoped to its category and carrying
|
|
16
|
+
* every criterion on screen the request can express, plus one count for the
|
|
17
|
+
* whole category. Nothing here groups a page: a category whose mail is entirely
|
|
18
|
+
* older than the newest unified page still has a section, and the section's
|
|
19
|
+
* header states the category's real size rather than how many of a shared window
|
|
20
|
+
* happened to fall in it (#312). `uncategorized` is its own section rather than
|
|
21
|
+
* being folded into Personal — unclassified mail is missing work, not a decision
|
|
22
|
+
* (issue #45). Starred mail is not a section — the star is a per-row marker, so
|
|
23
|
+
* a starred message stays in its category.
|
|
20
24
|
*
|
|
21
25
|
* Sender trust (vip/wellknown) no longer sections the brief — the signal is
|
|
22
26
|
* still carried on each row (see `toThreadRowData`) for future use, but it does
|
|
@@ -24,16 +28,17 @@
|
|
|
24
28
|
* a high-volume mailbox read≠handled and unread≠important; unread is a
|
|
25
29
|
* user-selectable filter chip instead.
|
|
26
30
|
*
|
|
27
|
-
* Muted senders and
|
|
28
|
-
* `excludeMutedSenders`, applied by the caller to the raw
|
|
29
|
-
* `toThreadRowData
|
|
30
|
-
* from the From address's flags (RFC 039 Decision 3, issue #301), so no
|
|
31
|
+
* Muted senders and categories the scope holds none of are excluded. Mute
|
|
32
|
+
* filtering happens in `excludeMutedSenders`, applied by the caller to the raw
|
|
33
|
+
* thread rows before `toThreadRowData` — the server denormalizes `muted` onto
|
|
34
|
+
* each row from the From address's flags (RFC 039 Decision 3, issue #301), so no
|
|
31
35
|
* client-side Address lookup is needed.
|
|
32
36
|
*/
|
|
33
37
|
|
|
34
38
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
35
39
|
import { MessageCategory } from "@remit/domain-enums";
|
|
36
40
|
import type {
|
|
41
|
+
ResultCount,
|
|
37
42
|
SenderTrustLevel,
|
|
38
43
|
ThreadCategory,
|
|
39
44
|
ThreadRowData,
|
|
@@ -79,6 +84,26 @@ export function toThreadRowData(
|
|
|
79
84
|
* mail, so callers outside the brief (mailbox listings, search) must not
|
|
80
85
|
* apply this filter.
|
|
81
86
|
*/
|
|
87
|
+
/**
|
|
88
|
+
* The section's count, or no number, once mute is taken into account.
|
|
89
|
+
*
|
|
90
|
+
* Mute is the one criterion the request cannot be asked about: `listAllThreads`
|
|
91
|
+
* has no `muted` parameter, so a scope holding a muted sender is counted with
|
|
92
|
+
* that sender's mail in it while the list renders without it. The header would
|
|
93
|
+
* overstate, and "Show all" would offer rows the brief will not show.
|
|
94
|
+
*
|
|
95
|
+
* Withheld per section rather than for the whole brief — one muted marketer must
|
|
96
|
+
* not take the number off Personal — and only where a mute is visible in the
|
|
97
|
+
* rows on hand. A muted sender below a section's page is not visible, so this
|
|
98
|
+
* suppresses a number known to be wrong rather than certifying the rest.
|
|
99
|
+
*/
|
|
100
|
+
export function briefSectionTotal(
|
|
101
|
+
total: ResultCount,
|
|
102
|
+
rows: RemitImapThreadMessageResponse[],
|
|
103
|
+
): ResultCount {
|
|
104
|
+
return rows.some((row) => row.muted === true) ? { kind: "unknown" } : total;
|
|
105
|
+
}
|
|
106
|
+
|
|
82
107
|
export function excludeMutedSenders(
|
|
83
108
|
threads: RemitImapThreadMessageResponse[],
|
|
84
109
|
): RemitImapThreadMessageResponse[] {
|
|
@@ -86,14 +111,19 @@ export function excludeMutedSenders(
|
|
|
86
111
|
}
|
|
87
112
|
|
|
88
113
|
/**
|
|
89
|
-
* Union of
|
|
90
|
-
* returned, newest first.
|
|
114
|
+
* Union of a complete server-filtered listing with the rows the server's
|
|
115
|
+
* cross-folder text search returned, newest first.
|
|
116
|
+
*
|
|
117
|
+
* Both lists are needed where the listing itself is complete under its criteria
|
|
118
|
+
* — the Flagged collection is, and pages to its end. The server matches subject
|
|
119
|
+
* and From only, so a row whose snippet carries the term is found only by the
|
|
120
|
+
* client-side pass; the server pass is the only one that reaches Archive, Sent,
|
|
121
|
+
* Spam and custom folders. The two overlap, so rows are deduped by id, the first
|
|
122
|
+
* occurrence winning.
|
|
91
123
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* Sent, Spam and custom folders, which the brief list never loads. The two
|
|
96
|
-
* overlap on INBOX, so rows are deduped by id, the first occurrence winning.
|
|
124
|
+
* Not for a paginated prefix. The brief's sections are per-category pages, so
|
|
125
|
+
* merging one with a search window and re-sorting would order two truncated
|
|
126
|
+
* lists against each other and call the result newest-first (#312).
|
|
97
127
|
*
|
|
98
128
|
* Rows without a `sentDate` sort last; the brief's own list is already newest
|
|
99
129
|
* first, so this only has to re-interleave the two sources.
|
|
@@ -114,9 +144,10 @@ export function mergeSearchRows(
|
|
|
114
144
|
|
|
115
145
|
/**
|
|
116
146
|
* Category sections in fixed display order. The `id`/`label` drive the rendered
|
|
117
|
-
* section; `category` is the
|
|
147
|
+
* section; `category` is both the section's own scope and the query parameter
|
|
148
|
+
* the section's request carries.
|
|
118
149
|
*/
|
|
119
|
-
const CATEGORY_SECTIONS: ReadonlyArray<{
|
|
150
|
+
export const CATEGORY_SECTIONS: ReadonlyArray<{
|
|
120
151
|
id: string;
|
|
121
152
|
label: string;
|
|
122
153
|
category: ThreadCategory;
|
|
@@ -142,36 +173,63 @@ const CATEGORY_SECTIONS: ReadonlyArray<{
|
|
|
142
173
|
},
|
|
143
174
|
];
|
|
144
175
|
|
|
176
|
+
/** The categories the brief asks for, in the order it renders them. */
|
|
177
|
+
export const BRIEF_CATEGORIES: readonly ThreadCategory[] =
|
|
178
|
+
CATEGORY_SECTIONS.map((section) => section.category);
|
|
179
|
+
|
|
180
|
+
/** One category's own server answer: its newest rows and how many it holds. */
|
|
181
|
+
export interface BriefCategoryResult {
|
|
182
|
+
category: ThreadCategory;
|
|
183
|
+
/** The newest rows the section's request returned, newest first. */
|
|
184
|
+
rows: ThreadRowData[];
|
|
185
|
+
/** The category's size over the whole scope, or no number at all. */
|
|
186
|
+
total: ResultCount;
|
|
187
|
+
/** The request came back full, so the category holds more than these rows. */
|
|
188
|
+
atCap: boolean;
|
|
189
|
+
/** The request has not answered yet. */
|
|
190
|
+
loading: boolean;
|
|
191
|
+
/** The request failed. This category alone; the others are unaffected. */
|
|
192
|
+
failed: boolean;
|
|
193
|
+
}
|
|
194
|
+
|
|
145
195
|
/**
|
|
146
|
-
*
|
|
147
|
-
* Rows should already be filtered for the selected account chip (if any) before
|
|
148
|
-
* calling this function.
|
|
196
|
+
* The per-category server answers as brief sections, in display order.
|
|
149
197
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
198
|
+
* A regrouping of complete responses, never of a page: each result already is
|
|
199
|
+
* its whole category as far as the request was concerned, so no further page can
|
|
200
|
+
* move a section's membership or its total.
|
|
201
|
+
*
|
|
202
|
+
* A category the scope holds none of has no section — a header stating zero is
|
|
203
|
+
* noise, not information. A category still being fetched, or whose own request
|
|
204
|
+
* failed, keeps its section so the loading and error treatments have somewhere
|
|
205
|
+
* to render, and a category with a real total keeps its section even with no
|
|
206
|
+
* rows left after the chips, so the reader sees which filter emptied it.
|
|
153
207
|
*/
|
|
154
|
-
export function
|
|
155
|
-
|
|
156
|
-
|
|
208
|
+
export function briefSections(
|
|
209
|
+
results: readonly BriefCategoryResult[],
|
|
210
|
+
): ThreadSection[] {
|
|
211
|
+
const byCategory = new Map(
|
|
212
|
+
results.map((result) => [result.category, result] as const),
|
|
157
213
|
);
|
|
158
|
-
|
|
159
|
-
for (const row of rows) {
|
|
160
|
-
const category = row.category ?? MessageCategory.uncategorized;
|
|
161
|
-
const bucket =
|
|
162
|
-
byCategory.get(category) ?? byCategory.get(MessageCategory.uncategorized);
|
|
163
|
-
bucket?.push(row);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
214
|
const sections: ThreadSection[] = [];
|
|
167
215
|
for (const section of CATEGORY_SECTIONS) {
|
|
168
|
-
const
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
216
|
+
const result = byCategory.get(section.category);
|
|
217
|
+
if (!result) continue;
|
|
218
|
+
const holdsMail =
|
|
219
|
+
result.loading ||
|
|
220
|
+
result.failed ||
|
|
221
|
+
result.rows.length > 0 ||
|
|
222
|
+
(result.total.kind === "exact" && result.total.value > 0);
|
|
223
|
+
if (!holdsMail) continue;
|
|
224
|
+
sections.push({
|
|
225
|
+
id: section.id,
|
|
226
|
+
label: section.label,
|
|
227
|
+
threads: result.rows,
|
|
228
|
+
total: result.total,
|
|
229
|
+
atCap: result.atCap,
|
|
230
|
+
loading: result.loading,
|
|
231
|
+
error: result.failed,
|
|
232
|
+
});
|
|
175
233
|
}
|
|
176
234
|
return sections;
|
|
177
235
|
}
|
|
@@ -190,15 +248,20 @@ export function matchesBriefSearch(t: ThreadRowData, query: string): boolean {
|
|
|
190
248
|
}
|
|
191
249
|
|
|
192
250
|
/**
|
|
193
|
-
* Returns true when `t` satisfies every
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
251
|
+
* Returns true when `t` satisfies every token handed to it.
|
|
252
|
+
*
|
|
253
|
+
* The residue applier: callers pass the tokens their request could not carry —
|
|
254
|
+
* `threadSearchTokens` decides which those are by reading the request back — and
|
|
255
|
+
* this narrows the rows that came back by them. A token the request did carry
|
|
256
|
+
* must not be passed here; the server already answered it over the whole scope,
|
|
257
|
+
* and re-answering it over one page is how a criterion ends up meaning "among
|
|
258
|
+
* the rows fetched so far" (#312).
|
|
259
|
+
*
|
|
260
|
+
* Every token is implemented so that a request carrying fewer parameters still
|
|
261
|
+
* has somewhere to put the rest: `listAllThreads` has no `from` or `subject` of
|
|
262
|
+
* its own, and no endpoint has `before:`, `after:`, `in:` or `account:`. A row
|
|
263
|
+
* without the data a token needs (no `sentDate` for `before:`/`after:`) never
|
|
264
|
+
* matches it, so it drops out rather than showing under an unverifiable filter.
|
|
202
265
|
*/
|
|
203
266
|
export function matchesSearchTokens(
|
|
204
267
|
t: ThreadRowData,
|
package/src/lib/drafts.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* the account that owns the open \Drafts mailbox.
|
|
9
9
|
* 2. On the server — IMAP \Drafts thread rows already loaded for the mailbox.
|
|
10
10
|
*
|
|
11
|
-
* Sections with no rows are omitted entirely (mirrors
|
|
11
|
+
* Sections with no rows are omitted entirely (mirrors `briefSections`).
|
|
12
12
|
*
|
|
13
13
|
* --- Dedup note ---
|
|
14
14
|
* Remit draft rows and IMAP \Drafts messages are rendered in both sections
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exact match count is opt-in because it is a second read over the whole
|
|
3
|
+
* match set (#307). These pin who pays for it and what happens when the server
|
|
4
|
+
* declines to answer.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, test } from "node:test";
|
|
9
|
+
import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
|
|
10
|
+
import {
|
|
11
|
+
type ResultCountRequestInput,
|
|
12
|
+
shouldRequestResultCount,
|
|
13
|
+
toResultCount,
|
|
14
|
+
} from "./result-count.js";
|
|
15
|
+
|
|
16
|
+
const request = (input: Partial<ResultCountRequestInput>): boolean =>
|
|
17
|
+
shouldRequestResultCount({
|
|
18
|
+
hasSearchQuery: true,
|
|
19
|
+
freeText: "",
|
|
20
|
+
residualTokenCount: 0,
|
|
21
|
+
...input,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("shouldRequestResultCount", () => {
|
|
25
|
+
test("browsing a mailbox asks for no count", () => {
|
|
26
|
+
assert.equal(request({ hasSearchQuery: false }), false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("a free-text query is not counted while it is being typed", () => {
|
|
30
|
+
for (const freeText of ["k", "ku", " ku "]) {
|
|
31
|
+
assert.equal(
|
|
32
|
+
request({ freeText }),
|
|
33
|
+
false,
|
|
34
|
+
`"${freeText}" was counted per keystroke`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a free-text query long enough for the index is counted", () => {
|
|
40
|
+
assert.equal(request({ freeText: "kub" }), true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a query made only of filter tokens is counted straight away", () => {
|
|
44
|
+
assert.equal(request({}), true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `before:`/`after:`/`account:` reach the request as nothing, so the server
|
|
49
|
+
* would count a wider set than the list shows — and `invoice before:2020`
|
|
50
|
+
* sends criteria identical to `invoice`, which is also the previous search's
|
|
51
|
+
* cache key.
|
|
52
|
+
*/
|
|
53
|
+
test("a term the request could not carry stops the count", () => {
|
|
54
|
+
assert.equal(
|
|
55
|
+
request({ freeText: "invoice", residualTokenCount: 1 }),
|
|
56
|
+
false,
|
|
57
|
+
"the count covered rows the list filters back out",
|
|
58
|
+
);
|
|
59
|
+
assert.equal(
|
|
60
|
+
request({ freeText: "invoice", residualTokenCount: 0 }),
|
|
61
|
+
true,
|
|
62
|
+
"the same search without the residual term must still be counted",
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("toResultCount", () => {
|
|
68
|
+
test("the server's number is the whole match set", () => {
|
|
69
|
+
assert.deepEqual(toResultCount(1284), { kind: "exact", value: 1284 });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("zero matches is a number, not an absent count", () => {
|
|
73
|
+
assert.deepEqual(toResultCount(0), { kind: "exact", value: 0 });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("an absent count stays absent rather than becoming zero", () => {
|
|
77
|
+
assert.deepEqual(toResultCount(undefined), { kind: "unknown" });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `searchThreads` withholds the count when the criteria carry an off-row term
|
|
83
|
+
* (#305), and the escalated selection reads `count` as a required number to size
|
|
84
|
+
* its run. Neither term can reach that predicate today; this fails the day one
|
|
85
|
+
* is added, so the absent-count state gets designed rather than surfacing as a
|
|
86
|
+
* run that refuses to start.
|
|
87
|
+
*/
|
|
88
|
+
describe("the escalation predicate carries no off-row criterion", () => {
|
|
89
|
+
test("senderTrust and dkimMismatch are not part of it", () => {
|
|
90
|
+
type OffRow = Extract<
|
|
91
|
+
keyof EscalationSearchQuery,
|
|
92
|
+
"senderTrust" | "dkimMismatch"
|
|
93
|
+
>;
|
|
94
|
+
const offRow: [OffRow] extends [never] ? true : false = true;
|
|
95
|
+
assert.equal(offRow, true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ResultCount } from "@remit/ui";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shortest free text a count is asked for. `npm-scripts/sqlite-search-index.sql`
|
|
5
|
+
* documents that a query under three characters falls out of the trigram index
|
|
6
|
+
* onto a folded `LIKE` scan, so counting one is a mailbox scan per typed
|
|
7
|
+
* character. The list itself still pages — only the count waits.
|
|
8
|
+
*/
|
|
9
|
+
export const MIN_COUNTED_QUERY_LENGTH = 3;
|
|
10
|
+
|
|
11
|
+
export interface ResultCountRequestInput {
|
|
12
|
+
/** True while the list is answering a search rather than plain browsing. */
|
|
13
|
+
hasSearchQuery: boolean;
|
|
14
|
+
/** The literal text of the search, with its filter tokens already taken out. */
|
|
15
|
+
freeText: string;
|
|
16
|
+
/**
|
|
17
|
+
* Tokens the request could not carry — `before:`, `after:`, `account:`, and
|
|
18
|
+
* any second value for a parameter that takes one — applied over the rows
|
|
19
|
+
* after they arrive.
|
|
20
|
+
*/
|
|
21
|
+
residualTokenCount: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether the exact match count is worth one request against these criteria.
|
|
26
|
+
*
|
|
27
|
+
* Chip- and token-driven criteria are counted straight away: they change once
|
|
28
|
+
* per click, and the predicate is one the index answers. Free text is counted
|
|
29
|
+
* only once it is long enough to be indexed, which is what keeps a per-character
|
|
30
|
+
* query from paying for a count it will throw away on the next keystroke.
|
|
31
|
+
*
|
|
32
|
+
* A residual token stops the count outright. The server never saw that term, so
|
|
33
|
+
* it would count a wider set than the list shows — and two searches differing
|
|
34
|
+
* only by one reach the request as the same criteria, so the answer would also
|
|
35
|
+
* be the one the previous search left in the cache.
|
|
36
|
+
*/
|
|
37
|
+
export const shouldRequestResultCount = ({
|
|
38
|
+
hasSearchQuery,
|
|
39
|
+
freeText,
|
|
40
|
+
residualTokenCount,
|
|
41
|
+
}: ResultCountRequestInput): boolean => {
|
|
42
|
+
if (!hasSearchQuery) return false;
|
|
43
|
+
if (residualTokenCount > 0) return false;
|
|
44
|
+
const typed = freeText.trim();
|
|
45
|
+
return typed.length === 0 || typed.length >= MIN_COUNTED_QUERY_LENGTH;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The server's answer, or no number.
|
|
50
|
+
*
|
|
51
|
+
* The only input is `ThreadSearchResponse.count`, which is the whole match set
|
|
52
|
+
* or absent (#305) — never a page length. Absent stays absent: a count the
|
|
53
|
+
* server declined to compute is not zero, and the surface renders nothing
|
|
54
|
+
* rather than a figure it made up.
|
|
55
|
+
*/
|
|
56
|
+
export const toResultCount = (count: number | undefined): ResultCount =>
|
|
57
|
+
count === undefined ? { kind: "unknown" } : { kind: "exact", value: count };
|