@remit/web-client 0.0.94 → 0.0.95

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.
@@ -16,7 +16,7 @@
16
16
  * are not rendered: focus stops moving, the highlight disappears, and the next
17
17
  * verb acts on a message the user cannot see.
18
18
  */
19
- import { SelectionTopBar } from "@remit/ui";
19
+ import { SelectionTopBar, type Verb } from "@remit/ui";
20
20
  import {
21
21
  createContext,
22
22
  type ReactNode,
@@ -52,8 +52,12 @@ interface ThreadListInteractionValue {
52
52
  selectedIds: Set<string>;
53
53
  selectedCount: number;
54
54
  exitSelection: () => void;
55
- /** Opens the move-to-Trash confirmation for the current selection. */
56
- requestDeleteSelection: () => void;
55
+ /**
56
+ * Runs a verb over the current selection by opening the wizard on it — the
57
+ * one route every selection action takes, whether the bar or the keyboard
58
+ * asked for it.
59
+ */
60
+ startSelectionVerb: (verb: Verb) => void;
57
61
  /** Rendered rows in display order — the same order a shift-range spans. */
58
62
  orderedIds: string[];
59
63
  /** Whether every rendered row is selected, for a select-all control. */
@@ -144,6 +148,15 @@ interface ThreadListInteractionProps {
144
148
  onOpen: (messageId: string, options?: OpenMessageOptions) => void;
145
149
  /** Deletes a set of messages. Absent disables the delete key for this list. */
146
150
  onDeleteMessages: (messageIds: string[]) => void;
151
+ /**
152
+ * Runs a verb over the selection — which means opening the wizard on it, the
153
+ * one place a bulk action is reviewed before it reaches the mail server
154
+ * (#477 1.4). The provider never runs one itself, so no surface can grow a
155
+ * second unreviewed route to the same verb.
156
+ */
157
+ onSelectionVerb: (verb: Verb) => void;
158
+ /** The wizard owns the screen, so this list's keyboard layer stands down. */
159
+ wizardOpen?: boolean;
147
160
  isDeleting?: boolean;
148
161
  commandsRef?: RefObject<MessageListCommands | null>;
149
162
  onTriageContextChange?: (context: TriageContextUpdate) => void;
@@ -154,6 +167,8 @@ export function ThreadListInteraction({
154
167
  selectedMessageId,
155
168
  onOpen,
156
169
  onDeleteMessages,
170
+ onSelectionVerb,
171
+ wizardOpen = false,
157
172
  isDeleting = false,
158
173
  commandsRef,
159
174
  onTriageContextChange,
@@ -237,36 +252,32 @@ export function ThreadListInteraction({
237
252
  open: followOpen,
238
253
  });
239
254
 
240
- // Pending move-to-Trash, awaiting confirmation. The ids are snapshotted at
241
- // request time so a selection change behind the dialog cannot retarget it —
242
- // the same contract the mailbox list's delete has.
255
+ // Pending move-to-Trash for the row under the cursor, awaiting confirmation.
256
+ // The id is snapshotted at request time so a cursor move behind the dialog
257
+ // cannot retarget it. A delete over a selection is a bulk action and walks the
258
+ // wizard instead — the same contract the mailbox list's delete has.
243
259
  const [pendingDelete, setPendingDelete] = useState<string[] | null>(null);
244
260
 
245
- const requestDeleteIds = useCallback((ids: string[]): boolean => {
246
- if (ids.length === 0) return false;
247
- setPendingDelete(ids);
248
- return true;
249
- }, []);
250
-
251
- const requestDeleteSelection = useCallback(() => {
252
- requestDeleteIds(Array.from(selectedIds));
253
- }, [requestDeleteIds, selectedIds]);
254
-
255
- const requestDelete = useCallback((): boolean => {
256
- // The confirmation is already asking about a delete: the keypress belongs
257
- // to it. Claiming the press here is what stops a second Delete from
258
- // reaching an unconfirmed delete.
259
- if (pendingDelete !== null) return true;
260
- if (selectedCount > 0) return requestDeleteIds(Array.from(selectedIds));
261
- if (focusedMessageId) return requestDeleteIds([focusedMessageId]);
262
- return false;
263
- }, [
264
- pendingDelete,
265
- selectedCount,
266
- selectedIds,
267
- focusedMessageId,
268
- requestDeleteIds,
269
- ]);
261
+ // A verb, routed the same way the bar routes its own (#477 1.4, #508). Over a
262
+ // selection every verb opens the wizard, so the keyboard cannot reach a bulk
263
+ // action the bar would have reviewed. Over a bare cursor only Delete is this
264
+ // list's, and it keeps its confirmation.
265
+ const requestVerb = useCallback(
266
+ (verb: Verb): boolean => {
267
+ // The confirmation is already asking about a delete: the keypress belongs
268
+ // to it. Claiming the press here is what stops a second Delete from
269
+ // reaching an unconfirmed delete.
270
+ if (pendingDelete !== null) return true;
271
+ if (selectedCount > 0) {
272
+ onSelectionVerb(verb);
273
+ return true;
274
+ }
275
+ if (verb !== "delete" || !focusedMessageId) return false;
276
+ setPendingDelete([focusedMessageId]);
277
+ return true;
278
+ },
279
+ [pendingDelete, selectedCount, onSelectionVerb, focusedMessageId],
280
+ );
270
281
 
271
282
  const confirmDelete = useCallback(() => {
272
283
  if (pendingDelete === null) return;
@@ -302,7 +313,7 @@ export function ThreadListInteraction({
302
313
  extendSelectUp: cursor.extendRangeUp,
303
314
  selectAll: cursor.selectAllLoaded,
304
315
  clearSelection: clearSelectionCommand,
305
- requestDelete,
316
+ requestVerb,
306
317
  // The brief and Flagged have no density switch; the key stays inert here
307
318
  // rather than moving a control these views do not offer.
308
319
  toggleDensity: () => undefined,
@@ -323,7 +334,7 @@ export function ThreadListInteraction({
323
334
  cursor.selectAllLoaded,
324
335
  openFocused,
325
336
  clearSelectionCommand,
326
- requestDelete,
337
+ requestVerb,
327
338
  ]);
328
339
 
329
340
  const selectedIdList = useMemo(() => Array.from(selectedIds), [selectedIds]);
@@ -334,9 +345,11 @@ export function ThreadListInteraction({
334
345
  selectedIds: selectedIdList,
335
346
  orderedIds,
336
347
  hasList,
337
- // The dialog owns the keyboard while it is up, so the triage layer
338
- // suspends rather than acting behind it.
339
- blocksKeyboard: confirmOpen,
348
+ // The dialog and the wizard each own the keyboard while they are up, so
349
+ // the triage layer suspends rather than acting behind them: a second
350
+ // Delete must not reach a delete, and no shortcut may start a second flow
351
+ // behind the screen already asking about one.
352
+ blocksKeyboard: confirmOpen || wizardOpen,
340
353
  });
341
354
  }, [
342
355
  onTriageContextChange,
@@ -345,6 +358,7 @@ export function ThreadListInteraction({
345
358
  orderedIds,
346
359
  hasList,
347
360
  confirmOpen,
361
+ wizardOpen,
348
362
  ]);
349
363
 
350
364
  const tabStop = tabStopId(orderedIds, focusedMessageId);
@@ -354,7 +368,7 @@ export function ThreadListInteraction({
354
368
  selectedIds,
355
369
  selectedCount,
356
370
  exitSelection,
357
- requestDeleteSelection,
371
+ startSelectionVerb: onSelectionVerb,
358
372
  orderedIds,
359
373
  allSelected,
360
374
  toggleAllLoaded,
@@ -376,7 +390,7 @@ export function ThreadListInteraction({
376
390
  selectedIds,
377
391
  selectedCount,
378
392
  exitSelection,
379
- requestDeleteSelection,
393
+ onSelectionVerb,
380
394
  orderedIds,
381
395
  allSelected,
382
396
  toggleAllLoaded,
@@ -416,13 +430,14 @@ export function ThreadListInteraction({
416
430
  interface ThreadListSelectionBarProps {
417
431
  /** The view's own name, used until the enclosing header supplies one. */
418
432
  title: string;
419
- onMarkAsRead?: (messageIds: string[]) => void;
420
433
  isDeleting?: boolean;
421
434
  }
422
435
 
423
436
  /**
424
437
  * The starred list's header, which is also its selection bar — the same
425
- * surface the mailbox list and the brief raise.
438
+ * surface the mailbox list and the brief raise, with the same rule: every verb
439
+ * on it opens the wizard, and the review screen there is what names the action
440
+ * before it reaches the mail server (#477 1.4).
426
441
  *
427
442
  * Move is not offered here: starred mail spans accounts and mailboxes, and a
428
443
  * move picker needs one account and one source folder to be honest about where
@@ -430,25 +445,18 @@ interface ThreadListSelectionBarProps {
430
445
  */
431
446
  export function ThreadListSelectionBar({
432
447
  title,
433
- onMarkAsRead,
434
448
  isDeleting,
435
449
  }: ThreadListSelectionBarProps) {
436
450
  const chrome = useListHeaderChrome();
437
451
  const {
438
- selectedIds,
439
452
  selectedCount,
440
453
  exitSelection,
441
- requestDeleteSelection,
454
+ startSelectionVerb,
442
455
  orderedIds,
443
456
  allSelected,
444
457
  toggleAllLoaded,
445
458
  } = useThreadListSelection();
446
459
 
447
- const handleMarkAsRead = useCallback(() => {
448
- onMarkAsRead?.(Array.from(selectedIds));
449
- exitSelection();
450
- }, [onMarkAsRead, selectedIds, exitSelection]);
451
-
452
460
  return (
453
461
  <SelectionTopBar
454
462
  title={chrome.title || title}
@@ -459,8 +467,8 @@ export function ThreadListSelectionBar({
459
467
  idleSlot={chrome.makeFilterSlot}
460
468
  count={selectedCount}
461
469
  onCancel={exitSelection}
462
- onDelete={requestDeleteSelection}
463
- onMarkRead={onMarkAsRead ? handleMarkAsRead : undefined}
470
+ onDelete={() => startSelectionVerb("delete")}
471
+ onMarkRead={() => startSelectionVerb("markRead")}
464
472
  isBusy={isDeleting}
465
473
  selectAll={
466
474
  orderedIds.length > 0
@@ -0,0 +1,278 @@
1
+ import assert from "node:assert/strict";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ /**
8
+ * One invariant, over every surface: a verb aimed at a selection reaches the
9
+ * mail server only through the wizard, whose review screen names what it covers
10
+ * (#477 1.4 and its FAQ, #508).
11
+ *
12
+ * There are two ways to ask for one — the selection bar and the keyboard — and
13
+ * three surfaces that raise a bar. Checked here rather than by a spec per
14
+ * surface, because what has repeatedly gone wrong is not a broken route but a
15
+ * missing one: a fourth surface, or a sixth verb, quietly keeping a direct
16
+ * handler while every other route was moved. That is invisible in review and
17
+ * indistinguishable from correct until someone selects 3,412 messages and
18
+ * presses a key.
19
+ *
20
+ * These read the source rather than rendering, for the reason
21
+ * `MessageList.selection.test.ts` gives: these components wire the DOM, the
22
+ * router and several data hooks together, and the rule is about which function
23
+ * a prop names.
24
+ */
25
+
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+ const read = (file: string): string =>
28
+ readFileSync(resolve(here, file), "utf8");
29
+
30
+ /** Every verb the bar can carry — the props `SelectionTopBar` accepts. */
31
+ const BAR_VERB_PROPS = [
32
+ "onDelete",
33
+ "onMove",
34
+ "onOrganize",
35
+ "onJunk",
36
+ "onMarkRead",
37
+ ] as const;
38
+
39
+ /**
40
+ * How a surface is allowed to answer a verb prop. `startWizard`/`wizard.start`/
41
+ * `startSelectionVerb` all open the wizard on the verb; `organizeSelection` and
42
+ * `startFromSearch` open it on the search entry, which is the same wizard
43
+ * reached by its other door (#477 1.8). Anything else is a route around the
44
+ * review screen.
45
+ */
46
+ const OPENS_THE_WIZARD =
47
+ /^(startWizard|wizard\.start|startSelectionVerb|organizeSelection|startFromSearch)\b/;
48
+
49
+ const surfaceFiles = (): string[] =>
50
+ readdirSync(here)
51
+ .filter((file) => file.endsWith(".tsx"))
52
+ .filter((file) => /<SelectionTopBar/.test(read(file)));
53
+
54
+ /**
55
+ * The bar element as written, to its own closing tag. Found by matching tags
56
+ * rather than by stopping at the first `/>` on a line: a nested self-closing
57
+ * element matches that too, so the scan would end early and silently skip
58
+ * whatever a surface happened to write after it.
59
+ */
60
+ const barMarkup = (source: string): string => {
61
+ const start = source.indexOf("<SelectionTopBar");
62
+ if (start === -1) return "";
63
+ let depth = 0;
64
+ for (let i = start; i < source.length; i++) {
65
+ if (source[i] === "<" && /[A-Za-z]/.test(source[i + 1] ?? "")) depth++;
66
+ else if (source.startsWith("/>", i)) {
67
+ depth--;
68
+ if (depth === 0) return source.slice(start, i + 2);
69
+ i++;
70
+ } else if (source.startsWith("</", i)) depth--;
71
+ }
72
+ return "";
73
+ };
74
+
75
+ /** The body of `const <name> = useCallback(…)`, or of an inline arrow value. */
76
+ const bracedFrom = (source: string, open: number): string => {
77
+ let depth = 0;
78
+ for (let i = open; i < source.length; i++) {
79
+ if (source[i] === "{") depth++;
80
+ else if (source[i] === "}") {
81
+ depth--;
82
+ if (depth === 0) return source.slice(open, i + 1);
83
+ }
84
+ }
85
+ return source.slice(open);
86
+ };
87
+
88
+ const namedCallbackBody = (source: string, name: string): string => {
89
+ const at = source.indexOf(`const ${name} = useCallback(`);
90
+ if (at === -1) return "";
91
+ const open = source.indexOf("{", at);
92
+ return open === -1 ? "" : bracedFrom(source, open);
93
+ };
94
+
95
+ /** The triage handlers a pane wires into the keyboard layer, by name. */
96
+ const declaredHandlers = (source: string): string[] => {
97
+ const at = source.indexOf("handlers: {");
98
+ if (at === -1) return [];
99
+ return Array.from(
100
+ bracedFrom(source, source.indexOf("{", at)).matchAll(/\n\t{3}(\w+): /g),
101
+ (match) => match[1],
102
+ );
103
+ };
104
+
105
+ /**
106
+ * What a pane does for one keyboard verb: the handler it wired into the triage
107
+ * layer, plus any callback that handler defers to for its target. A handler
108
+ * that offers the list the press and then falls through to a helper picking the
109
+ * selection is exactly as wrong as one picking it inline, so both are read.
110
+ */
111
+ const paneVerbBody = (source: string, handler: string): string => {
112
+ const at = source.search(new RegExp(`\\n\\t{3}${handler}: `));
113
+ if (at === -1) return "";
114
+ const value =
115
+ source.slice(at).match(new RegExp(`${handler}: (.*)`))?.[1] ?? "";
116
+ const body = value.startsWith("(")
117
+ ? bracedFrom(source, source.indexOf("{", at))
118
+ : namedCallbackBody(source, value.replace(/[,\s].*$/, ""));
119
+ const helpers = Array.from(body.matchAll(/\b(\w+MessageIds)\(\)/g), (match) =>
120
+ namedCallbackBody(source, match[1]),
121
+ );
122
+ return [body, ...helpers].join("\n");
123
+ };
124
+
125
+ /**
126
+ * What a verb prop is wired to, with the whitespace, the guard a verb is
127
+ * offered behind, and the arrow taken off — what is left is the call itself.
128
+ */
129
+ const verbHandler = (bar: string, prop: string): string | undefined => {
130
+ const assigned = bar.match(new RegExp(`${prop}=\\{([\\s\\S]*?)\\}\\n`))?.[1];
131
+ if (assigned === undefined) return undefined;
132
+ return assigned
133
+ .replace(/\s+/g, " ")
134
+ .replace(/^.*? \? /, "")
135
+ .replace(/ : undefined$/, "")
136
+ .replace(/^\(\)\s*=>\s*/, "")
137
+ .trim();
138
+ };
139
+
140
+ describe("every verb on a selection bar opens the wizard", () => {
141
+ it("covers the surfaces that raise one", () => {
142
+ assert.deepEqual(surfaceFiles().sort(), [
143
+ "DailyBrief.tsx",
144
+ "MessageList.tsx",
145
+ "ThreadListInteraction.tsx",
146
+ ]);
147
+ });
148
+
149
+ for (const file of surfaceFiles()) {
150
+ it(`${file} routes each verb it offers through the wizard`, () => {
151
+ const bar = barMarkup(read(file));
152
+ assert.notEqual(bar, "", `${file} renders a SelectionTopBar`);
153
+ const offered = BAR_VERB_PROPS.filter((prop) => bar.includes(`${prop}=`));
154
+ assert.ok(offered.length > 0, `${file} offers at least one verb`);
155
+ for (const prop of offered) {
156
+ const handler = verbHandler(bar, prop);
157
+ assert.ok(handler, `${file}'s ${prop} is wired to something`);
158
+ assert.match(
159
+ handler,
160
+ OPENS_THE_WIZARD,
161
+ `${file}'s ${prop} must open the wizard, not run the verb`,
162
+ );
163
+ }
164
+ });
165
+ }
166
+ });
167
+
168
+ /**
169
+ * The keyboard's half. A list offers the pane one seam — `requestVerb` — and
170
+ * claims the press whenever it has a selection, so a pane's own handler only
171
+ * ever sees a verb aimed at the bare cursor. A pane that runs a verb over
172
+ * `selectedIds` without offering the list the press first is the hole this
173
+ * binds shut.
174
+ */
175
+ describe("every keyboard verb over a selection goes through the list", () => {
176
+ const LISTS = ["MessageList.tsx", "ThreadListInteraction.tsx"];
177
+ const PANES = ["MailboxPane.tsx", "BriefPane.tsx", "FlaggedPane.tsx"];
178
+ /**
179
+ * The triage handlers that can be aimed at a selection, each with the wizard
180
+ * verb it has to route to. Star is a knowing carve-out: it is not one of the
181
+ * five verbs the wizard walks, it is not on `SelectionTopBar`, and it sets a
182
+ * flag on mail already in front of the user that the same key unsets — so
183
+ * there is nothing for a review screen to name. `LabelApplyTrigger` applies
184
+ * labels to the selection directly for the same reason, and because labels
185
+ * are out of scope for the epic that put the wizard in front of the rest
186
+ * (#477 §6). Mute and block are per-sender and reach no selection at all.
187
+ */
188
+ const PANE_VERB_ROUTE: Record<string, string> = {
189
+ delete: "delete",
190
+ toggleRead: "markRead",
191
+ markJunk: "junk",
192
+ };
193
+
194
+ for (const file of LISTS) {
195
+ it(`${file} publishes the one seam and claims a selection`, () => {
196
+ const source = read(file);
197
+ assert.match(source, /requestVerb,/);
198
+ assert.match(
199
+ source,
200
+ /if \(verb !== "delete"/,
201
+ "only delete is the list's over a bare cursor",
202
+ );
203
+ assert.doesNotMatch(
204
+ source,
205
+ /requestDelete:/,
206
+ "one seam, so no verb can have a second one",
207
+ );
208
+ });
209
+
210
+ it(`${file} stands its keyboard down while the wizard is up`, () => {
211
+ assert.match(
212
+ read(file),
213
+ /blocksKeyboard: confirmOpen \|\| wizard/,
214
+ "a shortcut must not act behind the screen already asking",
215
+ );
216
+ });
217
+ }
218
+
219
+ // Only the handlers a pane really declares get a case, so a pane that never
220
+ // wired one cannot pass by skipping — the roll-call below is what notices a
221
+ // handler nobody routed.
222
+ for (const file of PANES) {
223
+ for (const handler of declaredHandlers(read(file))) {
224
+ const verb = PANE_VERB_ROUTE[handler];
225
+ if (verb === undefined) continue;
226
+
227
+ it(`${file}'s ${handler} offers the list ${verb} first`, () => {
228
+ const body = paneVerbBody(read(file), handler);
229
+ assert.notEqual(body, "", `${handler}'s body was found`);
230
+ assert.match(
231
+ body,
232
+ new RegExp(`requestVerb\\("${verb}"\\)`),
233
+ `${handler} must hand ${verb} to the list before running it`,
234
+ );
235
+ });
236
+
237
+ it(`${file}'s ${handler} aims at the cursor once the list declines`, () => {
238
+ const body = paneVerbBody(read(file), handler);
239
+ assert.notEqual(body, "", `${handler}'s body was found`);
240
+ // The list declines only when its commands are gone, which an
241
+ // escalated selection outlives — so a fallback that could still see
242
+ // a selection would run the verb over a stale snapshot of rows that
243
+ // are no longer loaded, with no wizard in front of it.
244
+ assert.doesNotMatch(
245
+ body,
246
+ /selectedIds/i,
247
+ `${handler} falls back to the focused row, never to a selection`,
248
+ );
249
+ });
250
+ }
251
+ }
252
+
253
+ it("checks the routed handler on every pane that declares one", () => {
254
+ // The roll-call. Each pair above is generated from what a pane declares, so
255
+ // this is what says the generation reached everything it should have — a
256
+ // handler that stops being declared, or a body the reader stops finding,
257
+ // shows up here as a missing pair rather than as a case that passed by
258
+ // doing nothing.
259
+ const checked = PANES.flatMap((file) =>
260
+ declaredHandlers(read(file))
261
+ .filter((handler) => handler in PANE_VERB_ROUTE)
262
+ .map((handler) => `${file}:${handler}`),
263
+ );
264
+ assert.deepEqual(checked.sort(), [
265
+ "BriefPane.tsx:delete",
266
+ "BriefPane.tsx:toggleRead",
267
+ "FlaggedPane.tsx:delete",
268
+ "FlaggedPane.tsx:toggleRead",
269
+ "MailboxPane.tsx:delete",
270
+ "MailboxPane.tsx:markJunk",
271
+ "MailboxPane.tsx:toggleRead",
272
+ ]);
273
+ for (const entry of checked) {
274
+ const [file, handler] = entry.split(":");
275
+ assert.notEqual(paneVerbBody(read(file), handler), "", entry);
276
+ }
277
+ });
278
+ });
@@ -34,21 +34,6 @@ export const OneMessage: Story = {
34
34
  },
35
35
  };
36
36
 
37
- /**
38
- * An escalated (search-predicate) delete: the count was paged once by
39
- * `countMatches` and the delete re-pages the same predicate independently, so
40
- * it is not provably the number that gets deleted (#109). "about" and the
41
- * description say so up front rather than stating an exact number the run may
42
- * not honour.
43
- */
44
- export const EscalatedEstimate: Story = {
45
- args: {
46
- title: "Move about 3,412 messages to Trash?",
47
- description:
48
- "This count is a snapshot — new mail arriving during the delete won't be included. You can restore what's deleted from Trash later.",
49
- },
50
- };
51
-
52
37
  /** The mutation is in flight: the confirm button disables rather than
53
38
  * allowing a second concurrent delete request. */
54
39
  export const Busy: Story = {
@@ -97,8 +97,8 @@ export interface UseEscalatedActionsResult {
97
97
  * Runs `action` in chunks. Pass `ids` for a materialized (bounded)
98
98
  * selection; omit it to run against the escalated predicate (`phase` must
99
99
  * be "escalated"). Resolves once the run ends for any reason — cancelled,
100
- * errored, or complete — with a `done`/`failedIds` outcome the caller feeds
101
- * to `resolveSelectionAfterRun` to decide what selection looks like next.
100
+ * errored, or complete — with a `done`/`failedIds` outcome the caller reads
101
+ * to decide what is still outstanding.
102
102
  * Infrastructure failures are reported through the app's existing
103
103
  * escalation seam (`pushError`, which itself escalates a 5xx/exception to
104
104
  * the fatal overlay) — not swallowed here.
@@ -1,7 +1,14 @@
1
- import { messageOperationsDescribeMessageOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import type { RemitImapDescribeMessageResponse } from "@remit/api-http-client/types.gen.ts";
1
+ import {
2
+ messageOperationsDescribeMessageOptions,
3
+ threadOperationsSearchThreadsOptions,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import type {
6
+ RemitImapDescribeMessageResponse,
7
+ RemitImapThreadMessageResponse,
8
+ } from "@remit/api-http-client/types.gen.ts";
3
9
  import { senderLabel, type WizardMessage } from "@remit/ui";
4
- import { useQueries } from "@tanstack/react-query";
10
+ import { useQueries, useQuery } from "@tanstack/react-query";
11
+ import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
5
12
  import { formatEmailDate } from "@/lib/format";
6
13
 
7
14
  /**
@@ -44,6 +51,40 @@ const toWizardMessage = (
44
51
  };
45
52
  };
46
53
 
54
+ /**
55
+ * The members of an escalated predicate's match (#508). The predicate is the
56
+ * search the list is showing, so the search endpoint that resolved it is what
57
+ * names its members — one bounded request rather than the browser's own loaded
58
+ * rows, which stop at the page the list happens to have reached.
59
+ */
60
+ export const useSearchMatchSample = (
61
+ mailboxId: string | undefined,
62
+ query: EscalationSearchQuery | undefined,
63
+ ): MatchSample => {
64
+ const enabled = mailboxId !== undefined && query !== undefined;
65
+ const { data, isLoading } = useQuery({
66
+ ...threadOperationsSearchThreadsOptions({
67
+ path: { mailboxId: mailboxId ?? "" },
68
+ query: { ...query, limit: SAMPLE_LIMIT },
69
+ }),
70
+ enabled,
71
+ staleTime: 30_000,
72
+ });
73
+ return {
74
+ messages: (data?.items ?? []).map(toSearchWizardMessage),
75
+ isPending: enabled && isLoading,
76
+ };
77
+ };
78
+
79
+ const toSearchWizardMessage = (
80
+ row: RemitImapThreadMessageResponse,
81
+ ): WizardMessage => ({
82
+ id: row.messageId,
83
+ sender: row.fromName || row.fromEmail || "Unknown",
84
+ subject: row.subject ?? "(No subject)",
85
+ date: formatEmailDate(row.sentDate),
86
+ });
87
+
47
88
  export const useMatchSample = (messageIds: readonly string[]): MatchSample =>
48
89
  useQueries({
49
90
  queries: messageIds.slice(0, SAMPLE_LIMIT).map((messageId) => ({
@@ -5,7 +5,6 @@ import {
5
5
  bulkActionCompletionText,
6
6
  bulkActionFailureDetail,
7
7
  bulkActionFailureTitle,
8
- bulkActionPartialText,
9
8
  bulkActionProgressLabel,
10
9
  bulkActionProgressTone,
11
10
  } from "./bulk-action-copy.js";
@@ -46,23 +45,6 @@ describe("bulkActionCompletionText", () => {
46
45
  });
47
46
  });
48
47
 
49
- describe("bulkActionPartialText", () => {
50
- test("splits what landed from what is still selected", () => {
51
- assert.equal(
52
- bulkActionPartialText("delete", 3072, 340),
53
- "3,072 moved to Trash. 340 couldn't be deleted.",
54
- );
55
- assert.equal(
56
- bulkActionPartialText("move", 3072, 340),
57
- "3,072 moved. 340 couldn't be moved.",
58
- );
59
- assert.equal(
60
- bulkActionPartialText("markRead", 3072, 340),
61
- "3,072 marked as read. 340 couldn't be marked as read.",
62
- );
63
- });
64
- });
65
-
66
48
  describe("bulkActionFailureTitle", () => {
67
49
  test("reports where a partly-done run stopped", () => {
68
50
  assert.equal(
@@ -87,7 +69,6 @@ describe("every action carries its own wording", () => {
87
69
  test("no two actions share a sentence", () => {
88
70
  const sentences: Array<(kind: BulkActionKind) => string> = [
89
71
  (kind) => bulkActionCompletionText(kind, 5),
90
- (kind) => bulkActionPartialText(kind, 5, 2),
91
72
  (kind) => bulkActionFailureTitle(kind, 0),
92
73
  (kind) => bulkActionFailureTitle(kind, 5),
93
74
  bulkActionFailureDetail,
@@ -59,14 +59,6 @@ export const bulkActionCompletionText = (
59
59
  ): string =>
60
60
  `${formatNumber(done)} ${pastTense[kind]}. Your mail server is still catching up.`;
61
61
 
62
- /** Shown when part of a run landed and the rest is still selected for Retry. */
63
- export const bulkActionPartialText = (
64
- kind: BulkActionKind,
65
- succeeded: number,
66
- remaining: number,
67
- ): string =>
68
- `${formatNumber(succeeded)} ${pastTense[kind]}. ${formatNumber(remaining)} ${negated[kind]}.`;
69
-
70
62
  /** Error-banner title for a run stopped by an infrastructure failure. */
71
63
  export const bulkActionFailureTitle = (
72
64
  kind: BulkActionKind,