@remit/ui 0.0.7 → 0.0.9

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.7",
3
+ "version": "0.0.9",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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
@@ -0,0 +1,43 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { isForwardableKey } from "./isolated-email-frame.js";
4
+
5
+ describe("isForwardableKey", () => {
6
+ it("forwards the keys that move around the app", () => {
7
+ for (const key of [
8
+ "j",
9
+ "k",
10
+ "g",
11
+ "x",
12
+ "r",
13
+ "#",
14
+ "Enter",
15
+ "Escape",
16
+ "ArrowUp",
17
+ "ArrowDown",
18
+ "Home",
19
+ "End",
20
+ ]) {
21
+ assert.equal(isForwardableKey(key), true, key);
22
+ }
23
+ });
24
+
25
+ it("keeps destructive keys inside the message body", () => {
26
+ // The replayed event carries no focused control, so the app routes what it
27
+ // recognises straight at the message list. Backspace while reading must not
28
+ // become a delete of the row behind the reader.
29
+ assert.equal(isForwardableKey("Backspace"), false);
30
+ assert.equal(isForwardableKey("Delete"), false);
31
+ });
32
+
33
+ it("keeps Space inside the message body", () => {
34
+ // Space belongs to reading the email, not to selecting rows behind it.
35
+ assert.equal(isForwardableKey(" "), false);
36
+ });
37
+
38
+ it("does not forward unknown named keys", () => {
39
+ assert.equal(isForwardableKey("F5"), false);
40
+ assert.equal(isForwardableKey("Tab"), false);
41
+ assert.equal(isForwardableKey("PageDown"), false);
42
+ });
43
+ });
@@ -104,6 +104,61 @@ const useMatchMedia = (query: string): boolean => {
104
104
  return matches;
105
105
  };
106
106
 
107
+ /** Named (non-character) keys worth replaying: moving around and closing. */
108
+ const FORWARDED_NAMED_KEYS = new Set([
109
+ "Enter",
110
+ "Escape",
111
+ "ArrowUp",
112
+ "ArrowDown",
113
+ "ArrowLeft",
114
+ "ArrowRight",
115
+ "Home",
116
+ "End",
117
+ ]);
118
+
119
+ /**
120
+ * Which keystrokes leave the frame. An allowlist rather than a denylist: the
121
+ * replayed event's target is the host window, so the app's keyboard layer sees
122
+ * no focused control and routes everything it recognises to the message list.
123
+ * Forwarding indiscriminately would therefore let Backspace pressed while
124
+ * reading delete the message under the cursor, and Space select it.
125
+ *
126
+ * Single characters (letters, digits, punctuation) carry the app's navigation
127
+ * and verb bindings and are safe to replay. Space is excluded: it belongs to
128
+ * reading the email, not to selecting rows behind it.
129
+ */
130
+ export const isForwardableKey = (key: string): boolean => {
131
+ if (key === " ") return false;
132
+ if (key.length === 1) return true;
133
+ return FORWARDED_NAMED_KEYS.has(key);
134
+ };
135
+
136
+ /**
137
+ * Keydown events raised inside an iframe never reach the embedding window, so
138
+ * once the reader clicks into the message body every app shortcut goes dead —
139
+ * j/k, arrows, Esc, the lot (#43). Replay the allowlisted keystrokes on the host
140
+ * window so the app's one keyboard layer keeps hearing them. The replay is a
141
+ * copy: the original event is untouched, so selecting and scrolling inside the
142
+ * email still behave normally. Modifier combos stay with the frame and the
143
+ * browser.
144
+ */
145
+ const forwardKeyDown = (event: KeyboardEvent) => {
146
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
147
+ if (!isForwardableKey(event.key)) return;
148
+ const doc = (event.currentTarget ?? event.target) as Document | null;
149
+ const host = doc?.defaultView?.parent;
150
+ if (!host || host === doc?.defaultView) return;
151
+ host.dispatchEvent(
152
+ new KeyboardEvent("keydown", {
153
+ key: event.key,
154
+ code: event.code,
155
+ shiftKey: event.shiftKey,
156
+ bubbles: true,
157
+ cancelable: true,
158
+ }),
159
+ );
160
+ };
161
+
107
162
  /**
108
163
  * Render untrusted (sanitized) email HTML in a sandboxed iframe that fits the
109
164
  * viewport width on mobile and isolates the email's CSS from the app chrome.
@@ -175,6 +230,7 @@ export const IsolatedEmailFrame = ({
175
230
  };
176
231
 
177
232
  let observer: ResizeObserver | undefined;
233
+ let keyDoc: Document | undefined;
178
234
  const handleLoad = () => {
179
235
  measure();
180
236
  const doc = iframe.contentDocument;
@@ -182,11 +238,14 @@ export const IsolatedEmailFrame = ({
182
238
  observer = new ResizeObserver(measure);
183
239
  observer.observe(doc.body);
184
240
  if (doc.documentElement) observer.observe(doc.documentElement);
241
+ doc.addEventListener("keydown", forwardKeyDown);
242
+ keyDoc = doc;
185
243
  };
186
244
 
187
245
  iframe.addEventListener("load", handleLoad);
188
246
  return () => {
189
247
  iframe.removeEventListener("load", handleLoad);
248
+ keyDoc?.removeEventListener("keydown", forwardKeyDown);
190
249
  observer?.disconnect();
191
250
  };
192
251
  }, []);
@@ -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