@remit/web-client 0.0.165 → 0.0.166

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.165",
3
+ "version": "0.0.166",
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": {
@@ -44,7 +44,7 @@ import { useMailContext } from "@/lib/mail-context";
44
44
  import {
45
45
  type OpenThreadPath,
46
46
  type OpenThreadTarget,
47
- retainOpenPanels,
47
+ useRetainOpenPanels,
48
48
  } from "@/routing";
49
49
 
50
50
  /* ------------------------------------------------------------------ */
@@ -102,6 +102,7 @@ interface BriefPaneProps {
102
102
 
103
103
  function BriefPaneProvider({ thread, children }: BriefPaneProps) {
104
104
  const navigate = useNavigate();
105
+ const retainPanels = useRetainOpenPanels();
105
106
  const { searchInput } = useMailContext();
106
107
  const threadId = thread?.threadId;
107
108
  const pointedAtMessageId = thread?.messageId;
@@ -152,19 +153,19 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
152
153
  // `searchInput`: a row can be tapped before the debounce settles, when
153
154
  // the committed query is still empty.
154
155
  search: (prev) => ({ ...prev, q: searchInput || undefined }),
155
- hash: retainOpenPanels,
156
+ hash: retainPanels,
156
157
  });
157
158
  },
158
- [navigate, searchInput],
159
+ [navigate, retainPanels, searchInput],
159
160
  );
160
161
 
161
162
  const handleCloseThread = useCallback(() => {
162
163
  navigate({
163
164
  to: "/mail/brief",
164
165
  search: (prev) => prev,
165
- hash: retainOpenPanels,
166
+ hash: retainPanels,
166
167
  });
167
- }, [navigate]);
168
+ }, [navigate, retainPanels]);
168
169
 
169
170
  const handleDeselectIfRemoved = useCallback(
170
171
  (removedIds: string[]) => {
@@ -41,7 +41,7 @@ import { FileText, Inbox, Trash2 } from "lucide-react";
41
41
  import { useMemo } from "react";
42
42
  import { useCompose } from "@/components/compose/ComposeProvider";
43
43
  import { groupDraftSections } from "@/lib/drafts";
44
- import { retainOpenPanels } from "@/routing";
44
+ import { useRetainOpenPanels } from "@/routing";
45
45
  import { NavMenuButton } from "./NavMenuButton";
46
46
 
47
47
  // ---------------------------------------------------------------------------
@@ -156,6 +156,7 @@ export function DraftsView({
156
156
  }: DraftsViewProps) {
157
157
  const { openCompose, state: composeState } = useCompose();
158
158
  const navigate = useNavigate();
159
+ const retainPanels = useRetainOpenPanels();
159
160
  const queryClient = useQueryClient();
160
161
 
161
162
  // Fetch the full outbox list — both sources are already fetched by the
@@ -198,7 +199,7 @@ export function DraftsView({
198
199
  to: "/mail/$mailboxId/$threadId/$messageId",
199
200
  params: { mailboxId, threadId, messageId },
200
201
  search: (prev) => prev,
201
- hash: retainOpenPanels,
202
+ hash: retainPanels,
202
203
  });
203
204
  };
204
205
 
@@ -55,7 +55,7 @@ import { useMailContext } from "@/lib/mail-context";
55
55
  import {
56
56
  type OpenThreadPath,
57
57
  type OpenThreadTarget,
58
- retainOpenPanels,
58
+ useRetainOpenPanels,
59
59
  } from "@/routing";
60
60
 
61
61
  /* ------------------------------------------------------------------ */
@@ -113,6 +113,7 @@ interface FlaggedPaneProps {
113
113
 
114
114
  function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
115
115
  const navigate = useNavigate();
116
+ const retainPanels = useRetainOpenPanels();
116
117
  const { searchInput } = useMailContext();
117
118
  const threadId = thread?.threadId;
118
119
  const pointedAtMessageId = thread?.messageId;
@@ -158,19 +159,19 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
158
159
  // `searchInput`: a row can be tapped before the debounce settles, when
159
160
  // the committed query is still empty.
160
161
  search: (prev) => ({ ...prev, q: searchInput || undefined }),
161
- hash: retainOpenPanels,
162
+ hash: retainPanels,
162
163
  });
163
164
  },
164
- [navigate, searchInput],
165
+ [navigate, retainPanels, searchInput],
165
166
  );
166
167
 
167
168
  const handleCloseThread = useCallback(() => {
168
169
  navigate({
169
170
  to: "/mail/flagged",
170
171
  search: (prev) => prev,
171
- hash: retainOpenPanels,
172
+ hash: retainPanels,
172
173
  });
173
- }, [navigate]);
174
+ }, [navigate, retainPanels]);
174
175
 
175
176
  const handleDeselectIfRemoved = useCallback(
176
177
  (removedIds: string[]) => {
@@ -127,7 +127,7 @@ import {
127
127
  import {
128
128
  type OpenThreadPath,
129
129
  type OpenThreadTarget,
130
- retainOpenPanels,
130
+ useRetainOpenPanels,
131
131
  } from "@/routing";
132
132
  import { MailViewChrome } from "./MailViewChrome";
133
133
 
@@ -264,6 +264,7 @@ function MailboxPaneProvider({
264
264
  children,
265
265
  }: MailboxPaneProps) {
266
266
  const navigate = useNavigate();
267
+ const retainPanels = useRetainOpenPanels();
267
268
  const threadId = thread?.threadId;
268
269
  const pointedAtMessageId = thread?.messageId;
269
270
  const telemetry = useTelemetry();
@@ -467,9 +468,9 @@ function MailboxPaneProvider({
467
468
  to: "/mail/$mailboxId",
468
469
  params: { mailboxId },
469
470
  search: (prev) => prev,
470
- hash: retainOpenPanels,
471
+ hash: retainPanels,
471
472
  });
472
- }, [mailboxId, navigate]);
473
+ }, [mailboxId, navigate, retainPanels]);
473
474
 
474
475
  const handleOpenThread = useCallback(
475
476
  (target: OpenThreadTarget) => {
@@ -477,10 +478,10 @@ function MailboxPaneProvider({
477
478
  to: "/mail/$mailboxId/$threadId/$messageId",
478
479
  params: { mailboxId, ...target },
479
480
  search: (prev) => prev,
480
- hash: retainOpenPanels,
481
+ hash: retainPanels,
481
482
  });
482
483
  },
483
- [mailboxId, navigate],
484
+ [mailboxId, navigate, retainPanels],
484
485
  );
485
486
 
486
487
  const handleDeselectIfRemoved = useCallback(
@@ -942,6 +943,7 @@ function MailboxList() {
942
943
  useMailContext();
943
944
  const tier = useLayoutTier();
944
945
  const navigate = useNavigate();
946
+ const retainPanels = useRetainOpenPanels();
945
947
 
946
948
  const listTitle = mailboxName ?? "Inbox";
947
949
  const preset = useMemo(() => inboxFilterConfig(), []);
@@ -1029,10 +1031,10 @@ function MailboxList() {
1029
1031
  // `searchInput`: a row can be tapped before the debounce settles, when
1030
1032
  // the committed query is still empty.
1031
1033
  search: (prev) => ({ ...prev, q: searchInput || undefined }),
1032
- hash: retainOpenPanels,
1034
+ hash: retainPanels,
1033
1035
  });
1034
1036
  },
1035
- [mailboxId, navigate, searchInput, threads],
1037
+ [mailboxId, navigate, retainPanels, searchInput, threads],
1036
1038
  );
1037
1039
 
1038
1040
  // Drafts keep their own dedicated view (and header); they don't carry the
@@ -35,7 +35,7 @@ import {
35
35
  type ThreadListSnapshotEntry,
36
36
  threadListCacheKeys,
37
37
  } from "@/lib/thread-list-cache";
38
- import { retainOpenPanels, useOpenThreadPath } from "@/routing";
38
+ import { useOpenThreadPath, useRetainOpenPanels } from "@/routing";
39
39
  import { MoveToTrigger } from "./MoveToTrigger";
40
40
 
41
41
  interface ThreadMessagesData {
@@ -103,6 +103,7 @@ export const MessageActionMenu = ({
103
103
  }: MessageActionMenuProps) => {
104
104
  const queryClient = useQueryClient();
105
105
  const navigate = useNavigate();
106
+ const retainPanels = useRetainOpenPanels();
106
107
  const { pushError } = useErrorBanners();
107
108
  const selectedMessageId = useOpenThreadPath()?.messageId;
108
109
 
@@ -193,10 +194,10 @@ export const MessageActionMenu = ({
193
194
  to: "/mail/$mailboxId",
194
195
  params: { mailboxId },
195
196
  search: (prev) => prev,
196
- hash: retainOpenPanels,
197
+ hash: retainPanels,
197
198
  });
198
199
  },
199
- [selectedMessageId, mailboxId, navigate],
200
+ [selectedMessageId, mailboxId, navigate, retainPanels],
200
201
  );
201
202
 
202
203
  const { deleteMessages, isPending: isDeleting } = useDeleteMessages({
@@ -53,7 +53,7 @@ import { useListHeaderChrome } from "@/lib/list-header-chrome";
53
53
  import { listVerbRequest } from "@/lib/list-verb-request";
54
54
  import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
55
55
  import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
56
- import { retainOpenPanels } from "@/routing";
56
+ import { useRetainOpenPanels } from "@/routing";
57
57
  import { LabelApplyTrigger } from "./LabelApplyTrigger";
58
58
  import {
59
59
  type EscalatedSelection,
@@ -234,6 +234,7 @@ export const MessageList = ({
234
234
  }: MessageListProps) => {
235
235
  const parentRef = useRef<HTMLDivElement>(null);
236
236
  const navigate = useNavigate();
237
+ const retainPanels = useRetainOpenPanels();
237
238
  const isDesktop = useIsDesktop();
238
239
  const wizard = useSelectionWizard();
239
240
  const { verb: wizardVerb, start: startWizard, startFromSearch } = wizard;
@@ -635,11 +636,11 @@ export const MessageList = ({
635
636
  to: "/mail/$mailboxId/$threadId/$messageId",
636
637
  params: { mailboxId, threadId, messageId },
637
638
  search: (prev) => prev,
638
- hash: retainOpenPanels,
639
+ hash: retainPanels,
639
640
  replace: options?.replace,
640
641
  });
641
642
  },
642
- [navigate, mailboxId, threads],
643
+ [navigate, retainPanels, mailboxId, threads],
643
644
  );
644
645
 
645
646
  // Enter: open the focused row in the reading pane. This is the focus→open
@@ -63,7 +63,7 @@ import {
63
63
  import { isOutboxListRow, isUnsendableStatus } from "@/lib/outbox-status";
64
64
  import { normalizeSearchQuery } from "@/lib/search-query";
65
65
  import { parseSearchTokens } from "@/lib/search-tokens";
66
- import { retainOpenPanels } from "@/routing";
66
+ import { useRetainOpenPanels } from "@/routing";
67
67
 
68
68
  /* ------------------------------------------------------------------ */
69
69
  /* Helpers (shared between List, Reading, Phone sub-views) */
@@ -174,6 +174,7 @@ function OutboxPaneProvider({
174
174
  children,
175
175
  }: OutboxPaneProps) {
176
176
  const navigate = useNavigate();
177
+ const retainPanels = useRetainOpenPanels();
177
178
 
178
179
  const { data: outboxResponse, isLoading } = useQuery(
179
180
  outboxOperationsListOutboxMessagesOptions(),
@@ -219,19 +220,19 @@ function OutboxPaneProvider({
219
220
  to: "/mail/outbox/draft/$outboxMessageId",
220
221
  params: { outboxMessageId },
221
222
  search: (prev) => prev,
222
- hash: retainOpenPanels,
223
+ hash: retainPanels,
223
224
  });
224
225
  },
225
- [navigate],
226
+ [navigate, retainPanels],
226
227
  );
227
228
 
228
229
  const handleCloseMessage = useCallback(() => {
229
230
  navigate({
230
231
  to: "/mail/outbox",
231
232
  search: (prev) => prev,
232
- hash: retainOpenPanels,
233
+ hash: retainPanels,
233
234
  });
234
- }, [navigate]);
235
+ }, [navigate, retainPanels]);
235
236
 
236
237
  const ctx: OutboxPaneContextValue = {
237
238
  messages,
@@ -10,7 +10,7 @@ import { useNavigate } from "@tanstack/react-router";
10
10
  import { useCallback, useState } from "react";
11
11
  import { toDisplayCategory } from "@/lib/display-category";
12
12
  import { formatEmailDate } from "@/lib/format";
13
- import { retainOpenPanels } from "@/routing";
13
+ import { useRetainOpenPanels } from "@/routing";
14
14
  import { MessageListItem } from "./MessageListItem";
15
15
  import { useModifierSelect } from "./useModifierSelect";
16
16
 
@@ -75,6 +75,7 @@ export const SwipeableMessageRow = ({
75
75
  }: SwipeableMessageRowProps) => {
76
76
  const [peek, setPeek] = useState<SwipePeek>("none");
77
77
  const navigate = useNavigate();
78
+ const retainPanels = useRetainOpenPanels();
78
79
 
79
80
  const handleAct = useCallback(
80
81
  (side: "leading" | "trailing") => {
@@ -106,9 +107,9 @@ export const SwipeableMessageRow = ({
106
107
  messageId: thread.messageId,
107
108
  },
108
109
  search: (prev) => prev,
109
- hash: retainOpenPanels,
110
+ hash: retainPanels,
110
111
  });
111
- }, [navigate, mailboxId, thread.threadId, thread.messageId]);
112
+ }, [navigate, retainPanels, mailboxId, thread.threadId, thread.messageId]);
112
113
 
113
114
  const modifierSelect = useModifierSelect(thread.messageId, onRowSelect);
114
115
 
@@ -0,0 +1,208 @@
1
+ import { type IntelligenceData, IntelligencePanel } from "@remit/ui";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { ChevronLeft, Info } from "lucide-react";
4
+ import { useState } from "react";
5
+ import { expect, userEvent, within } from "storybook/test";
6
+ import { Drawer } from "@/components/layout/Drawer";
7
+ import { resolveRailOpen } from "@/lib/intelligence-pref";
8
+ import { parseOpenPanels, retainOpenPanelsAtTier } from "@/routing";
9
+
10
+ /**
11
+ * The intelligence drawer below the desktop tier (#777).
12
+ *
13
+ * There is no fourth pane here: the rail is a full-screen drawer over the open
14
+ * message, so it belongs to the thread it was opened for. The address is the
15
+ * only place its open state lives, and a navigation drops it — otherwise a DKIM
16
+ * mismatch on one message leaves the drawer covering every message opened
17
+ * after it.
18
+ *
19
+ * Both rules are the app's own: the fragment goes through
20
+ * `retainOpenPanelsAtTier`, and what is up is `resolveRailOpen`.
21
+ */
22
+ const meta: Meta = {
23
+ title: "Flows/Reading/Intelligence Drawer",
24
+ parameters: { layout: "centered" },
25
+ };
26
+ export default meta;
27
+
28
+ type Story = StoryObj;
29
+
30
+ const PHONE_WIDTH = 390;
31
+ const PHONE_HEIGHT = 720;
32
+
33
+ const retainOnPhone = retainOpenPanelsAtTier(false);
34
+
35
+ const intelligence: IntelligenceData = {
36
+ sender: {
37
+ name: "Mondial Relay",
38
+ email: "delivery.notice.4421@gmail.example",
39
+ trust: "unknown",
40
+ firstSeenLabel: "today",
41
+ inboundCount: 1,
42
+ replyCount: 0,
43
+ },
44
+ authenticity: {
45
+ verdict: "mismatch",
46
+ fromDomain: "mondialrelay.fr",
47
+ dkimDomain: "gmail.example",
48
+ claimedBrand: "Mondial Relay",
49
+ summary:
50
+ "The display name claims “Mondial Relay”, but the message was sent and signed by a personal gmail.example mailbox — not mondialrelay.fr.",
51
+ similarCount: 15,
52
+ },
53
+ category: { value: "automated" },
54
+ similar: [],
55
+ };
56
+
57
+ interface Message {
58
+ id: string;
59
+ from: string;
60
+ subject: string;
61
+ body: string;
62
+ }
63
+
64
+ const messages: readonly Message[] = [
65
+ {
66
+ id: "msg-parcel",
67
+ from: "Mondial Relay",
68
+ subject: "Your parcel could not be delivered",
69
+ body: "We attempted to deliver your parcel today and nobody was home. Pay the outstanding €2.40 redelivery fee within 24 hours.",
70
+ },
71
+ {
72
+ id: "msg-standup",
73
+ from: "Alex Rivera",
74
+ subject: "Standup moved to 10:15",
75
+ body: "Pushing today's standup back fifteen minutes — the room is taken until then.",
76
+ },
77
+ ];
78
+
79
+ /**
80
+ * The phone reader, driven by the address alone: `hash` is the whole state,
81
+ * and every navigation writes it through the same helper the panes use.
82
+ */
83
+ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
84
+ const [hash, setHash] = useState(initialHash);
85
+ const [openId, setOpenId] = useState<string | undefined>(messages[0]?.id);
86
+ const open = messages.find((message) => message.id === openId);
87
+ const drawerOpen = resolveRailOpen({
88
+ panels: parseOpenPanels(hash),
89
+ prefersOpen: true,
90
+ isDesktop: false,
91
+ hasThread: open !== undefined,
92
+ });
93
+ const navigate = (nextId: string | undefined) => {
94
+ setHash(retainOnPhone(hash));
95
+ setOpenId(nextId);
96
+ };
97
+
98
+ return (
99
+ <div className="flex flex-col gap-2">
100
+ <div
101
+ className="flex flex-col overflow-hidden rounded-lg border border-line bg-canvas"
102
+ // The transform makes the frame a containing block, so the drawer's
103
+ // `position: fixed` resolves against the phone instead of the canvas.
104
+ style={{
105
+ width: PHONE_WIDTH,
106
+ height: PHONE_HEIGHT,
107
+ transform: "translateZ(0)",
108
+ }}
109
+ >
110
+ {open ? (
111
+ <>
112
+ <header className="flex items-center gap-1 border-b border-line px-2 py-2">
113
+ <button
114
+ type="button"
115
+ aria-label="Back"
116
+ onClick={() => navigate(undefined)}
117
+ className="inline-flex size-11 items-center justify-center rounded-md text-fg hover:bg-surface-raised"
118
+ >
119
+ <ChevronLeft className="size-5" />
120
+ </button>
121
+ <h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
122
+ {open.subject}
123
+ </h1>
124
+ <button
125
+ type="button"
126
+ aria-label="Message details"
127
+ onClick={() => setHash("intelligence")}
128
+ className="inline-flex size-11 items-center justify-center rounded-md text-fg hover:bg-surface-raised"
129
+ >
130
+ <Info className="size-5" />
131
+ </button>
132
+ </header>
133
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 py-3 text-sm text-fg">
134
+ <p className="mb-2 text-2xs text-fg-subtle">{open.from}</p>
135
+ <p>{open.body}</p>
136
+ </div>
137
+ </>
138
+ ) : (
139
+ <ul className="flex flex-col">
140
+ {messages.map((message) => (
141
+ <li key={message.id}>
142
+ <button
143
+ type="button"
144
+ onClick={() => navigate(message.id)}
145
+ className="flex w-full flex-col items-start gap-0.5 border-b border-line px-4 py-3 text-left hover:bg-surface-raised"
146
+ >
147
+ <span className="text-sm font-semibold text-fg">
148
+ {message.from}
149
+ </span>
150
+ <span className="text-sm text-fg-muted">
151
+ {message.subject}
152
+ </span>
153
+ </button>
154
+ </li>
155
+ ))}
156
+ </ul>
157
+ )}
158
+ <Drawer
159
+ isOpen={drawerOpen}
160
+ onClose={() => setHash("")}
161
+ ariaLabel="Message details"
162
+ side="right"
163
+ >
164
+ <IntelligencePanel data={intelligence} hideCloseButton />
165
+ </Drawer>
166
+ </div>
167
+ <p className="font-mono text-2xs text-fg-subtle">
168
+ {`/mail/inbox${open ? `/${open.id}` : ""}${hash ? `#${hash}` : ""}`}
169
+ </p>
170
+ </div>
171
+ );
172
+ };
173
+
174
+ /** A message with no drawer over it: the address names no panel. */
175
+ export const Closed: Story = {
176
+ render: () => <PhoneReader />,
177
+ };
178
+
179
+ /**
180
+ * The drawer over the message it was opened for — here by a DKIM mismatch,
181
+ * which opens it without being asked.
182
+ */
183
+ export const OpenOverThread: Story = {
184
+ render: () => <PhoneReader initialHash="intelligence" />,
185
+ };
186
+
187
+ /**
188
+ * The same drawer, then Back and another message. The fragment is dropped on
189
+ * the way out, so the second message is not covered by a panel opened for the
190
+ * first. Press Back with the drawer up to walk it.
191
+ */
192
+ export const DroppedByNavigation: Story = {
193
+ render: () => <PhoneReader initialHash="intelligence" />,
194
+ play: async ({ canvasElement }) => {
195
+ const canvas = within(canvasElement);
196
+ await userEvent.click(canvas.getByLabelText("Back"));
197
+ await userEvent.click(canvas.getByText("Standup moved to 10:15"));
198
+ await expect(canvas.queryByRole("dialog")).toBeNull();
199
+ await expect(canvas.getByText("/mail/inbox/msg-standup")).toBeVisible();
200
+ },
201
+ };
202
+
203
+ /** The drawer over the dark theme. */
204
+ export const OpenOverThreadDark: Story = {
205
+ name: "Open Over Thread (dark)",
206
+ parameters: { theme: "dark" },
207
+ render: () => <PhoneReader initialHash="intelligence" />,
208
+ };
@@ -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
  });
@@ -1,6 +1,7 @@
1
1
  import { useLocation, useNavigate } from "@tanstack/react-router";
2
2
  import { useCallback, useMemo } from "react";
3
3
  import { z } from "zod";
4
+ import { useIsDesktop } from "@/hooks/useMediaQuery";
4
5
 
5
6
  /**
6
7
  * The fragment tier carries panel visibility and nothing else — no identity, no
@@ -57,21 +58,48 @@ export function useOpenPanels(): readonly PanelFragment[] {
57
58
  return useMemo(() => parseOpenPanels(hash), [hash]);
58
59
  }
59
60
 
61
+ /**
62
+ * Whether a navigation carries this panel at this tier.
63
+ *
64
+ * A pane is chrome a reader keeps up while they move from one conversation to
65
+ * the next; an overlay is dismissed by going somewhere, which is what closing
66
+ * it means. Below desktop the rail is not a pane at all — it is a full-screen
67
+ * drawer over the open message — so it belongs to the thread it was opened for
68
+ * and a navigation leaves it behind, rather than covering a message nobody
69
+ * asked to cover (#777).
70
+ */
71
+ const isRetainedPanel = (panel: PanelFragment, isDesktop: boolean): boolean =>
72
+ !isOverlayPanel(panel) && isDesktop;
73
+
60
74
  /**
61
75
  * A destination's `hash`, for a navigation that keeps the reader where they
62
76
  * are. The router drops the fragment on every navigation unless the destination
63
77
  * asks for it, so this is what a link or a `navigate` call passes to state the
64
- * rule rather than restate it: a pane is chrome a reader keeps up while they
65
- * move from one conversation to the next, and an overlay is dismissed by going
66
- * somewhere, which is what closing it means.
78
+ * rule rather than restate it.
67
79
  *
68
- * The parameter is optional because the router hands a `hash` updater the
69
- * previous fragment as `string | undefined`.
80
+ * The tier is bound here rather than passed at the call site, so the updater
81
+ * still has the shape the router hands a previous fragment to. That parameter
82
+ * is optional because the router types it `string | undefined`.
70
83
  */
71
- export function retainOpenPanels(hash = ""): string {
72
- return formatOpenPanels(
73
- parseOpenPanels(hash).filter((panel) => !isOverlayPanel(panel)),
74
- );
84
+ export function retainOpenPanelsAtTier(
85
+ isDesktop: boolean,
86
+ ): (hash?: string) => string {
87
+ return (hash = "") =>
88
+ formatOpenPanels(
89
+ parseOpenPanels(hash).filter((panel) =>
90
+ isRetainedPanel(panel, isDesktop),
91
+ ),
92
+ );
93
+ }
94
+
95
+ /**
96
+ * The `hash` updater for a navigation from the surface the reader is on. The
97
+ * layout tier is read here, once, so no call site decides for itself what a
98
+ * navigation keeps.
99
+ */
100
+ export function useRetainOpenPanels(): (hash?: string) => string {
101
+ const isDesktop = useIsDesktop();
102
+ return useMemo(() => retainOpenPanelsAtTier(isDesktop), [isDesktop]);
75
103
  }
76
104
 
77
105
  /**
@@ -6,8 +6,9 @@ export {
6
6
  type PanelFragment,
7
7
  panelFragments,
8
8
  parseOpenPanels,
9
- retainOpenPanels,
9
+ retainOpenPanelsAtTier,
10
10
  useOpenPanels,
11
+ useRetainOpenPanels,
11
12
  useSetOpenPanels,
12
13
  } from "./fragment";
13
14
  export { NavLink, type NavLinkProps } from "./nav-link";
@@ -7,7 +7,7 @@ import type {
7
7
  } from "@tanstack/react-router";
8
8
  import { createLink } from "@tanstack/react-router";
9
9
  import type { ReactElement } from "react";
10
- import { type PanelFragment, retainOpenPanels } from "./fragment";
10
+ import { type PanelFragment, useRetainOpenPanels } from "./fragment";
11
11
 
12
12
  const RouterNavLink = createLink(NavLinkSurface);
13
13
 
@@ -45,7 +45,8 @@ export type NavLinkProps<
45
45
  *
46
46
  * A link that names no panel carries the open panes across: the rail is up
47
47
  * until the reader puts it down, and every row in a list is a link. Overlays
48
- * are left behind, because going somewhere is what dismisses one.
48
+ * are left behind, because going somewhere is what dismisses one — and below
49
+ * desktop so is the rail, which is a drawer over the message there.
49
50
  */
50
51
  export function NavLink<
51
52
  TRouter extends AnyRouter = RegisteredRouter,
@@ -58,5 +59,6 @@ export function NavLink({
58
59
  fragment,
59
60
  ...props
60
61
  }: CreateLinkProps & { fragment?: PanelFragment }): ReactElement {
61
- return <RouterNavLink {...props} hash={fragment ?? retainOpenPanels} />;
62
+ const retainPanels = useRetainOpenPanels();
63
+ return <RouterNavLink {...props} hash={fragment ?? retainPanels} />;
62
64
  }