@remit/web-client 0.0.92 → 0.0.94

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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/DailyBrief.selection.test.ts +6 -2
  3. package/src/components/mail/DailyBrief.tsx +57 -112
  4. package/src/components/mail/FlaggedList.tsx +42 -0
  5. package/src/components/mail/MailListHeader.tsx +41 -39
  6. package/src/components/mail/MessageList.selection.test.ts +10 -5
  7. package/src/components/mail/MessageList.tsx +78 -82
  8. package/src/components/mail/SelectionWizardHost.tsx +721 -64
  9. package/src/components/mail/ThreadListInteraction.tsx +0 -9
  10. package/src/components/mail/make-filter-entry.test.ts +63 -0
  11. package/src/components/settings/FilterEditor.tsx +1 -1
  12. package/src/hooks/useCreateMailbox.ts +16 -4
  13. package/src/hooks/useFilters.ts +30 -1
  14. package/src/hooks/useMatchSample.ts +63 -0
  15. package/src/hooks/useRulePreview.ts +24 -5
  16. package/src/lib/list-header-chrome.ts +10 -0
  17. package/src/lib/mail-context.ts +0 -9
  18. package/src/lib/organize/organize-model.test.ts +110 -0
  19. package/src/lib/organize/organize-model.ts +69 -0
  20. package/src/lib/organize/rule-model.ts +2 -4
  21. package/src/lib/wizard-history.test.ts +31 -0
  22. package/src/lib/wizard-history.ts +65 -2
  23. package/src/routes/mail.tsx +3 -8
  24. package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -45
  25. package/src/components/mail/organize/MobileOrganizeFlow.tsx +0 -157
  26. package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -37
  27. package/src/components/mail/organize/OrganizeDialog.tsx +0 -91
  28. package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +0 -498
  29. package/src/components/mail/organize/OrganizeRuleEditor.tsx +0 -285
  30. package/src/components/mail/organize/SearchFilterDialog.render.test.ts +0 -47
  31. package/src/components/mail/organize/SearchFilterDialog.tsx +0 -90
  32. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +0 -260
  33. package/src/components/mail/organize/SearchFilterEditor.tsx +0 -136
  34. package/src/components/mail/organize/SomethingElsePanel.render.test.ts +0 -54
  35. package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
  36. package/src/components/mail/organize/rule-editor-states.stories.tsx +0 -147
  37. package/src/components/mail/organize/rule-editor-states.tsx +0 -139
  38. package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
  39. package/src/hooks/useRuleEditorState.ts +0 -182
  40. package/src/hooks/useSearchFilterSeed.render.test.ts +0 -118
  41. package/src/hooks/useSearchFilterSeed.ts +0 -79
  42. package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
  43. package/src/lib/organize/mobile-organize-flow.ts +0 -73
@@ -1,59 +1,429 @@
1
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
1
2
  import {
2
3
  type ClauseDraft,
3
4
  type ClauseEditState,
5
+ crossAccountDestinationReason,
6
+ crossAccountRuleReason,
7
+ derivePropertyClauses,
8
+ deriveSenderClauses,
9
+ dominantSender,
4
10
  type MatchCount,
5
11
  type MatchMode,
12
+ type MoveMailboxOption,
13
+ type RuleClause,
6
14
  type RunState,
15
+ type SearchConversion,
7
16
  SelectionWizard,
8
17
  type StepId,
18
+ searchConversionNotice,
19
+ senderLabel,
9
20
  stepBlockedReason,
10
21
  stepIndex,
11
22
  stepsFor,
23
+ suggestRuleName,
12
24
  type Verb,
13
25
  type WizardDraft,
14
26
  type WizardMessage,
15
27
  } from "@remit/ui";
16
- import { useCallback, useState } from "react";
17
- import { useWizardStep } from "@/lib/wizard-history";
28
+ import { useQuery } from "@tanstack/react-query";
29
+ import { useBlocker } from "@tanstack/react-router";
30
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
31
+ import {
32
+ useFolderAppointments,
33
+ useJunkMailbox,
34
+ } from "@/hooks/useArchiveMailbox";
35
+ import { useClauseSuggestions } from "@/hooks/useClauseSuggestions";
36
+ import { useCreateMailbox } from "@/hooks/useCreateMailbox";
37
+ import {
38
+ type EscalatedAction,
39
+ useEscalatedActions,
40
+ } from "@/hooks/useEscalatedActions";
41
+ import { useCreateFilter } from "@/hooks/useFilters";
42
+ import { useMatchSample } from "@/hooks/useMatchSample";
43
+ import { useOrganizeJob } from "@/hooks/useOrganizeJob";
44
+ import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
45
+ import { useRulePreview } from "@/hooks/useRulePreview";
46
+ import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
47
+ import type { BulkRunOutcome } from "@/lib/bulk-actions";
48
+ import { getMailboxDisplayName } from "@/lib/folder-roles";
49
+ import { useListHeaderChrome } from "@/lib/list-header-chrome";
50
+ import { useMailContext } from "@/lib/mail-context";
51
+ import { buildMoveTargets } from "@/lib/move-targets";
52
+ import {
53
+ buildWizardDraft,
54
+ canBackApplyDraft,
55
+ type OrganizeDraft,
56
+ type OrganizeScope,
57
+ organizeScopeFor,
58
+ } from "@/lib/organize/organize-model";
59
+ import {
60
+ normalizeClauseValue,
61
+ SUPPORTED_CLAUSE_FIELDS,
62
+ } from "@/lib/organize/rule-model";
63
+ import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
64
+ import { useWizardEntryValue, useWizardStep } from "@/lib/wizard-history";
65
+
66
+ const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "any" };
67
+
68
+ /** A ticked row, as the wizard's samples and its clause prefill read it. */
69
+ export interface WizardSelectionMessage extends WizardMessage {
70
+ /** Sender address — the widen's literal fallback and the prefill match on it. */
71
+ email: string;
72
+ }
73
+
74
+ interface SelectionWizardSessionProps extends SelectionWizardHostProps {
75
+ /** The step the URL holds. The session is mounted only while there is one. */
76
+ step: StepId;
77
+ goToStep: (step: StepId) => void;
78
+ goBack: () => void;
79
+ closeWizard: (steps: readonly StepId[], step: StepId) => void;
80
+ /**
81
+ * The wizard was entered by converting a search rather than by ticking rows
82
+ * (#477 1.8). The conversion seeds the clauses on the properties step and the
83
+ * notice above them, and nothing else: every step after that is the one a
84
+ * selection from the inbox walks (#477 3.4). Absent for the selection bar's
85
+ * entry.
86
+ */
87
+ searchConversion?: SearchConversion;
88
+ }
89
+
90
+ export interface SelectionWizardHostProps {
91
+ /** What the bar was pressed for. Every verb walks the same wizard. */
92
+ verb: Verb;
93
+ /** The account the ticked rows belong to, absent when they span several. */
94
+ accountId?: string;
95
+ /** Where the selection was made, so a run invalidates the listing it changed. */
96
+ mailboxId?: string;
97
+ selection: readonly WizardSelectionMessage[];
98
+ /** The ticked rows span more than one account, so no rule can be created. */
99
+ crossAccount?: boolean;
100
+ /** The wizard is done with the selection, and the list drops it. */
101
+ onFinished: () => void;
102
+ }
103
+
104
+ /**
105
+ * A search entry with no query behind it — a link typed by hand, or one whose
106
+ * query is gone. It opens the same properties step with nothing seeded, which
107
+ * the step already states, rather than a wizard that renders nothing.
108
+ */
109
+ const NO_CONVERSION: SearchConversion = {
110
+ clauses: [],
111
+ matchOperator: "all",
112
+ droppedFacets: [],
113
+ keptTerms: false,
114
+ droppedSemantic: false,
115
+ };
18
116
 
19
- const OPENING_STEP: StepId = "match";
20
- const UNCOUNTED: MatchCount = { status: "uncounted" };
21
- const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "all" };
117
+ /** A search entry has nothing ticked; its match is the query's clauses (#477 3.2). */
118
+ const EMPTY_SELECTION: readonly WizardSelectionMessage[] = [];
22
119
 
23
- /** How the commit ended, once something commits it (#483). */
24
- export interface WizardOutcome {
120
+ /** How far a commit has got, whichever of the three ways it took. */
121
+ interface RunSnapshot {
25
122
  state: RunState;
26
123
  matched: number;
27
124
  applied: number;
125
+ failed: number;
28
126
  failures: readonly WizardMessage[];
29
127
  }
30
128
 
31
- /** Nothing has been sent anywhere, so nothing can have been done. */
32
- const NOTHING_COMMITTED: WizardOutcome = {
33
- state: "commitFailed",
129
+ const NOT_STARTED: RunSnapshot = {
130
+ state: "saving",
34
131
  matched: 0,
35
132
  applied: 0,
133
+ failed: 0,
36
134
  failures: [],
37
135
  };
38
136
 
39
- export interface SelectionWizardHostProps {
40
- verb: Verb;
41
- selectedCount: number;
42
- outcome?: WizardOutcome;
43
- }
137
+ const withIds = (
138
+ clauses: readonly Omit<RuleClause, "id">[],
139
+ prefix: string,
140
+ ): RuleClause[] =>
141
+ clauses.map((clause, index) => ({ ...clause, id: `${prefix}-${index}` }));
142
+
143
+ /**
144
+ * The bulk call a verb makes over a concrete list of ids. Junk is a move: the
145
+ * message-flags API has no `$Junk` field, so the Junk verb files into the
146
+ * account's appointed Junk mailbox, and Move and Organize into the folder the
147
+ * folder step chose.
148
+ */
149
+ const bulkActionFor = (
150
+ verb: Verb,
151
+ moveMailboxId: string | undefined,
152
+ junkMailboxId: string | undefined,
153
+ ): EscalatedAction | undefined => {
154
+ if (verb === "delete") return { kind: "delete" };
155
+ if (verb === "markRead") return { kind: "markRead" };
156
+ const destinationMailboxId = verb === "junk" ? junkMailboxId : moveMailboxId;
157
+ return destinationMailboxId
158
+ ? { kind: "move", destinationMailboxId }
159
+ : undefined;
160
+ };
44
161
 
45
- export function SelectionWizardHost({
162
+ /**
163
+ * Whether a widened match is applied by the server's own pass rather than over
164
+ * the ids it matched. The back-apply job carries a move and nothing else, so
165
+ * Move, Junk and Organize hand it the whole match; Delete and Mark read have no
166
+ * server-side pass and act on the ids the preview matched.
167
+ */
168
+ const widenedRunsAsJob = (verb: Verb): boolean =>
169
+ verb === "move" || verb === "junk" || verb === "organize";
170
+
171
+ /**
172
+ * Where the wizard meets the app (#483). The steps and their bodies belong to
173
+ * `@remit/ui`; everything that talks to a server is here: the widen, the live
174
+ * count, the folder create, the bulk call, the back-apply job and the filter.
175
+ *
176
+ * Every commit routes through `organize-model.ts`, so the four `OrganizeScope`
177
+ * values are reconstructed in one place from the two answers the wizard asks
178
+ * for — what the action covers, and how long it holds.
179
+ *
180
+ * One walk of the wizard, and only that. Everything the walk answers — the door,
181
+ * the draft, whether a commit has been sent — lives here rather than in the host
182
+ * that mounts it, so closing the wizard takes all of it with the screen. Held a
183
+ * level up, on a component the list mounts for as long as it is on screen, the
184
+ * first commit's guard would still be up when the next selection reached Review,
185
+ * and the second one would press a control that does nothing at all.
186
+ */
187
+ function SelectionWizardSession({
46
188
  verb,
47
- selectedCount,
48
- outcome = NOTHING_COMMITTED,
49
- }: SelectionWizardHostProps) {
50
- const { step, goToStep, goBack, closeWizard } = useWizardStep(OPENING_STEP);
51
- const [mode, setMode] = useState<MatchMode>("selected");
52
- const [draft, setDraft] = useState<WizardDraft>(EMPTY_DRAFT);
189
+ accountId,
190
+ mailboxId,
191
+ selection,
192
+ crossAccount = false,
193
+ searchConversion,
194
+ onFinished,
195
+ step,
196
+ goToStep,
197
+ goBack,
198
+ closeWizard,
199
+ }: SelectionWizardSessionProps) {
200
+ // The query as it read when the wizard opened. Held for the walk, so a search
201
+ // still settling underneath cannot rewrite the notice a user is reading while
202
+ // the clauses beside it stay as they were seeded.
203
+ const [conversion] = useState(() => searchConversion);
204
+ const fromSearch = conversion !== undefined;
205
+ const [mode, setMode] = useState<MatchMode>(
206
+ fromSearch ? "properties" : "selected",
207
+ );
208
+ const [draft, setDraft] = useState<WizardDraft>(() =>
209
+ conversion
210
+ ? {
211
+ clauses: withIds(conversion.clauses, "search"),
212
+ matchOperator: conversion.matchOperator,
213
+ }
214
+ : EMPTY_DRAFT,
215
+ );
53
216
  const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>(
54
217
  undefined,
55
218
  );
56
219
  const [nudgedStep, setNudgedStep] = useState<StepId | undefined>(undefined);
220
+ const [semanticFallbackTaken, setSemanticFallbackTaken] = useState(false);
221
+ const [committedScope, setCommittedScope] = useState<
222
+ OrganizeScope | undefined
223
+ >(undefined);
224
+ const [bulkRun, setBulkRun] = useState<
225
+ { matched: number; outcome?: BulkRunOutcome } | undefined
226
+ >(undefined);
227
+ // The predicate the create chains its pass to. Undefined when the rule cannot
228
+ // be back-applied — a `HasWords` clause the vector-free pass cannot evaluate —
229
+ // in which case the filter still saves and applies to incoming mail. Kept, not
230
+ // cleared, so a failed start can be retried.
231
+ const [backApplyDraft, setBackApplyDraft] = useState<OrganizeDraft>();
232
+ const commitSent = useRef(false);
233
+
234
+ const messageIds = useMemo(
235
+ () => selection.map((message) => message.id),
236
+ [selection],
237
+ );
238
+ const senders = useMemo(
239
+ () => selection.map((message) => message.email).filter(Boolean),
240
+ [selection],
241
+ );
242
+ const anchorMessageId = messageIds[0];
243
+ const subjects = useSelectedSubjects(messageIds);
244
+ const { junkMailboxId } = useJunkMailbox(accountId);
245
+
246
+ const widen = useOrganizeWiden(accountId, anchorMessageId, senders);
247
+ const { preview: probeWiden } = widen;
248
+ // The similar door has to know before it is pressed whether it can run, so
249
+ // the probe fires with the wizard rather than with the door (#477 3.6).
250
+ useEffect(() => {
251
+ probeWiden();
252
+ }, [probeWiden]);
253
+
254
+ const widened = mode !== "selected";
255
+ const literalPredicate: OrganizeMatchPredicate = useMemo(
256
+ () => ({
257
+ matchOperator: draft.matchOperator === "all" ? "And" : "Or",
258
+ literalClauses: draft.clauses.map((clause) => ({
259
+ field: clause.field,
260
+ value: clause.value,
261
+ })),
262
+ }),
263
+ [draft.clauses, draft.matchOperator],
264
+ );
265
+ const predicate =
266
+ mode === "similar" ? widen.matchPredicate : literalPredicate;
267
+ // The count and the ids both come from the server, never from paging the list
268
+ // in the browser (#477 5.3). The ticked list is its own count, so no request
269
+ // is made for it.
270
+ const { count: previewCount, matchedIds } = useRulePreview(
271
+ widened ? accountId : undefined,
272
+ predicate,
273
+ );
274
+ const matchSample = useMatchSample(widened ? matchedIds : []);
275
+
276
+ const count: MatchCount = widened
277
+ ? previewCount
278
+ : { status: "ready", count: selection.length };
279
+
280
+ const { data: mailboxesData } = useQuery({
281
+ ...mailboxOperationsListMailboxesOptions({
282
+ path: { accountId: accountId ?? "" },
283
+ }),
284
+ enabled: !!accountId,
285
+ staleTime: Number.POSITIVE_INFINITY,
286
+ });
287
+ const folderAppointments = useFolderAppointments(accountId);
288
+ const mailboxes = useMemo<MoveMailboxOption[]>(
289
+ () =>
290
+ buildMoveTargets(mailboxesData?.items ?? [], folderAppointments).map(
291
+ (mailbox) => ({
292
+ id: mailbox.mailboxId,
293
+ label: getMailboxDisplayName(mailbox.fullPath),
294
+ searchValue: mailbox.fullPath,
295
+ isCurrent: mailbox.mailboxId === mailboxId,
296
+ }),
297
+ ),
298
+ [mailboxesData?.items, folderAppointments, mailboxId],
299
+ );
300
+ const { createFolder } = useCreateMailbox(accountId);
301
+
302
+ const folderLabel = mailboxes.find(
303
+ (mailbox) => mailbox.id === draft.moveMailboxId,
304
+ )?.label;
305
+ const leadSender = dominantSender(
306
+ selection.map((message) => ({
307
+ normalizedEmail: message.email,
308
+ displayName: message.sender,
309
+ })),
310
+ );
311
+ const suggestedName = suggestRuleName({
312
+ match:
313
+ mode === "properties"
314
+ ? draft.clauses[0]?.value.trim() || undefined
315
+ : undefined,
316
+ sender: leadSender ? senderLabel(leadSender) : undefined,
317
+ folder: folderLabel,
318
+ });
319
+ // The name the rule commits under: what the user typed, or the suggestion the
320
+ // field is showing them. The two must be the same string, or Continue blocks
321
+ // on a name that is on screen.
322
+ const named = useMemo<WizardDraft>(
323
+ () => ({ ...draft, name: draft.name ?? suggestedName }),
324
+ [draft, suggestedName],
325
+ );
326
+
327
+ const clauseSuggestions = useClauseSuggestions(
328
+ clauseEdit?.draft.field,
329
+ clauseEdit?.draft.value ?? "",
330
+ senders,
331
+ );
332
+
333
+ const organizeJob = useOrganizeJob(accountId);
334
+ const createFilter = useCreateFilter(accountId);
335
+ const bulk = useEscalatedActions({
336
+ mailboxId: mailboxId ?? "",
337
+ accountId,
338
+ enabled: false,
339
+ predicateKey: "selection-wizard",
340
+ searchQuery: {},
341
+ });
342
+ const { runAction } = bulk;
343
+
344
+ const steps = stepsFor({ verb, mode, scope: draft.scope, fromSearch });
345
+ // The step the screens are on, which is the held one only while the answers
346
+ // still hold it. Reading the URL's step here and the resolved one on screen
347
+ // is how a footer comes to name a screen nobody is looking at.
348
+ const current = steps[stepIndex(steps, step)];
349
+
350
+ // A filter and a folder both belong to one account, so a selection spanning
351
+ // accounts reaches neither and is told so on the step that asks (#477 5.5).
352
+ // The one-off scope acts on the messages themselves and is unaffected.
353
+ const accountScoped = !crossAccount && !!accountId;
354
+ const ruleRestriction = accountScoped ? undefined : crossAccountRuleReason;
355
+ const folderRestriction = accountScoped
356
+ ? undefined
357
+ : crossAccountDestinationReason;
358
+ const restrictionFor = (step: StepId): string | undefined => {
359
+ if (step === "folder") return folderRestriction;
360
+ if (step !== "rule") return undefined;
361
+ return named.scope === "standing" || named.scope === "until"
362
+ ? ruleRestriction
363
+ : undefined;
364
+ };
365
+ const blockedReason =
366
+ restrictionFor(current) ?? stepBlockedReason(current, named, count);
367
+
368
+ const seedPropertyClauses = useCallback(
369
+ () => withIds(derivePropertyClauses(senders, subjects), "seed"),
370
+ [senders, subjects],
371
+ );
372
+
373
+ const changeMode = useCallback(
374
+ (next: MatchMode) => {
375
+ setMode(next);
376
+ setDraft((held) => ({
377
+ ...held,
378
+ widen:
379
+ next === "similar" && anchorMessageId
380
+ ? { anchorCount: Math.max(selection.length, 1) }
381
+ : undefined,
382
+ ...(next === "properties" && held.clauses.length === 0
383
+ ? { clauses: seedPropertyClauses() }
384
+ : {}),
385
+ }));
386
+ },
387
+ [anchorMessageId, selection.length, seedPropertyClauses],
388
+ );
389
+
390
+ // The dimmed similar door, pressed. The senders the widen would have fallen
391
+ // back to are filled in on the property step instead of standing in for the
392
+ // semantic match without saying so (#477 3.6).
393
+ const takeSemanticFallback = useCallback(() => {
394
+ setSemanticFallbackTaken(true);
395
+ setMode("properties");
396
+ setDraft((held) => ({
397
+ ...held,
398
+ widen: undefined,
399
+ clauses: withIds(deriveSenderClauses(senders), "sender"),
400
+ matchOperator: "any",
401
+ }));
402
+ goToStep("properties");
403
+ }, [senders, goToStep]);
404
+
405
+ // The probe lands after the door is on screen, so it can report the widen
406
+ // unavailable while the user is already holding it. The fallback is taken then
407
+ // rather than left as a door that counts nothing: the property step says, in
408
+ // so many words, that these are the senders it substituted (#477 3.6).
409
+ const semanticUnavailable = widen.semanticUnavailable || widen.isError;
410
+ // Only while the door is the screen: the probe re-fires when the anchor
411
+ // changes under a background refetch, and a late answer that moved the step
412
+ // from Review would push an entry the wizard does not own and leave the count
413
+ // it rewinds by wrong.
414
+ useEffect(() => {
415
+ if (current !== "match") return;
416
+ if (mode !== "similar" || !semanticUnavailable || semanticFallbackTaken) {
417
+ return;
418
+ }
419
+ takeSemanticFallback();
420
+ }, [
421
+ current,
422
+ mode,
423
+ semanticUnavailable,
424
+ semanticFallbackTaken,
425
+ takeSemanticFallback,
426
+ ]);
57
427
 
58
428
  const startAddClause = useCallback(() => {
59
429
  setClauseEdit({ mode: "add", draft: { field: "From", value: "" } });
@@ -86,41 +456,263 @@ export function SelectionWizardHost({
86
456
  const submitClause = useCallback(() => {
87
457
  setClauseEdit((edit) => {
88
458
  if (!edit) return undefined;
459
+ const value = normalizeClauseValue(edit.draft.field, edit.draft.value);
460
+ const next = { ...edit.draft, value };
89
461
  setDraft((held) => ({
90
462
  ...held,
91
463
  clauses: edit.clauseId
92
464
  ? held.clauses.map((clause) =>
93
- clause.id === edit.clauseId
94
- ? { ...clause, ...edit.draft }
95
- : clause,
465
+ clause.id === edit.clauseId ? { ...clause, ...next } : clause,
96
466
  )
97
- : [...held.clauses, { id: crypto.randomUUID(), ...edit.draft }],
467
+ : [...held.clauses, { id: crypto.randomUUID(), ...next }],
98
468
  }));
99
469
  return undefined;
100
470
  });
101
471
  }, []);
102
472
 
103
- if (!step) return null;
104
-
105
- const steps = stepsFor({ verb, mode, scope: draft.scope });
106
- // The step the screens are on, which is the held one only while the answers
107
- // still hold it. Reading the URL's step here and the resolved one on screen
108
- // is how a footer comes to name a screen nobody is looking at.
109
- const current = steps[stepIndex(steps, step)];
110
- const blockedReason = stepBlockedReason(current, draft, UNCOUNTED);
111
- const sample = {
112
- messages: [],
113
- count: UNCOUNTED,
114
- label: "Messages this covers",
115
- };
116
-
117
- const advance = () => {
473
+ const advance = useCallback(() => {
118
474
  if (blockedReason) {
119
475
  setNudgedStep(current);
120
476
  return;
121
477
  }
478
+ setNudgedStep(undefined);
122
479
  const next = steps[stepIndex(steps, current) + 1];
123
480
  if (next) goToStep(next);
481
+ }, [blockedReason, current, steps, goToStep]);
482
+
483
+ const runBulk = useCallback(
484
+ async (ids: readonly string[]) => {
485
+ const action = bulkActionFor(verb, named.moveMailboxId, junkMailboxId);
486
+ if (!action) {
487
+ setBulkRun({
488
+ 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
+ },
499
+ });
500
+ return;
501
+ }
502
+ setBulkRun({ matched: ids.length });
503
+ const outcome = await runAction(action, [...ids]);
504
+ setBulkRun({ matched: ids.length, outcome });
505
+ },
506
+ [verb, named.moveMailboxId, junkMailboxId, runAction],
507
+ );
508
+
509
+ const { start: startJob } = organizeJob;
510
+ const { createFilterAsync } = createFilter;
511
+
512
+ const sendCommit = useCallback(() => {
513
+ const scope = organizeScopeFor({ mode, ruleScope: named.scope });
514
+ const organizeDraft = buildWizardDraft({
515
+ mode,
516
+ ruleScope: named.scope,
517
+ anchorMessageId,
518
+ clauses: named.clauses,
519
+ matchOperator: named.matchOperator,
520
+ moveMailboxId: named.moveMailboxId,
521
+ until: named.until,
522
+ });
523
+ setCommittedScope(scope);
524
+
525
+ if (scope === "standing" || scope === "temporary") {
526
+ // Creating a filter also moves the mail that already matches, not only
527
+ // the mail that arrives next. The pass is chained to the create's own
528
+ // request rather than to this screen: the screen offers a Close while
529
+ // the create is still in flight, and a rule that saved with no pass
530
+ // behind it — and nothing left to retry from — is a silent no-op.
531
+ const backApply = canBackApplyDraft(organizeDraft)
532
+ ? organizeDraft
533
+ : undefined;
534
+ setBackApplyDraft(backApply);
535
+ void createFilterAsync(
536
+ organizeDraft,
537
+ scope,
538
+ (named.name ?? "").trim(),
539
+ ).then((created) => {
540
+ if (created && backApply) startJob(backApply);
541
+ });
542
+ return;
543
+ }
544
+ if (scope === "all-like-these" && widenedRunsAsJob(verb)) {
545
+ startJob(organizeDraft);
546
+ return;
547
+ }
548
+ void runBulk(scope === "just-these" ? messageIds : matchedIds);
549
+ }, [
550
+ mode,
551
+ named,
552
+ anchorMessageId,
553
+ verb,
554
+ messageIds,
555
+ matchedIds,
556
+ createFilterAsync,
557
+ startJob,
558
+ runBulk,
559
+ ]);
560
+
561
+ // Two presses land in the same frame before either navigate settles, and both
562
+ // would send. The guard is a ref rather than the committed scope because that
563
+ // is state, and state has not been applied yet by the second press.
564
+ const commit = useCallback(() => {
565
+ if (blockedReason) {
566
+ setNudgedStep(current);
567
+ return;
568
+ }
569
+ if (commitSent.current) return;
570
+ commitSent.current = true;
571
+ goToStep("run");
572
+ sendCommit();
573
+ }, [blockedReason, current, goToStep, sendCommit]);
574
+
575
+ const jobSnapshot = useCallback((): RunSnapshot => {
576
+ const progress = organizeJob.progress;
577
+ const shared = {
578
+ matched: progress.matchedCount,
579
+ applied: progress.appliedCount,
580
+ failures: [],
581
+ };
582
+ if (organizeJob.isError) {
583
+ return { ...NOT_STARTED, state: "commitFailed" };
584
+ }
585
+ if (organizeJob.isDone) {
586
+ return {
587
+ ...shared,
588
+ state:
589
+ progress.failedCount > 0 ? "backApplyFailed" : "backApplyComplete",
590
+ failed: progress.failedCount,
591
+ };
592
+ }
593
+ if (organizeJob.isStarting || organizeJob.isRunning) {
594
+ return { ...shared, state: "backApplyRunning", failed: 0 };
595
+ }
596
+ return NOT_STARTED;
597
+ }, [organizeJob]);
598
+
599
+ const rowsById = useMemo(() => {
600
+ const rows = new Map<string, WizardMessage>();
601
+ for (const message of [...selection, ...matchSample.messages]) {
602
+ rows.set(message.id, message);
603
+ }
604
+ return rows;
605
+ }, [selection, matchSample.messages]);
606
+
607
+ const bulkSnapshot = useCallback((): RunSnapshot => {
608
+ if (!bulkRun) return NOT_STARTED;
609
+ const { matched, outcome } = bulkRun;
610
+ if (!outcome) {
611
+ return {
612
+ state: "backApplyRunning",
613
+ matched,
614
+ applied: bulk.progress?.done ?? 0,
615
+ failed: 0,
616
+ failures: [],
617
+ };
618
+ }
619
+ if (outcome.done === 0 && (outcome.error || outcome.failedIds.length > 0)) {
620
+ return { ...NOT_STARTED, state: "commitFailed" };
621
+ }
622
+ const failures = outcome.failedIds
623
+ .map((id) => rowsById.get(id))
624
+ .filter((message): message is WizardMessage => message !== undefined);
625
+ return {
626
+ state:
627
+ outcome.failedIds.length > 0 ? "backApplyFailed" : "backApplyComplete",
628
+ matched,
629
+ applied: outcome.done,
630
+ failed: outcome.failedIds.length,
631
+ failures,
632
+ };
633
+ }, [bulkRun, bulk.progress, rowsById]);
634
+
635
+ const runSnapshot = (): RunSnapshot => {
636
+ if (committedScope === "standing" || committedScope === "temporary") {
637
+ if (createFilter.isError)
638
+ return { ...NOT_STARTED, state: "commitFailed" };
639
+ if (!createFilter.isSuccess) return NOT_STARTED;
640
+ if (!backApplyDraft) return { ...NOT_STARTED, state: "filterSaved" };
641
+ if (organizeJob.isError) {
642
+ return { ...NOT_STARTED, state: "backApplyStartFailed" };
643
+ }
644
+ return jobSnapshot();
645
+ }
646
+ if (committedScope === "all-like-these" && widenedRunsAsJob(verb)) {
647
+ return jobSnapshot();
648
+ }
649
+ return bulkSnapshot();
650
+ };
651
+
652
+ // Retry stays on the run screen: it re-sends the same commit rather than
653
+ // walking back to Review, which would push an entry the wizard does not own
654
+ // and leave Cancel rewinding to a step instead of out.
655
+ const retry = (): void => {
656
+ if (committedScope === "standing" || committedScope === "temporary") {
657
+ if (createFilter.isError) {
658
+ createFilter.reset();
659
+ sendCommit();
660
+ return;
661
+ }
662
+ if (backApplyDraft) startJob(backApplyDraft);
663
+ return;
664
+ }
665
+ if (committedScope === "all-like-these" && widenedRunsAsJob(verb)) {
666
+ sendCommit();
667
+ return;
668
+ }
669
+ const outstanding = bulkRun?.outcome?.failedIds ?? [];
670
+ void runBulk(outstanding.length > 0 ? outstanding : messageIds);
671
+ };
672
+
673
+ // Cancel rewinds the entries the wizard owns and leaves the selection where
674
+ // it was — nothing has happened to it (#477 1.6).
675
+ const cancel = useCallback(() => {
676
+ closeWizard(steps, current);
677
+ }, [closeWizard, steps, current]);
678
+
679
+ // The run screen's way out. The action has happened, so the rows it was
680
+ // aimed at are no longer a selection waiting to be acted on.
681
+ const dismiss = useCallback(() => {
682
+ closeWizard(steps, current);
683
+ onFinished();
684
+ }, [closeWizard, steps, current, onFinished]);
685
+
686
+ // Hardware back on the run screen leaves the wizard, the movement the header
687
+ // arrow and the footer already make there (`backExits`). Run is an ordinary
688
+ // pushed entry, so without this the button pops back to Review, where the
689
+ // commit is offered a second time; the run screen holds the outcome, which is
690
+ // what makes rewinding the entries under it safe (#477 1.6).
691
+ //
692
+ // The rewind `dismiss` performs is `router.history.go(-N)`, which the history
693
+ // classifies as `"GO"` — so it does not re-enter this blocker and recurse.
694
+ // `selection-wizard-history.spec.ts` walks a real browser back off the run
695
+ // screen, which is what pins that classification.
696
+ useBlocker({
697
+ shouldBlockFn: ({ action }) => {
698
+ if (action !== "BACK") return false;
699
+ dismiss();
700
+ return true;
701
+ },
702
+ enableBeforeUnload: false,
703
+ disabled: current !== "run",
704
+ });
705
+
706
+ const run = runSnapshot();
707
+ const sample = {
708
+ messages: widened ? matchSample.messages : selection,
709
+ count,
710
+ label: widened ? "A sample of what matches" : "Your selection",
711
+ emptyReason:
712
+ mode === "similar" && semanticUnavailable
713
+ ? ("notIndexed" as const)
714
+ : ("noMatch" as const),
715
+ loading: widened && (count.status === "loading" || matchSample.isPending),
124
716
  };
125
717
 
126
718
  return (
@@ -131,19 +723,23 @@ export function SelectionWizardHost({
131
723
  blockedReason={blockedReason}
132
724
  nudged={nudgedStep === current}
133
725
  onBack={goBack}
134
- onExit={() => closeWizard(steps, current)}
726
+ onExit={cancel}
135
727
  onContinue={advance}
136
- onCommit={() => goToStep("run")}
728
+ onCommit={commit}
137
729
  match={{
138
- selectedCount,
730
+ selectedCount: selection.length,
139
731
  mode,
140
- onModeChange: setMode,
141
- onSemanticFallback: () => setMode("properties"),
732
+ onModeChange: changeMode,
733
+ semanticUnavailable,
734
+ semanticErrorDetail:
735
+ widen.error instanceof Error ? widen.error.message : undefined,
736
+ semanticFallbackTaken,
737
+ onSemanticFallback: takeSemanticFallback,
142
738
  sample,
143
739
  }}
144
740
  properties={{
145
- clauses: draft.clauses,
146
- matchOperator: draft.matchOperator,
741
+ clauses: named.clauses,
742
+ matchOperator: named.matchOperator,
147
743
  onMatchOperatorChange: (matchOperator) =>
148
744
  setDraft((held) => ({ ...held, matchOperator })),
149
745
  clauseEdit,
@@ -153,39 +749,100 @@ export function SelectionWizardHost({
153
749
  onChangeDraft: changeClauseDraft,
154
750
  onSubmitClause: submitClause,
155
751
  onCancelClause: () => setClauseEdit(undefined),
156
- sample,
752
+ clauseFields: SUPPORTED_CLAUSE_FIELDS,
753
+ clauseSuggestions,
754
+ conversionNotice: conversion
755
+ ? searchConversionNotice(conversion)
756
+ : undefined,
757
+ semanticFallbackTaken,
758
+ sample: { ...sample, label: "What this matches" },
759
+ }}
760
+ folder={{
761
+ mailboxes,
762
+ mailboxId: named.moveMailboxId,
763
+ onSelect: (moveMailboxId) =>
764
+ setDraft((held) => ({ ...held, moveMailboxId })),
765
+ onCreateFolder: accountId ? createFolder : undefined,
766
+ restriction: folderRestriction,
157
767
  }}
158
768
  rule={{
159
- draft,
769
+ draft: named,
160
770
  onScopeChange: (scope) => setDraft((held) => ({ ...held, scope })),
161
771
  onUntilChange: (until) => setDraft((held) => ({ ...held, until })),
772
+ restriction: ruleRestriction,
162
773
  }}
163
774
  name={{
164
- name: draft.name ?? "",
775
+ name: named.name ?? "",
165
776
  onNameChange: (name) => setDraft((held) => ({ ...held, name })),
166
777
  nudged: nudgedStep === current,
167
778
  }}
168
779
  review={{
169
780
  verb,
170
781
  mode,
171
- selectedCount,
172
- clauses: draft.clauses,
173
- matchOperator: draft.matchOperator,
174
- scope: draft.scope,
175
- until: draft.until,
176
- ruleName: draft.name,
782
+ selectedCount: selection.length,
783
+ clauses: named.clauses,
784
+ matchOperator: named.matchOperator,
785
+ folder: folderLabel,
786
+ scope: named.scope,
787
+ until: named.until,
788
+ ruleName: steps.includes("name") ? named.name : undefined,
177
789
  sample,
178
790
  }}
179
791
  run={{
180
- state: outcome.state,
792
+ state: run.state,
181
793
  verb,
182
- scope: draft.scope,
183
- matched: outcome.matched,
184
- applied: outcome.applied,
185
- failures: outcome.failures,
186
- onRetry: goBack,
187
- onDismiss: () => closeWizard(steps, current),
794
+ scope: named.scope,
795
+ matched: run.matched,
796
+ applied: run.applied,
797
+ failedCount: run.failed,
798
+ failures: run.failures,
799
+ onRetry: retry,
800
+ onDismiss: dismiss,
188
801
  }}
189
802
  />
190
803
  );
191
804
  }
805
+
806
+ /**
807
+ * The wizard's mount point, and the only one: both entries walk this host, so
808
+ * there is one owner of the step in the URL and of the history entries the
809
+ * wizard pushes. The walk itself is a separate component, mounted only while a
810
+ * step is held, so every answer it collects is gone by the time the next one
811
+ * starts.
812
+ *
813
+ * The URL says which affordance opened the wizard, and that decides two things
814
+ * and no more: which step it opens on, and whether the query seeds the clauses.
815
+ * A search entry ticks nothing and its rule belongs to the account the query
816
+ * names, so the selection the bar would have handed over is not the one it
817
+ * walks.
818
+ */
819
+ export function SelectionWizardHost(props: SelectionWizardHostProps) {
820
+ const fromSearch = useWizardEntryValue() === "search";
821
+ const { searchConversion } = useListHeaderChrome();
822
+ const { accounts } = useMailContext();
823
+ const { step, goToStep, goBack, closeWizard } = useWizardStep(
824
+ fromSearch ? "properties" : "match",
825
+ );
826
+ if (!step) return null;
827
+ const conversion = fromSearch
828
+ ? (searchConversion ?? NO_CONVERSION)
829
+ : undefined;
830
+ return (
831
+ <SelectionWizardSession
832
+ {...props}
833
+ {...(conversion
834
+ ? {
835
+ verb: "organize" as const,
836
+ accountId: conversion.targetAccountId ?? accounts[0]?.accountId,
837
+ selection: EMPTY_SELECTION,
838
+ crossAccount: false,
839
+ searchConversion: conversion,
840
+ }
841
+ : {})}
842
+ step={step}
843
+ goToStep={goToStep}
844
+ goBack={goBack}
845
+ closeWizard={closeWizard}
846
+ />
847
+ );
848
+ }