@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.50",
|
|
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": {
|
|
@@ -97,8 +97,6 @@
|
|
|
97
97
|
"@aws-sdk/client-dynamodb": "*",
|
|
98
98
|
"@aws-sdk/client-sqs": "*",
|
|
99
99
|
"@aws-sdk/lib-dynamodb": "*",
|
|
100
|
-
"@types/nodemailer": "*",
|
|
101
|
-
"nodemailer": "^9.0.3",
|
|
102
100
|
"@playwright/test": "1.58.2",
|
|
103
101
|
"@remit/drizzle-service": "*",
|
|
104
102
|
"@remit/electrodb-entities": "*",
|
|
@@ -107,11 +105,15 @@
|
|
|
107
105
|
"@tailwindcss/vite": "^4",
|
|
108
106
|
"@tanstack/router-generator": "^1",
|
|
109
107
|
"@tanstack/router-plugin": "^1",
|
|
108
|
+
"@types/jsdom": "^28.0.3",
|
|
109
|
+
"@types/nodemailer": "*",
|
|
110
110
|
"@types/pngjs": "^6.0.5",
|
|
111
111
|
"@vitejs/plugin-react": "^4",
|
|
112
112
|
"electrodb": "*",
|
|
113
113
|
"esbuild": "*",
|
|
114
114
|
"expect-env": "*",
|
|
115
|
+
"jsdom": "^29.1.1",
|
|
116
|
+
"nodemailer": "^9.0.3",
|
|
115
117
|
"pngjs": "^5",
|
|
116
118
|
"react": "^19",
|
|
117
119
|
"react-dom": "^19.2.4",
|
|
@@ -33,6 +33,14 @@ 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 { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
37
|
+
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
38
|
+
import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
|
|
39
|
+
import {
|
|
40
|
+
type TriageContext,
|
|
41
|
+
useTriageContext,
|
|
42
|
+
useTriageLayer,
|
|
43
|
+
} from "@/hooks/useTriageLayer";
|
|
36
44
|
import {
|
|
37
45
|
buildConversationTarget,
|
|
38
46
|
type ConversationTarget,
|
|
@@ -51,6 +59,17 @@ interface BriefPaneContextValue {
|
|
|
51
59
|
onSelectMessage: (id: string) => void;
|
|
52
60
|
onSelectSearchResult: (result: SearchResult) => void;
|
|
53
61
|
onCloseThread: () => void;
|
|
62
|
+
/**
|
|
63
|
+
* Toolbar verbs for the open thread, keyed by the thread's own mailbox and
|
|
64
|
+
* account — the brief spans accounts, so there is no route mailbox to key by.
|
|
65
|
+
*/
|
|
66
|
+
actions: ThreadActions;
|
|
67
|
+
/** Keyboard, multi-select and next/previous, shared with the mailbox view. */
|
|
68
|
+
triage: TriageContext;
|
|
69
|
+
onDeleteMessages: (messageIds: string[]) => void;
|
|
70
|
+
onMarkMessagesRead: (messageIds: string[]) => void;
|
|
71
|
+
nextMessageId: string | undefined;
|
|
72
|
+
previousMessageId: string | undefined;
|
|
54
73
|
}
|
|
55
74
|
|
|
56
75
|
const BriefPaneCtx = createContext<BriefPaneContextValue | null>(null);
|
|
@@ -136,6 +155,28 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
136
155
|
[navigate, searchInput],
|
|
137
156
|
);
|
|
138
157
|
|
|
158
|
+
const handleDeselectIfRemoved = useCallback(
|
|
159
|
+
(removedIds: string[]) => {
|
|
160
|
+
if (!selectedMessageId) return;
|
|
161
|
+
if (!removedIds.includes(selectedMessageId)) return;
|
|
162
|
+
navigate({
|
|
163
|
+
to: "/mail",
|
|
164
|
+
search: (prev) => ({
|
|
165
|
+
...prev,
|
|
166
|
+
selectedMessageId: undefined,
|
|
167
|
+
selectedThreadId: undefined,
|
|
168
|
+
selectedMailboxId: undefined,
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
[selectedMessageId, navigate],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const actions = useThreadActions({
|
|
176
|
+
thread: selectedThread,
|
|
177
|
+
onAfterOptimisticRemove: handleDeselectIfRemoved,
|
|
178
|
+
});
|
|
179
|
+
|
|
139
180
|
const handleCloseThread = useCallback(() => {
|
|
140
181
|
navigate({
|
|
141
182
|
to: "/mail",
|
|
@@ -148,6 +189,63 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
148
189
|
});
|
|
149
190
|
}, [navigate]);
|
|
150
191
|
|
|
192
|
+
const triage = useTriageContext();
|
|
193
|
+
|
|
194
|
+
// A brief selection spans accounts and mailboxes, so the listings these
|
|
195
|
+
// patch are resolved from each message's own mailbox — the open thread's is
|
|
196
|
+
// only the fallback.
|
|
197
|
+
const briefThreads = useMemo(() => threadsData?.items ?? [], [threadsData]);
|
|
198
|
+
const { deleteMessages } = useDeleteMessages({
|
|
199
|
+
mailboxId: selectedThread?.mailboxId ?? "",
|
|
200
|
+
messages: briefThreads,
|
|
201
|
+
onAfterOptimisticRemove: handleDeselectIfRemoved,
|
|
202
|
+
});
|
|
203
|
+
const { toggleReadFor } = useToggleReadFor({
|
|
204
|
+
mailboxId: selectedThread?.mailboxId ?? "",
|
|
205
|
+
messages: briefThreads,
|
|
206
|
+
});
|
|
207
|
+
const handleMarkMessagesRead = useCallback(
|
|
208
|
+
(messageIds: string[]) => toggleReadFor(messageIds, true),
|
|
209
|
+
[toggleReadFor],
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const focusedThreadId = triage.focusedMessageId;
|
|
213
|
+
const focusedThread = useMemo(
|
|
214
|
+
() => threadsData?.items.find((t) => t.messageId === focusedThreadId),
|
|
215
|
+
[threadsData, focusedThreadId],
|
|
216
|
+
);
|
|
217
|
+
const triageTarget = focusedThread ?? selectedThread;
|
|
218
|
+
const triageActions = useThreadActions({ thread: triageTarget });
|
|
219
|
+
|
|
220
|
+
const { nextMessageId, previousMessageId } = useTriageLayer({
|
|
221
|
+
context: triage,
|
|
222
|
+
orderedIds: triage.orderedIds,
|
|
223
|
+
selectedMessageId,
|
|
224
|
+
onClose: handleCloseThread,
|
|
225
|
+
handlers: {
|
|
226
|
+
reply: () => actions.requestCompose("reply"),
|
|
227
|
+
replyAll: () => actions.requestCompose("reply_all"),
|
|
228
|
+
forward: () => actions.requestCompose("forward"),
|
|
229
|
+
delete: () => {
|
|
230
|
+
if (triage.listCommandsRef.current?.requestDelete()) return;
|
|
231
|
+
triageActions.deleteThread();
|
|
232
|
+
},
|
|
233
|
+
toggleStar: triageActions.toggleStar,
|
|
234
|
+
toggleRead: () => {
|
|
235
|
+
const ids =
|
|
236
|
+
triage.selectedIds.length > 0
|
|
237
|
+
? triage.selectedIds
|
|
238
|
+
: triageTarget
|
|
239
|
+
? [triageTarget.messageId]
|
|
240
|
+
: [];
|
|
241
|
+
if (ids.length === 0) return;
|
|
242
|
+
toggleReadFor(ids, !(triageTarget?.isRead ?? false));
|
|
243
|
+
},
|
|
244
|
+
goFlagged: () => navigate({ to: "/mail/flagged" }),
|
|
245
|
+
goSettings: () => navigate({ to: "/settings" }),
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
151
249
|
const ctx: BriefPaneContextValue = {
|
|
152
250
|
selectedMessageId,
|
|
153
251
|
selectedThread,
|
|
@@ -155,6 +253,12 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
155
253
|
onSelectMessage: handleSelectMessage,
|
|
156
254
|
onSelectSearchResult: handleSelectSearchResult,
|
|
157
255
|
onCloseThread: handleCloseThread,
|
|
256
|
+
actions,
|
|
257
|
+
triage,
|
|
258
|
+
onDeleteMessages: deleteMessages,
|
|
259
|
+
onMarkMessagesRead: handleMarkMessagesRead,
|
|
260
|
+
nextMessageId,
|
|
261
|
+
previousMessageId,
|
|
158
262
|
};
|
|
159
263
|
|
|
160
264
|
return <BriefPaneCtx.Provider value={ctx}>{children}</BriefPaneCtx.Provider>;
|
|
@@ -168,8 +272,14 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
168
272
|
* Daily brief list. Mount in the `list` slot of `AppShellSlotted`.
|
|
169
273
|
*/
|
|
170
274
|
function BriefList() {
|
|
171
|
-
const {
|
|
172
|
-
|
|
275
|
+
const {
|
|
276
|
+
selectedMessageId,
|
|
277
|
+
onSelectMessage,
|
|
278
|
+
onSelectSearchResult,
|
|
279
|
+
triage,
|
|
280
|
+
onDeleteMessages,
|
|
281
|
+
onMarkMessagesRead,
|
|
282
|
+
} = useBriefPane();
|
|
173
283
|
const { accounts } = useMailContext();
|
|
174
284
|
|
|
175
285
|
return (
|
|
@@ -178,6 +288,10 @@ function BriefList() {
|
|
|
178
288
|
selectedMessageId={selectedMessageId}
|
|
179
289
|
onSelectMessage={onSelectMessage}
|
|
180
290
|
onSelectSearchResult={onSelectSearchResult}
|
|
291
|
+
commandsRef={triage.listCommandsRef}
|
|
292
|
+
onTriageContextChange={triage.onTriageContextChange}
|
|
293
|
+
onDeleteMessages={onDeleteMessages}
|
|
294
|
+
onMarkMessagesRead={onMarkMessagesRead}
|
|
181
295
|
/>
|
|
182
296
|
);
|
|
183
297
|
}
|
|
@@ -187,7 +301,7 @@ function BriefList() {
|
|
|
187
301
|
* Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
|
|
188
302
|
*/
|
|
189
303
|
function BriefReading() {
|
|
190
|
-
const { conversation } = useBriefPane();
|
|
304
|
+
const { conversation, actions } = useBriefPane();
|
|
191
305
|
const { intelligenceOpen, onToggleIntelligence } = useMailContext();
|
|
192
306
|
// The rail's own width gate, not the shell tier: between 1024 and 1280 the
|
|
193
307
|
// reading pane is mounted but the rail is not, so "enabled" would promise an
|
|
@@ -203,6 +317,25 @@ function BriefReading() {
|
|
|
203
317
|
intelligenceOpen={canToggleIntelligence && intelligenceOpen}
|
|
204
318
|
canToggleIntelligence={canToggleIntelligence}
|
|
205
319
|
onToggleIntelligence={onToggleIntelligence}
|
|
320
|
+
onReply={hasThread ? () => actions.requestCompose("reply") : undefined}
|
|
321
|
+
onReplyAll={
|
|
322
|
+
hasThread ? () => actions.requestCompose("reply_all") : undefined
|
|
323
|
+
}
|
|
324
|
+
onForward={
|
|
325
|
+
hasThread ? () => actions.requestCompose("forward") : undefined
|
|
326
|
+
}
|
|
327
|
+
onDelete={hasThread ? actions.deleteThread : undefined}
|
|
328
|
+
onToggleStar={hasThread ? actions.toggleStar : undefined}
|
|
329
|
+
isStarred={actions.isStarred}
|
|
330
|
+
moveContext={
|
|
331
|
+
hasThread && actions.accountId && actions.mailboxId
|
|
332
|
+
? {
|
|
333
|
+
accountId: actions.accountId,
|
|
334
|
+
currentMailboxId: actions.mailboxId,
|
|
335
|
+
onMove: actions.moveThread,
|
|
336
|
+
}
|
|
337
|
+
: undefined
|
|
338
|
+
}
|
|
206
339
|
/>
|
|
207
340
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
208
341
|
{conversation ? (
|
|
@@ -212,6 +345,8 @@ function BriefReading() {
|
|
|
212
345
|
subject={conversation.subject}
|
|
213
346
|
selectedMessageId={conversation.messageId}
|
|
214
347
|
authenticity={conversation.authenticity}
|
|
348
|
+
composeRequest={actions.composeRequest}
|
|
349
|
+
onComposeClose={actions.clearComposeRequest}
|
|
215
350
|
/>
|
|
216
351
|
) : (
|
|
217
352
|
<ReadingPaneEmpty />
|
|
@@ -250,6 +385,8 @@ function BriefPhone() {
|
|
|
250
385
|
onSelectMessage,
|
|
251
386
|
onSelectSearchResult,
|
|
252
387
|
onCloseThread,
|
|
388
|
+
nextMessageId,
|
|
389
|
+
previousMessageId,
|
|
253
390
|
} = useBriefPane();
|
|
254
391
|
const { accounts, intelligenceOpen, onToggleIntelligence } = useMailContext();
|
|
255
392
|
|
|
@@ -264,6 +401,15 @@ function BriefPhone() {
|
|
|
264
401
|
authenticity={conversation.authenticity}
|
|
265
402
|
onBack={onCloseThread}
|
|
266
403
|
onOpenIntelligence={onToggleIntelligence}
|
|
404
|
+
onSwipeNext={
|
|
405
|
+
nextMessageId ? () => onSelectMessage(nextMessageId) : undefined
|
|
406
|
+
}
|
|
407
|
+
onSwipePrevious={
|
|
408
|
+
previousMessageId
|
|
409
|
+
? () => onSelectMessage(previousMessageId)
|
|
410
|
+
: undefined
|
|
411
|
+
}
|
|
412
|
+
mobileIntelligenceOpen={intelligenceOpen}
|
|
267
413
|
/>
|
|
268
414
|
<Drawer
|
|
269
415
|
isOpen={intelligenceOpen}
|
|
@@ -34,10 +34,11 @@ import {
|
|
|
34
34
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
35
35
|
import { useNavigate } from "@tanstack/react-router";
|
|
36
36
|
import { AlertCircle, RefreshCw, Sparkles } from "lucide-react";
|
|
37
|
-
import { useCallback, useMemo, useState } from "react";
|
|
37
|
+
import { type RefObject, useCallback, useMemo, useState } from "react";
|
|
38
38
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
39
39
|
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
40
40
|
import { useSemanticSearch } from "@/hooks/useSemanticSearch";
|
|
41
|
+
import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
|
|
41
42
|
import { sortAccountsByCreatedAt } from "@/lib/account-order";
|
|
42
43
|
import {
|
|
43
44
|
groupBriefSections,
|
|
@@ -51,7 +52,12 @@ import { useMailContext } from "@/lib/mail-context";
|
|
|
51
52
|
import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
|
|
52
53
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
53
54
|
import { MailListHeader } from "./MailListHeader";
|
|
55
|
+
import type { MessageListCommands } from "./MessageList";
|
|
54
56
|
import { MessageRow } from "./MessageRow";
|
|
57
|
+
import {
|
|
58
|
+
ThreadListInteraction,
|
|
59
|
+
ThreadListSelectionBar,
|
|
60
|
+
} from "./ThreadListInteraction";
|
|
55
61
|
|
|
56
62
|
/* The brief's attribute chips as predicates (mirrors the kit `briefFilterChips`
|
|
57
63
|
ids) so the phone search takeover narrows results the same way the list does. */
|
|
@@ -134,6 +140,12 @@ interface DailyBriefProps {
|
|
|
134
140
|
* so it opens even when its message isn't in the loaded brief list.
|
|
135
141
|
*/
|
|
136
142
|
onSelectSearchResult?: (result: SearchResult) => void;
|
|
143
|
+
/** Where the list publishes the commands the keyboard layer drives. */
|
|
144
|
+
commandsRef?: RefObject<MessageListCommands | null>;
|
|
145
|
+
/** Cursor / selection / display order, reported up to the triage layer. */
|
|
146
|
+
onTriageContextChange?: (context: TriageContextUpdate) => void;
|
|
147
|
+
onDeleteMessages?: (messageIds: string[]) => void;
|
|
148
|
+
onMarkMessagesRead?: (messageIds: string[]) => void;
|
|
137
149
|
}
|
|
138
150
|
|
|
139
151
|
export function DailyBrief({
|
|
@@ -141,6 +153,10 @@ export function DailyBrief({
|
|
|
141
153
|
selectedMessageId,
|
|
142
154
|
onSelectMessage,
|
|
143
155
|
onSelectSearchResult,
|
|
156
|
+
commandsRef,
|
|
157
|
+
onTriageContextChange,
|
|
158
|
+
onDeleteMessages,
|
|
159
|
+
onMarkMessagesRead,
|
|
144
160
|
}: DailyBriefProps) {
|
|
145
161
|
const { searchQuery, resultFolderIndex } = useMailContext();
|
|
146
162
|
const tokenContext = useSearchTokenContext();
|
|
@@ -405,17 +421,33 @@ export function DailyBrief({
|
|
|
405
421
|
<p className="text-xs text-fg-subtle">Nothing needs attention.</p>
|
|
406
422
|
</div>
|
|
407
423
|
) : (
|
|
408
|
-
<
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
424
|
+
<ThreadListInteraction
|
|
425
|
+
selectedMessageId={selectedMessageId}
|
|
426
|
+
onOpen={(id) => onSelectMessage?.(id)}
|
|
427
|
+
onDeleteMessages={onDeleteMessages}
|
|
428
|
+
commandsRef={commandsRef}
|
|
429
|
+
onTriageContextChange={onTriageContextChange}
|
|
430
|
+
>
|
|
431
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
432
|
+
{onDeleteMessages ? (
|
|
433
|
+
<ThreadListSelectionBar onMarkAsRead={onMarkMessagesRead} />
|
|
434
|
+
) : null}
|
|
435
|
+
<div className="min-h-0 flex-1">
|
|
436
|
+
<BriefSections
|
|
437
|
+
sections={sections}
|
|
438
|
+
Row={MessageRow}
|
|
439
|
+
manageFocus={false}
|
|
440
|
+
briefCategory={selectedCategory}
|
|
441
|
+
onSelectBriefCategory={setSelectedCategory}
|
|
442
|
+
sources={accountSources}
|
|
443
|
+
sourcesNote={mutedCount > 0 ? `+${mutedCount} muted` : undefined}
|
|
444
|
+
onSelectSource={setSelectedAccountId}
|
|
445
|
+
selectedThreadId={selectedMessageId}
|
|
446
|
+
onSelectThread={onSelectMessage}
|
|
447
|
+
/>
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
</ThreadListInteraction>
|
|
419
451
|
);
|
|
420
452
|
|
|
421
453
|
return (
|
|
@@ -15,16 +15,15 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import {
|
|
17
17
|
flaggedFilterConfig,
|
|
18
|
-
LIST_ROW_SELECTOR,
|
|
19
18
|
MessageListPane,
|
|
20
19
|
type ThreadRowData,
|
|
21
|
-
useRovingFocus,
|
|
22
20
|
} from "@remit/ui";
|
|
23
|
-
import { useCallback, useMemo,
|
|
21
|
+
import { type RefObject, useCallback, useMemo, useState } from "react";
|
|
24
22
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
25
23
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
26
24
|
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
27
25
|
import { useStarredThreads } from "@/hooks/useStarredThreads";
|
|
26
|
+
import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
|
|
28
27
|
import {
|
|
29
28
|
matchesBriefSearch,
|
|
30
29
|
matchesSearchTokens,
|
|
@@ -36,7 +35,12 @@ import { rowToSearchResult } from "@/lib/search-result";
|
|
|
36
35
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
37
36
|
import { dedupeByThread } from "@/lib/starred-rows";
|
|
38
37
|
import { MailViewChrome } from "./MailViewChrome";
|
|
38
|
+
import type { MessageListCommands } from "./MessageList";
|
|
39
39
|
import { MessageRow } from "./MessageRow";
|
|
40
|
+
import {
|
|
41
|
+
ThreadListInteraction,
|
|
42
|
+
ThreadListSelectionBar,
|
|
43
|
+
} from "./ThreadListInteraction";
|
|
40
44
|
|
|
41
45
|
const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
|
|
42
46
|
unread: (t) => !t.isRead,
|
|
@@ -46,19 +50,26 @@ const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
|
|
|
46
50
|
interface FlaggedListProps {
|
|
47
51
|
selectedMessageId?: string;
|
|
48
52
|
onSelectMessage?: (id: string) => void;
|
|
53
|
+
/** Where the list publishes the commands the keyboard layer drives. */
|
|
54
|
+
commandsRef?: RefObject<MessageListCommands | null>;
|
|
55
|
+
/** Cursor / selection / display order, reported up to the triage layer. */
|
|
56
|
+
onTriageContextChange?: (context: TriageContextUpdate) => void;
|
|
57
|
+
onDeleteMessages?: (messageIds: string[]) => void;
|
|
58
|
+
onMarkMessagesRead?: (messageIds: string[]) => void;
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
export function FlaggedList({
|
|
52
62
|
selectedMessageId,
|
|
53
63
|
onSelectMessage,
|
|
64
|
+
commandsRef,
|
|
65
|
+
onTriageContextChange,
|
|
66
|
+
onDeleteMessages,
|
|
67
|
+
onMarkMessagesRead,
|
|
54
68
|
}: FlaggedListProps) {
|
|
55
69
|
const { searchQuery, resultFolderIndex } = useMailContext();
|
|
56
70
|
const tokenContext = useSearchTokenContext();
|
|
57
71
|
const isDesktop = useIsDesktop();
|
|
58
72
|
|
|
59
|
-
const listRef = useRef<HTMLDivElement>(null);
|
|
60
|
-
useRovingFocus({ containerRef: listRef, itemSelector: LIST_ROW_SELECTOR });
|
|
61
|
-
|
|
62
73
|
const [selectedCategory, setSelectedCategory] = useState("all");
|
|
63
74
|
const [activeFilters, setActiveFilters] = useState<ReadonlySet<string>>(
|
|
64
75
|
new Set(),
|
|
@@ -135,28 +146,41 @@ export function FlaggedList({
|
|
|
135
146
|
}, []);
|
|
136
147
|
|
|
137
148
|
const listBody = (
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
149
|
+
<ThreadListInteraction
|
|
150
|
+
selectedMessageId={selectedMessageId}
|
|
151
|
+
onOpen={(id) => onSelectMessage?.(id)}
|
|
152
|
+
onDeleteMessages={onDeleteMessages}
|
|
153
|
+
commandsRef={commandsRef}
|
|
154
|
+
onTriageContextChange={onTriageContextChange}
|
|
155
|
+
>
|
|
156
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
157
|
+
{onDeleteMessages ? (
|
|
158
|
+
<ThreadListSelectionBar onMarkAsRead={onMarkMessagesRead} />
|
|
159
|
+
) : null}
|
|
160
|
+
<div className="flex-1 overflow-y-auto">
|
|
161
|
+
<div className="divide-y divide-line">
|
|
162
|
+
{rows.map((thread) => (
|
|
163
|
+
<MessageRow
|
|
164
|
+
key={thread.id}
|
|
165
|
+
thread={thread}
|
|
166
|
+
active={thread.id === selectedMessageId}
|
|
167
|
+
onClick={() => onSelectMessage?.(thread.id)}
|
|
168
|
+
/>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
{hasNextPage ? (
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
className="w-full py-3 text-sm text-muted hover:text-fg disabled:opacity-50"
|
|
175
|
+
onClick={() => fetchNextPage()}
|
|
176
|
+
disabled={isFetchingNextPage}
|
|
177
|
+
>
|
|
178
|
+
{isFetchingNextPage ? "Loading…" : "Load more"}
|
|
179
|
+
</button>
|
|
180
|
+
) : null}
|
|
181
|
+
</div>
|
|
148
182
|
</div>
|
|
149
|
-
|
|
150
|
-
<button
|
|
151
|
-
type="button"
|
|
152
|
-
className="w-full py-3 text-sm text-muted hover:text-fg disabled:opacity-50"
|
|
153
|
-
onClick={() => fetchNextPage()}
|
|
154
|
-
disabled={isFetchingNextPage}
|
|
155
|
-
>
|
|
156
|
-
{isFetchingNextPage ? "Loading…" : "Load more"}
|
|
157
|
-
</button>
|
|
158
|
-
) : null}
|
|
159
|
-
</div>
|
|
183
|
+
</ThreadListInteraction>
|
|
160
184
|
);
|
|
161
185
|
|
|
162
186
|
return (
|