@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 +1 -1
- package/src/components/attachment-list.render.test.ts +120 -0
- package/src/components/attachment-list.stories.tsx +159 -0
- package/src/components/attachment-list.tsx +182 -0
- package/src/components/compose-mode-toggle.tsx +35 -0
- package/src/components/message-row.render.test.ts +31 -0
- package/src/components/message-row.tsx +10 -2
- package/src/components/plain-text-editor.mount.test.ts +238 -0
- package/src/components/plain-text-editor.stories.tsx +227 -0
- package/src/components/plain-text-editor.tsx +178 -0
- package/src/components/reading-pane.stories.tsx +58 -2
- package/src/components/rich-text-document.ts +111 -3
- package/src/components/rich-text-editor.stories.tsx +76 -0
- package/src/components/rich-text-editor.tsx +4 -1
- package/src/components/rich-text-formatting.test.ts +153 -0
- package/src/components/rich-text-markdown.test.ts +131 -0
- package/src/components/rich-text-markdown.ts +155 -0
- package/src/components/rich-text-toolbar.tsx +68 -46
- package/src/components/rich-text-value.ts +11 -1
- package/src/index.ts +11 -0
- package/src/lib/attachment-file.test.ts +162 -0
- package/src/lib/attachment-file.ts +94 -0
- package/src/rich-text.ts +17 -3
- package/src/tokens.css +4 -0
|
@@ -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={`
|
|
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
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
<
|
|
193
|
-
|
|
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 = {
|
|
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
|
|
3
|
-
*
|
|
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);
|