@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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="ml-auto text-2xs text-accent hover:underline"
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
- {isDesktop ? (
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
- <Avatar name={senderName} email={senderAddress} size="sm" />
74
+ {!hideSender && (
75
+ <Avatar name={senderName} email={senderAddress} size="sm" />
76
+ )}
67
77
 
68
78
  <span className="min-w-0 flex-1">
69
- <span className="flex items-center gap-1.5">
70
- <span className="truncate text-sm font-medium text-fg">
71
- {senderName}
72
- </span>
73
- {senderTrust && (
74
- <SenderTrustIndicator senderTrust={senderTrust} size="sm" />
75
- )}
76
- <span className="truncate text-2xs text-fg-subtle">
77
- {senderAddress}
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
- </span>
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
- <span className="mt-1.5 flex flex-wrap items-center gap-1.5">
85
- <Badge tone="positive">
86
- <ShieldCheck className="size-3" aria-hidden />
87
- {trustReason}
88
- </Badge>
89
- <span className="text-2xs text-fg-subtle">{trustSubReason}</span>
90
- </span>
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
- type RescueCandidate,
12
- RescueCandidateRow,
13
- } from "./rescue-candidate-row.js";
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 anything that
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
- {candidates.map((candidate) => (
123
- <RescueCandidateRow
124
- key={candidate.id}
125
- candidate={candidate}
126
- selected={selected.has(candidate.id)}
127
- onToggle={() => onToggle(candidate.id)}
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