@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 +1 -1
- package/src/components/mail/DailyBrief.tsx +5 -2
- package/src/components/settings/LabelsList.stories.tsx +134 -0
- package/src/components/settings/label-delete-confirm.stories.tsx +83 -0
- package/src/hooks/useToggleStar.test.ts +1 -0
- package/src/lib/brief.test.ts +77 -0
- package/src/lib/brief.ts +18 -1
- package/src/lib/dedupe-thread-messages.test.ts +1 -0
- package/src/test-support/fixtures.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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(
|
|
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
|
+
};
|
package/src/lib/brief.test.ts
CHANGED
|
@@ -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
|
|
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.
|