@remit/web-client 0.0.49 → 0.0.51
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 +6 -4
- package/src/components/mail/BriefPane.tsx +98 -2
- package/src/components/mail/DailyBrief.tsx +44 -12
- package/src/components/mail/FlaggedList.tsx +51 -27
- package/src/components/mail/FlaggedPane.tsx +101 -2
- package/src/components/mail/MailboxPane.tsx +28 -77
- 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/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.51",
|
|
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": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"build:dist": "npm run generate:routes && node --import tsx harness/build.mjs",
|
|
30
30
|
"preview": "vite preview",
|
|
31
31
|
"test:typecheck": "npm run generate:routes && tsgo --noEmit",
|
|
32
|
-
"test:run": "node --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=
|
|
32
|
+
"test:run": "node --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=70 --test 'src/**/*.test.ts'",
|
|
33
33
|
"test": "npm run test:typecheck && npm run test:run"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
@@ -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,7 +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";
|
|
36
38
|
import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
|
|
39
|
+
import {
|
|
40
|
+
type TriageContext,
|
|
41
|
+
useTriageContext,
|
|
42
|
+
useTriageLayer,
|
|
43
|
+
} from "@/hooks/useTriageLayer";
|
|
37
44
|
import {
|
|
38
45
|
buildConversationTarget,
|
|
39
46
|
type ConversationTarget,
|
|
@@ -57,6 +64,12 @@ interface BriefPaneContextValue {
|
|
|
57
64
|
* account — the brief spans accounts, so there is no route mailbox to key by.
|
|
58
65
|
*/
|
|
59
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;
|
|
60
73
|
}
|
|
61
74
|
|
|
62
75
|
const BriefPaneCtx = createContext<BriefPaneContextValue | null>(null);
|
|
@@ -176,6 +189,63 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
176
189
|
});
|
|
177
190
|
}, [navigate]);
|
|
178
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
|
+
|
|
179
249
|
const ctx: BriefPaneContextValue = {
|
|
180
250
|
selectedMessageId,
|
|
181
251
|
selectedThread,
|
|
@@ -184,6 +254,11 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
184
254
|
onSelectSearchResult: handleSelectSearchResult,
|
|
185
255
|
onCloseThread: handleCloseThread,
|
|
186
256
|
actions,
|
|
257
|
+
triage,
|
|
258
|
+
onDeleteMessages: deleteMessages,
|
|
259
|
+
onMarkMessagesRead: handleMarkMessagesRead,
|
|
260
|
+
nextMessageId,
|
|
261
|
+
previousMessageId,
|
|
187
262
|
};
|
|
188
263
|
|
|
189
264
|
return <BriefPaneCtx.Provider value={ctx}>{children}</BriefPaneCtx.Provider>;
|
|
@@ -197,8 +272,14 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
197
272
|
* Daily brief list. Mount in the `list` slot of `AppShellSlotted`.
|
|
198
273
|
*/
|
|
199
274
|
function BriefList() {
|
|
200
|
-
const {
|
|
201
|
-
|
|
275
|
+
const {
|
|
276
|
+
selectedMessageId,
|
|
277
|
+
onSelectMessage,
|
|
278
|
+
onSelectSearchResult,
|
|
279
|
+
triage,
|
|
280
|
+
onDeleteMessages,
|
|
281
|
+
onMarkMessagesRead,
|
|
282
|
+
} = useBriefPane();
|
|
202
283
|
const { accounts } = useMailContext();
|
|
203
284
|
|
|
204
285
|
return (
|
|
@@ -207,6 +288,10 @@ function BriefList() {
|
|
|
207
288
|
selectedMessageId={selectedMessageId}
|
|
208
289
|
onSelectMessage={onSelectMessage}
|
|
209
290
|
onSelectSearchResult={onSelectSearchResult}
|
|
291
|
+
commandsRef={triage.listCommandsRef}
|
|
292
|
+
onTriageContextChange={triage.onTriageContextChange}
|
|
293
|
+
onDeleteMessages={onDeleteMessages}
|
|
294
|
+
onMarkMessagesRead={onMarkMessagesRead}
|
|
210
295
|
/>
|
|
211
296
|
);
|
|
212
297
|
}
|
|
@@ -300,6 +385,8 @@ function BriefPhone() {
|
|
|
300
385
|
onSelectMessage,
|
|
301
386
|
onSelectSearchResult,
|
|
302
387
|
onCloseThread,
|
|
388
|
+
nextMessageId,
|
|
389
|
+
previousMessageId,
|
|
303
390
|
} = useBriefPane();
|
|
304
391
|
const { accounts, intelligenceOpen, onToggleIntelligence } = useMailContext();
|
|
305
392
|
|
|
@@ -314,6 +401,15 @@ function BriefPhone() {
|
|
|
314
401
|
authenticity={conversation.authenticity}
|
|
315
402
|
onBack={onCloseThread}
|
|
316
403
|
onOpenIntelligence={onToggleIntelligence}
|
|
404
|
+
onSwipeNext={
|
|
405
|
+
nextMessageId ? () => onSelectMessage(nextMessageId) : undefined
|
|
406
|
+
}
|
|
407
|
+
onSwipePrevious={
|
|
408
|
+
previousMessageId
|
|
409
|
+
? () => onSelectMessage(previousMessageId)
|
|
410
|
+
: undefined
|
|
411
|
+
}
|
|
412
|
+
mobileIntelligenceOpen={intelligenceOpen}
|
|
317
413
|
/>
|
|
318
414
|
<Drawer
|
|
319
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 (
|
|
@@ -35,8 +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";
|
|
39
41
|
import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
|
|
42
|
+
import {
|
|
43
|
+
type TriageContext,
|
|
44
|
+
useTriageContext,
|
|
45
|
+
useTriageLayer,
|
|
46
|
+
} from "@/hooks/useTriageLayer";
|
|
40
47
|
import { useMailContext } from "@/lib/mail-context";
|
|
41
48
|
|
|
42
49
|
/* ------------------------------------------------------------------ */
|
|
@@ -53,6 +60,12 @@ interface FlaggedPaneContextValue {
|
|
|
53
60
|
* account — Flagged spans accounts, so there is no route mailbox to key by.
|
|
54
61
|
*/
|
|
55
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;
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
const FlaggedPaneCtx = createContext<FlaggedPaneContextValue | null>(null);
|
|
@@ -116,12 +129,73 @@ function FlaggedPaneProvider({
|
|
|
116
129
|
onAfterOptimisticRemove: handleDeselectIfRemoved,
|
|
117
130
|
});
|
|
118
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
|
+
|
|
119
188
|
const ctx: FlaggedPaneContextValue = {
|
|
120
189
|
selectedMessageId,
|
|
121
190
|
selectedThread,
|
|
122
191
|
onSelectMessage: handleSelectMessage,
|
|
123
192
|
onCloseThread: handleCloseThread,
|
|
124
193
|
actions,
|
|
194
|
+
triage,
|
|
195
|
+
onDeleteMessages: deleteMessages,
|
|
196
|
+
onMarkMessagesRead: handleMarkMessagesRead,
|
|
197
|
+
nextMessageId,
|
|
198
|
+
previousMessageId,
|
|
125
199
|
};
|
|
126
200
|
|
|
127
201
|
return (
|
|
@@ -135,11 +209,21 @@ function FlaggedPaneProvider({
|
|
|
135
209
|
|
|
136
210
|
/** Flat starred list. Mount in the `list` slot of `AppShellSlotted`. */
|
|
137
211
|
function FlaggedListSlot() {
|
|
138
|
-
const {
|
|
212
|
+
const {
|
|
213
|
+
selectedMessageId,
|
|
214
|
+
onSelectMessage,
|
|
215
|
+
triage,
|
|
216
|
+
onDeleteMessages,
|
|
217
|
+
onMarkMessagesRead,
|
|
218
|
+
} = useFlaggedPane();
|
|
139
219
|
return (
|
|
140
220
|
<FlaggedList
|
|
141
221
|
selectedMessageId={selectedMessageId}
|
|
142
222
|
onSelectMessage={onSelectMessage}
|
|
223
|
+
commandsRef={triage.listCommandsRef}
|
|
224
|
+
onTriageContextChange={triage.onTriageContextChange}
|
|
225
|
+
onDeleteMessages={onDeleteMessages}
|
|
226
|
+
onMarkMessagesRead={onMarkMessagesRead}
|
|
143
227
|
/>
|
|
144
228
|
);
|
|
145
229
|
}
|
|
@@ -223,7 +307,13 @@ function FlaggedIntelligence() {
|
|
|
223
307
|
|
|
224
308
|
/** Phone view: ConversationView when a thread is open, else the flat list. */
|
|
225
309
|
function FlaggedPhone() {
|
|
226
|
-
const {
|
|
310
|
+
const {
|
|
311
|
+
selectedThread,
|
|
312
|
+
onCloseThread,
|
|
313
|
+
onSelectMessage,
|
|
314
|
+
nextMessageId,
|
|
315
|
+
previousMessageId,
|
|
316
|
+
} = useFlaggedPane();
|
|
227
317
|
const { intelligenceOpen, onToggleIntelligence } = useMailContext();
|
|
228
318
|
|
|
229
319
|
if (selectedThread) {
|
|
@@ -236,6 +326,15 @@ function FlaggedPhone() {
|
|
|
236
326
|
authenticity={selectedThread.authenticity}
|
|
237
327
|
onBack={onCloseThread}
|
|
238
328
|
onOpenIntelligence={onToggleIntelligence}
|
|
329
|
+
onSwipeNext={
|
|
330
|
+
nextMessageId ? () => onSelectMessage(nextMessageId) : undefined
|
|
331
|
+
}
|
|
332
|
+
onSwipePrevious={
|
|
333
|
+
previousMessageId
|
|
334
|
+
? () => onSelectMessage(previousMessageId)
|
|
335
|
+
: undefined
|
|
336
|
+
}
|
|
337
|
+
mobileIntelligenceOpen={intelligenceOpen}
|
|
239
338
|
/>
|
|
240
339
|
<Drawer
|
|
241
340
|
isOpen={intelligenceOpen}
|