@remit/web-client 0.0.162 → 0.0.163

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.162",
3
+ "version": "0.0.163",
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": {
@@ -41,7 +41,11 @@ import {
41
41
  } from "@/hooks/useTriageLayer";
42
42
  import type { ConversationTarget } from "@/lib/conversation-target";
43
43
  import { useMailContext } from "@/lib/mail-context";
44
- import type { OpenThreadPath, OpenThreadTarget } from "@/routing";
44
+ import {
45
+ type OpenThreadPath,
46
+ type OpenThreadTarget,
47
+ retainOpenPanels,
48
+ } from "@/routing";
45
49
 
46
50
  /* ------------------------------------------------------------------ */
47
51
  /* Context */
@@ -148,13 +152,18 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
148
152
  // `searchInput`: a row can be tapped before the debounce settles, when
149
153
  // the committed query is still empty.
150
154
  search: (prev) => ({ ...prev, q: searchInput || undefined }),
155
+ hash: retainOpenPanels,
151
156
  });
152
157
  },
153
158
  [navigate, searchInput],
154
159
  );
155
160
 
156
161
  const handleCloseThread = useCallback(() => {
157
- navigate({ to: "/mail/brief", search: (prev) => prev });
162
+ navigate({
163
+ to: "/mail/brief",
164
+ search: (prev) => prev,
165
+ hash: retainOpenPanels,
166
+ });
158
167
  }, [navigate]);
159
168
 
160
169
  const handleDeselectIfRemoved = useCallback(
@@ -41,6 +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
45
  import { NavMenuButton } from "./NavMenuButton";
45
46
 
46
47
  // ---------------------------------------------------------------------------
@@ -197,6 +198,7 @@ export function DraftsView({
197
198
  to: "/mail/$mailboxId/$threadId/$messageId",
198
199
  params: { mailboxId, threadId, messageId },
199
200
  search: (prev) => prev,
201
+ hash: retainOpenPanels,
200
202
  });
201
203
  };
202
204
 
@@ -52,7 +52,11 @@ import {
52
52
  } from "@/hooks/useTriageLayer";
53
53
  import type { ConversationTarget } from "@/lib/conversation-target";
54
54
  import { useMailContext } from "@/lib/mail-context";
55
- import type { OpenThreadPath, OpenThreadTarget } from "@/routing";
55
+ import {
56
+ type OpenThreadPath,
57
+ type OpenThreadTarget,
58
+ retainOpenPanels,
59
+ } from "@/routing";
56
60
 
57
61
  /* ------------------------------------------------------------------ */
58
62
  /* Context */
@@ -154,13 +158,18 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
154
158
  // `searchInput`: a row can be tapped before the debounce settles, when
155
159
  // the committed query is still empty.
156
160
  search: (prev) => ({ ...prev, q: searchInput || undefined }),
161
+ hash: retainOpenPanels,
157
162
  });
158
163
  },
159
164
  [navigate, searchInput],
160
165
  );
161
166
 
162
167
  const handleCloseThread = useCallback(() => {
163
- navigate({ to: "/mail/flagged", search: (prev) => prev });
168
+ navigate({
169
+ to: "/mail/flagged",
170
+ search: (prev) => prev,
171
+ hash: retainOpenPanels,
172
+ });
164
173
  }, [navigate]);
165
174
 
166
175
  const handleDeselectIfRemoved = useCallback(
@@ -108,7 +108,6 @@ import {
108
108
  inboxFilterParams,
109
109
  sameInboxFilter,
110
110
  } from "@/lib/inbox-filters";
111
- import { readIntelligencePref } from "@/lib/intelligence-pref";
112
111
  import { junkDestination } from "@/lib/junk-destination";
113
112
  import { useMailContext } from "@/lib/mail-context";
114
113
  import { useMailFreshness } from "@/lib/mail-freshness";
@@ -125,7 +124,11 @@ import {
125
124
  applyResidualTokens,
126
125
  threadSearchTokens,
127
126
  } from "@/lib/thread-search-tokens";
128
- import type { OpenThreadPath, OpenThreadTarget } from "@/routing";
127
+ import {
128
+ type OpenThreadPath,
129
+ type OpenThreadTarget,
130
+ retainOpenPanels,
131
+ } from "@/routing";
129
132
  import { MailViewChrome } from "./MailViewChrome";
130
133
 
131
134
  /* ------------------------------------------------------------------ */
@@ -263,16 +266,9 @@ function MailboxPaneProvider({
263
266
  const navigate = useNavigate();
264
267
  const threadId = thread?.threadId;
265
268
  const pointedAtMessageId = thread?.messageId;
266
- const tier = useLayoutTier();
267
- const isDesktop = tier === "desktop";
268
269
  const telemetry = useTelemetry();
269
- const {
270
- accounts,
271
- searchQuery,
272
- intelligenceOpen,
273
- onToggleIntelligence,
274
- onSetIntelligenceOpen,
275
- } = useMailContext();
270
+ const { accounts, searchQuery, intelligenceOpen, onToggleIntelligence } =
271
+ useMailContext();
276
272
  const tokenContext = useSearchTokenContext();
277
273
 
278
274
  const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
@@ -471,6 +467,7 @@ function MailboxPaneProvider({
471
467
  to: "/mail/$mailboxId",
472
468
  params: { mailboxId },
473
469
  search: (prev) => prev,
470
+ hash: retainOpenPanels,
474
471
  });
475
472
  }, [mailboxId, navigate]);
476
473
 
@@ -480,6 +477,7 @@ function MailboxPaneProvider({
480
477
  to: "/mail/$mailboxId/$threadId/$messageId",
481
478
  params: { mailboxId, ...target },
482
479
  search: (prev) => prev,
480
+ hash: retainOpenPanels,
483
481
  });
484
482
  },
485
483
  [mailboxId, navigate],
@@ -535,23 +533,6 @@ function MailboxPaneProvider({
535
533
  onToggleIntelligence,
536
534
  ]);
537
535
 
538
- // Desktop default-open (#782)
539
- const appliedDefaultRef = useRef(false);
540
- useEffect(() => {
541
- if (appliedDefaultRef.current) return;
542
- if (!isDesktop) return;
543
- if (!selectedThread?.messageId) return;
544
- appliedDefaultRef.current = true;
545
- if (readIntelligencePref() && !intelligenceOpen) {
546
- onSetIntelligenceOpen(true);
547
- }
548
- }, [
549
- isDesktop,
550
- selectedThread?.messageId,
551
- intelligenceOpen,
552
- onSetIntelligenceOpen,
553
- ]);
554
-
555
536
  // The mailbox's own unseen total. A count over the loaded pages undercounts
556
537
  // every mailbox larger than one page and creeps upward as the user scrolls,
557
538
  // so there is no fallback: until the mailbox resolves there is no number.
@@ -1048,6 +1029,7 @@ function MailboxList() {
1048
1029
  // `searchInput`: a row can be tapped before the debounce settles, when
1049
1030
  // the committed query is still empty.
1050
1031
  search: (prev) => ({ ...prev, q: searchInput || undefined }),
1032
+ hash: retainOpenPanels,
1051
1033
  });
1052
1034
  },
1053
1035
  [mailboxId, navigate, searchInput, threads],
@@ -35,7 +35,7 @@ import {
35
35
  type ThreadListSnapshotEntry,
36
36
  threadListCacheKeys,
37
37
  } from "@/lib/thread-list-cache";
38
- import { useOpenThreadPath } from "@/routing";
38
+ import { retainOpenPanels, useOpenThreadPath } from "@/routing";
39
39
  import { MoveToTrigger } from "./MoveToTrigger";
40
40
 
41
41
  interface ThreadMessagesData {
@@ -193,6 +193,7 @@ export const MessageActionMenu = ({
193
193
  to: "/mail/$mailboxId",
194
194
  params: { mailboxId },
195
195
  search: (prev) => prev,
196
+ hash: retainOpenPanels,
196
197
  });
197
198
  },
198
199
  [selectedMessageId, mailboxId, navigate],
@@ -53,6 +53,7 @@ import { listVerbRequest } from "@/lib/list-verb-request";
53
53
  import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
54
54
  import { cn } from "@/lib/utils";
55
55
  import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
56
+ import { retainOpenPanels } from "@/routing";
56
57
  import { LabelApplyTrigger } from "./LabelApplyTrigger";
57
58
  import {
58
59
  type EscalatedSelection,
@@ -634,6 +635,7 @@ export const MessageList = ({
634
635
  to: "/mail/$mailboxId/$threadId/$messageId",
635
636
  params: { mailboxId, threadId, messageId },
636
637
  search: (prev) => prev,
638
+ hash: retainOpenPanels,
637
639
  replace: options?.replace,
638
640
  });
639
641
  },
@@ -63,6 +63,7 @@ import { isOutboxListRow, isUnsendableStatus } from "@/lib/outbox-status";
63
63
  import { normalizeSearchQuery } from "@/lib/search-query";
64
64
  import { parseSearchTokens } from "@/lib/search-tokens";
65
65
  import { cn } from "@/lib/utils";
66
+ import { retainOpenPanels } from "@/routing";
66
67
 
67
68
  /* ------------------------------------------------------------------ */
68
69
  /* Helpers (shared between List, Reading, Phone sub-views) */
@@ -218,13 +219,18 @@ function OutboxPaneProvider({
218
219
  to: "/mail/outbox/draft/$outboxMessageId",
219
220
  params: { outboxMessageId },
220
221
  search: (prev) => prev,
222
+ hash: retainOpenPanels,
221
223
  });
222
224
  },
223
225
  [navigate],
224
226
  );
225
227
 
226
228
  const handleCloseMessage = useCallback(() => {
227
- navigate({ to: "/mail/outbox", search: (prev) => prev });
229
+ navigate({
230
+ to: "/mail/outbox",
231
+ search: (prev) => prev,
232
+ hash: retainOpenPanels,
233
+ });
228
234
  }, [navigate]);
229
235
 
230
236
  const ctx: OutboxPaneContextValue = {
@@ -10,6 +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
14
  import { MessageListItem } from "./MessageListItem";
14
15
  import { useModifierSelect } from "./useModifierSelect";
15
16
 
@@ -105,6 +106,7 @@ export const SwipeableMessageRow = ({
105
106
  messageId: thread.messageId,
106
107
  },
107
108
  search: (prev) => prev,
109
+ hash: retainOpenPanels,
108
110
  });
109
111
  }, [navigate, mailboxId, thread.threadId, thread.messageId]);
110
112
 
@@ -22,6 +22,8 @@ export const KeyboardShortcutsModal = ({
22
22
  (event: KeyboardEvent) => {
23
23
  if (event.key === "Escape") {
24
24
  event.preventDefault();
25
+ event.stopPropagation();
26
+ event.stopImmediatePropagation();
25
27
  onClose();
26
28
  }
27
29
  },
@@ -30,8 +32,11 @@ export const KeyboardShortcutsModal = ({
30
32
 
31
33
  useEffect(() => {
32
34
  if (!isOpen) return;
33
- window.addEventListener("keydown", handleKeyDown);
34
- return () => window.removeEventListener("keydown", handleKeyDown);
35
+ // Capture phase, as `ConfirmDialog` does: the sheet is the topmost surface,
36
+ // so Escape dismisses it and nothing else. Shared with the list's own
37
+ // Escape, the one keystroke also closed the conversation underneath.
38
+ window.addEventListener("keydown", handleKeyDown, true);
39
+ return () => window.removeEventListener("keydown", handleKeyDown, true);
35
40
  }, [isOpen, handleKeyDown]);
36
41
 
37
42
  if (!isOpen) return null;
@@ -65,7 +65,6 @@ const mailContext = (
65
65
  onSearchClearQuery: () => {},
66
66
  intelligenceOpen: false,
67
67
  onToggleIntelligence: () => {},
68
- onSetIntelligenceOpen: () => {},
69
68
  });
70
69
 
71
70
  type ListPath = "/mail/brief" | "/mail/flagged";
@@ -71,16 +71,24 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
71
71
  ...prev,
72
72
  q: committedQuery || undefined,
73
73
  });
74
+ // A query is a mode of the view the reader is already in, so the panels
75
+ // they have up are not ones they navigated away from.
74
76
  if (!queryGoesActive) {
75
- navigate({ to: ".", search, replace: true });
77
+ navigate({ to: ".", search, hash: true, replace: true });
76
78
  return;
77
79
  }
78
80
  if (to === "/mail/$mailboxId") {
79
81
  if (!mailboxId) return;
80
- navigate({ to, params: { mailboxId }, search, replace: true });
82
+ navigate({
83
+ to,
84
+ params: { mailboxId },
85
+ search,
86
+ hash: true,
87
+ replace: true,
88
+ });
81
89
  return;
82
90
  }
83
- navigate({ to, search, replace: true });
91
+ navigate({ to, search, hash: true, replace: true });
84
92
  }, [
85
93
  searchInput,
86
94
  committedQuery,
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { afterEach, beforeEach, describe, it } from "node:test";
3
3
  import {
4
4
  readIntelligencePref,
5
+ resolveRailOpen,
5
6
  writeIntelligencePref,
6
7
  } from "./intelligence-pref.js";
7
8
 
@@ -55,3 +56,79 @@ describe("intelligence-pref (#782)", () => {
55
56
  assert.doesNotThrow(() => writeIntelligencePref(false));
56
57
  });
57
58
  });
59
+
60
+ describe("resolveRailOpen (#722)", () => {
61
+ const withThread = { hasThread: true, isDesktop: true };
62
+
63
+ it("opens the rail with the thread where the address says nothing", () => {
64
+ assert.equal(
65
+ resolveRailOpen({ ...withThread, panels: [], prefersOpen: true }),
66
+ true,
67
+ );
68
+ });
69
+
70
+ it("leaves the rail down where the address says nothing and the reader collapsed it", () => {
71
+ assert.equal(
72
+ resolveRailOpen({ ...withThread, panels: [], prefersOpen: false }),
73
+ false,
74
+ );
75
+ });
76
+
77
+ it("never seeds the rail below the tier that has one", () => {
78
+ assert.equal(
79
+ resolveRailOpen({
80
+ panels: [],
81
+ prefersOpen: true,
82
+ isDesktop: false,
83
+ hasThread: true,
84
+ }),
85
+ false,
86
+ );
87
+ });
88
+
89
+ // Otherwise the address claims a pane the shell has nothing to put in it.
90
+ it("never seeds the rail with no conversation open", () => {
91
+ assert.equal(
92
+ resolveRailOpen({
93
+ panels: [],
94
+ prefersOpen: true,
95
+ isDesktop: true,
96
+ hasThread: false,
97
+ }),
98
+ false,
99
+ );
100
+ });
101
+
102
+ // A shared link naming another panel is an address that has spoken: the
103
+ // recipient's own preference does not get to add the rail to it.
104
+ it("hands a shared link's panels to the reader who opened it", () => {
105
+ assert.equal(
106
+ resolveRailOpen({
107
+ ...withThread,
108
+ panels: ["shortcuts"],
109
+ prefersOpen: true,
110
+ }),
111
+ false,
112
+ );
113
+ assert.equal(
114
+ resolveRailOpen({
115
+ ...withThread,
116
+ panels: ["intelligence", "shortcuts"],
117
+ prefersOpen: false,
118
+ }),
119
+ true,
120
+ );
121
+ });
122
+
123
+ it("opens the rail from an address on any tier", () => {
124
+ assert.equal(
125
+ resolveRailOpen({
126
+ panels: ["intelligence"],
127
+ prefersOpen: false,
128
+ isDesktop: false,
129
+ hasThread: true,
130
+ }),
131
+ true,
132
+ );
133
+ });
134
+ });
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Persistence for the intelligence-pane open/closed preference (#782).
2
+ * Persistence for the intelligence-pane open/closed preference (#782), and the
3
+ * precedence between it and the address (#722).
3
4
  *
4
5
  * The pane opens with the thread by default; a manual collapse sticks across
5
6
  * sessions. Storage failures (private mode / quota) fall back to the default
6
7
  * (open) rather than crashing.
7
8
  */
9
+ import type { PanelFragment } from "@/routing";
8
10
 
9
11
  export const INTELLIGENCE_PREF_KEY = "remit:intelligence-open";
10
12
 
@@ -23,3 +25,33 @@ export function writeIntelligencePref(open: boolean): void {
23
25
  // Storage unavailable — the in-memory default stands.
24
26
  }
25
27
  }
28
+
29
+ export interface RailVisibility {
30
+ /** The panels the address names. */
31
+ panels: readonly PanelFragment[];
32
+ prefersOpen: boolean;
33
+ /** The rail is a pane of the desktop shell; narrower tiers have a drawer. */
34
+ isDesktop: boolean;
35
+ /** The rail reads a conversation, so with none open there is nothing to be up. */
36
+ hasThread: boolean;
37
+ }
38
+
39
+ /**
40
+ * Whether the rail is up.
41
+ *
42
+ * An address that names any panel is the only owner of what is open, so a
43
+ * shared link showing the shortcuts sheet is not overwritten by the recipient's
44
+ * own preference. The preference speaks only where the address is silent, and
45
+ * only with a conversation open on the tier that has a rail — it opens with the
46
+ * thread there (#782), while a phone would get a full-screen drawer over a
47
+ * message nobody asked to cover.
48
+ */
49
+ export function resolveRailOpen({
50
+ panels,
51
+ prefersOpen,
52
+ isDesktop,
53
+ hasThread,
54
+ }: RailVisibility): boolean {
55
+ if (panels.length > 0) return panels.includes("intelligence");
56
+ return isDesktop && hasThread && prefersOpen;
57
+ }
@@ -47,13 +47,14 @@ export interface MailContextValue {
47
47
  onSearchClear: () => void;
48
48
  /** Query-only clear (Esc): drops the query, keeps the thread open (#489). */
49
49
  onSearchClearQuery: () => void;
50
- /** Pane 4 (intelligence) visibility. The shared toggle starts closed; the
51
- * desktop route opens it by default with the thread, honouring the stored
52
- * preference (#782). */
50
+ /**
51
+ * Whether pane 4 (intelligence) is up. Resolved once by the `/mail` layout
52
+ * from the address and this device's preference (`resolveRailOpen`), and
53
+ * handed down as the answer — a consumer re-deriving it from the fragment
54
+ * alone would lose the preference and disagree with the shell.
55
+ */
53
56
  intelligenceOpen: boolean;
54
57
  onToggleIntelligence: () => void;
55
- /** Set the pane open/closed and persist the choice (desktop default-open). */
56
- onSetIntelligenceOpen: (open: boolean) => void;
57
58
  }
58
59
 
59
60
  export const MailContext = createContext<MailContextValue | null>(null);
@@ -77,7 +78,6 @@ export const useMailContext = (): MailContextValue => {
77
78
  onSearchClearQuery: () => {},
78
79
  intelligenceOpen: false,
79
80
  onToggleIntelligence: () => {},
80
- onSetIntelligenceOpen: () => {},
81
81
  }
82
82
  );
83
83
  };
@@ -27,13 +27,24 @@ import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
27
27
  import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
28
28
  import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
29
29
  import { hostsComposeSurface } from "@/lib/compose-routes";
30
- import { writeIntelligencePref } from "@/lib/intelligence-pref";
30
+ import {
31
+ readIntelligencePref,
32
+ resolveRailOpen,
33
+ writeIntelligencePref,
34
+ } from "@/lib/intelligence-pref";
31
35
  import { MailContext } from "@/lib/mail-context";
32
36
  import { MailFreshnessProvider } from "@/lib/mail-freshness";
33
37
  import { mailViewKey } from "@/lib/mail-route";
34
38
  import { buildAccountNameIndex } from "@/lib/search-token-index";
35
39
  import { committedSearchQuery, searchInputForView } from "@/lib/search-view";
36
40
  import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
41
+ import {
42
+ isOverlayPanel,
43
+ type OverlayPanel,
44
+ useOpenPanels,
45
+ useOpenThreadPath,
46
+ useSetOpenPanels,
47
+ } from "@/routing";
37
48
  import "@/lib/client";
38
49
 
39
50
  // `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
@@ -79,19 +90,56 @@ function MailLayout() {
79
90
  // tablet with no compose surface (compose lives in the reading pane, which
80
91
  // tablet doesn't mount) — the "c" shortcut / FAB opened nothing.
81
92
  const isSinglePane = isSinglePaneTier(tier);
82
- const [showShortcuts, setShowShortcuts] = useState(false);
83
- const [drawerOpen, setDrawerOpen] = useState(false);
84
- // Pane 4 / the mobile details drawer share this toggle. It starts closed so
85
- // the phone never slams a full-screen intelligence drawer over a freshly
86
- // opened thread; the DESKTOP route opens it by default with the thread (the
87
- // intelligence rail is the product's core value) and honours the persisted
88
- // collapse preference there (#782). DKIM-mismatch auto-open still fires on
89
- // every tier. Explicit toggles persist the user's choice.
90
- const [intelligenceOpen, setIntelligenceOpen] = useState(false);
91
- const handleSetIntelligenceOpen = useCallback((open: boolean) => {
92
- setIntelligenceOpen(open);
93
- writeIntelligencePref(open);
94
- }, []);
93
+ // The panels the address carries (#722): the intelligence rail, the nav
94
+ // slide-over and the shortcuts sheet. The rail is a pane and the other two
95
+ // cover it, so the address holds a pane and an overlay at once — a sheet
96
+ // opening never takes the rail down while two overlays cannot both be up.
97
+ const openPanels = useOpenPanels();
98
+ const setOpenPanels = useSetOpenPanels();
99
+ const openOverlay = openPanels.find(isOverlayPanel);
100
+ const showShortcuts = openOverlay === "shortcuts";
101
+ const drawerOpen = openOverlay === "nav";
102
+ // Pane 4 on desktop, the details drawer below it. `resolveRailOpen` is the
103
+ // one place the address and the stored preference meet: the address decides
104
+ // whenever it says anything at all, and the preference opens the rail with
105
+ // the thread where it is silent (#782).
106
+ const openThread = useOpenThreadPath();
107
+ // Held in state, not read back from storage each render: closing the rail
108
+ // where the address is silent changes nothing about the address, and the
109
+ // answer has to move anyway.
110
+ const [prefersRail, setPrefersRail] = useState(readIntelligencePref);
111
+ const intelligenceOpen = resolveRailOpen({
112
+ panels: openPanels,
113
+ prefersOpen: prefersRail,
114
+ isDesktop: tier === "desktop",
115
+ hasThread: openThread !== undefined,
116
+ });
117
+ // Every write states the whole set, because it is composed from what is
118
+ // showing rather than from what the address happens to spell: the rail open
119
+ // by preference alone is still open, and an overlay must not close it.
120
+ const showPanels = useCallback(
121
+ (rail: boolean, overlay: OverlayPanel | undefined) => {
122
+ setOpenPanels([
123
+ ...(rail ? (["intelligence"] as const) : []),
124
+ ...(overlay ? [overlay] : []),
125
+ ]);
126
+ },
127
+ [setOpenPanels],
128
+ );
129
+ const handleSetIntelligenceOpen = useCallback(
130
+ (open: boolean) => {
131
+ writeIntelligencePref(open);
132
+ setPrefersRail(open);
133
+ showPanels(open, openOverlay);
134
+ },
135
+ [openOverlay, showPanels],
136
+ );
137
+ const showOverlay = useCallback(
138
+ (overlay: OverlayPanel | undefined) => {
139
+ showPanels(intelligenceOpen, overlay);
140
+ },
141
+ [intelligenceOpen, showPanels],
142
+ );
95
143
 
96
144
  // Within one view, URL `q` seeds the input and is a one-directional write
97
145
  // target: the debounced local value drives the search API and is mirrored
@@ -157,7 +205,7 @@ function MailLayout() {
157
205
  bindings: [
158
206
  {
159
207
  key: "?",
160
- handler: () => setShowShortcuts(true),
208
+ handler: () => showOverlay("shortcuts"),
161
209
  noModifiers: false, // Allow shift+/
162
210
  preventDefault: true,
163
211
  },
@@ -193,12 +241,8 @@ function MailLayout() {
193
241
  }, []);
194
242
 
195
243
  const handleToggleIntelligence = useCallback(() => {
196
- setIntelligenceOpen((open) => {
197
- const next = !open;
198
- writeIntelligencePref(next);
199
- return next;
200
- });
201
- }, []);
244
+ handleSetIntelligenceOpen(!intelligenceOpen);
245
+ }, [handleSetIntelligenceOpen, intelligenceOpen]);
202
246
 
203
247
  const accounts = config?.accounts ?? [];
204
248
  const accountIds = useMemo(
@@ -218,11 +262,12 @@ function MailLayout() {
218
262
  useStaleAccountSync(accounts);
219
263
 
220
264
  const handleMailboxSelect = useCallback(() => {
221
- // Auto-collapse the mobile drawer after the user picks an inbox
222
- // from the sidebar (#199). Desktop sidebar isn't a drawer so the
223
- // noop is fine there.
224
- setDrawerOpen(false);
225
- }, []);
265
+ // Auto-collapse the mobile drawer after the user picks an inbox from the
266
+ // sidebar (#199). Above the slide-over the sidebar is a pane, and there is
267
+ // no overlay of its own to take down.
268
+ if (openOverlay !== "nav") return;
269
+ showOverlay(undefined);
270
+ }, [openOverlay, showOverlay]);
226
271
 
227
272
  const mailContextValue = {
228
273
  accounts,
@@ -239,7 +284,6 @@ function MailLayout() {
239
284
  onSearchClearQuery: handleSearchClearQuery,
240
285
  intelligenceOpen,
241
286
  onToggleIntelligence: handleToggleIntelligence,
242
- onSetIntelligenceOpen: handleSetIntelligenceOpen,
243
287
  };
244
288
 
245
289
  // Single nav node: the kit renders it as a pane (≥1024px) or inside its
@@ -259,8 +303,8 @@ function MailLayout() {
259
303
  // top bar owns compose.
260
304
  overlay: isSinglePane ? <ComposeFab /> : undefined,
261
305
  navOpen: drawerOpen,
262
- onOpenNav: () => setDrawerOpen(true),
263
- onCloseNav: () => setDrawerOpen(false),
306
+ onOpenNav: () => showOverlay("nav"),
307
+ onCloseNav: () => showOverlay(undefined),
264
308
  };
265
309
 
266
310
  return (
@@ -284,7 +328,7 @@ function MailLayout() {
284
328
  )}
285
329
  <KeyboardShortcutsModal
286
330
  isOpen={showShortcuts}
287
- onClose={() => setShowShortcuts(false)}
331
+ onClose={() => showOverlay(undefined)}
288
332
  />
289
333
  </MailFreshnessProvider>
290
334
  </MailContext.Provider>
@@ -0,0 +1,68 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ formatOpenPanels,
5
+ panelFragments,
6
+ parseOpenPanels,
7
+ retainOpenPanels,
8
+ } from "./fragment.js";
9
+
10
+ describe("parseOpenPanels", () => {
11
+ it("reads every panel the union names", () => {
12
+ for (const panel of panelFragments) {
13
+ assert.deepEqual(parseOpenPanels(panel), [panel]);
14
+ }
15
+ });
16
+
17
+ it("reads a pane and an overlay together", () => {
18
+ assert.deepEqual(parseOpenPanels("intelligence,shortcuts"), [
19
+ "intelligence",
20
+ "shortcuts",
21
+ ]);
22
+ });
23
+
24
+ it("spells one set of panels one way", () => {
25
+ assert.deepEqual(parseOpenPanels("shortcuts,intelligence,shortcuts"), [
26
+ "intelligence",
27
+ "shortcuts",
28
+ ]);
29
+ });
30
+
31
+ it("reads an empty, unknown or mis-cased fragment as no panel", () => {
32
+ assert.deepEqual(parseOpenPanels(""), []);
33
+ assert.deepEqual(parseOpenPanels("confirm-delete"), []);
34
+ assert.deepEqual(parseOpenPanels("Intelligence"), []);
35
+ assert.deepEqual(parseOpenPanels("form:input:to"), []);
36
+ });
37
+
38
+ it("keeps the panels it recognises out of a fragment carrying junk", () => {
39
+ assert.deepEqual(parseOpenPanels("filters,intelligence"), ["intelligence"]);
40
+ });
41
+ });
42
+
43
+ describe("formatOpenPanels", () => {
44
+ it("writes the address a set of panels is read back from", () => {
45
+ assert.equal(
46
+ formatOpenPanels(["shortcuts", "intelligence"]),
47
+ "intelligence,shortcuts",
48
+ );
49
+ assert.equal(formatOpenPanels([]), "");
50
+ });
51
+ });
52
+
53
+ describe("retainOpenPanels", () => {
54
+ it("carries a pane across a navigation", () => {
55
+ assert.equal(retainOpenPanels("intelligence"), "intelligence");
56
+ });
57
+
58
+ 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
+ });
63
+
64
+ it("drops a fragment it does not recognise rather than passing it on", () => {
65
+ assert.equal(retainOpenPanels(), "");
66
+ assert.equal(retainOpenPanels("confirm-delete"), "");
67
+ });
68
+ });
@@ -1,4 +1,5 @@
1
- import { useLocation } from "@tanstack/react-router";
1
+ import { useLocation, useNavigate } from "@tanstack/react-router";
2
+ import { useCallback, useMemo } from "react";
2
3
  import { z } from "zod";
3
4
 
4
5
  /**
@@ -7,29 +8,91 @@ import { z } from "zod";
7
8
  * independent of in-memory data, so a cold load of the URL reproduces it
8
9
  * exactly.
9
10
  */
10
- export const panelFragments = [
11
- "intelligence",
12
- "nav",
13
- "shortcuts",
14
- "filters",
15
- ] as const;
11
+ export const panelFragments = ["intelligence", "nav", "shortcuts"] as const;
16
12
 
17
13
  export type PanelFragment = (typeof panelFragments)[number];
18
14
 
15
+ /**
16
+ * The panels that cover the view rather than sit in it. The rail is a pane in
17
+ * the shell's row and the other two are modal over it, so a sheet opening can
18
+ * never take the rail down with it — the address holds the pane and the overlay
19
+ * at once. Two overlays cannot be up together, because the second one hides the
20
+ * first.
21
+ */
22
+ export const overlayPanels = ["nav", "shortcuts"] as const;
23
+
24
+ export type OverlayPanel = (typeof overlayPanels)[number];
25
+
26
+ export const isOverlayPanel = (panel: PanelFragment): panel is OverlayPanel =>
27
+ (overlayPanels as readonly PanelFragment[]).includes(panel);
28
+
19
29
  const panelFragmentSchema = z.enum(panelFragments);
20
30
 
21
31
  /**
22
- * A hand-edited or stale URL is a normal thing to receive, so an unrecognised
23
- * fragment reads as "no panel open" instead of throwing.
32
+ * The panels the address asks for, in the union's own order so one set of open
33
+ * panels has one spelling. A hand-edited or stale URL is a normal thing to
34
+ * receive, so an unrecognised name is dropped instead of throwing.
24
35
  */
25
- export function parsePanelFragment(hash: string): PanelFragment | undefined {
26
- const parsed = panelFragmentSchema.safeParse(hash);
27
- return parsed.success ? parsed.data : undefined;
36
+ export function parseOpenPanels(hash: string): readonly PanelFragment[] {
37
+ const named = new Set(
38
+ hash
39
+ .split(",")
40
+ .map((token) => panelFragmentSchema.safeParse(token))
41
+ .filter((parsed) => parsed.success)
42
+ .map((parsed) => parsed.data),
43
+ );
44
+ return panelFragments.filter((panel) => named.has(panel));
45
+ }
46
+
47
+ /** The address a set of open panels is written as. */
48
+ export function formatOpenPanels(panels: readonly PanelFragment[]): string {
49
+ return panelFragments.filter((panel) => panels.includes(panel)).join(",");
28
50
  }
29
51
 
30
- /** The panel the URL currently asks for, if any. */
31
- export function useOpenPanel(): PanelFragment | undefined {
32
- return useLocation({
33
- select: (location) => parsePanelFragment(location.hash),
52
+ /** The panels the address currently asks for. */
53
+ export function useOpenPanels(): readonly PanelFragment[] {
54
+ const hash = useLocation({
55
+ select: (location) => formatOpenPanels(parseOpenPanels(location.hash)),
34
56
  });
57
+ return useMemo(() => parseOpenPanels(hash), [hash]);
58
+ }
59
+
60
+ /**
61
+ * A destination's `hash`, for a navigation that keeps the reader where they
62
+ * are. The router drops the fragment on every navigation unless the destination
63
+ * 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.
67
+ *
68
+ * The parameter is optional because the router hands a `hash` updater the
69
+ * previous fragment as `string | undefined`.
70
+ */
71
+ export function retainOpenPanels(hash = ""): string {
72
+ return formatOpenPanels(
73
+ parseOpenPanels(hash).filter((panel) => !isOverlayPanel(panel)),
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Puts a set of panels up, replacing whatever was up before.
79
+ *
80
+ * `replace`, because a panel is chrome over the current view rather than a place
81
+ * to go: Back belongs to the message the reader came from, not to the rail they
82
+ * just collapsed. The fragment names panels and never an element, so it is not
83
+ * an anchor to scroll to either.
84
+ */
85
+ export function useSetOpenPanels(): (panels: readonly PanelFragment[]) => void {
86
+ const navigate = useNavigate();
87
+ return useCallback(
88
+ (panels: readonly PanelFragment[]) => {
89
+ navigate({
90
+ search: true,
91
+ hash: formatOpenPanels(panels),
92
+ replace: true,
93
+ hashScrollIntoView: false,
94
+ });
95
+ },
96
+ [navigate],
97
+ );
35
98
  }
@@ -1,8 +1,14 @@
1
1
  export {
2
+ formatOpenPanels,
3
+ isOverlayPanel,
4
+ type OverlayPanel,
5
+ overlayPanels,
2
6
  type PanelFragment,
3
7
  panelFragments,
4
- parsePanelFragment,
5
- useOpenPanel,
8
+ parseOpenPanels,
9
+ retainOpenPanels,
10
+ useOpenPanels,
11
+ useSetOpenPanels,
6
12
  } from "./fragment";
7
13
  export { NavLink, type NavLinkProps } from "./nav-link";
8
14
  export {
@@ -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 } from "./fragment";
10
+ import { type PanelFragment, retainOpenPanels } from "./fragment";
11
11
 
12
12
  const RouterNavLink = createLink(NavLinkSurface);
13
13
 
@@ -42,6 +42,10 @@ export type NavLinkProps<
42
42
  * The application's only navigation link. `createLink` keeps the router's `to` /
43
43
  * `params` / `search` inference, so a route that does not exist or a param set
44
44
  * that does not match it fails to compile instead of rendering a blank pane.
45
+ *
46
+ * A link that names no panel carries the open panes across: the rail is up
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.
45
49
  */
46
50
  export function NavLink<
47
51
  TRouter extends AnyRouter = RegisteredRouter,
@@ -54,5 +58,5 @@ export function NavLink({
54
58
  fragment,
55
59
  ...props
56
60
  }: CreateLinkProps & { fragment?: PanelFragment }): ReactElement {
57
- return <RouterNavLink {...props} hash={fragment} />;
61
+ return <RouterNavLink {...props} hash={fragment ?? retainOpenPanels} />;
58
62
  }