@remit/web-client 0.0.165 → 0.0.167

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/components/compose/ComposeForm.tsx +77 -25
  3. package/src/components/compose/ComposeProvider.tsx +11 -122
  4. package/src/components/compose/FullCompose.tsx +29 -18
  5. package/src/components/compose/InlineCompose.tsx +21 -11
  6. package/src/components/compose/MobileComposeSheet.tsx +14 -18
  7. package/src/components/compose/compose-send-stops-autosave.render.test.ts +7 -9
  8. package/src/components/compose/compose-starts-a-second-message.render.test.ts +170 -0
  9. package/src/components/compose/compose-title.ts +10 -0
  10. package/src/components/compose/mobile-header-stays-expanded.render.test.ts +5 -13
  11. package/src/components/layout/ComposeFab.tsx +6 -15
  12. package/src/components/layout/MailShell.tsx +9 -1
  13. package/src/components/layout/MailTopBar.tsx +3 -6
  14. package/src/components/mail/BriefPane.tsx +6 -5
  15. package/src/components/mail/DraftsView.tsx +14 -15
  16. package/src/components/mail/FlaggedPane.tsx +6 -5
  17. package/src/components/mail/MailboxPane.tsx +36 -124
  18. package/src/components/mail/MessageActionMenu.tsx +4 -3
  19. package/src/components/mail/MessageList.tsx +4 -3
  20. package/src/components/mail/OutboxPane.tsx +8 -13
  21. package/src/components/mail/SwipeableMessageRow.tsx +4 -3
  22. package/src/components/mail/intelligence-drawer.stories.tsx +208 -0
  23. package/src/hooks/useSaveDraft.ts +11 -0
  24. package/src/hooks/useSearchMirror.ts +12 -1
  25. package/src/lib/mail-route.test.ts +35 -1
  26. package/src/lib/mail-route.ts +2 -1
  27. package/src/routeTree.gen.ts +92 -0
  28. package/src/routes/mail/$mailboxId/compose.{-$outboxMessageId}.tsx +14 -0
  29. package/src/routes/mail/brief/compose.{-$outboxMessageId}.tsx +22 -0
  30. package/src/routes/mail/flagged/compose.{-$outboxMessageId}.tsx +13 -0
  31. package/src/routes/mail/outbox/compose.{-$outboxMessageId}.tsx +17 -0
  32. package/src/routes/mail.tsx +12 -8
  33. package/src/routing/compose-press-opens.render.test.ts +179 -0
  34. package/src/routing/compose.ts +220 -0
  35. package/src/routing/fragment.render.test.ts +98 -0
  36. package/src/routing/fragment.test.ts +54 -9
  37. package/src/routing/fragment.ts +37 -9
  38. package/src/routing/index.ts +10 -1
  39. package/src/routing/nav-link.tsx +5 -3
  40. package/src/components/compose/compose-clears-open-thread.render.test.ts +0 -312
  41. package/src/hooks/useComposeTargetMailbox.ts +0 -75
  42. package/src/lib/compose-routes.test.ts +0 -46
  43. package/src/lib/compose-routes.ts +0 -25
@@ -13,7 +13,6 @@ import {
13
13
  } from "@tanstack/react-router";
14
14
  import { useCallback, useEffect, useMemo, useState } from "react";
15
15
  import { z } from "zod";
16
- import { useCompose } from "@/components/compose/ComposeProvider";
17
16
  import { ComposeFab } from "@/components/layout/ComposeFab";
18
17
  import { MailShellProvider } from "@/components/layout/MailShell";
19
18
  import { MailTopBar } from "@/components/layout/MailTopBar";
@@ -26,7 +25,6 @@ import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
26
25
  import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
27
26
  import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
28
27
  import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
29
- import { hostsComposeSurface } from "@/lib/compose-routes";
30
28
  import {
31
29
  readIntelligencePref,
32
30
  resolveRailOpen,
@@ -34,13 +32,15 @@ import {
34
32
  } from "@/lib/intelligence-pref";
35
33
  import { MailContext } from "@/lib/mail-context";
36
34
  import { MailFreshnessProvider } from "@/lib/mail-freshness";
37
- import { mailViewKey } from "@/lib/mail-route";
35
+ import { mailListRoute, mailViewKey } from "@/lib/mail-route";
38
36
  import { buildAccountNameIndex } from "@/lib/search-token-index";
39
37
  import { committedSearchQuery, searchInputForView } from "@/lib/search-view";
40
38
  import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
41
39
  import {
42
40
  isOverlayPanel,
43
41
  type OverlayPanel,
42
+ useIsComposing,
43
+ useOpenCompose,
44
44
  useOpenPanels,
45
45
  useOpenThreadPath,
46
46
  useSetOpenPanels,
@@ -166,7 +166,6 @@ function MailLayout() {
166
166
  // the render before committing and nothing is painted with the stale query.
167
167
  // An effect would commit one frame carrying the previous view's text, which
168
168
  // the mirror then has to be defended against.
169
- const pathname = useRouterState({ select: (s) => s.location.pathname });
170
169
  const viewKey = useRouterState({ select: (s) => mailViewKey(s.matches) });
171
170
  const [searchViewKey, setSearchViewKey] = useState(viewKey);
172
171
  if (searchViewKey !== viewKey) {
@@ -214,14 +213,19 @@ function MailLayout() {
214
213
 
215
214
  // `c` / ⌘N off the mailbox routes — the brief, Flagged, the outbox. On a
216
215
  // mailbox the pane's own triage layer owns the key, so this is disabled there
217
- // rather than firing a second compose alongside it.
218
- const { openCompose } = useCompose();
216
+ // rather than firing a second compose alongside it, and while the composer is
217
+ // up the key belongs to whatever is being typed.
218
+ const openCompose = useOpenCompose();
219
+ const isComposing = useIsComposing();
220
+ const onMailbox = useRouterState({
221
+ select: (s) => mailListRoute(s.matches)?.list === "mailbox",
222
+ });
219
223
  const composeHandlers = useMemo(
220
- () => ({ compose: () => openCompose({ mode: "new" }) }),
224
+ () => ({ compose: openCompose }),
221
225
  [openCompose],
222
226
  );
223
227
  useTriageKeyboard({
224
- enabled: !hostsComposeSurface(pathname),
228
+ enabled: !onMailbox && !isComposing,
225
229
  handlers: composeHandlers,
226
230
  });
227
231
 
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Every Compose press opens a composer (#719).
3
+ *
4
+ * The press used to resolve a folder to carry the reader to, and could refuse:
5
+ * a folder list still loading, or an account with no folders at all, left the
6
+ * button doing nothing but complaining. A message needs no folder to be written
7
+ * in now, so there is nothing left to refuse — including from `/mail` itself,
8
+ * which names no list because it is on its way to the brief.
9
+ *
10
+ * Closing has the same shape from the other side. Leaving is what the reader
11
+ * asked for, so every branch leaves rather than stranding them inside a surface
12
+ * they have just dismissed.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { afterEach, describe, it } from "node:test";
17
+ import {
18
+ type AnyRouter,
19
+ createMemoryHistory,
20
+ createRootRoute,
21
+ createRoute,
22
+ createRouter,
23
+ Outlet,
24
+ RouterProvider,
25
+ } from "@tanstack/react-router";
26
+ import { createElement } from "react";
27
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
28
+ import { useCloseCompose, useOpenCompose } from "./compose";
29
+
30
+ let harness: DomHarness | undefined;
31
+
32
+ afterEach(() => {
33
+ harness?.close();
34
+ harness = undefined;
35
+ });
36
+
37
+ // The router reads `self` at construction; the shared jsdom globals stop at
38
+ // `window`.
39
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
40
+
41
+ const MAILBOX_ID = "mbx-inbox";
42
+
43
+ const Press = () => {
44
+ const openCompose = useOpenCompose();
45
+ const closeCompose = useCloseCompose();
46
+ return createElement(
47
+ "div",
48
+ null,
49
+ createElement(
50
+ "button",
51
+ { type: "button", onClick: () => openCompose() },
52
+ "Compose",
53
+ ),
54
+ createElement(
55
+ "button",
56
+ { type: "button", onClick: () => closeCompose() },
57
+ "Close",
58
+ ),
59
+ );
60
+ };
61
+
62
+ const RootLayout = () =>
63
+ createElement("div", null, createElement(Press), createElement(Outlet));
64
+
65
+ /**
66
+ * The real shape: the lists are siblings under `/mail`, and compose is a child
67
+ * of each. `/mail` itself matches no list, which is the address the redirect to
68
+ * the brief passes through.
69
+ */
70
+ const routerAt = (href: string): AnyRouter => {
71
+ const rootRoute = createRootRoute({ component: RootLayout });
72
+ const mailRoute = createRoute({
73
+ getParentRoute: () => rootRoute,
74
+ path: "/mail",
75
+ validateSearch: (search: Record<string, unknown>) => search,
76
+ component: Outlet,
77
+ });
78
+ const briefRoute = createRoute({
79
+ getParentRoute: () => mailRoute,
80
+ path: "/brief",
81
+ component: Outlet,
82
+ });
83
+ const mailboxRoute = createRoute({
84
+ getParentRoute: () => mailRoute,
85
+ path: "/$mailboxId",
86
+ component: Outlet,
87
+ });
88
+ const routeTree = rootRoute.addChildren([
89
+ mailRoute.addChildren([
90
+ briefRoute.addChildren([
91
+ createRoute({
92
+ getParentRoute: () => briefRoute,
93
+ path: "/compose/{-$outboxMessageId}",
94
+ component: () => null,
95
+ }),
96
+ ]),
97
+ mailboxRoute.addChildren([
98
+ createRoute({
99
+ getParentRoute: () => mailboxRoute,
100
+ path: "/compose/{-$outboxMessageId}",
101
+ component: () => null,
102
+ }),
103
+ ]),
104
+ ]),
105
+ ]);
106
+ return createRouter({
107
+ routeTree,
108
+ history: createMemoryHistory({ initialEntries: [href] }),
109
+ }) as unknown as AnyRouter;
110
+ };
111
+
112
+ const mount = async (router: AnyRouter): Promise<DomHarness> => {
113
+ const created = createDomHarness();
114
+ harness = created;
115
+ // Resolve the first match before mounting: `RouterProvider` renders its
116
+ // pending state until the router has loaded, and nothing here waits for it.
117
+ await router.load();
118
+ created.renderApp(createElement(RouterProvider, { router }));
119
+ await created.flush();
120
+ await created.wait(20);
121
+ return created;
122
+ };
123
+
124
+ const press = async (mounted: DomHarness, label: string): Promise<void> => {
125
+ mounted.click(mounted.byText("button", label));
126
+ await mounted.flush();
127
+ await mounted.wait(20);
128
+ };
129
+
130
+ describe("a compose press always opens a composer", () => {
131
+ it("opens on the list being browsed", async () => {
132
+ const router = routerAt("/mail/brief");
133
+ const mounted = await mount(router);
134
+
135
+ await press(mounted, "Compose");
136
+
137
+ assert.equal(router.state.location.pathname, "/mail/brief/compose");
138
+ });
139
+
140
+ it("opens on the folder being browsed", async () => {
141
+ const router = routerAt(`/mail/${MAILBOX_ID}`);
142
+ const mounted = await mount(router);
143
+
144
+ await press(mounted, "Compose");
145
+
146
+ assert.equal(router.state.location.pathname, `/mail/${MAILBOX_ID}/compose`);
147
+ });
148
+
149
+ // The address the redirect to the brief passes through. It names no list, and
150
+ // the press still has to write a message rather than report that it cannot.
151
+ it("opens from an address that names no list at all", async () => {
152
+ const router = routerAt("/mail");
153
+ const mounted = await mount(router);
154
+
155
+ await press(mounted, "Compose");
156
+
157
+ assert.equal(router.state.location.pathname, "/mail/brief/compose");
158
+ });
159
+ });
160
+
161
+ describe("closing compose always leaves", () => {
162
+ it("walks up to the list the surface was opened on", async () => {
163
+ const router = routerAt(`/mail/${MAILBOX_ID}/compose`);
164
+ const mounted = await mount(router);
165
+
166
+ await press(mounted, "Close");
167
+
168
+ assert.equal(router.state.location.pathname, `/mail/${MAILBOX_ID}`);
169
+ });
170
+
171
+ it("lands somewhere real from an address that names no list", async () => {
172
+ const router = routerAt("/mail");
173
+ const mounted = await mount(router);
174
+
175
+ await press(mounted, "Close");
176
+
177
+ assert.equal(router.state.location.pathname, "/mail/brief");
178
+ });
179
+ });
@@ -0,0 +1,220 @@
1
+ import { useNavigate, useParams, useRouterState } from "@tanstack/react-router";
2
+ import { useCallback } from "react";
3
+ import { mailListRoute } from "@/lib/mail-route";
4
+ import { useRetainOpenPanels } from "./fragment";
5
+
6
+ /**
7
+ * The compose route under each list. The draft is an optional segment of the
8
+ * same route rather than a child of it, so adopting the id the first autosave
9
+ * creates rewrites the address without unmounting the composer.
10
+ */
11
+ const BRIEF_COMPOSE = "/mail/brief/compose/{-$outboxMessageId}" as const;
12
+ const FLAGGED_COMPOSE = "/mail/flagged/compose/{-$outboxMessageId}" as const;
13
+ const OUTBOX_COMPOSE = "/mail/outbox/compose/{-$outboxMessageId}" as const;
14
+ const MAILBOX_COMPOSE = "/mail/$mailboxId/compose/{-$outboxMessageId}" as const;
15
+
16
+ /**
17
+ * The compose match, if the address has one. Each `from` names a real route, so
18
+ * a segment that does not exist fails to compile.
19
+ *
20
+ * A path matches one list at a time, so at most one of these answers.
21
+ */
22
+ function useComposeParams(): { outboxMessageId?: string } | undefined {
23
+ const brief = useParams({ from: BRIEF_COMPOSE, shouldThrow: false });
24
+ const flagged = useParams({ from: FLAGGED_COMPOSE, shouldThrow: false });
25
+ const outbox = useParams({ from: OUTBOX_COMPOSE, shouldThrow: false });
26
+ const mailbox = useParams({ from: MAILBOX_COMPOSE, shouldThrow: false });
27
+ return brief ?? flagged ?? outbox ?? mailbox;
28
+ }
29
+
30
+ /**
31
+ * Whether the compose surface is showing.
32
+ *
33
+ * Compose is a child route of the list it was started from, so "is compose
34
+ * showing" is a question about the path rather than a flag somebody else has to
35
+ * keep in step with it.
36
+ */
37
+ export function useIsComposing(): boolean {
38
+ return useComposeParams() !== undefined;
39
+ }
40
+
41
+ /**
42
+ * The draft the open composer is writing to, or `undefined` while it is still a
43
+ * message with nothing saved behind it.
44
+ *
45
+ * One owner: the address. A copy in React state disagrees with it the first
46
+ * time the reader presses Back, and the composer comes back empty over a draft
47
+ * row that is still listed.
48
+ */
49
+ export function useComposeDraftId(): string | undefined {
50
+ return useComposeParams()?.outboxMessageId;
51
+ }
52
+
53
+ /**
54
+ * The list the address is browsing, as the two values a navigation to it needs.
55
+ * Both are primitives, so a caller holding them in a dependency array settles.
56
+ */
57
+ function useBrowsedList(): {
58
+ list: "brief" | "flagged" | "outbox" | "mailbox" | undefined;
59
+ mailboxId: string | undefined;
60
+ } {
61
+ const list = useRouterState({
62
+ select: (state) => mailListRoute(state.matches)?.list,
63
+ });
64
+ const mailbox = useParams({ from: "/mail/$mailboxId", shouldThrow: false });
65
+ return { list, mailboxId: mailbox?.mailboxId };
66
+ }
67
+
68
+ interface ComposeNavigation {
69
+ /** Which list's compose route, and the draft segment under it. */
70
+ to: typeof BRIEF_COMPOSE | typeof FLAGGED_COMPOSE | typeof OUTBOX_COMPOSE;
71
+ params: { outboxMessageId: string | undefined };
72
+ }
73
+
74
+ interface MailboxComposeNavigation {
75
+ to: typeof MAILBOX_COMPOSE;
76
+ params: { mailboxId: string; outboxMessageId: string | undefined };
77
+ }
78
+
79
+ /**
80
+ * Where compose opens: the list being browsed, or the brief.
81
+ *
82
+ * Every case lands on a composer. The brief is the fallback rather than a
83
+ * refusal because it always exists and always mounts the surface, so the two
84
+ * addresses that name no folder — `/mail` on its way to the brief, and a folder
85
+ * route in the frame before its id resolves — open a composer instead of
86
+ * explaining why they cannot. A press that reports rather than writes is the
87
+ * dead button this change deletes, and neither case has anything to report:
88
+ * a message needs no folder to be written in.
89
+ */
90
+ function composeTarget(
91
+ list: "brief" | "flagged" | "outbox" | "mailbox" | undefined,
92
+ mailboxId: string | undefined,
93
+ outboxMessageId: string | undefined,
94
+ ): ComposeNavigation | MailboxComposeNavigation {
95
+ if (list === "flagged")
96
+ return { to: FLAGGED_COMPOSE, params: { outboxMessageId } };
97
+ if (list === "outbox")
98
+ return { to: OUTBOX_COMPOSE, params: { outboxMessageId } };
99
+ if (list === "mailbox" && mailboxId)
100
+ return { to: MAILBOX_COMPOSE, params: { mailboxId, outboxMessageId } };
101
+ return { to: BRIEF_COMPOSE, params: { outboxMessageId } };
102
+ }
103
+
104
+ /**
105
+ * Navigate to compose on the list being browsed, on the draft named or on none.
106
+ *
107
+ * The draft travels as an argument rather than being set somewhere first: an
108
+ * opener that had to do both could do only one, and the composer would come up
109
+ * on whatever the last one left behind.
110
+ */
111
+ function useComposeNavigate(): (
112
+ outboxMessageId: string | undefined,
113
+ options?: { replace?: boolean; keepPanels?: boolean },
114
+ ) => void {
115
+ const navigate = useNavigate();
116
+ const retainPanels = useRetainOpenPanels();
117
+ const { list, mailboxId } = useBrowsedList();
118
+
119
+ return useCallback(
120
+ (
121
+ outboxMessageId: string | undefined,
122
+ options?: { replace?: boolean; keepPanels?: boolean },
123
+ ) => {
124
+ navigate({
125
+ ...composeTarget(list, mailboxId, outboxMessageId),
126
+ search: (prev: Record<string, unknown>) => prev,
127
+ // Opening or closing the surface is going somewhere, so the panes the
128
+ // reader keeps up travel and the overlays they were reading over do
129
+ // not. Recording the draft is not going anywhere — the address is
130
+ // rewritten under a composer they are still typing in — so whatever is
131
+ // up stays up, sheet included.
132
+ hash: options?.keepPanels ? true : retainPanels,
133
+ replace: options?.replace ?? false,
134
+ });
135
+ },
136
+ [navigate, retainPanels, list, mailboxId],
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Start a new message.
142
+ *
143
+ * Takes nothing, because it is wired straight to buttons and key handlers: an
144
+ * optional draft argument here would quietly receive a click event instead.
145
+ * Resuming a draft is `useEditDraft`, which says so.
146
+ *
147
+ * A push, so Back leaves the surface and returns whatever it was opened over,
148
+ * and the query travels with it — a compose started mid-search is still inside
149
+ * that search.
150
+ */
151
+ export function useOpenCompose(): () => void {
152
+ const openCompose = useComposeNavigate();
153
+ return useCallback(() => openCompose(undefined), [openCompose]);
154
+ }
155
+
156
+ /** Resume a draft: the same surface, addressed at what it is editing. */
157
+ export function useEditDraft(): (outboxMessageId: string) => void {
158
+ const openCompose = useComposeNavigate();
159
+ return useCallback(
160
+ (outboxMessageId: string) => openCompose(outboxMessageId),
161
+ [openCompose],
162
+ );
163
+ }
164
+
165
+ /**
166
+ * Record the draft the composer just created, in the address.
167
+ *
168
+ * A replace, and every panel left alone: the reader started one message, so the
169
+ * draft arriving under it is neither another step of history nor a move away
170
+ * from whatever they have open over the composer.
171
+ */
172
+ export function useAdoptComposeDraft(): (outboxMessageId: string) => void {
173
+ const openCompose = useComposeNavigate();
174
+ return useCallback(
175
+ (outboxMessageId: string) =>
176
+ openCompose(outboxMessageId, { replace: true, keepPanels: true }),
177
+ [openCompose],
178
+ );
179
+ }
180
+
181
+ /**
182
+ * Close compose, landing back on the list it was opened from.
183
+ *
184
+ * A push, like opening it: closing a surface moves the reader on, and Back is
185
+ * how they undo that. Replacing the entry instead makes the press after a close
186
+ * do nothing at all, because the entry behind it is the list they are already
187
+ * looking at.
188
+ *
189
+ * Every branch leaves, the brief included. Leaving is what the reader asked
190
+ * for, so a case this cannot name has to put them somewhere real rather than
191
+ * return and strand them inside a surface they just dismissed.
192
+ */
193
+ export function useCloseCompose(): () => void {
194
+ const navigate = useNavigate();
195
+ const retainPanels = useRetainOpenPanels();
196
+ const { list, mailboxId } = useBrowsedList();
197
+
198
+ return useCallback(() => {
199
+ const search = (prev: Record<string, unknown>) => prev;
200
+ const hash = retainPanels;
201
+ if (list === "flagged") {
202
+ navigate({ to: "/mail/flagged", search, hash });
203
+ return;
204
+ }
205
+ if (list === "outbox") {
206
+ navigate({ to: "/mail/outbox", search, hash });
207
+ return;
208
+ }
209
+ if (list === "mailbox" && mailboxId) {
210
+ navigate({
211
+ to: "/mail/$mailboxId",
212
+ params: { mailboxId },
213
+ search,
214
+ hash,
215
+ });
216
+ return;
217
+ }
218
+ navigate({ to: "/mail/brief", search, hash });
219
+ }, [navigate, retainPanels, list, mailboxId]);
220
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `useRetainOpenPanels` reads the tier off the app's own desktop breakpoint
3
+ * (#777), so a navigation below it lands without the rail in the address —
4
+ * which is what keeps the drawer with the thread it was opened for.
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import { afterEach, describe, it } from "node:test";
9
+ import {
10
+ type AnyRouter,
11
+ createMemoryHistory,
12
+ createRootRoute,
13
+ createRoute,
14
+ createRouter,
15
+ RouterProvider,
16
+ useNavigate,
17
+ } from "@tanstack/react-router";
18
+ import { createElement } from "react";
19
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
20
+ import { useRetainOpenPanels } from "./fragment";
21
+
22
+ const PHONE_WIDTH = 390;
23
+ const DESKTOP_WIDTH = 1440;
24
+
25
+ let harness: DomHarness | undefined;
26
+
27
+ afterEach(() => {
28
+ harness?.close();
29
+ harness = undefined;
30
+ });
31
+
32
+ // The router reads `self` at construction; the shared jsdom globals stop at
33
+ // `window`.
34
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
35
+
36
+ function CloseThread() {
37
+ const navigate = useNavigate();
38
+ const retainPanels = useRetainOpenPanels();
39
+ return createElement(
40
+ "button",
41
+ {
42
+ type: "button",
43
+ onClick: () => {
44
+ void navigate({ to: "/mail", hash: retainPanels });
45
+ },
46
+ },
47
+ "Back",
48
+ );
49
+ }
50
+
51
+ const rootRoute = createRootRoute();
52
+ const listRoute = createRoute({
53
+ getParentRoute: () => rootRoute,
54
+ path: "/mail",
55
+ });
56
+ const threadRoute = createRoute({
57
+ getParentRoute: () => rootRoute,
58
+ path: "/mail/thread",
59
+ component: CloseThread,
60
+ });
61
+
62
+ const openDrawerOverThread = (): AnyRouter =>
63
+ createRouter({
64
+ routeTree: rootRoute.addChildren([listRoute, threadRoute]),
65
+ history: createMemoryHistory({
66
+ initialEntries: ["/mail/thread#intelligence"],
67
+ }),
68
+ }) as unknown as AnyRouter;
69
+
70
+ const walkUpToTheList = async (viewportWidth: number): Promise<AnyRouter> => {
71
+ const router = openDrawerOverThread();
72
+ harness = createDomHarness({ viewportWidth });
73
+ // Resolve the first match before mounting: `RouterProvider` renders its
74
+ // pending state until the router has loaded, and nothing here waits for it.
75
+ await router.load();
76
+ harness.render(createElement(RouterProvider, { router }));
77
+ await harness.flush();
78
+ harness.click(harness.byText("button", "Back"));
79
+ await harness.flush();
80
+ await harness.wait(20);
81
+ return router;
82
+ };
83
+
84
+ describe("useRetainOpenPanels (#777)", () => {
85
+ it("leaves the drawer behind on a phone", async () => {
86
+ const router = await walkUpToTheList(PHONE_WIDTH);
87
+
88
+ assert.equal(router.state.location.pathname, "/mail");
89
+ assert.equal(router.state.location.hash, "");
90
+ });
91
+
92
+ it("carries the rail across on desktop, where it is a pane", async () => {
93
+ const router = await walkUpToTheList(DESKTOP_WIDTH);
94
+
95
+ assert.equal(router.state.location.pathname, "/mail");
96
+ assert.equal(router.state.location.hash, "intelligence");
97
+ });
98
+ });
@@ -4,9 +4,12 @@ import {
4
4
  formatOpenPanels,
5
5
  panelFragments,
6
6
  parseOpenPanels,
7
- retainOpenPanels,
7
+ retainOpenPanelsAtTier,
8
8
  } from "./fragment.js";
9
9
 
10
+ const retainOnDesktop = retainOpenPanelsAtTier(true);
11
+ const retainOnPhone = retainOpenPanelsAtTier(false);
12
+
10
13
  describe("parseOpenPanels", () => {
11
14
  it("reads every panel the union names", () => {
12
15
  for (const panel of panelFragments) {
@@ -50,19 +53,61 @@ describe("formatOpenPanels", () => {
50
53
  });
51
54
  });
52
55
 
53
- describe("retainOpenPanels", () => {
54
- it("carries a pane across a navigation", () => {
55
- assert.equal(retainOpenPanels("intelligence"), "intelligence");
56
+ describe("retainOpenPanelsAtTier", () => {
57
+ it("carries a pane across a navigation on the tier that has one", () => {
58
+ assert.equal(retainOnDesktop("intelligence"), "intelligence");
56
59
  });
57
60
 
58
61
  it("leaves the overlays behind, and keeps the pane under them", () => {
59
- assert.equal(retainOpenPanels("nav"), "");
60
- assert.equal(retainOpenPanels("shortcuts"), "");
61
- assert.equal(retainOpenPanels("intelligence,shortcuts"), "intelligence");
62
+ assert.equal(retainOnDesktop("nav"), "");
63
+ assert.equal(retainOnDesktop("shortcuts"), "");
64
+ assert.equal(retainOnDesktop("intelligence,shortcuts"), "intelligence");
62
65
  });
63
66
 
64
67
  it("drops a fragment it does not recognise rather than passing it on", () => {
65
- assert.equal(retainOpenPanels(), "");
66
- assert.equal(retainOpenPanels("confirm-delete"), "");
68
+ assert.equal(retainOnDesktop(), "");
69
+ assert.equal(retainOnDesktop("confirm-delete"), "");
70
+ assert.equal(retainOnPhone("confirm-delete"), "");
71
+ });
72
+ });
73
+
74
+ /**
75
+ * Below desktop the rail is a full-screen drawer over the open message, so it
76
+ * belongs to the thread it was opened for. Every navigation that handed the
77
+ * fragment on put the drawer over the next message the reader opened.
78
+ *
79
+ * Each case walks the fragment through the navigations of one reachable path,
80
+ * starting from the address the open drawer wrote.
81
+ */
82
+ describe("the drawer stays with its thread below desktop (#777)", () => {
83
+ const walk = (
84
+ retain: (hash?: string) => string,
85
+ hash: string,
86
+ steps: number,
87
+ ): string => {
88
+ let current = hash;
89
+ for (let step = 0; step < steps; step += 1) current = retain(current);
90
+ return current;
91
+ };
92
+
93
+ // A DKIM mismatch opens the drawer; the reader taps the back arrow instead
94
+ // of closing it, then taps another row.
95
+ it("drops the rail between the back arrow and the next row", () => {
96
+ assert.equal(walk(retainOnPhone, "intelligence", 2), "");
97
+ assert.equal(walk(retainOnDesktop, "intelligence", 2), "intelligence");
98
+ });
99
+
100
+ // "Report spam" inside the open drawer removes the thread the drawer is
101
+ // reading, which closes it up to the list — one navigation, no back button.
102
+ it("drops the rail when a quick action in the drawer removes the thread", () => {
103
+ assert.equal(retainOnPhone("intelligence"), "");
104
+ assert.equal(retainOnDesktop("intelligence"), "intelligence");
105
+ });
106
+
107
+ // The brief and the flagged list open a thread from a cross-mailbox row,
108
+ // and the shortcuts sheet may be up over the drawer when they do.
109
+ it("drops the rail when the brief or the flagged list opens a thread", () => {
110
+ assert.equal(retainOnPhone("intelligence,shortcuts"), "");
111
+ assert.equal(retainOnDesktop("intelligence,shortcuts"), "intelligence");
67
112
  });
68
113
  });