@remit/web-client 0.0.271 → 0.0.273

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.
@@ -1,4 +1,3 @@
1
- import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
1
  import {
3
2
  messageBulkOperationsDeleteMessages,
4
3
  messageBulkOperationsMoveMessages,
@@ -6,30 +5,22 @@ import {
6
5
  threadOperationsSearchThreads,
7
6
  } from "@remit/api-http-client/sdk.gen.ts";
8
7
  import type { ThreadOperationsSearchThreadsData } from "@remit/api-http-client/types.gen.ts";
9
- import { useQueryClient } from "@tanstack/react-query";
10
8
  import { useCallback, useEffect, useRef, useState } from "react";
11
- import { useRoleAppointmentPrompt } from "@/components/mail/RoleAppointmentPromptProvider";
9
+ import {
10
+ type BulkRunRequest,
11
+ type BulkRunSource,
12
+ useBulkRun,
13
+ } from "@/components/mail/BulkRunProvider";
12
14
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
13
15
  import { buildMutationErrorBanner } from "@/components/ui/error-banners";
14
- import { isFolderRoleRefusal } from "@/components/ui/folder-role-refusal";
15
- import {
16
- bulkActionFailureDetail,
17
- bulkActionFailureTitle,
18
- } from "@/lib/bulk-action-copy";
19
- import {
20
- type ApplyBatch,
21
- type BulkActionProgress,
22
- type BulkActionTarget,
23
- type BulkRunOutcome,
24
- type FetchIdsPage,
25
- honestProgress,
26
- runChunkedAction,
27
- runPredicateAction,
16
+ import type {
17
+ ApplyBatch,
18
+ BulkActionProgress,
19
+ BulkActionTarget,
20
+ BulkRunStart,
21
+ EscalatedAction,
22
+ FetchIdsPage,
28
23
  } from "@/lib/bulk-actions";
29
- import {
30
- invalidateThreadListQueries,
31
- threadListCacheKeys,
32
- } from "@/lib/thread-list-cache";
33
24
 
34
25
  /** The predicate a search-scoped run re-issues on every page — the same
35
26
  * filters the visible list is searching with, minus pagination/count knobs. */
@@ -45,16 +36,6 @@ export type EscalationSearchQuery = Pick<
45
36
  | "category"
46
37
  >;
47
38
 
48
- /**
49
- * What a bulk run applies to every batch it reaches (#114). Delete, move and
50
- * mark-read differ only in the bulk call they issue and the caches that call
51
- * invalidates; the paging, chunking, progress and cancellation are the same.
52
- */
53
- export type EscalatedAction =
54
- | { kind: "delete" }
55
- | { kind: "move"; destinationMailboxId: string }
56
- | { kind: "markRead" };
57
-
58
39
  /** Page size for the execution loop. Set to the write side's own 100-id cap so
59
40
  * an execution page IS a write chunk — no in-memory accumulation step between
60
41
  * reading ids and sending them. */
@@ -67,6 +48,8 @@ export type EscalationPhase =
67
48
 
68
49
  interface UseEscalatedActionsOptions {
69
50
  mailboxId: string;
51
+ /** The mailbox in the user's words, for a commit refused while this runs. */
52
+ mailboxLabel?: string;
70
53
  /** Owning account, forwarded to the unseen-count invalidation on completion. */
71
54
  accountId?: string;
72
55
  /** Disables escalation entirely (e.g. not searching). Resets any in-flight
@@ -76,6 +59,12 @@ interface UseEscalatedActionsOptions {
76
59
  * changes (a different search is a different question). */
77
60
  predicateKey: string;
78
61
  searchQuery: EscalationSearchQuery;
62
+ /**
63
+ * States how a run ended, for a user who is no longer looking at any screen
64
+ * that could show it. Handed to the run's owner rather than called here, so
65
+ * an ending that lands after this hook unmounted is still said (#112).
66
+ */
67
+ reportEnding?: BulkRunRequest["reportEnding"];
79
68
  }
80
69
 
81
70
  export interface UseEscalatedActionsResult {
@@ -84,11 +73,14 @@ export interface UseEscalatedActionsResult {
84
73
  * selection to that predicate once it answers. */
85
74
  escalate: () => void;
86
75
  /**
87
- * Stop whatever's running — the count or an action. The request in flight is
88
- * aborted and nothing further leaves (#113); a delete the server has already
89
- * accepted still applies, so the batch on the wire is what a stop is worth.
90
- * A no-op when nothing is running. The only thing that ends a run in
91
- * flight: leaving the selection, the wizard or the search does not.
76
+ * Stop whatever this mailbox has running — its count, and the run when the
77
+ * run is its own. The request in flight is aborted and nothing further leaves
78
+ * (#113); a delete the server has already accepted still applies, so the
79
+ * batch on the wire is what a stop is worth. A no-op when nothing is running.
80
+ * Cancelling a count here never reaches a run in another mailbox: that run is
81
+ * not what this screen is offering to stop, and ending it would be silent.
82
+ * The only thing that ends a run in flight: leaving the selection, the
83
+ * wizard, the search or the mailbox does not.
92
84
  */
93
85
  stop: () => void;
94
86
  /**
@@ -99,7 +91,7 @@ export interface UseEscalatedActionsResult {
99
91
  */
100
92
  clear: () => void;
101
93
  /** True while a chunked run (bounded->100 ids, or the escalated predicate)
102
- * is in flight. */
94
+ * over this mailbox is in flight, whichever screen started it. */
103
95
  isRunning: boolean;
104
96
  /** The action currently in flight, for status and progress wording. */
105
97
  runningAction: EscalatedAction | undefined;
@@ -112,61 +104,44 @@ export interface UseEscalatedActionsResult {
112
104
  * as one batch the endpoint refuses whole (#872). Resolves once the run ends
113
105
  * for any reason — cancelled, errored, or complete — with a
114
106
  * `done`/`failedIds` outcome the caller reads to decide what is still
115
- * outstanding.
116
- * Infrastructure failures are reported through the app's existing
117
- * escalation seam (`pushError`, which itself escalates a 5xx/exception to
118
- * the fatal overlay) — not swallowed here.
107
+ * outstanding, or with the refusal of a run that never started because
108
+ * another one is still going.
109
+ *
110
+ * The run itself belongs to `BulkRunProvider`, which outlives every screen
111
+ * that can show it: the caches, the refusal replay, the failure banner and
112
+ * the ending are its, so none of them are lost when the surface that started
113
+ * the run goes (#112).
119
114
  */
120
115
  runAction: (
121
116
  action: EscalatedAction,
122
117
  targets?: readonly BulkActionTarget[],
123
- ) => Promise<BulkRunOutcome>;
118
+ claimEnding?: BulkRunRequest["claimEnding"],
119
+ ) => Promise<BulkRunStart>;
124
120
  }
125
121
 
126
- /**
127
- * The mailboxes whose cached listings a bulk run affects: the mailbox it ran
128
- * over, plus a move's destination, which gains the messages the source loses.
129
- */
130
- export const mailboxesTouchedBy = (
131
- action: EscalatedAction,
132
- mailboxId: string,
133
- ): string[] =>
134
- action.kind === "move"
135
- ? [mailboxId, action.destinationMailboxId]
136
- : [mailboxId];
137
-
138
122
  export const useEscalatedActions = ({
139
123
  mailboxId,
124
+ mailboxLabel,
140
125
  accountId,
141
126
  enabled,
142
127
  predicateKey,
143
128
  searchQuery,
129
+ reportEnding,
144
130
  }: UseEscalatedActionsOptions): UseEscalatedActionsResult => {
145
131
  const [phase, setPhase] = useState<EscalationPhase>({ kind: "idle" });
146
- const [runningAction, setRunningAction] = useState<
147
- EscalatedAction | undefined
148
- >(undefined);
149
- const [progress, setProgress] = useState<BulkActionProgress | undefined>(
150
- undefined,
151
- );
152
- // Cancellation is the signal every call the run makes is issued under, so a
153
- // stop ends the request on the wire instead of waiting for it to come back
154
- // (#113). Replaced, never reused, at the start of each count and each run.
155
- const abortRef = useRef<AbortController | undefined>(undefined);
156
- // True from the moment a run starts until its outcome is in hand. A run is
157
- // mail already leaving the mailbox, so nothing that merely changes what the
158
- // list is showing gets to end it — only `stop`.
159
- const runningRef = useRef(false);
160
- const queryClient = useQueryClient();
132
+ // The count's own signal, which is not the run's: leaving the search ends a
133
+ // count, and never a run (#112).
134
+ const countAbortRef = useRef<AbortController | undefined>(undefined);
135
+ const { run, start, stop: stopRun } = useBulkRun();
161
136
  const { pushError } = useErrorBanners();
162
- const { requestAppointment } = useRoleAppointmentPrompt();
163
- // The run replays itself once a folder is appointed, so it needs a handle on
164
- // itself that does not make `runAction` its own dependency.
165
- const runRef = useRef<UseEscalatedActionsResult["runAction"]>(async () => ({
166
- done: 0,
167
- failedIds: [],
168
- cancelled: false,
169
- }));
137
+
138
+ // The live run, when it is this mailbox's. A run is mail leaving one mailbox,
139
+ // so it reports on that mailbox's list — not on whichever list is on screen.
140
+ const activeRun = run?.mailboxId === mailboxId ? run : undefined;
141
+ const isRunning = activeRun !== undefined;
142
+ // Read by callbacks that must not close over a stale render's answer.
143
+ const isRunningRef = useRef(false);
144
+ isRunningRef.current = isRunning;
170
145
 
171
146
  // A different search (or leaving search/desktop) makes any in-flight
172
147
  // escalation meaningless — it would otherwise keep counting or offering to
@@ -175,7 +150,7 @@ export const useEscalatedActions = ({
175
150
  // against and reports what it reached, wherever the list moved on to.
176
151
  // biome-ignore lint/correctness/useExhaustiveDependencies: enabled/predicateKey are trigger-only — the reset itself reads neither.
177
152
  useEffect(() => {
178
- if (!runningRef.current) abortRef.current?.abort();
153
+ countAbortRef.current?.abort();
179
154
  setPhase({ kind: "idle" });
180
155
  }, [enabled, predicateKey]);
181
156
 
@@ -252,36 +227,9 @@ export const useEscalatedActions = ({
252
227
  [],
253
228
  );
254
229
 
255
- /**
256
- * The unseen counts a run moved, per account. A cross-account selection has
257
- * no single owning account — the surface leaves the option undefined exactly
258
- * then — so the run's own targets are what name the accounts to refresh.
259
- */
260
- const invalidateAfterRun = useCallback(
261
- (action: EscalatedAction, targets: readonly BulkActionTarget[]) => {
262
- invalidateThreadListQueries(
263
- queryClient,
264
- threadListCacheKeys(mailboxesTouchedBy(action, mailboxId)),
265
- );
266
- const touched = new Set<string>();
267
- if (accountId) touched.add(accountId);
268
- for (const target of targets) {
269
- if (target.accountId) touched.add(target.accountId);
270
- }
271
- for (const touchedAccountId of touched) {
272
- queryClient.invalidateQueries({
273
- queryKey: mailboxOperationsListMailboxesQueryKey({
274
- path: { accountId: touchedAccountId },
275
- }),
276
- });
277
- }
278
- },
279
- [queryClient, mailboxId, accountId],
280
- );
281
-
282
230
  const escalate = useCallback(() => {
283
231
  const controller = new AbortController();
284
- abortRef.current = controller;
232
+ countAbortRef.current = controller;
285
233
  setPhase({ kind: "counting" });
286
234
  fetchMatchCount(controller.signal).then(
287
235
  (total) => {
@@ -311,12 +259,13 @@ export const useEscalatedActions = ({
311
259
  }, [fetchMatchCount, pushError]);
312
260
 
313
261
  const stop = useCallback(() => {
314
- abortRef.current?.abort();
315
- }, []);
262
+ countAbortRef.current?.abort();
263
+ if (isRunningRef.current) stopRun(mailboxId);
264
+ }, [stopRun, mailboxId]);
316
265
 
317
266
  const clear = useCallback(() => {
318
- if (runningRef.current) return;
319
- abortRef.current?.abort();
267
+ if (isRunningRef.current) return;
268
+ countAbortRef.current?.abort();
320
269
  setPhase({ kind: "idle" });
321
270
  }, []);
322
271
 
@@ -324,105 +273,62 @@ export const useEscalatedActions = ({
324
273
  async (
325
274
  action: EscalatedAction,
326
275
  targets?: readonly BulkActionTarget[],
327
- ): Promise<BulkRunOutcome> => {
328
- const controller = new AbortController();
329
- abortRef.current = controller;
330
- runningRef.current = true;
331
- setRunningAction(action);
276
+ claimEnding?: BulkRunRequest["claimEnding"],
277
+ ): Promise<BulkRunStart> => {
332
278
  // Read before the run clears the phase below, so a refusal can say how
333
279
  // many messages the appointment's replay is about.
334
280
  const matched =
335
281
  targets?.length ?? (phase.kind === "escalated" ? phase.total : 0);
336
- // `honestProgress` widens `total` if `done` overtakes it (#109) — the
337
- // predicate can match more by the time the run pages it than the count
338
- // saw, and the bar must never show more done than out of.
339
- const onProgress = (next: BulkActionProgress) =>
340
- setProgress(honestProgress(next));
341
- const applyBatch = applyBatchFor(action);
342
282
  // The predicate as it read when the run was confirmed. The run outlives
343
283
  // the screen that started it, so reading the live query on every page
344
284
  // would let a search typed afterwards redirect what is being deleted.
345
- const runPages = fetchPagesOf(searchQueryRef.current);
285
+ const source: BulkRunSource =
286
+ targets !== undefined
287
+ ? { kind: "targets", targets }
288
+ : {
289
+ kind: "predicate",
290
+ fetchPage: fetchPagesOf(searchQueryRef.current),
291
+ };
346
292
 
347
- // The one invariant nothing may lose: while `runningRef` is up, both
348
- // `clear` and the reset effect stand down, so a run that never marked
349
- // itself finished would leave an escalated selection nobody can leave.
350
- let outcome: BulkRunOutcome;
351
- try {
352
- outcome =
353
- targets !== undefined
354
- ? await runChunkedAction(
355
- targets,
356
- applyBatch,
357
- onProgress,
358
- controller.signal,
359
- )
360
- : await runPredicateAction(
361
- runPages,
362
- phase.kind === "escalated" ? phase.total : 0,
363
- applyBatch,
364
- onProgress,
365
- controller.signal,
366
- );
367
- } finally {
368
- runningRef.current = false;
369
- }
370
-
371
- setRunningAction(undefined);
372
- setProgress(undefined);
373
- setPhase({ kind: "idle" });
293
+ const started = await start({
294
+ action,
295
+ mailboxId,
296
+ mailboxLabel,
297
+ accountId,
298
+ matched,
299
+ source,
300
+ applyBatch: applyBatchFor(action),
301
+ reportEnding,
302
+ claimEnding,
303
+ });
374
304
 
375
- // A provenance refusal is answered by the appointment prompt here exactly
376
- // as it is on the single-row path (#887, #876). Left to the banner it
377
- // arrives as the API's own sentence — "Appoint one under Settings ›
378
- // Folder roles" — which asks the user to reassemble a select-all they
379
- // have already made.
380
- const refusal = outcome.error
381
- ? isFolderRoleRefusal(outcome.error)
382
- : undefined;
383
- if (refusal) {
384
- requestAppointment({
385
- accountId: refusal.accountId,
386
- role: refusal.role,
387
- reason: refusal.reason,
388
- action: { kind: "delete", count: matched },
389
- onAppointed: async () => {
390
- await runRef.current(action, targets);
391
- },
392
- });
393
- } else if (outcome.error) {
394
- pushError(
395
- buildMutationErrorBanner(
396
- bulkActionFailureTitle(action.kind, outcome.done),
397
- bulkActionFailureDetail(action.kind),
398
- outcome.error,
399
- ),
400
- );
401
- }
402
- if (outcome.done > 0) {
403
- invalidateAfterRun(action, targets ?? []);
404
- }
405
- return outcome;
305
+ // The escalated selection was what the run was confirmed from, and the
306
+ // run has now happened to it. A refused commit leaves it standing: the
307
+ // selection is still the question, and stopping the other run is what
308
+ // makes it answerable.
309
+ if (started.kind === "ran") setPhase({ kind: "idle" });
310
+ return started;
406
311
  },
407
312
  [
408
313
  applyBatchFor,
409
314
  fetchPagesOf,
410
315
  phase,
411
- pushError,
412
- invalidateAfterRun,
413
- requestAppointment,
316
+ mailboxId,
317
+ mailboxLabel,
318
+ accountId,
319
+ reportEnding,
320
+ start,
414
321
  ],
415
322
  );
416
- runRef.current = runAction;
417
323
 
418
324
  return {
419
325
  phase,
420
326
  escalate,
421
327
  stop,
422
328
  clear,
423
- isRunning: runningAction !== undefined,
424
- runningAction,
425
- progress,
329
+ isRunning,
330
+ runningAction: activeRun?.action,
331
+ progress: activeRun?.progress,
426
332
  runAction,
427
333
  };
428
334
  };
@@ -115,6 +115,27 @@ export const bulkActionFailureTitle = (
115
115
  export const bulkActionFailureDetail = (kind: BulkActionKind): string =>
116
116
  failureDetail[kind];
117
117
 
118
+ const busyPhrase: Record<BulkActionKind, (count: string) => string> = {
119
+ delete: (count) => `A delete of ${count} messages`,
120
+ move: (count) => `A move of ${count} messages`,
121
+ markRead: (count) => `Marking ${count} messages as read`,
122
+ };
123
+
124
+ /**
125
+ * The commit that could not start, because the one still going owns the bar and
126
+ * the Stop it would need. It names the run in flight and where it is, so the
127
+ * user knows which screen to end it from rather than pressing a commit that
128
+ * silently does nothing.
129
+ */
130
+ export const bulkRunBusyRefusal = (
131
+ kind: BulkActionKind,
132
+ matched: number,
133
+ mailboxLabel: string | undefined,
134
+ ): string =>
135
+ `${busyPhrase[kind](formatNumber(matched))}${
136
+ mailboxLabel ? ` in ${mailboxLabel}` : ""
137
+ } is still running — stop it first.`;
138
+
118
139
  /** Progress-bar tone: only delete is destructive. */
119
140
  export const bulkActionProgressTone = (
120
141
  kind: BulkActionKind,
@@ -34,6 +34,28 @@
34
34
 
35
35
  export const BULK_ACTION_CHUNK_SIZE = 100;
36
36
 
37
+ /**
38
+ * What a bulk run applies to every batch it reaches (#114). Delete, move and
39
+ * mark-read differ only in the bulk call they issue and the caches that call
40
+ * invalidates; the paging, chunking, progress and cancellation are the same.
41
+ */
42
+ export type EscalatedAction =
43
+ | { kind: "delete" }
44
+ | { kind: "move"; destinationMailboxId: string }
45
+ | { kind: "markRead" };
46
+
47
+ /**
48
+ * The mailboxes whose cached listings a bulk run affects: the mailbox it ran
49
+ * over, plus a move's destination, which gains the messages the source loses.
50
+ */
51
+ export const mailboxesTouchedBy = (
52
+ action: EscalatedAction,
53
+ mailboxId: string,
54
+ ): string[] =>
55
+ action.kind === "move"
56
+ ? [mailboxId, action.destinationMailboxId]
57
+ : [mailboxId];
58
+
37
59
  /** For a run with no Stop control to wire a signal to. */
38
60
  const NEVER_ABORTED = new AbortController().signal;
39
61
 
@@ -323,3 +345,13 @@ export interface BulkRunOutcome {
323
345
  cancelled: boolean;
324
346
  error?: unknown;
325
347
  }
348
+
349
+ /**
350
+ * What asking for a run got: the run itself, or the reason there is none. One
351
+ * run at a time is the design — a second one would page a second predicate
352
+ * behind the same bar and the same Stop — so a commit pressed while one is
353
+ * going is answered with what is going rather than with a run that replaces it.
354
+ */
355
+ export type BulkRunStart =
356
+ | { kind: "ran"; outcome: BulkRunOutcome }
357
+ | { kind: "refused"; reason: string };
@@ -7,6 +7,7 @@ import { Suspense } from "react";
7
7
  import { useTranslation } from "react-i18next";
8
8
  import { ComposeProvider } from "@/components/compose/ComposeProvider";
9
9
  import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
10
+ import { BulkRunProvider } from "@/components/mail/BulkRunProvider";
10
11
  import { RoleAppointmentPromptProvider } from "@/components/mail/RoleAppointmentPromptProvider";
11
12
  import { SelfUpdateOverlay } from "@/components/self-update/SelfUpdateOverlay";
12
13
  import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
@@ -59,17 +60,21 @@ function RootLayout() {
59
60
  return (
60
61
  <ErrorBannerProvider>
61
62
  <RoleAppointmentPromptProvider>
62
- <SelfUpdateProvider>
63
- <ComposeProvider>
64
- <SkipLink />
65
- <main id="main-content" className="h-dvh overflow-hidden">
66
- <Suspense fallback={<AppShellSkeleton />}>
67
- <Outlet />
68
- </Suspense>
69
- </main>
70
- </ComposeProvider>
71
- <SelfUpdateOverlay />
72
- </SelfUpdateProvider>
63
+ {/* A bulk run outlives the route that started it, so its owner sits
64
+ above the router rather than inside the list (#112). */}
65
+ <BulkRunProvider>
66
+ <SelfUpdateProvider>
67
+ <ComposeProvider>
68
+ <SkipLink />
69
+ <main id="main-content" className="h-dvh overflow-hidden">
70
+ <Suspense fallback={<AppShellSkeleton />}>
71
+ <Outlet />
72
+ </Suspense>
73
+ </main>
74
+ </ComposeProvider>
75
+ <SelfUpdateOverlay />
76
+ </SelfUpdateProvider>
77
+ </BulkRunProvider>
73
78
  </RoleAppointmentPromptProvider>
74
79
  <FatalErrorOverlay />
75
80
  </ErrorBannerProvider>
@@ -0,0 +1,13 @@
1
+ import type { BulkRunOutcome, BulkRunStart } from "../lib/bulk-actions";
2
+
3
+ /**
4
+ * The outcome of a run a test meant to start. A refusal here is the test's own
5
+ * setup — another run of its making is still going — so it says so rather than
6
+ * failing on a missing property three assertions later.
7
+ */
8
+ export const ranOutcome = (started: BulkRunStart): BulkRunOutcome => {
9
+ if (started.kind === "refused") {
10
+ throw new Error(`the run was refused: ${started.reason}`);
11
+ }
12
+ return started.outcome;
13
+ };
@@ -10,6 +10,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
10
10
  import React, { act, createElement } from "react";
11
11
  import { createRoot, type Root } from "react-dom/client";
12
12
  import { DEFAULT_VIEWPORT, setViewport } from "../../test-support/dom-env.mjs";
13
+ import { BulkRunProvider } from "../components/mail/BulkRunProvider";
13
14
  import { RoleAppointmentPromptProvider } from "../components/mail/RoleAppointmentPromptProvider";
14
15
  import { ErrorBannerProvider } from "../components/ui/ErrorBannerProvider";
15
16
 
@@ -113,7 +114,11 @@ export const createDomHarness = (options: DomOptions = {}): DomHarness => {
113
114
  createElement(
114
115
  ErrorBannerProvider,
115
116
  null,
116
- createElement(RoleAppointmentPromptProvider, null, element),
117
+ createElement(
118
+ RoleAppointmentPromptProvider,
119
+ null,
120
+ createElement(BulkRunProvider, null, element),
121
+ ),
117
122
  ),
118
123
  ),
119
124
  );