@remit/web-client 0.0.43 → 0.0.45
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 +1 -1
- package/src/components/mail/MailboxPane.tsx +1 -1
- package/src/components/mail/MessageActionMenu.tsx +24 -63
- package/src/components/mail/MessageList.selection.test.ts +41 -0
- package/src/components/mail/MessageList.tsx +122 -63
- package/src/components/mail/MobileMessageBar.tsx +8 -10
- package/src/hooks/useEscalatedActions.test.ts +36 -0
- package/src/hooks/{useEscalatedDelete.ts → useEscalatedActions.ts} +135 -74
- package/src/hooks/useMoveMessages.ts +8 -4
- package/src/hooks/useSelection.test.ts +1 -1
- package/src/lib/bulk-action-copy.test.ts +108 -0
- package/src/lib/bulk-action-copy.ts +85 -0
- package/src/lib/{bulk-delete.test.ts → bulk-actions.test.ts} +43 -43
- package/src/lib/{bulk-delete.ts → bulk-actions.ts} +53 -52
- package/src/lib/thread-list-cache.test.ts +104 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.45",
|
|
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": {
|
|
@@ -81,7 +81,7 @@ import {
|
|
|
81
81
|
dropDeletedThreads,
|
|
82
82
|
useDeleteMessages,
|
|
83
83
|
} from "@/hooks/useDeleteMessages";
|
|
84
|
-
import type { EscalationSearchQuery } from "@/hooks/
|
|
84
|
+
import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
|
|
85
85
|
import { useIntelligenceData } from "@/hooks/useIntelligenceData";
|
|
86
86
|
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
|
87
87
|
import { useLayoutTier } from "@/hooks/useLayoutTier";
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
messageBulkOperationsUpdateFlagsMutation,
|
|
3
3
|
threadDetailOperationsListThreadMessagesQueryKey,
|
|
4
|
-
threadOperationsListThreadsQueryKey,
|
|
5
|
-
threadOperationsSearchThreadsQueryKey,
|
|
6
4
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
7
5
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
8
6
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
@@ -25,9 +23,18 @@ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
|
25
23
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
26
24
|
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
27
25
|
import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
26
|
+
import { setReadOnItems } from "@/hooks/useMarkAsRead";
|
|
28
27
|
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
29
28
|
import { useToggleTrusted } from "@/hooks/useToggleTrusted";
|
|
30
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
cancelThreadListQueries,
|
|
31
|
+
invalidateThreadListQueries,
|
|
32
|
+
patchThreadListQueries,
|
|
33
|
+
restoreThreadListQueries,
|
|
34
|
+
snapshotThreadListQueries,
|
|
35
|
+
type ThreadListSnapshotEntry,
|
|
36
|
+
threadListCacheKeys,
|
|
37
|
+
} from "@/lib/thread-list-cache";
|
|
31
38
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
32
39
|
|
|
33
40
|
interface ThreadMessagesData {
|
|
@@ -35,13 +42,6 @@ interface ThreadMessagesData {
|
|
|
35
42
|
[key: string]: unknown;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
|
-
/**
|
|
39
|
-
* Either shape a thread list/search query caches — the infinite mailbox list
|
|
40
|
-
* or a single-shot page. `setQueriesData` matches by key prefix, so the
|
|
41
|
-
* optimistic updater sees both.
|
|
42
|
-
*/
|
|
43
|
-
type ThreadsListData = ThreadListCache;
|
|
44
|
-
|
|
45
45
|
interface SnapshotEntry<T> {
|
|
46
46
|
queryKey: readonly unknown[];
|
|
47
47
|
data: T;
|
|
@@ -49,10 +49,9 @@ interface SnapshotEntry<T> {
|
|
|
49
49
|
|
|
50
50
|
interface MarkUnreadContext {
|
|
51
51
|
threadMessagesPrefix: readonly unknown[];
|
|
52
|
-
|
|
53
|
-
threadsSearchPrefix: readonly unknown[];
|
|
52
|
+
listPrefixes: ReadonlyArray<readonly unknown[]>;
|
|
54
53
|
previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
|
|
55
|
-
previousThreadsList:
|
|
54
|
+
previousThreadsList: ThreadListSnapshotEntry[];
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
interface MessageActionMenuProps {
|
|
@@ -118,17 +117,11 @@ export const MessageActionMenu = ({
|
|
|
118
117
|
threadDetailOperationsListThreadMessagesQueryKey({
|
|
119
118
|
path: { threadId },
|
|
120
119
|
});
|
|
121
|
-
const
|
|
122
|
-
path: { mailboxId },
|
|
123
|
-
});
|
|
124
|
-
const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
|
|
125
|
-
path: { mailboxId },
|
|
126
|
-
});
|
|
120
|
+
const listPrefixes = threadListCacheKeys([mailboxId]);
|
|
127
121
|
|
|
128
122
|
await Promise.all([
|
|
129
123
|
queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
|
|
130
|
-
queryClient
|
|
131
|
-
queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
|
|
124
|
+
cancelThreadListQueries(queryClient, listPrefixes),
|
|
132
125
|
]);
|
|
133
126
|
|
|
134
127
|
const previousThreadMessages = queryClient
|
|
@@ -139,18 +132,10 @@ export const MessageActionMenu = ({
|
|
|
139
132
|
)
|
|
140
133
|
.map(([queryKey, data]) => ({ queryKey, data }));
|
|
141
134
|
|
|
142
|
-
const previousThreadsList =
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
queryKey: threadsSearchPrefix,
|
|
147
|
-
}),
|
|
148
|
-
)
|
|
149
|
-
.filter(
|
|
150
|
-
(entry): entry is [readonly unknown[], ThreadsListData] =>
|
|
151
|
-
entry[1] !== undefined,
|
|
152
|
-
)
|
|
153
|
-
.map(([queryKey, data]) => ({ queryKey, data }));
|
|
135
|
+
const previousThreadsList = snapshotThreadListQueries(
|
|
136
|
+
queryClient,
|
|
137
|
+
listPrefixes,
|
|
138
|
+
);
|
|
154
139
|
|
|
155
140
|
queryClient.setQueriesData<ThreadMessagesData>(
|
|
156
141
|
{ queryKey: threadMessagesPrefix },
|
|
@@ -158,37 +143,18 @@ export const MessageActionMenu = ({
|
|
|
158
143
|
if (!old) return old;
|
|
159
144
|
return {
|
|
160
145
|
...old,
|
|
161
|
-
items: old.items
|
|
162
|
-
targetIds.has(item.messageId)
|
|
163
|
-
? { ...item, isRead: isReadNext }
|
|
164
|
-
: item,
|
|
165
|
-
),
|
|
146
|
+
items: setReadOnItems(old.items, targetIds, isReadNext),
|
|
166
147
|
};
|
|
167
148
|
},
|
|
168
149
|
);
|
|
169
150
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
items.map((item) =>
|
|
173
|
-
targetIds.has(item.messageId)
|
|
174
|
-
? { ...item, isRead: isReadNext }
|
|
175
|
-
: item,
|
|
176
|
-
),
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
queryClient.setQueriesData(
|
|
180
|
-
{ queryKey: threadsListPrefix },
|
|
181
|
-
patchListData,
|
|
182
|
-
);
|
|
183
|
-
queryClient.setQueriesData(
|
|
184
|
-
{ queryKey: threadsSearchPrefix },
|
|
185
|
-
patchListData,
|
|
151
|
+
patchThreadListQueries(queryClient, listPrefixes, (items) =>
|
|
152
|
+
setReadOnItems(items, targetIds, isReadNext),
|
|
186
153
|
);
|
|
187
154
|
|
|
188
155
|
return {
|
|
189
156
|
threadMessagesPrefix,
|
|
190
|
-
|
|
191
|
-
threadsSearchPrefix,
|
|
157
|
+
listPrefixes,
|
|
192
158
|
previousThreadMessages,
|
|
193
159
|
previousThreadsList,
|
|
194
160
|
};
|
|
@@ -198,9 +164,7 @@ export const MessageActionMenu = ({
|
|
|
198
164
|
for (const entry of context.previousThreadMessages) {
|
|
199
165
|
queryClient.setQueryData(entry.queryKey, entry.data);
|
|
200
166
|
}
|
|
201
|
-
|
|
202
|
-
queryClient.setQueryData(entry.queryKey, entry.data);
|
|
203
|
-
}
|
|
167
|
+
restoreThreadListQueries(queryClient, context.previousThreadsList);
|
|
204
168
|
}
|
|
205
169
|
const isReadNext = variables.body.isRead ?? true;
|
|
206
170
|
pushError({
|
|
@@ -214,10 +178,7 @@ export const MessageActionMenu = ({
|
|
|
214
178
|
queryClient.invalidateQueries({
|
|
215
179
|
queryKey: context.threadMessagesPrefix,
|
|
216
180
|
});
|
|
217
|
-
queryClient
|
|
218
|
-
queryClient.invalidateQueries({
|
|
219
|
-
queryKey: context.threadsSearchPrefix,
|
|
220
|
-
});
|
|
181
|
+
invalidateThreadListQueries(queryClient, context.listPrefixes);
|
|
221
182
|
},
|
|
222
183
|
});
|
|
223
184
|
|
|
@@ -50,3 +50,44 @@ describe("MessageList selection mode", () => {
|
|
|
50
50
|
assert.match(source, /disabled: !hasSelection/);
|
|
51
51
|
});
|
|
52
52
|
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* An escalated selection is a predicate, so it has no id list to hand the
|
|
56
|
+
* optimistic move/mark-read mutations — before #114 the bar simply dropped
|
|
57
|
+
* both, leaving "select all 1,284 matching npm" able to delete and nothing
|
|
58
|
+
* else. All three actions now page the predicate through the same run.
|
|
59
|
+
*/
|
|
60
|
+
describe("MessageList escalated actions", () => {
|
|
61
|
+
it("routes move and mark-read through the predicate run when escalated", () => {
|
|
62
|
+
assert.match(
|
|
63
|
+
source,
|
|
64
|
+
/escalation\.phase\.kind === "escalated"\)\s*\{\s*void runEscalatedAction\(MARK_READ_ACTION\);/,
|
|
65
|
+
);
|
|
66
|
+
assert.match(
|
|
67
|
+
source,
|
|
68
|
+
/escalation\.phase\.kind === "escalated"\)\s*\{\s*void runEscalatedAction\(\{ kind: "move", destinationMailboxId \}\);/,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("keeps mark-read and the move slot on an escalated selection", () => {
|
|
73
|
+
assert.match(
|
|
74
|
+
source,
|
|
75
|
+
/onMarkRead=\{\s*escalation\.phase\.kind === "counting" \? undefined : handleMarkAsRead/,
|
|
76
|
+
);
|
|
77
|
+
assert.match(
|
|
78
|
+
source,
|
|
79
|
+
/moveSlot=\{\s*escalation\.phase\.kind !== "counting"/,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("words progress and completion per action instead of per delete", () => {
|
|
84
|
+
assert.match(
|
|
85
|
+
source,
|
|
86
|
+
/bulkActionProgressLabel\(\s*escalation\.runningAction\.kind,/,
|
|
87
|
+
);
|
|
88
|
+
assert.match(
|
|
89
|
+
source,
|
|
90
|
+
/bulkActionCompletionText\(action\.kind, outcome\.done\)/,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -13,9 +13,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
13
13
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
14
14
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
15
15
|
import {
|
|
16
|
+
type EscalatedAction,
|
|
16
17
|
type EscalationSearchQuery,
|
|
17
|
-
|
|
18
|
-
} from "@/hooks/
|
|
18
|
+
useEscalatedActions,
|
|
19
|
+
} from "@/hooks/useEscalatedActions";
|
|
19
20
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
20
21
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
21
22
|
import {
|
|
@@ -25,9 +26,15 @@ import {
|
|
|
25
26
|
} from "@/hooks/useSelection";
|
|
26
27
|
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
27
28
|
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
bulkActionCompletionText,
|
|
30
|
+
bulkActionPartialText,
|
|
31
|
+
bulkActionProgressLabel,
|
|
32
|
+
bulkActionProgressTone,
|
|
33
|
+
} from "@/lib/bulk-action-copy";
|
|
34
|
+
import {
|
|
35
|
+
BULK_ACTION_CHUNK_SIZE,
|
|
36
|
+
resolveSelectionAfterRun,
|
|
37
|
+
} from "@/lib/bulk-actions";
|
|
31
38
|
import {
|
|
32
39
|
escalatedStatusLabel,
|
|
33
40
|
escalationActionLabel,
|
|
@@ -88,7 +95,6 @@ interface MessageListProps {
|
|
|
88
95
|
*/
|
|
89
96
|
searchPredicate?: EscalationSearchQuery;
|
|
90
97
|
onDeleteMessages?: (messageIds: string[]) => void;
|
|
91
|
-
onMarkAsRead?: (messageIds: string[]) => void;
|
|
92
98
|
onMoveMessages?: (messageIds: string[], destinationMailboxId: string) => void;
|
|
93
99
|
isDeleting?: boolean;
|
|
94
100
|
isMoving?: boolean;
|
|
@@ -151,6 +157,11 @@ const COMPACT_ITEM_HEIGHT = 32;
|
|
|
151
157
|
const OVERSCAN_COUNT = 5;
|
|
152
158
|
const DENSITY_STORAGE_KEY = "remit:list-density";
|
|
153
159
|
|
|
160
|
+
// Stable action values for the two bulk actions that carry no parameters, so
|
|
161
|
+
// the callbacks running them keep stable dependencies.
|
|
162
|
+
const DELETE_ACTION: EscalatedAction = { kind: "delete" };
|
|
163
|
+
const MARK_READ_ACTION: EscalatedAction = { kind: "markRead" };
|
|
164
|
+
|
|
154
165
|
const readStoredDensity = (): Density => {
|
|
155
166
|
try {
|
|
156
167
|
const stored = localStorage.getItem(DENSITY_STORAGE_KEY);
|
|
@@ -187,7 +198,6 @@ export const MessageList = ({
|
|
|
187
198
|
searchQuery,
|
|
188
199
|
searchPredicate,
|
|
189
200
|
onDeleteMessages,
|
|
190
|
-
onMarkAsRead,
|
|
191
201
|
onMoveMessages,
|
|
192
202
|
isDeleting = false,
|
|
193
203
|
isMoving = false,
|
|
@@ -275,7 +285,7 @@ export const MessageList = ({
|
|
|
275
285
|
// block.
|
|
276
286
|
const escalationEnabled = !isDesktop && isSearching && !!searchPredicate;
|
|
277
287
|
const predicateKey = `${mailboxId}|${JSON.stringify(searchPredicate ?? {})}`;
|
|
278
|
-
const escalation =
|
|
288
|
+
const escalation = useEscalatedActions({
|
|
279
289
|
mailboxId,
|
|
280
290
|
accountId,
|
|
281
291
|
enabled: escalationEnabled,
|
|
@@ -295,14 +305,18 @@ export const MessageList = ({
|
|
|
295
305
|
}, [clearSelection, clearEscalation, escalationPhaseKind]);
|
|
296
306
|
|
|
297
307
|
// Non-null exactly once a bounded/escalated run just ended with some ids
|
|
298
|
-
//
|
|
299
|
-
// the partial-failure notice can say "N moved to Trash" alongside
|
|
300
|
-
//
|
|
301
|
-
// how many are left; this only
|
|
302
|
-
|
|
308
|
+
// the action never reached: which action ran and the count that DID succeed,
|
|
309
|
+
// so the partial-failure notice can say "N moved to Trash" alongside
|
|
310
|
+
// "Retry" and Retry can resend the same action. `selectedIds` (materialized
|
|
311
|
+
// to the failed ids) is the source of truth for how many are left; this only
|
|
312
|
+
// supplies the other half of that sentence.
|
|
313
|
+
const [lastRun, setLastRun] = useState<{
|
|
314
|
+
action: EscalatedAction;
|
|
315
|
+
succeeded: number;
|
|
316
|
+
} | null>(null);
|
|
303
317
|
// Transient, manually-dismissed success banner shown in place of the
|
|
304
|
-
// selection bar once a chunked/escalated
|
|
305
|
-
// `
|
|
318
|
+
// selection bar once a chunked/escalated run finishes cleanly — see
|
|
319
|
+
// `processRunOutcome`. Honest about IMAP's async catch-up rather than
|
|
306
320
|
// claiming a finality the bulk endpoint's response doesn't have.
|
|
307
321
|
const [completionBanner, setCompletionBanner] = useState<string | null>(null);
|
|
308
322
|
|
|
@@ -342,24 +356,27 @@ export const MessageList = ({
|
|
|
342
356
|
// completion banner instead of silently claiming a finality the bulk
|
|
343
357
|
// endpoint's response doesn't have (IMAP applies the move asynchronously).
|
|
344
358
|
// Anything left over becomes the new bounded selection — precisely what
|
|
345
|
-
// Retry resends, per `
|
|
346
|
-
const
|
|
347
|
-
(
|
|
348
|
-
|
|
359
|
+
// Retry resends, per `resolveSelectionAfterRun`.
|
|
360
|
+
const processRunOutcome = useCallback(
|
|
361
|
+
(
|
|
362
|
+
action: EscalatedAction,
|
|
363
|
+
outcome: Parameters<typeof resolveSelectionAfterRun>[0],
|
|
364
|
+
) => {
|
|
365
|
+
const { exit, retryIds } = resolveSelectionAfterRun(outcome);
|
|
349
366
|
if (exit) {
|
|
350
367
|
setCompletionBanner(
|
|
351
|
-
|
|
368
|
+
bulkActionCompletionText(action.kind, outcome.done),
|
|
352
369
|
);
|
|
353
|
-
|
|
370
|
+
setLastRun(null);
|
|
354
371
|
exitSelection();
|
|
355
372
|
return;
|
|
356
373
|
}
|
|
357
374
|
if (retryIds.length > 0) {
|
|
358
|
-
|
|
375
|
+
setLastRun({ action, succeeded: outcome.done });
|
|
359
376
|
selectAll(retryIds);
|
|
360
377
|
return;
|
|
361
378
|
}
|
|
362
|
-
|
|
379
|
+
setLastRun(null);
|
|
363
380
|
},
|
|
364
381
|
[exitSelection, selectAll],
|
|
365
382
|
);
|
|
@@ -568,14 +585,25 @@ export const MessageList = ({
|
|
|
568
585
|
// user's attention is on the progress bar, not the roving cursor, and
|
|
569
586
|
// rows update via cache invalidation once the run ends rather than an
|
|
570
587
|
// optimistic per-row removal.
|
|
588
|
+
// Runs one bulk action and hands its outcome to the single choke point that
|
|
589
|
+
// decides what selection looks like afterwards. `ids` omitted means the
|
|
590
|
+
// escalated predicate (#114) — delete, move and mark-read all take this
|
|
591
|
+
// path, so an escalated selection is never delete-only.
|
|
592
|
+
const runEscalatedAction = useCallback(
|
|
593
|
+
async (action: EscalatedAction, ids?: string[]) => {
|
|
594
|
+
const outcome = await escalation.runAction(action, ids);
|
|
595
|
+
processRunOutcome(action, outcome);
|
|
596
|
+
},
|
|
597
|
+
[escalation, processRunOutcome],
|
|
598
|
+
);
|
|
599
|
+
|
|
571
600
|
const runChunkedConfirmDelete = useCallback(
|
|
572
601
|
async (ids: string[] | undefined) => {
|
|
573
602
|
setPendingDelete(null);
|
|
574
603
|
focusBeforeConfirmRef.current = null;
|
|
575
|
-
|
|
576
|
-
processDeleteOutcome(outcome);
|
|
604
|
+
await runEscalatedAction(DELETE_ACTION, ids);
|
|
577
605
|
},
|
|
578
|
-
[
|
|
606
|
+
[runEscalatedAction],
|
|
579
607
|
);
|
|
580
608
|
|
|
581
609
|
// Confirm handler: run the actual bulk delete, then clear selection and
|
|
@@ -593,9 +621,9 @@ export const MessageList = ({
|
|
|
593
621
|
setPendingDelete(null);
|
|
594
622
|
return;
|
|
595
623
|
}
|
|
596
|
-
if (ids.length >
|
|
624
|
+
if (ids.length > BULK_ACTION_CHUNK_SIZE) {
|
|
597
625
|
// Selection stays put (still `ids`) for the duration of the run —
|
|
598
|
-
// `
|
|
626
|
+
// `processRunOutcome` is the one place that clears it, on success,
|
|
599
627
|
// or replaces it with whatever's left to retry.
|
|
600
628
|
void runChunkedConfirmDelete(ids);
|
|
601
629
|
return;
|
|
@@ -665,21 +693,38 @@ export const MessageList = ({
|
|
|
665
693
|
setFocusedMessageId(restoreTo);
|
|
666
694
|
}, []);
|
|
667
695
|
|
|
668
|
-
//
|
|
696
|
+
// Mark read, bounded and escalated alike (#114). Both go through the same
|
|
697
|
+
// chunked run: the bulk flags call caps at 100 ids, so a selection past
|
|
698
|
+
// that would 400 as a single call, and an escalated selection has no id
|
|
699
|
+
// list at all — it pages its predicate instead.
|
|
669
700
|
const handleMarkAsRead = useCallback(() => {
|
|
670
|
-
if (
|
|
671
|
-
|
|
701
|
+
if (escalation.phase.kind === "escalated") {
|
|
702
|
+
void runEscalatedAction(MARK_READ_ACTION);
|
|
703
|
+
return;
|
|
672
704
|
}
|
|
673
|
-
|
|
705
|
+
if (selectedCount === 0) return;
|
|
706
|
+
void runEscalatedAction(MARK_READ_ACTION, Array.from(selectedIds));
|
|
707
|
+
}, [escalation.phase, runEscalatedAction, selectedCount, selectedIds]);
|
|
674
708
|
|
|
675
|
-
//
|
|
709
|
+
// Move to a chosen folder, escalated selections included (#114).
|
|
676
710
|
const handleMoveSelected = useCallback(
|
|
677
711
|
(destinationMailboxId: string) => {
|
|
712
|
+
if (escalation.phase.kind === "escalated") {
|
|
713
|
+
void runEscalatedAction({ kind: "move", destinationMailboxId });
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
678
716
|
if (!onMoveMessages || selectedCount === 0) return;
|
|
679
717
|
onMoveMessages(Array.from(selectedIds), destinationMailboxId);
|
|
680
718
|
exitSelection();
|
|
681
719
|
},
|
|
682
|
-
[
|
|
720
|
+
[
|
|
721
|
+
escalation.phase,
|
|
722
|
+
runEscalatedAction,
|
|
723
|
+
onMoveMessages,
|
|
724
|
+
selectedCount,
|
|
725
|
+
selectedIds,
|
|
726
|
+
exitSelection,
|
|
727
|
+
],
|
|
683
728
|
);
|
|
684
729
|
|
|
685
730
|
// Cross-account guard: every selected thread row must belong to the
|
|
@@ -748,7 +793,7 @@ export const MessageList = ({
|
|
|
748
793
|
// the next page boundary; an escalated-but-idle selection drops back to
|
|
749
794
|
// bounded on the way out, same as tapping "Clear selection" first.
|
|
750
795
|
const handleMobileCancel = useCallback(() => {
|
|
751
|
-
if (escalation.
|
|
796
|
+
if (escalation.isRunning || escalation.phase.kind === "counting") {
|
|
752
797
|
escalation.stop();
|
|
753
798
|
return;
|
|
754
799
|
}
|
|
@@ -761,12 +806,20 @@ export const MessageList = ({
|
|
|
761
806
|
escalation.clear();
|
|
762
807
|
}, [escalation]);
|
|
763
808
|
|
|
764
|
-
// Partial-failure notice's Retry:
|
|
765
|
-
//
|
|
766
|
-
// `
|
|
809
|
+
// Partial-failure notice's Retry: rerun the same action against exactly the
|
|
810
|
+
// ids it never reached last time (`selectedIds`, materialized there by
|
|
811
|
+
// `processRunOutcome`) — never the original selection. A retried delete
|
|
812
|
+
// keeps going through the confirmation path so the dialog's own state is
|
|
813
|
+
// cleared with it.
|
|
767
814
|
const handleRetryFailed = useCallback(() => {
|
|
768
|
-
|
|
769
|
-
|
|
815
|
+
if (!lastRun) return;
|
|
816
|
+
const ids = Array.from(selectedIds);
|
|
817
|
+
if (lastRun.action.kind === "delete") {
|
|
818
|
+
void runChunkedConfirmDelete(ids);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
void runEscalatedAction(lastRun.action, ids);
|
|
822
|
+
}, [lastRun, runChunkedConfirmDelete, runEscalatedAction, selectedIds]);
|
|
770
823
|
|
|
771
824
|
// Scroll the roving focus cursor into view as it moves (j/k). Falls back to
|
|
772
825
|
// the open thread when nothing is focused yet.
|
|
@@ -849,7 +902,7 @@ export const MessageList = ({
|
|
|
849
902
|
// `selected.intersect(uniqueIds)`, the reference behavior #92's D2 cites.
|
|
850
903
|
// Wiping the whole selection because one id left (#111) cost the other 49
|
|
851
904
|
// rows on an ordinary refresh, and could take the post-delete Retry
|
|
852
|
-
// selection with it: `
|
|
905
|
+
// selection with it: `processRunOutcome` materializes the failed ids as
|
|
853
906
|
// the new selection and resets this effect to live by returning escalation
|
|
854
907
|
// to idle, so the cache invalidation's refetch ran this same effect against
|
|
855
908
|
// the retry set — a clear here would have dropped the Retry notice
|
|
@@ -861,9 +914,9 @@ export const MessageList = ({
|
|
|
861
914
|
// or a delete in progress — that would silently exit selection mode
|
|
862
915
|
// mid-run.
|
|
863
916
|
useEffect(() => {
|
|
864
|
-
if (escalation.phase.kind !== "idle" || escalation.
|
|
917
|
+
if (escalation.phase.kind !== "idle" || escalation.isRunning) return;
|
|
865
918
|
intersectWith(threads.map((t) => t.messageId));
|
|
866
|
-
}, [threads, intersectWith, escalation.phase, escalation.
|
|
919
|
+
}, [threads, intersectWith, escalation.phase, escalation.isRunning]);
|
|
867
920
|
|
|
868
921
|
// Switching mailboxes exits selection outright, instead of leaving it to the
|
|
869
922
|
// intersect effect above to empty the set by coincidence — a different
|
|
@@ -1001,7 +1054,7 @@ export const MessageList = ({
|
|
|
1001
1054
|
selectedCount={selectedCount}
|
|
1002
1055
|
onDelete={handleDelete}
|
|
1003
1056
|
onClearSelection={exitSelection}
|
|
1004
|
-
onMarkAsRead={
|
|
1057
|
+
onMarkAsRead={handleMarkAsRead}
|
|
1005
1058
|
onMove={onMoveMessages ? handleMoveSelected : undefined}
|
|
1006
1059
|
onOrganize={() => setOrganizeOpen(true)}
|
|
1007
1060
|
isDeleting={isDeleting}
|
|
@@ -1029,16 +1082,20 @@ export const MessageList = ({
|
|
|
1029
1082
|
!isDeleting &&
|
|
1030
1083
|
!isMoving &&
|
|
1031
1084
|
escalation.phase.kind === "idle" &&
|
|
1032
|
-
!escalation.
|
|
1085
|
+
!escalation.isRunning;
|
|
1033
1086
|
|
|
1034
|
-
const mobileIsBusy = isDeleting || isMoving || escalation.
|
|
1087
|
+
const mobileIsBusy = isDeleting || isMoving || escalation.isRunning;
|
|
1035
1088
|
const mobileCount =
|
|
1036
1089
|
escalation.phase.kind === "escalated"
|
|
1037
1090
|
? escalation.phase.total
|
|
1038
1091
|
: selectedCount;
|
|
1039
1092
|
|
|
1040
|
-
const mobileStatusLabel = escalation.
|
|
1041
|
-
?
|
|
1093
|
+
const mobileStatusLabel = escalation.runningAction
|
|
1094
|
+
? bulkActionProgressLabel(
|
|
1095
|
+
escalation.runningAction.kind,
|
|
1096
|
+
escalation.progress?.done ?? 0,
|
|
1097
|
+
escalation.progress?.total ?? 0,
|
|
1098
|
+
)
|
|
1042
1099
|
: escalation.phase.kind === "counting"
|
|
1043
1100
|
? escalation.phase.countSoFar >= 5000
|
|
1044
1101
|
? `Counting… ${formatNumber(escalation.phase.countSoFar)} so far. This is a big result set.`
|
|
@@ -1067,7 +1124,7 @@ export const MessageList = ({
|
|
|
1067
1124
|
text: "",
|
|
1068
1125
|
action: { label: "Stop", onClick: escalation.stop },
|
|
1069
1126
|
}
|
|
1070
|
-
: escalation.phase.kind === "escalated" && !escalation.
|
|
1127
|
+
: escalation.phase.kind === "escalated" && !escalation.isRunning
|
|
1071
1128
|
? {
|
|
1072
1129
|
tone: "info" as const,
|
|
1073
1130
|
text: "",
|
|
@@ -1085,10 +1142,14 @@ export const MessageList = ({
|
|
|
1085
1142
|
onClick: escalation.escalate,
|
|
1086
1143
|
},
|
|
1087
1144
|
}
|
|
1088
|
-
:
|
|
1145
|
+
: lastRun !== null && selectedCount > 0
|
|
1089
1146
|
? {
|
|
1090
1147
|
tone: "danger" as const,
|
|
1091
|
-
text:
|
|
1148
|
+
text: bulkActionPartialText(
|
|
1149
|
+
lastRun.action.kind,
|
|
1150
|
+
lastRun.succeeded,
|
|
1151
|
+
selectedCount,
|
|
1152
|
+
),
|
|
1092
1153
|
action: {
|
|
1093
1154
|
label: `Retry ${formatNumber(selectedCount)}`,
|
|
1094
1155
|
onClick: handleRetryFailed,
|
|
@@ -1106,32 +1167,30 @@ export const MessageList = ({
|
|
|
1106
1167
|
onCancel={handleMobileCancel}
|
|
1107
1168
|
onDelete={handleMobileDelete}
|
|
1108
1169
|
onMarkRead={
|
|
1109
|
-
|
|
1110
|
-
? handleMarkAsRead
|
|
1111
|
-
: undefined
|
|
1170
|
+
escalation.phase.kind === "counting" ? undefined : handleMarkAsRead
|
|
1112
1171
|
}
|
|
1113
1172
|
isBusy={mobileIsBusy}
|
|
1114
1173
|
isCounting={escalation.phase.kind === "counting"}
|
|
1115
1174
|
statusLabel={mobileStatusLabel}
|
|
1116
1175
|
selectAll={mobileSelectAll}
|
|
1117
1176
|
progress={
|
|
1118
|
-
escalation.
|
|
1177
|
+
escalation.runningAction && escalation.progress
|
|
1119
1178
|
? {
|
|
1120
|
-
value: escalation.
|
|
1121
|
-
max: escalation.
|
|
1122
|
-
tone:
|
|
1179
|
+
value: escalation.progress.done,
|
|
1180
|
+
max: escalation.progress.total,
|
|
1181
|
+
tone: bulkActionProgressTone(escalation.runningAction.kind),
|
|
1123
1182
|
}
|
|
1124
1183
|
: undefined
|
|
1125
1184
|
}
|
|
1126
1185
|
notice={mobileNotice}
|
|
1127
1186
|
moveSlot={
|
|
1128
|
-
escalation.phase.kind
|
|
1129
|
-
!escalation.
|
|
1130
|
-
onMoveMessages &&
|
|
1187
|
+
escalation.phase.kind !== "counting" &&
|
|
1188
|
+
!escalation.isRunning &&
|
|
1189
|
+
(onMoveMessages || escalation.phase.kind === "escalated") &&
|
|
1131
1190
|
accountId &&
|
|
1132
1191
|
mailboxId ? (
|
|
1133
1192
|
<>
|
|
1134
|
-
{!moveDisabledHint && (
|
|
1193
|
+
{escalation.phase.kind === "idle" && !moveDisabledHint && (
|
|
1135
1194
|
<button
|
|
1136
1195
|
type="button"
|
|
1137
1196
|
onClick={() => setOrganizeOpen(true)}
|
|
@@ -1206,7 +1265,7 @@ export const MessageList = ({
|
|
|
1206
1265
|
// #92) — the number in the bar and the rows underneath
|
|
1207
1266
|
// have to agree something is happening, and a row mid
|
|
1208
1267
|
// -delete must not be openable.
|
|
1209
|
-
escalation.
|
|
1268
|
+
escalation.isRunning && "pointer-events-none opacity-50",
|
|
1210
1269
|
)}
|
|
1211
1270
|
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
|
1212
1271
|
>
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
messageBulkOperationsUpdateFlagsMutation,
|
|
3
3
|
threadDetailOperationsListThreadMessagesQueryKey,
|
|
4
|
-
threadOperationsListThreadsQueryKey,
|
|
5
|
-
threadOperationsSearchThreadsQueryKey,
|
|
6
4
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
7
5
|
import { MobileMessageActionBar, type PopoverMenuItem } from "@remit/ui";
|
|
8
6
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
@@ -11,6 +9,10 @@ import { useCallback } from "react";
|
|
|
11
9
|
import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
12
10
|
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
13
11
|
import { useToggleTrusted } from "@/hooks/useToggleTrusted";
|
|
12
|
+
import {
|
|
13
|
+
invalidateThreadListQueries,
|
|
14
|
+
threadListCacheKeys,
|
|
15
|
+
} from "@/lib/thread-list-cache";
|
|
14
16
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
15
17
|
|
|
16
18
|
interface MobileMessageBarProps {
|
|
@@ -67,14 +69,10 @@ export const MobileMessageBar = ({
|
|
|
67
69
|
path: { threadId },
|
|
68
70
|
}),
|
|
69
71
|
});
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
queryKey: threadOperationsSearchThreadsQueryKey({
|
|
75
|
-
path: { mailboxId },
|
|
76
|
-
}),
|
|
77
|
-
});
|
|
72
|
+
invalidateThreadListQueries(
|
|
73
|
+
queryClient,
|
|
74
|
+
threadListCacheKeys([mailboxId]),
|
|
75
|
+
);
|
|
78
76
|
},
|
|
79
77
|
});
|
|
80
78
|
|