@remit/web-client 0.0.278 → 0.0.280
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/SelectionWizardHost.tsx +8 -6
- 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/useOrganizeWiden.test.ts +71 -5
- package/src/hooks/useOrganizeWiden.ts +24 -15
- 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/organize/sender-fallback.test.ts +31 -2
- package/src/lib/organize/sender-fallback.ts +12 -8
- package/src/lib/thread-search-tokens.ts +4 -3
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
|
-
});
|
package/src/lib/brief.ts
CHANGED
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
* a high-volume mailbox read≠handled and unread≠important; unread is a
|
|
29
29
|
* user-selectable filter chip instead.
|
|
30
30
|
*
|
|
31
|
-
* Muted senders and categories the scope holds none of are excluded. Mute
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
31
|
+
* Muted senders and categories the scope holds none of are excluded. Mute is
|
|
32
|
+
* `muted=false` on each section's request, so the rows and the count answer the
|
|
33
|
+
* same predicate — dropping muted rows here instead left a header stating a
|
|
34
|
+
* number larger than the list under it, and a "Show all" that opened mail the
|
|
35
|
+
* brief would not render (#1137).
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
38
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
@@ -80,71 +80,6 @@ export function toThreadRowData(
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
/**
|
|
84
|
-
* Excludes rows whose From address is muted (`thread.muted === true`,
|
|
85
|
-
* denormalized server-side from `Address.flags.muted`). Muting hides a
|
|
86
|
-
* sender from the brief only — it never deletes, marks read, or moves their
|
|
87
|
-
* mail, so callers outside the brief (mailbox listings, search) must not
|
|
88
|
-
* apply this filter.
|
|
89
|
-
*/
|
|
90
|
-
/**
|
|
91
|
-
* The section's count, or no number, once mute is taken into account.
|
|
92
|
-
*
|
|
93
|
-
* Mute is the one criterion the request cannot be asked about: `listAllThreads`
|
|
94
|
-
* has no `muted` parameter, so a scope holding a muted sender is counted with
|
|
95
|
-
* that sender's mail in it while the list renders without it. The header would
|
|
96
|
-
* overstate, and "Show all" would offer rows the brief will not show.
|
|
97
|
-
*
|
|
98
|
-
* Withheld per section rather than for the whole brief — one muted marketer must
|
|
99
|
-
* not take the number off Personal — and only where a mute is visible in the
|
|
100
|
-
* rows on hand. A muted sender below a section's page is not visible, so this
|
|
101
|
-
* suppresses a number known to be wrong rather than certifying the rest.
|
|
102
|
-
*/
|
|
103
|
-
export function briefSectionTotal(
|
|
104
|
-
total: ResultCount,
|
|
105
|
-
rows: RemitImapThreadMessageResponse[],
|
|
106
|
-
): ResultCount {
|
|
107
|
-
return rows.some((row) => row.muted === true) ? { kind: "unknown" } : total;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function excludeMutedSenders(
|
|
111
|
-
threads: RemitImapThreadMessageResponse[],
|
|
112
|
-
): RemitImapThreadMessageResponse[] {
|
|
113
|
-
return threads.filter((t) => t.muted !== true);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Union of a complete server-filtered listing with the rows the server's
|
|
118
|
-
* cross-folder text search returned, newest first.
|
|
119
|
-
*
|
|
120
|
-
* Both lists are needed where the listing itself is complete under its criteria
|
|
121
|
-
* — the Flagged collection is, and pages to its end. The server matches subject
|
|
122
|
-
* and From only, so a row whose snippet carries the term is found only by the
|
|
123
|
-
* client-side pass; the server pass is the only one that reaches Archive, Sent,
|
|
124
|
-
* Spam and custom folders. The two overlap, so rows are deduped by id, the first
|
|
125
|
-
* occurrence winning.
|
|
126
|
-
*
|
|
127
|
-
* Not for a paginated prefix. The brief's sections are per-category pages, so
|
|
128
|
-
* merging one with a search window and re-sorting would order two truncated
|
|
129
|
-
* lists against each other and call the result newest-first (#312).
|
|
130
|
-
*
|
|
131
|
-
* Rows without a `sentDate` sort last; the brief's own list is already newest
|
|
132
|
-
* first, so this only has to re-interleave the two sources.
|
|
133
|
-
*/
|
|
134
|
-
export function mergeSearchRows(
|
|
135
|
-
briefRows: ThreadRowData[],
|
|
136
|
-
searchRows: ThreadRowData[],
|
|
137
|
-
): ThreadRowData[] {
|
|
138
|
-
const seen = new Set<string>();
|
|
139
|
-
const merged: ThreadRowData[] = [];
|
|
140
|
-
for (const row of [...briefRows, ...searchRows]) {
|
|
141
|
-
if (seen.has(row.id)) continue;
|
|
142
|
-
seen.add(row.id);
|
|
143
|
-
merged.push(row);
|
|
144
|
-
}
|
|
145
|
-
return merged.sort((a, b) => (b.sentDate ?? 0) - (a.sentDate ?? 0));
|
|
146
|
-
}
|
|
147
|
-
|
|
148
83
|
/**
|
|
149
84
|
* The categories the brief asks for, in the order it renders them. A category
|
|
150
85
|
* is both the section's own scope and the query parameter its request carries;
|
|
@@ -210,19 +145,6 @@ export function briefSections(
|
|
|
210
145
|
return sections;
|
|
211
146
|
}
|
|
212
147
|
|
|
213
|
-
/**
|
|
214
|
-
* Returns true when `t` matches the free-text `query` (lower-cased).
|
|
215
|
-
* Checked against fromName, fromEmail, subject, and snippet.
|
|
216
|
-
*/
|
|
217
|
-
export function matchesBriefSearch(t: ThreadRowData, query: string): boolean {
|
|
218
|
-
return (
|
|
219
|
-
t.fromName.toLowerCase().includes(query) ||
|
|
220
|
-
t.fromEmail.toLowerCase().includes(query) ||
|
|
221
|
-
t.subject.toLowerCase().includes(query) ||
|
|
222
|
-
t.snippet.toLowerCase().includes(query)
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
148
|
/**
|
|
227
149
|
* Returns true when `t` satisfies every token handed to it.
|
|
228
150
|
*
|
|
@@ -234,10 +156,12 @@ export function matchesBriefSearch(t: ThreadRowData, query: string): boolean {
|
|
|
234
156
|
* the rows fetched so far" (#312).
|
|
235
157
|
*
|
|
236
158
|
* Every token is implemented so that a request carrying fewer parameters still
|
|
237
|
-
* has somewhere to put the rest:
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
159
|
+
* has somewhere to put the rest: no endpoint has `before:`, `after:`, `in:` or
|
|
160
|
+
* `account:` in the set these tokens are split against, and each takes one
|
|
161
|
+
* `from` and one `subject`, so a second of either — or one a chip overruled —
|
|
162
|
+
* lands here. A row without the data a token needs (no `sentDate` for
|
|
163
|
+
* `before:`/`after:`) never matches it, so it drops out rather than showing
|
|
164
|
+
* under an unverifiable filter.
|
|
241
165
|
*/
|
|
242
166
|
export function matchesSearchTokens(
|
|
243
167
|
t: ThreadRowData,
|
|
@@ -71,16 +71,31 @@ describe("flaggedCriteria", () => {
|
|
|
71
71
|
assert.deepEqual(residualTypes("is:starred"), []);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
74
|
+
// #1128: the free-text parameter matches subject and From at once, so
|
|
75
|
+
// neither token could be asked for on its own. Both applied over the pages
|
|
76
|
+
// already fetched, and a starred collection is paged — a match below them
|
|
77
|
+
// never appeared. Each has its own parameter now.
|
|
78
|
+
test("from: and subject: travel as parameters", () => {
|
|
79
|
+
assert.deepEqual(criteriaOf("from:alice"), { from: "alice" });
|
|
80
|
+
assert.deepEqual(residualTypes("from:alice"), []);
|
|
81
|
+
assert.deepEqual(criteriaOf("subject:invoice"), { subject: "invoice" });
|
|
82
|
+
assert.deepEqual(residualTypes("subject:invoice"), []);
|
|
83
|
+
});
|
|
84
|
+
|
|
77
85
|
test("keeps the tokens no parameter carries", () => {
|
|
78
|
-
assert.deepEqual(residualTypes("from:alice"), ["from"]);
|
|
79
86
|
assert.deepEqual(residualTypes("before:2026-01-01"), ["before"]);
|
|
80
87
|
assert.deepEqual(residualTypes("in:sent"), ["in"]);
|
|
81
88
|
assert.deepEqual(residualTypes("account:alice"), ["account"]);
|
|
82
89
|
});
|
|
83
90
|
|
|
91
|
+
// One parameter per field, so only the first value can be expressed.
|
|
92
|
+
test("a second subject: stays residue", () => {
|
|
93
|
+
assert.deepEqual(criteriaOf("subject:one subject:two"), {
|
|
94
|
+
subject: "one",
|
|
95
|
+
});
|
|
96
|
+
assert.deepEqual(residualTypes("subject:one subject:two"), ["subject"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
84
99
|
test("the chip wins over a token that contradicts it", () => {
|
|
85
100
|
const { criteria, residual } = flaggedCriteria(
|
|
86
101
|
chips("all", ["unread"]),
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* What the Flagged view asks `listAllThreads` for, and what is left over.
|
|
3
3
|
*
|
|
4
4
|
* Every criterion the view offers — the category chip, the Unread and Has
|
|
5
|
-
* attachment chips, and the `
|
|
6
|
-
* `has:attachment` / `is:starred` tokens typed in the field — is a
|
|
7
|
-
* the ThreadMessage row, so all of them travel as query parameters. A
|
|
8
|
-
* applied over the pages loaded so far is the defect this replaces: a
|
|
9
|
-
* collection is paged, so anything below the newest page was invisible
|
|
10
|
-
* (#308).
|
|
5
|
+
* attachment chips, and the `from:` / `subject:` / `category:` / `is:unread` /
|
|
6
|
+
* `is:read` / `has:attachment` / `is:starred` tokens typed in the field — is a
|
|
7
|
+
* field on the ThreadMessage row, so all of them travel as query parameters. A
|
|
8
|
+
* criterion applied over the pages loaded so far is the defect this replaces: a
|
|
9
|
+
* starred collection is paged, so anything below the newest page was invisible
|
|
10
|
+
* to it (#308, #1128).
|
|
11
11
|
*
|
|
12
12
|
* `starred` is the view rather than a criterion: the request always carries it,
|
|
13
13
|
* which is what makes `is:starred` a carried token instead of residue.
|
|
@@ -16,24 +16,25 @@
|
|
|
16
16
|
* beats text in a field — and the overruled token drops to the residue, where
|
|
17
17
|
* it matches nothing, which is the honest answer to a contradiction.
|
|
18
18
|
*/
|
|
19
|
-
import type {
|
|
20
|
-
InboxFilterCriteria,
|
|
21
|
-
InboxFilterParams,
|
|
22
|
-
} from "./inbox-filters.js";
|
|
19
|
+
import type { InboxFilterCriteria } from "./inbox-filters.js";
|
|
23
20
|
import { inboxFilterParams } from "./inbox-filters.js";
|
|
24
21
|
import type { SearchToken } from "./search-tokens.js";
|
|
25
22
|
import {
|
|
26
23
|
type ThreadSearchTokenParamName,
|
|
24
|
+
type ThreadSearchTokenParams,
|
|
27
25
|
threadSearchTokens,
|
|
28
26
|
} from "./thread-search-tokens.js";
|
|
29
27
|
|
|
30
28
|
/**
|
|
31
|
-
* The token parameters `listAllThreads` carries
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
29
|
+
* The token parameters `listAllThreads` carries, which is every parameter it
|
|
30
|
+
* takes. `from` and `subject` joined them once the operation grew a parameter
|
|
31
|
+
* for each field: its one free-text parameter matches both at once, so neither
|
|
32
|
+
* token could be asked for on its own and both were applied over the pages
|
|
33
|
+
* already fetched — a match below them never appeared (#1128).
|
|
35
34
|
*/
|
|
36
35
|
export const FLAGGED_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] = [
|
|
36
|
+
"from",
|
|
37
|
+
"subject",
|
|
37
38
|
"category",
|
|
38
39
|
"unread",
|
|
39
40
|
"starred",
|
|
@@ -42,7 +43,7 @@ export const FLAGGED_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] = [
|
|
|
42
43
|
|
|
43
44
|
export interface FlaggedCriteria {
|
|
44
45
|
/** What the request narrows by, chips and carried tokens together. */
|
|
45
|
-
criteria:
|
|
46
|
+
criteria: ThreadSearchTokenParams;
|
|
46
47
|
/** The tokens no parameter carries; apply these over the returned rows. */
|
|
47
48
|
residual: SearchToken[];
|
|
48
49
|
}
|
package/src/lib/inbox-filters.ts
CHANGED
|
@@ -89,11 +89,28 @@ export const filterReach = (query: ThreadSearchQuery): FilterReach =>
|
|
|
89
89
|
? "loaded-pages"
|
|
90
90
|
: "whole-folder";
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* A list request's narrowing parameters, as far as the previous-data guard
|
|
94
|
+
* reads them. The chips, plus the two scopes a view sets for itself: the
|
|
95
|
+
* account the pills name, and whether muted senders are in.
|
|
96
|
+
*
|
|
97
|
+
* The text parameters are deliberately absent, `from` and `subject` with the
|
|
98
|
+
* free text: all three are typed in one field, and a query being refined is the
|
|
99
|
+
* one change that may keep the previous answer on screen while the next is in
|
|
100
|
+
* flight.
|
|
101
|
+
*/
|
|
102
|
+
export type ListFilterParams = InboxFilterParams & {
|
|
103
|
+
accountId?: string;
|
|
104
|
+
muted?: boolean;
|
|
105
|
+
};
|
|
106
|
+
|
|
92
107
|
const FILTER_PARAM_NAMES = [
|
|
93
108
|
"category",
|
|
94
109
|
"unread",
|
|
95
110
|
"starred",
|
|
96
111
|
"attachments",
|
|
112
|
+
"accountId",
|
|
113
|
+
"muted",
|
|
97
114
|
] as const;
|
|
98
115
|
|
|
99
116
|
const filterIdentity = (query: Record<string, unknown> | undefined): string =>
|
|
@@ -121,7 +138,7 @@ const queryOf = (queryKey: unknown): Record<string, unknown> | undefined => {
|
|
|
121
138
|
*/
|
|
122
139
|
export const sameInboxFilter = (
|
|
123
140
|
queryKey: unknown,
|
|
124
|
-
params:
|
|
141
|
+
params: ListFilterParams,
|
|
125
142
|
): boolean => {
|
|
126
143
|
const previous = queryOf(queryKey);
|
|
127
144
|
if (!previous) return false;
|
|
@@ -4,7 +4,10 @@ import { buildSenderFallbackDraft } from "./sender-fallback";
|
|
|
4
4
|
|
|
5
5
|
describe("buildSenderFallbackDraft", () => {
|
|
6
6
|
it("combines the sender clauses with Or and carries no anchor", () => {
|
|
7
|
-
const draft = buildSenderFallbackDraft(
|
|
7
|
+
const draft = buildSenderFallbackDraft(
|
|
8
|
+
["npm@github.com", "a@x.com"],
|
|
9
|
+
["Deploy failed", "Standup notes"],
|
|
10
|
+
);
|
|
8
11
|
assert.equal(draft.matchOperator, "Or");
|
|
9
12
|
assert.equal(draft.anchorMessageId, undefined);
|
|
10
13
|
assert.deepEqual(draft.literalClauses, [
|
|
@@ -14,10 +17,36 @@ describe("buildSenderFallbackDraft", () => {
|
|
|
14
17
|
});
|
|
15
18
|
|
|
16
19
|
it("carries a single FromDomain clause when the senders share a domain", () => {
|
|
17
|
-
const draft = buildSenderFallbackDraft(
|
|
20
|
+
const draft = buildSenderFallbackDraft(
|
|
21
|
+
["npm@github.com", "ci@github.com"],
|
|
22
|
+
["Deploy failed", "Standup notes"],
|
|
23
|
+
);
|
|
18
24
|
assert.equal(draft.matchOperator, "Or");
|
|
19
25
|
assert.deepEqual(draft.literalClauses, [
|
|
20
26
|
{ field: "FromDomain", value: "github.com" },
|
|
21
27
|
]);
|
|
22
28
|
});
|
|
29
|
+
|
|
30
|
+
it("falls back to a shared subject when mixed senders share no domain (#458)", () => {
|
|
31
|
+
const draft = buildSenderFallbackDraft(
|
|
32
|
+
["billing@acme.test", "accounts@globex.test"],
|
|
33
|
+
["Invoice 1841", "Invoice 1902"],
|
|
34
|
+
);
|
|
35
|
+
assert.equal(draft.matchOperator, "Or");
|
|
36
|
+
assert.deepEqual(draft.literalClauses, [
|
|
37
|
+
{ field: "Subject", value: "Invoice" },
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("offers one sender chip each when mixed senders share no subject either", () => {
|
|
42
|
+
const draft = buildSenderFallbackDraft(
|
|
43
|
+
["billing@acme.test", "accounts@globex.test"],
|
|
44
|
+
["Invoice 1841", "Standup notes"],
|
|
45
|
+
);
|
|
46
|
+
assert.equal(draft.matchOperator, "Or");
|
|
47
|
+
assert.deepEqual(draft.literalClauses, [
|
|
48
|
+
{ field: "From", value: "billing@acme.test" },
|
|
49
|
+
{ field: "From", value: "accounts@globex.test" },
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
23
52
|
});
|
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
import type { RemitImapOrganizeInput } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { derivePropertyClauses } from "@remit/ui";
|
|
3
3
|
import type { OrganizeDraft } from "./organize-model";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* The widen fallback for a deployment that ships no vector pipeline (self-host
|
|
7
7
|
* sqlite — semantic-capability.ts). The semantic anchor matches nothing there,
|
|
8
8
|
* so a widen degrades to the literal vocabulary RFC 031 already matches
|
|
9
|
-
* vector-free: the
|
|
10
|
-
* anchor
|
|
11
|
-
*
|
|
9
|
+
* vector-free: what the whole selection agrees on, combined with `Or`, no
|
|
10
|
+
* anchor (#458) — the same evidence order the properties door prefills from,
|
|
11
|
+
* so a selection with nothing in common on sender still falls back to a
|
|
12
|
+
* subject it shares rather than one `From` chip per message. The same
|
|
13
|
+
* predicate matches at index time (RFC 034), so a standing filter built from
|
|
14
|
+
* it keeps working on future mail.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
|
-
* The literal predicate that stands in for the semantic anchor: the
|
|
16
|
-
*
|
|
17
|
-
* back-apply, and the standing filter all carry exactly this.
|
|
18
|
+
* The literal predicate that stands in for the semantic anchor: the clauses
|
|
19
|
+
* the selection agrees on, combined with `Or` and no anchor. The preview, the
|
|
20
|
+
* one-time back-apply, and the standing filter all carry exactly this.
|
|
18
21
|
*/
|
|
19
22
|
export const buildSenderFallbackDraft = (
|
|
20
23
|
senders: readonly string[],
|
|
24
|
+
subjects: readonly string[],
|
|
21
25
|
): OrganizeDraft => ({
|
|
22
26
|
matchOperator: "Or",
|
|
23
|
-
literalClauses:
|
|
27
|
+
literalClauses: derivePropertyClauses(senders, subjects),
|
|
24
28
|
});
|
|
25
29
|
|
|
26
30
|
/**
|
|
@@ -39,9 +39,10 @@ export type ThreadSearchTokenParamName = keyof ThreadSearchTokenParams;
|
|
|
39
39
|
/**
|
|
40
40
|
* Every parameter `searchThreads` takes, the default set.
|
|
41
41
|
*
|
|
42
|
-
* `listAllThreads` takes all
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* `listAllThreads` takes all six as well (#1128), so both callers carry the
|
|
43
|
+
* same tokens. A caller still names its own set rather than inheriting this
|
|
44
|
+
* one: the set is what decides which tokens come back as residue, and an
|
|
45
|
+
* endpoint that grows or loses a parameter has to say so where it is called.
|
|
45
46
|
*/
|
|
46
47
|
export const THREAD_SEARCH_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] =
|
|
47
48
|
["from", "subject", "category", "unread", "starred", "attachments"];
|