@remit/web-client 0.0.62 → 0.0.64
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 +15 -0
- package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +1 -0
- package/src/components/mail/organize/MobileOrganizeFlow.tsx +14 -6
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +1 -0
- package/src/components/mail/organize/OrganizeDialog.tsx +15 -10
- package/src/components/mail/organize/OrganizePanel.progress.test.ts +113 -0
- package/src/components/mail/organize/OrganizePanel.render.test.ts +32 -2
- package/src/components/mail/organize/OrganizePanel.tsx +58 -20
- package/src/components/mail/organize/smart-organize.stories.tsx +94 -5
- package/src/components/self-update/SelfUpdateOverlay.tsx +45 -0
- package/src/components/settings/AdvancedNavIcon.tsx +23 -0
- package/src/components/settings/SelfUpdatePanel.tsx +47 -0
- package/src/hooks/use-system-update.test.ts +342 -0
- package/src/hooks/use-system-update.ts +215 -0
- package/src/hooks/useOrganizeWiden.test.ts +151 -0
- package/src/hooks/useOrganizeWiden.ts +104 -0
- package/src/lib/organize/organize-copy.test.ts +41 -0
- package/src/lib/organize/organize-copy.ts +29 -0
- package/src/lib/organize/sender-fallback.test.ts +56 -0
- package/src/lib/organize/sender-fallback.ts +64 -0
- package/src/lib/self-update-state.test.ts +517 -0
- package/src/lib/self-update-state.ts +564 -0
- package/src/routes/__root.tsx +13 -8
- package/src/routes/settings/advanced.tsx +7 -4
- package/src/routes/settings.tsx +3 -9
- package/src/hooks/useOrganizePreview.ts +0 -43
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.64",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -474,6 +474,19 @@ export const MessageList = ({
|
|
|
474
474
|
// handled the click (caller should preventDefault and skip navigation);
|
|
475
475
|
// false for a plain click (caller lets the Link navigate).
|
|
476
476
|
const orderedIds = useMemo(() => threads.map((t) => t.messageId), [threads]);
|
|
477
|
+
|
|
478
|
+
// Sender addresses of the checked rows, for the organize widen's literal
|
|
479
|
+
// fallback when this deployment ships no vector pipeline (the sheet matches
|
|
480
|
+
// all mail from these senders). Read off the already-loaded thread rows, so
|
|
481
|
+
// no extra round-trip; the widen hook dedupes.
|
|
482
|
+
const selectedSenders = useMemo(() => {
|
|
483
|
+
const emails: string[] = [];
|
|
484
|
+
for (const thread of threads) {
|
|
485
|
+
if (!selectedIds.has(thread.messageId)) continue;
|
|
486
|
+
if (thread.fromEmail) emails.push(thread.fromEmail);
|
|
487
|
+
}
|
|
488
|
+
return emails;
|
|
489
|
+
}, [threads, selectedIds]);
|
|
477
490
|
const handleRowSelect = useCallback(
|
|
478
491
|
(messageId: string, modifiers: SelectionModifiers): boolean => {
|
|
479
492
|
if (modifiers.shiftKey) {
|
|
@@ -1256,6 +1269,7 @@ export const MessageList = ({
|
|
|
1256
1269
|
accountId={accountId}
|
|
1257
1270
|
mailboxId={mailboxId}
|
|
1258
1271
|
selectedMessageIds={Array.from(selectedIds)}
|
|
1272
|
+
selectedSenders={selectedSenders}
|
|
1259
1273
|
junkMailboxId={junkMailboxId}
|
|
1260
1274
|
onClose={() => {
|
|
1261
1275
|
setMobileOrganizeEntry(null);
|
|
@@ -1497,6 +1511,7 @@ export const MessageList = ({
|
|
|
1497
1511
|
accountId={accountId}
|
|
1498
1512
|
mailboxId={mailboxId}
|
|
1499
1513
|
selectedMessageIds={Array.from(selectedIds)}
|
|
1514
|
+
selectedSenders={selectedSenders}
|
|
1500
1515
|
onClose={() => setOrganizeOpen(false)}
|
|
1501
1516
|
/>
|
|
1502
1517
|
)}
|
|
@@ -3,7 +3,7 @@ import { BottomSheet, Button } from "@remit/ui";
|
|
|
3
3
|
import { useQuery } from "@tanstack/react-query";
|
|
4
4
|
import { Loader2 } from "lucide-react";
|
|
5
5
|
import { useEffect, useMemo, useState } from "react";
|
|
6
|
-
import {
|
|
6
|
+
import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
|
|
7
7
|
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
8
8
|
import { buildMoveTargets } from "@/lib/move-targets";
|
|
9
9
|
import {
|
|
@@ -20,6 +20,11 @@ interface MobileOrganizeFlowProps {
|
|
|
20
20
|
accountId: string;
|
|
21
21
|
mailboxId: string;
|
|
22
22
|
selectedMessageIds: string[];
|
|
23
|
+
/**
|
|
24
|
+
* Sender addresses of the selected messages, driving the literal fallback on
|
|
25
|
+
* a deployment without the vector pipeline (semantic-capability.ts).
|
|
26
|
+
*/
|
|
27
|
+
selectedSenders: string[];
|
|
23
28
|
junkMailboxId?: string;
|
|
24
29
|
/** Close the flow and return to the list — dismiss, "Not now", and Done all use it. */
|
|
25
30
|
onClose: () => void;
|
|
@@ -50,6 +55,7 @@ export function MobileOrganizeFlow({
|
|
|
50
55
|
accountId,
|
|
51
56
|
mailboxId,
|
|
52
57
|
selectedMessageIds,
|
|
58
|
+
selectedSenders,
|
|
53
59
|
junkMailboxId,
|
|
54
60
|
onClose,
|
|
55
61
|
}: MobileOrganizeFlowProps) {
|
|
@@ -60,15 +66,16 @@ export function MobileOrganizeFlow({
|
|
|
60
66
|
preview,
|
|
61
67
|
matchedCount,
|
|
62
68
|
semanticUnavailable,
|
|
69
|
+
senders,
|
|
70
|
+
matchPredicate,
|
|
63
71
|
isPending,
|
|
64
72
|
isError,
|
|
65
73
|
error,
|
|
66
|
-
} =
|
|
74
|
+
} = useOrganizeWiden(accountId, anchorMessageId, selectedSenders);
|
|
67
75
|
|
|
68
76
|
useEffect(() => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}, [anchorMessageId, preview]);
|
|
77
|
+
preview();
|
|
78
|
+
}, [preview]);
|
|
72
79
|
|
|
73
80
|
const { data: mailboxesData } = useQuery({
|
|
74
81
|
...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
@@ -122,12 +129,13 @@ export function MobileOrganizeFlow({
|
|
|
122
129
|
accountId={accountId}
|
|
123
130
|
mailboxId={mailboxId}
|
|
124
131
|
selectedMessageIds={selectedMessageIds}
|
|
125
|
-
|
|
132
|
+
matchPredicate={matchPredicate}
|
|
126
133
|
matchedCount={stage.matchedCount}
|
|
127
134
|
initialScope={seed?.scope}
|
|
128
135
|
seedMailboxId={seed?.moveMailboxId}
|
|
129
136
|
fallback={stage.fallback}
|
|
130
137
|
semanticUnavailable={semanticUnavailable}
|
|
138
|
+
senders={senders}
|
|
131
139
|
onClose={onClose}
|
|
132
140
|
/>
|
|
133
141
|
)}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Button, Dialog } from "@remit/ui";
|
|
2
2
|
import { Loader2 } from "lucide-react";
|
|
3
3
|
import { useEffect } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
|
|
5
5
|
import { OrganizePanel } from "./OrganizePanel";
|
|
6
6
|
|
|
7
7
|
interface OrganizeDialogProps {
|
|
@@ -9,6 +9,11 @@ interface OrganizeDialogProps {
|
|
|
9
9
|
accountId: string;
|
|
10
10
|
mailboxId: string;
|
|
11
11
|
selectedMessageIds: string[];
|
|
12
|
+
/**
|
|
13
|
+
* Sender addresses of the selected messages, driving the literal fallback on
|
|
14
|
+
* a deployment without the vector pipeline (semantic-capability.ts).
|
|
15
|
+
*/
|
|
16
|
+
selectedSenders: string[];
|
|
12
17
|
onClose: () => void;
|
|
13
18
|
}
|
|
14
19
|
|
|
@@ -23,6 +28,7 @@ export function OrganizeDialog({
|
|
|
23
28
|
accountId,
|
|
24
29
|
mailboxId,
|
|
25
30
|
selectedMessageIds,
|
|
31
|
+
selectedSenders,
|
|
26
32
|
onClose,
|
|
27
33
|
}: OrganizeDialogProps) {
|
|
28
34
|
const anchorMessageId = selectedMessageIds[0];
|
|
@@ -31,19 +37,17 @@ export function OrganizeDialog({
|
|
|
31
37
|
reset,
|
|
32
38
|
matchedCount,
|
|
33
39
|
semanticUnavailable,
|
|
40
|
+
senders,
|
|
41
|
+
matchPredicate,
|
|
34
42
|
isPending,
|
|
35
43
|
isError,
|
|
36
44
|
error,
|
|
37
|
-
} =
|
|
45
|
+
} = useOrganizeWiden(accountId, anchorMessageId, selectedSenders);
|
|
38
46
|
|
|
39
47
|
useEffect(() => {
|
|
40
|
-
if (!open
|
|
41
|
-
preview(
|
|
42
|
-
|
|
43
|
-
matchOperator: "And",
|
|
44
|
-
literalClauses: [],
|
|
45
|
-
});
|
|
46
|
-
}, [open, anchorMessageId, preview]);
|
|
48
|
+
if (!open) return;
|
|
49
|
+
preview();
|
|
50
|
+
}, [open, preview]);
|
|
47
51
|
|
|
48
52
|
const handleClose = () => {
|
|
49
53
|
reset();
|
|
@@ -80,9 +84,10 @@ export function OrganizeDialog({
|
|
|
80
84
|
accountId={accountId}
|
|
81
85
|
mailboxId={mailboxId}
|
|
82
86
|
selectedMessageIds={selectedMessageIds}
|
|
83
|
-
|
|
87
|
+
matchPredicate={matchPredicate}
|
|
84
88
|
matchedCount={matchedCount}
|
|
85
89
|
semanticUnavailable={semanticUnavailable}
|
|
90
|
+
senders={senders}
|
|
86
91
|
onClose={handleClose}
|
|
87
92
|
/>
|
|
88
93
|
)}
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
});
|
|
@@ -26,7 +26,11 @@ const render = (overrides: Partial<Parameters<typeof OrganizePanel>[0]> = {}) =>
|
|
|
26
26
|
accountId: "acc-1",
|
|
27
27
|
mailboxId: "mbx-inbox",
|
|
28
28
|
selectedMessageIds: ["msg-1", "msg-2"],
|
|
29
|
-
|
|
29
|
+
matchPredicate: {
|
|
30
|
+
anchorMessageId: "msg-1",
|
|
31
|
+
matchOperator: "And",
|
|
32
|
+
literalClauses: [],
|
|
33
|
+
},
|
|
30
34
|
matchedCount: 47,
|
|
31
35
|
onClose: () => undefined,
|
|
32
36
|
...overrides,
|
|
@@ -62,12 +66,38 @@ describe("OrganizePanel", () => {
|
|
|
62
66
|
assert.match(html, /organizing just your 2 selected/);
|
|
63
67
|
});
|
|
64
68
|
|
|
65
|
-
it("names the missing vector pipeline and organizes just the selection when the widen is unavailable (#226/#201)", () => {
|
|
69
|
+
it("names the missing vector pipeline and organizes just the selection when the widen is unavailable and no senders are known (#226/#201)", () => {
|
|
66
70
|
const html = render({ matchedCount: 0, semanticUnavailable: true });
|
|
67
71
|
assert.match(html, /available on this server/);
|
|
68
72
|
assert.match(html, /organizing just your 2 selected/);
|
|
69
73
|
});
|
|
70
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", "notifications@github.com"],
|
|
80
|
+
matchPredicate: {
|
|
81
|
+
matchOperator: "Or",
|
|
82
|
+
literalClauses: [
|
|
83
|
+
{ field: "From", value: "npm@github.com" },
|
|
84
|
+
{ field: "From", value: "notifications@github.com" },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
assert.match(html, /matching all mail from/);
|
|
89
|
+
assert.match(html, /npm@github\.com/);
|
|
90
|
+
assert.match(html, /notifications@github\.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
|
+
|
|
71
101
|
it("pre-selects the seeded scope (a 'Something else' shortcut seeds the sentence)", () => {
|
|
72
102
|
const html = render({ initialScope: "standing" });
|
|
73
103
|
// The standing scope's "Always keep" phrasing only renders when it is active.
|
|
@@ -13,17 +13,25 @@ import {
|
|
|
13
13
|
commitButtonLabel,
|
|
14
14
|
commitDisabledReason,
|
|
15
15
|
scopeActionCount,
|
|
16
|
+
senderFallbackSummary,
|
|
16
17
|
} from "@/lib/organize/organize-copy";
|
|
17
18
|
import type {
|
|
18
19
|
OrganizeDraft,
|
|
19
20
|
OrganizeScope,
|
|
20
21
|
} from "@/lib/organize/organize-model";
|
|
22
|
+
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
21
23
|
|
|
22
24
|
interface OrganizePanelProps {
|
|
23
25
|
accountId: string;
|
|
24
26
|
mailboxId: string;
|
|
25
27
|
selectedMessageIds: string[];
|
|
26
|
-
|
|
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;
|
|
27
35
|
/** Similar messages the widen preview matched. */
|
|
28
36
|
matchedCount: number;
|
|
29
37
|
/**
|
|
@@ -44,10 +52,17 @@ interface OrganizePanelProps {
|
|
|
44
52
|
fallback?: boolean;
|
|
45
53
|
/**
|
|
46
54
|
* The server ran no semantic widen because this deployment ships no vector
|
|
47
|
-
* pipeline.
|
|
48
|
-
*
|
|
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".
|
|
49
58
|
*/
|
|
50
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[];
|
|
51
66
|
onClose: () => void;
|
|
52
67
|
}
|
|
53
68
|
|
|
@@ -78,17 +93,22 @@ export function OrganizePanel({
|
|
|
78
93
|
accountId,
|
|
79
94
|
mailboxId,
|
|
80
95
|
selectedMessageIds,
|
|
81
|
-
|
|
96
|
+
matchPredicate,
|
|
82
97
|
matchedCount,
|
|
83
98
|
initialScope,
|
|
84
99
|
seedMailboxId,
|
|
85
100
|
fallback = false,
|
|
86
101
|
semanticUnavailable = false,
|
|
102
|
+
senders = [],
|
|
87
103
|
onClose,
|
|
88
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;
|
|
89
109
|
// A missing widen and an empty widen both organize just the selection; only
|
|
90
|
-
// the heading distinguishes them.
|
|
91
|
-
const noWiden = fallback || semanticUnavailable;
|
|
110
|
+
// the heading distinguishes them. The sender fallback is a real widen.
|
|
111
|
+
const noWiden = (fallback || semanticUnavailable) && !senderFallback;
|
|
92
112
|
const [scope, setScope] = useState<OrganizeScope>(
|
|
93
113
|
initialScope ?? (noWiden ? "just-these" : "all-like-these"),
|
|
94
114
|
);
|
|
@@ -113,14 +133,12 @@ export function OrganizePanel({
|
|
|
113
133
|
|
|
114
134
|
const draft: OrganizeDraft = useMemo(
|
|
115
135
|
() => ({
|
|
116
|
-
|
|
117
|
-
matchOperator: "And",
|
|
118
|
-
literalClauses: [],
|
|
136
|
+
...matchPredicate,
|
|
119
137
|
moveMailboxId: moveMailboxId || undefined,
|
|
120
138
|
expiresAt:
|
|
121
139
|
scope === "temporary" ? pickedDateToExpiresAt(pickedDate) : undefined,
|
|
122
140
|
}),
|
|
123
|
-
[
|
|
141
|
+
[matchPredicate, moveMailboxId, scope, pickedDate],
|
|
124
142
|
);
|
|
125
143
|
|
|
126
144
|
const { moveMessages } = useMoveMessages({ mailboxId, accountId });
|
|
@@ -161,6 +179,7 @@ export function OrganizePanel({
|
|
|
161
179
|
<JobProgress
|
|
162
180
|
progress={organizeJob.progress}
|
|
163
181
|
isDone={organizeJob.isDone}
|
|
182
|
+
senderFallback={senderFallback}
|
|
164
183
|
onClose={onClose}
|
|
165
184
|
/>
|
|
166
185
|
);
|
|
@@ -188,13 +207,17 @@ export function OrganizePanel({
|
|
|
188
207
|
<div className="border-b border-line px-5 py-3">
|
|
189
208
|
<h2 className="text-sm font-semibold text-fg">Organize</h2>
|
|
190
209
|
<p className="mt-0.5 text-xs text-fg-muted">
|
|
191
|
-
{
|
|
192
|
-
?
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
+
}.`}
|
|
198
221
|
</p>
|
|
199
222
|
</div>
|
|
200
223
|
|
|
@@ -205,7 +228,12 @@ export function OrganizePanel({
|
|
|
205
228
|
<span className="font-semibold text-fg">Always keep </span>
|
|
206
229
|
)}
|
|
207
230
|
{scope === "standing" ? "" : "Keep "}
|
|
208
|
-
{scope === "just-these"
|
|
231
|
+
{scope === "just-these"
|
|
232
|
+
? "these"
|
|
233
|
+
: senderFallback
|
|
234
|
+
? "mail from these senders"
|
|
235
|
+
: "emails like these"}{" "}
|
|
236
|
+
in
|
|
209
237
|
</p>
|
|
210
238
|
<Select
|
|
211
239
|
aria-label="Destination folder"
|
|
@@ -232,6 +260,10 @@ export function OrganizePanel({
|
|
|
232
260
|
<section className="space-y-1.5">
|
|
233
261
|
{SCOPE_OPTIONS.map((option) => {
|
|
234
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;
|
|
235
267
|
return (
|
|
236
268
|
<button
|
|
237
269
|
key={option.id}
|
|
@@ -263,7 +295,7 @@ export function OrganizePanel({
|
|
|
263
295
|
{option.label}
|
|
264
296
|
</span>
|
|
265
297
|
<span className="block text-xs text-fg-subtle">
|
|
266
|
-
{
|
|
298
|
+
{caption}
|
|
267
299
|
</span>
|
|
268
300
|
</span>
|
|
269
301
|
</button>
|
|
@@ -330,10 +362,12 @@ export function OrganizePanel({
|
|
|
330
362
|
function JobProgress({
|
|
331
363
|
progress,
|
|
332
364
|
isDone,
|
|
365
|
+
senderFallback,
|
|
333
366
|
onClose,
|
|
334
367
|
}: {
|
|
335
368
|
progress: ReturnType<typeof useOrganizeJob>["progress"];
|
|
336
369
|
isDone: boolean;
|
|
370
|
+
senderFallback: boolean;
|
|
337
371
|
onClose: () => void;
|
|
338
372
|
}) {
|
|
339
373
|
const failed = progress.state === "Failed";
|
|
@@ -350,7 +384,11 @@ function JobProgress({
|
|
|
350
384
|
)}
|
|
351
385
|
|
|
352
386
|
{!isDone && (
|
|
353
|
-
<p className="text-sm font-medium text-fg">
|
|
387
|
+
<p className="text-sm font-medium text-fg">
|
|
388
|
+
{senderFallback
|
|
389
|
+
? "Organizing mail from these senders…"
|
|
390
|
+
: "Organizing similar mail…"}
|
|
391
|
+
</p>
|
|
354
392
|
)}
|
|
355
393
|
|
|
356
394
|
{isDone && !failed && (
|
|
@@ -117,7 +117,11 @@ export const Organize: Story = {
|
|
|
117
117
|
accountId={ACCOUNT_ID}
|
|
118
118
|
mailboxId="mbx-inbox"
|
|
119
119
|
selectedMessageIds={["msg-1", "msg-2", "msg-3"]}
|
|
120
|
-
|
|
120
|
+
matchPredicate={{
|
|
121
|
+
anchorMessageId: "msg-1",
|
|
122
|
+
matchOperator: "And",
|
|
123
|
+
literalClauses: [],
|
|
124
|
+
}}
|
|
121
125
|
matchedCount={47}
|
|
122
126
|
onClose={() => undefined}
|
|
123
127
|
/>
|
|
@@ -137,7 +141,11 @@ export const FromSearch: Story = {
|
|
|
137
141
|
accountId={ACCOUNT_ID}
|
|
138
142
|
mailboxId="mbx-inbox"
|
|
139
143
|
selectedMessageIds={["msg-1"]}
|
|
140
|
-
|
|
144
|
+
matchPredicate={{
|
|
145
|
+
anchorMessageId: "msg-1",
|
|
146
|
+
matchOperator: "And",
|
|
147
|
+
literalClauses: [],
|
|
148
|
+
}}
|
|
141
149
|
matchedCount={412}
|
|
142
150
|
seedMailboxId="mbx-archive"
|
|
143
151
|
onClose={() => undefined}
|
|
@@ -155,7 +163,11 @@ export const AlwaysRule: Story = {
|
|
|
155
163
|
accountId={ACCOUNT_ID}
|
|
156
164
|
mailboxId="mbx-inbox"
|
|
157
165
|
selectedMessageIds={["msg-1", "msg-2"]}
|
|
158
|
-
|
|
166
|
+
matchPredicate={{
|
|
167
|
+
anchorMessageId: "msg-1",
|
|
168
|
+
matchOperator: "And",
|
|
169
|
+
literalClauses: [],
|
|
170
|
+
}}
|
|
159
171
|
matchedCount={47}
|
|
160
172
|
initialScope="standing"
|
|
161
173
|
seedMailboxId="mbx-travel"
|
|
@@ -174,7 +186,11 @@ export const NoSimilarFound: Story = {
|
|
|
174
186
|
accountId={ACCOUNT_ID}
|
|
175
187
|
mailboxId="mbx-inbox"
|
|
176
188
|
selectedMessageIds={["msg-1", "msg-2"]}
|
|
177
|
-
|
|
189
|
+
matchPredicate={{
|
|
190
|
+
anchorMessageId: "msg-1",
|
|
191
|
+
matchOperator: "And",
|
|
192
|
+
literalClauses: [],
|
|
193
|
+
}}
|
|
178
194
|
matchedCount={0}
|
|
179
195
|
fallback
|
|
180
196
|
onClose={() => undefined}
|
|
@@ -196,7 +212,11 @@ export const SemanticUnavailable: Story = {
|
|
|
196
212
|
accountId={ACCOUNT_ID}
|
|
197
213
|
mailboxId="mbx-inbox"
|
|
198
214
|
selectedMessageIds={["msg-1", "msg-2"]}
|
|
199
|
-
|
|
215
|
+
matchPredicate={{
|
|
216
|
+
anchorMessageId: "msg-1",
|
|
217
|
+
matchOperator: "And",
|
|
218
|
+
literalClauses: [],
|
|
219
|
+
}}
|
|
200
220
|
matchedCount={0}
|
|
201
221
|
semanticUnavailable
|
|
202
222
|
onClose={() => undefined}
|
|
@@ -205,6 +225,75 @@ export const SemanticUnavailable: Story = {
|
|
|
205
225
|
),
|
|
206
226
|
};
|
|
207
227
|
|
|
228
|
+
/**
|
|
229
|
+
* No vector pipeline, but the selection has senders to match on — the widen
|
|
230
|
+
* fell back to matching all mail from those senders. The heading names the
|
|
231
|
+
* senders and states the real semantics, and every commit scope (including the
|
|
232
|
+
* standing filter) carries the sender `From` clauses, so it reaches the widened
|
|
233
|
+
* set and keeps working on future mail.
|
|
234
|
+
*/
|
|
235
|
+
export const SenderFallback: Story = {
|
|
236
|
+
name: "Sender Fallback",
|
|
237
|
+
render: () => (
|
|
238
|
+
<SheetStage>
|
|
239
|
+
<OrganizePanel
|
|
240
|
+
accountId={ACCOUNT_ID}
|
|
241
|
+
mailboxId="mbx-inbox"
|
|
242
|
+
selectedMessageIds={["msg-1", "msg-2"]}
|
|
243
|
+
matchPredicate={{
|
|
244
|
+
matchOperator: "Or",
|
|
245
|
+
literalClauses: [
|
|
246
|
+
{ field: "From", value: "npm@github.com" },
|
|
247
|
+
{ field: "From", value: "notifications@github.com" },
|
|
248
|
+
],
|
|
249
|
+
}}
|
|
250
|
+
matchedCount={128}
|
|
251
|
+
semanticUnavailable
|
|
252
|
+
senders={["npm@github.com", "notifications@github.com"]}
|
|
253
|
+
onClose={() => undefined}
|
|
254
|
+
/>
|
|
255
|
+
</SheetStage>
|
|
256
|
+
),
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The sender fallback with a standing rule pre-selected — the "Always keep mail
|
|
261
|
+
* from these senders" sentence, the filter that will match future mail at index
|
|
262
|
+
* time.
|
|
263
|
+
*/
|
|
264
|
+
export const SenderFallbackStanding: Story = {
|
|
265
|
+
name: "Sender Fallback (standing)",
|
|
266
|
+
render: () => (
|
|
267
|
+
<SheetStage>
|
|
268
|
+
<OrganizePanel
|
|
269
|
+
accountId={ACCOUNT_ID}
|
|
270
|
+
mailboxId="mbx-inbox"
|
|
271
|
+
selectedMessageIds={["msg-1", "msg-2", "msg-3", "msg-4"]}
|
|
272
|
+
matchPredicate={{
|
|
273
|
+
matchOperator: "Or",
|
|
274
|
+
literalClauses: [
|
|
275
|
+
{ field: "From", value: "npm@github.com" },
|
|
276
|
+
{ field: "From", value: "notifications@github.com" },
|
|
277
|
+
{ field: "From", value: "noreply@medium.com" },
|
|
278
|
+
{ field: "From", value: "digest@substack.com" },
|
|
279
|
+
],
|
|
280
|
+
}}
|
|
281
|
+
matchedCount={412}
|
|
282
|
+
semanticUnavailable
|
|
283
|
+
senders={[
|
|
284
|
+
"npm@github.com",
|
|
285
|
+
"notifications@github.com",
|
|
286
|
+
"noreply@medium.com",
|
|
287
|
+
"digest@substack.com",
|
|
288
|
+
]}
|
|
289
|
+
initialScope="standing"
|
|
290
|
+
seedMailboxId="mbx-archive"
|
|
291
|
+
onClose={() => undefined}
|
|
292
|
+
/>
|
|
293
|
+
</SheetStage>
|
|
294
|
+
),
|
|
295
|
+
};
|
|
296
|
+
|
|
208
297
|
/** The "Something else" fallback: shortcuts derived from folders + an input. */
|
|
209
298
|
export const SomethingElse: Story = {
|
|
210
299
|
render: () => (
|