@remit/web-client 0.0.47 → 0.0.49

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.47",
3
+ "version": "0.0.49",
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": {
@@ -33,6 +33,7 @@ import { ConversationView } from "@/components/mail/ConversationView";
33
33
  import { DailyBrief } from "@/components/mail/DailyBrief";
34
34
  import { IntelligencePane } from "@/components/mail/IntelligencePane";
35
35
  import { MessageToolbar } from "@/components/mail/MessageToolbar";
36
+ import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
36
37
  import {
37
38
  buildConversationTarget,
38
39
  type ConversationTarget,
@@ -51,6 +52,11 @@ interface BriefPaneContextValue {
51
52
  onSelectMessage: (id: string) => void;
52
53
  onSelectSearchResult: (result: SearchResult) => void;
53
54
  onCloseThread: () => void;
55
+ /**
56
+ * Toolbar verbs for the open thread, keyed by the thread's own mailbox and
57
+ * account — the brief spans accounts, so there is no route mailbox to key by.
58
+ */
59
+ actions: ThreadActions;
54
60
  }
55
61
 
56
62
  const BriefPaneCtx = createContext<BriefPaneContextValue | null>(null);
@@ -136,6 +142,28 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
136
142
  [navigate, searchInput],
137
143
  );
138
144
 
145
+ const handleDeselectIfRemoved = useCallback(
146
+ (removedIds: string[]) => {
147
+ if (!selectedMessageId) return;
148
+ if (!removedIds.includes(selectedMessageId)) return;
149
+ navigate({
150
+ to: "/mail",
151
+ search: (prev) => ({
152
+ ...prev,
153
+ selectedMessageId: undefined,
154
+ selectedThreadId: undefined,
155
+ selectedMailboxId: undefined,
156
+ }),
157
+ });
158
+ },
159
+ [selectedMessageId, navigate],
160
+ );
161
+
162
+ const actions = useThreadActions({
163
+ thread: selectedThread,
164
+ onAfterOptimisticRemove: handleDeselectIfRemoved,
165
+ });
166
+
139
167
  const handleCloseThread = useCallback(() => {
140
168
  navigate({
141
169
  to: "/mail",
@@ -155,6 +183,7 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
155
183
  onSelectMessage: handleSelectMessage,
156
184
  onSelectSearchResult: handleSelectSearchResult,
157
185
  onCloseThread: handleCloseThread,
186
+ actions,
158
187
  };
159
188
 
160
189
  return <BriefPaneCtx.Provider value={ctx}>{children}</BriefPaneCtx.Provider>;
@@ -187,7 +216,7 @@ function BriefList() {
187
216
  * Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
188
217
  */
189
218
  function BriefReading() {
190
- const { conversation } = useBriefPane();
219
+ const { conversation, actions } = useBriefPane();
191
220
  const { intelligenceOpen, onToggleIntelligence } = useMailContext();
192
221
  // The rail's own width gate, not the shell tier: between 1024 and 1280 the
193
222
  // reading pane is mounted but the rail is not, so "enabled" would promise an
@@ -203,6 +232,25 @@ function BriefReading() {
203
232
  intelligenceOpen={canToggleIntelligence && intelligenceOpen}
204
233
  canToggleIntelligence={canToggleIntelligence}
205
234
  onToggleIntelligence={onToggleIntelligence}
235
+ onReply={hasThread ? () => actions.requestCompose("reply") : undefined}
236
+ onReplyAll={
237
+ hasThread ? () => actions.requestCompose("reply_all") : undefined
238
+ }
239
+ onForward={
240
+ hasThread ? () => actions.requestCompose("forward") : undefined
241
+ }
242
+ onDelete={hasThread ? actions.deleteThread : undefined}
243
+ onToggleStar={hasThread ? actions.toggleStar : undefined}
244
+ isStarred={actions.isStarred}
245
+ moveContext={
246
+ hasThread && actions.accountId && actions.mailboxId
247
+ ? {
248
+ accountId: actions.accountId,
249
+ currentMailboxId: actions.mailboxId,
250
+ onMove: actions.moveThread,
251
+ }
252
+ : undefined
253
+ }
206
254
  />
207
255
  <div className="min-h-0 flex-1 overflow-hidden">
208
256
  {conversation ? (
@@ -212,6 +260,8 @@ function BriefReading() {
212
260
  subject={conversation.subject}
213
261
  selectedMessageId={conversation.messageId}
214
262
  authenticity={conversation.authenticity}
263
+ composeRequest={actions.composeRequest}
264
+ onComposeClose={actions.clearComposeRequest}
215
265
  />
216
266
  ) : (
217
267
  <ReadingPaneEmpty />
@@ -21,17 +21,12 @@ import {
21
21
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
22
22
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
23
23
  import {
24
- Avatar,
25
24
  type BriefCategoryFilter,
26
25
  BriefSections,
27
26
  briefFilterConfig,
28
- ComfortableRowTextContent,
29
- cn,
30
- comfortableRowClass,
31
27
  type FilterSheetProps,
32
28
  type FilterSheetSource,
33
29
  KeyboardHintBar,
34
- LIST_ROW_ATTRIBUTE,
35
30
  type SearchResult,
36
31
  type ThreadRowData,
37
32
  type ThreadSection,
@@ -56,6 +51,7 @@ import { useMailContext } from "@/lib/mail-context";
56
51
  import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
57
52
  import { parseSearchTokens } from "@/lib/search-tokens";
58
53
  import { MailListHeader } from "./MailListHeader";
54
+ import { MessageRow } from "./MessageRow";
59
55
 
60
56
  /* The brief's attribute chips as predicates (mirrors the kit `briefFilterChips`
61
57
  ids) so the phone search takeover narrows results the same way the list does. */
@@ -124,36 +120,6 @@ const ErrorBanner = ({ accountEmail }: ErrorBannerProps) => {
124
120
  );
125
121
  };
126
122
 
127
- // ---------------------------------------------------------------------------
128
- // Brief row — a navigation-aware row satisfying remit-ui's BriefRowComponent
129
- // ---------------------------------------------------------------------------
130
-
131
- const BriefRow = ({
132
- thread,
133
- active,
134
- onClick,
135
- }: {
136
- thread: ThreadRowData;
137
- active?: boolean;
138
- onClick?: () => void;
139
- }) => {
140
- const unread = !thread.isRead;
141
- return (
142
- <button
143
- type="button"
144
- {...LIST_ROW_ATTRIBUTE}
145
- onClick={onClick}
146
- className={cn("group w-full", comfortableRowClass({ active }))}
147
- >
148
- {unread && (
149
- <span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
150
- )}
151
- <Avatar name={thread.fromName} email={thread.fromEmail} size="sm" />
152
- <ComfortableRowTextContent thread={thread} />
153
- </button>
154
- );
155
- };
156
-
157
123
  // ---------------------------------------------------------------------------
158
124
  // Main component
159
125
  // ---------------------------------------------------------------------------
@@ -441,7 +407,7 @@ export function DailyBrief({
441
407
  ) : (
442
408
  <BriefSections
443
409
  sections={sections}
444
- Row={BriefRow}
410
+ Row={MessageRow}
445
411
  briefCategory={selectedCategory}
446
412
  onSelectBriefCategory={setSelectedCategory}
447
413
  sources={accountSources}
@@ -14,7 +14,6 @@
14
14
  * `listBody` so the real rows render at every width.
15
15
  */
16
16
  import {
17
- ComfortableRow,
18
17
  flaggedFilterConfig,
19
18
  LIST_ROW_SELECTOR,
20
19
  MessageListPane,
@@ -37,6 +36,7 @@ import { rowToSearchResult } from "@/lib/search-result";
37
36
  import { parseSearchTokens } from "@/lib/search-tokens";
38
37
  import { dedupeByThread } from "@/lib/starred-rows";
39
38
  import { MailViewChrome } from "./MailViewChrome";
39
+ import { MessageRow } from "./MessageRow";
40
40
 
41
41
  const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
42
42
  unread: (t) => !t.isRead,
@@ -138,7 +138,7 @@ export function FlaggedList({
138
138
  <div ref={listRef} className="flex-1 overflow-y-auto">
139
139
  <div className="divide-y divide-line">
140
140
  {rows.map((thread) => (
141
- <ComfortableRow
141
+ <MessageRow
142
142
  key={thread.id}
143
143
  thread={thread}
144
144
  active={thread.id === selectedMessageId}
@@ -36,6 +36,7 @@ import { FlaggedList } from "@/components/mail/FlaggedList";
36
36
  import { IntelligencePane } from "@/components/mail/IntelligencePane";
37
37
  import { MessageToolbar } from "@/components/mail/MessageToolbar";
38
38
  import { useStarredThreads } from "@/hooks/useStarredThreads";
39
+ import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
39
40
  import { useMailContext } from "@/lib/mail-context";
40
41
 
41
42
  /* ------------------------------------------------------------------ */
@@ -47,6 +48,11 @@ interface FlaggedPaneContextValue {
47
48
  selectedThread: RemitImapThreadMessageResponse | undefined;
48
49
  onSelectMessage: (id: string) => void;
49
50
  onCloseThread: () => void;
51
+ /**
52
+ * Toolbar verbs for the open thread, keyed by the thread's own mailbox and
53
+ * account — Flagged spans accounts, so there is no route mailbox to key by.
54
+ */
55
+ actions: ThreadActions;
50
56
  }
51
57
 
52
58
  const FlaggedPaneCtx = createContext<FlaggedPaneContextValue | null>(null);
@@ -96,11 +102,26 @@ function FlaggedPaneProvider({
96
102
  });
97
103
  }, [navigate]);
98
104
 
105
+ const handleDeselectIfRemoved = useCallback(
106
+ (removedIds: string[]) => {
107
+ if (!selectedMessageId) return;
108
+ if (!removedIds.includes(selectedMessageId)) return;
109
+ handleCloseThread();
110
+ },
111
+ [selectedMessageId, handleCloseThread],
112
+ );
113
+
114
+ const actions = useThreadActions({
115
+ thread: selectedThread,
116
+ onAfterOptimisticRemove: handleDeselectIfRemoved,
117
+ });
118
+
99
119
  const ctx: FlaggedPaneContextValue = {
100
120
  selectedMessageId,
101
121
  selectedThread,
102
122
  onSelectMessage: handleSelectMessage,
103
123
  onCloseThread: handleCloseThread,
124
+ actions,
104
125
  };
105
126
 
106
127
  return (
@@ -128,7 +149,7 @@ function FlaggedListSlot() {
128
149
  * Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
129
150
  */
130
151
  function FlaggedReading() {
131
- const { selectedThread } = useFlaggedPane();
152
+ const { selectedThread, actions } = useFlaggedPane();
132
153
  const { intelligenceOpen, onToggleIntelligence } = useMailContext();
133
154
  // The rail's own width gate, not the shell tier: between 1024 and 1280 the
134
155
  // reading pane is mounted but the rail is not, so "enabled" would promise an
@@ -144,6 +165,25 @@ function FlaggedReading() {
144
165
  intelligenceOpen={canToggleIntelligence && intelligenceOpen}
145
166
  canToggleIntelligence={canToggleIntelligence}
146
167
  onToggleIntelligence={onToggleIntelligence}
168
+ onReply={hasThread ? () => actions.requestCompose("reply") : undefined}
169
+ onReplyAll={
170
+ hasThread ? () => actions.requestCompose("reply_all") : undefined
171
+ }
172
+ onForward={
173
+ hasThread ? () => actions.requestCompose("forward") : undefined
174
+ }
175
+ onDelete={hasThread ? actions.deleteThread : undefined}
176
+ onToggleStar={hasThread ? actions.toggleStar : undefined}
177
+ isStarred={actions.isStarred}
178
+ moveContext={
179
+ hasThread && actions.accountId && actions.mailboxId
180
+ ? {
181
+ accountId: actions.accountId,
182
+ currentMailboxId: actions.mailboxId,
183
+ onMove: actions.moveThread,
184
+ }
185
+ : undefined
186
+ }
147
187
  />
148
188
  <div className="min-h-0 flex-1 overflow-hidden">
149
189
  {selectedThread ? (
@@ -152,6 +192,8 @@ function FlaggedReading() {
152
192
  mailboxId={selectedThread.mailboxId}
153
193
  subject={selectedThread.subject}
154
194
  authenticity={selectedThread.authenticity}
195
+ composeRequest={actions.composeRequest}
196
+ onComposeClose={actions.clearComposeRequest}
155
197
  />
156
198
  ) : (
157
199
  <ReadingPaneEmpty />
@@ -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,6 +90,8 @@ 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
96
  import { useTriageKeyboard } from "@/hooks/useTriageKeyboard";
96
97
  import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
@@ -503,15 +504,9 @@ function MailboxPaneProvider({
503
504
  const queryClient = useQueryClient();
504
505
  const { pushError } = useErrorBanners();
505
506
 
506
- const { deleteMessages: toolbarDelete } = useDeleteMessages({
507
+ const toolbarActions = useThreadActions({
508
+ thread: selectedThread,
507
509
  mailboxId,
508
- threadId: selectedThread?.threadId,
509
- onAfterOptimisticRemove: handleDeselectIfRemoved,
510
- });
511
-
512
- const { moveMessages: toolbarMove } = useMoveMessages({
513
- mailboxId,
514
- threadId: selectedThread?.threadId,
515
510
  accountId: mailboxAccountId,
516
511
  onAfterOptimisticRemove: handleDeselectIfRemoved,
517
512
  });
@@ -522,63 +517,19 @@ function MailboxPaneProvider({
522
517
 
523
518
  const { archiveMailboxId } = useArchiveMailbox(mailboxAccountId);
524
519
 
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
- );
541
-
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]);
520
+ const getThreadMessageIds = useThreadMessageIds();
566
521
 
567
- const [toolbarComposeRequest, setToolbarComposeRequest] =
568
- useState<ComposeMode | null>(null);
522
+ const { requestCompose: setToolbarComposeRequest } = toolbarActions;
569
523
 
570
524
  const handleToolbarReply = useCallback(() => {
571
525
  setToolbarComposeRequest("reply");
572
- }, []);
526
+ }, [setToolbarComposeRequest]);
573
527
  const handleToolbarReplyAll = useCallback(() => {
574
528
  setToolbarComposeRequest("reply_all");
575
- }, []);
529
+ }, [setToolbarComposeRequest]);
576
530
  const handleToolbarForward = useCallback(() => {
577
531
  setToolbarComposeRequest("forward");
578
- }, []);
579
- const handleClearComposeRequest = useCallback(() => {
580
- setToolbarComposeRequest(null);
581
- }, []);
532
+ }, [setToolbarComposeRequest]);
582
533
 
583
534
  const { state: composeState, openCompose, closeCompose } = useCompose();
584
535
 
@@ -688,15 +639,15 @@ function MailboxPaneProvider({
688
639
  const triageReply = useCallback(() => {
689
640
  if (ensureFocusedOpen()) return;
690
641
  if (selectedThread) setToolbarComposeRequest("reply");
691
- }, [ensureFocusedOpen, selectedThread]);
642
+ }, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
692
643
  const triageReplyAll = useCallback(() => {
693
644
  if (ensureFocusedOpen()) return;
694
645
  if (selectedThread) setToolbarComposeRequest("reply_all");
695
- }, [ensureFocusedOpen, selectedThread]);
646
+ }, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
696
647
  const triageForward = useCallback(() => {
697
648
  if (ensureFocusedOpen()) return;
698
649
  if (selectedThread) setToolbarComposeRequest("forward");
699
- }, [ensureFocusedOpen, selectedThread]);
650
+ }, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
700
651
 
701
652
  const triageTargetMessageIds = useCallback((): string[] => {
702
653
  if (triageSelectedIds.length > 0) return triageSelectedIds;
@@ -889,15 +840,15 @@ function MailboxPaneProvider({
889
840
  onTriageContextChange: handleTriageContextChange,
890
841
  listCommandsRef,
891
842
  onRetry: () => refetch(),
892
- toolbarComposeRequest,
843
+ toolbarComposeRequest: toolbarActions.composeRequest,
893
844
  onToolbarReply: handleToolbarReply,
894
845
  onToolbarReplyAll: handleToolbarReplyAll,
895
846
  onToolbarForward: handleToolbarForward,
896
- onClearComposeRequest: handleClearComposeRequest,
897
- onToolbarDelete: handleToolbarDelete,
898
- onToolbarStar: handleToolbarStar,
847
+ onClearComposeRequest: toolbarActions.clearComposeRequest,
848
+ onToolbarDelete: toolbarActions.deleteThread,
849
+ onToolbarStar: toolbarActions.toggleStar,
899
850
  onToolbarDiscardDraft: handleToolbarDiscardDraft,
900
- onToolbarMove: handleToolbarMove,
851
+ onToolbarMove: toolbarActions.moveThread,
901
852
  composeState,
902
853
  closeCompose,
903
854
  hasRemitDraftOpen,
@@ -1,31 +1,19 @@
1
- import { messageOperationsDescribeMessageOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
1
+ /**
2
+ * MessageListItem — the mailbox list's adapter onto the shared `MessageRow`.
3
+ *
4
+ * It maps an API thread to the row's `ThreadRowData` shape and supplies the two
5
+ * things only the mailbox list has: route-linking rows and the auto-moved
6
+ * badge. Everything visual and interactive lives in `MessageRow`, which the
7
+ * brief and Flagged render too.
8
+ */
2
9
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
3
- import {
4
- Avatar,
5
- ComfortableRowTextContent,
6
- CompactRowBody,
7
- comfortableRowClass,
8
- compactRowClass,
9
- type Density,
10
- mergeProps,
11
- type SenderTrustLevel,
12
- type ThreadRowData,
13
- useLongPress,
14
- } from "@remit/ui";
15
- import { useQueryClient } from "@tanstack/react-query";
16
- import { Link } from "@tanstack/react-router";
17
- import { Check } from "lucide-react";
18
- import { type MouseEvent, memo, useCallback } from "react";
10
+ import type { Density, SenderTrustLevel, ThreadRowData } from "@remit/ui";
11
+ import { memo } from "react";
19
12
  import type { SelectionModifiers } from "@/hooks/useSelection";
20
13
  import { toDisplayCategory } from "@/lib/display-category";
21
14
  import { formatEmailDate } from "@/lib/format";
22
- import { cn } from "@/lib/utils";
23
15
  import { AutoMovedIndicator } from "./AutoMovedIndicator";
24
-
25
- interface MailboxLinkSearch {
26
- selectedMessageId?: string;
27
- q?: string;
28
- }
16
+ import { MessageRow } from "./MessageRow";
29
17
 
30
18
  interface MessageListItemProps {
31
19
  thread: RemitImapThreadMessageResponse;
@@ -33,305 +21,94 @@ interface MessageListItemProps {
33
21
  /** Owning account, used to resolve the Inbox/Junk mailboxes for the auto-moved badge's undo action. */
34
22
  accountId?: string;
35
23
  isSelected: boolean;
36
- /**
37
- * Roving keyboard focus cursor (#429). Distinct from `isSelected` (the open
38
- * thread): a focused-but-not-open row shows the left accent rail; the open
39
- * row shows the full highlight. Both can be true (the open row stays focused).
40
- */
41
24
  isFocused?: boolean;
42
- /**
43
- * Whether this row holds the list's single tab stop (roving tabindex). Every
44
- * other row is `tabIndex={-1}`, so Tab enters the list at the cursor and
45
- * Shift+Tab leaves it rather than stepping through hundreds of rows.
46
- */
47
25
  isTabStop?: boolean;
48
- /** Called when the row takes DOM focus, so the roving cursor follows it. */
49
26
  onFocusRow?: (messageId: string) => void;
50
27
  isChecked: boolean;
51
28
  onToggleCheck: (id: string) => void;
52
- /**
53
- * Desktop mouse selection. Called from the row's onClick with the click
54
- * modifiers. Returns true when selection consumed the click (the row should
55
- * not navigate); false for a plain click (navigation proceeds).
56
- */
57
29
  onRowSelect: (messageId: string, modifiers: SelectionModifiers) => boolean;
58
30
  messageCount?: number;
59
- /** When true, the checkbox is always visible (e.g. mobile multi-select mode). */
60
31
  isMultiSelectMode?: boolean;
61
- /** Called on long press (mobile only). Receives the row's messageId. */
62
32
  onLongPress?: (messageId: string) => void;
63
- /** Whether the current viewport is desktop size. */
64
33
  isDesktop?: boolean;
65
- /** Row density — comfortable (default) or compact (mutt mode). */
66
34
  density?: Density;
67
35
  }
68
36
 
69
37
  /**
70
- * Map a RemitImapThreadMessageResponse to the ThreadRowData shape used by
71
- * remit-ui row body components.
38
+ * Map a RemitImapThreadMessageResponse to the ThreadRowData shape the shared
39
+ * row renders.
72
40
  */
73
- const toThreadRowData = (
41
+ export const threadToRowData = (
74
42
  thread: RemitImapThreadMessageResponse,
75
- messageCount: number | undefined,
76
- ): ThreadRowData => {
77
- // Use the backend's authoritative DKIM-alignment verdict rather than
78
- // re-deriving it in the view: dkimMismatch already accounts for the
79
- // multi-signature / alignment semantics a single string compare misses.
80
- const suspicious = thread.authenticity?.dkimMismatch === true;
81
-
82
- return {
83
- id: thread.messageId,
84
- accountId: thread.accountConfigId,
85
- fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
86
- fromEmail: thread.fromEmail ?? "",
87
- subject: thread.subject ?? "(No subject)",
88
- snippet: thread.snippet ?? "",
89
- timeLabel: formatEmailDate(thread.sentDate),
90
- isRead: thread.isRead,
91
- hasAttachment: thread.hasAttachment,
92
- starred: thread.hasStars === true,
93
- trust: thread.senderTrust as SenderTrustLevel,
94
- category: toDisplayCategory(thread.category),
95
- messageCount,
96
- suspicious,
97
- };
98
- };
43
+ messageCount?: number,
44
+ ): ThreadRowData => ({
45
+ id: thread.messageId,
46
+ accountId: thread.accountConfigId,
47
+ mailboxId: thread.mailboxId,
48
+ fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
49
+ fromEmail: thread.fromEmail ?? "",
50
+ subject: thread.subject ?? "(No subject)",
51
+ snippet: thread.snippet ?? "",
52
+ timeLabel: formatEmailDate(thread.sentDate),
53
+ isRead: thread.isRead,
54
+ hasAttachment: thread.hasAttachment,
55
+ starred: thread.hasStars === true,
56
+ trust: thread.senderTrust as SenderTrustLevel,
57
+ category: toDisplayCategory(thread.category),
58
+ messageCount,
59
+ // The backend's DKIM-alignment verdict is authoritative: it already accounts
60
+ // for the multi-signature / alignment semantics a single string compare
61
+ // misses.
62
+ suspicious: thread.authenticity?.dkimMismatch === true,
63
+ });
99
64
 
100
65
  const MessageListItemComponent = ({
101
66
  thread,
102
67
  mailboxId,
103
68
  accountId,
104
69
  isSelected,
105
- isFocused = false,
106
- isTabStop = false,
70
+ isFocused,
71
+ isTabStop,
107
72
  onFocusRow,
108
73
  isChecked,
109
74
  onToggleCheck,
110
75
  onRowSelect,
111
76
  messageCount,
112
- isMultiSelectMode = false,
77
+ isMultiSelectMode,
113
78
  onLongPress,
114
- isDesktop = true,
115
- density = "comfortable",
116
- }: MessageListItemProps) => {
117
- const queryClient = useQueryClient();
118
- const messageId = thread.messageId;
119
- const rowData = toThreadRowData(thread, messageCount);
120
-
121
- const handleCheckboxClick = (e: MouseEvent) => {
122
- e.preventDefault();
123
- e.stopPropagation();
124
- onToggleCheck(thread.messageId);
125
- };
126
-
127
- // Desktop mouse selection semantics. Plain click falls through to the Link's
128
- // navigation; shift / cmd / ctrl click is routed to selection and the
129
- // navigation is suppressed.
130
- //
131
- // A modified click must preventDefault: the router skips navigation for any
132
- // modified click and leaves the anchor's own default in place, which in a
133
- // browser means shift-click opens a new window and cmd-click a new tab.
134
- // Shift-click also drags a native text selection across the rows it spans,
135
- // so drop it — the row highlight is the selection the user asked for.
136
- //
137
- // On mobile, once selection mode is active (#92) a plain tap toggles the
138
- // row instead of opening it — the same tap-to-toggle contract every
139
- // reference mail client uses once you're mid-selection. Outside selection
140
- // mode a short tap still falls through to the Link's native navigation.
141
- const handleRowClick = useCallback(
142
- (e: MouseEvent) => {
143
- if (!isDesktop) {
144
- if (!isMultiSelectMode) return;
145
- e.preventDefault();
146
- e.stopPropagation();
147
- onToggleCheck(messageId);
148
- return;
149
- }
150
- const modifiers = {
151
- shiftKey: e.shiftKey,
152
- metaKey: e.metaKey,
153
- ctrlKey: e.ctrlKey,
154
- };
155
- const handled = onRowSelect(messageId, modifiers);
156
- if (!handled) return;
157
- e.preventDefault();
158
- e.stopPropagation();
159
- if (modifiers.shiftKey) {
160
- window.getSelection()?.removeAllRanges();
161
- }
162
- },
163
- [isDesktop, isMultiSelectMode, onToggleCheck, onRowSelect, messageId],
164
- );
165
-
166
- // Shift-click starts a native text selection on mousedown; suppressing it
167
- // there keeps the drag from painting a text range over the rows.
168
- const handleRowMouseDown = useCallback(
169
- (e: MouseEvent) => {
170
- if (!isDesktop) return;
171
- if (e.shiftKey) e.preventDefault();
172
- },
173
- [isDesktop],
174
- );
175
-
176
- const handleLongPress = useCallback(() => {
177
- onLongPress?.(messageId);
178
- }, [onLongPress, messageId]);
179
-
180
- const { longPressProps } = useLongPress({
181
- onLongPress: handleLongPress,
182
- delayMs: 500,
183
- accessibilityDescription: isChecked ? "Deselect message" : "Select message",
184
- });
185
-
186
- // Intent-based prefetch: by the time the user clicks, the body is in
187
- // React Query's cache and the detail pane renders without a spinner.
188
- const prefetchMessage = useCallback(() => {
189
- queryClient.prefetchQuery(
190
- messageOperationsDescribeMessageOptions({
191
- path: { messageId: thread.messageId },
192
- }),
193
- );
194
- }, [queryClient, thread.messageId]);
195
-
196
- const handleRowFocus = useCallback(() => {
197
- prefetchMessage();
198
- onFocusRow?.(messageId);
199
- }, [prefetchMessage, onFocusRow, messageId]);
200
-
201
- // Listbox semantics + roving tabindex, shared by both densities. Merged
202
- // (not spread) with the mobile long-press props: react-aria's pressProps
203
- // carries its own onClick for its internal press bookkeeping, and a plain
204
- // object spread would silently drop whichever onClick landed second
205
- // instead of running both.
206
- const rowInteractionProps = mergeProps(
207
- {
208
- "data-message-row": true,
209
- "data-message-id": messageId,
210
- role: "option" as const,
211
- "aria-selected": isChecked,
212
- tabIndex: isTabStop ? 0 : -1,
213
- onClick: handleRowClick,
214
- onMouseDown: handleRowMouseDown,
215
- onMouseEnter: prefetchMessage,
216
- onFocus: handleRowFocus,
217
- },
218
- isDesktop ? {} : longPressProps,
219
- );
220
-
221
- const unread = !thread.isRead;
222
-
223
- if (density === "compact") {
224
- return (
225
- <Link
226
- to="/mail/$mailboxId"
227
- params={{ mailboxId }}
228
- search={(prev: MailboxLinkSearch) => ({
229
- ...prev,
230
- selectedMessageId: thread.messageId,
231
- })}
232
- {...rowInteractionProps}
233
- className={cn(
234
- compactRowClass({ active: isSelected, focused: isFocused }),
235
- "outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
236
- isChecked && "bg-accent-soft",
237
- // Long-press enters selection mode; without these, Android Chrome
238
- // opens the link context menu / starts text selection and iOS
239
- // Safari fires the callout, racing the app's handler. react-aria
240
- // suppresses contextmenu/text-selection but not iOS's callout —
241
- // it fires no cancelable event, so CSS is the only lever.
242
- !isDesktop && "min-h-11 select-none [-webkit-touch-callout:none]",
243
- )}
244
- >
245
- <CompactRowBody thread={rowData} />
246
- </Link>
247
- );
248
- }
249
-
250
- // Comfortable density: avatar/checkbox leading slot + text content.
251
- // The slot is a fixed 36px so the row never reflows when state changes.
252
- // Unread dot is positioned absolute (left-1.5, vertically centered).
253
- return (
254
- <Link
255
- to="/mail/$mailboxId"
256
- params={{ mailboxId }}
257
- search={(prev: MailboxLinkSearch) => ({
258
- ...prev,
259
- selectedMessageId: thread.messageId,
260
- })}
261
- {...rowInteractionProps}
262
- className={cn(
263
- "group",
264
- comfortableRowClass({ active: isSelected, focused: isFocused }),
265
- "outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
266
- isChecked && "bg-accent-soft",
267
- // See the compact-density Link above for why both are required.
268
- !isDesktop && "min-h-11 select-none [-webkit-touch-callout:none]",
269
- )}
270
- >
271
- {/* Absolute unread dot — 6px gutter from the left pane hairline */}
272
- {unread && (
273
- <span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
274
- )}
275
-
276
- {/* Leading slot: avatar by default, checkbox on hover (desktop) or
277
- while checked / in multi-select mode. Fixed 28px slot (size-7,
278
- matching Avatar size="sm" in remit-ui ComfortableRow). */}
279
- <div className="relative size-7 shrink-0">
280
- <Avatar
281
- name={thread.fromName ?? thread.fromEmail ?? "?"}
282
- email={thread.fromEmail ?? undefined}
79
+ isDesktop,
80
+ density,
81
+ }: MessageListItemProps) => (
82
+ <MessageRow
83
+ thread={threadToRowData(thread, messageCount)}
84
+ linkMailboxId={mailboxId}
85
+ inListbox
86
+ active={isSelected}
87
+ focused={isFocused}
88
+ isTabStop={isTabStop}
89
+ density={density}
90
+ isDesktop={isDesktop}
91
+ onFocusRow={onFocusRow}
92
+ selection={{
93
+ isChecked,
94
+ onToggleCheck,
95
+ onRowSelect,
96
+ isMultiSelectMode,
97
+ onLongPress,
98
+ }}
99
+ badge={
100
+ thread.autoMoved ? (
101
+ <AutoMovedIndicator
102
+ accountId={accountId}
103
+ messageId={thread.messageId}
104
+ threadId={thread.threadId}
105
+ mailboxId={thread.mailboxId}
106
+ autoMoved={thread.autoMoved}
283
107
  size="sm"
284
- className={cn(
285
- "absolute inset-0",
286
- "sm:group-hover:opacity-0 transition-opacity",
287
- (isChecked || isMultiSelectMode) && "opacity-0",
288
- )}
289
108
  />
290
- <button
291
- type="button"
292
- // Out of the tab order: the row is the list's single tab stop, and
293
- // this control is `opacity-0` until hover, so a tabbable one would
294
- // put focus on something invisible on every row.
295
- tabIndex={-1}
296
- onClick={handleCheckboxClick}
297
- className={cn(
298
- "absolute inset-0 size-7 rounded-full border items-center justify-center transition-opacity",
299
- isMultiSelectMode ? "flex" : "hidden sm:flex",
300
- isChecked
301
- ? "bg-accent border-accent text-accent-fg opacity-100"
302
- : isMultiSelectMode
303
- ? "border-fg-subtle/40 opacity-100 bg-canvas"
304
- : "border-fg-subtle/40 opacity-0 group-hover:opacity-100 bg-canvas",
305
- )}
306
- aria-label={isChecked ? "Deselect message" : "Select message"}
307
- >
308
- {isChecked && <Check className="size-3" />}
309
- </button>
310
- </div>
311
-
312
- {/* Text/glyph content block */}
313
- <ComfortableRowTextContent
314
- thread={rowData}
315
- badge={
316
- thread.autoMoved && (
317
- <AutoMovedIndicator
318
- accountId={accountId}
319
- messageId={thread.messageId}
320
- threadId={thread.threadId}
321
- mailboxId={thread.mailboxId}
322
- autoMoved={thread.autoMoved}
323
- size="sm"
324
- />
325
- )
326
- }
327
- />
328
- </Link>
329
- );
330
- };
109
+ ) : undefined
110
+ }
111
+ />
112
+ );
331
113
 
332
- // Wrapped in React.memo so virtualized rows don't re-render on every parent
333
- // state change. The `search` callback prop on Link is inline, but it gets a
334
- // stable reference because the parent itself is stable across re-renders
335
- // (mailboxId is a string from route params). React.memo with default shallow
336
- // equality is appropriate here since props are primitives + stable callback.
337
114
  export const MessageListItem = memo(MessageListItemComponent);
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Row semantics for the shared `MessageRow` (#149).
3
+ *
4
+ * `role="option"` belongs to the mailbox list, whose rows sit inside a
5
+ * `role="listbox"` container. The brief and Flagged render rows in ordinary
6
+ * containers: an orphan `option` there is invalid ARIA and, more concretely,
7
+ * strips the row of its button role — which is how the black-box suite and any
8
+ * screen reader address it.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import type { ThreadRowData } from "@remit/ui";
14
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
15
+ import React, { createElement } from "react";
16
+ import { renderToString } from "react-dom/server";
17
+ import { MessageRow } from "./MessageRow";
18
+
19
+ (globalThis as { React?: typeof React }).React = React;
20
+
21
+ const thread: ThreadRowData = {
22
+ id: "m1",
23
+ accountId: "a1",
24
+ fromName: "Alex Rivera",
25
+ fromEmail: "alex@example.com",
26
+ subject: "Quarterly numbers are in",
27
+ snippet: "The deck is on the shared drive.",
28
+ timeLabel: "9:42",
29
+ isRead: true,
30
+ };
31
+
32
+ const render = (props: Parameters<typeof MessageRow>[0]): string =>
33
+ renderToString(
34
+ createElement(
35
+ QueryClientProvider,
36
+ { client: new QueryClient() },
37
+ createElement(MessageRow, props),
38
+ ) as never,
39
+ );
40
+
41
+ describe("MessageRow semantics", () => {
42
+ it("renders a plain button outside a listbox (brief, Flagged)", () => {
43
+ const html = render({ thread });
44
+ assert.match(html, /<button/);
45
+ assert.doesNotMatch(html, /role="option"/);
46
+ assert.doesNotMatch(html, /aria-selected/);
47
+ });
48
+
49
+ it("renders listbox option semantics when the container is a listbox", () => {
50
+ const html = render({ thread, inListbox: true });
51
+ assert.match(html, /role="option"/);
52
+ assert.match(html, /aria-selected/);
53
+ });
54
+
55
+ it("renders the row's sender, subject and snippet", () => {
56
+ const html = render({ thread });
57
+ assert.match(html, /Alex Rivera/);
58
+ assert.match(html, /Quarterly numbers are in/);
59
+ assert.match(html, /shared drive/);
60
+ });
61
+ });
@@ -0,0 +1,279 @@
1
+ /**
2
+ * MessageRow — the one thread row every list in the app renders.
3
+ *
4
+ * The mailbox list, the daily brief and Flagged used to carry three
5
+ * near-identical rows (#149). They differ in two axes only, both props here:
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`.
10
+ * - `selection` — set where multi-select is available. Absent renders the
11
+ * avatar alone with no checkbox, which is what "non-selectable mode" means.
12
+ */
13
+ import { messageOperationsDescribeMessageOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
14
+ import {
15
+ ComfortableRowBody,
16
+ CompactRowBody,
17
+ comfortableRowClass,
18
+ compactRowClass,
19
+ type Density,
20
+ mergeProps,
21
+ type ThreadRowData,
22
+ useLongPress,
23
+ } from "@remit/ui";
24
+ import { useQueryClient } from "@tanstack/react-query";
25
+ import { Link } from "@tanstack/react-router";
26
+ import { type MouseEvent, memo, type ReactNode, useCallback } from "react";
27
+ import type { SelectionModifiers } from "@/hooks/useSelection";
28
+ import { cn } from "@/lib/utils";
29
+
30
+ interface MailboxLinkSearch {
31
+ selectedMessageId?: string;
32
+ q?: string;
33
+ }
34
+
35
+ export interface MessageRowSelection {
36
+ isChecked: boolean;
37
+ onToggleCheck: (messageId: string) => void;
38
+ /**
39
+ * Desktop mouse selection. Returns true when selection consumed the click
40
+ * (the row must not open); false for a plain click.
41
+ */
42
+ onRowSelect: (messageId: string, modifiers: SelectionModifiers) => boolean;
43
+ /** Checkbox stays visible — mobile multi-select mode. */
44
+ isMultiSelectMode?: boolean;
45
+ /** Long press enters selection mode (mobile only). */
46
+ onLongPress?: (messageId: string) => void;
47
+ }
48
+
49
+ export interface MessageRowProps {
50
+ thread: ThreadRowData;
51
+ /** The row is the open thread. */
52
+ active?: boolean;
53
+ /**
54
+ * Roving keyboard focus cursor (#429). Distinct from `active`: a
55
+ * focused-but-not-open row shows the left accent rail; the open row shows the
56
+ * full highlight. Both can be true.
57
+ */
58
+ focused?: boolean;
59
+ /**
60
+ * Whether this row holds the list's single tab stop (roving tabindex). Every
61
+ * other row is `tabIndex={-1}`, so Tab enters the list at the cursor and
62
+ * Shift+Tab leaves it rather than stepping through hundreds of rows.
63
+ */
64
+ isTabStop?: boolean;
65
+ density?: Density;
66
+ isDesktop?: boolean;
67
+ /** Extra chip after the category badge (e.g. the auto-moved indicator). */
68
+ badge?: ReactNode;
69
+ selection?: MessageRowSelection;
70
+ /**
71
+ * The row sits inside a container with `role="listbox"` (the mailbox list),
72
+ * so it carries `role="option"` and `aria-selected`. Off everywhere else:
73
+ * the brief and Flagged render their rows in ordinary containers, where an
74
+ * orphan `option` is invalid ARIA and costs the row its button semantics.
75
+ */
76
+ inListbox?: boolean;
77
+ /** Mailbox whose route the row links to; omit for callback-driven rows. */
78
+ linkMailboxId?: string;
79
+ /** Called when the row is opened by a plain click on a non-linking row. */
80
+ onClick?: () => void;
81
+ /** Called when the row takes DOM focus, so the roving cursor follows it. */
82
+ onFocusRow?: (messageId: string) => void;
83
+ }
84
+
85
+ const MessageRowComponent = ({
86
+ thread,
87
+ active = false,
88
+ focused = false,
89
+ isTabStop = false,
90
+ density = "comfortable",
91
+ isDesktop = true,
92
+ badge,
93
+ selection,
94
+ inListbox = false,
95
+ linkMailboxId,
96
+ onClick,
97
+ onFocusRow,
98
+ }: MessageRowProps) => {
99
+ const queryClient = useQueryClient();
100
+ const messageId = thread.id;
101
+ const isChecked = selection?.isChecked ?? false;
102
+ const isMultiSelectMode = selection?.isMultiSelectMode ?? false;
103
+ const onToggleCheck = selection?.onToggleCheck;
104
+ const onRowSelect = selection?.onRowSelect;
105
+ const onLongPress = selection?.onLongPress;
106
+
107
+ const handleCheckboxClick = useCallback(
108
+ (e: MouseEvent) => {
109
+ e.preventDefault();
110
+ e.stopPropagation();
111
+ onToggleCheck?.(messageId);
112
+ },
113
+ [onToggleCheck, messageId],
114
+ );
115
+
116
+ // Desktop mouse selection semantics. Plain click falls through to the Link's
117
+ // navigation (or `onClick` on a non-linking row); shift / cmd / ctrl click is
118
+ // routed to selection and the navigation is suppressed.
119
+ //
120
+ // A modified click must preventDefault: the router skips navigation for any
121
+ // modified click and leaves the anchor's own default in place, which in a
122
+ // browser means shift-click opens a new window and cmd-click a new tab.
123
+ // Shift-click also drags a native text selection across the rows it spans,
124
+ // so drop it — the row highlight is the selection the user asked for.
125
+ //
126
+ // On mobile, once selection mode is active (#92) a plain tap toggles the row
127
+ // instead of opening it — the same tap-to-toggle contract every reference
128
+ // mail client uses once you're mid-selection.
129
+ const handleRowClick = useCallback(
130
+ (e: MouseEvent) => {
131
+ if (!isDesktop) {
132
+ if (isMultiSelectMode) {
133
+ e.preventDefault();
134
+ e.stopPropagation();
135
+ onToggleCheck?.(messageId);
136
+ return;
137
+ }
138
+ onClick?.();
139
+ return;
140
+ }
141
+ const modifiers = {
142
+ shiftKey: e.shiftKey,
143
+ metaKey: e.metaKey,
144
+ ctrlKey: e.ctrlKey,
145
+ };
146
+ if (onRowSelect?.(messageId, modifiers)) {
147
+ e.preventDefault();
148
+ e.stopPropagation();
149
+ if (modifiers.shiftKey) window.getSelection()?.removeAllRanges();
150
+ return;
151
+ }
152
+ onClick?.();
153
+ },
154
+ [
155
+ isDesktop,
156
+ isMultiSelectMode,
157
+ onToggleCheck,
158
+ onRowSelect,
159
+ onClick,
160
+ messageId,
161
+ ],
162
+ );
163
+
164
+ // Shift-click starts a native text selection on mousedown; suppressing it
165
+ // there keeps the drag from painting a text range over the rows.
166
+ const handleRowMouseDown = useCallback(
167
+ (e: MouseEvent) => {
168
+ if (!isDesktop) return;
169
+ if (e.shiftKey) e.preventDefault();
170
+ },
171
+ [isDesktop],
172
+ );
173
+
174
+ const handleLongPress = useCallback(() => {
175
+ onLongPress?.(messageId);
176
+ }, [onLongPress, messageId]);
177
+
178
+ const { longPressProps } = useLongPress({
179
+ onLongPress: handleLongPress,
180
+ delayMs: 500,
181
+ accessibilityDescription: isChecked ? "Deselect message" : "Select message",
182
+ });
183
+
184
+ // Intent-based prefetch: by the time the user clicks, the body is in
185
+ // React Query's cache and the detail pane renders without a spinner.
186
+ const prefetchMessage = useCallback(() => {
187
+ queryClient.prefetchQuery(
188
+ messageOperationsDescribeMessageOptions({ path: { messageId } }),
189
+ );
190
+ }, [queryClient, messageId]);
191
+
192
+ const handleRowFocus = useCallback(() => {
193
+ prefetchMessage();
194
+ onFocusRow?.(messageId);
195
+ }, [prefetchMessage, onFocusRow, messageId]);
196
+
197
+ // Listbox semantics + roving tabindex, shared by both densities. Merged (not
198
+ // spread) with the mobile long-press props: react-aria's pressProps carries
199
+ // its own onClick for its internal press bookkeeping, and a plain object
200
+ // spread would silently drop whichever onClick landed second instead of
201
+ // running both.
202
+ const interactionProps = mergeProps(
203
+ {
204
+ "data-list-row": "",
205
+ "data-message-row": true,
206
+ "data-message-id": messageId,
207
+ ...(inListbox
208
+ ? { role: "option" as const, "aria-selected": isChecked }
209
+ : {}),
210
+ tabIndex: isTabStop ? 0 : -1,
211
+ onClick: handleRowClick,
212
+ onMouseDown: handleRowMouseDown,
213
+ onMouseEnter: prefetchMessage,
214
+ onFocus: handleRowFocus,
215
+ },
216
+ isDesktop || !selection ? {} : longPressProps,
217
+ );
218
+
219
+ const className = cn(
220
+ density === "compact"
221
+ ? compactRowClass({ active, focused })
222
+ : cn("group", comfortableRowClass({ active, focused })),
223
+ "outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
224
+ isChecked && "bg-accent-soft",
225
+ // Long-press enters selection mode; without these, Android Chrome opens
226
+ // the link context menu / starts text selection and iOS Safari fires the
227
+ // callout, racing the app's handler. react-aria suppresses
228
+ // contextmenu/text-selection but not iOS's callout — it fires no
229
+ // cancelable event, so CSS is the only lever.
230
+ !isDesktop && "min-h-11 select-none [-webkit-touch-callout:none]",
231
+ );
232
+
233
+ const body =
234
+ density === "compact" ? (
235
+ <CompactRowBody thread={thread} />
236
+ ) : (
237
+ <ComfortableRowBody
238
+ thread={thread}
239
+ badge={badge}
240
+ selection={
241
+ selection
242
+ ? {
243
+ checked: isChecked,
244
+ alwaysVisible: isMultiSelectMode,
245
+ onToggle: handleCheckboxClick,
246
+ }
247
+ : undefined
248
+ }
249
+ />
250
+ );
251
+
252
+ if (linkMailboxId === undefined) {
253
+ return (
254
+ <button type="button" {...interactionProps} className={className}>
255
+ {body}
256
+ </button>
257
+ );
258
+ }
259
+
260
+ return (
261
+ <Link
262
+ to="/mail/$mailboxId"
263
+ params={{ mailboxId: linkMailboxId }}
264
+ search={(prev: MailboxLinkSearch) => ({
265
+ ...prev,
266
+ selectedMessageId: messageId,
267
+ })}
268
+ {...interactionProps}
269
+ className={className}
270
+ >
271
+ {body}
272
+ </Link>
273
+ );
274
+ };
275
+
276
+ // Wrapped in React.memo so virtualized rows don't re-render on every parent
277
+ // state change. Props are primitives plus callbacks the parents keep stable, so
278
+ // default shallow equality is appropriate.
279
+ export const MessageRow = memo(MessageRowComponent);
@@ -0,0 +1,101 @@
1
+ /**
2
+ * useThreadActions — the reading pane's verbs for one open thread.
3
+ *
4
+ * Delete, move, star and the compose requests (reply / reply-all / forward),
5
+ * over the same mutation hooks the mailbox list uses. The mailbox view keys
6
+ * them by its route; the brief and Flagged are cross-account, so they key by
7
+ * the open thread's own `mailboxId` / `accountConfigId` (#149).
8
+ */
9
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
10
+ import { useCallback, useState } from "react";
11
+ import type { ComposeMode } from "@/components/compose/ComposeProvider";
12
+ import { useDeleteMessages } from "@/hooks/useDeleteMessages";
13
+ import { useMoveMessages } from "@/hooks/useMoveMessages";
14
+ import { useThreadMessageIds } from "@/hooks/useThreadMessageIds";
15
+ import { useToggleStar } from "@/hooks/useToggleStar";
16
+
17
+ interface UseThreadActionsOptions {
18
+ thread: RemitImapThreadMessageResponse | undefined;
19
+ /** Mailbox whose listings the mutations patch. Defaults to the thread's own. */
20
+ mailboxId?: string;
21
+ /** Account the move picker offers folders from. Defaults to the thread's own. */
22
+ accountId?: string;
23
+ onAfterOptimisticRemove?: (messageIds: string[]) => void;
24
+ }
25
+
26
+ export interface ThreadActions {
27
+ mailboxId: string | undefined;
28
+ accountId: string | undefined;
29
+ isStarred: boolean | undefined;
30
+ deleteThread: () => void;
31
+ moveThread: (destinationMailboxId: string) => void;
32
+ toggleStar: () => void;
33
+ composeRequest: ComposeMode | null;
34
+ requestCompose: (mode: ComposeMode) => void;
35
+ clearComposeRequest: () => void;
36
+ }
37
+
38
+ export const useThreadActions = ({
39
+ thread,
40
+ mailboxId,
41
+ accountId,
42
+ onAfterOptimisticRemove,
43
+ }: UseThreadActionsOptions): ThreadActions => {
44
+ const resolvedMailboxId = mailboxId ?? thread?.mailboxId;
45
+ const resolvedAccountId = accountId ?? thread?.accountConfigId;
46
+ const threadMessageIds = useThreadMessageIds();
47
+
48
+ const { deleteMessages } = useDeleteMessages({
49
+ mailboxId: resolvedMailboxId ?? "",
50
+ threadId: thread?.threadId,
51
+ accountId: resolvedAccountId,
52
+ onAfterOptimisticRemove,
53
+ });
54
+
55
+ const { moveMessages } = useMoveMessages({
56
+ mailboxId: resolvedMailboxId ?? "",
57
+ threadId: thread?.threadId,
58
+ accountId: resolvedAccountId,
59
+ onAfterOptimisticRemove,
60
+ });
61
+
62
+ const { toggleStar: toggleStarFor } = useToggleStar({
63
+ threadId: thread?.threadId ?? "",
64
+ mailboxId: resolvedMailboxId ?? "",
65
+ });
66
+
67
+ const deleteThread = useCallback(() => {
68
+ if (!thread) return;
69
+ deleteMessages(threadMessageIds(thread));
70
+ }, [thread, threadMessageIds, deleteMessages]);
71
+
72
+ const moveThread = useCallback(
73
+ (destinationMailboxId: string) => {
74
+ if (!thread) return;
75
+ moveMessages(threadMessageIds(thread), destinationMailboxId);
76
+ },
77
+ [thread, threadMessageIds, moveMessages],
78
+ );
79
+
80
+ const toggleStar = useCallback(() => {
81
+ if (!thread) return;
82
+ toggleStarFor(thread.messageId, thread.hasStars);
83
+ }, [thread, toggleStarFor]);
84
+
85
+ const [composeRequest, setComposeRequest] = useState<ComposeMode | null>(
86
+ null,
87
+ );
88
+ const clearComposeRequest = useCallback(() => setComposeRequest(null), []);
89
+
90
+ return {
91
+ mailboxId: resolvedMailboxId,
92
+ accountId: resolvedAccountId,
93
+ isStarred: thread?.hasStars,
94
+ deleteThread,
95
+ moveThread,
96
+ toggleStar,
97
+ composeRequest,
98
+ requestCompose: setComposeRequest,
99
+ clearComposeRequest,
100
+ };
101
+ };
@@ -0,0 +1,33 @@
1
+ import { threadDetailOperationsListThreadMessagesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
3
+ import { useQueryClient } from "@tanstack/react-query";
4
+ import { useCallback } from "react";
5
+
6
+ /**
7
+ * Every message id in a thread, read from the cached thread-messages listing.
8
+ *
9
+ * A toolbar verb acts on the whole conversation, not the one row that
10
+ * represents it in a list. When the conversation has not been opened yet its
11
+ * messages are not cached, and the representative message id is the best the
12
+ * caller can do.
13
+ */
14
+ export const useThreadMessageIds = (): ((
15
+ thread: RemitImapThreadMessageResponse,
16
+ ) => string[]) => {
17
+ const queryClient = useQueryClient();
18
+ return useCallback(
19
+ (thread: RemitImapThreadMessageResponse) => {
20
+ const threadKey = threadDetailOperationsListThreadMessagesQueryKey({
21
+ path: { threadId: thread.threadId },
22
+ });
23
+ const cached = queryClient.getQueriesData<{
24
+ items: { messageId: string }[];
25
+ }>({ queryKey: threadKey });
26
+ const ids = cached.flatMap(
27
+ ([, data]) => data?.items.map((m) => m.messageId) ?? [],
28
+ );
29
+ return ids.length > 0 ? ids : [thread.messageId];
30
+ },
31
+ [queryClient],
32
+ );
33
+ };