@remit/web-client 0.0.279 → 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/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/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/thread-search-tokens.ts +4 -3
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;
|
|
@@ -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"];
|