@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.143",
3
+ "version": "0.0.145",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -1,4 +1,5 @@
1
1
  import {
2
+ configOperationsGetConfigOptions,
2
3
  outboxDetailOperationsGetOutboxMessageOptions,
3
4
  outboxOperationsListOutboxMessagesQueryKey,
4
5
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
@@ -7,6 +8,7 @@ import type {
7
8
  RemitImapDescribeMessageResponse,
8
9
  } from "@remit/api-http-client/types.gen.ts";
9
10
  import { useQuery, useQueryClient } from "@tanstack/react-query";
11
+ import { useLocation, useNavigate } from "@tanstack/react-router";
10
12
  import {
11
13
  createContext,
12
14
  useCallback,
@@ -16,6 +18,9 @@ import {
16
18
  useRef,
17
19
  useState,
18
20
  } from "react";
21
+ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
22
+ import { useComposeTarget } from "@/hooks/useComposeTargetMailbox";
23
+ import { hostsComposeSurface } from "@/lib/compose-routes";
19
24
  export type ComposeMode = "reply" | "reply_all" | "forward" | "new";
20
25
 
21
26
  export interface ComposeState {
@@ -59,6 +64,19 @@ export const ComposeProvider = ({
59
64
  >();
60
65
  const startedAtRef = useRef(0);
61
66
  const queryClient = useQueryClient();
67
+ const navigate = useNavigate();
68
+ const location = useLocation();
69
+ const { pushError } = useErrorBanners();
70
+ // Compose is a mail action, so its target only has to be known under `/mail`.
71
+ // Resolving it from the root otherwise costs `/settings` and `/onboarding` a
72
+ // config fetch plus a folder list per account for a button they never show.
73
+ const underMail = location.pathname.startsWith("/mail");
74
+ const { data: config } = useQuery({
75
+ ...configOperationsGetConfigOptions(),
76
+ staleTime: Infinity,
77
+ enabled: underMail,
78
+ });
79
+ const target = useComposeTarget(underMail ? (config?.accounts ?? []) : []);
62
80
 
63
81
  const { data: polledMessage } = useQuery({
64
82
  ...outboxDetailOperationsGetOutboxMessageOptions({
@@ -91,14 +109,72 @@ export const ComposeProvider = ({
91
109
  }
92
110
  }, [polledMessage, pollingMessageId, queryClient]);
93
111
 
94
- const openCompose = useCallback((params: Omit<ComposeState, "isOpen">) => {
95
- setState({ ...params, isOpen: true });
96
- }, []);
112
+ // Opening compose also puts the surface on screen: only a mailbox route
113
+ // mounts `FullCompose`, and only with no thread in the pane it takes over.
114
+ const openCompose = useCallback(
115
+ (params: Omit<ComposeState, "isOpen">) => {
116
+ const search = location.search as Record<string, unknown>;
117
+ const showsThread = Boolean(
118
+ search.selectedMessageId ?? search.selectedThreadId,
119
+ );
120
+ if (!hostsComposeSurface(location.pathname)) {
121
+ if (target.status === "loading") {
122
+ pushError({
123
+ severity: "info",
124
+ title: "Not ready to write yet",
125
+ detail:
126
+ "Your folders are still loading. Try Compose again in a moment.",
127
+ });
128
+ return;
129
+ }
130
+ if (target.status === "none") {
131
+ pushError({
132
+ title: "Nowhere to write from",
133
+ detail:
134
+ "No account has a folder to open the message in. Check the account's folders in Settings, then try again.",
135
+ });
136
+ return;
137
+ }
138
+ setState({ ...params, isOpen: true });
139
+ navigate({
140
+ to: "/mail/$mailboxId",
141
+ params: { mailboxId: target.mailboxId },
142
+ });
143
+ return;
144
+ }
145
+ setState({ ...params, isOpen: true });
146
+ if (!showsThread) return;
147
+ // A push, so Back reopens the message.
148
+ navigate({
149
+ to: ".",
150
+ search: (prev: Record<string, unknown>) => ({
151
+ ...prev,
152
+ selectedMessageId: undefined,
153
+ selectedThreadId: undefined,
154
+ }),
155
+ });
156
+ },
157
+ [navigate, location.pathname, location.search, target, pushError],
158
+ );
97
159
 
98
160
  const closeCompose = useCallback(() => {
99
161
  setState(INITIAL_STATE);
100
162
  }, []);
101
163
 
164
+ // Leaving the routes that mount the surface closes it, so nothing is left
165
+ // open behind a view that cannot show it — which is how it used to reappear
166
+ // unannounced on the next mailbox. Only a *change* of route counts: opening
167
+ // compose navigates, and on the frame before that navigation lands the
168
+ // pathname is still the one compose was started from.
169
+ const previousPathnameRef = useRef(location.pathname);
170
+ useEffect(() => {
171
+ const previous = previousPathnameRef.current;
172
+ previousPathnameRef.current = location.pathname;
173
+ if (location.pathname === previous) return;
174
+ if (hostsComposeSurface(location.pathname)) return;
175
+ setState((current) => (current.isOpen ? INITIAL_STATE : current));
176
+ }, [location.pathname]);
177
+
102
178
  const setOutboxMessageId = useCallback((id: string) => {
103
179
  setState((prev) => ({ ...prev, outboxMessageId: id }));
104
180
  }, []);
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Issue #703: compose state opened with nothing mounting the surface, so the
3
+ * button looked dead and the window turned up on the next navigation.
4
+ */
5
+
6
+ import assert from "node:assert/strict";
7
+ import { afterEach, describe, it } from "node:test";
8
+ import {
9
+ type AnyRouter,
10
+ createMemoryHistory,
11
+ createRootRoute,
12
+ createRoute,
13
+ createRouter,
14
+ Outlet,
15
+ RouterProvider,
16
+ } from "@tanstack/react-router";
17
+ import { createElement, useEffect, useState } from "react";
18
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
19
+ import { type HttpMock, mockFetch } from "../../test-support/http";
20
+ import { ComposeProvider, useCompose } from "./ComposeProvider";
21
+
22
+ let harness: DomHarness | undefined;
23
+ let http: HttpMock | undefined;
24
+ let releaseMailboxes: (() => void) | undefined;
25
+
26
+ afterEach(() => {
27
+ releaseMailboxes?.();
28
+ releaseMailboxes = undefined;
29
+ harness?.close();
30
+ harness = undefined;
31
+ http?.restore();
32
+ http = undefined;
33
+ });
34
+
35
+ // The router reads `self` at construction; the shared jsdom globals stop at
36
+ // `window`.
37
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
38
+
39
+ const ACCOUNT_ID = "acc-1";
40
+ const INBOX_ID = "mbx-inbox";
41
+
42
+ const ComposeProbe = () => {
43
+ const { state, openCompose } = useCompose();
44
+ return createElement(
45
+ "button",
46
+ {
47
+ type: "button",
48
+ "data-open": String(state.isOpen),
49
+ onClick: () => openCompose({ mode: "new" }),
50
+ },
51
+ "Compose",
52
+ );
53
+ };
54
+
55
+ // The provider sits at the root the way `__root.tsx` mounts it, so navigation
56
+ // reaches it the way it does in the app.
57
+ const RootLayout = () =>
58
+ createElement(
59
+ ComposeProvider,
60
+ null,
61
+ createElement(ComposeProbe),
62
+ createElement(Outlet),
63
+ );
64
+
65
+ const routerAt = (href: string, layout = RootLayout): AnyRouter => {
66
+ const rootRoute = createRootRoute({ component: layout });
67
+ const routeTree = rootRoute.addChildren([
68
+ createRoute({
69
+ getParentRoute: () => rootRoute,
70
+ path: "/mail/outbox",
71
+ validateSearch: (search: Record<string, unknown>) => search,
72
+ component: () => null,
73
+ }),
74
+ createRoute({
75
+ getParentRoute: () => rootRoute,
76
+ path: "/mail/$mailboxId",
77
+ validateSearch: (search: Record<string, unknown>) => search,
78
+ component: () => null,
79
+ }),
80
+ createRoute({
81
+ getParentRoute: () => rootRoute,
82
+ path: "/settings",
83
+ component: () => null,
84
+ }),
85
+ ]);
86
+ return createRouter({
87
+ routeTree,
88
+ history: createMemoryHistory({ initialEntries: [href] }),
89
+ }) as unknown as AnyRouter;
90
+ };
91
+
92
+ interface MountOptions {
93
+ /** Hold the folder list open, so no target has resolved at press time. */
94
+ holdMailboxes?: boolean;
95
+ /** Answer with an account that has no folders at all. */
96
+ noMailboxes?: boolean;
97
+ }
98
+
99
+ const mount = async (
100
+ router: AnyRouter,
101
+ options: MountOptions = {},
102
+ ): Promise<DomHarness> => {
103
+ const held = options.holdMailboxes
104
+ ? new Promise<void>((resolve) => {
105
+ releaseMailboxes = resolve;
106
+ })
107
+ : undefined;
108
+
109
+ http = mockFetch(async (call) => {
110
+ if (call.path.endsWith("/config")) {
111
+ return {
112
+ accounts: [
113
+ {
114
+ accountId: ACCOUNT_ID,
115
+ email: "me@example.com",
116
+ folderAppointments: [{ role: "Inbox", mailboxId: INBOX_ID }],
117
+ },
118
+ ],
119
+ };
120
+ }
121
+ if (call.path.endsWith("/mailboxes")) {
122
+ if (held) await held;
123
+ if (options.noMailboxes) return { items: [] };
124
+ return { items: [{ mailboxId: INBOX_ID, fullPath: "INBOX" }] };
125
+ }
126
+ return {};
127
+ });
128
+
129
+ const created = createDomHarness();
130
+ harness = created;
131
+ // Resolve the first match before mounting: `RouterProvider` renders its
132
+ // pending state until the router has loaded, and nothing here waits for it.
133
+ await router.load();
134
+ created.renderApp(createElement(RouterProvider, { router }));
135
+ await created.flush();
136
+ await created.wait(20);
137
+ return created;
138
+ };
139
+
140
+ const press = async (mounted: DomHarness): Promise<HTMLElement> => {
141
+ const button = mounted.byText("button", "Compose");
142
+ mounted.click(button);
143
+ await mounted.flush();
144
+ await mounted.wait(20);
145
+ return button;
146
+ };
147
+
148
+ describe("opening compose over an open message (#703)", () => {
149
+ it("drops the selected message so the pane can render the surface", async () => {
150
+ const router = routerAt(
151
+ `/mail/${INBOX_ID}?selectedMessageId=msg-1&selectedThreadId=th-1`,
152
+ );
153
+ const mounted = await mount(router);
154
+
155
+ const button = mounted.byText("button", "Compose");
156
+ assert.equal(button.getAttribute("data-open"), "false");
157
+
158
+ await press(mounted);
159
+
160
+ assert.equal(button.getAttribute("data-open"), "true");
161
+ assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
162
+ const search = router.history.location.search;
163
+ assert.equal(search.includes("selectedMessageId"), false);
164
+ assert.equal(search.includes("selectedThreadId"), false);
165
+ });
166
+
167
+ it("keeps the rest of the query, so the search the user typed survives", async () => {
168
+ const router = routerAt(
169
+ `/mail/${INBOX_ID}?q=invoice&selectedMessageId=msg-1`,
170
+ );
171
+ const mounted = await mount(router);
172
+
173
+ await press(mounted);
174
+
175
+ const search = router.history.location.search;
176
+ assert.equal(search.includes("selectedMessageId"), false);
177
+ assert.match(search, /q=invoice/);
178
+ });
179
+
180
+ it("leaves the message one Back away rather than erasing it", async () => {
181
+ const router = routerAt(`/mail/${INBOX_ID}?selectedMessageId=msg-1`);
182
+ const mounted = await mount(router);
183
+
184
+ await press(mounted);
185
+ router.history.back();
186
+ await mounted.flush();
187
+
188
+ assert.match(router.history.location.search, /selectedMessageId=msg-1/);
189
+ });
190
+
191
+ it("adds no history entry when the pane had nothing open", async () => {
192
+ const router = routerAt(`/mail/${INBOX_ID}`);
193
+ const mounted = await mount(router);
194
+ const entries = router.history.length;
195
+
196
+ await press(mounted);
197
+
198
+ assert.equal(router.history.length, entries);
199
+ });
200
+
201
+ it("carries a compose started off the outbox to a route that mounts it", async () => {
202
+ const router = routerAt("/mail/outbox");
203
+ const mounted = await mount(router);
204
+
205
+ const button = await press(mounted);
206
+
207
+ assert.equal(button.getAttribute("data-open"), "true");
208
+ assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
209
+ });
210
+ });
211
+
212
+ // Walking off the routes that mount the surface closes it. That rule reads the
213
+ // live location, which this harness does not advance — `RouterProvider` here
214
+ // serves its first match and stays there — so it is pinned in the e2e suite
215
+ // (`compose-over-open-message.spec.ts`) instead. What is checked here is the
216
+ // half that would break it: opening compose navigates, and the surface has to
217
+ // survive its own navigation.
218
+ describe("compose survives the navigation that opens it (#703)", () => {
219
+ it("stays open when the press carried the user to another route", async () => {
220
+ const router = routerAt("/mail/outbox");
221
+ const mounted = await mount(router);
222
+
223
+ const button = await press(mounted);
224
+
225
+ assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
226
+ assert.equal(button.getAttribute("data-open"), "true");
227
+ });
228
+ });
229
+
230
+ describe("the open call is stable", () => {
231
+ // A fresh `openCompose` on every render is a loop, not a nuisance: callers
232
+ // hold it in dependency arrays, and one of them opens compose from an effect.
233
+ it("hands back the same function across a re-render", async () => {
234
+ const identities = new Set<unknown>();
235
+ const CountingProbe = () => {
236
+ const { openCompose } = useCompose();
237
+ const [bumped, setBumped] = useState(0);
238
+ useEffect(() => {
239
+ identities.add(openCompose);
240
+ }, [openCompose]);
241
+ return createElement(
242
+ "button",
243
+ { type: "button", onClick: () => setBumped(bumped + 1) },
244
+ `Bump ${bumped}`,
245
+ );
246
+ };
247
+ const CountingLayout = () =>
248
+ createElement(
249
+ ComposeProvider,
250
+ null,
251
+ createElement(CountingProbe),
252
+ createElement(Outlet),
253
+ );
254
+
255
+ const mounted = await mount(routerAt(`/mail/${INBOX_ID}`, CountingLayout));
256
+ // The target legitimately settles as config and the folder list land. What
257
+ // must not happen is another identity after that, on a render that has
258
+ // nothing to do with compose.
259
+ const settled = identities.size;
260
+
261
+ mounted.click(mounted.byText("button", "Bump"));
262
+ await mounted.flush();
263
+ await mounted.wait(20);
264
+
265
+ assert.equal(mounted.byText("button", "Bump").textContent, "Bump 1");
266
+ assert.equal(identities.size, settled);
267
+ });
268
+ });
269
+
270
+ describe("a compose with nowhere to land says so", () => {
271
+ it("opens nothing and reports it while the folder list is in flight", async () => {
272
+ const router = routerAt("/mail/outbox");
273
+ const mounted = await mount(router, { holdMailboxes: true });
274
+
275
+ const button = await press(mounted);
276
+
277
+ assert.equal(button.getAttribute("data-open"), "false");
278
+ assert.equal(router.history.location.pathname, "/mail/outbox");
279
+ assert.match(mounted.text(), /Not ready to write yet/);
280
+ });
281
+
282
+ it("names the fix when no account has a folder", async () => {
283
+ const router = routerAt("/mail/outbox");
284
+ const mounted = await mount(router, { noMailboxes: true });
285
+
286
+ const button = await press(mounted);
287
+
288
+ assert.equal(button.getAttribute("data-open"), "false");
289
+ assert.match(mounted.text(), /Nowhere to write from/);
290
+ assert.match(mounted.text(), /Settings/);
291
+ });
292
+
293
+ it("asks the API for nothing off the mail routes", async () => {
294
+ const router = routerAt("/settings");
295
+ await mount(router);
296
+
297
+ assert.deepEqual(
298
+ (http?.calls ?? []).map((call) => call.path),
299
+ [],
300
+ );
301
+ });
302
+ });
@@ -29,6 +29,14 @@ import type {
29
29
  RemitImapAccountResponse,
30
30
  RemitImapDescribeMessageResponse,
31
31
  } from "@remit/api-http-client/types.gen.ts";
32
+ import {
33
+ type AnyRouter,
34
+ createMemoryHistory,
35
+ createRootRoute,
36
+ createRoute,
37
+ createRouter,
38
+ RouterContextProvider,
39
+ } from "@tanstack/react-router";
32
40
  import { createElement, useEffect } from "react";
33
41
  import { createDomHarness, type DomHarness } from "../../test-support/dom";
34
42
  import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
@@ -128,6 +136,23 @@ interface MountOptions {
128
136
  failPatch?: boolean;
129
137
  }
130
138
 
139
+ // Opening compose closes whatever the reading pane had open, so the provider
140
+ // navigates (#703) and needs a router under it.
141
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
142
+
143
+ const rootRoute = createRootRoute();
144
+ const mailboxRoute = createRoute({
145
+ getParentRoute: () => rootRoute,
146
+ path: "/mail/$mailboxId",
147
+ validateSearch: (search: Record<string, unknown>) => search,
148
+ });
149
+
150
+ const testRouter = (): AnyRouter =>
151
+ createRouter({
152
+ routeTree: rootRoute.addChildren([mailboxRoute]),
153
+ history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
154
+ }) as unknown as AnyRouter;
155
+
131
156
  const mount = async (
132
157
  options: MountOptions = {},
133
158
  ): Promise<{ releasePatch: () => void }> => {
@@ -167,11 +192,15 @@ const mount = async (
167
192
 
168
193
  harness = createDomHarness();
169
194
  harness.renderApp(
170
- createElement(
171
- ComposeProvider,
172
- null,
173
- createElement(Opened, { outboxMessageId: options.outboxMessageId }),
174
- ),
195
+ createElement(RouterContextProvider, {
196
+ router: testRouter(),
197
+ // biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
198
+ children: createElement(
199
+ ComposeProvider,
200
+ null,
201
+ createElement(Opened, { outboxMessageId: options.outboxMessageId }),
202
+ ),
203
+ }),
175
204
  );
176
205
  await harness.flush();
177
206
  await harness.wait(50);
@@ -1,21 +1,7 @@
1
- import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
2
1
  import { useLocation } from "@tanstack/react-router";
3
2
  import { Pencil } from "lucide-react";
3
+ import { useCallback } from "react";
4
4
  import { useCompose } from "@/components/compose/ComposeProvider";
5
- import { useGlobalCompose } from "@/hooks/useComposeTarget";
6
-
7
- /**
8
- * Primary mobile surfaces where the FAB belongs: anywhere under
9
- * `/mail` or under `/settings`. The bare `/` route (sign-in / OAuth
10
- * landing) is intentionally excluded — compose has no useful target
11
- * before the user has an account.
12
- */
13
- const isOnPrimaryMobileRoute = (pathname: string): boolean =>
14
- pathname.startsWith("/mail") || pathname.startsWith("/settings");
15
-
16
- interface ComposeFabProps {
17
- accounts: RemitImapAccountResponse[];
18
- }
19
5
 
20
6
  /**
21
7
  * Floating Action Button for composing a new message. Mobile-only.
@@ -26,32 +12,21 @@ interface ComposeFabProps {
26
12
  * `/mail` shell also stops mounting the FAB above that width; the
27
13
  * `lg:hidden` class covers the pre-hydration frame.
28
14
  * - The compose surface is already open.
29
- * - The user is reading a thread (`?selectedMessageId=…`) — the
30
- * conversation's Reply/Forward action bar covers that workflow.
31
- * - The user is not on a primary mobile route (`/mail` or
32
- * `/settings`).
33
- *
34
- * The tap itself is `useGlobalCompose`, shared with the desktop top bar:
35
- * it opens compose in place on routes that mount `FullCompose` and
36
- * otherwise carries the user to a real mailbox that does. Compose state
37
- * survives that transition because `ComposeProvider` lives in
38
- * `__root.tsx`.
15
+ * - The user is reading a thread (`?selectedMessageId=…`) — the single
16
+ * pane is the conversation, and its reply bar is under this corner.
17
+ * - The user is off `/mail`, which is every route with no mail in it.
39
18
  */
40
- export const ComposeFab = ({ accounts }: ComposeFabProps) => {
41
- const { state } = useCompose();
19
+ export const ComposeFab = () => {
20
+ const { state, openCompose } = useCompose();
42
21
  const location = useLocation();
43
- const compose = useGlobalCompose(accounts);
22
+ const compose = useCallback(() => {
23
+ openCompose({ mode: "new" });
24
+ }, [openCompose]);
44
25
 
45
26
  const search = location.search as Record<string, unknown> | undefined;
46
- const isReadingThread =
47
- typeof search?.selectedMessageId === "string" &&
48
- search.selectedMessageId.length > 0;
27
+ const isReadingThread = Boolean(search?.selectedMessageId);
49
28
 
50
- if (
51
- !isOnPrimaryMobileRoute(location.pathname) ||
52
- state.isOpen ||
53
- isReadingThread
54
- )
29
+ if (!location.pathname.startsWith("/mail") || state.isOpen || isReadingThread)
55
30
  return null;
56
31
 
57
32
  return (
@@ -0,0 +1,98 @@
1
+ import { AppShellSlotted } from "@remit/ui";
2
+ import { createContext, type ReactNode, useContext } from "react";
3
+ import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
4
+
5
+ /**
6
+ * The chrome every list shares, published by the `/mail` layout and consumed by
7
+ * the list route that mounts the shell.
8
+ *
9
+ * It lives in a module reached only through the `@/` alias for the same reason
10
+ * `lib/mail-context.ts` does: the generated route tree imports route files
11
+ * relatively, so a context declared inside one of them can resolve to a second
12
+ * module instance and hand the consumer an empty default.
13
+ */
14
+ export interface MailShellChrome {
15
+ /**
16
+ * Below the reading boundary (phone AND tablet) the shell shows one pane, so
17
+ * the list route mounts its phone view instead of the slotted panes.
18
+ */
19
+ isSinglePane: boolean;
20
+ isLoading: boolean;
21
+ intelligenceOpen: boolean;
22
+ nav: ReactNode;
23
+ topBar: ReactNode;
24
+ overlay: ReactNode;
25
+ navOpen: boolean;
26
+ onOpenNav: () => void;
27
+ onCloseNav: () => void;
28
+ }
29
+
30
+ const MailShellCtx = createContext<MailShellChrome | null>(null);
31
+
32
+ export const MailShellProvider = ({
33
+ chrome,
34
+ children,
35
+ }: {
36
+ chrome: MailShellChrome;
37
+ children: ReactNode;
38
+ }) => <MailShellCtx.Provider value={chrome}>{children}</MailShellCtx.Provider>;
39
+
40
+ export interface MailShellProps {
41
+ /** The single pane, list and open thread both, below the reading boundary. */
42
+ phone: ReactNode;
43
+ list: ReactNode;
44
+ reading: ReactNode;
45
+ intelligence?: ReactNode;
46
+ /** Whether the reading pane has a thread — the intelligence rail needs one. */
47
+ hasThread?: boolean;
48
+ }
49
+
50
+ /**
51
+ * The shell a list route mounts around its own panes.
52
+ *
53
+ * Below the reading boundary the shell is one pane and takes `phone`, which
54
+ * swaps between the list and whatever is open in place. Above it the panes sit
55
+ * side by side and the reading and intelligence slots are filled.
56
+ */
57
+ export function MailShell({
58
+ phone,
59
+ list,
60
+ reading,
61
+ intelligence,
62
+ hasThread = false,
63
+ }: MailShellProps) {
64
+ const chrome = useContext(MailShellCtx);
65
+ if (!chrome) return <AppShellSkeleton />;
66
+
67
+ const shared = {
68
+ nav: chrome.nav,
69
+ overlay: chrome.overlay,
70
+ skeleton: <AppShellSkeleton />,
71
+ isLoading: chrome.isLoading,
72
+ navOpen: chrome.navOpen,
73
+ onOpenNav: chrome.onOpenNav,
74
+ onCloseNav: chrome.onCloseNav,
75
+ };
76
+
77
+ if (chrome.isSinglePane) {
78
+ return (
79
+ <AppShellSlotted
80
+ {...shared}
81
+ list={phone}
82
+ intelligenceOpen={chrome.intelligenceOpen}
83
+ />
84
+ );
85
+ }
86
+
87
+ return (
88
+ <AppShellSlotted
89
+ {...shared}
90
+ topBar={chrome.topBar}
91
+ list={list}
92
+ reading={reading}
93
+ intelligence={intelligence}
94
+ intelligenceOpen={chrome.intelligenceOpen}
95
+ hasThread={hasThread}
96
+ />
97
+ );
98
+ }
@@ -9,9 +9,9 @@
9
9
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
10
10
  import { RefreshButton, ShellTopBar, shortcutHintForAction } from "@remit/ui";
11
11
  import { useNavigate } from "@tanstack/react-router";
12
- import { useMemo } from "react";
12
+ import { useCallback, useMemo } from "react";
13
13
  import { AccountMenu } from "@/auth/AccountMenu";
14
- import { useGlobalCompose } from "@/hooks/useComposeTarget";
14
+ import { useCompose } from "@/components/compose/ComposeProvider";
15
15
  import { useRefreshControl } from "@/hooks/useRefreshControl";
16
16
  import { useSearchScope } from "@/hooks/useSearchScope";
17
17
  import { openBugReport } from "@/lib/bug-report";
@@ -26,7 +26,10 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
26
26
  const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
27
27
  useMailContext();
28
28
  const navigate = useNavigate();
29
- const compose = useGlobalCompose(accounts);
29
+ const { openCompose } = useCompose();
30
+ const compose = useCallback(() => {
31
+ openCompose({ mode: "new" });
32
+ }, [openCompose]);
30
33
  const { scope, clearScope } = useSearchScope(accounts);
31
34
  const chips =
32
35
  scope.kind === "scoped"