@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/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
- * 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
35
- * client-side Address lookup is needed.
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: `listAllThreads` has no `from` or `subject` of
238
- * its own, and no endpoint has `before:`, `after:`, `in:` or `account:`. A row
239
- * without the data a token needs (no `sentDate` for `before:`/`after:`) never
240
- * matches it, so it drops out rather than showing under an unverifiable filter.
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
- // No `from`/`subject` parameter on this endpoint: its one text parameter
75
- // matches both at once, so these stay a pass over the returned rows rather
76
- // than being dropped from a request that never carried them.
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 `category:` / `is:unread` / `is:read` /
6
- * `has:attachment` / `is:starred` tokens typed in the field — is a column on
7
- * the ThreadMessage row, so all of them travel as query parameters. A criterion
8
- * applied over the pages loaded so far is the defect this replaces: a starred
9
- * collection is paged, so anything below the newest page was invisible to it
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. It has no `from` or `subject`
32
- * of its own — its one text parameter matches both at once — so those two
33
- * tokens stay residue rather than being dropped from a request that never had
34
- * them.
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: InboxFilterParams;
46
+ criteria: ThreadSearchTokenParams;
46
47
  /** The tokens no parameter carries; apply these over the returned rows. */
47
48
  residual: SearchToken[];
48
49
  }
@@ -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: InboxFilterParams,
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 of these except `from` and `subject`, so a caller
43
- * on that endpoint names its own set and the two tokens come back as residue
44
- * rather than being silently dropped from a request that never carried them.
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"];