@remit/web-client 0.0.137 → 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 +190 -1
- package/src/components/compose/ComposeBody.tsx +44 -4
- package/src/components/compose/ComposeForm.tsx +50 -4
- package/src/components/settings/AccountFormPanel.tsx +23 -0
- 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": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { RichTextValue } from "@remit/ui/rich-text";
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
3
|
import { useState } from "react";
|
|
4
|
-
import { expect, fn, userEvent, within } from "storybook/test";
|
|
4
|
+
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
|
5
5
|
import { ComposeBody, type ConversionFailure } from "./ComposeBody";
|
|
6
6
|
|
|
7
7
|
const RICH_DOCUMENT = [
|
|
@@ -24,12 +24,25 @@ const PLAIN_MARKDOWN = [
|
|
|
24
24
|
"| EMEA | 412 |",
|
|
25
25
|
].join("\n");
|
|
26
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
|
+
|
|
27
38
|
const Harness = ({
|
|
28
39
|
initialHtml = "",
|
|
29
40
|
initialText = "",
|
|
30
41
|
startIn = "rich",
|
|
31
42
|
onConversionError = () => undefined,
|
|
32
43
|
conversions,
|
|
44
|
+
languages = LANGUAGES,
|
|
45
|
+
quoted,
|
|
33
46
|
}: {
|
|
34
47
|
initialHtml?: string;
|
|
35
48
|
initialText?: string;
|
|
@@ -39,6 +52,8 @@ const Harness = ({
|
|
|
39
52
|
toPlain: (value: RichTextValue) => string;
|
|
40
53
|
toRich: (text: string) => string;
|
|
41
54
|
};
|
|
55
|
+
languages?: string[];
|
|
56
|
+
quoted?: string;
|
|
42
57
|
}) => {
|
|
43
58
|
const [mode, setMode] = useState<"rich" | "plain">(startIn);
|
|
44
59
|
return (
|
|
@@ -51,11 +66,30 @@ const Harness = ({
|
|
|
51
66
|
onChange={() => undefined}
|
|
52
67
|
onConversionError={onConversionError}
|
|
53
68
|
conversions={conversions}
|
|
69
|
+
languages={languages}
|
|
70
|
+
onLanguageChange={noop}
|
|
54
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
|
+
)}
|
|
55
81
|
</div>
|
|
56
82
|
);
|
|
57
83
|
};
|
|
58
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
|
+
|
|
59
93
|
/**
|
|
60
94
|
* The mode switch as the compose window runs it: the toolbar control, the one
|
|
61
95
|
* warning it raises, and the two surfaces it swaps between. The live
|
|
@@ -272,3 +306,158 @@ export const ReachableFromTheBody: Story = {
|
|
|
272
306
|
await expect(plainSurface(canvasElement)).not.toBeNull();
|
|
273
307
|
},
|
|
274
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,12 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type ComposeBodyMode,
|
|
3
|
+
ComposeLanguageChip,
|
|
3
4
|
ComposeModeToggle,
|
|
4
5
|
markdownToHtml,
|
|
5
6
|
PlainTextEditor,
|
|
6
7
|
RichTextEditor,
|
|
7
8
|
type RichTextValue,
|
|
9
|
+
useComposeLanguage,
|
|
8
10
|
} from "@remit/ui/rich-text";
|
|
9
|
-
import { useRef, useState } from "react";
|
|
11
|
+
import { useEffect, useRef, useState } from "react";
|
|
10
12
|
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
|
11
13
|
import { conversionOutcome, switchNeedsWarning } from "./compose-mode";
|
|
12
14
|
|
|
@@ -50,6 +52,12 @@ interface ComposeBodyProps {
|
|
|
50
52
|
autoFocus?: boolean;
|
|
51
53
|
onConversionError: (failure: ConversionFailure) => void;
|
|
52
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;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
/**
|
|
@@ -68,10 +76,14 @@ export const ComposeBody = ({
|
|
|
68
76
|
autoFocus = false,
|
|
69
77
|
onConversionError,
|
|
70
78
|
conversions = DEFAULT_COMPOSE_CONVERSIONS,
|
|
79
|
+
languages,
|
|
80
|
+
initialLanguage,
|
|
81
|
+
onLanguageChange,
|
|
71
82
|
}: ComposeBodyProps) => {
|
|
72
83
|
const [richHtml, setRichHtml] = useState(initialHtml);
|
|
73
84
|
const [richGeneration, setRichGeneration] = useState(0);
|
|
74
85
|
const [plainText, setPlainText] = useState(initialText);
|
|
86
|
+
const [bodyText, setBodyText] = useState(initialText);
|
|
75
87
|
const [confirming, setConfirming] = useState(false);
|
|
76
88
|
// The caret does not survive a conversion: a rich selection is a node path
|
|
77
89
|
// and Markdown is a character offset. The surface that arrives takes focus
|
|
@@ -83,13 +95,27 @@ export const ComposeBody = ({
|
|
|
83
95
|
formatting: [],
|
|
84
96
|
});
|
|
85
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
|
+
|
|
86
110
|
const handleRichChange = (value: RichTextValue) => {
|
|
87
111
|
richValue.current = value;
|
|
112
|
+
setBodyText(value.text);
|
|
88
113
|
onChange(value);
|
|
89
114
|
};
|
|
90
115
|
|
|
91
116
|
const handlePlainChange = (text: string) => {
|
|
92
117
|
setPlainText(text);
|
|
118
|
+
setBodyText(text);
|
|
93
119
|
onChange(plainValue(text));
|
|
94
120
|
};
|
|
95
121
|
|
|
@@ -102,6 +128,7 @@ export const ComposeBody = ({
|
|
|
102
128
|
return;
|
|
103
129
|
}
|
|
104
130
|
setPlainText(converted);
|
|
131
|
+
setBodyText(converted);
|
|
105
132
|
setFocusSwitchedSurface(true);
|
|
106
133
|
onChange(plainValue(converted));
|
|
107
134
|
onModeChange("plain");
|
|
@@ -132,7 +159,18 @@ export const ComposeBody = ({
|
|
|
132
159
|
switchToPlain();
|
|
133
160
|
};
|
|
134
161
|
|
|
135
|
-
|
|
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
|
+
);
|
|
136
174
|
|
|
137
175
|
return (
|
|
138
176
|
<>
|
|
@@ -142,7 +180,8 @@ export const ComposeBody = ({
|
|
|
142
180
|
onChange={handlePlainChange}
|
|
143
181
|
onSubmit={onSubmit}
|
|
144
182
|
autoFocus={focusSwitchedSurface}
|
|
145
|
-
|
|
183
|
+
lang={language}
|
|
184
|
+
trailing={trailing}
|
|
146
185
|
/>
|
|
147
186
|
) : (
|
|
148
187
|
<RichTextEditor
|
|
@@ -151,7 +190,8 @@ export const ComposeBody = ({
|
|
|
151
190
|
onChange={handleRichChange}
|
|
152
191
|
onSubmit={onSubmit}
|
|
153
192
|
autoFocus={autoFocus || focusSwitchedSurface}
|
|
154
|
-
|
|
193
|
+
lang={language}
|
|
194
|
+
trailing={trailing}
|
|
155
195
|
/>
|
|
156
196
|
)}
|
|
157
197
|
<ConfirmDialog
|
|
@@ -11,10 +11,13 @@ 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";
|
|
19
22
|
import type { ComposeBodyMode } from "@remit/ui/rich-text";
|
|
20
23
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
@@ -23,6 +26,7 @@ import {
|
|
|
23
26
|
Suspense,
|
|
24
27
|
useCallback,
|
|
25
28
|
useEffect,
|
|
29
|
+
useMemo,
|
|
26
30
|
useRef,
|
|
27
31
|
useState,
|
|
28
32
|
} from "react";
|
|
@@ -166,9 +170,15 @@ const getReferences = (
|
|
|
166
170
|
const outgoingBody = (
|
|
167
171
|
bodyMode: ComposeBodyMode,
|
|
168
172
|
body: RichTextValue,
|
|
173
|
+
language: string,
|
|
169
174
|
): { textBody: string | undefined; htmlBody: string | undefined } => ({
|
|
170
175
|
textBody: body.text || undefined,
|
|
171
|
-
htmlBody:
|
|
176
|
+
htmlBody:
|
|
177
|
+
bodyMode === "plain"
|
|
178
|
+
? ""
|
|
179
|
+
: body.html
|
|
180
|
+
? wrapWithLanguage(body.html, language)
|
|
181
|
+
: undefined,
|
|
172
182
|
});
|
|
173
183
|
|
|
174
184
|
const isFormEmpty = (
|
|
@@ -356,6 +366,7 @@ export const ComposeForm = ({
|
|
|
356
366
|
setInitialHtml("");
|
|
357
367
|
setInitialText("");
|
|
358
368
|
setBodyMode("rich");
|
|
369
|
+
setDraftLanguage(undefined);
|
|
359
370
|
setBody(EMPTY_RICH_TEXT);
|
|
360
371
|
setDocumentGeneration((generation) => generation + 1);
|
|
361
372
|
setDraftLoaded(false);
|
|
@@ -372,6 +383,11 @@ export const ComposeForm = ({
|
|
|
372
383
|
);
|
|
373
384
|
const [initialText, setInitialText] = useState(signature.plainText);
|
|
374
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>();
|
|
375
391
|
const [body, setBody] = useState<RichTextValue>(() => ({
|
|
376
392
|
html: buildInitialHtml(signature.plainText),
|
|
377
393
|
text: signature.plainText,
|
|
@@ -414,9 +430,15 @@ export const ComposeForm = ({
|
|
|
414
430
|
// is read off that rather than a field of its own. A rich draft comes back
|
|
415
431
|
// from its HTML — reading its text into one paragraph, as this did, brought
|
|
416
432
|
// a formatted message back flattened.
|
|
417
|
-
|
|
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;
|
|
418
439
|
const loadedText = draftData.textBody ?? "";
|
|
419
440
|
setBodyMode(modeOfDraft(draftData.htmlBody));
|
|
441
|
+
setDraftLanguage(stored.language ?? undefined);
|
|
420
442
|
setInitialHtml(loadedHtml);
|
|
421
443
|
setInitialText(loadedText);
|
|
422
444
|
setBody({ html: loadedHtml, text: loadedText, formatting: [] });
|
|
@@ -525,6 +547,17 @@ export const ComposeForm = ({
|
|
|
525
547
|
? accountIsMissingSmtp(selectedAccount)
|
|
526
548
|
: false;
|
|
527
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
|
+
|
|
528
561
|
// The action bar refuses a second press while one is in flight, but the
|
|
529
562
|
// editor's own Cmd+Enter goes straight to `handleSend`, and the write that
|
|
530
563
|
// now precedes the request widens the window a second press lands in.
|
|
@@ -545,7 +578,11 @@ export const ComposeForm = ({
|
|
|
545
578
|
if (isFormEmpty(toAddresses, ccAddresses, bccAddresses, subject, body))
|
|
546
579
|
return;
|
|
547
580
|
|
|
548
|
-
const { htmlBody, textBody } = outgoingBody(
|
|
581
|
+
const { htmlBody, textBody } = outgoingBody(
|
|
582
|
+
bodyMode,
|
|
583
|
+
body,
|
|
584
|
+
composeLanguage,
|
|
585
|
+
);
|
|
549
586
|
|
|
550
587
|
saveDraft({
|
|
551
588
|
accountId: selectedAccountId,
|
|
@@ -568,6 +605,7 @@ export const ComposeForm = ({
|
|
|
568
605
|
subject,
|
|
569
606
|
body,
|
|
570
607
|
bodyMode,
|
|
608
|
+
composeLanguage,
|
|
571
609
|
saveDraft,
|
|
572
610
|
]);
|
|
573
611
|
|
|
@@ -585,7 +623,11 @@ export const ComposeForm = ({
|
|
|
585
623
|
? getReferences(sourceMessage)
|
|
586
624
|
: {};
|
|
587
625
|
|
|
588
|
-
const { htmlBody, textBody } = outgoingBody(
|
|
626
|
+
const { htmlBody, textBody } = outgoingBody(
|
|
627
|
+
bodyMode,
|
|
628
|
+
body,
|
|
629
|
+
composeLanguage,
|
|
630
|
+
);
|
|
589
631
|
const createdThisAttempt = !outboxMessageId;
|
|
590
632
|
|
|
591
633
|
// The debounce dropped above may have been holding the last two seconds
|
|
@@ -653,6 +695,7 @@ export const ComposeForm = ({
|
|
|
653
695
|
subject,
|
|
654
696
|
body,
|
|
655
697
|
bodyMode,
|
|
698
|
+
composeLanguage,
|
|
656
699
|
mode,
|
|
657
700
|
sourceMessage,
|
|
658
701
|
outboxMessageId,
|
|
@@ -746,6 +789,9 @@ export const ComposeForm = ({
|
|
|
746
789
|
onSubmit={handleSend}
|
|
747
790
|
autoFocus={mode === "new"}
|
|
748
791
|
onConversionError={pushError}
|
|
792
|
+
languages={accountLanguages}
|
|
793
|
+
initialLanguage={draftLanguage}
|
|
794
|
+
onLanguageChange={setComposeLanguage}
|
|
749
795
|
/>
|
|
750
796
|
</Suspense>
|
|
751
797
|
</ComposeFormShell>
|
|
@@ -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
|
);
|
|
@@ -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
|
+
};
|