@remit/web-client 0.0.136 → 0.0.138
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/ComposeBody.stories.tsx +463 -0
- package/src/components/compose/ComposeBody.tsx +198 -10
- package/src/components/compose/ComposeForm.tsx +96 -11
- package/src/components/compose/MobileComposeSheet.tsx +11 -28
- package/src/components/compose/compose-mode.test.ts +76 -0
- package/src/components/compose/compose-mode.ts +50 -0
- package/src/components/settings/AccountFormPanel.tsx +23 -0
- package/src/components/ui/ConfirmDialog.tsx +17 -4
- package/src/hooks/useComposeLanguages.ts +69 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.138",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import type { RichTextValue } from "@remit/ui/rich-text";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
|
5
|
+
import { ComposeBody, type ConversionFailure } from "./ComposeBody";
|
|
6
|
+
|
|
7
|
+
const RICH_DOCUMENT = [
|
|
8
|
+
"<h2>Quarterly numbers</h2>",
|
|
9
|
+
"<p>Revenue is <strong>up</strong> on the quarter.</p>",
|
|
10
|
+
"<table><thead><tr><th>Region</th><th>Total</th></tr></thead>",
|
|
11
|
+
"<tbody><tr><td>EMEA</td><td>412</td></tr></tbody></table>",
|
|
12
|
+
].join("");
|
|
13
|
+
|
|
14
|
+
const PLAIN_PARAGRAPHS =
|
|
15
|
+
"<p>Thanks — that works.</p><p></p><p>See you then.</p>";
|
|
16
|
+
|
|
17
|
+
const UNDERLINED = "<p>Please <u>read this</u> before Friday.</p>";
|
|
18
|
+
|
|
19
|
+
const PLAIN_MARKDOWN = [
|
|
20
|
+
"## Quarterly numbers",
|
|
21
|
+
"",
|
|
22
|
+
"| Region | Total |",
|
|
23
|
+
"| --- | --- |",
|
|
24
|
+
"| EMEA | 412 |",
|
|
25
|
+
].join("\n");
|
|
26
|
+
|
|
27
|
+
const DUTCH_DOCUMENT =
|
|
28
|
+
"<p>Beste Anna, de vergadering van donderdag gaat niet door. Ik stuur je morgen een nieuw voorstel voor de planning.</p>";
|
|
29
|
+
|
|
30
|
+
const DUTCH_PROSE =
|
|
31
|
+
"Beste Anna, de vergadering van donderdag gaat niet door. Ik stuur je morgen een nieuw voorstel.";
|
|
32
|
+
|
|
33
|
+
/** The languages a Dutch-writing account has configured, most-used first. */
|
|
34
|
+
const LANGUAGES = ["nl", "en", "de"];
|
|
35
|
+
|
|
36
|
+
const noop = () => undefined;
|
|
37
|
+
|
|
38
|
+
const Harness = ({
|
|
39
|
+
initialHtml = "",
|
|
40
|
+
initialText = "",
|
|
41
|
+
startIn = "rich",
|
|
42
|
+
onConversionError = () => undefined,
|
|
43
|
+
conversions,
|
|
44
|
+
languages = LANGUAGES,
|
|
45
|
+
quoted,
|
|
46
|
+
}: {
|
|
47
|
+
initialHtml?: string;
|
|
48
|
+
initialText?: string;
|
|
49
|
+
startIn?: "rich" | "plain";
|
|
50
|
+
onConversionError?: (failure: ConversionFailure) => void;
|
|
51
|
+
conversions?: {
|
|
52
|
+
toPlain: (value: RichTextValue) => string;
|
|
53
|
+
toRich: (text: string) => string;
|
|
54
|
+
};
|
|
55
|
+
languages?: string[];
|
|
56
|
+
quoted?: string;
|
|
57
|
+
}) => {
|
|
58
|
+
const [mode, setMode] = useState<"rich" | "plain">(startIn);
|
|
59
|
+
return (
|
|
60
|
+
<div className="flex h-[460px] w-[680px] flex-col overflow-auto rounded-md border border-line bg-canvas">
|
|
61
|
+
<ComposeBody
|
|
62
|
+
mode={mode}
|
|
63
|
+
onModeChange={setMode}
|
|
64
|
+
initialHtml={initialHtml}
|
|
65
|
+
initialText={initialText}
|
|
66
|
+
onChange={() => undefined}
|
|
67
|
+
onConversionError={onConversionError}
|
|
68
|
+
conversions={conversions}
|
|
69
|
+
languages={languages}
|
|
70
|
+
onLanguageChange={noop}
|
|
71
|
+
/>
|
|
72
|
+
{quoted && (
|
|
73
|
+
<blockquote
|
|
74
|
+
data-testid="compose-quoted"
|
|
75
|
+
lang="fr"
|
|
76
|
+
className="border-l-2 border-line px-3 py-2 text-sm text-fg-muted"
|
|
77
|
+
>
|
|
78
|
+
{quoted}
|
|
79
|
+
</blockquote>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const chipOf = (canvasElement: HTMLElement): HTMLElement => {
|
|
86
|
+
const chip = canvasElement.querySelector<HTMLElement>(
|
|
87
|
+
"[data-testid=compose-language-chip]",
|
|
88
|
+
);
|
|
89
|
+
if (!chip) throw new Error("the language chip is not mounted");
|
|
90
|
+
return chip;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The mode switch as the compose window runs it: the toolbar control, the one
|
|
95
|
+
* warning it raises, and the two surfaces it swaps between. The live
|
|
96
|
+
* `ComposeForm` adds the recipients, the autosave and the send around this.
|
|
97
|
+
*/
|
|
98
|
+
const meta: Meta<typeof Harness> = {
|
|
99
|
+
title: "Screens/WebClient/ComposeModes",
|
|
100
|
+
component: Harness,
|
|
101
|
+
parameters: { layout: "centered" },
|
|
102
|
+
};
|
|
103
|
+
export default meta;
|
|
104
|
+
|
|
105
|
+
type Story = StoryObj<typeof Harness>;
|
|
106
|
+
|
|
107
|
+
const toggleOf = (canvasElement: HTMLElement): HTMLElement => {
|
|
108
|
+
const toggle = canvasElement.querySelector<HTMLElement>(
|
|
109
|
+
"[data-testid=compose-mode-toggle]",
|
|
110
|
+
);
|
|
111
|
+
if (!toggle) throw new Error("the mode toggle is not mounted");
|
|
112
|
+
return toggle;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const plainSurface = (canvasElement: HTMLElement): HTMLTextAreaElement | null =>
|
|
116
|
+
canvasElement.querySelector<HTMLTextAreaElement>(
|
|
117
|
+
"[data-testid=compose-body-plain]",
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
export const RichDocument: Story = {
|
|
121
|
+
name: "Rich, with formatting",
|
|
122
|
+
args: { initialHtml: RICH_DOCUMENT },
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const PlainDraft: Story = {
|
|
126
|
+
name: "Plain, reopened from Markdown",
|
|
127
|
+
args: { startIn: "plain", initialText: PLAIN_MARKDOWN },
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Cancel changes nothing: the mode stays rich, the document is untouched, and
|
|
132
|
+
* focus comes back to the control that was pressed. `aria-pressed` never flips
|
|
133
|
+
* optimistically.
|
|
134
|
+
*/
|
|
135
|
+
export const WarningCancelled: Story = {
|
|
136
|
+
name: "The warning, cancelled",
|
|
137
|
+
args: { initialHtml: RICH_DOCUMENT },
|
|
138
|
+
play: async ({ canvasElement }) => {
|
|
139
|
+
const toggle = toggleOf(canvasElement);
|
|
140
|
+
await userEvent.click(toggle);
|
|
141
|
+
|
|
142
|
+
const dialog = within(document.body).getByRole("dialog");
|
|
143
|
+
await expect(dialog).toHaveTextContent("Switch to plain text?");
|
|
144
|
+
await expect(dialog).toHaveTextContent(
|
|
145
|
+
"Formatting becomes Markdown. Bold keeps its asterisks, a table becomes rows of pipes, and that text is what the recipient gets. No formatted version is sent alongside it.",
|
|
146
|
+
);
|
|
147
|
+
await expect(toggle).toHaveAttribute("aria-pressed", "false");
|
|
148
|
+
|
|
149
|
+
await userEvent.click(
|
|
150
|
+
within(dialog).getByRole("button", { name: "Cancel" }),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
await expect(plainSurface(canvasElement)).toBeNull();
|
|
154
|
+
await expect(
|
|
155
|
+
canvasElement.querySelector("[data-testid=compose-body] table"),
|
|
156
|
+
).not.toBeNull();
|
|
157
|
+
await expect(toggleOf(canvasElement)).toHaveFocus();
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const WarningConfirmed: Story = {
|
|
162
|
+
name: "The warning, confirmed",
|
|
163
|
+
args: { initialHtml: RICH_DOCUMENT },
|
|
164
|
+
play: async ({ canvasElement }) => {
|
|
165
|
+
await userEvent.click(toggleOf(canvasElement));
|
|
166
|
+
await userEvent.click(
|
|
167
|
+
within(within(document.body).getByRole("dialog")).getByRole("button", {
|
|
168
|
+
name: "Switch to plain text",
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const textarea = plainSurface(canvasElement);
|
|
173
|
+
if (!textarea) throw new Error("the plain surface did not arrive");
|
|
174
|
+
await expect(textarea.value).toContain("## Quarterly numbers");
|
|
175
|
+
await expect(textarea.value).toContain("**up**");
|
|
176
|
+
await expect(textarea.value).toContain("| EMEA | 412 |");
|
|
177
|
+
|
|
178
|
+
// The formatting buttons leave with the rich surface.
|
|
179
|
+
await expect(
|
|
180
|
+
canvasElement.querySelector("[aria-label='Bold (Ctrl+B)']"),
|
|
181
|
+
).toBeNull();
|
|
182
|
+
await expect(toggleOf(canvasElement)).toHaveAttribute(
|
|
183
|
+
"aria-pressed",
|
|
184
|
+
"true",
|
|
185
|
+
);
|
|
186
|
+
await expect(textarea).toHaveFocus();
|
|
187
|
+
await expect(textarea.selectionStart).toBe(textarea.value.length);
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** Nothing but paragraphs and a blank line: switching changes nothing, so nothing is asked. */
|
|
192
|
+
export const PlainProseSwitchesSilently: Story = {
|
|
193
|
+
name: "Plain paragraphs switch without asking",
|
|
194
|
+
args: { initialHtml: PLAIN_PARAGRAPHS },
|
|
195
|
+
play: async ({ canvasElement }) => {
|
|
196
|
+
await userEvent.click(toggleOf(canvasElement));
|
|
197
|
+
|
|
198
|
+
await expect(within(document.body).queryByRole("dialog")).toBeNull();
|
|
199
|
+
const textarea = plainSurface(canvasElement);
|
|
200
|
+
if (!textarea) throw new Error("the plain surface did not arrive");
|
|
201
|
+
await expect(textarea.value).toContain("Thanks");
|
|
202
|
+
await expect(textarea.value).toContain("See you then.");
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* An underlined word exports identical to its own characters, so a comparison
|
|
208
|
+
* of the two strings would switch in silence and destroy it. The rule reads the
|
|
209
|
+
* document instead.
|
|
210
|
+
*/
|
|
211
|
+
export const UnderlineStillWarns: Story = {
|
|
212
|
+
name: "An underline alone still warns",
|
|
213
|
+
args: { initialHtml: UNDERLINED },
|
|
214
|
+
play: async ({ canvasElement }) => {
|
|
215
|
+
await userEvent.click(toggleOf(canvasElement));
|
|
216
|
+
|
|
217
|
+
await expect(within(document.body).getByRole("dialog")).toHaveTextContent(
|
|
218
|
+
"Switch to plain text?",
|
|
219
|
+
);
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export const PlainToRich: Story = {
|
|
224
|
+
name: "Markdown back to rich, without asking",
|
|
225
|
+
args: { startIn: "plain", initialText: PLAIN_MARKDOWN },
|
|
226
|
+
play: async ({ canvasElement }) => {
|
|
227
|
+
await userEvent.click(toggleOf(canvasElement));
|
|
228
|
+
|
|
229
|
+
await expect(within(document.body).queryByRole("dialog")).toBeNull();
|
|
230
|
+
const editable = canvasElement.querySelector("[data-testid=compose-body]");
|
|
231
|
+
if (!editable) throw new Error("the rich surface did not arrive");
|
|
232
|
+
await expect(editable.querySelector("h2")).not.toBeNull();
|
|
233
|
+
await expect(editable.querySelector("table td")).not.toBeNull();
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/** Switching an ordinary note to rich must not reflow it. */
|
|
238
|
+
export const PlainProseToRich: Story = {
|
|
239
|
+
name: "Prose with no Markdown in it",
|
|
240
|
+
args: {
|
|
241
|
+
startIn: "plain",
|
|
242
|
+
initialText: "Thanks — that works.\n\nI'll send the deck tomorrow.",
|
|
243
|
+
},
|
|
244
|
+
play: async ({ canvasElement }) => {
|
|
245
|
+
await userEvent.click(toggleOf(canvasElement));
|
|
246
|
+
|
|
247
|
+
const editable = canvasElement.querySelector("[data-testid=compose-body]");
|
|
248
|
+
if (!editable) throw new Error("the rich surface did not arrive");
|
|
249
|
+
await expect(editable.querySelectorAll("p").length).toBe(2);
|
|
250
|
+
await expect(editable.textContent).toContain("Thanks — that works.");
|
|
251
|
+
await expect(editable.textContent).toContain(
|
|
252
|
+
"I'll send the deck tomorrow.",
|
|
253
|
+
);
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* A conversion that would blank a written message does not happen: autosave
|
|
259
|
+
* would persist the empty body a moment later and the draft would be gone with
|
|
260
|
+
* nothing said.
|
|
261
|
+
*/
|
|
262
|
+
export const ConversionCameBackEmpty: Story = {
|
|
263
|
+
name: "A conversion that came back empty",
|
|
264
|
+
args: {
|
|
265
|
+
startIn: "plain",
|
|
266
|
+
initialText: "Everything I wrote this morning.",
|
|
267
|
+
onConversionError: fn(),
|
|
268
|
+
conversions: {
|
|
269
|
+
toPlain: (value) => value.text,
|
|
270
|
+
toRich: () => "",
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
play: async ({ args, canvasElement }) => {
|
|
274
|
+
await userEvent.click(toggleOf(canvasElement));
|
|
275
|
+
|
|
276
|
+
await expect(args.onConversionError).toHaveBeenCalledWith({
|
|
277
|
+
outcome: "blocked",
|
|
278
|
+
title: "Couldn't switch to rich text",
|
|
279
|
+
detail: "The conversion came back empty, so your message is unchanged.",
|
|
280
|
+
});
|
|
281
|
+
const textarea = plainSurface(canvasElement);
|
|
282
|
+
if (!textarea) throw new Error("the plain surface left");
|
|
283
|
+
await expect(textarea.value).toBe("Everything I wrote this morning.");
|
|
284
|
+
await expect(toggleOf(canvasElement)).toHaveAttribute(
|
|
285
|
+
"aria-pressed",
|
|
286
|
+
"true",
|
|
287
|
+
);
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/** One Shift+Tab out of the body reaches the toggle, and Enter acts. */
|
|
292
|
+
export const ReachableFromTheBody: Story = {
|
|
293
|
+
name: "Shift+Tab from the body reaches it",
|
|
294
|
+
args: { initialHtml: PLAIN_PARAGRAPHS },
|
|
295
|
+
play: async ({ canvasElement }) => {
|
|
296
|
+
const editable = canvasElement.querySelector<HTMLElement>(
|
|
297
|
+
"[data-testid=compose-body]",
|
|
298
|
+
);
|
|
299
|
+
if (!editable) throw new Error("the rich surface is not mounted");
|
|
300
|
+
|
|
301
|
+
await userEvent.click(editable);
|
|
302
|
+
await userEvent.tab({ shift: true });
|
|
303
|
+
await expect(toggleOf(canvasElement)).toHaveFocus();
|
|
304
|
+
|
|
305
|
+
await userEvent.keyboard("{Enter}");
|
|
306
|
+
await expect(plainSurface(canvasElement)).not.toBeNull();
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Detection runs over the body against the account's own languages and writes
|
|
312
|
+
* the result onto the writing surface. Firefox picks a dictionary from that tag
|
|
313
|
+
* among the ones the user installed; Chrome and Safari ignore it, and nothing
|
|
314
|
+
* here says otherwise.
|
|
315
|
+
*/
|
|
316
|
+
export const DutchIsDetected: Story = {
|
|
317
|
+
name: "Dutch prose sets the chip",
|
|
318
|
+
args: { initialHtml: DUTCH_DOCUMENT },
|
|
319
|
+
play: async ({ canvasElement }) => {
|
|
320
|
+
await waitFor(
|
|
321
|
+
async () => {
|
|
322
|
+
await expect(chipOf(canvasElement)).toHaveTextContent("NL");
|
|
323
|
+
},
|
|
324
|
+
{ timeout: 5000 },
|
|
325
|
+
);
|
|
326
|
+
await expect(
|
|
327
|
+
canvasElement.querySelector("[data-testid=compose-body]"),
|
|
328
|
+
).toHaveAttribute("lang", "nl");
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/** Under twenty characters detection is a coin toss, so the account default stands. */
|
|
333
|
+
export const TooShortHoldsTheDefault: Story = {
|
|
334
|
+
name: "Nine characters hold the default",
|
|
335
|
+
args: { startIn: "plain" },
|
|
336
|
+
play: async ({ canvasElement }) => {
|
|
337
|
+
const textarea = plainSurface(canvasElement);
|
|
338
|
+
if (!textarea) throw new Error("the plain surface is not mounted");
|
|
339
|
+
|
|
340
|
+
await userEvent.click(textarea);
|
|
341
|
+
await userEvent.keyboard("Hi Sophie");
|
|
342
|
+
|
|
343
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
344
|
+
await expect(chipOf(canvasElement)).toHaveTextContent("NL");
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The first manual pick freezes the language for the rest of the message.
|
|
350
|
+
* Detection does not argue with a choice the user made — a tag that moved back
|
|
351
|
+
* under the caret would be a control that undoes itself.
|
|
352
|
+
*/
|
|
353
|
+
export const ManualPickSticks: Story = {
|
|
354
|
+
name: "A picked language survives more typing",
|
|
355
|
+
args: { startIn: "plain" },
|
|
356
|
+
play: async ({ canvasElement }) => {
|
|
357
|
+
const textarea = plainSurface(canvasElement);
|
|
358
|
+
if (!textarea) throw new Error("the plain surface is not mounted");
|
|
359
|
+
|
|
360
|
+
await userEvent.click(textarea);
|
|
361
|
+
await userEvent.keyboard(DUTCH_PROSE);
|
|
362
|
+
await waitFor(
|
|
363
|
+
async () => {
|
|
364
|
+
await expect(chipOf(canvasElement)).toHaveTextContent("NL");
|
|
365
|
+
},
|
|
366
|
+
{ timeout: 5000 },
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
await userEvent.click(chipOf(canvasElement));
|
|
370
|
+
await userEvent.click(
|
|
371
|
+
within(canvasElement).getByRole("menuitemradio", { name: /English/ }),
|
|
372
|
+
);
|
|
373
|
+
await expect(chipOf(canvasElement)).toHaveTextContent("EN");
|
|
374
|
+
|
|
375
|
+
await userEvent.click(textarea);
|
|
376
|
+
await userEvent.keyboard(" Groetjes, Matthijs.");
|
|
377
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
378
|
+
await expect(chipOf(canvasElement)).toHaveTextContent("EN");
|
|
379
|
+
await expect(textarea).toHaveAttribute("lang", "en");
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Two Shift+Tabs out of the body reach the chip — one still reaches the mode
|
|
385
|
+
* toggle, where #673 put it. The menu takes focus as it opens and hands it back
|
|
386
|
+
* to the chip on a pick, so the keyboard never lands somewhere it cannot leave.
|
|
387
|
+
*/
|
|
388
|
+
export const ChipFromTheKeyboard: Story = {
|
|
389
|
+
name: "Shift+Tab twice reaches the chip",
|
|
390
|
+
// Short enough that detection declines, so the chip is on the account
|
|
391
|
+
// default and the arrow key below has a known row to move off.
|
|
392
|
+
args: { initialHtml: "<p>Hoi.</p>" },
|
|
393
|
+
play: async ({ canvasElement }) => {
|
|
394
|
+
const editable = canvasElement.querySelector<HTMLElement>(
|
|
395
|
+
"[data-testid=compose-body]",
|
|
396
|
+
);
|
|
397
|
+
if (!editable) throw new Error("the rich surface is not mounted");
|
|
398
|
+
|
|
399
|
+
await userEvent.click(editable);
|
|
400
|
+
await userEvent.tab({ shift: true });
|
|
401
|
+
await expect(toggleOf(canvasElement)).toHaveFocus();
|
|
402
|
+
await userEvent.tab({ shift: true });
|
|
403
|
+
await expect(chipOf(canvasElement)).toHaveFocus();
|
|
404
|
+
|
|
405
|
+
await userEvent.keyboard("{Enter}");
|
|
406
|
+
await waitFor(async () => {
|
|
407
|
+
await expect(
|
|
408
|
+
canvasElement.querySelector("[data-testid=compose-language-menu]"),
|
|
409
|
+
).not.toBeNull();
|
|
410
|
+
});
|
|
411
|
+
await userEvent.keyboard("{ArrowDown}{Enter}");
|
|
412
|
+
|
|
413
|
+
await expect(chipOf(canvasElement)).toHaveTextContent("EN");
|
|
414
|
+
await expect(chipOf(canvasElement)).toHaveFocus();
|
|
415
|
+
await expect(editable).toHaveAttribute("lang", "en");
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/** The tag follows the message across the mode switch, onto whichever surface is up. */
|
|
420
|
+
export const PlainSurfaceCarriesTheLanguage: Story = {
|
|
421
|
+
name: "Plain text keeps the same language",
|
|
422
|
+
args: { initialHtml: DUTCH_DOCUMENT },
|
|
423
|
+
play: async ({ canvasElement }) => {
|
|
424
|
+
await waitFor(
|
|
425
|
+
async () => {
|
|
426
|
+
await expect(chipOf(canvasElement)).toHaveTextContent("NL");
|
|
427
|
+
},
|
|
428
|
+
{ timeout: 5000 },
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
await userEvent.click(toggleOf(canvasElement));
|
|
432
|
+
const textarea = plainSurface(canvasElement);
|
|
433
|
+
if (!textarea) throw new Error("the plain surface did not arrive");
|
|
434
|
+
|
|
435
|
+
await expect(textarea).toHaveAttribute("lang", "nl");
|
|
436
|
+
await expect(chipOf(canvasElement)).toHaveTextContent("NL");
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The quoted block a reply is written above is somebody else's text. It lives
|
|
442
|
+
* outside the editor, so detection never sees it and a French thread answered
|
|
443
|
+
* in Dutch is tagged Dutch.
|
|
444
|
+
*/
|
|
445
|
+
export const QuotedTextIsNotRead: Story = {
|
|
446
|
+
name: "A French quote under a Dutch reply",
|
|
447
|
+
args: {
|
|
448
|
+
initialHtml: DUTCH_DOCUMENT,
|
|
449
|
+
quoted:
|
|
450
|
+
"Bonjour, je vous confirme que la réunion de jeudi est annulée. Je vous propose de la reporter à la semaine prochaine.",
|
|
451
|
+
},
|
|
452
|
+
play: async ({ canvasElement }) => {
|
|
453
|
+
await waitFor(
|
|
454
|
+
async () => {
|
|
455
|
+
await expect(chipOf(canvasElement)).toHaveTextContent("NL");
|
|
456
|
+
},
|
|
457
|
+
{ timeout: 5000 },
|
|
458
|
+
);
|
|
459
|
+
await expect(
|
|
460
|
+
canvasElement.querySelector("[data-testid=compose-body]"),
|
|
461
|
+
).toHaveAttribute("lang", "nl");
|
|
462
|
+
},
|
|
463
|
+
};
|
|
@@ -1,22 +1,210 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type ComposeBodyMode,
|
|
3
|
+
ComposeLanguageChip,
|
|
4
|
+
ComposeModeToggle,
|
|
5
|
+
markdownToHtml,
|
|
6
|
+
PlainTextEditor,
|
|
7
|
+
RichTextEditor,
|
|
8
|
+
type RichTextValue,
|
|
9
|
+
useComposeLanguage,
|
|
10
|
+
} from "@remit/ui/rich-text";
|
|
11
|
+
import { useEffect, useRef, useState } from "react";
|
|
12
|
+
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
|
13
|
+
import { conversionOutcome, switchNeedsWarning } from "./compose-mode";
|
|
14
|
+
|
|
15
|
+
export interface ConversionFailure {
|
|
16
|
+
title: string;
|
|
17
|
+
detail: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The two directions of the mode switch, injectable so a story can drive the
|
|
22
|
+
* conversion that comes back empty — the branch that keeps autosave from
|
|
23
|
+
* persisting a blanked draft, and the one case no real document produces on
|
|
24
|
+
* demand.
|
|
25
|
+
*/
|
|
26
|
+
export interface ComposeConversions {
|
|
27
|
+
toPlain: (value: RichTextValue) => string;
|
|
28
|
+
toRich: (text: string) => string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_COMPOSE_CONVERSIONS: ComposeConversions = {
|
|
32
|
+
toPlain: (value) => value.text,
|
|
33
|
+
toRich: (text) => markdownToHtml(text),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const textOf = (html: string): string =>
|
|
37
|
+
new DOMParser().parseFromString(html, "text/html").body.textContent ?? "";
|
|
38
|
+
|
|
39
|
+
const plainValue = (text: string): RichTextValue => ({
|
|
40
|
+
html: "",
|
|
41
|
+
text,
|
|
42
|
+
formatting: [],
|
|
43
|
+
});
|
|
2
44
|
|
|
3
45
|
interface ComposeBodyProps {
|
|
46
|
+
mode: ComposeBodyMode;
|
|
47
|
+
onModeChange: (mode: ComposeBodyMode) => void;
|
|
4
48
|
initialHtml: string;
|
|
49
|
+
initialText: string;
|
|
5
50
|
onChange: (value: RichTextValue) => void;
|
|
6
51
|
onSubmit?: () => void;
|
|
7
52
|
autoFocus?: boolean;
|
|
53
|
+
onConversionError: (failure: ConversionFailure) => void;
|
|
54
|
+
conversions?: ComposeConversions;
|
|
55
|
+
/** The account's writing languages, most-used first. */
|
|
56
|
+
languages: readonly string[];
|
|
57
|
+
/** The tag a reopened draft was stored under. */
|
|
58
|
+
initialLanguage?: string;
|
|
59
|
+
/** Reports the language the message is being written in, so the form can tag it. */
|
|
60
|
+
onLanguageChange: (language: string) => void;
|
|
8
61
|
}
|
|
9
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The compose writing surface and the control that swaps it. Rich text is the
|
|
65
|
+
* WYSIWYG document; plain text is a textarea whose content is the Markdown that
|
|
66
|
+
* will be sent verbatim. The conversion runs over an in-memory document, so the
|
|
67
|
+
* surface swaps in the same frame the choice is made.
|
|
68
|
+
*/
|
|
10
69
|
export const ComposeBody = ({
|
|
70
|
+
mode,
|
|
71
|
+
onModeChange,
|
|
11
72
|
initialHtml,
|
|
73
|
+
initialText,
|
|
12
74
|
onChange,
|
|
13
75
|
onSubmit,
|
|
14
|
-
autoFocus,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
);
|
|
76
|
+
autoFocus = false,
|
|
77
|
+
onConversionError,
|
|
78
|
+
conversions = DEFAULT_COMPOSE_CONVERSIONS,
|
|
79
|
+
languages,
|
|
80
|
+
initialLanguage,
|
|
81
|
+
onLanguageChange,
|
|
82
|
+
}: ComposeBodyProps) => {
|
|
83
|
+
const [richHtml, setRichHtml] = useState(initialHtml);
|
|
84
|
+
const [richGeneration, setRichGeneration] = useState(0);
|
|
85
|
+
const [plainText, setPlainText] = useState(initialText);
|
|
86
|
+
const [bodyText, setBodyText] = useState(initialText);
|
|
87
|
+
const [confirming, setConfirming] = useState(false);
|
|
88
|
+
// The caret does not survive a conversion: a rich selection is a node path
|
|
89
|
+
// and Markdown is a character offset. The surface that arrives takes focus
|
|
90
|
+
// with the caret at the end; the toggle keeps it when the mode did not change.
|
|
91
|
+
const [focusSwitchedSurface, setFocusSwitchedSurface] = useState(false);
|
|
92
|
+
const richValue = useRef<RichTextValue>({
|
|
93
|
+
html: initialHtml,
|
|
94
|
+
text: initialText,
|
|
95
|
+
formatting: [],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Detection reads the body the user typed. The quoted reply block is not part
|
|
99
|
+
// of it — that lives outside the editor, in the form's own `quoted` slot.
|
|
100
|
+
const { language, choose } = useComposeLanguage({
|
|
101
|
+
languages,
|
|
102
|
+
text: bodyText,
|
|
103
|
+
initialLanguage,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
onLanguageChange(language);
|
|
108
|
+
}, [language, onLanguageChange]);
|
|
109
|
+
|
|
110
|
+
const handleRichChange = (value: RichTextValue) => {
|
|
111
|
+
richValue.current = value;
|
|
112
|
+
setBodyText(value.text);
|
|
113
|
+
onChange(value);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handlePlainChange = (text: string) => {
|
|
117
|
+
setPlainText(text);
|
|
118
|
+
setBodyText(text);
|
|
119
|
+
onChange(plainValue(text));
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const switchToPlain = () => {
|
|
123
|
+
const value = richValue.current;
|
|
124
|
+
const converted = conversions.toPlain(value);
|
|
125
|
+
const decision = conversionOutcome("plain", textOf(value.html), converted);
|
|
126
|
+
if (decision.outcome === "blocked") {
|
|
127
|
+
onConversionError(decision);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
setPlainText(converted);
|
|
131
|
+
setBodyText(converted);
|
|
132
|
+
setFocusSwitchedSurface(true);
|
|
133
|
+
onChange(plainValue(converted));
|
|
134
|
+
onModeChange("plain");
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const switchToRich = () => {
|
|
138
|
+
const converted = conversions.toRich(plainText);
|
|
139
|
+
const decision = conversionOutcome("rich", plainText, textOf(converted));
|
|
140
|
+
if (decision.outcome === "blocked") {
|
|
141
|
+
onConversionError(decision);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
setRichHtml(converted);
|
|
145
|
+
setRichGeneration((generation) => generation + 1);
|
|
146
|
+
setFocusSwitchedSurface(true);
|
|
147
|
+
onModeChange("rich");
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const handleToggle = () => {
|
|
151
|
+
if (mode === "plain") {
|
|
152
|
+
switchToRich();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (switchNeedsWarning("plain", richValue.current.formatting)) {
|
|
156
|
+
setConfirming(true);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
switchToPlain();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// The chip comes first so the mode toggle stays one Shift+Tab out of the
|
|
163
|
+
// body, where #673 put it, and the chip is the second.
|
|
164
|
+
const trailing = (
|
|
165
|
+
<>
|
|
166
|
+
<ComposeLanguageChip
|
|
167
|
+
language={language}
|
|
168
|
+
languages={languages}
|
|
169
|
+
onSelect={choose}
|
|
170
|
+
/>
|
|
171
|
+
<ComposeModeToggle mode={mode} onToggle={handleToggle} />
|
|
172
|
+
</>
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<>
|
|
177
|
+
{mode === "plain" ? (
|
|
178
|
+
<PlainTextEditor
|
|
179
|
+
value={plainText}
|
|
180
|
+
onChange={handlePlainChange}
|
|
181
|
+
onSubmit={onSubmit}
|
|
182
|
+
autoFocus={focusSwitchedSurface}
|
|
183
|
+
lang={language}
|
|
184
|
+
trailing={trailing}
|
|
185
|
+
/>
|
|
186
|
+
) : (
|
|
187
|
+
<RichTextEditor
|
|
188
|
+
key={richGeneration}
|
|
189
|
+
initialHtml={richHtml}
|
|
190
|
+
onChange={handleRichChange}
|
|
191
|
+
onSubmit={onSubmit}
|
|
192
|
+
autoFocus={autoFocus || focusSwitchedSurface}
|
|
193
|
+
lang={language}
|
|
194
|
+
trailing={trailing}
|
|
195
|
+
/>
|
|
196
|
+
)}
|
|
197
|
+
<ConfirmDialog
|
|
198
|
+
isOpen={confirming}
|
|
199
|
+
title="Switch to plain text?"
|
|
200
|
+
description="Formatting becomes Markdown. Bold keeps its asterisks, a table becomes rows of pipes, and that text is what the recipient gets. No formatted version is sent alongside it."
|
|
201
|
+
confirmLabel="Switch to plain text"
|
|
202
|
+
onConfirm={() => {
|
|
203
|
+
setConfirming(false);
|
|
204
|
+
switchToPlain();
|
|
205
|
+
}}
|
|
206
|
+
onCancel={() => setConfirming(false)}
|
|
207
|
+
/>
|
|
208
|
+
</>
|
|
209
|
+
);
|
|
210
|
+
};
|
|
@@ -11,17 +11,22 @@ import type {
|
|
|
11
11
|
import {
|
|
12
12
|
ComposeActionBar,
|
|
13
13
|
ComposeFormShell,
|
|
14
|
+
defaultComposeLanguages,
|
|
14
15
|
EMPTY_RICH_TEXT,
|
|
15
16
|
QuotedText,
|
|
16
17
|
type RichTextValue,
|
|
17
18
|
sanitizeQuotedHtml,
|
|
19
|
+
unwrapLanguage,
|
|
20
|
+
wrapWithLanguage,
|
|
18
21
|
} from "@remit/ui";
|
|
22
|
+
import type { ComposeBodyMode } from "@remit/ui/rich-text";
|
|
19
23
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
20
24
|
import {
|
|
21
25
|
lazy,
|
|
22
26
|
Suspense,
|
|
23
27
|
useCallback,
|
|
24
28
|
useEffect,
|
|
29
|
+
useMemo,
|
|
25
30
|
useRef,
|
|
26
31
|
useState,
|
|
27
32
|
} from "react";
|
|
@@ -37,6 +42,7 @@ import {
|
|
|
37
42
|
import type { AddressEntry } from "./AddressField";
|
|
38
43
|
import { AddressField } from "./AddressField";
|
|
39
44
|
import { ComposeSmtpMissingBanner } from "./ComposeSmtpMissingBanner";
|
|
45
|
+
import { modeOfDraft } from "./compose-mode";
|
|
40
46
|
|
|
41
47
|
const LazyComposeBody = lazy(() =>
|
|
42
48
|
import("./ComposeBody.js").then((m) => ({ default: m.ComposeBody })),
|
|
@@ -147,6 +153,34 @@ const getReferences = (
|
|
|
147
153
|
};
|
|
148
154
|
};
|
|
149
155
|
|
|
156
|
+
/**
|
|
157
|
+
* What the two body columns carry for this mode.
|
|
158
|
+
*
|
|
159
|
+
* Plain mode writes the empty string rather than omitting `htmlBody`: absent
|
|
160
|
+
* means "leave alone" at every layer below, so a plain draft that omitted it
|
|
161
|
+
* would send the HTML it was written as before the switch. The empty string is
|
|
162
|
+
* defined, so the repository's update guard clears the column, and nodemailer
|
|
163
|
+
* branches on the value being truthy — an empty one builds no HTML alternative
|
|
164
|
+
* and the message leaves as a single `text/plain` part.
|
|
165
|
+
*
|
|
166
|
+
* Rich mode leaves it alone while it has nothing to say, so the moments before
|
|
167
|
+
* the lazily-loaded editor reports its document cannot write a draft back as
|
|
168
|
+
* plain.
|
|
169
|
+
*/
|
|
170
|
+
const outgoingBody = (
|
|
171
|
+
bodyMode: ComposeBodyMode,
|
|
172
|
+
body: RichTextValue,
|
|
173
|
+
language: string,
|
|
174
|
+
): { textBody: string | undefined; htmlBody: string | undefined } => ({
|
|
175
|
+
textBody: body.text || undefined,
|
|
176
|
+
htmlBody:
|
|
177
|
+
bodyMode === "plain"
|
|
178
|
+
? ""
|
|
179
|
+
: body.html
|
|
180
|
+
? wrapWithLanguage(body.html, language)
|
|
181
|
+
: undefined,
|
|
182
|
+
});
|
|
183
|
+
|
|
150
184
|
const isFormEmpty = (
|
|
151
185
|
toAddresses: AddressEntry[],
|
|
152
186
|
ccAddresses: AddressEntry[],
|
|
@@ -330,6 +364,9 @@ export const ComposeForm = ({
|
|
|
330
364
|
setShowCc(false);
|
|
331
365
|
setShowBcc(false);
|
|
332
366
|
setInitialHtml("");
|
|
367
|
+
setInitialText("");
|
|
368
|
+
setBodyMode("rich");
|
|
369
|
+
setDraftLanguage(undefined);
|
|
333
370
|
setBody(EMPTY_RICH_TEXT);
|
|
334
371
|
setDocumentGeneration((generation) => generation + 1);
|
|
335
372
|
setDraftLoaded(false);
|
|
@@ -344,9 +381,17 @@ export const ComposeForm = ({
|
|
|
344
381
|
const [initialHtml, setInitialHtml] = useState(() =>
|
|
345
382
|
buildInitialHtml(signature.plainText),
|
|
346
383
|
);
|
|
384
|
+
const [initialText, setInitialText] = useState(signature.plainText);
|
|
385
|
+
const [bodyMode, setBodyMode] = useState<ComposeBodyMode>("rich");
|
|
386
|
+
// What the body is tagged with on the way out. The composer owns the value —
|
|
387
|
+
// it is the surface that has the text detection reads — and reports it here,
|
|
388
|
+
// because this is where a draft is written and where a send is assembled.
|
|
389
|
+
const [composeLanguage, setComposeLanguage] = useState("en");
|
|
390
|
+
const [draftLanguage, setDraftLanguage] = useState<string | undefined>();
|
|
347
391
|
const [body, setBody] = useState<RichTextValue>(() => ({
|
|
348
392
|
html: buildInitialHtml(signature.plainText),
|
|
349
393
|
text: signature.plainText,
|
|
394
|
+
formatting: [],
|
|
350
395
|
}));
|
|
351
396
|
|
|
352
397
|
const { data: draftData } = useQuery({
|
|
@@ -381,12 +426,22 @@ export const ComposeForm = ({
|
|
|
381
426
|
setShowBcc(true);
|
|
382
427
|
}
|
|
383
428
|
if (draftData.subject) setSubject(draftData.subject);
|
|
384
|
-
// A draft stores what would have been sent, so
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
429
|
+
// A draft stores what would have been sent, so which surface it reopens in
|
|
430
|
+
// is read off that rather than a field of its own. A rich draft comes back
|
|
431
|
+
// from its HTML — reading its text into one paragraph, as this did, brought
|
|
432
|
+
// a formatted message back flattened.
|
|
433
|
+
// A rich draft carries its language in the `<div lang>` it was stored
|
|
434
|
+
// under; the editor reopens on what is inside that, so a reopened draft
|
|
435
|
+
// does not gain a second wrapper on its next autosave. A plain draft has
|
|
436
|
+
// no HTML to have carried one, and comes back on the account default.
|
|
437
|
+
const stored = unwrapLanguage(draftData.htmlBody ?? "");
|
|
438
|
+
const loadedHtml = stored.html;
|
|
439
|
+
const loadedText = draftData.textBody ?? "";
|
|
440
|
+
setBodyMode(modeOfDraft(draftData.htmlBody));
|
|
441
|
+
setDraftLanguage(stored.language ?? undefined);
|
|
388
442
|
setInitialHtml(loadedHtml);
|
|
389
|
-
|
|
443
|
+
setInitialText(loadedText);
|
|
444
|
+
setBody({ html: loadedHtml, text: loadedText, formatting: [] });
|
|
390
445
|
setDocumentGeneration((generation) => generation + 1);
|
|
391
446
|
setSelectedAccountId(draftData.accountId);
|
|
392
447
|
setDraftLoaded(true);
|
|
@@ -492,6 +547,17 @@ export const ComposeForm = ({
|
|
|
492
547
|
? accountIsMissingSmtp(selectedAccount)
|
|
493
548
|
: false;
|
|
494
549
|
|
|
550
|
+
// An account that has never been to the language setting falls back to what
|
|
551
|
+
// the browser already knows the user reads, which is an ordered answer.
|
|
552
|
+
const configured = selectedAccount?.composeLanguages;
|
|
553
|
+
const accountLanguages = useMemo(
|
|
554
|
+
() =>
|
|
555
|
+
configured && configured.length > 0
|
|
556
|
+
? configured
|
|
557
|
+
: defaultComposeLanguages(navigator.languages),
|
|
558
|
+
[configured],
|
|
559
|
+
);
|
|
560
|
+
|
|
495
561
|
// The action bar refuses a second press while one is in flight, but the
|
|
496
562
|
// editor's own Cmd+Enter goes straight to `handleSend`, and the write that
|
|
497
563
|
// now precedes the request widens the window a second press lands in.
|
|
@@ -512,7 +578,11 @@ export const ComposeForm = ({
|
|
|
512
578
|
if (isFormEmpty(toAddresses, ccAddresses, bccAddresses, subject, body))
|
|
513
579
|
return;
|
|
514
580
|
|
|
515
|
-
const {
|
|
581
|
+
const { htmlBody, textBody } = outgoingBody(
|
|
582
|
+
bodyMode,
|
|
583
|
+
body,
|
|
584
|
+
composeLanguage,
|
|
585
|
+
);
|
|
516
586
|
|
|
517
587
|
saveDraft({
|
|
518
588
|
accountId: selectedAccountId,
|
|
@@ -522,8 +592,8 @@ export const ComposeForm = ({
|
|
|
522
592
|
bccAddresses:
|
|
523
593
|
bccAddresses.length > 0 ? bccAddresses.map((a) => a.email) : undefined,
|
|
524
594
|
subject: subject || undefined,
|
|
525
|
-
textBody
|
|
526
|
-
htmlBody
|
|
595
|
+
textBody,
|
|
596
|
+
htmlBody,
|
|
527
597
|
});
|
|
528
598
|
}, [
|
|
529
599
|
selectedAccountId,
|
|
@@ -534,6 +604,8 @@ export const ComposeForm = ({
|
|
|
534
604
|
bccAddresses,
|
|
535
605
|
subject,
|
|
536
606
|
body,
|
|
607
|
+
bodyMode,
|
|
608
|
+
composeLanguage,
|
|
537
609
|
saveDraft,
|
|
538
610
|
]);
|
|
539
611
|
|
|
@@ -551,7 +623,11 @@ export const ComposeForm = ({
|
|
|
551
623
|
? getReferences(sourceMessage)
|
|
552
624
|
: {};
|
|
553
625
|
|
|
554
|
-
const {
|
|
626
|
+
const { htmlBody, textBody } = outgoingBody(
|
|
627
|
+
bodyMode,
|
|
628
|
+
body,
|
|
629
|
+
composeLanguage,
|
|
630
|
+
);
|
|
555
631
|
const createdThisAttempt = !outboxMessageId;
|
|
556
632
|
|
|
557
633
|
// The debounce dropped above may have been holding the last two seconds
|
|
@@ -568,8 +644,8 @@ export const ComposeForm = ({
|
|
|
568
644
|
? bccAddresses.map((a) => a.email)
|
|
569
645
|
: undefined,
|
|
570
646
|
subject: subject || undefined,
|
|
571
|
-
textBody
|
|
572
|
-
htmlBody
|
|
647
|
+
textBody,
|
|
648
|
+
htmlBody,
|
|
573
649
|
...replyData,
|
|
574
650
|
});
|
|
575
651
|
|
|
@@ -618,6 +694,8 @@ export const ComposeForm = ({
|
|
|
618
694
|
bccAddresses,
|
|
619
695
|
subject,
|
|
620
696
|
body,
|
|
697
|
+
bodyMode,
|
|
698
|
+
composeLanguage,
|
|
621
699
|
mode,
|
|
622
700
|
sourceMessage,
|
|
623
701
|
outboxMessageId,
|
|
@@ -703,10 +781,17 @@ export const ComposeForm = ({
|
|
|
703
781
|
<Suspense fallback={<ComposeBodyFallback />}>
|
|
704
782
|
<LazyComposeBody
|
|
705
783
|
key={documentGeneration}
|
|
784
|
+
mode={bodyMode}
|
|
785
|
+
onModeChange={setBodyMode}
|
|
706
786
|
initialHtml={initialHtml}
|
|
787
|
+
initialText={initialText}
|
|
707
788
|
onChange={setBody}
|
|
708
789
|
onSubmit={handleSend}
|
|
709
790
|
autoFocus={mode === "new"}
|
|
791
|
+
onConversionError={pushError}
|
|
792
|
+
languages={accountLanguages}
|
|
793
|
+
initialLanguage={draftLanguage}
|
|
794
|
+
onLanguageChange={setComposeLanguage}
|
|
710
795
|
/>
|
|
711
796
|
</Suspense>
|
|
712
797
|
</ComposeFormShell>
|
|
@@ -2,6 +2,7 @@ import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tansta
|
|
|
2
2
|
import { useQuery } from "@tanstack/react-query";
|
|
3
3
|
import { useCallback, useRef, useState } from "react";
|
|
4
4
|
import { Drawer } from "vaul";
|
|
5
|
+
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
5
6
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
6
7
|
import { ComposeForm } from "./ComposeForm";
|
|
7
8
|
import { useCompose } from "./ComposeProvider";
|
|
@@ -119,34 +120,16 @@ export const MobileComposeSheet = () => {
|
|
|
119
120
|
</Drawer.Content>
|
|
120
121
|
</Drawer.Portal>
|
|
121
122
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
<button
|
|
133
|
-
type="button"
|
|
134
|
-
onClick={handleCancelDiscard}
|
|
135
|
-
className="rounded-md px-4 py-2 text-sm font-medium hover:bg-surface-raised transition-colors"
|
|
136
|
-
>
|
|
137
|
-
Keep editing
|
|
138
|
-
</button>
|
|
139
|
-
<button
|
|
140
|
-
type="button"
|
|
141
|
-
onClick={handleConfirmDiscard}
|
|
142
|
-
className="rounded-md bg-danger px-4 py-2 text-sm font-medium text-canvas hover:bg-danger/90 transition-colors"
|
|
143
|
-
>
|
|
144
|
-
Discard
|
|
145
|
-
</button>
|
|
146
|
-
</div>
|
|
147
|
-
</div>
|
|
148
|
-
</div>
|
|
149
|
-
)}
|
|
123
|
+
<ConfirmDialog
|
|
124
|
+
isOpen={showConfirm}
|
|
125
|
+
title="Discard draft?"
|
|
126
|
+
description="Your message has unsaved content. Are you sure you want to discard it?"
|
|
127
|
+
confirmLabel="Discard"
|
|
128
|
+
cancelLabel="Keep editing"
|
|
129
|
+
destructive
|
|
130
|
+
onConfirm={handleConfirmDiscard}
|
|
131
|
+
onCancel={handleCancelDiscard}
|
|
132
|
+
/>
|
|
150
133
|
</Drawer.Root>
|
|
151
134
|
);
|
|
152
135
|
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which surface a draft reopens in, and when a mode switch is refused.
|
|
3
|
+
*
|
|
4
|
+
* The mode is derived from `htmlBody`, with no field of its own. It has to be
|
|
5
|
+
* "a non-empty string" and not "truthy": the rich editor serializes an empty
|
|
6
|
+
* document to `<p><br></p>` and a plain draft clears the column to `""`, so a
|
|
7
|
+
* falsy check opens a plain draft correctly by accident and an absent column
|
|
8
|
+
* — an old draft, a partial write — the wrong way round.
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { describe, it } from "node:test";
|
|
12
|
+
import {
|
|
13
|
+
conversionOutcome,
|
|
14
|
+
modeOfDraft,
|
|
15
|
+
switchNeedsWarning,
|
|
16
|
+
} from "./compose-mode.js";
|
|
17
|
+
|
|
18
|
+
describe("the mode a draft reopens in", () => {
|
|
19
|
+
it("opens a draft with HTML as rich", () => {
|
|
20
|
+
assert.equal(modeOfDraft("<p>Hello</p>"), "rich");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("opens an empty rich document as rich", () => {
|
|
24
|
+
assert.equal(modeOfDraft("<p><br></p>"), "rich");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("opens a draft whose HTML was cleared as plain", () => {
|
|
28
|
+
assert.equal(modeOfDraft(""), "plain");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("opens a draft that never had HTML as plain", () => {
|
|
32
|
+
assert.equal(modeOfDraft(undefined), "plain");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("whether the switch warns first", () => {
|
|
37
|
+
it("warns when the document holds formatting", () => {
|
|
38
|
+
assert.equal(switchNeedsWarning("plain", ["table"]), true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("says nothing over plain paragraphs", () => {
|
|
42
|
+
assert.equal(switchNeedsWarning("plain", []), false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("never warns on the way back to rich", () => {
|
|
46
|
+
assert.equal(switchNeedsWarning("rich", ["table", "bold"]), false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("a conversion that would empty a written message", () => {
|
|
51
|
+
it("goes ahead when the conversion carried the message across", () => {
|
|
52
|
+
assert.deepEqual(conversionOutcome("plain", "Due Friday.", "Due Friday."), {
|
|
53
|
+
outcome: "switch",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("goes ahead when there was nothing to carry", () => {
|
|
58
|
+
assert.deepEqual(conversionOutcome("plain", "", ""), { outcome: "switch" });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("refuses, naming the direction, when plain text came back empty", () => {
|
|
62
|
+
assert.deepEqual(conversionOutcome("plain", "Due Friday.", " "), {
|
|
63
|
+
outcome: "blocked",
|
|
64
|
+
title: "Couldn't switch to plain text",
|
|
65
|
+
detail: "The conversion came back empty, so your message is unchanged.",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("refuses, naming the direction, when rich text came back empty", () => {
|
|
70
|
+
assert.deepEqual(conversionOutcome("rich", "Due Friday.", ""), {
|
|
71
|
+
outcome: "blocked",
|
|
72
|
+
title: "Couldn't switch to rich text",
|
|
73
|
+
detail: "The conversion came back empty, so your message is unchanged.",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ComposeBodyMode } from "@remit/ui/rich-text";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which surface a draft reopens in, with no field of its own. `htmlBody` a
|
|
5
|
+
* non-empty string is a rich draft; anything else is plain.
|
|
6
|
+
*
|
|
7
|
+
* Not "falsy": the rich editor serializes an empty document to `<p><br></p>`,
|
|
8
|
+
* so a rich draft's `htmlBody` is never absent, and a plain draft clears the
|
|
9
|
+
* column to the empty string rather than omitting it — absent means "leave
|
|
10
|
+
* alone" at every layer below this one.
|
|
11
|
+
*/
|
|
12
|
+
export const modeOfDraft = (htmlBody: string | undefined): ComposeBodyMode =>
|
|
13
|
+
typeof htmlBody === "string" && htmlBody.length > 0 ? "rich" : "plain";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether switching to plain text destroys something. True for any node type or
|
|
17
|
+
* text format the document holds that plain text cannot carry.
|
|
18
|
+
*/
|
|
19
|
+
export const switchNeedsWarning = (
|
|
20
|
+
target: ComposeBodyMode,
|
|
21
|
+
formatting: readonly string[],
|
|
22
|
+
): boolean => target === "plain" && formatting.length > 0;
|
|
23
|
+
|
|
24
|
+
export type ConversionOutcome =
|
|
25
|
+
| { outcome: "switch" }
|
|
26
|
+
| { outcome: "blocked"; title: string; detail: string };
|
|
27
|
+
|
|
28
|
+
const BLOCKED_TITLES: Record<ComposeBodyMode, string> = {
|
|
29
|
+
plain: "Couldn't switch to plain text",
|
|
30
|
+
rich: "Couldn't switch to rich text",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A conversion that empties a written message does not happen. Autosave would
|
|
35
|
+
* persist the blank body a moment later, so the draft would be gone with
|
|
36
|
+
* nothing said. An empty body converting to an empty body is not this case.
|
|
37
|
+
*/
|
|
38
|
+
export const conversionOutcome = (
|
|
39
|
+
target: ComposeBodyMode,
|
|
40
|
+
source: string,
|
|
41
|
+
converted: string,
|
|
42
|
+
): ConversionOutcome => {
|
|
43
|
+
if (source.trim() === "" || converted.trim() !== "")
|
|
44
|
+
return { outcome: "switch" };
|
|
45
|
+
return {
|
|
46
|
+
outcome: "blocked",
|
|
47
|
+
title: BLOCKED_TITLES[target],
|
|
48
|
+
detail: "The conversion came back empty, so your message is unchanged.",
|
|
49
|
+
};
|
|
50
|
+
};
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
9
9
|
import {
|
|
10
10
|
Button,
|
|
11
|
+
ComposeLanguageSetting,
|
|
11
12
|
Input,
|
|
12
13
|
PasswordInput,
|
|
13
14
|
Select,
|
|
@@ -19,6 +20,7 @@ import { Check, Loader2, X } from "lucide-react";
|
|
|
19
20
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
20
21
|
import { useForm } from "react-hook-form";
|
|
21
22
|
import { z } from "zod";
|
|
23
|
+
import { useComposeLanguages } from "../../hooks/useComposeLanguages";
|
|
22
24
|
import { useSignature } from "../../hooks/useSignature";
|
|
23
25
|
import {
|
|
24
26
|
getPresetById,
|
|
@@ -319,6 +321,25 @@ export const AccountFormPanel = ({
|
|
|
319
321
|
} = useSignature(account?.accountId);
|
|
320
322
|
const [signatureText, setSignatureText] = useState(signature.plainText);
|
|
321
323
|
|
|
324
|
+
const {
|
|
325
|
+
languages,
|
|
326
|
+
setLanguages,
|
|
327
|
+
isSaving: isLanguagesSaving,
|
|
328
|
+
} = useComposeLanguages(account?.accountId);
|
|
329
|
+
|
|
330
|
+
const languagesSection = (
|
|
331
|
+
<section>
|
|
332
|
+
<h3 className="text-2xs font-semibold text-fg-subtle uppercase tracking-wider mb-3">
|
|
333
|
+
Writing languages
|
|
334
|
+
</h3>
|
|
335
|
+
<ComposeLanguageSetting
|
|
336
|
+
value={languages}
|
|
337
|
+
onChange={setLanguages}
|
|
338
|
+
busy={isLanguagesSaving}
|
|
339
|
+
/>
|
|
340
|
+
</section>
|
|
341
|
+
);
|
|
342
|
+
|
|
322
343
|
useEffect(() => {
|
|
323
344
|
setSignatureText(signature.plainText);
|
|
324
345
|
}, [signature.plainText]);
|
|
@@ -475,6 +496,7 @@ export const AccountFormPanel = ({
|
|
|
475
496
|
</div>
|
|
476
497
|
</section>
|
|
477
498
|
)}
|
|
499
|
+
{isEditing && languagesSection}
|
|
478
500
|
</div>
|
|
479
501
|
</SlidePanel>
|
|
480
502
|
);
|
|
@@ -940,6 +962,7 @@ export const AccountFormPanel = ({
|
|
|
940
962
|
</div>
|
|
941
963
|
</section>
|
|
942
964
|
)}
|
|
965
|
+
{isEditing && languagesSection}
|
|
943
966
|
</form>
|
|
944
967
|
</SlidePanel>
|
|
945
968
|
);
|
|
@@ -57,16 +57,29 @@ export const ConfirmDialog = ({
|
|
|
57
57
|
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
58
58
|
}, [isOpen, handleKeyDown]);
|
|
59
59
|
|
|
60
|
+
// Whoever opened the dialog gets the focus back when it closes. Without this
|
|
61
|
+
// a cancelled confirmation drops focus to the body, and the control the user
|
|
62
|
+
// was on — the compose mode toggle, a row's delete button — is gone from
|
|
63
|
+
// under the keyboard.
|
|
60
64
|
useEffect(() => {
|
|
61
|
-
if (isOpen)
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
if (!isOpen) return;
|
|
66
|
+
const opener =
|
|
67
|
+
document.activeElement instanceof HTMLElement
|
|
68
|
+
? document.activeElement
|
|
69
|
+
: null;
|
|
70
|
+
cancelRef.current?.focus();
|
|
71
|
+
return () => {
|
|
72
|
+
if (opener?.isConnected) opener.focus();
|
|
73
|
+
};
|
|
64
74
|
}, [isOpen]);
|
|
65
75
|
|
|
66
76
|
if (!isOpen) return null;
|
|
67
77
|
|
|
68
78
|
return (
|
|
69
|
-
|
|
79
|
+
// Above every other overlay, the mobile compose sheet included: a
|
|
80
|
+
// confirmation is the decision blocking whatever is under it, and a drawer
|
|
81
|
+
// portalled to the body at the same level would cover it.
|
|
82
|
+
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
|
70
83
|
{/* Backdrop. It carries the click-to-dismiss and the aria-hidden: the
|
|
71
84
|
dialog itself must stay in the accessibility tree, and an
|
|
72
85
|
aria-hidden ancestor would take it out. */}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
accountDetailOperationsUpdateAccountMutation,
|
|
3
|
+
configOperationsGetConfigOptions,
|
|
4
|
+
configOperationsGetConfigQueryKey,
|
|
5
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
6
|
+
import { defaultComposeLanguages } from "@remit/ui";
|
|
7
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
8
|
+
import { useCallback, useMemo } from "react";
|
|
9
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
10
|
+
import { buildMutationErrorBanner } from "@/components/ui/error-banners";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The account's writing languages: the menu the composer's language chip
|
|
14
|
+
* offers, and the set detection is allowed to choose inside. Absent on the
|
|
15
|
+
* server means the user has never been here, and the browser's own ordered
|
|
16
|
+
* answer stands in.
|
|
17
|
+
*/
|
|
18
|
+
export const useComposeLanguages = (accountId?: string) => {
|
|
19
|
+
const queryClient = useQueryClient();
|
|
20
|
+
const { pushError } = useErrorBanners();
|
|
21
|
+
|
|
22
|
+
const { data: config } = useQuery({
|
|
23
|
+
...configOperationsGetConfigOptions(),
|
|
24
|
+
staleTime: Infinity,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const configured = config?.accounts.find(
|
|
28
|
+
(account) => account.accountId === accountId,
|
|
29
|
+
)?.composeLanguages;
|
|
30
|
+
|
|
31
|
+
const languages = useMemo<string[]>(
|
|
32
|
+
() =>
|
|
33
|
+
configured && configured.length > 0
|
|
34
|
+
? [...configured]
|
|
35
|
+
: defaultComposeLanguages(navigator.languages),
|
|
36
|
+
[configured],
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const mutation = useMutation({
|
|
40
|
+
...accountDetailOperationsUpdateAccountMutation(),
|
|
41
|
+
onSuccess: () => {
|
|
42
|
+
queryClient.invalidateQueries({
|
|
43
|
+
queryKey: configOperationsGetConfigQueryKey(),
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
onError: (error) => {
|
|
47
|
+
pushError(
|
|
48
|
+
buildMutationErrorBanner(
|
|
49
|
+
"Couldn't save languages",
|
|
50
|
+
"The writing languages weren't saved.",
|
|
51
|
+
error,
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const setLanguages = useCallback(
|
|
58
|
+
(next: string[]) => {
|
|
59
|
+
if (!accountId) return;
|
|
60
|
+
mutation.mutate({
|
|
61
|
+
path: { accountId },
|
|
62
|
+
body: { composeLanguages: next },
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
[accountId, mutation],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return { languages, setLanguages, isSaving: mutation.isPending };
|
|
69
|
+
};
|