@remit/web-client 0.0.64 → 0.0.66
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 +2 -1
- package/src/components/mail/MessageList.tsx +0 -2
- package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -1
- package/src/components/mail/organize/MobileOrganizeFlow.tsx +14 -10
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -1
- package/src/components/mail/organize/OrganizeDialog.tsx +7 -12
- package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +358 -0
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +361 -0
- package/src/components/mail/organize/smart-organize.stories.tsx +57 -104
- package/src/hooks/useRulePreview.ts +86 -0
- package/src/lib/organize/rule-model.test.ts +297 -0
- package/src/lib/organize/rule-model.ts +205 -0
- package/src/lib/organize/sender-fallback.test.ts +64 -1
- package/src/lib/organize/sender-fallback.ts +38 -5
- package/src/components/mail/organize/OrganizePanel.progress.test.ts +0 -113
- package/src/components/mail/organize/OrganizePanel.render.test.ts +0 -151
- package/src/components/mail/organize/OrganizePanel.tsx +0 -420
- package/src/lib/organize/organize-copy.test.ts +0 -134
- package/src/lib/organize/organize-copy.ts +0 -90
|
@@ -1,420 +0,0 @@
|
|
|
1
|
-
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import { Badge, Button, Input, Select } from "@remit/ui";
|
|
3
|
-
import { useQuery } from "@tanstack/react-query";
|
|
4
|
-
import { CheckCircle2, Loader2 } from "lucide-react";
|
|
5
|
-
import { useMemo, useState } from "react";
|
|
6
|
-
import { useCreateFilter } from "@/hooks/useFilters";
|
|
7
|
-
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
8
|
-
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
9
|
-
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
10
|
-
import { buildMoveTargets } from "@/lib/move-targets";
|
|
11
|
-
import { pickedDateToExpiresAt } from "@/lib/organize/filter-status";
|
|
12
|
-
import {
|
|
13
|
-
commitButtonLabel,
|
|
14
|
-
commitDisabledReason,
|
|
15
|
-
scopeActionCount,
|
|
16
|
-
senderFallbackSummary,
|
|
17
|
-
} from "@/lib/organize/organize-copy";
|
|
18
|
-
import type {
|
|
19
|
-
OrganizeDraft,
|
|
20
|
-
OrganizeScope,
|
|
21
|
-
} from "@/lib/organize/organize-model";
|
|
22
|
-
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
23
|
-
|
|
24
|
-
interface OrganizePanelProps {
|
|
25
|
-
accountId: string;
|
|
26
|
-
mailboxId: string;
|
|
27
|
-
selectedMessageIds: string[];
|
|
28
|
-
/**
|
|
29
|
-
* The predicate the widen previewed — the semantic anchor, or the
|
|
30
|
-
* sender-derived literal clauses when the vector pipeline is absent. Every
|
|
31
|
-
* commit scope carries exactly this, so the previewed set equals the set the
|
|
32
|
-
* scope acts on.
|
|
33
|
-
*/
|
|
34
|
-
matchPredicate: OrganizeMatchPredicate;
|
|
35
|
-
/** Similar messages the widen preview matched. */
|
|
36
|
-
matchedCount: number;
|
|
37
|
-
/**
|
|
38
|
-
* Which scope is selected on open. Defaults to `all-like-these`; the
|
|
39
|
-
* zero-match fallback and a "Something else" seed override it.
|
|
40
|
-
*/
|
|
41
|
-
initialScope?: OrganizeScope;
|
|
42
|
-
/**
|
|
43
|
-
* Destination folder to pre-select — a "Something else" shortcut or a
|
|
44
|
-
* plain-language input seeds this (issue #211).
|
|
45
|
-
*/
|
|
46
|
-
seedMailboxId?: string;
|
|
47
|
-
/**
|
|
48
|
-
* The widen matched nothing, so the sentence organizes just the selection
|
|
49
|
-
* rather than dead-ending. Changes the heading and defaults the scope to
|
|
50
|
-
* `just-these`.
|
|
51
|
-
*/
|
|
52
|
-
fallback?: boolean;
|
|
53
|
-
/**
|
|
54
|
-
* The server ran no semantic widen because this deployment ships no vector
|
|
55
|
-
* pipeline. With senders to match on ({@link senders}) the widen fell back to
|
|
56
|
-
* matching all mail from those senders; with none it organizes just the
|
|
57
|
-
* selection. Either way the heading names the real reason, never "similar".
|
|
58
|
-
*/
|
|
59
|
-
semanticUnavailable?: boolean;
|
|
60
|
-
/**
|
|
61
|
-
* Distinct sender addresses driving the sender fallback. Non-empty only when
|
|
62
|
-
* {@link semanticUnavailable} and the fallback produced a literal match set;
|
|
63
|
-
* named in the heading and folder line so the copy states the real semantics.
|
|
64
|
-
*/
|
|
65
|
-
senders?: string[];
|
|
66
|
-
onClose: () => void;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const SCOPE_OPTIONS: { id: OrganizeScope; label: string; caption: string }[] = [
|
|
70
|
-
{
|
|
71
|
-
id: "just-these",
|
|
72
|
-
label: "Just these",
|
|
73
|
-
caption: "the ones you selected",
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
id: "all-like-these",
|
|
77
|
-
label: "All like these",
|
|
78
|
-
caption: "including the similar ones found now",
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
id: "standing",
|
|
82
|
-
label: "These and new mail like this",
|
|
83
|
-
caption: "keeps working on future mail",
|
|
84
|
-
},
|
|
85
|
-
{
|
|
86
|
-
id: "temporary",
|
|
87
|
-
label: "Until a date",
|
|
88
|
-
caption: "stops on its own",
|
|
89
|
-
},
|
|
90
|
-
];
|
|
91
|
-
|
|
92
|
-
export function OrganizePanel({
|
|
93
|
-
accountId,
|
|
94
|
-
mailboxId,
|
|
95
|
-
selectedMessageIds,
|
|
96
|
-
matchPredicate,
|
|
97
|
-
matchedCount,
|
|
98
|
-
initialScope,
|
|
99
|
-
seedMailboxId,
|
|
100
|
-
fallback = false,
|
|
101
|
-
semanticUnavailable = false,
|
|
102
|
-
senders = [],
|
|
103
|
-
onClose,
|
|
104
|
-
}: OrganizePanelProps) {
|
|
105
|
-
// The vector pipeline is absent but the selection has senders to match on:
|
|
106
|
-
// the widen fell back to sender matching, which produces a real widened set
|
|
107
|
-
// the commit scopes reach — unlike a missing or empty widen.
|
|
108
|
-
const senderFallback = semanticUnavailable && senders.length > 0;
|
|
109
|
-
// A missing widen and an empty widen both organize just the selection; only
|
|
110
|
-
// the heading distinguishes them. The sender fallback is a real widen.
|
|
111
|
-
const noWiden = (fallback || semanticUnavailable) && !senderFallback;
|
|
112
|
-
const [scope, setScope] = useState<OrganizeScope>(
|
|
113
|
-
initialScope ?? (noWiden ? "just-these" : "all-like-these"),
|
|
114
|
-
);
|
|
115
|
-
const [moveMailboxId, setMoveMailboxId] = useState<string>(
|
|
116
|
-
seedMailboxId ?? "",
|
|
117
|
-
);
|
|
118
|
-
const [name, setName] = useState("");
|
|
119
|
-
const [pickedDate, setPickedDate] = useState("");
|
|
120
|
-
|
|
121
|
-
const { data: mailboxesData } = useQuery({
|
|
122
|
-
...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
123
|
-
staleTime: Infinity,
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
const folderOptions = useMemo(() => {
|
|
127
|
-
const targets = buildMoveTargets(mailboxesData?.items ?? []);
|
|
128
|
-
return targets.map((mailbox) => ({
|
|
129
|
-
id: mailbox.mailboxId,
|
|
130
|
-
label: getMailboxDisplayName(mailbox.fullPath),
|
|
131
|
-
}));
|
|
132
|
-
}, [mailboxesData?.items]);
|
|
133
|
-
|
|
134
|
-
const draft: OrganizeDraft = useMemo(
|
|
135
|
-
() => ({
|
|
136
|
-
...matchPredicate,
|
|
137
|
-
moveMailboxId: moveMailboxId || undefined,
|
|
138
|
-
expiresAt:
|
|
139
|
-
scope === "temporary" ? pickedDateToExpiresAt(pickedDate) : undefined,
|
|
140
|
-
}),
|
|
141
|
-
[matchPredicate, moveMailboxId, scope, pickedDate],
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
const { moveMessages } = useMoveMessages({ mailboxId, accountId });
|
|
145
|
-
const organizeJob = useOrganizeJob(accountId);
|
|
146
|
-
const createFilter = useCreateFilter(accountId);
|
|
147
|
-
|
|
148
|
-
const selectionCount = selectedMessageIds.length;
|
|
149
|
-
const actionCount = scopeActionCount(scope, selectionCount, matchedCount);
|
|
150
|
-
|
|
151
|
-
const disabledReason = commitDisabledReason({
|
|
152
|
-
draft,
|
|
153
|
-
scope,
|
|
154
|
-
name,
|
|
155
|
-
pickedDate,
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
const handleCommit = () => {
|
|
159
|
-
if (disabledReason) return;
|
|
160
|
-
switch (scope) {
|
|
161
|
-
case "just-these":
|
|
162
|
-
if (moveMailboxId) moveMessages(selectedMessageIds, moveMailboxId);
|
|
163
|
-
onClose();
|
|
164
|
-
return;
|
|
165
|
-
case "all-like-these":
|
|
166
|
-
organizeJob.start(draft);
|
|
167
|
-
return;
|
|
168
|
-
case "standing":
|
|
169
|
-
createFilter.createFilter(draft, "standing", name.trim());
|
|
170
|
-
return;
|
|
171
|
-
case "temporary":
|
|
172
|
-
createFilter.createFilter(draft, "temporary", name.trim());
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
if (organizeJob.isRunning || organizeJob.isDone) {
|
|
178
|
-
return (
|
|
179
|
-
<JobProgress
|
|
180
|
-
progress={organizeJob.progress}
|
|
181
|
-
isDone={organizeJob.isDone}
|
|
182
|
-
senderFallback={senderFallback}
|
|
183
|
-
onClose={onClose}
|
|
184
|
-
/>
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (createFilter.isSuccess) {
|
|
189
|
-
return (
|
|
190
|
-
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
191
|
-
<CheckCircle2 className="size-8 text-positive" />
|
|
192
|
-
<p className="text-sm font-medium text-fg">Filter saved</p>
|
|
193
|
-
<p className="max-w-xs text-xs text-fg-muted">
|
|
194
|
-
You can see it, and when it expires, under Settings › Filters.
|
|
195
|
-
</p>
|
|
196
|
-
<Button variant="primary" onClick={onClose} className="mt-2">
|
|
197
|
-
Done
|
|
198
|
-
</Button>
|
|
199
|
-
</div>
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const needsName = scope === "standing" || scope === "temporary";
|
|
204
|
-
|
|
205
|
-
return (
|
|
206
|
-
<div className="flex min-h-0 flex-col">
|
|
207
|
-
<div className="border-b border-line px-5 py-3">
|
|
208
|
-
<h2 className="text-sm font-semibold text-fg">Organize</h2>
|
|
209
|
-
<p className="mt-0.5 text-xs text-fg-muted">
|
|
210
|
-
{senderFallback
|
|
211
|
-
? `${senderFallbackSummary(senders)} ${matchedCount} match${
|
|
212
|
-
matchedCount === 1 ? "" : "es"
|
|
213
|
-
}.`
|
|
214
|
-
: semanticUnavailable
|
|
215
|
-
? `Finding similar mail isn't available on this server — organizing just your ${selectionCount} selected.`
|
|
216
|
-
: fallback
|
|
217
|
-
? `No similar messages found — organizing just your ${selectionCount} selected.`
|
|
218
|
-
: `${matchedCount} similar message${matchedCount === 1 ? "" : "s"} found${
|
|
219
|
-
selectionCount > 0 ? ` from ${selectionCount} selected` : ""
|
|
220
|
-
}.`}
|
|
221
|
-
</p>
|
|
222
|
-
</div>
|
|
223
|
-
|
|
224
|
-
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">
|
|
225
|
-
<section className="space-y-2 rounded-lg border border-line bg-surface-sunken p-3">
|
|
226
|
-
<p className="text-sm leading-relaxed text-fg-muted">
|
|
227
|
-
{scope === "standing" && (
|
|
228
|
-
<span className="font-semibold text-fg">Always keep </span>
|
|
229
|
-
)}
|
|
230
|
-
{scope === "standing" ? "" : "Keep "}
|
|
231
|
-
{scope === "just-these"
|
|
232
|
-
? "these"
|
|
233
|
-
: senderFallback
|
|
234
|
-
? "mail from these senders"
|
|
235
|
-
: "emails like these"}{" "}
|
|
236
|
-
in
|
|
237
|
-
</p>
|
|
238
|
-
<Select
|
|
239
|
-
aria-label="Destination folder"
|
|
240
|
-
value={moveMailboxId}
|
|
241
|
-
onChange={(e) => setMoveMailboxId(e.target.value)}
|
|
242
|
-
>
|
|
243
|
-
<option value="">Choose a folder…</option>
|
|
244
|
-
{folderOptions.map((option) => (
|
|
245
|
-
<option key={option.id} value={option.id}>
|
|
246
|
-
{option.label}
|
|
247
|
-
</option>
|
|
248
|
-
))}
|
|
249
|
-
</Select>
|
|
250
|
-
|
|
251
|
-
<div className="flex items-center gap-2 pt-1">
|
|
252
|
-
<Badge tone="neutral">label them…</Badge>
|
|
253
|
-
<span className="text-2xs text-fg-subtle">
|
|
254
|
-
Labeling isn't available yet — arrives with mail-labeling (RFC
|
|
255
|
-
031).
|
|
256
|
-
</span>
|
|
257
|
-
</div>
|
|
258
|
-
</section>
|
|
259
|
-
|
|
260
|
-
<section className="space-y-1.5">
|
|
261
|
-
{SCOPE_OPTIONS.map((option) => {
|
|
262
|
-
const active = scope === option.id;
|
|
263
|
-
const caption =
|
|
264
|
-
senderFallback && option.id === "all-like-these"
|
|
265
|
-
? "including others from these senders"
|
|
266
|
-
: option.caption;
|
|
267
|
-
return (
|
|
268
|
-
<button
|
|
269
|
-
key={option.id}
|
|
270
|
-
type="button"
|
|
271
|
-
onClick={() => setScope(option.id)}
|
|
272
|
-
className={`flex w-full items-start gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors ${
|
|
273
|
-
active
|
|
274
|
-
? "border-accent-2 bg-accent-2-soft"
|
|
275
|
-
: "border-line bg-surface hover:bg-surface-sunken"
|
|
276
|
-
}`}
|
|
277
|
-
>
|
|
278
|
-
<span
|
|
279
|
-
className={`mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border ${
|
|
280
|
-
active
|
|
281
|
-
? "border-accent-2 bg-accent-2"
|
|
282
|
-
: "border-line bg-surface"
|
|
283
|
-
}`}
|
|
284
|
-
>
|
|
285
|
-
{active && (
|
|
286
|
-
<span className="size-1.5 rounded-full bg-white" />
|
|
287
|
-
)}
|
|
288
|
-
</span>
|
|
289
|
-
<span className="min-w-0">
|
|
290
|
-
<span
|
|
291
|
-
className={`block text-sm font-medium ${
|
|
292
|
-
active ? "text-accent-2" : "text-fg"
|
|
293
|
-
}`}
|
|
294
|
-
>
|
|
295
|
-
{option.label}
|
|
296
|
-
</span>
|
|
297
|
-
<span className="block text-xs text-fg-subtle">
|
|
298
|
-
{caption}
|
|
299
|
-
</span>
|
|
300
|
-
</span>
|
|
301
|
-
</button>
|
|
302
|
-
);
|
|
303
|
-
})}
|
|
304
|
-
</section>
|
|
305
|
-
|
|
306
|
-
{needsName && (
|
|
307
|
-
<section className="space-y-2">
|
|
308
|
-
<Input
|
|
309
|
-
value={name}
|
|
310
|
-
onChange={(e) => setName(e.target.value)}
|
|
311
|
-
placeholder="Name this filter (e.g. Travel)"
|
|
312
|
-
aria-label="Filter name"
|
|
313
|
-
/>
|
|
314
|
-
{scope === "temporary" && (
|
|
315
|
-
<label className="flex items-center gap-2 text-xs text-fg-muted">
|
|
316
|
-
Until
|
|
317
|
-
<input
|
|
318
|
-
type="date"
|
|
319
|
-
value={pickedDate}
|
|
320
|
-
onChange={(e) => setPickedDate(e.target.value)}
|
|
321
|
-
aria-label="Expiry date"
|
|
322
|
-
className="rounded-md border border-line bg-surface-sunken px-2 py-1 text-sm text-fg"
|
|
323
|
-
/>
|
|
324
|
-
</label>
|
|
325
|
-
)}
|
|
326
|
-
</section>
|
|
327
|
-
)}
|
|
328
|
-
</div>
|
|
329
|
-
|
|
330
|
-
<div className="space-y-2 border-t border-line px-5 py-3">
|
|
331
|
-
{disabledReason && (
|
|
332
|
-
// biome-ignore lint/a11y/useSemanticElements: <p> with role="status" keeps block layout; <output> is inline
|
|
333
|
-
<p className="text-xs text-fg-subtle" role="status">
|
|
334
|
-
{disabledReason}
|
|
335
|
-
</p>
|
|
336
|
-
)}
|
|
337
|
-
{createFilter.isError && (
|
|
338
|
-
<p className="text-xs text-danger" role="alert">
|
|
339
|
-
Couldn't save the filter. Please try again.
|
|
340
|
-
</p>
|
|
341
|
-
)}
|
|
342
|
-
<Button
|
|
343
|
-
variant="primary"
|
|
344
|
-
onClick={handleCommit}
|
|
345
|
-
disabled={
|
|
346
|
-
!!disabledReason || createFilter.isPending || organizeJob.isStarting
|
|
347
|
-
}
|
|
348
|
-
className="w-full"
|
|
349
|
-
>
|
|
350
|
-
{createFilter.isPending
|
|
351
|
-
? "Saving…"
|
|
352
|
-
: commitButtonLabel(scope, actionCount)}
|
|
353
|
-
</Button>
|
|
354
|
-
<Button variant="ghost" onClick={onClose} className="w-full">
|
|
355
|
-
Not now
|
|
356
|
-
</Button>
|
|
357
|
-
</div>
|
|
358
|
-
</div>
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function JobProgress({
|
|
363
|
-
progress,
|
|
364
|
-
isDone,
|
|
365
|
-
senderFallback,
|
|
366
|
-
onClose,
|
|
367
|
-
}: {
|
|
368
|
-
progress: ReturnType<typeof useOrganizeJob>["progress"];
|
|
369
|
-
isDone: boolean;
|
|
370
|
-
senderFallback: boolean;
|
|
371
|
-
onClose: () => void;
|
|
372
|
-
}) {
|
|
373
|
-
const failed = progress.state === "Failed";
|
|
374
|
-
return (
|
|
375
|
-
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
376
|
-
{!isDone ? (
|
|
377
|
-
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
378
|
-
) : failed ? (
|
|
379
|
-
<span className="text-sm font-semibold text-danger">
|
|
380
|
-
Organize failed
|
|
381
|
-
</span>
|
|
382
|
-
) : (
|
|
383
|
-
<CheckCircle2 className="size-8 text-positive" />
|
|
384
|
-
)}
|
|
385
|
-
|
|
386
|
-
{!isDone && (
|
|
387
|
-
<p className="text-sm font-medium text-fg">
|
|
388
|
-
{senderFallback
|
|
389
|
-
? "Organizing mail from these senders…"
|
|
390
|
-
: "Organizing similar mail…"}
|
|
391
|
-
</p>
|
|
392
|
-
)}
|
|
393
|
-
|
|
394
|
-
{isDone && !failed && (
|
|
395
|
-
<div className="text-sm text-fg">
|
|
396
|
-
<p className="font-medium">Done</p>
|
|
397
|
-
<p className="mt-1 text-xs text-fg-muted">
|
|
398
|
-
{progress.appliedCount} of {progress.matchedCount} moved
|
|
399
|
-
{progress.failedCount > 0
|
|
400
|
-
? ` · ${progress.failedCount} failed`
|
|
401
|
-
: ""}
|
|
402
|
-
.
|
|
403
|
-
</p>
|
|
404
|
-
</div>
|
|
405
|
-
)}
|
|
406
|
-
|
|
407
|
-
{isDone && failed && (
|
|
408
|
-
<p className="max-w-xs text-xs text-fg-muted">
|
|
409
|
-
{progress.errorMessage || "Something went wrong. Please try again."}
|
|
410
|
-
</p>
|
|
411
|
-
)}
|
|
412
|
-
|
|
413
|
-
{isDone && (
|
|
414
|
-
<Button variant="primary" onClick={onClose} className="mt-2">
|
|
415
|
-
Done
|
|
416
|
-
</Button>
|
|
417
|
-
)}
|
|
418
|
-
</div>
|
|
419
|
-
);
|
|
420
|
-
}
|
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
commitButtonLabel,
|
|
5
|
-
commitDisabledReason,
|
|
6
|
-
formatSenderList,
|
|
7
|
-
scopeActionCount,
|
|
8
|
-
senderFallbackSummary,
|
|
9
|
-
} from "./organize-copy";
|
|
10
|
-
import type { OrganizeDraft } from "./organize-model";
|
|
11
|
-
|
|
12
|
-
const draft = (overrides: Partial<OrganizeDraft> = {}): OrganizeDraft => ({
|
|
13
|
-
matchOperator: "And",
|
|
14
|
-
literalClauses: [],
|
|
15
|
-
...overrides,
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
describe("commitDisabledReason", () => {
|
|
19
|
-
it("blocks with a labeling explanation when no folder is chosen", () => {
|
|
20
|
-
const reason = commitDisabledReason({
|
|
21
|
-
draft: draft(),
|
|
22
|
-
scope: "just-these",
|
|
23
|
-
name: "",
|
|
24
|
-
pickedDate: "",
|
|
25
|
-
});
|
|
26
|
-
assert.match(reason ?? "", /Pick a folder/);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("requires a name for a standing filter", () => {
|
|
30
|
-
const reason = commitDisabledReason({
|
|
31
|
-
draft: draft({ moveMailboxId: "mbx-1" }),
|
|
32
|
-
scope: "standing",
|
|
33
|
-
name: " ",
|
|
34
|
-
pickedDate: "",
|
|
35
|
-
});
|
|
36
|
-
assert.match(reason ?? "", /Name this filter/);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("requires a date for a temporary filter", () => {
|
|
40
|
-
const reason = commitDisabledReason({
|
|
41
|
-
draft: draft({ moveMailboxId: "mbx-1" }),
|
|
42
|
-
scope: "temporary",
|
|
43
|
-
name: "Trip",
|
|
44
|
-
pickedDate: "",
|
|
45
|
-
});
|
|
46
|
-
assert.match(reason ?? "", /Pick the date/);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("is undefined when a move target is set for a one-time scope", () => {
|
|
50
|
-
assert.equal(
|
|
51
|
-
commitDisabledReason({
|
|
52
|
-
draft: draft({ moveMailboxId: "mbx-1" }),
|
|
53
|
-
scope: "all-like-these",
|
|
54
|
-
name: "",
|
|
55
|
-
pickedDate: "",
|
|
56
|
-
}),
|
|
57
|
-
undefined,
|
|
58
|
-
);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("is undefined for a fully-specified temporary filter", () => {
|
|
62
|
-
assert.equal(
|
|
63
|
-
commitDisabledReason({
|
|
64
|
-
draft: draft({ moveMailboxId: "mbx-1" }),
|
|
65
|
-
scope: "temporary",
|
|
66
|
-
name: "Trip",
|
|
67
|
-
pickedDate: "2026-07-16",
|
|
68
|
-
}),
|
|
69
|
-
undefined,
|
|
70
|
-
);
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
describe("commitButtonLabel", () => {
|
|
75
|
-
it("pluralizes the one-time labels", () => {
|
|
76
|
-
assert.equal(commitButtonLabel("just-these", 1), "Move 1 message");
|
|
77
|
-
assert.equal(
|
|
78
|
-
commitButtonLabel("all-like-these", 48),
|
|
79
|
-
"Organize 48 messages",
|
|
80
|
-
);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("uses standing copy for the persisted scopes", () => {
|
|
84
|
-
assert.equal(commitButtonLabel("standing", 48), "Always do this");
|
|
85
|
-
assert.equal(commitButtonLabel("temporary", 48), "Do this until then");
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
describe("scopeActionCount", () => {
|
|
90
|
-
it("uses the selection for just-these and the match set otherwise", () => {
|
|
91
|
-
assert.equal(scopeActionCount("just-these", 3, 48), 3);
|
|
92
|
-
assert.equal(scopeActionCount("all-like-these", 3, 48), 48);
|
|
93
|
-
assert.equal(scopeActionCount("standing", 3, 48), 48);
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
describe("formatSenderList", () => {
|
|
98
|
-
it("names a single sender bare", () => {
|
|
99
|
-
assert.equal(formatSenderList(["npm@github.com"]), "npm@github.com");
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("joins a couple with 'and'", () => {
|
|
103
|
-
assert.equal(
|
|
104
|
-
formatSenderList(["a@x.com", "b@y.com"]),
|
|
105
|
-
"a@x.com and b@y.com",
|
|
106
|
-
);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("lists up to three in full", () => {
|
|
110
|
-
assert.equal(
|
|
111
|
-
formatSenderList(["a@x.com", "b@y.com", "c@z.com"]),
|
|
112
|
-
"a@x.com, b@y.com and c@z.com",
|
|
113
|
-
);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("truncates past three, summing the rest", () => {
|
|
117
|
-
assert.equal(
|
|
118
|
-
formatSenderList(["a@x.com", "b@y.com", "c@z.com", "d@w.com"]),
|
|
119
|
-
"a@x.com, b@y.com, c@z.com and 1 other",
|
|
120
|
-
);
|
|
121
|
-
assert.equal(
|
|
122
|
-
formatSenderList(["a@x.com", "b@y.com", "c@z.com", "d@w.com", "e@v.com"]),
|
|
123
|
-
"a@x.com, b@y.com, c@z.com and 2 others",
|
|
124
|
-
);
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
describe("senderFallbackSummary", () => {
|
|
129
|
-
it("states it is matching all mail from the senders, never 'similar'", () => {
|
|
130
|
-
const summary = senderFallbackSummary(["npm@github.com"]);
|
|
131
|
-
assert.match(summary, /isn't available on this server/);
|
|
132
|
-
assert.match(summary, /matching all mail from npm@github\.com instead/);
|
|
133
|
-
});
|
|
134
|
-
});
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import type { OrganizeScope } from "./organize-model";
|
|
2
|
-
import { hasCommittableAction, type OrganizeDraft } from "./organize-model";
|
|
3
|
-
|
|
4
|
-
export interface CommitContext {
|
|
5
|
-
draft: OrganizeDraft;
|
|
6
|
-
scope: OrganizeScope;
|
|
7
|
-
/** The filter name, required for the two standing scopes. */
|
|
8
|
-
name: string;
|
|
9
|
-
/** The raw picked date (`YYYY-MM-DD`), required for the temporary scope. */
|
|
10
|
-
pickedDate: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Why the commit button is disabled, or `undefined` when it is actionable.
|
|
15
|
-
* Never disable a control without saying why (ux.md), so the caller renders
|
|
16
|
-
* this string next to the button.
|
|
17
|
-
*/
|
|
18
|
-
export const commitDisabledReason = ({
|
|
19
|
-
draft,
|
|
20
|
-
scope,
|
|
21
|
-
name,
|
|
22
|
-
pickedDate,
|
|
23
|
-
}: CommitContext): string | undefined => {
|
|
24
|
-
if (!hasCommittableAction(draft)) {
|
|
25
|
-
return "Pick a folder to move these into — labeling isn't available yet.";
|
|
26
|
-
}
|
|
27
|
-
if ((scope === "standing" || scope === "temporary") && name.trim() === "") {
|
|
28
|
-
return "Name this filter so you can find it later.";
|
|
29
|
-
}
|
|
30
|
-
if (scope === "temporary" && pickedDate === "") {
|
|
31
|
-
return "Pick the date this should stop on.";
|
|
32
|
-
}
|
|
33
|
-
return undefined;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
/** The commit button label for each scope. */
|
|
37
|
-
export const commitButtonLabel = (
|
|
38
|
-
scope: OrganizeScope,
|
|
39
|
-
matchedTotal: number,
|
|
40
|
-
): string => {
|
|
41
|
-
switch (scope) {
|
|
42
|
-
case "just-these":
|
|
43
|
-
return `Move ${matchedTotal} message${matchedTotal === 1 ? "" : "s"}`;
|
|
44
|
-
case "all-like-these":
|
|
45
|
-
return `Organize ${matchedTotal} message${matchedTotal === 1 ? "" : "s"}`;
|
|
46
|
-
case "standing":
|
|
47
|
-
return "Always do this";
|
|
48
|
-
case "temporary":
|
|
49
|
-
return "Do this until then";
|
|
50
|
-
}
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* The count a scope acts on: the current selection for "just these", the
|
|
55
|
-
* widened match set for every scope that reaches similar mail.
|
|
56
|
-
*/
|
|
57
|
-
export const scopeActionCount = (
|
|
58
|
-
scope: OrganizeScope,
|
|
59
|
-
selectionCount: number,
|
|
60
|
-
matchedCount: number,
|
|
61
|
-
): number => (scope === "just-these" ? selectionCount : matchedCount);
|
|
62
|
-
|
|
63
|
-
/** How many sender addresses are named before the rest are summed as "N others". */
|
|
64
|
-
const MAX_SENDERS_SHOWN = 3;
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* The sender addresses read out in prose: all of them when there are a few, or
|
|
68
|
-
* the first {@link MAX_SENDERS_SHOWN} and a count of the rest so a long selection
|
|
69
|
-
* stays legible in the sheet.
|
|
70
|
-
*/
|
|
71
|
-
export const formatSenderList = (senders: readonly string[]): string => {
|
|
72
|
-
if (senders.length === 0) return "these senders";
|
|
73
|
-
if (senders.length === 1) return senders[0];
|
|
74
|
-
if (senders.length <= MAX_SENDERS_SHOWN) {
|
|
75
|
-
return `${senders.slice(0, -1).join(", ")} and ${senders[senders.length - 1]}`;
|
|
76
|
-
}
|
|
77
|
-
const shown = senders.slice(0, MAX_SENDERS_SHOWN);
|
|
78
|
-
const rest = senders.length - MAX_SENDERS_SHOWN;
|
|
79
|
-
return `${shown.join(", ")} and ${rest} other${rest === 1 ? "" : "s"}`;
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* The heading when the widen fell back to sender matching (no vector pipeline on
|
|
84
|
-
* this server). States the actual semantics — matching every mail from these
|
|
85
|
-
* senders — and never claims semantic similarity.
|
|
86
|
-
*/
|
|
87
|
-
export const senderFallbackSummary = (senders: readonly string[]): string =>
|
|
88
|
-
`Similar-mail matching isn't available on this server — matching all mail from ${formatSenderList(
|
|
89
|
-
senders,
|
|
90
|
-
)} instead.`;
|