@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.
@@ -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", () => {
@@ -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") ?? "")) node.remove();
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
  };
package/src/rich-text.ts CHANGED
@@ -48,6 +48,7 @@ export {
48
48
  type SpellWorkerPort,
49
49
  } from "./components/rich-text-spellcheck-provider.js";
50
50
  export {
51
+ type ComposeCaret,
51
52
  EMPTY_RICH_TEXT,
52
53
  type RichTextValue,
53
54
  } from "./components/rich-text-value.js";