@remit/web-client 0.0.136 → 0.0.137

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.136",
3
+ "version": "0.0.137",
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,274 @@
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, 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 Harness = ({
28
+ initialHtml = "",
29
+ initialText = "",
30
+ startIn = "rich",
31
+ onConversionError = () => undefined,
32
+ conversions,
33
+ }: {
34
+ initialHtml?: string;
35
+ initialText?: string;
36
+ startIn?: "rich" | "plain";
37
+ onConversionError?: (failure: ConversionFailure) => void;
38
+ conversions?: {
39
+ toPlain: (value: RichTextValue) => string;
40
+ toRich: (text: string) => string;
41
+ };
42
+ }) => {
43
+ const [mode, setMode] = useState<"rich" | "plain">(startIn);
44
+ return (
45
+ <div className="flex h-[460px] w-[680px] flex-col overflow-auto rounded-md border border-line bg-canvas">
46
+ <ComposeBody
47
+ mode={mode}
48
+ onModeChange={setMode}
49
+ initialHtml={initialHtml}
50
+ initialText={initialText}
51
+ onChange={() => undefined}
52
+ onConversionError={onConversionError}
53
+ conversions={conversions}
54
+ />
55
+ </div>
56
+ );
57
+ };
58
+
59
+ /**
60
+ * The mode switch as the compose window runs it: the toolbar control, the one
61
+ * warning it raises, and the two surfaces it swaps between. The live
62
+ * `ComposeForm` adds the recipients, the autosave and the send around this.
63
+ */
64
+ const meta: Meta<typeof Harness> = {
65
+ title: "Screens/WebClient/ComposeModes",
66
+ component: Harness,
67
+ parameters: { layout: "centered" },
68
+ };
69
+ export default meta;
70
+
71
+ type Story = StoryObj<typeof Harness>;
72
+
73
+ const toggleOf = (canvasElement: HTMLElement): HTMLElement => {
74
+ const toggle = canvasElement.querySelector<HTMLElement>(
75
+ "[data-testid=compose-mode-toggle]",
76
+ );
77
+ if (!toggle) throw new Error("the mode toggle is not mounted");
78
+ return toggle;
79
+ };
80
+
81
+ const plainSurface = (canvasElement: HTMLElement): HTMLTextAreaElement | null =>
82
+ canvasElement.querySelector<HTMLTextAreaElement>(
83
+ "[data-testid=compose-body-plain]",
84
+ );
85
+
86
+ export const RichDocument: Story = {
87
+ name: "Rich, with formatting",
88
+ args: { initialHtml: RICH_DOCUMENT },
89
+ };
90
+
91
+ export const PlainDraft: Story = {
92
+ name: "Plain, reopened from Markdown",
93
+ args: { startIn: "plain", initialText: PLAIN_MARKDOWN },
94
+ };
95
+
96
+ /**
97
+ * Cancel changes nothing: the mode stays rich, the document is untouched, and
98
+ * focus comes back to the control that was pressed. `aria-pressed` never flips
99
+ * optimistically.
100
+ */
101
+ export const WarningCancelled: Story = {
102
+ name: "The warning, cancelled",
103
+ args: { initialHtml: RICH_DOCUMENT },
104
+ play: async ({ canvasElement }) => {
105
+ const toggle = toggleOf(canvasElement);
106
+ await userEvent.click(toggle);
107
+
108
+ const dialog = within(document.body).getByRole("dialog");
109
+ await expect(dialog).toHaveTextContent("Switch to plain text?");
110
+ await expect(dialog).toHaveTextContent(
111
+ "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.",
112
+ );
113
+ await expect(toggle).toHaveAttribute("aria-pressed", "false");
114
+
115
+ await userEvent.click(
116
+ within(dialog).getByRole("button", { name: "Cancel" }),
117
+ );
118
+
119
+ await expect(plainSurface(canvasElement)).toBeNull();
120
+ await expect(
121
+ canvasElement.querySelector("[data-testid=compose-body] table"),
122
+ ).not.toBeNull();
123
+ await expect(toggleOf(canvasElement)).toHaveFocus();
124
+ },
125
+ };
126
+
127
+ export const WarningConfirmed: Story = {
128
+ name: "The warning, confirmed",
129
+ args: { initialHtml: RICH_DOCUMENT },
130
+ play: async ({ canvasElement }) => {
131
+ await userEvent.click(toggleOf(canvasElement));
132
+ await userEvent.click(
133
+ within(within(document.body).getByRole("dialog")).getByRole("button", {
134
+ name: "Switch to plain text",
135
+ }),
136
+ );
137
+
138
+ const textarea = plainSurface(canvasElement);
139
+ if (!textarea) throw new Error("the plain surface did not arrive");
140
+ await expect(textarea.value).toContain("## Quarterly numbers");
141
+ await expect(textarea.value).toContain("**up**");
142
+ await expect(textarea.value).toContain("| EMEA | 412 |");
143
+
144
+ // The formatting buttons leave with the rich surface.
145
+ await expect(
146
+ canvasElement.querySelector("[aria-label='Bold (Ctrl+B)']"),
147
+ ).toBeNull();
148
+ await expect(toggleOf(canvasElement)).toHaveAttribute(
149
+ "aria-pressed",
150
+ "true",
151
+ );
152
+ await expect(textarea).toHaveFocus();
153
+ await expect(textarea.selectionStart).toBe(textarea.value.length);
154
+ },
155
+ };
156
+
157
+ /** Nothing but paragraphs and a blank line: switching changes nothing, so nothing is asked. */
158
+ export const PlainProseSwitchesSilently: Story = {
159
+ name: "Plain paragraphs switch without asking",
160
+ args: { initialHtml: PLAIN_PARAGRAPHS },
161
+ play: async ({ canvasElement }) => {
162
+ await userEvent.click(toggleOf(canvasElement));
163
+
164
+ await expect(within(document.body).queryByRole("dialog")).toBeNull();
165
+ const textarea = plainSurface(canvasElement);
166
+ if (!textarea) throw new Error("the plain surface did not arrive");
167
+ await expect(textarea.value).toContain("Thanks");
168
+ await expect(textarea.value).toContain("See you then.");
169
+ },
170
+ };
171
+
172
+ /**
173
+ * An underlined word exports identical to its own characters, so a comparison
174
+ * of the two strings would switch in silence and destroy it. The rule reads the
175
+ * document instead.
176
+ */
177
+ export const UnderlineStillWarns: Story = {
178
+ name: "An underline alone still warns",
179
+ args: { initialHtml: UNDERLINED },
180
+ play: async ({ canvasElement }) => {
181
+ await userEvent.click(toggleOf(canvasElement));
182
+
183
+ await expect(within(document.body).getByRole("dialog")).toHaveTextContent(
184
+ "Switch to plain text?",
185
+ );
186
+ },
187
+ };
188
+
189
+ export const PlainToRich: Story = {
190
+ name: "Markdown back to rich, without asking",
191
+ args: { startIn: "plain", initialText: PLAIN_MARKDOWN },
192
+ play: async ({ canvasElement }) => {
193
+ await userEvent.click(toggleOf(canvasElement));
194
+
195
+ await expect(within(document.body).queryByRole("dialog")).toBeNull();
196
+ const editable = canvasElement.querySelector("[data-testid=compose-body]");
197
+ if (!editable) throw new Error("the rich surface did not arrive");
198
+ await expect(editable.querySelector("h2")).not.toBeNull();
199
+ await expect(editable.querySelector("table td")).not.toBeNull();
200
+ },
201
+ };
202
+
203
+ /** Switching an ordinary note to rich must not reflow it. */
204
+ export const PlainProseToRich: Story = {
205
+ name: "Prose with no Markdown in it",
206
+ args: {
207
+ startIn: "plain",
208
+ initialText: "Thanks — that works.\n\nI'll send the deck tomorrow.",
209
+ },
210
+ play: async ({ canvasElement }) => {
211
+ await userEvent.click(toggleOf(canvasElement));
212
+
213
+ const editable = canvasElement.querySelector("[data-testid=compose-body]");
214
+ if (!editable) throw new Error("the rich surface did not arrive");
215
+ await expect(editable.querySelectorAll("p").length).toBe(2);
216
+ await expect(editable.textContent).toContain("Thanks — that works.");
217
+ await expect(editable.textContent).toContain(
218
+ "I'll send the deck tomorrow.",
219
+ );
220
+ },
221
+ };
222
+
223
+ /**
224
+ * A conversion that would blank a written message does not happen: autosave
225
+ * would persist the empty body a moment later and the draft would be gone with
226
+ * nothing said.
227
+ */
228
+ export const ConversionCameBackEmpty: Story = {
229
+ name: "A conversion that came back empty",
230
+ args: {
231
+ startIn: "plain",
232
+ initialText: "Everything I wrote this morning.",
233
+ onConversionError: fn(),
234
+ conversions: {
235
+ toPlain: (value) => value.text,
236
+ toRich: () => "",
237
+ },
238
+ },
239
+ play: async ({ args, canvasElement }) => {
240
+ await userEvent.click(toggleOf(canvasElement));
241
+
242
+ await expect(args.onConversionError).toHaveBeenCalledWith({
243
+ outcome: "blocked",
244
+ title: "Couldn't switch to rich text",
245
+ detail: "The conversion came back empty, so your message is unchanged.",
246
+ });
247
+ const textarea = plainSurface(canvasElement);
248
+ if (!textarea) throw new Error("the plain surface left");
249
+ await expect(textarea.value).toBe("Everything I wrote this morning.");
250
+ await expect(toggleOf(canvasElement)).toHaveAttribute(
251
+ "aria-pressed",
252
+ "true",
253
+ );
254
+ },
255
+ };
256
+
257
+ /** One Shift+Tab out of the body reaches the toggle, and Enter acts. */
258
+ export const ReachableFromTheBody: Story = {
259
+ name: "Shift+Tab from the body reaches it",
260
+ args: { initialHtml: PLAIN_PARAGRAPHS },
261
+ play: async ({ canvasElement }) => {
262
+ const editable = canvasElement.querySelector<HTMLElement>(
263
+ "[data-testid=compose-body]",
264
+ );
265
+ if (!editable) throw new Error("the rich surface is not mounted");
266
+
267
+ await userEvent.click(editable);
268
+ await userEvent.tab({ shift: true });
269
+ await expect(toggleOf(canvasElement)).toHaveFocus();
270
+
271
+ await userEvent.keyboard("{Enter}");
272
+ await expect(plainSurface(canvasElement)).not.toBeNull();
273
+ },
274
+ };
@@ -1,22 +1,170 @@
1
- import { RichTextEditor, type RichTextValue } from "@remit/ui/rich-text";
1
+ import {
2
+ type ComposeBodyMode,
3
+ ComposeModeToggle,
4
+ markdownToHtml,
5
+ PlainTextEditor,
6
+ RichTextEditor,
7
+ type RichTextValue,
8
+ } from "@remit/ui/rich-text";
9
+ import { useRef, useState } from "react";
10
+ import { ConfirmDialog } from "../ui/ConfirmDialog";
11
+ import { conversionOutcome, switchNeedsWarning } from "./compose-mode";
12
+
13
+ export interface ConversionFailure {
14
+ title: string;
15
+ detail: string;
16
+ }
17
+
18
+ /**
19
+ * The two directions of the mode switch, injectable so a story can drive the
20
+ * conversion that comes back empty — the branch that keeps autosave from
21
+ * persisting a blanked draft, and the one case no real document produces on
22
+ * demand.
23
+ */
24
+ export interface ComposeConversions {
25
+ toPlain: (value: RichTextValue) => string;
26
+ toRich: (text: string) => string;
27
+ }
28
+
29
+ export const DEFAULT_COMPOSE_CONVERSIONS: ComposeConversions = {
30
+ toPlain: (value) => value.text,
31
+ toRich: (text) => markdownToHtml(text),
32
+ };
33
+
34
+ const textOf = (html: string): string =>
35
+ new DOMParser().parseFromString(html, "text/html").body.textContent ?? "";
36
+
37
+ const plainValue = (text: string): RichTextValue => ({
38
+ html: "",
39
+ text,
40
+ formatting: [],
41
+ });
2
42
 
3
43
  interface ComposeBodyProps {
44
+ mode: ComposeBodyMode;
45
+ onModeChange: (mode: ComposeBodyMode) => void;
4
46
  initialHtml: string;
47
+ initialText: string;
5
48
  onChange: (value: RichTextValue) => void;
6
49
  onSubmit?: () => void;
7
50
  autoFocus?: boolean;
51
+ onConversionError: (failure: ConversionFailure) => void;
52
+ conversions?: ComposeConversions;
8
53
  }
9
54
 
55
+ /**
56
+ * The compose writing surface and the control that swaps it. Rich text is the
57
+ * WYSIWYG document; plain text is a textarea whose content is the Markdown that
58
+ * will be sent verbatim. The conversion runs over an in-memory document, so the
59
+ * surface swaps in the same frame the choice is made.
60
+ */
10
61
  export const ComposeBody = ({
62
+ mode,
63
+ onModeChange,
11
64
  initialHtml,
65
+ initialText,
12
66
  onChange,
13
67
  onSubmit,
14
- autoFocus,
15
- }: ComposeBodyProps) => (
16
- <RichTextEditor
17
- initialHtml={initialHtml}
18
- onChange={onChange}
19
- onSubmit={onSubmit}
20
- autoFocus={autoFocus}
21
- />
22
- );
68
+ autoFocus = false,
69
+ onConversionError,
70
+ conversions = DEFAULT_COMPOSE_CONVERSIONS,
71
+ }: ComposeBodyProps) => {
72
+ const [richHtml, setRichHtml] = useState(initialHtml);
73
+ const [richGeneration, setRichGeneration] = useState(0);
74
+ const [plainText, setPlainText] = useState(initialText);
75
+ const [confirming, setConfirming] = useState(false);
76
+ // The caret does not survive a conversion: a rich selection is a node path
77
+ // and Markdown is a character offset. The surface that arrives takes focus
78
+ // with the caret at the end; the toggle keeps it when the mode did not change.
79
+ const [focusSwitchedSurface, setFocusSwitchedSurface] = useState(false);
80
+ const richValue = useRef<RichTextValue>({
81
+ html: initialHtml,
82
+ text: initialText,
83
+ formatting: [],
84
+ });
85
+
86
+ const handleRichChange = (value: RichTextValue) => {
87
+ richValue.current = value;
88
+ onChange(value);
89
+ };
90
+
91
+ const handlePlainChange = (text: string) => {
92
+ setPlainText(text);
93
+ onChange(plainValue(text));
94
+ };
95
+
96
+ const switchToPlain = () => {
97
+ const value = richValue.current;
98
+ const converted = conversions.toPlain(value);
99
+ const decision = conversionOutcome("plain", textOf(value.html), converted);
100
+ if (decision.outcome === "blocked") {
101
+ onConversionError(decision);
102
+ return;
103
+ }
104
+ setPlainText(converted);
105
+ setFocusSwitchedSurface(true);
106
+ onChange(plainValue(converted));
107
+ onModeChange("plain");
108
+ };
109
+
110
+ const switchToRich = () => {
111
+ const converted = conversions.toRich(plainText);
112
+ const decision = conversionOutcome("rich", plainText, textOf(converted));
113
+ if (decision.outcome === "blocked") {
114
+ onConversionError(decision);
115
+ return;
116
+ }
117
+ setRichHtml(converted);
118
+ setRichGeneration((generation) => generation + 1);
119
+ setFocusSwitchedSurface(true);
120
+ onModeChange("rich");
121
+ };
122
+
123
+ const handleToggle = () => {
124
+ if (mode === "plain") {
125
+ switchToRich();
126
+ return;
127
+ }
128
+ if (switchNeedsWarning("plain", richValue.current.formatting)) {
129
+ setConfirming(true);
130
+ return;
131
+ }
132
+ switchToPlain();
133
+ };
134
+
135
+ const toggle = <ComposeModeToggle mode={mode} onToggle={handleToggle} />;
136
+
137
+ return (
138
+ <>
139
+ {mode === "plain" ? (
140
+ <PlainTextEditor
141
+ value={plainText}
142
+ onChange={handlePlainChange}
143
+ onSubmit={onSubmit}
144
+ autoFocus={focusSwitchedSurface}
145
+ trailing={toggle}
146
+ />
147
+ ) : (
148
+ <RichTextEditor
149
+ key={richGeneration}
150
+ initialHtml={richHtml}
151
+ onChange={handleRichChange}
152
+ onSubmit={onSubmit}
153
+ autoFocus={autoFocus || focusSwitchedSurface}
154
+ trailing={toggle}
155
+ />
156
+ )}
157
+ <ConfirmDialog
158
+ isOpen={confirming}
159
+ title="Switch to plain text?"
160
+ 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."
161
+ confirmLabel="Switch to plain text"
162
+ onConfirm={() => {
163
+ setConfirming(false);
164
+ switchToPlain();
165
+ }}
166
+ onCancel={() => setConfirming(false)}
167
+ />
168
+ </>
169
+ );
170
+ };
@@ -16,6 +16,7 @@ import {
16
16
  type RichTextValue,
17
17
  sanitizeQuotedHtml,
18
18
  } from "@remit/ui";
19
+ import type { ComposeBodyMode } from "@remit/ui/rich-text";
19
20
  import { useMutation, useQuery } from "@tanstack/react-query";
20
21
  import {
21
22
  lazy,
@@ -37,6 +38,7 @@ import {
37
38
  import type { AddressEntry } from "./AddressField";
38
39
  import { AddressField } from "./AddressField";
39
40
  import { ComposeSmtpMissingBanner } from "./ComposeSmtpMissingBanner";
41
+ import { modeOfDraft } from "./compose-mode";
40
42
 
41
43
  const LazyComposeBody = lazy(() =>
42
44
  import("./ComposeBody.js").then((m) => ({ default: m.ComposeBody })),
@@ -147,6 +149,28 @@ const getReferences = (
147
149
  };
148
150
  };
149
151
 
152
+ /**
153
+ * What the two body columns carry for this mode.
154
+ *
155
+ * Plain mode writes the empty string rather than omitting `htmlBody`: absent
156
+ * means "leave alone" at every layer below, so a plain draft that omitted it
157
+ * would send the HTML it was written as before the switch. The empty string is
158
+ * defined, so the repository's update guard clears the column, and nodemailer
159
+ * branches on the value being truthy — an empty one builds no HTML alternative
160
+ * and the message leaves as a single `text/plain` part.
161
+ *
162
+ * Rich mode leaves it alone while it has nothing to say, so the moments before
163
+ * the lazily-loaded editor reports its document cannot write a draft back as
164
+ * plain.
165
+ */
166
+ const outgoingBody = (
167
+ bodyMode: ComposeBodyMode,
168
+ body: RichTextValue,
169
+ ): { textBody: string | undefined; htmlBody: string | undefined } => ({
170
+ textBody: body.text || undefined,
171
+ htmlBody: bodyMode === "plain" ? "" : body.html || undefined,
172
+ });
173
+
150
174
  const isFormEmpty = (
151
175
  toAddresses: AddressEntry[],
152
176
  ccAddresses: AddressEntry[],
@@ -330,6 +354,8 @@ export const ComposeForm = ({
330
354
  setShowCc(false);
331
355
  setShowBcc(false);
332
356
  setInitialHtml("");
357
+ setInitialText("");
358
+ setBodyMode("rich");
333
359
  setBody(EMPTY_RICH_TEXT);
334
360
  setDocumentGeneration((generation) => generation + 1);
335
361
  setDraftLoaded(false);
@@ -344,9 +370,12 @@ export const ComposeForm = ({
344
370
  const [initialHtml, setInitialHtml] = useState(() =>
345
371
  buildInitialHtml(signature.plainText),
346
372
  );
373
+ const [initialText, setInitialText] = useState(signature.plainText);
374
+ const [bodyMode, setBodyMode] = useState<ComposeBodyMode>("rich");
347
375
  const [body, setBody] = useState<RichTextValue>(() => ({
348
376
  html: buildInitialHtml(signature.plainText),
349
377
  text: signature.plainText,
378
+ formatting: [],
350
379
  }));
351
380
 
352
381
  const { data: draftData } = useQuery({
@@ -381,12 +410,16 @@ export const ComposeForm = ({
381
410
  setShowBcc(true);
382
411
  }
383
412
  if (draftData.subject) setSubject(draftData.subject);
384
- // A draft stores what would have been sent, so a rich one reopens from its
385
- // HTML. Only a draft that never had any falls back to its text.
386
- const loadedHtml =
387
- draftData.htmlBody || textToHtml(draftData.textBody ?? "");
413
+ // A draft stores what would have been sent, so which surface it reopens in
414
+ // is read off that rather than a field of its own. A rich draft comes back
415
+ // from its HTML — reading its text into one paragraph, as this did, brought
416
+ // a formatted message back flattened.
417
+ const loadedHtml = draftData.htmlBody ?? "";
418
+ const loadedText = draftData.textBody ?? "";
419
+ setBodyMode(modeOfDraft(draftData.htmlBody));
388
420
  setInitialHtml(loadedHtml);
389
- setBody({ html: loadedHtml, text: draftData.textBody ?? "" });
421
+ setInitialText(loadedText);
422
+ setBody({ html: loadedHtml, text: loadedText, formatting: [] });
390
423
  setDocumentGeneration((generation) => generation + 1);
391
424
  setSelectedAccountId(draftData.accountId);
392
425
  setDraftLoaded(true);
@@ -512,7 +545,7 @@ export const ComposeForm = ({
512
545
  if (isFormEmpty(toAddresses, ccAddresses, bccAddresses, subject, body))
513
546
  return;
514
547
 
515
- const { html: htmlBody, text: textBody } = body;
548
+ const { htmlBody, textBody } = outgoingBody(bodyMode, body);
516
549
 
517
550
  saveDraft({
518
551
  accountId: selectedAccountId,
@@ -522,8 +555,8 @@ export const ComposeForm = ({
522
555
  bccAddresses:
523
556
  bccAddresses.length > 0 ? bccAddresses.map((a) => a.email) : undefined,
524
557
  subject: subject || undefined,
525
- textBody: textBody || undefined,
526
- htmlBody: htmlBody || undefined,
558
+ textBody,
559
+ htmlBody,
527
560
  });
528
561
  }, [
529
562
  selectedAccountId,
@@ -534,6 +567,7 @@ export const ComposeForm = ({
534
567
  bccAddresses,
535
568
  subject,
536
569
  body,
570
+ bodyMode,
537
571
  saveDraft,
538
572
  ]);
539
573
 
@@ -551,7 +585,7 @@ export const ComposeForm = ({
551
585
  ? getReferences(sourceMessage)
552
586
  : {};
553
587
 
554
- const { html: htmlBody, text: textBody } = body;
588
+ const { htmlBody, textBody } = outgoingBody(bodyMode, body);
555
589
  const createdThisAttempt = !outboxMessageId;
556
590
 
557
591
  // The debounce dropped above may have been holding the last two seconds
@@ -568,8 +602,8 @@ export const ComposeForm = ({
568
602
  ? bccAddresses.map((a) => a.email)
569
603
  : undefined,
570
604
  subject: subject || undefined,
571
- textBody: textBody || undefined,
572
- htmlBody: htmlBody || undefined,
605
+ textBody,
606
+ htmlBody,
573
607
  ...replyData,
574
608
  });
575
609
 
@@ -618,6 +652,7 @@ export const ComposeForm = ({
618
652
  bccAddresses,
619
653
  subject,
620
654
  body,
655
+ bodyMode,
621
656
  mode,
622
657
  sourceMessage,
623
658
  outboxMessageId,
@@ -703,10 +738,14 @@ export const ComposeForm = ({
703
738
  <Suspense fallback={<ComposeBodyFallback />}>
704
739
  <LazyComposeBody
705
740
  key={documentGeneration}
741
+ mode={bodyMode}
742
+ onModeChange={setBodyMode}
706
743
  initialHtml={initialHtml}
744
+ initialText={initialText}
707
745
  onChange={setBody}
708
746
  onSubmit={handleSend}
709
747
  autoFocus={mode === "new"}
748
+ onConversionError={pushError}
710
749
  />
711
750
  </Suspense>
712
751
  </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
- {/* Confirmation dialog when dismissing dirty draft */}
123
- {showConfirm && (
124
- <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
125
- <div className="mx-4 w-full max-w-sm rounded-xl bg-canvas p-6 shadow-xl">
126
- <h3 className="text-lg font-semibold">Discard draft?</h3>
127
- <p className="mt-2 text-sm text-fg-muted">
128
- Your message has unsaved content. Are you sure you want to discard
129
- it?
130
- </p>
131
- <div className="mt-4 flex justify-end gap-2">
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
+ };
@@ -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
- cancelRef.current?.focus();
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
- <div className="fixed inset-0 z-50 flex items-center justify-center">
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. */}