@remit/ui 0.0.111 → 0.0.113

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.111",
3
+ "version": "0.0.113",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -435,3 +435,13 @@ export const categoryTone: Record<
435
435
  transactional: "positive",
436
436
  social: "warning",
437
437
  };
438
+
439
+ /**
440
+ * Whether a string names one of the classifier's categories. A host reading a
441
+ * category off the API narrows it with this rather than asserting it: a value
442
+ * from a newer server that this build has no tone for is a value it cannot
443
+ * render, and it needs to know that rather than find out at lookup time.
444
+ */
445
+ export function isThreadCategory(value: string): value is ThreadCategory {
446
+ return Object.hasOwn(categoryTone, value);
447
+ }
@@ -0,0 +1,94 @@
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 { categoryTone, isThreadCategory } from "./app-shell-types.js";
6
+ import type { IntelligenceData } from "./intelligence-panel.js";
7
+ import { IntelligencePanel } from "./intelligence-panel.js";
8
+
9
+ const vipWithFailingAuthenticity: IntelligenceData = {
10
+ sender: {
11
+ name: "Sabrina Basten",
12
+ email: "sabrina@sabrinabasten.example",
13
+ trust: "vip",
14
+ firstSeenLabel: "Mar 2023",
15
+ inboundCount: 214,
16
+ replyCount: 188,
17
+ },
18
+ authenticity: {
19
+ verdict: "mismatch",
20
+ fromDomain: "sabrinabasten.example",
21
+ dkimDomain: "custmx.one.example",
22
+ summary: "Signed by custmx.one.example instead.",
23
+ },
24
+ category: { value: "personal" },
25
+ flags: { vip: true },
26
+ similar: [],
27
+ };
28
+
29
+ /** The rendered chip in the Category section. */
30
+ function categoryChip(data: IntelligenceData): string {
31
+ const html = renderToString(createElement(IntelligencePanel, { data }));
32
+ const marker = `>${data.category.value}</span>`;
33
+ const end = html.indexOf(marker);
34
+ assert.notEqual(end, -1, `expected a chip reading '${data.category.value}'`);
35
+ const start = html.lastIndexOf("<span", end);
36
+ return html.slice(start, end + marker.length);
37
+ }
38
+
39
+ describe("IntelligencePanel category chip", () => {
40
+ it("takes its colour from the category, not the authenticity verdict", () => {
41
+ const chip = categoryChip(vipWithFailingAuthenticity);
42
+ assert.doesNotMatch(chip, /danger/);
43
+ assert.match(chip, /text-accent-2/);
44
+ });
45
+
46
+ it("renders the same chip for a category whatever the verdict said", () => {
47
+ const failing = categoryChip(vipWithFailingAuthenticity);
48
+ const aligned = categoryChip({
49
+ ...vipWithFailingAuthenticity,
50
+ authenticity: {
51
+ verdict: "aligned",
52
+ fromDomain: "sabrinabasten.example",
53
+ summary: "Nothing looks unusual about this sender.",
54
+ },
55
+ });
56
+ assert.equal(failing, aligned);
57
+ });
58
+
59
+ it("gives each category its own tone", () => {
60
+ const receipt = categoryChip({
61
+ ...vipWithFailingAuthenticity,
62
+ category: { value: "transactional" },
63
+ });
64
+ assert.match(receipt, /text-positive/);
65
+ const newsletter = categoryChip({
66
+ ...vipWithFailingAuthenticity,
67
+ category: { value: "newsletter" },
68
+ });
69
+ assert.match(newsletter, /text-fg-muted/);
70
+ });
71
+
72
+ it("renders a readable chip for a message the classifier placed nowhere", () => {
73
+ const chip = categoryChip({
74
+ ...vipWithFailingAuthenticity,
75
+ category: { value: "uncategorized" },
76
+ });
77
+ assert.match(chip, /text-fg-muted/);
78
+ assert.doesNotMatch(chip, /danger/);
79
+ });
80
+ });
81
+
82
+ describe("isThreadCategory", () => {
83
+ it("accepts every category the tone map covers", () => {
84
+ for (const category of Object.keys(categoryTone)) {
85
+ assert.equal(isThreadCategory(category), true, category);
86
+ }
87
+ });
88
+
89
+ it("rejects a category this build has no tone for", () => {
90
+ assert.equal(isThreadCategory("invoice"), false);
91
+ assert.equal(isThreadCategory("Personal"), false);
92
+ assert.equal(isThreadCategory("toString"), false);
93
+ });
94
+ });
@@ -15,7 +15,7 @@ const base: IntelligenceData = {
15
15
  dkimDomain: "example.com",
16
16
  summary: "This message was signed by example.com.",
17
17
  },
18
- category: { value: "Personal" },
18
+ category: { value: "personal" },
19
19
  similar: [],
20
20
  };
21
21
 
@@ -62,7 +62,7 @@ export const SignedButUnrecognised: Story = {
62
62
  trust: "unknown",
63
63
  firstSeenLabel: "today",
64
64
  },
65
- category: { value: "Automated" },
65
+ category: { value: "automated" },
66
66
  authenticity: {
67
67
  verdict: "caution",
68
68
  fromDomain: "serviceupdatebank.atlassian.net",
@@ -88,7 +88,7 @@ export const SignedButLookalikeName: Story = {
88
88
  trust: "unknown",
89
89
  firstSeenLabel: "today",
90
90
  },
91
- category: { value: "Transactional" },
91
+ category: { value: "transactional" },
92
92
  authenticity: {
93
93
  verdict: "caution",
94
94
  fromDomain: "1nfomedics.nl",
@@ -111,7 +111,7 @@ export const Impersonation: Story = {
111
111
  trust: "unknown",
112
112
  firstSeenLabel: "today",
113
113
  },
114
- category: { value: "Phishing" },
114
+ category: { value: "automated" },
115
115
  authenticity: {
116
116
  verdict: "mismatch",
117
117
  fromDomain: "your-bank.example",
@@ -136,7 +136,7 @@ export const UnreadableSender: Story = {
136
136
  firstSeenLabel: "today",
137
137
  addressUnverified: true,
138
138
  },
139
- category: { value: "Phishing" },
139
+ category: { value: "uncategorized" },
140
140
  authenticity: {
141
141
  verdict: "mismatch",
142
142
  fromDomain: "",
@@ -12,6 +12,7 @@ import {
12
12
  } from "lucide-react";
13
13
  import type { ReactElement, ReactNode } from "react";
14
14
  import { cn } from "../lib/cn.js";
15
+ import { categoryTone, type ThreadCategory } from "./app-shell-types.js";
15
16
  import { Avatar } from "./avatar.js";
16
17
  import { Badge } from "./badge.js";
17
18
  import { Button } from "./button.js";
@@ -128,7 +129,7 @@ export interface SenderFlagsIntel {
128
129
  export interface IntelligenceData {
129
130
  sender: SenderIntel;
130
131
  authenticity: AuthenticityIntel;
131
- category: { value: string; overridden?: boolean };
132
+ category: { value: ThreadCategory; overridden?: boolean };
132
133
  flags?: SenderFlagsIntel;
133
134
  similar: SimilarMessageIntel[];
134
135
  }
@@ -395,7 +396,6 @@ export function IntelligencePanel({
395
396
  notSpamPending = false,
396
397
  }: IntelligencePanelProps) {
397
398
  const { sender, authenticity, category, flags = {}, similar } = data;
398
- const suspicious = authenticity.verdict === "mismatch";
399
399
 
400
400
  return (
401
401
  <aside
@@ -430,9 +430,7 @@ export function IntelligencePanel({
430
430
 
431
431
  <Section label="Category">
432
432
  <div className="flex items-center gap-2">
433
- <Badge tone={suspicious ? "danger" : "neutral"}>
434
- {category.value}
435
- </Badge>
433
+ <Badge tone={categoryTone[category.value]}>{category.value}</Badge>
436
434
  {category.overridden && (
437
435
  <span className="text-2xs text-fg-subtle">your override</span>
438
436
  )}
@@ -52,7 +52,7 @@ const intelligence: IntelligenceData = {
52
52
  fromDomain: "example.com",
53
53
  summary: "Verified.",
54
54
  },
55
- category: { value: "Personal" },
55
+ category: { value: "personal" },
56
56
  similar: [],
57
57
  };
58
58
 
@@ -203,6 +203,27 @@ export const ExpandedRowComposed: StoryObj<typeof ExpandedMessage> = {
203
203
  ),
204
204
  };
205
205
 
206
+ /** The chevron beside the sender is the disclosure control, not a picture of
207
+ * one: press it to put the message away and press the row to bring it back.
208
+ * Reclaiming the space a long message takes is what a reader aims at it for. */
209
+ export const RowDisclosure: StoryObj<typeof ExpandedMessage> = {
210
+ render: function RowDisclosureRender() {
211
+ const [expanded, setExpanded] = useState(true);
212
+ return (
213
+ <div className="max-w-3xl border border-line">
214
+ {expanded ? (
215
+ <ExpandedMessage
216
+ message={row}
217
+ onHeaderClick={() => setExpanded(false)}
218
+ />
219
+ ) : (
220
+ <CollapsedMessage message={row} onClick={() => setExpanded(true)} />
221
+ )}
222
+ </div>
223
+ );
224
+ },
225
+ };
226
+
206
227
  /** The app owns the fetch and hands the row its state back; so does this. */
207
228
  const ThreadAttachments = () => {
208
229
  const [rows, setRows] = useState<AttachmentItem[]>([
@@ -184,7 +184,23 @@ export function ExpandedMessage({
184
184
  )}
185
185
  >
186
186
  <div className="flex items-start gap-3">
187
- <ChevronDown className="mt-1 size-3.5 shrink-0 text-fg-subtle" />
187
+ {/* The chevron is the disclosure control, not a picture of one: it is
188
+ what a reader aims at to put a message away, so it carries the
189
+ collapse itself rather than sitting beside the sender block that
190
+ does. */}
191
+ {onHeaderClick ? (
192
+ <button
193
+ type="button"
194
+ onClick={onHeaderClick}
195
+ aria-expanded={true}
196
+ aria-label="Collapse message"
197
+ className="mt-1 shrink-0 text-fg-subtle transition-colors hover:text-fg"
198
+ >
199
+ <ChevronDown className="size-3.5" />
200
+ </button>
201
+ ) : (
202
+ <ChevronDown className="mt-1 size-3.5 shrink-0 text-fg-subtle" />
203
+ )}
188
204
  <Avatar name={message.fromName} email={message.fromEmail} size="md" />
189
205
  <div className="min-w-0 flex-1">
190
206
  {onHeaderClick ? (
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export {
30
30
  type Density,
31
31
  INTELLIGENCE_MIN_WIDTH,
32
32
  isBriefCategory,
33
+ isThreadCategory,
33
34
  type MessageListKeyboard,
34
35
  type MessageListSelection,
35
36
  type NarrowView,