@remit/web-client 0.0.311 → 0.0.313

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.311",
3
+ "version": "0.0.313",
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": {
@@ -30,8 +30,8 @@ import {
30
30
  sanitizeQuotedHtml,
31
31
  unwrapLanguage,
32
32
  } from "@remit/ui";
33
- import type { ComposeBodyMode } from "@remit/ui/rich-text";
34
- import { useMutation, useQuery } from "@tanstack/react-query";
33
+ import type { ComposeBodyMode, ComposeInsertion } from "@remit/ui/rich-text";
34
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
35
35
  import type { RefObject } from "react";
36
36
  import {
37
37
  lazy,
@@ -46,10 +46,12 @@ import {
46
46
  type DraftForAttachment,
47
47
  useComposeAttachments,
48
48
  } from "../../hooks/useComposeAttachments";
49
+ import { useInlineImages } from "../../hooks/useInlineImages";
49
50
  import { useMessageBodyContent } from "../../hooks/useMessageBodyContent";
50
51
  import { useSaveDraft } from "../../hooks/useSaveDraft";
51
52
  import { useSignature } from "../../hooks/useSignature.js";
52
53
  import { isNotFound, softErrorMeta } from "../../lib/error-classifier";
54
+ import type { ComposeSeed } from "../../lib/mail-search";
53
55
  import { accountIsMissingSmtp } from "../settings/account-form-helpers.js";
54
56
  import { useErrorBanners } from "../ui/ErrorBannerProvider.js";
55
57
  import {
@@ -94,6 +96,8 @@ interface ComposeFormProps {
94
96
  * shared would open on the last draft any other composer touched.
95
97
  */
96
98
  outboxMessageId?: string;
99
+ seed?: ComposeSeed;
100
+ insertion?: ComposeInsertion;
97
101
  /** The draft the first autosave created, for the owner to record. */
98
102
  onDraftCreated: (outboxMessageId: string) => void;
99
103
  onClose: () => void;
@@ -118,10 +122,19 @@ const buildInitialHtml = (signaturePlainText: string): string => {
118
122
  */
119
123
  const freshDocument = (
120
124
  signaturePlainText: string,
121
- ): { html: string; text: string } => ({
122
- html: buildInitialHtml(signaturePlainText),
123
- text: signaturePlainText,
124
- });
125
+ seed?: ComposeSeed,
126
+ ): { html: string; text: string } => {
127
+ if (!seed) {
128
+ return {
129
+ html: buildInitialHtml(signaturePlainText),
130
+ text: signaturePlainText,
131
+ };
132
+ }
133
+ return {
134
+ html: `${textToHtml(seed.body)}${buildInitialHtml(signaturePlainText)}`,
135
+ text: [seed.body, signaturePlainText].filter(Boolean).join("\n\n"),
136
+ };
137
+ };
125
138
 
126
139
  const buildReplySubject = (subject?: string): string => {
127
140
  if (!subject) return "Re: ";
@@ -396,6 +409,8 @@ export const ComposeForm = ({
396
409
  account,
397
410
  sourceMessage,
398
411
  outboxMessageId,
412
+ seed,
413
+ insertion,
399
414
  onDraftCreated,
400
415
  onClose,
401
416
  onAccountChange,
@@ -448,9 +463,7 @@ export const ComposeForm = ({
448
463
  * failure to `draft` (#933), so opening a Failed message and pressing Escape
449
464
  * would take it out of the Outbox with the reader having changed nothing. The
450
465
  * first run after a load records what was loaded; only a payload that differs
451
- * from it is an edit. Nothing updates it afterwards — once the reader has
452
- * touched the document, every later run saves as it always did, retries of a
453
- * failed write included.
466
+ * from it is an edit.
454
467
  */
455
468
  const loadedPayloadRef = useRef<string | undefined>(undefined);
456
469
  const captureLoadedPayloadRef = useRef(false);
@@ -546,7 +559,7 @@ export const ComposeForm = ({
546
559
  // the signature, the same document a fresh mount would have started on.
547
560
  const opening = outboxMessageId
548
561
  ? { html: "", text: "" }
549
- : freshDocument(signatureRef.current);
562
+ : freshDocument(signatureRef.current, seedRef.current);
550
563
  setToAddresses([]);
551
564
  setCcAddresses([]);
552
565
  setBccAddresses([]);
@@ -571,15 +584,19 @@ export const ComposeForm = ({
571
584
  // not a reason to reopen the document somebody is typing in.
572
585
  const signatureRef = useRef(signature.plainText);
573
586
  signatureRef.current = signature.plainText;
587
+ const seedRef = useRef(seed);
588
+ seedRef.current = seed;
574
589
  // The editor reads its document once, so this is the document it opens on,
575
590
  // not the live value, and it is remounted when the generation changes. Only
576
591
  // loading a different document bumps that — remounting mid-compose would take
577
592
  // the caret, the focus and the undo history with it.
578
593
  const [documentGeneration, setDocumentGeneration] = useState(0);
579
594
  const [initialHtml, setInitialHtml] = useState(
580
- () => freshDocument(signature.plainText).html,
595
+ () => freshDocument(signature.plainText, seed).html,
596
+ );
597
+ const [initialText, setInitialText] = useState(
598
+ () => freshDocument(signature.plainText, seed).text,
581
599
  );
582
- const [initialText, setInitialText] = useState(signature.plainText);
583
600
  const [bodyMode, setBodyMode] = useState<ComposeBodyMode>("rich");
584
601
  // What the body is tagged with on the way out. The composer owns the value —
585
602
  // it is the surface that has the text detection reads — and reports it here,
@@ -587,9 +604,18 @@ export const ComposeForm = ({
587
604
  const [composeLanguage, setComposeLanguage] = useState("en");
588
605
  const [draftLanguage, setDraftLanguage] = useState<string | undefined>();
589
606
  const [body, setBody] = useState<RichTextValue>(() => ({
590
- ...freshDocument(signature.plainText),
607
+ ...freshDocument(signature.plainText, seed),
591
608
  formatting: [],
592
609
  }));
610
+ const [insertedVersion, setInsertedVersion] = useState(
611
+ () => insertion?.version ?? 0,
612
+ );
613
+ const pendingInsertion =
614
+ insertion &&
615
+ insertion.version > insertedVersion &&
616
+ (outboxMessageId === undefined || draftLoaded)
617
+ ? insertion
618
+ : undefined;
593
619
 
594
620
  const { data: draftData, error: draftError } = useQuery({
595
621
  ...outboxDetailOperationsGetOutboxMessageOptions({
@@ -850,11 +876,41 @@ export const ComposeForm = ({
850
876
  remove: removeAttachment,
851
877
  load: loadAttachments,
852
878
  reset: resetAttachments,
879
+ owns: ownsAttachment,
880
+ isLoaded: attachmentsLoaded,
881
+ refreshLinks: refreshAttachmentLinks,
882
+ storedContent,
883
+ onRemoved: onAttachmentRemoved,
853
884
  blockingReason: attachmentBlockingReason,
854
885
  } = useComposeAttachments({
855
886
  ensureDraft: ensureDraftForAttachment,
856
887
  });
857
888
 
889
+ const queryClient = useQueryClient();
890
+ const inlineImages = useInlineImages({
891
+ attach,
892
+ owns: ownsAttachment,
893
+ isLoaded: attachmentsLoaded,
894
+ storedContent,
895
+ refreshLinks: () => {
896
+ if (!outboxMessageId) return Promise.resolve();
897
+ return queryClient
898
+ .fetchQuery({
899
+ ...outboxDetailOperationsGetOutboxMessageOptions({
900
+ path: { outboxMessageId },
901
+ }),
902
+ staleTime: 0,
903
+ })
904
+ .then((draft) => refreshAttachmentLinks(draft.attachments));
905
+ },
906
+ onRemoved: onAttachmentRemoved,
907
+ draftAttachments:
908
+ draftData && draftData.outboxMessageId === outboxMessageId
909
+ ? draftData.attachments
910
+ : [],
911
+ pushError,
912
+ });
913
+
858
914
  const attachmentDocumentRef = useRef(outboxMessageId);
859
915
  useEffect(() => {
860
916
  const previous = attachmentDocumentRef.current;
@@ -1059,6 +1115,7 @@ export const ComposeForm = ({
1059
1115
  return;
1060
1116
  }
1061
1117
  if (loadedPayloadRef.current === fingerprint) return;
1118
+ loadedPayloadRef.current = undefined;
1062
1119
 
1063
1120
  saveDraft(payload);
1064
1121
  }, [
@@ -1256,6 +1313,9 @@ export const ComposeForm = ({
1256
1313
  return (
1257
1314
  <ComposeFormShell
1258
1315
  layout={layout}
1316
+ onDropFiles={(files) => {
1317
+ void attach(files);
1318
+ }}
1259
1319
  banner={
1260
1320
  <>
1261
1321
  {selectedAccount && selectedAccountMissingSmtp ? (
@@ -1352,6 +1412,9 @@ export const ComposeForm = ({
1352
1412
  initialLanguage={draftLanguage}
1353
1413
  onLanguageChange={setComposeLanguage}
1354
1414
  spellcheck={spellcheck}
1415
+ insertion={pendingInsertion}
1416
+ onInserted={setInsertedVersion}
1417
+ inlineImages={inlineImages}
1355
1418
  />
1356
1419
  </Suspense>
1357
1420
  </ComposeFormShell>
@@ -9,6 +9,7 @@ import {
9
9
  useContext,
10
10
  useEffect,
11
11
  useMemo,
12
+ useRef,
12
13
  useState,
13
14
  } from "react";
14
15
  import { isNotFound, softErrorStatuses } from "@/lib/error-classifier";
@@ -30,8 +31,15 @@ export type ComposeMode = ReplyMode | "new";
30
31
  * the address's answers — `useComposeDraftId` and `useIsComposing`. A second
31
32
  * copy here is what let a reply's draft turn up in the next new message.
32
33
  */
34
+ type InsertIntoReply = (text: string) => void;
35
+
33
36
  interface ComposeContextValue {
34
37
  startSendPolling: (outboxMessageId: string) => void;
38
+ insertIntoOpenReply: (sourceMessageId: string, text: string) => boolean;
39
+ registerOpenReply: (
40
+ sourceMessageId: string,
41
+ insert: InsertIntoReply,
42
+ ) => () => void;
35
43
  }
36
44
 
37
45
  const ComposeContext = createContext<ComposeContextValue | undefined>(
@@ -124,7 +132,33 @@ export const ComposeProvider = ({
124
132
  setPollingMessageId(outboxMessageId);
125
133
  }, []);
126
134
 
127
- const value = useMemo(() => ({ startSendPolling }), [startSendPolling]);
135
+ const openReplies = useRef(new Map<string, InsertIntoReply>());
136
+
137
+ const insertIntoOpenReply = useCallback(
138
+ (sourceMessageId: string, text: string): boolean => {
139
+ const insert = openReplies.current.get(sourceMessageId);
140
+ if (!insert) return false;
141
+ insert(text);
142
+ return true;
143
+ },
144
+ [],
145
+ );
146
+
147
+ const registerOpenReply = useCallback(
148
+ (sourceMessageId: string, insert: InsertIntoReply) => {
149
+ openReplies.current.set(sourceMessageId, insert);
150
+ return () => {
151
+ if (openReplies.current.get(sourceMessageId) === insert)
152
+ openReplies.current.delete(sourceMessageId);
153
+ };
154
+ },
155
+ [],
156
+ );
157
+
158
+ const value = useMemo(
159
+ () => ({ startSendPolling, insertIntoOpenReply, registerOpenReply }),
160
+ [startSendPolling, insertIntoOpenReply, registerOpenReply],
161
+ );
128
162
 
129
163
  return (
130
164
  <ComposeContext.Provider value={value}>{children}</ComposeContext.Provider>
@@ -2,7 +2,9 @@ import {
2
2
  configOperationsGetConfigOptions,
3
3
  messageOperationsDescribeMessageOptions,
4
4
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import type { ComposeInsertion } from "@remit/ui/rich-text";
5
6
  import { useQuery } from "@tanstack/react-query";
7
+ import { useCallback, useEffect, useState } from "react";
6
8
  import { useMailboxAccount } from "@/hooks/useMailboxAccount";
7
9
  import {
8
10
  type ReplyAddress,
@@ -10,6 +12,7 @@ import {
10
12
  useCloseReply,
11
13
  } from "@/routing";
12
14
  import { ComposeForm } from "./ComposeForm";
15
+ import { useCompose } from "./ComposeProvider";
13
16
 
14
17
  /**
15
18
  * The answer to a message, as the top block of the conversation it answers.
@@ -27,6 +30,19 @@ import { ComposeForm } from "./ComposeForm";
27
30
  export const ConversationCompose = ({ surface }: { surface: ReplyAddress }) => {
28
31
  const adoptCreatedDraft = useAdoptReplyDraft();
29
32
  const closeReply = useCloseReply();
33
+ const { registerOpenReply } = useCompose();
34
+ const [insertion, setInsertion] = useState<ComposeInsertion>();
35
+ const insert = useCallback(
36
+ (text: string) =>
37
+ setInsertion((prev) => ({ version: (prev?.version ?? 0) + 1, text })),
38
+ [],
39
+ );
40
+ const answering = surface.sourceMessageId;
41
+
42
+ useEffect(
43
+ () => registerOpenReply(answering, insert),
44
+ [answering, insert, registerOpenReply],
45
+ );
30
46
 
31
47
  const { data: sourceMessage } = useQuery({
32
48
  ...messageOperationsDescribeMessageOptions({
@@ -56,11 +72,14 @@ export const ConversationCompose = ({ surface }: { surface: ReplyAddress }) => {
56
72
  the address, and remounting the form on that would take the caret out
57
73
  of the sentence being typed. */}
58
74
  <ComposeForm
75
+ key={surface.sourceMessageId}
59
76
  layout="flow"
60
77
  mode={surface.mode}
61
78
  account={account}
62
79
  sourceMessage={sourceMessage}
63
80
  outboxMessageId={surface.outboxMessageId}
81
+ seed={surface.seed}
82
+ insertion={insertion}
64
83
  onDraftCreated={adoptCreatedDraft}
65
84
  onClose={closeReply}
66
85
  />