@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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/components/compose/ComposeForm.tsx +77 -25
  3. package/src/components/compose/ComposeProvider.tsx +11 -122
  4. package/src/components/compose/FullCompose.tsx +29 -18
  5. package/src/components/compose/InlineCompose.tsx +21 -11
  6. package/src/components/compose/MobileComposeSheet.tsx +14 -18
  7. package/src/components/compose/compose-send-stops-autosave.render.test.ts +7 -9
  8. package/src/components/compose/compose-starts-a-second-message.render.test.ts +170 -0
  9. package/src/components/compose/compose-title.ts +10 -0
  10. package/src/components/compose/mobile-header-stays-expanded.render.test.ts +5 -13
  11. package/src/components/layout/ComposeFab.tsx +6 -15
  12. package/src/components/layout/MailShell.tsx +9 -1
  13. package/src/components/layout/MailTopBar.tsx +3 -6
  14. package/src/components/mail/DraftsView.tsx +12 -14
  15. package/src/components/mail/MailboxPane.tsx +27 -117
  16. package/src/components/mail/OutboxPane.tsx +3 -9
  17. package/src/hooks/useSaveDraft.ts +11 -0
  18. package/src/hooks/useSearchMirror.ts +12 -1
  19. package/src/lib/mail-route.test.ts +35 -1
  20. package/src/lib/mail-route.ts +2 -1
  21. package/src/routeTree.gen.ts +92 -0
  22. package/src/routes/mail/$mailboxId/compose.{-$outboxMessageId}.tsx +14 -0
  23. package/src/routes/mail/brief/compose.{-$outboxMessageId}.tsx +22 -0
  24. package/src/routes/mail/flagged/compose.{-$outboxMessageId}.tsx +13 -0
  25. package/src/routes/mail/outbox/compose.{-$outboxMessageId}.tsx +17 -0
  26. package/src/routes/mail.tsx +12 -8
  27. package/src/routing/compose-press-opens.render.test.ts +179 -0
  28. package/src/routing/compose.ts +220 -0
  29. package/src/routing/index.ts +8 -0
  30. package/src/components/compose/compose-clears-open-thread.render.test.ts +0 -312
  31. package/src/hooks/useComposeTargetMailbox.ts +0 -75
  32. package/src/lib/compose-routes.test.ts +0 -46
  33. 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.166",
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 { state, setOutboxMessageId, startSendPolling } = useCompose();
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
- // Reset form when the user switches to a different draft (outboxMessageId
331
- // changes while compose is already open). Without this, the previous draft's
332
- // fields stay visible and the new draft never loads because draftLoaded
333
- // remains true from the prior session (#536).
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
- // A first autosave also sets the id, from nothing to the draft it just
336
- // created. That is this session's own content arriving back, not a switch to
337
- // somebody else's document, and blanking the form there would throw away
338
- // whatever is being typed.
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 (!outboxMessageId || previous === undefined) return;
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(EMPTY_RICH_TEXT);
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
- buildInitialHtml(signature.plainText),
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
- html: buildInitialHtml(signature.plainText),
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
- setOutboxMessageId(createdId);
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
- [setOutboxMessageId],
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 while a draft is still being loaded. The fields are mid-
582
- // population when opening an existing draft, or have just been blanked for
583
- // a draft switch (#535/#536). Saving now would either write the previous
584
- // draft's content to the new outboxMessageId or create a spurious duplicate.
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 interface ComposeState {
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 { useCompose } from "./ComposeProvider";
13
+ import { composeSurfaceTitle } from "./compose-title";
8
14
  import { MobileComposeSheet } from "./MobileComposeSheet";
9
15
 
10
- const MODE_LABELS: Record<string, string> = {
11
- reply: "Reply",
12
- reply_all: "Reply All",
13
- forward: "Forward",
14
- new: "New Message",
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 { state, closeCompose } = useCompose();
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
- if (!isDesktop) return <MobileComposeSheet />;
30
-
31
- if (!state.isOpen) return null;
35
+ useKeyboardNavigation({
36
+ bindings: [{ key: "Escape", handler: closeCompose, preventDefault: true }],
37
+ });
32
38
 
33
- const title = MODE_LABELS[state.mode] ?? "New Message";
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">{title}</h2>
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
- key={state.outboxMessageId ?? "new"}
74
- mode={state.mode}
75
- account={state.account}
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
- <div className="border-b border-line bg-canvas">
29
- <ComposeForm
30
- layout="flow"
31
- mode={mode}
32
- account={account}
33
- sourceMessage={sourceMessage}
34
- onClose={onClose}
35
- />
36
- </div>
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 { useCompose } from "./ComposeProvider";
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 { state, closeCompose } = useCompose();
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={state.isOpen}
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
- {title}
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={state.mode}
114
- account={state.account}
115
- sourceMessage={state.sourceMessage}
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, useEffect } from "react";
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, useCompose } from "./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 { state, openCompose } = useCompose();
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
  },