@remit/ui 0.0.99 → 0.0.101

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.99",
3
+ "version": "0.0.101",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,120 @@
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 { AttachmentList, type AttachmentListProps } from "./attachment-list.js";
6
+
7
+ const render = (props: AttachmentListProps): string =>
8
+ renderToString(createElement(AttachmentList, props));
9
+
10
+ const item = (
11
+ overrides: Partial<AttachmentListProps["attachments"][number]>,
12
+ ) => ({
13
+ attachmentId: "part-1",
14
+ filename: "Quarterly report.pdf",
15
+ typeLabel: "PDF",
16
+ sizeOctets: 1024 * 512,
17
+ download: { status: "idle" } as const,
18
+ ...overrides,
19
+ });
20
+
21
+ describe("AttachmentList", () => {
22
+ it("renders nothing when the message has no attachments", () => {
23
+ assert.equal(render({ attachments: [], onDownload: () => undefined }), "");
24
+ });
25
+
26
+ it("names the file, its type and its size", () => {
27
+ const html = render({
28
+ attachments: [item({})],
29
+ onDownload: () => undefined,
30
+ });
31
+ assert.match(html, /Quarterly report\.pdf/);
32
+ assert.match(html, /PDF/);
33
+ assert.match(html, /512 KB/);
34
+ });
35
+
36
+ it("gives every attachment a labelled download control", () => {
37
+ const html = render({
38
+ attachments: [
39
+ item({}),
40
+ item({ attachmentId: "part-2", filename: "photo.png" }),
41
+ ],
42
+ onDownload: () => undefined,
43
+ });
44
+ assert.match(html, /aria-label="Download Quarterly report\.pdf"/);
45
+ assert.match(html, /aria-label="Download photo\.png"/);
46
+ assert.match(html, /2 attachments/);
47
+ });
48
+
49
+ it("counts a single attachment in the singular", () => {
50
+ const html = render({
51
+ attachments: [item({})],
52
+ onDownload: () => undefined,
53
+ });
54
+ assert.match(html, /1 attachment</);
55
+ });
56
+
57
+ it("isolates the filename's text direction", () => {
58
+ const html = render({
59
+ attachments: [item({})],
60
+ onDownload: () => undefined,
61
+ });
62
+ assert.match(html, /<bdi/);
63
+ });
64
+
65
+ it("disables the control while a download is in flight", () => {
66
+ const html = render({
67
+ attachments: [item({ download: { status: "downloading" } })],
68
+ onDownload: () => undefined,
69
+ });
70
+ assert.match(html, /disabled=""/);
71
+ });
72
+
73
+ it("states what failed, the likely fix, and offers a retry", () => {
74
+ const html = render({
75
+ attachments: [
76
+ item({
77
+ download: {
78
+ status: "failed",
79
+ title: "Your session expired",
80
+ detail: "Sign in again, then download it once more.",
81
+ },
82
+ }),
83
+ ],
84
+ onDownload: () => undefined,
85
+ });
86
+ assert.match(html, /role="alert"/);
87
+ assert.match(html, /Your session expired/);
88
+ assert.match(html, /Sign in again, then download it once more\./);
89
+ assert.match(html, /Try again/);
90
+ });
91
+
92
+ it("offers a report link when the failure came with one", () => {
93
+ const html = render({
94
+ attachments: [
95
+ item({
96
+ download: {
97
+ status: "failed",
98
+ title: "Couldn't download this attachment",
99
+ detail: "The server refused the request.",
100
+ reportUrl:
101
+ "https://github.com/remit-mail/reader/issues/new?title=x",
102
+ },
103
+ }),
104
+ ],
105
+ onDownload: () => undefined,
106
+ });
107
+ assert.match(html, /Report this/);
108
+ assert.match(html, /issues\/new\?title=x/);
109
+ });
110
+
111
+ it("says so when the server promised an attachment no part describes", () => {
112
+ const html = render({
113
+ attachments: [],
114
+ onDownload: () => undefined,
115
+ hasUnlistedAttachment: true,
116
+ });
117
+ assert.match(html, /carries an attachment, but none of/);
118
+ assert.match(html, /Attachment</);
119
+ });
120
+ });
@@ -0,0 +1,159 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { type AttachmentItem, AttachmentList } from "./attachment-list.js";
3
+
4
+ /**
5
+ * The attachment list on an open message (#683). Every row saves a file, so
6
+ * every row is a button; the paperclip in the heading is the only glyph and it
7
+ * is decoration. Nothing here fetches — the app owns the download and hands
8
+ * back per-row state, which is what makes the failure story below the same
9
+ * component the app renders.
10
+ */
11
+ const meta: Meta<typeof AttachmentList> = {
12
+ title: "Mail/AttachmentList",
13
+ component: AttachmentList,
14
+ parameters: { layout: "padded" },
15
+ };
16
+ export default meta;
17
+
18
+ type Story = StoryObj<typeof AttachmentList>;
19
+
20
+ const report: AttachmentItem = {
21
+ attachmentId: "part-2",
22
+ filename: "Q3 board pack.pdf",
23
+ typeLabel: "PDF",
24
+ sizeOctets: 2_411_724,
25
+ download: { status: "idle" },
26
+ };
27
+
28
+ export const OneAttachment: Story = {
29
+ args: {
30
+ attachments: [report],
31
+ onDownload: (id) => alert(`Download ${id}`),
32
+ },
33
+ };
34
+
35
+ export const SeveralAttachments: Story = {
36
+ args: {
37
+ attachments: [
38
+ report,
39
+ {
40
+ attachmentId: "part-3",
41
+ filename: "site-plan.png",
42
+ typeLabel: "PNG",
43
+ sizeOctets: 486_120,
44
+ download: { status: "idle" },
45
+ },
46
+ {
47
+ attachmentId: "part-4",
48
+ filename: "notes.txt",
49
+ typeLabel: "PLAIN",
50
+ sizeOctets: 812,
51
+ download: { status: "idle" },
52
+ },
53
+ {
54
+ attachmentId: "part-5",
55
+ filename: "archive",
56
+ typeLabel: "FILE",
57
+ sizeOctets: 1024 ** 3 + 1024 ** 2 * 200,
58
+ download: { status: "idle" },
59
+ },
60
+ ],
61
+ onDownload: (id) => alert(`Download ${id}`),
62
+ },
63
+ };
64
+
65
+ export const Downloading: Story = {
66
+ args: {
67
+ attachments: [
68
+ { ...report, download: { status: "downloading" } },
69
+ {
70
+ attachmentId: "part-3",
71
+ filename: "site-plan.png",
72
+ typeLabel: "PNG",
73
+ sizeOctets: 486_120,
74
+ download: { status: "idle" },
75
+ },
76
+ ],
77
+ onDownload: (id) => alert(`Download ${id}`),
78
+ },
79
+ };
80
+
81
+ /**
82
+ * A fetch that failed. The row keeps its control, and the alert underneath
83
+ * names what broke and what to do about it — a dead click that leaves the user
84
+ * guessing whether the app, the server or they themselves are at fault is the
85
+ * outcome this list exists to make impossible.
86
+ */
87
+ export const DownloadFailed: Story = {
88
+ args: {
89
+ attachments: [
90
+ {
91
+ ...report,
92
+ download: {
93
+ status: "failed",
94
+ title: "This attachment is missing from storage",
95
+ detail:
96
+ "Remit has the message but not the file. Re-sync the account from Settings, then try again.",
97
+ reportUrl: "https://github.com/remit-mail/reader/issues/new",
98
+ },
99
+ },
100
+ {
101
+ attachmentId: "part-3",
102
+ filename: "site-plan.png",
103
+ typeLabel: "PNG",
104
+ sizeOctets: 486_120,
105
+ download: { status: "idle" },
106
+ },
107
+ ],
108
+ onDownload: (id) => alert(`Download ${id}`),
109
+ },
110
+ };
111
+
112
+ /**
113
+ * Names written to deceive, as `sanitizeAttachmentFilename` leaves them. The
114
+ * senders wrote `../../../etc/passwd`, `invoice<RLO>gnp.exe` — which renders as
115
+ * `invoiceexe.png` with the override intact — and 400 characters of padding.
116
+ * The list shows exactly the name the file is saved under, so what is read is
117
+ * what lands.
118
+ */
119
+ export const HostileFilename: Story = {
120
+ args: {
121
+ attachments: [
122
+ {
123
+ attachmentId: "part-6",
124
+ filename: "passwd",
125
+ typeLabel: "FILE",
126
+ sizeOctets: 3_120,
127
+ download: { status: "idle" },
128
+ },
129
+ {
130
+ attachmentId: "part-7",
131
+ filename: "invoicegnp.exe",
132
+ typeLabel: "FILE",
133
+ sizeOctets: 118_400,
134
+ download: { status: "idle" },
135
+ },
136
+ {
137
+ attachmentId: "part-8",
138
+ filename: `${"long-name-".repeat(11)}report.pdf`,
139
+ typeLabel: "PDF",
140
+ sizeOctets: 44_000,
141
+ download: { status: "idle" },
142
+ },
143
+ ],
144
+ onDownload: (id) => alert(`Download ${id}`),
145
+ },
146
+ };
147
+
148
+ /**
149
+ * The mail server flagged the message as carrying an attachment, but no body
150
+ * part describes one. Saying nothing here is what made the original paperclip
151
+ * read as broken.
152
+ */
153
+ export const UnlistedAttachment: Story = {
154
+ args: {
155
+ attachments: [],
156
+ onDownload: () => undefined,
157
+ hasUnlistedAttachment: true,
158
+ },
159
+ };
@@ -0,0 +1,182 @@
1
+ import { AlertCircle, Download, Loader2, Paperclip } from "lucide-react";
2
+ import { formatByteSize } from "../lib/attachment-file.js";
3
+ import { cn } from "../lib/cn.js";
4
+
5
+ export type AttachmentDownloadState =
6
+ | { status: "idle" }
7
+ | { status: "downloading" }
8
+ | { status: "failed"; title: string; detail: string; reportUrl?: string };
9
+
10
+ export interface AttachmentItem {
11
+ attachmentId: string;
12
+ /**
13
+ * The name shown and the name saved — one string for both, already reduced
14
+ * by `sanitizeAttachmentFilename`. Anything that displays a different name
15
+ * than it writes to disk is the bug this field exists to prevent.
16
+ */
17
+ filename: string;
18
+ /** Short type label derived from the MIME subtype, e.g. `PDF`. */
19
+ typeLabel: string;
20
+ sizeOctets: number;
21
+ download: AttachmentDownloadState;
22
+ }
23
+
24
+ export interface AttachmentListProps {
25
+ attachments: readonly AttachmentItem[];
26
+ onDownload: (attachmentId: string) => void;
27
+ /**
28
+ * The message is flagged on the server as carrying an attachment that none
29
+ * of its body parts describe. Says so rather than rendering nothing, which
30
+ * is indistinguishable from a broken app to someone who saw the paperclip
31
+ * in the list.
32
+ */
33
+ hasUnlistedAttachment?: boolean;
34
+ className?: string;
35
+ }
36
+
37
+ function DownloadFailure({
38
+ state,
39
+ onRetry,
40
+ }: {
41
+ state: Extract<AttachmentDownloadState, { status: "failed" }>;
42
+ onRetry: () => void;
43
+ }) {
44
+ return (
45
+ <div
46
+ role="alert"
47
+ data-testid="attachment-error"
48
+ className="flex items-start gap-2 border-t border-danger/30 bg-danger-soft px-2 py-2 text-xs"
49
+ >
50
+ <AlertCircle
51
+ className="mt-0.5 size-3.5 shrink-0 text-danger"
52
+ aria-hidden
53
+ />
54
+ <div className="min-w-0 flex-1">
55
+ <p className="font-medium text-danger">{state.title}</p>
56
+ <p className="mt-0.5 text-fg-muted">{state.detail}</p>
57
+ </div>
58
+ <div className="flex shrink-0 items-center gap-3">
59
+ <button
60
+ type="button"
61
+ onClick={onRetry}
62
+ className="font-medium text-accent hover:underline"
63
+ >
64
+ Try again
65
+ </button>
66
+ {state.reportUrl && (
67
+ <a
68
+ href={state.reportUrl}
69
+ target="_blank"
70
+ rel="noreferrer"
71
+ className="font-medium text-accent hover:underline"
72
+ >
73
+ Report this
74
+ </a>
75
+ )}
76
+ </div>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ /**
82
+ * The attachments carried by an open message: what each one is, how big it is,
83
+ * and a control that saves it.
84
+ *
85
+ * Every row is a real button. The paperclip in the section heading is the only
86
+ * glyph here and it is labelled as decoration — a paperclip that looks
87
+ * pressable and is not reads as a broken download, which is the failure this
88
+ * list replaces (#683).
89
+ */
90
+ export function AttachmentList({
91
+ attachments,
92
+ onDownload,
93
+ hasUnlistedAttachment = false,
94
+ className,
95
+ }: AttachmentListProps) {
96
+ if (attachments.length === 0 && !hasUnlistedAttachment) return null;
97
+
98
+ return (
99
+ <section
100
+ aria-label="Attachments"
101
+ data-testid="attachment-list"
102
+ className={cn("space-y-1.5", className)}
103
+ >
104
+ <h3 className="flex items-center gap-1.5 text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
105
+ <Paperclip className="size-3" aria-hidden />
106
+ {attachments.length === 0
107
+ ? "Attachment"
108
+ : attachments.length === 1
109
+ ? "1 attachment"
110
+ : `${attachments.length} attachments`}
111
+ </h3>
112
+
113
+ {attachments.length > 0 && (
114
+ <ul className="overflow-hidden rounded-sm border border-line bg-surface">
115
+ {attachments.map((attachment) => {
116
+ const isDownloading = attachment.download.status === "downloading";
117
+ return (
118
+ <li
119
+ key={attachment.attachmentId}
120
+ className="border-t border-line first:border-t-0"
121
+ >
122
+ <button
123
+ type="button"
124
+ onClick={() => onDownload(attachment.attachmentId)}
125
+ disabled={isDownloading}
126
+ data-testid="attachment-download"
127
+ aria-label={`Download ${attachment.filename}`}
128
+ className="flex w-full items-center gap-2 px-2 py-2 text-left transition-colors hover:bg-surface-sunken disabled:cursor-progress disabled:opacity-60"
129
+ >
130
+ <span className="min-w-0 flex-1">
131
+ {/* <bdi> isolates the name's own direction so a
132
+ right-to-left filename cannot reorder the label
133
+ around it. */}
134
+ <bdi
135
+ className="block truncate text-sm text-fg"
136
+ title={attachment.filename}
137
+ >
138
+ {attachment.filename}
139
+ </bdi>
140
+ <span className="block truncate text-2xs text-fg-subtle">
141
+ {attachment.typeLabel} ·{" "}
142
+ {formatByteSize(attachment.sizeOctets)}
143
+ </span>
144
+ </span>
145
+ {isDownloading ? (
146
+ <Loader2
147
+ className="size-4 shrink-0 animate-spin text-fg-subtle"
148
+ aria-hidden
149
+ />
150
+ ) : (
151
+ <Download
152
+ className="size-4 shrink-0 text-fg-subtle"
153
+ aria-hidden
154
+ />
155
+ )}
156
+ </button>
157
+ {attachment.download.status === "failed" && (
158
+ <DownloadFailure
159
+ state={attachment.download}
160
+ onRetry={() => onDownload(attachment.attachmentId)}
161
+ />
162
+ )}
163
+ </li>
164
+ );
165
+ })}
166
+ </ul>
167
+ )}
168
+
169
+ {hasUnlistedAttachment && (
170
+ <p
171
+ role="alert"
172
+ data-testid="attachment-unlisted"
173
+ className="rounded-sm border border-line bg-surface px-2 py-2 text-xs text-fg-muted"
174
+ >
175
+ The mail server says this message carries an attachment, but none of
176
+ its parts describe one. Re-sync the account from Settings; if the
177
+ attachment still does not appear, the message is worth reporting.
178
+ </p>
179
+ )}
180
+ </section>
181
+ );
182
+ }
@@ -0,0 +1,35 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Button } from "./button.js";
3
+
4
+ export type ComposeBodyMode = "rich" | "plain";
5
+
6
+ export interface ComposeModeToggleProps {
7
+ mode: ComposeBodyMode;
8
+ onToggle: () => void;
9
+ }
10
+
11
+ /**
12
+ * A text button, not an icon. The two glyphs a reader would reach for are
13
+ * already spoken for in an editor toolbar — the eraser clears formatting on a
14
+ * selection and the `A` opens formatting options — and the label reads the same
15
+ * in both states, so the control never changes under the finger.
16
+ */
17
+ export const ComposeModeToggle = ({
18
+ mode,
19
+ onToggle,
20
+ }: ComposeModeToggleProps) => (
21
+ <Button
22
+ variant="ghost"
23
+ size="md"
24
+ aria-pressed={mode === "plain"}
25
+ title="Plain text"
26
+ onClick={onToggle}
27
+ data-testid="compose-mode-toggle"
28
+ className={cn(
29
+ "min-h-11 shrink-0",
30
+ mode === "plain" && "bg-accent-2-soft text-accent-2",
31
+ )}
32
+ >
33
+ Plain text
34
+ </Button>
35
+ );
@@ -138,3 +138,34 @@ describe("CompactRow", () => {
138
138
  assert.match(html, /Q3 planning notes/);
139
139
  });
140
140
  });
141
+
142
+ describe("attachment indicator", () => {
143
+ // A list row cannot download anything — the file lives on the message, which
144
+ // is one pane over. So the paperclip here is metadata and says so, rather
145
+ // than sitting inertly where a control belongs (#683).
146
+ it("marks a comfortable row as carrying an attachment, without a control", () => {
147
+ const html = renderToString(
148
+ createElement(ComfortableRow, {
149
+ thread: { ...base, isRead: true, hasAttachment: true },
150
+ }),
151
+ );
152
+ assert.match(html, /role="img"/);
153
+ assert.match(html, /aria-label="Has an attachment"/);
154
+ });
155
+
156
+ it("marks a compact row as carrying an attachment", () => {
157
+ const html = renderToString(
158
+ createElement(CompactRow, {
159
+ thread: { ...base, isRead: true, hasAttachment: true },
160
+ }),
161
+ );
162
+ assert.match(html, /aria-label="Has an attachment"/);
163
+ });
164
+
165
+ it("renders no indicator when the message carries nothing", () => {
166
+ const html = renderToString(
167
+ createElement(ComfortableRow, { thread: { ...base, isRead: true } }),
168
+ );
169
+ assert.doesNotMatch(html, /Has an attachment/);
170
+ });
171
+ });
@@ -98,7 +98,11 @@ export function CompactRowBody({ thread }: { thread: ThreadRowData }) {
98
98
  <span className="text-fg-subtle"> — {thread.snippet}</span>
99
99
  </span>
100
100
  {thread.hasAttachment && (
101
- <Paperclip className="size-3 shrink-0 text-fg-subtle" />
101
+ <Paperclip
102
+ className="size-3 shrink-0 text-fg-subtle"
103
+ role="img"
104
+ aria-label="Has an attachment"
105
+ />
102
106
  )}
103
107
  <span className="w-11 shrink-0 text-right text-2xs text-fg-subtle tabular-nums">
104
108
  {thread.timeLabel}
@@ -192,7 +196,11 @@ export function ComfortableRowTextContent({
192
196
  <Star className="size-3 shrink-0 fill-warning text-warning" />
193
197
  )}
194
198
  {thread.hasAttachment && (
195
- <Paperclip className="size-3 shrink-0 text-fg-subtle" />
199
+ <Paperclip
200
+ className="size-3 shrink-0 text-fg-subtle"
201
+ role="img"
202
+ aria-label="Has an attachment"
203
+ />
196
204
  )}
197
205
  </span>
198
206
  <span className="flex items-center gap-1.5">