@remit/web-client 0.0.133 → 0.0.135

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.133",
3
+ "version": "0.0.135",
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": {
@@ -61,9 +61,6 @@
61
61
  },
62
62
  "dependencies": {
63
63
  "@hookform/resolvers": "*",
64
- "@platejs/autoformat": "*",
65
- "@platejs/basic-nodes": "*",
66
- "@platejs/link": "*",
67
64
  "@remit/api-http-client": "*",
68
65
  "@remit/domain-enums": "*",
69
66
  "@remit/ui": "*",
@@ -82,7 +79,6 @@
82
79
  "i18next-http-backend": "^3.0.6",
83
80
  "lucide-react": "^0.468",
84
81
  "p-map": "*",
85
- "platejs": "*",
86
82
  "react-hook-form": "*",
87
83
  "react-i18next": "^15",
88
84
  "tailwind-merge": "^2",
@@ -1,36 +1,22 @@
1
- import type { Value } from "platejs";
2
- import { Plate } from "platejs/react";
3
- import { PlateEditorContent, usePlateComposeEditor } from "./PlateEditor.js";
4
- import { PlateToolbar } from "./PlateToolbar.js";
1
+ import { RichTextEditor, type RichTextValue } from "@remit/ui/rich-text";
5
2
 
6
3
  interface ComposeBodyProps {
7
- value: Value;
8
- onChange: (value: Value) => void;
4
+ initialHtml: string;
5
+ onChange: (value: RichTextValue) => void;
9
6
  onSubmit?: () => void;
10
7
  autoFocus?: boolean;
11
8
  }
12
9
 
13
10
  export const ComposeBody = ({
14
- value,
11
+ initialHtml,
15
12
  onChange,
16
13
  onSubmit,
17
14
  autoFocus,
18
- }: ComposeBodyProps) => {
19
- const editor = usePlateComposeEditor(value);
20
-
21
- return (
22
- <Plate
23
- editor={editor}
24
- onValueChange={({ value: v }) => {
25
- onChange(v);
26
- }}
27
- >
28
- <PlateToolbar />
29
- <PlateEditorContent
30
- editor={editor}
31
- onSubmit={onSubmit}
32
- autoFocus={autoFocus}
33
- />
34
- </Plate>
35
- );
36
- };
15
+ }: ComposeBodyProps) => (
16
+ <RichTextEditor
17
+ initialHtml={initialHtml}
18
+ onChange={onChange}
19
+ onSubmit={onSubmit}
20
+ autoFocus={autoFocus}
21
+ />
22
+ );
@@ -3,15 +3,20 @@ import {
3
3
  outboxDetailOperationsDeleteOutboxMessageMutation,
4
4
  outboxDetailOperationsGetOutboxMessageOptions,
5
5
  outboxDetailOperationsSendOutboxMessageMutation,
6
- outboxOperationsCreateOutboxMessageMutation,
7
6
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
7
  import type {
9
8
  RemitImapAccountResponse,
10
9
  RemitImapDescribeMessageResponse,
11
10
  } from "@remit/api-http-client/types.gen.ts";
12
- import { ComposeActionBar, ComposeFormShell, QuotedText } from "@remit/ui";
11
+ import {
12
+ ComposeActionBar,
13
+ ComposeFormShell,
14
+ EMPTY_RICH_TEXT,
15
+ QuotedText,
16
+ type RichTextValue,
17
+ sanitizeQuotedHtml,
18
+ } from "@remit/ui";
13
19
  import { useMutation, useQuery } from "@tanstack/react-query";
14
- import type { Value } from "platejs";
15
20
  import {
16
21
  lazy,
17
22
  Suspense,
@@ -23,10 +28,6 @@ import {
23
28
  import { useMessageBodyContent } from "../../hooks/useMessageBodyContent";
24
29
  import { useSaveDraft } from "../../hooks/useSaveDraft";
25
30
  import { useSignature } from "../../hooks/useSignature.js";
26
- import {
27
- plateValueToHtml,
28
- plateValueToText,
29
- } from "../../lib/plate-serializer.js";
30
31
  import { accountIsMissingSmtp } from "../settings/account-form-helpers.js";
31
32
  import { useErrorBanners } from "../ui/ErrorBannerProvider.js";
32
33
  import {
@@ -54,7 +55,6 @@ import type { ComposeMode } from "./ComposeProvider";
54
55
  import { useCompose } from "./ComposeProvider";
55
56
  import { FromSelector } from "./FromSelector";
56
57
  import { SubjectField } from "./SubjectField";
57
- import { sanitizeQuoteHtml } from "./sanitize-quote-html.js";
58
58
 
59
59
  interface ComposeFormProps {
60
60
  mode: ComposeMode;
@@ -64,19 +64,22 @@ interface ComposeFormProps {
64
64
  onAccountChange?: (account: RemitImapAccountResponse) => void;
65
65
  }
66
66
 
67
- const EMPTY_PARAGRAPH: Value = [{ type: "p", children: [{ text: "" }] }];
68
-
69
- const SIGNATURE_SEPARATOR: Value = [
70
- { type: "p", children: [{ text: "" }] },
71
- { type: "p", children: [{ text: "-- " }] },
72
- ];
73
-
74
- const buildInitialBody = (signaturePlainText: string): Value => {
75
- if (!signaturePlainText) return EMPTY_PARAGRAPH;
76
- return [
77
- ...SIGNATURE_SEPARATOR,
78
- { type: "p", children: [{ text: signaturePlainText }] },
79
- ];
67
+ const escapeHtml = (text: string): string =>
68
+ text
69
+ .replace(/&/g, "&amp;")
70
+ .replace(/</g, "&lt;")
71
+ .replace(/>/g, "&gt;")
72
+ .replace(/"/g, "&quot;");
73
+
74
+ const textToHtml = (text: string): string =>
75
+ text
76
+ .split("\n")
77
+ .map((line) => `<p>${escapeHtml(line)}</p>`)
78
+ .join("");
79
+
80
+ const buildInitialHtml = (signaturePlainText: string): string => {
81
+ if (!signaturePlainText) return "";
82
+ return `<p></p><p>-- </p>${textToHtml(signaturePlainText)}`;
80
83
  };
81
84
 
82
85
  const buildReplySubject = (subject?: string): string => {
@@ -149,13 +152,13 @@ const isFormEmpty = (
149
152
  ccAddresses: AddressEntry[],
150
153
  bccAddresses: AddressEntry[],
151
154
  subject: string,
152
- body: Value,
155
+ body: RichTextValue,
153
156
  ): boolean =>
154
157
  toAddresses.length === 0 &&
155
158
  ccAddresses.length === 0 &&
156
159
  bccAddresses.length === 0 &&
157
160
  subject.trim() === "" &&
158
- plateValueToText(body).trim() === "";
161
+ body.text.trim() === "";
159
162
 
160
163
  // ---------------------------------------------------------------------------
161
164
  // ComposeHeader — collapsed on mobile when the software keyboard is open
@@ -310,24 +313,41 @@ export const ComposeForm = ({
310
313
  // changes while compose is already open). Without this, the previous draft's
311
314
  // fields stay visible and the new draft never loads because draftLoaded
312
315
  // remains true from the prior session (#536).
316
+ //
317
+ // A first autosave also sets the id, from nothing to the draft it just
318
+ // created. That is this session's own content arriving back, not a switch to
319
+ // somebody else's document, and blanking the form there would throw away
320
+ // whatever is being typed.
313
321
  useEffect(() => {
314
- if (prevOutboxMessageIdRef.current === outboxMessageId) return;
322
+ const previous = prevOutboxMessageIdRef.current;
323
+ if (previous === outboxMessageId) return;
315
324
  prevOutboxMessageIdRef.current = outboxMessageId;
316
- if (!outboxMessageId) return;
325
+ if (!outboxMessageId || previous === undefined) return;
317
326
  setToAddresses([]);
318
327
  setCcAddresses([]);
319
328
  setBccAddresses([]);
320
329
  setSubject("");
321
330
  setShowCc(false);
322
331
  setShowBcc(false);
323
- setBody(EMPTY_PARAGRAPH);
332
+ setInitialHtml("");
333
+ setBody(EMPTY_RICH_TEXT);
334
+ setDocumentGeneration((generation) => generation + 1);
324
335
  setDraftLoaded(false);
325
336
  }, [outboxMessageId]);
326
337
 
327
338
  const { signature } = useSignature(selectedAccountId);
328
- const [body, setBody] = useState<Value>(() =>
329
- buildInitialBody(signature.plainText),
339
+ // The editor reads its document once, so this is the document it opens on,
340
+ // not the live value, and it is remounted when the generation changes. Only
341
+ // loading a different document bumps that — remounting mid-compose would take
342
+ // the caret, the focus and the undo history with it.
343
+ const [documentGeneration, setDocumentGeneration] = useState(0);
344
+ const [initialHtml, setInitialHtml] = useState(() =>
345
+ buildInitialHtml(signature.plainText),
330
346
  );
347
+ const [body, setBody] = useState<RichTextValue>(() => ({
348
+ html: buildInitialHtml(signature.plainText),
349
+ text: signature.plainText,
350
+ }));
331
351
 
332
352
  const { data: draftData } = useQuery({
333
353
  ...outboxDetailOperationsGetOutboxMessageOptions({
@@ -361,8 +381,13 @@ export const ComposeForm = ({
361
381
  setShowBcc(true);
362
382
  }
363
383
  if (draftData.subject) setSubject(draftData.subject);
364
- if (draftData.textBody)
365
- setBody([{ type: "p", children: [{ text: draftData.textBody }] }]);
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 ?? "");
388
+ setInitialHtml(loadedHtml);
389
+ setBody({ html: loadedHtml, text: draftData.textBody ?? "" });
390
+ setDocumentGeneration((generation) => generation + 1);
366
391
  setSelectedAccountId(draftData.accountId);
367
392
  setDraftLoaded(true);
368
393
  }, [draftData, draftLoaded]);
@@ -398,17 +423,29 @@ export const ComposeForm = ({
398
423
  const quotedText = sourceBody?.kind === "text" ? sourceBody.body : "";
399
424
  const quotedHtml =
400
425
  sourceBody?.kind === "html"
401
- ? sanitizeQuoteHtml(sourceBody.body)
426
+ ? sanitizeQuotedHtml(sourceBody.body)
402
427
  : undefined;
403
428
 
404
429
  const senderName =
405
430
  sourceMessage?.envelope.from[0]?.displayName ??
406
431
  sourceMessage?.envelope.from[0]?.normalizedEmail;
407
432
 
408
- const { saveStatus, saveError, saveDraft, stopAutoSave } = useSaveDraft({
409
- outboxMessageId,
410
- onDraftCreated: setOutboxMessageId,
411
- });
433
+ // The draft this session just created holds what is already on screen, so
434
+ // there is nothing to read back — and reading it back would replace the
435
+ // document under the caret with the server's copy of it.
436
+ const adoptCreatedDraft = useCallback(
437
+ (createdId: string) => {
438
+ setDraftLoaded(true);
439
+ setOutboxMessageId(createdId);
440
+ },
441
+ [setOutboxMessageId],
442
+ );
443
+
444
+ const { saveStatus, saveError, saveDraft, saveImmediately, stopAutoSave } =
445
+ useSaveDraft({
446
+ outboxMessageId,
447
+ onDraftCreated: adoptCreatedDraft,
448
+ });
412
449
 
413
450
  // Auto-save runs on a debounce, so a failure has no inline call site to
414
451
  // surface it. Push the real error detail to a banner instead of leaving only
@@ -423,10 +460,6 @@ export const ComposeForm = ({
423
460
  });
424
461
  }, [saveError, pushError]);
425
462
 
426
- const createMutation = useMutation(
427
- outboxOperationsCreateOutboxMessageMutation(),
428
- );
429
-
430
463
  const sendMutation = useMutation(
431
464
  outboxDetailOperationsSendOutboxMessageMutation(),
432
465
  );
@@ -459,7 +492,11 @@ export const ComposeForm = ({
459
492
  ? accountIsMissingSmtp(selectedAccount)
460
493
  : false;
461
494
 
462
- const isSending = createMutation.isPending || sendMutation.isPending;
495
+ // The action bar refuses a second press while one is in flight, but the
496
+ // editor's own Cmd+Enter goes straight to `handleSend`, and the write that
497
+ // now precedes the request widens the window a second press lands in.
498
+ const sendInFlightRef = useRef(false);
499
+ const [isSending, setIsSending] = useState(false);
463
500
  const canSend =
464
501
  toAddresses.length > 0 &&
465
502
  !!selectedAccountId &&
@@ -475,8 +512,7 @@ export const ComposeForm = ({
475
512
  if (isFormEmpty(toAddresses, ccAddresses, bccAddresses, subject, body))
476
513
  return;
477
514
 
478
- const textBody = plateValueToText(body);
479
- const htmlBody = plateValueToHtml(body);
515
+ const { html: htmlBody, text: textBody } = body;
480
516
 
481
517
  saveDraft({
482
518
  accountId: selectedAccountId,
@@ -502,77 +538,79 @@ export const ComposeForm = ({
502
538
  ]);
503
539
 
504
540
  const handleSend = useCallback(async () => {
541
+ if (sendInFlightRef.current) return;
505
542
  if (!selectedAccountId || toAddresses.length === 0) return;
506
543
 
507
- stopAutoSave();
508
-
509
- const replyData =
510
- sourceMessage && (mode === "reply" || mode === "reply_all")
511
- ? getReferences(sourceMessage)
512
- : {};
544
+ sendInFlightRef.current = true;
545
+ setIsSending(true);
546
+ try {
547
+ stopAutoSave();
548
+
549
+ const replyData =
550
+ sourceMessage && (mode === "reply" || mode === "reply_all")
551
+ ? getReferences(sourceMessage)
552
+ : {};
553
+
554
+ const { html: htmlBody, text: textBody } = body;
555
+ const createdThisAttempt = !outboxMessageId;
556
+
557
+ // The debounce dropped above may have been holding the last two seconds
558
+ // of typing, and an existing entry would otherwise go out as the server
559
+ // last saw it (#674). What is on screen is written first, and a write
560
+ // that fails stops the send rather than transmitting the older copy.
561
+ const flushed = await saveImmediately({
562
+ accountId: selectedAccountId,
563
+ toAddresses: toAddresses.map((a) => a.email),
564
+ ccAddresses:
565
+ ccAddresses.length > 0 ? ccAddresses.map((a) => a.email) : undefined,
566
+ bccAddresses:
567
+ bccAddresses.length > 0
568
+ ? bccAddresses.map((a) => a.email)
569
+ : undefined,
570
+ subject: subject || undefined,
571
+ textBody: textBody || undefined,
572
+ htmlBody: htmlBody || undefined,
573
+ ...replyData,
574
+ });
513
575
 
514
- let messageId = outboxMessageId;
515
- let createdThisAttempt = false;
576
+ if (flushed.outcome === "failed") {
577
+ pushError({
578
+ title: "Couldn't send message",
579
+ detail:
580
+ formatErrorDetail(flushed.error) ??
581
+ "Saving the message failed, so nothing was sent. Try again.",
582
+ error: flushed.error,
583
+ });
584
+ return;
585
+ }
516
586
 
517
- if (!messageId) {
518
- const textBody = plateValueToText(body);
519
- const htmlBody = plateValueToHtml(body);
587
+ const messageId = flushed.outboxMessageId;
520
588
 
521
- const outboxMessage = await createMutation
589
+ const sent = await sendMutation
522
590
  .mutateAsync({
523
- body: {
524
- accountId: selectedAccountId,
525
- toAddresses: toAddresses.map((a) => a.email),
526
- ccAddresses:
527
- ccAddresses.length > 0
528
- ? ccAddresses.map((a) => a.email)
529
- : undefined,
530
- bccAddresses:
531
- bccAddresses.length > 0
532
- ? bccAddresses.map((a) => a.email)
533
- : undefined,
534
- subject: subject || undefined,
535
- textBody: textBody || undefined,
536
- htmlBody: htmlBody || undefined,
537
- sendImmediately: false,
538
- ...replyData,
539
- },
591
+ path: { outboxMessageId: messageId },
540
592
  })
541
593
  .catch((error: unknown) => {
542
594
  pushError({
543
595
  title: "Couldn't send message",
544
- detail: formatErrorDetail(error) ?? "Saving the draft failed.",
596
+ detail:
597
+ formatErrorDetail(error) ??
598
+ (createdThisAttempt
599
+ ? "The draft was saved but the send request failed. Try again from the Outbox."
600
+ : "The send request failed. Try again."),
545
601
  error,
546
602
  });
547
603
  return null;
548
604
  });
549
- if (outboxMessage === null) return;
550
- messageId = outboxMessage.outboxMessageId;
551
- createdThisAttempt = true;
552
- setOutboxMessageId(messageId);
605
+ if (sent === null) return;
606
+
607
+ stopAutoSave(messageId);
608
+ startSendPolling(messageId);
609
+ onClose();
610
+ } finally {
611
+ sendInFlightRef.current = false;
612
+ setIsSending(false);
553
613
  }
554
-
555
- const sent = await sendMutation
556
- .mutateAsync({
557
- path: { outboxMessageId: messageId },
558
- })
559
- .catch((error: unknown) => {
560
- pushError({
561
- title: "Couldn't send message",
562
- detail:
563
- formatErrorDetail(error) ??
564
- (createdThisAttempt
565
- ? "The draft was saved but the send request failed. Try again from the Outbox."
566
- : "The send request failed. Try again."),
567
- error,
568
- });
569
- return null;
570
- });
571
- if (sent === null) return;
572
-
573
- stopAutoSave(messageId);
574
- startSendPolling(messageId);
575
- onClose();
576
614
  }, [
577
615
  selectedAccountId,
578
616
  toAddresses,
@@ -583,11 +621,10 @@ export const ComposeForm = ({
583
621
  mode,
584
622
  sourceMessage,
585
623
  outboxMessageId,
586
- createMutation,
624
+ saveImmediately,
587
625
  sendMutation,
588
626
  stopAutoSave,
589
627
  startSendPolling,
590
- setOutboxMessageId,
591
628
  pushError,
592
629
  onClose,
593
630
  ]);
@@ -665,7 +702,8 @@ export const ComposeForm = ({
665
702
  >
666
703
  <Suspense fallback={<ComposeBodyFallback />}>
667
704
  <LazyComposeBody
668
- value={body}
705
+ key={documentGeneration}
706
+ initialHtml={initialHtml}
669
707
  onChange={setBody}
670
708
  onSubmit={handleSend}
671
709
  autoFocus={mode === "new"}
@@ -27,7 +27,7 @@ const useIsDraftDirty = (): (() => boolean) => {
27
27
  const subject =
28
28
  document.querySelector<HTMLInputElement>("[data-subject-field]")?.value ??
29
29
  "";
30
- const editorEl = document.querySelector("[data-slate-editor]");
30
+ const editorEl = document.querySelector('[data-testid="compose-body"]');
31
31
  const bodyText = editorEl?.textContent ?? "";
32
32
  // Strip signature separator "-- " to avoid false positives
33
33
  const cleaned = bodyText.replace(/--\s*/g, "").trim();
@@ -14,6 +14,12 @@
14
14
  * then sends it — and creating it as already-queued makes that send a second
15
15
  * dispatch, which the server refuses.
16
16
  *
17
+ * Issue #674 is the boundary between the two: pressing Send inside the two
18
+ * seconds the debounce is still counting down. The cancelled timer was carrying
19
+ * the last edits, and the entry went out as the server last saw it. So the send
20
+ * writes what is on screen first, and a write that fails takes the send with it
21
+ * rather than transmitting the older copy behind the user's back.
22
+ *
17
23
  * The stub holds the outbox rule the API holds: only a draft accepts a send.
18
24
  */
19
25
 
@@ -81,8 +87,18 @@ afterEach(() => {
81
87
  const callsTo = (suffix: string) =>
82
88
  (http?.calls ?? []).filter((call) => call.path.endsWith(suffix));
83
89
 
84
- const patchCount = (): number =>
85
- (http?.calls ?? []).filter((call) => call.method === "PATCH").length;
90
+ const patches = () =>
91
+ (http?.calls ?? []).filter((call) => call.method === "PATCH");
92
+
93
+ const patchCount = (): number => patches().length;
94
+
95
+ const patchesAfterTheSend = (): number => {
96
+ const calls = http?.calls ?? [];
97
+ const sendIndex = calls.findIndex((call) => call.path.endsWith("/send"));
98
+ if (sendIndex === -1) throw new Error("the send never went out");
99
+ return calls.slice(sendIndex + 1).filter((call) => call.method === "PATCH")
100
+ .length;
101
+ };
86
102
 
87
103
  const Opened = ({ outboxMessageId }: { outboxMessageId?: string }) => {
88
104
  const { state, openCompose } = useCompose();
@@ -108,6 +124,8 @@ interface MountOptions {
108
124
  outboxMessageId?: string;
109
125
  /** Hold the PATCH response open so a save is still in flight at send time. */
110
126
  gatePatch?: boolean;
127
+ /** Refuse every PATCH, so the write compose makes before sending fails. */
128
+ failPatch?: boolean;
111
129
  }
112
130
 
113
131
  const mount = async (
@@ -140,6 +158,7 @@ const mount = async (
140
158
 
141
159
  if (call.method === "PATCH") {
142
160
  await patchGate;
161
+ if (options.failPatch) return httpError(409, "The draft moved on.");
143
162
  return outboxEntry(status);
144
163
  }
145
164
 
@@ -172,7 +191,7 @@ const sendButton = (): HTMLElement => {
172
191
  return button;
173
192
  };
174
193
 
175
- describe("compose and the outbox entry it is sending (#604)", () => {
194
+ describe("compose and the outbox entry it is sending (#604, #674)", () => {
176
195
  it("writes no further PATCH to an entry once it has been sent", async () => {
177
196
  const { releasePatch } = await mount({
178
197
  outboxMessageId: OUTBOX_MESSAGE_ID,
@@ -193,7 +212,76 @@ describe("compose and the outbox entry it is sending (#604)", () => {
193
212
  await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 200);
194
213
 
195
214
  assert.equal(callsTo("/send").length, 1, "the send went out");
196
- assert.equal(patchCount(), 1, "no autosave PATCH followed the send");
215
+ assert.equal(patchesAfterTheSend(), 0, "no write followed the send");
216
+ // The in-flight autosave and the write before the send are the two, in
217
+ // that order: a second write issued alongside the first could land behind
218
+ // it and put the older body back.
219
+ assert.equal(
220
+ patchCount(),
221
+ 2,
222
+ "the two writes went out one after the other",
223
+ );
224
+ });
225
+
226
+ it("writes the edit made inside the debounce window before it sends", async () => {
227
+ await mount({ outboxMessageId: OUTBOX_MESSAGE_ID });
228
+
229
+ harness?.type(subjectField(), "Re: Lunch tomorrow");
230
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 100);
231
+ assert.equal(patchCount(), 1, "the typing burst autosaves once");
232
+
233
+ // Inside the two seconds the next autosave would have waited.
234
+ harness?.type(subjectField(), "Re: Lunch on Thursday");
235
+ harness?.click(sendButton());
236
+ await harness?.flush();
237
+ await harness?.wait(100);
238
+
239
+ assert.equal(callsTo("/send").length, 1, "the send went out");
240
+ const written = patches().at(-1);
241
+ assert.equal(
242
+ written?.body?.subject,
243
+ "Re: Lunch on Thursday",
244
+ "the entry carries the edit the debounce was still holding",
245
+ );
246
+ // Autosave never writes these, so an entry autosaved before Send used to
247
+ // go out unchained from the message it replies to.
248
+ assert.equal(written?.body?.inReplyTo, "<m1@example.com>");
249
+ assert.deepEqual(written?.body?.references, ["<m1@example.com>"]);
250
+ assert.equal(closed, 1, "compose closed on a successful send");
251
+ });
252
+
253
+ it("sends nothing, and says so, when that write fails", async () => {
254
+ await mount({ outboxMessageId: OUTBOX_MESSAGE_ID, failPatch: true });
255
+
256
+ harness?.type(subjectField(), "Re: Lunch on Thursday");
257
+ harness?.click(sendButton());
258
+ await harness?.flush();
259
+ await harness?.wait(100);
260
+
261
+ assert.equal(patchCount(), 1, "the write was attempted");
262
+ assert.equal(callsTo("/send").length, 0, "the older copy stayed put");
263
+ assert.equal(closed, 0, "compose stayed open");
264
+ assert.match(harness?.text() ?? "", /Couldn't send message/);
265
+ });
266
+
267
+ it("dispatches once when Send is pressed twice over the write", async () => {
268
+ const { releasePatch } = await mount({
269
+ outboxMessageId: OUTBOX_MESSAGE_ID,
270
+ gatePatch: true,
271
+ });
272
+
273
+ harness?.type(subjectField(), "Re: Lunch on Thursday");
274
+ harness?.click(sendButton());
275
+ harness?.click(sendButton());
276
+ await harness?.flush();
277
+
278
+ releasePatch();
279
+ await harness?.flush();
280
+ await harness?.wait(100);
281
+
282
+ assert.equal(patchCount(), 1, "the entry was written once");
283
+ assert.equal(callsTo("/send").length, 1, "the message went out once");
284
+ assert.equal(closed, 1, "compose closed on a successful send");
197
285
  });
198
286
 
199
287
  it("sends a reply pressed before the first autosave, in one dispatch", async () => {
@@ -8,6 +8,10 @@ import { useCallback, useRef, useState } from "react";
8
8
 
9
9
  export type SaveStatus = "idle" | "saving" | "saved" | "error";
10
10
 
11
+ export type ImmediateSave =
12
+ | { outcome: "saved"; outboxMessageId: string }
13
+ | { outcome: "failed"; error: unknown };
14
+
11
15
  interface DraftData {
12
16
  accountId: string;
13
17
  toAddresses: string[];
@@ -25,6 +29,12 @@ interface UseSaveDraftOptions {
25
29
  onDraftCreated: (id: string) => void;
26
30
  }
27
31
 
32
+ const settled = (promise: Promise<unknown>): Promise<void> =>
33
+ promise.then(
34
+ () => undefined,
35
+ () => undefined,
36
+ );
37
+
28
38
  export const useSaveDraft = ({
29
39
  outboxMessageId,
30
40
  onDraftCreated,
@@ -35,6 +45,17 @@ export const useSaveDraft = ({
35
45
  const closedIdsRef = useRef<Set<string>>(new Set());
36
46
  const queryClient = useQueryClient();
37
47
 
48
+ // The entry a save writes to. That is the prop, except between a save
49
+ // creating the draft and the id arriving back as the prop — reading the prop
50
+ // in that window creates the same draft a second time and strands one of the
51
+ // two in the outbox.
52
+ const propIdRef = useRef(outboxMessageId);
53
+ const targetIdRef = useRef(outboxMessageId);
54
+ if (propIdRef.current !== outboxMessageId) {
55
+ propIdRef.current = outboxMessageId;
56
+ targetIdRef.current = outboxMessageId;
57
+ }
58
+
38
59
  const createMutation = useMutation(
39
60
  outboxOperationsCreateOutboxMessageMutation(),
40
61
  );
@@ -47,9 +68,10 @@ export const useSaveDraft = ({
47
68
  setSaveStatus("saving");
48
69
  setSaveError(null);
49
70
 
50
- if (outboxMessageId) {
71
+ const targetId = targetIdRef.current;
72
+ if (targetId) {
51
73
  const result = await updateMutation.mutateAsync({
52
- path: { outboxMessageId },
74
+ path: { outboxMessageId: targetId },
53
75
  body: {
54
76
  toAddresses: data.toAddresses,
55
77
  ccAddresses: data.ccAddresses,
@@ -71,6 +93,7 @@ export const useSaveDraft = ({
71
93
  sendImmediately: false,
72
94
  },
73
95
  });
96
+ targetIdRef.current = result.outboxMessageId;
74
97
  onDraftCreated(result.outboxMessageId);
75
98
  setSaveStatus("saved");
76
99
  queryClient.invalidateQueries({
@@ -78,38 +101,60 @@ export const useSaveDraft = ({
78
101
  });
79
102
  return result;
80
103
  },
81
- [
82
- outboxMessageId,
83
- createMutation,
84
- updateMutation,
85
- onDraftCreated,
86
- queryClient,
87
- ],
104
+ [createMutation, updateMutation, onDraftCreated, queryClient],
105
+ );
106
+
107
+ // One entry takes one write at a time. Overlapping writes settle in whatever
108
+ // order the network gives them, so an older body can land last — and two of
109
+ // them racing while the draft has no id yet each create one.
110
+ const writesRef = useRef<Promise<void>>(Promise.resolve());
111
+ const enqueueSave = useCallback(
112
+ (data: DraftData) => {
113
+ const write = writesRef.current.then(() => executeSave(data));
114
+ writesRef.current = settled(write);
115
+ return write;
116
+ },
117
+ [executeSave],
88
118
  );
89
119
 
90
120
  const saveDraft = useCallback(
91
121
  (data: DraftData) => {
92
- if (outboxMessageId && closedIdsRef.current.has(outboxMessageId)) return;
93
122
  if (timerRef.current) clearTimeout(timerRef.current);
94
123
  timerRef.current = setTimeout(() => {
124
+ const targetId = targetIdRef.current;
125
+ if (targetId && closedIdsRef.current.has(targetId)) return;
95
126
  // Keep the real error, not just a vague "error" status — the caller
96
127
  // surfaces its detail in a banner. A fatal 5xx additionally escalates
97
128
  // through the global MutationCache.onError sink.
98
- executeSave(data).catch((error: unknown) => {
129
+ enqueueSave(data).catch((error: unknown) => {
99
130
  setSaveError(error);
100
131
  setSaveStatus("error");
101
132
  });
102
133
  }, 2000);
103
134
  },
104
- [executeSave, outboxMessageId],
135
+ [enqueueSave],
105
136
  );
106
137
 
138
+ // Whoever asks for this is acting on the draft right now and owns the
139
+ // outcome, so the failure is returned rather than thrown and `saveError` is
140
+ // left alone — the caller's own message is the accurate one, and setting
141
+ // `saveError` would raise a second "Couldn't save draft" banner beside it.
107
142
  const saveImmediately = useCallback(
108
- (data: DraftData) => {
143
+ (data: DraftData): Promise<ImmediateSave> => {
109
144
  if (timerRef.current) clearTimeout(timerRef.current);
110
- return executeSave(data);
145
+ return enqueueSave(data)
146
+ .then(
147
+ (result): ImmediateSave => ({
148
+ outcome: "saved",
149
+ outboxMessageId: result.outboxMessageId,
150
+ }),
151
+ )
152
+ .catch((error: unknown): ImmediateSave => {
153
+ setSaveStatus("error");
154
+ return { outcome: "failed", error };
155
+ });
111
156
  },
112
- [executeSave],
157
+ [enqueueSave],
113
158
  );
114
159
 
115
160
  // Called with an id, the entry is closed to autosave for good. Sending and
@@ -1,86 +0,0 @@
1
- import { AutoformatPlugin } from "@platejs/autoformat";
2
- import {
3
- BlockquotePlugin,
4
- BoldPlugin,
5
- ItalicPlugin,
6
- } from "@platejs/basic-nodes/react";
7
- import { LinkPlugin } from "@platejs/link/react";
8
- import type { Value } from "platejs";
9
- import { PlateContent, usePlateEditor } from "platejs/react";
10
- import { useEffect } from "react";
11
-
12
- export interface PlateEditorProps {
13
- initialValue?: Value;
14
- onChange?: (value: Value) => void;
15
- onSubmit?: () => void;
16
- autoFocus?: boolean;
17
- }
18
-
19
- const EMPTY_VALUE: Value = [{ type: "p", children: [{ text: "" }] }];
20
-
21
- export const COMPOSE_PLUGINS = [
22
- BoldPlugin,
23
- ItalicPlugin,
24
- LinkPlugin,
25
- BlockquotePlugin,
26
- AutoformatPlugin.configure({
27
- options: {
28
- rules: [
29
- {
30
- match: "**",
31
- mode: "mark" as const,
32
- type: "bold",
33
- },
34
- {
35
- match: "*",
36
- mode: "mark" as const,
37
- type: "italic",
38
- },
39
- {
40
- match: "> ",
41
- mode: "block" as const,
42
- type: "blockquote",
43
- },
44
- ],
45
- enableUndoOnDelete: true,
46
- },
47
- }),
48
- ];
49
-
50
- export const usePlateComposeEditor = (initialValue?: Value) =>
51
- usePlateEditor({
52
- plugins: COMPOSE_PLUGINS,
53
- value: initialValue ?? EMPTY_VALUE,
54
- });
55
-
56
- export const PlateEditorContent = ({
57
- onSubmit,
58
- autoFocus,
59
- editor,
60
- }: Omit<PlateEditorProps, "initialValue" | "onChange"> & {
61
- editor: ReturnType<typeof usePlateComposeEditor>;
62
- }) => {
63
- useEffect(() => {
64
- if (autoFocus) {
65
- const timer = setTimeout(() => {
66
- editor.tf.focus();
67
- }, 0);
68
- return () => clearTimeout(timer);
69
- }
70
- }, [autoFocus, editor]);
71
-
72
- const handleKeyDown = (e: React.KeyboardEvent) => {
73
- if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && onSubmit) {
74
- e.preventDefault();
75
- onSubmit();
76
- }
77
- };
78
-
79
- return (
80
- <PlateContent
81
- className="w-full px-3 py-2 bg-canvas text-sm outline-none min-h-[120px] [&_blockquote]:pl-3 [&_blockquote]:border-l-2 [&_blockquote]:border-fg-subtle/30 [&_blockquote]:text-fg-muted [&_a]:text-accent [&_a]:underline"
82
- placeholder="Write your message..."
83
- onKeyDown={handleKeyDown}
84
- />
85
- );
86
- };
@@ -1,110 +0,0 @@
1
- import {
2
- BlockquotePlugin,
3
- BoldPlugin,
4
- ItalicPlugin,
5
- } from "@platejs/basic-nodes/react";
6
- import { insertLink } from "@platejs/link";
7
- import { Bold, Italic, Link, Quote, Redo2, Undo2 } from "lucide-react";
8
- import { useEditorRef, useEditorSelector } from "platejs/react";
9
-
10
- const ToolbarButton = ({
11
- isActive,
12
- onClick,
13
- children,
14
- title,
15
- }: {
16
- isActive: boolean;
17
- onClick: () => void;
18
- children: React.ReactNode;
19
- title: string;
20
- }) => (
21
- <button
22
- type="button"
23
- onMouseDown={(e) => {
24
- e.preventDefault();
25
- onClick();
26
- }}
27
- title={title}
28
- className={`p-1.5 rounded transition-colors ${
29
- isActive
30
- ? "text-fg bg-accent-2-soft"
31
- : "text-fg-muted hover:text-fg hover:bg-surface-raised"
32
- }`}
33
- >
34
- {children}
35
- </button>
36
- );
37
-
38
- export const PlateToolbar = () => {
39
- const editor = useEditorRef();
40
-
41
- const isBoldActive = useEditorSelector(
42
- (editor) => !!editor.api.mark(BoldPlugin.key),
43
- [],
44
- );
45
- const isItalicActive = useEditorSelector(
46
- (editor) => !!editor.api.mark(ItalicPlugin.key),
47
- [],
48
- );
49
- const isBlockquoteActive = useEditorSelector((editor) => {
50
- const entry = editor.api.block();
51
- return entry ? entry[0].type === BlockquotePlugin.key : false;
52
- }, []);
53
-
54
- const canUndo = useEditorSelector(
55
- (editor) => editor.history.undos.length > 0,
56
- [],
57
- );
58
- const canRedo = useEditorSelector(
59
- (editor) => editor.history.redos.length > 0,
60
- [],
61
- );
62
-
63
- return (
64
- <div className="flex items-center gap-0.5 px-3 py-1 border-b border-line">
65
- <ToolbarButton
66
- isActive={isBoldActive}
67
- onClick={() => editor.tf.toggleMark(BoldPlugin.key)}
68
- title="Bold (Ctrl+B)"
69
- >
70
- <Bold className="size-4" />
71
- </ToolbarButton>
72
- <ToolbarButton
73
- isActive={isItalicActive}
74
- onClick={() => editor.tf.toggleMark(ItalicPlugin.key)}
75
- title="Italic (Ctrl+I)"
76
- >
77
- <Italic className="size-4" />
78
- </ToolbarButton>
79
- <ToolbarButton
80
- isActive={false}
81
- onClick={() => insertLink(editor, { url: "" })}
82
- title="Link (Ctrl+K)"
83
- >
84
- <Link className="size-4" />
85
- </ToolbarButton>
86
- <ToolbarButton
87
- isActive={isBlockquoteActive}
88
- onClick={() => editor.tf.toggleBlock(BlockquotePlugin.key)}
89
- title="Blockquote"
90
- >
91
- <Quote className="size-4" />
92
- </ToolbarButton>
93
- <div className="mx-1.5 h-4 w-px bg-line" />
94
- <ToolbarButton
95
- isActive={false}
96
- onClick={() => editor.undo()}
97
- title="Undo (Ctrl+Z)"
98
- >
99
- <Undo2 className={`size-4 ${!canUndo ? "opacity-40" : ""}`} />
100
- </ToolbarButton>
101
- <ToolbarButton
102
- isActive={false}
103
- onClick={() => editor.redo()}
104
- title="Redo (Ctrl+Y)"
105
- >
106
- <Redo2 className={`size-4 ${!canRedo ? "opacity-40" : ""}`} />
107
- </ToolbarButton>
108
- </div>
109
- );
110
- };
@@ -1,47 +0,0 @@
1
- import DOMPurify from "dompurify";
2
-
3
- const QUOTE_ALLOWED_TAGS = [
4
- "p",
5
- "br",
6
- "strong",
7
- "b",
8
- "em",
9
- "i",
10
- "a",
11
- "blockquote",
12
- "ul",
13
- "ol",
14
- "li",
15
- ];
16
-
17
- const QUOTE_ALLOWED_ATTR = ["href"];
18
-
19
- /**
20
- * Quoted mail renders in the app's own document rather than in the reading
21
- * pane's sandboxed frame, so a link in it would otherwise navigate the app
22
- * window itself to wherever the sender points. Launched from a home screen
23
- * there is no address bar and no back button to return from that, so quoted
24
- * links leave for a separate context and take no handle on this one with them.
25
- *
26
- * Its own DOMPurify instance: the hook below must not reach the default
27
- * instance any other caller might use.
28
- */
29
- let purifier: ReturnType<typeof DOMPurify> | null = null;
30
-
31
- const quotePurifier = (): ReturnType<typeof DOMPurify> => {
32
- if (purifier) return purifier;
33
- const instance = DOMPurify();
34
- instance.addHook("afterSanitizeAttributes", (node) => {
35
- if (node.tagName !== "A") return;
36
- node.setAttribute("target", "_blank");
37
- node.setAttribute("rel", "noopener noreferrer nofollow");
38
- });
39
- purifier = instance;
40
- return instance;
41
- };
42
-
43
- export const sanitizeQuoteHtml = (html: string): string =>
44
- quotePurifier().sanitize(html, {
45
- ALLOWED_TAGS: QUOTE_ALLOWED_TAGS,
46
- ALLOWED_ATTR: QUOTE_ALLOWED_ATTR,
47
- });
@@ -1,52 +0,0 @@
1
- import type { TElement, TText, Value } from "platejs";
2
-
3
- type PlateNode = TElement | TText;
4
-
5
- const isText = (node: PlateNode): node is TText =>
6
- "text" in node && !("children" in node);
7
-
8
- const escapeHtml = (text: string): string =>
9
- text
10
- .replace(/&/g, "&amp;")
11
- .replace(/</g, "&lt;")
12
- .replace(/>/g, "&gt;")
13
- .replace(/"/g, "&quot;");
14
-
15
- const serializeTextNode = (node: TText): string => {
16
- let html = escapeHtml(node.text);
17
- if (node.bold) html = `<strong>${html}</strong>`;
18
- if (node.italic) html = `<em>${html}</em>`;
19
- return html;
20
- };
21
-
22
- const serializeElement = (node: TElement): string => {
23
- const children = node.children
24
- .map((child) =>
25
- isText(child as PlateNode)
26
- ? serializeTextNode(child as TText)
27
- : serializeElement(child as TElement),
28
- )
29
- .join("");
30
-
31
- switch (node.type) {
32
- case "blockquote":
33
- return `<blockquote>${children}</blockquote>`;
34
- case "a": {
35
- const url = (node as TElement & { url?: string }).url ?? "";
36
- return `<a href="${escapeHtml(url)}">${children}</a>`;
37
- }
38
- default:
39
- return `<p>${children}</p>`;
40
- }
41
- };
42
-
43
- export const plateValueToHtml = (value: Value): string =>
44
- value.map(serializeElement).join("");
45
-
46
- const extractText = (node: PlateNode): string => {
47
- if (isText(node)) return node.text;
48
- return (node.children as PlateNode[]).map(extractText).join("");
49
- };
50
-
51
- export const plateValueToText = (value: Value): string =>
52
- value.map(extractText).join("\n");