@remit/ui 0.0.109 → 0.0.111
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/compose-action-bar.render.test.ts +7 -5
- package/src/components/compose-action-bar.stories.tsx +36 -19
- package/src/components/compose-action-bar.tsx +31 -23
- package/src/components/compose-body-skeleton.tsx +10 -0
- package/src/components/compose-body.stories.tsx +66 -1
- package/src/components/compose-body.tsx +11 -6
- package/src/components/compose-header.stories.tsx +27 -1
- package/src/components/compose-header.tsx +18 -6
- package/src/components/compose-language-chip.stories.tsx +35 -1
- package/src/components/compose-language-chip.tsx +20 -2
- package/src/components/compose-smtp-missing-banner.tsx +15 -3
- package/src/components/filter-rule-editor.stories.tsx +11 -2
- package/src/components/plain-text-editor.mount.test.ts +4 -4
- package/src/components/plain-text-editor.stories.tsx +1 -0
- package/src/components/plain-text-editor.tsx +20 -14
- package/src/components/rich-text-editor.stories.tsx +54 -0
- package/src/components/rich-text-editor.tsx +30 -8
- package/src/components/rich-text-formatting.test.ts +3 -2
- package/src/components/rich-text-image-node.test.ts +157 -0
- package/src/components/rich-text-image-node.ts +124 -0
- package/src/components/rich-text-nodes.ts +5 -1
- package/src/components/rich-text-paste.test.ts +48 -0
- package/src/components/rich-text-value.ts +6 -0
- package/src/index.ts +3 -0
- package/src/lib/adopted-html.test.ts +23 -0
- package/src/lib/adopted-html.ts +16 -1
- package/src/rich-text.ts +1 -0
|
@@ -10,6 +10,15 @@ import { before, describe, it } from "node:test";
|
|
|
10
10
|
import type { JSDOM } from "jsdom";
|
|
11
11
|
import type { LexicalEditor } from "lexical";
|
|
12
12
|
|
|
13
|
+
const PASTED_IMAGE = [
|
|
14
|
+
"<p>before</p>",
|
|
15
|
+
'<p><img src="https://example.com/logo.png" alt="The logo"></p>',
|
|
16
|
+
"<p>after</p>",
|
|
17
|
+
].join("");
|
|
18
|
+
|
|
19
|
+
const PIXEL =
|
|
20
|
+
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
|
21
|
+
|
|
13
22
|
const PASTED = [
|
|
14
23
|
'<meta charset="utf-8">',
|
|
15
24
|
'<h2 style="color:#111">Quarterly numbers</h2>',
|
|
@@ -106,3 +115,42 @@ describe("adopting pasted HTML", () => {
|
|
|
106
115
|
assert.match(text, /\*\*this quarter\*\*/);
|
|
107
116
|
});
|
|
108
117
|
});
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* #684: the sanitizer admitted the element and the editor had nowhere to put
|
|
121
|
+
* it, so the picture was gone by the time the paste landed — between two
|
|
122
|
+
* paragraphs that both survived.
|
|
123
|
+
*/
|
|
124
|
+
describe("adopting a pasted image", () => {
|
|
125
|
+
it("keeps the image between the text around it", () => {
|
|
126
|
+
const { html } = adoptAndSerialize(PASTED_IMAGE);
|
|
127
|
+
|
|
128
|
+
assert.match(html, /<img[^>]+src="https:\/\/example\.com\/logo\.png"/);
|
|
129
|
+
assert.match(html, /<img[^>]+alt="The logo"/);
|
|
130
|
+
assert.match(html, /before/);
|
|
131
|
+
assert.match(html, /after/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("keeps an image the clipboard carried as its own data", () => {
|
|
135
|
+
const { html } = adoptAndSerialize(
|
|
136
|
+
`<p><img src="${PIXEL}" alt="A pixel"></p>`,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
assert.ok(html.includes(`src="${PIXEL}"`));
|
|
140
|
+
assert.match(html, /alt="A pixel"/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("sends the image with a width the recipient's layout survives", () => {
|
|
144
|
+
const { html } = adoptAndSerialize(PASTED_IMAGE);
|
|
145
|
+
|
|
146
|
+
assert.match(html, /<img[^>]+style="max-width:100%;height:auto"/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("drops an image this app would not send", () => {
|
|
150
|
+
const { html } = adoptAndSerialize(
|
|
151
|
+
'<p><img src="http://tracker.example/px.gif"></p>',
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
assert.equal(html.includes("<img"), false);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -17,6 +17,12 @@ export interface RichTextValue {
|
|
|
17
17
|
formatting: readonly string[];
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Where a writing surface puts the caret when it takes focus on mount. Both
|
|
22
|
+
* surfaces read it, so it lives with the value rather than with either one.
|
|
23
|
+
*/
|
|
24
|
+
export type ComposeCaret = "start" | "end";
|
|
25
|
+
|
|
20
26
|
export const EMPTY_RICH_TEXT: RichTextValue = {
|
|
21
27
|
html: "",
|
|
22
28
|
text: "",
|
package/src/index.ts
CHANGED
|
@@ -129,12 +129,14 @@ export {
|
|
|
129
129
|
ComposeActionBar,
|
|
130
130
|
type ComposeActionBarProps,
|
|
131
131
|
type ComposeSaveStatus,
|
|
132
|
+
type ComposeSendState,
|
|
132
133
|
} from "./components/compose-action-bar.js";
|
|
133
134
|
export {
|
|
134
135
|
type AddressEntry,
|
|
135
136
|
ComposeAddressField,
|
|
136
137
|
type ComposeAddressFieldProps,
|
|
137
138
|
} from "./components/compose-address-field.js";
|
|
139
|
+
export { ComposeBodySkeleton } from "./components/compose-body-skeleton.js";
|
|
138
140
|
export {
|
|
139
141
|
ComposeFormShell,
|
|
140
142
|
type ComposeFormShellProps,
|
|
@@ -153,6 +155,7 @@ export {
|
|
|
153
155
|
export {
|
|
154
156
|
ComposeSmtpMissingBanner,
|
|
155
157
|
type ComposeSmtpMissingBannerProps,
|
|
158
|
+
SMTP_MISSING_MESSAGE,
|
|
156
159
|
} from "./components/compose-smtp-missing-banner.js";
|
|
157
160
|
export {
|
|
158
161
|
ComposeSubjectField,
|
|
@@ -124,6 +124,29 @@ describe("sanitizeAdoptedHtml", () => {
|
|
|
124
124
|
assert.equal(result.includes("tracker.gif"), false);
|
|
125
125
|
assert.equal(result.includes("cid:part1"), false);
|
|
126
126
|
});
|
|
127
|
+
|
|
128
|
+
it("caps an image at the width the recipient has for it", () => {
|
|
129
|
+
const result = sanitizeAdoptedHtml(
|
|
130
|
+
'<img src="https://example.com/screenshot.png" width="1600">',
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
assert.match(result, /style="max-width:100%;height:auto"/);
|
|
134
|
+
assert.equal(result.includes("1600"), false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("writes that cap itself rather than trusting the source", () => {
|
|
138
|
+
const result = sanitizeAdoptedHtml(
|
|
139
|
+
[
|
|
140
|
+
'<img src="https://example.com/a.png" style="width:1600px;border:8px solid red">',
|
|
141
|
+
'<p style="color:red">Text</p>',
|
|
142
|
+
].join(""),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
assert.equal(result.includes("1600px"), false);
|
|
146
|
+
assert.equal(result.includes("border"), false);
|
|
147
|
+
assert.equal(result.includes("color:red"), false);
|
|
148
|
+
assert.match(result, /<img src="https:\/\/example.com\/a.png"/);
|
|
149
|
+
});
|
|
127
150
|
});
|
|
128
151
|
|
|
129
152
|
describe("sanitizeQuotedHtml", () => {
|
package/src/lib/adopted-html.ts
CHANGED
|
@@ -63,6 +63,17 @@ const ADOPTED_ATTR = [
|
|
|
63
63
|
const LINK_SCHEMES = /^(?:https?:|mailto:)/i;
|
|
64
64
|
const IMAGE_SCHEMES = /^(?:https:|data:image\/)/i;
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* A pasted screenshot is as wide as the screen it came off, and the class the
|
|
68
|
+
* editor draws it with is this app's own presentation — it does not leave with
|
|
69
|
+
* the message. So the one constraint that has to survive is written here, on
|
|
70
|
+
* the way out, rather than admitted from whoever wrote the source: `style`
|
|
71
|
+
* stays out of ADOPTED_ATTR, and an image carries this declaration and no
|
|
72
|
+
* other. Whether a `data:` image reaches a given client at all is a separate
|
|
73
|
+
* question (#679).
|
|
74
|
+
*/
|
|
75
|
+
const IMAGE_SIZE = "max-width:100%;height:auto";
|
|
76
|
+
|
|
66
77
|
/**
|
|
67
78
|
* `"quoted"` is mail rendered back at its sender, and it renders in the app's
|
|
68
79
|
* own document rather than in the reading pane's sandboxed frame. A link in it
|
|
@@ -96,7 +107,11 @@ const createPurifier = (profile: Profile): ReturnType<typeof DOMPurify> => {
|
|
|
96
107
|
node.remove();
|
|
97
108
|
return;
|
|
98
109
|
}
|
|
99
|
-
if (!IMAGE_SCHEMES.test(node.getAttribute("src") ?? ""))
|
|
110
|
+
if (!IMAGE_SCHEMES.test(node.getAttribute("src") ?? "")) {
|
|
111
|
+
node.remove();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
node.setAttribute("style", IMAGE_SIZE);
|
|
100
115
|
});
|
|
101
116
|
return instance;
|
|
102
117
|
};
|