@remit/web-client 0.0.249 → 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/brief-server-sections.render.test.ts +655 -0
- 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
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
|