@remit/web-client 0.0.153 → 0.0.155

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.
@@ -6,15 +6,19 @@
6
6
  * reading / phone slots. The list itself is a FLAT inbox of starred mail (see
7
7
  * `FlaggedList`), not the sectioned brief.
8
8
  *
9
- * The selection resolves from the starred listing, the same query that produced
10
- * the rows. The unified listing is INBOX-scoped, so resolving against it left
11
- * every starred thread filed elsewhere Sent, an archive folder, anything past
12
- * the inbox window visible in the list but impossible to open (issue #70).
9
+ * The row prefers the starred listing, the same query that produced the rows,
10
+ * and falls back to resolving the thread on its own when the address names
11
+ * one the listing doesn't hold. The unified listing is INBOX-scoped, so
12
+ * resolving against it left every starred thread filed elsewhere Sent, an
13
+ * archive folder, anything past the inbox window — visible in the list but
14
+ * impossible to open (issue #70).
13
15
  *
14
- * <FlaggedPane selectedMessageId={...}>
15
- * <AppShellSlotted
16
+ * Usage in the list layout route:
17
+ *
18
+ * <FlaggedPane thread={useOpenThreadPath()}>
19
+ * <MailShell
16
20
  * list={<FlaggedPane.List />}
17
- * reading={<FlaggedPane.Reading />}
21
+ * reading={<Outlet />}
18
22
  * />
19
23
  * </FlaggedPane>
20
24
  *
@@ -40,22 +44,34 @@ import { useDeleteMessages } from "@/hooks/useDeleteMessages";
40
44
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
41
45
  import { useStarredThreads } from "@/hooks/useStarredThreads";
42
46
  import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
47
+ import { useThreadRow } from "@/hooks/useThreadRow";
43
48
  import {
44
49
  type TriageContext,
45
50
  useTriageContext,
46
51
  useTriageLayer,
47
52
  } from "@/hooks/useTriageLayer";
53
+ import type { ConversationTarget } from "@/lib/conversation-target";
48
54
  import { useMailContext } from "@/lib/mail-context";
55
+ import type { OpenThreadPath, OpenThreadTarget } from "@/routing";
49
56
 
50
57
  /* ------------------------------------------------------------------ */
51
58
  /* Context */
52
59
  /* ------------------------------------------------------------------ */
53
60
 
54
61
  interface FlaggedPaneContextValue {
62
+ /** The row the reader pointed at, which is the one the list highlights. */
55
63
  selectedMessageId: string | undefined;
56
64
  selectedThread: RemitImapThreadMessageResponse | undefined;
57
- onSelectMessage: (id: string, options?: OpenMessageOptions) => void;
65
+ /** The conversation the pane shows, or none when the address names no thread. */
66
+ conversation: ConversationTarget | undefined;
67
+ onOpenThread: (
68
+ target: OpenThreadTarget,
69
+ options?: OpenMessageOptions,
70
+ ) => void;
58
71
  onCloseThread: () => void;
72
+ /** The rows either side of the open one — the phone's swipe gestures. */
73
+ nextThread: OpenThreadTarget | undefined;
74
+ previousThread: OpenThreadTarget | undefined;
59
75
  /**
60
76
  * Toolbar verbs for the open thread, keyed by the thread's own mailbox and
61
77
  * account — Flagged spans accounts, so there is no route mailbox to key by.
@@ -64,8 +80,6 @@ interface FlaggedPaneContextValue {
64
80
  /** Keyboard, multi-select and next/previous, shared with the mailbox view. */
65
81
  triage: TriageContext;
66
82
  onDeleteMessages: (messageIds: string[]) => void;
67
- nextMessageId: string | undefined;
68
- previousMessageId: string | undefined;
69
83
  /**
70
84
  * Deselects the open message when it's the one a mutation just removed
71
85
  * from view — wired into every mutation that can take the open message out
@@ -88,39 +102,65 @@ function useFlaggedPane(): FlaggedPaneContextValue {
88
102
  /* ------------------------------------------------------------------ */
89
103
 
90
104
  interface FlaggedPaneProps {
91
- selectedMessageId: string | undefined;
105
+ /** The open conversation, as the address states it. */
106
+ thread: OpenThreadPath | undefined;
92
107
  children: ReactNode;
93
108
  }
94
109
 
95
- function FlaggedPaneProvider({
96
- selectedMessageId,
97
- children,
98
- }: FlaggedPaneProps) {
110
+ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
99
111
  const navigate = useNavigate();
112
+ const { searchInput } = useMailContext();
113
+ const threadId = thread?.threadId;
114
+ const pointedAtMessageId = thread?.messageId;
100
115
 
101
116
  const { threads } = useStarredThreads();
102
117
 
103
- const selectedThread = useMemo(() => {
104
- if (!selectedMessageId) return undefined;
105
- return threads.find((t) => t.messageId === selectedMessageId);
106
- }, [threads, selectedMessageId]);
118
+ // The row the starred listing itself holds, preferred because a mutation
119
+ // patches it in place. A thread reached from a cold address is in no listing
120
+ // here and answers for itself — the folder it is filed in is the thread's own
121
+ // data, so Starred spanning folders costs the URL nothing.
122
+ const listedThread = useMemo(() => {
123
+ if (!threadId) return undefined;
124
+ return (
125
+ threads.find((t) => t.messageId === pointedAtMessageId) ??
126
+ threads.find((t) => t.threadId === threadId)
127
+ );
128
+ }, [threads, threadId, pointedAtMessageId]);
129
+ const ownRow = useThreadRow(threadId, pointedAtMessageId);
130
+ const selectedThread = listedThread ?? ownRow;
131
+
132
+ const selectedMessageId = pointedAtMessageId ?? selectedThread?.messageId;
107
133
 
108
- const handleSelectMessage = useCallback(
109
- (id: string, options?: OpenMessageOptions) => {
134
+ const conversation = useMemo<ConversationTarget | undefined>(() => {
135
+ if (!threadId) return undefined;
136
+ return {
137
+ threadId,
138
+ mailboxId: selectedThread?.mailboxId ?? "",
139
+ subject: selectedThread?.subject,
140
+ messageId: selectedMessageId,
141
+ authenticity: selectedThread?.authenticity,
142
+ };
143
+ }, [threadId, selectedThread, selectedMessageId]);
144
+
145
+ const handleOpenThread = useCallback(
146
+ (target: OpenThreadTarget, options?: OpenMessageOptions) => {
110
147
  navigate({
111
- to: "/mail/flagged",
112
- search: (prev) => ({ ...prev, selectedMessageId: id }),
148
+ to: "/mail/flagged/$threadId/$messageId",
149
+ params: target,
113
150
  replace: options?.replace,
151
+ // Commit the active query with the open so the debounced q-mirror —
152
+ // which walks back up to the list when the query goes active — is
153
+ // already satisfied and leaves the conversation alone. The *live*
154
+ // `searchInput`: a row can be tapped before the debounce settles, when
155
+ // the committed query is still empty.
156
+ search: (prev) => ({ ...prev, q: searchInput || undefined }),
114
157
  });
115
158
  },
116
- [navigate],
159
+ [navigate, searchInput],
117
160
  );
118
161
 
119
162
  const handleCloseThread = useCallback(() => {
120
- navigate({
121
- to: "/mail/flagged",
122
- search: (prev) => ({ ...prev, selectedMessageId: undefined }),
123
- });
163
+ navigate({ to: "/mail/flagged", search: (prev) => prev });
124
164
  }, [navigate]);
125
165
 
126
166
  const handleDeselectIfRemoved = useCallback(
@@ -187,16 +227,29 @@ function FlaggedPaneProvider({
187
227
  },
188
228
  });
189
229
 
230
+ // The swipe gestures open a whole conversation, so the adjacent row has to
231
+ // name its thread. The starred listing is where that is looked up, so a row
232
+ // it does not hold offers no gesture rather than a tap that goes nowhere.
233
+ const adjacentThread = useCallback(
234
+ (messageId: string | undefined): OpenThreadTarget | undefined => {
235
+ if (!messageId) return undefined;
236
+ const row = threads.find((t) => t.messageId === messageId);
237
+ return row ? { threadId: row.threadId, messageId } : undefined;
238
+ },
239
+ [threads],
240
+ );
241
+
190
242
  const ctx: FlaggedPaneContextValue = {
191
243
  selectedMessageId,
192
244
  selectedThread,
193
- onSelectMessage: handleSelectMessage,
245
+ conversation,
246
+ onOpenThread: handleOpenThread,
194
247
  onCloseThread: handleCloseThread,
248
+ nextThread: adjacentThread(nextMessageId),
249
+ previousThread: adjacentThread(previousMessageId),
195
250
  actions,
196
251
  triage,
197
252
  onDeleteMessages: deleteMessages,
198
- nextMessageId,
199
- previousMessageId,
200
253
  handleDeselectIfRemoved,
201
254
  };
202
255
 
@@ -211,12 +264,12 @@ function FlaggedPaneProvider({
211
264
 
212
265
  /** Flat starred list. Mount in the `list` slot of `AppShellSlotted`. */
213
266
  function FlaggedListSlot() {
214
- const { selectedMessageId, onSelectMessage, triage, onDeleteMessages } =
267
+ const { selectedMessageId, onOpenThread, triage, onDeleteMessages } =
215
268
  useFlaggedPane();
216
269
  return (
217
270
  <FlaggedList
218
271
  selectedMessageId={selectedMessageId}
219
- onSelectMessage={onSelectMessage}
272
+ onOpenThread={onOpenThread}
220
273
  commandsRef={triage.listCommandsRef}
221
274
  onTriageContextChange={triage.onTriageContextChange}
222
275
  onDeleteMessages={onDeleteMessages}
@@ -229,13 +282,13 @@ function FlaggedListSlot() {
229
282
  * Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
230
283
  */
231
284
  function FlaggedReading() {
232
- const { selectedThread, actions } = useFlaggedPane();
285
+ const { conversation, actions } = useFlaggedPane();
233
286
  const { intelligenceOpen, onToggleIntelligence } = useMailContext();
234
287
  // The rail's own width gate, not the shell tier: between 1024 and 1280 the
235
288
  // reading pane is mounted but the rail is not, so "enabled" would promise an
236
289
  // open that cannot happen.
237
290
  const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
238
- const hasThread = Boolean(selectedThread);
291
+ const hasThread = Boolean(conversation);
239
292
  const canToggleIntelligence = railFits && hasThread;
240
293
 
241
294
  return (
@@ -266,12 +319,13 @@ function FlaggedReading() {
266
319
  }
267
320
  />
268
321
  <div className="min-h-0 flex-1 overflow-hidden">
269
- {selectedThread ? (
322
+ {conversation ? (
270
323
  <ConversationView
271
- threadId={selectedThread.threadId}
272
- mailboxId={selectedThread.mailboxId}
273
- subject={selectedThread.subject}
274
- authenticity={selectedThread.authenticity}
324
+ threadId={conversation.threadId}
325
+ mailboxId={conversation.mailboxId}
326
+ subject={conversation.subject}
327
+ selectedMessageId={conversation.messageId}
328
+ authenticity={conversation.authenticity}
275
329
  composeRequest={actions.composeRequest}
276
330
  onComposeClose={actions.clearComposeRequest}
277
331
  />
@@ -306,31 +360,29 @@ function FlaggedIntelligence() {
306
360
  function FlaggedPhone() {
307
361
  const {
308
362
  selectedThread,
363
+ conversation,
309
364
  onCloseThread,
310
- onSelectMessage,
311
- nextMessageId,
312
- previousMessageId,
365
+ onOpenThread,
366
+ nextThread,
367
+ previousThread,
313
368
  handleDeselectIfRemoved,
314
369
  } = useFlaggedPane();
315
370
  const { intelligenceOpen, onToggleIntelligence } = useMailContext();
316
371
 
317
- if (selectedThread) {
372
+ if (conversation) {
318
373
  return (
319
374
  <>
320
375
  <ConversationView
321
- threadId={selectedThread.threadId}
322
- mailboxId={selectedThread.mailboxId}
323
- subject={selectedThread.subject}
324
- authenticity={selectedThread.authenticity}
376
+ threadId={conversation.threadId}
377
+ mailboxId={conversation.mailboxId}
378
+ subject={conversation.subject}
379
+ selectedMessageId={conversation.messageId}
380
+ authenticity={conversation.authenticity}
325
381
  onBack={onCloseThread}
326
382
  onOpenIntelligence={onToggleIntelligence}
327
- onSwipeNext={
328
- nextMessageId ? () => onSelectMessage(nextMessageId) : undefined
329
- }
383
+ onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
330
384
  onSwipePrevious={
331
- previousMessageId
332
- ? () => onSelectMessage(previousMessageId)
333
- : undefined
385
+ previousThread ? () => onOpenThread(previousThread) : undefined
334
386
  }
335
387
  mobileIntelligenceOpen={intelligenceOpen}
336
388
  />
@@ -258,7 +258,7 @@ export function MailSidebarAdapter({
258
258
  return (
259
259
  <NavLink
260
260
  to="/mail/flagged"
261
- search={{ q: undefined, selectedMessageId: undefined }}
261
+ search={{ q: undefined }}
262
262
  onClick={() => onClick?.()}
263
263
  className={className}
264
264
  aria-label={ariaLabel}
@@ -4,7 +4,7 @@ import {
4
4
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
5
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
6
6
  import { useMutation, useQueryClient } from "@tanstack/react-query";
7
- import { useNavigate, useSearch } from "@tanstack/react-router";
7
+ import { useNavigate } from "@tanstack/react-router";
8
8
  import {
9
9
  BadgeCheck,
10
10
  Check,
@@ -104,15 +104,7 @@ export const MessageActionMenu = ({
104
104
  const queryClient = useQueryClient();
105
105
  const navigate = useNavigate();
106
106
  const { pushError } = useErrorBanners();
107
- // Which message the address has open. The brief and a folder say so in their
108
- // path; the flagged list, still to move, says so in `?selectedMessageId=`.
109
- const openThread = useOpenThreadPath();
110
- const { selectedMessageId: selectedFromQuery } = useSearch({
111
- strict: false,
112
- }) as {
113
- selectedMessageId?: string;
114
- };
115
- const selectedMessageId = openThread?.messageId ?? selectedFromQuery;
107
+ const selectedMessageId = useOpenThreadPath()?.messageId;
116
108
 
117
109
  const { mutate: updateFlags, isPending: isUpdatingFlags } = useMutation({
118
110
  ...messageBulkOperationsUpdateFlagsMutation(),
@@ -243,7 +243,7 @@ export const MessageList = ({
243
243
  const { pushError } = useErrorBanners();
244
244
 
245
245
  // Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
246
- // from the open thread (`selectedMessageId` in the URL). j/k move this
246
+ // from the open thread (the message segment in the path). j/k move this
247
247
  // cursor; Enter opens the focused row → sets selected, and on desktop the
248
248
  // reading pane follows the cursor of its own accord (see the follow-focus
249
249
  // wiring below). It seeds from the open thread so opening a message also
@@ -1,12 +1,16 @@
1
1
  import { AlertCircle, AlertTriangle, Info, X } from "lucide-react";
2
2
  import { cn } from "../../lib/utils";
3
- import type { ErrorBannerSeverity } from "./error-banners.js";
3
+ import type {
4
+ ErrorBannerAction,
5
+ ErrorBannerSeverity,
6
+ } from "./error-banners.js";
4
7
 
5
8
  interface ErrorBannerProps {
6
9
  id: string;
7
10
  severity: ErrorBannerSeverity;
8
11
  title: string;
9
12
  detail?: string;
13
+ action?: ErrorBannerAction;
10
14
  onDismiss: (id: string) => void;
11
15
  }
12
16
 
@@ -51,6 +55,7 @@ export const ErrorBanner = ({
51
55
  severity,
52
56
  title,
53
57
  detail,
58
+ action,
54
59
  onDismiss,
55
60
  }: ErrorBannerProps) => {
56
61
  const styles = SEVERITY_STYLES[severity];
@@ -77,6 +82,16 @@ export const ErrorBanner = ({
77
82
  {detail && (
78
83
  <p className="mt-0.5 text-xs text-fg-muted break-words">{detail}</p>
79
84
  )}
85
+ {action && (
86
+ <a
87
+ href={action.href}
88
+ target="_blank"
89
+ rel="noopener noreferrer"
90
+ className="mt-1 inline-block text-xs font-medium text-accent-2 underline underline-offset-2"
91
+ >
92
+ {action.label}
93
+ </a>
94
+ )}
80
95
  </div>
81
96
  <button
82
97
  type="button"
@@ -25,6 +25,7 @@ export const ErrorBannerStack = ({
25
25
  severity={entry.severity}
26
26
  title={entry.title}
27
27
  detail={entry.detail}
28
+ action={entry.action}
28
29
  onDismiss={onDismiss}
29
30
  />
30
31
  ))}
@@ -1,10 +1,20 @@
1
1
  export type ErrorBannerSeverity = "error" | "warning" | "info";
2
2
 
3
+ /**
4
+ * A way out of the banner. A failure the user cannot act on is still theirs to
5
+ * report, and a link they have to assemble themselves is one nobody follows.
6
+ */
7
+ export interface ErrorBannerAction {
8
+ label: string;
9
+ href: string;
10
+ }
11
+
3
12
  export interface ErrorBannerEntry {
4
13
  id: string;
5
14
  severity: ErrorBannerSeverity;
6
15
  title: string;
7
16
  detail?: string;
17
+ action?: ErrorBannerAction;
8
18
  createdAt: number;
9
19
  }
10
20
 
@@ -12,6 +22,7 @@ export interface PushErrorInput {
12
22
  severity?: ErrorBannerSeverity;
13
23
  title: string;
14
24
  detail?: string;
25
+ action?: ErrorBannerAction;
15
26
  /**
16
27
  * The error being reported, when there is one. Pass it: a banner is a soft
17
28
  * surface, and `pushError` uses this to refuse errors that are not soft —
@@ -116,5 +127,6 @@ export const buildEntry = (
116
127
  severity: input.severity ?? "error",
117
128
  title: input.title,
118
129
  detail: input.detail,
130
+ action: input.action,
119
131
  createdAt: now,
120
132
  });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Where the search mirror writes, now that the brief's open thread is a path
2
+ * Where the search mirror writes, now that a list's open thread is a path
3
3
  * segment (#718).
4
4
  *
5
5
  * Mirroring `q` used to be one navigation with one destination, because the
@@ -8,9 +8,12 @@
8
8
  * walking up to the list — while every other write has to keep the address it
9
9
  * found, or clearing the search would shut the conversation being read.
10
10
  *
11
- * Driven through a real router over the brief's real route shape, because the
11
+ * Driven through a real router over each list's real route shape, because the
12
12
  * whole question is what the router resolves a destination to from a thread
13
- * route. Reasoning about `to: "."` is not evidence.
13
+ * route under it. Reasoning about `to: "."` is not evidence. The brief and the
14
+ * flagged list share this exact shape — a flat/sectioned list with a
15
+ * `$threadId/$messageId` pair below it — so the two are driven through the
16
+ * same cases rather than one covering for the other.
14
17
  */
15
18
 
16
19
  import assert from "node:assert/strict";
@@ -45,14 +48,18 @@ afterEach(() => {
45
48
  const THREAD_ID = "th-1";
46
49
  const MESSAGE_ID = "msg-1";
47
50
 
48
- const mailContext = (input: string, committed: string): MailContextValue => ({
51
+ const mailContext = (
52
+ input: string,
53
+ committed: string,
54
+ searchViewKey: string,
55
+ ): MailContextValue => ({
49
56
  accounts: [],
50
57
  mailboxNameIndex: new Map(),
51
58
  accountNameIndex: new Map(),
52
59
  resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
53
60
  searchQuery: committed,
54
61
  searchInput: input,
55
- searchViewKey: "/mail/brief",
62
+ searchViewKey,
56
63
  onSearchChange: () => {},
57
64
  onSearchClear: () => {},
58
65
  onSearchClearQuery: () => {},
@@ -61,11 +68,15 @@ const mailContext = (input: string, committed: string): MailContextValue => ({
61
68
  onSetIntelligenceOpen: () => {},
62
69
  });
63
70
 
71
+ type ListPath = "/mail/brief" | "/mail/flagged";
72
+
64
73
  /**
65
- * The brief's shape: the list is a layout route and the thread and message are
66
- * segments under it, so a write from the list has a matched child to lose.
74
+ * Both lists' shape: the list is a layout route and the thread and message
75
+ * are segments under it, so a write from the list has a matched child to
76
+ * lose.
67
77
  */
68
- const routerAt = (href: string): AnyRouter => {
78
+ const routerAt = (listPath: ListPath, href: string): AnyRouter => {
79
+ const listSegment = listPath.slice("/mail".length);
69
80
  const rootRoute = createRootRoute({ component: Outlet });
70
81
  const passthrough = (search: Record<string, unknown>) => search;
71
82
  const mailRoute = createRoute({
@@ -74,17 +85,17 @@ const routerAt = (href: string): AnyRouter => {
74
85
  validateSearch: passthrough,
75
86
  component: Outlet,
76
87
  });
77
- const briefRoute = createRoute({
88
+ const listRoute = createRoute({
78
89
  getParentRoute: () => mailRoute,
79
- path: "/brief",
90
+ path: listSegment,
80
91
  validateSearch: passthrough,
81
92
  component: () => {
82
- useSearchMirror({ to: "/mail/brief" });
93
+ useSearchMirror({ to: listPath });
83
94
  return createElement(Outlet);
84
95
  },
85
96
  });
86
97
  const threadRoute = createRoute({
87
- getParentRoute: () => briefRoute,
98
+ getParentRoute: () => listRoute,
88
99
  path: "/$threadId",
89
100
  component: Outlet,
90
101
  });
@@ -95,7 +106,7 @@ const routerAt = (href: string): AnyRouter => {
95
106
  });
96
107
  const routeTree = rootRoute.addChildren([
97
108
  mailRoute.addChildren([
98
- briefRoute.addChildren([threadRoute.addChildren([messageRoute])]),
109
+ listRoute.addChildren([threadRoute.addChildren([messageRoute])]),
99
110
  ]),
100
111
  ]);
101
112
  return createRouter({
@@ -108,6 +119,7 @@ const mount = async (
108
119
  router: AnyRouter,
109
120
  input: string,
110
121
  committed: string,
122
+ searchViewKey: string,
111
123
  ): Promise<DomHarness> => {
112
124
  const created = createDomHarness();
113
125
  harness = created;
@@ -115,7 +127,7 @@ const mount = async (
115
127
  created.renderApp(
116
128
  createElement(
117
129
  MailContext.Provider,
118
- { value: mailContext(input, committed) },
130
+ { value: mailContext(input, committed, searchViewKey) },
119
131
  createElement(RouterProvider, { router }),
120
132
  ),
121
133
  );
@@ -124,58 +136,62 @@ const mount = async (
124
136
  return created;
125
137
  };
126
138
 
127
- const threadHref = `/mail/brief/${THREAD_ID}/${MESSAGE_ID}`;
139
+ const LIST_PATHS: ListPath[] = ["/mail/brief", "/mail/flagged"];
128
140
 
129
- describe("mirroring a query that goes active", () => {
130
- it("walks up to the list, so no thread stays matched behind the results", async () => {
131
- const router = routerAt(threadHref);
132
- await mount(router, "invoice", "invoice");
141
+ for (const listPath of LIST_PATHS) {
142
+ const threadHref = `${listPath}/${THREAD_ID}/${MESSAGE_ID}`;
133
143
 
134
- assert.equal(router.history.location.pathname, "/mail/brief");
135
- assert.match(router.history.location.search, /q=invoice/);
136
- });
144
+ describe(`mirroring a query that goes active (${listPath})`, () => {
145
+ it("walks up to the list, so no thread stays matched behind the results", async () => {
146
+ const router = routerAt(listPath, threadHref);
147
+ await mount(router, "invoice", "invoice", listPath);
137
148
 
138
- it("closes the pane from a bare thread address too", async () => {
139
- const router = routerAt(`/mail/brief/${THREAD_ID}`);
140
- await mount(router, "invoice", "invoice");
149
+ assert.equal(router.history.location.pathname, listPath);
150
+ assert.match(router.history.location.search, /q=invoice/);
151
+ });
141
152
 
142
- assert.equal(router.history.location.pathname, "/mail/brief");
143
- });
153
+ it("closes the pane from a bare thread address too", async () => {
154
+ const router = routerAt(listPath, `${listPath}/${THREAD_ID}`);
155
+ await mount(router, "invoice", "invoice", listPath);
156
+
157
+ assert.equal(router.history.location.pathname, listPath);
158
+ });
144
159
 
145
- it("replaces rather than pushes, so Back is not a search step", async () => {
146
- const router = routerAt(threadHref);
147
- const before = router.history.length;
148
- await mount(router, "invoice", "invoice");
160
+ it("replaces rather than pushes, so Back is not a search step", async () => {
161
+ const router = routerAt(listPath, threadHref);
162
+ const before = router.history.length;
163
+ await mount(router, "invoice", "invoice", listPath);
149
164
 
150
- assert.equal(router.history.length, before);
165
+ assert.equal(router.history.length, before);
166
+ });
151
167
  });
152
- });
153
168
 
154
- describe("mirroring a cleared query", () => {
155
- it("keeps the conversation open — dropping the search is not closing it", async () => {
156
- const router = routerAt(`${threadHref}?q=invoice`);
157
- await mount(router, "", "");
169
+ describe(`mirroring a cleared query (${listPath})`, () => {
170
+ it("keeps the conversation open — dropping the search is not closing it", async () => {
171
+ const router = routerAt(listPath, `${threadHref}?q=invoice`);
172
+ await mount(router, "", "", listPath);
158
173
 
159
- assert.equal(router.history.location.pathname, threadHref);
160
- assert.equal(router.history.location.search.includes("q="), false);
161
- });
174
+ assert.equal(router.history.location.pathname, threadHref);
175
+ assert.equal(router.history.location.search.includes("q="), false);
176
+ });
162
177
 
163
- it("leaves the address alone when there is nothing to write", async () => {
164
- const router = routerAt(`${threadHref}?q=invoice`);
165
- await mount(router, "invoice", "invoice");
178
+ it("leaves the address alone when there is nothing to write", async () => {
179
+ const router = routerAt(listPath, `${threadHref}?q=invoice`);
180
+ await mount(router, "invoice", "invoice", listPath);
166
181
 
167
- assert.equal(router.history.location.pathname, threadHref);
168
- assert.match(router.history.location.search, /q=invoice/);
169
- });
182
+ assert.equal(router.history.location.pathname, threadHref);
183
+ assert.match(router.history.location.search, /q=invoice/);
184
+ });
170
185
 
171
- it("waits for the debounce rather than writing the previous query", async () => {
172
- const router = routerAt(threadHref);
173
- await mount(router, "invo", "");
186
+ it("waits for the debounce rather than writing the previous query", async () => {
187
+ const router = routerAt(listPath, threadHref);
188
+ await mount(router, "invo", "", listPath);
174
189
 
175
- assert.equal(router.history.location.pathname, threadHref);
176
- assert.equal(router.history.location.search.includes("q="), false);
190
+ assert.equal(router.history.location.pathname, threadHref);
191
+ assert.equal(router.history.location.search.includes("q="), false);
192
+ });
177
193
  });
178
- });
194
+ }
179
195
 
180
196
  /**
181
197
  * The other half of why a typed query survives an opened thread: the field
@@ -185,9 +201,12 @@ describe("mirroring a cleared query", () => {
185
201
  * Read off the matches the router resolved for a real address, so the ids under
186
202
  * test are the ones path segments produce rather than ids written out here.
187
203
  */
188
- describe("the view key of an open brief thread", () => {
189
- const viewKeyAt = async (href: string): Promise<string> => {
190
- const router = routerAt(href);
204
+ describe("the view key of an open thread", () => {
205
+ const viewKeyAt = async (
206
+ listPath: ListPath,
207
+ href: string,
208
+ ): Promise<string> => {
209
+ const router = routerAt(listPath, href);
191
210
  await router.load();
192
211
  return mailViewKey(
193
212
  router.state.matches.map((match: { routeId: string }) => ({
@@ -196,10 +215,15 @@ describe("the view key of an open brief thread", () => {
196
215
  );
197
216
  };
198
217
 
199
- it("is the brief's own, thread and message segments alike", async () => {
200
- const list = await viewKeyAt("/mail/brief");
201
- assert.notEqual(list, "", "the brief resolves to a view key at all");
202
- assert.equal(await viewKeyAt(`/mail/brief/${THREAD_ID}`), list);
203
- assert.equal(await viewKeyAt(threadHref), list);
204
- });
218
+ for (const listPath of LIST_PATHS) {
219
+ it(`is the list's own, thread and message segments alike (${listPath})`, async () => {
220
+ const list = await viewKeyAt(listPath, listPath);
221
+ assert.notEqual(list, "", "the list resolves to a view key at all");
222
+ assert.equal(await viewKeyAt(listPath, `${listPath}/${THREAD_ID}`), list);
223
+ assert.equal(
224
+ await viewKeyAt(listPath, `${listPath}/${THREAD_ID}/${MESSAGE_ID}`),
225
+ list,
226
+ );
227
+ });
228
+ }
205
229
  });