@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.129",
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 { RemitImapAutoMovedInfo } from "@remit/api-http-client/types.gen.ts";
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 message only while the
15
- * move is still in effect (current mailbox matches the verdict's implied
16
- * destination) — see `useAutoMovedBadge`. Mailbox list rows deliberately do not
17
- * show it: there the move is noise, and here it carries the Undo and
18
- * Manage-filter actions. Mount this only when `autoMoved` is present (callers
19
- * gate with `threadMessage.autoMoved &&`) so messages without a move never pay
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
- const junkId = "junk-mailbox";
6
- const inboxId = "inbox-mailbox";
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 nothing in Junk when the Inbox target hasn't loaded", () => {
43
- assert.equal(
44
- resolveSpamAction({
45
- mailboxId: junkId,
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 nothing once the panel has moved this message", () => {
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 inJunk = resolveSpamAction({
90
- mailboxId: junkId,
91
- junkMailboxId: junkId,
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(inJunk, elsewhere);
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 { useMoveMessages } from "@/hooks/useMoveMessages";
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; resolves the Junk/Inbox move targets. */
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 #594).
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
- * `moveApplied` says the panel has already moved this message and the row it is
65
- * rendering has not caught up. The list drops the row on the move, but the
66
- * reading pane keeps the message open from the row it was opened with, whose
67
- * mailbox is still the source so without this the button survives its own
68
- * press, and a second press asks the server to move the message to where it now
69
- * already is.
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 = (input: {
76
- mailboxId?: string;
77
- junkMailboxId?: string;
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, isPending } = useUpdateAddressFlags({
236
+ const { updateFlags } = useUpdateAddressFlags({
244
237
  addressId,
245
238
  senderEmail,
246
239
  });
247
240
 
248
- // "Not spam" / "Mark spam" move the message across the Junk boundary. Only
249
- // one is wired at a time, decided by the mailbox the message itself is in —
250
- // not by the list the user happens to be looking at, which for a search or a
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 [lastMove, setLastMove] = useState<
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
- if (!inboxMailboxId) return;
280
- setLastMove({ messageId: thread.messageId, mailboxId: inboxMailboxId });
281
- moveMessages([thread.messageId], inboxMailboxId);
282
- }, [inboxMailboxId, moveMessages, thread.messageId]);
253
+ notSpam([thread.messageId]);
254
+ }, [notSpam, thread.messageId]);
283
255
 
284
- const handleMarkSpam = useCallback(() => {
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
- setLastMove({ messageId: thread.messageId, mailboxId: junkMailboxId });
292
- moveMessages([thread.messageId], junkMailboxId);
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
- onMarkSpam: spamAction === "markSpam" ? handleMarkSpam : undefined,
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 { mailboxId, mailboxAccountId, selectedThread, onToggleIntelligence } =
1227
- useMailboxPane();
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 = threadMessage.autoMoved ? (
202
- <AutoMovedIndicator
203
- accountId={accountId}
204
- messageId={threadMessage.messageId}
205
- threadId={threadMessage.threadId}
206
- mailboxId={threadMessage.mailboxId}
207
- autoMoved={threadMessage.autoMoved}
208
- />
209
- ) : null;
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