@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 +1 -1
- package/src/components/compose/ComposeForm.tsx +76 -13
- package/src/components/compose/ComposeProvider.tsx +35 -1
- package/src/components/compose/ConversationCompose.tsx +19 -0
- package/src/components/compose/compose-inline-image.render.test.ts +542 -0
- package/src/components/compose/compose.stories.tsx +13 -0
- package/src/components/compose/reply-with-times.ts +22 -0
- package/src/components/mail/BriefPane.tsx +7 -0
- package/src/components/mail/FlaggedPane.tsx +7 -0
- package/src/components/mail/IntelligenceDrawer.tsx +4 -0
- package/src/components/mail/IntelligencePane.tsx +7 -1
- package/src/components/mail/MailboxPane.tsx +7 -0
- package/src/components/mail/offer-times-reply.render.test.ts +529 -0
- package/src/hooks/intelligence-calendar.render.test.ts +0 -19
- package/src/hooks/useComposeAttachments.ts +6 -1
- package/src/hooks/useInlineImages.ts +93 -0
- package/src/hooks/useIntelligenceCalendar.ts +8 -19
- package/src/lib/compose-attachment-controller.test.ts +110 -0
- package/src/lib/compose-attachment-controller.ts +120 -16
- package/src/lib/mail-search.ts +17 -0
- package/src/routes/mail/$mailboxId/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +2 -1
- package/src/routes/mail/brief/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +2 -1
- package/src/routes/mail/flagged/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +2 -1
- package/src/routing/compose.ts +2 -1
- package/src/routing/outbox-draft.ts +2 -1
- package/src/routing/reply.ts +34 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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.
|
|
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
|
|
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
|
/>
|