@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/src/lib/brief.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Daily brief grouping logic.
2
+ * Daily brief section assembly.
3
3
  *
4
- * Pure function: takes a flat list of thread message rows and returns one
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 row lands in the section for its category; a row with no category counts
16
- * as `uncategorized`, which is its own section rather than being folded into
17
- * Personal — unclassified mail is missing work, not a decision (issue #45).
18
- * Starred mail is not a section —
19
- * the star is a per-row marker, so a starred message stays in its category.
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 empty sections are excluded. Mute filtering happens in
28
- * `excludeMutedSenders`, applied by the caller to the raw thread rows before
29
- * `toThreadRowData`/grouping — the server denormalizes `muted` onto each row
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 the brief's own rows with the rows the server's cross-folder search
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
- * Both lists are needed. The server matches subject and From only, so a row
93
- * whose snippet carries the term is found only by the client-side pass over the
94
- * loaded brief rows; the server pass is the only one that reaches Archive,
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 row category that routes into it.
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
- * Group a flat list of thread row data into one section per message category.
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
- * @param rows Flat array of ThreadRowData, sorted newest-first.
151
- * @returns Array of ThreadSection in category display order — empty
152
- * sections are omitted.
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 groupBriefSections(rows: ThreadRowData[]): ThreadSection[] {
155
- const byCategory = new Map<string, ThreadRowData[]>(
156
- CATEGORY_SECTIONS.map((s) => [s.category, []]),
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 threads = byCategory.get(section.category);
169
- if (threads && threads.length > 0)
170
- sections.push({
171
- id: section.id,
172
- label: section.label,
173
- threads,
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 parsed filter token — the client-side
194
- * counterpart of the params `threadOperationsSearchThreads` accepts, applied
195
- * over the already-loaded brief/flagged rows (#428). A row without the data a
196
- * token needs (e.g. no `sentDate` for `before:`/`after:`) never matches that
197
- * token, so it drops out rather than showing under an unverifiable filter.
198
- * `in:`/`account:` have no server param at all — this is the entire
199
- * implementation of both, reusing the same per-account fan-out the daily
200
- * brief already reads its rows from (`accountId`/`mailboxId` are already on
201
- * every row).
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 groupBriefSections).
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 };