@remit/ui 0.0.1
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 +50 -0
- package/src/components/address-display.render.test.ts +93 -0
- package/src/components/address-display.stories.tsx +55 -0
- package/src/components/address-display.tsx +132 -0
- package/src/components/address-tag.render.test.ts +39 -0
- package/src/components/address-tag.stories.tsx +41 -0
- package/src/components/address-tag.tsx +38 -0
- package/src/components/app-password-hint.render.test.ts +23 -0
- package/src/components/app-password-hint.stories.tsx +19 -0
- package/src/components/app-password-hint.tsx +24 -0
- package/src/components/app-shell-slotted.render.test.ts +128 -0
- package/src/components/app-shell-slotted.tsx +267 -0
- package/src/components/app-shell-types.ts +328 -0
- package/src/components/app-shell.render.test.ts +415 -0
- package/src/components/app-shell.tsx +277 -0
- package/src/components/auth-card.render.test.ts +24 -0
- package/src/components/auth-card.stories.tsx +50 -0
- package/src/components/auth-card.tsx +32 -0
- package/src/components/auth-footer.render.test.ts +17 -0
- package/src/components/auth-footer.stories.tsx +23 -0
- package/src/components/auth-footer.tsx +14 -0
- package/src/components/auth-hero.render.test.ts +31 -0
- package/src/components/auth-hero.stories.tsx +19 -0
- package/src/components/auth-hero.tsx +41 -0
- package/src/components/auto-moved-badge.render.test.ts +42 -0
- package/src/components/auto-moved-badge.stories.tsx +48 -0
- package/src/components/auto-moved-badge.tsx +57 -0
- package/src/components/avatar.tsx +60 -0
- package/src/components/badge.tsx +49 -0
- package/src/components/banner.render.test.ts +58 -0
- package/src/components/banner.stories.tsx +92 -0
- package/src/components/banner.tsx +81 -0
- package/src/components/bottom-sheet.render.test.ts +48 -0
- package/src/components/bottom-sheet.stories.tsx +71 -0
- package/src/components/bottom-sheet.tsx +146 -0
- package/src/components/brief-section.render.test.ts +98 -0
- package/src/components/brief-section.stories.tsx +68 -0
- package/src/components/brief-section.tsx +102 -0
- package/src/components/brief-sections.render.test.ts +72 -0
- package/src/components/brief-sections.stories.tsx +178 -0
- package/src/components/brief-sections.tsx +201 -0
- package/src/components/button.tsx +48 -0
- package/src/components/card.tsx +48 -0
- package/src/components/category-badge.render.test.ts +40 -0
- package/src/components/category-badge.stories.tsx +37 -0
- package/src/components/category-badge.tsx +71 -0
- package/src/components/checkbox.render.test.ts +65 -0
- package/src/components/checkbox.stories.tsx +77 -0
- package/src/components/checkbox.tsx +78 -0
- package/src/components/compose-action-bar.render.test.ts +47 -0
- package/src/components/compose-action-bar.stories.tsx +35 -0
- package/src/components/compose-action-bar.tsx +95 -0
- package/src/components/compose-form-shell.render.test.ts +54 -0
- package/src/components/compose-form-shell.stories.tsx +96 -0
- package/src/components/compose-form-shell.tsx +49 -0
- package/src/components/danger-zone-section.tsx +36 -0
- package/src/components/dialog.render.test.ts +55 -0
- package/src/components/dialog.tsx +104 -0
- package/src/components/email-frame-css.ts +137 -0
- package/src/components/field-label.render.test.ts +16 -0
- package/src/components/field-label.tsx +19 -0
- package/src/components/filter-sheet.render.test.ts +76 -0
- package/src/components/filter-sheet.stories.tsx +146 -0
- package/src/components/filter-sheet.tsx +316 -0
- package/src/components/folder-role.tsx +66 -0
- package/src/components/input.tsx +44 -0
- package/src/components/intelligence-panel.stories.tsx +149 -0
- package/src/components/intelligence-panel.tsx +546 -0
- package/src/components/isolated-email-frame.render.test.ts +147 -0
- package/src/components/isolated-email-frame.stories.tsx +192 -0
- package/src/components/isolated-email-frame.tsx +265 -0
- package/src/components/kbd.tsx +17 -0
- package/src/components/keyboard-hint-bar.tsx +58 -0
- package/src/components/list-item.tsx +39 -0
- package/src/components/mail-action-toolbar.render.test.ts +43 -0
- package/src/components/mail-action-toolbar.tsx +180 -0
- package/src/components/mail-header.render.test.ts +69 -0
- package/src/components/mail-header.stories.tsx +233 -0
- package/src/components/mail-header.tsx +123 -0
- package/src/components/message-body-view.render.test.ts +59 -0
- package/src/components/message-body-view.stories.tsx +146 -0
- package/src/components/message-body-view.tsx +150 -0
- package/src/components/message-header.render.test.ts +58 -0
- package/src/components/message-header.stories.tsx +79 -0
- package/src/components/message-header.tsx +59 -0
- package/src/components/message-list-pane.render.test.ts +136 -0
- package/src/components/message-list-pane.stories.tsx +246 -0
- package/src/components/message-list-pane.tsx +248 -0
- package/src/components/message-list-state.render.test.ts +72 -0
- package/src/components/message-list-state.tsx +97 -0
- package/src/components/message-row.render.test.ts +48 -0
- package/src/components/message-row.stories.tsx +121 -0
- package/src/components/message-row.tsx +231 -0
- package/src/components/mobile-message-action-bar.render.test.ts +87 -0
- package/src/components/mobile-message-action-bar.stories.tsx +57 -0
- package/src/components/mobile-message-action-bar.tsx +175 -0
- package/src/components/mobile-reading-pane.render.test.ts +102 -0
- package/src/components/mobile-reading-pane.tsx +188 -0
- package/src/components/mobile-search-view.stories.tsx +247 -0
- package/src/components/mobile-search-view.tsx +100 -0
- package/src/components/move-mailbox-picker.render.test.ts +113 -0
- package/src/components/move-mailbox-picker.stories.tsx +76 -0
- package/src/components/move-mailbox-picker.tsx +324 -0
- package/src/components/nav-sidebar.render.test.ts +351 -0
- package/src/components/nav-sidebar.stories.tsx +216 -0
- package/src/components/nav-sidebar.tsx +625 -0
- package/src/components/outbox-row.render.test.ts +61 -0
- package/src/components/outbox-row.stories.tsx +53 -0
- package/src/components/outbox-row.tsx +120 -0
- package/src/components/outbox-status-badge.tsx +60 -0
- package/src/components/pane-layout.render.test.ts +105 -0
- package/src/components/popover-menu.render.test.ts +32 -0
- package/src/components/popover-menu.stories.tsx +57 -0
- package/src/components/popover-menu.tsx +110 -0
- package/src/components/primitives.stories.tsx +173 -0
- package/src/components/pull-to-refresh.render.test.ts +46 -0
- package/src/components/pull-to-refresh.stories.tsx +71 -0
- package/src/components/pull-to-refresh.tsx +54 -0
- package/src/components/quoted-text.render.test.ts +39 -0
- package/src/components/quoted-text.stories.tsx +67 -0
- package/src/components/quoted-text.tsx +67 -0
- package/src/components/reading-pane-empty.tsx +44 -0
- package/src/components/reading-pane.render.test.ts +148 -0
- package/src/components/reading-pane.stories.tsx +194 -0
- package/src/components/reading-pane.tsx +351 -0
- package/src/components/rescue-banner.render.test.ts +32 -0
- package/src/components/rescue-banner.stories.tsx +31 -0
- package/src/components/rescue-banner.tsx +50 -0
- package/src/components/rescue-candidate-row.render.test.ts +68 -0
- package/src/components/rescue-candidate-row.stories.tsx +108 -0
- package/src/components/rescue-candidate-row.tsx +94 -0
- package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
- package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
- package/src/components/rescue-from-spam-flow.tsx +342 -0
- package/src/components/resizable.tsx +55 -0
- package/src/components/role-appointment-list.render.test.ts +97 -0
- package/src/components/role-appointment-list.stories.tsx +138 -0
- package/src/components/role-appointment-list.tsx +227 -0
- package/src/components/row-actions.render.test.ts +83 -0
- package/src/components/row-actions.stories.tsx +87 -0
- package/src/components/row-actions.tsx +139 -0
- package/src/components/search-bar.render.test.ts +55 -0
- package/src/components/search-bar.stories.tsx +46 -0
- package/src/components/search-bar.tsx +118 -0
- package/src/components/search-result-row.tsx +139 -0
- package/src/components/search-results.render.test.ts +89 -0
- package/src/components/search-results.stories.tsx +126 -0
- package/src/components/search-results.tsx +215 -0
- package/src/components/search-token-chip.render.test.ts +42 -0
- package/src/components/search-token-chip.stories.tsx +42 -0
- package/src/components/search-token-chip.tsx +66 -0
- package/src/components/security-select.render.test.ts +33 -0
- package/src/components/security-select.tsx +53 -0
- package/src/components/segmented-control.tsx +70 -0
- package/src/components/select.tsx +33 -0
- package/src/components/selection-top-bar.render.test.ts +67 -0
- package/src/components/selection-top-bar.stories.tsx +39 -0
- package/src/components/selection-top-bar.tsx +95 -0
- package/src/components/sender-group-switch.tsx +63 -0
- package/src/components/sender-trust-indicator.render.test.ts +67 -0
- package/src/components/sender-trust-indicator.stories.tsx +58 -0
- package/src/components/sender-trust-indicator.tsx +87 -0
- package/src/components/settings-primitives.render.test.ts +160 -0
- package/src/components/settings-primitives.stories.tsx +80 -0
- package/src/components/settings-screen.tsx +354 -0
- package/src/components/swipeable-row.render.test.ts +106 -0
- package/src/components/swipeable-row.stories.tsx +117 -0
- package/src/components/swipeable-row.tsx +268 -0
- package/src/components/touch-list-body.render.test.ts +66 -0
- package/src/components/touch-list-body.stories.tsx +76 -0
- package/src/components/touch-list.tsx +87 -0
- package/src/components/wizard.render.test.ts +49 -0
- package/src/components/wizard.stories.tsx +113 -0
- package/src/components/wizard.tsx +321 -0
- package/src/filter-presets.test.ts +93 -0
- package/src/filter-presets.ts +101 -0
- package/src/index.ts +378 -0
- package/src/lib/cid-resolver.ts +35 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/email-layout-clamp.test.ts +109 -0
- package/src/lib/email-layout-clamp.ts +64 -0
- package/src/lib/email-render-treatment.test.ts +82 -0
- package/src/lib/email-render-treatment.ts +53 -0
- package/src/lib/email-sanitizer.test.ts +362 -0
- package/src/lib/email-sanitizer.ts +298 -0
- package/src/lib/row-keyboard.test.ts +41 -0
- package/src/lib/row-keyboard.ts +20 -0
- package/src/tokens.css +217 -0
|
@@ -0,0 +1,32 @@
|
|
|
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 { RescueBanner } from "./rescue-banner.js";
|
|
6
|
+
|
|
7
|
+
const noop = () => {};
|
|
8
|
+
|
|
9
|
+
describe("RescueBanner", () => {
|
|
10
|
+
it("states the count in plain language and offers the action", () => {
|
|
11
|
+
const html = renderToString(
|
|
12
|
+
createElement(RescueBanner, { count: 5, onReview: noop }),
|
|
13
|
+
);
|
|
14
|
+
assert.match(html, /5 messages here/);
|
|
15
|
+
assert.match(html, /senders we can verify/);
|
|
16
|
+
assert.match(html, /Review & rescue/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("uses the singular form for one message", () => {
|
|
20
|
+
const html = renderToString(
|
|
21
|
+
createElement(RescueBanner, { count: 1, onReview: noop }),
|
|
22
|
+
);
|
|
23
|
+
assert.match(html, /1 message here/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("never leaks authentication jargon", () => {
|
|
27
|
+
const html = renderToString(
|
|
28
|
+
createElement(RescueBanner, { count: 3, onReview: noop }),
|
|
29
|
+
);
|
|
30
|
+
assert.doesNotMatch(html, /DKIM|SPF|DMARC|spam score/i);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { RescueBanner } from "./rescue-banner.js";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof RescueBanner> = {
|
|
5
|
+
title: "Components/RescueBanner",
|
|
6
|
+
component: RescueBanner,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
args: { onReview: () => {} },
|
|
9
|
+
decorators: [
|
|
10
|
+
(Story) => (
|
|
11
|
+
<div className="mx-auto max-w-md">
|
|
12
|
+
<Story />
|
|
13
|
+
</div>
|
|
14
|
+
),
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
export default meta;
|
|
18
|
+
|
|
19
|
+
type Story = StoryObj<typeof RescueBanner>;
|
|
20
|
+
|
|
21
|
+
export const Several: Story = {
|
|
22
|
+
args: { count: 5 },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const One: Story = {
|
|
26
|
+
args: { count: 1 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const Dismissable: Story = {
|
|
30
|
+
args: { count: 3, onDismiss: () => {} },
|
|
31
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ShieldCheck } from "lucide-react";
|
|
2
|
+
import { Banner } from "./banner.js";
|
|
3
|
+
import { Button } from "./button.js";
|
|
4
|
+
|
|
5
|
+
export interface RescueBannerProps {
|
|
6
|
+
/** How many messages in this folder look safe to rescue. */
|
|
7
|
+
count: number;
|
|
8
|
+
onReview: () => void;
|
|
9
|
+
onDismiss?: () => void;
|
|
10
|
+
/** Override the call-to-action label. */
|
|
11
|
+
actionLabel?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const plural = (n: number): string => (n === 1 ? "message" : "messages");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Entry call-to-action shown atop a Spam folder when some messages look safe.
|
|
18
|
+
* Plain language only — no talk of authentication, scores or rules.
|
|
19
|
+
*/
|
|
20
|
+
export function RescueBanner({
|
|
21
|
+
count,
|
|
22
|
+
onReview,
|
|
23
|
+
onDismiss,
|
|
24
|
+
actionLabel = "Review & rescue",
|
|
25
|
+
}: RescueBannerProps) {
|
|
26
|
+
return (
|
|
27
|
+
<Banner tone="info" variant="soft" onDismiss={onDismiss}>
|
|
28
|
+
<div className="flex flex-col gap-2">
|
|
29
|
+
<p className="flex items-start gap-2 text-sm text-fg">
|
|
30
|
+
<ShieldCheck
|
|
31
|
+
className="mt-0.5 size-4 shrink-0 text-positive"
|
|
32
|
+
aria-hidden
|
|
33
|
+
/>
|
|
34
|
+
<span>
|
|
35
|
+
<span className="font-semibold text-fg">{`${count} ${plural(count)} here`}</span>{" "}
|
|
36
|
+
may not be spam — they're from senders we can verify.
|
|
37
|
+
</span>
|
|
38
|
+
</p>
|
|
39
|
+
<Button
|
|
40
|
+
variant="primary"
|
|
41
|
+
size="sm"
|
|
42
|
+
onClick={onReview}
|
|
43
|
+
className="self-start"
|
|
44
|
+
>
|
|
45
|
+
{actionLabel}
|
|
46
|
+
</Button>
|
|
47
|
+
</div>
|
|
48
|
+
</Banner>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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 {
|
|
6
|
+
type RescueCandidate,
|
|
7
|
+
RescueCandidateRow,
|
|
8
|
+
} from "./rescue-candidate-row.js";
|
|
9
|
+
|
|
10
|
+
const candidate: RescueCandidate = {
|
|
11
|
+
id: "1",
|
|
12
|
+
senderName: "Anna de Vries",
|
|
13
|
+
senderAddress: "anna@studio-noord.nl",
|
|
14
|
+
subject: "Re: invoice",
|
|
15
|
+
snippet: "final files attached",
|
|
16
|
+
trustReason: "We can verify this sender",
|
|
17
|
+
trustSubReason: "You've emailed them before",
|
|
18
|
+
senderTrust: "wellknown",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const noop = () => {};
|
|
22
|
+
|
|
23
|
+
describe("RescueCandidateRow", () => {
|
|
24
|
+
it("renders sender, subject and the plain-language trust reason", () => {
|
|
25
|
+
const html = renderToString(
|
|
26
|
+
createElement(RescueCandidateRow, {
|
|
27
|
+
candidate,
|
|
28
|
+
selected: true,
|
|
29
|
+
onToggle: noop,
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
assert.match(html, /Anna de Vries/);
|
|
33
|
+
assert.match(html, /We can verify this sender/);
|
|
34
|
+
assert.match(html, /emailed them before/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("never leaks authentication jargon to the user", () => {
|
|
38
|
+
const html = renderToString(
|
|
39
|
+
createElement(RescueCandidateRow, {
|
|
40
|
+
candidate,
|
|
41
|
+
selected: false,
|
|
42
|
+
onToggle: noop,
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
assert.doesNotMatch(html, /DKIM|SPF|DMARC/i);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("uses a real checkbox that reflects selection", () => {
|
|
49
|
+
const selectedHtml = renderToString(
|
|
50
|
+
createElement(RescueCandidateRow, {
|
|
51
|
+
candidate,
|
|
52
|
+
selected: true,
|
|
53
|
+
onToggle: noop,
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
assert.match(selectedHtml, /type="checkbox"/);
|
|
57
|
+
assert.match(selectedHtml, /checked=""/);
|
|
58
|
+
|
|
59
|
+
const unselectedHtml = renderToString(
|
|
60
|
+
createElement(RescueCandidateRow, {
|
|
61
|
+
candidate,
|
|
62
|
+
selected: false,
|
|
63
|
+
onToggle: noop,
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
assert.doesNotMatch(unselectedHtml, /checked=""/);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
RescueCandidateRow,
|
|
5
|
+
type RescueCandidateRowProps,
|
|
6
|
+
} from "./rescue-candidate-row.js";
|
|
7
|
+
|
|
8
|
+
const meta: Meta<typeof RescueCandidateRow> = {
|
|
9
|
+
title: "Components/RescueCandidateRow",
|
|
10
|
+
component: RescueCandidateRow,
|
|
11
|
+
parameters: { layout: "padded" },
|
|
12
|
+
decorators: [
|
|
13
|
+
(Story) => (
|
|
14
|
+
<div className="mx-auto max-w-md divide-y divide-line rounded-xl border border-line bg-surface">
|
|
15
|
+
<Story />
|
|
16
|
+
</div>
|
|
17
|
+
),
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
export default meta;
|
|
21
|
+
|
|
22
|
+
type Story = StoryObj<typeof RescueCandidateRow>;
|
|
23
|
+
|
|
24
|
+
const base: RescueCandidateRowProps["candidate"] = {
|
|
25
|
+
id: "1",
|
|
26
|
+
senderName: "Anna de Vries",
|
|
27
|
+
senderAddress: "anna@studio-noord.nl",
|
|
28
|
+
subject: "Re: invoice for the September shoot",
|
|
29
|
+
snippet: "Thanks for the quick turnaround — final files attached as agreed.",
|
|
30
|
+
trustReason: "We can verify this sender",
|
|
31
|
+
trustSubReason: "You've emailed them before",
|
|
32
|
+
senderTrust: "wellknown",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function Interactive(props: {
|
|
36
|
+
candidate: RescueCandidateRowProps["candidate"];
|
|
37
|
+
}) {
|
|
38
|
+
const [selected, setSelected] = useState(true);
|
|
39
|
+
return (
|
|
40
|
+
<RescueCandidateRow
|
|
41
|
+
candidate={props.candidate}
|
|
42
|
+
selected={selected}
|
|
43
|
+
onToggle={() => setSelected((v) => !v)}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const Selected: Story = {
|
|
49
|
+
render: () => <Interactive candidate={base} />,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const KnownContact: Story = {
|
|
53
|
+
render: () => <Interactive candidate={base} />,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const PassedAuthentication: Story = {
|
|
57
|
+
render: () => (
|
|
58
|
+
<Interactive
|
|
59
|
+
candidate={{
|
|
60
|
+
...base,
|
|
61
|
+
id: "2",
|
|
62
|
+
senderName: "GitHub",
|
|
63
|
+
senderAddress: "noreply@github.com",
|
|
64
|
+
subject: "[remit] CI passed on rescue-from-spam",
|
|
65
|
+
snippet: "All checks have passed on your pull request.",
|
|
66
|
+
trustReason: "We can verify this sender",
|
|
67
|
+
trustSubReason: "Passed authentication",
|
|
68
|
+
senderTrust: undefined,
|
|
69
|
+
}}
|
|
70
|
+
/>
|
|
71
|
+
),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const KnownMailingList: Story = {
|
|
75
|
+
render: () => (
|
|
76
|
+
<Interactive
|
|
77
|
+
candidate={{
|
|
78
|
+
...base,
|
|
79
|
+
id: "3",
|
|
80
|
+
senderName: "Stripe Weekly",
|
|
81
|
+
senderAddress: "weekly@stripe.com",
|
|
82
|
+
subject: "Your payouts this week",
|
|
83
|
+
snippet: "Here is a summary of the payouts settled to your account.",
|
|
84
|
+
trustReason: "We can verify this sender",
|
|
85
|
+
trustSubReason: "Known mailing list you read",
|
|
86
|
+
senderTrust: undefined,
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const Vip: Story = {
|
|
93
|
+
render: () => (
|
|
94
|
+
<Interactive
|
|
95
|
+
candidate={{
|
|
96
|
+
...base,
|
|
97
|
+
id: "4",
|
|
98
|
+
senderName: "Mum",
|
|
99
|
+
senderAddress: "mum@gmail.com",
|
|
100
|
+
subject: "dinner sunday?",
|
|
101
|
+
snippet: "Let me know if you and the kids are coming over.",
|
|
102
|
+
trustReason: "We can verify this sender",
|
|
103
|
+
trustSubReason: "Someone you email often",
|
|
104
|
+
senderTrust: "vip",
|
|
105
|
+
}}
|
|
106
|
+
/>
|
|
107
|
+
),
|
|
108
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { ShieldCheck } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { Avatar } from "./avatar.js";
|
|
4
|
+
import { Badge } from "./badge.js";
|
|
5
|
+
import { Checkbox } from "./checkbox.js";
|
|
6
|
+
import {
|
|
7
|
+
type SenderTrust,
|
|
8
|
+
SenderTrustIndicator,
|
|
9
|
+
} from "./sender-trust-indicator.js";
|
|
10
|
+
|
|
11
|
+
export interface RescueCandidate {
|
|
12
|
+
id: string;
|
|
13
|
+
senderName: string;
|
|
14
|
+
senderAddress: string;
|
|
15
|
+
subject: string;
|
|
16
|
+
snippet: string;
|
|
17
|
+
/** Plain-language headline chip, e.g. "We can verify this sender". */
|
|
18
|
+
trustReason: string;
|
|
19
|
+
/** Why we trust it, e.g. "You've emailed them before". Never DKIM/SPF jargon. */
|
|
20
|
+
trustSubReason: string;
|
|
21
|
+
senderTrust?: SenderTrust;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RescueCandidateRowProps {
|
|
25
|
+
candidate: RescueCandidate;
|
|
26
|
+
selected: boolean;
|
|
27
|
+
onToggle: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A single suspected-safe message in the Rescue-from-Spam review list. The whole
|
|
32
|
+
* row is a label around a real checkbox: tapping anywhere toggles selection for
|
|
33
|
+
* the bulk move. The green trust chip explains, in plain language, why we
|
|
34
|
+
* believe it isn't spam.
|
|
35
|
+
*/
|
|
36
|
+
export function RescueCandidateRow({
|
|
37
|
+
candidate,
|
|
38
|
+
selected,
|
|
39
|
+
onToggle,
|
|
40
|
+
}: RescueCandidateRowProps) {
|
|
41
|
+
const {
|
|
42
|
+
senderName,
|
|
43
|
+
senderAddress,
|
|
44
|
+
subject,
|
|
45
|
+
snippet,
|
|
46
|
+
trustReason,
|
|
47
|
+
trustSubReason,
|
|
48
|
+
senderTrust,
|
|
49
|
+
} = candidate;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
// biome-ignore lint/a11y/noLabelWithoutControl: label wraps a custom Checkbox component that handles focus
|
|
53
|
+
<label
|
|
54
|
+
className={cn(
|
|
55
|
+
"flex w-full cursor-pointer items-start gap-3 px-3 py-2.5 text-left transition-colors",
|
|
56
|
+
selected ? "bg-positive/10" : "bg-surface hover:bg-surface-sunken",
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<Checkbox
|
|
60
|
+
className="mt-0.5"
|
|
61
|
+
checked={selected}
|
|
62
|
+
onChange={onToggle}
|
|
63
|
+
aria-label={`${selected ? "Deselect" : "Select"} message from ${senderName}`}
|
|
64
|
+
/>
|
|
65
|
+
|
|
66
|
+
<Avatar name={senderName} email={senderAddress} size="sm" />
|
|
67
|
+
|
|
68
|
+
<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}
|
|
78
|
+
</span>
|
|
79
|
+
</span>
|
|
80
|
+
<span className="block truncate text-sm text-fg-muted">{subject}</span>
|
|
81
|
+
<span className="block line-clamp-1 text-xs text-fg-subtle">
|
|
82
|
+
{snippet}
|
|
83
|
+
</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>
|
|
91
|
+
</span>
|
|
92
|
+
</label>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
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 { MoveMailboxOption } from "./move-mailbox-picker.js";
|
|
6
|
+
import type { RescueCandidate } from "./rescue-candidate-row.js";
|
|
7
|
+
import {
|
|
8
|
+
RescueFromSpamFlow,
|
|
9
|
+
rescueMoveConsequence,
|
|
10
|
+
} from "./rescue-from-spam-flow.js";
|
|
11
|
+
|
|
12
|
+
const noop = () => {};
|
|
13
|
+
|
|
14
|
+
const candidates: RescueCandidate[] = [
|
|
15
|
+
{
|
|
16
|
+
id: "c1",
|
|
17
|
+
senderName: "Anna de Vries",
|
|
18
|
+
senderAddress: "anna@studio-noord.nl",
|
|
19
|
+
subject: "Re: invoice for the September shoot",
|
|
20
|
+
snippet: "Final files attached as agreed.",
|
|
21
|
+
trustReason: "We can verify this sender",
|
|
22
|
+
trustSubReason: "You've emailed them before",
|
|
23
|
+
senderTrust: "wellknown",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "c2",
|
|
27
|
+
senderName: "Mum",
|
|
28
|
+
senderAddress: "mum@gmail.com",
|
|
29
|
+
subject: "dinner sunday?",
|
|
30
|
+
snippet: "Are you coming over?",
|
|
31
|
+
trustReason: "We can verify this sender",
|
|
32
|
+
trustSubReason: "A sender you know",
|
|
33
|
+
senderTrust: "vip",
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const folders: MoveMailboxOption[] = [
|
|
38
|
+
{ id: "inbox", label: "Inbox" },
|
|
39
|
+
{ id: "spam", label: "Spam", isCurrent: true },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const renderFlow = (open: boolean): string =>
|
|
43
|
+
renderToString(
|
|
44
|
+
createElement(RescueFromSpamFlow, {
|
|
45
|
+
open,
|
|
46
|
+
candidates,
|
|
47
|
+
defaultDestinationId: "inbox",
|
|
48
|
+
availableFolders: folders,
|
|
49
|
+
onConfirmMove: noop,
|
|
50
|
+
onCancel: noop,
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
describe("RescueFromSpamFlow", () => {
|
|
55
|
+
it("opens on the review step listing every candidate", () => {
|
|
56
|
+
const html = renderFlow(true);
|
|
57
|
+
assert.match(html, /Rescue from Spam/);
|
|
58
|
+
assert.match(html, /Anna de Vries/);
|
|
59
|
+
assert.match(html, /Mum/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("pre-selects all candidates and shows the live count", () => {
|
|
63
|
+
const html = renderFlow(true);
|
|
64
|
+
assert.match(html, /2 of 2 selected/);
|
|
65
|
+
assert.match(html, /Continue with 2 messages/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("offers select-all / none and a way back", () => {
|
|
69
|
+
const html = renderFlow(true);
|
|
70
|
+
assert.match(html, /Select all/);
|
|
71
|
+
assert.match(html, /Select none/);
|
|
72
|
+
assert.match(html, /Cancel/);
|
|
73
|
+
assert.match(html, /Why these\?/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("never leaks authentication jargon", () => {
|
|
77
|
+
const html = renderFlow(true);
|
|
78
|
+
assert.doesNotMatch(html, /DKIM|SPF|DMARC|spam score|predicate/i);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("rescueMoveConsequence", () => {
|
|
83
|
+
it("states a one-off move with destination and count", () => {
|
|
84
|
+
assert.equal(
|
|
85
|
+
rescueMoveConsequence(3, "Inbox"),
|
|
86
|
+
"Moves these 3 messages out of Spam to Inbox now. Nothing later.",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("uses the singular form for one message", () => {
|
|
91
|
+
assert.equal(
|
|
92
|
+
rescueMoveConsequence(1, "Family"),
|
|
93
|
+
"Moves this message out of Spam to Family now. Nothing later.",
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("always promises nothing ongoing", () => {
|
|
98
|
+
assert.match(rescueMoveConsequence(9, "Inbox"), /Nothing later\.$/);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Button } from "./button.js";
|
|
4
|
+
import type { MoveMailboxOption } from "./move-mailbox-picker.js";
|
|
5
|
+
import type { RescueCandidate } from "./rescue-candidate-row.js";
|
|
6
|
+
import { RescueFromSpamFlow } from "./rescue-from-spam-flow.js";
|
|
7
|
+
|
|
8
|
+
const meta: Meta<typeof RescueFromSpamFlow> = {
|
|
9
|
+
title: "Flows/RescueFromSpamFlow",
|
|
10
|
+
component: RescueFromSpamFlow,
|
|
11
|
+
parameters: { layout: "fullscreen" },
|
|
12
|
+
decorators: [
|
|
13
|
+
(Story) => (
|
|
14
|
+
<div className="relative mx-auto h-dvh w-full shrink-0 overflow-hidden bg-surface sm:my-6 sm:h-[680px] sm:w-[390px] sm:rounded-[2rem] sm:border sm:border-line sm:shadow-sm">
|
|
15
|
+
<Story />
|
|
16
|
+
</div>
|
|
17
|
+
),
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
export default meta;
|
|
21
|
+
|
|
22
|
+
type Story = StoryObj<typeof RescueFromSpamFlow>;
|
|
23
|
+
|
|
24
|
+
const CANDIDATES: RescueCandidate[] = [
|
|
25
|
+
{
|
|
26
|
+
id: "c1",
|
|
27
|
+
senderName: "Anna de Vries",
|
|
28
|
+
senderAddress: "anna@studio-noord.nl",
|
|
29
|
+
subject: "Re: invoice for the September shoot",
|
|
30
|
+
snippet: "Thanks for the quick turnaround — final files attached.",
|
|
31
|
+
trustReason: "We can verify this sender",
|
|
32
|
+
trustSubReason: "You've emailed them before",
|
|
33
|
+
senderTrust: "wellknown",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "c2",
|
|
37
|
+
senderName: "Mum",
|
|
38
|
+
senderAddress: "mum@gmail.com",
|
|
39
|
+
subject: "dinner sunday?",
|
|
40
|
+
snippet: "Let me know if you and the kids are coming over this weekend.",
|
|
41
|
+
trustReason: "We can verify this sender",
|
|
42
|
+
trustSubReason: "A sender you know",
|
|
43
|
+
senderTrust: "vip",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "c3",
|
|
47
|
+
senderName: "Huisarts Centrum Oost",
|
|
48
|
+
senderAddress: "no-reply@hcoost.nl",
|
|
49
|
+
subject: "Afspraakbevestiging — 3 juli 09:20",
|
|
50
|
+
snippet: "This confirms your appointment. Reply STOP to cancel.",
|
|
51
|
+
trustReason: "We can verify this sender",
|
|
52
|
+
trustSubReason: "You've emailed them before",
|
|
53
|
+
senderTrust: "wellknown",
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const FOLDERS: MoveMailboxOption[] = [
|
|
58
|
+
{ id: "inbox", label: "Inbox" },
|
|
59
|
+
{ id: "spam", label: "Spam", isCurrent: true },
|
|
60
|
+
{ id: "receipts", label: "Receipts" },
|
|
61
|
+
{ id: "family", label: "Family" },
|
|
62
|
+
{ id: "work", label: "Work", searchValue: "work clients projects" },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function Demo() {
|
|
66
|
+
const [open, setOpen] = useState(true);
|
|
67
|
+
return (
|
|
68
|
+
<div className="relative h-full overflow-hidden bg-surface">
|
|
69
|
+
{!open && (
|
|
70
|
+
<Button
|
|
71
|
+
variant="primary"
|
|
72
|
+
onClick={() => setOpen(true)}
|
|
73
|
+
className="absolute inset-x-0 bottom-0 m-3 h-11 font-semibold"
|
|
74
|
+
>
|
|
75
|
+
Open rescue
|
|
76
|
+
</Button>
|
|
77
|
+
)}
|
|
78
|
+
<RescueFromSpamFlow
|
|
79
|
+
open={open}
|
|
80
|
+
candidates={CANDIDATES}
|
|
81
|
+
defaultDestinationId="inbox"
|
|
82
|
+
availableFolders={FOLDERS}
|
|
83
|
+
onConfirmMove={() => {}}
|
|
84
|
+
onCancel={() => setOpen(false)}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const Default: Story = {
|
|
91
|
+
render: () => <Demo />,
|
|
92
|
+
};
|