@remit/web-client 0.0.144 → 0.0.146

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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/components/compose/ComposeForm.tsx +169 -120
  3. package/src/components/compose/ComposeSmtpMissingBanner.tsx +4 -0
  4. package/src/components/compose/mobile-header-stays-expanded.render.test.ts +182 -0
  5. package/src/components/layout/MailShell.tsx +98 -0
  6. package/src/components/mail/BriefPane.tsx +5 -5
  7. package/src/components/mail/FlaggedPane.tsx +1 -1
  8. package/src/components/mail/MailSidebarAdapter.tsx +5 -8
  9. package/src/components/mail/MailboxPane.tsx +5 -5
  10. package/src/components/ui/FatalErrorOverlay.tsx +4 -1
  11. package/src/hooks/useSearchMirror.ts +93 -0
  12. package/src/hooks/useSearchScope.ts +1 -1
  13. package/src/lib/compose-routes.test.ts +3 -1
  14. package/src/lib/compose-routes.ts +1 -1
  15. package/src/lib/mail-route.test.ts +157 -57
  16. package/src/lib/mail-route.ts +71 -38
  17. package/src/lib/mail-search.ts +51 -0
  18. package/src/lib/route-search-query.test.ts +50 -28
  19. package/src/lib/search-scope.ts +11 -19
  20. package/src/lib/search-view.test.ts +142 -38
  21. package/src/lib/search-view.ts +35 -11
  22. package/src/routeTree.gen.ts +155 -18
  23. package/src/router.tsx +17 -0
  24. package/src/routes/index.tsx +1 -1
  25. package/src/routes/mail/$mailboxId/index.tsx +10 -0
  26. package/src/routes/mail/$mailboxId.tsx +27 -19
  27. package/src/routes/mail/brief/index.tsx +14 -0
  28. package/src/routes/mail/brief.tsx +50 -0
  29. package/src/routes/mail/flagged/index.tsx +10 -0
  30. package/src/routes/mail/flagged.tsx +25 -13
  31. package/src/routes/mail/index.tsx +9 -36
  32. package/src/routes/mail/outbox/index.tsx +10 -0
  33. package/src/routes/mail/outbox.tsx +23 -13
  34. package/src/routes/mail.tsx +35 -246
  35. package/src/test-support/list-search-binding.ts +34 -0
@@ -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, useTriageKeyboard } from "@remit/ui";
5
+ import { useTriageKeyboard } from "@remit/ui";
6
6
  import { useQuery } from "@tanstack/react-query";
7
7
  import {
8
8
  createFileRoute,
@@ -11,17 +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
16
  import { useCompose } from "@/components/compose/ComposeProvider";
17
- import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
18
17
  import { ComposeFab } from "@/components/layout/ComposeFab";
18
+ import { MailShellProvider } from "@/components/layout/MailShell";
19
19
  import { MailTopBar } from "@/components/layout/MailTopBar";
20
- import { BriefPane } from "@/components/mail/BriefPane";
21
- import { FlaggedPane } from "@/components/mail/FlaggedPane";
22
- import { MailboxPane } from "@/components/mail/MailboxPane";
23
20
  import { MailNav } from "@/components/mail/MailNav";
24
- import { OutboxPane } from "@/components/mail/OutboxPane";
25
21
  import { ErrorState } from "@/components/ui/ErrorState";
26
22
  import { KeyboardShortcutsModal } from "@/components/ui/KeyboardShortcutsModal";
27
23
  import { useDebouncedValue } from "@/hooks/useDebouncedValue";
@@ -34,18 +30,9 @@ import { hostsComposeSurface } from "@/lib/compose-routes";
34
30
  import { writeIntelligencePref } from "@/lib/intelligence-pref";
35
31
  import { MailContext } from "@/lib/mail-context";
36
32
  import { MailFreshnessProvider } from "@/lib/mail-freshness";
37
- import {
38
- isBriefRoute,
39
- isFlaggedRoute,
40
- isOutboxRoute,
41
- mailViewKey,
42
- } from "@/lib/mail-route";
33
+ import { mailViewKey } from "@/lib/mail-route";
43
34
  import { buildAccountNameIndex } from "@/lib/search-token-index";
44
- import {
45
- committedSearchQuery,
46
- searchInputForView,
47
- shouldMirrorQuery,
48
- } from "@/lib/search-view";
35
+ import { committedSearchQuery, searchInputForView } from "@/lib/search-view";
49
36
  import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
50
37
  import "@/lib/client";
51
38
 
@@ -85,12 +72,12 @@ function MailLayout() {
85
72
  const { q: searchQuery = "" } = useSearch({ from: "/mail" });
86
73
  const navigate = useNavigate();
87
74
  const tier = useLayoutTier();
88
- // Below the reading boundary (phone AND tablet) AppShellSlotted shows a
89
- // SINGLE pane — there is no reading pane to host the thread or the compose
90
- // surface. So both tiers use the single-pane view, which swaps the pane in
91
- // place between list, open thread, and compose. Keying this off "phone"
92
- // alone left tablet with no compose surface (compose lives in the reading
93
- // 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.
94
81
  const isSinglePane = isSinglePaneTier(tier);
95
82
  const [showShortcuts, setShowShortcuts] = useState(false);
96
83
  const [drawerOpen, setDrawerOpen] = useState(false);
@@ -108,8 +95,8 @@ function MailLayout() {
108
95
 
109
96
  // Within one view, URL `q` seeds the input and is a one-directional write
110
97
  // target: the debounced local value drives the search API and is mirrored
111
- // back. Across views the URL wins again see the view-change adjustment
112
- // 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).
113
100
  const [searchInput, setSearchInput] = useState(searchQuery);
114
101
  const debouncedSearchInput = useDebouncedValue(searchInput, 200);
115
102
  const committedQuery = committedSearchQuery(
@@ -117,23 +104,20 @@ function MailLayout() {
117
104
  debouncedSearchInput,
118
105
  );
119
106
 
120
- const searchQueryRef = useRef(searchQuery);
121
- searchQueryRef.current = searchQuery;
122
-
123
107
  // Search is a mode of the view it was typed in, so leaving that view re-seeds
124
108
  // the field from wherever we land (#47): empty when the sidebar dropped `q`
125
109
  // (a folder switch starts that folder's search fresh), and the carried query
126
110
  // when the top bar's scope chip was removed and sent the user to the brief to
127
- // search everything. Views that differ only in search params opening a
128
- // result, mirroring `q` — are the same view, so in-flight typing survives
129
- // 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`).
130
114
  //
131
115
  // Adjusted during render, not in an effect. This is React's documented
132
116
  // "adjusting state when a prop changes" pattern: both updates are to this
133
117
  // component's own state and are guarded by a changed value, so React re-runs
134
118
  // the render before committing and nothing is painted with the stale query.
135
119
  // An effect would commit one frame carrying the previous view's text, which
136
- // the mirror below then has to be defended against.
120
+ // the mirror then has to be defended against.
137
121
  const pathname = useRouterState({ select: (s) => s.location.pathname });
138
122
  const viewKey = useRouterState({ select: (s) => mailViewKey(s.matches) });
139
123
  const [searchViewKey, setSearchViewKey] = useState(viewKey);
@@ -143,44 +127,6 @@ function MailLayout() {
143
127
  if (seeded !== undefined) setSearchInput(seeded);
144
128
  }
145
129
 
146
- // Mirror the settled search into the URL so links are shareable and a
147
- // refresh restores the query. Within a view this is one-directional — the
148
- // URL is not read back into state, so there is no sync loop — and it writes
149
- // only once the debounce agrees with the field, so a query arriving by URL
150
- // is never overwritten mid-debounce, and the query the user just left behind
151
- // is never written onto the view they landed on (`shouldMirrorQuery`).
152
- // When a query *goes* active, also strip selectedMessageId so the reading
153
- // pane closes (#539): an open message from the pre-search list is not
154
- // meaningful in the search result set. Only strip on that transition though —
155
- // tapping a search result commits the same `q` with the selection, so when
156
- // `prev.q` already equals the query the result was opened under it (not a
157
- // pre-search leftover) and must survive. The strip otherwise raced the tap:
158
- // the row shows before the debounce settles, so this mirror can land just
159
- // after the open and close it again.
160
- useEffect(() => {
161
- if (!shouldMirrorQuery(searchInput, committedQuery, searchQueryRef.current))
162
- return;
163
- navigate({
164
- to: ".",
165
- search: (prev) => {
166
- const queryAlreadyActive =
167
- (prev as { q?: string }).q === committedQuery;
168
- return {
169
- ...prev,
170
- q: committedQuery || undefined,
171
- ...(committedQuery && !queryAlreadyActive
172
- ? {
173
- selectedMessageId: undefined,
174
- selectedThreadId: undefined,
175
- selectedMailboxId: undefined,
176
- }
177
- : {}),
178
- };
179
- },
180
- replace: true,
181
- });
182
- }, [searchInput, committedQuery, navigate]);
183
-
184
130
  const {
185
131
  data: config,
186
132
  isLoading,
@@ -235,8 +181,8 @@ function MailLayout() {
235
181
  setSearchInput(query);
236
182
  }, []);
237
183
 
238
- // Clears the search field; the write effect drops `q` from the URL after
239
- // the debounce settles.
184
+ // Clears the search field; the list route's mirror drops `q` from the URL
185
+ // after the debounce settles.
240
186
  const handleSearchClear = useCallback(() => {
241
187
  setSearchInput("");
242
188
  }, []);
@@ -266,43 +212,6 @@ function MailLayout() {
266
212
  [accounts],
267
213
  );
268
214
 
269
- // Read the current mailbox params and selected message (if any) from the
270
- // child route so the parent shell can mount the right pane (brief / mailbox /
271
- // outbox) and forward the open thread into it.
272
- //
273
- // `useParams({ strict: false })` resolves against the *nearest* route match
274
- // in the React component tree — which for this parent layout is /mail (no
275
- // path params). The child's $mailboxId param never appears there, so
276
- // mobileMailboxId would always be undefined and the navigate guard would
277
- // short-circuit every tap. Use `useRouterState` instead: it exposes all
278
- // currently matched routes, letting us find the one that carries mailboxId.
279
- const mobileMailboxId = useRouterState({
280
- select: (s) => {
281
- const match = s.matches.find(
282
- (m): m is typeof m & { params: { mailboxId: string } } =>
283
- "mailboxId" in m.params,
284
- );
285
- return match?.params.mailboxId;
286
- },
287
- });
288
- const { selectedMessageId: mobileSelectedMessageId } = useSearch({
289
- strict: false,
290
- }) as { selectedMessageId?: string };
291
-
292
- // Detect which leaf route is active by its own routeId — never the parent
293
- // /mail layout's pathname (which is "/mail" on EVERY child route and would
294
- // wrongly route every mailbox through the brief pane, dropping message rows).
295
- // See lib/mail-route.ts for the contract + regression test.
296
- const onBriefRoute = useRouterState({
297
- select: (s) => isBriefRoute(s.matches),
298
- });
299
- const onFlaggedRoute = useRouterState({
300
- select: (s) => isFlaggedRoute(s.matches),
301
- });
302
- const onOutboxRoute = useRouterState({
303
- select: (s) => isOutboxRoute(s.matches),
304
- });
305
-
306
215
  // Auto-trigger a mailbox-list sync for any account whose lastSyncAt is
307
216
  // older than 15 minutes (or unset). Fires once per accountId per session
308
217
  // — re-mounts of MailLayout will not retrigger. See #205.
@@ -337,17 +246,18 @@ function MailLayout() {
337
246
  // own slide-over Dialog (narrow), never both — so there is exactly one
338
247
  // "Mailboxes" nav landmark at any width. `MailNav` adds the mobile
339
248
  // Settings + bug-report footer when it detects the slide-over context.
340
- const navContent = (
341
- <MailNav accounts={accounts} onMailboxSelect={handleMailboxSelect} />
342
- );
343
- // Single-pane only, where the FAB is the compose entry point. Above it the
344
- // top bar owns compose.
345
- const overlayContent = isSinglePane ? <ComposeFab /> : undefined;
346
- // Desktop only. Below 1024px the single pane keeps its own header search and
347
- // the phone takeover; there is no room for a bar spanning panes that do not
348
- // exist side by side.
349
- const topBar = isSinglePane ? undefined : <MailTopBar accounts={accounts} />;
350
- 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,
351
261
  navOpen: drawerOpen,
352
262
  onOpenNav: () => setDrawerOpen(true),
353
263
  onCloseNav: () => setDrawerOpen(false),
@@ -366,137 +276,16 @@ function MailLayout() {
366
276
  }}
367
277
  />
368
278
  </div>
369
- ) : onBriefRoute ? (
370
- // Daily brief (/mail/) — no mailboxId param; same 3-pane layout as a
371
- // mailbox: an open message has an intelligence rail here too (#52).
372
- <BriefPane selectedMessageId={mobileSelectedMessageId}>
373
- {isSinglePane ? (
374
- <AppShellSlotted
375
- nav={navContent}
376
- list={<BriefPane.Phone />}
377
- intelligenceOpen={intelligenceOpen}
378
- overlay={overlayContent}
379
- skeleton={<AppShellSkeleton />}
380
- isLoading={isLoading || hasNoAccounts}
381
- {...navSlideOver}
382
- />
383
- ) : (
384
- <AppShellSlotted
385
- nav={navContent}
386
- topBar={topBar}
387
- list={<BriefPane.List />}
388
- reading={<BriefPane.Reading />}
389
- intelligence={<BriefPane.Intelligence />}
390
- intelligenceOpen={intelligenceOpen}
391
- hasThread={Boolean(mobileSelectedMessageId)}
392
- overlay={overlayContent}
393
- skeleton={<AppShellSkeleton />}
394
- isLoading={isLoading || hasNoAccounts}
395
- {...navSlideOver}
396
- />
397
- )}
398
- </BriefPane>
399
- ) : onFlaggedRoute ? (
400
- // Flagged virtual mailbox (/mail/flagged) — flat starred list across
401
- // accounts; same slots as the brief, intelligence rail included.
402
- <FlaggedPane selectedMessageId={mobileSelectedMessageId}>
403
- {isSinglePane ? (
404
- <AppShellSlotted
405
- nav={navContent}
406
- list={<FlaggedPane.Phone />}
407
- intelligenceOpen={intelligenceOpen}
408
- overlay={overlayContent}
409
- skeleton={<AppShellSkeleton />}
410
- isLoading={isLoading || hasNoAccounts}
411
- {...navSlideOver}
412
- />
413
- ) : (
414
- <AppShellSlotted
415
- nav={navContent}
416
- topBar={topBar}
417
- list={<FlaggedPane.List />}
418
- reading={<FlaggedPane.Reading />}
419
- intelligence={<FlaggedPane.Intelligence />}
420
- intelligenceOpen={intelligenceOpen}
421
- hasThread={Boolean(mobileSelectedMessageId)}
422
- overlay={overlayContent}
423
- skeleton={<AppShellSkeleton />}
424
- isLoading={isLoading || hasNoAccounts}
425
- {...navSlideOver}
426
- />
427
- )}
428
- </FlaggedPane>
429
- ) : mobileMailboxId ? (
430
- // Mailbox view (/mail/$mailboxId) — full 4-pane layout.
431
- <MailboxPane
432
- mailboxId={mobileMailboxId}
433
- selectedMessageId={mobileSelectedMessageId}
434
- >
435
- {isSinglePane ? (
436
- <AppShellSlotted
437
- nav={navContent}
438
- list={<MailboxPane.Phone />}
439
- intelligenceOpen={intelligenceOpen}
440
- overlay={overlayContent}
441
- skeleton={<AppShellSkeleton />}
442
- isLoading={isLoading || hasNoAccounts}
443
- {...navSlideOver}
444
- />
445
- ) : (
446
- <AppShellSlotted
447
- nav={navContent}
448
- topBar={topBar}
449
- list={<MailboxPane.List />}
450
- reading={<MailboxPane.Reading />}
451
- intelligence={<MailboxPane.Intelligence />}
452
- intelligenceOpen={intelligenceOpen}
453
- hasThread={Boolean(mobileSelectedMessageId)}
454
- overlay={overlayContent}
455
- skeleton={<AppShellSkeleton />}
456
- isLoading={isLoading || hasNoAccounts}
457
- {...navSlideOver}
458
- />
459
- )}
460
- </MailboxPane>
461
- ) : onOutboxRoute ? (
462
- // Outbox — 2-pane layout (list + reading, no intelligence).
463
- <OutboxPane>
464
- {isSinglePane ? (
465
- <AppShellSlotted
466
- nav={navContent}
467
- list={<OutboxPane.Phone />}
468
- intelligenceOpen={false}
469
- overlay={overlayContent}
470
- skeleton={<AppShellSkeleton />}
471
- isLoading={isLoading || hasNoAccounts}
472
- {...navSlideOver}
473
- />
474
- ) : (
475
- <AppShellSlotted
476
- nav={navContent}
477
- topBar={topBar}
478
- list={<OutboxPane.List />}
479
- reading={<OutboxPane.Reading />}
480
- intelligenceOpen={false}
481
- overlay={overlayContent}
482
- skeleton={<AppShellSkeleton />}
483
- isLoading={isLoading || hasNoAccounts}
484
- {...navSlideOver}
485
- />
486
- )}
487
- </OutboxPane>
488
279
  ) : (
489
- // Fallback: transitioning or unrecognized route show skeleton.
490
- <AppShellSkeleton />
280
+ // Each list route mounts its own shell around its own panes.
281
+ <MailShellProvider chrome={shellChrome}>
282
+ <Outlet />
283
+ </MailShellProvider>
491
284
  )}
492
285
  <KeyboardShortcutsModal
493
286
  isOpen={showShortcuts}
494
287
  onClose={() => setShowShortcuts(false)}
495
288
  />
496
- {/* Outlet is required for TanStack Router to activate child routes.
497
- Routes that manage their own rendering (brief, mailbox, outbox) return
498
- null from their component — the parent shell owns the layout. */}
499
- <Outlet />
500
289
  </MailFreshnessProvider>
501
290
  </MailContext.Provider>
502
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
+ }