@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
@@ -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 },
@@ -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
  }
@@ -131,6 +131,34 @@ describe("mailViewKey", () => {
131
131
  }
132
132
  });
133
133
 
134
+ // Same trap one surface over: compose is a child of the list, and a key that
135
+ // moved when it opened would wipe the query the reader was mid-search on.
136
+ it("gives a list and its compose surface the same key", () => {
137
+ assert.equal(
138
+ mailViewKey(
139
+ matches([
140
+ MAIL_BRIEF_ROUTE_ID,
141
+ `${MAIL_BRIEF_ROUTE_ID}/compose/{-$outboxMessageId}`,
142
+ ]),
143
+ ),
144
+ mailViewKey(brief),
145
+ );
146
+ assert.equal(
147
+ mailViewKey(
148
+ matches(
149
+ [
150
+ MAIL_MAILBOX_ROUTE_ID,
151
+ `${MAIL_MAILBOX_ROUTE_ID}/compose/{-$outboxMessageId}`,
152
+ ],
153
+ {
154
+ mailboxId: "inbox-1",
155
+ },
156
+ ),
157
+ ),
158
+ mailViewKey(mailbox),
159
+ );
160
+ });
161
+
134
162
  it("gives a list and its reading-pane index child the same key", () => {
135
163
  assert.equal(
136
164
  mailViewKey(matches([MAIL_BRIEF_ROUTE_ID, `${MAIL_BRIEF_ROUTE_ID}/`])),
@@ -157,7 +185,7 @@ describe("locationIsOnList", () => {
157
185
  );
158
186
  assert.equal(locationIsOnList("/mail/brief/", "/mail/brief"), true);
159
187
  assert.equal(
160
- locationIsOnList("/mail/inbox-1/compose", "/mail/inbox-1"),
188
+ locationIsOnList("/mail/inbox-1/compose/ob-1", "/mail/inbox-1"),
161
189
  true,
162
190
  );
163
191
  });
@@ -191,6 +219,12 @@ describe("locationOpensDetail", () => {
191
219
  assert.equal(locationOpensDetail("/mail/inbox-1/thread-1"), true);
192
220
  });
193
221
 
222
+ it("is true for the compose surface, which the FAB must not sit over", () => {
223
+ assert.equal(locationOpensDetail("/mail/brief/compose"), true);
224
+ assert.equal(locationOpensDetail("/mail/inbox-1/compose"), true);
225
+ assert.equal(locationOpensDetail("/mail/inbox-1/compose/ob-1"), true);
226
+ });
227
+
194
228
  it("is false on a bare list, trailing slash and query included", () => {
195
229
  assert.equal(locationOpensDetail("/mail/brief"), false);
196
230
  assert.equal(locationOpensDetail("/mail/brief/"), false);
@@ -95,7 +95,8 @@ export function locationIsOnList(pathname: string, listPath: string): boolean {
95
95
 
96
96
  /**
97
97
  * Whether the address names something the list has open below it — a thread, a
98
- * message. A list on its own, and its bare reading pane, do not.
98
+ * message, the compose surface. A list on its own, and its bare reading pane,
99
+ * do not.
99
100
  *
100
101
  * Answers what an open thread used to be asked of the query. Anything sharing
101
102
  * the single pane with the conversation reads it from here rather than keeping a
@@ -36,10 +36,14 @@ import { Route as MailFlaggedThreadIdRouteImport } from './routes/mail/flagged/$
36
36
  import { Route as MailOutboxIndexRouteImport } from './routes/mail/outbox/index'
37
37
  import { Route as MailMailboxIdThreadIdIndexRouteImport } from './routes/mail/$mailboxId/$threadId/index'
38
38
  import { Route as MailMailboxIdThreadIdMessageIdRouteImport } from './routes/mail/$mailboxId/$threadId/$messageId'
39
+ import { Route as MailMailboxIdComposeChar123OutboxMessageIdChar125RouteImport } from './routes/mail/$mailboxId/compose.{-$outboxMessageId}'
39
40
  import { Route as MailBriefThreadIdIndexRouteImport } from './routes/mail/brief/$threadId/index'
40
41
  import { Route as MailBriefThreadIdMessageIdRouteImport } from './routes/mail/brief/$threadId/$messageId'
42
+ import { Route as MailBriefComposeChar123OutboxMessageIdChar125RouteImport } from './routes/mail/brief/compose.{-$outboxMessageId}'
41
43
  import { Route as MailFlaggedThreadIdIndexRouteImport } from './routes/mail/flagged/$threadId/index'
42
44
  import { Route as MailFlaggedThreadIdMessageIdRouteImport } from './routes/mail/flagged/$threadId/$messageId'
45
+ import { Route as MailFlaggedComposeChar123OutboxMessageIdChar125RouteImport } from './routes/mail/flagged/compose.{-$outboxMessageId}'
46
+ import { Route as MailOutboxComposeChar123OutboxMessageIdChar125RouteImport } from './routes/mail/outbox/compose.{-$outboxMessageId}'
43
47
  import { Route as MailOutboxDraftOutboxMessageIdRouteImport } from './routes/mail/outbox/draft/$outboxMessageId'
44
48
 
45
49
  const IndexRoute = IndexRouteImport.update({
@@ -179,6 +183,12 @@ const MailMailboxIdThreadIdMessageIdRoute =
179
183
  path: '/$messageId',
180
184
  getParentRoute: () => MailMailboxIdThreadIdRoute,
181
185
  } as any)
186
+ const MailMailboxIdComposeChar123OutboxMessageIdChar125Route =
187
+ MailMailboxIdComposeChar123OutboxMessageIdChar125RouteImport.update({
188
+ id: '/compose/{-$outboxMessageId}',
189
+ path: '/compose/{-$outboxMessageId}',
190
+ getParentRoute: () => MailMailboxIdRoute,
191
+ } as any)
182
192
  const MailBriefThreadIdIndexRoute = MailBriefThreadIdIndexRouteImport.update({
183
193
  id: '/',
184
194
  path: '/',
@@ -190,6 +200,12 @@ const MailBriefThreadIdMessageIdRoute =
190
200
  path: '/$messageId',
191
201
  getParentRoute: () => MailBriefThreadIdRoute,
192
202
  } as any)
203
+ const MailBriefComposeChar123OutboxMessageIdChar125Route =
204
+ MailBriefComposeChar123OutboxMessageIdChar125RouteImport.update({
205
+ id: '/compose/{-$outboxMessageId}',
206
+ path: '/compose/{-$outboxMessageId}',
207
+ getParentRoute: () => MailBriefRoute,
208
+ } as any)
193
209
  const MailFlaggedThreadIdIndexRoute =
194
210
  MailFlaggedThreadIdIndexRouteImport.update({
195
211
  id: '/',
@@ -202,6 +218,18 @@ const MailFlaggedThreadIdMessageIdRoute =
202
218
  path: '/$messageId',
203
219
  getParentRoute: () => MailFlaggedThreadIdRoute,
204
220
  } as any)
221
+ const MailFlaggedComposeChar123OutboxMessageIdChar125Route =
222
+ MailFlaggedComposeChar123OutboxMessageIdChar125RouteImport.update({
223
+ id: '/compose/{-$outboxMessageId}',
224
+ path: '/compose/{-$outboxMessageId}',
225
+ getParentRoute: () => MailFlaggedRoute,
226
+ } as any)
227
+ const MailOutboxComposeChar123OutboxMessageIdChar125Route =
228
+ MailOutboxComposeChar123OutboxMessageIdChar125RouteImport.update({
229
+ id: '/compose/{-$outboxMessageId}',
230
+ path: '/compose/{-$outboxMessageId}',
231
+ getParentRoute: () => MailOutboxRoute,
232
+ } as any)
205
233
  const MailOutboxDraftOutboxMessageIdRoute =
206
234
  MailOutboxDraftOutboxMessageIdRouteImport.update({
207
235
  id: '/draft/$outboxMessageId',
@@ -236,8 +264,12 @@ export interface FileRoutesByFullPath {
236
264
  '/mail/flagged/': typeof MailFlaggedIndexRoute
237
265
  '/mail/outbox/': typeof MailOutboxIndexRoute
238
266
  '/mail/$mailboxId/$threadId/$messageId': typeof MailMailboxIdThreadIdMessageIdRoute
267
+ '/mail/$mailboxId/compose/{-$outboxMessageId}': typeof MailMailboxIdComposeChar123OutboxMessageIdChar125Route
239
268
  '/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
269
+ '/mail/brief/compose/{-$outboxMessageId}': typeof MailBriefComposeChar123OutboxMessageIdChar125Route
240
270
  '/mail/flagged/$threadId/$messageId': typeof MailFlaggedThreadIdMessageIdRoute
271
+ '/mail/flagged/compose/{-$outboxMessageId}': typeof MailFlaggedComposeChar123OutboxMessageIdChar125Route
272
+ '/mail/outbox/compose/{-$outboxMessageId}': typeof MailOutboxComposeChar123OutboxMessageIdChar125Route
241
273
  '/mail/outbox/draft/$outboxMessageId': typeof MailOutboxDraftOutboxMessageIdRoute
242
274
  '/mail/$mailboxId/$threadId/': typeof MailMailboxIdThreadIdIndexRoute
243
275
  '/mail/brief/$threadId/': typeof MailBriefThreadIdIndexRoute
@@ -261,8 +293,12 @@ export interface FileRoutesByTo {
261
293
  '/mail/flagged': typeof MailFlaggedIndexRoute
262
294
  '/mail/outbox': typeof MailOutboxIndexRoute
263
295
  '/mail/$mailboxId/$threadId/$messageId': typeof MailMailboxIdThreadIdMessageIdRoute
296
+ '/mail/$mailboxId/compose/{-$outboxMessageId}': typeof MailMailboxIdComposeChar123OutboxMessageIdChar125Route
264
297
  '/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
298
+ '/mail/brief/compose/{-$outboxMessageId}': typeof MailBriefComposeChar123OutboxMessageIdChar125Route
265
299
  '/mail/flagged/$threadId/$messageId': typeof MailFlaggedThreadIdMessageIdRoute
300
+ '/mail/flagged/compose/{-$outboxMessageId}': typeof MailFlaggedComposeChar123OutboxMessageIdChar125Route
301
+ '/mail/outbox/compose/{-$outboxMessageId}': typeof MailOutboxComposeChar123OutboxMessageIdChar125Route
266
302
  '/mail/outbox/draft/$outboxMessageId': typeof MailOutboxDraftOutboxMessageIdRoute
267
303
  '/mail/$mailboxId/$threadId': typeof MailMailboxIdThreadIdIndexRoute
268
304
  '/mail/brief/$threadId': typeof MailBriefThreadIdIndexRoute
@@ -296,8 +332,12 @@ export interface FileRoutesById {
296
332
  '/mail/flagged/': typeof MailFlaggedIndexRoute
297
333
  '/mail/outbox/': typeof MailOutboxIndexRoute
298
334
  '/mail/$mailboxId/$threadId/$messageId': typeof MailMailboxIdThreadIdMessageIdRoute
335
+ '/mail/$mailboxId/compose/{-$outboxMessageId}': typeof MailMailboxIdComposeChar123OutboxMessageIdChar125Route
299
336
  '/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
337
+ '/mail/brief/compose/{-$outboxMessageId}': typeof MailBriefComposeChar123OutboxMessageIdChar125Route
300
338
  '/mail/flagged/$threadId/$messageId': typeof MailFlaggedThreadIdMessageIdRoute
339
+ '/mail/flagged/compose/{-$outboxMessageId}': typeof MailFlaggedComposeChar123OutboxMessageIdChar125Route
340
+ '/mail/outbox/compose/{-$outboxMessageId}': typeof MailOutboxComposeChar123OutboxMessageIdChar125Route
301
341
  '/mail/outbox/draft/$outboxMessageId': typeof MailOutboxDraftOutboxMessageIdRoute
302
342
  '/mail/$mailboxId/$threadId/': typeof MailMailboxIdThreadIdIndexRoute
303
343
  '/mail/brief/$threadId/': typeof MailBriefThreadIdIndexRoute
@@ -332,8 +372,12 @@ export interface FileRouteTypes {
332
372
  | '/mail/flagged/'
333
373
  | '/mail/outbox/'
334
374
  | '/mail/$mailboxId/$threadId/$messageId'
375
+ | '/mail/$mailboxId/compose/{-$outboxMessageId}'
335
376
  | '/mail/brief/$threadId/$messageId'
377
+ | '/mail/brief/compose/{-$outboxMessageId}'
336
378
  | '/mail/flagged/$threadId/$messageId'
379
+ | '/mail/flagged/compose/{-$outboxMessageId}'
380
+ | '/mail/outbox/compose/{-$outboxMessageId}'
337
381
  | '/mail/outbox/draft/$outboxMessageId'
338
382
  | '/mail/$mailboxId/$threadId/'
339
383
  | '/mail/brief/$threadId/'
@@ -357,8 +401,12 @@ export interface FileRouteTypes {
357
401
  | '/mail/flagged'
358
402
  | '/mail/outbox'
359
403
  | '/mail/$mailboxId/$threadId/$messageId'
404
+ | '/mail/$mailboxId/compose/{-$outboxMessageId}'
360
405
  | '/mail/brief/$threadId/$messageId'
406
+ | '/mail/brief/compose/{-$outboxMessageId}'
361
407
  | '/mail/flagged/$threadId/$messageId'
408
+ | '/mail/flagged/compose/{-$outboxMessageId}'
409
+ | '/mail/outbox/compose/{-$outboxMessageId}'
362
410
  | '/mail/outbox/draft/$outboxMessageId'
363
411
  | '/mail/$mailboxId/$threadId'
364
412
  | '/mail/brief/$threadId'
@@ -391,8 +439,12 @@ export interface FileRouteTypes {
391
439
  | '/mail/flagged/'
392
440
  | '/mail/outbox/'
393
441
  | '/mail/$mailboxId/$threadId/$messageId'
442
+ | '/mail/$mailboxId/compose/{-$outboxMessageId}'
394
443
  | '/mail/brief/$threadId/$messageId'
444
+ | '/mail/brief/compose/{-$outboxMessageId}'
395
445
  | '/mail/flagged/$threadId/$messageId'
446
+ | '/mail/flagged/compose/{-$outboxMessageId}'
447
+ | '/mail/outbox/compose/{-$outboxMessageId}'
396
448
  | '/mail/outbox/draft/$outboxMessageId'
397
449
  | '/mail/$mailboxId/$threadId/'
398
450
  | '/mail/brief/$threadId/'
@@ -597,6 +649,13 @@ declare module '@tanstack/react-router' {
597
649
  preLoaderRoute: typeof MailMailboxIdThreadIdMessageIdRouteImport
598
650
  parentRoute: typeof MailMailboxIdThreadIdRoute
599
651
  }
652
+ '/mail/$mailboxId/compose/{-$outboxMessageId}': {
653
+ id: '/mail/$mailboxId/compose/{-$outboxMessageId}'
654
+ path: '/compose/{-$outboxMessageId}'
655
+ fullPath: '/mail/$mailboxId/compose/{-$outboxMessageId}'
656
+ preLoaderRoute: typeof MailMailboxIdComposeChar123OutboxMessageIdChar125RouteImport
657
+ parentRoute: typeof MailMailboxIdRoute
658
+ }
600
659
  '/mail/brief/$threadId/': {
601
660
  id: '/mail/brief/$threadId/'
602
661
  path: '/'
@@ -611,6 +670,13 @@ declare module '@tanstack/react-router' {
611
670
  preLoaderRoute: typeof MailBriefThreadIdMessageIdRouteImport
612
671
  parentRoute: typeof MailBriefThreadIdRoute
613
672
  }
673
+ '/mail/brief/compose/{-$outboxMessageId}': {
674
+ id: '/mail/brief/compose/{-$outboxMessageId}'
675
+ path: '/compose/{-$outboxMessageId}'
676
+ fullPath: '/mail/brief/compose/{-$outboxMessageId}'
677
+ preLoaderRoute: typeof MailBriefComposeChar123OutboxMessageIdChar125RouteImport
678
+ parentRoute: typeof MailBriefRoute
679
+ }
614
680
  '/mail/flagged/$threadId/': {
615
681
  id: '/mail/flagged/$threadId/'
616
682
  path: '/'
@@ -625,6 +691,20 @@ declare module '@tanstack/react-router' {
625
691
  preLoaderRoute: typeof MailFlaggedThreadIdMessageIdRouteImport
626
692
  parentRoute: typeof MailFlaggedThreadIdRoute
627
693
  }
694
+ '/mail/flagged/compose/{-$outboxMessageId}': {
695
+ id: '/mail/flagged/compose/{-$outboxMessageId}'
696
+ path: '/compose/{-$outboxMessageId}'
697
+ fullPath: '/mail/flagged/compose/{-$outboxMessageId}'
698
+ preLoaderRoute: typeof MailFlaggedComposeChar123OutboxMessageIdChar125RouteImport
699
+ parentRoute: typeof MailFlaggedRoute
700
+ }
701
+ '/mail/outbox/compose/{-$outboxMessageId}': {
702
+ id: '/mail/outbox/compose/{-$outboxMessageId}'
703
+ path: '/compose/{-$outboxMessageId}'
704
+ fullPath: '/mail/outbox/compose/{-$outboxMessageId}'
705
+ preLoaderRoute: typeof MailOutboxComposeChar123OutboxMessageIdChar125RouteImport
706
+ parentRoute: typeof MailOutboxRoute
707
+ }
628
708
  '/mail/outbox/draft/$outboxMessageId': {
629
709
  id: '/mail/outbox/draft/$outboxMessageId'
630
710
  path: '/draft/$outboxMessageId'
@@ -653,11 +733,14 @@ const MailMailboxIdThreadIdRouteWithChildren =
653
733
  interface MailMailboxIdRouteChildren {
654
734
  MailMailboxIdThreadIdRoute: typeof MailMailboxIdThreadIdRouteWithChildren
655
735
  MailMailboxIdIndexRoute: typeof MailMailboxIdIndexRoute
736
+ MailMailboxIdComposeChar123OutboxMessageIdChar125Route: typeof MailMailboxIdComposeChar123OutboxMessageIdChar125Route
656
737
  }
657
738
 
658
739
  const MailMailboxIdRouteChildren: MailMailboxIdRouteChildren = {
659
740
  MailMailboxIdThreadIdRoute: MailMailboxIdThreadIdRouteWithChildren,
660
741
  MailMailboxIdIndexRoute: MailMailboxIdIndexRoute,
742
+ MailMailboxIdComposeChar123OutboxMessageIdChar125Route:
743
+ MailMailboxIdComposeChar123OutboxMessageIdChar125Route,
661
744
  }
662
745
 
663
746
  const MailMailboxIdRouteWithChildren = MailMailboxIdRoute._addFileChildren(
@@ -680,11 +763,14 @@ const MailBriefThreadIdRouteWithChildren =
680
763
  interface MailBriefRouteChildren {
681
764
  MailBriefThreadIdRoute: typeof MailBriefThreadIdRouteWithChildren
682
765
  MailBriefIndexRoute: typeof MailBriefIndexRoute
766
+ MailBriefComposeChar123OutboxMessageIdChar125Route: typeof MailBriefComposeChar123OutboxMessageIdChar125Route
683
767
  }
684
768
 
685
769
  const MailBriefRouteChildren: MailBriefRouteChildren = {
686
770
  MailBriefThreadIdRoute: MailBriefThreadIdRouteWithChildren,
687
771
  MailBriefIndexRoute: MailBriefIndexRoute,
772
+ MailBriefComposeChar123OutboxMessageIdChar125Route:
773
+ MailBriefComposeChar123OutboxMessageIdChar125Route,
688
774
  }
689
775
 
690
776
  const MailBriefRouteWithChildren = MailBriefRoute._addFileChildren(
@@ -707,11 +793,14 @@ const MailFlaggedThreadIdRouteWithChildren =
707
793
  interface MailFlaggedRouteChildren {
708
794
  MailFlaggedThreadIdRoute: typeof MailFlaggedThreadIdRouteWithChildren
709
795
  MailFlaggedIndexRoute: typeof MailFlaggedIndexRoute
796
+ MailFlaggedComposeChar123OutboxMessageIdChar125Route: typeof MailFlaggedComposeChar123OutboxMessageIdChar125Route
710
797
  }
711
798
 
712
799
  const MailFlaggedRouteChildren: MailFlaggedRouteChildren = {
713
800
  MailFlaggedThreadIdRoute: MailFlaggedThreadIdRouteWithChildren,
714
801
  MailFlaggedIndexRoute: MailFlaggedIndexRoute,
802
+ MailFlaggedComposeChar123OutboxMessageIdChar125Route:
803
+ MailFlaggedComposeChar123OutboxMessageIdChar125Route,
715
804
  }
716
805
 
717
806
  const MailFlaggedRouteWithChildren = MailFlaggedRoute._addFileChildren(
@@ -720,11 +809,14 @@ const MailFlaggedRouteWithChildren = MailFlaggedRoute._addFileChildren(
720
809
 
721
810
  interface MailOutboxRouteChildren {
722
811
  MailOutboxIndexRoute: typeof MailOutboxIndexRoute
812
+ MailOutboxComposeChar123OutboxMessageIdChar125Route: typeof MailOutboxComposeChar123OutboxMessageIdChar125Route
723
813
  MailOutboxDraftOutboxMessageIdRoute: typeof MailOutboxDraftOutboxMessageIdRoute
724
814
  }
725
815
 
726
816
  const MailOutboxRouteChildren: MailOutboxRouteChildren = {
727
817
  MailOutboxIndexRoute: MailOutboxIndexRoute,
818
+ MailOutboxComposeChar123OutboxMessageIdChar125Route:
819
+ MailOutboxComposeChar123OutboxMessageIdChar125Route,
728
820
  MailOutboxDraftOutboxMessageIdRoute: MailOutboxDraftOutboxMessageIdRoute,
729
821
  }
730
822
 
@@ -0,0 +1,14 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ /**
3
+ * /mail/$mailboxId/compose — the compose surface, open over a folder, and
4
+ * /mail/$mailboxId/compose/<outboxMessageId> for the draft it is editing. The
5
+ * Drafts folder's own rows open here.
6
+ */
7
+ import { createFileRoute } from "@tanstack/react-router";
8
+ import { FullCompose } from "@/components/compose/FullCompose";
9
+
10
+ export const Route = createFileRoute(
11
+ "/mail/$mailboxId/compose/{-$outboxMessageId}",
12
+ )({
13
+ component: FullCompose,
14
+ });
@@ -0,0 +1,22 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ /**
3
+ * /mail/brief/compose — the compose surface, open over the daily brief, and
4
+ * /mail/brief/compose/<outboxMessageId> once it has a draft to write to.
5
+ *
6
+ * A sibling of the thread route, so navigating here unmatches whatever the list
7
+ * had open in the same transition: nothing has to be closed first, and "opened"
8
+ * and "rendered" are one fact.
9
+ *
10
+ * The draft is an optional segment of this one route rather than a child route,
11
+ * because the first autosave adopts the id it just created while the reader is
12
+ * still typing. Two routes would unmount the composer mid-sentence; one route
13
+ * with a param that arrives is a rewritten address and nothing more.
14
+ */
15
+ import { createFileRoute } from "@tanstack/react-router";
16
+ import { FullCompose } from "@/components/compose/FullCompose";
17
+
18
+ export const Route = createFileRoute("/mail/brief/compose/{-$outboxMessageId}")(
19
+ {
20
+ component: FullCompose,
21
+ },
22
+ );
@@ -0,0 +1,13 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ /**
3
+ * /mail/flagged/compose — the compose surface, open over the starred list, and
4
+ * /mail/flagged/compose/<outboxMessageId> once it has a draft to write to.
5
+ */
6
+ import { createFileRoute } from "@tanstack/react-router";
7
+ import { FullCompose } from "@/components/compose/FullCompose";
8
+
9
+ export const Route = createFileRoute(
10
+ "/mail/flagged/compose/{-$outboxMessageId}",
11
+ )({
12
+ component: FullCompose,
13
+ });
@@ -0,0 +1,17 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ /**
3
+ * /mail/outbox/compose — the compose surface, open over the outbox, and
4
+ * /mail/outbox/compose/<outboxMessageId> for the draft it is editing.
5
+ *
6
+ * `draft/<outboxMessageId>` beside it reads an outbox message; this writes one.
7
+ * The two are separate addresses because they are separate surfaces, and "Edit
8
+ * as draft" is the move from one to the other.
9
+ */
10
+ import { createFileRoute } from "@tanstack/react-router";
11
+ import { FullCompose } from "@/components/compose/FullCompose";
12
+
13
+ export const Route = createFileRoute(
14
+ "/mail/outbox/compose/{-$outboxMessageId}",
15
+ )({
16
+ component: FullCompose,
17
+ });
@@ -13,7 +13,6 @@ import {
13
13
  } from "@tanstack/react-router";
14
14
  import { useCallback, useEffect, useMemo, useState } from "react";
15
15
  import { z } from "zod";
16
- import { useCompose } from "@/components/compose/ComposeProvider";
17
16
  import { ComposeFab } from "@/components/layout/ComposeFab";
18
17
  import { MailShellProvider } from "@/components/layout/MailShell";
19
18
  import { MailTopBar } from "@/components/layout/MailTopBar";
@@ -26,7 +25,6 @@ import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
26
25
  import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
27
26
  import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
28
27
  import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
29
- import { hostsComposeSurface } from "@/lib/compose-routes";
30
28
  import {
31
29
  readIntelligencePref,
32
30
  resolveRailOpen,
@@ -34,13 +32,15 @@ import {
34
32
  } from "@/lib/intelligence-pref";
35
33
  import { MailContext } from "@/lib/mail-context";
36
34
  import { MailFreshnessProvider } from "@/lib/mail-freshness";
37
- import { mailViewKey } from "@/lib/mail-route";
35
+ import { mailListRoute, mailViewKey } from "@/lib/mail-route";
38
36
  import { buildAccountNameIndex } from "@/lib/search-token-index";
39
37
  import { committedSearchQuery, searchInputForView } from "@/lib/search-view";
40
38
  import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
41
39
  import {
42
40
  isOverlayPanel,
43
41
  type OverlayPanel,
42
+ useIsComposing,
43
+ useOpenCompose,
44
44
  useOpenPanels,
45
45
  useOpenThreadPath,
46
46
  useSetOpenPanels,
@@ -166,7 +166,6 @@ function MailLayout() {
166
166
  // the render before committing and nothing is painted with the stale query.
167
167
  // An effect would commit one frame carrying the previous view's text, which
168
168
  // the mirror then has to be defended against.
169
- const pathname = useRouterState({ select: (s) => s.location.pathname });
170
169
  const viewKey = useRouterState({ select: (s) => mailViewKey(s.matches) });
171
170
  const [searchViewKey, setSearchViewKey] = useState(viewKey);
172
171
  if (searchViewKey !== viewKey) {
@@ -214,14 +213,19 @@ function MailLayout() {
214
213
 
215
214
  // `c` / ⌘N off the mailbox routes — the brief, Flagged, the outbox. On a
216
215
  // mailbox the pane's own triage layer owns the key, so this is disabled there
217
- // rather than firing a second compose alongside it.
218
- const { openCompose } = useCompose();
216
+ // rather than firing a second compose alongside it, and while the composer is
217
+ // up the key belongs to whatever is being typed.
218
+ const openCompose = useOpenCompose();
219
+ const isComposing = useIsComposing();
220
+ const onMailbox = useRouterState({
221
+ select: (s) => mailListRoute(s.matches)?.list === "mailbox",
222
+ });
219
223
  const composeHandlers = useMemo(
220
- () => ({ compose: () => openCompose({ mode: "new" }) }),
224
+ () => ({ compose: openCompose }),
221
225
  [openCompose],
222
226
  );
223
227
  useTriageKeyboard({
224
- enabled: !hostsComposeSurface(pathname),
228
+ enabled: !onMailbox && !isComposing,
225
229
  handlers: composeHandlers,
226
230
  });
227
231