@remit/web-client 0.0.201 → 0.0.203

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 (50) hide show
  1. package/package.json +1 -1
  2. package/src/components/compose/ComposeSmtpMissingBanner.tsx +3 -6
  3. package/src/components/layout/ComposeFab.tsx +3 -5
  4. package/src/components/layout/MailTopBar.tsx +3 -4
  5. package/src/components/mail/BriefPane.tsx +14 -31
  6. package/src/components/mail/DailyBrief.tsx +9 -12
  7. package/src/components/mail/DraftsView.tsx +3 -17
  8. package/src/components/mail/FlaggedList.tsx +1 -1
  9. package/src/components/mail/FlaggedPane.tsx +14 -31
  10. package/src/components/mail/MailListHeader.tsx +10 -11
  11. package/src/components/mail/MailSidebarAdapter.tsx +4 -25
  12. package/src/components/mail/MailboxPane.tsx +21 -59
  13. package/src/components/mail/MessageActionMenu.tsx +4 -11
  14. package/src/components/mail/MessageList.tsx +9 -13
  15. package/src/components/mail/OutboxPane.tsx +9 -26
  16. package/src/components/mail/SelectionWizardHost.tsx +1 -1
  17. package/src/components/mail/SwipeableMessageRow.tsx +4 -15
  18. package/src/hooks/useCurrentMailboxName.ts +5 -9
  19. package/src/hooks/useSearchScope.ts +6 -12
  20. package/src/hooks/useSearchTokenContext.ts +3 -2
  21. package/src/hooks/useSemanticSearch.ts +3 -3
  22. package/src/lib/delete-folder.test.ts +17 -0
  23. package/src/lib/delete-folder.ts +3 -1
  24. package/src/lib/mail-route.ts +10 -0
  25. package/src/lib/new-folder.test.ts +18 -0
  26. package/src/lib/new-folder.ts +4 -3
  27. package/src/lib/search-scope.test.ts +45 -32
  28. package/src/lib/search-scope.ts +14 -19
  29. package/src/lib/search-view.test.ts +1 -1
  30. package/src/lib/search-view.ts +2 -2
  31. package/src/routes/mail/$mailboxId.tsx +1 -2
  32. package/src/routes/mail/brief.tsx +1 -2
  33. package/src/routes/mail/flagged.tsx +1 -2
  34. package/src/routes/mail/outbox.tsx +1 -2
  35. package/src/routes/mail.tsx +7 -3
  36. package/src/routing/browsed-list.ts +60 -9
  37. package/src/routing/compose.ts +2 -27
  38. package/src/routing/index.ts +40 -1
  39. package/src/routing/nav-selection.ts +24 -0
  40. package/src/routing/open-thread.ts +89 -2
  41. package/src/routing/outbox-draft.ts +27 -1
  42. package/src/{hooks/useSearchField.render.test.ts → routing/search-field.render.test.ts} +1 -1
  43. package/src/{hooks/useSearchField.ts → routing/search-field.ts} +1 -1
  44. package/src/{hooks → routing}/search-mirror-convergence.render.test.ts +1 -1
  45. package/src/{hooks → routing}/search-mirror-detail.render.test.ts +1 -1
  46. package/src/{hooks/useSearchMirror.ts → routing/search-mirror.ts} +2 -1
  47. package/src/routing/search-navigation.ts +44 -0
  48. package/src/routing/sections.ts +53 -0
  49. /package/src/{lib → routing}/wizard-history.test.ts +0 -0
  50. /package/src/{lib → routing}/wizard-history.ts +0 -0
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import {
4
+ type BrowsedList,
4
5
  MAIL_BRIEF_ROUTE_ID,
5
6
  MAIL_FLAGGED_ROUTE_ID,
6
7
  MAIL_MAILBOX_ROUTE_ID,
@@ -15,18 +16,28 @@ import {
15
16
  semanticMailboxScope,
16
17
  } from "./search-scope";
17
18
 
18
- const matches = (routeId: string) => [{ routeId: "/mail" }, { routeId }];
19
+ const listFor: Record<string, BrowsedList["list"]> = {
20
+ [MAIL_BRIEF_ROUTE_ID]: "brief",
21
+ [MAIL_FLAGGED_ROUTE_ID]: "flagged",
22
+ [MAIL_OUTBOX_ROUTE_ID]: "outbox",
23
+ [MAIL_MAILBOX_ROUTE_ID]: "mailbox",
24
+ };
25
+
26
+ const browsing = (routeId: string): BrowsedList => ({
27
+ list: listFor[routeId],
28
+ mailboxId: undefined,
29
+ });
19
30
 
20
31
  describe("searchScopeForRoute", () => {
21
32
  it("gives the daily brief no scope — it is the global view", () => {
22
- assert.deepEqual(searchScopeForRoute(matches(MAIL_BRIEF_ROUTE_ID)), {
33
+ assert.deepEqual(searchScopeForRoute(browsing(MAIL_BRIEF_ROUTE_ID)), {
23
34
  kind: "global",
24
35
  });
25
36
  });
26
37
 
27
38
  it("scopes a mailbox route to its sidebar label", () => {
28
39
  assert.deepEqual(
29
- searchScopeForRoute(matches(MAIL_MAILBOX_ROUTE_ID), "Spam"),
40
+ searchScopeForRoute(browsing(MAIL_MAILBOX_ROUTE_ID), "Spam"),
30
41
  {
31
42
  kind: "scoped",
32
43
  chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:spam" },
@@ -39,23 +50,23 @@ describe("searchScopeForRoute", () => {
39
50
  // uuid is worse than no chip, but the field must not claim to search
40
51
  // everything either.
41
52
  assert.deepEqual(
42
- searchScopeForRoute(matches(MAIL_MAILBOX_ROUTE_ID), null),
53
+ searchScopeForRoute(browsing(MAIL_MAILBOX_ROUTE_ID), null),
43
54
  { kind: "pending" },
44
55
  );
45
56
  });
46
57
 
47
58
  it("scopes the starred view", () => {
48
- const scope = searchScopeForRoute(matches(MAIL_FLAGGED_ROUTE_ID));
59
+ const scope = searchScopeForRoute(browsing(MAIL_FLAGGED_ROUTE_ID));
49
60
  assert.equal(scope.kind === "scoped" && scope.chip.label, "is:starred");
50
61
  });
51
62
 
52
63
  it("scopes the outbox", () => {
53
- const scope = searchScopeForRoute(matches(MAIL_OUTBOX_ROUTE_ID));
64
+ const scope = searchScopeForRoute(browsing(MAIL_OUTBOX_ROUTE_ID));
54
65
  assert.equal(scope.kind === "scoped" && scope.chip.label, "in:outbox");
55
66
  });
56
67
 
57
68
  it("prefers the virtual views over a lingering mailbox name", () => {
58
- const scope = searchScopeForRoute(matches(MAIL_OUTBOX_ROUTE_ID), "Spam");
69
+ const scope = searchScopeForRoute(browsing(MAIL_OUTBOX_ROUTE_ID), "Spam");
59
70
  assert.equal(scope.kind === "scoped" && scope.chip.label, "in:outbox");
60
71
  });
61
72
  });
@@ -70,12 +81,12 @@ describe("isScopedRoute", () => {
70
81
  MAIL_OUTBOX_ROUTE_ID,
71
82
  ]) {
72
83
  it(`${routeId} carries a scope`, () => {
73
- assert.equal(isScopedRoute(matches(routeId)), true);
84
+ assert.equal(isScopedRoute(browsing(routeId)), true);
74
85
  });
75
86
  }
76
87
 
77
88
  it("the daily brief carries none", () => {
78
- assert.equal(isScopedRoute(matches(MAIL_BRIEF_ROUTE_ID)), false);
89
+ assert.equal(isScopedRoute(browsing(MAIL_BRIEF_ROUTE_ID)), false);
79
90
  });
80
91
 
81
92
  it("agrees with the chip on every route", () => {
@@ -85,9 +96,9 @@ describe("isScopedRoute", () => {
85
96
  MAIL_FLAGGED_ROUTE_ID,
86
97
  MAIL_OUTBOX_ROUTE_ID,
87
98
  ]) {
88
- const scope = searchScopeForRoute(matches(routeId), "Spam");
99
+ const scope = searchScopeForRoute(browsing(routeId), "Spam");
89
100
  assert.equal(
90
- isScopedRoute(matches(routeId)),
101
+ isScopedRoute(browsing(routeId)),
91
102
  scope.kind === "scoped",
92
103
  `${routeId} disagrees about whether it has a scope`,
93
104
  );
@@ -109,14 +120,14 @@ describe("scopeLabelForMailboxName", () => {
109
120
  // respects it. The semantic engine used to run unscoped inside a mailbox under
110
121
  // an "Everywhere" heading, contradicting the `in:` chip the same bar showed.
111
122
  describe("semanticMailboxScope — a chip binds every engine on the route", () => {
112
- const mailbox = (mailboxId: string) => [
113
- { routeId: "/mail" },
114
- { routeId: MAIL_MAILBOX_ROUTE_ID, params: { mailboxId } },
115
- ];
123
+ const mailbox = (mailboxId: string): BrowsedList => ({
124
+ list: "mailbox",
125
+ mailboxId,
126
+ });
116
127
 
117
128
  it("pins a mailbox route to its own mailbox", () => {
118
129
  assert.equal(
119
- semanticMailboxScope({ matches: mailbox("mbx-spam") }),
130
+ semanticMailboxScope({ browsed: mailbox("mbx-spam") }),
120
131
  "mbx-spam",
121
132
  );
122
133
  });
@@ -124,7 +135,7 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
124
135
  it("a mailbox route ignores a caller asking for somewhere else", () => {
125
136
  assert.equal(
126
137
  semanticMailboxScope({
127
- matches: mailbox("mbx-spam"),
138
+ browsed: mailbox("mbx-spam"),
128
139
  callerMailboxId: "mbx-archive",
129
140
  }),
130
141
  "mbx-spam",
@@ -134,7 +145,7 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
134
145
  it("a mailbox route ignores a typed in: term", () => {
135
146
  assert.equal(
136
147
  semanticMailboxScope({
137
- matches: mailbox("mbx-spam"),
148
+ browsed: mailbox("mbx-spam"),
138
149
  inTokenMailboxId: "mbx-archive",
139
150
  }),
140
151
  "mbx-spam",
@@ -142,7 +153,7 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
142
153
  });
143
154
 
144
155
  it("a mailbox route never widens to global", () => {
145
- const scope = semanticMailboxScope({ matches: mailbox("mbx-spam") });
156
+ const scope = semanticMailboxScope({ browsed: mailbox("mbx-spam") });
146
157
  assert.notEqual(scope, undefined, "a scoped route must carry a scope");
147
158
  });
148
159
 
@@ -153,7 +164,7 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
153
164
  it(`${routeId} never honours a typed in: term`, () => {
154
165
  assert.equal(
155
166
  semanticMailboxScope({
156
- matches: matches(routeId),
167
+ browsed: browsing(routeId),
157
168
  inTokenMailboxId: "mbx-archive",
158
169
  }),
159
170
  undefined,
@@ -163,7 +174,7 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
163
174
 
164
175
  it("the daily brief is global — no route scope, no chip", () => {
165
176
  assert.equal(
166
- semanticMailboxScope({ matches: matches(MAIL_BRIEF_ROUTE_ID) }),
177
+ semanticMailboxScope({ browsed: browsing(MAIL_BRIEF_ROUTE_ID) }),
167
178
  undefined,
168
179
  );
169
180
  });
@@ -171,7 +182,7 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
171
182
  it("the daily brief is the one route a typed in: narrows", () => {
172
183
  assert.equal(
173
184
  semanticMailboxScope({
174
- matches: matches(MAIL_BRIEF_ROUTE_ID),
185
+ browsed: browsing(MAIL_BRIEF_ROUTE_ID),
175
186
  inTokenMailboxId: "mbx-archive",
176
187
  }),
177
188
  "mbx-archive",
@@ -183,14 +194,14 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
183
194
  // time: on a scoped route the resolved scope is either the route's own
184
195
  // mailbox, or nothing that came from the typed query.
185
196
  const scoped = [
186
- { matches: mailbox("mbx-spam"), expected: "mbx-spam" },
187
- { matches: matches(MAIL_FLAGGED_ROUTE_ID), expected: undefined },
188
- { matches: matches(MAIL_OUTBOX_ROUTE_ID), expected: undefined },
197
+ { browsed: mailbox("mbx-spam"), expected: "mbx-spam" },
198
+ { browsed: browsing(MAIL_FLAGGED_ROUTE_ID), expected: undefined },
199
+ { browsed: browsing(MAIL_OUTBOX_ROUTE_ID), expected: undefined },
189
200
  ];
190
- for (const { matches: m, expected } of scoped) {
201
+ for (const { browsed: m, expected } of scoped) {
191
202
  assert.equal(isScopedRoute(m), true);
192
203
  assert.equal(
193
- semanticMailboxScope({ matches: m, inTokenMailboxId: "mbx-elsewhere" }),
204
+ semanticMailboxScope({ browsed: m, inTokenMailboxId: "mbx-elsewhere" }),
194
205
  expected,
195
206
  );
196
207
  }
@@ -200,7 +211,7 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
200
211
  describe("resultsScopeForRoute", () => {
201
212
  it("passes the global search through as global", () => {
202
213
  assert.deepEqual(
203
- resultsScopeForRoute(matches(MAIL_BRIEF_ROUTE_ID), { kind: "global" }),
214
+ resultsScopeForRoute(browsing(MAIL_BRIEF_ROUTE_ID), { kind: "global" }),
204
215
  {
205
216
  kind: "global",
206
217
  },
@@ -209,7 +220,9 @@ describe("resultsScopeForRoute", () => {
209
220
 
210
221
  it("treats a mailbox whose name has not loaded as a folder, not global", () => {
211
222
  assert.deepEqual(
212
- resultsScopeForRoute(matches(MAIL_MAILBOX_ROUTE_ID), { kind: "pending" }),
223
+ resultsScopeForRoute(browsing(MAIL_MAILBOX_ROUTE_ID), {
224
+ kind: "pending",
225
+ }),
213
226
  {
214
227
  kind: "folder",
215
228
  },
@@ -219,7 +232,7 @@ describe("resultsScopeForRoute", () => {
219
232
  it("carries the mailbox's role so a Spam search shows its rows", () => {
220
233
  assert.deepEqual(
221
234
  resultsScopeForRoute(
222
- matches(MAIL_MAILBOX_ROUTE_ID),
235
+ browsing(MAIL_MAILBOX_ROUTE_ID),
223
236
  {
224
237
  kind: "scoped",
225
238
  chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:spam" },
@@ -232,7 +245,7 @@ describe("resultsScopeForRoute", () => {
232
245
 
233
246
  it("calls starred a collection, so its spam is not dropped", () => {
234
247
  assert.deepEqual(
235
- resultsScopeForRoute(matches(MAIL_FLAGGED_ROUTE_ID), {
248
+ resultsScopeForRoute(browsing(MAIL_FLAGGED_ROUTE_ID), {
236
249
  kind: "scoped",
237
250
  chip: { id: SEARCH_SCOPE_CHIP_ID, label: "is:starred" },
238
251
  }),
@@ -242,7 +255,7 @@ describe("resultsScopeForRoute", () => {
242
255
 
243
256
  it("keeps the outbox a folder — it is one queue, and never holds spam", () => {
244
257
  assert.deepEqual(
245
- resultsScopeForRoute(matches(MAIL_OUTBOX_ROUTE_ID), {
258
+ resultsScopeForRoute(browsing(MAIL_OUTBOX_ROUTE_ID), {
246
259
  kind: "scoped",
247
260
  chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:outbox" },
248
261
  }),
@@ -22,7 +22,7 @@
22
22
  * Pure functions only. `useSearchScope` binds these to the router.
23
23
  */
24
24
  import type { FolderRole, SearchScope as ResultsScope } from "@remit/ui";
25
- import { type MailRouteMatch, mailListRoute } from "./mail-route";
25
+ import type { BrowsedList } from "./mail-route";
26
26
 
27
27
  /**
28
28
  * Chip id of the scope chip. The top bar owns exactly one, so a fixed id is
@@ -64,19 +64,15 @@ export function scopeLabelForMailboxName(name: string): string {
64
64
  * These carry a scope; the daily brief (and any route not recognized here) does
65
65
  * not.
66
66
  */
67
- export function isScopedRoute(matches: readonly MailRouteMatch[]): boolean {
68
- const route = mailListRoute(matches);
69
- return route !== undefined && route.list !== "brief";
67
+ export function isScopedRoute(browsed: BrowsedList): boolean {
68
+ return browsed.list !== undefined && browsed.list !== "brief";
70
69
  }
71
70
 
72
71
  /**
73
72
  * The mailbox id carried by a mailbox route, or `undefined` on any other route.
74
73
  */
75
- export function routeMailboxId(
76
- matches: readonly MailRouteMatch[],
77
- ): string | undefined {
78
- const route = mailListRoute(matches);
79
- return route?.list === "mailbox" ? route.mailboxId : undefined;
74
+ export function routeMailboxId(browsed: BrowsedList): string | undefined {
75
+ return browsed.list === "mailbox" ? browsed.mailboxId : undefined;
80
76
  }
81
77
 
82
78
  /**
@@ -98,13 +94,13 @@ export function routeMailboxId(
98
94
  * it, which is the one place `useSearchTokenContext` resolves that term.
99
95
  */
100
96
  export function semanticMailboxScope(args: {
101
- matches: readonly MailRouteMatch[];
97
+ browsed: BrowsedList;
102
98
  callerMailboxId?: string;
103
99
  inTokenMailboxId?: string;
104
100
  }): string | undefined {
105
- const fromRoute = routeMailboxId(args.matches);
101
+ const fromRoute = routeMailboxId(args.browsed);
106
102
  if (fromRoute) return fromRoute;
107
- if (isScopedRoute(args.matches)) return args.callerMailboxId;
103
+ if (isScopedRoute(args.browsed)) return args.callerMailboxId;
108
104
  return args.callerMailboxId ?? args.inTokenMailboxId;
109
105
  }
110
106
 
@@ -117,23 +113,22 @@ export function semanticMailboxScope(args: {
117
113
  * either, because the list underneath is already narrowed.
118
114
  */
119
115
  export function searchScopeForRoute(
120
- matches: readonly MailRouteMatch[],
116
+ browsed: BrowsedList,
121
117
  mailboxName?: string | null,
122
118
  ): SearchScopeState {
123
- const route = mailListRoute(matches);
124
- if (route?.list === "flagged") {
119
+ if (browsed.list === "flagged") {
125
120
  return {
126
121
  kind: "scoped",
127
122
  chip: { id: SEARCH_SCOPE_CHIP_ID, label: "is:starred" },
128
123
  };
129
124
  }
130
- if (route?.list === "outbox") {
125
+ if (browsed.list === "outbox") {
131
126
  return {
132
127
  kind: "scoped",
133
128
  chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:outbox" },
134
129
  };
135
130
  }
136
- if (route?.list === "mailbox") {
131
+ if (browsed.list === "mailbox") {
137
132
  if (!mailboxName) return { kind: "pending" };
138
133
  return {
139
134
  kind: "scoped",
@@ -164,11 +159,11 @@ export function searchScopeForRoute(
164
159
  * folder nobody appointed carries no role.
165
160
  */
166
161
  export function resultsScopeForRoute(
167
- matches: readonly MailRouteMatch[],
162
+ browsed: BrowsedList,
168
163
  state: SearchScopeState,
169
164
  role?: FolderRole,
170
165
  ): ResultsScope {
171
166
  if (state.kind === "global") return { kind: "global" };
172
- if (mailListRoute(matches)?.list === "flagged") return { kind: "collection" };
167
+ if (browsed.list === "flagged") return { kind: "collection" };
173
168
  return { kind: "folder", ...(role ? { role } : {}) };
174
169
  }
@@ -165,7 +165,7 @@ describe("shouldMirrorQuery", () => {
165
165
  interface Shell {
166
166
  /** The view the address names, which is what a render reads. */
167
167
  viewKey: string;
168
- /** The view the text in the field was typed in (`hooks/useSearchField.ts`). */
168
+ /** The view the text in the field was typed in (`routing/search-field.ts`). */
169
169
  typedInView: string;
170
170
  /** The path of the list whose mirror is running. */
171
171
  listPath: string;
@@ -13,7 +13,7 @@
13
13
  * "Every view change" is the move the reader made, not every render that
14
14
  * notices one. Text typed once the address already names the destination was
15
15
  * typed in the destination, so it is that view's query and survives
16
- * (`hooks/useSearchField.ts`).
16
+ * (`routing/search-field.ts`).
17
17
  */
18
18
  import { locationIsOnList } from "./mail-route";
19
19
 
@@ -82,7 +82,7 @@ export interface MirrorDecision {
82
82
  * the mirror fight a query arriving by URL — that arrives mid-debounce, where
83
83
  * the first rule already refuses, and by the time the debounce settles the
84
84
  * field has been re-seeded from the address it landed on
85
- * (`hooks/useSearchField.ts`).
85
+ * (`routing/search-field.ts`).
86
86
  */
87
87
  export function shouldMirrorQuery({
88
88
  searchInput,
@@ -11,9 +11,8 @@
11
11
  import { createFileRoute, Outlet } from "@tanstack/react-router";
12
12
  import { MailShell } from "@/components/layout/MailShell";
13
13
  import { MailboxPane } from "@/components/mail/MailboxPane";
14
- import { useSearchMirror } from "@/hooks/useSearchMirror";
15
14
  import { mailboxSearchSchema } from "@/lib/mail-search";
16
- import { useOpenThreadPath } from "@/routing";
15
+ import { useOpenThreadPath, useSearchMirror } from "@/routing";
17
16
 
18
17
  function MailboxLayout() {
19
18
  const { mailboxId } = Route.useParams();
@@ -15,9 +15,8 @@ import {
15
15
  import { MailShell } from "@/components/layout/MailShell";
16
16
  import { BriefPane } from "@/components/mail/BriefPane";
17
17
  import { ErrorState } from "@/components/ui/ErrorState";
18
- import { useSearchMirror } from "@/hooks/useSearchMirror";
19
18
  import { briefSearchSchema } from "@/lib/mail-search";
20
- import { useOpenThreadPath } from "@/routing";
19
+ import { useOpenThreadPath, useSearchMirror } from "@/routing";
21
20
 
22
21
  const BriefError = ({ error, reset }: ErrorComponentProps) => (
23
22
  <div className="flex h-full items-center justify-center bg-canvas p-4">
@@ -14,9 +14,8 @@ import {
14
14
  import { MailShell } from "@/components/layout/MailShell";
15
15
  import { FlaggedPane } from "@/components/mail/FlaggedPane";
16
16
  import { ErrorState } from "@/components/ui/ErrorState";
17
- import { useSearchMirror } from "@/hooks/useSearchMirror";
18
17
  import { flaggedSearchSchema } from "@/lib/mail-search";
19
- import { useOpenThreadPath } from "@/routing";
18
+ import { useOpenThreadPath, useSearchMirror } from "@/routing";
20
19
 
21
20
  const FlaggedError = ({ error, reset }: ErrorComponentProps) => (
22
21
  <div className="flex h-full items-center justify-center bg-canvas p-4">
@@ -8,9 +8,8 @@
8
8
  import { createFileRoute, Outlet } from "@tanstack/react-router";
9
9
  import { MailShell } from "@/components/layout/MailShell";
10
10
  import { OutboxPane } from "@/components/mail/OutboxPane";
11
- import { useSearchMirror } from "@/hooks/useSearchMirror";
12
11
  import { outboxSearchSchema } from "@/lib/mail-search";
13
- import { useOutboxDraftId } from "@/routing";
12
+ import { useOutboxDraftId, useSearchMirror } from "@/routing";
14
13
 
15
14
  function OutboxLayout() {
16
15
  const outboxMessageId = useOutboxDraftId();
@@ -23,14 +23,18 @@ import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
23
23
  import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
24
24
  import { useRailPanels } from "@/hooks/useRailPanels";
25
25
  import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
26
- import { useSearchField } from "@/hooks/useSearchField";
27
26
  import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
28
27
  import { MailContext } from "@/lib/mail-context";
29
28
  import { MailFreshnessProvider } from "@/lib/mail-freshness";
30
29
  import { mailListRoute } from "@/lib/mail-route";
31
30
  import { buildAccountNameIndex } from "@/lib/search-token-index";
32
- import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
33
- import { useIsComposing, useOpenCompose } from "@/routing";
31
+ import {
32
+ useIsComposing,
33
+ useOpenCompose,
34
+ useSearchField,
35
+ wizardEntryValue,
36
+ wizardStepValue,
37
+ } from "@/routing";
34
38
  import "@/lib/client";
35
39
 
36
40
  // `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
@@ -1,17 +1,24 @@
1
- import { useParams, useRouterState } from "@tanstack/react-router";
2
- import { type MailListRoute, mailListRoute } from "@/lib/mail-route";
1
+ import {
2
+ useLocation,
3
+ useNavigate,
4
+ useParams,
5
+ useRouterState,
6
+ } from "@tanstack/react-router";
7
+ import { useCallback } from "react";
8
+ import {
9
+ type BrowsedList,
10
+ locationOpensDetail,
11
+ mailListRoute,
12
+ } from "@/lib/mail-route";
13
+ import { useRetainOpenPanels } from "./fragment";
3
14
 
4
- export interface BrowsedList {
5
- list: MailListRoute["list"] | undefined;
6
- mailboxId: string | undefined;
7
- }
15
+ export type { BrowsedList };
8
16
 
9
17
  /**
10
18
  * The list the address is browsing, as the two values a navigation to it needs.
11
19
  *
12
- * Both are primitives, so a caller holding them in a dependency array settles.
13
- * Every surface a list can open — compose, a reply — reads the same answer, so
14
- * the four lists cannot disagree about which one is underneath.
20
+ * Every surface a list can open — compose, a reply, a conversation — reads the
21
+ * same answer, so the four lists cannot disagree about which one is underneath.
15
22
  */
16
23
  export function useBrowsedList(): BrowsedList {
17
24
  const list = useRouterState({
@@ -20,3 +27,47 @@ export function useBrowsedList(): BrowsedList {
20
27
  const mailbox = useParams({ from: "/mail/$mailboxId", shouldThrow: false });
21
28
  return { list, mailboxId: mailbox?.mailboxId };
22
29
  }
30
+
31
+ /**
32
+ * Whether the address names something the list has open below it — a
33
+ * conversation, a draft, the compose surface.
34
+ *
35
+ * Answers off the pathname, which is what says where the reader is going while
36
+ * the destination mounts; see `locationOpensDetail`.
37
+ */
38
+ export function useOpensDetail(): boolean {
39
+ const { pathname } = useLocation();
40
+ return locationOpensDetail(pathname);
41
+ }
42
+
43
+ /**
44
+ * Back up to the list, leaving whatever it had open.
45
+ *
46
+ * A push, like the open was: leaving is a move the reader makes, and Back is how
47
+ * they undo it. Every branch lands somewhere real — the brief is the fallback,
48
+ * because a list this cannot name still has to put them on one rather than
49
+ * strand them inside a surface they just dismissed.
50
+ */
51
+ export function useNavigateToBrowsedList(): () => void {
52
+ const navigate = useNavigate();
53
+ const retainPanels = useRetainOpenPanels();
54
+ const { list, mailboxId } = useBrowsedList();
55
+
56
+ return useCallback(() => {
57
+ const search = (prev: Record<string, unknown>) => prev;
58
+ const hash = retainPanels;
59
+ if (list === "flagged") {
60
+ navigate({ to: "/mail/flagged", search, hash });
61
+ return;
62
+ }
63
+ if (list === "outbox") {
64
+ navigate({ to: "/mail/outbox", search, hash });
65
+ return;
66
+ }
67
+ if (list === "mailbox" && mailboxId) {
68
+ navigate({ to: "/mail/$mailboxId", params: { mailboxId }, search, hash });
69
+ return;
70
+ }
71
+ navigate({ to: "/mail/brief", search, hash });
72
+ }, [navigate, retainPanels, list, mailboxId]);
73
+ }
@@ -1,6 +1,6 @@
1
1
  import { useNavigate, useParams } from "@tanstack/react-router";
2
2
  import { useCallback } from "react";
3
- import { useBrowsedList } from "./browsed-list";
3
+ import { useBrowsedList, useNavigateToBrowsedList } from "./browsed-list";
4
4
  import { useRetainOpenPanels } from "./fragment";
5
5
 
6
6
  /**
@@ -176,30 +176,5 @@ export function useAdoptComposeDraft(): (outboxMessageId: string) => void {
176
176
  * return and strand them inside a surface they just dismissed.
177
177
  */
178
178
  export function useCloseCompose(): () => void {
179
- const navigate = useNavigate();
180
- const retainPanels = useRetainOpenPanels();
181
- const { list, mailboxId } = useBrowsedList();
182
-
183
- return useCallback(() => {
184
- const search = (prev: Record<string, unknown>) => prev;
185
- const hash = retainPanels;
186
- if (list === "flagged") {
187
- navigate({ to: "/mail/flagged", search, hash });
188
- return;
189
- }
190
- if (list === "outbox") {
191
- navigate({ to: "/mail/outbox", search, hash });
192
- return;
193
- }
194
- if (list === "mailbox" && mailboxId) {
195
- navigate({
196
- to: "/mail/$mailboxId",
197
- params: { mailboxId },
198
- search,
199
- hash,
200
- });
201
- return;
202
- }
203
- navigate({ to: "/mail/brief", search, hash });
204
- }, [navigate, retainPanels, list, mailboxId]);
179
+ return useNavigateToBrowsedList();
205
180
  }
@@ -1,3 +1,8 @@
1
+ export {
2
+ type BrowsedList,
3
+ useBrowsedList,
4
+ useOpensDetail,
5
+ } from "./browsed-list";
1
6
  export {
2
7
  useAdoptComposeDraft,
3
8
  useCloseCompose,
@@ -20,12 +25,20 @@ export {
20
25
  useSetOpenPanels,
21
26
  } from "./fragment";
22
27
  export { NavLink, type NavLinkProps } from "./nav-link";
28
+ export { useSelectedNavId } from "./nav-selection";
23
29
  export {
30
+ type OpenThreadOptions,
24
31
  type OpenThreadPath,
25
32
  type OpenThreadTarget,
33
+ useCloseThread,
34
+ useOpenThread,
26
35
  useOpenThreadPath,
27
36
  } from "./open-thread";
28
- export { useOutboxDraftId } from "./outbox-draft";
37
+ export {
38
+ useCloseOutboxDraft,
39
+ useOpenOutboxDraft,
40
+ useOutboxDraftId,
41
+ } from "./outbox-draft";
29
42
  export {
30
43
  type ReplyAddress,
31
44
  type ReplyMode,
@@ -39,3 +52,29 @@ export {
39
52
  useOpenReply,
40
53
  useReplySurface,
41
54
  } from "./reply";
55
+ export { type SearchField, useSearchField } from "./search-field";
56
+ export { type SearchMirrorTarget, useSearchMirror } from "./search-mirror";
57
+ export {
58
+ useScopeSearchToMailbox,
59
+ useSearchEverything,
60
+ } from "./search-navigation";
61
+ export {
62
+ type AppSection,
63
+ useConfigureAccountSmtp,
64
+ useGoToSection,
65
+ } from "./sections";
66
+ export {
67
+ ownedHistoryEntries,
68
+ type SelectionWizardControl,
69
+ useOpenWizard,
70
+ useSelectionWizard,
71
+ useWizardEntryValue,
72
+ useWizardStep,
73
+ useWizardStepValue,
74
+ type WizardEntry,
75
+ type WizardStepNavigation,
76
+ wizardEntryFromParam,
77
+ wizardEntryValue,
78
+ wizardStepFromParam,
79
+ wizardStepValue,
80
+ } from "./wizard-history";
@@ -0,0 +1,24 @@
1
+ import { useLocation, useParams } from "@tanstack/react-router";
2
+ import { locationIsOnList } from "@/lib/mail-route";
3
+
4
+ /**
5
+ * The nav entry the address selects, or `""` where none does.
6
+ *
7
+ * - `/settings/*` → "settings"
8
+ * - `/mail/outbox`, `/mail/flagged`, `/mail/brief` → that list
9
+ * - `/mail/$mailboxId` → the mailbox id, which is what the sidebar keys on
10
+ *
11
+ * Read off the pathname rather than the matches: the router commits the new
12
+ * location before it swaps them, so the entry the reader just tapped highlights
13
+ * on the tap instead of when its list finishes loading.
14
+ */
15
+ export function useSelectedNavId(): string {
16
+ const { pathname } = useLocation();
17
+ const mailbox = useParams({ from: "/mail/$mailboxId", shouldThrow: false });
18
+
19
+ if (locationIsOnList(pathname, "/settings")) return "settings";
20
+ if (locationIsOnList(pathname, "/mail/outbox")) return "outbox";
21
+ if (locationIsOnList(pathname, "/mail/flagged")) return "flagged";
22
+ if (locationIsOnList(pathname, "/mail/brief")) return "brief";
23
+ return mailbox?.mailboxId ?? "";
24
+ }