@remit/web-client 0.0.143 → 0.0.145

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/components/compose/ComposeProvider.tsx +79 -3
  3. package/src/components/compose/compose-clears-open-thread.render.test.ts +302 -0
  4. package/src/components/compose/compose-send-stops-autosave.render.test.ts +34 -5
  5. package/src/components/layout/ComposeFab.tsx +11 -36
  6. package/src/components/layout/MailShell.tsx +98 -0
  7. package/src/components/layout/MailTopBar.tsx +6 -3
  8. package/src/components/mail/BriefPane.tsx +5 -5
  9. package/src/components/mail/DraftsView.tsx +0 -11
  10. package/src/components/mail/FlaggedPane.tsx +1 -1
  11. package/src/components/mail/MailSidebarAdapter.tsx +5 -8
  12. package/src/components/mail/MailboxPane.tsx +15 -5
  13. package/src/components/ui/FatalErrorOverlay.tsx +4 -1
  14. package/src/hooks/useComposeTargetMailbox.ts +75 -0
  15. package/src/hooks/useSearchMirror.ts +93 -0
  16. package/src/hooks/useSearchScope.ts +1 -1
  17. package/src/lib/compose-routes.test.ts +3 -1
  18. package/src/lib/compose-routes.ts +6 -6
  19. package/src/lib/mail-route.test.ts +157 -57
  20. package/src/lib/mail-route.ts +71 -38
  21. package/src/lib/mail-search.ts +51 -0
  22. package/src/lib/route-search-query.test.ts +50 -28
  23. package/src/lib/search-scope.ts +11 -19
  24. package/src/lib/search-view.test.ts +142 -38
  25. package/src/lib/search-view.ts +35 -11
  26. package/src/routeTree.gen.ts +155 -18
  27. package/src/router.tsx +17 -0
  28. package/src/routes/index.tsx +1 -1
  29. package/src/routes/mail/$mailboxId/index.tsx +10 -0
  30. package/src/routes/mail/$mailboxId.tsx +27 -19
  31. package/src/routes/mail/brief/index.tsx +14 -0
  32. package/src/routes/mail/brief.tsx +50 -0
  33. package/src/routes/mail/flagged/index.tsx +10 -0
  34. package/src/routes/mail/flagged.tsx +25 -13
  35. package/src/routes/mail/index.tsx +9 -36
  36. package/src/routes/mail/outbox/index.tsx +10 -0
  37. package/src/routes/mail/outbox.tsx +23 -13
  38. package/src/routes/mail.tsx +51 -249
  39. package/src/test-support/list-search-binding.ts +34 -0
  40. package/src/hooks/useComposeTarget.ts +0 -92
@@ -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,
@@ -185,17 +185,6 @@ export function DraftsView({
185
185
  };
186
186
 
187
187
  const handleRemitDraftOpen = (outboxMessageId: string) => {
188
- // Clear any open IMAP draft first. The route's detailPane only renders
189
- // FullCompose when `composeState.isOpen && !selectedThread`; if an IMAP
190
- // draft is open (selectedMessageId set) the reading pane would keep
191
- // showing ConversationView and compose would never surface (#505).
192
- if (selectedMessageId) {
193
- navigate({
194
- to: "/mail/$mailboxId",
195
- params: { mailboxId },
196
- search: { selectedMessageId: undefined },
197
- });
198
- }
199
188
  openCompose({ mode: "new", outboxMessageId });
200
189
  };
201
190
 
@@ -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?.();
@@ -598,6 +598,16 @@ function MailboxPaneProvider({
598
598
  openCompose({ mode: "new" });
599
599
  }, [openCompose]);
600
600
 
601
+ // A thread opening closes compose. Only a selection arriving counts, so this
602
+ // cannot close the compose that just cleared one.
603
+ const previousSelectionRef = useRef(selectedMessageId);
604
+ useEffect(() => {
605
+ const previous = previousSelectionRef.current;
606
+ previousSelectionRef.current = selectedMessageId;
607
+ if (!selectedMessageId || selectedMessageId === previous) return;
608
+ closeCompose();
609
+ }, [selectedMessageId, closeCompose]);
610
+
601
611
  const deleteOutboxMutation = useMutation({
602
612
  ...outboxDetailOperationsDeleteOutboxMessageMutation(),
603
613
  onError: (mutationError) => {
@@ -793,7 +803,7 @@ function MailboxPaneProvider({
793
803
  }, [intelligenceOpen, onToggleIntelligence]);
794
804
 
795
805
  const goToRoute = useCallback(
796
- (to: "/mail" | "/mail/flagged" | "/settings") => {
806
+ (to: "/mail/brief" | "/mail/flagged" | "/settings") => {
797
807
  navigate({ to });
798
808
  },
799
809
  [navigate],
@@ -841,9 +851,9 @@ function MailboxPaneProvider({
841
851
  markJunk: triageMarkJunk,
842
852
  toggleIntelligence: selectedThread ? onToggleIntelligence : undefined,
843
853
  compose: handleNewCompose,
844
- goBrief: () => goToRoute("/mail"),
845
- goInbox: () => goToRoute("/mail"),
846
- goSent: () => goToRoute("/mail"),
854
+ goBrief: () => goToRoute("/mail/brief"),
855
+ goInbox: () => goToRoute("/mail/brief"),
856
+ goSent: () => goToRoute("/mail/brief"),
847
857
  goFlagged: () => goToRoute("/mail/flagged"),
848
858
  goSettings: () => goToRoute("/settings"),
849
859
  },
@@ -1037,7 +1047,7 @@ function MailboxList() {
1037
1047
  search: (prev: Record<string, unknown>) => ({
1038
1048
  ...prev,
1039
1049
  // Commit the active query alongside the selection. The debounced
1040
- // q-mirror (mail.tsx) strips the selection whenever it sees the
1050
+ // q-mirror (`useSearchMirror`) strips the selection whenever it sees the
1041
1051
  // query go active; the row can be tapped before the debounce settles
1042
1052
  // (it shows in the still-unfiltered list), so use the *live*
1043
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,75 @@
1
+ /**
2
+ * Where a compose started off a mailbox route has to land.
3
+ *
4
+ * `FullCompose` is mounted by the mailbox route only, so compose started from
5
+ * the daily brief, flagged or the outbox has to carry the user to a mailbox
6
+ * first. The target is the first account's inbox, falling back to its first
7
+ * mailbox.
8
+ */
9
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
10
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
11
+ import { useQueries } from "@tanstack/react-query";
12
+ import { useMemo } from "react";
13
+ import { buildMailboxRoleMap } from "@/lib/folder-roles";
14
+
15
+ /**
16
+ * The mailbox to land on, or why there is none: a folder list still in flight
17
+ * is a different answer to the user than an account with no folders at all.
18
+ */
19
+ export type ComposeTarget =
20
+ | { status: "ready"; mailboxId: string }
21
+ | { status: "loading" }
22
+ | { status: "none" };
23
+
24
+ /**
25
+ * Accounts resolve in order and an account whose mailbox query is still in
26
+ * flight blocks rather than being skipped: skipping hands back a later
27
+ * account's inbox and then silently swaps the target once the earlier query
28
+ * settles.
29
+ *
30
+ * The answer is memoised on the two values it is made of. A fresh object every
31
+ * render would rebuild `openCompose` on every render of the provider, and a
32
+ * caller with it in a dependency array then never settles.
33
+ */
34
+ export function useComposeTarget(
35
+ accounts: RemitImapAccountResponse[],
36
+ ): ComposeTarget {
37
+ const mailboxQueries = useQueries({
38
+ queries: accounts.map((account) => ({
39
+ ...mailboxOperationsListMailboxesOptions({
40
+ path: { accountId: account.accountId },
41
+ }),
42
+ staleTime: Infinity,
43
+ })),
44
+ });
45
+
46
+ let status: ComposeTarget["status"] = "none";
47
+ let readyMailboxId: string | undefined;
48
+ for (const [index, account] of accounts.entries()) {
49
+ const query = mailboxQueries[index];
50
+ if (!query || query.isPending) {
51
+ status = "loading";
52
+ break;
53
+ }
54
+ const mailboxes = query.data?.items ?? [];
55
+ if (mailboxes.length === 0) continue;
56
+ const roleMap = buildMailboxRoleMap(account.folderAppointments);
57
+ const inbox = mailboxes.find(
58
+ (mailbox) => roleMap.get(mailbox.mailboxId) === "inbox",
59
+ );
60
+ const mailboxId = (inbox ?? mailboxes[0])?.mailboxId;
61
+ if (mailboxId) {
62
+ status = "ready";
63
+ readyMailboxId = mailboxId;
64
+ break;
65
+ }
66
+ }
67
+
68
+ return useMemo(
69
+ () =>
70
+ status === "ready" && readyMailboxId
71
+ ? { status: "ready", mailboxId: readyMailboxId }
72
+ : { status: status === "ready" ? "none" : status },
73
+ [status, readyMailboxId],
74
+ );
75
+ }
@@ -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", () => {
@@ -2,15 +2,15 @@
2
2
  * Which routes mount the compose surface.
3
3
  *
4
4
  * `FullCompose` is mounted by the mailbox route only, so compose started from
5
- * anywhere else has to carry the user to a mailbox first. Kept apart from
6
- * `useComposeTarget` so it stays a plain function with no React or API
7
- * dependencies both compose entry points resolve routes through this one
8
- * definition, and a divergent second copy is what left the mobile FAB dead on
9
- * `/mail/flagged` and on the brief.
5
+ * anywhere else has to carry the user to a mailbox first and compose left
6
+ * open when the user walks off those routes has to close. `ComposeProvider`
7
+ * decides both from here, and the mail layout binds `c` off the same answer.
8
+ * A plain function with no React or API dependencies: a divergent second copy
9
+ * is what left the mobile FAB dead on `/mail/flagged` and on the brief.
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
  });