@remit/ui 0.0.112 → 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.112",
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
 
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,