@remit/web-client 0.0.144 → 0.0.146

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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/components/compose/ComposeForm.tsx +169 -120
  3. package/src/components/compose/ComposeSmtpMissingBanner.tsx +4 -0
  4. package/src/components/compose/mobile-header-stays-expanded.render.test.ts +182 -0
  5. package/src/components/layout/MailShell.tsx +98 -0
  6. package/src/components/mail/BriefPane.tsx +5 -5
  7. package/src/components/mail/FlaggedPane.tsx +1 -1
  8. package/src/components/mail/MailSidebarAdapter.tsx +5 -8
  9. package/src/components/mail/MailboxPane.tsx +5 -5
  10. package/src/components/ui/FatalErrorOverlay.tsx +4 -1
  11. package/src/hooks/useSearchMirror.ts +93 -0
  12. package/src/hooks/useSearchScope.ts +1 -1
  13. package/src/lib/compose-routes.test.ts +3 -1
  14. package/src/lib/compose-routes.ts +1 -1
  15. package/src/lib/mail-route.test.ts +157 -57
  16. package/src/lib/mail-route.ts +71 -38
  17. package/src/lib/mail-search.ts +51 -0
  18. package/src/lib/route-search-query.test.ts +50 -28
  19. package/src/lib/search-scope.ts +11 -19
  20. package/src/lib/search-view.test.ts +142 -38
  21. package/src/lib/search-view.ts +35 -11
  22. package/src/routeTree.gen.ts +155 -18
  23. package/src/router.tsx +17 -0
  24. package/src/routes/index.tsx +1 -1
  25. package/src/routes/mail/$mailboxId/index.tsx +10 -0
  26. package/src/routes/mail/$mailboxId.tsx +27 -19
  27. package/src/routes/mail/brief/index.tsx +14 -0
  28. package/src/routes/mail/brief.tsx +50 -0
  29. package/src/routes/mail/flagged/index.tsx +10 -0
  30. package/src/routes/mail/flagged.tsx +25 -13
  31. package/src/routes/mail/index.tsx +9 -36
  32. package/src/routes/mail/outbox/index.tsx +10 -0
  33. package/src/routes/mail/outbox.tsx +23 -13
  34. package/src/routes/mail.tsx +35 -246
  35. package/src/test-support/list-search-binding.ts +34 -0
@@ -0,0 +1,98 @@
1
+ import { AppShellSlotted } from "@remit/ui";
2
+ import { createContext, type ReactNode, useContext } from "react";
3
+ import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
4
+
5
+ /**
6
+ * The chrome every list shares, published by the `/mail` layout and consumed by
7
+ * the list route that mounts the shell.
8
+ *
9
+ * It lives in a module reached only through the `@/` alias for the same reason
10
+ * `lib/mail-context.ts` does: the generated route tree imports route files
11
+ * relatively, so a context declared inside one of them can resolve to a second
12
+ * module instance and hand the consumer an empty default.
13
+ */
14
+ export interface MailShellChrome {
15
+ /**
16
+ * Below the reading boundary (phone AND tablet) the shell shows one pane, so
17
+ * the list route mounts its phone view instead of the slotted panes.
18
+ */
19
+ isSinglePane: boolean;
20
+ isLoading: boolean;
21
+ intelligenceOpen: boolean;
22
+ nav: ReactNode;
23
+ topBar: ReactNode;
24
+ overlay: ReactNode;
25
+ navOpen: boolean;
26
+ onOpenNav: () => void;
27
+ onCloseNav: () => void;
28
+ }
29
+
30
+ const MailShellCtx = createContext<MailShellChrome | null>(null);
31
+
32
+ export const MailShellProvider = ({
33
+ chrome,
34
+ children,
35
+ }: {
36
+ chrome: MailShellChrome;
37
+ children: ReactNode;
38
+ }) => <MailShellCtx.Provider value={chrome}>{children}</MailShellCtx.Provider>;
39
+
40
+ export interface MailShellProps {
41
+ /** The single pane, list and open thread both, below the reading boundary. */
42
+ phone: ReactNode;
43
+ list: ReactNode;
44
+ reading: ReactNode;
45
+ intelligence?: ReactNode;
46
+ /** Whether the reading pane has a thread — the intelligence rail needs one. */
47
+ hasThread?: boolean;
48
+ }
49
+
50
+ /**
51
+ * The shell a list route mounts around its own panes.
52
+ *
53
+ * Below the reading boundary the shell is one pane and takes `phone`, which
54
+ * swaps between the list and whatever is open in place. Above it the panes sit
55
+ * side by side and the reading and intelligence slots are filled.
56
+ */
57
+ export function MailShell({
58
+ phone,
59
+ list,
60
+ reading,
61
+ intelligence,
62
+ hasThread = false,
63
+ }: MailShellProps) {
64
+ const chrome = useContext(MailShellCtx);
65
+ if (!chrome) return <AppShellSkeleton />;
66
+
67
+ const shared = {
68
+ nav: chrome.nav,
69
+ overlay: chrome.overlay,
70
+ skeleton: <AppShellSkeleton />,
71
+ isLoading: chrome.isLoading,
72
+ navOpen: chrome.navOpen,
73
+ onOpenNav: chrome.onOpenNav,
74
+ onCloseNav: chrome.onCloseNav,
75
+ };
76
+
77
+ if (chrome.isSinglePane) {
78
+ return (
79
+ <AppShellSlotted
80
+ {...shared}
81
+ list={phone}
82
+ intelligenceOpen={chrome.intelligenceOpen}
83
+ />
84
+ );
85
+ }
86
+
87
+ return (
88
+ <AppShellSlotted
89
+ {...shared}
90
+ topBar={chrome.topBar}
91
+ list={list}
92
+ reading={reading}
93
+ intelligence={intelligence}
94
+ intelligenceOpen={chrome.intelligenceOpen}
95
+ hasThread={hasThread}
96
+ />
97
+ );
98
+ }
@@ -132,7 +132,7 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
132
132
  const handleSelectMessage = useCallback(
133
133
  (id: string, options?: OpenMessageOptions) => {
134
134
  navigate({
135
- to: "/mail",
135
+ to: "/mail/brief",
136
136
  search: (prev) => ({
137
137
  ...prev,
138
138
  selectedMessageId: id,
@@ -148,12 +148,12 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
148
148
  const handleSelectSearchResult = useCallback(
149
149
  (result: SearchResult, options?: OpenMessageOptions) => {
150
150
  navigate({
151
- to: "/mail",
151
+ to: "/mail/brief",
152
152
  replace: options?.replace,
153
153
  search: (prev) => ({
154
154
  ...prev,
155
155
  // Commit the active query with the selection so the debounced
156
- // q-mirror (mail.tsx) — which strips the selection when the query
156
+ // q-mirror (`useSearchMirror`) — which strips the selection when the query
157
157
  // goes active — is already satisfied and leaves the opened result
158
158
  // alone. Use the *live* `searchInput`: the row can be tapped before
159
159
  // the debounce settles, when the committed query is still empty.
@@ -172,7 +172,7 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
172
172
  if (!selectedMessageId) return;
173
173
  if (!removedIds.includes(selectedMessageId)) return;
174
174
  navigate({
175
- to: "/mail",
175
+ to: "/mail/brief",
176
176
  search: (prev) => ({
177
177
  ...prev,
178
178
  selectedMessageId: undefined,
@@ -191,7 +191,7 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
191
191
 
192
192
  const handleCloseThread = useCallback(() => {
193
193
  navigate({
194
- to: "/mail",
194
+ to: "/mail/brief",
195
195
  search: (prev) => ({
196
196
  ...prev,
197
197
  selectedMessageId: undefined,
@@ -182,7 +182,7 @@ function FlaggedPaneProvider({
182
182
  if (!triageTarget) return;
183
183
  toggleReadFor([triageTarget.messageId], !triageTarget.isRead);
184
184
  },
185
- goBrief: () => navigate({ to: "/mail" }),
185
+ goBrief: () => navigate({ to: "/mail/brief" }),
186
186
  goSettings: () => navigate({ to: "/settings" }),
187
187
  },
188
188
  });
@@ -117,8 +117,8 @@ function toNavMailbox(
117
117
  * component can highlight the right item.
118
118
  * - /mail/outbox → "outbox"
119
119
  * - /mail/flagged → "flagged"
120
+ * - /mail/brief → "brief"
120
121
  * - /mail/$mailboxId → mailboxId
121
- * - /mail (daily brief) → "brief"
122
122
  */
123
123
  function useSelectedNavId(): string {
124
124
  const location = useLocation();
@@ -127,9 +127,8 @@ function useSelectedNavId(): string {
127
127
  if (location.pathname.startsWith("/settings")) return "settings";
128
128
  if (location.pathname.startsWith("/mail/outbox")) return "outbox";
129
129
  if (location.pathname.startsWith("/mail/flagged")) return "flagged";
130
+ if (location.pathname.startsWith("/mail/brief")) return "brief";
130
131
  if (params.mailboxId) return params.mailboxId;
131
- if (location.pathname === "/mail" || location.pathname === "/mail/")
132
- return "brief";
133
132
  return "";
134
133
  }
135
134
 
@@ -229,11 +228,9 @@ export function MailSidebarAdapter({
229
228
  if (navId === "brief") {
230
229
  return (
231
230
  <NavLink
232
- to="/mail"
231
+ to="/mail/brief"
233
232
  search={{ q: undefined, selectedMessageId: undefined }}
234
- // Every other mail list lives under /mail, so the default prefix
235
- // match would leave the brief marked current on all of them.
236
- activeOptions={{ exact: true, includeSearch: false }}
233
+ activeOptions={{ includeSearch: false }}
237
234
  onClick={() => onClick?.()}
238
235
  className={className}
239
236
  aria-label={ariaLabel}
@@ -344,7 +341,7 @@ export function MailSidebarAdapter({
344
341
  (query: string) => {
345
342
  onSearchChange(query);
346
343
  navigate({
347
- to: "/mail",
344
+ to: "/mail/brief",
348
345
  search: { q: query, selectedMessageId: undefined },
349
346
  });
350
347
  onMailboxSelect?.();
@@ -803,7 +803,7 @@ function MailboxPaneProvider({
803
803
  }, [intelligenceOpen, onToggleIntelligence]);
804
804
 
805
805
  const goToRoute = useCallback(
806
- (to: "/mail" | "/mail/flagged" | "/settings") => {
806
+ (to: "/mail/brief" | "/mail/flagged" | "/settings") => {
807
807
  navigate({ to });
808
808
  },
809
809
  [navigate],
@@ -851,9 +851,9 @@ function MailboxPaneProvider({
851
851
  markJunk: triageMarkJunk,
852
852
  toggleIntelligence: selectedThread ? onToggleIntelligence : undefined,
853
853
  compose: handleNewCompose,
854
- goBrief: () => goToRoute("/mail"),
855
- goInbox: () => goToRoute("/mail"),
856
- goSent: () => goToRoute("/mail"),
854
+ goBrief: () => goToRoute("/mail/brief"),
855
+ goInbox: () => goToRoute("/mail/brief"),
856
+ goSent: () => goToRoute("/mail/brief"),
857
857
  goFlagged: () => goToRoute("/mail/flagged"),
858
858
  goSettings: () => goToRoute("/settings"),
859
859
  },
@@ -1047,7 +1047,7 @@ function MailboxList() {
1047
1047
  search: (prev: Record<string, unknown>) => ({
1048
1048
  ...prev,
1049
1049
  // Commit the active query alongside the selection. The debounced
1050
- // q-mirror (mail.tsx) strips the selection whenever it sees the
1050
+ // q-mirror (`useSearchMirror`) strips the selection whenever it sees the
1051
1051
  // query go active; the row can be tapped before the debounce settles
1052
1052
  // (it shows in the still-unfiltered list), so use the *live*
1053
1053
  // `searchInput` here — committing `q` makes the mirror a no-op and
@@ -12,7 +12,10 @@ import {
12
12
  subscribeFatalError,
13
13
  } from "@/lib/fatal-error";
14
14
 
15
- /** Safe route to escape a deterministically-crashing page. `/` redirects here. */
15
+ /**
16
+ * Safe route to escape a deterministically-crashing page. It names no list, so
17
+ * it redirects on to the brief.
18
+ */
16
19
  const SAFE_ROUTE = "/mail";
17
20
 
18
21
  /**
@@ -0,0 +1,93 @@
1
+ import { useNavigate, useRouterState, useSearch } from "@tanstack/react-router";
2
+ import { useEffect, useRef } from "react";
3
+ import { useMailContext } from "@/lib/mail-context";
4
+ import { shouldMirrorQuery } from "@/lib/search-view";
5
+
6
+ /**
7
+ * The list the mirror writes to. Each list calls the hook with its own route,
8
+ * so the query lands on the list rather than on whatever is open below it.
9
+ */
10
+ export type SearchMirrorTarget =
11
+ | { to: "/mail/brief" | "/mail/flagged" | "/mail/outbox" }
12
+ | { to: "/mail/$mailboxId"; params: { mailboxId: string } };
13
+
14
+ /**
15
+ * Mirrors the settled search into the URL so links are shareable and a refresh
16
+ * restores the query.
17
+ *
18
+ * Within a view this is one-directional — the URL is not read back into state,
19
+ * so there is no sync loop — and it writes only once the debounce agrees with
20
+ * the field, so a query arriving by URL is never overwritten mid-debounce, and
21
+ * the query the user just left behind is never written onto the view they landed
22
+ * on (`shouldMirrorQuery`).
23
+ *
24
+ * It also writes only while the reader is still on this list. A list stays
25
+ * mounted, effects and all, until the list they navigated to is ready to paint,
26
+ * and by then the address is already the new one — so a debounce settling in
27
+ * that window would fire a navigation to *this* list, superseding the load in
28
+ * flight and replacing the entry the reader had just pushed. They would click
29
+ * Inbox and land back on the brief.
30
+ *
31
+ * When a query *goes* active it also strips the selection so the reading pane
32
+ * closes (#539): an open message from the pre-search list is not meaningful in
33
+ * the search result set. Only on that transition though — tapping a search
34
+ * result commits the same `q` with the selection, so when `prev.q` already
35
+ * equals the query the result was opened under it (not a pre-search leftover)
36
+ * and must survive. The strip otherwise raced the tap: the row shows before the
37
+ * debounce settles, so this mirror can land just after the open and close it
38
+ * again.
39
+ */
40
+ export function useSearchMirror(target: SearchMirrorTarget): void {
41
+ const navigate = useNavigate();
42
+ const { searchInput, searchQuery: committedQuery } = useMailContext();
43
+ const { q: urlQuery = "" } = useSearch({ from: "/mail" });
44
+ const pathname = useRouterState({ select: (s) => s.location.pathname });
45
+
46
+ // Read at effect time rather than depended on: the URL is what the mirror
47
+ // compares against, not what re-triggers it.
48
+ const urlQueryRef = useRef(urlQuery);
49
+ urlQueryRef.current = urlQuery;
50
+
51
+ const { to } = target;
52
+ const mailboxId = "params" in target ? target.params.mailboxId : undefined;
53
+ const listPath = mailboxId ? `/mail/${mailboxId}` : to;
54
+
55
+ useEffect(() => {
56
+ const mayWrite = shouldMirrorQuery({
57
+ searchInput,
58
+ committedQuery,
59
+ urlQuery: urlQueryRef.current,
60
+ pathname,
61
+ listPath,
62
+ });
63
+ if (!mayWrite) return;
64
+ const search = (prev: Record<string, unknown>) => {
65
+ const queryAlreadyActive = prev.q === committedQuery;
66
+ return {
67
+ ...prev,
68
+ q: committedQuery || undefined,
69
+ ...(committedQuery && !queryAlreadyActive
70
+ ? {
71
+ selectedMessageId: undefined,
72
+ selectedThreadId: undefined,
73
+ selectedMailboxId: undefined,
74
+ }
75
+ : {}),
76
+ };
77
+ };
78
+ if (to === "/mail/$mailboxId") {
79
+ if (!mailboxId) return;
80
+ navigate({ to, params: { mailboxId }, search, replace: true });
81
+ return;
82
+ }
83
+ navigate({ to, search, replace: true });
84
+ }, [
85
+ searchInput,
86
+ committedQuery,
87
+ navigate,
88
+ to,
89
+ mailboxId,
90
+ listPath,
91
+ pathname,
92
+ ]);
93
+ }
@@ -38,7 +38,7 @@ export function useSearchScope(accounts: RemitImapAccountResponse[]): {
38
38
  (chipId: string) => {
39
39
  if (chipId !== SEARCH_SCOPE_CHIP_ID) return;
40
40
  navigate({
41
- to: "/mail",
41
+ to: "/mail/brief",
42
42
  search: { q: searchInput || undefined, selectedMessageId: undefined },
43
43
  });
44
44
  },
@@ -15,11 +15,12 @@ describe("hostsComposeSurface", () => {
15
15
  });
16
16
 
17
17
  it("is false for the virtual views, which mount no surface", () => {
18
+ assert.equal(hostsComposeSurface("/mail/brief"), false);
18
19
  assert.equal(hostsComposeSurface("/mail/outbox"), false);
19
20
  assert.equal(hostsComposeSurface("/mail/flagged"), false);
20
21
  });
21
22
 
22
- it("is false for the daily brief, which is /mail itself", () => {
23
+ it("is false for /mail itself, which only redirects to the brief", () => {
23
24
  assert.equal(hostsComposeSurface("/mail"), false);
24
25
  assert.equal(hostsComposeSurface("/mail/"), false);
25
26
  });
@@ -34,6 +35,7 @@ describe("hostsComposeSurface", () => {
34
35
  assert.equal(hostsComposeSurface("/mail/outbox-2024"), true);
35
36
  assert.equal(hostsComposeSurface("/mail/flagged-archive"), true);
36
37
  assert.equal(hostsComposeSurface("/mail/outboxes"), true);
38
+ assert.equal(hostsComposeSurface("/mail/briefing"), true);
37
39
  });
38
40
 
39
41
  it("ignores a query string or hash on the path", () => {
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  /** `/mail/<segment>` values that name a view rather than a mailbox. */
13
- const VIRTUAL_MAIL_VIEWS = new Set(["outbox", "flagged"]);
13
+ const VIRTUAL_MAIL_VIEWS = new Set(["brief", "outbox", "flagged"]);
14
14
 
15
15
  /**
16
16
  * True for `/mail/<id>` where `<id>` is a real mailbox.
@@ -1,80 +1,180 @@
1
1
  /**
2
- * Regression: the /mail shell must mount the MAILBOX pane (MessageList →
3
- * SwipeableMessageRow → `a[href*=selectedMessageId]`) on /mail/$mailboxId,
4
- * NOT the brief pane. The first slotted-shell cut keyed brief detection off
5
- * the parent /mail layout's matched pathname ("/mail"), which is present on
6
- * EVERY child route so every mailbox rendered the unified DailyBrief and
7
- * the message-row anchors disappeared (e2e sync-flow + smoke both failed on
8
- * `a[href*='selectedMessageId']`). These tests pin detection to each leaf
9
- * route's own routeId so that can't regress.
2
+ * Three contracts, each of which has already cost a regression.
3
+ *
4
+ * The list is read off a matched route id, never the parent /mail layout's
5
+ * pathname: that pathname is "/mail" on every child route, and keying off it
6
+ * routed every mailbox through the brief pane, so the message-row anchors
7
+ * vanished.
8
+ *
9
+ * The view key of a list equals the view key of anything nested under it. A
10
+ * thread is a child route of the list it was opened from, so a key that moved
11
+ * when the thread opened would tell `lib/search-view.ts` the reader had left
12
+ * the view — and the query they had just typed would be re-seeded from the URL
13
+ * and disappear the moment they opened a hit.
14
+ *
15
+ * And "am I the current list" is answered by the pathname, not by the matches,
16
+ * because the matches lag a navigation by as long as the destination takes to
17
+ * mount.
10
18
  */
11
19
  import assert from "node:assert/strict";
12
20
  import { describe, it } from "node:test";
13
21
  import {
14
- isBriefRoute,
15
- isFlaggedRoute,
16
- isMailboxRoute,
17
- isOutboxRoute,
22
+ locationIsOnList,
23
+ MAIL_BRIEF_ROUTE_ID,
24
+ MAIL_FLAGGED_ROUTE_ID,
25
+ MAIL_MAILBOX_ROUTE_ID,
26
+ MAIL_OUTBOX_ROUTE_ID,
18
27
  type MailRouteMatch,
28
+ mailListRoute,
29
+ mailViewKey,
19
30
  } from "./mail-route.js";
20
31
 
21
- /** Matches as TanStack Router reports them: parent /mail layout + the leaf. */
22
- const briefMatches: MailRouteMatch[] = [
32
+ /** Matches as TanStack Router reports them: outermost first, leaf last. */
33
+ const matches = (
34
+ routeIds: readonly string[],
35
+ params?: Record<string, string>,
36
+ ): MailRouteMatch[] => [
23
37
  { routeId: "__root__" },
24
38
  { routeId: "/mail" },
25
- { routeId: "/mail/" },
26
- ];
27
- const mailboxMatches: MailRouteMatch[] = [
28
- { routeId: "__root__" },
29
- { routeId: "/mail" },
30
- { routeId: "/mail/$mailboxId" },
31
- ];
32
- const outboxMatches: MailRouteMatch[] = [
33
- { routeId: "__root__" },
34
- { routeId: "/mail" },
35
- { routeId: "/mail/outbox" },
36
- ];
37
- const flaggedMatches: MailRouteMatch[] = [
38
- { routeId: "__root__" },
39
- { routeId: "/mail" },
40
- { routeId: "/mail/flagged" },
39
+ ...routeIds.map((routeId) => ({ routeId, ...(params ? { params } : {}) })),
41
40
  ];
42
41
 
43
- describe("mail route pane detection", () => {
44
- it("classifies the brief index route as brief only", () => {
45
- assert.equal(isBriefRoute(briefMatches), true);
46
- assert.equal(isMailboxRoute(briefMatches), false);
47
- assert.equal(isOutboxRoute(briefMatches), false);
42
+ const brief = matches([MAIL_BRIEF_ROUTE_ID]);
43
+ const flagged = matches([MAIL_FLAGGED_ROUTE_ID]);
44
+ const outbox = matches([MAIL_OUTBOX_ROUTE_ID]);
45
+ const mailbox = matches([MAIL_MAILBOX_ROUTE_ID], { mailboxId: "inbox-1" });
46
+
47
+ describe("mailListRoute", () => {
48
+ it("names each of the four lists", () => {
49
+ assert.deepEqual(mailListRoute(brief), { list: "brief" });
50
+ assert.deepEqual(mailListRoute(flagged), { list: "flagged" });
51
+ assert.deepEqual(mailListRoute(outbox), { list: "outbox" });
52
+ assert.deepEqual(mailListRoute(mailbox), {
53
+ list: "mailbox",
54
+ mailboxId: "inbox-1",
55
+ });
56
+ assert.deepEqual(mailListRoute(matches([MAIL_MAILBOX_ROUTE_ID])), {
57
+ list: "mailbox",
58
+ mailboxId: undefined,
59
+ });
60
+ });
61
+
62
+ it("never reads the parent /mail layout as a list", () => {
63
+ assert.equal(mailListRoute(matches([])), undefined);
64
+ assert.equal(mailListRoute([{ routeId: "__root__" }]), undefined);
65
+ });
66
+
67
+ it("names the list a thread was opened from, not the thread", () => {
68
+ assert.deepEqual(
69
+ mailListRoute(
70
+ matches([MAIL_BRIEF_ROUTE_ID, `${MAIL_BRIEF_ROUTE_ID}/$threadId`]),
71
+ ),
72
+ { list: "brief" },
73
+ );
74
+ assert.deepEqual(
75
+ mailListRoute(
76
+ matches([MAIL_MAILBOX_ROUTE_ID, `${MAIL_MAILBOX_ROUTE_ID}/$threadId`], {
77
+ mailboxId: "inbox-1",
78
+ }),
79
+ ),
80
+ { list: "mailbox", mailboxId: "inbox-1" },
81
+ );
82
+ });
83
+ });
84
+
85
+ describe("mailViewKey", () => {
86
+ it("gives the four lists four distinct keys", () => {
87
+ const keys = [brief, flagged, outbox, mailbox].map(mailViewKey);
88
+ assert.equal(new Set(keys).size, 4);
48
89
  });
49
90
 
50
- it("classifies a mailbox route as mailbox, NOT brief (the regression)", () => {
51
- // The bug: the parent /mail layout match made isBriefRoute true here,
52
- // routing the mailbox through the brief pane and dropping message rows.
53
- assert.equal(isBriefRoute(mailboxMatches), false);
54
- assert.equal(isMailboxRoute(mailboxMatches), true);
55
- assert.equal(isOutboxRoute(mailboxMatches), false);
91
+ it("distinguishes two mailboxes", () => {
92
+ assert.notEqual(
93
+ mailViewKey(matches([MAIL_MAILBOX_ROUTE_ID], { mailboxId: "inbox-1" })),
94
+ mailViewKey(matches([MAIL_MAILBOX_ROUTE_ID], { mailboxId: "archive-1" })),
95
+ );
56
96
  });
57
97
 
58
- it("classifies the outbox route as outbox only", () => {
59
- assert.equal(isBriefRoute(outboxMatches), false);
60
- assert.equal(isMailboxRoute(outboxMatches), false);
61
- assert.equal(isOutboxRoute(outboxMatches), true);
98
+ it("is empty outside the mail shell", () => {
99
+ assert.equal(mailViewKey([{ routeId: "__root__" }]), "");
62
100
  });
63
101
 
64
- it("classifies the flagged route as flagged only", () => {
65
- assert.equal(isFlaggedRoute(flaggedMatches), true);
66
- assert.equal(isBriefRoute(flaggedMatches), false);
67
- assert.equal(isMailboxRoute(flaggedMatches), false);
68
- assert.equal(isOutboxRoute(flaggedMatches), false);
102
+ it("is empty on a mailbox route whose param has not resolved", () => {
103
+ assert.equal(mailViewKey(matches([MAIL_MAILBOX_ROUTE_ID])), "");
69
104
  });
70
105
 
71
- it("never treats the parent /mail layout match as the brief route", () => {
72
- // Every child route carries the parent /mail layout match. Brief
73
- // detection must ignore it entirely.
74
- const parentOnly: MailRouteMatch[] = [
75
- { routeId: "__root__" },
76
- { routeId: "/mail" },
106
+ // The trap: opening a thread must not read as leaving the list, or the
107
+ // search field re-seeds and the typed query is gone.
108
+ it("gives a list and its open thread the same key", () => {
109
+ const lists: [readonly string[], Record<string, string> | undefined][] = [
110
+ [[MAIL_BRIEF_ROUTE_ID], undefined],
111
+ [[MAIL_FLAGGED_ROUTE_ID], undefined],
112
+ [[MAIL_OUTBOX_ROUTE_ID], undefined],
113
+ [[MAIL_MAILBOX_ROUTE_ID], { mailboxId: "inbox-1" }],
77
114
  ];
78
- assert.equal(isBriefRoute(parentOnly), false);
115
+
116
+ for (const [routeIds, params] of lists) {
117
+ const list = routeIds[0];
118
+ const thread = `${list}/$threadId`;
119
+ const message = `${thread}/$messageId`;
120
+ assert.equal(
121
+ mailViewKey(matches([list, thread], params)),
122
+ mailViewKey(matches([list], params)),
123
+ `${list} changes view key when a thread opens`,
124
+ );
125
+ assert.equal(
126
+ mailViewKey(matches([list, thread, message], params)),
127
+ mailViewKey(matches([list], params)),
128
+ `${list} changes view key when a message expands`,
129
+ );
130
+ }
131
+ });
132
+
133
+ it("gives a list and its reading-pane index child the same key", () => {
134
+ assert.equal(
135
+ mailViewKey(matches([MAIL_BRIEF_ROUTE_ID, `${MAIL_BRIEF_ROUTE_ID}/`])),
136
+ mailViewKey(brief),
137
+ );
138
+ });
139
+ });
140
+
141
+ /**
142
+ * The router commits the new location before it swaps the matches, so a list on
143
+ * its way off the screen sees its own matches under the destination's address.
144
+ * Anything that acts on "am I the current list" has to ask the pathname.
145
+ */
146
+ describe("locationIsOnList", () => {
147
+ it("is true on the list itself", () => {
148
+ assert.equal(locationIsOnList("/mail/brief", "/mail/brief"), true);
149
+ assert.equal(locationIsOnList("/mail/inbox-1", "/mail/inbox-1"), true);
150
+ });
151
+
152
+ it("is true for anything the list has open below it", () => {
153
+ assert.equal(
154
+ locationIsOnList("/mail/brief/thread-1/message-1", "/mail/brief"),
155
+ true,
156
+ );
157
+ assert.equal(locationIsOnList("/mail/brief/", "/mail/brief"), true);
158
+ assert.equal(
159
+ locationIsOnList("/mail/inbox-1/compose", "/mail/inbox-1"),
160
+ true,
161
+ );
162
+ });
163
+
164
+ it("is false once the address names another list", () => {
165
+ assert.equal(locationIsOnList("/mail/inbox-1", "/mail/brief"), false);
166
+ assert.equal(locationIsOnList("/mail/brief", "/mail/inbox-1"), false);
167
+ assert.equal(locationIsOnList("/mail/archive-1", "/mail/inbox-1"), false);
168
+ assert.equal(locationIsOnList("/mail/flagged", "/mail/brief"), false);
169
+ });
170
+
171
+ it("is false outside the mail shell", () => {
172
+ assert.equal(locationIsOnList("/settings/accounts", "/mail/brief"), false);
173
+ assert.equal(locationIsOnList("/onboarding", "/mail/brief"), false);
174
+ });
175
+
176
+ it("compares whole segments, so a folder may be named after a list", () => {
177
+ assert.equal(locationIsOnList("/mail/briefing", "/mail/brief"), false);
178
+ assert.equal(locationIsOnList("/mail/outbox-2024", "/mail/outbox"), false);
79
179
  });
80
180
  });