@remit/web-client 0.0.94 → 0.0.96

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.
@@ -8,6 +8,7 @@ import {
8
8
  deriveSenderClauses,
9
9
  dominantSender,
10
10
  type MatchCount,
11
+ type MatchDoor,
11
12
  type MatchMode,
12
13
  type MoveMailboxOption,
13
14
  type RuleClause,
@@ -36,15 +37,16 @@ import { useClauseSuggestions } from "@/hooks/useClauseSuggestions";
36
37
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
37
38
  import {
38
39
  type EscalatedAction,
40
+ type EscalationSearchQuery,
39
41
  useEscalatedActions,
40
42
  } from "@/hooks/useEscalatedActions";
41
43
  import { useCreateFilter } from "@/hooks/useFilters";
42
- import { useMatchSample } from "@/hooks/useMatchSample";
44
+ import { useMatchSample, useSearchMatchSample } from "@/hooks/useMatchSample";
43
45
  import { useOrganizeJob } from "@/hooks/useOrganizeJob";
44
46
  import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
45
47
  import { useRulePreview } from "@/hooks/useRulePreview";
46
48
  import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
47
- import type { BulkRunOutcome } from "@/lib/bulk-actions";
49
+ import type { BulkActionProgress, BulkRunOutcome } from "@/lib/bulk-actions";
48
50
  import { getMailboxDisplayName } from "@/lib/folder-roles";
49
51
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
50
52
  import { useMailContext } from "@/lib/mail-context";
@@ -87,6 +89,28 @@ interface SelectionWizardSessionProps extends SelectionWizardHostProps {
87
89
  searchConversion?: SearchConversion;
88
90
  }
89
91
 
92
+ /**
93
+ * The selection the list escalated to a predicate — every message matching the
94
+ * query it is showing, rather than the rows on screen (#508). The wizard walks
95
+ * it exactly as it walks a ticked selection: the same screens, the same review
96
+ * before anything is sent. What differs is that the match has no member list,
97
+ * so the count and the sample both come from the server that resolved it.
98
+ */
99
+ export interface EscalatedSelection {
100
+ /** What the predicate covers, in the words the list escalated it with. */
101
+ scope: string;
102
+ /** The server's count of the whole match, which the run is offered against. */
103
+ total: number;
104
+ /** The predicate itself, which the sample and the run both re-resolve. */
105
+ searchQuery: EscalationSearchQuery;
106
+ /**
107
+ * Runs one verb over the whole predicate, paging it server-side. The chunked
108
+ * runner the list already owns: the run screen drives it, and the review
109
+ * screen is what now stands in front of it.
110
+ */
111
+ run: (action: EscalatedAction) => Promise<BulkRunOutcome>;
112
+ }
113
+
90
114
  export interface SelectionWizardHostProps {
91
115
  /** What the bar was pressed for. Every verb walks the same wizard. */
92
116
  verb: Verb;
@@ -97,6 +121,16 @@ export interface SelectionWizardHostProps {
97
121
  selection: readonly WizardSelectionMessage[];
98
122
  /** The ticked rows span more than one account, so no rule can be created. */
99
123
  crossAccount?: boolean;
124
+ /** Present when the selection is a predicate rather than the ticked rows. */
125
+ escalated?: EscalatedSelection;
126
+ /**
127
+ * How far the escalated runner has got. Read live and passed apart from
128
+ * {@link EscalatedSelection}, which the wizard holds for the whole walk: the
129
+ * list's escalation returns to idle when a run ends, so a progress reading
130
+ * taken off the held predicate would be frozen at `undefined` for every
131
+ * retry after the first and the bar would never move.
132
+ */
133
+ escalatedProgress?: BulkActionProgress;
100
134
  /** The wizard is done with the selection, and the list drops it. */
101
135
  onFinished: () => void;
102
136
  }
@@ -168,6 +202,26 @@ const bulkActionFor = (
168
202
  const widenedRunsAsJob = (verb: Verb): boolean =>
169
203
  verb === "move" || verb === "junk" || verb === "organize";
170
204
 
205
+ /**
206
+ * The commit pressed with nowhere for the mail to go. A verb that files mail
207
+ * needs a destination, and reaching the run screen without one is a failure the
208
+ * screen states along with the way out of it — never a control that does
209
+ * nothing.
210
+ */
211
+ const noDestinationOutcome = (
212
+ verb: Verb,
213
+ ids: readonly string[],
214
+ ): BulkRunOutcome => ({
215
+ done: 0,
216
+ failedIds: [...ids],
217
+ cancelled: false,
218
+ error: new Error(
219
+ verb === "junk"
220
+ ? "This account has no Junk folder appointed, so there is nowhere to file these. Appoint one under Settings › Folders."
221
+ : "No destination was chosen, so there is nowhere to file these. Go back and pick a folder.",
222
+ ),
223
+ });
224
+
171
225
  /**
172
226
  * Where the wizard meets the app (#483). The steps and their bodies belong to
173
227
  * `@remit/ui`; everything that talks to a server is here: the widen, the live
@@ -190,6 +244,8 @@ function SelectionWizardSession({
190
244
  mailboxId,
191
245
  selection,
192
246
  crossAccount = false,
247
+ escalated: escalatedSelection,
248
+ escalatedProgress,
193
249
  searchConversion,
194
250
  onFinished,
195
251
  step,
@@ -201,10 +257,20 @@ function SelectionWizardSession({
201
257
  // still settling underneath cannot rewrite the notice a user is reading while
202
258
  // the clauses beside it stay as they were seeded.
203
259
  const [conversion] = useState(() => searchConversion);
260
+ // The predicate this walk is for, held for the walk. The list's escalation
261
+ // returns to idle the moment a run ends, and a wizard reading it live would
262
+ // lose the match its run screen is reporting on — and would offer a retry over
263
+ // the loaded rows instead of over the predicate.
264
+ const [escalated] = useState(() => escalatedSelection);
204
265
  const fromSearch = conversion !== undefined;
205
- const [mode, setMode] = useState<MatchMode>(
266
+ // The door the wizard is on. An escalated predicate is not one of them and is
267
+ // not held here: it is a property of the selection the list handed over, so
268
+ // the mode below is derived rather than settable. No screen can escalate a
269
+ // selection, and the commit path can be typed to the doors.
270
+ const [door, setDoor] = useState<MatchDoor>(
206
271
  fromSearch ? "properties" : "selected",
207
272
  );
273
+ const mode: MatchMode = escalated ? "escalated" : door;
208
274
  const [draft, setDraft] = useState<WizardDraft>(() =>
209
275
  conversion
210
276
  ? {
@@ -243,7 +309,13 @@ function SelectionWizardSession({
243
309
  const subjects = useSelectedSubjects(messageIds);
244
310
  const { junkMailboxId } = useJunkMailbox(accountId);
245
311
 
246
- const widen = useOrganizeWiden(accountId, anchorMessageId, senders);
312
+ // No door is offered over an escalated predicate, so nothing here has a widen
313
+ // to probe for.
314
+ const widen = useOrganizeWiden(
315
+ escalated ? undefined : accountId,
316
+ anchorMessageId,
317
+ senders,
318
+ );
247
319
  const { preview: probeWiden } = widen;
248
320
  // The similar door has to know before it is pressed whether it can run, so
249
321
  // the probe fires with the wizard rather than with the door (#477 3.6).
@@ -251,7 +323,10 @@ function SelectionWizardSession({
251
323
  probeWiden();
252
324
  }, [probeWiden]);
253
325
 
254
- const widened = mode !== "selected";
326
+ // A door the wizard resolves itself, through the preview endpoint. The
327
+ // escalated predicate is resolved by the list before the wizard opens, and
328
+ // the ticked list is its own answer, so neither is previewed.
329
+ const previewed = mode === "similar" || mode === "properties";
255
330
  const literalPredicate: OrganizeMatchPredicate = useMemo(
256
331
  () => ({
257
332
  matchOperator: draft.matchOperator === "all" ? "And" : "Or",
@@ -268,14 +343,21 @@ function SelectionWizardSession({
268
343
  // in the browser (#477 5.3). The ticked list is its own count, so no request
269
344
  // is made for it.
270
345
  const { count: previewCount, matchedIds } = useRulePreview(
271
- widened ? accountId : undefined,
346
+ previewed ? accountId : undefined,
272
347
  predicate,
273
348
  );
274
- const matchSample = useMatchSample(widened ? matchedIds : []);
349
+ const matchSample = useMatchSample(previewed ? matchedIds : []);
350
+ // The escalated match's own members, read from the search that resolved it.
351
+ const escalatedSample = useSearchMatchSample(
352
+ escalated ? mailboxId : undefined,
353
+ escalated?.searchQuery,
354
+ );
275
355
 
276
- const count: MatchCount = widened
277
- ? previewCount
278
- : { status: "ready", count: selection.length };
356
+ const count: MatchCount = escalated
357
+ ? { status: "ready", count: escalated.total }
358
+ : previewed
359
+ ? previewCount
360
+ : { status: "ready", count: selection.length };
279
361
 
280
362
  const { data: mailboxesData } = useQuery({
281
363
  ...mailboxOperationsListMailboxesOptions({
@@ -371,8 +453,8 @@ function SelectionWizardSession({
371
453
  );
372
454
 
373
455
  const changeMode = useCallback(
374
- (next: MatchMode) => {
375
- setMode(next);
456
+ (next: MatchDoor) => {
457
+ setDoor(next);
376
458
  setDraft((held) => ({
377
459
  ...held,
378
460
  widen:
@@ -392,7 +474,7 @@ function SelectionWizardSession({
392
474
  // semantic match without saying so (#477 3.6).
393
475
  const takeSemanticFallback = useCallback(() => {
394
476
  setSemanticFallbackTaken(true);
395
- setMode("properties");
477
+ setDoor("properties");
396
478
  setDraft((held) => ({
397
479
  ...held,
398
480
  widen: undefined,
@@ -486,16 +568,7 @@ function SelectionWizardSession({
486
568
  if (!action) {
487
569
  setBulkRun({
488
570
  matched: ids.length,
489
- outcome: {
490
- done: 0,
491
- failedIds: [...ids],
492
- cancelled: false,
493
- error: new Error(
494
- verb === "junk"
495
- ? "This account has no Junk folder appointed, so there is nowhere to file these. Appoint one under Settings › Folders."
496
- : "No destination was chosen, so there is nowhere to file these. Go back and pick a folder.",
497
- ),
498
- },
571
+ outcome: noDestinationOutcome(verb, ids),
499
572
  });
500
573
  return;
501
574
  }
@@ -506,13 +579,38 @@ function SelectionWizardSession({
506
579
  [verb, named.moveMailboxId, junkMailboxId, runAction],
507
580
  );
508
581
 
582
+ // The escalated predicate, run by the chunked runner the list already owns.
583
+ // It pages the match on the server rather than acting on ids the browser
584
+ // loaded, so the verb reaches every message the review screen counted.
585
+ const runEscalated = useCallback(async () => {
586
+ if (!escalated) return;
587
+ const action = bulkActionFor(verb, named.moveMailboxId, junkMailboxId);
588
+ if (!action) {
589
+ setBulkRun({
590
+ matched: escalated.total,
591
+ outcome: noDestinationOutcome(verb, []),
592
+ });
593
+ return;
594
+ }
595
+ setBulkRun({ matched: escalated.total });
596
+ const outcome = await escalated.run(action);
597
+ setBulkRun({ matched: escalated.total, outcome });
598
+ }, [escalated, verb, named.moveMailboxId, junkMailboxId]);
599
+
509
600
  const { start: startJob } = organizeJob;
510
601
  const { createFilterAsync } = createFilter;
511
602
 
512
603
  const sendCommit = useCallback(() => {
513
- const scope = organizeScopeFor({ mode, ruleScope: named.scope });
604
+ // An escalated predicate is not a rule and has no scope to reconstruct: it
605
+ // is the search the list is showing, applied once, by the runner that
606
+ // resolved it.
607
+ if (escalated) {
608
+ void runEscalated();
609
+ return;
610
+ }
611
+ const scope = organizeScopeFor({ mode: door, ruleScope: named.scope });
514
612
  const organizeDraft = buildWizardDraft({
515
- mode,
613
+ mode: door,
516
614
  ruleScope: named.scope,
517
615
  anchorMessageId,
518
616
  clauses: named.clauses,
@@ -547,7 +645,9 @@ function SelectionWizardSession({
547
645
  }
548
646
  void runBulk(scope === "just-these" ? messageIds : matchedIds);
549
647
  }, [
550
- mode,
648
+ escalated,
649
+ runEscalated,
650
+ door,
551
651
  named,
552
652
  anchorMessageId,
553
653
  verb,
@@ -596,43 +696,75 @@ function SelectionWizardSession({
596
696
  return NOT_STARTED;
597
697
  }, [organizeJob]);
598
698
 
699
+ // Every row the wizard has seen a description of, so a run that names what it
700
+ // did not reach can name it rather than listing an id.
599
701
  const rowsById = useMemo(() => {
600
702
  const rows = new Map<string, WizardMessage>();
601
- for (const message of [...selection, ...matchSample.messages]) {
703
+ for (const message of [
704
+ ...selection,
705
+ ...matchSample.messages,
706
+ ...escalatedSample.messages,
707
+ ]) {
602
708
  rows.set(message.id, message);
603
709
  }
604
710
  return rows;
605
- }, [selection, matchSample.messages]);
711
+ }, [selection, matchSample.messages, escalatedSample.messages]);
712
+
713
+ const runProgress = escalated ? escalatedProgress : bulk.progress;
606
714
 
607
715
  const bulkSnapshot = useCallback((): RunSnapshot => {
608
716
  if (!bulkRun) return NOT_STARTED;
609
717
  const { matched, outcome } = bulkRun;
610
718
  if (!outcome) {
719
+ const applied = runProgress?.done ?? 0;
720
+ // A predicate matches more by the time the run re-pages it than the count
721
+ // saw, so what it has covered can overtake what it was offered against.
722
+ // The bar never reads more done than out of, and neither does this.
611
723
  return {
612
724
  state: "backApplyRunning",
613
- matched,
614
- applied: bulk.progress?.done ?? 0,
725
+ matched: Math.max(matched, applied),
726
+ applied,
615
727
  failed: 0,
616
728
  failures: [],
617
729
  };
618
730
  }
619
- if (outcome.done === 0 && (outcome.error || outcome.failedIds.length > 0)) {
731
+ const stopped = outcome.cancelled || outcome.error !== undefined;
732
+ if (!stopped) {
733
+ return {
734
+ state: "backApplyComplete",
735
+ matched: Math.max(matched, outcome.done),
736
+ applied: outcome.done,
737
+ failed: 0,
738
+ failures: [],
739
+ };
740
+ }
741
+ if (outcome.done === 0) {
620
742
  return { ...NOT_STARTED, state: "commitFailed" };
621
743
  }
744
+ // The run stopped part-way. Nothing here was rejected: a returned bulk call
745
+ // accepts every id in it, so the only failure this layer sees is a call that
746
+ // threw, and everything after it was never sent. A bounded run hands back
747
+ // exactly those ids; a predicate run re-resolves its match on every pass and
748
+ // has no remainder to hand back, so what is left is the difference between
749
+ // the count and what the run covered.
622
750
  const failures = outcome.failedIds
623
751
  .map((id) => rowsById.get(id))
624
752
  .filter((message): message is WizardMessage => message !== undefined);
753
+ const unreached =
754
+ outcome.failedIds.length > 0
755
+ ? outcome.failedIds.length
756
+ : Math.max(matched - outcome.done, 1);
625
757
  return {
626
- state:
627
- outcome.failedIds.length > 0 ? "backApplyFailed" : "backApplyComplete",
628
- matched,
758
+ state: "runStopped",
759
+ matched: Math.max(matched, outcome.done),
629
760
  applied: outcome.done,
630
- failed: outcome.failedIds.length,
761
+ failed: unreached,
631
762
  failures,
632
763
  };
633
- }, [bulkRun, bulk.progress, rowsById]);
764
+ }, [bulkRun, runProgress, rowsById]);
634
765
 
635
766
  const runSnapshot = (): RunSnapshot => {
767
+ if (escalated) return bulkSnapshot();
636
768
  if (committedScope === "standing" || committedScope === "temporary") {
637
769
  if (createFilter.isError)
638
770
  return { ...NOT_STARTED, state: "commitFailed" };
@@ -653,6 +785,12 @@ function SelectionWizardSession({
653
785
  // walking back to Review, which would push an entry the wizard does not own
654
786
  // and leave Cancel rewinding to a step instead of out.
655
787
  const retry = (): void => {
788
+ // The predicate is re-resolved, not resumed: every verb it carries is
789
+ // idempotent, so the messages the first pass already reached are a no-op.
790
+ if (escalated) {
791
+ void runEscalated();
792
+ return;
793
+ }
656
794
  if (committedScope === "standing" || committedScope === "temporary") {
657
795
  if (createFilter.isError) {
658
796
  createFilter.reset();
@@ -704,15 +842,22 @@ function SelectionWizardSession({
704
842
  });
705
843
 
706
844
  const run = runSnapshot();
845
+ const sampleMessages = escalated
846
+ ? escalatedSample.messages
847
+ : previewed
848
+ ? matchSample.messages
849
+ : selection;
707
850
  const sample = {
708
- messages: widened ? matchSample.messages : selection,
851
+ messages: sampleMessages,
709
852
  count,
710
- label: widened ? "A sample of what matches" : "Your selection",
853
+ label: mode === "selected" ? "Your selection" : "A sample of what matches",
711
854
  emptyReason:
712
855
  mode === "similar" && semanticUnavailable
713
856
  ? ("notIndexed" as const)
714
857
  : ("noMatch" as const),
715
- loading: widened && (count.status === "loading" || matchSample.isPending),
858
+ loading: escalated
859
+ ? escalatedSample.isPending
860
+ : previewed && (count.status === "loading" || matchSample.isPending),
716
861
  };
717
862
 
718
863
  return (
@@ -735,6 +880,7 @@ function SelectionWizardSession({
735
880
  widen.error instanceof Error ? widen.error.message : undefined,
736
881
  semanticFallbackTaken,
737
882
  onSemanticFallback: takeSemanticFallback,
883
+ escalatedScope: escalated?.scope,
738
884
  sample,
739
885
  }}
740
886
  properties={{
@@ -786,6 +932,7 @@ function SelectionWizardSession({
786
932
  scope: named.scope,
787
933
  until: named.until,
788
934
  ruleName: steps.includes("name") ? named.name : undefined,
935
+ escalatedScope: escalated?.scope,
789
936
  sample,
790
937
  }}
791
938
  run={{
@@ -836,6 +983,7 @@ export function SelectionWizardHost(props: SelectionWizardHostProps) {
836
983
  accountId: conversion.targetAccountId ?? accounts[0]?.accountId,
837
984
  selection: EMPTY_SELECTION,
838
985
  crossAccount: false,
986
+ escalated: undefined,
839
987
  searchConversion: conversion,
840
988
  }
841
989
  : {})}
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import assert from "node:assert/strict";
11
11
  import { after, afterEach, before, beforeEach, describe, it } from "node:test";
12
+ import type { Verb } from "@remit/ui";
12
13
  import type { JSDOM } from "jsdom";
13
14
  import { act, createElement, createRef, useState } from "react";
14
15
  import { createRoot, type Root } from "react-dom/client";
@@ -68,8 +69,10 @@ const rowElements = (ids: string[]) =>
68
69
  function mountList(options: {
69
70
  initialIds: string[];
70
71
  onDeleteMessages?: (ids: string[]) => void;
72
+ onSelectionVerb?: (verb: Verb) => void;
71
73
  }) {
72
74
  const onDeleteMessages = options.onDeleteMessages ?? (() => undefined);
75
+ const onSelectionVerb = options.onSelectionVerb ?? (() => undefined);
73
76
  const commandsRef = createRef<MessageListCommands | null>();
74
77
  let setIds: ((ids: string[]) => void) | undefined;
75
78
  const Harness = () => {
@@ -81,6 +84,7 @@ function mountList(options: {
81
84
  selectedMessageId: undefined,
82
85
  onOpen: () => undefined,
83
86
  onDeleteMessages,
87
+ onSelectionVerb,
84
88
  commandsRef,
85
89
  },
86
90
  ...rowElements(ids),
@@ -146,6 +150,71 @@ describe("ThreadListInteraction — the cursor follows the rendered rows", () =>
146
150
  });
147
151
  });
148
152
 
153
+ /**
154
+ * The provider runs no verb over a selection. Every one of them is handed out
155
+ * to be opened on the wizard, which is the only place a bulk action is named
156
+ * before it reaches the mail server (#477 1.4, #508). A verb aimed at the bare
157
+ * cursor is one message rather than a bulk action, and only Delete is this
158
+ * list's — it keeps its confirmation.
159
+ */
160
+ describe("ThreadListInteraction — a verb over a selection goes to the wizard", () => {
161
+ const selectRow = (
162
+ list: ReturnType<typeof mountList>,
163
+ toggleFirst = true,
164
+ ) => {
165
+ act(() => list.commands().focusFirst());
166
+ if (toggleFirst) act(() => list.commands().toggleSelect());
167
+ };
168
+
169
+ for (const verb of [
170
+ "delete",
171
+ "move",
172
+ "junk",
173
+ "markRead",
174
+ "organize",
175
+ ] as const) {
176
+ it(`hands ${verb} over rather than running it`, () => {
177
+ const handed: Verb[] = [];
178
+ const deleted: string[][] = [];
179
+ const list = mountList({
180
+ initialIds: ["m1", "m2"],
181
+ onDeleteMessages: (ids) => deleted.push(ids),
182
+ onSelectionVerb: (v) => handed.push(v),
183
+ });
184
+
185
+ selectRow(list);
186
+ act(() => {
187
+ assert.equal(list.commands().requestVerb(verb), true);
188
+ });
189
+
190
+ assert.deepEqual(handed, [verb]);
191
+ assert.deepEqual(deleted, [], "nothing runs from here");
192
+ assert.equal(
193
+ Array.from(
194
+ dom.window.document.querySelectorAll<HTMLButtonElement>("button"),
195
+ ).some((b) => b.textContent === "Move to Trash"),
196
+ false,
197
+ "no second confirmation stands in for the review screen",
198
+ );
199
+ });
200
+ }
201
+
202
+ it("leaves a verb aimed at the bare cursor to the pane, except delete", () => {
203
+ const handed: Verb[] = [];
204
+ const list = mountList({
205
+ initialIds: ["m1", "m2"],
206
+ onSelectionVerb: (v) => handed.push(v),
207
+ });
208
+
209
+ act(() => list.commands().focusFirst());
210
+ act(() => {
211
+ assert.equal(list.commands().requestVerb("markRead"), false);
212
+ assert.equal(list.commands().requestVerb("junk"), false);
213
+ });
214
+ assert.deepEqual(handed, [], "one row is not a selection");
215
+ });
216
+ });
217
+
149
218
  describe("ThreadListInteraction — delete confirms first", () => {
150
219
  const confirmButton = () =>
151
220
  Array.from(
@@ -161,7 +230,7 @@ describe("ThreadListInteraction — delete confirms first", () => {
161
230
 
162
231
  act(() => list.commands().focusFirst());
163
232
  act(() => {
164
- assert.equal(list.commands().requestDelete(), true);
233
+ assert.equal(list.commands().requestVerb("delete"), true);
165
234
  });
166
235
  assert.deepEqual(deleted, [], "nothing is deleted before confirming");
167
236
  assert.ok(confirmButton(), "the confirmation is on screen");
@@ -179,11 +248,11 @@ describe("ThreadListInteraction — delete confirms first", () => {
179
248
 
180
249
  act(() => list.commands().focusFirst());
181
250
  act(() => {
182
- list.commands().requestDelete();
251
+ list.commands().requestVerb("delete");
183
252
  });
184
253
  act(() => {
185
254
  assert.equal(
186
- list.commands().requestDelete(),
255
+ list.commands().requestVerb("delete"),
187
256
  true,
188
257
  "the second press belongs to the dialog",
189
258
  );
@@ -197,7 +266,7 @@ describe("ThreadListInteraction — delete confirms first", () => {
197
266
  onDeleteMessages: () => undefined,
198
267
  });
199
268
  act(() => {
200
- assert.equal(list.commands().requestDelete(), false);
269
+ assert.equal(list.commands().requestVerb("delete"), false);
201
270
  });
202
271
  });
203
272
  });
@@ -227,6 +296,7 @@ function mountSelectableList(initialIds: string[]) {
227
296
  selectedMessageId: undefined,
228
297
  onOpen: () => undefined,
229
298
  onDeleteMessages: () => undefined,
299
+ onSelectionVerb: () => undefined,
230
300
  commandsRef,
231
301
  },
232
302
  ...rowElements(ids),