@remit/web-client 0.0.133 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.133",
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 { 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
+ );
@@ -9,9 +9,15 @@ import type {
9
9
  RemitImapAccountResponse,
10
10
  RemitImapDescribeMessageResponse,
11
11
  } from "@remit/api-http-client/types.gen.ts";
12
- import { ComposeActionBar, ComposeFormShell, QuotedText } from "@remit/ui";
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 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
- ];
68
+ const escapeHtml = (text: string): string =>
69
+ text
70
+ .replace(/&/g, "&amp;")
71
+ .replace(/</g, "&lt;")
72
+ .replace(/>/g, "&gt;")
73
+ .replace(/"/g, "&quot;");
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: Value,
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
- plateValueToText(body).trim() === "";
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
- if (prevOutboxMessageIdRef.current === outboxMessageId) return;
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
- setBody(EMPTY_PARAGRAPH);
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
- const [body, setBody] = useState<Value>(() =>
329
- buildInitialBody(signature.plainText),
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
- if (draftData.textBody)
365
- setBody([{ type: "p", children: [{ text: draftData.textBody }] }]);
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
- ? sanitizeQuoteHtml(sourceBody.body)
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: setOutboxMessageId,
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 = plateValueToText(body);
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 = plateValueToText(body);
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
- value={body}
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("[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();
@@ -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");