@remit/web-client 0.0.150 → 0.0.152

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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/components/compose/ComposeProvider.tsx +27 -5
  3. package/src/components/layout/ComposeFab.tsx +3 -2
  4. package/src/components/mail/BriefPane.tsx +8 -8
  5. package/src/components/mail/DailyBrief.tsx +3 -7
  6. package/src/components/mail/DraftsView.tsx +7 -3
  7. package/src/components/mail/IntelligencePane.tsx +4 -3
  8. package/src/components/mail/MailListHeader.tsx +1 -5
  9. package/src/components/mail/MailSidebarAdapter.tsx +3 -2
  10. package/src/components/mail/MailboxPane.tsx +130 -150
  11. package/src/components/mail/MessageActionMenu.tsx +12 -8
  12. package/src/components/mail/MessageList.selection.test.ts +6 -7
  13. package/src/components/mail/MessageList.tsx +28 -32
  14. package/src/components/mail/MessageListItem.tsx +2 -1
  15. package/src/components/mail/MessageRow.tsx +13 -10
  16. package/src/components/mail/SwipeableMessageRow.modifier-select.test.ts +8 -3
  17. package/src/components/mail/SwipeableMessageRow.tsx +8 -12
  18. package/src/hooks/useIntelligenceData.ts +1 -0
  19. package/src/hooks/useLayoutTier.ts +8 -5
  20. package/src/hooks/useMediaQuery.ts +2 -29
  21. package/src/lib/conversation-target.ts +7 -49
  22. package/src/lib/mail-search.ts +7 -8
  23. package/src/routeTree.gen.ts +74 -0
  24. package/src/routes/mail/$mailboxId/$threadId/$messageId.tsx +19 -0
  25. package/src/routes/mail/$mailboxId/$threadId/index.tsx +14 -0
  26. package/src/routes/mail/$mailboxId/$threadId.tsx +12 -0
  27. package/src/routes/mail/$mailboxId.tsx +7 -3
  28. package/src/routes/mail/brief.tsx +2 -2
  29. package/src/routing/index.ts +5 -5
  30. package/src/routing/open-thread.ts +58 -0
  31. package/src/lib/conversation-target.test.ts +0 -68
  32. package/src/lib/search-pending.test.ts +0 -99
  33. package/src/lib/search-pending.ts +0 -49
  34. package/src/routing/brief-thread.ts +0 -47
@@ -4,9 +4,9 @@
4
4
  * The mailbox list, the daily brief and Flagged used to carry three
5
5
  * near-identical rows (#149). They differ in two axes only, both props here:
6
6
  *
7
- * - `linkMailboxId` — set for the mailbox list, whose rows are real links so a
8
- * plain click routes and a middle click opens a tab. Omitted for the brief
9
- * and Flagged, whose rows report the tap through `onClick`.
7
+ * - `link` — set for the mailbox list, whose rows are real links so a plain
8
+ * click routes and a middle click opens a tab. Omitted for the brief and
9
+ * Flagged, whose rows report the tap through `onClick`.
10
10
  * - `selection` — set where multi-select is available. Absent renders the
11
11
  * avatar alone with no checkbox, which is what "non-selectable mode" means.
12
12
  */
@@ -73,8 +73,11 @@ export interface MessageRowProps {
73
73
  * orphan `option` is invalid ARIA and costs the row its button semantics.
74
74
  */
75
75
  inListbox?: boolean;
76
- /** Mailbox whose route the row links to; omit for callback-driven rows. */
77
- linkMailboxId?: string;
76
+ /**
77
+ * The address the row links to: the folder being browsed and the conversation
78
+ * this row opens. Omit for callback-driven rows.
79
+ */
80
+ link?: { mailboxId: string; threadId: string };
78
81
  /** Called when the row is opened by a plain click on a non-linking row. */
79
82
  onClick?: () => void;
80
83
  /** Called when the row takes DOM focus, so the roving cursor follows it. */
@@ -91,7 +94,7 @@ const MessageRowComponent = ({
91
94
  badge,
92
95
  selection: selectionProp,
93
96
  inListbox = false,
94
- linkMailboxId,
97
+ link,
95
98
  onClick,
96
99
  onFocusRow: onFocusRowProp,
97
100
  }: MessageRowProps) => {
@@ -240,7 +243,7 @@ const MessageRowComponent = ({
240
243
  />
241
244
  );
242
245
 
243
- if (linkMailboxId === undefined) {
246
+ if (link === undefined) {
244
247
  return (
245
248
  <button type="button" {...interactionProps} className={className}>
246
249
  {body}
@@ -250,9 +253,9 @@ const MessageRowComponent = ({
250
253
 
251
254
  return (
252
255
  <NavLink
253
- to="/mail/$mailboxId"
254
- params={{ mailboxId: linkMailboxId }}
255
- search={(prev) => ({ ...prev, selectedMessageId: messageId })}
256
+ to="/mail/$mailboxId/$threadId/$messageId"
257
+ params={{ ...link, messageId }}
258
+ search={(prev) => prev}
256
259
  variant="row"
257
260
  {...interactionProps}
258
261
  className={className}
@@ -64,6 +64,10 @@ const mailRoute = createRoute({
64
64
  getParentRoute: () => rootRoute,
65
65
  path: "/mail/$mailboxId",
66
66
  });
67
+ const messageRoute = createRoute({
68
+ getParentRoute: () => mailRoute,
69
+ path: "$threadId/$messageId",
70
+ });
67
71
 
68
72
  interface Mounted {
69
73
  row: HTMLElement;
@@ -74,7 +78,7 @@ interface Mounted {
74
78
  const mountRow = (selectionTakesIt = true): Mounted => {
75
79
  const selects: SelectionModifiers[] = [];
76
80
  const router = createRouter({
77
- routeTree: rootRoute.addChildren([mailRoute]),
81
+ routeTree: rootRoute.addChildren([mailRoute.addChildren([messageRoute])]),
78
82
  history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
79
83
  }) as unknown as AnyRouter;
80
84
  const created = createDomHarness({ viewportWidth: HALF_SCREEN_WIDTH });
@@ -148,9 +152,10 @@ const click = (
148
152
  }),
149
153
  );
150
154
 
155
+ // The conversation the row opened is the address, so the message it named is the
156
+ // last segment of the path.
151
157
  const openedMessageId = (router: AnyRouter): string | undefined =>
152
- (router.state.location.search as { selectedMessageId?: string })
153
- .selectedMessageId;
158
+ router.state.location.pathname.split("/")[4];
154
159
 
155
160
  describe("SwipeableMessageRow — modifier selection below the desktop width", () => {
156
161
  it("takes a shift-press for selection instead of opening the message", async () => {
@@ -13,11 +13,6 @@ import { formatEmailDate } from "@/lib/format";
13
13
  import { MessageListItem } from "./MessageListItem";
14
14
  import { useModifierSelect } from "./useModifierSelect";
15
15
 
16
- interface MailboxLinkSearch {
17
- selectedMessageId?: string;
18
- q?: string;
19
- }
20
-
21
16
  interface SwipeableMessageRowProps {
22
17
  thread: RemitImapThreadMessageResponse;
23
18
  mailboxId: string;
@@ -103,14 +98,15 @@ export const SwipeableMessageRow = ({
103
98
 
104
99
  const handleOpen = useCallback(() => {
105
100
  navigate({
106
- to: "/mail/$mailboxId",
107
- params: { mailboxId },
108
- search: (prev: MailboxLinkSearch) => ({
109
- ...prev,
110
- selectedMessageId: thread.messageId,
111
- }),
101
+ to: "/mail/$mailboxId/$threadId/$messageId",
102
+ params: {
103
+ mailboxId,
104
+ threadId: thread.threadId,
105
+ messageId: thread.messageId,
106
+ },
107
+ search: (prev) => prev,
112
108
  });
113
- }, [navigate, mailboxId, thread.messageId]);
109
+ }, [navigate, mailboxId, thread.threadId, thread.messageId]);
114
110
 
115
111
  const modifierSelect = useModifierSelect(thread.messageId, onRowSelect);
116
112
 
@@ -363,6 +363,7 @@ export function useIntelligenceData(
363
363
  .filter((r) => r.messageId !== thread.messageId)
364
364
  .map((r) => ({
365
365
  id: r.messageId,
366
+ threadId: r.threadId,
366
367
  // Prefer the mailbox the user is already viewing when the message also
367
368
  // lives there, so opening a similar message stays in the same folder;
368
369
  // otherwise fall back to the first mailbox it lives in.
@@ -1,5 +1,8 @@
1
- import { DESKTOP_MEDIA_QUERY, DESKTOP_MIN_WIDTH } from "@remit/ui";
2
- import { useMediaQuery } from "./useMediaQuery";
1
+ import {
2
+ DESKTOP_MEDIA_QUERY,
3
+ DESKTOP_MIN_WIDTH,
4
+ useMatchMedia,
5
+ } from "@remit/ui";
3
6
 
4
7
  /**
5
8
  * Responsive layout tiers for the mail shell:
@@ -46,11 +49,11 @@ export const isSinglePaneTier = (tier: LayoutTier): boolean =>
46
49
 
47
50
  /**
48
51
  * Returns the current layout tier. SSR-safe (falls back to `phone` before
49
- * hydration, matching `useMediaQuery`'s server default).
52
+ * hydration, matching `useMatchMedia`'s server default).
50
53
  */
51
54
  export const useLayoutTier = (): LayoutTier => {
52
- const isTabletUp = useMediaQuery(`(min-width: ${TABLET_MIN_WIDTH}px)`);
53
- const isDesktop = useMediaQuery(DESKTOP_MEDIA_QUERY);
55
+ const isTabletUp = useMatchMedia(`(min-width: ${TABLET_MIN_WIDTH}px)`);
56
+ const isDesktop = useMatchMedia(DESKTOP_MEDIA_QUERY);
54
57
  if (isDesktop) return "desktop";
55
58
  if (isTabletUp) return "tablet";
56
59
  return "phone";
@@ -1,31 +1,4 @@
1
- import { DESKTOP_MEDIA_QUERY } from "@remit/ui";
2
- import { useEffect, useState } from "react";
3
-
4
- /**
5
- * Subscribes to a CSS media query and returns its current `matches` value.
6
- * SSR-safe: returns `false` on the server / initial render before hydration.
7
- *
8
- * Layout breakpoints are exported by `@remit/ui` alongside the Tailwind
9
- * variants they mirror — pass those, not a hand-written width.
10
- */
11
- export const useMediaQuery = (query: string): boolean => {
12
- const [matches, setMatches] = useState(() => {
13
- if (typeof window === "undefined" || !window.matchMedia) return false;
14
- return window.matchMedia(query).matches;
15
- });
16
-
17
- useEffect(() => {
18
- if (typeof window === "undefined" || !window.matchMedia) return;
19
- const mql = window.matchMedia(query);
20
- const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
21
- // Sync state on mount in case the SSR/initial render disagreed.
22
- setMatches(mql.matches);
23
- mql.addEventListener("change", handler);
24
- return () => mql.removeEventListener("change", handler);
25
- }, [query]);
26
-
27
- return matches;
28
- };
1
+ import { DESKTOP_MEDIA_QUERY, useMatchMedia } from "@remit/ui";
29
2
 
30
3
  /**
31
4
  * True at desktop (Tailwind `lg:` and up): at least 1024px wide and not a
@@ -37,4 +10,4 @@ export const useMediaQuery = (query: string): boolean => {
37
10
  * `lg` variant is redefined in `@remit/ui`'s token sheet with the same
38
11
  * condition — change `DESKTOP_MEDIA_QUERY` and both move together.
39
12
  */
40
- export const useIsDesktop = (): boolean => useMediaQuery(DESKTOP_MEDIA_QUERY);
13
+ export const useIsDesktop = (): boolean => useMatchMedia(DESKTOP_MEDIA_QUERY);
@@ -1,14 +1,12 @@
1
1
  /**
2
- * The minimal data needed to open a conversation. A list view normally opens the
3
- * thread it already has loaded, but a semantic "Related" search hit can point at a
4
- * message that isn't in the loaded list at all. Such a hit still carries its
5
- * `threadId` and `mailboxId`, and `ConversationView` fetches by `threadId`, so we
6
- * can open it directly from those alone no dependency on the loaded list.
2
+ * What the reading pane needs to show a conversation.
3
+ *
4
+ * The thread is the whole address `ConversationView` fetches by it and the
5
+ * rest is what the list already knows about the row the reader pointed at, so
6
+ * the pane paints a subject before the thread's own messages arrive. A thread no
7
+ * list holds carries none of it and still opens.
7
8
  */
8
- import type {
9
- RemitImapMessageAuthenticity,
10
- RemitImapThreadMessageResponse,
11
- } from "@remit/api-http-client/types.gen.ts";
9
+ import type { RemitImapMessageAuthenticity } from "@remit/api-http-client/types.gen.ts";
12
10
 
13
11
  export interface ConversationTarget {
14
12
  threadId: string;
@@ -17,43 +15,3 @@ export interface ConversationTarget {
17
15
  messageId?: string;
18
16
  authenticity?: RemitImapMessageAuthenticity;
19
17
  }
20
-
21
- function threadToConversationTarget(
22
- thread: RemitImapThreadMessageResponse,
23
- ): ConversationTarget {
24
- return {
25
- threadId: thread.threadId,
26
- mailboxId: thread.mailboxId,
27
- subject: thread.subject,
28
- messageId: thread.messageId,
29
- authenticity: thread.authenticity,
30
- };
31
- }
32
-
33
- /**
34
- * Resolve the conversation to open. Prefer the fully loaded thread (it carries
35
- * the subject, authenticity and read state the reading pane wants); otherwise
36
- * fall back to the `threadId` + `mailboxId` carried in the URL by a tapped
37
- * "Related" hit so a message outside the loaded list still opens.
38
- */
39
- export function buildConversationTarget(
40
- selectedThread: RemitImapThreadMessageResponse | undefined,
41
- fallback: {
42
- messageId?: string;
43
- threadId?: string;
44
- mailboxId?: string;
45
- },
46
- ): ConversationTarget | undefined {
47
- if (selectedThread) return threadToConversationTarget(selectedThread);
48
- // Require the messageId too: it's always set alongside the threadId when a
49
- // result is tapped, and gating on it means clearing `selectedMessageId` (e.g.
50
- // pressing Back) closes the conversation even if a stale threadId lingers.
51
- if (fallback.messageId && fallback.threadId && fallback.mailboxId) {
52
- return {
53
- threadId: fallback.threadId,
54
- mailboxId: fallback.mailboxId,
55
- messageId: fallback.messageId,
56
- };
57
- }
58
- return undefined;
59
- }
@@ -16,7 +16,7 @@ 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 other three lists follow.
19
+ * "tolerated and ignored" means until the remaining lists follow.
20
20
  */
21
21
  export const briefSearchSchema = listSearch.extend({});
22
22
 
@@ -31,13 +31,12 @@ export const flaggedSearchSchema = listSearch.extend({
31
31
  */
32
32
  export const outboxSearchSchema = listSearch.extend({});
33
33
 
34
- export const mailboxSearchSchema = listSearch.extend({
35
- selectedMessageId: z.string().optional(),
36
- // A tapped semantic "Related" hit can point at a message outside the loaded
37
- // list; carrying its thread lets the mailbox open it directly (the mailbox is
38
- // the route param). See `buildConversationTarget`.
39
- selectedThreadId: z.string().optional(),
40
- });
34
+ /**
35
+ * A folder's open thread and the message inside it are path segments
36
+ * (`/mail/<mailbox>/<thread>/<message>`), so the query carries nothing but the
37
+ * search.
38
+ */
39
+ export const mailboxSearchSchema = listSearch.extend({});
41
40
 
42
41
  /**
43
42
  * `/mail` itself only redirects to the brief, so it accepts what the brief
@@ -28,10 +28,13 @@ import { Route as SettingsLabelsRouteImport } from './routes/settings/labels'
28
28
  import { Route as SettingsSendersRouteImport } from './routes/settings/senders'
29
29
  import { Route as SettingsSuggestedVipsRouteImport } from './routes/settings/suggested-vips'
30
30
  import { Route as MailMailboxIdIndexRouteImport } from './routes/mail/$mailboxId/index'
31
+ import { Route as MailMailboxIdThreadIdRouteImport } from './routes/mail/$mailboxId/$threadId'
31
32
  import { Route as MailBriefIndexRouteImport } from './routes/mail/brief/index'
32
33
  import { Route as MailBriefThreadIdRouteImport } from './routes/mail/brief/$threadId'
33
34
  import { Route as MailFlaggedIndexRouteImport } from './routes/mail/flagged/index'
34
35
  import { Route as MailOutboxIndexRouteImport } from './routes/mail/outbox/index'
36
+ import { Route as MailMailboxIdThreadIdIndexRouteImport } from './routes/mail/$mailboxId/$threadId/index'
37
+ import { Route as MailMailboxIdThreadIdMessageIdRouteImport } from './routes/mail/$mailboxId/$threadId/$messageId'
35
38
  import { Route as MailBriefThreadIdIndexRouteImport } from './routes/mail/brief/$threadId/index'
36
39
  import { Route as MailBriefThreadIdMessageIdRouteImport } from './routes/mail/brief/$threadId/$messageId'
37
40
  import { Route as MailOutboxDraftOutboxMessageIdRouteImport } from './routes/mail/outbox/draft/$outboxMessageId'
@@ -131,6 +134,11 @@ const MailMailboxIdIndexRoute = MailMailboxIdIndexRouteImport.update({
131
134
  path: '/',
132
135
  getParentRoute: () => MailMailboxIdRoute,
133
136
  } as any)
137
+ const MailMailboxIdThreadIdRoute = MailMailboxIdThreadIdRouteImport.update({
138
+ id: '/$threadId',
139
+ path: '/$threadId',
140
+ getParentRoute: () => MailMailboxIdRoute,
141
+ } as any)
134
142
  const MailBriefIndexRoute = MailBriefIndexRouteImport.update({
135
143
  id: '/',
136
144
  path: '/',
@@ -151,6 +159,18 @@ const MailOutboxIndexRoute = MailOutboxIndexRouteImport.update({
151
159
  path: '/',
152
160
  getParentRoute: () => MailOutboxRoute,
153
161
  } as any)
162
+ const MailMailboxIdThreadIdIndexRoute =
163
+ MailMailboxIdThreadIdIndexRouteImport.update({
164
+ id: '/',
165
+ path: '/',
166
+ getParentRoute: () => MailMailboxIdThreadIdRoute,
167
+ } as any)
168
+ const MailMailboxIdThreadIdMessageIdRoute =
169
+ MailMailboxIdThreadIdMessageIdRouteImport.update({
170
+ id: '/$messageId',
171
+ path: '/$messageId',
172
+ getParentRoute: () => MailMailboxIdThreadIdRoute,
173
+ } as any)
154
174
  const MailBriefThreadIdIndexRoute = MailBriefThreadIdIndexRouteImport.update({
155
175
  id: '/',
156
176
  path: '/',
@@ -188,13 +208,16 @@ export interface FileRoutesByFullPath {
188
208
  '/settings/suggested-vips': typeof SettingsSuggestedVipsRoute
189
209
  '/mail/': typeof MailIndexRoute
190
210
  '/settings/': typeof SettingsIndexRoute
211
+ '/mail/$mailboxId/$threadId': typeof MailMailboxIdThreadIdRouteWithChildren
191
212
  '/mail/brief/$threadId': typeof MailBriefThreadIdRouteWithChildren
192
213
  '/mail/$mailboxId/': typeof MailMailboxIdIndexRoute
193
214
  '/mail/brief/': typeof MailBriefIndexRoute
194
215
  '/mail/flagged/': typeof MailFlaggedIndexRoute
195
216
  '/mail/outbox/': typeof MailOutboxIndexRoute
217
+ '/mail/$mailboxId/$threadId/$messageId': typeof MailMailboxIdThreadIdMessageIdRoute
196
218
  '/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
197
219
  '/mail/outbox/draft/$outboxMessageId': typeof MailOutboxDraftOutboxMessageIdRoute
220
+ '/mail/$mailboxId/$threadId/': typeof MailMailboxIdThreadIdIndexRoute
198
221
  '/mail/brief/$threadId/': typeof MailBriefThreadIdIndexRoute
199
222
  }
200
223
  export interface FileRoutesByTo {
@@ -214,8 +237,10 @@ export interface FileRoutesByTo {
214
237
  '/mail/brief': typeof MailBriefIndexRoute
215
238
  '/mail/flagged': typeof MailFlaggedIndexRoute
216
239
  '/mail/outbox': typeof MailOutboxIndexRoute
240
+ '/mail/$mailboxId/$threadId/$messageId': typeof MailMailboxIdThreadIdMessageIdRoute
217
241
  '/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
218
242
  '/mail/outbox/draft/$outboxMessageId': typeof MailOutboxDraftOutboxMessageIdRoute
243
+ '/mail/$mailboxId/$threadId': typeof MailMailboxIdThreadIdIndexRoute
219
244
  '/mail/brief/$threadId': typeof MailBriefThreadIdIndexRoute
220
245
  }
221
246
  export interface FileRoutesById {
@@ -238,13 +263,16 @@ export interface FileRoutesById {
238
263
  '/settings/suggested-vips': typeof SettingsSuggestedVipsRoute
239
264
  '/mail/': typeof MailIndexRoute
240
265
  '/settings/': typeof SettingsIndexRoute
266
+ '/mail/$mailboxId/$threadId': typeof MailMailboxIdThreadIdRouteWithChildren
241
267
  '/mail/brief/$threadId': typeof MailBriefThreadIdRouteWithChildren
242
268
  '/mail/$mailboxId/': typeof MailMailboxIdIndexRoute
243
269
  '/mail/brief/': typeof MailBriefIndexRoute
244
270
  '/mail/flagged/': typeof MailFlaggedIndexRoute
245
271
  '/mail/outbox/': typeof MailOutboxIndexRoute
272
+ '/mail/$mailboxId/$threadId/$messageId': typeof MailMailboxIdThreadIdMessageIdRoute
246
273
  '/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
247
274
  '/mail/outbox/draft/$outboxMessageId': typeof MailOutboxDraftOutboxMessageIdRoute
275
+ '/mail/$mailboxId/$threadId/': typeof MailMailboxIdThreadIdIndexRoute
248
276
  '/mail/brief/$threadId/': typeof MailBriefThreadIdIndexRoute
249
277
  }
250
278
  export interface FileRouteTypes {
@@ -268,13 +296,16 @@ export interface FileRouteTypes {
268
296
  | '/settings/suggested-vips'
269
297
  | '/mail/'
270
298
  | '/settings/'
299
+ | '/mail/$mailboxId/$threadId'
271
300
  | '/mail/brief/$threadId'
272
301
  | '/mail/$mailboxId/'
273
302
  | '/mail/brief/'
274
303
  | '/mail/flagged/'
275
304
  | '/mail/outbox/'
305
+ | '/mail/$mailboxId/$threadId/$messageId'
276
306
  | '/mail/brief/$threadId/$messageId'
277
307
  | '/mail/outbox/draft/$outboxMessageId'
308
+ | '/mail/$mailboxId/$threadId/'
278
309
  | '/mail/brief/$threadId/'
279
310
  fileRoutesByTo: FileRoutesByTo
280
311
  to:
@@ -294,8 +325,10 @@ export interface FileRouteTypes {
294
325
  | '/mail/brief'
295
326
  | '/mail/flagged'
296
327
  | '/mail/outbox'
328
+ | '/mail/$mailboxId/$threadId/$messageId'
297
329
  | '/mail/brief/$threadId/$messageId'
298
330
  | '/mail/outbox/draft/$outboxMessageId'
331
+ | '/mail/$mailboxId/$threadId'
299
332
  | '/mail/brief/$threadId'
300
333
  id:
301
334
  | '__root__'
@@ -317,13 +350,16 @@ export interface FileRouteTypes {
317
350
  | '/settings/suggested-vips'
318
351
  | '/mail/'
319
352
  | '/settings/'
353
+ | '/mail/$mailboxId/$threadId'
320
354
  | '/mail/brief/$threadId'
321
355
  | '/mail/$mailboxId/'
322
356
  | '/mail/brief/'
323
357
  | '/mail/flagged/'
324
358
  | '/mail/outbox/'
359
+ | '/mail/$mailboxId/$threadId/$messageId'
325
360
  | '/mail/brief/$threadId/$messageId'
326
361
  | '/mail/outbox/draft/$outboxMessageId'
362
+ | '/mail/$mailboxId/$threadId/'
327
363
  | '/mail/brief/$threadId/'
328
364
  fileRoutesById: FileRoutesById
329
365
  }
@@ -469,6 +505,13 @@ declare module '@tanstack/react-router' {
469
505
  preLoaderRoute: typeof MailMailboxIdIndexRouteImport
470
506
  parentRoute: typeof MailMailboxIdRoute
471
507
  }
508
+ '/mail/$mailboxId/$threadId': {
509
+ id: '/mail/$mailboxId/$threadId'
510
+ path: '/$threadId'
511
+ fullPath: '/mail/$mailboxId/$threadId'
512
+ preLoaderRoute: typeof MailMailboxIdThreadIdRouteImport
513
+ parentRoute: typeof MailMailboxIdRoute
514
+ }
472
515
  '/mail/brief/': {
473
516
  id: '/mail/brief/'
474
517
  path: '/'
@@ -497,6 +540,20 @@ declare module '@tanstack/react-router' {
497
540
  preLoaderRoute: typeof MailOutboxIndexRouteImport
498
541
  parentRoute: typeof MailOutboxRoute
499
542
  }
543
+ '/mail/$mailboxId/$threadId/': {
544
+ id: '/mail/$mailboxId/$threadId/'
545
+ path: '/'
546
+ fullPath: '/mail/$mailboxId/$threadId/'
547
+ preLoaderRoute: typeof MailMailboxIdThreadIdIndexRouteImport
548
+ parentRoute: typeof MailMailboxIdThreadIdRoute
549
+ }
550
+ '/mail/$mailboxId/$threadId/$messageId': {
551
+ id: '/mail/$mailboxId/$threadId/$messageId'
552
+ path: '/$messageId'
553
+ fullPath: '/mail/$mailboxId/$threadId/$messageId'
554
+ preLoaderRoute: typeof MailMailboxIdThreadIdMessageIdRouteImport
555
+ parentRoute: typeof MailMailboxIdThreadIdRoute
556
+ }
500
557
  '/mail/brief/$threadId/': {
501
558
  id: '/mail/brief/$threadId/'
502
559
  path: '/'
@@ -521,11 +578,28 @@ declare module '@tanstack/react-router' {
521
578
  }
522
579
  }
523
580
 
581
+ interface MailMailboxIdThreadIdRouteChildren {
582
+ MailMailboxIdThreadIdMessageIdRoute: typeof MailMailboxIdThreadIdMessageIdRoute
583
+ MailMailboxIdThreadIdIndexRoute: typeof MailMailboxIdThreadIdIndexRoute
584
+ }
585
+
586
+ const MailMailboxIdThreadIdRouteChildren: MailMailboxIdThreadIdRouteChildren = {
587
+ MailMailboxIdThreadIdMessageIdRoute: MailMailboxIdThreadIdMessageIdRoute,
588
+ MailMailboxIdThreadIdIndexRoute: MailMailboxIdThreadIdIndexRoute,
589
+ }
590
+
591
+ const MailMailboxIdThreadIdRouteWithChildren =
592
+ MailMailboxIdThreadIdRoute._addFileChildren(
593
+ MailMailboxIdThreadIdRouteChildren,
594
+ )
595
+
524
596
  interface MailMailboxIdRouteChildren {
597
+ MailMailboxIdThreadIdRoute: typeof MailMailboxIdThreadIdRouteWithChildren
525
598
  MailMailboxIdIndexRoute: typeof MailMailboxIdIndexRoute
526
599
  }
527
600
 
528
601
  const MailMailboxIdRouteChildren: MailMailboxIdRouteChildren = {
602
+ MailMailboxIdThreadIdRoute: MailMailboxIdThreadIdRouteWithChildren,
529
603
  MailMailboxIdIndexRoute: MailMailboxIdIndexRoute,
530
604
  }
531
605
 
@@ -0,0 +1,19 @@
1
+ /**
2
+ * /mail/$mailboxId/$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 { MailboxPane } from "@/components/mail/MailboxPane";
12
+
13
+ function MailboxMessagePane() {
14
+ return <MailboxPane.Reading />;
15
+ }
16
+
17
+ export const Route = createFileRoute("/mail/$mailboxId/$threadId/$messageId")({
18
+ component: MailboxMessagePane,
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 { MailboxPane } from "@/components/mail/MailboxPane";
7
+
8
+ function MailboxThreadPane() {
9
+ return <MailboxPane.Reading />;
10
+ }
11
+
12
+ export const Route = createFileRoute("/mail/$mailboxId/$threadId/")({
13
+ component: MailboxThreadPane,
14
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * /mail/$mailboxId/$threadId — a conversation open in a folder's reading pane.
3
+ *
4
+ * The folder in the address is the list being browsed, not the thread's home:
5
+ * a cross-folder search hit opens here too, and every message it holds names
6
+ * its own mailbox. A layout, because the message segment nests under it.
7
+ */
8
+ import { createFileRoute, Outlet } from "@tanstack/react-router";
9
+
10
+ export const Route = createFileRoute("/mail/$mailboxId/$threadId")({
11
+ component: Outlet,
12
+ });
@@ -4,26 +4,30 @@
4
4
  *
5
5
  * The literal lists are declared as siblings and TanStack matches literals
6
6
  * first, so a mailbox id can never be read as one of them.
7
+ *
8
+ * The open thread and the message inside it are the segments below, which is why
9
+ * nothing here reads a selection out of the query.
7
10
  */
8
11
  import { createFileRoute, Outlet } from "@tanstack/react-router";
9
12
  import { MailShell } from "@/components/layout/MailShell";
10
13
  import { MailboxPane } from "@/components/mail/MailboxPane";
11
14
  import { useSearchMirror } from "@/hooks/useSearchMirror";
12
15
  import { mailboxSearchSchema } from "@/lib/mail-search";
16
+ import { useOpenThreadPath } from "@/routing";
13
17
 
14
18
  function MailboxLayout() {
15
19
  const { mailboxId } = Route.useParams();
16
- const { selectedMessageId } = Route.useSearch();
20
+ const thread = useOpenThreadPath();
17
21
  useSearchMirror({ to: "/mail/$mailboxId", params: { mailboxId } });
18
22
 
19
23
  return (
20
- <MailboxPane mailboxId={mailboxId} selectedMessageId={selectedMessageId}>
24
+ <MailboxPane mailboxId={mailboxId} thread={thread}>
21
25
  <MailShell
22
26
  phone={<MailboxPane.Phone />}
23
27
  list={<MailboxPane.List />}
24
28
  reading={<Outlet />}
25
29
  intelligence={<MailboxPane.Intelligence />}
26
- hasThread={Boolean(selectedMessageId)}
30
+ hasThread={Boolean(thread)}
27
31
  />
28
32
  </MailboxPane>
29
33
  );
@@ -17,7 +17,7 @@ import { BriefPane } from "@/components/mail/BriefPane";
17
17
  import { ErrorState } from "@/components/ui/ErrorState";
18
18
  import { useSearchMirror } from "@/hooks/useSearchMirror";
19
19
  import { briefSearchSchema } from "@/lib/mail-search";
20
- import { useBriefThreadPath } from "@/routing";
20
+ import { useOpenThreadPath } from "@/routing";
21
21
 
22
22
  const BriefError = ({ error, reset }: ErrorComponentProps) => (
23
23
  <div className="flex h-full items-center justify-center bg-canvas p-4">
@@ -30,7 +30,7 @@ const BriefError = ({ error, reset }: ErrorComponentProps) => (
30
30
  );
31
31
 
32
32
  function BriefLayout() {
33
- const thread = useBriefThreadPath();
33
+ const thread = useOpenThreadPath();
34
34
  useSearchMirror({ to: "/mail/brief" });
35
35
 
36
36
  return (
@@ -1,8 +1,3 @@
1
- export {
2
- type BriefThreadPath,
3
- type BriefThreadTarget,
4
- useBriefThreadPath,
5
- } from "./brief-thread";
6
1
  export {
7
2
  type PanelFragment,
8
3
  panelFragments,
@@ -10,4 +5,9 @@ export {
10
5
  useOpenPanel,
11
6
  } from "./fragment";
12
7
  export { NavLink, type NavLinkProps } from "./nav-link";
8
+ export {
9
+ type OpenThreadPath,
10
+ type OpenThreadTarget,
11
+ useOpenThreadPath,
12
+ } from "./open-thread";
13
13
  export { useOutboxDraftId } from "./outbox-draft";