@remit/web-client 0.0.129 → 0.0.131
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/AutoMovedIndicator.tsx +14 -8
- package/src/components/mail/BriefPane.tsx +14 -1
- package/src/components/mail/FlaggedPane.tsx +14 -1
- package/src/components/mail/IntelligencePane.test.ts +13 -84
- package/src/components/mail/IntelligencePane.tsx +45 -96
- package/src/components/mail/MailboxPane.tsx +22 -2
- package/src/components/mail/MessageCard.tsx +11 -9
- package/src/components/mail/SwipeableMessageRow.modifier-select.test.ts +237 -0
- package/src/components/mail/SwipeableMessageRow.tsx +28 -12
- package/src/hooks/useAutoMovedBadge.ts +57 -20
- package/src/hooks/useReportSpam.integration.test.ts +93 -0
- package/src/hooks/useReportSpam.render.test.ts +113 -0
- package/src/hooks/useReportSpam.test.ts +115 -0
- package/src/hooks/useReportSpam.ts +244 -0
- package/src/lib/auto-moved.test.ts +8 -0
- package/src/lib/auto-moved.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.131",
|
|
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": {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapAutoMovedInfo,
|
|
3
|
+
RemitImapMessageSpamReport,
|
|
4
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
2
5
|
import { AutoMovedBadge } from "@remit/ui";
|
|
3
6
|
import { useAutoMovedBadge } from "@/hooks/useAutoMovedBadge";
|
|
4
7
|
|
|
@@ -8,16 +11,17 @@ interface AutoMovedIndicatorProps {
|
|
|
8
11
|
threadId: string;
|
|
9
12
|
mailboxId: string;
|
|
10
13
|
autoMoved: RemitImapAutoMovedInfo | undefined;
|
|
14
|
+
spamReport: RemitImapMessageSpamReport | undefined;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
|
-
* Renders the "auto-moved by Remit" badge on the open
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* gate with `threadMessage.autoMoved
|
|
20
|
-
* for the Inbox/Junk mailbox lookups.
|
|
18
|
+
* Renders the "auto-moved by Remit" / "Reported as spam" badge on the open
|
|
19
|
+
* message only while the underlying state is still in effect — see
|
|
20
|
+
* `useAutoMovedBadge`. Mailbox list rows deliberately do not show it: there
|
|
21
|
+
* the indicator is noise, and here it carries the Undo and Manage-filter
|
|
22
|
+
* actions. Mount this only when `autoMoved` or `spamReport` is present
|
|
23
|
+
* (callers gate with `threadMessage.autoMoved || threadMessage.spamReport`)
|
|
24
|
+
* so messages with neither never pay for the Inbox/Junk mailbox lookups.
|
|
21
25
|
*/
|
|
22
26
|
export function AutoMovedIndicator({
|
|
23
27
|
accountId,
|
|
@@ -25,6 +29,7 @@ export function AutoMovedIndicator({
|
|
|
25
29
|
threadId,
|
|
26
30
|
mailboxId,
|
|
27
31
|
autoMoved,
|
|
32
|
+
spamReport,
|
|
28
33
|
}: AutoMovedIndicatorProps) {
|
|
29
34
|
const badge = useAutoMovedBadge({
|
|
30
35
|
accountId,
|
|
@@ -32,6 +37,7 @@ export function AutoMovedIndicator({
|
|
|
32
37
|
threadId,
|
|
33
38
|
mailboxId,
|
|
34
39
|
autoMoved,
|
|
40
|
+
spamReport,
|
|
35
41
|
});
|
|
36
42
|
|
|
37
43
|
if (!badge.show) return null;
|
|
@@ -73,6 +73,13 @@ interface BriefPaneContextValue {
|
|
|
73
73
|
onDeleteMessages: (messageIds: string[]) => void;
|
|
74
74
|
nextMessageId: string | undefined;
|
|
75
75
|
previousMessageId: string | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Deselects the open message when it's the one a mutation just removed
|
|
78
|
+
* from view — wired into every mutation that can take the open message out
|
|
79
|
+
* of view (delete, report-spam/undo), so the reading pane and intelligence
|
|
80
|
+
* panel never keep rendering a message that's left the list.
|
|
81
|
+
*/
|
|
82
|
+
handleDeselectIfRemoved: (removedIds: string[]) => void;
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
const BriefPaneCtx = createContext<BriefPaneContextValue | null>(null);
|
|
@@ -257,6 +264,7 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
257
264
|
onDeleteMessages: deleteMessages,
|
|
258
265
|
nextMessageId,
|
|
259
266
|
previousMessageId,
|
|
267
|
+
handleDeselectIfRemoved,
|
|
260
268
|
};
|
|
261
269
|
|
|
262
270
|
return <BriefPaneCtx.Provider value={ctx}>{children}</BriefPaneCtx.Provider>;
|
|
@@ -357,7 +365,7 @@ function BriefReading() {
|
|
|
357
365
|
* Mount in the `intelligence` slot of `AppShellSlotted`. Only rendered ≥ 1280px.
|
|
358
366
|
*/
|
|
359
367
|
function BriefIntelligence() {
|
|
360
|
-
const { selectedThread } = useBriefPane();
|
|
368
|
+
const { selectedThread, handleDeselectIfRemoved } = useBriefPane();
|
|
361
369
|
const { onToggleIntelligence } = useMailContext();
|
|
362
370
|
|
|
363
371
|
return (
|
|
@@ -366,6 +374,7 @@ function BriefIntelligence() {
|
|
|
366
374
|
thread={selectedThread}
|
|
367
375
|
mailboxId={selectedThread?.mailboxId}
|
|
368
376
|
accountId={selectedThread?.accountId}
|
|
377
|
+
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
369
378
|
/>
|
|
370
379
|
);
|
|
371
380
|
}
|
|
@@ -381,6 +390,7 @@ function BriefPhone() {
|
|
|
381
390
|
onCloseThread,
|
|
382
391
|
nextMessageId,
|
|
383
392
|
previousMessageId,
|
|
393
|
+
handleDeselectIfRemoved,
|
|
384
394
|
} = useBriefPane();
|
|
385
395
|
const { intelligenceOpen, onToggleIntelligence } = useMailContext();
|
|
386
396
|
|
|
@@ -414,7 +424,10 @@ function BriefPhone() {
|
|
|
414
424
|
<IntelligencePane
|
|
415
425
|
onClose={onToggleIntelligence}
|
|
416
426
|
thread={selectedThread}
|
|
427
|
+
mailboxId={selectedThread?.mailboxId}
|
|
428
|
+
accountId={selectedThread?.accountId}
|
|
417
429
|
hideCloseButton
|
|
430
|
+
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
418
431
|
/>
|
|
419
432
|
</Drawer>
|
|
420
433
|
</>
|
|
@@ -66,6 +66,13 @@ interface FlaggedPaneContextValue {
|
|
|
66
66
|
onDeleteMessages: (messageIds: string[]) => void;
|
|
67
67
|
nextMessageId: string | undefined;
|
|
68
68
|
previousMessageId: string | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Deselects the open message when it's the one a mutation just removed
|
|
71
|
+
* from view — wired into every mutation that can take the open message out
|
|
72
|
+
* of view (delete, report-spam/undo), so the reading pane and intelligence
|
|
73
|
+
* panel never keep rendering a message that's left the list.
|
|
74
|
+
*/
|
|
75
|
+
handleDeselectIfRemoved: (removedIds: string[]) => void;
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
const FlaggedPaneCtx = createContext<FlaggedPaneContextValue | null>(null);
|
|
@@ -190,6 +197,7 @@ function FlaggedPaneProvider({
|
|
|
190
197
|
onDeleteMessages: deleteMessages,
|
|
191
198
|
nextMessageId,
|
|
192
199
|
previousMessageId,
|
|
200
|
+
handleDeselectIfRemoved,
|
|
193
201
|
};
|
|
194
202
|
|
|
195
203
|
return (
|
|
@@ -280,7 +288,7 @@ function FlaggedReading() {
|
|
|
280
288
|
* Mount in the `intelligence` slot of `AppShellSlotted`. Only rendered ≥ 1280px.
|
|
281
289
|
*/
|
|
282
290
|
function FlaggedIntelligence() {
|
|
283
|
-
const { selectedThread } = useFlaggedPane();
|
|
291
|
+
const { selectedThread, handleDeselectIfRemoved } = useFlaggedPane();
|
|
284
292
|
const { onToggleIntelligence } = useMailContext();
|
|
285
293
|
|
|
286
294
|
return (
|
|
@@ -289,6 +297,7 @@ function FlaggedIntelligence() {
|
|
|
289
297
|
thread={selectedThread}
|
|
290
298
|
mailboxId={selectedThread?.mailboxId}
|
|
291
299
|
accountId={selectedThread?.accountId}
|
|
300
|
+
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
292
301
|
/>
|
|
293
302
|
);
|
|
294
303
|
}
|
|
@@ -301,6 +310,7 @@ function FlaggedPhone() {
|
|
|
301
310
|
onSelectMessage,
|
|
302
311
|
nextMessageId,
|
|
303
312
|
previousMessageId,
|
|
313
|
+
handleDeselectIfRemoved,
|
|
304
314
|
} = useFlaggedPane();
|
|
305
315
|
const { intelligenceOpen, onToggleIntelligence } = useMailContext();
|
|
306
316
|
|
|
@@ -333,7 +343,10 @@ function FlaggedPhone() {
|
|
|
333
343
|
<IntelligencePane
|
|
334
344
|
onClose={onToggleIntelligence}
|
|
335
345
|
thread={selectedThread}
|
|
346
|
+
mailboxId={selectedThread?.mailboxId}
|
|
347
|
+
accountId={selectedThread?.accountId}
|
|
336
348
|
hideCloseButton
|
|
349
|
+
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
337
350
|
/>
|
|
338
351
|
</Drawer>
|
|
339
352
|
</>
|
|
@@ -2,101 +2,30 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
3
|
import { resolveSimilarState, resolveSpamAction } from "./IntelligencePane";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
describe("resolveSpamAction (#594)", () => {
|
|
9
|
-
test("offers Not spam when the message is in Junk and Inbox is resolved", () => {
|
|
10
|
-
assert.equal(
|
|
11
|
-
resolveSpamAction({
|
|
12
|
-
mailboxId: junkId,
|
|
13
|
-
junkMailboxId: junkId,
|
|
14
|
-
inboxMailboxId: inboxId,
|
|
15
|
-
}),
|
|
16
|
-
"notSpam",
|
|
17
|
-
);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test("offers Mark spam when the message is outside Junk and Junk is resolved", () => {
|
|
21
|
-
assert.equal(
|
|
22
|
-
resolveSpamAction({
|
|
23
|
-
mailboxId: inboxId,
|
|
24
|
-
junkMailboxId: junkId,
|
|
25
|
-
inboxMailboxId: inboxId,
|
|
26
|
-
}),
|
|
27
|
-
"markSpam",
|
|
28
|
-
);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("offers nothing when the move source mailbox is unknown", () => {
|
|
32
|
-
assert.equal(
|
|
33
|
-
resolveSpamAction({
|
|
34
|
-
mailboxId: undefined,
|
|
35
|
-
junkMailboxId: junkId,
|
|
36
|
-
inboxMailboxId: inboxId,
|
|
37
|
-
}),
|
|
38
|
-
null,
|
|
39
|
-
);
|
|
5
|
+
describe("resolveSpamAction (#648)", () => {
|
|
6
|
+
test("offers Report spam when the message carries no spam report", () => {
|
|
7
|
+
assert.equal(resolveSpamAction({}), "reportSpam");
|
|
40
8
|
});
|
|
41
9
|
|
|
42
|
-
test("offers
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
junkMailboxId: junkId,
|
|
47
|
-
inboxMailboxId: undefined,
|
|
48
|
-
}),
|
|
49
|
-
null,
|
|
50
|
-
);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("offers nothing outside Junk when the Junk target hasn't loaded", () => {
|
|
54
|
-
assert.equal(
|
|
55
|
-
resolveSpamAction({
|
|
56
|
-
mailboxId: inboxId,
|
|
57
|
-
junkMailboxId: undefined,
|
|
58
|
-
inboxMailboxId: inboxId,
|
|
59
|
-
}),
|
|
60
|
-
null,
|
|
61
|
-
);
|
|
10
|
+
test("offers Report spam regardless of which mailbox the message is in", () => {
|
|
11
|
+
// A report is never derived from placement — reporting a message the
|
|
12
|
+
// provider's own filter already put in Junk is a real, no-op-move case.
|
|
13
|
+
assert.equal(resolveSpamAction({ spamReport: undefined }), "reportSpam");
|
|
62
14
|
});
|
|
63
15
|
|
|
64
|
-
test("offers
|
|
16
|
+
test("offers Not spam once the message carries a spam report", () => {
|
|
65
17
|
assert.equal(
|
|
66
|
-
resolveSpamAction({
|
|
67
|
-
mailboxId: junkId,
|
|
68
|
-
junkMailboxId: junkId,
|
|
69
|
-
inboxMailboxId: inboxId,
|
|
70
|
-
moveApplied: true,
|
|
71
|
-
}),
|
|
72
|
-
null,
|
|
73
|
-
);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("offers Not spam again when the move came back failed", () => {
|
|
77
|
-
assert.equal(
|
|
78
|
-
resolveSpamAction({
|
|
79
|
-
mailboxId: junkId,
|
|
80
|
-
junkMailboxId: junkId,
|
|
81
|
-
inboxMailboxId: inboxId,
|
|
82
|
-
moveApplied: false,
|
|
83
|
-
}),
|
|
18
|
+
resolveSpamAction({ spamReport: { reportedAt: Date.now() } }),
|
|
84
19
|
"notSpam",
|
|
85
20
|
);
|
|
86
21
|
});
|
|
87
22
|
|
|
88
23
|
test("the two actions are mutually exclusive", () => {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
inboxMailboxId: inboxId,
|
|
93
|
-
});
|
|
94
|
-
const elsewhere = resolveSpamAction({
|
|
95
|
-
mailboxId: inboxId,
|
|
96
|
-
junkMailboxId: junkId,
|
|
97
|
-
inboxMailboxId: inboxId,
|
|
24
|
+
const reportable = resolveSpamAction({});
|
|
25
|
+
const reported = resolveSpamAction({
|
|
26
|
+
spamReport: { reportedAt: Date.now() },
|
|
98
27
|
});
|
|
99
|
-
assert.notEqual(
|
|
28
|
+
assert.notEqual(reportable, reported);
|
|
100
29
|
});
|
|
101
30
|
});
|
|
102
31
|
|
|
@@ -8,10 +8,8 @@ import {
|
|
|
8
8
|
import { Link } from "@tanstack/react-router";
|
|
9
9
|
import { Sparkles } from "lucide-react";
|
|
10
10
|
import { useCallback, useState } from "react";
|
|
11
|
-
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
12
|
-
import { useInboxMailbox, useJunkMailbox } from "@/hooks/useArchiveMailbox";
|
|
13
11
|
import { useIntelligenceData } from "@/hooks/useIntelligenceData";
|
|
14
|
-
import {
|
|
12
|
+
import { useReportSpam } from "@/hooks/useReportSpam";
|
|
15
13
|
import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
|
|
16
14
|
import { isRescueCandidate } from "@/lib/rescue-candidates";
|
|
17
15
|
import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
|
|
@@ -27,13 +25,28 @@ export interface IntelligencePaneProps {
|
|
|
27
25
|
thread?: RemitImapThreadMessageResponse;
|
|
28
26
|
/** The mailbox the message list is currently showing. */
|
|
29
27
|
mailboxId?: string;
|
|
30
|
-
/** Account that owns the open message;
|
|
28
|
+
/** Account that owns the open message; scopes mailbox-list cache invalidation after a spam-report mutation. */
|
|
31
29
|
accountId?: string;
|
|
32
30
|
/**
|
|
33
31
|
* Hide the panel's own close (X). Set when hosted inside the mobile Drawer,
|
|
34
32
|
* whose header already renders a close button — one way back, not two (#874).
|
|
35
33
|
*/
|
|
36
34
|
hideCloseButton?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Called once a "Report spam" / "Not spam" press succeeds, with the acted-on
|
|
37
|
+
* message id. The host's own `handleDeselectIfRemoved` (already wired into
|
|
38
|
+
* `useMoveMessages`/`useDeleteMessages` for the same reason) belongs here
|
|
39
|
+
* too: the panel's `thread` prop is a list-derived snapshot the host stops
|
|
40
|
+
* refreshing once the row leaves the list it's watching, so without this a
|
|
41
|
+
* reported message keeps rendering "Report spam" here forever, even while
|
|
42
|
+
* the reading pane's own per-thread query — a different cache — already
|
|
43
|
+
* shows "Reported as spam · Undo" (issue #648 review). Fires on every
|
|
44
|
+
* success, no-op included: neither endpoint says whether the message
|
|
45
|
+
* actually left the list, so a no-op report/undo also deselects even
|
|
46
|
+
* though the row never moved. That is a real, accepted trade — an
|
|
47
|
+
* occasional unneeded pane-close — not a bug; see `useReportSpam`.
|
|
48
|
+
*/
|
|
49
|
+
onAfterOptimisticRemove?: (messageIds: string[]) => void;
|
|
37
50
|
}
|
|
38
51
|
|
|
39
52
|
/**
|
|
@@ -52,39 +65,18 @@ const CATEGORY_OVERRIDES = [
|
|
|
52
65
|
type CategoryOverride = (typeof CATEGORY_OVERRIDES)[number];
|
|
53
66
|
|
|
54
67
|
/**
|
|
55
|
-
* Decide which spam quick-action to offer for the current message (issue #
|
|
56
|
-
*
|
|
57
|
-
* `mailboxId` is the mailbox the MESSAGE is in, which is what the move acts on.
|
|
58
|
-
* The two buttons are symmetric and mutually exclusive:
|
|
59
|
-
* - In the Junk mailbox → offer **Not spam** (move out, promote sender), but
|
|
60
|
-
* only when an Inbox destination is resolved.
|
|
61
|
-
* - Anywhere else → offer **Mark spam** (move in, demote sender), but only when
|
|
62
|
-
* the Junk destination is resolved.
|
|
68
|
+
* Decide which spam quick-action to offer for the current message (issue #648).
|
|
63
69
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* Returns `"notSpam"`, `"markSpam"`, or `null` (no actionable button — e.g. the
|
|
72
|
-
* move source isn't known, or the needed target mailbox hasn't loaded). Pure so
|
|
73
|
-
* the wiring decision can be unit-tested without rendering.
|
|
70
|
+
* Driven entirely by `Message.spamReport`, never by which mailbox the message
|
|
71
|
+
* is currently in: reporting a message that's already in Junk (the provider's
|
|
72
|
+
* own filter put it there) is a real, no-op-move case, so mailbox membership
|
|
73
|
+
* cannot stand in for "has this been reported". A message that carries a
|
|
74
|
+
* report offers the undo ("Not spam"); everything else offers "Report spam".
|
|
75
|
+
* Pure so the wiring decision can be unit-tested without rendering.
|
|
74
76
|
*/
|
|
75
|
-
export const resolveSpamAction = (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
inboxMailboxId?: string;
|
|
79
|
-
moveApplied?: boolean;
|
|
80
|
-
}): "notSpam" | "markSpam" | null => {
|
|
81
|
-
const { mailboxId, junkMailboxId, inboxMailboxId, moveApplied } = input;
|
|
82
|
-
if (moveApplied) return null;
|
|
83
|
-
if (!mailboxId) return null;
|
|
84
|
-
const isInJunk = Boolean(junkMailboxId && mailboxId === junkMailboxId);
|
|
85
|
-
if (isInJunk) return inboxMailboxId ? "notSpam" : null;
|
|
86
|
-
return junkMailboxId ? "markSpam" : null;
|
|
87
|
-
};
|
|
77
|
+
export const resolveSpamAction = (thread: {
|
|
78
|
+
spamReport?: unknown;
|
|
79
|
+
}): "notSpam" | "reportSpam" => (thread.spamReport ? "notSpam" : "reportSpam");
|
|
88
80
|
|
|
89
81
|
/**
|
|
90
82
|
* Decide the "Similar messages" section state from the semantic-search query.
|
|
@@ -217,6 +209,7 @@ interface WiredPanelProps {
|
|
|
217
209
|
mailboxId?: string;
|
|
218
210
|
accountId?: string;
|
|
219
211
|
hideCloseButton?: boolean;
|
|
212
|
+
onAfterOptimisticRemove?: (messageIds: string[]) => void;
|
|
220
213
|
}
|
|
221
214
|
|
|
222
215
|
/**
|
|
@@ -228,6 +221,7 @@ function WiredPanel({
|
|
|
228
221
|
mailboxId,
|
|
229
222
|
accountId,
|
|
230
223
|
hideCloseButton,
|
|
224
|
+
onAfterOptimisticRemove,
|
|
231
225
|
}: WiredPanelProps) {
|
|
232
226
|
const {
|
|
233
227
|
data,
|
|
@@ -236,61 +230,37 @@ function WiredPanel({
|
|
|
236
230
|
similarError,
|
|
237
231
|
similarErrorIsFatal,
|
|
238
232
|
} = useIntelligenceData(thread, mailboxId);
|
|
239
|
-
const [confirmBlock, setConfirmBlock] = useState(false);
|
|
240
233
|
const [reclassifyOpen, setReclassifyOpen] = useState(false);
|
|
241
234
|
const senderEmail = thread.fromEmail ?? undefined;
|
|
242
235
|
|
|
243
|
-
const { updateFlags
|
|
236
|
+
const { updateFlags } = useUpdateAddressFlags({
|
|
244
237
|
addressId,
|
|
245
238
|
senderEmail,
|
|
246
239
|
});
|
|
247
240
|
|
|
248
|
-
// "
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
// cross-account view names a different folder (issue #594).
|
|
252
|
-
const { junkMailboxId } = useJunkMailbox(accountId);
|
|
253
|
-
const { inboxMailboxId } = useInboxMailbox(accountId);
|
|
254
|
-
const { moveMessages, isError: moveFailed } = useMoveMessages({
|
|
241
|
+
// "Report spam" / "Not spam" are a contextual pair, decided by whether the
|
|
242
|
+
// message carries a spam report — never by the mailbox it's in (issue #648).
|
|
243
|
+
const { reportSpam, notSpam, isReporting, isRestoring } = useReportSpam({
|
|
255
244
|
mailboxId: thread.mailboxId,
|
|
256
245
|
threadId: thread.threadId,
|
|
257
246
|
accountId,
|
|
247
|
+
onAfterOptimisticRemove,
|
|
258
248
|
});
|
|
259
249
|
const telemetry = useTelemetry();
|
|
260
|
-
const
|
|
261
|
-
{ messageId: string; mailboxId: string } | undefined
|
|
262
|
-
>(undefined);
|
|
263
|
-
|
|
264
|
-
// Spent while the row this panel is rendering still names the mailbox the
|
|
265
|
-
// move took the message OUT of. It resolves itself: a row that has caught up
|
|
266
|
-
// with the move offers the actions again, and a move that comes back failed
|
|
267
|
-
// is rolled back with a banner, so the retry is against the same button.
|
|
268
|
-
const spamAction = resolveSpamAction({
|
|
269
|
-
mailboxId: thread.mailboxId,
|
|
270
|
-
junkMailboxId,
|
|
271
|
-
inboxMailboxId,
|
|
272
|
-
moveApplied:
|
|
273
|
-
!moveFailed &&
|
|
274
|
-
lastMove?.messageId === thread.messageId &&
|
|
275
|
-
lastMove.mailboxId !== thread.mailboxId,
|
|
276
|
-
});
|
|
250
|
+
const spamAction = resolveSpamAction(thread);
|
|
277
251
|
|
|
278
252
|
const handleNotSpam = useCallback(() => {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
moveMessages([thread.messageId], inboxMailboxId);
|
|
282
|
-
}, [inboxMailboxId, moveMessages, thread.messageId]);
|
|
253
|
+
notSpam([thread.messageId]);
|
|
254
|
+
}, [notSpam, thread.messageId]);
|
|
283
255
|
|
|
284
|
-
const
|
|
285
|
-
if (!junkMailboxId) return;
|
|
256
|
+
const handleReportSpam = useCallback(() => {
|
|
286
257
|
recordRescueSentToJunk(telemetry, {
|
|
287
258
|
count: 1,
|
|
288
259
|
senderTrust: thread.senderTrust,
|
|
289
260
|
wasRescuable: isRescueCandidate(thread),
|
|
290
261
|
});
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}, [junkMailboxId, moveMessages, thread, telemetry]);
|
|
262
|
+
reportSpam([thread.messageId]);
|
|
263
|
+
}, [reportSpam, thread, telemetry]);
|
|
294
264
|
|
|
295
265
|
const handleShowSimilar = useCallback(() => {
|
|
296
266
|
// The similar-messages section scrolls into view automatically when
|
|
@@ -309,20 +279,6 @@ function WiredPanel({
|
|
|
309
279
|
updateFlags({ muted: { value: next } });
|
|
310
280
|
}, [data?.flags?.muted, updateFlags]);
|
|
311
281
|
|
|
312
|
-
const handleToggleBlock = useCallback(() => {
|
|
313
|
-
if (data?.flags?.blocked === true) {
|
|
314
|
-
// Unblock — no confirm needed
|
|
315
|
-
updateFlags({ blocked: { value: false } });
|
|
316
|
-
} else {
|
|
317
|
-
setConfirmBlock(true);
|
|
318
|
-
}
|
|
319
|
-
}, [data?.flags?.blocked, updateFlags]);
|
|
320
|
-
|
|
321
|
-
const handleBlockConfirm = useCallback(() => {
|
|
322
|
-
setConfirmBlock(false);
|
|
323
|
-
updateFlags({ blocked: { value: true } });
|
|
324
|
-
}, [updateFlags]);
|
|
325
|
-
|
|
326
282
|
const handleToggleUnsubscribe = useCallback(() => {
|
|
327
283
|
const next = !(data?.flags?.unsubscribed === true);
|
|
328
284
|
updateFlags({ unsubscribed: { value: next } });
|
|
@@ -344,11 +300,10 @@ function WiredPanel({
|
|
|
344
300
|
const actions: IntelligenceQuickActions = {
|
|
345
301
|
onToggleVip: canUpdateFlags ? handleToggleVip : undefined,
|
|
346
302
|
onToggleMute: canUpdateFlags ? handleToggleMute : undefined,
|
|
347
|
-
onToggleBlock: canUpdateFlags ? handleToggleBlock : undefined,
|
|
348
303
|
onToggleUnsubscribe: canUpdateFlags ? handleToggleUnsubscribe : undefined,
|
|
349
304
|
onReclassify: canUpdateFlags ? () => setReclassifyOpen(true) : undefined,
|
|
350
305
|
onNotSpam: spamAction === "notSpam" ? handleNotSpam : undefined,
|
|
351
|
-
|
|
306
|
+
onReportSpam: spamAction === "reportSpam" ? handleReportSpam : undefined,
|
|
352
307
|
};
|
|
353
308
|
|
|
354
309
|
// Each similar-message row is a real router anchor — same link shape as a
|
|
@@ -392,21 +347,13 @@ function WiredPanel({
|
|
|
392
347
|
actions={actions}
|
|
393
348
|
similarState={similarState}
|
|
394
349
|
similarLinkComponent={similarLinkComponent}
|
|
350
|
+
reportSpamPending={isReporting}
|
|
351
|
+
notSpamPending={isRestoring}
|
|
395
352
|
// No left border: the ResizableHandle to our left already draws the
|
|
396
353
|
// hairline seam. The remit-ui IntelligencePanel default `border-l`
|
|
397
354
|
// would double it to 2px.
|
|
398
355
|
className="border-l-0 h-full w-full"
|
|
399
356
|
/>
|
|
400
|
-
<ConfirmDialog
|
|
401
|
-
isOpen={confirmBlock}
|
|
402
|
-
title="Block this sender?"
|
|
403
|
-
description={`Messages from ${senderEmail ?? "this sender"} will never load images and will be flagged. You can undo this in Settings → Senders.`}
|
|
404
|
-
confirmLabel="Block sender"
|
|
405
|
-
destructive
|
|
406
|
-
isBusy={isPending}
|
|
407
|
-
onConfirm={handleBlockConfirm}
|
|
408
|
-
onCancel={() => setConfirmBlock(false)}
|
|
409
|
-
/>
|
|
410
357
|
<ReclassifyDialog
|
|
411
358
|
isOpen={reclassifyOpen}
|
|
412
359
|
current={data.category.value}
|
|
@@ -433,6 +380,7 @@ export const IntelligencePane = ({
|
|
|
433
380
|
mailboxId,
|
|
434
381
|
accountId,
|
|
435
382
|
hideCloseButton,
|
|
383
|
+
onAfterOptimisticRemove,
|
|
436
384
|
}: IntelligencePaneProps) => {
|
|
437
385
|
if (!thread) {
|
|
438
386
|
return (
|
|
@@ -461,6 +409,7 @@ export const IntelligencePane = ({
|
|
|
461
409
|
mailboxId={mailboxId}
|
|
462
410
|
accountId={accountId}
|
|
463
411
|
hideCloseButton={hideCloseButton}
|
|
412
|
+
onAfterOptimisticRemove={onAfterOptimisticRemove}
|
|
464
413
|
/>
|
|
465
414
|
);
|
|
466
415
|
};
|
|
@@ -173,6 +173,14 @@ interface MailboxPaneContextValue {
|
|
|
173
173
|
listFilter: MessageListFilter | undefined;
|
|
174
174
|
intelligenceOpen: boolean;
|
|
175
175
|
onToggleIntelligence: () => void;
|
|
176
|
+
/**
|
|
177
|
+
* Deselects the open message when it's the one a mutation just removed
|
|
178
|
+
* from this mailbox's list — wired into every mutation that can take the
|
|
179
|
+
* open message out of view (delete, move, report-spam/undo), so the
|
|
180
|
+
* reading pane and intelligence panel never keep rendering a message
|
|
181
|
+
* that's left the list they're watching.
|
|
182
|
+
*/
|
|
183
|
+
handleDeselectIfRemoved: (removedIds: string[]) => void;
|
|
176
184
|
/**
|
|
177
185
|
* The active search predicate — undefined when not searching. Threaded
|
|
178
186
|
* down so the mobile list can re-issue the identical filter while paging
|
|
@@ -867,6 +875,7 @@ function MailboxPaneProvider({
|
|
|
867
875
|
listFilter,
|
|
868
876
|
intelligenceOpen,
|
|
869
877
|
onToggleIntelligence,
|
|
878
|
+
handleDeselectIfRemoved,
|
|
870
879
|
// Escalation ("select all N matching") re-issues this predicate server-side
|
|
871
880
|
// and acts on every match, so it is only offered when the predicate IS the
|
|
872
881
|
// search: a residual token narrows the rows on screen but not the request,
|
|
@@ -1223,8 +1232,13 @@ function MailboxReading() {
|
|
|
1223
1232
|
* Mount in the `intelligence` slot of `AppShellSlotted`. Only rendered ≥ 1280px.
|
|
1224
1233
|
*/
|
|
1225
1234
|
function MailboxIntelligence() {
|
|
1226
|
-
const {
|
|
1227
|
-
|
|
1235
|
+
const {
|
|
1236
|
+
mailboxId,
|
|
1237
|
+
mailboxAccountId,
|
|
1238
|
+
selectedThread,
|
|
1239
|
+
onToggleIntelligence,
|
|
1240
|
+
handleDeselectIfRemoved,
|
|
1241
|
+
} = useMailboxPane();
|
|
1228
1242
|
|
|
1229
1243
|
return (
|
|
1230
1244
|
<IntelligencePane
|
|
@@ -1232,6 +1246,7 @@ function MailboxIntelligence() {
|
|
|
1232
1246
|
thread={selectedThread}
|
|
1233
1247
|
mailboxId={mailboxId}
|
|
1234
1248
|
accountId={mailboxAccountId}
|
|
1249
|
+
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
1235
1250
|
/>
|
|
1236
1251
|
);
|
|
1237
1252
|
}
|
|
@@ -1243,6 +1258,7 @@ function MailboxIntelligence() {
|
|
|
1243
1258
|
function MailboxPhone() {
|
|
1244
1259
|
const {
|
|
1245
1260
|
mailboxId,
|
|
1261
|
+
mailboxAccountId,
|
|
1246
1262
|
selectedThread,
|
|
1247
1263
|
conversation,
|
|
1248
1264
|
intelligenceOpen,
|
|
@@ -1251,6 +1267,7 @@ function MailboxPhone() {
|
|
|
1251
1267
|
nextMessageId,
|
|
1252
1268
|
previousMessageId,
|
|
1253
1269
|
composeState,
|
|
1270
|
+
handleDeselectIfRemoved,
|
|
1254
1271
|
} = useMailboxPane();
|
|
1255
1272
|
const navigate = useNavigate();
|
|
1256
1273
|
|
|
@@ -1293,7 +1310,10 @@ function MailboxPhone() {
|
|
|
1293
1310
|
<IntelligencePane
|
|
1294
1311
|
onClose={onToggleIntelligence}
|
|
1295
1312
|
thread={selectedThread}
|
|
1313
|
+
mailboxId={mailboxId}
|
|
1314
|
+
accountId={mailboxAccountId}
|
|
1296
1315
|
hideCloseButton
|
|
1316
|
+
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
1297
1317
|
/>
|
|
1298
1318
|
</Drawer>
|
|
1299
1319
|
</>
|
|
@@ -198,15 +198,17 @@ const ExpandedCard = ({
|
|
|
198
198
|
const fromAddressId = messageData?.envelope.from[0]?.addressId;
|
|
199
199
|
const message = toThreadMessageData(threadMessage, date);
|
|
200
200
|
|
|
201
|
-
const autoMovedNode =
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
201
|
+
const autoMovedNode =
|
|
202
|
+
threadMessage.autoMoved || threadMessage.spamReport ? (
|
|
203
|
+
<AutoMovedIndicator
|
|
204
|
+
accountId={accountId}
|
|
205
|
+
messageId={threadMessage.messageId}
|
|
206
|
+
threadId={threadMessage.threadId}
|
|
207
|
+
mailboxId={threadMessage.mailboxId}
|
|
208
|
+
autoMoved={threadMessage.autoMoved}
|
|
209
|
+
spamReport={threadMessage.spamReport}
|
|
210
|
+
/>
|
|
211
|
+
) : null;
|
|
210
212
|
|
|
211
213
|
return (
|
|
212
214
|
<ExpandedMessage
|