@remit/web-client 0.0.132 → 0.0.134
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 +72 -36
- package/src/components/compose/MobileComposeSheet.tsx +1 -1
- package/src/components/mail/IntelligencePane.render.test.ts +134 -0
- package/src/hooks/useReportSpam.escalation.test.ts +135 -0
- package/src/test-support/dom.ts +16 -6
- 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/hooks/useReportSpam.integration.test.ts +0 -93
- 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.134",
|
|
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
|
+
);
|
|
@@ -9,9 +9,15 @@ import type {
|
|
|
9
9
|
RemitImapAccountResponse,
|
|
10
10
|
RemitImapDescribeMessageResponse,
|
|
11
11
|
} from "@remit/api-http-client/types.gen.ts";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
ComposeActionBar,
|
|
14
|
+
ComposeFormShell,
|
|
15
|
+
EMPTY_RICH_TEXT,
|
|
16
|
+
QuotedText,
|
|
17
|
+
type RichTextValue,
|
|
18
|
+
sanitizeQuotedHtml,
|
|
19
|
+
} from "@remit/ui";
|
|
13
20
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
14
|
-
import type { Value } from "platejs";
|
|
15
21
|
import {
|
|
16
22
|
lazy,
|
|
17
23
|
Suspense,
|
|
@@ -23,10 +29,6 @@ import {
|
|
|
23
29
|
import { useMessageBodyContent } from "../../hooks/useMessageBodyContent";
|
|
24
30
|
import { useSaveDraft } from "../../hooks/useSaveDraft";
|
|
25
31
|
import { useSignature } from "../../hooks/useSignature.js";
|
|
26
|
-
import {
|
|
27
|
-
plateValueToHtml,
|
|
28
|
-
plateValueToText,
|
|
29
|
-
} from "../../lib/plate-serializer.js";
|
|
30
32
|
import { accountIsMissingSmtp } from "../settings/account-form-helpers.js";
|
|
31
33
|
import { useErrorBanners } from "../ui/ErrorBannerProvider.js";
|
|
32
34
|
import {
|
|
@@ -54,7 +56,6 @@ import type { ComposeMode } from "./ComposeProvider";
|
|
|
54
56
|
import { useCompose } from "./ComposeProvider";
|
|
55
57
|
import { FromSelector } from "./FromSelector";
|
|
56
58
|
import { SubjectField } from "./SubjectField";
|
|
57
|
-
import { sanitizeQuoteHtml } from "./sanitize-quote-html.js";
|
|
58
59
|
|
|
59
60
|
interface ComposeFormProps {
|
|
60
61
|
mode: ComposeMode;
|
|
@@ -64,19 +65,22 @@ interface ComposeFormProps {
|
|
|
64
65
|
onAccountChange?: (account: RemitImapAccountResponse) => void;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
68
|
+
const escapeHtml = (text: string): string =>
|
|
69
|
+
text
|
|
70
|
+
.replace(/&/g, "&")
|
|
71
|
+
.replace(/</g, "<")
|
|
72
|
+
.replace(/>/g, ">")
|
|
73
|
+
.replace(/"/g, """);
|
|
74
|
+
|
|
75
|
+
const textToHtml = (text: string): string =>
|
|
76
|
+
text
|
|
77
|
+
.split("\n")
|
|
78
|
+
.map((line) => `<p>${escapeHtml(line)}</p>`)
|
|
79
|
+
.join("");
|
|
80
|
+
|
|
81
|
+
const buildInitialHtml = (signaturePlainText: string): string => {
|
|
82
|
+
if (!signaturePlainText) return "";
|
|
83
|
+
return `<p></p><p>-- </p>${textToHtml(signaturePlainText)}`;
|
|
80
84
|
};
|
|
81
85
|
|
|
82
86
|
const buildReplySubject = (subject?: string): string => {
|
|
@@ -149,13 +153,13 @@ const isFormEmpty = (
|
|
|
149
153
|
ccAddresses: AddressEntry[],
|
|
150
154
|
bccAddresses: AddressEntry[],
|
|
151
155
|
subject: string,
|
|
152
|
-
body:
|
|
156
|
+
body: RichTextValue,
|
|
153
157
|
): boolean =>
|
|
154
158
|
toAddresses.length === 0 &&
|
|
155
159
|
ccAddresses.length === 0 &&
|
|
156
160
|
bccAddresses.length === 0 &&
|
|
157
161
|
subject.trim() === "" &&
|
|
158
|
-
|
|
162
|
+
body.text.trim() === "";
|
|
159
163
|
|
|
160
164
|
// ---------------------------------------------------------------------------
|
|
161
165
|
// ComposeHeader — collapsed on mobile when the software keyboard is open
|
|
@@ -310,24 +314,41 @@ export const ComposeForm = ({
|
|
|
310
314
|
// changes while compose is already open). Without this, the previous draft's
|
|
311
315
|
// fields stay visible and the new draft never loads because draftLoaded
|
|
312
316
|
// remains true from the prior session (#536).
|
|
317
|
+
//
|
|
318
|
+
// A first autosave also sets the id, from nothing to the draft it just
|
|
319
|
+
// created. That is this session's own content arriving back, not a switch to
|
|
320
|
+
// somebody else's document, and blanking the form there would throw away
|
|
321
|
+
// whatever is being typed.
|
|
313
322
|
useEffect(() => {
|
|
314
|
-
|
|
323
|
+
const previous = prevOutboxMessageIdRef.current;
|
|
324
|
+
if (previous === outboxMessageId) return;
|
|
315
325
|
prevOutboxMessageIdRef.current = outboxMessageId;
|
|
316
|
-
if (!outboxMessageId) return;
|
|
326
|
+
if (!outboxMessageId || previous === undefined) return;
|
|
317
327
|
setToAddresses([]);
|
|
318
328
|
setCcAddresses([]);
|
|
319
329
|
setBccAddresses([]);
|
|
320
330
|
setSubject("");
|
|
321
331
|
setShowCc(false);
|
|
322
332
|
setShowBcc(false);
|
|
323
|
-
|
|
333
|
+
setInitialHtml("");
|
|
334
|
+
setBody(EMPTY_RICH_TEXT);
|
|
335
|
+
setDocumentGeneration((generation) => generation + 1);
|
|
324
336
|
setDraftLoaded(false);
|
|
325
337
|
}, [outboxMessageId]);
|
|
326
338
|
|
|
327
339
|
const { signature } = useSignature(selectedAccountId);
|
|
328
|
-
|
|
329
|
-
|
|
340
|
+
// The editor reads its document once, so this is the document it opens on,
|
|
341
|
+
// not the live value, and it is remounted when the generation changes. Only
|
|
342
|
+
// loading a different document bumps that — remounting mid-compose would take
|
|
343
|
+
// the caret, the focus and the undo history with it.
|
|
344
|
+
const [documentGeneration, setDocumentGeneration] = useState(0);
|
|
345
|
+
const [initialHtml, setInitialHtml] = useState(() =>
|
|
346
|
+
buildInitialHtml(signature.plainText),
|
|
330
347
|
);
|
|
348
|
+
const [body, setBody] = useState<RichTextValue>(() => ({
|
|
349
|
+
html: buildInitialHtml(signature.plainText),
|
|
350
|
+
text: signature.plainText,
|
|
351
|
+
}));
|
|
331
352
|
|
|
332
353
|
const { data: draftData } = useQuery({
|
|
333
354
|
...outboxDetailOperationsGetOutboxMessageOptions({
|
|
@@ -361,8 +382,13 @@ export const ComposeForm = ({
|
|
|
361
382
|
setShowBcc(true);
|
|
362
383
|
}
|
|
363
384
|
if (draftData.subject) setSubject(draftData.subject);
|
|
364
|
-
|
|
365
|
-
|
|
385
|
+
// A draft stores what would have been sent, so a rich one reopens from its
|
|
386
|
+
// HTML. Only a draft that never had any falls back to its text.
|
|
387
|
+
const loadedHtml =
|
|
388
|
+
draftData.htmlBody || textToHtml(draftData.textBody ?? "");
|
|
389
|
+
setInitialHtml(loadedHtml);
|
|
390
|
+
setBody({ html: loadedHtml, text: draftData.textBody ?? "" });
|
|
391
|
+
setDocumentGeneration((generation) => generation + 1);
|
|
366
392
|
setSelectedAccountId(draftData.accountId);
|
|
367
393
|
setDraftLoaded(true);
|
|
368
394
|
}, [draftData, draftLoaded]);
|
|
@@ -398,16 +424,27 @@ export const ComposeForm = ({
|
|
|
398
424
|
const quotedText = sourceBody?.kind === "text" ? sourceBody.body : "";
|
|
399
425
|
const quotedHtml =
|
|
400
426
|
sourceBody?.kind === "html"
|
|
401
|
-
?
|
|
427
|
+
? sanitizeQuotedHtml(sourceBody.body)
|
|
402
428
|
: undefined;
|
|
403
429
|
|
|
404
430
|
const senderName =
|
|
405
431
|
sourceMessage?.envelope.from[0]?.displayName ??
|
|
406
432
|
sourceMessage?.envelope.from[0]?.normalizedEmail;
|
|
407
433
|
|
|
434
|
+
// The draft this session just created holds what is already on screen, so
|
|
435
|
+
// there is nothing to read back — and reading it back would replace the
|
|
436
|
+
// document under the caret with the server's copy of it.
|
|
437
|
+
const adoptCreatedDraft = useCallback(
|
|
438
|
+
(createdId: string) => {
|
|
439
|
+
setDraftLoaded(true);
|
|
440
|
+
setOutboxMessageId(createdId);
|
|
441
|
+
},
|
|
442
|
+
[setOutboxMessageId],
|
|
443
|
+
);
|
|
444
|
+
|
|
408
445
|
const { saveStatus, saveError, saveDraft, stopAutoSave } = useSaveDraft({
|
|
409
446
|
outboxMessageId,
|
|
410
|
-
onDraftCreated:
|
|
447
|
+
onDraftCreated: adoptCreatedDraft,
|
|
411
448
|
});
|
|
412
449
|
|
|
413
450
|
// Auto-save runs on a debounce, so a failure has no inline call site to
|
|
@@ -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,
|
|
@@ -515,8 +551,7 @@ export const ComposeForm = ({
|
|
|
515
551
|
let createdThisAttempt = false;
|
|
516
552
|
|
|
517
553
|
if (!messageId) {
|
|
518
|
-
const textBody =
|
|
519
|
-
const htmlBody = plateValueToHtml(body);
|
|
554
|
+
const { html: htmlBody, text: textBody } = body;
|
|
520
555
|
|
|
521
556
|
const outboxMessage = await createMutation
|
|
522
557
|
.mutateAsync({
|
|
@@ -665,7 +700,8 @@ export const ComposeForm = ({
|
|
|
665
700
|
>
|
|
666
701
|
<Suspense fallback={<ComposeBodyFallback />}>
|
|
667
702
|
<LazyComposeBody
|
|
668
|
-
|
|
703
|
+
key={documentGeneration}
|
|
704
|
+
initialHtml={initialHtml}
|
|
669
705
|
onChange={setBody}
|
|
670
706
|
onSubmit={handleSend}
|
|
671
707
|
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();
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The spam quick action stayed clickable throughout its own request (issue
|
|
3
|
+
* #648 review): the server dedupes message ids only within a single call, so
|
|
4
|
+
* two clicks fired two separate HTTP requests. For the undo direction this
|
|
5
|
+
* produced a wrong message — concurrent `notSpam` #2 reads
|
|
6
|
+
* `originalMailboxId` before #1 clears it, waits on #1's restore move, and
|
|
7
|
+
* can throw `MoveNotSettledError` on an undo that already succeeded. Wires
|
|
8
|
+
* the real `useReportSpam` hook to the real `IntelligencePanel` (the shape
|
|
9
|
+
* `IntelligencePane.tsx`'s `WiredPanel` uses) and clicks the real button
|
|
10
|
+
* twice to prove the fix: disabled while pending blocks the second request
|
|
11
|
+
* at the DOM level, not just in the hook.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { afterEach, describe, it } from "node:test";
|
|
16
|
+
import type { IntelligenceData } from "@remit/ui";
|
|
17
|
+
import { IntelligencePanel } from "@remit/ui";
|
|
18
|
+
import { createElement } from "react";
|
|
19
|
+
import { useReportSpam } from "@/hooks/useReportSpam";
|
|
20
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
21
|
+
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
22
|
+
|
|
23
|
+
const baseData: IntelligenceData = {
|
|
24
|
+
sender: {
|
|
25
|
+
name: "Alex Rivera",
|
|
26
|
+
email: "alex@example.com",
|
|
27
|
+
trust: "wellknown",
|
|
28
|
+
firstSeenLabel: "Jan 2025",
|
|
29
|
+
},
|
|
30
|
+
authenticity: {
|
|
31
|
+
verdict: "aligned",
|
|
32
|
+
fromDomain: "example.com",
|
|
33
|
+
dkimDomain: "example.com",
|
|
34
|
+
summary: "This message was signed by example.com.",
|
|
35
|
+
},
|
|
36
|
+
category: { value: "Personal" },
|
|
37
|
+
similar: [],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const ReportHarness = () => {
|
|
41
|
+
const { reportSpam, isReporting } = useReportSpam({ mailboxId: "mbx-inbox" });
|
|
42
|
+
return createElement(IntelligencePanel, {
|
|
43
|
+
data: baseData,
|
|
44
|
+
actions: { onReportSpam: () => reportSpam(["msg-1"]) },
|
|
45
|
+
reportSpamPending: isReporting,
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const UndoHarness = () => {
|
|
50
|
+
const { notSpam, isRestoring } = useReportSpam({ mailboxId: "mbx-junk" });
|
|
51
|
+
return createElement(IntelligencePanel, {
|
|
52
|
+
data: baseData,
|
|
53
|
+
actions: { onNotSpam: () => notSpam(["msg-1"]) },
|
|
54
|
+
notSpamPending: isRestoring,
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
let harness: DomHarness | undefined;
|
|
59
|
+
let http: HttpMock;
|
|
60
|
+
|
|
61
|
+
const waitFor = async (
|
|
62
|
+
predicate: () => boolean,
|
|
63
|
+
timeoutMs = 2000,
|
|
64
|
+
): Promise<void> => {
|
|
65
|
+
if (!harness) throw new Error("nothing mounted");
|
|
66
|
+
const deadline = Date.now() + timeoutMs;
|
|
67
|
+
while (!predicate()) {
|
|
68
|
+
if (Date.now() > deadline) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`waitFor: condition never became true within ${timeoutMs}ms`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
await harness.flush();
|
|
74
|
+
await harness.wait(5);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
harness?.close();
|
|
80
|
+
harness = undefined;
|
|
81
|
+
http.restore();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("the spam quick action disables itself for the duration of its own request (#648 review)", () => {
|
|
85
|
+
it("report direction: a second click while the report is in flight sends only one request", async () => {
|
|
86
|
+
let resolveRequest: (() => void) | undefined;
|
|
87
|
+
http = mockFetch(
|
|
88
|
+
() =>
|
|
89
|
+
new Promise((resolve) => {
|
|
90
|
+
resolveRequest = () => resolve({ successCount: 1, failureCount: 0 });
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
harness = createDomHarness();
|
|
94
|
+
harness.renderApp(createElement(ReportHarness));
|
|
95
|
+
|
|
96
|
+
const button = harness.byText("button", "Report spam") as HTMLButtonElement;
|
|
97
|
+
harness.click(button);
|
|
98
|
+
await waitFor(() => button.textContent === "Reporting…");
|
|
99
|
+
|
|
100
|
+
assert.equal(button.disabled, true, "a control mid-request must disable");
|
|
101
|
+
harness.click(button);
|
|
102
|
+
|
|
103
|
+
assert.ok(resolveRequest, "the request never reached the mock");
|
|
104
|
+
resolveRequest?.();
|
|
105
|
+
await waitFor(() => button.textContent === "Report spam");
|
|
106
|
+
|
|
107
|
+
assert.equal(http.to("/messages/report-spam").length, 1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("undo direction: a second click while the undo is in flight sends only one request", async () => {
|
|
111
|
+
let resolveRequest: (() => void) | undefined;
|
|
112
|
+
http = mockFetch(
|
|
113
|
+
() =>
|
|
114
|
+
new Promise((resolve) => {
|
|
115
|
+
resolveRequest = () => resolve({ successCount: 1, failureCount: 0 });
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
harness = createDomHarness();
|
|
119
|
+
harness.renderApp(createElement(UndoHarness));
|
|
120
|
+
|
|
121
|
+
const button = harness.byText("button", "Not spam") as HTMLButtonElement;
|
|
122
|
+
harness.click(button);
|
|
123
|
+
await waitFor(() => button.textContent === "Undoing…");
|
|
124
|
+
|
|
125
|
+
assert.equal(button.disabled, true, "a control mid-request must disable");
|
|
126
|
+
harness.click(button);
|
|
127
|
+
|
|
128
|
+
assert.ok(resolveRequest, "the request never reached the mock");
|
|
129
|
+
resolveRequest?.();
|
|
130
|
+
await waitFor(() => button.textContent === "Not spam");
|
|
131
|
+
|
|
132
|
+
assert.equal(http.to("/messages/not-spam").length, 1);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closes the seam the existing #648-review tests each cover half of.
|
|
3
|
+
* `useReportSpam.integration.test.ts` builds a synthetic mutation via
|
|
4
|
+
* `mutationCache.build()` and never mounts the hook; `useReportSpam.render.
|
|
5
|
+
* test.ts` mounts the hook but under `createDomHarness`'s default
|
|
6
|
+
* `QueryClient`, which carries no global cache handlers. Neither exercises the
|
|
7
|
+
* JOIN: the real `useMutation` calls in `useReportSpam.ts`, under a
|
|
8
|
+
* `QueryClient` wired with the real `QueryCache`/`MutationCache` handlers from
|
|
9
|
+
* `lib/query-error-handler.ts` the way `shell/index.tsx` builds it, wrapped in
|
|
10
|
+
* the real `ErrorBannerProvider`. Deleting `meta: { softError: true }` from
|
|
11
|
+
* the real hook passes both existing tests — that is the bug that shipped
|
|
12
|
+
* twice and passed CI both times.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { afterEach, describe, it } from "node:test";
|
|
17
|
+
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
|
18
|
+
import { createElement, Fragment } from "react";
|
|
19
|
+
import { FatalErrorOverlay } from "../components/ui/FatalErrorOverlay";
|
|
20
|
+
import { __resetFatalError, subscribeFatalError } from "../lib/fatal-error";
|
|
21
|
+
import {
|
|
22
|
+
handleMutationCacheError,
|
|
23
|
+
handleQueryCacheError,
|
|
24
|
+
} from "../lib/query-error-handler";
|
|
25
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
26
|
+
import { type HttpMock, httpError, mockFetch } from "../test-support/http";
|
|
27
|
+
import { useReportSpam } from "./useReportSpam";
|
|
28
|
+
|
|
29
|
+
let harness: DomHarness | undefined;
|
|
30
|
+
let http: HttpMock;
|
|
31
|
+
|
|
32
|
+
const mountHook = <T>(useHook: () => T): (() => T) => {
|
|
33
|
+
let value: T | undefined;
|
|
34
|
+
const Probe = () => {
|
|
35
|
+
value = useHook();
|
|
36
|
+
return null;
|
|
37
|
+
};
|
|
38
|
+
const queryClient = new QueryClient({
|
|
39
|
+
queryCache: new QueryCache({ onError: handleQueryCacheError }),
|
|
40
|
+
mutationCache: new MutationCache({ onError: handleMutationCacheError }),
|
|
41
|
+
defaultOptions: { mutations: { retry: false } },
|
|
42
|
+
});
|
|
43
|
+
harness = createDomHarness({ queryClient });
|
|
44
|
+
harness.renderApp(
|
|
45
|
+
createElement(
|
|
46
|
+
Fragment,
|
|
47
|
+
null,
|
|
48
|
+
createElement(FatalErrorOverlay),
|
|
49
|
+
createElement(Probe),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
return () => {
|
|
53
|
+
if (value === undefined) throw new Error("hook did not render");
|
|
54
|
+
return value;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** See `useReportSpam.render.test.ts` — the chain from a resolved fetch to a re-render crosses enough async boundaries that a fixed microtask count reads as flaky under load. */
|
|
59
|
+
const waitFor = async (
|
|
60
|
+
predicate: () => boolean,
|
|
61
|
+
timeoutMs = 2000,
|
|
62
|
+
): Promise<void> => {
|
|
63
|
+
if (!harness) throw new Error("nothing mounted");
|
|
64
|
+
const deadline = Date.now() + timeoutMs;
|
|
65
|
+
while (!predicate()) {
|
|
66
|
+
if (Date.now() > deadline) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`waitFor: condition never became true within ${timeoutMs}ms`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
await harness.flush();
|
|
72
|
+
await harness.wait(5);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const bannerAlerts = () =>
|
|
77
|
+
harness?.queryAll('[aria-label="Notifications"] [role="alert"]') ?? [];
|
|
78
|
+
|
|
79
|
+
const fatalOverlay = () =>
|
|
80
|
+
harness?.query('[data-testid="fatal-error-overlay"]') ?? null;
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
harness?.close();
|
|
84
|
+
harness = undefined;
|
|
85
|
+
http.restore();
|
|
86
|
+
__resetFatalError();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("useReportSpam under the real global cache handlers (#648 review, the join both existing tests missed)", () => {
|
|
90
|
+
it("a per-message report failure banners once and never mounts the fatal overlay — report direction", async () => {
|
|
91
|
+
const fatalsSeen: string[] = [];
|
|
92
|
+
subscribeFatalError((fatal) => fatalsSeen.push(fatal.message));
|
|
93
|
+
http = mockFetch(() => ({ failureCount: 1 }));
|
|
94
|
+
const hook = mountHook(() => useReportSpam({ mailboxId: "mbx-inbox" }));
|
|
95
|
+
|
|
96
|
+
hook().reportSpam(["msg-1"]);
|
|
97
|
+
await waitFor(() => bannerAlerts().length > 0);
|
|
98
|
+
|
|
99
|
+
assert.equal(
|
|
100
|
+
fatalOverlay() !== null,
|
|
101
|
+
false,
|
|
102
|
+
"the fatal overlay must not mount",
|
|
103
|
+
);
|
|
104
|
+
assert.deepEqual(fatalsSeen, [], "no fatal error must be raised");
|
|
105
|
+
assert.equal(bannerAlerts().length, 1, "exactly one banner");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("a per-message undo failure banners once and never mounts the fatal overlay — undo direction", async () => {
|
|
109
|
+
const fatalsSeen: string[] = [];
|
|
110
|
+
subscribeFatalError((fatal) => fatalsSeen.push(fatal.message));
|
|
111
|
+
http = mockFetch(() => ({ failureCount: 1 }));
|
|
112
|
+
const hook = mountHook(() => useReportSpam({ mailboxId: "mbx-junk" }));
|
|
113
|
+
|
|
114
|
+
hook().notSpam(["msg-1"]);
|
|
115
|
+
await waitFor(() => bannerAlerts().length > 0);
|
|
116
|
+
|
|
117
|
+
assert.equal(
|
|
118
|
+
fatalOverlay() !== null,
|
|
119
|
+
false,
|
|
120
|
+
"the fatal overlay must not mount",
|
|
121
|
+
);
|
|
122
|
+
assert.deepEqual(fatalsSeen, [], "no fatal error must be raised");
|
|
123
|
+
assert.equal(bannerAlerts().length, 1, "exactly one banner");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("a genuine 500 from the same endpoint still escalates — meta.softError must not swallow the 5xx class", async () => {
|
|
127
|
+
http = mockFetch(() => httpError(500, "spam service is down"));
|
|
128
|
+
const hook = mountHook(() => useReportSpam({ mailboxId: "mbx-inbox" }));
|
|
129
|
+
|
|
130
|
+
hook().reportSpam(["msg-1"]);
|
|
131
|
+
await waitFor(() => fatalOverlay() !== null);
|
|
132
|
+
|
|
133
|
+
assert.ok(fatalOverlay(), "a 5xx must still reach the fatal overlay");
|
|
134
|
+
});
|
|
135
|
+
});
|
package/src/test-support/dom.ts
CHANGED
|
@@ -46,6 +46,14 @@ export interface DomOptions {
|
|
|
46
46
|
/** Screen posture `matchMedia` answers against — jsdom has no device. */
|
|
47
47
|
orientation?: "portrait" | "landscape";
|
|
48
48
|
pointer?: "coarse" | "fine";
|
|
49
|
+
/**
|
|
50
|
+
* Use this `QueryClient` instead of the harness's default retry-disabled
|
|
51
|
+
* one — e.g. one wired with the real `QueryCache`/`MutationCache` error
|
|
52
|
+
* handlers from `lib/query-error-handler.ts`, the way `shell/index.tsx`
|
|
53
|
+
* builds it, so a test can exercise the real global escalation path
|
|
54
|
+
* instead of just the per-mutation `onError`.
|
|
55
|
+
*/
|
|
56
|
+
queryClient?: QueryClient;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
export const createDomHarness = (options: DomOptions = {}): DomHarness => {
|
|
@@ -64,12 +72,14 @@ export const createDomHarness = (options: DomOptions = {}): DomHarness => {
|
|
|
64
72
|
let root: Root | undefined = createRoot(container);
|
|
65
73
|
// No retries: a test asserting a failure should not have to wait out a
|
|
66
74
|
// backoff before it can see one.
|
|
67
|
-
const queryClient =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
const queryClient =
|
|
76
|
+
options.queryClient ??
|
|
77
|
+
new QueryClient({
|
|
78
|
+
defaultOptions: {
|
|
79
|
+
queries: { retry: false },
|
|
80
|
+
mutations: { retry: false },
|
|
81
|
+
},
|
|
82
|
+
});
|
|
73
83
|
|
|
74
84
|
const requireRoot = (): Root => {
|
|
75
85
|
if (!root) throw new Error("harness already unmounted");
|
|
@@ -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,93 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Integration: prove a per-message report-spam/not-spam failure resolves to a
|
|
3
|
-
* banner, never the full-screen fatal overlay, through the REAL global
|
|
4
|
-
* `MutationCache` wiring (`lib/query-error-handler.ts`, wired on the
|
|
5
|
-
* `QueryClient` in `shell/index.tsx`) — not just `ErrorBannerProvider`'s own
|
|
6
|
-
* `isAlwaysFatal` check.
|
|
7
|
-
*
|
|
8
|
-
* That distinction is the whole point of this file. `ErrorBannerProvider.
|
|
9
|
-
* pushError` refuses to banner an always-fatal error, but every mutation
|
|
10
|
-
* ALSO reports to the MutationCache's global `onError`, independent of
|
|
11
|
-
* whatever the per-mutation `onError` did — and `shouldEscalate` (what the
|
|
12
|
-
* global handler calls) escalates by DEFAULT for any non-5xx that didn't opt
|
|
13
|
-
* out via `meta.softError`. A test that only checked `isAlwaysFatal` passed
|
|
14
|
-
* while the real app crashed to the fatal overlay on the same error: this is
|
|
15
|
-
* exactly `query-escalation.integration.test.ts`'s pattern, applied to
|
|
16
|
-
* `useReportSpam`'s own mutation shape (mutationFn + meta), because that is
|
|
17
|
-
* the seam the earlier fix went through unexercised.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import assert from "node:assert/strict";
|
|
21
|
-
import { afterEach, describe, it } from "node:test";
|
|
22
|
-
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
|
23
|
-
import { __resetFatalError, subscribeFatalError } from "../lib/fatal-error";
|
|
24
|
-
import {
|
|
25
|
-
handleMutationCacheError,
|
|
26
|
-
handleQueryCacheError,
|
|
27
|
-
} from "../lib/query-error-handler";
|
|
28
|
-
import { throwOnBulkFailure } from "./useReportSpam.js";
|
|
29
|
-
|
|
30
|
-
afterEach(() => {
|
|
31
|
-
__resetFatalError();
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
const makeClient = () =>
|
|
35
|
-
new QueryClient({
|
|
36
|
-
queryCache: new QueryCache({ onError: handleQueryCacheError }),
|
|
37
|
-
mutationCache: new MutationCache({ onError: handleMutationCacheError }),
|
|
38
|
-
defaultOptions: { mutations: { retry: false } },
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
/** The exact shape a failed report-spam/not-spam call resolves to on the wire (200, not a rejection) — see `throwOnBulkFailure`. */
|
|
42
|
-
const failedBulkResult = () => ({
|
|
43
|
-
successCount: 0,
|
|
44
|
-
failureCount: 1,
|
|
45
|
-
failures: [
|
|
46
|
-
{
|
|
47
|
-
messageId: "9m2k7x4vqz1jd0tn3wf8b6y5c",
|
|
48
|
-
reason:
|
|
49
|
-
"Message 9m2k7x4vqz1jd0tn3wf8b6y5c's move to Junk has not settled yet; try again in a moment.",
|
|
50
|
-
},
|
|
51
|
-
],
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
describe("useReportSpam's mutations under the real MutationCache (#648 review)", () => {
|
|
55
|
-
it("with meta.softError, a per-message failure does NOT escalate to the fatal overlay (regression)", async () => {
|
|
56
|
-
const seen: string[] = [];
|
|
57
|
-
subscribeFatalError((fatal) => seen.push(fatal.message));
|
|
58
|
-
const client = makeClient();
|
|
59
|
-
|
|
60
|
-
const mutation = client.getMutationCache().build(client, {
|
|
61
|
-
mutationFn: async () => {
|
|
62
|
-
throwOnBulkFailure(failedBulkResult());
|
|
63
|
-
},
|
|
64
|
-
meta: { softError: true },
|
|
65
|
-
});
|
|
66
|
-
await mutation.execute(undefined).catch(() => {});
|
|
67
|
-
|
|
68
|
-
assert.deepEqual(
|
|
69
|
-
seen,
|
|
70
|
-
[],
|
|
71
|
-
"a designed, retryable per-message failure must not reach the fatal overlay",
|
|
72
|
-
);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("without meta.softError, the same failure DOES escalate — proving the opt-out is load-bearing, not a no-op", async () => {
|
|
76
|
-
const seen: string[] = [];
|
|
77
|
-
subscribeFatalError((fatal) => seen.push(fatal.message));
|
|
78
|
-
const client = makeClient();
|
|
79
|
-
|
|
80
|
-
const mutation = client.getMutationCache().build(client, {
|
|
81
|
-
mutationFn: async () => {
|
|
82
|
-
throwOnBulkFailure(failedBulkResult());
|
|
83
|
-
},
|
|
84
|
-
});
|
|
85
|
-
await mutation.execute(undefined).catch(() => {});
|
|
86
|
-
|
|
87
|
-
assert.equal(
|
|
88
|
-
seen.length,
|
|
89
|
-
1,
|
|
90
|
-
"this asserts the failure mode the fix removes — a non-5xx with no softError opt-out escalates by default",
|
|
91
|
-
);
|
|
92
|
-
});
|
|
93
|
-
});
|
|
@@ -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");
|