@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/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