@remit/web-client 0.0.79 → 0.0.81

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.79",
3
+ "version": "0.0.81",
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(
@@ -0,0 +1,134 @@
1
+ import type { RemitImapLabelResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { LabelsList } from "./LabelsList";
4
+
5
+ const meta: Meta<typeof LabelsList> = {
6
+ title: "Flows/Settings Labels/LabelsList",
7
+ component: LabelsList,
8
+ parameters: { layout: "padded" },
9
+ decorators: [
10
+ (Story) => (
11
+ <div className="mx-auto max-w-md">
12
+ <Story />
13
+ </div>
14
+ ),
15
+ ],
16
+ args: {
17
+ onRename: () => undefined,
18
+ onRecolor: () => undefined,
19
+ onDelete: () => undefined,
20
+ },
21
+ };
22
+ export default meta;
23
+
24
+ type Story = StoryObj<typeof LabelsList>;
25
+
26
+ const makeLabel = (
27
+ overrides: Partial<RemitImapLabelResponse>,
28
+ ): RemitImapLabelResponse => ({
29
+ labelId: "lbl-1",
30
+ accountConfigId: "acc-1",
31
+ name: "Receipts",
32
+ color: "Blue",
33
+ createdAt: 0,
34
+ updatedAt: 0,
35
+ filterCount: 0,
36
+ ...overrides,
37
+ });
38
+
39
+ const fewLabels: RemitImapLabelResponse[] = [
40
+ makeLabel({
41
+ labelId: "lbl-1",
42
+ name: "Receipts",
43
+ color: "Blue",
44
+ filterCount: 0,
45
+ }),
46
+ makeLabel({
47
+ labelId: "lbl-2",
48
+ name: "Travel",
49
+ color: "Green",
50
+ filterCount: 1,
51
+ }),
52
+ makeLabel({ labelId: "lbl-3", name: "Urgent", color: "Red", filterCount: 3 }),
53
+ ];
54
+
55
+ const manyLabels: RemitImapLabelResponse[] = [
56
+ ...fewLabels,
57
+ makeLabel({
58
+ labelId: "lbl-4",
59
+ name: "Newsletters",
60
+ color: "Yellow",
61
+ filterCount: 2,
62
+ }),
63
+ makeLabel({
64
+ labelId: "lbl-5",
65
+ name: "Work",
66
+ color: "Purple",
67
+ filterCount: 0,
68
+ }),
69
+ makeLabel({
70
+ labelId: "lbl-6",
71
+ name: "Family",
72
+ color: "Teal",
73
+ filterCount: 0,
74
+ }),
75
+ makeLabel({
76
+ labelId: "lbl-7",
77
+ name: "Finance",
78
+ color: "Orange",
79
+ filterCount: 4,
80
+ }),
81
+ makeLabel({
82
+ labelId: "lbl-8",
83
+ name: "Recruiting",
84
+ color: "Gray",
85
+ filterCount: 1,
86
+ }),
87
+ makeLabel({
88
+ labelId: "lbl-9",
89
+ name: "Quarterly compliance filings that need a second look",
90
+ color: "Default",
91
+ filterCount: 1,
92
+ }),
93
+ ];
94
+
95
+ /** No labels yet — the empty-state copy points at the create form below. */
96
+ export const Empty: Story = {
97
+ args: { labels: [] },
98
+ };
99
+
100
+ /** A few labels, each showing its filter-usage line. */
101
+ export const Few: Story = {
102
+ args: { labels: fewLabels },
103
+ };
104
+
105
+ /** The same few labels, on the dark theme. */
106
+ export const FewDark: Story = {
107
+ name: "Few (dark)",
108
+ parameters: { theme: "dark" },
109
+ args: { labels: fewLabels },
110
+ };
111
+
112
+ /** Many labels, including one with a long name that truncates in its chip. */
113
+ export const Many: Story = {
114
+ args: { labels: manyLabels },
115
+ };
116
+
117
+ /**
118
+ * Renaming inline: clicking a label's chip swaps it for a focused text input.
119
+ * Driven here rather than passed as a prop — `editingId` is internal state.
120
+ */
121
+ export const Renaming: Story = {
122
+ args: { labels: fewLabels },
123
+ play: async ({ canvasElement }) => {
124
+ const renameButton = canvasElement.querySelector<HTMLButtonElement>(
125
+ 'button[aria-label="Rename label Receipts"]',
126
+ );
127
+ renameButton?.click();
128
+ },
129
+ };
130
+
131
+ /** A delete in flight — the row's delete button disables. */
132
+ export const Deleting: Story = {
133
+ args: { labels: fewLabels, deletingLabelId: "lbl-2" },
134
+ };
@@ -0,0 +1,83 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
3
+ import { deleteLabelConfirmCopy } from "@/lib/organize/label-delete-copy";
4
+
5
+ /**
6
+ * The cascade-aware label delete confirmation (issue #26, #335) — the generic
7
+ * `ConfirmDialog` driven by `deleteLabelConfirmCopy`'s computed title and
8
+ * description, exactly as `AccountLabels` in the labels settings route wires
9
+ * it. No dedicated component exists for this; reusing `ConfirmDialog` is the
10
+ * approved surface, not a placeholder for one.
11
+ */
12
+ const meta: Meta<typeof ConfirmDialog> = {
13
+ title: "Flows/Settings Labels/Delete Confirmation",
14
+ component: ConfirmDialog,
15
+ parameters: { layout: "centered" },
16
+ };
17
+ export default meta;
18
+
19
+ type Story = StoryObj<typeof ConfirmDialog>;
20
+
21
+ /** Nothing references the label — no blast-radius line under the title. */
22
+ export const Unused: Story = {
23
+ args: {
24
+ isOpen: true,
25
+ ...deleteLabelConfirmCopy("Receipts", 0),
26
+ confirmLabel: "Delete label",
27
+ destructive: true,
28
+ onConfirm: () => undefined,
29
+ onCancel: () => undefined,
30
+ },
31
+ };
32
+
33
+ /** Exactly one filter applies the label — singular wording. */
34
+ export const OneFilter: Story = {
35
+ args: {
36
+ isOpen: true,
37
+ ...deleteLabelConfirmCopy("Receipts", 1),
38
+ confirmLabel: "Delete label",
39
+ destructive: true,
40
+ onConfirm: () => undefined,
41
+ onCancel: () => undefined,
42
+ },
43
+ };
44
+
45
+ /** Several filters apply the label — the cascade warning names the count. */
46
+ export const SeveralFilters: Story = {
47
+ args: {
48
+ isOpen: true,
49
+ ...deleteLabelConfirmCopy("Receipts", 5),
50
+ confirmLabel: "Delete label",
51
+ destructive: true,
52
+ onConfirm: () => undefined,
53
+ onCancel: () => undefined,
54
+ },
55
+ };
56
+
57
+ /** Several filters, on the dark theme. */
58
+ export const SeveralFiltersDark: Story = {
59
+ name: "Several Filters (dark)",
60
+ parameters: { theme: "dark" },
61
+ args: {
62
+ isOpen: true,
63
+ ...deleteLabelConfirmCopy("Receipts", 5),
64
+ confirmLabel: "Delete label",
65
+ destructive: true,
66
+ onConfirm: () => undefined,
67
+ onCancel: () => undefined,
68
+ },
69
+ };
70
+
71
+ /** The delete is in flight — confirm disables rather than allowing a second
72
+ * concurrent delete. */
73
+ export const Busy: Story = {
74
+ args: {
75
+ isOpen: true,
76
+ ...deleteLabelConfirmCopy("Receipts", 5),
77
+ confirmLabel: "Delete label",
78
+ destructive: true,
79
+ isBusy: true,
80
+ onConfirm: () => undefined,
81
+ onCancel: () => undefined,
82
+ },
83
+ };
@@ -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,
@@ -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,