@remit/web-client 0.0.48 → 0.0.50

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.
@@ -35,7 +35,15 @@ import { ConversationView } from "@/components/mail/ConversationView";
35
35
  import { FlaggedList } from "@/components/mail/FlaggedList";
36
36
  import { IntelligencePane } from "@/components/mail/IntelligencePane";
37
37
  import { MessageToolbar } from "@/components/mail/MessageToolbar";
38
+ import { useDeleteMessages } from "@/hooks/useDeleteMessages";
39
+ import { useToggleReadFor } from "@/hooks/useMarkAsRead";
38
40
  import { useStarredThreads } from "@/hooks/useStarredThreads";
41
+ import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
42
+ import {
43
+ type TriageContext,
44
+ useTriageContext,
45
+ useTriageLayer,
46
+ } from "@/hooks/useTriageLayer";
39
47
  import { useMailContext } from "@/lib/mail-context";
40
48
 
41
49
  /* ------------------------------------------------------------------ */
@@ -47,6 +55,17 @@ interface FlaggedPaneContextValue {
47
55
  selectedThread: RemitImapThreadMessageResponse | undefined;
48
56
  onSelectMessage: (id: string) => void;
49
57
  onCloseThread: () => void;
58
+ /**
59
+ * Toolbar verbs for the open thread, keyed by the thread's own mailbox and
60
+ * account — Flagged spans accounts, so there is no route mailbox to key by.
61
+ */
62
+ actions: ThreadActions;
63
+ /** Keyboard, multi-select and next/previous, shared with the mailbox view. */
64
+ triage: TriageContext;
65
+ onDeleteMessages: (messageIds: string[]) => void;
66
+ onMarkMessagesRead: (messageIds: string[]) => void;
67
+ nextMessageId: string | undefined;
68
+ previousMessageId: string | undefined;
50
69
  }
51
70
 
52
71
  const FlaggedPaneCtx = createContext<FlaggedPaneContextValue | null>(null);
@@ -96,11 +115,87 @@ function FlaggedPaneProvider({
96
115
  });
97
116
  }, [navigate]);
98
117
 
118
+ const handleDeselectIfRemoved = useCallback(
119
+ (removedIds: string[]) => {
120
+ if (!selectedMessageId) return;
121
+ if (!removedIds.includes(selectedMessageId)) return;
122
+ handleCloseThread();
123
+ },
124
+ [selectedMessageId, handleCloseThread],
125
+ );
126
+
127
+ const actions = useThreadActions({
128
+ thread: selectedThread,
129
+ onAfterOptimisticRemove: handleDeselectIfRemoved,
130
+ });
131
+
132
+ const triage = useTriageContext();
133
+
134
+ // A Flagged selection spans accounts and mailboxes, so the listings these
135
+ // patch are resolved from each message's own mailbox — the open thread's is
136
+ // only the fallback.
137
+ const { deleteMessages } = useDeleteMessages({
138
+ mailboxId: selectedThread?.mailboxId ?? "",
139
+ messages: threads,
140
+ onAfterOptimisticRemove: handleDeselectIfRemoved,
141
+ });
142
+ const { toggleReadFor } = useToggleReadFor({
143
+ mailboxId: selectedThread?.mailboxId ?? "",
144
+ messages: threads,
145
+ });
146
+ const handleMarkMessagesRead = useCallback(
147
+ (messageIds: string[]) => toggleReadFor(messageIds, true),
148
+ [toggleReadFor],
149
+ );
150
+
151
+ const focusedThreadId = triage.focusedMessageId;
152
+ const focusedThread = useMemo(
153
+ () => threads.find((t) => t.messageId === focusedThreadId),
154
+ [threads, focusedThreadId],
155
+ );
156
+ const triageTarget = focusedThread ?? selectedThread;
157
+ const triageActions = useThreadActions({ thread: triageTarget });
158
+
159
+ const { nextMessageId, previousMessageId } = useTriageLayer({
160
+ context: triage,
161
+ orderedIds: triage.orderedIds,
162
+ selectedMessageId,
163
+ onClose: handleCloseThread,
164
+ handlers: {
165
+ reply: () => actions.requestCompose("reply"),
166
+ replyAll: () => actions.requestCompose("reply_all"),
167
+ forward: () => actions.requestCompose("forward"),
168
+ delete: () => {
169
+ if (triage.listCommandsRef.current?.requestDelete()) return;
170
+ triageActions.deleteThread();
171
+ },
172
+ toggleStar: triageActions.toggleStar,
173
+ toggleRead: () => {
174
+ const ids =
175
+ triage.selectedIds.length > 0
176
+ ? triage.selectedIds
177
+ : triageTarget
178
+ ? [triageTarget.messageId]
179
+ : [];
180
+ if (ids.length === 0) return;
181
+ toggleReadFor(ids, !(triageTarget?.isRead ?? false));
182
+ },
183
+ goBrief: () => navigate({ to: "/mail" }),
184
+ goSettings: () => navigate({ to: "/settings" }),
185
+ },
186
+ });
187
+
99
188
  const ctx: FlaggedPaneContextValue = {
100
189
  selectedMessageId,
101
190
  selectedThread,
102
191
  onSelectMessage: handleSelectMessage,
103
192
  onCloseThread: handleCloseThread,
193
+ actions,
194
+ triage,
195
+ onDeleteMessages: deleteMessages,
196
+ onMarkMessagesRead: handleMarkMessagesRead,
197
+ nextMessageId,
198
+ previousMessageId,
104
199
  };
105
200
 
106
201
  return (
@@ -114,11 +209,21 @@ function FlaggedPaneProvider({
114
209
 
115
210
  /** Flat starred list. Mount in the `list` slot of `AppShellSlotted`. */
116
211
  function FlaggedListSlot() {
117
- const { selectedMessageId, onSelectMessage } = useFlaggedPane();
212
+ const {
213
+ selectedMessageId,
214
+ onSelectMessage,
215
+ triage,
216
+ onDeleteMessages,
217
+ onMarkMessagesRead,
218
+ } = useFlaggedPane();
118
219
  return (
119
220
  <FlaggedList
120
221
  selectedMessageId={selectedMessageId}
121
222
  onSelectMessage={onSelectMessage}
223
+ commandsRef={triage.listCommandsRef}
224
+ onTriageContextChange={triage.onTriageContextChange}
225
+ onDeleteMessages={onDeleteMessages}
226
+ onMarkMessagesRead={onMarkMessagesRead}
122
227
  />
123
228
  );
124
229
  }
@@ -128,7 +233,7 @@ function FlaggedListSlot() {
128
233
  * Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
129
234
  */
130
235
  function FlaggedReading() {
131
- const { selectedThread } = useFlaggedPane();
236
+ const { selectedThread, actions } = useFlaggedPane();
132
237
  const { intelligenceOpen, onToggleIntelligence } = useMailContext();
133
238
  // The rail's own width gate, not the shell tier: between 1024 and 1280 the
134
239
  // reading pane is mounted but the rail is not, so "enabled" would promise an
@@ -144,6 +249,25 @@ function FlaggedReading() {
144
249
  intelligenceOpen={canToggleIntelligence && intelligenceOpen}
145
250
  canToggleIntelligence={canToggleIntelligence}
146
251
  onToggleIntelligence={onToggleIntelligence}
252
+ onReply={hasThread ? () => actions.requestCompose("reply") : undefined}
253
+ onReplyAll={
254
+ hasThread ? () => actions.requestCompose("reply_all") : undefined
255
+ }
256
+ onForward={
257
+ hasThread ? () => actions.requestCompose("forward") : undefined
258
+ }
259
+ onDelete={hasThread ? actions.deleteThread : undefined}
260
+ onToggleStar={hasThread ? actions.toggleStar : undefined}
261
+ isStarred={actions.isStarred}
262
+ moveContext={
263
+ hasThread && actions.accountId && actions.mailboxId
264
+ ? {
265
+ accountId: actions.accountId,
266
+ currentMailboxId: actions.mailboxId,
267
+ onMove: actions.moveThread,
268
+ }
269
+ : undefined
270
+ }
147
271
  />
148
272
  <div className="min-h-0 flex-1 overflow-hidden">
149
273
  {selectedThread ? (
@@ -152,6 +276,8 @@ function FlaggedReading() {
152
276
  mailboxId={selectedThread.mailboxId}
153
277
  subject={selectedThread.subject}
154
278
  authenticity={selectedThread.authenticity}
279
+ composeRequest={actions.composeRequest}
280
+ onComposeClose={actions.clearComposeRequest}
155
281
  />
156
282
  ) : (
157
283
  <ReadingPaneEmpty />
@@ -181,7 +307,13 @@ function FlaggedIntelligence() {
181
307
 
182
308
  /** Phone view: ConversationView when a thread is open, else the flat list. */
183
309
  function FlaggedPhone() {
184
- const { selectedThread, onCloseThread } = useFlaggedPane();
310
+ const {
311
+ selectedThread,
312
+ onCloseThread,
313
+ onSelectMessage,
314
+ nextMessageId,
315
+ previousMessageId,
316
+ } = useFlaggedPane();
185
317
  const { intelligenceOpen, onToggleIntelligence } = useMailContext();
186
318
 
187
319
  if (selectedThread) {
@@ -194,6 +326,15 @@ function FlaggedPhone() {
194
326
  authenticity={selectedThread.authenticity}
195
327
  onBack={onCloseThread}
196
328
  onOpenIntelligence={onToggleIntelligence}
329
+ onSwipeNext={
330
+ nextMessageId ? () => onSelectMessage(nextMessageId) : undefined
331
+ }
332
+ onSwipePrevious={
333
+ previousMessageId
334
+ ? () => onSelectMessage(previousMessageId)
335
+ : undefined
336
+ }
337
+ mobileIntelligenceOpen={intelligenceOpen}
197
338
  />
198
339
  <Drawer
199
340
  isOpen={intelligenceOpen}
@@ -18,7 +18,6 @@
18
18
  import {
19
19
  outboxDetailOperationsDeleteOutboxMessageMutation,
20
20
  outboxOperationsListOutboxMessagesQueryKey,
21
- threadDetailOperationsListThreadMessagesQueryKey,
22
21
  threadOperationsListThreadsQueryKey,
23
22
  threadOperationsSearchThreadsQueryKey,
24
23
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
@@ -91,10 +90,11 @@ import { useMoveMessages } from "@/hooks/useMoveMessages";
91
90
  import { useRescueCandidates } from "@/hooks/useRescueCandidates";
92
91
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
93
92
  import { useSemanticSearch } from "@/hooks/useSemanticSearch";
93
+ import { useThreadActions } from "@/hooks/useThreadActions";
94
+ import { useThreadMessageIds } from "@/hooks/useThreadMessageIds";
94
95
  import { useToggleStar } from "@/hooks/useToggleStar";
95
- import { useTriageKeyboard } from "@/hooks/useTriageKeyboard";
96
+ import { useTriageContext, useTriageLayer } from "@/hooks/useTriageLayer";
96
97
  import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
97
- import { adjacentMessageId } from "@/lib/adjacent-message";
98
98
  import {
99
99
  buildConversationTarget,
100
100
  type ConversationTarget,
@@ -432,32 +432,13 @@ function MailboxPaneProvider({
432
432
  ],
433
433
  );
434
434
 
435
- const [triageFocusedId, setTriageFocusedId] = useState<string | undefined>(
436
- undefined,
437
- );
438
- const [triageSelectedIds, setTriageSelectedIds] = useState<string[]>([]);
439
- // The mounted message list publishes its navigation/selection commands here.
440
- // Null whenever no list is mounted (reading-only phone view, drafts) — the
441
- // keyboard layer then simply has nothing to drive.
442
- const listCommandsRef = useRef<MessageListCommands | null>(null);
443
- // Whether a message list is mounted and serving commands, and whether it has
444
- // a modal that owns the keyboard. Both drive which keys this route claims.
445
- const [hasList, setHasList] = useState(false);
446
- const [listBlocksKeyboard, setListBlocksKeyboard] = useState(false);
447
- const handleTriageContextChange = useCallback(
448
- (context: {
449
- focusedMessageId: string | undefined;
450
- selectedIds: string[];
451
- hasList: boolean;
452
- blocksKeyboard: boolean;
453
- }) => {
454
- setTriageFocusedId(context.focusedMessageId);
455
- setTriageSelectedIds(context.selectedIds);
456
- setHasList(context.hasList);
457
- setListBlocksKeyboard(context.blocksKeyboard);
458
- },
459
- [],
460
- );
435
+ const triage = useTriageContext();
436
+ const {
437
+ listCommandsRef,
438
+ onTriageContextChange: handleTriageContextChange,
439
+ focusedMessageId: triageFocusedId,
440
+ selectedIds: triageSelectedIds,
441
+ } = triage;
461
442
 
462
443
  const focusedThread =
463
444
  threads.find((t) => t.messageId === triageFocusedId) ?? selectedThread;
@@ -503,15 +484,9 @@ function MailboxPaneProvider({
503
484
  const queryClient = useQueryClient();
504
485
  const { pushError } = useErrorBanners();
505
486
 
506
- const { deleteMessages: toolbarDelete } = useDeleteMessages({
487
+ const toolbarActions = useThreadActions({
488
+ thread: selectedThread,
507
489
  mailboxId,
508
- threadId: selectedThread?.threadId,
509
- onAfterOptimisticRemove: handleDeselectIfRemoved,
510
- });
511
-
512
- const { moveMessages: toolbarMove } = useMoveMessages({
513
- mailboxId,
514
- threadId: selectedThread?.threadId,
515
490
  accountId: mailboxAccountId,
516
491
  onAfterOptimisticRemove: handleDeselectIfRemoved,
517
492
  });
@@ -522,63 +497,19 @@ function MailboxPaneProvider({
522
497
 
523
498
  const { archiveMailboxId } = useArchiveMailbox(mailboxAccountId);
524
499
 
525
- // Get thread message ids from cache; fall back to representative message id.
526
- const getThreadMessageIds = useCallback(
527
- (thread: RemitImapThreadMessageResponse) => {
528
- const threadKey = threadDetailOperationsListThreadMessagesQueryKey({
529
- path: { threadId: thread.threadId },
530
- });
531
- const cached = queryClient.getQueriesData<{
532
- items: { messageId: string }[];
533
- }>({ queryKey: threadKey });
534
- const ids = cached.flatMap(
535
- ([, data]) => data?.items.map((m) => m.messageId) ?? [],
536
- );
537
- return ids.length > 0 ? ids : [thread.messageId];
538
- },
539
- [queryClient],
540
- );
500
+ const getThreadMessageIds = useThreadMessageIds();
541
501
 
542
- const handleToolbarDelete = useCallback(() => {
543
- if (!selectedThread) return;
544
- const messageIds = getThreadMessageIds(selectedThread);
545
- toolbarDelete(messageIds);
546
- }, [selectedThread, getThreadMessageIds, toolbarDelete]);
547
-
548
- const handleToolbarMove = useCallback(
549
- (destMailboxId: string) => {
550
- if (!selectedThread) return;
551
- const messageIds = getThreadMessageIds(selectedThread);
552
- toolbarMove(messageIds, destMailboxId);
553
- },
554
- [selectedThread, getThreadMessageIds, toolbarMove],
555
- );
556
-
557
- const { toggleStar: toolbarToggleStar } = useToggleStar({
558
- threadId: selectedThread?.threadId ?? "",
559
- mailboxId,
560
- });
561
-
562
- const handleToolbarStar = useCallback(() => {
563
- if (!selectedThread) return;
564
- toolbarToggleStar(selectedThread.messageId, selectedThread.hasStars);
565
- }, [selectedThread, toolbarToggleStar]);
566
-
567
- const [toolbarComposeRequest, setToolbarComposeRequest] =
568
- useState<ComposeMode | null>(null);
502
+ const { requestCompose: setToolbarComposeRequest } = toolbarActions;
569
503
 
570
504
  const handleToolbarReply = useCallback(() => {
571
505
  setToolbarComposeRequest("reply");
572
- }, []);
506
+ }, [setToolbarComposeRequest]);
573
507
  const handleToolbarReplyAll = useCallback(() => {
574
508
  setToolbarComposeRequest("reply_all");
575
- }, []);
509
+ }, [setToolbarComposeRequest]);
576
510
  const handleToolbarForward = useCallback(() => {
577
511
  setToolbarComposeRequest("forward");
578
- }, []);
579
- const handleClearComposeRequest = useCallback(() => {
580
- setToolbarComposeRequest(null);
581
- }, []);
512
+ }, [setToolbarComposeRequest]);
582
513
 
583
514
  const { state: composeState, openCompose, closeCompose } = useCompose();
584
515
 
@@ -613,22 +544,19 @@ function MailboxPaneProvider({
613
544
  closeCompose,
614
545
  ]);
615
546
 
616
- // Esc unwinds one step at a time: an active selection first, then the open
617
- // thread.
618
- const goBack = useCallback(() => {
619
- if (listCommandsRef.current?.clearSelection()) return;
620
- if (selectedMessageId) {
621
- navigate({
622
- to: "/mail/$mailboxId",
623
- params: { mailboxId },
624
- search: (prev: Record<string, unknown>) => ({
625
- ...prev,
626
- selectedMessageId: undefined,
627
- selectedThreadId: undefined,
628
- }),
629
- });
630
- }
631
- }, [selectedMessageId, mailboxId, navigate]);
547
+ // Esc unwinds one step at a time: an active selection first (handled by the
548
+ // triage layer), then the open thread.
549
+ const closeThread = useCallback(() => {
550
+ navigate({
551
+ to: "/mail/$mailboxId",
552
+ params: { mailboxId },
553
+ search: (prev: Record<string, unknown>) => ({
554
+ ...prev,
555
+ selectedMessageId: undefined,
556
+ selectedThreadId: undefined,
557
+ }),
558
+ });
559
+ }, [mailboxId, navigate]);
632
560
 
633
561
  const messageIdsForFocusedThread = useCallback(
634
562
  (thread: typeof focusedThread): string[] => {
@@ -688,15 +616,15 @@ function MailboxPaneProvider({
688
616
  const triageReply = useCallback(() => {
689
617
  if (ensureFocusedOpen()) return;
690
618
  if (selectedThread) setToolbarComposeRequest("reply");
691
- }, [ensureFocusedOpen, selectedThread]);
619
+ }, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
692
620
  const triageReplyAll = useCallback(() => {
693
621
  if (ensureFocusedOpen()) return;
694
622
  if (selectedThread) setToolbarComposeRequest("reply_all");
695
- }, [ensureFocusedOpen, selectedThread]);
623
+ }, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
696
624
  const triageForward = useCallback(() => {
697
625
  if (ensureFocusedOpen()) return;
698
626
  if (selectedThread) setToolbarComposeRequest("forward");
699
- }, [ensureFocusedOpen, selectedThread]);
627
+ }, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
700
628
 
701
629
  const triageTargetMessageIds = useCallback((): string[] => {
702
630
  if (triageSelectedIds.length > 0) return triageSelectedIds;
@@ -710,7 +638,7 @@ function MailboxPaneProvider({
710
638
  if (listCommandsRef.current?.requestDelete()) return;
711
639
  const ids = triageTargetMessageIds();
712
640
  if (ids.length > 0) triageDelete(ids);
713
- }, [triageTargetMessageIds, triageDelete]);
641
+ }, [listCommandsRef, triageTargetMessageIds, triageDelete]);
714
642
 
715
643
  const triageMarkJunk = useCallback(() => {
716
644
  if (!junkMailboxId) return;
@@ -798,31 +726,13 @@ function MailboxPaneProvider({
798
726
  !!composeState.outboxMessageId &&
799
727
  !selectedThread;
800
728
 
801
- useTriageKeyboard({
802
- // A modal owns the keyboard outright. Suspending the layer is what keeps a
803
- // second Delete press from reaching a delete while the confirmation for
804
- // the first one is still on screen.
805
- enabled: !composeState.isOpen && !listBlocksKeyboard,
729
+ const { goBack, nextMessageId, previousMessageId } = useTriageLayer({
730
+ context: triage,
731
+ orderedIds: threads.map((t) => t.messageId),
732
+ selectedMessageId,
733
+ enabled: !composeState.isOpen,
734
+ onClose: closeThread,
806
735
  handlers: {
807
- // List navigation and selection, registered only while a list is
808
- // mounted to serve them. An unregistered action is never
809
- // preventDefault-ed, so with no list the browser keeps Enter, Space and
810
- // ⌘A — select-all-text in the reading pane still works.
811
- ...(hasList
812
- ? {
813
- focusNext: () => listCommandsRef.current?.focusNext(),
814
- focusPrevious: () => listCommandsRef.current?.focusPrevious(),
815
- focusFirst: () => listCommandsRef.current?.focusFirst(),
816
- focusLast: () => listCommandsRef.current?.focusLast(),
817
- openFocused: () => listCommandsRef.current?.openFocused(),
818
- toggleSelect: () => listCommandsRef.current?.toggleSelect(),
819
- extendSelectDown: () => listCommandsRef.current?.extendSelectDown(),
820
- extendSelectUp: () => listCommandsRef.current?.extendSelectUp(),
821
- selectAll: () => listCommandsRef.current?.selectAll(),
822
- toggleDensity: () => listCommandsRef.current?.toggleDensity(),
823
- }
824
- : {}),
825
- back: goBack,
826
736
  reply: triageReply,
827
737
  replyAll: triageReplyAll,
828
738
  forward: triageForward,
@@ -848,14 +758,6 @@ function MailboxPaneProvider({
848
758
  bindings: [{ key: "Escape", handler: closeCompose, preventDefault: true }],
849
759
  });
850
760
 
851
- const orderedMessageIds = threads.map((t) => t.messageId);
852
- const nextMessageId =
853
- adjacentMessageId(orderedMessageIds, selectedMessageId, "next") ??
854
- undefined;
855
- const previousMessageId =
856
- adjacentMessageId(orderedMessageIds, selectedMessageId, "previous") ??
857
- undefined;
858
-
859
761
  const ctx: MailboxPaneContextValue = {
860
762
  mailboxId,
861
763
  selectedMessageId,
@@ -889,15 +791,15 @@ function MailboxPaneProvider({
889
791
  onTriageContextChange: handleTriageContextChange,
890
792
  listCommandsRef,
891
793
  onRetry: () => refetch(),
892
- toolbarComposeRequest,
794
+ toolbarComposeRequest: toolbarActions.composeRequest,
893
795
  onToolbarReply: handleToolbarReply,
894
796
  onToolbarReplyAll: handleToolbarReplyAll,
895
797
  onToolbarForward: handleToolbarForward,
896
- onClearComposeRequest: handleClearComposeRequest,
897
- onToolbarDelete: handleToolbarDelete,
898
- onToolbarStar: handleToolbarStar,
798
+ onClearComposeRequest: toolbarActions.clearComposeRequest,
799
+ onToolbarDelete: toolbarActions.deleteThread,
800
+ onToolbarStar: toolbarActions.toggleStar,
899
801
  onToolbarDiscardDraft: handleToolbarDiscardDraft,
900
- onToolbarMove: handleToolbarMove,
802
+ onToolbarMove: toolbarActions.moveThread,
901
803
  composeState,
902
804
  closeCompose,
903
805
  hasRemitDraftOpen,
@@ -18,6 +18,7 @@ import {
18
18
  compactRowClass,
19
19
  type Density,
20
20
  mergeProps,
21
+ type RowToggleEvent,
21
22
  type ThreadRowData,
22
23
  useLongPress,
23
24
  } from "@remit/ui";
@@ -26,6 +27,7 @@ import { Link } from "@tanstack/react-router";
26
27
  import { type MouseEvent, memo, type ReactNode, useCallback } from "react";
27
28
  import type { SelectionModifiers } from "@/hooks/useSelection";
28
29
  import { cn } from "@/lib/utils";
30
+ import { useThreadRowInteraction } from "./ThreadListInteraction";
29
31
 
30
32
  interface MailboxLinkSearch {
31
33
  selectedMessageId?: string;
@@ -85,27 +87,37 @@ export interface MessageRowProps {
85
87
  const MessageRowComponent = ({
86
88
  thread,
87
89
  active = false,
88
- focused = false,
89
- isTabStop = false,
90
+ focused: focusedProp,
91
+ isTabStop: isTabStopProp,
90
92
  density = "comfortable",
91
- isDesktop = true,
93
+ isDesktop: isDesktopProp,
92
94
  badge,
93
- selection,
95
+ selection: selectionProp,
94
96
  inListbox = false,
95
97
  linkMailboxId,
96
98
  onClick,
97
- onFocusRow,
99
+ onFocusRow: onFocusRowProp,
98
100
  }: MessageRowProps) => {
99
101
  const queryClient = useQueryClient();
100
102
  const messageId = thread.id;
103
+ // A list that renders its own rows (the brief, Flagged) supplies the cursor
104
+ // and selection through context; the mailbox list passes them as props.
105
+ const fromContext = useThreadRowInteraction(messageId);
106
+ const focused = focusedProp ?? fromContext?.focused ?? false;
107
+ const isTabStop = isTabStopProp ?? fromContext?.isTabStop ?? false;
108
+ const selection = selectionProp ?? fromContext?.selection;
109
+ const onFocusRow = onFocusRowProp ?? fromContext?.onFocusRow;
110
+ // The cursor and the row must agree on the device: the cursor's multi-select
111
+ // mode is derived from it, and the row's tap semantics branch on it.
112
+ const isDesktop = isDesktopProp ?? fromContext?.isDesktop ?? true;
101
113
  const isChecked = selection?.isChecked ?? false;
102
114
  const isMultiSelectMode = selection?.isMultiSelectMode ?? false;
103
115
  const onToggleCheck = selection?.onToggleCheck;
104
116
  const onRowSelect = selection?.onRowSelect;
105
117
  const onLongPress = selection?.onLongPress;
106
118
 
107
- const handleCheckboxClick = useCallback(
108
- (e: MouseEvent) => {
119
+ const handleToggleCheck = useCallback(
120
+ (e: RowToggleEvent) => {
109
121
  e.preventDefault();
110
122
  e.stopPropagation();
111
123
  onToggleCheck?.(messageId);
@@ -242,7 +254,7 @@ const MessageRowComponent = ({
242
254
  ? {
243
255
  checked: isChecked,
244
256
  alwaysVisible: isMultiSelectMode,
245
- onToggle: handleCheckboxClick,
257
+ onToggle: handleToggleCheck,
246
258
  }
247
259
  : undefined
248
260
  }