@remit/web-client 0.0.80 → 0.0.82

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.80",
3
+ "version": "0.0.82",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -41,6 +41,7 @@ import { useSemanticSearch } from "@/hooks/useSemanticSearch";
41
41
  import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
42
42
  import { sortAccountsByCreatedAt } from "@/lib/account-order";
43
43
  import {
44
+ excludeMutedSenders,
44
45
  groupBriefSections,
45
46
  matchesBriefSearch,
46
47
  matchesSearchTokens,
@@ -275,12 +276,14 @@ export function DailyBrief({
275
276
  // `BriefSections` filter row's job, so the full per-category sections are
276
277
  // handed to it; it groups, narrows, and flattens.
277
278
  const filteredRows = useMemo<ThreadRowData[]>(() => {
278
- const briefRows = (threadsData?.items ?? []).map(toThreadRowData);
279
+ const briefRows = excludeMutedSenders(threadsData?.items ?? []).map(
280
+ toThreadRowData,
281
+ );
279
282
  // No free text: the brief list as it comes, order untouched.
280
283
  const rows = sq
281
284
  ? mergeSearchRows(
282
285
  briefRows.filter((t) => matchesBriefSearch(t, sq)),
283
- (searchData?.items ?? []).map(toThreadRowData),
286
+ excludeMutedSenders(searchData?.items ?? []).map(toThreadRowData),
284
287
  )
285
288
  : briefRows;
286
289
  return rows.filter(
@@ -49,6 +49,7 @@ const filterFixture = (
49
49
  state: "Active",
50
50
  hasAnchor: false,
51
51
  ruleChangedAt: 100,
52
+ actionChangedAt: 100,
52
53
  matchOperator: "And",
53
54
  literalClauses: [{ field: "From", value: "receipts@stripe.com" }],
54
55
  actionLabelId: "None",
@@ -23,6 +23,7 @@ const filter = (
23
23
  state: "Active",
24
24
  hasAnchor: true,
25
25
  ruleChangedAt: 0,
26
+ actionChangedAt: 0,
26
27
  matchOperator: "And",
27
28
  literalClauses: [],
28
29
  actionLabelId: "None",
@@ -39,6 +39,7 @@ const makeFilter = (
39
39
  state: "Active",
40
40
  hasAnchor: false,
41
41
  ruleChangedAt: 0,
42
+ actionChangedAt: 0,
42
43
  matchOperator: "And",
43
44
  literalClauses: [
44
45
  { field: "From", value: "receipts@stripe.com" },
@@ -34,6 +34,7 @@ const filter = (
34
34
  state: "Active",
35
35
  hasAnchor: false,
36
36
  ruleChangedAt: 0,
37
+ actionChangedAt: 0,
37
38
  matchOperator: "And",
38
39
  literalClauses: [],
39
40
  actionLabelId: "None",
@@ -28,6 +28,7 @@ const make = (
28
28
  hasAttachment: false,
29
29
  star: "none",
30
30
  hasStars,
31
+ muted: false,
31
32
  createdAt: 0,
32
33
  updatedAt: 0,
33
34
  });
@@ -3,6 +3,7 @@ import { describe, test } from "node:test";
3
3
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
4
4
  import type { ThreadRowData } from "@remit/ui";
5
5
  import {
6
+ excludeMutedSenders,
6
7
  groupBriefSections,
7
8
  matchesBriefSearch,
8
9
  matchesSearchTokens,
@@ -32,6 +33,7 @@ function threadResponse(
32
33
  hasStars: false,
33
34
  star: "none",
34
35
  senderTrust: "unknown",
36
+ muted: false,
35
37
  createdAt: 0,
36
38
  updatedAt: 0,
37
39
  ...overrides,
@@ -265,6 +267,81 @@ describe("groupBriefSections", () => {
265
267
  });
266
268
  });
267
269
 
270
+ // issue #301: `Address.flags.muted` is denormalized onto each row as
271
+ // `muted`; the brief excludes those rows before grouping into sections.
272
+ describe("excludeMutedSenders", () => {
273
+ test("drops a row whose sender is muted", () => {
274
+ const kept = threadResponse({ threadMessageId: "keep" });
275
+ const muted = threadResponse({ threadMessageId: "mute-me", muted: true });
276
+ const result = excludeMutedSenders([kept, muted]);
277
+ assert.deepStrictEqual(
278
+ result.map((t) => t.threadMessageId),
279
+ ["keep"],
280
+ );
281
+ });
282
+
283
+ test("keeps rows whose sender is not muted", () => {
284
+ const rows = [
285
+ threadResponse({ threadMessageId: "a", muted: false }),
286
+ threadResponse({ threadMessageId: "b" }),
287
+ ];
288
+ assert.strictEqual(excludeMutedSenders(rows).length, 2);
289
+ });
290
+
291
+ test("returns an empty array when every sender is muted", () => {
292
+ const rows = [
293
+ threadResponse({ threadMessageId: "a", muted: true }),
294
+ threadResponse({ threadMessageId: "b", muted: true }),
295
+ ];
296
+ assert.deepStrictEqual(excludeMutedSenders(rows), []);
297
+ });
298
+
299
+ test("a muted sender's message is excluded from every section, not folded into uncategorized", () => {
300
+ const rows = [
301
+ threadResponse({
302
+ threadMessageId: "muted-personal",
303
+ messageId: "muted-personal",
304
+ category: "personal",
305
+ muted: true,
306
+ }),
307
+ threadResponse({
308
+ threadMessageId: "kept-personal",
309
+ messageId: "kept-personal",
310
+ category: "personal",
311
+ }),
312
+ ];
313
+ const sections = groupBriefSections(
314
+ excludeMutedSenders(rows).map(toThreadRowData),
315
+ );
316
+ const allIds = sections.flatMap((s) => s.threads.map((t) => t.id));
317
+ assert.deepStrictEqual(allIds, ["kept-personal"]);
318
+ for (const section of sections) {
319
+ assert.ok(!section.threads.some((t) => t.id === "muted-personal"));
320
+ }
321
+ });
322
+
323
+ test("muting every candidate row leaves no sections (brief empty state)", () => {
324
+ const rows = [
325
+ threadResponse({
326
+ threadMessageId: "a",
327
+ messageId: "a",
328
+ category: "personal",
329
+ muted: true,
330
+ }),
331
+ threadResponse({
332
+ threadMessageId: "b",
333
+ messageId: "b",
334
+ category: "newsletter",
335
+ muted: true,
336
+ }),
337
+ ];
338
+ const sections = groupBriefSections(
339
+ excludeMutedSenders(rows).map(toThreadRowData),
340
+ );
341
+ assert.deepStrictEqual(sections, []);
342
+ });
343
+ });
344
+
268
345
  describe("matchesBriefSearch", () => {
269
346
  const r = row({
270
347
  id: "1",
package/src/lib/brief.ts CHANGED
@@ -24,7 +24,11 @@
24
24
  * a high-volume mailbox read≠handled and unread≠important; unread is a
25
25
  * user-selectable filter chip instead.
26
26
  *
27
- * Muted senders (filtered by the caller) and empty sections are excluded.
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
+ * client-side Address lookup is needed.
28
32
  */
29
33
 
30
34
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
@@ -67,6 +71,19 @@ export function toThreadRowData(
67
71
  };
68
72
  }
69
73
 
74
+ /**
75
+ * Excludes rows whose From address is muted (`thread.muted === true`,
76
+ * denormalized server-side from `Address.flags.muted`). Muting hides a
77
+ * sender from the brief only — it never deletes, marks read, or moves their
78
+ * mail, so callers outside the brief (mailbox listings, search) must not
79
+ * apply this filter.
80
+ */
81
+ export function excludeMutedSenders(
82
+ threads: RemitImapThreadMessageResponse[],
83
+ ): RemitImapThreadMessageResponse[] {
84
+ return threads.filter((t) => t.muted !== true);
85
+ }
86
+
70
87
  /**
71
88
  * Union of the brief's own rows with the rows the server's cross-folder search
72
89
  * returned, newest first.
@@ -24,6 +24,7 @@ function threadMessage(
24
24
  hasStars: false,
25
25
  star: "none",
26
26
  senderTrust: "unknown",
27
+ muted: false,
27
28
  createdAt: 0,
28
29
  updatedAt: 0,
29
30
  ...overrides,
@@ -20,6 +20,7 @@ const filter = (
20
20
  state: "Active",
21
21
  hasAnchor: false,
22
22
  ruleChangedAt: 0,
23
+ actionChangedAt: 0,
23
24
  matchOperator: "And",
24
25
  literalClauses: [{ field: "From", value: "receipts@stripe.com" }],
25
26
  actionLabelId: "None",
@@ -78,6 +78,7 @@ export const makeThreadMessage = (
78
78
  hasStars: false,
79
79
  isDeleted: false,
80
80
  senderTrust: "unknown",
81
+ muted: false,
81
82
  createdAt: 0,
82
83
  updatedAt: 0,
83
84
  ...overrides,