@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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/src/components/compose/ComposeForm.tsx +91 -29
  3. package/src/components/compose/ComposeProvider.tsx +19 -124
  4. package/src/components/compose/ConversationCompose.tsx +57 -0
  5. package/src/components/compose/FullCompose.tsx +29 -18
  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/BriefPane.tsx +49 -13
  15. package/src/components/mail/ConversationView.tsx +82 -77
  16. package/src/components/mail/DraftsView.tsx +12 -14
  17. package/src/components/mail/FlaggedPane.tsx +49 -13
  18. package/src/components/mail/MailboxPane.tsx +77 -167
  19. package/src/components/mail/OutboxPane.tsx +3 -9
  20. package/src/components/mail/conversation-reply-reach.render.test.ts +81 -28
  21. package/src/hooks/useSaveDraft.ts +11 -0
  22. package/src/hooks/useSearchMirror.ts +12 -1
  23. package/src/hooks/useThreadActions.ts +8 -17
  24. package/src/lib/mail-route.test.ts +35 -1
  25. package/src/lib/mail-route.ts +2 -1
  26. package/src/routeTree.gen.ts +220 -15
  27. package/src/routes/mail/$mailboxId/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +14 -0
  28. package/src/routes/mail/$mailboxId/$threadId/$messageId.tsx +4 -0
  29. package/src/routes/mail/$mailboxId/compose.{-$outboxMessageId}.tsx +14 -0
  30. package/src/routes/mail/brief/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +26 -0
  31. package/src/routes/mail/brief/$threadId/$messageId.tsx +4 -0
  32. package/src/routes/mail/brief/compose.{-$outboxMessageId}.tsx +22 -0
  33. package/src/routes/mail/flagged/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +14 -0
  34. package/src/routes/mail/flagged/$threadId/$messageId.tsx +4 -0
  35. package/src/routes/mail/flagged/compose.{-$outboxMessageId}.tsx +13 -0
  36. package/src/routes/mail/outbox/compose.{-$outboxMessageId}.tsx +17 -0
  37. package/src/routes/mail.tsx +12 -8
  38. package/src/routing/browsed-list.ts +22 -0
  39. package/src/routing/compose-press-opens.render.test.ts +179 -0
  40. package/src/routing/compose.ts +205 -0
  41. package/src/routing/index.ts +20 -0
  42. package/src/routing/reply.ts +282 -0
  43. package/src/components/compose/InlineCompose.tsx +0 -37
  44. package/src/components/compose/compose-clears-open-thread.render.test.ts +0 -312
  45. package/src/hooks/useComposeTargetMailbox.ts +0 -75
  46. package/src/lib/compose-routes.test.ts +0 -46
  47. 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.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 !== "reply_all") return { to, cc: [] };
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 { 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,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
- // 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).
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
- // 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.
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 (!outboxMessageId || previous === undefined) return;
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(EMPTY_RICH_TEXT);
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
- buildInitialHtml(signature.plainText),
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
- html: buildInitialHtml(signature.plainText),
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 === "reply_all") {
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 === "reply_all" || mode === "forward";
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
- setOutboxMessageId(createdId);
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
- [setOutboxMessageId],
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 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.
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 === "reply_all")
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 { 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
-
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
- }
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 { 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>
@@ -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>