@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
|
@@ -55,6 +55,35 @@ const EmptyBody = () => (
|
|
|
55
55
|
</p>
|
|
56
56
|
);
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The message-body region of a reading pane. The sandboxed email frame — and
|
|
60
|
+
* only it — leaves the message's gutter, so mail renders on its own ground with
|
|
61
|
+
* its own margins and no app canvas shows down either side of it (#763).
|
|
62
|
+
* Everything else in the region is app chrome (the blocked-images notice, a
|
|
63
|
+
* plain-text body, an error, the attachment list) and keeps the inset the
|
|
64
|
+
* header has.
|
|
65
|
+
*
|
|
66
|
+
* The rule lives here, next to the `message-body-frame` marker it moves, so the
|
|
67
|
+
* pane and Storybook cannot drift apart on it. The negative margins mirror the
|
|
68
|
+
* message block's own `px-2 lg:px-4`.
|
|
69
|
+
*/
|
|
70
|
+
export const MessageBodyRegion = ({
|
|
71
|
+
className,
|
|
72
|
+
children,
|
|
73
|
+
}: {
|
|
74
|
+
className?: string;
|
|
75
|
+
children: ReactNode;
|
|
76
|
+
}) => (
|
|
77
|
+
<div
|
|
78
|
+
className={cn(
|
|
79
|
+
"[&_.message-body-frame]:-mx-2 lg:[&_.message-body-frame]:-mx-4",
|
|
80
|
+
className,
|
|
81
|
+
)}
|
|
82
|
+
>
|
|
83
|
+
{children}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
|
|
58
87
|
/**
|
|
59
88
|
* Render an email body the way the app does: sanitize the raw HTML
|
|
60
89
|
* (DOMPurify + privacy/XSS scrubbing), classify it as framed (designed mail —
|
|
@@ -92,6 +121,16 @@ export const MessageBodyView = ({
|
|
|
92
121
|
sanitized?.hasAuthorBackground ?? false,
|
|
93
122
|
);
|
|
94
123
|
|
|
124
|
+
// Stable across renders: the frame rebuilds its srcDoc — and so reloads the
|
|
125
|
+
// iframe — whenever this changes identity.
|
|
126
|
+
const declares = useMemo(
|
|
127
|
+
() => ({
|
|
128
|
+
background: sanitized?.hasAuthorBackground ?? false,
|
|
129
|
+
spacing: sanitized?.hasAuthorSpacing ?? false,
|
|
130
|
+
}),
|
|
131
|
+
[sanitized?.hasAuthorBackground, sanitized?.hasAuthorSpacing],
|
|
132
|
+
);
|
|
133
|
+
|
|
95
134
|
const blockedImageCount = useMemo(() => {
|
|
96
135
|
if (!sanitizedHtml || allowImages) return 0;
|
|
97
136
|
return (sanitizedHtml.match(/data-blocked-src/g) || []).length;
|
|
@@ -107,46 +146,56 @@ export const MessageBodyView = ({
|
|
|
107
146
|
|
|
108
147
|
return (
|
|
109
148
|
<div className={cn("message-body", className)}>
|
|
110
|
-
{/*
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
chrome rather than part of the email. */}
|
|
149
|
+
{/* The notice keeps the container's gutter: it is app chrome, not part
|
|
150
|
+
of the email, and only `message-body-frame` below leaves the
|
|
151
|
+
gutter. */}
|
|
114
152
|
{blockedImageCount > 0 && (
|
|
115
|
-
<div
|
|
116
|
-
{renderBlockedNotice?.(blockedImageCount)}
|
|
117
|
-
</div>
|
|
153
|
+
<div>{renderBlockedNotice?.(blockedImageCount)}</div>
|
|
118
154
|
)}
|
|
119
155
|
|
|
120
156
|
{sanitizedHtml ? (
|
|
121
157
|
// Email HTML renders inside a sandboxed iframe so its own CSS and any
|
|
122
158
|
// (already-DOMPurify'd) markup cannot bleed into the app chrome. The
|
|
123
|
-
// frame
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
159
|
+
// frame is the width of this box and never the width of the mail —
|
|
160
|
+
// content that cannot wrap scrolls inside the frame's own document —
|
|
161
|
+
// and its sandbox omits `allow-scripts` so even a hypothetical
|
|
162
|
+
// sanitizer escape can't execute.
|
|
163
|
+
//
|
|
164
|
+
// `message-body-frame` marks the box the gutter cancel moves, and it
|
|
165
|
+
// carries no width of its own: a block with both a width and a margin
|
|
166
|
+
// is over-constrained, and the browser resolves that by dropping the
|
|
167
|
+
// right margin — which left a strip of app canvas down one side of
|
|
168
|
+
// every email. The width lives on the child instead.
|
|
169
|
+
<div className="message-body-frame">
|
|
170
|
+
{framed ? (
|
|
171
|
+
// Full-width wrapper so a fluid newsletter fills the reading
|
|
172
|
+
// column. No border, padding or background — the email renders
|
|
173
|
+
// flush (#727).
|
|
174
|
+
<div className="w-full max-w-full">
|
|
175
|
+
<IsolatedEmailFrame
|
|
176
|
+
html={sanitizedHtml}
|
|
177
|
+
variant="framed"
|
|
178
|
+
isDark={isDark}
|
|
179
|
+
declares={declares}
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
) : (
|
|
183
|
+
// `lg:max-w-2xl` caps the reading column on desktop; `max-w-full`
|
|
184
|
+
// keeps the box within the viewport on mobile.
|
|
185
|
+
<div className="max-w-full lg:max-w-2xl">
|
|
186
|
+
<IsolatedEmailFrame
|
|
187
|
+
html={sanitizedHtml}
|
|
188
|
+
variant={isPlain ? "plain" : "framed"}
|
|
189
|
+
isDark={isDark}
|
|
190
|
+
declares={declares}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
149
195
|
) : text ? (
|
|
196
|
+
// Plain text is not an email document: it has no ground of its own and
|
|
197
|
+
// no layout to respect, so it keeps the message's gutter rather than
|
|
198
|
+
// running to the pane edge like the sandboxed frame does.
|
|
150
199
|
<pre className="email-text whitespace-pre-wrap text-sm leading-relaxed">
|
|
151
200
|
{text}
|
|
152
201
|
</pre>
|
|
@@ -134,8 +134,9 @@ export function MobileReadingPane({
|
|
|
134
134
|
style={touchHandlers ? { touchAction: "pan-y" } : undefined}
|
|
135
135
|
{...touchHandlers}
|
|
136
136
|
>
|
|
137
|
+
{/* Newest first, as on the wide pane. */}
|
|
137
138
|
{children ??
|
|
138
|
-
thread.messages.map((message) => {
|
|
139
|
+
[...thread.messages].reverse().map((message) => {
|
|
139
140
|
const bind = (handler?: (id: string) => void) =>
|
|
140
141
|
handler ? () => handler(message.id) : undefined;
|
|
141
142
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { Paperclip, Star } from "lucide-react";
|
|
3
3
|
import { useState } from "react";
|
|
4
|
+
import { expect } from "storybook/test";
|
|
4
5
|
import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
|
|
5
6
|
import {
|
|
6
7
|
type AttachmentDownloadState,
|
|
@@ -42,7 +43,7 @@ const thread: ThreadData = {
|
|
|
42
43
|
};
|
|
43
44
|
|
|
44
45
|
// A designed (framed) newsletter so the Screens story exercises the REAL
|
|
45
|
-
// renderer — sanitized + sandboxed iframe, flush layout,
|
|
46
|
+
// renderer — sanitized + sandboxed iframe, flush layout, in-document overflow —
|
|
46
47
|
// rather than a plain inline paragraph (#940). The fixed 600px table is the
|
|
47
48
|
// kind of markup that overflowed a phone before #727.
|
|
48
49
|
const newsletterThread: ThreadData = {
|
|
@@ -72,6 +73,40 @@ const newsletterThread: ThreadData = {
|
|
|
72
73
|
],
|
|
73
74
|
};
|
|
74
75
|
|
|
76
|
+
// Formatted HTML mail that declares no background, no padding and no width —
|
|
77
|
+
// the ordinary case. The app supplies the ground and the breathing room, so the
|
|
78
|
+
// email must read as one surface with the pane rather than a card inside it: no
|
|
79
|
+
// border, no accent line down the side, no gutter of app canvas beside it.
|
|
80
|
+
const bareThread: ThreadData = {
|
|
81
|
+
subject: "Repetitie donderdag",
|
|
82
|
+
messages: [
|
|
83
|
+
{
|
|
84
|
+
id: "bare-1",
|
|
85
|
+
fromName: "Ingrid Bakker",
|
|
86
|
+
fromEmail: "ingrid@koor.example",
|
|
87
|
+
toLabel: "you",
|
|
88
|
+
dateLabel: "Today, 11:24",
|
|
89
|
+
snippet: "De repetitie van donderdag gaat door…",
|
|
90
|
+
expanded: true,
|
|
91
|
+
bodyHtml: `<div>
|
|
92
|
+
<p>Hoi allemaal,</p>
|
|
93
|
+
<p>De repetitie van donderdag gaat door. We beginnen met het nieuwe stuk en
|
|
94
|
+
repeteren om 20.00 uur verder aan het programma voor het najaarsconcert.</p>
|
|
95
|
+
<p><b>Neem je eigen partituur mee</b> — er zijn geen reservekopieën.</p>
|
|
96
|
+
<p>Groeten,<br>Ingrid</p>
|
|
97
|
+
</div>`,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const PLAIN_TEXT_BODY = `Hoi allemaal,
|
|
103
|
+
|
|
104
|
+
De repetitie van donderdag gaat door. We beginnen met het nieuwe stuk en
|
|
105
|
+
repeteren om 20.00 uur verder aan het programma voor het najaarsconcert.
|
|
106
|
+
|
|
107
|
+
Groeten,
|
|
108
|
+
Ingrid`;
|
|
109
|
+
|
|
75
110
|
const meta: Meta<typeof ReadingPane> = {
|
|
76
111
|
title: "Screens/Kit/ReadingPane",
|
|
77
112
|
component: ReadingPane,
|
|
@@ -89,9 +124,29 @@ type Story = StoryObj<typeof ReadingPane>;
|
|
|
89
124
|
export const WithThread: Story = { args: { thread } };
|
|
90
125
|
|
|
91
126
|
/** A designed newsletter rendered through the real sanitize → sandboxed-iframe
|
|
92
|
-
* pipeline — the same rendering the live app shows (#940).
|
|
127
|
+
* pipeline — the same rendering the live app shows (#940). It brings its own
|
|
128
|
+
* background and its own 24px padding and keeps both, undoubled. */
|
|
93
129
|
export const Newsletter: Story = { args: { thread: newsletterThread } };
|
|
94
130
|
|
|
131
|
+
/** The same newsletter on the dark pane: darkened as authored, not repainted. */
|
|
132
|
+
export const NewsletterDark: Story = {
|
|
133
|
+
args: { thread: newsletterThread },
|
|
134
|
+
parameters: { theme: "dark" },
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Ordinary formatted mail that declares nothing of its own. The sender header
|
|
138
|
+
* keeps its inset; below it the email is one surface with the pane — no card
|
|
139
|
+
* edge, no accent line, no app gutter beside it — with the breathing room
|
|
140
|
+
* inside the email's own ground. */
|
|
141
|
+
export const BareHtmlMail: Story = { args: { thread: bareThread } };
|
|
142
|
+
|
|
143
|
+
/** The same on the dark pane, where a lighter app-supplied ground used to show
|
|
144
|
+
* as a rectangle seamed into the pane. */
|
|
145
|
+
export const BareHtmlMailDark: Story = {
|
|
146
|
+
args: { thread: bareThread },
|
|
147
|
+
parameters: { theme: "dark" },
|
|
148
|
+
};
|
|
149
|
+
|
|
95
150
|
export const Empty: Story = { args: { thread: undefined } };
|
|
96
151
|
|
|
97
152
|
export const WithIntelligenceToggle: Story = {
|
|
@@ -252,7 +307,7 @@ const ThreadAttachments = () => {
|
|
|
252
307
|
|
|
253
308
|
return (
|
|
254
309
|
<AttachmentList
|
|
255
|
-
className="mt-4
|
|
310
|
+
className="mt-4"
|
|
256
311
|
attachments={rows}
|
|
257
312
|
onDownload={(attachmentId) => {
|
|
258
313
|
setStatus(attachmentId, { status: "downloading" });
|
|
@@ -293,3 +348,83 @@ export const ExpandedRowWithAttachments: StoryObj<typeof ExpandedMessage> = {
|
|
|
293
348
|
</div>
|
|
294
349
|
),
|
|
295
350
|
};
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The email sits square in the pane: the frame leaves the message gutter on
|
|
354
|
+
* both sides, not just the left.
|
|
355
|
+
*
|
|
356
|
+
* A block box cannot have both a width and two margins — the browser resolves
|
|
357
|
+
* the over-constraint by throwing the right margin away — so a gutter cancel on
|
|
358
|
+
* a `w-full` box moved the mail left and left a strip of app canvas down its
|
|
359
|
+
* right-hand side. Bare mail hides that (it paints the pane's own colour); a
|
|
360
|
+
* newsletter with a ground of its own shows it plainly, which is why this
|
|
361
|
+
* measures a framed message.
|
|
362
|
+
*/
|
|
363
|
+
const framedRow: ThreadMessageData = {
|
|
364
|
+
...newsletterThread.messages[0],
|
|
365
|
+
id: "symmetry-1",
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const paneGutters = (canvasElement: HTMLElement) => {
|
|
369
|
+
const pane = canvasElement.querySelector<HTMLElement>("[data-pane]");
|
|
370
|
+
const frame = pane?.querySelector<HTMLElement>(".message-body-frame");
|
|
371
|
+
if (!pane || !frame) throw new Error("no framed message body in the pane");
|
|
372
|
+
const paneBox = pane.getBoundingClientRect();
|
|
373
|
+
const frameBox = frame.getBoundingClientRect();
|
|
374
|
+
return {
|
|
375
|
+
left: frameBox.left - paneBox.left,
|
|
376
|
+
right: paneBox.right - frameBox.right,
|
|
377
|
+
};
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const assertSymmetric = async (canvasElement: HTMLElement) => {
|
|
381
|
+
const { left, right } = paneGutters(canvasElement);
|
|
382
|
+
await expect(Math.abs(left - right)).toBeLessThan(1);
|
|
383
|
+
await expect(Math.abs(left)).toBeLessThan(1);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
/** A designed newsletter on a desktop reading column. */
|
|
387
|
+
export const FramedBodySitsSquareInThePane: StoryObj<typeof ExpandedMessage> = {
|
|
388
|
+
render: () => (
|
|
389
|
+
<div data-pane className="w-[900px] bg-canvas">
|
|
390
|
+
<ExpandedMessage message={framedRow} />
|
|
391
|
+
</div>
|
|
392
|
+
),
|
|
393
|
+
play: async ({ canvasElement }) => {
|
|
394
|
+
await assertSymmetric(canvasElement);
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
/** The same at a phone width, where the gutter is half as wide and a lost
|
|
399
|
+
* right margin is half the pane's breathing room. */
|
|
400
|
+
export const FramedBodySitsSquareOnAPhone: StoryObj<typeof ExpandedMessage> = {
|
|
401
|
+
render: () => (
|
|
402
|
+
<div data-pane className="w-[390px] bg-canvas">
|
|
403
|
+
<ExpandedMessage message={framedRow} />
|
|
404
|
+
</div>
|
|
405
|
+
),
|
|
406
|
+
play: async ({ canvasElement }) => {
|
|
407
|
+
await assertSymmetric(canvasElement);
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* A message with no HTML part. The two body paths side by side: an email
|
|
413
|
+
* document runs flush on its own ground, while plain text — which has neither —
|
|
414
|
+
* keeps the message gutter and stays off the pane edge.
|
|
415
|
+
*/
|
|
416
|
+
export const PlainTextMessage: StoryObj<typeof ExpandedMessage> = {
|
|
417
|
+
render: () => (
|
|
418
|
+
<div className="max-w-3xl bg-canvas">
|
|
419
|
+
<ExpandedMessage
|
|
420
|
+
message={{ ...row, fromName: "Ingrid Bakker" }}
|
|
421
|
+
to={<>to the choir list</>}
|
|
422
|
+
body={
|
|
423
|
+
<div className="mt-3">
|
|
424
|
+
<MessageBodyView text={PLAIN_TEXT_BODY} />
|
|
425
|
+
</div>
|
|
426
|
+
}
|
|
427
|
+
/>
|
|
428
|
+
</div>
|
|
429
|
+
),
|
|
430
|
+
};
|
|
@@ -6,7 +6,7 @@ import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
|
|
|
6
6
|
import { Avatar } from "./avatar.js";
|
|
7
7
|
import { IntelligenceToggle } from "./intelligence-toggle.js";
|
|
8
8
|
import { MailActionToolbar } from "./mail-action-toolbar.js";
|
|
9
|
-
import { MessageBodyView } from "./message-body-view.js";
|
|
9
|
+
import { MessageBodyRegion, MessageBodyView } from "./message-body-view.js";
|
|
10
10
|
import { ReadingPaneEmpty } from "./reading-pane-empty.js";
|
|
11
11
|
|
|
12
12
|
/* ------------------------------------------------------------------ */
|
|
@@ -133,6 +133,11 @@ export function ExpandedMessage({
|
|
|
133
133
|
warning?: string;
|
|
134
134
|
/** Collapse handler on the sender block. Omit for a static row. */
|
|
135
135
|
onHeaderClick?: () => void;
|
|
136
|
+
/**
|
|
137
|
+
* Keyboard cursor. Rings the sender row, the way it rings a collapsed row —
|
|
138
|
+
* never the message, which would draw an accent line down both sides of the
|
|
139
|
+
* email and read as a card edge around mail that carries its own.
|
|
140
|
+
*/
|
|
136
141
|
isFocused?: boolean;
|
|
137
142
|
/** Rendered after the sender name (e.g. a trusted-sender badge). */
|
|
138
143
|
senderBadge?: ReactNode;
|
|
@@ -177,13 +182,17 @@ export function ExpandedMessage({
|
|
|
177
182
|
);
|
|
178
183
|
|
|
179
184
|
return (
|
|
180
|
-
<div
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
185
|
+
<div className="px-2 py-3 lg:px-4">
|
|
186
|
+
{/* The cursor rings the sender row, not the message. Around the message
|
|
187
|
+
it drew an accent line down both sides of every open email, which
|
|
188
|
+
reads as a card edge on mail that brings its own. The negative
|
|
189
|
+
margins hold the row exactly where it sits unringed. */}
|
|
190
|
+
<div
|
|
191
|
+
className={cn(
|
|
192
|
+
"-mx-2 -my-1 flex items-start gap-3 rounded-sm px-2 py-1",
|
|
193
|
+
isFocused && "ring-1 ring-inset ring-accent/30",
|
|
194
|
+
)}
|
|
195
|
+
>
|
|
187
196
|
{/* The chevron is the disclosure control, not a picture of one: it is
|
|
188
197
|
what a reader aims at to put a message away, so it carries the
|
|
189
198
|
collapse itself rather than sitting beside the sender block that
|
|
@@ -240,12 +249,7 @@ export function ExpandedMessage({
|
|
|
240
249
|
exactly what the app renders, not a divergent inline-HTML mock
|
|
241
250
|
(#940). `framed` fixtures map to the newsletter treatment (author
|
|
242
251
|
colors preserved); the rest render plain. */}
|
|
243
|
-
|
|
244
|
-
this block's `px-2` so no app canvas shows beside the body and the
|
|
245
|
-
email doesn't read as sitting in a tinted frame (#763). The header
|
|
246
|
-
keeps its inset. The blocked-images notice is app chrome, not part
|
|
247
|
-
of the email, so it takes the gutter back. Desktop is unchanged. */}
|
|
248
|
-
<div className="-mx-2 [&_.message-body-notice]:px-2 lg:mx-0 lg:[&_.message-body-notice]:px-0">
|
|
252
|
+
<MessageBodyRegion>
|
|
249
253
|
{body ?? (
|
|
250
254
|
<MessageBodyView
|
|
251
255
|
className="mt-3"
|
|
@@ -254,7 +258,7 @@ export function ExpandedMessage({
|
|
|
254
258
|
allowImages
|
|
255
259
|
/>
|
|
256
260
|
)}
|
|
257
|
-
</
|
|
261
|
+
</MessageBodyRegion>
|
|
258
262
|
</div>
|
|
259
263
|
);
|
|
260
264
|
}
|
|
@@ -332,17 +336,22 @@ export function ReadingPane({
|
|
|
332
336
|
</p>
|
|
333
337
|
</div>
|
|
334
338
|
|
|
335
|
-
{
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
339
|
+
{/* Newest first. A thread arrives in the order it happened; the
|
|
340
|
+
turn a reader opened it for is the last one, and reading it
|
|
341
|
+
should not cost a scroll past everything that led there. */}
|
|
342
|
+
{[...thread.messages]
|
|
343
|
+
.reverse()
|
|
344
|
+
.map((message) =>
|
|
345
|
+
message.expanded ? (
|
|
346
|
+
<ExpandedMessage
|
|
347
|
+
key={message.id}
|
|
348
|
+
message={message}
|
|
349
|
+
warning={thread.warning}
|
|
350
|
+
/>
|
|
351
|
+
) : (
|
|
352
|
+
<CollapsedMessage key={message.id} message={message} />
|
|
353
|
+
),
|
|
354
|
+
)}
|
|
346
355
|
</div>
|
|
347
356
|
)}
|
|
348
357
|
</article>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
1
2
|
import {
|
|
2
3
|
Panel,
|
|
3
4
|
PanelGroup,
|
|
@@ -52,4 +53,45 @@ export function ResizableHandle({
|
|
|
52
53
|
);
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Gives everything inside it a whole-pixel box. `react-resizable-panels` sizes a
|
|
58
|
+
* pane with a fractional `flexGrow`, so a pane lands on 712.5px and every box
|
|
59
|
+
* under it inherits the fraction — and a fraction is where the DOM's whole-pixel
|
|
60
|
+
* measurements start disagreeing with each other. Floor rather than round, so
|
|
61
|
+
* the box is never a hair wider than the pane holding it.
|
|
62
|
+
*/
|
|
63
|
+
export function WholePixelWidth({
|
|
64
|
+
className,
|
|
65
|
+
children,
|
|
66
|
+
}: {
|
|
67
|
+
className?: string;
|
|
68
|
+
children: ReactNode;
|
|
69
|
+
}) {
|
|
70
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
71
|
+
const [width, setWidth] = useState<number | null>(null);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const pane = ref.current?.parentElement;
|
|
75
|
+
if (!pane || typeof ResizeObserver === "undefined") return;
|
|
76
|
+
const measure = () => {
|
|
77
|
+
const next = Math.floor(pane.getBoundingClientRect().width);
|
|
78
|
+
setWidth((prev) => (prev === next ? prev : next));
|
|
79
|
+
};
|
|
80
|
+
measure();
|
|
81
|
+
const observer = new ResizeObserver(measure);
|
|
82
|
+
observer.observe(pane);
|
|
83
|
+
return () => observer.disconnect();
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
ref={ref}
|
|
89
|
+
className={className}
|
|
90
|
+
style={width === null ? undefined : { width: `${width}px` }}
|
|
91
|
+
>
|
|
92
|
+
{children}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
55
97
|
export type { PanelGroupProps, PanelProps, PanelResizeHandleProps };
|
|
@@ -54,7 +54,7 @@ export function SlidePanel({
|
|
|
54
54
|
|
|
55
55
|
<div
|
|
56
56
|
className={cn(
|
|
57
|
-
"safe-area-frame fixed top-0 right-0 z-50 h-full w-full border-l border-line bg-canvas shadow-xl sm:w-[400px] sm:max-w-[90vw]",
|
|
57
|
+
"safe-area-frame fixed top-0 right-0 z-50 flex h-full w-full flex-col border-l border-line bg-canvas shadow-xl sm:w-[400px] sm:max-w-[90vw]",
|
|
58
58
|
"transform transition-transform duration-200 ease-out",
|
|
59
59
|
isOpen ? "translate-x-0" : "pointer-events-none translate-x-full",
|
|
60
60
|
)}
|
|
@@ -64,7 +64,7 @@ export function SlidePanel({
|
|
|
64
64
|
inert={!isOpen}
|
|
65
65
|
aria-labelledby="slide-panel-title"
|
|
66
66
|
>
|
|
67
|
-
<div className="flex h-14 items-center justify-between border-b border-line px-4">
|
|
67
|
+
<div className="flex h-14 shrink-0 items-center justify-between border-b border-line px-4">
|
|
68
68
|
<h2 id="slide-panel-title" className="font-semibold">
|
|
69
69
|
{title}
|
|
70
70
|
</h2>
|
|
@@ -78,7 +78,11 @@ export function SlidePanel({
|
|
|
78
78
|
</button>
|
|
79
79
|
</div>
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
{/* The body takes what the header leaves, from the layout rather than
|
|
82
|
+
from arithmetic over the header's height: a computed height puts
|
|
83
|
+
the box on a fractional pixel and drifts the moment the header
|
|
84
|
+
does. */}
|
|
85
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
82
86
|
<div className="flex-1 overflow-auto p-4">{children}</div>
|
|
83
87
|
{footer && (
|
|
84
88
|
<div className="flex justify-end gap-3 border-t border-line bg-canvas p-4">
|
package/src/index.ts
CHANGED
|
@@ -179,6 +179,7 @@ export {
|
|
|
179
179
|
ComposeFormShell,
|
|
180
180
|
type ComposeFormShellProps,
|
|
181
181
|
type ComposeMode,
|
|
182
|
+
type ComposeShellLayout,
|
|
182
183
|
composeModeLabels,
|
|
183
184
|
} from "./components/compose-form-shell.js";
|
|
184
185
|
export {
|
|
@@ -218,7 +219,10 @@ export {
|
|
|
218
219
|
DialogBackdrop,
|
|
219
220
|
type DialogBackdropProps,
|
|
220
221
|
} from "./components/dialog-backdrop.js";
|
|
221
|
-
export type {
|
|
222
|
+
export type {
|
|
223
|
+
AuthorDeclarations,
|
|
224
|
+
EmailFrameVariant,
|
|
225
|
+
} from "./components/email-frame-css.js";
|
|
222
226
|
export {
|
|
223
227
|
EventDetail,
|
|
224
228
|
type EventDetailProps,
|
|
@@ -408,6 +412,7 @@ export {
|
|
|
408
412
|
} from "./components/mail-header.js";
|
|
409
413
|
export {
|
|
410
414
|
type EmailRenderCategory,
|
|
415
|
+
MessageBodyRegion,
|
|
411
416
|
MessageBodyView,
|
|
412
417
|
type MessageBodyViewProps,
|
|
413
418
|
} from "./components/message-body-view.js";
|
|
@@ -569,6 +574,7 @@ export {
|
|
|
569
574
|
ResizableHandle,
|
|
570
575
|
ResizablePanel,
|
|
571
576
|
ResizablePanelGroup,
|
|
577
|
+
WholePixelWidth,
|
|
572
578
|
} from "./components/resizable.js";
|
|
573
579
|
export {
|
|
574
580
|
EMPTY_RICH_TEXT,
|
|
@@ -819,6 +825,7 @@ export {
|
|
|
819
825
|
export {
|
|
820
826
|
createEmailSanitizer,
|
|
821
827
|
detectAuthorBackground,
|
|
828
|
+
detectAuthorSpacing,
|
|
822
829
|
type SanitizedEmail,
|
|
823
830
|
type SanitizeOptions,
|
|
824
831
|
sanitizeInlineStyle,
|
|
@@ -13,6 +13,9 @@ import {
|
|
|
13
13
|
wrapWithLanguage,
|
|
14
14
|
} from "./compose-language.js";
|
|
15
15
|
|
|
16
|
+
/** The dictionaries the published image stages, per `REMIT_SPELLCHECK_LANGUAGES`. */
|
|
17
|
+
const BUILT = ["en", "en-GB", "nl"];
|
|
18
|
+
|
|
16
19
|
before(() => {
|
|
17
20
|
const dom = new JSDOM("");
|
|
18
21
|
globalThis.DOMParser = dom.window.DOMParser;
|
|
@@ -66,6 +69,20 @@ describe("defaultComposeLanguages", () => {
|
|
|
66
69
|
it("falls back to English when the browser offers nothing usable", () => {
|
|
67
70
|
assert.deepEqual(defaultComposeLanguages([]), ["en"]);
|
|
68
71
|
});
|
|
72
|
+
|
|
73
|
+
it("offers what the build can spellcheck, not the browser alone", () => {
|
|
74
|
+
assert.deepEqual(defaultComposeLanguages(["en-US", "en"], BUILT), [
|
|
75
|
+
"en",
|
|
76
|
+
"nl",
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("keeps the browser's own language first, so it stays the default", () => {
|
|
81
|
+
assert.deepEqual(defaultComposeLanguages(["nl-NL", "nl"], BUILT), [
|
|
82
|
+
"nl",
|
|
83
|
+
"en",
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
69
86
|
});
|
|
70
87
|
|
|
71
88
|
describe("browserSpellcheckHelp", () => {
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* message the recipient's client can read the language off. See issue #686.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { spellcheckLanguages } from "../components/rich-text-spellcheck-languages.js";
|
|
14
|
+
|
|
13
15
|
/**
|
|
14
16
|
* A language the composer offers, and the ISO 639-3 code `franc-min` knows it
|
|
15
17
|
* by. The two are separate alphabets: the tag is what goes on the document and
|
|
@@ -111,16 +113,25 @@ export const languageLabel = (tag: string): string => {
|
|
|
111
113
|
};
|
|
112
114
|
|
|
113
115
|
/**
|
|
114
|
-
* The languages an account writes in when it has not said
|
|
115
|
-
*
|
|
116
|
-
*
|
|
116
|
+
* The languages an account writes in when it has not said: the browser's own
|
|
117
|
+
* ordered answer first, then the dictionaries this build carries, then `en`.
|
|
118
|
+
*
|
|
119
|
+
* The dictionaries are in there because the browser routinely names one
|
|
120
|
+
* language and the writer uses another — Dutch mail written on an English
|
|
121
|
+
* browser is the ordinary case, not the exception. A candidate set of one turns
|
|
122
|
+
* detection off, since there is nothing to choose between, and every message
|
|
123
|
+
* then keeps the default tag and is checked against the wrong dictionary. What
|
|
124
|
+
* the deployment staged is the other statement about which languages are
|
|
125
|
+
* written here, and it is a short list, which is what keeps detection accurate.
|
|
117
126
|
*/
|
|
118
127
|
export const defaultComposeLanguages = (
|
|
119
128
|
locales: readonly string[],
|
|
129
|
+
built: readonly string[] = spellcheckLanguages(),
|
|
120
130
|
): string[] => {
|
|
121
|
-
const known = locales.filter(
|
|
122
|
-
|
|
123
|
-
|
|
131
|
+
const known = [...locales, ...built].filter(
|
|
132
|
+
(locale) => detectionCodeFor(locale) !== null,
|
|
133
|
+
);
|
|
134
|
+
const unique = [...new Set(known.map(primaryLanguageSubtag))];
|
|
124
135
|
if (!unique.includes("en")) unique.push("en");
|
|
125
136
|
return unique;
|
|
126
137
|
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
7
|
import { describe, it } from "node:test";
|
|
8
|
+
import { defaultComposeLanguages } from "./compose-language.js";
|
|
8
9
|
import { detectComposeLanguage } from "./detect-compose-language.js";
|
|
9
10
|
|
|
10
11
|
const DUTCH =
|
|
@@ -45,6 +46,23 @@ describe("detectComposeLanguage", () => {
|
|
|
45
46
|
assert.equal(detectComposeLanguage(DUTCH, ["nl", "ja", "en"]), "nl");
|
|
46
47
|
});
|
|
47
48
|
|
|
49
|
+
it("reads a Dutch line written on an English browser", () => {
|
|
50
|
+
// The account has never been to the language setting, and the browser it is
|
|
51
|
+
// read on says English and nothing else. A candidate set built from that
|
|
52
|
+
// alone has nothing to choose between, and every Dutch message goes out
|
|
53
|
+
// tagged `en` with the English dictionary underlining all of it.
|
|
54
|
+
const candidates = defaultComposeLanguages(
|
|
55
|
+
["en-US", "en"],
|
|
56
|
+
["en", "en-GB", "nl"],
|
|
57
|
+
);
|
|
58
|
+
assert.equal(
|
|
59
|
+
detectComposeLanguage("OK nou dank je wel hoor flapsigaar", candidates),
|
|
60
|
+
"nl",
|
|
61
|
+
);
|
|
62
|
+
assert.equal(detectComposeLanguage(DUTCH, candidates), "nl");
|
|
63
|
+
assert.equal(detectComposeLanguage(ENGLISH, candidates), "en");
|
|
64
|
+
});
|
|
65
|
+
|
|
48
66
|
it("resolves a regional tag through its language", () => {
|
|
49
67
|
assert.equal(detectComposeLanguage(ENGLISH, ["nl", "en-GB"]), "en-GB");
|
|
50
68
|
});
|