@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
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Compose pressed while already composing starts a new message (#719).
3
+ *
4
+ * The compose route carries the draft as an optional segment, so pressing
5
+ * Compose from inside the composer drops that segment and leaves the same route
6
+ * matched — nothing remounts the form. The reset effect used to bail whenever
7
+ * the incoming id was absent, so the previous draft's recipients, subject and
8
+ * body stayed on screen under an address that said new message, and the next
9
+ * autosave took the create branch and wrote a SECOND draft holding the first
10
+ * one's content.
11
+ *
12
+ * Two properties, one cause: what is on screen, and what is written. The write
13
+ * is the damaging half — the duplicate outlives the session, in the reader's
14
+ * drafts.
15
+ */
16
+
17
+ import assert from "node:assert/strict";
18
+ import { afterEach, describe, it } from "node:test";
19
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
20
+ import {
21
+ type AnyRouter,
22
+ createMemoryHistory,
23
+ createRootRoute,
24
+ createRoute,
25
+ createRouter,
26
+ RouterContextProvider,
27
+ } from "@tanstack/react-router";
28
+ import { createElement, useState } from "react";
29
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
30
+ import { type HttpMock, mockFetch } from "../../test-support/http";
31
+ import { ComposeForm } from "./ComposeForm";
32
+ import { ComposeProvider } from "./ComposeProvider";
33
+
34
+ const ACCOUNT_ID = "acc-1";
35
+ const DRAFT_ID = "ob-first";
36
+ const AUTOSAVE_DEBOUNCE_MS = 2000;
37
+
38
+ const account = {
39
+ accountId: ACCOUNT_ID,
40
+ email: "me@example.com",
41
+ smtpEnabled: true,
42
+ } as unknown as RemitImapAccountResponse;
43
+
44
+ const draft = {
45
+ outboxMessageId: DRAFT_ID,
46
+ accountId: ACCOUNT_ID,
47
+ fromAddress: account.email,
48
+ toAddresses: ["them@example.com"],
49
+ ccAddresses: [],
50
+ bccAddresses: [],
51
+ references: [],
52
+ subject: "Lunch on Thursday",
53
+ textBody: "Does one o'clock work?",
54
+ status: "draft",
55
+ };
56
+
57
+ let harness: DomHarness | undefined;
58
+ let http: HttpMock | undefined;
59
+
60
+ afterEach(() => {
61
+ harness?.close();
62
+ harness = undefined;
63
+ http?.restore();
64
+ http = undefined;
65
+ });
66
+
67
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
68
+
69
+ const rootRoute = createRootRoute();
70
+ const mailboxRoute = createRoute({
71
+ getParentRoute: () => rootRoute,
72
+ path: "/mail/$mailboxId",
73
+ validateSearch: (search: Record<string, unknown>) => search,
74
+ });
75
+
76
+ const testRouter = (): AnyRouter =>
77
+ createRouter({
78
+ routeTree: rootRoute.addChildren([mailboxRoute]),
79
+ history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
80
+ }) as unknown as AnyRouter;
81
+
82
+ /**
83
+ * The composer with its draft handed in, and a button that takes it away again
84
+ * — which is what pressing Compose does to the address it is mounted under.
85
+ */
86
+ const Composer = () => {
87
+ const [draftId, setDraftId] = useState<string | undefined>(DRAFT_ID);
88
+
89
+ return createElement(
90
+ "div",
91
+ null,
92
+ createElement(
93
+ "button",
94
+ { type: "button", onClick: () => setDraftId(undefined) },
95
+ "Compose",
96
+ ),
97
+ createElement(ComposeForm, {
98
+ mode: "new",
99
+ account,
100
+ outboxMessageId: draftId,
101
+ onDraftCreated: setDraftId,
102
+ onClose: () => undefined,
103
+ }),
104
+ );
105
+ };
106
+
107
+ const creates = () =>
108
+ (http?.calls ?? []).filter(
109
+ (call) => call.method === "POST" && call.path.endsWith("/outbox"),
110
+ );
111
+
112
+ const mount = async (): Promise<DomHarness> => {
113
+ http = mockFetch(async (call) => {
114
+ if (call.path.endsWith("/config")) return { accounts: [account] };
115
+ if (call.method === "POST" && call.path.endsWith("/outbox"))
116
+ return { ...draft, outboxMessageId: "ob-second" };
117
+ return draft;
118
+ });
119
+
120
+ const created = createDomHarness();
121
+ harness = created;
122
+ created.renderApp(
123
+ createElement(RouterContextProvider, {
124
+ router: testRouter(),
125
+ // biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
126
+ children: createElement(ComposeProvider, null, createElement(Composer)),
127
+ }),
128
+ );
129
+ await created.flush();
130
+ await created.wait(50);
131
+ return created;
132
+ };
133
+
134
+ const subjectField = (mounted: DomHarness): HTMLInputElement => {
135
+ const field = mounted.query<HTMLInputElement>("[data-subject-field]");
136
+ if (!field) throw new Error("the composer has no subject field");
137
+ return field;
138
+ };
139
+
140
+ describe("Compose pressed while already composing", () => {
141
+ it("takes the previous draft's message off the screen", async () => {
142
+ const mounted = await mount();
143
+ assert.equal(subjectField(mounted).value, draft.subject);
144
+ assert.match(mounted.text(), /them@example.com/);
145
+
146
+ mounted.click(mounted.byText("button", "Compose"));
147
+ await mounted.flush();
148
+ await mounted.wait(20);
149
+
150
+ assert.equal(subjectField(mounted).value, "");
151
+ assert.doesNotMatch(mounted.text(), /them@example.com/);
152
+ });
153
+
154
+ // The half that outlives the session: an autosave from a form still holding
155
+ // the old content, with no draft to write it to, creates a second one.
156
+ it("writes no second draft carrying the first one's content", async () => {
157
+ const mounted = await mount();
158
+ assert.equal(subjectField(mounted).value, draft.subject);
159
+
160
+ mounted.click(mounted.byText("button", "Compose"));
161
+ await mounted.flush();
162
+ await mounted.wait(AUTOSAVE_DEBOUNCE_MS + 500);
163
+
164
+ assert.deepEqual(
165
+ creates().map((call) => call.body?.subject),
166
+ [],
167
+ "a blank new message has nothing to save, so nothing was written",
168
+ );
169
+ });
170
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * What the compose surface calls itself.
3
+ *
4
+ * The address says which message is being written, so the heading says the same
5
+ * thing: a message with nothing saved behind it yet is a new one, and one the
6
+ * autosave has written is the draft it wrote. The heading changing as the first
7
+ * save lands is the same moment the action bar says "Draft saved".
8
+ */
9
+ export const composeSurfaceTitle = (outboxMessageId: string | undefined) =>
10
+ outboxMessageId ? "Draft" : "New Message";
@@ -23,11 +23,11 @@ import {
23
23
  createRouter,
24
24
  RouterContextProvider,
25
25
  } from "@tanstack/react-router";
26
- import { act, createElement, useEffect } from "react";
26
+ import { act, createElement } from "react";
27
27
  import { createDomHarness, type DomHarness } from "../../test-support/dom";
28
28
  import { type HttpMock, mockFetch } from "../../test-support/http";
29
29
  import { ComposeForm } from "./ComposeForm";
30
- import { ComposeProvider, useCompose } from "./ComposeProvider";
30
+ import { ComposeProvider } from "./ComposeProvider";
31
31
 
32
32
  const ACCOUNT_ID = "acc-1";
33
33
  const PHONE = {
@@ -97,21 +97,13 @@ const testRouter = (): AnyRouter =>
97
97
  history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
98
98
  }) as unknown as AnyRouter;
99
99
 
100
- const Opened = () => {
101
- const { state, openCompose } = useCompose();
102
-
103
- useEffect(() => {
104
- openCompose({ mode: "new", account });
105
- }, [openCompose]);
106
-
107
- if (!state.isOpen) return null;
108
-
109
- return createElement(ComposeForm, {
100
+ const Opened = () =>
101
+ createElement(ComposeForm, {
110
102
  mode: "new",
111
103
  account,
104
+ onDraftCreated: () => {},
112
105
  onClose: () => {},
113
106
  });
114
- };
115
107
 
116
108
  const mount = async (): Promise<void> => {
117
109
  http = mockFetch(async (call) => {
@@ -1,33 +1,24 @@
1
1
  import { useLocation } from "@tanstack/react-router";
2
2
  import { Pencil } from "lucide-react";
3
- import { useCallback } from "react";
4
- import { useCompose } from "@/components/compose/ComposeProvider";
5
3
  import { locationOpensDetail } from "@/lib/mail-route";
4
+ import { useOpenCompose } from "@/routing";
6
5
 
7
6
  /**
8
7
  * Floating Action Button for composing a new message. Mobile-only.
9
8
  *
10
9
  * Layout follows Material 3: 56×56 surface, 16px from the right and
11
- * bottom edges (plus the iOS safe-area inset). Hidden when any of:
10
+ * bottom edges (plus the iOS safe-area inset). Hidden when either:
12
11
  * - Viewport is `≥ lg` (1024px), where the top bar owns compose. The
13
12
  * `/mail` shell also stops mounting the FAB above that width; the
14
13
  * `lg:hidden` class covers the pre-hydration frame.
15
- * - The compose surface is already open.
16
- * - The user is reading a thread — the single pane is the conversation, and
17
- * its reply bar is under this corner. Every list says so in its path.
18
- * - The user is off `/mail`, which is every route with no mail in it.
14
+ * - The single pane has something open — a conversation, or compose itself.
15
+ * Every list says so in its path.
19
16
  */
20
17
  export const ComposeFab = () => {
21
- const { state, openCompose } = useCompose();
18
+ const compose = useOpenCompose();
22
19
  const location = useLocation();
23
- const compose = useCallback(() => {
24
- openCompose({ mode: "new" });
25
- }, [openCompose]);
26
20
 
27
- const isReadingThread = locationOpensDetail(location.pathname);
28
-
29
- if (!location.pathname.startsWith("/mail") || state.isOpen || isReadingThread)
30
- return null;
21
+ if (locationOpensDetail(location.pathname)) return null;
31
22
 
32
23
  return (
33
24
  <button
@@ -1,6 +1,8 @@
1
1
  import { AppShellSlotted } from "@remit/ui";
2
2
  import { createContext, type ReactNode, useContext } from "react";
3
+ import { FullCompose } from "@/components/compose/FullCompose";
3
4
  import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
5
+ import { useIsComposing } from "@/routing";
4
6
 
5
7
  /**
6
8
  * The chrome every list shares, published by the `/mail` layout and consumed by
@@ -53,6 +55,11 @@ export interface MailShellProps {
53
55
  * Below the reading boundary the shell is one pane and takes `phone`, which
54
56
  * swaps between the list and whatever is open in place. Above it the panes sit
55
57
  * side by side and the reading and intelligence slots are filled.
58
+ *
59
+ * Compose is the one surface the single pane cannot take from the `Outlet`,
60
+ * because at this width there is no reading slot to fill. It is read off the
61
+ * address here rather than in each list's phone view, so the four lists cannot
62
+ * disagree about it.
56
63
  */
57
64
  export function MailShell({
58
65
  phone,
@@ -62,6 +69,7 @@ export function MailShell({
62
69
  hasThread = false,
63
70
  }: MailShellProps) {
64
71
  const chrome = useContext(MailShellCtx);
72
+ const isComposing = useIsComposing();
65
73
  if (!chrome) return <AppShellSkeleton />;
66
74
 
67
75
  const shared = {
@@ -78,7 +86,7 @@ export function MailShell({
78
86
  return (
79
87
  <AppShellSlotted
80
88
  {...shared}
81
- list={phone}
89
+ list={isComposing ? <FullCompose /> : phone}
82
90
  intelligenceOpen={chrome.intelligenceOpen}
83
91
  />
84
92
  );
@@ -9,14 +9,14 @@
9
9
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
10
10
  import { RefreshButton, ShellTopBar, shortcutHintForAction } from "@remit/ui";
11
11
  import { useNavigate } from "@tanstack/react-router";
12
- import { useCallback, useMemo } from "react";
12
+ import { useMemo } from "react";
13
13
  import { AccountMenu } from "@/auth/AccountMenu";
14
- import { useCompose } from "@/components/compose/ComposeProvider";
15
14
  import { useRefreshControl } from "@/hooks/useRefreshControl";
16
15
  import { useSearchScope } from "@/hooks/useSearchScope";
17
16
  import { openBugReport } from "@/lib/bug-report";
18
17
  import { useMailContext } from "@/lib/mail-context";
19
18
  import { useMailFreshness } from "@/lib/mail-freshness";
19
+ import { useOpenCompose } from "@/routing";
20
20
 
21
21
  interface MailTopBarProps {
22
22
  accounts: RemitImapAccountResponse[];
@@ -26,10 +26,7 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
26
26
  const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
27
27
  useMailContext();
28
28
  const navigate = useNavigate();
29
- const { openCompose } = useCompose();
30
- const compose = useCallback(() => {
31
- openCompose({ mode: "new" });
32
- }, [openCompose]);
29
+ const compose = useOpenCompose();
33
30
  const { scope, clearScope } = useSearchScope(accounts);
34
31
  const chips =
35
32
  scope.kind === "scoped"
@@ -5,8 +5,8 @@
5
5
  * \Drafts special-use folder (issue #505):
6
6
  *
7
7
  * 1. "Not yet sent (Remit)" — outbox rows with status === "draft" belonging
8
- * to the account that owns the open \Drafts mailbox. Clicking a row opens
9
- * compose pre-filled via openCompose({ mode: "new", outboxMessageId }).
8
+ * to the account that owns the open \Drafts mailbox. Clicking a row names
9
+ * the draft and navigates to the folder's compose route.
10
10
  *
11
11
  * 2. "On the server" — IMAP \Drafts thread rows already loaded for the
12
12
  * mailbox. Clicking a row opens the normal reading pane (read-only;
@@ -39,9 +39,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
39
39
  import { useNavigate } from "@tanstack/react-router";
40
40
  import { FileText, Inbox, Trash2 } from "lucide-react";
41
41
  import { useMemo } from "react";
42
- import { useCompose } from "@/components/compose/ComposeProvider";
42
+
43
43
  import { groupDraftSections } from "@/lib/drafts";
44
- import { useRetainOpenPanels } from "@/routing";
44
+ import {
45
+ useComposeDraftId,
46
+ useEditDraft,
47
+ useRetainOpenPanels,
48
+ } from "@/routing";
45
49
  import { NavMenuButton } from "./NavMenuButton";
46
50
 
47
51
  // ---------------------------------------------------------------------------
@@ -154,7 +158,8 @@ export function DraftsView({
154
158
  title,
155
159
  unreadCount,
156
160
  }: DraftsViewProps) {
157
- const { openCompose, state: composeState } = useCompose();
161
+ const openDraftId = useComposeDraftId();
162
+ const editDraft = useEditDraft();
158
163
  const navigate = useNavigate();
159
164
  const retainPanels = useRetainOpenPanels();
160
165
  const queryClient = useQueryClient();
@@ -186,10 +191,6 @@ export function DraftsView({
186
191
  deleteMutation.mutate({ path: { outboxMessageId } });
187
192
  };
188
193
 
189
- const handleRemitDraftOpen = (outboxMessageId: string) => {
190
- openCompose({ mode: "new", outboxMessageId });
191
- };
192
-
193
194
  const handleImapDraftOpen = (messageId: string) => {
194
195
  const threadId = imapThreads.find(
195
196
  (thread) => thread.messageId === messageId,
@@ -246,11 +247,8 @@ export function DraftsView({
246
247
  <RemitDraftRow
247
248
  key={thread.id}
248
249
  row={thread}
249
- isSelected={
250
- composeState.isOpen &&
251
- thread.id === composeState.outboxMessageId
252
- }
253
- onOpen={handleRemitDraftOpen}
250
+ isSelected={thread.id === openDraftId}
251
+ onOpen={editDraft}
254
252
  onDelete={handleRemitDraftDelete}
255
253
  isDeleting={deleteMutation.isPending}
256
254
  />
@@ -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,
@@ -53,8 +47,6 @@ import {
53
47
  useState,
54
48
  } from "react";
55
49
  import type { ComposeMode } from "@/components/compose/ComposeProvider";
56
- import { useCompose } from "@/components/compose/ComposeProvider";
57
- import { FullCompose } from "@/components/compose/FullCompose";
58
50
  import { Drawer } from "@/components/layout/Drawer";
59
51
  import { ConversationView } from "@/components/mail/ConversationView";
60
52
  import { DraftsView } from "@/components/mail/DraftsView";
@@ -66,8 +58,6 @@ import {
66
58
  import { MessageToolbar } from "@/components/mail/MessageToolbar";
67
59
  import { PullToRefresh } from "@/components/mail/PullToRefresh";
68
60
  import { SpamRescue } from "@/components/mail/SpamRescue";
69
- import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
70
- import { buildMutationErrorBanner } from "@/components/ui/error-banners";
71
61
  import {
72
62
  useArchiveMailbox,
73
63
  useDraftsMailbox,
@@ -83,7 +73,6 @@ import {
83
73
  } from "@/hooks/useDeleteMessages";
84
74
  import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
85
75
  import { useIntelligenceData } from "@/hooks/useIntelligenceData";
86
- import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
87
76
  import { useLayoutTier } from "@/hooks/useLayoutTier";
88
77
  import { useMailboxAccount } from "@/hooks/useMailboxAccount";
89
78
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
@@ -127,6 +116,8 @@ import {
127
116
  import {
128
117
  type OpenThreadPath,
129
118
  type OpenThreadTarget,
119
+ useIsComposing,
120
+ useOpenCompose,
130
121
  useRetainOpenPanels,
131
122
  } from "@/routing";
132
123
  import { MailViewChrome } from "./MailViewChrome";
@@ -215,11 +206,7 @@ interface MailboxPaneContextValue {
215
206
  onClearComposeRequest: () => void;
216
207
  onToolbarDelete: () => void;
217
208
  onToolbarStar: () => void;
218
- onToolbarDiscardDraft: () => void;
219
209
  onToolbarMove: (destMailboxId: string) => void;
220
- composeState: ReturnType<typeof useCompose>["state"];
221
- closeCompose: () => void;
222
- hasRemitDraftOpen: boolean;
223
210
  // Phone actions
224
211
  onBack: () => void;
225
212
  /** The rows either side of the open one — the phone's swipe gestures. */
@@ -539,9 +526,6 @@ function MailboxPaneProvider({
539
526
  // so there is no fallback: until the mailbox resolves there is no number.
540
527
  const unreadCount = useCurrentMailboxUnseenCount({ accounts }) ?? 0;
541
528
 
542
- const queryClient = useQueryClient();
543
- const { pushError } = useErrorBanners();
544
-
545
529
  const toolbarActions = useThreadActions({
546
530
  thread: selectedThread,
547
531
  mailboxId,
@@ -569,48 +553,8 @@ function MailboxPaneProvider({
569
553
  setToolbarComposeRequest("forward");
570
554
  }, [setToolbarComposeRequest]);
571
555
 
572
- const { state: composeState, openCompose, closeCompose } = useCompose();
573
-
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
- ]);
556
+ const isComposing = useIsComposing();
557
+ const openCompose = useOpenCompose();
614
558
 
615
559
  const messageIdsForFocusedThread = useCallback(
616
560
  (thread: typeof focusedThread): string[] => {
@@ -784,17 +728,13 @@ function MailboxPaneProvider({
784
728
  }
785
729
  }, [normalizedSearchQuery, mailboxType, telemetry]);
786
730
 
787
- const hasRemitDraftOpen =
788
- isDraftsMailbox &&
789
- composeState.isOpen &&
790
- !!composeState.outboxMessageId &&
791
- !selectedThread;
792
-
793
731
  const { goBack, nextMessageId, previousMessageId } = useTriageLayer({
794
732
  context: triage,
795
733
  orderedIds: threads.map((t) => t.messageId),
796
734
  selectedMessageId,
797
- enabled: !composeState.isOpen,
735
+ // The list stays mounted under the compose surface, so the triage keys
736
+ // would otherwise fire at the message behind whatever is being typed.
737
+ enabled: !isComposing,
798
738
  onClose: closeThread,
799
739
  handlers: {
800
740
  reply: triageReply,
@@ -808,7 +748,7 @@ function MailboxPaneProvider({
808
748
  vipSender: triageVip,
809
749
  markJunk: triageMarkJunk,
810
750
  toggleIntelligence: selectedThread ? onToggleIntelligence : undefined,
811
- compose: handleNewCompose,
751
+ compose: openCompose,
812
752
  goBrief: () => goToRoute("/mail/brief"),
813
753
  goInbox: () => goToRoute("/mail/brief"),
814
754
  goSent: () => goToRoute("/mail/brief"),
@@ -817,11 +757,6 @@ function MailboxPaneProvider({
817
757
  },
818
758
  });
819
759
 
820
- useKeyboardNavigation({
821
- enabled: composeState.isOpen,
822
- bindings: [{ key: "Escape", handler: closeCompose, preventDefault: true }],
823
- });
824
-
825
760
  // The swipe gestures open a whole conversation, so the adjacent row has to
826
761
  // name its thread. This folder's own listing is where that is looked up, so a
827
762
  // row it does not hold offers no gesture rather than a tap that goes nowhere.
@@ -884,11 +819,7 @@ function MailboxPaneProvider({
884
819
  onClearComposeRequest: toolbarActions.clearComposeRequest,
885
820
  onToolbarDelete: toolbarActions.deleteThread,
886
821
  onToolbarStar: toolbarActions.toggleStar,
887
- onToolbarDiscardDraft: handleToolbarDiscardDraft,
888
822
  onToolbarMove: toolbarActions.moveThread,
889
- composeState,
890
- closeCompose,
891
- hasRemitDraftOpen,
892
823
  onBack: goBack,
893
824
  nextThread: adjacentThread(nextMessageId),
894
825
  previousThread: adjacentThread(previousMessageId),
@@ -1138,7 +1069,6 @@ function MailboxReading() {
1138
1069
  mailboxAccountId,
1139
1070
  selectedThread,
1140
1071
  conversation,
1141
- hasRemitDraftOpen,
1142
1072
  intelligenceOpen,
1143
1073
  onToggleIntelligence,
1144
1074
  toolbarComposeRequest,
@@ -1148,9 +1078,7 @@ function MailboxReading() {
1148
1078
  onClearComposeRequest,
1149
1079
  onToolbarDelete,
1150
1080
  onToolbarStar,
1151
- onToolbarDiscardDraft,
1152
1081
  onToolbarMove,
1153
- composeState,
1154
1082
  handleDeselectIfRemoved,
1155
1083
  } = useMailboxPane();
1156
1084
  // Which surface intelligence has here: the rail between 1280 and up, the
@@ -1197,25 +1125,22 @@ function MailboxReading() {
1197
1125
  const intelligenceShowing =
1198
1126
  hasThread && (railFits ? intelligenceOpen : drawerOpen);
1199
1127
 
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
- );
1128
+ const detailPane = conversation ? (
1129
+ <ConversationView
1130
+ threadId={conversation.threadId}
1131
+ mailboxId={conversation.mailboxId}
1132
+ subject={conversation.subject}
1133
+ selectedMessageId={conversation.messageId}
1134
+ authenticity={conversation.authenticity}
1135
+ onOpenIntelligence={
1136
+ conversation.authenticity?.dkimMismatch ? openIntelligence : undefined
1137
+ }
1138
+ composeRequest={toolbarComposeRequest}
1139
+ onComposeClose={onClearComposeRequest}
1140
+ />
1141
+ ) : (
1142
+ <ReadingPaneEmpty />
1143
+ );
1219
1144
 
1220
1145
  return (
1221
1146
  <>
@@ -1228,14 +1153,8 @@ function MailboxReading() {
1228
1153
  onReply={hasThread ? onToolbarReply : undefined}
1229
1154
  onReplyAll={hasThread ? onToolbarReplyAll : undefined}
1230
1155
  onForward={hasThread ? onToolbarForward : undefined}
1231
- canDelete={hasThread || hasRemitDraftOpen}
1232
- onDelete={
1233
- hasThread
1234
- ? onToolbarDelete
1235
- : hasRemitDraftOpen
1236
- ? onToolbarDiscardDraft
1237
- : undefined
1238
- }
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