@remit/web-client 0.0.311 → 0.0.312
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 +38 -9
- package/src/components/compose/ComposeProvider.tsx +35 -1
- package/src/components/compose/ConversationCompose.tsx +19 -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/useIntelligenceCalendar.ts +8 -19
- 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.312",
|
|
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,7 +30,7 @@ import {
|
|
|
30
30
|
sanitizeQuotedHtml,
|
|
31
31
|
unwrapLanguage,
|
|
32
32
|
} from "@remit/ui";
|
|
33
|
-
import type { ComposeBodyMode } from "@remit/ui/rich-text";
|
|
33
|
+
import type { ComposeBodyMode, ComposeInsertion } from "@remit/ui/rich-text";
|
|
34
34
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
35
35
|
import type { RefObject } from "react";
|
|
36
36
|
import {
|
|
@@ -50,6 +50,7 @@ import { useMessageBodyContent } from "../../hooks/useMessageBodyContent";
|
|
|
50
50
|
import { useSaveDraft } from "../../hooks/useSaveDraft";
|
|
51
51
|
import { useSignature } from "../../hooks/useSignature.js";
|
|
52
52
|
import { isNotFound, softErrorMeta } from "../../lib/error-classifier";
|
|
53
|
+
import type { ComposeSeed } from "../../lib/mail-search";
|
|
53
54
|
import { accountIsMissingSmtp } from "../settings/account-form-helpers.js";
|
|
54
55
|
import { useErrorBanners } from "../ui/ErrorBannerProvider.js";
|
|
55
56
|
import {
|
|
@@ -94,6 +95,8 @@ interface ComposeFormProps {
|
|
|
94
95
|
* shared would open on the last draft any other composer touched.
|
|
95
96
|
*/
|
|
96
97
|
outboxMessageId?: string;
|
|
98
|
+
seed?: ComposeSeed;
|
|
99
|
+
insertion?: ComposeInsertion;
|
|
97
100
|
/** The draft the first autosave created, for the owner to record. */
|
|
98
101
|
onDraftCreated: (outboxMessageId: string) => void;
|
|
99
102
|
onClose: () => void;
|
|
@@ -118,10 +121,19 @@ const buildInitialHtml = (signaturePlainText: string): string => {
|
|
|
118
121
|
*/
|
|
119
122
|
const freshDocument = (
|
|
120
123
|
signaturePlainText: string,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
seed?: ComposeSeed,
|
|
125
|
+
): { html: string; text: string } => {
|
|
126
|
+
if (!seed) {
|
|
127
|
+
return {
|
|
128
|
+
html: buildInitialHtml(signaturePlainText),
|
|
129
|
+
text: signaturePlainText,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
html: `${textToHtml(seed.body)}${buildInitialHtml(signaturePlainText)}`,
|
|
134
|
+
text: [seed.body, signaturePlainText].filter(Boolean).join("\n\n"),
|
|
135
|
+
};
|
|
136
|
+
};
|
|
125
137
|
|
|
126
138
|
const buildReplySubject = (subject?: string): string => {
|
|
127
139
|
if (!subject) return "Re: ";
|
|
@@ -396,6 +408,8 @@ export const ComposeForm = ({
|
|
|
396
408
|
account,
|
|
397
409
|
sourceMessage,
|
|
398
410
|
outboxMessageId,
|
|
411
|
+
seed,
|
|
412
|
+
insertion,
|
|
399
413
|
onDraftCreated,
|
|
400
414
|
onClose,
|
|
401
415
|
onAccountChange,
|
|
@@ -546,7 +560,7 @@ export const ComposeForm = ({
|
|
|
546
560
|
// the signature, the same document a fresh mount would have started on.
|
|
547
561
|
const opening = outboxMessageId
|
|
548
562
|
? { html: "", text: "" }
|
|
549
|
-
: freshDocument(signatureRef.current);
|
|
563
|
+
: freshDocument(signatureRef.current, seedRef.current);
|
|
550
564
|
setToAddresses([]);
|
|
551
565
|
setCcAddresses([]);
|
|
552
566
|
setBccAddresses([]);
|
|
@@ -571,15 +585,19 @@ export const ComposeForm = ({
|
|
|
571
585
|
// not a reason to reopen the document somebody is typing in.
|
|
572
586
|
const signatureRef = useRef(signature.plainText);
|
|
573
587
|
signatureRef.current = signature.plainText;
|
|
588
|
+
const seedRef = useRef(seed);
|
|
589
|
+
seedRef.current = seed;
|
|
574
590
|
// The editor reads its document once, so this is the document it opens on,
|
|
575
591
|
// not the live value, and it is remounted when the generation changes. Only
|
|
576
592
|
// loading a different document bumps that — remounting mid-compose would take
|
|
577
593
|
// the caret, the focus and the undo history with it.
|
|
578
594
|
const [documentGeneration, setDocumentGeneration] = useState(0);
|
|
579
595
|
const [initialHtml, setInitialHtml] = useState(
|
|
580
|
-
() => freshDocument(signature.plainText).html,
|
|
596
|
+
() => freshDocument(signature.plainText, seed).html,
|
|
597
|
+
);
|
|
598
|
+
const [initialText, setInitialText] = useState(
|
|
599
|
+
() => freshDocument(signature.plainText, seed).text,
|
|
581
600
|
);
|
|
582
|
-
const [initialText, setInitialText] = useState(signature.plainText);
|
|
583
601
|
const [bodyMode, setBodyMode] = useState<ComposeBodyMode>("rich");
|
|
584
602
|
// What the body is tagged with on the way out. The composer owns the value —
|
|
585
603
|
// it is the surface that has the text detection reads — and reports it here,
|
|
@@ -587,9 +605,18 @@ export const ComposeForm = ({
|
|
|
587
605
|
const [composeLanguage, setComposeLanguage] = useState("en");
|
|
588
606
|
const [draftLanguage, setDraftLanguage] = useState<string | undefined>();
|
|
589
607
|
const [body, setBody] = useState<RichTextValue>(() => ({
|
|
590
|
-
...freshDocument(signature.plainText),
|
|
608
|
+
...freshDocument(signature.plainText, seed),
|
|
591
609
|
formatting: [],
|
|
592
610
|
}));
|
|
611
|
+
const [insertedVersion, setInsertedVersion] = useState(
|
|
612
|
+
() => insertion?.version ?? 0,
|
|
613
|
+
);
|
|
614
|
+
const pendingInsertion =
|
|
615
|
+
insertion &&
|
|
616
|
+
insertion.version > insertedVersion &&
|
|
617
|
+
(outboxMessageId === undefined || draftLoaded)
|
|
618
|
+
? insertion
|
|
619
|
+
: undefined;
|
|
593
620
|
|
|
594
621
|
const { data: draftData, error: draftError } = useQuery({
|
|
595
622
|
...outboxDetailOperationsGetOutboxMessageOptions({
|
|
@@ -1352,6 +1379,8 @@ export const ComposeForm = ({
|
|
|
1352
1379
|
initialLanguage={draftLanguage}
|
|
1353
1380
|
onLanguageChange={setComposeLanguage}
|
|
1354
1381
|
spellcheck={spellcheck}
|
|
1382
|
+
insertion={pendingInsertion}
|
|
1383
|
+
onInserted={setInsertedVersion}
|
|
1355
1384
|
/>
|
|
1356
1385
|
</Suspense>
|
|
1357
1386
|
</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
|
/>
|
|
@@ -174,6 +174,19 @@ export const Reply: Story = {
|
|
|
174
174
|
},
|
|
175
175
|
};
|
|
176
176
|
|
|
177
|
+
const OFFERED_TIMES = "Thursday 11 June: 12:30 – 13:00, 15:00 – 15:30";
|
|
178
|
+
|
|
179
|
+
export const ReplyWithOfferedTimes: Story = {
|
|
180
|
+
args: {
|
|
181
|
+
url: `${messageUrl("lunch")}/reply?body=${encodeURIComponent(OFFERED_TIMES)}`,
|
|
182
|
+
},
|
|
183
|
+
play: async () => {
|
|
184
|
+
const subject = await page().findByPlaceholderText("Subject");
|
|
185
|
+
await waitFor(() => expect(subject).toHaveValue(`Re: ${LUNCH_SUBJECT}`));
|
|
186
|
+
await expect(await writingSurface(OFFERED_TIMES)).toBeVisible();
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
177
190
|
export const Forward: Story = {
|
|
178
191
|
args: { url: `${messageUrl("lunch")}/forward` },
|
|
179
192
|
play: async () => {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useOpenReply } from "@/routing";
|
|
3
|
+
import { useCompose } from "./ComposeProvider";
|
|
4
|
+
|
|
5
|
+
export interface AnsweredMessage {
|
|
6
|
+
threadId: string;
|
|
7
|
+
messageId: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ReplyWithText = (message: AnsweredMessage, text: string) => void;
|
|
11
|
+
|
|
12
|
+
export function useReplyWithText(): ReplyWithText {
|
|
13
|
+
const { insertIntoOpenReply } = useCompose();
|
|
14
|
+
const openReply = useOpenReply();
|
|
15
|
+
return useCallback(
|
|
16
|
+
(message: AnsweredMessage, text: string) => {
|
|
17
|
+
if (insertIntoOpenReply(message.messageId, text)) return;
|
|
18
|
+
openReply({ ...message, mode: "reply" }, { body: text });
|
|
19
|
+
},
|
|
20
|
+
[insertIntoOpenReply, openReply],
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
useContext,
|
|
24
24
|
useMemo,
|
|
25
25
|
} from "react";
|
|
26
|
+
import { useReplyWithText } from "@/components/compose/reply-with-times";
|
|
26
27
|
import { ConversationView } from "@/components/mail/ConversationView";
|
|
27
28
|
import { DailyBrief } from "@/components/mail/DailyBrief";
|
|
28
29
|
import { IntelligenceDrawer } from "@/components/mail/IntelligenceDrawer";
|
|
@@ -317,6 +318,7 @@ function BriefList() {
|
|
|
317
318
|
* Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
|
|
318
319
|
*/
|
|
319
320
|
function BriefReading() {
|
|
321
|
+
const replyWithText = useReplyWithText();
|
|
320
322
|
const {
|
|
321
323
|
conversation,
|
|
322
324
|
selectedThread,
|
|
@@ -374,6 +376,7 @@ function BriefReading() {
|
|
|
374
376
|
mailboxId={selectedThread?.mailboxId}
|
|
375
377
|
accountId={selectedThread?.accountId}
|
|
376
378
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
379
|
+
onReplyWithText={replyWithText}
|
|
377
380
|
/>
|
|
378
381
|
</>
|
|
379
382
|
);
|
|
@@ -384,6 +387,7 @@ function BriefReading() {
|
|
|
384
387
|
* Mount in the `intelligence` slot of `AppShellSlotted`. Only rendered ≥ 1280px.
|
|
385
388
|
*/
|
|
386
389
|
function BriefIntelligence() {
|
|
390
|
+
const replyWithText = useReplyWithText();
|
|
387
391
|
const { selectedThread, handleDeselectIfRemoved } = useBriefPane();
|
|
388
392
|
const { onToggleIntelligence } = useMailContext();
|
|
389
393
|
|
|
@@ -394,6 +398,7 @@ function BriefIntelligence() {
|
|
|
394
398
|
mailboxId={selectedThread?.mailboxId}
|
|
395
399
|
accountId={selectedThread?.accountId}
|
|
396
400
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
401
|
+
onReplyWithText={replyWithText}
|
|
397
402
|
/>
|
|
398
403
|
);
|
|
399
404
|
}
|
|
@@ -402,6 +407,7 @@ function BriefIntelligence() {
|
|
|
402
407
|
* Phone view: ConversationView when thread is open, or the DailyBrief list.
|
|
403
408
|
*/
|
|
404
409
|
function BriefPhone() {
|
|
410
|
+
const replyWithText = useReplyWithText();
|
|
405
411
|
const {
|
|
406
412
|
selectedThread,
|
|
407
413
|
conversation,
|
|
@@ -437,6 +443,7 @@ function BriefPhone() {
|
|
|
437
443
|
mailboxId={selectedThread?.mailboxId}
|
|
438
444
|
accountId={selectedThread?.accountId}
|
|
439
445
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
446
|
+
onReplyWithText={replyWithText}
|
|
440
447
|
/>
|
|
441
448
|
</>
|
|
442
449
|
);
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
useContext,
|
|
34
34
|
useMemo,
|
|
35
35
|
} from "react";
|
|
36
|
+
import { useReplyWithText } from "@/components/compose/reply-with-times";
|
|
36
37
|
import { ConversationView } from "@/components/mail/ConversationView";
|
|
37
38
|
import { FlaggedList } from "@/components/mail/FlaggedList";
|
|
38
39
|
import { IntelligenceDrawer } from "@/components/mail/IntelligenceDrawer";
|
|
@@ -320,6 +321,7 @@ function FlaggedListSlot() {
|
|
|
320
321
|
* Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
|
|
321
322
|
*/
|
|
322
323
|
function FlaggedReading() {
|
|
324
|
+
const replyWithText = useReplyWithText();
|
|
323
325
|
const {
|
|
324
326
|
conversation,
|
|
325
327
|
selectedThread,
|
|
@@ -377,6 +379,7 @@ function FlaggedReading() {
|
|
|
377
379
|
mailboxId={selectedThread?.mailboxId}
|
|
378
380
|
accountId={selectedThread?.accountId}
|
|
379
381
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
382
|
+
onReplyWithText={replyWithText}
|
|
380
383
|
/>
|
|
381
384
|
</>
|
|
382
385
|
);
|
|
@@ -387,6 +390,7 @@ function FlaggedReading() {
|
|
|
387
390
|
* Mount in the `intelligence` slot of `AppShellSlotted`. Only rendered ≥ 1280px.
|
|
388
391
|
*/
|
|
389
392
|
function FlaggedIntelligence() {
|
|
393
|
+
const replyWithText = useReplyWithText();
|
|
390
394
|
const { selectedThread, handleDeselectIfRemoved } = useFlaggedPane();
|
|
391
395
|
const { onToggleIntelligence } = useMailContext();
|
|
392
396
|
|
|
@@ -397,12 +401,14 @@ function FlaggedIntelligence() {
|
|
|
397
401
|
mailboxId={selectedThread?.mailboxId}
|
|
398
402
|
accountId={selectedThread?.accountId}
|
|
399
403
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
404
|
+
onReplyWithText={replyWithText}
|
|
400
405
|
/>
|
|
401
406
|
);
|
|
402
407
|
}
|
|
403
408
|
|
|
404
409
|
/** Phone view: ConversationView when a thread is open, else the flat list. */
|
|
405
410
|
function FlaggedPhone() {
|
|
411
|
+
const replyWithText = useReplyWithText();
|
|
406
412
|
const {
|
|
407
413
|
selectedThread,
|
|
408
414
|
conversation,
|
|
@@ -438,6 +444,7 @@ function FlaggedPhone() {
|
|
|
438
444
|
mailboxId={selectedThread?.mailboxId}
|
|
439
445
|
accountId={selectedThread?.accountId}
|
|
440
446
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
447
|
+
onReplyWithText={replyWithText}
|
|
441
448
|
/>
|
|
442
449
|
</>
|
|
443
450
|
);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import type { ReplyWithText } from "@/components/compose/reply-with-times";
|
|
2
3
|
import { Drawer } from "@/components/layout/Drawer";
|
|
3
4
|
import { IntelligencePane } from "@/components/mail/IntelligencePane";
|
|
4
5
|
|
|
@@ -16,6 +17,7 @@ export function IntelligenceDrawer({
|
|
|
16
17
|
mailboxId,
|
|
17
18
|
accountId,
|
|
18
19
|
onAfterOptimisticRemove,
|
|
20
|
+
onReplyWithText,
|
|
19
21
|
}: {
|
|
20
22
|
isOpen: boolean;
|
|
21
23
|
onClose: () => void;
|
|
@@ -23,6 +25,7 @@ export function IntelligenceDrawer({
|
|
|
23
25
|
mailboxId?: string;
|
|
24
26
|
accountId?: string;
|
|
25
27
|
onAfterOptimisticRemove?: (messageIds: string[]) => void;
|
|
28
|
+
onReplyWithText?: ReplyWithText;
|
|
26
29
|
}) {
|
|
27
30
|
return (
|
|
28
31
|
<Drawer
|
|
@@ -41,6 +44,7 @@ export function IntelligenceDrawer({
|
|
|
41
44
|
accountId={accountId}
|
|
42
45
|
hideCloseButton
|
|
43
46
|
onAfterOptimisticRemove={onAfterOptimisticRemove}
|
|
47
|
+
onReplyWithText={onReplyWithText}
|
|
44
48
|
/>
|
|
45
49
|
</Drawer>
|
|
46
50
|
);
|
|
@@ -20,6 +20,7 @@ import { isRescueCandidate } from "@/lib/rescue-candidates";
|
|
|
20
20
|
import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
|
|
21
21
|
import { useTelemetry } from "@/lib/telemetry-context";
|
|
22
22
|
import { NavLink } from "@/routing";
|
|
23
|
+
import type { ReplyWithText } from "../compose/reply-with-times";
|
|
23
24
|
|
|
24
25
|
export interface IntelligencePaneProps {
|
|
25
26
|
onClose: () => void;
|
|
@@ -53,6 +54,7 @@ export interface IntelligencePaneProps {
|
|
|
53
54
|
* occasional unneeded pane-close — not a bug; see `useReportSpam`.
|
|
54
55
|
*/
|
|
55
56
|
onAfterOptimisticRemove?: (messageIds: string[]) => void;
|
|
57
|
+
onReplyWithText?: ReplyWithText;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/**
|
|
@@ -251,6 +253,7 @@ interface WiredPanelProps {
|
|
|
251
253
|
accountId?: string;
|
|
252
254
|
hideCloseButton?: boolean;
|
|
253
255
|
onAfterOptimisticRemove?: (messageIds: string[]) => void;
|
|
256
|
+
onReplyWithText?: ReplyWithText;
|
|
254
257
|
}
|
|
255
258
|
|
|
256
259
|
/**
|
|
@@ -263,6 +266,7 @@ function WiredPanel({
|
|
|
263
266
|
accountId,
|
|
264
267
|
hideCloseButton,
|
|
265
268
|
onAfterOptimisticRemove,
|
|
269
|
+
onReplyWithText,
|
|
266
270
|
}: WiredPanelProps) {
|
|
267
271
|
const {
|
|
268
272
|
data,
|
|
@@ -273,7 +277,7 @@ function WiredPanel({
|
|
|
273
277
|
similarErrorIsFatal,
|
|
274
278
|
semanticEnabled,
|
|
275
279
|
} = useIntelligenceData(thread, mailboxId);
|
|
276
|
-
const calendar = useIntelligenceCalendar(thread);
|
|
280
|
+
const calendar = useIntelligenceCalendar(thread, onReplyWithText);
|
|
277
281
|
const [reclassifyOpen, setReclassifyOpen] = useState(false);
|
|
278
282
|
const senderEmail = thread.fromEmail ?? undefined;
|
|
279
283
|
|
|
@@ -432,6 +436,7 @@ export const IntelligencePane = ({
|
|
|
432
436
|
accountId,
|
|
433
437
|
hideCloseButton,
|
|
434
438
|
onAfterOptimisticRemove,
|
|
439
|
+
onReplyWithText,
|
|
435
440
|
}: IntelligencePaneProps) => {
|
|
436
441
|
if (!thread) {
|
|
437
442
|
return (
|
|
@@ -461,6 +466,7 @@ export const IntelligencePane = ({
|
|
|
461
466
|
accountId={accountId}
|
|
462
467
|
hideCloseButton={hideCloseButton}
|
|
463
468
|
onAfterOptimisticRemove={onAfterOptimisticRemove}
|
|
469
|
+
onReplyWithText={onReplyWithText}
|
|
464
470
|
/>
|
|
465
471
|
);
|
|
466
472
|
};
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
useRef,
|
|
46
46
|
useState,
|
|
47
47
|
} from "react";
|
|
48
|
+
import { useReplyWithText } from "@/components/compose/reply-with-times";
|
|
48
49
|
import { ConversationView } from "@/components/mail/ConversationView";
|
|
49
50
|
import { DraftsView } from "@/components/mail/DraftsView";
|
|
50
51
|
import { EmptyTrashBar } from "@/components/mail/EmptyTrashBar";
|
|
@@ -1115,6 +1116,7 @@ function MailboxList() {
|
|
|
1115
1116
|
* Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
|
|
1116
1117
|
*/
|
|
1117
1118
|
function MailboxReading() {
|
|
1119
|
+
const replyWithText = useReplyWithText();
|
|
1118
1120
|
const {
|
|
1119
1121
|
mailboxId,
|
|
1120
1122
|
mailboxAccountId,
|
|
@@ -1204,6 +1206,7 @@ function MailboxReading() {
|
|
|
1204
1206
|
mailboxId={mailboxId}
|
|
1205
1207
|
accountId={mailboxAccountId}
|
|
1206
1208
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
1209
|
+
onReplyWithText={replyWithText}
|
|
1207
1210
|
/>
|
|
1208
1211
|
</>
|
|
1209
1212
|
);
|
|
@@ -1214,6 +1217,7 @@ function MailboxReading() {
|
|
|
1214
1217
|
* Mount in the `intelligence` slot of `AppShellSlotted`. Only rendered ≥ 1280px.
|
|
1215
1218
|
*/
|
|
1216
1219
|
function MailboxIntelligence() {
|
|
1220
|
+
const replyWithText = useReplyWithText();
|
|
1217
1221
|
const {
|
|
1218
1222
|
mailboxId,
|
|
1219
1223
|
mailboxAccountId,
|
|
@@ -1229,6 +1233,7 @@ function MailboxIntelligence() {
|
|
|
1229
1233
|
mailboxId={mailboxId}
|
|
1230
1234
|
accountId={mailboxAccountId}
|
|
1231
1235
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
1236
|
+
onReplyWithText={replyWithText}
|
|
1232
1237
|
/>
|
|
1233
1238
|
);
|
|
1234
1239
|
}
|
|
@@ -1238,6 +1243,7 @@ function MailboxIntelligence() {
|
|
|
1238
1243
|
* Use this on phones instead of the slot sub-views.
|
|
1239
1244
|
*/
|
|
1240
1245
|
function MailboxPhone() {
|
|
1246
|
+
const replyWithText = useReplyWithText();
|
|
1241
1247
|
const {
|
|
1242
1248
|
mailboxId,
|
|
1243
1249
|
mailboxAccountId,
|
|
@@ -1281,6 +1287,7 @@ function MailboxPhone() {
|
|
|
1281
1287
|
mailboxId={mailboxId}
|
|
1282
1288
|
accountId={mailboxAccountId}
|
|
1283
1289
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
1290
|
+
onReplyWithText={replyWithText}
|
|
1284
1291
|
/>
|
|
1285
1292
|
</>
|
|
1286
1293
|
);
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, before, describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
RemitImapCalendarResponse,
|
|
5
|
+
RemitImapCalendarSuggestionResponse,
|
|
6
|
+
RemitImapDescribeMessageResponse,
|
|
7
|
+
RemitImapThreadMessageResponse,
|
|
8
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
9
|
+
import { type IntelligenceData, IntelligencePanel } from "@remit/ui";
|
|
10
|
+
import {
|
|
11
|
+
type AnyRouter,
|
|
12
|
+
createMemoryHistory,
|
|
13
|
+
RouterContextProvider,
|
|
14
|
+
} from "@tanstack/react-router";
|
|
15
|
+
import { createElement } from "react";
|
|
16
|
+
import { ComposeProvider } from "@/components/compose/ComposeProvider";
|
|
17
|
+
import { useReplyWithText } from "@/components/compose/reply-with-times";
|
|
18
|
+
import { useIntelligenceCalendar } from "@/hooks/useIntelligenceCalendar";
|
|
19
|
+
import { noopTelemetry } from "@/lib/telemetry";
|
|
20
|
+
import { createAppRouter } from "@/router";
|
|
21
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
22
|
+
import {
|
|
23
|
+
makeAccount,
|
|
24
|
+
makeMailbox,
|
|
25
|
+
makeThreadMessage,
|
|
26
|
+
} from "@/test-support/fixtures";
|
|
27
|
+
import { type HttpCall, type HttpMock, mockFetch } from "@/test-support/http";
|
|
28
|
+
import { ConversationView } from "./ConversationView";
|
|
29
|
+
|
|
30
|
+
const ACCOUNT_ID = "acc-1";
|
|
31
|
+
const MAILBOX_ID = "mbx-inbox";
|
|
32
|
+
const THREAD_ID = "thread-1";
|
|
33
|
+
const MESSAGE_ID = "msg-1";
|
|
34
|
+
const OTHER_ID = "msg-2";
|
|
35
|
+
const DRAFT_ID = "ob-times";
|
|
36
|
+
const CALENDAR = "11111111-1111-4111-8111-111111111111";
|
|
37
|
+
const SUGGESTION = "22222222-2222-4222-8222-222222222222";
|
|
38
|
+
const AUTOSAVE_DEBOUNCE_MS = 2000;
|
|
39
|
+
|
|
40
|
+
const account = makeAccount({
|
|
41
|
+
accountId: ACCOUNT_ID,
|
|
42
|
+
email: "me@example.com",
|
|
43
|
+
username: "me@example.com",
|
|
44
|
+
smtpUsername: "me@example.com",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const inbox = makeMailbox({
|
|
48
|
+
mailboxId: MAILBOX_ID,
|
|
49
|
+
accountId: ACCOUNT_ID,
|
|
50
|
+
fullPath: "INBOX",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const thread: RemitImapThreadMessageResponse = makeThreadMessage({
|
|
54
|
+
messageId: MESSAGE_ID,
|
|
55
|
+
threadId: THREAD_ID,
|
|
56
|
+
mailboxId: MAILBOX_ID,
|
|
57
|
+
accountId: ACCOUNT_ID,
|
|
58
|
+
subject: "Quarterly review",
|
|
59
|
+
fromName: "Organizer",
|
|
60
|
+
fromEmail: "organizer@example.test",
|
|
61
|
+
isRead: true,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const otherThread: RemitImapThreadMessageResponse = {
|
|
65
|
+
...thread,
|
|
66
|
+
messageId: OTHER_ID,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const describeMessage: RemitImapDescribeMessageResponse = {
|
|
70
|
+
message: {
|
|
71
|
+
messageId: MESSAGE_ID,
|
|
72
|
+
mailboxId: MAILBOX_ID,
|
|
73
|
+
uid: 1,
|
|
74
|
+
rfc822Size: 512,
|
|
75
|
+
internalDate: 1_767_225_600_000,
|
|
76
|
+
status: "active",
|
|
77
|
+
syncStatus: "pending",
|
|
78
|
+
abandonedMutation: "none",
|
|
79
|
+
},
|
|
80
|
+
envelope: {
|
|
81
|
+
messageId: MESSAGE_ID,
|
|
82
|
+
date: 1_767_225_600_000,
|
|
83
|
+
subject: "Quarterly review",
|
|
84
|
+
messageIdValue: "<review-1@example.test>",
|
|
85
|
+
from: [
|
|
86
|
+
{
|
|
87
|
+
addressId: "addr-organizer",
|
|
88
|
+
displayName: "Organizer",
|
|
89
|
+
normalizedEmail: "organizer@example.test",
|
|
90
|
+
addressRole: "from",
|
|
91
|
+
addressOrder: 0,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
to: [
|
|
95
|
+
{
|
|
96
|
+
addressId: "addr-me",
|
|
97
|
+
normalizedEmail: "me@example.com",
|
|
98
|
+
addressRole: "to",
|
|
99
|
+
addressOrder: 0,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
cc: [],
|
|
103
|
+
bcc: [],
|
|
104
|
+
replyTo: [],
|
|
105
|
+
category: "uncategorized",
|
|
106
|
+
senderTrust: "unknown",
|
|
107
|
+
},
|
|
108
|
+
flags: ["\\Seen"],
|
|
109
|
+
bodyParts: [],
|
|
110
|
+
references: [],
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const calendars = [
|
|
114
|
+
{
|
|
115
|
+
calendarId: CALENDAR,
|
|
116
|
+
accountConfigId: "cfg-1",
|
|
117
|
+
urlSegment: "default",
|
|
118
|
+
displayName: "Personal",
|
|
119
|
+
color: "Cal2",
|
|
120
|
+
componentSet: "VeventOnly",
|
|
121
|
+
source: "Default",
|
|
122
|
+
timezone: "Europe/Amsterdam",
|
|
123
|
+
syncSequence: 1,
|
|
124
|
+
subscriptionUrl: "",
|
|
125
|
+
subscriptionEnabled: false,
|
|
126
|
+
subscriptionCheckedAt: 0,
|
|
127
|
+
subscriptionFetchedAt: 0,
|
|
128
|
+
subscriptionError: "",
|
|
129
|
+
createdAt: 0,
|
|
130
|
+
updatedAt: 0,
|
|
131
|
+
},
|
|
132
|
+
] satisfies RemitImapCalendarResponse[];
|
|
133
|
+
|
|
134
|
+
const invitation: RemitImapCalendarSuggestionResponse = {
|
|
135
|
+
suggestionId: SUGGESTION,
|
|
136
|
+
accountConfigId: "cfg-1",
|
|
137
|
+
messageId: MESSAGE_ID,
|
|
138
|
+
bodyPartId: "part-1",
|
|
139
|
+
icalUid: "uid-1",
|
|
140
|
+
sequence: 0,
|
|
141
|
+
method: "Request",
|
|
142
|
+
source: "IcalendarPart",
|
|
143
|
+
state: "Pending",
|
|
144
|
+
summary: "Quarterly review",
|
|
145
|
+
dtStart: "2026-09-01T08:00:00+00:00",
|
|
146
|
+
dtEnd: "2026-09-01T09:00:00+00:00",
|
|
147
|
+
allDay: false,
|
|
148
|
+
location: "",
|
|
149
|
+
organizer: "organizer@example.test",
|
|
150
|
+
zoneCertainty: "Explicit",
|
|
151
|
+
acceptedCalendarObjectId: "",
|
|
152
|
+
createdAt: 0,
|
|
153
|
+
updatedAt: 0,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const sender: IntelligenceData = {
|
|
157
|
+
sender: {
|
|
158
|
+
name: "Organizer",
|
|
159
|
+
email: "organizer@example.test",
|
|
160
|
+
trust: "wellknown",
|
|
161
|
+
firstSeenLabel: "Jan 2025",
|
|
162
|
+
},
|
|
163
|
+
authenticity: {
|
|
164
|
+
verdict: "aligned",
|
|
165
|
+
fromDomain: "example.test",
|
|
166
|
+
summary: "Signed by example.test.",
|
|
167
|
+
},
|
|
168
|
+
category: { value: "personal" },
|
|
169
|
+
similar: [],
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
let harness: DomHarness | undefined;
|
|
173
|
+
let http: HttpMock | undefined;
|
|
174
|
+
|
|
175
|
+
before(() => {
|
|
176
|
+
Object.defineProperty(Range.prototype, "getBoundingClientRect", {
|
|
177
|
+
value: () => ({
|
|
178
|
+
top: 0,
|
|
179
|
+
bottom: 0,
|
|
180
|
+
left: 0,
|
|
181
|
+
right: 0,
|
|
182
|
+
width: 0,
|
|
183
|
+
height: 0,
|
|
184
|
+
x: 0,
|
|
185
|
+
y: 0,
|
|
186
|
+
}),
|
|
187
|
+
configurable: true,
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
afterEach(() => {
|
|
192
|
+
harness?.close();
|
|
193
|
+
harness = undefined;
|
|
194
|
+
http?.restore();
|
|
195
|
+
http = undefined;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
199
|
+
|
|
200
|
+
const MESSAGE_PATH = `/mail/${MAILBOX_ID}/${THREAD_ID}/${MESSAGE_ID}`;
|
|
201
|
+
|
|
202
|
+
const describeOther: RemitImapDescribeMessageResponse = {
|
|
203
|
+
...describeMessage,
|
|
204
|
+
message: { ...describeMessage.message, messageId: OTHER_ID, uid: 2 },
|
|
205
|
+
envelope: {
|
|
206
|
+
...describeMessage.envelope,
|
|
207
|
+
messageId: OTHER_ID,
|
|
208
|
+
messageIdValue: "<review-2@example.test>",
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const CalendarRail = ({
|
|
213
|
+
railThread,
|
|
214
|
+
}: {
|
|
215
|
+
railThread: RemitImapThreadMessageResponse;
|
|
216
|
+
}) => {
|
|
217
|
+
const calendar = useIntelligenceCalendar(railThread, useReplyWithText());
|
|
218
|
+
return createElement(IntelligencePanel, {
|
|
219
|
+
data: sender,
|
|
220
|
+
calendar: calendar.surface,
|
|
221
|
+
tab: calendar.tab,
|
|
222
|
+
onTabChange: calendar.onTabChange,
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const Surface = ({
|
|
227
|
+
railThread,
|
|
228
|
+
}: {
|
|
229
|
+
railThread: RemitImapThreadMessageResponse;
|
|
230
|
+
}) =>
|
|
231
|
+
createElement(
|
|
232
|
+
ComposeProvider,
|
|
233
|
+
null,
|
|
234
|
+
createElement(ConversationView, {
|
|
235
|
+
threadId: THREAD_ID,
|
|
236
|
+
mailboxId: MAILBOX_ID,
|
|
237
|
+
subject: "Quarterly review",
|
|
238
|
+
selectedMessageId: MESSAGE_ID,
|
|
239
|
+
}),
|
|
240
|
+
createElement(CalendarRail, { railThread }),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const savedDraft = {
|
|
244
|
+
outboxMessageId: DRAFT_ID,
|
|
245
|
+
accountId: ACCOUNT_ID,
|
|
246
|
+
fromAddress: "me@example.com",
|
|
247
|
+
toAddresses: ["organizer@example.test"],
|
|
248
|
+
ccAddresses: [],
|
|
249
|
+
bccAddresses: [],
|
|
250
|
+
attachments: [],
|
|
251
|
+
references: [],
|
|
252
|
+
subject: "Re: Quarterly review",
|
|
253
|
+
textBody: "See you then",
|
|
254
|
+
htmlBody: "<p>See you then</p>",
|
|
255
|
+
status: "draft",
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const creates = (): HttpCall[] =>
|
|
259
|
+
(http?.calls ?? []).filter(
|
|
260
|
+
(call) => call.method === "POST" && call.path.endsWith("/outbox"),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const mount = async (
|
|
264
|
+
href: string,
|
|
265
|
+
{
|
|
266
|
+
railThread = thread,
|
|
267
|
+
draftArrives = Promise.resolve(),
|
|
268
|
+
}: {
|
|
269
|
+
railThread?: RemitImapThreadMessageResponse;
|
|
270
|
+
draftArrives?: Promise<void>;
|
|
271
|
+
} = {},
|
|
272
|
+
): Promise<{ mounted: DomHarness; router: AnyRouter; redraw: () => void }> => {
|
|
273
|
+
let saved: Record<string, unknown> = savedDraft;
|
|
274
|
+
http = mockFetch(async (call) => {
|
|
275
|
+
if (call.path.endsWith("/config")) return { accounts: [account] };
|
|
276
|
+
if (call.path.endsWith(`/accounts/${ACCOUNT_ID}/mailboxes`))
|
|
277
|
+
return { items: [inbox] };
|
|
278
|
+
if (call.path.endsWith(`/threads/${THREAD_ID}/messages`))
|
|
279
|
+
return { items: [thread, otherThread] };
|
|
280
|
+
if (call.path.endsWith(`/messages/${MESSAGE_ID}`)) return describeMessage;
|
|
281
|
+
if (call.path.endsWith(`/messages/${OTHER_ID}`)) return describeOther;
|
|
282
|
+
if (call.path.endsWith(`/messages/${OTHER_ID}/calendar-suggestions`))
|
|
283
|
+
return { items: [{ ...invitation, messageId: OTHER_ID }] };
|
|
284
|
+
if (call.path.endsWith("/calendars")) return { items: calendars };
|
|
285
|
+
if (call.path.endsWith(`/messages/${MESSAGE_ID}/calendar-suggestions`))
|
|
286
|
+
return { items: [invitation] };
|
|
287
|
+
if (call.method === "POST" && call.path.endsWith("/outbox")) {
|
|
288
|
+
saved = {
|
|
289
|
+
...savedDraft,
|
|
290
|
+
...(call.body as Record<string, unknown>),
|
|
291
|
+
};
|
|
292
|
+
return saved;
|
|
293
|
+
}
|
|
294
|
+
if (call.path.endsWith(`/outbox/${DRAFT_ID}`)) {
|
|
295
|
+
await draftArrives;
|
|
296
|
+
return saved;
|
|
297
|
+
}
|
|
298
|
+
return { items: [] };
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const mounted = createDomHarness();
|
|
302
|
+
harness = mounted;
|
|
303
|
+
const router = createAppRouter(
|
|
304
|
+
mounted.queryClient,
|
|
305
|
+
noopTelemetry,
|
|
306
|
+
createMemoryHistory({ initialEntries: [href] }),
|
|
307
|
+
) as unknown as AnyRouter;
|
|
308
|
+
await router.load();
|
|
309
|
+
const redraw = () =>
|
|
310
|
+
mounted.renderApp(
|
|
311
|
+
createElement(RouterContextProvider, {
|
|
312
|
+
router,
|
|
313
|
+
// biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
|
|
314
|
+
children: createElement(Surface, { railThread }),
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
redraw();
|
|
318
|
+
await mounted.waitFor(
|
|
319
|
+
() => mounted.text().includes("Offer other times"),
|
|
320
|
+
"the invitation card to be drawn",
|
|
321
|
+
);
|
|
322
|
+
return { mounted, router, redraw };
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const drawnUntil = async (
|
|
326
|
+
mounted: DomHarness,
|
|
327
|
+
redraw: () => void,
|
|
328
|
+
predicate: () => boolean,
|
|
329
|
+
description: string,
|
|
330
|
+
): Promise<void> => {
|
|
331
|
+
const deadline = Date.now() + 5000;
|
|
332
|
+
while (!predicate()) {
|
|
333
|
+
if (Date.now() > deadline)
|
|
334
|
+
throw new Error(`gave up waiting for ${description}`);
|
|
335
|
+
redraw();
|
|
336
|
+
await mounted.wait(50);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const composerText = (mounted: DomHarness): string =>
|
|
341
|
+
mounted.query("[data-testid=compose-body]")?.textContent ?? "";
|
|
342
|
+
|
|
343
|
+
const plainBody = (mounted: DomHarness): HTMLTextAreaElement | null =>
|
|
344
|
+
mounted.query<HTMLTextAreaElement>("[data-testid=compose-body-plain]");
|
|
345
|
+
|
|
346
|
+
const freeSlot = (mounted: DomHarness): HTMLElement | undefined =>
|
|
347
|
+
mounted
|
|
348
|
+
.queryAll("button[aria-pressed]")
|
|
349
|
+
.find((button) =>
|
|
350
|
+
/\d{2}:\d{2} – \d{2}:\d{2}/.test(button.textContent ?? ""),
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
const pickAndReply = async (mounted: DomHarness): Promise<string> => {
|
|
354
|
+
mounted.click(mounted.byText("button", "Offer other times"));
|
|
355
|
+
await mounted.waitFor(
|
|
356
|
+
() => freeSlot(mounted) !== undefined,
|
|
357
|
+
"free half-hours to be offered",
|
|
358
|
+
);
|
|
359
|
+
const slot = freeSlot(mounted);
|
|
360
|
+
assert.ok(slot, "a free half-hour is offered");
|
|
361
|
+
const picked = (slot.textContent ?? "").trim();
|
|
362
|
+
mounted.click(slot);
|
|
363
|
+
await mounted.flush();
|
|
364
|
+
mounted.click(mounted.byText("button", "Reply with these times"));
|
|
365
|
+
return picked;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const seedInAddress = (router: AnyRouter): unknown =>
|
|
369
|
+
(router.state.location.search as Record<string, unknown>).body;
|
|
370
|
+
|
|
371
|
+
describe("offering other times from an invitation", () => {
|
|
372
|
+
it("opens the reply with the picked times written into it", async () => {
|
|
373
|
+
const { mounted, router, redraw } = await mount(MESSAGE_PATH);
|
|
374
|
+
|
|
375
|
+
const picked = await pickAndReply(mounted);
|
|
376
|
+
|
|
377
|
+
await drawnUntil(
|
|
378
|
+
mounted,
|
|
379
|
+
redraw,
|
|
380
|
+
() => composerText(mounted).includes(picked),
|
|
381
|
+
"the picked time to be written into the reply",
|
|
382
|
+
);
|
|
383
|
+
assert.match(composerText(mounted), /September/);
|
|
384
|
+
assert.equal(router.state.location.pathname, `${MESSAGE_PATH}/reply`);
|
|
385
|
+
|
|
386
|
+
await mounted.wait(AUTOSAVE_DEBOUNCE_MS + 300);
|
|
387
|
+
await mounted.waitFor(
|
|
388
|
+
() => creates().length === 1,
|
|
389
|
+
"the reply to be saved as a draft",
|
|
390
|
+
);
|
|
391
|
+
const draft = creates()[0].body as { textBody?: string };
|
|
392
|
+
assert.ok(
|
|
393
|
+
draft.textBody?.includes(picked),
|
|
394
|
+
`the saved draft holds the picked time, got ${JSON.stringify(draft.textBody)}`,
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
await drawnUntil(
|
|
398
|
+
mounted,
|
|
399
|
+
redraw,
|
|
400
|
+
() => router.state.location.pathname.endsWith(`/reply/${DRAFT_ID}`),
|
|
401
|
+
"the address to adopt the draft",
|
|
402
|
+
);
|
|
403
|
+
assert.equal(
|
|
404
|
+
seedInAddress(router),
|
|
405
|
+
undefined,
|
|
406
|
+
"once the draft holds the times, the address no longer carries them",
|
|
407
|
+
);
|
|
408
|
+
await drawnUntil(
|
|
409
|
+
mounted,
|
|
410
|
+
redraw,
|
|
411
|
+
() => composerText(mounted).includes(picked),
|
|
412
|
+
"the composer to keep the times once the draft is adopted",
|
|
413
|
+
);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("keeps what was typed when the times go into a reply already open", async () => {
|
|
417
|
+
const { mounted, router } = await mount(`${MESSAGE_PATH}/reply`);
|
|
418
|
+
await mounted.waitFor(
|
|
419
|
+
() => mounted.query("[data-testid=compose-mode-toggle]") !== null,
|
|
420
|
+
"the reply composer to open",
|
|
421
|
+
5000,
|
|
422
|
+
);
|
|
423
|
+
const toggle = mounted.query("[data-testid=compose-mode-toggle]");
|
|
424
|
+
assert.ok(toggle);
|
|
425
|
+
mounted.click(toggle);
|
|
426
|
+
await mounted.waitFor(
|
|
427
|
+
() => plainBody(mounted) !== null,
|
|
428
|
+
"the plain writing surface",
|
|
429
|
+
);
|
|
430
|
+
const typing = plainBody(mounted);
|
|
431
|
+
assert.ok(typing);
|
|
432
|
+
mounted.type(typing, "Hi");
|
|
433
|
+
await mounted.flush();
|
|
434
|
+
|
|
435
|
+
const picked = await pickAndReply(mounted);
|
|
436
|
+
|
|
437
|
+
await mounted.waitFor(
|
|
438
|
+
() => plainBody(mounted)?.value.includes(picked) === true,
|
|
439
|
+
"the picked time to go into the open reply",
|
|
440
|
+
);
|
|
441
|
+
assert.match(plainBody(mounted)?.value ?? "", /^Hi/);
|
|
442
|
+
assert.equal(router.state.location.pathname, `${MESSAGE_PATH}/reply`);
|
|
443
|
+
assert.equal(seedInAddress(router), undefined);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("keeps an open reply-all a reply-all and adds the times to it", async () => {
|
|
447
|
+
const href = `${MESSAGE_PATH}/reply-all/${DRAFT_ID}`;
|
|
448
|
+
const { mounted, router } = await mount(href);
|
|
449
|
+
await mounted.waitFor(
|
|
450
|
+
() => composerText(mounted).includes("See you then"),
|
|
451
|
+
"the saved reply-all to reopen",
|
|
452
|
+
5000,
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
const picked = await pickAndReply(mounted);
|
|
456
|
+
|
|
457
|
+
await mounted.waitFor(
|
|
458
|
+
() => composerText(mounted).includes(picked),
|
|
459
|
+
"the picked time to go into the open reply-all",
|
|
460
|
+
);
|
|
461
|
+
assert.ok(composerText(mounted).includes("See you then"));
|
|
462
|
+
assert.equal(router.state.location.pathname, href);
|
|
463
|
+
assert.equal(seedInAddress(router), undefined);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("adds the times to a forward of the same message already open", async () => {
|
|
467
|
+
const href = `${MESSAGE_PATH}/forward`;
|
|
468
|
+
const { mounted, router } = await mount(href);
|
|
469
|
+
await mounted.waitFor(
|
|
470
|
+
() => mounted.query("[data-testid=compose-body]") !== null,
|
|
471
|
+
"the forward to open",
|
|
472
|
+
5000,
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
const picked = await pickAndReply(mounted);
|
|
476
|
+
|
|
477
|
+
await mounted.waitFor(
|
|
478
|
+
() => composerText(mounted).includes(picked),
|
|
479
|
+
"the picked time to go into the open forward",
|
|
480
|
+
);
|
|
481
|
+
assert.equal(router.state.location.pathname, href);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("opens a reply holding the times when the open reply answers another message", async () => {
|
|
485
|
+
const { mounted, router, redraw } = await mount(`${MESSAGE_PATH}/reply`, {
|
|
486
|
+
railThread: otherThread,
|
|
487
|
+
});
|
|
488
|
+
await mounted.waitFor(
|
|
489
|
+
() => mounted.query("[data-testid=compose-body]") !== null,
|
|
490
|
+
"the reply to the first message to open",
|
|
491
|
+
5000,
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
const picked = await pickAndReply(mounted);
|
|
495
|
+
|
|
496
|
+
await drawnUntil(
|
|
497
|
+
mounted,
|
|
498
|
+
redraw,
|
|
499
|
+
() => composerText(mounted).includes(picked),
|
|
500
|
+
"a reply to the other message holding the times",
|
|
501
|
+
);
|
|
502
|
+
assert.equal(
|
|
503
|
+
router.state.location.pathname,
|
|
504
|
+
`/mail/${MAILBOX_ID}/${THREAD_ID}/${OTHER_ID}/reply`,
|
|
505
|
+
);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("adds the times on top of a draft still loading when they were picked", async () => {
|
|
509
|
+
let deliver = () => {};
|
|
510
|
+
const draftArrives = new Promise<void>((resolve) => {
|
|
511
|
+
deliver = resolve;
|
|
512
|
+
});
|
|
513
|
+
const { mounted } = await mount(`${MESSAGE_PATH}/reply-all/${DRAFT_ID}`, {
|
|
514
|
+
draftArrives,
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
const picked = await pickAndReply(mounted);
|
|
518
|
+
await mounted.wait(100);
|
|
519
|
+
deliver();
|
|
520
|
+
|
|
521
|
+
await mounted.waitFor(
|
|
522
|
+
() =>
|
|
523
|
+
composerText(mounted).includes("See you then") &&
|
|
524
|
+
composerText(mounted).includes(picked),
|
|
525
|
+
"the loaded draft to hold the picked time",
|
|
526
|
+
5000,
|
|
527
|
+
);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
@@ -218,25 +218,6 @@ describe("the invitation beside the open message", () => {
|
|
|
218
218
|
);
|
|
219
219
|
});
|
|
220
220
|
|
|
221
|
-
it("offers the picked times to select by hand where the page has no clipboard", async () => {
|
|
222
|
-
await mount(server(() => "Accepted"));
|
|
223
|
-
Object.defineProperty(navigator, "clipboard", {
|
|
224
|
-
value: undefined,
|
|
225
|
-
configurable: true,
|
|
226
|
-
});
|
|
227
|
-
press("Offer other times");
|
|
228
|
-
const slot = harness?.query("[aria-pressed]");
|
|
229
|
-
if (!slot) throw new Error("no slot offered");
|
|
230
|
-
harness?.click(slot);
|
|
231
|
-
press("Copy picked times");
|
|
232
|
-
|
|
233
|
-
await harness?.waitFor(
|
|
234
|
-
() => harness?.query('textarea[aria-label="Picked times"]') !== null,
|
|
235
|
-
"the times to be offered for selecting",
|
|
236
|
-
);
|
|
237
|
-
assert.match(harness?.text() ?? "", /can't copy for you/);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
221
|
it("declines through the API", async () => {
|
|
241
222
|
await mount(server(() => "Declined"));
|
|
242
223
|
press("Decline");
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
calendarUnavailable,
|
|
23
23
|
calendarWriteGate,
|
|
24
24
|
} from "@/components/calendar/CalendarUnavailable";
|
|
25
|
+
import type { ReplyWithText } from "@/components/compose/reply-with-times";
|
|
25
26
|
import {
|
|
26
27
|
calendarWindowOfDays,
|
|
27
28
|
deviceTimeZone,
|
|
@@ -57,7 +58,6 @@ interface Working {
|
|
|
57
58
|
tab: IntelligenceTabId | undefined;
|
|
58
59
|
offering: boolean;
|
|
59
60
|
picked: string[];
|
|
60
|
-
copy: "idle" | "copied" | "failed";
|
|
61
61
|
answering: string;
|
|
62
62
|
/** The card the last refusal was about, and what it said. */
|
|
63
63
|
failure: { suggestionId: string; text: string };
|
|
@@ -68,7 +68,6 @@ const fresh = (messageId: string): Working => ({
|
|
|
68
68
|
tab: undefined,
|
|
69
69
|
offering: false,
|
|
70
70
|
picked: [],
|
|
71
|
-
copy: "idle",
|
|
72
71
|
answering: "",
|
|
73
72
|
failure: { suggestionId: "", text: "" },
|
|
74
73
|
});
|
|
@@ -92,6 +91,7 @@ export interface IntelligenceCalendar {
|
|
|
92
91
|
|
|
93
92
|
export function useIntelligenceCalendar(
|
|
94
93
|
thread: RemitImapThreadMessageResponse,
|
|
94
|
+
replyWithText?: ReplyWithText,
|
|
95
95
|
): IntelligenceCalendar {
|
|
96
96
|
const messageId = thread.messageId;
|
|
97
97
|
const { suggestions, isLoading, error } =
|
|
@@ -238,7 +238,8 @@ export function useIntelligenceCalendar(
|
|
|
238
238
|
: "Couldn't read the invitations in this message. Reopen it to try again.";
|
|
239
239
|
|
|
240
240
|
const cancellation = invitation?.method === "Cancel";
|
|
241
|
-
const
|
|
241
|
+
const pickedText =
|
|
242
|
+
date === "" ? "" : slotsAsText(date, slots, working.picked);
|
|
242
243
|
|
|
243
244
|
const actions: IntelligenceCalendarActions = {
|
|
244
245
|
onAddInvite: () =>
|
|
@@ -272,24 +273,14 @@ export function useIntelligenceCalendar(
|
|
|
272
273
|
onOfferOtherTimes: () => update({ offering: true }),
|
|
273
274
|
onToggleSlot: (slot) =>
|
|
274
275
|
update({
|
|
275
|
-
copy: "idle",
|
|
276
276
|
picked: working.picked.includes(slot.startTime)
|
|
277
277
|
? working.picked.filter((start) => start !== slot.startTime)
|
|
278
278
|
: [...working.picked, slot.startTime],
|
|
279
279
|
}),
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (!clipboard) {
|
|
285
|
-
update({ copy: "failed" });
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
void Promise.resolve()
|
|
289
|
-
.then(() => clipboard.writeText(copyText))
|
|
290
|
-
.then(() => update({ copy: "copied" }))
|
|
291
|
-
.catch(() => update({ copy: "failed" }));
|
|
292
|
-
},
|
|
280
|
+
onReplyWithSlots: replyWithText
|
|
281
|
+
? () =>
|
|
282
|
+
replyWithText({ threadId: thread.threadId, messageId }, pickedText)
|
|
283
|
+
: undefined,
|
|
293
284
|
onAddSuggestion: (suggestionId) =>
|
|
294
285
|
answer(suggestionId, "add this to your calendar", () =>
|
|
295
286
|
answers.accept(suggestionId, defaultCalendarId),
|
|
@@ -334,8 +325,6 @@ export function useIntelligenceCalendar(
|
|
|
334
325
|
proposals: [],
|
|
335
326
|
slots,
|
|
336
327
|
picked: working.picked,
|
|
337
|
-
copy: working.copy,
|
|
338
|
-
copyText,
|
|
339
328
|
}
|
|
340
329
|
: undefined,
|
|
341
330
|
suggestions: deck,
|
package/src/lib/mail-search.ts
CHANGED
|
@@ -50,3 +50,20 @@ export const mailListSearchSchemas = {
|
|
|
50
50
|
"/mail/outbox": outboxSearchSchema,
|
|
51
51
|
"/mail/$mailboxId": mailboxSearchSchema,
|
|
52
52
|
} as const;
|
|
53
|
+
|
|
54
|
+
export const composeSeedSearch = z.object({ body: z.string().optional() });
|
|
55
|
+
|
|
56
|
+
export type ComposeSeed = Required<z.infer<typeof composeSeedSearch>>;
|
|
57
|
+
|
|
58
|
+
export const withoutComposeSeed = (
|
|
59
|
+
prev: Record<string, unknown>,
|
|
60
|
+
): Record<string, unknown> => {
|
|
61
|
+
const { body: _seed, ...rest } = prev;
|
|
62
|
+
return rest;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const replySearchSchemas = {
|
|
66
|
+
brief: briefSearchSchema.extend(composeSeedSearch.shape),
|
|
67
|
+
flagged: flaggedSearchSchema.extend(composeSeedSearch.shape),
|
|
68
|
+
mailbox: mailboxSearchSchema.extend(composeSeedSearch.shape),
|
|
69
|
+
} as const;
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* The conversation reads it off the address and opens it at its head.
|
|
9
9
|
*/
|
|
10
10
|
import { createFileRoute } from "@tanstack/react-router";
|
|
11
|
+
import { replySearchSchemas } from "@/lib/mail-search";
|
|
11
12
|
|
|
12
13
|
export const Route = createFileRoute(
|
|
13
14
|
"/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}",
|
|
14
|
-
)({});
|
|
15
|
+
)({ validateSearch: replySearchSchemas.mailbox });
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
* the pane reads the reply off the address, the way the shell reads compose.
|
|
21
21
|
*/
|
|
22
22
|
import { createFileRoute } from "@tanstack/react-router";
|
|
23
|
+
import { replySearchSchemas } from "@/lib/mail-search";
|
|
23
24
|
|
|
24
25
|
export const Route = createFileRoute(
|
|
25
26
|
"/mail/brief/$threadId/$messageId/$mode/{-$outboxMessageId}",
|
|
26
|
-
)({});
|
|
27
|
+
)({ validateSearch: replySearchSchemas.brief });
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* The conversation reads it off the address and opens it at its head.
|
|
9
9
|
*/
|
|
10
10
|
import { createFileRoute } from "@tanstack/react-router";
|
|
11
|
+
import { replySearchSchemas } from "@/lib/mail-search";
|
|
11
12
|
|
|
12
13
|
export const Route = createFileRoute(
|
|
13
14
|
"/mail/flagged/$threadId/$messageId/$mode/{-$outboxMessageId}",
|
|
14
|
-
)({});
|
|
15
|
+
)({ validateSearch: replySearchSchemas.flagged });
|
package/src/routing/compose.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useNavigate, useParams } from "@tanstack/react-router";
|
|
2
2
|
import { useCallback } from "react";
|
|
3
|
+
import { withoutComposeSeed } from "@/lib/mail-search";
|
|
3
4
|
import { useBrowsedList, useNavigateToBrowsedList } from "./browsed-list";
|
|
4
5
|
import { useRetainOpenPanels } from "./fragment";
|
|
5
6
|
|
|
@@ -108,7 +109,7 @@ function useComposeNavigate(): (
|
|
|
108
109
|
) => {
|
|
109
110
|
navigate({
|
|
110
111
|
...composeTarget(list, mailboxId, outboxMessageId),
|
|
111
|
-
search:
|
|
112
|
+
search: withoutComposeSeed,
|
|
112
113
|
// Opening or closing the surface is going somewhere, so the panes the
|
|
113
114
|
// reader keeps up travel and the overlays they were reading over do
|
|
114
115
|
// not. Recording the draft is not going anywhere — the address is
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useNavigate, useParams } from "@tanstack/react-router";
|
|
2
2
|
import { useCallback } from "react";
|
|
3
|
+
import { withoutComposeSeed } from "@/lib/mail-search";
|
|
3
4
|
import { useNavigateToBrowsedList } from "./browsed-list";
|
|
4
5
|
import { useRetainOpenPanels } from "./fragment";
|
|
5
6
|
|
|
@@ -29,7 +30,7 @@ export function useOpenOutboxDraft(): (outboxMessageId: string) => void {
|
|
|
29
30
|
navigate({
|
|
30
31
|
to: "/mail/outbox/draft/$outboxMessageId",
|
|
31
32
|
params: { outboxMessageId },
|
|
32
|
-
search:
|
|
33
|
+
search: withoutComposeSeed,
|
|
33
34
|
hash: retainPanels,
|
|
34
35
|
});
|
|
35
36
|
},
|
package/src/routing/reply.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { useNavigate, useParams } from "@tanstack/react-router";
|
|
1
|
+
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
|
|
2
2
|
import { useCallback, useMemo } from "react";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import type { MailListRoute } from "@/lib/mail-route";
|
|
5
|
+
import { type ComposeSeed, withoutComposeSeed } from "@/lib/mail-search";
|
|
5
6
|
import { useBrowsedList } from "./browsed-list";
|
|
6
7
|
import { useRetainOpenPanels } from "./fragment";
|
|
7
8
|
|
|
@@ -53,6 +54,7 @@ export interface ReplyAddress {
|
|
|
53
54
|
sourceMessageId: string;
|
|
54
55
|
/** The draft it writes to, once the first autosave has made one. */
|
|
55
56
|
outboxMessageId: string | undefined;
|
|
57
|
+
seed: ComposeSeed | undefined;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
export type ReplySurface =
|
|
@@ -79,6 +81,20 @@ function useReplyParams(): ReplyParams | undefined {
|
|
|
79
81
|
return brief ?? flagged ?? mailbox;
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
function useReplySeed(): string | undefined {
|
|
85
|
+
const brief = useSearch({ from: BRIEF_REPLY, shouldThrow: false });
|
|
86
|
+
const flagged = useSearch({ from: FLAGGED_REPLY, shouldThrow: false });
|
|
87
|
+
const mailbox = useSearch({ from: MAILBOX_REPLY, shouldThrow: false });
|
|
88
|
+
return (brief ?? flagged ?? mailbox)?.body;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const seededSearch =
|
|
92
|
+
(seed: ComposeSeed | undefined) =>
|
|
93
|
+
(prev: Record<string, unknown>): Record<string, unknown> =>
|
|
94
|
+
seed === undefined
|
|
95
|
+
? withoutComposeSeed(prev)
|
|
96
|
+
: { ...withoutComposeSeed(prev), body: seed.body };
|
|
97
|
+
|
|
82
98
|
/**
|
|
83
99
|
* The reply surface the address names, or none.
|
|
84
100
|
*
|
|
@@ -89,6 +105,7 @@ function useReplyParams(): ReplyParams | undefined {
|
|
|
89
105
|
*/
|
|
90
106
|
export function useReplySurface(): ReplySurface | undefined {
|
|
91
107
|
const params = useReplyParams();
|
|
108
|
+
const seedBody = useReplySeed();
|
|
92
109
|
return useMemo(() => {
|
|
93
110
|
if (!params) return undefined;
|
|
94
111
|
const mode = replyModeSchema.safeParse(params.mode);
|
|
@@ -99,8 +116,9 @@ export function useReplySurface(): ReplySurface | undefined {
|
|
|
99
116
|
threadId: params.threadId,
|
|
100
117
|
sourceMessageId: params.messageId,
|
|
101
118
|
outboxMessageId: params.outboxMessageId,
|
|
119
|
+
seed: seedBody === undefined ? undefined : { body: seedBody },
|
|
102
120
|
};
|
|
103
|
-
}, [params]);
|
|
121
|
+
}, [params, seedBody]);
|
|
104
122
|
}
|
|
105
123
|
|
|
106
124
|
/**
|
|
@@ -183,17 +201,25 @@ function inheritedDraft(
|
|
|
183
201
|
* A push, so Back leaves the reply and returns the message it was written
|
|
184
202
|
* under, and the query travels with it.
|
|
185
203
|
*/
|
|
186
|
-
export function useOpenReply(): (
|
|
204
|
+
export function useOpenReply(): (
|
|
205
|
+
target: ReplyTarget,
|
|
206
|
+
seed?: ComposeSeed,
|
|
207
|
+
) => void {
|
|
187
208
|
const navigate = useNavigate();
|
|
188
209
|
const retainPanels = useRetainOpenPanels();
|
|
189
210
|
const { list, mailboxId } = useBrowsedList();
|
|
190
211
|
const open = useReplySurface();
|
|
191
212
|
|
|
192
213
|
return useCallback(
|
|
193
|
-
(target: ReplyTarget) => {
|
|
214
|
+
(target: ReplyTarget, seed?: ComposeSeed) => {
|
|
194
215
|
navigate({
|
|
195
|
-
...replyTarget(
|
|
196
|
-
|
|
216
|
+
...replyTarget(
|
|
217
|
+
list,
|
|
218
|
+
mailboxId,
|
|
219
|
+
target,
|
|
220
|
+
seed === undefined ? inheritedDraft(open, target) : undefined,
|
|
221
|
+
),
|
|
222
|
+
search: seededSearch(seed),
|
|
197
223
|
hash: retainPanels,
|
|
198
224
|
});
|
|
199
225
|
},
|
|
@@ -254,7 +280,7 @@ export function useAdoptReplyDraft(): (outboxMessageId: string) => void {
|
|
|
254
280
|
},
|
|
255
281
|
outboxMessageId,
|
|
256
282
|
),
|
|
257
|
-
search: (
|
|
283
|
+
search: seededSearch(undefined),
|
|
258
284
|
hash: true,
|
|
259
285
|
replace: true,
|
|
260
286
|
});
|
|
@@ -278,7 +304,7 @@ export function useCloseReply(): () => void {
|
|
|
278
304
|
|
|
279
305
|
return useCallback(() => {
|
|
280
306
|
if (!params) return;
|
|
281
|
-
const search = (
|
|
307
|
+
const search = seededSearch(undefined);
|
|
282
308
|
const hash = retainPanels;
|
|
283
309
|
const message = { threadId: params.threadId, messageId: params.messageId };
|
|
284
310
|
if (list === "flagged") {
|