@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 +1 -5
- package/src/components/compose/ComposeBody.tsx +12 -26
- package/src/components/compose/ComposeForm.tsx +139 -101
- package/src/components/compose/MobileComposeSheet.tsx +1 -1
- package/src/components/compose/compose-send-stops-autosave.render.test.ts +92 -4
- package/src/hooks/useSaveDraft.ts +60 -15
- package/src/components/compose/PlateEditor.tsx +0 -86
- package/src/components/compose/PlateToolbar.tsx +0 -110
- package/src/components/compose/sanitize-quote-html.ts +0 -47
- package/src/lib/plate-serializer.ts +0 -52
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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
|
|
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
|
-
|
|
8
|
-
onChange: (value:
|
|
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
|
-
|
|
11
|
+
initialHtml,
|
|
15
12
|
onChange,
|
|
16
13
|
onSubmit,
|
|
17
14
|
autoFocus,
|
|
18
|
-
}: ComposeBodyProps) =>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 {
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
67
|
+
const escapeHtml = (text: string): string =>
|
|
68
|
+
text
|
|
69
|
+
.replace(/&/g, "&")
|
|
70
|
+
.replace(/</g, "<")
|
|
71
|
+
.replace(/>/g, ">")
|
|
72
|
+
.replace(/"/g, """);
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
515
|
-
|
|
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
|
-
|
|
518
|
-
const textBody = plateValueToText(body);
|
|
519
|
-
const htmlBody = plateValueToHtml(body);
|
|
587
|
+
const messageId = flushed.outboxMessageId;
|
|
520
588
|
|
|
521
|
-
const
|
|
589
|
+
const sent = await sendMutation
|
|
522
590
|
.mutateAsync({
|
|
523
|
-
|
|
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:
|
|
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 (
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
85
|
-
(http?.calls ?? []).filter((call) => call.method === "PATCH")
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
129
|
+
enqueueSave(data).catch((error: unknown) => {
|
|
99
130
|
setSaveError(error);
|
|
100
131
|
setSaveStatus("error");
|
|
101
132
|
});
|
|
102
133
|
}, 2000);
|
|
103
134
|
},
|
|
104
|
-
[
|
|
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
|
|
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
|
-
[
|
|
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, "&")
|
|
11
|
-
.replace(/</g, "<")
|
|
12
|
-
.replace(/>/g, ">")
|
|
13
|
-
.replace(/"/g, """);
|
|
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");
|