@remit/web-client 0.0.65 → 0.0.67
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/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 +39 -119
- package/src/components/settings/FilterEditor.render.test.ts +281 -0
- package/src/components/settings/FilterEditor.tsx +338 -0
- package/src/components/settings/FilterEditorSurface.tsx +52 -0
- package/src/components/settings/FiltersList.render.test.ts +27 -1
- package/src/components/settings/FiltersList.tsx +33 -5
- package/src/components/settings/settings-filter.stories.tsx +165 -0
- package/src/hooks/useRulePreview.ts +91 -0
- package/src/hooks/useUpdateFilter.ts +56 -0
- package/src/lib/organize/filter-edit-model.test.ts +175 -0
- package/src/lib/organize/filter-edit-model.ts +106 -0
- package/src/lib/organize/rule-model.test.ts +297 -0
- package/src/lib/organize/rule-model.ts +205 -0
- package/src/routes/settings/filters.tsx +26 -1
- package/src/components/mail/organize/OrganizePanel.progress.test.ts +0 -113
- package/src/components/mail/organize/OrganizePanel.render.test.ts +0 -170
- package/src/components/mail/organize/OrganizePanel.tsx +0 -420
- package/src/lib/organize/organize-copy.test.ts +0 -142
- package/src/lib/organize/organize-copy.ts +0 -95
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The back-apply progress copy. On a deployment without the vector pipeline the
|
|
3
|
-
* "all like these" job moves the sender-matched set, so the in-progress line must
|
|
4
|
-
* state the sender semantics — never "similar mail" (#250's honesty
|
|
5
|
-
* requirement). The semantic path keeps the "similar mail" wording.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import assert from "node:assert/strict";
|
|
9
|
-
import { afterEach, describe, it } from "node:test";
|
|
10
|
-
import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
11
|
-
import { createElement } from "react";
|
|
12
|
-
import type { OrganizeMatchPredicate } from "../../../lib/organize/sender-fallback";
|
|
13
|
-
import { createDomHarness, type DomHarness } from "../../../test-support/dom";
|
|
14
|
-
import { makeMailbox } from "../../../test-support/fixtures";
|
|
15
|
-
import { type HttpMock, mockFetch } from "../../../test-support/http";
|
|
16
|
-
import { OrganizePanel } from "./OrganizePanel";
|
|
17
|
-
|
|
18
|
-
let harness: DomHarness | undefined;
|
|
19
|
-
let http: HttpMock | undefined;
|
|
20
|
-
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
harness?.close();
|
|
23
|
-
harness = undefined;
|
|
24
|
-
http?.restore();
|
|
25
|
-
http = undefined;
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
const ACCOUNT_ID = "acc-1";
|
|
29
|
-
|
|
30
|
-
const MAILBOXES = [
|
|
31
|
-
makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
|
|
32
|
-
makeMailbox({ mailboxId: "mbx-archive", fullPath: "Archive" }),
|
|
33
|
-
];
|
|
34
|
-
|
|
35
|
-
const SEMANTIC_PREDICATE: OrganizeMatchPredicate = {
|
|
36
|
-
anchorMessageId: "msg-1",
|
|
37
|
-
matchOperator: "And",
|
|
38
|
-
literalClauses: [],
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const SENDER_PREDICATE: OrganizeMatchPredicate = {
|
|
42
|
-
matchOperator: "Or",
|
|
43
|
-
literalClauses: [{ field: "From", value: "npm@github.com" }],
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const mount = (
|
|
47
|
-
props: Partial<Parameters<typeof OrganizePanel>[0]>,
|
|
48
|
-
): DomHarness => {
|
|
49
|
-
http = mockFetch((call) => {
|
|
50
|
-
if (call.path.endsWith("/organize") && call.method === "POST") {
|
|
51
|
-
return { organizeJobId: "job-1", state: "Processing" };
|
|
52
|
-
}
|
|
53
|
-
if (call.path.endsWith("/organize/job-1") && call.method === "GET") {
|
|
54
|
-
return {
|
|
55
|
-
organizeJobId: "job-1",
|
|
56
|
-
state: "Processing",
|
|
57
|
-
matchedCount: 128,
|
|
58
|
-
appliedCount: 0,
|
|
59
|
-
failedCount: 0,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
return {};
|
|
63
|
-
});
|
|
64
|
-
harness = createDomHarness();
|
|
65
|
-
harness.queryClient.setQueryData(
|
|
66
|
-
mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT_ID } }),
|
|
67
|
-
{ items: MAILBOXES },
|
|
68
|
-
);
|
|
69
|
-
harness.renderApp(
|
|
70
|
-
createElement(OrganizePanel, {
|
|
71
|
-
accountId: ACCOUNT_ID,
|
|
72
|
-
mailboxId: "mbx-inbox",
|
|
73
|
-
selectedMessageIds: ["msg-1", "msg-2"],
|
|
74
|
-
matchPredicate: SEMANTIC_PREDICATE,
|
|
75
|
-
matchedCount: 128,
|
|
76
|
-
seedMailboxId: "mbx-archive",
|
|
77
|
-
onClose: () => undefined,
|
|
78
|
-
...props,
|
|
79
|
-
}),
|
|
80
|
-
);
|
|
81
|
-
return harness;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
async function startJob(dom: DomHarness): Promise<void> {
|
|
85
|
-
dom.click(dom.byText("button", "Organize"));
|
|
86
|
-
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
87
|
-
await dom.flush();
|
|
88
|
-
if (/Organizing/.test(dom.text())) return;
|
|
89
|
-
await dom.wait(1);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
describe("OrganizePanel back-apply progress copy", () => {
|
|
94
|
-
it("states the sender semantics while organizing in the sender fallback", async () => {
|
|
95
|
-
const dom = mount({
|
|
96
|
-
matchPredicate: SENDER_PREDICATE,
|
|
97
|
-
semanticUnavailable: true,
|
|
98
|
-
senders: ["npm@github.com"],
|
|
99
|
-
});
|
|
100
|
-
await startJob(dom);
|
|
101
|
-
|
|
102
|
-
assert.match(dom.text(), /Organizing mail from these senders/);
|
|
103
|
-
assert.doesNotMatch(dom.text(), /Organizing similar mail/);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("keeps the similar-mail wording on the semantic path", async () => {
|
|
107
|
-
const dom = mount({});
|
|
108
|
-
await startJob(dom);
|
|
109
|
-
|
|
110
|
-
assert.match(dom.text(), /Organizing similar mail/);
|
|
111
|
-
assert.doesNotMatch(dom.text(), /from these senders/);
|
|
112
|
-
});
|
|
113
|
-
});
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import { organizeOperationsCreateOrganizeJobMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
4
|
-
import { MutationObserver } from "@tanstack/query-core";
|
|
5
|
-
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
6
|
-
import React, { createElement } from "react";
|
|
7
|
-
import { renderToString } from "react-dom/server";
|
|
8
|
-
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
9
|
-
import { buildOrganizeInput } from "@/lib/organize/organize-model";
|
|
10
|
-
import { OrganizePanel } from "./OrganizePanel";
|
|
11
|
-
|
|
12
|
-
// The node test loader transpiles remit-ui's `.tsx` with the classic JSX
|
|
13
|
-
// runtime, which references a global `React`. Vite uses the automatic runtime,
|
|
14
|
-
// so this shim only exists for the SSR test harness.
|
|
15
|
-
(globalThis as { React?: typeof React }).React = React;
|
|
16
|
-
|
|
17
|
-
const render = (overrides: Partial<Parameters<typeof OrganizePanel>[0]> = {}) =>
|
|
18
|
-
renderToString(
|
|
19
|
-
createElement(
|
|
20
|
-
QueryClientProvider,
|
|
21
|
-
{ client: new QueryClient() },
|
|
22
|
-
createElement(
|
|
23
|
-
ErrorBannerProvider,
|
|
24
|
-
null,
|
|
25
|
-
createElement(OrganizePanel, {
|
|
26
|
-
accountId: "acc-1",
|
|
27
|
-
mailboxId: "mbx-inbox",
|
|
28
|
-
selectedMessageIds: ["msg-1", "msg-2"],
|
|
29
|
-
matchPredicate: {
|
|
30
|
-
anchorMessageId: "msg-1",
|
|
31
|
-
matchOperator: "And",
|
|
32
|
-
literalClauses: [],
|
|
33
|
-
},
|
|
34
|
-
matchedCount: 47,
|
|
35
|
-
onClose: () => undefined,
|
|
36
|
-
...overrides,
|
|
37
|
-
}),
|
|
38
|
-
),
|
|
39
|
-
) as never,
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
describe("OrganizePanel", () => {
|
|
43
|
-
it("renders the organize sentence with the widened count", () => {
|
|
44
|
-
const html = render();
|
|
45
|
-
assert.match(html, /similar message/);
|
|
46
|
-
assert.match(html, /from 2 selected/);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("surfaces the disabled reason until a folder is chosen (ux.md — say why)", () => {
|
|
50
|
-
const html = render();
|
|
51
|
-
assert.match(html, /Pick a folder to move these into/);
|
|
52
|
-
assert.match(html, /available yet/i);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("offers all four commit scopes", () => {
|
|
56
|
-
const html = render();
|
|
57
|
-
assert.match(html, /Just these/);
|
|
58
|
-
assert.match(html, /All like these/);
|
|
59
|
-
assert.match(html, /These and new mail like this/);
|
|
60
|
-
assert.match(html, /Until a date/);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("says it is organizing just the selection when the widen matched nothing (#211 — no dead end)", () => {
|
|
64
|
-
const html = render({ matchedCount: 0, fallback: true });
|
|
65
|
-
assert.match(html, /No similar messages found/);
|
|
66
|
-
assert.match(html, /organizing just your 2 selected/);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("names the missing vector pipeline and organizes just the selection when the widen is unavailable and no senders are known (#226/#201)", () => {
|
|
70
|
-
const html = render({ matchedCount: 0, semanticUnavailable: true });
|
|
71
|
-
assert.match(html, /available on this server/);
|
|
72
|
-
assert.match(html, /organizing just your 2 selected/);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("names the senders and states it is matching all mail from them when the widen fell back (no vector pipeline)", () => {
|
|
76
|
-
const html = render({
|
|
77
|
-
matchedCount: 128,
|
|
78
|
-
semanticUnavailable: true,
|
|
79
|
-
senders: ["npm@github.com", "digest@substack.com"],
|
|
80
|
-
matchPredicate: {
|
|
81
|
-
matchOperator: "Or",
|
|
82
|
-
literalClauses: [
|
|
83
|
-
{ field: "From", value: "npm@github.com" },
|
|
84
|
-
{ field: "From", value: "digest@substack.com" },
|
|
85
|
-
],
|
|
86
|
-
},
|
|
87
|
-
});
|
|
88
|
-
assert.match(html, /matching all mail from/);
|
|
89
|
-
assert.match(html, /npm@github\.com/);
|
|
90
|
-
assert.match(html, /digest@substack\.com/);
|
|
91
|
-
assert.match(html, /128 matches/);
|
|
92
|
-
// Never claims the matched set is semantically similar (only denies that
|
|
93
|
-
// similar-mail matching is available).
|
|
94
|
-
assert.doesNotMatch(html, /similar message/i);
|
|
95
|
-
assert.doesNotMatch(html, /similar ones found/i);
|
|
96
|
-
// The sender fallback is a real widen: the scopes reach it, so the
|
|
97
|
-
// default scope is "all like these", not "just these".
|
|
98
|
-
assert.match(html, /All like these/);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("names the shared domain when every fallback sender shares one (RFC 038 D2 collapse)", () => {
|
|
102
|
-
const html = render({
|
|
103
|
-
matchedCount: 342,
|
|
104
|
-
semanticUnavailable: true,
|
|
105
|
-
senders: [
|
|
106
|
-
"npm@github.com",
|
|
107
|
-
"notifications@github.com",
|
|
108
|
-
"ci@sub.github.com",
|
|
109
|
-
],
|
|
110
|
-
matchPredicate: {
|
|
111
|
-
matchOperator: "Or",
|
|
112
|
-
literalClauses: [{ field: "FromDomain", value: "github.com" }],
|
|
113
|
-
},
|
|
114
|
-
});
|
|
115
|
-
assert.match(html, /matching all mail from anyone at github\.com/);
|
|
116
|
-
assert.match(html, /342 matches/);
|
|
117
|
-
assert.doesNotMatch(html, /similar message/i);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("pre-selects the seeded scope (a 'Something else' shortcut seeds the sentence)", () => {
|
|
121
|
-
const html = render({ initialScope: "standing" });
|
|
122
|
-
// The standing scope's "Always keep" phrasing only renders when it is active.
|
|
123
|
-
assert.match(html, /Always keep/);
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
// The commit button's `disabled` prop is
|
|
128
|
-
// `!!disabledReason || createFilter.isPending || organizeJob.isStarting`
|
|
129
|
-
// (#1279) — a slow POST to /organize must block a second click just like an
|
|
130
|
-
// in-flight createFilter does, or the back-apply runs twice. `renderToString`
|
|
131
|
-
// never dispatches the click that would exercise that wiring end-to-end (SSR
|
|
132
|
-
// doesn't run event handlers), so this drives the exact mutation config
|
|
133
|
-
// `useOrganizeJob().start()` calls — `organizeOperationsCreateOrganizeJobMutation()`
|
|
134
|
-
// — through a real `MutationObserver` and confirms it reports `isPending`
|
|
135
|
-
// (what `organizeJob.isStarting` is) for the whole time the POST is in
|
|
136
|
-
// flight, which is the invariant the disabled expression relies on.
|
|
137
|
-
describe("organizeJob.isStarting — the create-organize-job POST stays pending until it resolves (#1279)", () => {
|
|
138
|
-
it("reports isPending immediately after start() and keeps it pending while the POST hasn't come back", () => {
|
|
139
|
-
const queryClient = new QueryClient();
|
|
140
|
-
const observer = new MutationObserver(
|
|
141
|
-
queryClient,
|
|
142
|
-
organizeOperationsCreateOrganizeJobMutation(),
|
|
143
|
-
);
|
|
144
|
-
|
|
145
|
-
assert.equal(
|
|
146
|
-
observer.getCurrentResult().isPending,
|
|
147
|
-
false,
|
|
148
|
-
"idle before the first submit — same as a fresh commit button",
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
observer.mutate({
|
|
152
|
-
path: { accountId: "acc-1" },
|
|
153
|
-
body: buildOrganizeInput({
|
|
154
|
-
anchorMessageId: "msg-1",
|
|
155
|
-
matchOperator: "And",
|
|
156
|
-
literalClauses: [],
|
|
157
|
-
moveMailboxId: "mbx-work",
|
|
158
|
-
}),
|
|
159
|
-
// Stands in for a slow POST that hasn't come back yet — the exact
|
|
160
|
-
// window a double click must be blocked in.
|
|
161
|
-
fetch: () => new Promise<Response>(() => {}),
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
assert.equal(
|
|
165
|
-
observer.getCurrentResult().isPending,
|
|
166
|
-
true,
|
|
167
|
-
"a second click during this window must be blocked — this is what organizeJob.isStarting disables the button on",
|
|
168
|
-
);
|
|
169
|
-
});
|
|
170
|
-
});
|
|
@@ -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
|
-
}
|