@remit/web-client 0.0.166 → 0.0.167
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 +77 -25
- package/src/components/compose/ComposeProvider.tsx +11 -122
- package/src/components/compose/FullCompose.tsx +29 -18
- package/src/components/compose/InlineCompose.tsx +21 -11
- 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/DraftsView.tsx +12 -14
- package/src/components/mail/MailboxPane.tsx +27 -117
- package/src/components/mail/OutboxPane.tsx +3 -9
- package/src/hooks/useSaveDraft.ts +11 -0
- package/src/hooks/useSearchMirror.ts +12 -1
- package/src/lib/mail-route.test.ts +35 -1
- package/src/lib/mail-route.ts +2 -1
- package/src/routeTree.gen.ts +92 -0
- package/src/routes/mail/$mailboxId/compose.{-$outboxMessageId}.tsx +14 -0
- package/src/routes/mail/brief/compose.{-$outboxMessageId}.tsx +22 -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/compose-press-opens.render.test.ts +179 -0
- package/src/routing/compose.ts +220 -0
- package/src/routing/index.ts +8 -0
- 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.167",
|
|
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;
|
|
@@ -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,67 @@ 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);
|
|
329
356
|
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
357
|
+
// The document this form is on changed under it, so it starts again: another
|
|
358
|
+
// draft, or no draft at all. Without this the previous one's fields stay on
|
|
359
|
+
// screen and the new one never loads, because `draftLoaded` is still true
|
|
360
|
+
// from the session before (#536).
|
|
334
361
|
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
362
|
+
// "No draft at all" is Compose pressed while already composing — the address
|
|
363
|
+
// drops the draft segment and the same route stays matched, so nothing
|
|
364
|
+
// remounts the form. Leaving it alone there showed the old message under an
|
|
365
|
+
// address that said new one, and the next autosave took the create branch
|
|
366
|
+
// and wrote a second draft holding the first one's content.
|
|
367
|
+
//
|
|
368
|
+
// The one change that is not a different document is the first autosave
|
|
369
|
+
// adopting the id it just created. That is this session's own content coming
|
|
370
|
+
// back, and blanking the form there would throw away what is being typed.
|
|
339
371
|
useEffect(() => {
|
|
340
372
|
const previous = prevOutboxMessageIdRef.current;
|
|
341
373
|
if (previous === outboxMessageId) return;
|
|
342
374
|
prevOutboxMessageIdRef.current = outboxMessageId;
|
|
343
|
-
if (
|
|
375
|
+
if (previous === undefined) return;
|
|
376
|
+
// A draft brings its own body along in a moment; a new message opens on
|
|
377
|
+
// the signature, the same document a fresh mount would have started on.
|
|
378
|
+
const opening = outboxMessageId
|
|
379
|
+
? { html: "", text: "" }
|
|
380
|
+
: freshDocument(signatureRef.current);
|
|
344
381
|
setToAddresses([]);
|
|
345
382
|
setCcAddresses([]);
|
|
346
383
|
setBccAddresses([]);
|
|
347
384
|
setSubject("");
|
|
348
385
|
setShowCc(false);
|
|
349
386
|
setShowBcc(false);
|
|
350
|
-
setInitialHtml(
|
|
351
|
-
setInitialText(
|
|
387
|
+
setInitialHtml(opening.html);
|
|
388
|
+
setInitialText(opening.text);
|
|
352
389
|
setBodyMode("rich");
|
|
353
390
|
setDraftLanguage(undefined);
|
|
354
|
-
setBody(
|
|
391
|
+
setBody({ ...opening, formatting: [] });
|
|
355
392
|
setDocumentGeneration((generation) => generation + 1);
|
|
356
393
|
setDraftLoaded(false);
|
|
394
|
+
setOpenDocumentId(outboxMessageId);
|
|
357
395
|
}, [outboxMessageId]);
|
|
358
396
|
|
|
359
397
|
const { signature } = useSignature(selectedAccountId);
|
|
398
|
+
// Read when a new message starts, never depended on: a signature arriving is
|
|
399
|
+
// not a reason to reopen the document somebody is typing in.
|
|
400
|
+
const signatureRef = useRef(signature.plainText);
|
|
401
|
+
signatureRef.current = signature.plainText;
|
|
360
402
|
// The editor reads its document once, so this is the document it opens on,
|
|
361
403
|
// not the live value, and it is remounted when the generation changes. Only
|
|
362
404
|
// loading a different document bumps that — remounting mid-compose would take
|
|
363
405
|
// the caret, the focus and the undo history with it.
|
|
364
406
|
const [documentGeneration, setDocumentGeneration] = useState(0);
|
|
365
|
-
const [initialHtml, setInitialHtml] = useState(
|
|
366
|
-
|
|
407
|
+
const [initialHtml, setInitialHtml] = useState(
|
|
408
|
+
() => freshDocument(signature.plainText).html,
|
|
367
409
|
);
|
|
368
410
|
const [initialText, setInitialText] = useState(signature.plainText);
|
|
369
411
|
const [bodyMode, setBodyMode] = useState<ComposeBodyMode>("rich");
|
|
@@ -373,8 +415,7 @@ export const ComposeForm = ({
|
|
|
373
415
|
const [composeLanguage, setComposeLanguage] = useState("en");
|
|
374
416
|
const [draftLanguage, setDraftLanguage] = useState<string | undefined>();
|
|
375
417
|
const [body, setBody] = useState<RichTextValue>(() => ({
|
|
376
|
-
|
|
377
|
-
text: signature.plainText,
|
|
418
|
+
...freshDocument(signature.plainText),
|
|
378
419
|
formatting: [],
|
|
379
420
|
}));
|
|
380
421
|
|
|
@@ -475,9 +516,14 @@ export const ComposeForm = ({
|
|
|
475
516
|
const adoptCreatedDraft = useCallback(
|
|
476
517
|
(createdId: string) => {
|
|
477
518
|
setDraftLoaded(true);
|
|
478
|
-
|
|
519
|
+
// What is on screen is what was just written, so the fields belong to
|
|
520
|
+
// the new draft the moment it exists — before the id has travelled out
|
|
521
|
+
// through the address and back. Waiting for that would hold the next
|
|
522
|
+
// autosave, and the last thing typed before a pause would not be saved.
|
|
523
|
+
setOpenDocumentId(createdId);
|
|
524
|
+
onDraftCreated(createdId);
|
|
479
525
|
},
|
|
480
|
-
[
|
|
526
|
+
[onDraftCreated],
|
|
481
527
|
);
|
|
482
528
|
|
|
483
529
|
const { saveStatus, saveError, saveDraft, saveImmediately, stopAutoSave } =
|
|
@@ -578,10 +624,15 @@ export const ComposeForm = ({
|
|
|
578
624
|
|
|
579
625
|
useEffect(() => {
|
|
580
626
|
if (!selectedAccountId) return;
|
|
581
|
-
// Don't autosave
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
// draft's content
|
|
627
|
+
// Don't autosave a document the fields are no longer on. They trail the
|
|
628
|
+
// address by a commit whenever it changes, so writing here sends the
|
|
629
|
+
// message just left to whatever the address now names — the previous
|
|
630
|
+
// draft's content onto the next draft, or, with no draft named at all, a
|
|
631
|
+
// spurious second one holding it (#535/#536).
|
|
632
|
+
if (openDocumentId !== undefined && openDocumentId !== outboxMessageId)
|
|
633
|
+
return;
|
|
634
|
+
// Nor one still arriving: opening an existing draft leaves the fields
|
|
635
|
+
// mid-population until the read lands.
|
|
585
636
|
if (outboxMessageId && !draftLoaded) return;
|
|
586
637
|
if (isFormEmpty(toAddresses, ccAddresses, bccAddresses, subject, body))
|
|
587
638
|
return;
|
|
@@ -606,6 +657,7 @@ export const ComposeForm = ({
|
|
|
606
657
|
}, [
|
|
607
658
|
selectedAccountId,
|
|
608
659
|
outboxMessageId,
|
|
660
|
+
openDocumentId,
|
|
609
661
|
draftLoaded,
|
|
610
662
|
toAddresses,
|
|
611
663
|
ccAddresses,
|
|
@@ -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,19 @@ import {
|
|
|
18
12
|
useRef,
|
|
19
13
|
useState,
|
|
20
14
|
} from "react";
|
|
21
|
-
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
22
|
-
import { useComposeTarget } from "@/hooks/useComposeTargetMailbox";
|
|
23
|
-
import { hostsComposeSurface } from "@/lib/compose-routes";
|
|
24
|
-
import { locationOpensDetail } from "@/lib/mail-route";
|
|
25
|
-
export type ComposeMode = "reply" | "reply_all" | "forward" | "new";
|
|
26
15
|
|
|
27
|
-
export
|
|
28
|
-
isOpen: boolean;
|
|
29
|
-
mode: ComposeMode;
|
|
30
|
-
account?: RemitImapAccountResponse;
|
|
31
|
-
sourceMessage?: RemitImapDescribeMessageResponse;
|
|
32
|
-
threadId?: string;
|
|
33
|
-
mailboxId?: string;
|
|
34
|
-
outboxMessageId?: string;
|
|
35
|
-
}
|
|
16
|
+
export type ComposeMode = "reply" | "reply_all" | "forward" | "new";
|
|
36
17
|
|
|
18
|
+
/**
|
|
19
|
+
* A message on its way out outlives the composer that wrote it: the surface
|
|
20
|
+
* closes on Send and the outbox row is still queued. So the watch sits above
|
|
21
|
+
* every composer, and is the only thing here.
|
|
22
|
+
*
|
|
23
|
+
* Which draft is being written, and whether a composer is on screen at all, are
|
|
24
|
+
* the address's answers — `useComposeDraftId` and `useIsComposing`. A second
|
|
25
|
+
* copy here is what let a reply's draft turn up in the next new message.
|
|
26
|
+
*/
|
|
37
27
|
interface ComposeContextValue {
|
|
38
|
-
state: ComposeState;
|
|
39
|
-
openCompose: (params: Omit<ComposeState, "isOpen">) => void;
|
|
40
|
-
closeCompose: () => void;
|
|
41
|
-
setOutboxMessageId: (id: string) => void;
|
|
42
28
|
startSendPolling: (outboxMessageId: string) => void;
|
|
43
29
|
}
|
|
44
30
|
|
|
@@ -46,11 +32,6 @@ const ComposeContext = createContext<ComposeContextValue | undefined>(
|
|
|
46
32
|
undefined,
|
|
47
33
|
);
|
|
48
34
|
|
|
49
|
-
const INITIAL_STATE: ComposeState = {
|
|
50
|
-
isOpen: false,
|
|
51
|
-
mode: "new",
|
|
52
|
-
};
|
|
53
|
-
|
|
54
35
|
const POLL_INTERVAL_MS = 2000;
|
|
55
36
|
const MAX_POLL_DURATION_MS = 60_000;
|
|
56
37
|
|
|
@@ -59,26 +40,11 @@ export const ComposeProvider = ({
|
|
|
59
40
|
}: {
|
|
60
41
|
children: React.ReactNode;
|
|
61
42
|
}) => {
|
|
62
|
-
const [state, setState] = useState<ComposeState>(INITIAL_STATE);
|
|
63
43
|
const [pollingMessageId, setPollingMessageId] = useState<
|
|
64
44
|
string | undefined
|
|
65
45
|
>();
|
|
66
46
|
const startedAtRef = useRef(0);
|
|
67
47
|
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
48
|
|
|
83
49
|
const { data: polledMessage } = useQuery({
|
|
84
50
|
...outboxDetailOperationsGetOutboxMessageOptions({
|
|
@@ -111,89 +77,12 @@ export const ComposeProvider = ({
|
|
|
111
77
|
}
|
|
112
78
|
}, [polledMessage, pollingMessageId, queryClient]);
|
|
113
79
|
|
|
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
80
|
const startSendPolling = useCallback((outboxMessageId: string) => {
|
|
183
81
|
startedAtRef.current = Date.now();
|
|
184
82
|
setPollingMessageId(outboxMessageId);
|
|
185
83
|
}, []);
|
|
186
84
|
|
|
187
|
-
const value = useMemo(
|
|
188
|
-
() => ({
|
|
189
|
-
state,
|
|
190
|
-
openCompose,
|
|
191
|
-
closeCompose,
|
|
192
|
-
setOutboxMessageId,
|
|
193
|
-
startSendPolling,
|
|
194
|
-
}),
|
|
195
|
-
[state, openCompose, closeCompose, setOutboxMessageId, startSendPolling],
|
|
196
|
-
);
|
|
85
|
+
const value = useMemo(() => ({ startSendPolling }), [startSendPolling]);
|
|
197
86
|
|
|
198
87
|
return (
|
|
199
88
|
<ComposeContext.Provider value={value}>{children}</ComposeContext.Provider>
|
|
@@ -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>
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
RemitImapAccountResponse,
|
|
3
3
|
RemitImapDescribeMessageResponse,
|
|
4
4
|
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import { useState } from "react";
|
|
5
6
|
import { ComposeForm } from "./ComposeForm";
|
|
6
7
|
import type { ComposeMode } from "./ComposeProvider";
|
|
7
8
|
|
|
@@ -24,14 +25,23 @@ export const InlineCompose = ({
|
|
|
24
25
|
account,
|
|
25
26
|
sourceMessage,
|
|
26
27
|
onClose,
|
|
27
|
-
}: InlineComposeProps) =>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
}: InlineComposeProps) => {
|
|
29
|
+
// The reply's draft lives and dies with the reply. It is not addressable yet
|
|
30
|
+
// — that is the `$mode` child route (#720) — so it is held here, where the
|
|
31
|
+
// composer is, rather than anywhere a later composer could read it back.
|
|
32
|
+
const [outboxMessageId, setOutboxMessageId] = useState<string>();
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="border-b border-line bg-canvas">
|
|
36
|
+
<ComposeForm
|
|
37
|
+
layout="flow"
|
|
38
|
+
mode={mode}
|
|
39
|
+
account={account}
|
|
40
|
+
sourceMessage={sourceMessage}
|
|
41
|
+
outboxMessageId={outboxMessageId}
|
|
42
|
+
onDraftCreated={setOutboxMessageId}
|
|
43
|
+
onClose={onClose}
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
};
|
|
@@ -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>
|
|
@@ -37,11 +37,11 @@ import {
|
|
|
37
37
|
createRouter,
|
|
38
38
|
RouterContextProvider,
|
|
39
39
|
} from "@tanstack/react-router";
|
|
40
|
-
import { createElement,
|
|
40
|
+
import { createElement, useState } from "react";
|
|
41
41
|
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
42
42
|
import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
|
|
43
43
|
import { ComposeForm } from "./ComposeForm";
|
|
44
|
-
import { ComposeProvider
|
|
44
|
+
import { ComposeProvider } from "./ComposeProvider";
|
|
45
45
|
|
|
46
46
|
const ACCOUNT_ID = "acc-1";
|
|
47
47
|
const OUTBOX_MESSAGE_ID = "ob-604";
|
|
@@ -108,19 +108,17 @@ const patchesAfterTheSend = (): number => {
|
|
|
108
108
|
.length;
|
|
109
109
|
};
|
|
110
110
|
|
|
111
|
+
// The draft the composer is on is its owner's to hand it — the address for the
|
|
112
|
+
// compose route, local state for an inline reply. Here it is the test's.
|
|
111
113
|
const Opened = ({ outboxMessageId }: { outboxMessageId?: string }) => {
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
useEffect(() => {
|
|
115
|
-
openCompose({ mode: "reply", account, sourceMessage, outboxMessageId });
|
|
116
|
-
}, [openCompose, outboxMessageId]);
|
|
117
|
-
|
|
118
|
-
if (!state.isOpen) return null;
|
|
114
|
+
const [draftId, setDraftId] = useState(outboxMessageId);
|
|
119
115
|
|
|
120
116
|
return createElement(ComposeForm, {
|
|
121
117
|
mode: "reply",
|
|
122
118
|
account,
|
|
123
119
|
sourceMessage,
|
|
120
|
+
outboxMessageId: draftId,
|
|
121
|
+
onDraftCreated: setDraftId,
|
|
124
122
|
onClose: () => {
|
|
125
123
|
closed += 1;
|
|
126
124
|
},
|