@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
@@ -2,13 +2,13 @@
2
2
  * MailboxPane — compound component for the mailbox view.
3
3
  *
4
4
  * Encapsulates all state, hooks, and rendering for the /mail/$mailboxId view.
5
- * The parent (`mail.tsx`) mounts `<MailboxPane>` around the `AppShellSlotted`
6
- * and passes the sub-views into slots:
5
+ * The list layout route mounts `<MailboxPane>` around the shell and passes the
6
+ * sub-views into slots, with the reading pane as the `Outlet`:
7
7
  *
8
- * <MailboxPane mailboxId={...} selectedMessageId={...}>
9
- * <AppShellSlotted
8
+ * <MailboxPane mailboxId={...} thread={useOpenThreadPath()}>
9
+ * <MailShell
10
10
  * list={<MailboxPane.List />}
11
- * reading={<MailboxPane.Reading />}
11
+ * reading={<Outlet />}
12
12
  * intelligence={<MailboxPane.Intelligence />}
13
13
  * />
14
14
  * </MailboxPane>
@@ -40,7 +40,7 @@ import {
40
40
  useMutation,
41
41
  useQueryClient,
42
42
  } from "@tanstack/react-query";
43
- import { useNavigate, useSearch } from "@tanstack/react-router";
43
+ import { useNavigate } from "@tanstack/react-router";
44
44
  import {
45
45
  createContext,
46
46
  type ReactNode,
@@ -94,13 +94,11 @@ import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
94
94
  import { useSemanticSearch } from "@/hooks/useSemanticSearch";
95
95
  import { useThreadActions } from "@/hooks/useThreadActions";
96
96
  import { useThreadMessageIds } from "@/hooks/useThreadMessageIds";
97
+ import { useThreadRow } from "@/hooks/useThreadRow";
97
98
  import { useToggleStar } from "@/hooks/useToggleStar";
98
99
  import { useTriageContext, useTriageLayer } from "@/hooks/useTriageLayer";
99
100
  import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
100
- import {
101
- buildConversationTarget,
102
- type ConversationTarget,
103
- } from "@/lib/conversation-target";
101
+ import type { ConversationTarget } from "@/lib/conversation-target";
104
102
  import { dedupeThreadMessages } from "@/lib/dedupe-thread-messages";
105
103
  import {
106
104
  filterReach,
@@ -116,11 +114,6 @@ import { useMailContext } from "@/lib/mail-context";
116
114
  import { useMailFreshness } from "@/lib/mail-freshness";
117
115
  import { isRescueCandidate } from "@/lib/rescue-candidates";
118
116
  import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
119
- import {
120
- isSearchPending as computeIsSearchPending,
121
- resolveOpenThread,
122
- resolveSelectedThread,
123
- } from "@/lib/search-pending";
124
117
  import { normalizeSearchQuery } from "@/lib/search-query";
125
118
  import {
126
119
  relatedSearchResults,
@@ -132,6 +125,7 @@ import {
132
125
  applyResidualTokens,
133
126
  threadSearchTokens,
134
127
  } from "@/lib/thread-search-tokens";
128
+ import type { OpenThreadPath, OpenThreadTarget } from "@/routing";
135
129
  import { MailViewChrome } from "./MailViewChrome";
136
130
 
137
131
  /* ------------------------------------------------------------------ */
@@ -140,10 +134,13 @@ import { MailViewChrome } from "./MailViewChrome";
140
134
 
141
135
  interface MailboxPaneContextValue {
142
136
  mailboxId: string;
137
+ /** The row the reader pointed at, which is the one the list highlights. */
143
138
  selectedMessageId: string | undefined;
144
139
  selectedThread: RemitImapThreadMessageResponse | undefined;
145
- /** The conversation to open — the loaded thread, or a tapped "Related" hit. */
140
+ /** The conversation the pane shows, or none when the address names no thread. */
146
141
  conversation: ConversationTarget | undefined;
142
+ /** Opens a conversation in this folder's reading pane. */
143
+ onOpenThread: (target: OpenThreadTarget) => void;
147
144
  threads: RemitImapThreadMessageResponse[];
148
145
  isLoading: boolean;
149
146
  isError: boolean;
@@ -222,8 +219,9 @@ interface MailboxPaneContextValue {
222
219
  hasRemitDraftOpen: boolean;
223
220
  // Phone actions
224
221
  onBack: () => void;
225
- nextMessageId: string | undefined;
226
- previousMessageId: string | undefined;
222
+ /** The rows either side of the open one — the phone's swipe gestures. */
223
+ nextThread: OpenThreadTarget | undefined;
224
+ previousThread: OpenThreadTarget | undefined;
227
225
  }
228
226
 
229
227
  /** The server's own default page size (`DEFAULT_THREADS_PAGE_SIZE`), sent so the
@@ -252,23 +250,25 @@ function useMailboxPane(): MailboxPaneContextValue {
252
250
 
253
251
  interface MailboxPaneProps {
254
252
  mailboxId: string;
255
- selectedMessageId: string | undefined;
253
+ /** The open conversation, as the address states it. */
254
+ thread: OpenThreadPath | undefined;
256
255
  children: ReactNode;
257
256
  }
258
257
 
259
258
  function MailboxPaneProvider({
260
259
  mailboxId,
261
- selectedMessageId,
260
+ thread,
262
261
  children,
263
262
  }: MailboxPaneProps) {
264
263
  const navigate = useNavigate();
264
+ const threadId = thread?.threadId;
265
+ const pointedAtMessageId = thread?.messageId;
265
266
  const tier = useLayoutTier();
266
267
  const isDesktop = tier === "desktop";
267
268
  const telemetry = useTelemetry();
268
269
  const {
269
270
  accounts,
270
271
  searchQuery,
271
- searchInput,
272
272
  intelligenceOpen,
273
273
  onToggleIntelligence,
274
274
  onSetIntelligenceOpen,
@@ -387,39 +387,9 @@ function MailboxPaneProvider({
387
387
  : undefined,
388
388
  });
389
389
 
390
- const handleDeselectIfRemoved = useCallback(
391
- (removedIds: string[]) => {
392
- if (!selectedMessageId) return;
393
- if (!removedIds.includes(selectedMessageId)) return;
394
- navigate({
395
- to: "/mail/$mailboxId",
396
- params: { mailboxId },
397
- search: (prev: Record<string, unknown>) => ({
398
- ...prev,
399
- selectedMessageId: undefined,
400
- selectedThreadId: undefined,
401
- }),
402
- });
403
- },
404
- [selectedMessageId, mailboxId, navigate],
405
- );
406
-
407
- const { deleteMessages: handleDeleteMessages, isPending: isDeleting } =
408
- useDeleteMessages({
409
- mailboxId,
410
- onAfterOptimisticRemove: handleDeselectIfRemoved,
411
- });
412
-
413
390
  const { accountId: mailboxAccountId } = useMailboxAccount(mailboxId);
414
391
  const mailboxName = useCurrentMailboxName({ accounts });
415
392
 
416
- const { moveMessages: handleMoveMessages, isPending: isMoving } =
417
- useMoveMessages({
418
- mailboxId,
419
- accountId: mailboxAccountId,
420
- onAfterOptimisticRemove: handleDeselectIfRemoved,
421
- });
422
-
423
393
  // The server answered the active predicate, so these rows are the list: the
424
394
  // dedupe spans pages and the deleted drop repeats the server's own
425
395
  // `excludeDeleted`, and neither result changes when another page loads.
@@ -468,49 +438,74 @@ function MailboxPaneProvider({
468
438
  }
469
439
  : undefined;
470
440
 
471
- const isSearchPending = computeIsSearchPending(searchInput, searchQuery);
472
- const listedThread = resolveSelectedThread(
473
- threads,
474
- selectedMessageId,
475
- isSearchPending,
441
+ // The row this folder itself lists, preferred because a mutation patches it in
442
+ // place. A thread the loaded pages do not hold — a chip that paged it out, a
443
+ // cross-folder hit, a cold address — is in no listing here and answers for
444
+ // itself, which is what the snapshot and the URL's spare thread id used to
445
+ // stand in for.
446
+ const listedThread = threadId
447
+ ? (threads.find((t) => t.messageId === pointedAtMessageId) ??
448
+ threads.find((t) => t.threadId === threadId))
449
+ : undefined;
450
+ const ownRow = useThreadRow(threadId, pointedAtMessageId);
451
+ const selectedThread = listedThread ?? ownRow;
452
+
453
+ const selectedMessageId = pointedAtMessageId ?? selectedThread?.messageId;
454
+
455
+ const conversation = useMemo<ConversationTarget | undefined>(() => {
456
+ if (!threadId) return undefined;
457
+ return {
458
+ threadId,
459
+ mailboxId: selectedThread?.mailboxId ?? mailboxId,
460
+ subject: selectedThread?.subject,
461
+ messageId: selectedMessageId,
462
+ authenticity: selectedThread?.authenticity,
463
+ };
464
+ }, [threadId, selectedThread, selectedMessageId, mailboxId]);
465
+
466
+ // Esc unwinds one step at a time: an active selection first (handled by the
467
+ // triage layer), then the open thread — which is a navigation up to the list,
468
+ // so nothing is left mounted below it.
469
+ const closeThread = useCallback(() => {
470
+ navigate({
471
+ to: "/mail/$mailboxId",
472
+ params: { mailboxId },
473
+ search: (prev) => prev,
474
+ });
475
+ }, [mailboxId, navigate]);
476
+
477
+ const handleOpenThread = useCallback(
478
+ (target: OpenThreadTarget) => {
479
+ navigate({
480
+ to: "/mail/$mailboxId/$threadId/$messageId",
481
+ params: { mailboxId, ...target },
482
+ search: (prev) => prev,
483
+ });
484
+ },
485
+ [mailboxId, navigate],
476
486
  );
477
- // There is no unfiltered set in the client any more, so a chip the open
478
- // message does not match would otherwise close the reading pane under the
479
- // user. Snapshot what they opened and let it answer for itself.
480
- const [openedThread, setOpenedThread] = useState<
481
- RemitImapThreadMessageResponse | undefined
482
- >(undefined);
483
- useEffect(() => {
484
- if (listedThread) setOpenedThread(listedThread);
485
- }, [listedThread]);
486
- const selectedThread = resolveOpenThread(
487
- listedThread,
488
- openedThread,
489
- selectedMessageId,
490
- isSearchPending,
487
+
488
+ const handleDeselectIfRemoved = useCallback(
489
+ (removedIds: string[]) => {
490
+ if (!selectedMessageId) return;
491
+ if (!removedIds.includes(selectedMessageId)) return;
492
+ closeThread();
493
+ },
494
+ [selectedMessageId, closeThread],
491
495
  );
492
496
 
493
- // A semantic "Related" hit can point at a message outside the loaded list, so
494
- // it carries its threadId through the URL; fall back to it (the mailbox is the
495
- // route param) so the conversation still opens.
496
- const { selectedThreadId } = useSearch({ strict: false }) as {
497
- selectedThreadId?: string;
498
- };
499
- const conversation = useMemo(
500
- () =>
501
- buildConversationTarget(selectedThread, {
502
- messageId: selectedMessageId,
503
- threadId: isSearchPending ? undefined : selectedThreadId,
504
- mailboxId,
505
- }),
506
- [
507
- selectedThread,
508
- selectedMessageId,
509
- selectedThreadId,
510
- isSearchPending,
497
+ const { deleteMessages: handleDeleteMessages, isPending: isDeleting } =
498
+ useDeleteMessages({
511
499
  mailboxId,
512
- ],
513
- );
500
+ onAfterOptimisticRemove: handleDeselectIfRemoved,
501
+ });
502
+
503
+ const { moveMessages: handleMoveMessages, isPending: isMoving } =
504
+ useMoveMessages({
505
+ mailboxId,
506
+ accountId: mailboxAccountId,
507
+ onAfterOptimisticRemove: handleDeselectIfRemoved,
508
+ });
514
509
 
515
510
  const triage = useTriageContext();
516
511
  const {
@@ -635,20 +630,6 @@ function MailboxPaneProvider({
635
630
  closeCompose,
636
631
  ]);
637
632
 
638
- // Esc unwinds one step at a time: an active selection first (handled by the
639
- // triage layer), then the open thread.
640
- const closeThread = useCallback(() => {
641
- navigate({
642
- to: "/mail/$mailboxId",
643
- params: { mailboxId },
644
- search: (prev: Record<string, unknown>) => ({
645
- ...prev,
646
- selectedMessageId: undefined,
647
- selectedThreadId: undefined,
648
- }),
649
- });
650
- }, [mailboxId, navigate]);
651
-
652
633
  const messageIdsForFocusedThread = useCallback(
653
634
  (thread: typeof focusedThread): string[] => {
654
635
  if (!thread) return [];
@@ -694,16 +675,11 @@ function MailboxPaneProvider({
694
675
 
695
676
  const ensureFocusedOpen = useCallback(() => {
696
677
  if (selectedMessageId || !triageFocusedId) return false;
697
- navigate({
698
- to: "/mail/$mailboxId",
699
- params: { mailboxId },
700
- search: (prev: Record<string, unknown>) => ({
701
- ...prev,
702
- selectedMessageId: triageFocusedId,
703
- }),
704
- });
678
+ const row = threads.find((t) => t.messageId === triageFocusedId);
679
+ if (!row) return false;
680
+ handleOpenThread({ threadId: row.threadId, messageId: triageFocusedId });
705
681
  return true;
706
- }, [selectedMessageId, triageFocusedId, mailboxId, navigate]);
682
+ }, [selectedMessageId, triageFocusedId, threads, handleOpenThread]);
707
683
 
708
684
  const triageReply = useCallback(() => {
709
685
  if (ensureFocusedOpen()) return;
@@ -864,11 +840,23 @@ function MailboxPaneProvider({
864
840
  bindings: [{ key: "Escape", handler: closeCompose, preventDefault: true }],
865
841
  });
866
842
 
843
+ // The swipe gestures open a whole conversation, so the adjacent row has to
844
+ // name its thread. This folder's own listing is where that is looked up, so a
845
+ // row it does not hold offers no gesture rather than a tap that goes nowhere.
846
+ const adjacentThread = (
847
+ messageId: string | undefined,
848
+ ): OpenThreadTarget | undefined => {
849
+ if (!messageId) return undefined;
850
+ const row = threads.find((t) => t.messageId === messageId);
851
+ return row ? { threadId: row.threadId, messageId } : undefined;
852
+ };
853
+
867
854
  const ctx: MailboxPaneContextValue = {
868
855
  mailboxId,
869
856
  selectedMessageId,
870
857
  selectedThread,
871
858
  conversation,
859
+ onOpenThread: handleOpenThread,
872
860
  threads,
873
861
  isLoading,
874
862
  isError,
@@ -920,8 +908,8 @@ function MailboxPaneProvider({
920
908
  closeCompose,
921
909
  hasRemitDraftOpen,
922
910
  onBack: goBack,
923
- nextMessageId,
924
- previousMessageId,
911
+ nextThread: adjacentThread(nextMessageId),
912
+ previousThread: adjacentThread(previousMessageId),
925
913
  };
926
914
 
927
915
  return (
@@ -1036,28 +1024,33 @@ function MailboxList() {
1036
1024
  ),
1037
1025
  [semanticHits, threads, resultFolderIndex],
1038
1026
  );
1027
+ // The two-engine results panel, whose semantic hits are in no list at all —
1028
+ // each one carries the thread it belongs to.
1039
1029
  const handleSelectSearchResult = useCallback(
1040
- (result: SearchResult) =>
1030
+ (result: SearchResult) => {
1031
+ const threadId =
1032
+ result.threadId ??
1033
+ threads.find((thread) => thread.messageId === result.id)?.threadId;
1034
+ if (!threadId) return;
1041
1035
  navigate({
1042
- to: "/mail/$mailboxId",
1036
+ to: "/mail/$mailboxId/$threadId/$messageId",
1043
1037
  // Both sections are scoped to this mailbox, so a result's own
1044
1038
  // mailbox is normally this one; keep reading it off the result so a
1045
1039
  // row can never open under a mailbox it does not belong to.
1046
- params: { mailboxId: result.mailboxId ?? mailboxId },
1047
- search: (prev: Record<string, unknown>) => ({
1048
- ...prev,
1049
- // Commit the active query alongside the selection. The debounced
1050
- // q-mirror (`useSearchMirror`) strips the selection whenever it sees the
1051
- // query go active; the row can be tapped before the debounce settles
1052
- // (it shows in the still-unfiltered list), so use the *live*
1053
- // `searchInput` here committing `q` makes the mirror a no-op and
1054
- // keeps the opened result from being stripped out from under us.
1055
- q: searchInput || undefined,
1056
- selectedMessageId: result.id,
1057
- selectedThreadId: result.threadId,
1058
- }),
1059
- }),
1060
- [mailboxId, navigate, searchInput],
1040
+ params: {
1041
+ mailboxId: result.mailboxId ?? mailboxId,
1042
+ threadId,
1043
+ messageId: result.id,
1044
+ },
1045
+ // Commit the active query with the open so the debounced q-mirror —
1046
+ // which walks back up to the list when the query goes active — is
1047
+ // already satisfied and leaves the conversation alone. The *live*
1048
+ // `searchInput`: a row can be tapped before the debounce settles, when
1049
+ // the committed query is still empty.
1050
+ search: (prev) => ({ ...prev, q: searchInput || undefined }),
1051
+ });
1052
+ },
1053
+ [mailboxId, navigate, searchInput, threads],
1061
1054
  );
1062
1055
 
1063
1056
  // Drafts keep their own dedicated view (and header); they don't carry the
@@ -1273,28 +1266,17 @@ function MailboxPhone() {
1273
1266
  mailboxAccountId,
1274
1267
  selectedThread,
1275
1268
  conversation,
1269
+ onOpenThread,
1276
1270
  intelligenceOpen,
1277
1271
  onToggleIntelligence,
1278
1272
  onBack,
1279
- nextMessageId,
1280
- previousMessageId,
1273
+ nextThread,
1274
+ previousThread,
1281
1275
  composeState,
1282
1276
  handleDeselectIfRemoved,
1283
1277
  } = useMailboxPane();
1284
- const navigate = useNavigate();
1285
1278
 
1286
1279
  if (conversation) {
1287
- const openMessage = (messageId: string) =>
1288
- navigate({
1289
- to: "/mail/$mailboxId",
1290
- params: { mailboxId },
1291
- search: (prev: Record<string, unknown>) => ({
1292
- ...prev,
1293
- selectedMessageId: messageId,
1294
- selectedThreadId: undefined,
1295
- }),
1296
- });
1297
-
1298
1280
  return (
1299
1281
  <>
1300
1282
  <ConversationView
@@ -1305,11 +1287,9 @@ function MailboxPhone() {
1305
1287
  authenticity={conversation.authenticity}
1306
1288
  onBack={onBack}
1307
1289
  onOpenIntelligence={onToggleIntelligence}
1308
- onSwipeNext={
1309
- nextMessageId ? () => openMessage(nextMessageId) : undefined
1310
- }
1290
+ onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
1311
1291
  onSwipePrevious={
1312
- previousMessageId ? () => openMessage(previousMessageId) : undefined
1292
+ previousThread ? () => onOpenThread(previousThread) : undefined
1313
1293
  }
1314
1294
  mobileIntelligenceOpen={intelligenceOpen}
1315
1295
  />
@@ -35,6 +35,7 @@ import {
35
35
  type ThreadListSnapshotEntry,
36
36
  threadListCacheKeys,
37
37
  } from "@/lib/thread-list-cache";
38
+ import { useOpenThreadPath } from "@/routing";
38
39
  import { MoveToTrigger } from "./MoveToTrigger";
39
40
 
40
41
  interface ThreadMessagesData {
@@ -103,9 +104,15 @@ export const MessageActionMenu = ({
103
104
  const queryClient = useQueryClient();
104
105
  const navigate = useNavigate();
105
106
  const { pushError } = useErrorBanners();
106
- const { selectedMessageId } = useSearch({ strict: false }) as {
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 {
107
113
  selectedMessageId?: string;
108
114
  };
115
+ const selectedMessageId = openThread?.messageId ?? selectedFromQuery;
109
116
 
110
117
  const { mutate: updateFlags, isPending: isUpdatingFlags } = useMutation({
111
118
  ...messageBulkOperationsUpdateFlagsMutation(),
@@ -182,10 +189,10 @@ export const MessageActionMenu = ({
182
189
  },
183
190
  });
184
191
 
185
- // Deselect the thread before the server response arrives if the message
192
+ // Close the conversation before the server response arrives if the message
186
193
  // being deleted is the one currently routed to. Mirrors the bulk-delete
187
- // flow on the mailbox route — without this, the URL keeps a stale
188
- // `selectedMessageId` after the row vanishes from the list.
194
+ // flow on the mailbox route — without this, the address keeps naming a
195
+ // message after the row vanishes from the list.
189
196
  const handleAfterOptimisticRemove = useCallback(
190
197
  (removedIds: string[]) => {
191
198
  if (!selectedMessageId) return;
@@ -193,10 +200,7 @@ export const MessageActionMenu = ({
193
200
  navigate({
194
201
  to: "/mail/$mailboxId",
195
202
  params: { mailboxId },
196
- search: (prev: Record<string, unknown>) => ({
197
- ...prev,
198
- selectedMessageId: undefined,
199
- }),
203
+ search: (prev) => prev,
200
204
  });
201
205
  },
202
206
  [selectedMessageId, mailboxId, navigate],
@@ -172,18 +172,17 @@ describe("MessageList escalation reaches desktop (#212)", () => {
172
172
  });
173
173
 
174
174
  /**
175
- * A confirmed delete used to open the surviving neighbour by writing
176
- * `selectedMessageId` into the URL. On desktop that fills the reading pane
177
- * beside the list; on a single-pane mobile layout the same navigation replaced
178
- * the list with a full-screen message, so the delete read as "jumped into a
179
- * random message" rather than "the row is gone" (#202). Mobile now stays on the
180
- * list and raises a completion banner instead.
175
+ * A confirmed delete opens the surviving neighbour. On desktop that fills the
176
+ * reading pane beside the list; on a single-pane mobile layout the same
177
+ * navigation replaced the list with a full-screen message, so the delete read as
178
+ * "jumped into a random message" rather than "the row is gone" (#202). Mobile
179
+ * now stays on the list and raises a completion banner instead.
181
180
  */
182
181
  describe("MessageList confirmed delete stays on the list on mobile (#202)", () => {
183
182
  it("only the desktop two-pane opens the surviving neighbour after a delete", () => {
184
183
  assert.match(
185
184
  source,
186
- /if \(isDesktop\) \{\s*navigate\(\{\s*to: "\/mail\/\$mailboxId",\s*params: \{ mailboxId \},\s*search: \(prev\) => \(\{ \.\.\.prev, selectedMessageId: nextFocus \}\),\s*replace: true,\s*\}\);\s*\}/,
185
+ /if \(isDesktop\) \{\s*openRow\(nextFocus, \{ replace: true \}\);\s*\}/,
187
186
  );
188
187
  });
189
188
 
@@ -621,16 +621,31 @@ export const MessageList = ({
621
621
  ],
622
622
  );
623
623
 
624
- // Enter: open the focused row in the reading pane (sets selected/URL). This
625
- // is the focus→open transition of the 2-state model.
624
+ // Opening a row is a navigation to the conversation under this folder. Every
625
+ // row the list renders names its thread, which is what the reading pane
626
+ // fetches by — a row the list no longer holds cannot be opened from here.
627
+ const openRow = useCallback(
628
+ (messageId: string, options?: { replace?: boolean }) => {
629
+ const threadId = threads.find(
630
+ (thread) => thread.messageId === messageId,
631
+ )?.threadId;
632
+ if (!threadId) return;
633
+ navigate({
634
+ to: "/mail/$mailboxId/$threadId/$messageId",
635
+ params: { mailboxId, threadId, messageId },
636
+ search: (prev) => prev,
637
+ replace: options?.replace,
638
+ });
639
+ },
640
+ [navigate, mailboxId, threads],
641
+ );
642
+
643
+ // Enter: open the focused row in the reading pane. This is the focus→open
644
+ // transition of the 2-state model.
626
645
  const handleOpenFocused = useCallback(() => {
627
646
  if (!focusedMessageId) return;
628
- navigate({
629
- to: "/mail/$mailboxId",
630
- params: { mailboxId },
631
- search: (prev) => ({ ...prev, selectedMessageId: focusedMessageId }),
632
- });
633
- }, [focusedMessageId, navigate, mailboxId]);
647
+ openRow(focusedMessageId);
648
+ }, [focusedMessageId, openRow]);
634
649
 
635
650
  // The reading pane follows the cursor on desktop: j/k load the row they land
636
651
  // on, without the user having to press Enter for every message. It replaces
@@ -643,15 +658,8 @@ export const MessageList = ({
643
658
  // Suspended off desktop too, where the reading pane replaces the list
644
659
  // entirely, so following the cursor would throw the user off the list.
645
660
  const followFocusOpen = useCallback(
646
- (messageId: string) => {
647
- navigate({
648
- to: "/mail/$mailboxId",
649
- params: { mailboxId },
650
- search: (prev) => ({ ...prev, selectedMessageId: messageId }),
651
- replace: true,
652
- });
653
- },
654
- [navigate, mailboxId],
661
+ (messageId: string) => openRow(messageId, { replace: true }),
662
+ [openRow],
655
663
  );
656
664
  useFollowFocusOpen({
657
665
  keyboardFocusedMessageId,
@@ -724,12 +732,7 @@ export const MessageList = ({
724
732
  // like it opened a random neighbour instead of removing the rows (#202).
725
733
  // Mobile keeps the cursor move but stays on the list.
726
734
  if (isDesktop) {
727
- navigate({
728
- to: "/mail/$mailboxId",
729
- params: { mailboxId },
730
- search: (prev) => ({ ...prev, selectedMessageId: nextFocus }),
731
- replace: true,
732
- });
735
+ openRow(nextFocus, { replace: true });
733
736
  }
734
737
  }
735
738
 
@@ -744,8 +747,7 @@ export const MessageList = ({
744
747
  threads,
745
748
  onDeleteMessages,
746
749
  exitSelection,
747
- navigate,
748
- mailboxId,
750
+ openRow,
749
751
  isDesktop,
750
752
  setFocusedMessageId,
751
753
  ]);
@@ -1323,13 +1325,7 @@ export const MessageList = ({
1323
1325
  onReportError={handleReportError}
1324
1326
  density={density}
1325
1327
  selectedThreadId={selectedMessageId}
1326
- onSelectThread={(id) =>
1327
- navigate({
1328
- to: "/mail/$mailboxId",
1329
- params: { mailboxId },
1330
- search: (prev) => ({ ...prev, selectedMessageId: id }),
1331
- })
1332
- }
1328
+ onSelectThread={(id) => openRow(id)}
1333
1329
  isDesktop={isDesktop}
1334
1330
  hideHeader={hideHeader}
1335
1331
  selectionBar={activeSelectionBar}
@@ -45,6 +45,7 @@ export const threadToRowData = (
45
45
  id: thread.messageId,
46
46
  accountId: thread.accountId,
47
47
  mailboxId: thread.mailboxId,
48
+ threadId: thread.threadId,
48
49
  fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
49
50
  fromEmail: thread.fromEmail ?? "",
50
51
  subject: thread.subject ?? "(No subject)",
@@ -81,7 +82,7 @@ const MessageListItemComponent = ({
81
82
  }: MessageListItemProps) => (
82
83
  <MessageRow
83
84
  thread={threadToRowData(thread, messageCount)}
84
- linkMailboxId={mailboxId}
85
+ link={{ mailboxId, threadId: thread.threadId }}
85
86
  inListbox
86
87
  active={isSelected}
87
88
  focused={isFocused}