@remit/web-client 0.0.271 → 0.0.273
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/BlockedImagesNotice.render.test.ts +54 -5
- package/src/components/mail/BlockedImagesNotice.tsx +40 -18
- package/src/components/mail/BulkRunProvider.tsx +331 -0
- package/src/components/mail/MessageBody.blocked-sender.test.ts +193 -0
- package/src/components/mail/MessageBody.tsx +21 -2
- package/src/components/mail/MessageCard.blocked-sender.test.ts +239 -0
- package/src/components/mail/MessageCard.tsx +8 -1
- package/src/components/mail/MessageList.tsx +38 -23
- package/src/components/mail/SelectionWizardHost.tsx +57 -24
- package/src/components/mail/blocked-images-notice.stories.tsx +90 -0
- package/src/hooks/cross-account-bulk.render.test.ts +3 -3
- package/src/hooks/escalated-run-lifetime.render.test.ts +5 -4
- package/src/hooks/escalated-run-refusal.render.test.ts +219 -5
- package/src/hooks/escalated-run-survives-navigation.render.test.ts +524 -0
- package/src/hooks/useEscalatedActions.test.ts +1 -1
- package/src/hooks/useEscalatedActions.ts +93 -187
- package/src/lib/bulk-action-copy.ts +21 -0
- package/src/lib/bulk-actions.ts +32 -0
- package/src/routes/__root.tsx +16 -11
- package/src/test-support/bulk-run.ts +13 -0
- package/src/test-support/dom.ts +6 -1
|
@@ -47,9 +47,17 @@ interface MessageBodyProps {
|
|
|
47
47
|
/**
|
|
48
48
|
* Whether the From-address has the `trusted` flag set. Trusted senders
|
|
49
49
|
* auto-load images. The "Load images" bar is suppressed entirely for
|
|
50
|
-
* trusted senders.
|
|
50
|
+
* trusted senders. Ignored when `isBlocked` is set.
|
|
51
51
|
*/
|
|
52
52
|
isTrusted?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Whether the From-address has the `blocked` flag set. A blocked sender
|
|
55
|
+
* never loads images, "even on explicit click" (`BlockedFlag`), so this
|
|
56
|
+
* suppresses auto-load and removes the manual load affordances rather than
|
|
57
|
+
* greying them out. Blocked outranks trusted: it is the more restrictive
|
|
58
|
+
* instruction, and a sender carrying both is still blocked.
|
|
59
|
+
*/
|
|
60
|
+
isBlocked?: boolean;
|
|
53
61
|
/**
|
|
54
62
|
* The current message id. Required for the body-content query cache key
|
|
55
63
|
* and by `useToggleTrusted` for the optimistic cache patch.
|
|
@@ -99,13 +107,23 @@ export const MessageBody = ({
|
|
|
99
107
|
html,
|
|
100
108
|
text,
|
|
101
109
|
isTrusted = false,
|
|
110
|
+
isBlocked = false,
|
|
102
111
|
messageId,
|
|
103
112
|
fromAddressId,
|
|
104
113
|
category,
|
|
105
114
|
className,
|
|
106
115
|
}: MessageBodyProps) => {
|
|
116
|
+
// "Load once" is a decision about one message. Reset it when the instance is
|
|
117
|
+
// handed a different message so the grant can't ride along — today every
|
|
118
|
+
// caller keys this component by message, which makes the leak unreachable,
|
|
119
|
+
// but the guarantee should hold structurally rather than by a caller's key.
|
|
107
120
|
const [allowImagesOnce, setAllowImagesOnce] = useState(false);
|
|
108
|
-
const
|
|
121
|
+
const [grantedFor, setGrantedFor] = useState(messageId);
|
|
122
|
+
if (grantedFor !== messageId) {
|
|
123
|
+
setGrantedFor(messageId);
|
|
124
|
+
setAllowImagesOnce(false);
|
|
125
|
+
}
|
|
126
|
+
const allowImages = !isBlocked && (isTrusted || allowImagesOnce);
|
|
109
127
|
const isDark = useIsDark();
|
|
110
128
|
// `useToggleTrusted` surfaces its own failure (banner + rollback) and a fatal
|
|
111
129
|
// 5xx escalates globally — no consumer-side error effect needed here.
|
|
@@ -197,6 +215,7 @@ export const MessageBody = ({
|
|
|
197
215
|
blockedImageCount={blockedImageCount}
|
|
198
216
|
canAlwaysTrust={canAlwaysTrust}
|
|
199
217
|
isTrustPending={isTrustPending}
|
|
218
|
+
isSenderBlocked={isBlocked}
|
|
200
219
|
onLoadOnce={() => setAllowImagesOnce(true)}
|
|
201
220
|
onAlwaysTrust={handleAlwaysTrust}
|
|
202
221
|
/>
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image gating at the `MessageCard` seam (#352). The expanded card resolves the
|
|
3
|
+
* From-address flags and hands them to the body: `blocked` is read there
|
|
4
|
+
* alongside `trusted`, and wins when both are set. Without that read the promise
|
|
5
|
+
* Settings > Senders makes — a blocked sender never loads images — has no reach
|
|
6
|
+
* into the one surface that renders mail.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { afterEach, describe, it } from "node:test";
|
|
11
|
+
import type {
|
|
12
|
+
RemitImapAddressFlags,
|
|
13
|
+
RemitImapDescribeMessageResponse,
|
|
14
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
15
|
+
import {
|
|
16
|
+
type AnyRouter,
|
|
17
|
+
createMemoryHistory,
|
|
18
|
+
createRootRoute,
|
|
19
|
+
createRoute,
|
|
20
|
+
createRouter,
|
|
21
|
+
Outlet,
|
|
22
|
+
RouterProvider,
|
|
23
|
+
} from "@tanstack/react-router";
|
|
24
|
+
import { createElement } from "react";
|
|
25
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
26
|
+
import { makeThreadMessage } from "@/test-support/fixtures";
|
|
27
|
+
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
28
|
+
import { MessageCard } from "./MessageCard";
|
|
29
|
+
|
|
30
|
+
const MAILBOX_ID = "mbx-inbox";
|
|
31
|
+
const THREAD_ID = "thread-1";
|
|
32
|
+
const MESSAGE_ID = "msg-1";
|
|
33
|
+
const CONTENT_PATH = "/content/parts/1";
|
|
34
|
+
|
|
35
|
+
const MARKETING_HTML = `<div><img src="https://tracker.example/hero.png" alt="Hero"><p>Sale ends Sunday.</p></div>`;
|
|
36
|
+
|
|
37
|
+
let harness: DomHarness | undefined;
|
|
38
|
+
let http: HttpMock | undefined;
|
|
39
|
+
|
|
40
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
41
|
+
// `window`.
|
|
42
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
43
|
+
|
|
44
|
+
// The sandboxed frame measures itself once the iframe loads. jsdom fires that
|
|
45
|
+
// load but has no ResizeObserver, and the resulting ReferenceError is raised
|
|
46
|
+
// inside a listener where no assertion can see it.
|
|
47
|
+
(globalThis as { ResizeObserver?: unknown }).ResizeObserver = class {
|
|
48
|
+
observe() {}
|
|
49
|
+
unobserve() {}
|
|
50
|
+
disconnect() {}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
harness?.close();
|
|
55
|
+
harness = undefined;
|
|
56
|
+
http?.restore();
|
|
57
|
+
http = undefined;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const threadMessage = makeThreadMessage({
|
|
61
|
+
messageId: MESSAGE_ID,
|
|
62
|
+
threadId: THREAD_ID,
|
|
63
|
+
mailboxId: MAILBOX_ID,
|
|
64
|
+
subject: "Sale ends Sunday",
|
|
65
|
+
fromName: "Deals",
|
|
66
|
+
fromEmail: "deals@shop.example",
|
|
67
|
+
category: "marketing",
|
|
68
|
+
isRead: true,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const describeMessage = (
|
|
72
|
+
flags: RemitImapAddressFlags,
|
|
73
|
+
): RemitImapDescribeMessageResponse =>
|
|
74
|
+
({
|
|
75
|
+
message: {
|
|
76
|
+
messageId: MESSAGE_ID,
|
|
77
|
+
mailboxId: MAILBOX_ID,
|
|
78
|
+
uid: 1,
|
|
79
|
+
rfc822Size: 512,
|
|
80
|
+
internalDate: 1_767_225_600_000,
|
|
81
|
+
status: "active",
|
|
82
|
+
syncStatus: "pending",
|
|
83
|
+
},
|
|
84
|
+
envelope: {
|
|
85
|
+
messageId: MESSAGE_ID,
|
|
86
|
+
date: 1_767_225_600_000,
|
|
87
|
+
subject: "Sale ends Sunday",
|
|
88
|
+
messageIdValue: "<deals-1@shop.example>",
|
|
89
|
+
from: [
|
|
90
|
+
{
|
|
91
|
+
addressId: "addr-deals",
|
|
92
|
+
displayName: "Deals",
|
|
93
|
+
normalizedEmail: "deals@shop.example",
|
|
94
|
+
addressRole: "from",
|
|
95
|
+
addressOrder: 0,
|
|
96
|
+
flags,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
to: [],
|
|
100
|
+
cc: [],
|
|
101
|
+
bcc: [],
|
|
102
|
+
replyTo: [],
|
|
103
|
+
category: "marketing",
|
|
104
|
+
senderTrust: "unknown",
|
|
105
|
+
},
|
|
106
|
+
flags: ["\\Seen"],
|
|
107
|
+
bodyParts: [
|
|
108
|
+
{
|
|
109
|
+
bodyPartId: "part-1",
|
|
110
|
+
mediaType: "TEXT",
|
|
111
|
+
mediaSubtype: "HTML",
|
|
112
|
+
contentUrl: `http://localhost${CONTENT_PATH}`,
|
|
113
|
+
isMultipart: false,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
references: [],
|
|
117
|
+
}) as unknown as RemitImapDescribeMessageResponse;
|
|
118
|
+
|
|
119
|
+
const testRouter = (): AnyRouter => {
|
|
120
|
+
const rootRoute = createRootRoute({ component: () => createElement(Outlet) });
|
|
121
|
+
const mailboxRoute = createRoute({
|
|
122
|
+
getParentRoute: () => rootRoute,
|
|
123
|
+
path: "/mail/$mailboxId",
|
|
124
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
125
|
+
});
|
|
126
|
+
const threadRoute = createRoute({
|
|
127
|
+
getParentRoute: () => mailboxRoute,
|
|
128
|
+
path: "$threadId",
|
|
129
|
+
});
|
|
130
|
+
const messageRoute = createRoute({
|
|
131
|
+
getParentRoute: () => threadRoute,
|
|
132
|
+
path: "$messageId",
|
|
133
|
+
component: () =>
|
|
134
|
+
createElement(MessageCard, {
|
|
135
|
+
threadMessage,
|
|
136
|
+
isExpanded: true,
|
|
137
|
+
onToggle: () => undefined,
|
|
138
|
+
onToggleStar: () => undefined,
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
const routeTree = rootRoute.addChildren([
|
|
142
|
+
mailboxRoute.addChildren([threadRoute.addChildren([messageRoute])]),
|
|
143
|
+
]);
|
|
144
|
+
return createRouter({
|
|
145
|
+
routeTree,
|
|
146
|
+
history: createMemoryHistory({
|
|
147
|
+
initialEntries: [`/mail/${MAILBOX_ID}/${THREAD_ID}/${MESSAGE_ID}`],
|
|
148
|
+
}),
|
|
149
|
+
}) as unknown as AnyRouter;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const mount = async (flags: RemitImapAddressFlags): Promise<DomHarness> => {
|
|
153
|
+
http = mockFetch((call) => {
|
|
154
|
+
if (call.path === CONTENT_PATH) {
|
|
155
|
+
return new Response(MARKETING_HTML, {
|
|
156
|
+
status: 200,
|
|
157
|
+
headers: { "content-type": "text/html" },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (call.path.endsWith(`/messages/${MESSAGE_ID}`)) {
|
|
161
|
+
return describeMessage(flags);
|
|
162
|
+
}
|
|
163
|
+
return { items: [] };
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const router = testRouter();
|
|
167
|
+
await router.load();
|
|
168
|
+
const dom = createDomHarness();
|
|
169
|
+
harness = dom;
|
|
170
|
+
dom.renderApp(createElement(RouterProvider, { router }));
|
|
171
|
+
await dom.waitFor(
|
|
172
|
+
() => dom.query("iframe") !== null,
|
|
173
|
+
"the message body to render",
|
|
174
|
+
);
|
|
175
|
+
return dom;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const frameHtml = (dom: DomHarness): string =>
|
|
179
|
+
dom.query<HTMLIFrameElement>("iframe")?.getAttribute("srcdoc") ?? "";
|
|
180
|
+
|
|
181
|
+
// A blocked image keeps its original URL in `data-blocked-src`; only a live
|
|
182
|
+
// `src=` means the browser will fetch it.
|
|
183
|
+
const REMOTE_SRC = /\ssrc="https:\/\/tracker\.example\/hero\.png"/;
|
|
184
|
+
|
|
185
|
+
const notice = (dom: DomHarness) =>
|
|
186
|
+
dom.query('[data-testid="blocked-images-notice"]');
|
|
187
|
+
|
|
188
|
+
// A boolean, not the node: an assertion that fails on a jsdom element makes the
|
|
189
|
+
// runner serialize the whole tree and the process dies on memory, not on a diff.
|
|
190
|
+
const hasTrustedBadge = (dom: DomHarness): boolean =>
|
|
191
|
+
dom.query('[data-testid="trusted-sender-badge"]') !== null;
|
|
192
|
+
|
|
193
|
+
describe("MessageCard reads flags.blocked on the render path (#352)", () => {
|
|
194
|
+
it("keeps a blocked sender's images out and offers no way to load them", async () => {
|
|
195
|
+
const dom = await mount({ blocked: { value: true, setAt: 1 } });
|
|
196
|
+
|
|
197
|
+
assert.doesNotMatch(frameHtml(dom), REMOTE_SRC);
|
|
198
|
+
assert.match(frameHtml(dom), /data-blocked-src/);
|
|
199
|
+
assert.match(notice(dom)?.textContent ?? "", /you blocked this sender/);
|
|
200
|
+
assert.equal(
|
|
201
|
+
dom.queryAll('[data-testid="blocked-images-notice"] button').length,
|
|
202
|
+
0,
|
|
203
|
+
"never load, even on explicit click",
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("stays blocked when the sender carries both blocked and trusted", async () => {
|
|
208
|
+
const dom = await mount({
|
|
209
|
+
blocked: { value: true, setAt: 1 },
|
|
210
|
+
trusted: { value: true, setAt: 1 },
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
assert.doesNotMatch(frameHtml(dom), REMOTE_SRC);
|
|
214
|
+
assert.match(notice(dom)?.textContent ?? "", /you blocked this sender/);
|
|
215
|
+
assert.equal(
|
|
216
|
+
hasTrustedBadge(dom),
|
|
217
|
+
false,
|
|
218
|
+
"a trusted badge beside 'you blocked this sender' tells the user two opposite things",
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("still auto-loads a trusted sender's images", async () => {
|
|
223
|
+
const dom = await mount({ trusted: { value: true, setAt: 1 } });
|
|
224
|
+
|
|
225
|
+
assert.match(frameHtml(dom), REMOTE_SRC);
|
|
226
|
+
assert.equal(notice(dom), null);
|
|
227
|
+
assert.equal(hasTrustedBadge(dom), true);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("still offers the load affordances to a merely untrusted sender", async () => {
|
|
231
|
+
const dom = await mount({});
|
|
232
|
+
|
|
233
|
+
assert.doesNotMatch(frameHtml(dom), REMOTE_SRC);
|
|
234
|
+
assert.match(notice(dom)?.textContent ?? "", /blocked for privacy/);
|
|
235
|
+
assert.ok(
|
|
236
|
+
dom.queryAll('[data-testid="blocked-images-notice"] button').length > 0,
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -200,6 +200,12 @@ const ExpandedCard = ({
|
|
|
200
200
|
const isUnread = !threadMessage.isRead;
|
|
201
201
|
const isTrusted =
|
|
202
202
|
messageData?.envelope.from[0]?.flags?.trusted?.value === true;
|
|
203
|
+
// `BlockedFlag`: never load images, even on explicit click. Read alongside
|
|
204
|
+
// `trusted` and handed to the body, which lets blocked win over trusted —
|
|
205
|
+
// including on the header, where a trusted badge beside "you blocked this
|
|
206
|
+
// sender" would tell the user two opposite things about the same address.
|
|
207
|
+
const isBlocked =
|
|
208
|
+
messageData?.envelope.from[0]?.flags?.blocked?.value === true;
|
|
203
209
|
const fromAddressId = messageData?.envelope.from[0]?.addressId;
|
|
204
210
|
const message = toThreadMessageData(threadMessage, date);
|
|
205
211
|
|
|
@@ -220,7 +226,7 @@ const ExpandedCard = ({
|
|
|
220
226
|
message={message}
|
|
221
227
|
isFocused={mobile ? false : isFocused}
|
|
222
228
|
onHeaderClick={onToggle}
|
|
223
|
-
senderBadge={isTrusted ? <TrustedSenderBadge /> : undefined}
|
|
229
|
+
senderBadge={isTrusted && !isBlocked ? <TrustedSenderBadge /> : undefined}
|
|
224
230
|
trailing={
|
|
225
231
|
<span data-testid="message-date" className="text-2xs text-fg-subtle">
|
|
226
232
|
{date}
|
|
@@ -328,6 +334,7 @@ const ExpandedCard = ({
|
|
|
328
334
|
messageId={threadMessage.messageId}
|
|
329
335
|
fromAddressId={messageData?.envelope.from[0]?.addressId}
|
|
330
336
|
isTrusted={isTrusted}
|
|
337
|
+
isBlocked={isBlocked}
|
|
331
338
|
category={toDisplayCategory(threadMessage.category)}
|
|
332
339
|
/>
|
|
333
340
|
<MessageAttachments
|
|
@@ -24,7 +24,6 @@ import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
|
24
24
|
import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
|
|
25
25
|
import { useDeleteOutcome } from "@/hooks/useDeleteOutcome";
|
|
26
26
|
import {
|
|
27
|
-
type EscalatedAction,
|
|
28
27
|
type EscalationSearchQuery,
|
|
29
28
|
useEscalatedActions,
|
|
30
29
|
} from "@/hooks/useEscalatedActions";
|
|
@@ -355,21 +354,12 @@ export const MessageList = ({
|
|
|
355
354
|
// are loaded. `orderedIds` below feeds `allLoadedSelected`; declared after
|
|
356
355
|
// this hook so its callback deps stay simple — see the
|
|
357
356
|
// `orderedIds`/`handleRowSelect` block.
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
predicateKey,
|
|
365
|
-
searchQuery: searchPredicate ?? {},
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
// How a run ended, for the user who is no longer looking at the run screen.
|
|
369
|
-
// Closing the wizard mid-run is a movement the screen invites, and the run
|
|
370
|
-
// keeps going past it — so the list is what states the ending, whether the
|
|
371
|
-
// run covered everything or stopped short of it. The wizard calls this only
|
|
372
|
-
// once the user has left it, so an ending is never said twice.
|
|
357
|
+
// How a run ended, for the user who is no longer looking at any screen that
|
|
358
|
+
// could show it. Closing the wizard mid-run is a movement the screen invites,
|
|
359
|
+
// and leaving the mailbox on top of that is one nothing prevents — the run
|
|
360
|
+
// keeps going past both, so this states the ending, whether the run covered
|
|
361
|
+
// everything or stopped short of it. The run's owner calls it only once no
|
|
362
|
+
// screen is left reporting in place, so an ending is never said twice.
|
|
373
363
|
const reportRunOutcome = useCallback(
|
|
374
364
|
(kind: BulkActionKind, matched: number, outcome: BulkRunOutcome) => {
|
|
375
365
|
const banner = runEndingBanner(kind, matched, outcome, deleteOutcome);
|
|
@@ -380,6 +370,18 @@ export const MessageList = ({
|
|
|
380
370
|
[pushError, deleteOutcome],
|
|
381
371
|
);
|
|
382
372
|
|
|
373
|
+
const escalationEnabled = isSearching && !!searchPredicate;
|
|
374
|
+
const predicateKey = `${mailboxId}|${JSON.stringify(searchPredicate ?? {})}`;
|
|
375
|
+
const escalation = useEscalatedActions({
|
|
376
|
+
mailboxId,
|
|
377
|
+
mailboxLabel: listTitle,
|
|
378
|
+
accountId,
|
|
379
|
+
enabled: escalationEnabled,
|
|
380
|
+
predicateKey,
|
|
381
|
+
searchQuery: searchPredicate ?? {},
|
|
382
|
+
reportEnding: reportRunOutcome,
|
|
383
|
+
});
|
|
384
|
+
|
|
383
385
|
// The one way selection mode ends (#115): cancel, a completed delete or
|
|
384
386
|
// move, a plain click that collapses a range, switching mailboxes, the back
|
|
385
387
|
// gesture. Nothing calls the selection hook's `clearSelection` directly, so
|
|
@@ -690,7 +692,8 @@ export const MessageList = ({
|
|
|
690
692
|
scope: describeSearchScope(searchPredicate ?? {}),
|
|
691
693
|
total: escalation.phase.total,
|
|
692
694
|
searchQuery: searchPredicate ?? {},
|
|
693
|
-
run: (action
|
|
695
|
+
run: (action, claimEnding) =>
|
|
696
|
+
escalation.runAction(action, undefined, claimEnding),
|
|
694
697
|
stop: escalation.stop,
|
|
695
698
|
}
|
|
696
699
|
: undefined;
|
|
@@ -1086,10 +1089,16 @@ export const MessageList = ({
|
|
|
1086
1089
|
// The escalation-derived surface state — viewport-independent, fed to both
|
|
1087
1090
|
// the mobile sheet and the desktop toolbar so the two never diverge (#212).
|
|
1088
1091
|
const selectionIsBusy = isDeleting || isMoving || escalation.isRunning;
|
|
1092
|
+
// A run outlives the selection it came from, and leaving the mailbox and
|
|
1093
|
+
// coming back leaves no selection at all — so while one is in flight the run's
|
|
1094
|
+
// own count is what the bar is about, or the bar would be an empty header over
|
|
1095
|
+
// mail that is still being deleted (#112).
|
|
1089
1096
|
const selectionCount =
|
|
1090
1097
|
escalation.phase.kind === "escalated"
|
|
1091
1098
|
? escalation.phase.total
|
|
1092
|
-
:
|
|
1099
|
+
: escalation.progress
|
|
1100
|
+
? escalation.progress.total
|
|
1101
|
+
: selectedCount;
|
|
1093
1102
|
|
|
1094
1103
|
// A run reports on the bar from the moment it starts, not from its first
|
|
1095
1104
|
// finished batch: the wizard invites the user back here mid-run, and a run
|
|
@@ -1128,17 +1137,22 @@ export const MessageList = ({
|
|
|
1128
1137
|
: undefined;
|
|
1129
1138
|
|
|
1130
1139
|
// At most one escalation notice at a time, ranked by how actionable it is:
|
|
1131
|
-
//
|
|
1132
|
-
// otherwise a fresh escalation offer. The
|
|
1133
|
-
// layered on behind them below.
|
|
1140
|
+
// a run in flight and a count both carry Stop, and that always wins; then an
|
|
1141
|
+
// escalated selection's way out; otherwise a fresh escalation offer. The
|
|
1142
|
+
// (rare) cross-account move hint is layered on behind them below.
|
|
1143
|
+
//
|
|
1144
|
+
// Stop rides on the bar rather than only on the wizard's run screen because
|
|
1145
|
+
// the run outlives both the wizard and the route: a user who comes back to
|
|
1146
|
+
// the mailbox mid-run finds the progress there, and has to be able to end it
|
|
1147
|
+
// from the same place (#112).
|
|
1134
1148
|
const escalationNotice =
|
|
1135
|
-
escalation.phase.kind === "counting"
|
|
1149
|
+
escalation.isRunning || escalation.phase.kind === "counting"
|
|
1136
1150
|
? {
|
|
1137
1151
|
tone: "info" as const,
|
|
1138
1152
|
text: "",
|
|
1139
1153
|
action: { label: "Stop", onClick: escalation.stop },
|
|
1140
1154
|
}
|
|
1141
|
-
: escalation.phase.kind === "escalated"
|
|
1155
|
+
: escalation.phase.kind === "escalated"
|
|
1142
1156
|
? {
|
|
1143
1157
|
tone: "info" as const,
|
|
1144
1158
|
text: "",
|
|
@@ -1343,6 +1357,7 @@ export const MessageList = ({
|
|
|
1343
1357
|
verb={wizardVerb}
|
|
1344
1358
|
accountId={accountId}
|
|
1345
1359
|
mailboxId={mailboxId}
|
|
1360
|
+
mailboxLabel={listTitle}
|
|
1346
1361
|
selection={wizardSelection}
|
|
1347
1362
|
selectionRestriction={moveDisabledHint ? "spansAccounts" : undefined}
|
|
1348
1363
|
escalated={escalatedSelection}
|
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
import { useClauseSuggestions } from "@/hooks/useClauseSuggestions";
|
|
40
40
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
41
41
|
import {
|
|
42
|
-
type EscalatedAction,
|
|
43
42
|
type EscalationSearchQuery,
|
|
44
43
|
useEscalatedActions,
|
|
45
44
|
} from "@/hooks/useEscalatedActions";
|
|
@@ -55,6 +54,8 @@ import type {
|
|
|
55
54
|
BulkActionProgress,
|
|
56
55
|
BulkActionTarget,
|
|
57
56
|
BulkRunOutcome,
|
|
57
|
+
BulkRunStart,
|
|
58
|
+
EscalatedAction,
|
|
58
59
|
} from "@/lib/bulk-actions";
|
|
59
60
|
import { NO_JUNK_FOLDER_REASON } from "@/lib/junk-destination";
|
|
60
61
|
import { useMailContext } from "@/lib/mail-context";
|
|
@@ -122,7 +123,15 @@ export interface EscalatedSelection {
|
|
|
122
123
|
* screen is what now stands in front of it. The run belongs to the list, so
|
|
123
124
|
* it survives the wizard closing over it — as the run screen promises.
|
|
124
125
|
*/
|
|
125
|
-
run: (
|
|
126
|
+
run: (
|
|
127
|
+
action: EscalatedAction,
|
|
128
|
+
/**
|
|
129
|
+
* Handed the release for the ending of the run it starts, so the wizard's
|
|
130
|
+
* run screen states that ending in place and the list does not banner it a
|
|
131
|
+
* second time (#112).
|
|
132
|
+
*/
|
|
133
|
+
claimEnding: (release: () => void) => void,
|
|
134
|
+
) => Promise<BulkRunStart>;
|
|
126
135
|
/** Ends that run at the next page boundary, which leaving the wizard does not. */
|
|
127
136
|
stop: () => void;
|
|
128
137
|
}
|
|
@@ -134,6 +143,8 @@ export interface SelectionWizardHostProps {
|
|
|
134
143
|
accountId?: string;
|
|
135
144
|
/** Where the selection was made, so a run invalidates the listing it changed. */
|
|
136
145
|
mailboxId?: string;
|
|
146
|
+
/** That mailbox in the user's words, for a commit refused while it runs. */
|
|
147
|
+
mailboxLabel?: string;
|
|
137
148
|
selection: readonly WizardSelectionMessage[];
|
|
138
149
|
/**
|
|
139
150
|
* Which scope the ticked rows span more of than the folder and rule steps can
|
|
@@ -154,10 +165,11 @@ export interface SelectionWizardHostProps {
|
|
|
154
165
|
/** The wizard is done with the selection, and the list drops it. */
|
|
155
166
|
onFinished: () => void;
|
|
156
167
|
/**
|
|
157
|
-
* How a run ended, once
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
168
|
+
* How a run ended, once no screen reporting on it is left. Handed to the run's
|
|
169
|
+
* owner rather than called here: the run screen invites the user to close it
|
|
170
|
+
* and keep the run going, and leaving the mailbox on top of that takes this
|
|
171
|
+
* component with it — so the ending is stated by something that outlives both
|
|
172
|
+
* (#112). A run screen still up reports in place instead.
|
|
161
173
|
*/
|
|
162
174
|
onRunEnded?: (
|
|
163
175
|
kind: EscalatedAction["kind"],
|
|
@@ -298,6 +310,7 @@ function SelectionWizardSession({
|
|
|
298
310
|
verb,
|
|
299
311
|
accountId,
|
|
300
312
|
mailboxId,
|
|
313
|
+
mailboxLabel,
|
|
301
314
|
selection,
|
|
302
315
|
selectionRestriction,
|
|
303
316
|
escalated: escalatedSelection,
|
|
@@ -361,11 +374,6 @@ function SelectionWizardSession({
|
|
|
361
374
|
// cleared, so a failed start can be retried.
|
|
362
375
|
const [backApplyDraft, setBackApplyDraft] = useState<OrganizeDraft>();
|
|
363
376
|
const commitSent = useRef(false);
|
|
364
|
-
// The user left the run screen while it was still reporting. Held here rather
|
|
365
|
-
// than read back off the URL: the rewind the exit starts lands a frame or two
|
|
366
|
-
// later, and an outcome arriving in between would find a screen that is on
|
|
367
|
-
// its way out and say nothing.
|
|
368
|
-
const walkedAway = useRef(false);
|
|
369
377
|
|
|
370
378
|
const messageIds = useMemo(
|
|
371
379
|
() => selection.map((message) => message.id),
|
|
@@ -491,14 +499,36 @@ function SelectionWizardSession({
|
|
|
491
499
|
senders,
|
|
492
500
|
);
|
|
493
501
|
|
|
502
|
+
// While this screen is up it states the ending itself, so the run's owner
|
|
503
|
+
// holds its banner back. The claim is over the run this screen starts and no
|
|
504
|
+
// other: a commit that starts none — a filter, a back-apply job — has no
|
|
505
|
+
// ending of its own to hold, and holding one anyway swallowed the banner of
|
|
506
|
+
// whatever run was going elsewhere. Leaving — closing the wizard, or leaving
|
|
507
|
+
// the mailbox altogether — unmounts this and releases it, and the ending is
|
|
508
|
+
// bannered where the user now is (#112, #521).
|
|
509
|
+
const releaseReport = useRef<(() => void) | undefined>(undefined);
|
|
510
|
+
const claimEnding = useCallback((release: () => void) => {
|
|
511
|
+
releaseReport.current?.();
|
|
512
|
+
releaseReport.current = release;
|
|
513
|
+
}, []);
|
|
514
|
+
useEffect(
|
|
515
|
+
() => () => {
|
|
516
|
+
releaseReport.current?.();
|
|
517
|
+
releaseReport.current = undefined;
|
|
518
|
+
},
|
|
519
|
+
[],
|
|
520
|
+
);
|
|
521
|
+
|
|
494
522
|
const organizeJob = useOrganizeJob(accountId);
|
|
495
523
|
const createFilter = useCreateFilter(accountId);
|
|
496
524
|
const bulk = useEscalatedActions({
|
|
497
525
|
mailboxId: mailboxId ?? "",
|
|
526
|
+
mailboxLabel,
|
|
498
527
|
accountId,
|
|
499
528
|
enabled: false,
|
|
500
529
|
predicateKey: "selection-wizard",
|
|
501
530
|
searchQuery: {},
|
|
531
|
+
reportEnding: onRunEnded,
|
|
502
532
|
});
|
|
503
533
|
const { runAction } = bulk;
|
|
504
534
|
|
|
@@ -661,13 +691,15 @@ function SelectionWizardSession({
|
|
|
661
691
|
return;
|
|
662
692
|
}
|
|
663
693
|
setBulkRun({ matched: targets.length, sent: targets });
|
|
664
|
-
const
|
|
665
|
-
setBulkRun({
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
694
|
+
const started = await runAction(action, targets, claimEnding);
|
|
695
|
+
setBulkRun({
|
|
696
|
+
matched: targets.length,
|
|
697
|
+
sent: targets,
|
|
698
|
+
outcome: started.kind === "ran" ? started.outcome : undefined,
|
|
699
|
+
failureReason: started.kind === "refused" ? started.reason : undefined,
|
|
700
|
+
});
|
|
669
701
|
},
|
|
670
|
-
[verb, named.moveMailboxId, junkMailboxId, runAction,
|
|
702
|
+
[verb, named.moveMailboxId, junkMailboxId, runAction, claimEnding],
|
|
671
703
|
);
|
|
672
704
|
|
|
673
705
|
// The escalated predicate, run by the chunked runner the list already owns.
|
|
@@ -685,12 +717,14 @@ function SelectionWizardSession({
|
|
|
685
717
|
return;
|
|
686
718
|
}
|
|
687
719
|
setBulkRun({ matched: escalated.total, sent: [] });
|
|
688
|
-
const
|
|
689
|
-
setBulkRun({
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
720
|
+
const started = await escalated.run(action, claimEnding);
|
|
721
|
+
setBulkRun({
|
|
722
|
+
matched: escalated.total,
|
|
723
|
+
sent: [],
|
|
724
|
+
outcome: started.kind === "ran" ? started.outcome : undefined,
|
|
725
|
+
failureReason: started.kind === "refused" ? started.reason : undefined,
|
|
726
|
+
});
|
|
727
|
+
}, [escalated, verb, named.moveMailboxId, junkMailboxId, claimEnding]);
|
|
694
728
|
|
|
695
729
|
const { start: startJob } = organizeJob;
|
|
696
730
|
const { createFilterAsync } = createFilter;
|
|
@@ -909,7 +943,6 @@ function SelectionWizardSession({
|
|
|
909
943
|
// run alone: the screen says so in as many words, and stopping it is a
|
|
910
944
|
// control of its own.
|
|
911
945
|
const dismiss = useCallback(() => {
|
|
912
|
-
walkedAway.current = true;
|
|
913
946
|
closeWizard(steps, current);
|
|
914
947
|
onFinished();
|
|
915
948
|
}, [closeWizard, steps, current, onFinished]);
|