@remit/ui 0.0.122 → 0.0.124
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/app-shell-slotted.tsx +5 -1
- package/src/components/compose-body.language.test.ts +236 -0
- package/src/components/compose-body.stories.tsx +26 -0
- package/src/components/compose-form-shell.render.test.ts +59 -0
- package/src/components/compose-form-shell.tsx +36 -10
- package/src/components/email-frame-css.ts +110 -29
- package/src/components/isolated-email-frame.render.test.ts +174 -36
- package/src/components/isolated-email-frame.stories.tsx +518 -33
- package/src/components/isolated-email-frame.tsx +58 -135
- package/src/components/message-body-view.stories.tsx +200 -2
- package/src/components/message-body-view.tsx +82 -33
- package/src/components/mobile-reading-pane.tsx +2 -1
- package/src/components/reading-pane.stories.tsx +138 -3
- package/src/components/reading-pane.tsx +35 -26
- package/src/components/resizable.tsx +42 -0
- package/src/components/slide-panel.tsx +7 -3
- package/src/index.ts +8 -1
- package/src/lib/compose-language.test.ts +17 -0
- package/src/lib/compose-language.ts +17 -6
- package/src/lib/detect-compose-language.test.ts +18 -0
- package/src/lib/email-layout-clamp.test.ts +30 -0
- package/src/lib/email-layout-clamp.ts +17 -0
- package/src/lib/email-sanitizer.test.ts +153 -0
- package/src/lib/email-sanitizer.ts +165 -0
- package/src/lib/keymap.test.ts +23 -0
- package/src/lib/keymap.ts +61 -4
- package/src/lib/shortcut-tree.test.ts +438 -0
- package/src/lib/shortcut-tree.ts +373 -0
|
@@ -89,6 +89,36 @@ describe("generateLayoutClampCSS (#374 / #727)", () => {
|
|
|
89
89
|
);
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
+
test("overrides an author nowrap so flowing text can never be clipped", () => {
|
|
93
|
+
// The frame is the pane's width, so a pinned paragraph is read by dragging
|
|
94
|
+
// the whole email sideways; inside an author `overflow:hidden` it is cut
|
|
95
|
+
// mid-character with nothing to scroll at all.
|
|
96
|
+
assert.ok(
|
|
97
|
+
/\[nowrap\][^{]*\{[^}]*white-space:\s*normal\s*!important/.test(css),
|
|
98
|
+
"a nowrap attribute must not pin a line wider than the frame",
|
|
99
|
+
);
|
|
100
|
+
assert.ok(
|
|
101
|
+
/\[style\*="nowrap" i\][^{]*\{[^}]*white-space:\s*normal\s*!important/.test(
|
|
102
|
+
css,
|
|
103
|
+
),
|
|
104
|
+
"an inline white-space:nowrap must not pin a line wider than the frame",
|
|
105
|
+
);
|
|
106
|
+
assert.ok(
|
|
107
|
+
/\[style\*="nowrap" i\]/.test(css),
|
|
108
|
+
"Outlook emits WHITE-SPACE: NOWRAP — the attribute match must ignore case",
|
|
109
|
+
);
|
|
110
|
+
assert.ok(
|
|
111
|
+
/\[nowrap\]:not\(pre, code, pre \*, code \*\)/.test(css),
|
|
112
|
+
"pre/code AND their descendants keep their own whitespace handling",
|
|
113
|
+
);
|
|
114
|
+
assert.ok(
|
|
115
|
+
/:is\(pre, code, pre \*, code \*\):is\(\[nowrap\], \[style\*="nowrap" i\]\)[^{]*\{[^}]*white-space:\s*pre-wrap\s*!important/.test(
|
|
116
|
+
css,
|
|
117
|
+
),
|
|
118
|
+
"a nowrap inside a pre wraps rather than collapsing the block's spacing",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
92
122
|
test("wraps long unbroken lines in pre/code blocks", () => {
|
|
93
123
|
assert.ok(/\bpre\b/.test(css));
|
|
94
124
|
assert.ok(/\bcode\b/.test(css));
|
|
@@ -55,10 +55,27 @@ td, th {
|
|
|
55
55
|
* {
|
|
56
56
|
min-width: 0;
|
|
57
57
|
}
|
|
58
|
+
/* An author \`nowrap\` assumes a viewport as wide as the line. The frame is the
|
|
59
|
+
pane's width whatever the mail is, so a pinned paragraph turns reading one
|
|
60
|
+
sentence into a sideways drag — and inside an author \`overflow:hidden\` it is
|
|
61
|
+
cut mid-character with nothing left to scroll at all. Flowing text wraps. The
|
|
62
|
+
\`i\` flag is load-bearing: Outlook and older generators emit \`WHITE-SPACE:
|
|
63
|
+
NOWRAP\`, and an attribute value match is case-sensitive without it. */
|
|
64
|
+
[nowrap]:not(pre, code, pre *, code *),
|
|
65
|
+
[style*="nowrap" i]:not(pre, code, pre *, code *) {
|
|
66
|
+
white-space: normal !important;
|
|
67
|
+
}
|
|
58
68
|
/* Long unbroken strings (URLs, tokens) wrap instead of forcing a wide line. */
|
|
59
69
|
pre, code {
|
|
60
70
|
white-space: pre-wrap;
|
|
61
71
|
overflow-wrap: anywhere;
|
|
62
72
|
word-break: break-word;
|
|
63
73
|
}
|
|
74
|
+
/* Preformatted text keeps its spacing even where a nowrap is declared — on the
|
|
75
|
+
block or on a span inside it. \`normal\` would collapse the runs of spaces that
|
|
76
|
+
are the entire content of a \`pre\`, and the un-important rule above cannot
|
|
77
|
+
defend an inline style, so this wraps to \`pre-wrap\` instead of unwrapping. */
|
|
78
|
+
:is(pre, code, pre *, code *):is([nowrap], [style*="nowrap" i]) {
|
|
79
|
+
white-space: pre-wrap !important;
|
|
80
|
+
}
|
|
64
81
|
`;
|
|
@@ -3,6 +3,7 @@ import { describe, test } from "node:test";
|
|
|
3
3
|
import { buildCidResolver } from "./cid-resolver.js";
|
|
4
4
|
import {
|
|
5
5
|
detectAuthorBackground,
|
|
6
|
+
detectAuthorSpacing,
|
|
6
7
|
sanitizeInlineStyle,
|
|
7
8
|
sanitizeStyleElementCss,
|
|
8
9
|
} from "./email-sanitizer.js";
|
|
@@ -360,3 +361,155 @@ describe("detectAuthorBackground — <style> block over-match hardening (#483)",
|
|
|
360
361
|
assert.equal(detectAuthorBackground(html), false);
|
|
361
362
|
});
|
|
362
363
|
});
|
|
364
|
+
|
|
365
|
+
describe("detectAuthorSpacing — does the mail lay out its own breathing room?", () => {
|
|
366
|
+
test("a bare message declares none", () => {
|
|
367
|
+
assert.equal(
|
|
368
|
+
detectAuthorSpacing("<div><p>Hi there,</p><p>See you at 3.</p></div>"),
|
|
369
|
+
false,
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("a reset is not breathing room", () => {
|
|
374
|
+
assert.equal(
|
|
375
|
+
detectAuthorSpacing(
|
|
376
|
+
'<table style="margin:0;padding:0"><tr></tr></table>',
|
|
377
|
+
),
|
|
378
|
+
false,
|
|
379
|
+
);
|
|
380
|
+
assert.equal(
|
|
381
|
+
detectAuthorSpacing("<style>body{margin:0 auto;padding:0px}</style>"),
|
|
382
|
+
false,
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("a reset that shouts is still a reset", () => {
|
|
387
|
+
assert.equal(
|
|
388
|
+
detectAuthorSpacing('<div style="padding:0 !important">x</div>'),
|
|
389
|
+
false,
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("an inline padding on a container counts", () => {
|
|
394
|
+
assert.equal(
|
|
395
|
+
detectAuthorSpacing('<td style="padding:24px;color:#111">x</td>'),
|
|
396
|
+
true,
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("a horizontal margin in a <style> block counts", () => {
|
|
401
|
+
assert.equal(
|
|
402
|
+
detectAuthorSpacing("<style>.wrap{margin:0 24px}</style><p>x</p>"),
|
|
403
|
+
true,
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("the tables newsletters are still built from count via cellpadding", () => {
|
|
408
|
+
assert.equal(detectAuthorSpacing('<table cellpadding="8"></table>'), true);
|
|
409
|
+
assert.equal(detectAuthorSpacing('<table cellpadding="0"></table>'), false);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("the word margin in prose is not a declaration", () => {
|
|
413
|
+
assert.equal(
|
|
414
|
+
detectAuthorSpacing("<p>The margin was thin this quarter.</p>"),
|
|
415
|
+
false,
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("a mail about CSS quotes a declaration without making one", () => {
|
|
420
|
+
assert.equal(
|
|
421
|
+
detectAuthorSpacing(
|
|
422
|
+
'<p>Set style="padding:9px" on the cell and it lines up.</p>',
|
|
423
|
+
),
|
|
424
|
+
false,
|
|
425
|
+
);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("a commented-out declaration is not a declaration", () => {
|
|
429
|
+
assert.equal(
|
|
430
|
+
detectAuthorSpacing("<style>.wrap{/* padding: 20px */}</style><p>x</p>"),
|
|
431
|
+
false,
|
|
432
|
+
);
|
|
433
|
+
assert.equal(
|
|
434
|
+
detectAuthorSpacing(
|
|
435
|
+
'<!--[if mso]><td style="padding:20px"><![endif]--><p>x</p>',
|
|
436
|
+
),
|
|
437
|
+
false,
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("CSS hidden from prehistoric clients still counts", () => {
|
|
442
|
+
assert.equal(
|
|
443
|
+
detectAuthorSpacing(
|
|
444
|
+
"<style><!-- .wrap{padding:24px} --></style><p>x</p>",
|
|
445
|
+
),
|
|
446
|
+
true,
|
|
447
|
+
);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("a property that merely ends in padding is not padding", () => {
|
|
451
|
+
assert.equal(
|
|
452
|
+
detectAuthorSpacing('<div style="scroll-padding:40px">x</div>'),
|
|
453
|
+
false,
|
|
454
|
+
);
|
|
455
|
+
assert.equal(
|
|
456
|
+
detectAuthorSpacing("<style>td{mso-padding-alt:0cm 5.4pt}</style>"),
|
|
457
|
+
false,
|
|
458
|
+
);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("a negative margin pulls content out, it does not space it", () => {
|
|
462
|
+
assert.equal(
|
|
463
|
+
detectAuthorSpacing('<div style="margin-left:-12px">x</div>'),
|
|
464
|
+
false,
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The commonest mail in an inbox is a personal reply, and a personal reply
|
|
471
|
+
* carries a quote. Every client indents that quote and spaces its paragraphs,
|
|
472
|
+
* so counting either as "the mail lays out its own container" left exactly the
|
|
473
|
+
* mail the inset exists for flush against a phone's screen edge.
|
|
474
|
+
*/
|
|
475
|
+
describe("detectAuthorSpacing — the mail the inset exists for", () => {
|
|
476
|
+
test("an Apple Mail reply with a quoted original declares none", () => {
|
|
477
|
+
const html = [
|
|
478
|
+
"<div>Ja hoor, tot donderdag.</div><br>",
|
|
479
|
+
'<blockquote type="cite" style="margin:0 0 0 40px;border:none;padding:0px">',
|
|
480
|
+
"<div>Kun je donderdag?</div>",
|
|
481
|
+
"</blockquote>",
|
|
482
|
+
].join("");
|
|
483
|
+
assert.equal(detectAuthorSpacing(html), false);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("a Gmail reply with a quoted original declares none", () => {
|
|
487
|
+
const html = [
|
|
488
|
+
'<div dir="ltr">Works for me.</div>',
|
|
489
|
+
'<div class="gmail_quote">',
|
|
490
|
+
'<blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;',
|
|
491
|
+
'border-left:1px solid rgb(204,204,204);padding-left:1ex">',
|
|
492
|
+
"<div>Thursday still good?</div>",
|
|
493
|
+
"</blockquote></div>",
|
|
494
|
+
].join("");
|
|
495
|
+
assert.equal(detectAuthorSpacing(html), false);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("Outlook paragraph spacing is not a container", () => {
|
|
499
|
+
const html = [
|
|
500
|
+
"<style>p.MsoNormal{margin:0cm;margin-bottom:.0001pt}</style>",
|
|
501
|
+
"<p class=MsoNormal style='margin-bottom:12.0pt'>Beste Matthijs,</p>",
|
|
502
|
+
"<p class=MsoNormal style='margin-bottom:12.0pt'>Met vriendelijke groet,</p>",
|
|
503
|
+
].join("");
|
|
504
|
+
assert.equal(detectAuthorSpacing(html), false);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("a newsletter that lays out its own container still counts", () => {
|
|
508
|
+
const html = [
|
|
509
|
+
'<table width="600" style="margin:0 auto">',
|
|
510
|
+
'<tr><td style="width:600px;padding:24px;background:#83cd29">',
|
|
511
|
+
"<h1>Node Weekly</h1></td></tr></table>",
|
|
512
|
+
].join("");
|
|
513
|
+
assert.equal(detectAuthorSpacing(html), true);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
@@ -27,10 +27,18 @@ export interface SanitizeOptions {
|
|
|
27
27
|
* so author colors survive (newsletter / designed mail). When false, the
|
|
28
28
|
* subtree inherits the app theme so plain mail blends with dark chrome
|
|
29
29
|
* instead of becoming a bright slab (#375).
|
|
30
|
+
*
|
|
31
|
+
* `hasAuthorSpacing` is the same question for layout: does the mail hold its own
|
|
32
|
+
* content off its container's left and right edges? A newsletter that already
|
|
33
|
+
* spaces its container must not be given a second helping; everything else —
|
|
34
|
+
* including a personal reply whose only declarations are a quote's indent and
|
|
35
|
+
* the gaps between paragraphs — gets breathing room injected inside its own
|
|
36
|
+
* background.
|
|
30
37
|
*/
|
|
31
38
|
export interface SanitizedEmail {
|
|
32
39
|
html: string;
|
|
33
40
|
hasAuthorBackground: boolean;
|
|
41
|
+
hasAuthorSpacing: boolean;
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
/**
|
|
@@ -112,6 +120,161 @@ export const detectAuthorBackground = (html: string): boolean => {
|
|
|
112
120
|
return false;
|
|
113
121
|
};
|
|
114
122
|
|
|
123
|
+
/**
|
|
124
|
+
* CSS keywords that space nothing: a `margin: 0` reset is not the mail laying
|
|
125
|
+
* out its own breathing room, and neither is `margin: auto` centring a table.
|
|
126
|
+
*/
|
|
127
|
+
const NO_SPACING_KEYWORDS = new Set([
|
|
128
|
+
"auto",
|
|
129
|
+
"none",
|
|
130
|
+
"initial",
|
|
131
|
+
"inherit",
|
|
132
|
+
"unset",
|
|
133
|
+
"revert",
|
|
134
|
+
"revert-layer",
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* `padding` / `margin` declarations, and only those. The lookbehind keeps the
|
|
139
|
+
* properties that merely end in the same word out of the scan — `scroll-padding`
|
|
140
|
+
* on a scroll container and Outlook's `mso-padding-alt` are not the mail
|
|
141
|
+
* spacing its container.
|
|
142
|
+
*/
|
|
143
|
+
const SPACING_DECL_RE =
|
|
144
|
+
/(?<![-\w])(?:padding|margin)(?:-(top|right|bottom|left|block|inline)(?:-(?:start|end))?)?\s*:\s*([^;}"']+)/gi;
|
|
145
|
+
|
|
146
|
+
const VERTICAL_SIDES = new Set(["top", "bottom", "block"]);
|
|
147
|
+
|
|
148
|
+
/** CSS comments hold no declarations, in a `<style>` block or in a `style=`. */
|
|
149
|
+
const stripCssComments = (css: string): string =>
|
|
150
|
+
css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Whether one length token pushes content off the container edge. Zero, a
|
|
154
|
+
* keyword and a negative all fail — a negative margin pulls content out rather
|
|
155
|
+
* than spacing it in. Anything unrecognised (`calc()`, `var()`) counts, on the
|
|
156
|
+
* conservative side of leaving a self-spacing mail alone.
|
|
157
|
+
*/
|
|
158
|
+
const isSpacingLength = (token: string): boolean => {
|
|
159
|
+
const value = token.toLowerCase();
|
|
160
|
+
if (!value || NO_SPACING_KEYWORDS.has(value)) return false;
|
|
161
|
+
if (value.startsWith("-")) return false;
|
|
162
|
+
const numeric = /^\+?(\d*\.?\d+)[a-z%]*$/.exec(value);
|
|
163
|
+
if (numeric) return Number.parseFloat(numeric[1]) !== 0;
|
|
164
|
+
return true;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** The left/right components of a `padding`/`margin` shorthand value. */
|
|
168
|
+
const horizontalComponents = (value: string): string[] => {
|
|
169
|
+
const parts = value.split(/\s+/).filter(Boolean);
|
|
170
|
+
if (parts.length < 2) return parts;
|
|
171
|
+
if (parts.length < 4) return [parts[1]];
|
|
172
|
+
return [parts[1], parts[3]];
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Whether one declaration spaces the container horizontally. Only the
|
|
177
|
+
* horizontal axis answers the question the inset asks: a `margin-bottom` between
|
|
178
|
+
* two Outlook paragraphs is the mail spacing its own paragraphs, not laying out
|
|
179
|
+
* a container, and a mail with nothing but that still runs into a phone's screen
|
|
180
|
+
* edge without the inset.
|
|
181
|
+
*/
|
|
182
|
+
const spacesHorizontally = (side: string | undefined, raw: string): boolean => {
|
|
183
|
+
if (side && VERTICAL_SIDES.has(side)) return false;
|
|
184
|
+
const value = raw.replace(/!\s*important/gi, "").trim();
|
|
185
|
+
if (!value) return false;
|
|
186
|
+
if (side) return value.split(/\s+/).some(isSpacingLength);
|
|
187
|
+
return horizontalComponents(value).some(isSpacingLength);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** Whether CSS text lays out horizontal container spacing. */
|
|
191
|
+
const declaresContainerSpacing = (css: string): boolean => {
|
|
192
|
+
for (const match of stripCssComments(css).matchAll(SPACING_DECL_RE)) {
|
|
193
|
+
if (spacesHorizontally(match[1], match[2])) return true;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Elements whose spacing is a convention rather than a container. Every mail
|
|
200
|
+
* client on earth indents a quote with a left margin on the `<blockquote>`, and
|
|
201
|
+
* a reply carrying a quote is exactly the mail the injected inset exists for.
|
|
202
|
+
*/
|
|
203
|
+
const SPACING_EXEMPT_TAGS = new Set(["blockquote"]);
|
|
204
|
+
|
|
205
|
+
/** HTML comments hold no markup — including the `<!--[if mso]>` conditionals
|
|
206
|
+
* Outlook mail is padded with, which no other client ever renders. */
|
|
207
|
+
const stripHtmlComments = (html: string): string =>
|
|
208
|
+
html.replace(/<!--[\s\S]*?-->/g, "");
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Every inline `style="…"` / `style='…'` value in the raw markup, with the tag
|
|
212
|
+
* carrying it. Scanning tags rather than the whole string is what keeps a
|
|
213
|
+
* literal `style="padding:9px"` written out in the text of a mail ABOUT CSS
|
|
214
|
+
* from reading as layout.
|
|
215
|
+
*/
|
|
216
|
+
const extractStyledTags = (html: string): { tag: string; style: string }[] => {
|
|
217
|
+
const found: { tag: string; style: string }[] = [];
|
|
218
|
+
const tags = /<([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
|
|
219
|
+
const styles = /\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
|
|
220
|
+
for (const [, tag, attrs] of html.matchAll(tags)) {
|
|
221
|
+
for (const match of attrs.matchAll(styles)) {
|
|
222
|
+
found.push({ tag: tag.toLowerCase(), style: match[1] ?? match[2] ?? "" });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return found;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/** `selector { … }` pairs; a nested at-rule yields its inner rules. */
|
|
229
|
+
const extractCssRules = (css: string): { selector: string; body: string }[] =>
|
|
230
|
+
[...stripCssComments(css).matchAll(/([^{}]*)\{([^{}]*)\}/g)].map((match) => ({
|
|
231
|
+
selector: match[1].trim(),
|
|
232
|
+
body: match[2],
|
|
233
|
+
}));
|
|
234
|
+
|
|
235
|
+
/** The element a selector actually styles — the tag of its last compound. */
|
|
236
|
+
const selectorSubjectTag = (selector: string): string => {
|
|
237
|
+
const last =
|
|
238
|
+
selector
|
|
239
|
+
.trim()
|
|
240
|
+
.split(/[\s>+~]+/)
|
|
241
|
+
.pop() ?? "";
|
|
242
|
+
return (/^[a-zA-Z][\w-]*/.exec(last)?.[0] ?? "").toLowerCase();
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const stylesOnlyQuotes = (selector: string): boolean =>
|
|
246
|
+
selector
|
|
247
|
+
.split(",")
|
|
248
|
+
.every((one) => SPACING_EXEMPT_TAGS.has(selectorSubjectTag(one)));
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Detect whether the mail lays out horizontal spacing of its own — in an inline
|
|
252
|
+
* style, in a `<style>` block, or through a non-zero `cellpadding` on the tables
|
|
253
|
+
* newsletters are still built from. Same shape and same limits as
|
|
254
|
+
* `detectAuthorBackground`: a declaration scan over the raw markup, deliberately
|
|
255
|
+
* conservative, because the cost of a false positive (no injected inset) is a
|
|
256
|
+
* message that runs into the screen edge, while a false negative doubles a
|
|
257
|
+
* newsletter's own padding.
|
|
258
|
+
*/
|
|
259
|
+
export const detectAuthorSpacing = (html: string): boolean => {
|
|
260
|
+
// Style blocks come off the raw string: old mail wraps its CSS in an HTML
|
|
261
|
+
// comment to hide it from clients that never shipped `<style>`.
|
|
262
|
+
const blocks = extractStyleBlocks(html);
|
|
263
|
+
const markup = stripHtmlComments(html);
|
|
264
|
+
if (/\bcellpadding\s*=\s*["']?(?!0["'\s>])\d/i.test(markup)) return true;
|
|
265
|
+
for (const { tag, style } of extractStyledTags(markup)) {
|
|
266
|
+
if (SPACING_EXEMPT_TAGS.has(tag)) continue;
|
|
267
|
+
if (declaresContainerSpacing(style)) return true;
|
|
268
|
+
}
|
|
269
|
+
for (const block of blocks) {
|
|
270
|
+
for (const rule of extractCssRules(block)) {
|
|
271
|
+
if (stylesOnlyQuotes(rule.selector)) continue;
|
|
272
|
+
if (declaresContainerSpacing(rule.body)) return true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return false;
|
|
276
|
+
};
|
|
277
|
+
|
|
115
278
|
const FORBIDDEN_TAGS = [
|
|
116
279
|
"script",
|
|
117
280
|
"iframe",
|
|
@@ -282,6 +445,7 @@ export const createEmailSanitizer = (options: SanitizeOptions = {}) => {
|
|
|
282
445
|
// #375), but running the detector on the raw input is simpler and
|
|
283
446
|
// not sensitive to any future hook rewrites.
|
|
284
447
|
const hasAuthorBackground = detectAuthorBackground(html);
|
|
448
|
+
const hasAuthorSpacing = detectAuthorSpacing(html);
|
|
285
449
|
|
|
286
450
|
const sanitized = purify.sanitize(html, config);
|
|
287
451
|
|
|
@@ -293,6 +457,7 @@ export const createEmailSanitizer = (options: SanitizeOptions = {}) => {
|
|
|
293
457
|
return {
|
|
294
458
|
html: `<style>${layoutCss}</style>${sanitized}`,
|
|
295
459
|
hasAuthorBackground,
|
|
460
|
+
hasAuthorSpacing,
|
|
296
461
|
};
|
|
297
462
|
};
|
|
298
463
|
};
|
package/src/lib/keymap.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
3
|
import {
|
|
4
|
+
ALL_TRIAGE_ACTIONS,
|
|
4
5
|
KEY_HINT_GROUPS,
|
|
5
6
|
keysForAction,
|
|
6
7
|
shortcutHintForAction,
|
|
@@ -54,6 +55,28 @@ describe("keymap module", () => {
|
|
|
54
55
|
assert.strictEqual(tooltipForAction(unbound), "");
|
|
55
56
|
});
|
|
56
57
|
|
|
58
|
+
test("the navigation hints name a direction on screen, not a place in time", () => {
|
|
59
|
+
const navigation = KEY_HINT_GROUPS.find((g) => g.title === "Navigation");
|
|
60
|
+
assert.ok(navigation, "the Navigation group is declared");
|
|
61
|
+
|
|
62
|
+
const describes = (key: string): string | undefined =>
|
|
63
|
+
navigation.hints.find((hint) => hint.keys.join("") === key)?.description;
|
|
64
|
+
|
|
65
|
+
// Both surfaces the keys serve read downward, and a conversation reads
|
|
66
|
+
// newest first — so down is back in time there. A description saying
|
|
67
|
+
// "next message" is read as the newer one, which is the opposite.
|
|
68
|
+
assert.strictEqual(describes("j"), "Focus the message below");
|
|
69
|
+
assert.strictEqual(describes("↓"), "Focus the message below");
|
|
70
|
+
assert.strictEqual(describes("k"), "Focus the message above");
|
|
71
|
+
assert.strictEqual(describes("↑"), "Focus the message above");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("every action the app can produce has a displayed binding", () => {
|
|
75
|
+
for (const action of ALL_TRIAGE_ACTIONS) {
|
|
76
|
+
assert.ok(keysForAction(action), `${action} has keys`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
57
80
|
test("every hint's action is a non-empty key list", () => {
|
|
58
81
|
for (const group of KEY_HINT_GROUPS) {
|
|
59
82
|
for (const hint of group.hints) {
|
package/src/lib/keymap.ts
CHANGED
|
@@ -48,6 +48,51 @@ export type TriageAction =
|
|
|
48
48
|
| "goFlagged"
|
|
49
49
|
| "goSettings";
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Presence table for {@link TriageAction}. `Record<TriageAction, true>` is what
|
|
53
|
+
* makes it exhaustive: adding a member to the union without adding it here is a
|
|
54
|
+
* type error, so anything that must reason about *every* action — the shortcut
|
|
55
|
+
* tree's completeness tests, the help overlay's audit — has a runtime list that
|
|
56
|
+
* cannot silently fall behind the type.
|
|
57
|
+
*/
|
|
58
|
+
const TRIAGE_ACTION_PRESENCE: Record<TriageAction, true> = {
|
|
59
|
+
focusNext: true,
|
|
60
|
+
focusPrevious: true,
|
|
61
|
+
focusFirst: true,
|
|
62
|
+
focusLast: true,
|
|
63
|
+
openFocused: true,
|
|
64
|
+
back: true,
|
|
65
|
+
toggleSelect: true,
|
|
66
|
+
extendSelectDown: true,
|
|
67
|
+
extendSelectUp: true,
|
|
68
|
+
selectAll: true,
|
|
69
|
+
reply: true,
|
|
70
|
+
replyAll: true,
|
|
71
|
+
forward: true,
|
|
72
|
+
delete: true,
|
|
73
|
+
toggleStar: true,
|
|
74
|
+
toggleRead: true,
|
|
75
|
+
muteSender: true,
|
|
76
|
+
blockSender: true,
|
|
77
|
+
vipSender: true,
|
|
78
|
+
markJunk: true,
|
|
79
|
+
toggleIntelligence: true,
|
|
80
|
+
toggleDensity: true,
|
|
81
|
+
focusSearch: true,
|
|
82
|
+
compose: true,
|
|
83
|
+
help: true,
|
|
84
|
+
goBrief: true,
|
|
85
|
+
goInbox: true,
|
|
86
|
+
goSent: true,
|
|
87
|
+
goFlagged: true,
|
|
88
|
+
goSettings: true,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Every action the app can produce, in declaration order. */
|
|
92
|
+
export const ALL_TRIAGE_ACTIONS: readonly TriageAction[] = Object.keys(
|
|
93
|
+
TRIAGE_ACTION_PRESENCE,
|
|
94
|
+
) as TriageAction[];
|
|
95
|
+
|
|
51
96
|
/** Map of action → handler. Omitted actions are inert (no-op). */
|
|
52
97
|
export type TriageHandlers = Partial<Record<TriageAction, () => void>>;
|
|
53
98
|
|
|
@@ -104,17 +149,29 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
|
|
|
104
149
|
{
|
|
105
150
|
title: "Navigation",
|
|
106
151
|
hints: [
|
|
107
|
-
|
|
152
|
+
// The keys move through what is on screen, so the description is a
|
|
153
|
+
// direction rather than a position in time. A conversation reads newest
|
|
154
|
+
// first, where the message below is the older one — "next message" read
|
|
155
|
+
// as the newer one there and named the opposite of what j does.
|
|
156
|
+
{
|
|
157
|
+
action: "focusNext",
|
|
158
|
+
keys: ["j"],
|
|
159
|
+
description: "Focus the message below",
|
|
160
|
+
},
|
|
108
161
|
{
|
|
109
162
|
action: "focusPrevious",
|
|
110
163
|
keys: ["k"],
|
|
111
|
-
description: "Focus
|
|
164
|
+
description: "Focus the message above",
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
action: "focusNext",
|
|
168
|
+
keys: ["↓"],
|
|
169
|
+
description: "Focus the message below",
|
|
112
170
|
},
|
|
113
|
-
{ action: "focusNext", keys: ["↓"], description: "Focus next message" },
|
|
114
171
|
{
|
|
115
172
|
action: "focusPrevious",
|
|
116
173
|
keys: ["↑"],
|
|
117
|
-
description: "Focus
|
|
174
|
+
description: "Focus the message above",
|
|
118
175
|
},
|
|
119
176
|
{ action: "focusFirst", keys: ["Home"], description: "Focus first" },
|
|
120
177
|
{ action: "focusLast", keys: ["End"], description: "Focus last" },
|