@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.137",
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
- const toggle = <ComposeModeToggle mode={mode} onToggle={handleToggle} />;
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
- trailing={toggle}
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
- trailing={toggle}
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: bodyMode === "plain" ? "" : body.html || undefined,
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
- const loadedHtml = draftData.htmlBody ?? "";
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(bodyMode, body);
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(bodyMode, body);
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
+ };