@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.test.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
3
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
-
import type { ThreadRowData } from "@remit/ui";
|
|
4
|
+
import type { ResultCount, ThreadCategory, ThreadRowData } from "@remit/ui";
|
|
5
5
|
import {
|
|
6
|
+
type BriefCategoryResult,
|
|
7
|
+
briefSections,
|
|
8
|
+
briefSectionTotal,
|
|
6
9
|
excludeMutedSenders,
|
|
7
|
-
groupBriefSections,
|
|
8
10
|
matchesBriefSearch,
|
|
9
11
|
matchesSearchTokens,
|
|
10
12
|
mergeSearchRows,
|
|
@@ -83,151 +85,172 @@ describe("toThreadRowData", () => {
|
|
|
83
85
|
});
|
|
84
86
|
});
|
|
85
87
|
|
|
86
|
-
describe("
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
88
|
+
describe("briefSections", () => {
|
|
89
|
+
const result = (
|
|
90
|
+
category: ThreadCategory,
|
|
91
|
+
rows: ThreadRowData[],
|
|
92
|
+
total: ResultCount = { kind: "exact", value: rows.length },
|
|
93
|
+
loading = false,
|
|
94
|
+
failed = false,
|
|
95
|
+
atCap = false,
|
|
96
|
+
): BriefCategoryResult => ({
|
|
97
|
+
category,
|
|
98
|
+
rows,
|
|
99
|
+
total,
|
|
100
|
+
atCap,
|
|
101
|
+
loading,
|
|
102
|
+
failed,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("returns no sections when the brief asked for nothing", () => {
|
|
106
|
+
assert.deepStrictEqual(briefSections([]), []);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("each category answers for its own section, with its own label", () => {
|
|
110
|
+
const labels = new Map<ThreadCategory, string>([
|
|
111
|
+
["personal", "Personal"],
|
|
112
|
+
["transactional", "Transactional"],
|
|
113
|
+
["newsletter", "Newsletter"],
|
|
114
|
+
["marketing", "Marketing"],
|
|
115
|
+
["social", "Social"],
|
|
116
|
+
["automated", "Automated"],
|
|
117
|
+
["uncategorized", "Unclassified"],
|
|
118
|
+
]);
|
|
119
|
+
for (const [category, label] of labels) {
|
|
120
|
+
const sections = briefSections([
|
|
121
|
+
result(category, [row({ id: "1", category })]),
|
|
122
|
+
]);
|
|
123
|
+
assert.strictEqual(sections.length, 1);
|
|
124
|
+
assert.strictEqual(sections[0].id, category);
|
|
125
|
+
assert.strictEqual(sections[0].label, label);
|
|
126
|
+
}
|
|
100
127
|
});
|
|
101
128
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
129
|
+
// The reading #312 fixes: on a mailbox whose Marketing mail is all older than
|
|
130
|
+
// the newest unified page, the section held nothing and its header read zero.
|
|
131
|
+
// Its own request answers over the whole scope, so it has both.
|
|
132
|
+
test("a category renders its rows and its real size, page order aside", () => {
|
|
133
|
+
const sections = briefSections([
|
|
134
|
+
result("marketing", [row({ id: "old-1" }), row({ id: "old-2" })], {
|
|
135
|
+
kind: "exact",
|
|
136
|
+
value: 3942,
|
|
137
|
+
}),
|
|
138
|
+
]);
|
|
139
|
+
assert.deepStrictEqual(
|
|
140
|
+
sections[0].threads.map((t) => t.id),
|
|
141
|
+
["old-1", "old-2"],
|
|
142
|
+
);
|
|
143
|
+
assert.deepStrictEqual(sections[0].total, { kind: "exact", value: 3942 });
|
|
107
144
|
});
|
|
108
145
|
|
|
109
|
-
test("
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
assert.
|
|
146
|
+
test("the total is the server's, never the number of rows loaded", () => {
|
|
147
|
+
const sections = briefSections([
|
|
148
|
+
result("personal", [row({ id: "1" })], { kind: "exact", value: 4753 }),
|
|
149
|
+
]);
|
|
150
|
+
assert.deepStrictEqual(sections[0].total, { kind: "exact", value: 4753 });
|
|
114
151
|
});
|
|
115
152
|
|
|
116
|
-
test("
|
|
117
|
-
|
|
118
|
-
const sections = groupBriefSections([r]);
|
|
119
|
-
assert.strictEqual(sections[0].id, "marketing");
|
|
120
|
-
assert.strictEqual(sections[0].label, "Marketing");
|
|
153
|
+
test("a category the scope holds none of has no section", () => {
|
|
154
|
+
assert.deepStrictEqual(briefSections([result("social", [])]), []);
|
|
121
155
|
});
|
|
122
156
|
|
|
123
|
-
test("
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
assert.strictEqual(sections
|
|
157
|
+
test("a category still being fetched keeps its section", () => {
|
|
158
|
+
const sections = briefSections([
|
|
159
|
+
result("social", [], { kind: "exact", value: 88 }, true),
|
|
160
|
+
]);
|
|
161
|
+
assert.strictEqual(sections.length, 1);
|
|
162
|
+
assert.strictEqual(sections[0].loading, true);
|
|
128
163
|
});
|
|
129
164
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
165
|
+
// Seven requests are seven answers. One category's failure states itself where
|
|
166
|
+
// that category would have been; it does not take the other six down.
|
|
167
|
+
test("a category whose own request failed keeps its section", () => {
|
|
168
|
+
const sections = briefSections([
|
|
169
|
+
result("marketing", [], { kind: "unknown" }, false, true),
|
|
170
|
+
result("personal", [row({ id: "1", category: "personal" })]),
|
|
171
|
+
]);
|
|
172
|
+
assert.deepStrictEqual(
|
|
173
|
+
sections.map((s) => s.id),
|
|
174
|
+
["personal", "marketing"],
|
|
175
|
+
);
|
|
176
|
+
assert.strictEqual(sections.find((s) => s.id === "marketing")?.error, true);
|
|
135
177
|
});
|
|
136
178
|
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
179
|
+
// The count is withheld whenever something narrows rows after they arrive, and
|
|
180
|
+
// a full page is then the only thing that says the category holds more. Losing
|
|
181
|
+
// it would leave the section with no number and no way out (#312).
|
|
182
|
+
test("carries the full-page flag through to the section", () => {
|
|
183
|
+
const sections = briefSections([
|
|
184
|
+
result(
|
|
185
|
+
"marketing",
|
|
186
|
+
[row({ id: "1" })],
|
|
187
|
+
{ kind: "unknown" },
|
|
188
|
+
false,
|
|
189
|
+
false,
|
|
190
|
+
true,
|
|
191
|
+
),
|
|
192
|
+
]);
|
|
193
|
+
assert.strictEqual(sections[0].atCap, true);
|
|
148
194
|
});
|
|
149
195
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// has not done, and hiding it inside Personal is what made issue #45
|
|
155
|
-
// look like a working classifier with a huge personal inbox.
|
|
156
|
-
const r = row({ id: "1" });
|
|
157
|
-
const sections = groupBriefSections([r]);
|
|
196
|
+
test("a counted category with no rows left keeps its section", () => {
|
|
197
|
+
const sections = briefSections([
|
|
198
|
+
result("personal", [], { kind: "exact", value: 4753 }),
|
|
199
|
+
]);
|
|
158
200
|
assert.strictEqual(sections.length, 1);
|
|
159
|
-
assert.
|
|
201
|
+
assert.deepStrictEqual(sections[0].threads, []);
|
|
160
202
|
});
|
|
161
203
|
|
|
162
|
-
test("
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
]);
|
|
168
|
-
const personal = sections.find((s) => s.id === "personal");
|
|
169
|
-
const unclassified = sections.find((s) => s.id === "uncategorized");
|
|
170
|
-
assert.strictEqual(personal?.threads.length, 1);
|
|
171
|
-
assert.strictEqual(unclassified?.threads.length, 2);
|
|
204
|
+
test("an uncounted category with no rows has nothing to render", () => {
|
|
205
|
+
assert.deepStrictEqual(
|
|
206
|
+
briefSections([result("personal", [], { kind: "unknown" })]),
|
|
207
|
+
[],
|
|
208
|
+
);
|
|
172
209
|
});
|
|
173
210
|
|
|
174
|
-
// ---
|
|
211
|
+
// --- Row markers do not move a row out of its category ---
|
|
175
212
|
|
|
176
|
-
test("
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
test("a starred personal message stays in the personal section", () => {
|
|
184
|
-
const r = row({ id: "1", starred: true, category: "personal" });
|
|
185
|
-
const sections = groupBriefSections([r]);
|
|
213
|
+
test("read and unread rows share their category's section", () => {
|
|
214
|
+
const sections = briefSections([
|
|
215
|
+
result("personal", [
|
|
216
|
+
row({ id: "1", isRead: false, category: "personal" }),
|
|
217
|
+
row({ id: "2", isRead: true, category: "personal" }),
|
|
218
|
+
]),
|
|
219
|
+
]);
|
|
186
220
|
assert.strictEqual(sections.length, 1);
|
|
187
|
-
assert.strictEqual(sections[0].
|
|
221
|
+
assert.strictEqual(sections[0].threads.length, 2);
|
|
188
222
|
});
|
|
189
223
|
|
|
190
224
|
test("starred mail never produces a flagged section", () => {
|
|
191
|
-
const
|
|
192
|
-
row({ id: "p", category: "personal" }),
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
225
|
+
const sections = briefSections([
|
|
226
|
+
result("personal", [row({ id: "p", category: "personal" })]),
|
|
227
|
+
result("automated", [
|
|
228
|
+
row({ id: "f", starred: true, category: "automated" }),
|
|
229
|
+
]),
|
|
230
|
+
]);
|
|
196
231
|
assert.deepStrictEqual(
|
|
197
232
|
sections.map((s) => s.id),
|
|
198
233
|
["personal", "automated"],
|
|
199
234
|
);
|
|
200
235
|
});
|
|
201
236
|
|
|
202
|
-
// --- Trust no longer sections ---
|
|
203
|
-
|
|
204
|
-
test("a vip newsletter stays in the newsletter section (trust does not section)", () => {
|
|
205
|
-
const r = row({ id: "1", trust: "vip", category: "newsletter" });
|
|
206
|
-
const sections = groupBriefSections([r]);
|
|
207
|
-
assert.strictEqual(sections.length, 1);
|
|
208
|
-
assert.strictEqual(sections[0].id, "newsletter");
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
test("a wellknown automated row stays in the automated section", () => {
|
|
212
|
-
const r = row({ id: "1", trust: "wellknown", category: "automated" });
|
|
213
|
-
const sections = groupBriefSections([r]);
|
|
214
|
-
assert.strictEqual(sections.length, 1);
|
|
215
|
-
assert.strictEqual(sections[0].id, "automated");
|
|
216
|
-
});
|
|
217
|
-
|
|
218
237
|
// --- Section order and omission ---
|
|
219
238
|
|
|
220
|
-
test("display order is
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
239
|
+
test("display order is fixed, Unclassified last", () => {
|
|
240
|
+
const seeded: ThreadCategory[] = [
|
|
241
|
+
"automated",
|
|
242
|
+
"social",
|
|
243
|
+
"marketing",
|
|
244
|
+
"newsletter",
|
|
245
|
+
"transactional",
|
|
246
|
+
"personal",
|
|
247
|
+
"uncategorized",
|
|
229
248
|
];
|
|
230
|
-
const sections =
|
|
249
|
+
const sections = briefSections(
|
|
250
|
+
seeded.map((category) =>
|
|
251
|
+
result(category, [row({ id: category, category })]),
|
|
252
|
+
),
|
|
253
|
+
);
|
|
231
254
|
assert.deepStrictEqual(
|
|
232
255
|
sections.map((s) => s.id),
|
|
233
256
|
[
|
|
@@ -237,33 +260,30 @@ describe("groupBriefSections", () => {
|
|
|
237
260
|
"marketing",
|
|
238
261
|
"social",
|
|
239
262
|
"automated",
|
|
263
|
+
"uncategorized",
|
|
240
264
|
],
|
|
241
265
|
);
|
|
242
266
|
});
|
|
243
267
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
268
|
+
// D6 / issue #45: unclassified mail is work the classifier has not done, and
|
|
269
|
+
// hiding it inside Personal is what made #45 look like a working classifier
|
|
270
|
+
// with a huge personal inbox.
|
|
271
|
+
test("uncategorized is its own section and never inflates personal", () => {
|
|
272
|
+
const sections = briefSections([
|
|
273
|
+
result("personal", [row({ id: "1", category: "personal" })]),
|
|
274
|
+
result("uncategorized", [
|
|
275
|
+
row({ id: "2", category: "uncategorized" }),
|
|
276
|
+
row({ id: "3", category: "uncategorized" }),
|
|
277
|
+
]),
|
|
278
|
+
]);
|
|
279
|
+
assert.strictEqual(
|
|
280
|
+
sections.find((s) => s.id === "personal")?.threads.length,
|
|
281
|
+
1,
|
|
282
|
+
);
|
|
283
|
+
assert.strictEqual(
|
|
284
|
+
sections.find((s) => s.id === "uncategorized")?.threads.length,
|
|
285
|
+
2,
|
|
249
286
|
);
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
test("each row appears in exactly one section", () => {
|
|
253
|
-
const rows: ThreadRowData[] = [
|
|
254
|
-
row({ id: "1", category: "personal" }),
|
|
255
|
-
row({ id: "2", starred: true, category: "automated" }),
|
|
256
|
-
row({ id: "3", category: "automated" }),
|
|
257
|
-
row({ id: "4", category: "transactional" }),
|
|
258
|
-
row({ id: "5", starred: true, category: "newsletter" }),
|
|
259
|
-
row({ id: "6", category: "newsletter" }),
|
|
260
|
-
row({ id: "7", category: "marketing" }),
|
|
261
|
-
];
|
|
262
|
-
const sections = groupBriefSections(rows);
|
|
263
|
-
const allIds = sections.flatMap((s) => s.threads.map((t) => t.id));
|
|
264
|
-
assert.strictEqual(allIds.length, rows.length);
|
|
265
|
-
const uniqueIds = new Set(allIds);
|
|
266
|
-
assert.strictEqual(uniqueIds.size, rows.length);
|
|
267
287
|
});
|
|
268
288
|
});
|
|
269
289
|
|
|
@@ -310,14 +330,19 @@ describe("excludeMutedSenders", () => {
|
|
|
310
330
|
category: "personal",
|
|
311
331
|
}),
|
|
312
332
|
];
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
333
|
+
const kept = excludeMutedSenders(rows).map(toThreadRowData);
|
|
334
|
+
const sections = briefSections([
|
|
335
|
+
{
|
|
336
|
+
category: "personal",
|
|
337
|
+
rows: kept,
|
|
338
|
+
total: { kind: "exact", value: kept.length },
|
|
339
|
+
atCap: false,
|
|
340
|
+
loading: false,
|
|
341
|
+
failed: false,
|
|
342
|
+
},
|
|
343
|
+
]);
|
|
316
344
|
const allIds = sections.flatMap((s) => s.threads.map((t) => t.id));
|
|
317
345
|
assert.deepStrictEqual(allIds, ["kept-personal"]);
|
|
318
|
-
for (const section of sections) {
|
|
319
|
-
assert.ok(!section.threads.some((t) => t.id === "muted-personal"));
|
|
320
|
-
}
|
|
321
346
|
});
|
|
322
347
|
|
|
323
348
|
test("muting every candidate row leaves no sections (brief empty state)", () => {
|
|
@@ -335,13 +360,54 @@ describe("excludeMutedSenders", () => {
|
|
|
335
360
|
muted: true,
|
|
336
361
|
}),
|
|
337
362
|
];
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
363
|
+
const kept = excludeMutedSenders(rows).map(toThreadRowData);
|
|
364
|
+
const sections = briefSections([
|
|
365
|
+
{
|
|
366
|
+
category: "personal",
|
|
367
|
+
rows: kept,
|
|
368
|
+
total: { kind: "exact", value: 0 },
|
|
369
|
+
atCap: false,
|
|
370
|
+
loading: false,
|
|
371
|
+
failed: false,
|
|
372
|
+
},
|
|
373
|
+
]);
|
|
341
374
|
assert.deepStrictEqual(sections, []);
|
|
342
375
|
});
|
|
343
376
|
});
|
|
344
377
|
|
|
378
|
+
// The count is the server's, taken over a scope that includes senders the reader
|
|
379
|
+
// muted — `listAllThreads` has no `muted` parameter. The list drops their mail,
|
|
380
|
+
// so the number would overstate what is on screen.
|
|
381
|
+
describe("briefSectionTotal", () => {
|
|
382
|
+
const counted: ResultCount = { kind: "exact", value: 4753 };
|
|
383
|
+
|
|
384
|
+
test("keeps the count when no sender in the rows is muted", () => {
|
|
385
|
+
assert.deepStrictEqual(
|
|
386
|
+
briefSectionTotal(counted, [
|
|
387
|
+
threadResponse({ threadMessageId: "a" }),
|
|
388
|
+
threadResponse({ threadMessageId: "b", muted: false }),
|
|
389
|
+
]),
|
|
390
|
+
counted,
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("withholds the count when the rows show a muted sender", () => {
|
|
395
|
+
assert.deepStrictEqual(
|
|
396
|
+
briefSectionTotal(counted, [
|
|
397
|
+
threadResponse({ threadMessageId: "a" }),
|
|
398
|
+
threadResponse({ threadMessageId: "b", muted: true }),
|
|
399
|
+
]),
|
|
400
|
+
{ kind: "unknown" },
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("a count nobody took stays absent", () => {
|
|
405
|
+
assert.deepStrictEqual(briefSectionTotal({ kind: "unknown" }, []), {
|
|
406
|
+
kind: "unknown",
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
345
411
|
describe("matchesBriefSearch", () => {
|
|
346
412
|
const r = row({
|
|
347
413
|
id: "1",
|
|
@@ -580,9 +646,10 @@ describe("matchesSearchTokens", () => {
|
|
|
580
646
|
});
|
|
581
647
|
});
|
|
582
648
|
|
|
583
|
-
// #49:
|
|
584
|
-
//
|
|
585
|
-
//
|
|
649
|
+
// #49: a listing complete under its own criteria still cannot see a snippet
|
|
650
|
+
// match, and the server's cross-folder search cannot see one either. The two
|
|
651
|
+
// halves are merged. The brief no longer uses this — its sections are pages, and
|
|
652
|
+
// merging a page with a search window orders two truncated lists (#312).
|
|
586
653
|
describe("mergeSearchRows", () => {
|
|
587
654
|
test("keeps rows the server found in other folders", () => {
|
|
588
655
|
const merged = mergeSearchRows(
|