@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.
@@ -0,0 +1,155 @@
1
+ import {
2
+ $convertFromMarkdownString,
3
+ $convertToMarkdownString,
4
+ type ElementTransformer,
5
+ isTableRowDivider,
6
+ TRANSFORMERS,
7
+ type Transformer,
8
+ } from "@lexical/markdown";
9
+ import {
10
+ $createTableCellNode,
11
+ $createTableNode,
12
+ $createTableRowNode,
13
+ $isTableCellNode,
14
+ $isTableNode,
15
+ $isTableRowNode,
16
+ TableCellHeaderStates,
17
+ TableCellNode,
18
+ TableNode,
19
+ TableRowNode,
20
+ } from "@lexical/table";
21
+ import { $isTextNode, type ElementNode, type LexicalNode } from "lexical";
22
+
23
+ const TABLE_ROW = /^\|(.+)\|\s*$/;
24
+
25
+ /**
26
+ * A cell is a document of its own, so its content goes through the same
27
+ * transformers. A newline or a bare pipe inside one would end the row early,
28
+ * which is the difference between a table and a wall of broken syntax.
29
+ */
30
+ const exportCell = (cell: TableCellNode): string =>
31
+ $convertToMarkdownString(COMPOSE_TRANSFORMERS, cell)
32
+ .replace(/\n+/g, " ")
33
+ .replace(/\|/g, "\\|")
34
+ .trim();
35
+
36
+ const splitRow = (line: string): string[] => {
37
+ const inner = line.trim().replace(/^\|/, "").replace(/\|$/, "");
38
+ const cells: string[] = [];
39
+ let current = "";
40
+ for (let index = 0; index < inner.length; index++) {
41
+ const character = inner[index];
42
+ if (character === "\\" && inner[index + 1] === "|") {
43
+ current += "|";
44
+ index++;
45
+ continue;
46
+ }
47
+ if (character === "|") {
48
+ cells.push(current);
49
+ current = "";
50
+ continue;
51
+ }
52
+ current += character;
53
+ }
54
+ cells.push(current);
55
+ return cells;
56
+ };
57
+
58
+ const $createCell = (text: string): TableCellNode => {
59
+ const cell = $createTableCellNode(TableCellHeaderStates.NO_STATUS);
60
+ $convertFromMarkdownString(text.trim(), COMPOSE_TRANSFORMERS, cell);
61
+ return cell;
62
+ };
63
+
64
+ const $createRow = (texts: string[], columns: number): TableRowNode => {
65
+ const row = $createTableRowNode();
66
+ for (let index = 0; index < columns; index++) {
67
+ row.append($createCell(texts[index] ?? ""));
68
+ }
69
+ return row;
70
+ };
71
+
72
+ const columnsOf = (table: TableNode): number => {
73
+ const first = table.getFirstChild();
74
+ return $isTableRowNode(first) ? first.getChildrenSize() : 0;
75
+ };
76
+
77
+ const $markFirstRowAsHeader = (table: TableNode): void => {
78
+ const first = table.getFirstChild();
79
+ if (!$isTableRowNode(first)) return;
80
+ for (const cell of first.getChildren()) {
81
+ if (!$isTableCellNode(cell)) continue;
82
+ cell.setHeaderStyles(TableCellHeaderStates.ROW, TableCellHeaderStates.ROW);
83
+ }
84
+ };
85
+
86
+ /**
87
+ * `@lexical/markdown` 0.49 exports `isTableRowDivider` but no table
88
+ * transformer, so the composer supplies one over `@lexical/table` nodes. One
89
+ * list drives Markdown export, Markdown import and the cell round trip, which
90
+ * is what keeps a table that left as pipes coming back as a table.
91
+ *
92
+ * Import runs a line at a time, each line arriving as a paragraph. A row
93
+ * appended to the table its predecessor left behind is how the rows join up.
94
+ * A divider carries no content of its own: it says the row above is a header.
95
+ */
96
+ export const TABLE: ElementTransformer = {
97
+ dependencies: [TableNode, TableRowNode, TableCellNode],
98
+ export: (node: LexicalNode): string | null => {
99
+ if (!$isTableNode(node)) return null;
100
+ const lines: string[] = [];
101
+ for (const row of node.getChildren()) {
102
+ if (!$isTableRowNode(row)) continue;
103
+ const cells = row.getChildren().filter($isTableCellNode);
104
+ if (cells.length === 0) continue;
105
+ lines.push(`| ${cells.map(exportCell).join(" | ")} |`);
106
+ // GFM only reads a block of pipe rows as a table when a divider follows
107
+ // the first one, so it is written whether or not that row was a header.
108
+ // Without it a recipient sees the pipes and no table.
109
+ if (lines.length === 1) {
110
+ lines.push(`| ${cells.map(() => "---").join(" | ")} |`);
111
+ }
112
+ }
113
+ return lines.length === 0 ? null : lines.join("\n");
114
+ },
115
+ regExp: TABLE_ROW,
116
+ replace: (parentNode: ElementNode, children, match): void => {
117
+ const line = match[0].trimEnd();
118
+ const previous = parentNode.getPreviousSibling();
119
+
120
+ if (isTableRowDivider(line)) {
121
+ if ($isTableNode(previous)) {
122
+ $markFirstRowAsHeader(previous);
123
+ parentNode.remove();
124
+ return;
125
+ }
126
+ // A divider with no rows above it is not a table. The import emptied
127
+ // this line's text node before calling here, so declining the match
128
+ // would drop the characters rather than leave them alone.
129
+ const first = children[0];
130
+ if ($isTextNode(first)) first.setTextContent(line);
131
+ return;
132
+ }
133
+
134
+ const texts = splitRow(line);
135
+ if ($isTableNode(previous)) {
136
+ previous.append($createRow(texts, columnsOf(previous)));
137
+ parentNode.remove();
138
+ return;
139
+ }
140
+
141
+ const table = $createTableNode();
142
+ table.append($createRow(texts, texts.length));
143
+ parentNode.replace(table);
144
+ },
145
+ type: "element",
146
+ };
147
+
148
+ /**
149
+ * The composer's own transformer set. Everything that reads or writes Markdown
150
+ * in compose uses this one list — the plain alternative on a rich send, the
151
+ * body of a plain send, the down-conversion of a paste, and both directions of
152
+ * the mode switch — so the conversions are inverses rather than four
153
+ * independent best efforts.
154
+ */
155
+ export const COMPOSE_TRANSFORMERS: Transformer[] = [TABLE, ...TRANSFORMERS];
@@ -38,7 +38,7 @@ const ToolbarButton = ({
38
38
  title={title}
39
39
  aria-label={title}
40
40
  aria-pressed={isActive}
41
- className={`p-1.5 rounded transition-colors ${
41
+ className={`inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded transition-colors ${
42
42
  isActive
43
43
  ? "text-fg bg-accent-2-soft"
44
44
  : "text-fg-muted hover:text-fg hover:bg-surface-raised"
@@ -66,7 +66,16 @@ const INITIAL_STATE: ToolbarState = {
66
66
  canRedo: false,
67
67
  };
68
68
 
69
- export const RichTextToolbar = () => {
69
+ export interface RichTextToolbarProps {
70
+ /**
71
+ * Pinned to the right of the strip, outside the part that scrolls. The mode
72
+ * toggle rides here, and it is the last element in the toolbar's DOM so one
73
+ * Shift+Tab out of the body reaches it.
74
+ */
75
+ trailing?: React.ReactNode;
76
+ }
77
+
78
+ export const RichTextToolbar = ({ trailing }: RichTextToolbarProps) => {
70
79
  const [editor] = useLexicalComposerContext();
71
80
  const [state, setState] = useState<ToolbarState>(INITIAL_STATE);
72
81
  const [linkDraft, setLinkDraft] = useState<string | null>(null);
@@ -146,51 +155,64 @@ export const RichTextToolbar = () => {
146
155
  };
147
156
 
148
157
  return (
149
- <div className="border-b border-line">
150
- <div className="flex items-center gap-0.5 px-3 py-1">
151
- <ToolbarButton
152
- isActive={state.bold}
153
- onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")}
154
- title="Bold (Ctrl+B)"
155
- >
156
- <Bold className="size-4" />
157
- </ToolbarButton>
158
- <ToolbarButton
159
- isActive={state.italic}
160
- onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic")}
161
- title="Italic (Ctrl+I)"
162
- >
163
- <Italic className="size-4" />
164
- </ToolbarButton>
165
- <ToolbarButton
166
- isActive={state.link !== null}
167
- onClick={() => setLinkDraft(state.link ?? "https://")}
168
- title="Link (Ctrl+K)"
169
- >
170
- <Link className="size-4" />
171
- </ToolbarButton>
172
- <ToolbarButton
173
- isActive={state.quote}
174
- onClick={toggleQuote}
175
- title="Blockquote"
176
- >
177
- <Quote className="size-4" />
178
- </ToolbarButton>
179
- <div className="mx-1.5 h-4 w-px bg-line" />
180
- <ToolbarButton
181
- isActive={false}
182
- onClick={() => editor.dispatchCommand(UNDO_COMMAND, undefined)}
183
- title="Undo (Ctrl+Z)"
184
- >
185
- <Undo2 className={`size-4 ${state.canUndo ? "" : "opacity-40"}`} />
186
- </ToolbarButton>
187
- <ToolbarButton
188
- isActive={false}
189
- onClick={() => editor.dispatchCommand(REDO_COMMAND, undefined)}
190
- title="Redo (Ctrl+Y)"
158
+ // The toolbar and the body share one scroller, so twenty lines of typing
159
+ // would carry the toolbar off the top of the compose window with them.
160
+ <div className="sticky top-0 z-10 border-b border-line bg-canvas">
161
+ <div className="flex items-center gap-2 px-3 py-1">
162
+ {/* `min-w-0`, without which a flex child refuses to shrink below its
163
+ content and pushes the trailing control off the edge instead of
164
+ scrolling. */}
165
+ <div
166
+ data-testid="compose-format-cluster"
167
+ className="flex min-w-0 items-center gap-0.5 overflow-x-auto"
191
168
  >
192
- <Redo2 className={`size-4 ${state.canRedo ? "" : "opacity-40"}`} />
193
- </ToolbarButton>
169
+ <ToolbarButton
170
+ isActive={state.bold}
171
+ onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")}
172
+ title="Bold (Ctrl+B)"
173
+ >
174
+ <Bold className="size-4" />
175
+ </ToolbarButton>
176
+ <ToolbarButton
177
+ isActive={state.italic}
178
+ onClick={() =>
179
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic")
180
+ }
181
+ title="Italic (Ctrl+I)"
182
+ >
183
+ <Italic className="size-4" />
184
+ </ToolbarButton>
185
+ <ToolbarButton
186
+ isActive={state.link !== null}
187
+ onClick={() => setLinkDraft(state.link ?? "https://")}
188
+ title="Link (Ctrl+K)"
189
+ >
190
+ <Link className="size-4" />
191
+ </ToolbarButton>
192
+ <ToolbarButton
193
+ isActive={state.quote}
194
+ onClick={toggleQuote}
195
+ title="Blockquote"
196
+ >
197
+ <Quote className="size-4" />
198
+ </ToolbarButton>
199
+ <div className="mx-1.5 h-4 w-px shrink-0 bg-line" />
200
+ <ToolbarButton
201
+ isActive={false}
202
+ onClick={() => editor.dispatchCommand(UNDO_COMMAND, undefined)}
203
+ title="Undo (Ctrl+Z)"
204
+ >
205
+ <Undo2 className={`size-4 ${state.canUndo ? "" : "opacity-40"}`} />
206
+ </ToolbarButton>
207
+ <ToolbarButton
208
+ isActive={false}
209
+ onClick={() => editor.dispatchCommand(REDO_COMMAND, undefined)}
210
+ title="Redo (Ctrl+Y)"
211
+ >
212
+ <Redo2 className={`size-4 ${state.canRedo ? "" : "opacity-40"}`} />
213
+ </ToolbarButton>
214
+ </div>
215
+ {trailing && <div className="ml-auto shrink-0">{trailing}</div>}
194
216
  </div>
195
217
  {linkDraft !== null && (
196
218
  <div className="flex items-center gap-2 border-t border-line px-3 py-1.5">
@@ -3,12 +3,22 @@
3
3
  * not the editor's own JSON, which changes shape between Lexical versions.
4
4
  * `text` is the plain alternative, as Markdown over the same document.
5
5
  *
6
+ * `formatting` is the inventory of node types and text formats in the document
7
+ * that plain text cannot carry, sorted, empty for a document of ordinary
8
+ * paragraphs. It is reported rather than reduced to a flag so the caller can
9
+ * both decide and say what is at stake.
10
+ *
6
11
  * Kept apart from the editor so a caller can name the shape without pulling
7
12
  * the editor, and its dependencies, out of their own chunk.
8
13
  */
9
14
  export interface RichTextValue {
10
15
  html: string;
11
16
  text: string;
17
+ formatting: readonly string[];
12
18
  }
13
19
 
14
- export const EMPTY_RICH_TEXT: RichTextValue = { html: "", text: "" };
20
+ export const EMPTY_RICH_TEXT: RichTextValue = {
21
+ html: "",
22
+ text: "",
23
+ formatting: [],
24
+ };
package/src/index.ts CHANGED
@@ -51,6 +51,12 @@ export {
51
51
  useContainerWidth,
52
52
  } from "./components/app-shell-types.js";
53
53
  export { AppTopBar, type AppTopBarProps } from "./components/app-top-bar.js";
54
+ export {
55
+ type AttachmentDownloadState,
56
+ type AttachmentItem,
57
+ AttachmentList,
58
+ type AttachmentListProps,
59
+ } from "./components/attachment-list.js";
54
60
  export { AuthCard, type AuthCardProps } from "./components/auth-card.js";
55
61
  export {
56
62
  AuthFooter,
@@ -641,6 +647,11 @@ export {
641
647
  sanitizeAdoptedHtml,
642
648
  sanitizeQuotedHtml,
643
649
  } from "./lib/adopted-html.js";
650
+ export {
651
+ DEFAULT_ATTACHMENT_FILENAME,
652
+ formatByteSize,
653
+ sanitizeAttachmentFilename,
654
+ } from "./lib/attachment-file.js";
644
655
  export {
645
656
  buildCidResolver,
646
657
  type CidResolvableBodyPart,
@@ -0,0 +1,162 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ formatByteSize,
5
+ sanitizeAttachmentFilename,
6
+ } from "./attachment-file.js";
7
+
8
+ const RLO = "\u202e";
9
+ const LRI = "\u2066";
10
+ const PDI = "\u2069";
11
+ const ZWSP = "\u200b";
12
+ const BOM = "\ufeff";
13
+
14
+ describe("sanitizeAttachmentFilename", () => {
15
+ it("leaves an ordinary filename alone", () => {
16
+ assert.equal(
17
+ sanitizeAttachmentFilename("Quarterly report.pdf"),
18
+ "Quarterly report.pdf",
19
+ );
20
+ });
21
+
22
+ it("keeps only the last segment of a POSIX path", () => {
23
+ assert.equal(sanitizeAttachmentFilename("../../../etc/passwd"), "passwd");
24
+ });
25
+
26
+ it("keeps only the last segment of a Windows path", () => {
27
+ assert.equal(
28
+ sanitizeAttachmentFilename("..\\..\\Windows\\System32\\evil.dll"),
29
+ "evil.dll",
30
+ );
31
+ });
32
+
33
+ it("falls back when the name is nothing but traversal", () => {
34
+ assert.equal(sanitizeAttachmentFilename("../../"), "attachment");
35
+ });
36
+
37
+ it("uses the caller's fallback when nothing usable survives", () => {
38
+ assert.equal(
39
+ sanitizeAttachmentFilename(" ", "attachment.pdf"),
40
+ "attachment.pdf",
41
+ );
42
+ });
43
+
44
+ it("strips the right-to-left override that disguises an extension", () => {
45
+ assert.equal(
46
+ sanitizeAttachmentFilename(`invoice${RLO}gnp.exe`),
47
+ "invoicegnp.exe",
48
+ );
49
+ });
50
+
51
+ it("strips bidi isolates, zero-width and BOM characters", () => {
52
+ assert.equal(
53
+ sanitizeAttachmentFilename(`${LRI}re${ZWSP}port${PDI}${BOM}.pdf`),
54
+ "report.pdf",
55
+ );
56
+ });
57
+
58
+ it("strips control characters that would break a header line", () => {
59
+ assert.equal(
60
+ sanitizeAttachmentFilename("note\r\n\tX-Evil 1.txt"),
61
+ "noteX-Evil 1.txt",
62
+ );
63
+ });
64
+
65
+ it("replaces characters that are illegal in a path", () => {
66
+ assert.equal(
67
+ sanitizeAttachmentFilename('re<po>rt|"?*.txt'),
68
+ "re_po_rt____.txt",
69
+ );
70
+ });
71
+
72
+ it("drops a leading dot so the file cannot land hidden", () => {
73
+ assert.equal(sanitizeAttachmentFilename(".bashrc"), "bashrc");
74
+ });
75
+
76
+ it("drops trailing dots and spaces", () => {
77
+ assert.equal(sanitizeAttachmentFilename("report.pdf. . "), "report.pdf");
78
+ });
79
+
80
+ it("guards a reserved Windows device name", () => {
81
+ assert.equal(sanitizeAttachmentFilename("NUL.txt"), "_NUL.txt");
82
+ assert.equal(sanitizeAttachmentFilename("com1"), "_com1");
83
+ });
84
+
85
+ it("does not guard a name that merely starts with a device name", () => {
86
+ assert.equal(sanitizeAttachmentFilename("console.log"), "console.log");
87
+ });
88
+
89
+ it("clamps an overlong name and keeps its extension", () => {
90
+ const result = sanitizeAttachmentFilename(`${"a".repeat(400)}.pdf`);
91
+ assert.equal(result.length, 120);
92
+ assert.ok(result.endsWith(".pdf"));
93
+ });
94
+
95
+ it("clamps an overlong name that has no extension", () => {
96
+ assert.equal(sanitizeAttachmentFilename("b".repeat(400)), "b".repeat(120));
97
+ });
98
+
99
+ it("clamps an overlong trailing segment rather than treating it as an extension", () => {
100
+ assert.equal(
101
+ sanitizeAttachmentFilename(`${"c".repeat(200)}.${"d".repeat(40)}`),
102
+ "c".repeat(120),
103
+ );
104
+ });
105
+
106
+ it("clamps an overlong fallback too", () => {
107
+ const result = sanitizeAttachmentFilename(
108
+ "",
109
+ `attachment.${"x".repeat(400)}`,
110
+ );
111
+ assert.equal(result.length, 120);
112
+ });
113
+
114
+ it("clamps on characters, never splitting a surrogate pair", () => {
115
+ const result = sanitizeAttachmentFilename(`${"😀".repeat(200)}.pdf`);
116
+ assert.equal([...result].length, 120);
117
+ assert.ok(result.endsWith(".pdf"));
118
+ assert.equal(/[\ud800-\udfff]/.test(result.replaceAll("😀", "")), false);
119
+ });
120
+ });
121
+
122
+ describe("formatByteSize", () => {
123
+ it("counts small payloads in bytes", () => {
124
+ assert.equal(formatByteSize(0), "0 bytes");
125
+ assert.equal(formatByteSize(1), "1 byte");
126
+ assert.equal(formatByteSize(1023), "1023 bytes");
127
+ });
128
+
129
+ it("switches to kilobytes at 1024", () => {
130
+ assert.equal(formatByteSize(1024), "1 KB");
131
+ assert.equal(formatByteSize(1536), "1.5 KB");
132
+ assert.equal(formatByteSize(1024 * 999), "999 KB");
133
+ });
134
+
135
+ it("switches to megabytes, gigabytes and terabytes", () => {
136
+ assert.equal(formatByteSize(1024 * 1024), "1 MB");
137
+ assert.equal(formatByteSize(1024 * 1024 * 2.5), "2.5 MB");
138
+ assert.equal(formatByteSize(1024 ** 3), "1 GB");
139
+ assert.equal(formatByteSize(1024 ** 4), "1 TB");
140
+ });
141
+
142
+ it("promotes at the point one decimal would round up to 1024", () => {
143
+ assert.equal(formatByteSize(1024 * 1023), "1023 KB");
144
+ assert.equal(formatByteSize(1024 * 1024 - 6), "1 MB");
145
+ assert.equal(formatByteSize(1024 ** 3 - 1), "1 GB");
146
+ assert.equal(formatByteSize(1024 ** 4 - 1), "1 TB");
147
+ });
148
+
149
+ it("stays in terabytes rather than inventing a larger unit", () => {
150
+ assert.equal(formatByteSize(1024 ** 5), "1024 TB");
151
+ });
152
+
153
+ it("drops the decimal once the value no longer needs it", () => {
154
+ assert.equal(formatByteSize(1024 * 1024 * 14.04), "14 MB");
155
+ });
156
+
157
+ it("reads as unknown for a size that was never declared", () => {
158
+ assert.equal(formatByteSize(Number.NaN), "unknown size");
159
+ assert.equal(formatByteSize(-1), "unknown size");
160
+ assert.equal(formatByteSize(Number.POSITIVE_INFINITY), "unknown size");
161
+ });
162
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Filename and size presentation for a mail attachment.
3
+ *
4
+ * An attachment filename is attacker-controlled: it arrives verbatim in a
5
+ * `Content-Disposition` header written by whoever sent the mail. Two things
6
+ * downstream trust it — the browser's save dialog (`<a download>`) and the
7
+ * rendered list — so one sanitizer serves both. What the list shows is exactly
8
+ * what the file is saved as; a name that reads one way and saves another is the
9
+ * whole point of the attack.
10
+ */
11
+
12
+ /**
13
+ * Control characters, zero-width joiners, line/paragraph separators and the
14
+ * bidirectional overrides. A RIGHT-TO-LEFT OVERRIDE placed inside
15
+ * `report<RLO>gnp.exe` makes it render as `reportexe.png`: the extension a
16
+ * user reads is not the extension that executes. Stripping rather than
17
+ * escaping keeps the displayed name and the saved name identical.
18
+ */
19
+ const INVISIBLE_CHARACTERS =
20
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping C0/C1 controls out of an attacker-supplied filename is the point
21
+ /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g;
22
+
23
+ /** Illegal in a Windows path, and `:` is a separator on classic macOS. */
24
+ const UNSAFE_CHARACTERS = /[<>:"|?*]/g;
25
+
26
+ /** Reserved device names on Windows — saving to one has no defined outcome. */
27
+ const RESERVED_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i;
28
+
29
+ const MAX_FILENAME_LENGTH = 120;
30
+ const MAX_EXTENSION_LENGTH = 12;
31
+
32
+ export const DEFAULT_ATTACHMENT_FILENAME = "attachment";
33
+
34
+ const clampLength = (name: string): string => {
35
+ // Code points, not UTF-16 units: slicing units splits a surrogate pair and
36
+ // leaves a lone half in the saved name.
37
+ const characters = [...name];
38
+ if (characters.length <= MAX_FILENAME_LENGTH) return name;
39
+ const dot = name.lastIndexOf(".");
40
+ const extension =
41
+ dot > 0 && [...name.slice(dot)].length <= MAX_EXTENSION_LENGTH
42
+ ? name.slice(dot)
43
+ : "";
44
+ const budget = MAX_FILENAME_LENGTH - [...extension].length;
45
+ return characters.slice(0, budget).join("") + extension;
46
+ };
47
+
48
+ /**
49
+ * Reduce an attacker-supplied attachment filename to a name that is safe to
50
+ * both display and save: the final path segment only, no invisible characters,
51
+ * no leading dot (a hidden file the user never sees land), and bounded length.
52
+ * Falls back to `fallback` when nothing usable survives.
53
+ */
54
+ export const sanitizeAttachmentFilename = (
55
+ raw: string,
56
+ fallback: string = DEFAULT_ATTACHMENT_FILENAME,
57
+ ): string => {
58
+ const visible = raw.replace(INVISIBLE_CHARACTERS, "");
59
+ const segments = visible.split(/[/\\]/);
60
+ const basename = segments[segments.length - 1] ?? "";
61
+ const trimmed = basename
62
+ .replace(UNSAFE_CHARACTERS, "_")
63
+ .replace(/^[.\s]+/, "")
64
+ .replace(/[.\s]+$/, "");
65
+ if (trimmed.length === 0) return clampLength(fallback);
66
+ const guarded = RESERVED_DEVICE_NAME.test(trimmed) ? `_${trimmed}` : trimmed;
67
+ return clampLength(guarded);
68
+ };
69
+
70
+ const SIZE_UNITS = ["KB", "MB", "GB", "TB"] as const;
71
+
72
+ /**
73
+ * Human-readable byte size, 1024-based, at most one decimal. A negative or
74
+ * non-finite size reads as unknown rather than as a number, so a broken
75
+ * BODYSTRUCTURE never presents itself as a measurement.
76
+ *
77
+ * The promotion threshold is the point where one decimal would round up to
78
+ * `1024`, not `1024` itself — `1048570 B` is `1 MB`, never `1024 KB`.
79
+ */
80
+ export const formatByteSize = (bytes: number): string => {
81
+ if (!Number.isFinite(bytes) || bytes < 0) return "unknown size";
82
+ const octets = Math.round(bytes);
83
+ if (octets < 1024) return octets === 1 ? "1 byte" : `${octets} bytes`;
84
+
85
+ const promoteAt = 1023.95;
86
+ let value = octets / 1024;
87
+ let unit = 0;
88
+ while (value >= promoteAt && unit < SIZE_UNITS.length - 1) {
89
+ value /= 1024;
90
+ unit += 1;
91
+ }
92
+ const rendered = value.toFixed(1).replace(/\.0$/, "");
93
+ return `${rendered} ${SIZE_UNITS[unit]}`;
94
+ };
package/src/rich-text.ts CHANGED
@@ -1,12 +1,26 @@
1
1
  /**
2
- * The rich-text editor and nothing else. Its own entry point so an app can load
3
- * it on demand: reached through the package barrel it would land in whichever
4
- * chunk already imports `@remit/ui`, which is every screen.
2
+ * The compose writing surfaces and nothing else. Their own entry point so an
3
+ * app can load them on demand: reached through the package barrel they would
4
+ * land in whichever chunk already imports `@remit/ui`, which is every screen.
5
5
  */
6
+ export {
7
+ type ComposeBodyMode,
8
+ ComposeModeToggle,
9
+ type ComposeModeToggleProps,
10
+ } from "./components/compose-mode-toggle.js";
11
+ export {
12
+ PlainTextEditor,
13
+ type PlainTextEditorProps,
14
+ } from "./components/plain-text-editor.js";
15
+ export {
16
+ htmlToMarkdown,
17
+ markdownToHtml,
18
+ } from "./components/rich-text-document.js";
6
19
  export {
7
20
  RichTextEditor,
8
21
  type RichTextEditorProps,
9
22
  } from "./components/rich-text-editor.js";
23
+ export { COMPOSE_TRANSFORMERS } from "./components/rich-text-markdown.js";
10
24
  export {
11
25
  EMPTY_RICH_TEXT,
12
26
  type RichTextValue,
package/src/tokens.css CHANGED
@@ -38,6 +38,10 @@
38
38
  --font-ui: var(--font-geist);
39
39
  --font-sans: var(--font-ui);
40
40
 
41
+ /* The plain compose surface promises the characters that will be sent, and a
42
+ pipe table in a proportional face is unaligned bars. Nothing downloaded. */
43
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
44
+
41
45
  /* surfaces & text */
42
46
  --color-canvas: var(--canvas);
43
47
  --color-surface: var(--surface);