@remit/web-client 0.0.143 → 0.0.145

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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/components/compose/ComposeProvider.tsx +79 -3
  3. package/src/components/compose/compose-clears-open-thread.render.test.ts +302 -0
  4. package/src/components/compose/compose-send-stops-autosave.render.test.ts +34 -5
  5. package/src/components/layout/ComposeFab.tsx +11 -36
  6. package/src/components/layout/MailShell.tsx +98 -0
  7. package/src/components/layout/MailTopBar.tsx +6 -3
  8. package/src/components/mail/BriefPane.tsx +5 -5
  9. package/src/components/mail/DraftsView.tsx +0 -11
  10. package/src/components/mail/FlaggedPane.tsx +1 -1
  11. package/src/components/mail/MailSidebarAdapter.tsx +5 -8
  12. package/src/components/mail/MailboxPane.tsx +15 -5
  13. package/src/components/ui/FatalErrorOverlay.tsx +4 -1
  14. package/src/hooks/useComposeTargetMailbox.ts +75 -0
  15. package/src/hooks/useSearchMirror.ts +93 -0
  16. package/src/hooks/useSearchScope.ts +1 -1
  17. package/src/lib/compose-routes.test.ts +3 -1
  18. package/src/lib/compose-routes.ts +6 -6
  19. package/src/lib/mail-route.test.ts +157 -57
  20. package/src/lib/mail-route.ts +71 -38
  21. package/src/lib/mail-search.ts +51 -0
  22. package/src/lib/route-search-query.test.ts +50 -28
  23. package/src/lib/search-scope.ts +11 -19
  24. package/src/lib/search-view.test.ts +142 -38
  25. package/src/lib/search-view.ts +35 -11
  26. package/src/routeTree.gen.ts +155 -18
  27. package/src/router.tsx +17 -0
  28. package/src/routes/index.tsx +1 -1
  29. package/src/routes/mail/$mailboxId/index.tsx +10 -0
  30. package/src/routes/mail/$mailboxId.tsx +27 -19
  31. package/src/routes/mail/brief/index.tsx +14 -0
  32. package/src/routes/mail/brief.tsx +50 -0
  33. package/src/routes/mail/flagged/index.tsx +10 -0
  34. package/src/routes/mail/flagged.tsx +25 -13
  35. package/src/routes/mail/index.tsx +9 -36
  36. package/src/routes/mail/outbox/index.tsx +10 -0
  37. package/src/routes/mail/outbox.tsx +23 -13
  38. package/src/routes/mail.tsx +51 -249
  39. package/src/test-support/list-search-binding.ts +34 -0
  40. package/src/hooks/useComposeTarget.ts +0 -92
@@ -1,18 +1,28 @@
1
- import { createFileRoute } from "@tanstack/react-router";
2
- import { z } from "zod";
1
+ /**
2
+ * /mail/outbox one of the four list layouts. Two panes, list and reading;
3
+ * there is no intelligence rail for mail that has not been sent yet.
4
+ */
5
+ import { createFileRoute, Outlet } from "@tanstack/react-router";
6
+ import { MailShell } from "@/components/layout/MailShell";
7
+ import { OutboxPane } from "@/components/mail/OutboxPane";
8
+ import { useSearchMirror } from "@/hooks/useSearchMirror";
9
+ import { outboxSearchSchema } from "@/lib/mail-search";
3
10
 
4
- // Search schema: `selectedOutboxMessageId` is read by `OutboxPane` in the
5
- // parent `/mail` shell via `useSearch({ strict: false })`.
6
- //
7
- // `q` is inherited from the parent /mail route; re-declared here so it survives
8
- // this route's own search validation and isn't dropped when navigating with a
9
- // functional search updater.
10
- const outboxSearchSchema = z.object({
11
- selectedOutboxMessageId: z.string().optional(),
12
- q: z.string().optional(),
13
- });
11
+ function OutboxLayout() {
12
+ useSearchMirror({ to: "/mail/outbox" });
13
+
14
+ return (
15
+ <OutboxPane>
16
+ <MailShell
17
+ phone={<OutboxPane.Phone />}
18
+ list={<OutboxPane.List />}
19
+ reading={<Outlet />}
20
+ />
21
+ </OutboxPane>
22
+ );
23
+ }
14
24
 
15
25
  export const Route = createFileRoute("/mail/outbox")({
16
- component: () => null,
26
+ component: OutboxLayout,
17
27
  validateSearch: outboxSearchSchema,
18
28
  });
@@ -2,7 +2,7 @@ import {
2
2
  configOperationsGetConfigOptions,
3
3
  unifiedThreadOperationsListAllThreadsOptions,
4
4
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
- import { AppShellSlotted } from "@remit/ui";
5
+ import { useTriageKeyboard } from "@remit/ui";
6
6
  import { useQuery } from "@tanstack/react-query";
7
7
  import {
8
8
  createFileRoute,
@@ -11,16 +11,13 @@ import {
11
11
  useRouterState,
12
12
  useSearch,
13
13
  } from "@tanstack/react-router";
14
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
14
+ import { useCallback, useEffect, useMemo, useState } from "react";
15
15
  import { z } from "zod";
16
- import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
16
+ import { useCompose } from "@/components/compose/ComposeProvider";
17
17
  import { ComposeFab } from "@/components/layout/ComposeFab";
18
+ import { MailShellProvider } from "@/components/layout/MailShell";
18
19
  import { MailTopBar } from "@/components/layout/MailTopBar";
19
- import { BriefPane } from "@/components/mail/BriefPane";
20
- import { FlaggedPane } from "@/components/mail/FlaggedPane";
21
- import { MailboxPane } from "@/components/mail/MailboxPane";
22
20
  import { MailNav } from "@/components/mail/MailNav";
23
- import { OutboxPane } from "@/components/mail/OutboxPane";
24
21
  import { ErrorState } from "@/components/ui/ErrorState";
25
22
  import { KeyboardShortcutsModal } from "@/components/ui/KeyboardShortcutsModal";
26
23
  import { useDebouncedValue } from "@/hooks/useDebouncedValue";
@@ -29,21 +26,13 @@ import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
29
26
  import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
30
27
  import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
31
28
  import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
29
+ import { hostsComposeSurface } from "@/lib/compose-routes";
32
30
  import { writeIntelligencePref } from "@/lib/intelligence-pref";
33
31
  import { MailContext } from "@/lib/mail-context";
34
32
  import { MailFreshnessProvider } from "@/lib/mail-freshness";
35
- import {
36
- isBriefRoute,
37
- isFlaggedRoute,
38
- isOutboxRoute,
39
- mailViewKey,
40
- } from "@/lib/mail-route";
33
+ import { mailViewKey } from "@/lib/mail-route";
41
34
  import { buildAccountNameIndex } from "@/lib/search-token-index";
42
- import {
43
- committedSearchQuery,
44
- searchInputForView,
45
- shouldMirrorQuery,
46
- } from "@/lib/search-view";
35
+ import { committedSearchQuery, searchInputForView } from "@/lib/search-view";
47
36
  import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
48
37
  import "@/lib/client";
49
38
 
@@ -83,12 +72,12 @@ function MailLayout() {
83
72
  const { q: searchQuery = "" } = useSearch({ from: "/mail" });
84
73
  const navigate = useNavigate();
85
74
  const tier = useLayoutTier();
86
- // Below the reading boundary (phone AND tablet) AppShellSlotted shows a
87
- // SINGLE pane — there is no reading pane to host the thread or the compose
88
- // surface. So both tiers use the single-pane view, which swaps the pane in
89
- // place between list, open thread, and compose. Keying this off "phone"
90
- // alone left tablet with no compose surface (compose lives in the reading
91
- // pane, which tablet doesn't mount) — the "c" shortcut / FAB opened nothing.
75
+ // Below the reading boundary (phone AND tablet) the shell shows a SINGLE
76
+ // pane — there is no reading pane to host the thread or the compose surface.
77
+ // So both tiers use the single-pane view, which swaps the pane in place
78
+ // between list, open thread, and compose. Keying this off "phone" alone left
79
+ // tablet with no compose surface (compose lives in the reading pane, which
80
+ // tablet doesn't mount) — the "c" shortcut / FAB opened nothing.
92
81
  const isSinglePane = isSinglePaneTier(tier);
93
82
  const [showShortcuts, setShowShortcuts] = useState(false);
94
83
  const [drawerOpen, setDrawerOpen] = useState(false);
@@ -106,8 +95,8 @@ function MailLayout() {
106
95
 
107
96
  // Within one view, URL `q` seeds the input and is a one-directional write
108
97
  // target: the debounced local value drives the search API and is mirrored
109
- // back. Across views the URL wins again see the view-change adjustment
110
- // below and `lib/search-view.ts` (#47).
98
+ // back by the list route's own `useSearchMirror`. Across views the URL wins
99
+ // again — see the view-change adjustment below and `lib/search-view.ts` (#47).
111
100
  const [searchInput, setSearchInput] = useState(searchQuery);
112
101
  const debouncedSearchInput = useDebouncedValue(searchInput, 200);
113
102
  const committedQuery = committedSearchQuery(
@@ -115,23 +104,21 @@ function MailLayout() {
115
104
  debouncedSearchInput,
116
105
  );
117
106
 
118
- const searchQueryRef = useRef(searchQuery);
119
- searchQueryRef.current = searchQuery;
120
-
121
107
  // Search is a mode of the view it was typed in, so leaving that view re-seeds
122
108
  // the field from wherever we land (#47): empty when the sidebar dropped `q`
123
109
  // (a folder switch starts that folder's search fresh), and the carried query
124
110
  // when the top bar's scope chip was removed and sent the user to the brief to
125
- // search everything. Views that differ only in search params opening a
126
- // result, mirroring `q` — are the same view, so in-flight typing survives
127
- // them (`searchInputForView`).
111
+ // search everything. Views that differ only in what is open below the list —
112
+ // a thread, a mirrored `q` — are the same view, so in-flight typing survives
113
+ // them (`searchInputForView`, `mailViewKey`).
128
114
  //
129
115
  // Adjusted during render, not in an effect. This is React's documented
130
116
  // "adjusting state when a prop changes" pattern: both updates are to this
131
117
  // component's own state and are guarded by a changed value, so React re-runs
132
118
  // the render before committing and nothing is painted with the stale query.
133
119
  // An effect would commit one frame carrying the previous view's text, which
134
- // the mirror below then has to be defended against.
120
+ // the mirror then has to be defended against.
121
+ const pathname = useRouterState({ select: (s) => s.location.pathname });
135
122
  const viewKey = useRouterState({ select: (s) => mailViewKey(s.matches) });
136
123
  const [searchViewKey, setSearchViewKey] = useState(viewKey);
137
124
  if (searchViewKey !== viewKey) {
@@ -140,44 +127,6 @@ function MailLayout() {
140
127
  if (seeded !== undefined) setSearchInput(seeded);
141
128
  }
142
129
 
143
- // Mirror the settled search into the URL so links are shareable and a
144
- // refresh restores the query. Within a view this is one-directional — the
145
- // URL is not read back into state, so there is no sync loop — and it writes
146
- // only once the debounce agrees with the field, so a query arriving by URL
147
- // is never overwritten mid-debounce, and the query the user just left behind
148
- // is never written onto the view they landed on (`shouldMirrorQuery`).
149
- // When a query *goes* active, also strip selectedMessageId so the reading
150
- // pane closes (#539): an open message from the pre-search list is not
151
- // meaningful in the search result set. Only strip on that transition though —
152
- // tapping a search result commits the same `q` with the selection, so when
153
- // `prev.q` already equals the query the result was opened under it (not a
154
- // pre-search leftover) and must survive. The strip otherwise raced the tap:
155
- // the row shows before the debounce settles, so this mirror can land just
156
- // after the open and close it again.
157
- useEffect(() => {
158
- if (!shouldMirrorQuery(searchInput, committedQuery, searchQueryRef.current))
159
- return;
160
- navigate({
161
- to: ".",
162
- search: (prev) => {
163
- const queryAlreadyActive =
164
- (prev as { q?: string }).q === committedQuery;
165
- return {
166
- ...prev,
167
- q: committedQuery || undefined,
168
- ...(committedQuery && !queryAlreadyActive
169
- ? {
170
- selectedMessageId: undefined,
171
- selectedThreadId: undefined,
172
- selectedMailboxId: undefined,
173
- }
174
- : {}),
175
- };
176
- },
177
- replace: true,
178
- });
179
- }, [searchInput, committedQuery, navigate]);
180
-
181
130
  const {
182
131
  data: config,
183
132
  isLoading,
@@ -215,12 +164,25 @@ function MailLayout() {
215
164
  ],
216
165
  });
217
166
 
167
+ // `c` / ⌘N off the mailbox routes — the brief, Flagged, the outbox. On a
168
+ // mailbox the pane's own triage layer owns the key, so this is disabled there
169
+ // rather than firing a second compose alongside it.
170
+ const { openCompose } = useCompose();
171
+ const composeHandlers = useMemo(
172
+ () => ({ compose: () => openCompose({ mode: "new" }) }),
173
+ [openCompose],
174
+ );
175
+ useTriageKeyboard({
176
+ enabled: !hostsComposeSurface(pathname),
177
+ handlers: composeHandlers,
178
+ });
179
+
218
180
  const handleSearchChange = useCallback((query: string) => {
219
181
  setSearchInput(query);
220
182
  }, []);
221
183
 
222
- // Clears the search field; the write effect drops `q` from the URL after
223
- // the debounce settles.
184
+ // Clears the search field; the list route's mirror drops `q` from the URL
185
+ // after the debounce settles.
224
186
  const handleSearchClear = useCallback(() => {
225
187
  setSearchInput("");
226
188
  }, []);
@@ -250,43 +212,6 @@ function MailLayout() {
250
212
  [accounts],
251
213
  );
252
214
 
253
- // Read the current mailbox params and selected message (if any) from the
254
- // child route so the parent shell can mount the right pane (brief / mailbox /
255
- // outbox) and forward the open thread into it.
256
- //
257
- // `useParams({ strict: false })` resolves against the *nearest* route match
258
- // in the React component tree — which for this parent layout is /mail (no
259
- // path params). The child's $mailboxId param never appears there, so
260
- // mobileMailboxId would always be undefined and the navigate guard would
261
- // short-circuit every tap. Use `useRouterState` instead: it exposes all
262
- // currently matched routes, letting us find the one that carries mailboxId.
263
- const mobileMailboxId = useRouterState({
264
- select: (s) => {
265
- const match = s.matches.find(
266
- (m): m is typeof m & { params: { mailboxId: string } } =>
267
- "mailboxId" in m.params,
268
- );
269
- return match?.params.mailboxId;
270
- },
271
- });
272
- const { selectedMessageId: mobileSelectedMessageId } = useSearch({
273
- strict: false,
274
- }) as { selectedMessageId?: string };
275
-
276
- // Detect which leaf route is active by its own routeId — never the parent
277
- // /mail layout's pathname (which is "/mail" on EVERY child route and would
278
- // wrongly route every mailbox through the brief pane, dropping message rows).
279
- // See lib/mail-route.ts for the contract + regression test.
280
- const onBriefRoute = useRouterState({
281
- select: (s) => isBriefRoute(s.matches),
282
- });
283
- const onFlaggedRoute = useRouterState({
284
- select: (s) => isFlaggedRoute(s.matches),
285
- });
286
- const onOutboxRoute = useRouterState({
287
- select: (s) => isOutboxRoute(s.matches),
288
- });
289
-
290
215
  // Auto-trigger a mailbox-list sync for any account whose lastSyncAt is
291
216
  // older than 15 minutes (or unset). Fires once per accountId per session
292
217
  // — re-mounts of MailLayout will not retrigger. See #205.
@@ -321,20 +246,18 @@ function MailLayout() {
321
246
  // own slide-over Dialog (narrow), never both — so there is exactly one
322
247
  // "Mailboxes" nav landmark at any width. `MailNav` adds the mobile
323
248
  // Settings + bug-report footer when it detects the slide-over context.
324
- const navContent = (
325
- <MailNav accounts={accounts} onMailboxSelect={handleMailboxSelect} />
326
- );
327
- // Single-pane only, where the FAB is the compose entry point. Above it the
328
- // top bar owns compose, and mounting the FAB there would resolve the same
329
- // compose target twice for a button CSS keeps hidden.
330
- const overlayContent = isSinglePane ? (
331
- <ComposeFab accounts={accounts} />
332
- ) : undefined;
333
- // Desktop only. Below 1024px the single pane keeps its own header search and
334
- // the phone takeover; there is no room for a bar spanning panes that do not
335
- // exist side by side.
336
- const topBar = isSinglePane ? undefined : <MailTopBar accounts={accounts} />;
337
- const navSlideOver = {
249
+ const shellChrome = {
250
+ isSinglePane,
251
+ isLoading: isLoading || hasNoAccounts,
252
+ intelligenceOpen,
253
+ nav: <MailNav accounts={accounts} onMailboxSelect={handleMailboxSelect} />,
254
+ // Desktop only. Below 1024px the single pane keeps its own header search
255
+ // and the phone takeover; there is no room for a bar spanning panes that do
256
+ // not exist side by side.
257
+ topBar: isSinglePane ? undefined : <MailTopBar accounts={accounts} />,
258
+ // Single-pane only, where the FAB is the compose entry point. Above it the
259
+ // top bar owns compose.
260
+ overlay: isSinglePane ? <ComposeFab /> : undefined,
338
261
  navOpen: drawerOpen,
339
262
  onOpenNav: () => setDrawerOpen(true),
340
263
  onCloseNav: () => setDrawerOpen(false),
@@ -353,137 +276,16 @@ function MailLayout() {
353
276
  }}
354
277
  />
355
278
  </div>
356
- ) : onBriefRoute ? (
357
- // Daily brief (/mail/) — no mailboxId param; same 3-pane layout as a
358
- // mailbox: an open message has an intelligence rail here too (#52).
359
- <BriefPane selectedMessageId={mobileSelectedMessageId}>
360
- {isSinglePane ? (
361
- <AppShellSlotted
362
- nav={navContent}
363
- list={<BriefPane.Phone />}
364
- intelligenceOpen={intelligenceOpen}
365
- overlay={overlayContent}
366
- skeleton={<AppShellSkeleton />}
367
- isLoading={isLoading || hasNoAccounts}
368
- {...navSlideOver}
369
- />
370
- ) : (
371
- <AppShellSlotted
372
- nav={navContent}
373
- topBar={topBar}
374
- list={<BriefPane.List />}
375
- reading={<BriefPane.Reading />}
376
- intelligence={<BriefPane.Intelligence />}
377
- intelligenceOpen={intelligenceOpen}
378
- hasThread={Boolean(mobileSelectedMessageId)}
379
- overlay={overlayContent}
380
- skeleton={<AppShellSkeleton />}
381
- isLoading={isLoading || hasNoAccounts}
382
- {...navSlideOver}
383
- />
384
- )}
385
- </BriefPane>
386
- ) : onFlaggedRoute ? (
387
- // Flagged virtual mailbox (/mail/flagged) — flat starred list across
388
- // accounts; same slots as the brief, intelligence rail included.
389
- <FlaggedPane selectedMessageId={mobileSelectedMessageId}>
390
- {isSinglePane ? (
391
- <AppShellSlotted
392
- nav={navContent}
393
- list={<FlaggedPane.Phone />}
394
- intelligenceOpen={intelligenceOpen}
395
- overlay={overlayContent}
396
- skeleton={<AppShellSkeleton />}
397
- isLoading={isLoading || hasNoAccounts}
398
- {...navSlideOver}
399
- />
400
- ) : (
401
- <AppShellSlotted
402
- nav={navContent}
403
- topBar={topBar}
404
- list={<FlaggedPane.List />}
405
- reading={<FlaggedPane.Reading />}
406
- intelligence={<FlaggedPane.Intelligence />}
407
- intelligenceOpen={intelligenceOpen}
408
- hasThread={Boolean(mobileSelectedMessageId)}
409
- overlay={overlayContent}
410
- skeleton={<AppShellSkeleton />}
411
- isLoading={isLoading || hasNoAccounts}
412
- {...navSlideOver}
413
- />
414
- )}
415
- </FlaggedPane>
416
- ) : mobileMailboxId ? (
417
- // Mailbox view (/mail/$mailboxId) — full 4-pane layout.
418
- <MailboxPane
419
- mailboxId={mobileMailboxId}
420
- selectedMessageId={mobileSelectedMessageId}
421
- >
422
- {isSinglePane ? (
423
- <AppShellSlotted
424
- nav={navContent}
425
- list={<MailboxPane.Phone />}
426
- intelligenceOpen={intelligenceOpen}
427
- overlay={overlayContent}
428
- skeleton={<AppShellSkeleton />}
429
- isLoading={isLoading || hasNoAccounts}
430
- {...navSlideOver}
431
- />
432
- ) : (
433
- <AppShellSlotted
434
- nav={navContent}
435
- topBar={topBar}
436
- list={<MailboxPane.List />}
437
- reading={<MailboxPane.Reading />}
438
- intelligence={<MailboxPane.Intelligence />}
439
- intelligenceOpen={intelligenceOpen}
440
- hasThread={Boolean(mobileSelectedMessageId)}
441
- overlay={overlayContent}
442
- skeleton={<AppShellSkeleton />}
443
- isLoading={isLoading || hasNoAccounts}
444
- {...navSlideOver}
445
- />
446
- )}
447
- </MailboxPane>
448
- ) : onOutboxRoute ? (
449
- // Outbox — 2-pane layout (list + reading, no intelligence).
450
- <OutboxPane>
451
- {isSinglePane ? (
452
- <AppShellSlotted
453
- nav={navContent}
454
- list={<OutboxPane.Phone />}
455
- intelligenceOpen={false}
456
- overlay={overlayContent}
457
- skeleton={<AppShellSkeleton />}
458
- isLoading={isLoading || hasNoAccounts}
459
- {...navSlideOver}
460
- />
461
- ) : (
462
- <AppShellSlotted
463
- nav={navContent}
464
- topBar={topBar}
465
- list={<OutboxPane.List />}
466
- reading={<OutboxPane.Reading />}
467
- intelligenceOpen={false}
468
- overlay={overlayContent}
469
- skeleton={<AppShellSkeleton />}
470
- isLoading={isLoading || hasNoAccounts}
471
- {...navSlideOver}
472
- />
473
- )}
474
- </OutboxPane>
475
279
  ) : (
476
- // Fallback: transitioning or unrecognized route show skeleton.
477
- <AppShellSkeleton />
280
+ // Each list route mounts its own shell around its own panes.
281
+ <MailShellProvider chrome={shellChrome}>
282
+ <Outlet />
283
+ </MailShellProvider>
478
284
  )}
479
285
  <KeyboardShortcutsModal
480
286
  isOpen={showShortcuts}
481
287
  onClose={() => setShowShortcuts(false)}
482
288
  />
483
- {/* Outlet is required for TanStack Router to activate child routes.
484
- Routes that manage their own rendering (brief, mailbox, outbox) return
485
- null from their component — the parent shell owns the layout. */}
486
- <Outlet />
487
289
  </MailFreshnessProvider>
488
290
  </MailContext.Provider>
489
291
  );
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Asserts that each mail list route validates its search with the schema
3
+ * `lib/mail-search.ts` declares for that path. Wire a list to another list's
4
+ * schema and it fails here.
5
+ *
6
+ * It runs as its own process, spawned by `lib/route-search-query.test.ts`.
7
+ * Importing a route file pulls in the whole pane it mounts, and every module a
8
+ * test loads counts in the coverage denominator — reading `validateSearch`
9
+ * in-process dropped web-client's line coverage by ten points with nothing
10
+ * rendered to earn it back.
11
+ */
12
+ import assert from "node:assert/strict";
13
+ import { mailListSearchSchemas } from "@/lib/mail-search";
14
+ import { Route as MailboxRoute } from "@/routes/mail/$mailboxId";
15
+ import { Route as BriefRoute } from "@/routes/mail/brief";
16
+ import { Route as FlaggedRoute } from "@/routes/mail/flagged";
17
+ import { Route as OutboxRoute } from "@/routes/mail/outbox";
18
+
19
+ const routes = {
20
+ "/mail/brief": BriefRoute,
21
+ "/mail/flagged": FlaggedRoute,
22
+ "/mail/outbox": OutboxRoute,
23
+ "/mail/$mailboxId": MailboxRoute,
24
+ } as const;
25
+
26
+ for (const [path, route] of Object.entries(routes)) {
27
+ const declared =
28
+ mailListSearchSchemas[path as keyof typeof mailListSearchSchemas];
29
+ assert.equal(
30
+ route.options.validateSearch,
31
+ declared,
32
+ `${path} does not validate its search with the schema declared for it`,
33
+ );
34
+ }
@@ -1,92 +0,0 @@
1
- /**
2
- * Where a global compose has to land.
3
- *
4
- * The compose surface (`FullCompose`) is mounted by the mailbox route only, so
5
- * compose started from anywhere else — the daily brief, flagged, outbox — has
6
- * to carry the user to a mailbox first. The target is the first account's
7
- * inbox, falling back to its first mailbox.
8
- *
9
- * Both compose entry points use this: the top bar's button on desktop and the
10
- * mobile `ComposeFab`. One resolver, so the two surfaces cannot disagree about
11
- * which routes host the compose surface.
12
- */
13
- import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
14
- import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
15
- import { useQueries } from "@tanstack/react-query";
16
- import { useLocation, useNavigate } from "@tanstack/react-router";
17
- import { startTransition, useCallback } from "react";
18
- import { useCompose } from "@/components/compose/ComposeProvider";
19
- import { hostsComposeSurface } from "@/lib/compose-routes";
20
- import { buildMailboxRoleMap } from "@/lib/folder-roles";
21
-
22
- /**
23
- * The mailbox id a global compose should navigate to, or undefined if none is
24
- * known yet.
25
- *
26
- * Accounts resolve in order and an account whose mailbox query is still in
27
- * flight blocks rather than being skipped: skipping hands back a later
28
- * account's inbox and then silently swaps the target once the earlier query
29
- * settles.
30
- */
31
- export function useComposeTargetMailboxId(
32
- accounts: RemitImapAccountResponse[],
33
- ): string | undefined {
34
- const mailboxQueries = useQueries({
35
- queries: accounts.map((account) => ({
36
- ...mailboxOperationsListMailboxesOptions({
37
- path: { accountId: account.accountId },
38
- }),
39
- staleTime: Infinity,
40
- })),
41
- });
42
-
43
- for (const [index, account] of accounts.entries()) {
44
- const query = mailboxQueries[index];
45
- if (!query || query.isPending) return undefined;
46
- const mailboxes = query.data?.items ?? [];
47
- if (mailboxes.length === 0) continue;
48
- const roleMap = buildMailboxRoleMap(account.folderAppointments);
49
- const inbox = mailboxes.find(
50
- (mailbox) => roleMap.get(mailbox.mailboxId) === "inbox",
51
- );
52
- return (inbox ?? mailboxes[0])?.mailboxId;
53
- }
54
- return undefined;
55
- }
56
-
57
- /**
58
- * A compose action that works from every view: opens compose in place when the
59
- * current route already hosts the surface, otherwise navigates to the target
60
- * mailbox — compose state lives in `ComposeProvider` (mounted at the root), so
61
- * it survives the transition and the destination mounts straight into it.
62
- *
63
- * With no target resolved yet — a cold load whose mailbox queries have not
64
- * settled, or accounts with no mailboxes — the action does nothing. Opening
65
- * compose state first would leave it open with nothing rendering it, and it
66
- * would then pop up unprompted on the next navigation.
67
- */
68
- export function useGlobalCompose(
69
- accounts: RemitImapAccountResponse[],
70
- ): () => void {
71
- const { openCompose } = useCompose();
72
- const navigate = useNavigate();
73
- const location = useLocation();
74
- const targetMailboxId = useComposeTargetMailboxId(accounts);
75
-
76
- return useCallback(() => {
77
- if (hostsComposeSurface(location.pathname)) {
78
- startTransition(() => {
79
- openCompose({ mode: "new" });
80
- });
81
- return;
82
- }
83
- if (!targetMailboxId) return;
84
- startTransition(() => {
85
- openCompose({ mode: "new" });
86
- });
87
- navigate({
88
- to: "/mail/$mailboxId",
89
- params: { mailboxId: targetMailboxId },
90
- });
91
- }, [openCompose, navigate, location.pathname, targetMailboxId]);
92
- }