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