@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.
- package/package.json +5 -3
- package/src/components/mail/BriefPane.tsx +149 -3
- package/src/components/mail/DailyBrief.tsx +44 -12
- package/src/components/mail/FlaggedList.tsx +51 -27
- package/src/components/mail/FlaggedPane.tsx +144 -3
- package/src/components/mail/MailboxPane.tsx +45 -143
- package/src/components/mail/MessageRow.tsx +20 -8
- package/src/components/mail/ThreadListInteraction.test.ts +199 -0
- package/src/components/mail/ThreadListInteraction.tsx +408 -0
- package/src/hooks/useDeleteMessages.ts +16 -4
- package/src/hooks/useListCursor.test.ts +273 -0
- package/src/hooks/useListCursor.ts +226 -0
- package/src/hooks/useMarkAsRead.ts +15 -3
- package/src/hooks/useThreadActions.ts +101 -0
- package/src/hooks/useThreadMessageIds.ts +33 -0
- package/src/hooks/useTriageLayer.ts +148 -0
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
617
|
-
// thread.
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
enabled: !composeState.isOpen
|
|
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:
|
|
897
|
-
onToolbarDelete:
|
|
898
|
-
onToolbarStar:
|
|
798
|
+
onClearComposeRequest: toolbarActions.clearComposeRequest,
|
|
799
|
+
onToolbarDelete: toolbarActions.deleteThread,
|
|
800
|
+
onToolbarStar: toolbarActions.toggleStar,
|
|
899
801
|
onToolbarDiscardDraft: handleToolbarDiscardDraft,
|
|
900
|
-
onToolbarMove:
|
|
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
|
|
89
|
-
isTabStop
|
|
90
|
+
focused: focusedProp,
|
|
91
|
+
isTabStop: isTabStopProp,
|
|
90
92
|
density = "comfortable",
|
|
91
|
-
isDesktop
|
|
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
|
|
108
|
-
(e:
|
|
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:
|
|
257
|
+
onToggle: handleToggleCheck,
|
|
246
258
|
}
|
|
247
259
|
: undefined
|
|
248
260
|
}
|