@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
@@ -16,8 +16,6 @@
16
16
  * On phone, use `<MailboxPane.Phone />` instead of the slot sub-views.
17
17
  */
18
18
  import {
19
- outboxDetailOperationsDeleteOutboxMessageMutation,
20
- outboxOperationsListOutboxMessagesQueryKey,
21
19
  threadOperationsListThreadsQueryKey,
22
20
  threadOperationsSearchThreadsQueryKey,
23
21
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
@@ -35,11 +33,7 @@ import {
35
33
  type SearchResult,
36
34
  useAppShellLayout,
37
35
  } from "@remit/ui";
38
- import {
39
- useInfiniteQuery,
40
- useMutation,
41
- useQueryClient,
42
- } from "@tanstack/react-query";
36
+ import { useInfiniteQuery } from "@tanstack/react-query";
43
37
  import { useNavigate } from "@tanstack/react-router";
44
38
  import {
45
39
  createContext,
@@ -52,9 +46,6 @@ import {
52
46
  useRef,
53
47
  useState,
54
48
  } from "react";
55
- import type { ComposeMode } from "@/components/compose/ComposeProvider";
56
- import { useCompose } from "@/components/compose/ComposeProvider";
57
- import { FullCompose } from "@/components/compose/FullCompose";
58
49
  import { Drawer } from "@/components/layout/Drawer";
59
50
  import { ConversationView } from "@/components/mail/ConversationView";
60
51
  import { DraftsView } from "@/components/mail/DraftsView";
@@ -66,8 +57,6 @@ import {
66
57
  import { MessageToolbar } from "@/components/mail/MessageToolbar";
67
58
  import { PullToRefresh } from "@/components/mail/PullToRefresh";
68
59
  import { SpamRescue } from "@/components/mail/SpamRescue";
69
- import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
70
- import { buildMutationErrorBanner } from "@/components/ui/error-banners";
71
60
  import {
72
61
  useArchiveMailbox,
73
62
  useDraftsMailbox,
@@ -83,7 +72,6 @@ import {
83
72
  } from "@/hooks/useDeleteMessages";
84
73
  import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
85
74
  import { useIntelligenceData } from "@/hooks/useIntelligenceData";
86
- import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
87
75
  import { useLayoutTier } from "@/hooks/useLayoutTier";
88
76
  import { useMailboxAccount } from "@/hooks/useMailboxAccount";
89
77
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
@@ -127,6 +115,11 @@ import {
127
115
  import {
128
116
  type OpenThreadPath,
129
117
  type OpenThreadTarget,
118
+ type ReplyMode,
119
+ useIsComposing,
120
+ useIsReplying,
121
+ useOpenCompose,
122
+ useOpenReply,
130
123
  useRetainOpenPanels,
131
124
  } from "@/routing";
132
125
  import { MailViewChrome } from "./MailViewChrome";
@@ -208,18 +201,14 @@ interface MailboxPaneContextValue {
208
201
  listCommandsRef: RefObject<MessageListCommands | null>;
209
202
  onRetry: () => void;
210
203
  // Toolbar / reading pane actions
211
- toolbarComposeRequest: ComposeMode | null;
212
- onToolbarReply: () => void;
213
- onToolbarReplyAll: () => void;
214
- onToolbarForward: () => void;
215
- onClearComposeRequest: () => void;
204
+ /**
205
+ * Answer the open conversation. Absent when none is open, which is what the
206
+ * toolbar turns into its own explanation.
207
+ */
208
+ onReply: ((mode: ReplyMode) => void) | undefined;
216
209
  onToolbarDelete: () => void;
217
210
  onToolbarStar: () => void;
218
- onToolbarDiscardDraft: () => void;
219
211
  onToolbarMove: (destMailboxId: string) => void;
220
- composeState: ReturnType<typeof useCompose>["state"];
221
- closeCompose: () => void;
222
- hasRemitDraftOpen: boolean;
223
212
  // Phone actions
224
213
  onBack: () => void;
225
214
  /** The rows either side of the open one — the phone's swipe gestures. */
@@ -539,9 +528,6 @@ function MailboxPaneProvider({
539
528
  // so there is no fallback: until the mailbox resolves there is no number.
540
529
  const unreadCount = useCurrentMailboxUnseenCount({ accounts }) ?? 0;
541
530
 
542
- const queryClient = useQueryClient();
543
- const { pushError } = useErrorBanners();
544
-
545
531
  const toolbarActions = useThreadActions({
546
532
  thread: selectedThread,
547
533
  mailboxId,
@@ -557,60 +543,35 @@ function MailboxPaneProvider({
557
543
 
558
544
  const getThreadMessageIds = useThreadMessageIds();
559
545
 
560
- const { requestCompose: setToolbarComposeRequest } = toolbarActions;
561
-
562
- const handleToolbarReply = useCallback(() => {
563
- setToolbarComposeRequest("reply");
564
- }, [setToolbarComposeRequest]);
565
- const handleToolbarReplyAll = useCallback(() => {
566
- setToolbarComposeRequest("reply_all");
567
- }, [setToolbarComposeRequest]);
568
- const handleToolbarForward = useCallback(() => {
569
- setToolbarComposeRequest("forward");
570
- }, [setToolbarComposeRequest]);
571
-
572
- const { state: composeState, openCompose, closeCompose } = useCompose();
546
+ const openReply = useOpenReply();
547
+
548
+ // The toolbar answers the conversation on screen; the keyboard answers the
549
+ // row the cursor is on, which may be one the address has not opened yet. Both
550
+ // are one navigation, because the mode and the message it answers are
551
+ // segments of the same address.
552
+ const replyToOpenThread = useMemo(() => {
553
+ if (!selectedThread || !selectedMessageId) return undefined;
554
+ return (mode: ReplyMode) =>
555
+ openReply({
556
+ threadId: selectedThread.threadId,
557
+ messageId: selectedMessageId,
558
+ mode,
559
+ });
560
+ }, [openReply, selectedThread, selectedMessageId]);
561
+
562
+ const replyToFocusedThread = useMemo(() => {
563
+ if (!focusedThread) return undefined;
564
+ return (mode: ReplyMode) =>
565
+ openReply({
566
+ threadId: focusedThread.threadId,
567
+ messageId: focusedThread.messageId,
568
+ mode,
569
+ });
570
+ }, [openReply, focusedThread]);
573
571
 
574
- const handleNewCompose = useCallback(() => {
575
- openCompose({ mode: "new" });
576
- }, [openCompose]);
577
-
578
- // A thread opening closes compose. Only a selection arriving counts, so this
579
- // cannot close the compose that just cleared one.
580
- const previousSelectionRef = useRef(selectedMessageId);
581
- useEffect(() => {
582
- const previous = previousSelectionRef.current;
583
- previousSelectionRef.current = selectedMessageId;
584
- if (!selectedMessageId || selectedMessageId === previous) return;
585
- closeCompose();
586
- }, [selectedMessageId, closeCompose]);
587
-
588
- const deleteOutboxMutation = useMutation({
589
- ...outboxDetailOperationsDeleteOutboxMessageMutation(),
590
- onError: (mutationError) => {
591
- pushError(
592
- buildMutationErrorBanner(
593
- "Couldn't discard draft",
594
- "The draft wasn't deleted.",
595
- mutationError,
596
- ),
597
- );
598
- },
599
- });
600
- const handleToolbarDiscardDraft = useCallback(() => {
601
- const outboxMessageId = composeState.outboxMessageId;
602
- if (!outboxMessageId) return;
603
- deleteOutboxMutation.mutate({ path: { outboxMessageId } });
604
- queryClient.invalidateQueries({
605
- queryKey: outboxOperationsListOutboxMessagesQueryKey(),
606
- });
607
- closeCompose();
608
- }, [
609
- composeState.outboxMessageId,
610
- deleteOutboxMutation,
611
- queryClient,
612
- closeCompose,
613
- ]);
572
+ const isComposing = useIsComposing();
573
+ const isReplying = useIsReplying();
574
+ const openCompose = useOpenCompose();
614
575
 
615
576
  const messageIdsForFocusedThread = useCallback(
616
577
  (thread: typeof focusedThread): string[] => {
@@ -655,26 +616,18 @@ function MailboxPaneProvider({
655
616
  senderEmail: focusedThread?.fromEmail ?? undefined,
656
617
  });
657
618
 
658
- const ensureFocusedOpen = useCallback(() => {
659
- if (selectedMessageId || !triageFocusedId) return false;
660
- const row = threads.find((t) => t.messageId === triageFocusedId);
661
- if (!row) return false;
662
- handleOpenThread({ threadId: row.threadId, messageId: triageFocusedId });
663
- return true;
664
- }, [selectedMessageId, triageFocusedId, threads, handleOpenThread]);
665
-
666
- const triageReply = useCallback(() => {
667
- if (ensureFocusedOpen()) return;
668
- if (selectedThread) setToolbarComposeRequest("reply");
669
- }, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
670
- const triageReplyAll = useCallback(() => {
671
- if (ensureFocusedOpen()) return;
672
- if (selectedThread) setToolbarComposeRequest("reply_all");
673
- }, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
674
- const triageForward = useCallback(() => {
675
- if (ensureFocusedOpen()) return;
676
- if (selectedThread) setToolbarComposeRequest("forward");
677
- }, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
619
+ const triageReply = useCallback(
620
+ () => replyToFocusedThread?.("reply"),
621
+ [replyToFocusedThread],
622
+ );
623
+ const triageReplyAll = useCallback(
624
+ () => replyToFocusedThread?.("reply-all"),
625
+ [replyToFocusedThread],
626
+ );
627
+ const triageForward = useCallback(
628
+ () => replyToFocusedThread?.("forward"),
629
+ [replyToFocusedThread],
630
+ );
678
631
 
679
632
  const triageTargetMessageIds = useCallback(
680
633
  (): string[] => messageIdsForFocusedThread(focusedThread),
@@ -784,17 +737,14 @@ function MailboxPaneProvider({
784
737
  }
785
738
  }, [normalizedSearchQuery, mailboxType, telemetry]);
786
739
 
787
- const hasRemitDraftOpen =
788
- isDraftsMailbox &&
789
- composeState.isOpen &&
790
- !!composeState.outboxMessageId &&
791
- !selectedThread;
792
-
793
740
  const { goBack, nextMessageId, previousMessageId } = useTriageLayer({
794
741
  context: triage,
795
742
  orderedIds: threads.map((t) => t.messageId),
796
743
  selectedMessageId,
797
- enabled: !composeState.isOpen,
744
+ // The list stays mounted under both writing surfaces, so the triage keys
745
+ // would otherwise fire at the message behind whatever is being typed — or
746
+ // answer a row the cursor moved to while a reply was open.
747
+ enabled: !isComposing && !isReplying,
798
748
  onClose: closeThread,
799
749
  handlers: {
800
750
  reply: triageReply,
@@ -808,7 +758,7 @@ function MailboxPaneProvider({
808
758
  vipSender: triageVip,
809
759
  markJunk: triageMarkJunk,
810
760
  toggleIntelligence: selectedThread ? onToggleIntelligence : undefined,
811
- compose: handleNewCompose,
761
+ compose: openCompose,
812
762
  goBrief: () => goToRoute("/mail/brief"),
813
763
  goInbox: () => goToRoute("/mail/brief"),
814
764
  goSent: () => goToRoute("/mail/brief"),
@@ -817,11 +767,6 @@ function MailboxPaneProvider({
817
767
  },
818
768
  });
819
769
 
820
- useKeyboardNavigation({
821
- enabled: composeState.isOpen,
822
- bindings: [{ key: "Escape", handler: closeCompose, preventDefault: true }],
823
- });
824
-
825
770
  // The swipe gestures open a whole conversation, so the adjacent row has to
826
771
  // name its thread. This folder's own listing is where that is looked up, so a
827
772
  // row it does not hold offers no gesture rather than a tap that goes nowhere.
@@ -877,18 +822,10 @@ function MailboxPaneProvider({
877
822
  onTriageContextChange: handleTriageContextChange,
878
823
  listCommandsRef,
879
824
  onRetry: () => refetch(),
880
- toolbarComposeRequest: toolbarActions.composeRequest,
881
- onToolbarReply: handleToolbarReply,
882
- onToolbarReplyAll: handleToolbarReplyAll,
883
- onToolbarForward: handleToolbarForward,
884
- onClearComposeRequest: toolbarActions.clearComposeRequest,
825
+ onReply: replyToOpenThread,
885
826
  onToolbarDelete: toolbarActions.deleteThread,
886
827
  onToolbarStar: toolbarActions.toggleStar,
887
- onToolbarDiscardDraft: handleToolbarDiscardDraft,
888
828
  onToolbarMove: toolbarActions.moveThread,
889
- composeState,
890
- closeCompose,
891
- hasRemitDraftOpen,
892
829
  onBack: goBack,
893
830
  nextThread: adjacentThread(nextMessageId),
894
831
  previousThread: adjacentThread(previousMessageId),
@@ -1138,19 +1075,12 @@ function MailboxReading() {
1138
1075
  mailboxAccountId,
1139
1076
  selectedThread,
1140
1077
  conversation,
1141
- hasRemitDraftOpen,
1142
1078
  intelligenceOpen,
1143
1079
  onToggleIntelligence,
1144
- toolbarComposeRequest,
1145
- onToolbarReply,
1146
- onToolbarReplyAll,
1147
- onToolbarForward,
1148
- onClearComposeRequest,
1080
+ onReply,
1149
1081
  onToolbarDelete,
1150
1082
  onToolbarStar,
1151
- onToolbarDiscardDraft,
1152
1083
  onToolbarMove,
1153
- composeState,
1154
1084
  handleDeselectIfRemoved,
1155
1085
  } = useMailboxPane();
1156
1086
  // Which surface intelligence has here: the rail between 1280 and up, the
@@ -1197,25 +1127,20 @@ function MailboxReading() {
1197
1127
  const intelligenceShowing =
1198
1128
  hasThread && (railFits ? intelligenceOpen : drawerOpen);
1199
1129
 
1200
- const detailPane =
1201
- composeState.isOpen && !conversation ? (
1202
- <FullCompose />
1203
- ) : conversation ? (
1204
- <ConversationView
1205
- threadId={conversation.threadId}
1206
- mailboxId={conversation.mailboxId}
1207
- subject={conversation.subject}
1208
- selectedMessageId={conversation.messageId}
1209
- authenticity={conversation.authenticity}
1210
- onOpenIntelligence={
1211
- conversation.authenticity?.dkimMismatch ? openIntelligence : undefined
1212
- }
1213
- composeRequest={toolbarComposeRequest}
1214
- onComposeClose={onClearComposeRequest}
1215
- />
1216
- ) : (
1217
- <ReadingPaneEmpty />
1218
- );
1130
+ const detailPane = conversation ? (
1131
+ <ConversationView
1132
+ threadId={conversation.threadId}
1133
+ mailboxId={conversation.mailboxId}
1134
+ subject={conversation.subject}
1135
+ selectedMessageId={conversation.messageId}
1136
+ authenticity={conversation.authenticity}
1137
+ onOpenIntelligence={
1138
+ conversation.authenticity?.dkimMismatch ? openIntelligence : undefined
1139
+ }
1140
+ />
1141
+ ) : (
1142
+ <ReadingPaneEmpty />
1143
+ );
1219
1144
 
1220
1145
  return (
1221
1146
  <>
@@ -1225,17 +1150,11 @@ function MailboxReading() {
1225
1150
  intelligenceOpen={intelligenceShowing}
1226
1151
  canToggleIntelligence={hasThread}
1227
1152
  onToggleIntelligence={toggleIntelligence}
1228
- onReply={hasThread ? onToolbarReply : undefined}
1229
- onReplyAll={hasThread ? onToolbarReplyAll : undefined}
1230
- onForward={hasThread ? onToolbarForward : undefined}
1231
- canDelete={hasThread || hasRemitDraftOpen}
1232
- onDelete={
1233
- hasThread
1234
- ? onToolbarDelete
1235
- : hasRemitDraftOpen
1236
- ? onToolbarDiscardDraft
1237
- : undefined
1238
- }
1153
+ onReply={onReply ? () => onReply("reply") : undefined}
1154
+ onReplyAll={onReply ? () => onReply("reply-all") : undefined}
1155
+ onForward={onReply ? () => onReply("forward") : undefined}
1156
+ canDelete={hasThread}
1157
+ onDelete={hasThread ? onToolbarDelete : undefined}
1239
1158
  onToggleStar={hasThread ? onToolbarStar : undefined}
1240
1159
  isStarred={selectedThread?.hasStars}
1241
1160
  moveContext={
@@ -1309,7 +1228,6 @@ function MailboxPhone() {
1309
1228
  onBack,
1310
1229
  nextThread,
1311
1230
  previousThread,
1312
- composeState,
1313
1231
  handleDeselectIfRemoved,
1314
1232
  } = useMailboxPane();
1315
1233
 
@@ -1349,14 +1267,6 @@ function MailboxPhone() {
1349
1267
  );
1350
1268
  }
1351
1269
 
1352
- if (composeState.isOpen) {
1353
- return (
1354
- <div className="h-full">
1355
- <FullCompose />
1356
- </div>
1357
- );
1358
- }
1359
-
1360
1270
  return <MailboxList />;
1361
1271
  }
1362
1272
 
@@ -48,7 +48,6 @@ import {
48
48
  useMemo,
49
49
  useRef,
50
50
  } from "react";
51
- import { useCompose } from "@/components/compose/ComposeProvider";
52
51
  import { MessageBody } from "@/components/mail/MessageBody";
53
52
  import { NavMenuButton } from "@/components/mail/NavMenuButton";
54
53
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
@@ -63,7 +62,7 @@ import {
63
62
  import { isOutboxListRow, isUnsendableStatus } from "@/lib/outbox-status";
64
63
  import { normalizeSearchQuery } from "@/lib/search-query";
65
64
  import { parseSearchTokens } from "@/lib/search-tokens";
66
- import { useRetainOpenPanels } from "@/routing";
65
+ import { useEditDraft, useRetainOpenPanels } from "@/routing";
67
66
 
68
67
  /* ------------------------------------------------------------------ */
69
68
  /* Helpers (shared between List, Reading, Phone sub-views) */
@@ -266,7 +265,7 @@ function OutboxMessageRow({
266
265
  onSelect,
267
266
  }: OutboxMessageRowProps) {
268
267
  const queryClient = useQueryClient();
269
- const { openCompose } = useCompose();
268
+ const editDraft = useEditDraft();
270
269
  const { pushError } = useErrorBanners();
271
270
 
272
271
  const invalidateOutbox = useCallback(() => {
@@ -331,12 +330,7 @@ function OutboxMessageRow({
331
330
  : undefined
332
331
  }
333
332
  retrying={retryMutation.isPending}
334
- onEdit={() =>
335
- openCompose({
336
- mode: "new",
337
- outboxMessageId: message.outboxMessageId,
338
- })
339
- }
333
+ onEdit={() => editDraft(message.outboxMessageId)}
340
334
  onDelete={() =>
341
335
  deleteMutation.mutate({
342
336
  path: { outboxMessageId: message.outboxMessageId },
@@ -126,35 +126,55 @@ afterEach(() => {
126
126
  // `window`.
127
127
  (globalThis as { self?: typeof globalThis }).self ??= globalThis;
128
128
 
129
- // The conversation is what the mailbox route renders, and the per-message
130
- // action menu reads the route's own search — so it is mounted on a route
131
- // rather than beside one.
132
- const testRouter = (): AnyRouter => {
129
+ const MESSAGE_PATH = `/mail/${MAILBOX_ID}/${THREAD_ID}/${MESSAGE_ID}`;
130
+ const REPLY_PATH = `${MESSAGE_PATH}/reply`;
131
+
132
+ // The conversation is what the message route renders, and the reply is the
133
+ // segment under it — so the tree here is the app's: the folder, the thread, the
134
+ // message, and the mode below it. The address is what opens the reply, so the
135
+ // cases below mount at one or the other.
136
+ const testRouter = (href: string): AnyRouter => {
133
137
  // The compose provider sits at the root the way `__root.tsx` mounts it.
134
138
  const rootRoute = createRootRoute({
135
139
  component: () =>
136
140
  createElement(ComposeProvider, null, createElement(Outlet)),
137
141
  });
142
+ const mailboxRoute = createRoute({
143
+ getParentRoute: () => rootRoute,
144
+ path: "/mail/$mailboxId",
145
+ validateSearch: (search: Record<string, unknown>) => search,
146
+ });
147
+ const threadRoute = createRoute({
148
+ getParentRoute: () => mailboxRoute,
149
+ path: "$threadId",
150
+ });
151
+ const messageRoute = createRoute({
152
+ getParentRoute: () => threadRoute,
153
+ path: "$messageId",
154
+ component: () =>
155
+ createElement(ConversationView, {
156
+ threadId: THREAD_ID,
157
+ mailboxId: MAILBOX_ID,
158
+ subject: "Lunch Thursday?",
159
+ selectedMessageId: MESSAGE_ID,
160
+ }),
161
+ });
162
+ const replyRoute = createRoute({
163
+ getParentRoute: () => messageRoute,
164
+ path: "$mode/{-$outboxMessageId}",
165
+ });
138
166
  const routeTree = rootRoute.addChildren([
139
- createRoute({
140
- getParentRoute: () => rootRoute,
141
- path: "/mail/$mailboxId",
142
- validateSearch: (search: Record<string, unknown>) => search,
143
- component: () =>
144
- createElement(ConversationView, {
145
- threadId: THREAD_ID,
146
- mailboxId: MAILBOX_ID,
147
- subject: "Lunch Thursday?",
148
- }),
149
- }),
167
+ mailboxRoute.addChildren([
168
+ threadRoute.addChildren([messageRoute.addChildren([replyRoute])]),
169
+ ]),
150
170
  ]);
151
171
  return createRouter({
152
172
  routeTree,
153
- history: createMemoryHistory({ initialEntries: [`/mail/${MAILBOX_ID}`] }),
173
+ history: createMemoryHistory({ initialEntries: [href] }),
154
174
  }) as unknown as AnyRouter;
155
175
  };
156
176
 
157
- const mount = async (): Promise<DomHarness> => {
177
+ const mountAt = async (href: string): Promise<[DomHarness, AnyRouter]> => {
158
178
  http = mockFetch((call) => {
159
179
  if (call.path.endsWith("/config")) return { accounts: [account] };
160
180
  if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
@@ -164,17 +184,39 @@ const mount = async (): Promise<DomHarness> => {
164
184
  return { items: [] };
165
185
  });
166
186
 
167
- const router = testRouter();
187
+ const router = testRouter(href);
168
188
  await router.load();
169
189
  harness = createDomHarness();
170
190
  harness.renderApp(createElement(RouterProvider, { router }));
171
191
  await harness.flush();
172
192
  await harness.wait(20);
173
193
  await harness.flush();
174
- return harness;
194
+ return [harness, router];
175
195
  };
176
196
 
177
- /** The r shortcut the reading pane binds — how a reader opens the reply. */
197
+ /** The conversation, with nothing being written under it. */
198
+ const mount = async (): Promise<DomHarness> => {
199
+ const [mounted] = await mountAt(MESSAGE_PATH);
200
+ return mounted;
201
+ };
202
+
203
+ /**
204
+ * The same conversation at the address that names a reply — which is the whole
205
+ * of what opens one.
206
+ *
207
+ * A cold load rather than a press: a navigation inside this harness moves the
208
+ * router but never re-renders the tree, so nothing here can say what the pane
209
+ * does as an address changes under it. Every case below is what one address
210
+ * renders, and the transitions between two of them — the reply opening, and its
211
+ * closing again with the conversation left standing — are asserted in
212
+ * `packages/e2e/specs/detail-routes.spec.ts`, against a real browser.
213
+ */
214
+ const mountReplying = async (): Promise<DomHarness> => {
215
+ const [mounted] = await mountAt(REPLY_PATH);
216
+ return mounted;
217
+ };
218
+
219
+ /** The r shortcut the reading pane binds — how a reader asks for the reply. */
178
220
  const pressReply = async (mounted: DomHarness): Promise<void> => {
179
221
  mounted.dispatch(
180
222
  mounted.window,
@@ -224,9 +266,7 @@ const verticalScrollers = (root: Element): Element[] =>
224
266
 
225
267
  describe("answering the message that is open", () => {
226
268
  it("puts the reply in the same scrolling region as the message", async () => {
227
- const mounted = await mount();
228
-
229
- await pressReply(mounted);
269
+ const mounted = await mountReplying();
230
270
 
231
271
  const pane = mounted.query("article");
232
272
  assert.ok(pane, "the conversation pane is mounted");
@@ -246,9 +286,7 @@ describe("answering the message that is open", () => {
246
286
  });
247
287
 
248
288
  it("leads the pane with the reply, above the thread it answers", async () => {
249
- const mounted = await mount();
250
-
251
- await pressReply(mounted);
289
+ const mounted = await mountReplying();
252
290
 
253
291
  const compose = mounted.query('[data-testid="compose-body-area"]');
254
292
  assert.ok(compose, "the reply opened");
@@ -313,7 +351,7 @@ describe("answering the message that is open", () => {
313
351
  );
314
352
  });
315
353
 
316
- it("gives the pane one scrollbar, reply open or not", async () => {
354
+ it("gives the pane one scrollbar with nothing being written", async () => {
317
355
  const mounted = await mount();
318
356
 
319
357
  const pane = mounted.query("article");
@@ -323,9 +361,13 @@ describe("answering the message that is open", () => {
323
361
  1,
324
362
  "reading a thread scrolls one thing",
325
363
  );
364
+ });
326
365
 
327
- await pressReply(mounted);
366
+ it("gives the pane one scrollbar with the reply open", async () => {
367
+ const mounted = await mountReplying();
328
368
 
369
+ const pane = mounted.query("article");
370
+ assert.ok(pane, "the conversation pane is mounted");
329
371
  assert.equal(
330
372
  verticalScrollers(pane).length,
331
373
  1,
@@ -333,6 +375,17 @@ describe("answering the message that is open", () => {
333
375
  );
334
376
  });
335
377
 
378
+ // The address is the whole of what opens a reply, so what the key does is
379
+ // move it: the mode lands under the message that was open, and the thread
380
+ // that was matched behind it stays matched.
381
+ it("moves the address to the reply under the open message", async () => {
382
+ const [mounted, router] = await mountAt(MESSAGE_PATH);
383
+
384
+ await pressReply(mounted);
385
+
386
+ assert.equal(router.state.location.pathname, REPLY_PATH);
387
+ });
388
+
336
389
  it("collapses the message from the chevron beside the sender", async () => {
337
390
  const mounted = await mount();
338
391
 
@@ -52,8 +52,19 @@ export const useSaveDraft = ({
52
52
  const propIdRef = useRef(outboxMessageId);
53
53
  const targetIdRef = useRef(outboxMessageId);
54
54
  if (propIdRef.current !== outboxMessageId) {
55
+ // A write waiting out the debounce holds the document that has just been
56
+ // left, aimed at whatever entry is current when it fires. Leaving one is
57
+ // therefore dropping it: otherwise it lands on the next draft, or — with
58
+ // no entry to land on at all — creates one holding the last message's
59
+ // content, which is what a second Compose press used to do.
60
+ //
61
+ // Adoption is the exception, as it is everywhere: the id arriving is this
62
+ // session's own draft coming back, and a save scheduled while it was in
63
+ // flight is still the right content for it.
64
+ const leavingADocument = propIdRef.current !== undefined;
55
65
  propIdRef.current = outboxMessageId;
56
66
  targetIdRef.current = outboxMessageId;
67
+ if (leavingADocument && timerRef.current) clearTimeout(timerRef.current);
57
68
  }
58
69
 
59
70
  const createMutation = useMutation(
@@ -2,6 +2,7 @@ import { useNavigate, useRouterState, useSearch } from "@tanstack/react-router";
2
2
  import { useEffect, useRef } from "react";
3
3
  import { useMailContext } from "@/lib/mail-context";
4
4
  import { shouldMirrorQuery } from "@/lib/search-view";
5
+ import { useIsComposing } from "@/routing";
5
6
 
6
7
  /**
7
8
  * The list the mirror writes to. Each list calls the hook with its own route,
@@ -40,12 +41,17 @@ export type SearchMirrorTarget =
40
41
  * was opened under it (not a pre-search leftover) and must survive. The close
41
42
  * otherwise raced the tap: the row shows before the debounce settles, so this
42
43
  * mirror can land just after the open and undo it.
44
+ *
45
+ * A composer is not the pre-search list's leftover either. It is the reader's
46
+ * own unsent message, so a search started while it is up narrows the list
47
+ * behind it and leaves it standing.
43
48
  */
44
49
  export function useSearchMirror(target: SearchMirrorTarget): void {
45
50
  const navigate = useNavigate();
46
51
  const { searchInput, searchQuery: committedQuery } = useMailContext();
47
52
  const { q: urlQuery = "" } = useSearch({ from: "/mail" });
48
53
  const pathname = useRouterState({ select: (s) => s.location.pathname });
54
+ const isComposing = useIsComposing();
49
55
 
50
56
  // Read at effect time rather than depended on: the URL is what the mirror
51
57
  // compares against, not what re-triggers it.
@@ -67,13 +73,17 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
67
73
  if (!mayWrite) return;
68
74
  const queryGoesActive =
69
75
  Boolean(committedQuery) && urlQueryRef.current !== committedQuery;
76
+ // The composer is the reader's own unsent message, not a leftover of the
77
+ // list they were on, so a query going active narrows the list behind it and
78
+ // leaves it where it is.
79
+ const closesTheOpenSurface = queryGoesActive && !isComposing;
70
80
  const search = (prev: Record<string, unknown>) => ({
71
81
  ...prev,
72
82
  q: committedQuery || undefined,
73
83
  });
74
84
  // A query is a mode of the view the reader is already in, so the panels
75
85
  // they have up are not ones they navigated away from.
76
- if (!queryGoesActive) {
86
+ if (!closesTheOpenSurface) {
77
87
  navigate({ to: ".", search, hash: true, replace: true });
78
88
  return;
79
89
  }
@@ -97,5 +107,6 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
97
107
  mailboxId,
98
108
  listPath,
99
109
  pathname,
110
+ isComposing,
100
111
  ]);
101
112
  }