@remit/web-client 0.0.166 → 0.0.168
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 +91 -29
- package/src/components/compose/ComposeProvider.tsx +19 -124
- package/src/components/compose/ConversationCompose.tsx +57 -0
- package/src/components/compose/FullCompose.tsx +29 -18
- package/src/components/compose/MobileComposeSheet.tsx +14 -18
- package/src/components/compose/compose-send-stops-autosave.render.test.ts +7 -9
- package/src/components/compose/compose-starts-a-second-message.render.test.ts +170 -0
- package/src/components/compose/compose-title.ts +10 -0
- package/src/components/compose/mobile-header-stays-expanded.render.test.ts +5 -13
- package/src/components/layout/ComposeFab.tsx +6 -15
- package/src/components/layout/MailShell.tsx +9 -1
- package/src/components/layout/MailTopBar.tsx +3 -6
- package/src/components/mail/BriefPane.tsx +49 -13
- package/src/components/mail/ConversationView.tsx +82 -77
- package/src/components/mail/DraftsView.tsx +12 -14
- package/src/components/mail/FlaggedPane.tsx +49 -13
- package/src/components/mail/MailboxPane.tsx +77 -167
- package/src/components/mail/OutboxPane.tsx +3 -9
- package/src/components/mail/conversation-reply-reach.render.test.ts +81 -28
- package/src/hooks/useSaveDraft.ts +11 -0
- package/src/hooks/useSearchMirror.ts +12 -1
- package/src/hooks/useThreadActions.ts +8 -17
- package/src/lib/mail-route.test.ts +35 -1
- package/src/lib/mail-route.ts +2 -1
- package/src/routeTree.gen.ts +220 -15
- package/src/routes/mail/$mailboxId/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +14 -0
- package/src/routes/mail/$mailboxId/$threadId/$messageId.tsx +4 -0
- package/src/routes/mail/$mailboxId/compose.{-$outboxMessageId}.tsx +14 -0
- package/src/routes/mail/brief/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +26 -0
- package/src/routes/mail/brief/$threadId/$messageId.tsx +4 -0
- package/src/routes/mail/brief/compose.{-$outboxMessageId}.tsx +22 -0
- package/src/routes/mail/flagged/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +14 -0
- package/src/routes/mail/flagged/$threadId/$messageId.tsx +4 -0
- package/src/routes/mail/flagged/compose.{-$outboxMessageId}.tsx +13 -0
- package/src/routes/mail/outbox/compose.{-$outboxMessageId}.tsx +17 -0
- package/src/routes/mail.tsx +12 -8
- package/src/routing/browsed-list.ts +22 -0
- package/src/routing/compose-press-opens.render.test.ts +179 -0
- package/src/routing/compose.ts +205 -0
- package/src/routing/index.ts +20 -0
- package/src/routing/reply.ts +282 -0
- package/src/components/compose/InlineCompose.tsx +0 -37
- package/src/components/compose/compose-clears-open-thread.render.test.ts +0 -312
- package/src/hooks/useComposeTargetMailbox.ts +0 -75
- package/src/lib/compose-routes.test.ts +0 -46
- package/src/lib/compose-routes.ts +0 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.168",
|
|
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": {
|
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
ComposeSubjectField,
|
|
19
19
|
composeHeaderSummary,
|
|
20
20
|
defaultComposeLanguages,
|
|
21
|
-
EMPTY_RICH_TEXT,
|
|
22
21
|
modeOfDraft,
|
|
23
22
|
QuotedText,
|
|
24
23
|
type RichTextValue,
|
|
@@ -66,6 +65,15 @@ interface ComposeFormProps {
|
|
|
66
65
|
mode: ComposeMode;
|
|
67
66
|
account?: RemitImapAccountResponse;
|
|
68
67
|
sourceMessage?: RemitImapDescribeMessageResponse;
|
|
68
|
+
/**
|
|
69
|
+
* The draft this composer writes to. Absent until there is one, and owned by
|
|
70
|
+
* whoever mounted the form — the address for the compose route, the inline
|
|
71
|
+
* composer's own state for a reply. A composer that read it from somewhere
|
|
72
|
+
* shared would open on the last draft any other composer touched.
|
|
73
|
+
*/
|
|
74
|
+
outboxMessageId?: string;
|
|
75
|
+
/** The draft the first autosave created, for the owner to record. */
|
|
76
|
+
onDraftCreated: (outboxMessageId: string) => void;
|
|
69
77
|
onClose: () => void;
|
|
70
78
|
onAccountChange?: (account: RemitImapAccountResponse) => void;
|
|
71
79
|
/**
|
|
@@ -94,6 +102,18 @@ const buildInitialHtml = (signaturePlainText: string): string => {
|
|
|
94
102
|
return `<p></p><p>-- </p>${textToHtml(signaturePlainText)}`;
|
|
95
103
|
};
|
|
96
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The document a new message opens on. One definition, because a form that
|
|
107
|
+
* starts a second message without remounting has to open on the same thing the
|
|
108
|
+
* first one did.
|
|
109
|
+
*/
|
|
110
|
+
const freshDocument = (
|
|
111
|
+
signaturePlainText: string,
|
|
112
|
+
): { html: string; text: string } => ({
|
|
113
|
+
html: buildInitialHtml(signaturePlainText),
|
|
114
|
+
text: signaturePlainText,
|
|
115
|
+
});
|
|
116
|
+
|
|
97
117
|
const buildReplySubject = (subject?: string): string => {
|
|
98
118
|
if (!subject) return "Re: ";
|
|
99
119
|
if (/^re:\s/i.test(subject)) return subject;
|
|
@@ -120,7 +140,7 @@ const getReplyAddresses = (
|
|
|
120
140
|
displayName: a.displayName,
|
|
121
141
|
}));
|
|
122
142
|
|
|
123
|
-
if (mode !== "
|
|
143
|
+
if (mode !== "reply-all") return { to, cc: [] };
|
|
124
144
|
|
|
125
145
|
const myEmailLower = myEmail?.toLowerCase();
|
|
126
146
|
const toEmails = new Set(to.map((a) => a.email.toLowerCase()));
|
|
@@ -306,13 +326,14 @@ export const ComposeForm = ({
|
|
|
306
326
|
mode,
|
|
307
327
|
account,
|
|
308
328
|
sourceMessage,
|
|
329
|
+
outboxMessageId,
|
|
330
|
+
onDraftCreated,
|
|
309
331
|
onClose,
|
|
310
332
|
onAccountChange,
|
|
311
333
|
layout = "fill",
|
|
312
334
|
}: ComposeFormProps) => {
|
|
313
|
-
const {
|
|
335
|
+
const { startSendPolling } = useCompose();
|
|
314
336
|
const { pushError } = useErrorBanners();
|
|
315
|
-
const { outboxMessageId } = state;
|
|
316
337
|
|
|
317
338
|
const [toAddresses, setToAddresses] = useState<AddressEntry[]>([]);
|
|
318
339
|
const [ccAddresses, setCcAddresses] = useState<AddressEntry[]>([]);
|
|
@@ -324,46 +345,76 @@ export const ComposeForm = ({
|
|
|
324
345
|
account?.accountId,
|
|
325
346
|
);
|
|
326
347
|
const [draftLoaded, setDraftLoaded] = useState(false);
|
|
348
|
+
/**
|
|
349
|
+
* The document the fields on screen belong to. It trails `outboxMessageId`
|
|
350
|
+
* for one commit whenever the document changes, and autosave has to sit that
|
|
351
|
+
* commit out — the fields still hold the message that has just been left.
|
|
352
|
+
*/
|
|
353
|
+
const [openDocumentId, setOpenDocumentId] = useState(outboxMessageId);
|
|
327
354
|
const smtpConfigureRef = useRef<HTMLButtonElement>(null);
|
|
328
355
|
const prevOutboxMessageIdRef = useRef<string | undefined>(outboxMessageId);
|
|
356
|
+
/**
|
|
357
|
+
* Whether the document on screen came from a draft the server already held.
|
|
358
|
+
* A reply carries the message it answers whether it is being started or being
|
|
359
|
+
* resumed, so this is what separates the two: the source seeds a reply that
|
|
360
|
+
* has nothing behind it, and leaves a saved one holding the recipients and
|
|
361
|
+
* the subject the reader edited.
|
|
362
|
+
*/
|
|
363
|
+
const resumedDraftRef = useRef(outboxMessageId !== undefined);
|
|
329
364
|
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
365
|
+
// The document this form is on changed under it, so it starts again: another
|
|
366
|
+
// draft, or no draft at all. Without this the previous one's fields stay on
|
|
367
|
+
// screen and the new one never loads, because `draftLoaded` is still true
|
|
368
|
+
// from the session before (#536).
|
|
369
|
+
//
|
|
370
|
+
// "No draft at all" is Compose pressed while already composing — the address
|
|
371
|
+
// drops the draft segment and the same route stays matched, so nothing
|
|
372
|
+
// remounts the form. Leaving it alone there showed the old message under an
|
|
373
|
+
// address that said new one, and the next autosave took the create branch
|
|
374
|
+
// and wrote a second draft holding the first one's content.
|
|
334
375
|
//
|
|
335
|
-
//
|
|
336
|
-
// created. That is this session's own content
|
|
337
|
-
//
|
|
338
|
-
// whatever is being typed.
|
|
376
|
+
// The one change that is not a different document is the first autosave
|
|
377
|
+
// adopting the id it just created. That is this session's own content coming
|
|
378
|
+
// back, and blanking the form there would throw away what is being typed.
|
|
339
379
|
useEffect(() => {
|
|
340
380
|
const previous = prevOutboxMessageIdRef.current;
|
|
341
381
|
if (previous === outboxMessageId) return;
|
|
342
382
|
prevOutboxMessageIdRef.current = outboxMessageId;
|
|
343
|
-
if (
|
|
383
|
+
if (previous === undefined) return;
|
|
384
|
+
resumedDraftRef.current = outboxMessageId !== undefined;
|
|
385
|
+
// A draft brings its own body along in a moment; a new message opens on
|
|
386
|
+
// the signature, the same document a fresh mount would have started on.
|
|
387
|
+
const opening = outboxMessageId
|
|
388
|
+
? { html: "", text: "" }
|
|
389
|
+
: freshDocument(signatureRef.current);
|
|
344
390
|
setToAddresses([]);
|
|
345
391
|
setCcAddresses([]);
|
|
346
392
|
setBccAddresses([]);
|
|
347
393
|
setSubject("");
|
|
348
394
|
setShowCc(false);
|
|
349
395
|
setShowBcc(false);
|
|
350
|
-
setInitialHtml(
|
|
351
|
-
setInitialText(
|
|
396
|
+
setInitialHtml(opening.html);
|
|
397
|
+
setInitialText(opening.text);
|
|
352
398
|
setBodyMode("rich");
|
|
353
399
|
setDraftLanguage(undefined);
|
|
354
|
-
setBody(
|
|
400
|
+
setBody({ ...opening, formatting: [] });
|
|
355
401
|
setDocumentGeneration((generation) => generation + 1);
|
|
356
402
|
setDraftLoaded(false);
|
|
403
|
+
setOpenDocumentId(outboxMessageId);
|
|
357
404
|
}, [outboxMessageId]);
|
|
358
405
|
|
|
359
406
|
const { signature } = useSignature(selectedAccountId);
|
|
407
|
+
// Read when a new message starts, never depended on: a signature arriving is
|
|
408
|
+
// not a reason to reopen the document somebody is typing in.
|
|
409
|
+
const signatureRef = useRef(signature.plainText);
|
|
410
|
+
signatureRef.current = signature.plainText;
|
|
360
411
|
// The editor reads its document once, so this is the document it opens on,
|
|
361
412
|
// not the live value, and it is remounted when the generation changes. Only
|
|
362
413
|
// loading a different document bumps that — remounting mid-compose would take
|
|
363
414
|
// the caret, the focus and the undo history with it.
|
|
364
415
|
const [documentGeneration, setDocumentGeneration] = useState(0);
|
|
365
|
-
const [initialHtml, setInitialHtml] = useState(
|
|
366
|
-
|
|
416
|
+
const [initialHtml, setInitialHtml] = useState(
|
|
417
|
+
() => freshDocument(signature.plainText).html,
|
|
367
418
|
);
|
|
368
419
|
const [initialText, setInitialText] = useState(signature.plainText);
|
|
369
420
|
const [bodyMode, setBodyMode] = useState<ComposeBodyMode>("rich");
|
|
@@ -373,8 +424,7 @@ export const ComposeForm = ({
|
|
|
373
424
|
const [composeLanguage, setComposeLanguage] = useState("en");
|
|
374
425
|
const [draftLanguage, setDraftLanguage] = useState<string | undefined>();
|
|
375
426
|
const [body, setBody] = useState<RichTextValue>(() => ({
|
|
376
|
-
|
|
377
|
-
text: signature.plainText,
|
|
427
|
+
...freshDocument(signature.plainText),
|
|
378
428
|
formatting: [],
|
|
379
429
|
}));
|
|
380
430
|
|
|
@@ -432,9 +482,10 @@ export const ComposeForm = ({
|
|
|
432
482
|
}, [draftData, draftLoaded]);
|
|
433
483
|
|
|
434
484
|
useEffect(() => {
|
|
485
|
+
if (resumedDraftRef.current) return;
|
|
435
486
|
if (!sourceMessage) return;
|
|
436
487
|
|
|
437
|
-
if (mode === "reply" || mode === "
|
|
488
|
+
if (mode === "reply" || mode === "reply-all") {
|
|
438
489
|
const { to, cc } = getReplyAddresses(sourceMessage, mode, account?.email);
|
|
439
490
|
setToAddresses(to);
|
|
440
491
|
setCcAddresses(cc);
|
|
@@ -452,7 +503,7 @@ export const ComposeForm = ({
|
|
|
452
503
|
// to an empty quote when nothing renderable exists (the user can still
|
|
453
504
|
// attribute the reply manually).
|
|
454
505
|
const isQuoting =
|
|
455
|
-
mode === "reply" || mode === "
|
|
506
|
+
mode === "reply" || mode === "reply-all" || mode === "forward";
|
|
456
507
|
const { data: sourceBody } = useMessageBodyContent({
|
|
457
508
|
messageId: sourceMessage?.message.messageId,
|
|
458
509
|
bodyParts: sourceMessage?.bodyParts,
|
|
@@ -475,9 +526,14 @@ export const ComposeForm = ({
|
|
|
475
526
|
const adoptCreatedDraft = useCallback(
|
|
476
527
|
(createdId: string) => {
|
|
477
528
|
setDraftLoaded(true);
|
|
478
|
-
|
|
529
|
+
// What is on screen is what was just written, so the fields belong to
|
|
530
|
+
// the new draft the moment it exists — before the id has travelled out
|
|
531
|
+
// through the address and back. Waiting for that would hold the next
|
|
532
|
+
// autosave, and the last thing typed before a pause would not be saved.
|
|
533
|
+
setOpenDocumentId(createdId);
|
|
534
|
+
onDraftCreated(createdId);
|
|
479
535
|
},
|
|
480
|
-
[
|
|
536
|
+
[onDraftCreated],
|
|
481
537
|
);
|
|
482
538
|
|
|
483
539
|
const { saveStatus, saveError, saveDraft, saveImmediately, stopAutoSave } =
|
|
@@ -578,10 +634,15 @@ export const ComposeForm = ({
|
|
|
578
634
|
|
|
579
635
|
useEffect(() => {
|
|
580
636
|
if (!selectedAccountId) return;
|
|
581
|
-
// Don't autosave
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
// draft's content
|
|
637
|
+
// Don't autosave a document the fields are no longer on. They trail the
|
|
638
|
+
// address by a commit whenever it changes, so writing here sends the
|
|
639
|
+
// message just left to whatever the address now names — the previous
|
|
640
|
+
// draft's content onto the next draft, or, with no draft named at all, a
|
|
641
|
+
// spurious second one holding it (#535/#536).
|
|
642
|
+
if (openDocumentId !== undefined && openDocumentId !== outboxMessageId)
|
|
643
|
+
return;
|
|
644
|
+
// Nor one still arriving: opening an existing draft leaves the fields
|
|
645
|
+
// mid-population until the read lands.
|
|
585
646
|
if (outboxMessageId && !draftLoaded) return;
|
|
586
647
|
if (isFormEmpty(toAddresses, ccAddresses, bccAddresses, subject, body))
|
|
587
648
|
return;
|
|
@@ -606,6 +667,7 @@ export const ComposeForm = ({
|
|
|
606
667
|
}, [
|
|
607
668
|
selectedAccountId,
|
|
608
669
|
outboxMessageId,
|
|
670
|
+
openDocumentId,
|
|
609
671
|
draftLoaded,
|
|
610
672
|
toAddresses,
|
|
611
673
|
ccAddresses,
|
|
@@ -627,7 +689,7 @@ export const ComposeForm = ({
|
|
|
627
689
|
stopAutoSave();
|
|
628
690
|
|
|
629
691
|
const replyData =
|
|
630
|
-
sourceMessage && (mode === "reply" || mode === "
|
|
692
|
+
sourceMessage && (mode === "reply" || mode === "reply-all")
|
|
631
693
|
? getReferences(sourceMessage)
|
|
632
694
|
: {};
|
|
633
695
|
|
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
-
configOperationsGetConfigOptions,
|
|
3
2
|
outboxDetailOperationsGetOutboxMessageOptions,
|
|
4
3
|
outboxOperationsListOutboxMessagesQueryKey,
|
|
5
4
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
6
|
-
import type {
|
|
7
|
-
RemitImapAccountResponse,
|
|
8
|
-
RemitImapDescribeMessageResponse,
|
|
9
|
-
} from "@remit/api-http-client/types.gen.ts";
|
|
10
5
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
11
|
-
import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
|
|
12
6
|
import {
|
|
13
7
|
createContext,
|
|
14
8
|
useCallback,
|
|
@@ -18,27 +12,25 @@ import {
|
|
|
18
12
|
useRef,
|
|
19
13
|
useState,
|
|
20
14
|
} from "react";
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
15
|
+
import type { ReplyMode } from "@/routing";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What a composer opened on. The three ways to answer a message are the path
|
|
19
|
+
* segment `$mode` verbatim, so the address and the form say one word each
|
|
20
|
+
* rather than translating between two vocabularies.
|
|
21
|
+
*/
|
|
22
|
+
export type ComposeMode = ReplyMode | "new";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A message on its way out outlives the composer that wrote it: the surface
|
|
26
|
+
* closes on Send and the outbox row is still queued. So the watch sits above
|
|
27
|
+
* every composer, and is the only thing here.
|
|
28
|
+
*
|
|
29
|
+
* Which draft is being written, and whether a composer is on screen at all, are
|
|
30
|
+
* the address's answers — `useComposeDraftId` and `useIsComposing`. A second
|
|
31
|
+
* copy here is what let a reply's draft turn up in the next new message.
|
|
32
|
+
*/
|
|
37
33
|
interface ComposeContextValue {
|
|
38
|
-
state: ComposeState;
|
|
39
|
-
openCompose: (params: Omit<ComposeState, "isOpen">) => void;
|
|
40
|
-
closeCompose: () => void;
|
|
41
|
-
setOutboxMessageId: (id: string) => void;
|
|
42
34
|
startSendPolling: (outboxMessageId: string) => void;
|
|
43
35
|
}
|
|
44
36
|
|
|
@@ -46,11 +38,6 @@ const ComposeContext = createContext<ComposeContextValue | undefined>(
|
|
|
46
38
|
undefined,
|
|
47
39
|
);
|
|
48
40
|
|
|
49
|
-
const INITIAL_STATE: ComposeState = {
|
|
50
|
-
isOpen: false,
|
|
51
|
-
mode: "new",
|
|
52
|
-
};
|
|
53
|
-
|
|
54
41
|
const POLL_INTERVAL_MS = 2000;
|
|
55
42
|
const MAX_POLL_DURATION_MS = 60_000;
|
|
56
43
|
|
|
@@ -59,26 +46,11 @@ export const ComposeProvider = ({
|
|
|
59
46
|
}: {
|
|
60
47
|
children: React.ReactNode;
|
|
61
48
|
}) => {
|
|
62
|
-
const [state, setState] = useState<ComposeState>(INITIAL_STATE);
|
|
63
49
|
const [pollingMessageId, setPollingMessageId] = useState<
|
|
64
50
|
string | undefined
|
|
65
51
|
>();
|
|
66
52
|
const startedAtRef = useRef(0);
|
|
67
53
|
const queryClient = useQueryClient();
|
|
68
|
-
const navigate = useNavigate();
|
|
69
|
-
const location = useLocation();
|
|
70
|
-
const routeParams = useParams({ strict: false, shouldThrow: false });
|
|
71
|
-
const { pushError } = useErrorBanners();
|
|
72
|
-
// Compose is a mail action, so its target only has to be known under `/mail`.
|
|
73
|
-
// Resolving it from the root otherwise costs `/settings` and `/onboarding` a
|
|
74
|
-
// config fetch plus a folder list per account for a button they never show.
|
|
75
|
-
const underMail = location.pathname.startsWith("/mail");
|
|
76
|
-
const { data: config } = useQuery({
|
|
77
|
-
...configOperationsGetConfigOptions(),
|
|
78
|
-
staleTime: Infinity,
|
|
79
|
-
enabled: underMail,
|
|
80
|
-
});
|
|
81
|
-
const target = useComposeTarget(underMail ? (config?.accounts ?? []) : []);
|
|
82
54
|
|
|
83
55
|
const { data: polledMessage } = useQuery({
|
|
84
56
|
...outboxDetailOperationsGetOutboxMessageOptions({
|
|
@@ -111,89 +83,12 @@ export const ComposeProvider = ({
|
|
|
111
83
|
}
|
|
112
84
|
}, [polledMessage, pollingMessageId, queryClient]);
|
|
113
85
|
|
|
114
|
-
// Opening compose also puts the surface on screen: only a mailbox route
|
|
115
|
-
// mounts `FullCompose`, and only with no thread in the pane it takes over.
|
|
116
|
-
const openCompose = useCallback(
|
|
117
|
-
(params: Omit<ComposeState, "isOpen">) => {
|
|
118
|
-
// Every list names its open conversation in the path, so leaving one is a
|
|
119
|
-
// navigation up to the list.
|
|
120
|
-
const threadMailboxId = locationOpensDetail(location.pathname)
|
|
121
|
-
? routeParams?.mailboxId
|
|
122
|
-
: undefined;
|
|
123
|
-
if (!hostsComposeSurface(location.pathname)) {
|
|
124
|
-
if (target.status === "loading") {
|
|
125
|
-
pushError({
|
|
126
|
-
severity: "info",
|
|
127
|
-
title: "Not ready to write yet",
|
|
128
|
-
detail:
|
|
129
|
-
"Your folders are still loading. Try Compose again in a moment.",
|
|
130
|
-
});
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
if (target.status === "none") {
|
|
134
|
-
pushError({
|
|
135
|
-
title: "Nowhere to write from",
|
|
136
|
-
detail:
|
|
137
|
-
"No account has a folder to open the message in. Check the account's folders in Settings, then try again.",
|
|
138
|
-
});
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
setState({ ...params, isOpen: true });
|
|
142
|
-
navigate({
|
|
143
|
-
to: "/mail/$mailboxId",
|
|
144
|
-
params: { mailboxId: target.mailboxId },
|
|
145
|
-
});
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
setState({ ...params, isOpen: true });
|
|
149
|
-
if (!threadMailboxId) return;
|
|
150
|
-
// A push, so Back reopens the message.
|
|
151
|
-
navigate({
|
|
152
|
-
to: "/mail/$mailboxId",
|
|
153
|
-
params: { mailboxId: threadMailboxId },
|
|
154
|
-
search: (prev) => prev,
|
|
155
|
-
});
|
|
156
|
-
},
|
|
157
|
-
[navigate, location.pathname, routeParams?.mailboxId, target, pushError],
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
const closeCompose = useCallback(() => {
|
|
161
|
-
setState(INITIAL_STATE);
|
|
162
|
-
}, []);
|
|
163
|
-
|
|
164
|
-
// Leaving the routes that mount the surface closes it, so nothing is left
|
|
165
|
-
// open behind a view that cannot show it — which is how it used to reappear
|
|
166
|
-
// unannounced on the next mailbox. Only a *change* of route counts: opening
|
|
167
|
-
// compose navigates, and on the frame before that navigation lands the
|
|
168
|
-
// pathname is still the one compose was started from.
|
|
169
|
-
const previousPathnameRef = useRef(location.pathname);
|
|
170
|
-
useEffect(() => {
|
|
171
|
-
const previous = previousPathnameRef.current;
|
|
172
|
-
previousPathnameRef.current = location.pathname;
|
|
173
|
-
if (location.pathname === previous) return;
|
|
174
|
-
if (hostsComposeSurface(location.pathname)) return;
|
|
175
|
-
setState((current) => (current.isOpen ? INITIAL_STATE : current));
|
|
176
|
-
}, [location.pathname]);
|
|
177
|
-
|
|
178
|
-
const setOutboxMessageId = useCallback((id: string) => {
|
|
179
|
-
setState((prev) => ({ ...prev, outboxMessageId: id }));
|
|
180
|
-
}, []);
|
|
181
|
-
|
|
182
86
|
const startSendPolling = useCallback((outboxMessageId: string) => {
|
|
183
87
|
startedAtRef.current = Date.now();
|
|
184
88
|
setPollingMessageId(outboxMessageId);
|
|
185
89
|
}, []);
|
|
186
90
|
|
|
187
|
-
const value = useMemo(
|
|
188
|
-
() => ({
|
|
189
|
-
state,
|
|
190
|
-
openCompose,
|
|
191
|
-
closeCompose,
|
|
192
|
-
setOutboxMessageId,
|
|
193
|
-
startSendPolling,
|
|
194
|
-
}),
|
|
195
|
-
[state, openCompose, closeCompose, setOutboxMessageId, startSendPolling],
|
|
196
|
-
);
|
|
91
|
+
const value = useMemo(() => ({ startSendPolling }), [startSendPolling]);
|
|
197
92
|
|
|
198
93
|
return (
|
|
199
94
|
<ComposeContext.Provider value={value}>{children}</ComposeContext.Provider>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
configOperationsGetConfigOptions,
|
|
3
|
+
messageOperationsDescribeMessageOptions,
|
|
4
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
|
+
import { useQuery } from "@tanstack/react-query";
|
|
6
|
+
import {
|
|
7
|
+
type ReplyAddress,
|
|
8
|
+
useAdoptReplyDraft,
|
|
9
|
+
useCloseReply,
|
|
10
|
+
} from "@/routing";
|
|
11
|
+
import { ComposeForm } from "./ComposeForm";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The answer to a message, as the top block of the conversation it answers.
|
|
15
|
+
*
|
|
16
|
+
* It takes no height of its own: it is as tall as what has been written in it
|
|
17
|
+
* and pushes the thread down as that grows, so the pane keeps the one scrollbar
|
|
18
|
+
* it had before the reply opened. A height here would give the pane a second
|
|
19
|
+
* one, with the caret in the inner track.
|
|
20
|
+
*
|
|
21
|
+
* Mode, source and draft are all segments of the address, so the surface is on
|
|
22
|
+
* screen because the path says so and is writing to what the path names. The
|
|
23
|
+
* conversation renders it because it belongs inside the thread and because the
|
|
24
|
+
* phone has no reading `Outlet` for a route to fill.
|
|
25
|
+
*/
|
|
26
|
+
export const ConversationCompose = ({ surface }: { surface: ReplyAddress }) => {
|
|
27
|
+
const adoptCreatedDraft = useAdoptReplyDraft();
|
|
28
|
+
const closeReply = useCloseReply();
|
|
29
|
+
|
|
30
|
+
const { data: sourceMessage } = useQuery({
|
|
31
|
+
...messageOperationsDescribeMessageOptions({
|
|
32
|
+
path: { messageId: surface.sourceMessageId },
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const { data: config } = useQuery({
|
|
37
|
+
...configOperationsGetConfigOptions(),
|
|
38
|
+
staleTime: Infinity,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="border-b border-line bg-canvas">
|
|
43
|
+
{/* No key on the draft: the first autosave writes the id it created into
|
|
44
|
+
the address, and remounting the form on that would take the caret out
|
|
45
|
+
of the sentence being typed. */}
|
|
46
|
+
<ComposeForm
|
|
47
|
+
layout="flow"
|
|
48
|
+
mode={surface.mode}
|
|
49
|
+
account={config?.accounts?.[0]}
|
|
50
|
+
sourceMessage={sourceMessage}
|
|
51
|
+
outboxMessageId={surface.outboxMessageId}
|
|
52
|
+
onDraftCreated={adoptCreatedDraft}
|
|
53
|
+
onClose={closeReply}
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
};
|
|
@@ -2,20 +2,26 @@ import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tansta
|
|
|
2
2
|
import { useQuery } from "@tanstack/react-query";
|
|
3
3
|
import { X } from "lucide-react";
|
|
4
4
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
5
|
+
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
|
5
6
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
7
|
+
import {
|
|
8
|
+
useAdoptComposeDraft,
|
|
9
|
+
useCloseCompose,
|
|
10
|
+
useComposeDraftId,
|
|
11
|
+
} from "@/routing";
|
|
6
12
|
import { ComposeForm } from "./ComposeForm";
|
|
7
|
-
import {
|
|
13
|
+
import { composeSurfaceTitle } from "./compose-title";
|
|
8
14
|
import { MobileComposeSheet } from "./MobileComposeSheet";
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
};
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The compose surface, mounted by the compose route and by nothing else. It is
|
|
18
|
+
* on screen because the address says so, and writing to the draft the address
|
|
19
|
+
* names — so there is no flag to consult and nothing to be out of step with.
|
|
20
|
+
*/
|
|
17
21
|
export const FullCompose = () => {
|
|
18
|
-
const
|
|
22
|
+
const outboxMessageId = useComposeDraftId();
|
|
23
|
+
const adoptCreatedDraft = useAdoptComposeDraft();
|
|
24
|
+
const closeCompose = useCloseCompose();
|
|
19
25
|
const isDesktop = useIsDesktop();
|
|
20
26
|
const {
|
|
21
27
|
isError: isConfigError,
|
|
@@ -26,16 +32,18 @@ export const FullCompose = () => {
|
|
|
26
32
|
staleTime: Infinity,
|
|
27
33
|
});
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
useKeyboardNavigation({
|
|
36
|
+
bindings: [{ key: "Escape", handler: closeCompose, preventDefault: true }],
|
|
37
|
+
});
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
if (!isDesktop) return <MobileComposeSheet />;
|
|
34
40
|
|
|
35
41
|
const header = (
|
|
36
42
|
<header className="flex items-center justify-between gap-2 px-4 py-3 border-b border-line shrink-0">
|
|
37
43
|
<div className="flex items-center gap-2 min-w-0">
|
|
38
|
-
<h2 className="text-lg font-semibold truncate">
|
|
44
|
+
<h2 className="text-lg font-semibold truncate">
|
|
45
|
+
{composeSurfaceTitle(outboxMessageId)}
|
|
46
|
+
</h2>
|
|
39
47
|
</div>
|
|
40
48
|
<button
|
|
41
49
|
type="button"
|
|
@@ -69,11 +77,14 @@ export const FullCompose = () => {
|
|
|
69
77
|
<div className="flex h-full min-h-0 flex-col bg-canvas">
|
|
70
78
|
{header}
|
|
71
79
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
80
|
+
{/* No key on the draft: the first autosave writes the id it created
|
|
81
|
+
into the address, and remounting the form on that would take the
|
|
82
|
+
caret out of the sentence being typed. Switching to a different
|
|
83
|
+
draft is the form's own `outboxMessageId` effect. */}
|
|
72
84
|
<ComposeForm
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
sourceMessage={state.sourceMessage}
|
|
85
|
+
mode="new"
|
|
86
|
+
outboxMessageId={outboxMessageId}
|
|
87
|
+
onDraftCreated={adoptCreatedDraft}
|
|
77
88
|
onClose={closeCompose}
|
|
78
89
|
/>
|
|
79
90
|
</div>
|
|
@@ -4,15 +4,13 @@ import { useQuery } from "@tanstack/react-query";
|
|
|
4
4
|
import { useCallback, useRef, useState } from "react";
|
|
5
5
|
import { Drawer } from "vaul";
|
|
6
6
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
7
|
+
import {
|
|
8
|
+
useAdoptComposeDraft,
|
|
9
|
+
useCloseCompose,
|
|
10
|
+
useComposeDraftId,
|
|
11
|
+
} from "@/routing";
|
|
7
12
|
import { ComposeForm } from "./ComposeForm";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
const MODE_LABELS: Record<string, string> = {
|
|
11
|
-
reply: "Reply",
|
|
12
|
-
reply_all: "Reply All",
|
|
13
|
-
forward: "Forward",
|
|
14
|
-
new: "New Message",
|
|
15
|
-
};
|
|
13
|
+
import { composeSurfaceTitle } from "./compose-title";
|
|
16
14
|
|
|
17
15
|
/**
|
|
18
16
|
* Returns true when the draft has meaningful content (subject or body
|
|
@@ -40,7 +38,9 @@ const useIsDraftDirty = (): (() => boolean) => {
|
|
|
40
38
|
};
|
|
41
39
|
|
|
42
40
|
export const MobileComposeSheet = () => {
|
|
43
|
-
const
|
|
41
|
+
const outboxMessageId = useComposeDraftId();
|
|
42
|
+
const adoptCreatedDraft = useAdoptComposeDraft();
|
|
43
|
+
const closeCompose = useCloseCompose();
|
|
44
44
|
const isDraftDirty = useIsDraftDirty();
|
|
45
45
|
const [showConfirm, setShowConfirm] = useState(false);
|
|
46
46
|
|
|
@@ -75,13 +75,9 @@ export const MobileComposeSheet = () => {
|
|
|
75
75
|
setShowConfirm(false);
|
|
76
76
|
}, []);
|
|
77
77
|
|
|
78
|
-
if (!state.isOpen) return null;
|
|
79
|
-
|
|
80
|
-
const title = MODE_LABELS[state.mode] ?? "New Message";
|
|
81
|
-
|
|
82
78
|
return (
|
|
83
79
|
<Drawer.Root
|
|
84
|
-
open
|
|
80
|
+
open
|
|
85
81
|
onOpenChange={handleOpenChange}
|
|
86
82
|
// Don't auto-dismiss on drag when dirty — we intercept via onOpenChange
|
|
87
83
|
>
|
|
@@ -94,7 +90,7 @@ export const MobileComposeSheet = () => {
|
|
|
94
90
|
<Drawer.Handle className="mx-auto mt-2 mb-1 h-1.5 w-12 rounded-full bg-fg-muted/30" />
|
|
95
91
|
|
|
96
92
|
<Drawer.Title className="px-4 py-2 text-base font-semibold border-b border-line">
|
|
97
|
-
{
|
|
93
|
+
{composeSurfaceTitle(outboxMessageId)}
|
|
98
94
|
</Drawer.Title>
|
|
99
95
|
|
|
100
96
|
{isConfigError ? (
|
|
@@ -110,9 +106,9 @@ export const MobileComposeSheet = () => {
|
|
|
110
106
|
) : (
|
|
111
107
|
<div className="flex-1 overflow-hidden">
|
|
112
108
|
<ComposeForm
|
|
113
|
-
mode=
|
|
114
|
-
|
|
115
|
-
|
|
109
|
+
mode="new"
|
|
110
|
+
outboxMessageId={outboxMessageId}
|
|
111
|
+
onDraftCreated={adoptCreatedDraft}
|
|
116
112
|
onClose={closeCompose}
|
|
117
113
|
/>
|
|
118
114
|
</div>
|