@remit/web-client 0.0.153 → 0.0.154

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.153",
3
+ "version": "0.0.154",
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": {
@@ -115,15 +115,11 @@ export const ComposeProvider = ({
115
115
  // mounts `FullCompose`, and only with no thread in the pane it takes over.
116
116
  const openCompose = useCallback(
117
117
  (params: Omit<ComposeState, "isOpen">) => {
118
- const search = location.search as Record<string, unknown>;
119
- // A folder names its open conversation in the path, so leaving it is a
120
- // navigation up to the list; the lists still to move name it in the query.
118
+ // Every list names its open conversation in the path, so leaving one is a
119
+ // navigation up to the list.
121
120
  const threadMailboxId = locationOpensDetail(location.pathname)
122
121
  ? routeParams?.mailboxId
123
122
  : undefined;
124
- const showsThread =
125
- Boolean(threadMailboxId) ||
126
- Boolean(search.selectedMessageId ?? search.selectedThreadId);
127
123
  if (!hostsComposeSurface(location.pathname)) {
128
124
  if (target.status === "loading") {
129
125
  pushError({
@@ -150,33 +146,15 @@ export const ComposeProvider = ({
150
146
  return;
151
147
  }
152
148
  setState({ ...params, isOpen: true });
153
- if (!showsThread) return;
149
+ if (!threadMailboxId) return;
154
150
  // A push, so Back reopens the message.
155
- if (threadMailboxId) {
156
- navigate({
157
- to: "/mail/$mailboxId",
158
- params: { mailboxId: threadMailboxId },
159
- search: (prev) => prev,
160
- });
161
- return;
162
- }
163
151
  navigate({
164
- to: ".",
165
- search: (prev: Record<string, unknown>) => ({
166
- ...prev,
167
- selectedMessageId: undefined,
168
- selectedThreadId: undefined,
169
- }),
152
+ to: "/mail/$mailboxId",
153
+ params: { mailboxId: threadMailboxId },
154
+ search: (prev) => prev,
170
155
  });
171
156
  },
172
- [
173
- navigate,
174
- location.pathname,
175
- location.search,
176
- routeParams?.mailboxId,
177
- target,
178
- pushError,
179
- ],
157
+ [navigate, location.pathname, routeParams?.mailboxId, target, pushError],
180
158
  );
181
159
 
182
160
  const closeCompose = useCallback(() => {
@@ -62,8 +62,29 @@ const RootLayout = () =>
62
62
  createElement(Outlet),
63
63
  );
64
64
 
65
+ /**
66
+ * The folder's real shape: the thread and the message are segments under the
67
+ * list, so an open conversation is something the address holds and compose has
68
+ * to navigate out of.
69
+ */
65
70
  const routerAt = (href: string, layout = RootLayout): AnyRouter => {
66
71
  const rootRoute = createRootRoute({ component: layout });
72
+ const mailboxRoute = createRoute({
73
+ getParentRoute: () => rootRoute,
74
+ path: "/mail/$mailboxId",
75
+ validateSearch: (search: Record<string, unknown>) => search,
76
+ component: Outlet,
77
+ });
78
+ const threadRoute = createRoute({
79
+ getParentRoute: () => mailboxRoute,
80
+ path: "/$threadId",
81
+ component: Outlet,
82
+ });
83
+ const messageRoute = createRoute({
84
+ getParentRoute: () => threadRoute,
85
+ path: "/$messageId",
86
+ component: () => null,
87
+ });
67
88
  const routeTree = rootRoute.addChildren([
68
89
  createRoute({
69
90
  getParentRoute: () => rootRoute,
@@ -71,12 +92,7 @@ const routerAt = (href: string, layout = RootLayout): AnyRouter => {
71
92
  validateSearch: (search: Record<string, unknown>) => search,
72
93
  component: () => null,
73
94
  }),
74
- createRoute({
75
- getParentRoute: () => rootRoute,
76
- path: "/mail/$mailboxId",
77
- validateSearch: (search: Record<string, unknown>) => search,
78
- component: () => null,
79
- }),
95
+ mailboxRoute.addChildren([threadRoute.addChildren([messageRoute])]),
80
96
  createRoute({
81
97
  getParentRoute: () => rootRoute,
82
98
  path: "/settings",
@@ -145,11 +161,11 @@ const press = async (mounted: DomHarness): Promise<HTMLElement> => {
145
161
  return button;
146
162
  };
147
163
 
164
+ const THREAD_HREF = `/mail/${INBOX_ID}/th-1/msg-1`;
165
+
148
166
  describe("opening compose over an open message (#703)", () => {
149
- it("drops the selected message so the pane can render the surface", async () => {
150
- const router = routerAt(
151
- `/mail/${INBOX_ID}?selectedMessageId=msg-1&selectedThreadId=th-1`,
152
- );
167
+ it("walks up to the list so the pane can render the surface", async () => {
168
+ const router = routerAt(THREAD_HREF);
153
169
  const mounted = await mount(router);
154
170
 
155
171
  const button = mounted.byText("button", "Compose");
@@ -159,33 +175,27 @@ describe("opening compose over an open message (#703)", () => {
159
175
 
160
176
  assert.equal(button.getAttribute("data-open"), "true");
161
177
  assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
162
- const search = router.history.location.search;
163
- assert.equal(search.includes("selectedMessageId"), false);
164
- assert.equal(search.includes("selectedThreadId"), false);
165
178
  });
166
179
 
167
- it("keeps the rest of the query, so the search the user typed survives", async () => {
168
- const router = routerAt(
169
- `/mail/${INBOX_ID}?q=invoice&selectedMessageId=msg-1`,
170
- );
180
+ it("keeps the query, so the search the user typed survives", async () => {
181
+ const router = routerAt(`${THREAD_HREF}?q=invoice`);
171
182
  const mounted = await mount(router);
172
183
 
173
184
  await press(mounted);
174
185
 
175
- const search = router.history.location.search;
176
- assert.equal(search.includes("selectedMessageId"), false);
177
- assert.match(search, /q=invoice/);
186
+ assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
187
+ assert.match(router.history.location.search, /q=invoice/);
178
188
  });
179
189
 
180
190
  it("leaves the message one Back away rather than erasing it", async () => {
181
- const router = routerAt(`/mail/${INBOX_ID}?selectedMessageId=msg-1`);
191
+ const router = routerAt(THREAD_HREF);
182
192
  const mounted = await mount(router);
183
193
 
184
194
  await press(mounted);
185
195
  router.history.back();
186
196
  await mounted.flush();
187
197
 
188
- assert.match(router.history.location.search, /selectedMessageId=msg-1/);
198
+ assert.equal(router.history.location.pathname, THREAD_HREF);
189
199
  });
190
200
 
191
201
  it("adds no history entry when the pane had nothing open", async () => {
@@ -14,9 +14,7 @@ import { locationOpensDetail } from "@/lib/mail-route";
14
14
  * `lg:hidden` class covers the pre-hydration frame.
15
15
  * - The compose surface is already open.
16
16
  * - The user is reading a thread — the single pane is the conversation, and
17
- * its reply bar is under this corner. The brief and a folder say so in
18
- * their path; the flagged list, still to move, says so in
19
- * `?selectedMessageId=…`.
17
+ * its reply bar is under this corner. Every list says so in its path.
20
18
  * - The user is off `/mail`, which is every route with no mail in it.
21
19
  */
22
20
  export const ComposeFab = () => {
@@ -26,10 +24,7 @@ export const ComposeFab = () => {
26
24
  openCompose({ mode: "new" });
27
25
  }, [openCompose]);
28
26
 
29
- const search = location.search as Record<string, unknown> | undefined;
30
- const isReadingThread =
31
- Boolean(search?.selectedMessageId) ||
32
- locationOpensDetail(location.pathname);
27
+ const isReadingThread = locationOpensDetail(location.pathname);
33
28
 
34
29
  if (!location.pathname.startsWith("/mail") || state.isOpen || isReadingThread)
35
30
  return null;
@@ -3,19 +3,20 @@
3
3
  *
4
4
  * Reads the starred listing through `useStarredThreads` — GET /threads with
5
5
  * `starred=true`, served by the `byStarred` index — which returns every starred
6
- * thread in the config across all non-muted mailboxes, paged. `FlaggedPane`
7
- * resolves the open thread from that same hook, so every row rendered here can
8
- * be opened. Starredness is decided server-side from `hasStars`; the client
9
- * neither re-filters nor caps the set, so a starred thread outside the newest
10
- * inbox page still appears. Rendered as one continuous list (no category
11
- * sections). The shared `MailViewChrome` owns the `MailHeader` + filter
12
- * expando; the kit `MessageListPane` (flat, no `briefFilters`) owns the loading
13
- * / empty / error chrome and keyboard hints, with a consumer-supplied
14
- * `listBody` so the real rows render at every width.
6
+ * thread in the config across all non-muted mailboxes, paged. Every row names
7
+ * its thread, which is the whole address a conversation opens by, so a starred
8
+ * message filed outside the inbox opens like any other. Starredness is decided
9
+ * server-side from `hasStars`; the client neither re-filters nor caps the set,
10
+ * so a starred thread outside the newest inbox page still appears. Rendered as
11
+ * one continuous list (no category sections). The shared `MailViewChrome` owns
12
+ * the `MailHeader` + filter expando; the kit `MessageListPane` (flat, no
13
+ * `briefFilters`) owns the loading / empty / error chrome and keyboard hints,
14
+ * with a consumer-supplied `listBody` so the real rows render at every width.
15
15
  */
16
16
  import {
17
17
  flaggedFilterConfig,
18
18
  MessageListPane,
19
+ type SearchResult,
19
20
  type ThreadRowData,
20
21
  type Verb,
21
22
  } from "@remit/ui";
@@ -37,6 +38,7 @@ import { rowToSearchResult } from "@/lib/search-result";
37
38
  import { parseSearchTokens } from "@/lib/search-tokens";
38
39
  import { dedupeByThread } from "@/lib/starred-rows";
39
40
  import { useSelectionWizard } from "@/lib/wizard-history";
41
+ import type { OpenThreadTarget } from "@/routing";
40
42
  import { MailViewChrome } from "./MailViewChrome";
41
43
  import type { MessageListCommands } from "./MessageList";
42
44
  import { MessageRow } from "./MessageRow";
@@ -101,7 +103,14 @@ function StarredWizardHost({
101
103
 
102
104
  interface FlaggedListProps {
103
105
  selectedMessageId?: string;
104
- onSelectMessage?: (id: string, options?: OpenMessageOptions) => void;
106
+ /**
107
+ * Opens a row. Every starred row names its thread, so a message filed outside
108
+ * the inbox opens by exactly the same route as one inside it.
109
+ */
110
+ onOpenThread?: (
111
+ target: OpenThreadTarget,
112
+ options?: OpenMessageOptions,
113
+ ) => void;
105
114
  /** Where the list publishes the commands the keyboard layer drives. */
106
115
  commandsRef?: RefObject<MessageListCommands | null>;
107
116
  /** Cursor / selection / display order, reported up to the triage layer. */
@@ -111,7 +120,7 @@ interface FlaggedListProps {
111
120
 
112
121
  export function FlaggedList({
113
122
  selectedMessageId,
114
- onSelectMessage,
123
+ onOpenThread,
115
124
  commandsRef,
116
125
  onTriageContextChange,
117
126
  onDeleteMessages,
@@ -171,6 +180,27 @@ export function FlaggedList({
171
180
  );
172
181
  }, [threads, selectedCategory, activeFilters, sq, queryTokens]);
173
182
 
183
+ const openRow = useCallback(
184
+ (id: string, options?: OpenMessageOptions) => {
185
+ const threadId = rows.find((row) => row.id === id)?.threadId;
186
+ if (!threadId) return;
187
+ onOpenThread?.({ threadId, messageId: id }, options);
188
+ },
189
+ [rows, onOpenThread],
190
+ );
191
+
192
+ // The two-engine results panel, whose semantic hits are in no list at all —
193
+ // each one carries the thread it belongs to.
194
+ const openResult = useCallback(
195
+ (result: SearchResult) => {
196
+ const threadId =
197
+ result.threadId ?? rows.find((row) => row.id === result.id)?.threadId;
198
+ if (!threadId) return;
199
+ onOpenThread?.({ threadId, messageId: result.id });
200
+ },
201
+ [rows, onOpenThread],
202
+ );
203
+
174
204
  const preset = useMemo(() => flaggedFilterConfig(), []);
175
205
 
176
206
  const searchResults = useMemo(
@@ -206,7 +236,7 @@ export function FlaggedList({
206
236
  key={thread.id}
207
237
  thread={thread}
208
238
  active={thread.id === selectedMessageId}
209
- onClick={() => onSelectMessage?.(thread.id)}
239
+ onClick={() => openRow(thread.id)}
210
240
  />
211
241
  ))}
212
242
  </div>
@@ -236,7 +266,7 @@ export function FlaggedList({
236
266
  onClearFilters={clearFilters}
237
267
  searchResults={searchResults}
238
268
  searchLoading={isLoading}
239
- onSelectSearchResult={(result) => onSelectMessage?.(result.id)}
269
+ onSelectSearchResult={openResult}
240
270
  // A committed search renders in this view's own rows (`rows` already
241
271
  // narrows to the query above), so the multi-select toolbar stays
242
272
  // reachable exactly as it does on the mailbox route (#212). The
@@ -245,7 +275,7 @@ export function FlaggedList({
245
275
  >
246
276
  <ThreadListInteraction
247
277
  selectedMessageId={selectedMessageId}
248
- onOpen={(id, options) => onSelectMessage?.(id, options)}
278
+ onOpen={openRow}
249
279
  onDeleteMessages={onDeleteMessages}
250
280
  onSelectionVerb={wizard.start}
251
281
  wizardOpen={wizard.isOpen}
@@ -263,7 +293,7 @@ export function FlaggedList({
263
293
  onRetry={() => refetch()}
264
294
  onReportError={handleReportError}
265
295
  selectedThreadId={selectedMessageId}
266
- onSelectThread={onSelectMessage}
296
+ onSelectThread={openRow}
267
297
  isDesktop={isDesktop}
268
298
  selectionBar={<ThreadListSelectionBar title="Starred" />}
269
299
  listBody={
@@ -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,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
  });
@@ -31,10 +31,9 @@ export type SearchMirrorTarget =
31
31
  * When a query *goes* active it also closes the reading pane (#539): an open
32
32
  * message from the pre-search list is not meaningful in the search result set.
33
33
  * The close is a navigation to the list route, which unmatches the thread that
34
- * was open under it plus, until the remaining lists move their selection into
35
- * the path, dropping the params they still read it from. Any other write keeps
36
- * the address it found: mirroring a query the reader is editing, or clearing
37
- * one, must not shut the conversation they are reading.
34
+ * was open under it. Any other write keeps the address it found: mirroring a
35
+ * query the reader is editing, or clearing one, must not shut the conversation
36
+ * they are reading.
38
37
  *
39
38
  * Only on that transition, though — tapping a search result commits the same `q`
40
39
  * with the open thread, so when the URL already says the query the conversation
@@ -71,13 +70,6 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
71
70
  const search = (prev: Record<string, unknown>) => ({
72
71
  ...prev,
73
72
  q: committedQuery || undefined,
74
- ...(queryGoesActive
75
- ? {
76
- selectedMessageId: undefined,
77
- selectedThreadId: undefined,
78
- selectedMailboxId: undefined,
79
- }
80
- : {}),
81
73
  });
82
74
  if (!queryGoesActive) {
83
75
  navigate({ to: ".", search, replace: true });
@@ -16,13 +16,12 @@ const listSearch = z.object({ q: z.string().optional() });
16
16
  * The brief's open thread and the message inside it are path segments
17
17
  * (`/mail/brief/<thread>/<message>`), so the query carries nothing but the
18
18
  * search. An old link's selection params are dropped here, which is what
19
- * "tolerated and ignored" means until the remaining lists follow.
19
+ * "tolerated and ignored" means.
20
20
  */
21
21
  export const briefSearchSchema = listSearch.extend({});
22
22
 
23
- export const flaggedSearchSchema = listSearch.extend({
24
- selectedMessageId: z.string().optional(),
25
- });
23
+ /** The same, for `/mail/flagged/<thread>/<message>`. */
24
+ export const flaggedSearchSchema = listSearch.extend({});
26
25
 
27
26
  /**
28
27
  * The outbox's open message is a path segment
@@ -32,11 +32,14 @@ import { Route as MailMailboxIdThreadIdRouteImport } from './routes/mail/$mailbo
32
32
  import { Route as MailBriefIndexRouteImport } from './routes/mail/brief/index'
33
33
  import { Route as MailBriefThreadIdRouteImport } from './routes/mail/brief/$threadId'
34
34
  import { Route as MailFlaggedIndexRouteImport } from './routes/mail/flagged/index'
35
+ import { Route as MailFlaggedThreadIdRouteImport } from './routes/mail/flagged/$threadId'
35
36
  import { Route as MailOutboxIndexRouteImport } from './routes/mail/outbox/index'
36
37
  import { Route as MailMailboxIdThreadIdIndexRouteImport } from './routes/mail/$mailboxId/$threadId/index'
37
38
  import { Route as MailMailboxIdThreadIdMessageIdRouteImport } from './routes/mail/$mailboxId/$threadId/$messageId'
38
39
  import { Route as MailBriefThreadIdIndexRouteImport } from './routes/mail/brief/$threadId/index'
39
40
  import { Route as MailBriefThreadIdMessageIdRouteImport } from './routes/mail/brief/$threadId/$messageId'
41
+ import { Route as MailFlaggedThreadIdIndexRouteImport } from './routes/mail/flagged/$threadId/index'
42
+ import { Route as MailFlaggedThreadIdMessageIdRouteImport } from './routes/mail/flagged/$threadId/$messageId'
40
43
  import { Route as MailOutboxDraftOutboxMessageIdRouteImport } from './routes/mail/outbox/draft/$outboxMessageId'
41
44
 
42
45
  const IndexRoute = IndexRouteImport.update({
@@ -154,6 +157,11 @@ const MailFlaggedIndexRoute = MailFlaggedIndexRouteImport.update({
154
157
  path: '/',
155
158
  getParentRoute: () => MailFlaggedRoute,
156
159
  } as any)
160
+ const MailFlaggedThreadIdRoute = MailFlaggedThreadIdRouteImport.update({
161
+ id: '/$threadId',
162
+ path: '/$threadId',
163
+ getParentRoute: () => MailFlaggedRoute,
164
+ } as any)
157
165
  const MailOutboxIndexRoute = MailOutboxIndexRouteImport.update({
158
166
  id: '/',
159
167
  path: '/',
@@ -182,6 +190,18 @@ const MailBriefThreadIdMessageIdRoute =
182
190
  path: '/$messageId',
183
191
  getParentRoute: () => MailBriefThreadIdRoute,
184
192
  } as any)
193
+ const MailFlaggedThreadIdIndexRoute =
194
+ MailFlaggedThreadIdIndexRouteImport.update({
195
+ id: '/',
196
+ path: '/',
197
+ getParentRoute: () => MailFlaggedThreadIdRoute,
198
+ } as any)
199
+ const MailFlaggedThreadIdMessageIdRoute =
200
+ MailFlaggedThreadIdMessageIdRouteImport.update({
201
+ id: '/$messageId',
202
+ path: '/$messageId',
203
+ getParentRoute: () => MailFlaggedThreadIdRoute,
204
+ } as any)
185
205
  const MailOutboxDraftOutboxMessageIdRoute =
186
206
  MailOutboxDraftOutboxMessageIdRouteImport.update({
187
207
  id: '/draft/$outboxMessageId',
@@ -210,15 +230,18 @@ export interface FileRoutesByFullPath {
210
230
  '/settings/': typeof SettingsIndexRoute
211
231
  '/mail/$mailboxId/$threadId': typeof MailMailboxIdThreadIdRouteWithChildren
212
232
  '/mail/brief/$threadId': typeof MailBriefThreadIdRouteWithChildren
233
+ '/mail/flagged/$threadId': typeof MailFlaggedThreadIdRouteWithChildren
213
234
  '/mail/$mailboxId/': typeof MailMailboxIdIndexRoute
214
235
  '/mail/brief/': typeof MailBriefIndexRoute
215
236
  '/mail/flagged/': typeof MailFlaggedIndexRoute
216
237
  '/mail/outbox/': typeof MailOutboxIndexRoute
217
238
  '/mail/$mailboxId/$threadId/$messageId': typeof MailMailboxIdThreadIdMessageIdRoute
218
239
  '/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
240
+ '/mail/flagged/$threadId/$messageId': typeof MailFlaggedThreadIdMessageIdRoute
219
241
  '/mail/outbox/draft/$outboxMessageId': typeof MailOutboxDraftOutboxMessageIdRoute
220
242
  '/mail/$mailboxId/$threadId/': typeof MailMailboxIdThreadIdIndexRoute
221
243
  '/mail/brief/$threadId/': typeof MailBriefThreadIdIndexRoute
244
+ '/mail/flagged/$threadId/': typeof MailFlaggedThreadIdIndexRoute
222
245
  }
223
246
  export interface FileRoutesByTo {
224
247
  '/': typeof IndexRoute
@@ -239,9 +262,11 @@ export interface FileRoutesByTo {
239
262
  '/mail/outbox': typeof MailOutboxIndexRoute
240
263
  '/mail/$mailboxId/$threadId/$messageId': typeof MailMailboxIdThreadIdMessageIdRoute
241
264
  '/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
265
+ '/mail/flagged/$threadId/$messageId': typeof MailFlaggedThreadIdMessageIdRoute
242
266
  '/mail/outbox/draft/$outboxMessageId': typeof MailOutboxDraftOutboxMessageIdRoute
243
267
  '/mail/$mailboxId/$threadId': typeof MailMailboxIdThreadIdIndexRoute
244
268
  '/mail/brief/$threadId': typeof MailBriefThreadIdIndexRoute
269
+ '/mail/flagged/$threadId': typeof MailFlaggedThreadIdIndexRoute
245
270
  }
246
271
  export interface FileRoutesById {
247
272
  __root__: typeof rootRouteImport
@@ -265,15 +290,18 @@ export interface FileRoutesById {
265
290
  '/settings/': typeof SettingsIndexRoute
266
291
  '/mail/$mailboxId/$threadId': typeof MailMailboxIdThreadIdRouteWithChildren
267
292
  '/mail/brief/$threadId': typeof MailBriefThreadIdRouteWithChildren
293
+ '/mail/flagged/$threadId': typeof MailFlaggedThreadIdRouteWithChildren
268
294
  '/mail/$mailboxId/': typeof MailMailboxIdIndexRoute
269
295
  '/mail/brief/': typeof MailBriefIndexRoute
270
296
  '/mail/flagged/': typeof MailFlaggedIndexRoute
271
297
  '/mail/outbox/': typeof MailOutboxIndexRoute
272
298
  '/mail/$mailboxId/$threadId/$messageId': typeof MailMailboxIdThreadIdMessageIdRoute
273
299
  '/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
300
+ '/mail/flagged/$threadId/$messageId': typeof MailFlaggedThreadIdMessageIdRoute
274
301
  '/mail/outbox/draft/$outboxMessageId': typeof MailOutboxDraftOutboxMessageIdRoute
275
302
  '/mail/$mailboxId/$threadId/': typeof MailMailboxIdThreadIdIndexRoute
276
303
  '/mail/brief/$threadId/': typeof MailBriefThreadIdIndexRoute
304
+ '/mail/flagged/$threadId/': typeof MailFlaggedThreadIdIndexRoute
277
305
  }
278
306
  export interface FileRouteTypes {
279
307
  fileRoutesByFullPath: FileRoutesByFullPath
@@ -298,15 +326,18 @@ export interface FileRouteTypes {
298
326
  | '/settings/'
299
327
  | '/mail/$mailboxId/$threadId'
300
328
  | '/mail/brief/$threadId'
329
+ | '/mail/flagged/$threadId'
301
330
  | '/mail/$mailboxId/'
302
331
  | '/mail/brief/'
303
332
  | '/mail/flagged/'
304
333
  | '/mail/outbox/'
305
334
  | '/mail/$mailboxId/$threadId/$messageId'
306
335
  | '/mail/brief/$threadId/$messageId'
336
+ | '/mail/flagged/$threadId/$messageId'
307
337
  | '/mail/outbox/draft/$outboxMessageId'
308
338
  | '/mail/$mailboxId/$threadId/'
309
339
  | '/mail/brief/$threadId/'
340
+ | '/mail/flagged/$threadId/'
310
341
  fileRoutesByTo: FileRoutesByTo
311
342
  to:
312
343
  | '/'
@@ -327,9 +358,11 @@ export interface FileRouteTypes {
327
358
  | '/mail/outbox'
328
359
  | '/mail/$mailboxId/$threadId/$messageId'
329
360
  | '/mail/brief/$threadId/$messageId'
361
+ | '/mail/flagged/$threadId/$messageId'
330
362
  | '/mail/outbox/draft/$outboxMessageId'
331
363
  | '/mail/$mailboxId/$threadId'
332
364
  | '/mail/brief/$threadId'
365
+ | '/mail/flagged/$threadId'
333
366
  id:
334
367
  | '__root__'
335
368
  | '/'
@@ -352,15 +385,18 @@ export interface FileRouteTypes {
352
385
  | '/settings/'
353
386
  | '/mail/$mailboxId/$threadId'
354
387
  | '/mail/brief/$threadId'
388
+ | '/mail/flagged/$threadId'
355
389
  | '/mail/$mailboxId/'
356
390
  | '/mail/brief/'
357
391
  | '/mail/flagged/'
358
392
  | '/mail/outbox/'
359
393
  | '/mail/$mailboxId/$threadId/$messageId'
360
394
  | '/mail/brief/$threadId/$messageId'
395
+ | '/mail/flagged/$threadId/$messageId'
361
396
  | '/mail/outbox/draft/$outboxMessageId'
362
397
  | '/mail/$mailboxId/$threadId/'
363
398
  | '/mail/brief/$threadId/'
399
+ | '/mail/flagged/$threadId/'
364
400
  fileRoutesById: FileRoutesById
365
401
  }
366
402
  export interface RootRouteChildren {
@@ -533,6 +569,13 @@ declare module '@tanstack/react-router' {
533
569
  preLoaderRoute: typeof MailFlaggedIndexRouteImport
534
570
  parentRoute: typeof MailFlaggedRoute
535
571
  }
572
+ '/mail/flagged/$threadId': {
573
+ id: '/mail/flagged/$threadId'
574
+ path: '/$threadId'
575
+ fullPath: '/mail/flagged/$threadId'
576
+ preLoaderRoute: typeof MailFlaggedThreadIdRouteImport
577
+ parentRoute: typeof MailFlaggedRoute
578
+ }
536
579
  '/mail/outbox/': {
537
580
  id: '/mail/outbox/'
538
581
  path: '/'
@@ -568,6 +611,20 @@ declare module '@tanstack/react-router' {
568
611
  preLoaderRoute: typeof MailBriefThreadIdMessageIdRouteImport
569
612
  parentRoute: typeof MailBriefThreadIdRoute
570
613
  }
614
+ '/mail/flagged/$threadId/': {
615
+ id: '/mail/flagged/$threadId/'
616
+ path: '/'
617
+ fullPath: '/mail/flagged/$threadId/'
618
+ preLoaderRoute: typeof MailFlaggedThreadIdIndexRouteImport
619
+ parentRoute: typeof MailFlaggedThreadIdRoute
620
+ }
621
+ '/mail/flagged/$threadId/$messageId': {
622
+ id: '/mail/flagged/$threadId/$messageId'
623
+ path: '/$messageId'
624
+ fullPath: '/mail/flagged/$threadId/$messageId'
625
+ preLoaderRoute: typeof MailFlaggedThreadIdMessageIdRouteImport
626
+ parentRoute: typeof MailFlaggedThreadIdRoute
627
+ }
571
628
  '/mail/outbox/draft/$outboxMessageId': {
572
629
  id: '/mail/outbox/draft/$outboxMessageId'
573
630
  path: '/draft/$outboxMessageId'
@@ -634,11 +691,26 @@ const MailBriefRouteWithChildren = MailBriefRoute._addFileChildren(
634
691
  MailBriefRouteChildren,
635
692
  )
636
693
 
694
+ interface MailFlaggedThreadIdRouteChildren {
695
+ MailFlaggedThreadIdMessageIdRoute: typeof MailFlaggedThreadIdMessageIdRoute
696
+ MailFlaggedThreadIdIndexRoute: typeof MailFlaggedThreadIdIndexRoute
697
+ }
698
+
699
+ const MailFlaggedThreadIdRouteChildren: MailFlaggedThreadIdRouteChildren = {
700
+ MailFlaggedThreadIdMessageIdRoute: MailFlaggedThreadIdMessageIdRoute,
701
+ MailFlaggedThreadIdIndexRoute: MailFlaggedThreadIdIndexRoute,
702
+ }
703
+
704
+ const MailFlaggedThreadIdRouteWithChildren =
705
+ MailFlaggedThreadIdRoute._addFileChildren(MailFlaggedThreadIdRouteChildren)
706
+
637
707
  interface MailFlaggedRouteChildren {
708
+ MailFlaggedThreadIdRoute: typeof MailFlaggedThreadIdRouteWithChildren
638
709
  MailFlaggedIndexRoute: typeof MailFlaggedIndexRoute
639
710
  }
640
711
 
641
712
  const MailFlaggedRouteChildren: MailFlaggedRouteChildren = {
713
+ MailFlaggedThreadIdRoute: MailFlaggedThreadIdRouteWithChildren,
642
714
  MailFlaggedIndexRoute: MailFlaggedIndexRoute,
643
715
  }
644
716
 
@@ -0,0 +1,19 @@
1
+ /**
2
+ * /mail/flagged/$threadId/$messageId — the same conversation, with one of its
3
+ * messages expanded and scrolled to.
4
+ *
5
+ * A message is not addressable on its own: `GET /messages/{messageId}` answers
6
+ * with no thread, so the pane has nothing to fetch by. The segment names which
7
+ * message inside the thread the reader pointed at, and the surface it renders is
8
+ * the thread's.
9
+ */
10
+ import { createFileRoute } from "@tanstack/react-router";
11
+ import { FlaggedPane } from "@/components/mail/FlaggedPane";
12
+
13
+ function FlaggedMessagePane() {
14
+ return <FlaggedPane.Reading />;
15
+ }
16
+
17
+ export const Route = createFileRoute("/mail/flagged/$threadId/$messageId")({
18
+ component: FlaggedMessagePane,
19
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The thread with no message named. The pane opens on the newest message, the
3
+ * same as for a thread whose row the reader never pointed at.
4
+ */
5
+ import { createFileRoute } from "@tanstack/react-router";
6
+ import { FlaggedPane } from "@/components/mail/FlaggedPane";
7
+
8
+ function FlaggedThreadPane() {
9
+ return <FlaggedPane.Reading />;
10
+ }
11
+
12
+ export const Route = createFileRoute("/mail/flagged/$threadId/")({
13
+ component: FlaggedThreadPane,
14
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * /mail/flagged/$threadId — a conversation open in the flagged list's reading
3
+ * pane.
4
+ *
5
+ * Starred mail spans accounts and folders, and the thread is still the whole
6
+ * address: the folder its mail is filed in comes from the thread's own data,
7
+ * since `GET /threads/{threadId}/messages` answers with rows that each name
8
+ * their mailbox.
9
+ *
10
+ * A layout, because the message segment nests under it.
11
+ */
12
+ import { createFileRoute, Outlet } from "@tanstack/react-router";
13
+
14
+ export const Route = createFileRoute("/mail/flagged/$threadId")({
15
+ component: Outlet,
16
+ });
@@ -2,7 +2,9 @@
2
2
  * /mail/flagged — the Flagged virtual mailbox, one of the four list layouts.
3
3
  *
4
4
  * A flat starred list across accounts; same slots as the brief, intelligence
5
- * rail included.
5
+ * rail included. The reading pane is the `Outlet`, so what it shows is whatever
6
+ * route matched below this one, and the open thread and the message inside it
7
+ * are the segments there rather than a selection read out of the query.
6
8
  */
7
9
  import {
8
10
  createFileRoute,
@@ -14,6 +16,7 @@ import { FlaggedPane } from "@/components/mail/FlaggedPane";
14
16
  import { ErrorState } from "@/components/ui/ErrorState";
15
17
  import { useSearchMirror } from "@/hooks/useSearchMirror";
16
18
  import { flaggedSearchSchema } from "@/lib/mail-search";
19
+ import { useOpenThreadPath } from "@/routing";
17
20
 
18
21
  const FlaggedError = ({ error, reset }: ErrorComponentProps) => (
19
22
  <div className="flex h-full items-center justify-center bg-canvas p-4">
@@ -26,17 +29,17 @@ const FlaggedError = ({ error, reset }: ErrorComponentProps) => (
26
29
  );
27
30
 
28
31
  function FlaggedLayout() {
29
- const { selectedMessageId } = Route.useSearch();
32
+ const thread = useOpenThreadPath();
30
33
  useSearchMirror({ to: "/mail/flagged" });
31
34
 
32
35
  return (
33
- <FlaggedPane selectedMessageId={selectedMessageId}>
36
+ <FlaggedPane thread={thread}>
34
37
  <MailShell
35
38
  phone={<FlaggedPane.Phone />}
36
39
  list={<FlaggedPane.List />}
37
40
  reading={<Outlet />}
38
41
  intelligence={<FlaggedPane.Intelligence />}
39
- hasThread={Boolean(selectedMessageId)}
42
+ hasThread={Boolean(thread)}
40
43
  />
41
44
  </FlaggedPane>
42
45
  );
@@ -39,6 +39,14 @@ export function useOpenThreadPath(): OpenThreadPath | undefined {
39
39
  from: "/mail/brief/$threadId/$messageId",
40
40
  shouldThrow: false,
41
41
  });
42
+ const flaggedThread = useParams({
43
+ from: "/mail/flagged/$threadId",
44
+ shouldThrow: false,
45
+ });
46
+ const flaggedMessage = useParams({
47
+ from: "/mail/flagged/$threadId/$messageId",
48
+ shouldThrow: false,
49
+ });
42
50
  const mailboxThread = useParams({
43
51
  from: "/mail/$mailboxId/$threadId",
44
52
  shouldThrow: false,
@@ -48,8 +56,12 @@ export function useOpenThreadPath(): OpenThreadPath | undefined {
48
56
  shouldThrow: false,
49
57
  });
50
58
 
51
- const threadId = briefThread?.threadId ?? mailboxThread?.threadId;
52
- const messageId = briefMessage?.messageId ?? mailboxMessage?.messageId;
59
+ const threadId =
60
+ briefThread?.threadId ?? flaggedThread?.threadId ?? mailboxThread?.threadId;
61
+ const messageId =
62
+ briefMessage?.messageId ??
63
+ flaggedMessage?.messageId ??
64
+ mailboxMessage?.messageId;
53
65
 
54
66
  return useMemo(
55
67
  () => (threadId ? { threadId, messageId } : undefined),