@remit/ui 0.0.6 → 0.0.8
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/app-shell-slotted.render.test.ts +31 -0
- package/src/components/app-shell-slotted.tsx +10 -0
- package/src/components/intelligence-panel.tsx +12 -1
- package/src/components/mail-header.render.test.ts +57 -0
- package/src/components/mail-header.tsx +18 -1
- package/src/components/rescue-candidate-row.tsx +33 -19
- package/src/components/rescue-from-spam-flow.tsx +34 -13
- package/src/components/rescue-sender-group.render.test.ts +67 -0
- package/src/components/rescue-sender-group.test.ts +91 -0
- package/src/components/rescue-sender-group.tsx +173 -0
- package/src/index.ts +8 -0
- package/src/tokens.css +3 -0
package/package.json
CHANGED
|
@@ -105,6 +105,37 @@ describe("AppShellSlotted slot rendering", () => {
|
|
|
105
105
|
);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
it("renders the top bar above the panes, at every width", () => {
|
|
109
|
+
const topBarEl = createElement("div", { "data-testid": "topbar" }, "Bar");
|
|
110
|
+
for (const initialWidth of [800, 1100, 1400]) {
|
|
111
|
+
const html = render({ initialWidth, topBar: topBarEl });
|
|
112
|
+
assert.match(
|
|
113
|
+
html,
|
|
114
|
+
/data-testid="topbar"/,
|
|
115
|
+
`top bar at ${initialWidth}px`,
|
|
116
|
+
);
|
|
117
|
+
assert.ok(
|
|
118
|
+
html.indexOf('data-testid="topbar"') <
|
|
119
|
+
html.indexOf('data-testid="list"'),
|
|
120
|
+
`top bar precedes the pane group at ${initialWidth}px`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("omits the top bar when the caller passes none", () => {
|
|
126
|
+
const html = render({ initialWidth: 1100 });
|
|
127
|
+
assert.doesNotMatch(html, /data-testid="topbar"/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("hides the top bar with the rest of the layout while loading", () => {
|
|
131
|
+
const html = render({
|
|
132
|
+
isLoading: true,
|
|
133
|
+
skeleton: createElement("div", { "data-testid": "skeleton" }, "Loading"),
|
|
134
|
+
topBar: createElement("div", { "data-testid": "topbar" }, "Bar"),
|
|
135
|
+
});
|
|
136
|
+
assert.doesNotMatch(html, /data-testid="topbar"/);
|
|
137
|
+
});
|
|
138
|
+
|
|
108
139
|
it("renders the overlay regardless of width", () => {
|
|
109
140
|
const overlayEl = createElement("div", { "data-testid": "overlay" }, "FAB");
|
|
110
141
|
const narrowHtml = render({ initialWidth: 800, overlay: overlayEl });
|
|
@@ -59,6 +59,13 @@ export interface AppShellSlottedProps {
|
|
|
59
59
|
* The web-client injects the app-specific bar (hamburger / title / search).
|
|
60
60
|
*/
|
|
61
61
|
header?: ReactNode;
|
|
62
|
+
/**
|
|
63
|
+
* Full-width row above every pane, including the nav. Unlike `header` it is
|
|
64
|
+
* rendered at whatever width the caller mounts it, and it spans the whole
|
|
65
|
+
* shell rather than sitting inside a pane — that width is what makes the
|
|
66
|
+
* search field in it read as the app's search rather than the list's.
|
|
67
|
+
*/
|
|
68
|
+
topBar?: ReactNode;
|
|
62
69
|
/**
|
|
63
70
|
* Content rendered outside the pane group (e.g., the compose FAB). Floats
|
|
64
71
|
* over the layout regardless of width.
|
|
@@ -130,6 +137,7 @@ export function AppShellSlotted({
|
|
|
130
137
|
hasThread = true,
|
|
131
138
|
density = "comfortable",
|
|
132
139
|
header,
|
|
140
|
+
topBar,
|
|
133
141
|
overlay,
|
|
134
142
|
skeleton,
|
|
135
143
|
isLoading = false,
|
|
@@ -178,6 +186,8 @@ export function AppShellSlotted({
|
|
|
178
186
|
skeleton
|
|
179
187
|
) : (
|
|
180
188
|
<>
|
|
189
|
+
{topBar}
|
|
190
|
+
|
|
181
191
|
{/* Narrow top bar: rendered only when the nav is a slide-over
|
|
182
192
|
(< 1024px). Desktop has no slim bar. */}
|
|
183
193
|
{!showNavPane && header && <div className="shrink-0">{header}</div>}
|
|
@@ -320,16 +320,22 @@ function QuickAction({
|
|
|
320
320
|
danger?: boolean;
|
|
321
321
|
onClick?: () => void;
|
|
322
322
|
}) {
|
|
323
|
+
// No handler means the action cannot be serviced yet (the sender's address
|
|
324
|
+
// record has not resolved). Render it visibly unavailable rather than as a
|
|
325
|
+
// live button that swallows the click.
|
|
326
|
+
const disabled = onClick === undefined;
|
|
323
327
|
return (
|
|
324
328
|
<button
|
|
325
329
|
type="button"
|
|
326
330
|
onClick={onClick}
|
|
331
|
+
disabled={disabled}
|
|
327
332
|
className={cn(
|
|
328
333
|
"flex items-center gap-2 rounded-md border px-2 py-1 text-xs transition-colors",
|
|
329
334
|
active
|
|
330
335
|
? "border-accent-2 bg-accent-2-soft text-accent-2"
|
|
331
336
|
: "border-line text-fg-muted hover:border-line-strong hover:text-fg",
|
|
332
337
|
danger && !active && "text-danger hover:bg-danger-soft",
|
|
338
|
+
disabled && "cursor-not-allowed opacity-50 hover:border-line",
|
|
333
339
|
)}
|
|
334
340
|
>
|
|
335
341
|
{icon}
|
|
@@ -401,7 +407,12 @@ export function IntelligencePanel({
|
|
|
401
407
|
)}
|
|
402
408
|
<button
|
|
403
409
|
type="button"
|
|
404
|
-
className=
|
|
410
|
+
className={cn(
|
|
411
|
+
"ml-auto text-2xs text-accent hover:underline",
|
|
412
|
+
!actions?.onReclassify &&
|
|
413
|
+
"cursor-not-allowed opacity-50 hover:no-underline",
|
|
414
|
+
)}
|
|
415
|
+
disabled={!actions?.onReclassify}
|
|
405
416
|
onClick={actions?.onReclassify}
|
|
406
417
|
>
|
|
407
418
|
reclassify
|
|
@@ -2,7 +2,9 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import { createElement } from "react";
|
|
4
4
|
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { AppTopBar } from "./app-top-bar.js";
|
|
5
6
|
import { MailHeader, type MailHeaderProps } from "./mail-header.js";
|
|
7
|
+
import { SearchBar } from "./search-bar.js";
|
|
6
8
|
|
|
7
9
|
function render(overrides: Partial<MailHeaderProps> = {}): string {
|
|
8
10
|
return renderToString(
|
|
@@ -67,3 +69,58 @@ describe("MailHeader", () => {
|
|
|
67
69
|
assert.match(html, /Daily brief/);
|
|
68
70
|
});
|
|
69
71
|
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One search field per page (#49).
|
|
75
|
+
*
|
|
76
|
+
* Moving search into `AppTopBar` puts a second `SearchBar` on the same screen
|
|
77
|
+
* as the list header's own. Two mounted fields compete for the "/" shortcut and
|
|
78
|
+
* for focus, and phase 1's review already found a pair colliding on a shared
|
|
79
|
+
* DOM id. `showSearch` is what holds the count at one, so the count is what
|
|
80
|
+
* these assert.
|
|
81
|
+
*/
|
|
82
|
+
describe("MailHeader search ownership", () => {
|
|
83
|
+
const countSearchInputs = (html: string): number =>
|
|
84
|
+
html.match(/aria-label="Search mail"/g)?.length ?? 0;
|
|
85
|
+
|
|
86
|
+
const topBar = (): string =>
|
|
87
|
+
renderToString(
|
|
88
|
+
createElement(AppTopBar, {
|
|
89
|
+
search: createElement(SearchBar, {
|
|
90
|
+
value: "",
|
|
91
|
+
onChange: () => undefined,
|
|
92
|
+
onClear: () => undefined,
|
|
93
|
+
size: "lg",
|
|
94
|
+
}),
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
it("mounts no search field, and no magnifier, when showSearch is false", () => {
|
|
99
|
+
const html = render({ isDesktop: true, showSearch: false });
|
|
100
|
+
assert.equal(countSearchInputs(html), 0);
|
|
101
|
+
assert.doesNotMatch(html, /aria-label="Search"/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("keeps the title and unread count when search lives elsewhere", () => {
|
|
105
|
+
const html = render({ isDesktop: true, showSearch: false });
|
|
106
|
+
assert.match(html, /Daily brief/);
|
|
107
|
+
assert.match(html, /15,338 unread/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("suppresses the field below desktop too, where the takeover owns search", () => {
|
|
111
|
+
assert.equal(
|
|
112
|
+
countSearchInputs(render({ isDesktop: false, showSearch: false })),
|
|
113
|
+
0,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("leaves exactly one field on screen beside the app top bar", () => {
|
|
118
|
+
const combined = topBar() + render({ isDesktop: true, showSearch: false });
|
|
119
|
+
assert.equal(countSearchInputs(combined), 1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("would be two fields if the header kept its own — the premise being guarded", () => {
|
|
123
|
+
const combined = topBar() + render({ isDesktop: true });
|
|
124
|
+
assert.equal(countSearchInputs(combined), 2);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -22,6 +22,12 @@ export interface MailHeaderProps {
|
|
|
22
22
|
/** Mobile only: whether the inline search is expanded over the title. */
|
|
23
23
|
searchOpen: boolean;
|
|
24
24
|
onSearchOpenChange: (open: boolean) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Whether this header owns a search surface at all. Defaults to true. Set
|
|
27
|
+
* false where an enclosing `AppTopBar` already carries the search field, so
|
|
28
|
+
* the page never mounts two search inputs competing for the same focus.
|
|
29
|
+
*/
|
|
30
|
+
showSearch?: boolean;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
/**
|
|
@@ -44,6 +50,7 @@ export function MailHeader({
|
|
|
44
50
|
onSearchClear,
|
|
45
51
|
searchOpen,
|
|
46
52
|
onSearchOpenChange,
|
|
53
|
+
showSearch = true,
|
|
47
54
|
}: MailHeaderProps) {
|
|
48
55
|
const unreadLabel = `${unreadCount.toLocaleString()} unread`;
|
|
49
56
|
const clearSearch = onSearchClear ?? (() => onSearchChange(""));
|
|
@@ -75,7 +82,17 @@ export function MailHeader({
|
|
|
75
82
|
return (
|
|
76
83
|
<header className="flex shrink-0 flex-col bg-canvas">
|
|
77
84
|
<div className="flex h-12 items-center gap-2 px-row-inset">
|
|
78
|
-
{
|
|
85
|
+
{!showSearch ? (
|
|
86
|
+
<>
|
|
87
|
+
{menuButton}
|
|
88
|
+
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
89
|
+
{title}
|
|
90
|
+
</h1>
|
|
91
|
+
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
92
|
+
{unreadLabel}
|
|
93
|
+
</span>
|
|
94
|
+
</>
|
|
95
|
+
) : isDesktop ? (
|
|
79
96
|
<>
|
|
80
97
|
{menuButton}
|
|
81
98
|
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
@@ -25,6 +25,12 @@ export interface RescueCandidateRowProps {
|
|
|
25
25
|
candidate: RescueCandidate;
|
|
26
26
|
selected: boolean;
|
|
27
27
|
onToggle: () => void;
|
|
28
|
+
/**
|
|
29
|
+
* Drop the sender identity and trust chip from the row. Set when the row sits
|
|
30
|
+
* under a sender group that already states both — repeating them per message
|
|
31
|
+
* is the noise the grouping exists to remove.
|
|
32
|
+
*/
|
|
33
|
+
hideSender?: boolean;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
/**
|
|
@@ -37,6 +43,7 @@ export function RescueCandidateRow({
|
|
|
37
43
|
candidate,
|
|
38
44
|
selected,
|
|
39
45
|
onToggle,
|
|
46
|
+
hideSender = false,
|
|
40
47
|
}: RescueCandidateRowProps) {
|
|
41
48
|
const {
|
|
42
49
|
senderName,
|
|
@@ -53,6 +60,7 @@ export function RescueCandidateRow({
|
|
|
53
60
|
<label
|
|
54
61
|
className={cn(
|
|
55
62
|
"flex w-full cursor-pointer items-start gap-3 px-3 py-2.5 text-left transition-colors",
|
|
63
|
+
hideSender && "pl-11",
|
|
56
64
|
selected ? "bg-positive/10" : "bg-surface hover:bg-surface-sunken",
|
|
57
65
|
)}
|
|
58
66
|
>
|
|
@@ -60,34 +68,40 @@ export function RescueCandidateRow({
|
|
|
60
68
|
className="mt-0.5"
|
|
61
69
|
checked={selected}
|
|
62
70
|
onChange={onToggle}
|
|
63
|
-
aria-label={`${selected ? "Deselect" : "Select"} message from ${senderName}`}
|
|
71
|
+
aria-label={`${selected ? "Deselect" : "Select"} ${hideSender ? subject : `message from ${senderName}`}`}
|
|
64
72
|
/>
|
|
65
73
|
|
|
66
|
-
|
|
74
|
+
{!hideSender && (
|
|
75
|
+
<Avatar name={senderName} email={senderAddress} size="sm" />
|
|
76
|
+
)}
|
|
67
77
|
|
|
68
78
|
<span className="min-w-0 flex-1">
|
|
69
|
-
|
|
70
|
-
<span className="
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
{!hideSender && (
|
|
80
|
+
<span className="flex items-center gap-1.5">
|
|
81
|
+
<span className="truncate text-sm font-medium text-fg">
|
|
82
|
+
{senderName}
|
|
83
|
+
</span>
|
|
84
|
+
{senderTrust && (
|
|
85
|
+
<SenderTrustIndicator senderTrust={senderTrust} size="sm" />
|
|
86
|
+
)}
|
|
87
|
+
<span className="truncate text-2xs text-fg-subtle">
|
|
88
|
+
{senderAddress}
|
|
89
|
+
</span>
|
|
78
90
|
</span>
|
|
79
|
-
|
|
91
|
+
)}
|
|
80
92
|
<span className="block truncate text-sm text-fg-muted">{subject}</span>
|
|
81
93
|
<span className="block line-clamp-1 text-xs text-fg-subtle">
|
|
82
94
|
{snippet}
|
|
83
95
|
</span>
|
|
84
|
-
|
|
85
|
-
<
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
96
|
+
{!hideSender && (
|
|
97
|
+
<span className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
|
98
|
+
<Badge tone="positive">
|
|
99
|
+
<ShieldCheck className="size-3" aria-hidden />
|
|
100
|
+
{trustReason}
|
|
101
|
+
</Badge>
|
|
102
|
+
<span className="text-2xs text-fg-subtle">{trustSubReason}</span>
|
|
103
|
+
</span>
|
|
104
|
+
)}
|
|
91
105
|
</span>
|
|
92
106
|
</label>
|
|
93
107
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CheckCircle2, HelpCircle, ShieldCheck } from "lucide-react";
|
|
2
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { Banner } from "./banner.js";
|
|
4
4
|
import { BottomSheet } from "./bottom-sheet.js";
|
|
5
5
|
import { Button } from "./button.js";
|
|
@@ -7,10 +7,12 @@ import {
|
|
|
7
7
|
type MoveMailboxOption,
|
|
8
8
|
MoveMailboxPicker,
|
|
9
9
|
} from "./move-mailbox-picker.js";
|
|
10
|
+
import type { RescueCandidate } from "./rescue-candidate-row.js";
|
|
10
11
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
groupRescueCandidatesBySender,
|
|
13
|
+
type RescueSenderGroup,
|
|
14
|
+
RescueSenderGroupRow,
|
|
15
|
+
} from "./rescue-sender-group.js";
|
|
14
16
|
|
|
15
17
|
type Step = "review" | "destination" | "done";
|
|
16
18
|
|
|
@@ -60,6 +62,7 @@ function ReviewStep({
|
|
|
60
62
|
candidates,
|
|
61
63
|
selected,
|
|
62
64
|
onToggle,
|
|
65
|
+
onToggleGroup,
|
|
63
66
|
onSelectAll,
|
|
64
67
|
onSelectNone,
|
|
65
68
|
onContinue,
|
|
@@ -68,6 +71,7 @@ function ReviewStep({
|
|
|
68
71
|
candidates: RescueCandidate[];
|
|
69
72
|
selected: Set<string>;
|
|
70
73
|
onToggle: (id: string) => void;
|
|
74
|
+
onToggleGroup: (group: RescueSenderGroup, nextSelected: boolean) => void;
|
|
71
75
|
onSelectAll: () => void;
|
|
72
76
|
onSelectNone: () => void;
|
|
73
77
|
onContinue: () => void;
|
|
@@ -75,6 +79,10 @@ function ReviewStep({
|
|
|
75
79
|
}) {
|
|
76
80
|
const [showWhy, setShowWhy] = useState(false);
|
|
77
81
|
const count = selected.size;
|
|
82
|
+
const groups = useMemo(
|
|
83
|
+
() => groupRescueCandidatesBySender(candidates),
|
|
84
|
+
[candidates],
|
|
85
|
+
);
|
|
78
86
|
return (
|
|
79
87
|
<div className="flex min-h-0 flex-col">
|
|
80
88
|
<div className="px-row-inset pb-2 pt-1">
|
|
@@ -94,8 +102,8 @@ function ReviewStep({
|
|
|
94
102
|
</Button>
|
|
95
103
|
</div>
|
|
96
104
|
<p className="mt-1 text-2xs text-fg-subtle">
|
|
97
|
-
These look safe — from senders we can verify. Uncheck
|
|
98
|
-
belongs in Spam.
|
|
105
|
+
These look safe — from senders we can verify. Uncheck any sender that
|
|
106
|
+
belongs in Spam, or open a sender to pick individual messages.
|
|
99
107
|
</p>
|
|
100
108
|
{showWhy && (
|
|
101
109
|
<div className="mt-2">
|
|
@@ -106,7 +114,7 @@ function ReviewStep({
|
|
|
106
114
|
|
|
107
115
|
<div className="flex items-center justify-between px-row-inset pb-1.5">
|
|
108
116
|
<span className="text-2xs font-medium text-fg-muted">
|
|
109
|
-
{`${count} of ${candidates.length} selected`}
|
|
117
|
+
{`${count} of ${candidates.length} selected · ${groups.length} ${groups.length === 1 ? "sender" : "senders"}`}
|
|
110
118
|
</span>
|
|
111
119
|
<span className="flex gap-1">
|
|
112
120
|
<Button variant="ghost" size="sm" onClick={onSelectAll}>
|
|
@@ -119,12 +127,13 @@ function ReviewStep({
|
|
|
119
127
|
</div>
|
|
120
128
|
|
|
121
129
|
<div className="min-h-0 flex-1 divide-y divide-line overflow-y-auto border-y border-line">
|
|
122
|
-
{
|
|
123
|
-
<
|
|
124
|
-
key={
|
|
125
|
-
|
|
126
|
-
selected={selected
|
|
127
|
-
|
|
130
|
+
{groups.map((group) => (
|
|
131
|
+
<RescueSenderGroupRow
|
|
132
|
+
key={group.key}
|
|
133
|
+
group={group}
|
|
134
|
+
selected={selected}
|
|
135
|
+
onToggleGroup={onToggleGroup}
|
|
136
|
+
onToggleMessage={onToggle}
|
|
128
137
|
/>
|
|
129
138
|
))}
|
|
130
139
|
</div>
|
|
@@ -300,6 +309,17 @@ export function RescueFromSpamFlow({
|
|
|
300
309
|
});
|
|
301
310
|
};
|
|
302
311
|
|
|
312
|
+
const toggleGroup = (group: RescueSenderGroup, nextSelected: boolean) => {
|
|
313
|
+
setSelected((prev) => {
|
|
314
|
+
const next = new Set(prev);
|
|
315
|
+
for (const message of group.messages) {
|
|
316
|
+
if (nextSelected) next.add(message.id);
|
|
317
|
+
else next.delete(message.id);
|
|
318
|
+
}
|
|
319
|
+
return next;
|
|
320
|
+
});
|
|
321
|
+
};
|
|
322
|
+
|
|
303
323
|
const move = () => {
|
|
304
324
|
const ids = Array.from(selected);
|
|
305
325
|
setMovedCount(ids.length);
|
|
@@ -314,6 +334,7 @@ export function RescueFromSpamFlow({
|
|
|
314
334
|
candidates={candidates}
|
|
315
335
|
selected={selected}
|
|
316
336
|
onToggle={toggle}
|
|
337
|
+
onToggleGroup={toggleGroup}
|
|
317
338
|
onSelectAll={() => setSelected(new Set(candidates.map((c) => c.id)))}
|
|
318
339
|
onSelectNone={() => setSelected(new Set())}
|
|
319
340
|
onContinue={() => setStep("destination")}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import type { RescueCandidate } from "./rescue-candidate-row.js";
|
|
6
|
+
import {
|
|
7
|
+
groupRescueCandidatesBySender,
|
|
8
|
+
RescueSenderGroupRow,
|
|
9
|
+
} from "./rescue-sender-group.js";
|
|
10
|
+
|
|
11
|
+
const noop = () => {};
|
|
12
|
+
|
|
13
|
+
const candidate = (id: string, subject: string): RescueCandidate => ({
|
|
14
|
+
id,
|
|
15
|
+
senderName: "Shop News",
|
|
16
|
+
senderAddress: "news@shop.com",
|
|
17
|
+
subject,
|
|
18
|
+
snippet: "",
|
|
19
|
+
trustReason: "We can verify this sender",
|
|
20
|
+
trustSubReason: "You've emailed them before",
|
|
21
|
+
senderTrust: "wellknown",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const [group] = groupRescueCandidatesBySender([
|
|
25
|
+
candidate("a", "Autumn sale"),
|
|
26
|
+
candidate("b", "Winter sale"),
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const render = (selected: Set<string>): string =>
|
|
30
|
+
renderToString(
|
|
31
|
+
createElement(RescueSenderGroupRow, {
|
|
32
|
+
group,
|
|
33
|
+
selected,
|
|
34
|
+
onToggleGroup: noop,
|
|
35
|
+
onToggleMessage: noop,
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
describe("RescueSenderGroupRow", () => {
|
|
40
|
+
it("states the sender once and the message count instead of repeating rows", () => {
|
|
41
|
+
const html = render(new Set(["a", "b"]));
|
|
42
|
+
assert.match(html, /Shop News/);
|
|
43
|
+
assert.match(html, /2 messages/);
|
|
44
|
+
assert.equal(
|
|
45
|
+
html.split("news@shop.com").length - 1,
|
|
46
|
+
1,
|
|
47
|
+
"the address appears once per sender, not once per message",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("keeps the individual messages collapsed until asked for", () => {
|
|
52
|
+
const html = render(new Set(["a", "b"]));
|
|
53
|
+
assert.doesNotMatch(html, /Autumn sale/);
|
|
54
|
+
assert.match(html, /aria-expanded="false"/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("offers one checkbox covering every message from the sender", () => {
|
|
58
|
+
const html = render(new Set(["a", "b"]));
|
|
59
|
+
assert.match(html, /Deselect all 2 messages from Shop News/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("shows a partial selection as indeterminate, not as unselected", () => {
|
|
63
|
+
const html = render(new Set(["a"]));
|
|
64
|
+
assert.doesNotMatch(html, /checked=""/);
|
|
65
|
+
assert.match(html, /Select all 2 messages from Shop News/);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { RescueCandidate } from "./rescue-candidate-row.js";
|
|
4
|
+
import {
|
|
5
|
+
groupRescueCandidatesBySender,
|
|
6
|
+
senderGroupSelectionState,
|
|
7
|
+
} from "./rescue-sender-group.js";
|
|
8
|
+
|
|
9
|
+
const candidate = (
|
|
10
|
+
id: string,
|
|
11
|
+
senderAddress: string,
|
|
12
|
+
senderName = senderAddress,
|
|
13
|
+
): RescueCandidate => ({
|
|
14
|
+
id,
|
|
15
|
+
senderName,
|
|
16
|
+
senderAddress,
|
|
17
|
+
subject: `subject ${id}`,
|
|
18
|
+
snippet: "",
|
|
19
|
+
trustReason: "We can verify this sender",
|
|
20
|
+
trustSubReason: "You've emailed them before",
|
|
21
|
+
senderTrust: "wellknown",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("groupRescueCandidatesBySender", () => {
|
|
25
|
+
it("collapses a queue into one entry per sender", () => {
|
|
26
|
+
const groups = groupRescueCandidatesBySender([
|
|
27
|
+
candidate("a", "news@shop.com"),
|
|
28
|
+
candidate("b", "mum@gmail.com"),
|
|
29
|
+
candidate("c", "news@shop.com"),
|
|
30
|
+
candidate("d", "news@shop.com"),
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
groups.map((g) => [g.senderAddress, g.messages.length]),
|
|
35
|
+
[
|
|
36
|
+
["news@shop.com", 3],
|
|
37
|
+
["mum@gmail.com", 1],
|
|
38
|
+
],
|
|
39
|
+
"biggest sender first — that is where the review time goes",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("treats a sender as one sender regardless of address casing", () => {
|
|
44
|
+
const groups = groupRescueCandidatesBySender([
|
|
45
|
+
candidate("a", "News@Shop.com"),
|
|
46
|
+
candidate("b", "news@shop.com"),
|
|
47
|
+
]);
|
|
48
|
+
assert.equal(groups.length, 1);
|
|
49
|
+
assert.equal(groups[0].messages.length, 2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("keeps arrival order between senders with the same message count", () => {
|
|
53
|
+
const groups = groupRescueCandidatesBySender([
|
|
54
|
+
candidate("a", "first@x.com"),
|
|
55
|
+
candidate("b", "second@x.com"),
|
|
56
|
+
]);
|
|
57
|
+
assert.deepEqual(
|
|
58
|
+
groups.map((g) => g.senderAddress),
|
|
59
|
+
["first@x.com", "second@x.com"],
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("falls back to the sender name when there is no address", () => {
|
|
64
|
+
const groups = groupRescueCandidatesBySender([
|
|
65
|
+
candidate("a", "", "Unknown sender"),
|
|
66
|
+
candidate("b", "", "Unknown sender"),
|
|
67
|
+
]);
|
|
68
|
+
assert.equal(groups.length, 1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns nothing for an empty queue", () => {
|
|
72
|
+
assert.deepEqual(groupRescueCandidatesBySender([]), []);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("senderGroupSelectionState", () => {
|
|
77
|
+
const [group] = groupRescueCandidatesBySender([
|
|
78
|
+
candidate("a", "news@shop.com"),
|
|
79
|
+
candidate("b", "news@shop.com"),
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
it("reports all, some and none so the group checkbox can be tri-state", () => {
|
|
83
|
+
assert.equal(senderGroupSelectionState(group, new Set(["a", "b"])), "all");
|
|
84
|
+
assert.equal(senderGroupSelectionState(group, new Set(["a"])), "some");
|
|
85
|
+
assert.equal(senderGroupSelectionState(group, new Set()), "none");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("ignores selections belonging to other senders", () => {
|
|
89
|
+
assert.equal(senderGroupSelectionState(group, new Set(["zz"])), "none");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { ChevronDown, ChevronRight, ShieldCheck } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { Avatar } from "./avatar.js";
|
|
5
|
+
import { Badge } from "./badge.js";
|
|
6
|
+
import { Checkbox } from "./checkbox.js";
|
|
7
|
+
import {
|
|
8
|
+
type RescueCandidate,
|
|
9
|
+
RescueCandidateRow,
|
|
10
|
+
} from "./rescue-candidate-row.js";
|
|
11
|
+
import { SenderTrustIndicator } from "./sender-trust-indicator.js";
|
|
12
|
+
|
|
13
|
+
/** Every suspected-safe message from one sender, as one reviewable unit. */
|
|
14
|
+
export interface RescueSenderGroup {
|
|
15
|
+
/**
|
|
16
|
+
* Stable identity for the group: the lowercased sender address, falling back
|
|
17
|
+
* to the lowercased sender name when a message carries no address. Treat it
|
|
18
|
+
* as an opaque key — it is not guaranteed to be an email address.
|
|
19
|
+
*/
|
|
20
|
+
key: string;
|
|
21
|
+
senderName: string;
|
|
22
|
+
senderAddress: string;
|
|
23
|
+
trustReason: string;
|
|
24
|
+
trustSubReason: string;
|
|
25
|
+
senderTrust?: RescueCandidate["senderTrust"];
|
|
26
|
+
messages: RescueCandidate[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Collapse the candidate list into one entry per sender.
|
|
31
|
+
*
|
|
32
|
+
* Trust is a property of the sender, not of the individual message, so a flat
|
|
33
|
+
* list makes the user answer the same question once per message — 342 messages
|
|
34
|
+
* from a few dozen senders is a few dozen real decisions padded out into
|
|
35
|
+
* hundreds of identical ones. Grouping restores the decision to its actual
|
|
36
|
+
* granularity.
|
|
37
|
+
*
|
|
38
|
+
* Groups are ordered by message count descending, so the senders that account
|
|
39
|
+
* for most of the queue are settled first; ties keep the order the messages
|
|
40
|
+
* arrived in.
|
|
41
|
+
*/
|
|
42
|
+
export const groupRescueCandidatesBySender = (
|
|
43
|
+
candidates: RescueCandidate[],
|
|
44
|
+
): RescueSenderGroup[] => {
|
|
45
|
+
const groups = new Map<string, RescueSenderGroup>();
|
|
46
|
+
const order: string[] = [];
|
|
47
|
+
|
|
48
|
+
for (const candidate of candidates) {
|
|
49
|
+
const key = (candidate.senderAddress || candidate.senderName).toLowerCase();
|
|
50
|
+
const existing = groups.get(key);
|
|
51
|
+
if (existing) {
|
|
52
|
+
existing.messages.push(candidate);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
groups.set(key, {
|
|
56
|
+
key,
|
|
57
|
+
senderName: candidate.senderName,
|
|
58
|
+
senderAddress: candidate.senderAddress,
|
|
59
|
+
trustReason: candidate.trustReason,
|
|
60
|
+
trustSubReason: candidate.trustSubReason,
|
|
61
|
+
senderTrust: candidate.senderTrust,
|
|
62
|
+
messages: [candidate],
|
|
63
|
+
});
|
|
64
|
+
order.push(key);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return order
|
|
68
|
+
.map((key) => groups.get(key) as RescueSenderGroup)
|
|
69
|
+
.sort((a, b) => b.messages.length - a.messages.length);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type GroupSelectionState = "none" | "some" | "all";
|
|
73
|
+
|
|
74
|
+
export const senderGroupSelectionState = (
|
|
75
|
+
group: RescueSenderGroup,
|
|
76
|
+
selected: Set<string>,
|
|
77
|
+
): GroupSelectionState => {
|
|
78
|
+
const count = group.messages.filter((m) => selected.has(m.id)).length;
|
|
79
|
+
if (count === 0) return "none";
|
|
80
|
+
if (count === group.messages.length) return "all";
|
|
81
|
+
return "some";
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export interface RescueSenderGroupRowProps {
|
|
85
|
+
group: RescueSenderGroup;
|
|
86
|
+
selected: Set<string>;
|
|
87
|
+
/** Select or deselect every message from this sender at once. */
|
|
88
|
+
onToggleGroup: (group: RescueSenderGroup, nextSelected: boolean) => void;
|
|
89
|
+
onToggleMessage: (id: string) => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const plural = (n: number): string => (n === 1 ? "message" : "messages");
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One sender in the Rescue-from-Spam review list: a single decision covering
|
|
96
|
+
* every message that sender has sitting in Spam. The individual messages stay
|
|
97
|
+
* available behind a disclosure for the cases where a sender is trusted but one
|
|
98
|
+
* particular message is not.
|
|
99
|
+
*/
|
|
100
|
+
export function RescueSenderGroupRow({
|
|
101
|
+
group,
|
|
102
|
+
selected,
|
|
103
|
+
onToggleGroup,
|
|
104
|
+
onToggleMessage,
|
|
105
|
+
}: RescueSenderGroupRowProps) {
|
|
106
|
+
const [expanded, setExpanded] = useState(false);
|
|
107
|
+
const state = senderGroupSelectionState(group, selected);
|
|
108
|
+
const count = group.messages.length;
|
|
109
|
+
const Chevron = expanded ? ChevronDown : ChevronRight;
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className={cn(state !== "none" ? "bg-positive/10" : "bg-surface")}>
|
|
113
|
+
<div className="flex w-full items-start gap-3 px-3 py-2.5">
|
|
114
|
+
<Checkbox
|
|
115
|
+
className="mt-0.5"
|
|
116
|
+
checked={state === "all"}
|
|
117
|
+
indeterminate={state === "some"}
|
|
118
|
+
onChange={() => onToggleGroup(group, state !== "all")}
|
|
119
|
+
aria-label={`${state === "all" ? "Deselect" : "Select"} all ${count} ${plural(count)} from ${group.senderName}`}
|
|
120
|
+
/>
|
|
121
|
+
|
|
122
|
+
<Avatar name={group.senderName} email={group.senderAddress} size="sm" />
|
|
123
|
+
|
|
124
|
+
<div className="min-w-0 flex-1">
|
|
125
|
+
<div className="flex items-center gap-1.5">
|
|
126
|
+
<span className="truncate text-sm font-medium text-fg">
|
|
127
|
+
{group.senderName}
|
|
128
|
+
</span>
|
|
129
|
+
{group.senderTrust && (
|
|
130
|
+
<SenderTrustIndicator senderTrust={group.senderTrust} size="sm" />
|
|
131
|
+
)}
|
|
132
|
+
<span className="truncate text-2xs text-fg-subtle">
|
|
133
|
+
{group.senderAddress}
|
|
134
|
+
</span>
|
|
135
|
+
</div>
|
|
136
|
+
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
|
137
|
+
<Badge tone="positive">
|
|
138
|
+
<ShieldCheck className="size-3" aria-hidden />
|
|
139
|
+
{group.trustReason}
|
|
140
|
+
</Badge>
|
|
141
|
+
<span className="text-2xs text-fg-subtle">
|
|
142
|
+
{group.trustSubReason}
|
|
143
|
+
</span>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
onClick={() => setExpanded((v) => !v)}
|
|
150
|
+
aria-expanded={expanded}
|
|
151
|
+
className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-2xs text-fg-muted transition-colors hover:bg-surface-sunken hover:text-fg"
|
|
152
|
+
>
|
|
153
|
+
{`${count} ${plural(count)}`}
|
|
154
|
+
<Chevron className="size-3.5" aria-hidden />
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{expanded && (
|
|
159
|
+
<div className="divide-y divide-line border-t border-line">
|
|
160
|
+
{group.messages.map((candidate) => (
|
|
161
|
+
<RescueCandidateRow
|
|
162
|
+
key={candidate.id}
|
|
163
|
+
candidate={candidate}
|
|
164
|
+
selected={selected.has(candidate.id)}
|
|
165
|
+
onToggle={() => onToggleMessage(candidate.id)}
|
|
166
|
+
hideSender
|
|
167
|
+
/>
|
|
168
|
+
))}
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -259,6 +259,14 @@ export {
|
|
|
259
259
|
type RescueFromSpamFlowProps,
|
|
260
260
|
rescueMoveConsequence,
|
|
261
261
|
} from "./components/rescue-from-spam-flow.js";
|
|
262
|
+
export {
|
|
263
|
+
type GroupSelectionState,
|
|
264
|
+
groupRescueCandidatesBySender,
|
|
265
|
+
type RescueSenderGroup,
|
|
266
|
+
RescueSenderGroupRow,
|
|
267
|
+
type RescueSenderGroupRowProps,
|
|
268
|
+
senderGroupSelectionState,
|
|
269
|
+
} from "./components/rescue-sender-group.js";
|
|
262
270
|
export {
|
|
263
271
|
type PanelGroupProps,
|
|
264
272
|
type PanelProps,
|
package/src/tokens.css
CHANGED
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
/* status */
|
|
52
52
|
--color-positive: var(--positive);
|
|
53
53
|
--color-warning: var(--warning);
|
|
54
|
+
--color-warning-soft: var(--warning-soft);
|
|
54
55
|
--color-danger: var(--danger);
|
|
55
56
|
--color-danger-soft: var(--danger-soft);
|
|
56
57
|
|
|
@@ -141,6 +142,7 @@
|
|
|
141
142
|
--positive: oklch(0.55 0.12 155);
|
|
142
143
|
/* muted amber, used sparingly */
|
|
143
144
|
--warning: oklch(0.65 0.13 80);
|
|
145
|
+
--warning-soft: oklch(0.94 0.04 80);
|
|
144
146
|
/* muted coral — the only warm color in the UI */
|
|
145
147
|
--danger: oklch(0.56 0.16 25);
|
|
146
148
|
--danger-soft: oklch(0.93 0.04 25);
|
|
@@ -175,6 +177,7 @@
|
|
|
175
177
|
|
|
176
178
|
--positive: oklch(0.74 0.13 155);
|
|
177
179
|
--warning: oklch(0.75 0.12 80);
|
|
180
|
+
--warning-soft: oklch(0.32 0.06 80);
|
|
178
181
|
--danger: oklch(0.68 0.15 25);
|
|
179
182
|
--danger-soft: oklch(0.32 0.07 25);
|
|
180
183
|
|