@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
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A modifier can only come from a real keyboard, so the touch row honours it at
|
|
3
|
+
* every width (#586).
|
|
4
|
+
*
|
|
5
|
+
* Below 1024px the list renders the swipe row, which reads a press as a pointer
|
|
6
|
+
* gesture and opens the message from the release. Shift and cmd have to reach
|
|
7
|
+
* selection before that gesture starts, or a half-screen window and a tablet
|
|
8
|
+
* with a keyboard can only ever open messages one at a time.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { afterEach, describe, it } from "node:test";
|
|
13
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
14
|
+
import type { SelectionModifiers } from "@remit/ui";
|
|
15
|
+
import {
|
|
16
|
+
type AnyRouter,
|
|
17
|
+
createMemoryHistory,
|
|
18
|
+
createRootRoute,
|
|
19
|
+
createRoute,
|
|
20
|
+
createRouter,
|
|
21
|
+
RouterContextProvider,
|
|
22
|
+
} from "@tanstack/react-router";
|
|
23
|
+
import { createElement } from "react";
|
|
24
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
25
|
+
import { SwipeableMessageRow } from "./SwipeableMessageRow";
|
|
26
|
+
|
|
27
|
+
const HALF_SCREEN_WIDTH = 900;
|
|
28
|
+
|
|
29
|
+
let harness: DomHarness | undefined;
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
harness?.close();
|
|
33
|
+
harness = undefined;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const thread = {
|
|
37
|
+
threadMessageId: "tm-1",
|
|
38
|
+
threadId: "th-1",
|
|
39
|
+
messageId: "msg-1",
|
|
40
|
+
accountId: "acc-1",
|
|
41
|
+
accountConfigId: "acc-1",
|
|
42
|
+
mailboxId: "mbx-1",
|
|
43
|
+
subject: "Q3 planning notes",
|
|
44
|
+
fromName: "Alex Rivera",
|
|
45
|
+
fromEmail: "alex@example.com",
|
|
46
|
+
snippet: "Notes from the planning session.",
|
|
47
|
+
sentDate: 0,
|
|
48
|
+
isRead: false,
|
|
49
|
+
hasAttachment: false,
|
|
50
|
+
hasStars: false,
|
|
51
|
+
star: "None",
|
|
52
|
+
isDeleted: false,
|
|
53
|
+
senderTrust: "unknown",
|
|
54
|
+
createdAt: 0,
|
|
55
|
+
updatedAt: 0,
|
|
56
|
+
} as unknown as RemitImapThreadMessageResponse;
|
|
57
|
+
|
|
58
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
59
|
+
// `window`.
|
|
60
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
61
|
+
|
|
62
|
+
const rootRoute = createRootRoute();
|
|
63
|
+
const mailRoute = createRoute({
|
|
64
|
+
getParentRoute: () => rootRoute,
|
|
65
|
+
path: "/mail/$mailboxId",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
interface Mounted {
|
|
69
|
+
row: HTMLElement;
|
|
70
|
+
router: AnyRouter;
|
|
71
|
+
selects: SelectionModifiers[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const mountRow = (selectionTakesIt = true): Mounted => {
|
|
75
|
+
const selects: SelectionModifiers[] = [];
|
|
76
|
+
const router = createRouter({
|
|
77
|
+
routeTree: rootRoute.addChildren([mailRoute]),
|
|
78
|
+
history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
|
|
79
|
+
}) as unknown as AnyRouter;
|
|
80
|
+
const created = createDomHarness({ viewportWidth: HALF_SCREEN_WIDTH });
|
|
81
|
+
harness = created;
|
|
82
|
+
created.render(
|
|
83
|
+
createElement(RouterContextProvider, {
|
|
84
|
+
router,
|
|
85
|
+
// biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
|
|
86
|
+
children: createElement(SwipeableMessageRow, {
|
|
87
|
+
thread,
|
|
88
|
+
mailboxId: "mbx-1",
|
|
89
|
+
isSelected: false,
|
|
90
|
+
isChecked: false,
|
|
91
|
+
onToggleCheck: () => undefined,
|
|
92
|
+
onRowSelect: (_id: string, modifiers: SelectionModifiers) => {
|
|
93
|
+
selects.push(modifiers);
|
|
94
|
+
return selectionTakesIt;
|
|
95
|
+
},
|
|
96
|
+
isMultiSelectMode: false,
|
|
97
|
+
onLongPress: () => undefined,
|
|
98
|
+
isDesktop: false,
|
|
99
|
+
onDelete: () => undefined,
|
|
100
|
+
onToggleRead: () => undefined,
|
|
101
|
+
}),
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
const row = created.query("button[data-message-row]");
|
|
105
|
+
assert.ok(row, "the swipe row did not mount");
|
|
106
|
+
return { row, router, selects };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const press = (
|
|
110
|
+
row: Element,
|
|
111
|
+
modifiers: Partial<SelectionModifiers> = {},
|
|
112
|
+
): PointerEvent => {
|
|
113
|
+
const event = new PointerEvent("pointerdown", {
|
|
114
|
+
bubbles: true,
|
|
115
|
+
cancelable: true,
|
|
116
|
+
pointerId: 1,
|
|
117
|
+
clientX: 10,
|
|
118
|
+
clientY: 10,
|
|
119
|
+
shiftKey: modifiers.shiftKey ?? false,
|
|
120
|
+
metaKey: modifiers.metaKey ?? false,
|
|
121
|
+
ctrlKey: modifiers.ctrlKey ?? false,
|
|
122
|
+
});
|
|
123
|
+
harness?.dispatch(row, event);
|
|
124
|
+
return event;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const release = (row: Element): void => {
|
|
128
|
+
harness?.dispatch(
|
|
129
|
+
row,
|
|
130
|
+
new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }),
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// A press whose default was taken still delivers a click, so the click has to
|
|
135
|
+
// be consumed too rather than reaching selection a second time.
|
|
136
|
+
const click = (
|
|
137
|
+
row: Element,
|
|
138
|
+
modifiers: Partial<SelectionModifiers> = {},
|
|
139
|
+
): void =>
|
|
140
|
+
harness?.dispatch(
|
|
141
|
+
row,
|
|
142
|
+
new MouseEvent("click", {
|
|
143
|
+
bubbles: true,
|
|
144
|
+
cancelable: true,
|
|
145
|
+
shiftKey: modifiers.shiftKey ?? false,
|
|
146
|
+
metaKey: modifiers.metaKey ?? false,
|
|
147
|
+
ctrlKey: modifiers.ctrlKey ?? false,
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const openedMessageId = (router: AnyRouter): string | undefined =>
|
|
152
|
+
(router.state.location.search as { selectedMessageId?: string })
|
|
153
|
+
.selectedMessageId;
|
|
154
|
+
|
|
155
|
+
describe("SwipeableMessageRow — modifier selection below the desktop width", () => {
|
|
156
|
+
it("takes a shift-press for selection instead of opening the message", async () => {
|
|
157
|
+
const { row, router, selects } = mountRow();
|
|
158
|
+
|
|
159
|
+
const event = press(row, { shiftKey: true });
|
|
160
|
+
release(row);
|
|
161
|
+
await harness?.flush();
|
|
162
|
+
|
|
163
|
+
assert.deepEqual(selects, [
|
|
164
|
+
{ shiftKey: true, metaKey: false, ctrlKey: false },
|
|
165
|
+
]);
|
|
166
|
+
assert.equal(event.defaultPrevented, true);
|
|
167
|
+
assert.equal(openedMessageId(router), undefined);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("takes a cmd-press for selection instead of opening the message", async () => {
|
|
171
|
+
const { row, router, selects } = mountRow();
|
|
172
|
+
|
|
173
|
+
press(row, { metaKey: true });
|
|
174
|
+
release(row);
|
|
175
|
+
click(row, { metaKey: true });
|
|
176
|
+
await harness?.flush();
|
|
177
|
+
|
|
178
|
+
assert.deepEqual(selects, [
|
|
179
|
+
{ shiftKey: false, metaKey: true, ctrlKey: false },
|
|
180
|
+
]);
|
|
181
|
+
assert.equal(openedMessageId(router), undefined);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("drops the native text selection a shift-press would drag across rows", () => {
|
|
185
|
+
const { row } = mountRow();
|
|
186
|
+
const selection = harness?.window.getSelection();
|
|
187
|
+
assert.ok(selection, "jsdom exposes no selection");
|
|
188
|
+
const range = harness?.document.createRange();
|
|
189
|
+
assert.ok(range, "jsdom exposes no range");
|
|
190
|
+
range.selectNodeContents(row);
|
|
191
|
+
selection.removeAllRanges();
|
|
192
|
+
selection.addRange(range);
|
|
193
|
+
|
|
194
|
+
press(row, { shiftKey: true });
|
|
195
|
+
|
|
196
|
+
assert.equal(selection.rangeCount, 0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("suppresses the context menu a ctrl-press already spent on selection", () => {
|
|
200
|
+
const { row, selects } = mountRow();
|
|
201
|
+
|
|
202
|
+
press(row, { ctrlKey: true });
|
|
203
|
+
const menu = new MouseEvent("contextmenu", {
|
|
204
|
+
bubbles: true,
|
|
205
|
+
cancelable: true,
|
|
206
|
+
ctrlKey: true,
|
|
207
|
+
});
|
|
208
|
+
harness?.dispatch(row, menu);
|
|
209
|
+
|
|
210
|
+
assert.deepEqual(selects, [
|
|
211
|
+
{ shiftKey: false, metaKey: false, ctrlKey: true },
|
|
212
|
+
]);
|
|
213
|
+
assert.equal(menu.defaultPrevented, true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("opens on an unmodified tap", async () => {
|
|
217
|
+
const { row, router, selects } = mountRow();
|
|
218
|
+
|
|
219
|
+
press(row);
|
|
220
|
+
release(row);
|
|
221
|
+
click(row);
|
|
222
|
+
await harness?.flush();
|
|
223
|
+
|
|
224
|
+
assert.deepEqual(selects, []);
|
|
225
|
+
assert.equal(openedMessageId(router), "msg-1");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("opens when selection declines the modified press", async () => {
|
|
229
|
+
const { row, router } = mountRow(false);
|
|
230
|
+
|
|
231
|
+
press(row, { metaKey: true });
|
|
232
|
+
release(row);
|
|
233
|
+
await harness?.flush();
|
|
234
|
+
|
|
235
|
+
assert.equal(openedMessageId(router), "msg-1");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -11,6 +11,7 @@ import { useCallback, useState } from "react";
|
|
|
11
11
|
import { toDisplayCategory } from "@/lib/display-category";
|
|
12
12
|
import { formatEmailDate } from "@/lib/format";
|
|
13
13
|
import { MessageListItem } from "./MessageListItem";
|
|
14
|
+
import { useModifierSelect } from "./useModifierSelect";
|
|
14
15
|
|
|
15
16
|
interface MailboxLinkSearch {
|
|
16
17
|
selectedMessageId?: string;
|
|
@@ -111,6 +112,8 @@ export const SwipeableMessageRow = ({
|
|
|
111
112
|
});
|
|
112
113
|
}, [navigate, mailboxId, thread.messageId]);
|
|
113
114
|
|
|
115
|
+
const modifierSelect = useModifierSelect(thread.messageId, onRowSelect);
|
|
116
|
+
|
|
114
117
|
if (isDesktop || isMultiSelectMode) {
|
|
115
118
|
return (
|
|
116
119
|
<MessageListItem
|
|
@@ -131,18 +134,31 @@ export const SwipeableMessageRow = ({
|
|
|
131
134
|
);
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
// The swipe row reads the press as a pointer gesture, and it opens the message
|
|
138
|
+
// from the release — a `mousedown` handler on the row would already be behind
|
|
139
|
+
// it. Taking the modified press in the capture phase keeps it away from the
|
|
140
|
+
// gesture entirely, so a shift- or cmd-click selects instead of starting a
|
|
141
|
+
// swipe, a long press or an open.
|
|
134
142
|
return (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: the wrapper only intercepts mouse modifiers ahead of the row's own gesture; the row beneath keeps the button semantics and the whole keyboard path
|
|
144
|
+
<div
|
|
145
|
+
role="presentation"
|
|
146
|
+
onPointerDownCapture={modifierSelect.onMouseDown}
|
|
147
|
+
onClickCapture={modifierSelect.claimClick}
|
|
148
|
+
onContextMenu={modifierSelect.onContextMenu}
|
|
149
|
+
>
|
|
150
|
+
<SwipeableRow
|
|
151
|
+
thread={toThreadRowData(thread)}
|
|
152
|
+
selectionMode={false}
|
|
153
|
+
checked={false}
|
|
154
|
+
active={isSelected}
|
|
155
|
+
peek={peek}
|
|
156
|
+
onPeek={setPeek}
|
|
157
|
+
onToggleCheck={handleToggleCheck}
|
|
158
|
+
onLongPress={handleLongPress}
|
|
159
|
+
onOpen={handleOpen}
|
|
160
|
+
onAct={handleAct}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
147
163
|
);
|
|
148
164
|
};
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
RemitImapAutoMovedInfo,
|
|
4
|
+
RemitImapMessageSpamReport,
|
|
5
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
3
6
|
import { useQuery } from "@tanstack/react-query";
|
|
4
7
|
import { useCallback } from "react";
|
|
5
8
|
import {
|
|
6
9
|
autoMovedLabel,
|
|
7
10
|
isAutoMoveInEffect,
|
|
8
11
|
resolveUndoTargetMailboxId,
|
|
12
|
+
spamReportLabel,
|
|
9
13
|
} from "@/lib/auto-moved";
|
|
10
14
|
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
11
15
|
import { useInboxMailbox, useJunkMailbox } from "./useArchiveMailbox";
|
|
12
16
|
import { useMoveMessages } from "./useMoveMessages";
|
|
17
|
+
import { useReportSpam } from "./useReportSpam";
|
|
13
18
|
|
|
14
19
|
interface UseAutoMovedBadgeOptions {
|
|
15
20
|
accountId: string | undefined;
|
|
@@ -18,6 +23,14 @@ interface UseAutoMovedBadgeOptions {
|
|
|
18
23
|
/** The message's current mailbox — the row/card it's rendered in. */
|
|
19
24
|
mailboxId: string;
|
|
20
25
|
autoMoved: RemitImapAutoMovedInfo | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Present when the user reported this message as spam and the report has
|
|
28
|
+
* not been undone (issue #648). Takes precedence over `autoMoved`: a
|
|
29
|
+
* report can be a no-op move (the provider's own filter already placed the
|
|
30
|
+
* message in Junk), so it carries its own badge independent of the
|
|
31
|
+
* message's current folder, unlike a classifier/filter move.
|
|
32
|
+
*/
|
|
33
|
+
spamReport: RemitImapMessageSpamReport | undefined;
|
|
21
34
|
}
|
|
22
35
|
|
|
23
36
|
export interface AutoMovedBadgeState {
|
|
@@ -36,22 +49,27 @@ export interface AutoMovedBadgeState {
|
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
/**
|
|
39
|
-
* Composes the account's mailboxes with the message's `autoMoved`
|
|
40
|
-
* into everything the `AutoMovedBadge` kit component
|
|
41
|
-
* in effect" gate, the plain-language label, and a
|
|
42
|
-
*
|
|
43
|
-
* same bulk move operation, the other direction).
|
|
52
|
+
* Composes the account's mailboxes with the message's `autoMoved` and
|
|
53
|
+
* `spamReport` projections into everything the `AutoMovedBadge` kit component
|
|
54
|
+
* needs: the derived "still in effect" gate, the plain-language label, and a
|
|
55
|
+
* one-click undo.
|
|
44
56
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
57
|
+
* Three provenances are handled, `spamReport` taking priority when present:
|
|
58
|
+
* - A spam report (issue #648) undoes via `POST /messages/not-spam`, which
|
|
59
|
+
* resolves its own restore target server-side — the client never needs an
|
|
60
|
+
* Inbox/Junk lookup for it, and the badge shows regardless of the message's
|
|
61
|
+
* current folder (a report can be a no-op move).
|
|
62
|
+
* - A classifier move resolves its Inbox/Junk role mailboxes and undoes via a
|
|
63
|
+
* plain move back to the source role.
|
|
64
|
+
* - A standing-filter move names an arbitrary source folder, whose display
|
|
65
|
+
* name is resolved from the account's mailbox list, and carries a
|
|
66
|
+
* Settings › Filters link so the filter that keeps moving mail is one tap
|
|
67
|
+
* away — undo does not disable it.
|
|
50
68
|
*
|
|
51
|
-
* `show` re-derives on every render from `mailboxId`
|
|
52
|
-
* flag. Once
|
|
53
|
-
* thread row with its updated
|
|
54
|
-
* showing (doc/rules/data-flow.md).
|
|
69
|
+
* `show` re-derives on every render from `mailboxId` (or from `spamReport`'s
|
|
70
|
+
* presence) — no local dismissed flag. Once the undo mutation settles, its
|
|
71
|
+
* query invalidation refetches the thread row with its updated state, and the
|
|
72
|
+
* badge naturally stops showing (doc/rules/data-flow.md).
|
|
55
73
|
*/
|
|
56
74
|
export const useAutoMovedBadge = ({
|
|
57
75
|
accountId,
|
|
@@ -59,10 +77,16 @@ export const useAutoMovedBadge = ({
|
|
|
59
77
|
threadId,
|
|
60
78
|
mailboxId,
|
|
61
79
|
autoMoved,
|
|
80
|
+
spamReport,
|
|
62
81
|
}: UseAutoMovedBadgeOptions): AutoMovedBadgeState => {
|
|
63
82
|
const { inboxMailboxId } = useInboxMailbox(accountId);
|
|
64
83
|
const { junkMailboxId } = useJunkMailbox(accountId);
|
|
65
|
-
const { moveMessages, isPending } = useMoveMessages({
|
|
84
|
+
const { moveMessages, isPending: isMoveUndoing } = useMoveMessages({
|
|
85
|
+
mailboxId,
|
|
86
|
+
threadId,
|
|
87
|
+
accountId,
|
|
88
|
+
});
|
|
89
|
+
const { notSpam, isRestoring: isSpamUndoing } = useReportSpam({
|
|
66
90
|
mailboxId,
|
|
67
91
|
threadId,
|
|
68
92
|
accountId,
|
|
@@ -77,17 +101,30 @@ export const useAutoMovedBadge = ({
|
|
|
77
101
|
});
|
|
78
102
|
|
|
79
103
|
const roleMailboxes = { inboxMailboxId, junkMailboxId };
|
|
80
|
-
const show = isAutoMoveInEffect(autoMoved, mailboxId, roleMailboxes);
|
|
81
104
|
const undoTargetMailboxId = resolveUndoTargetMailboxId(
|
|
82
105
|
autoMoved,
|
|
83
106
|
roleMailboxes,
|
|
84
107
|
);
|
|
85
108
|
|
|
86
|
-
const
|
|
109
|
+
const handleUndoMove = useCallback(() => {
|
|
87
110
|
if (!undoTargetMailboxId) return;
|
|
88
111
|
moveMessages([messageId], undoTargetMailboxId);
|
|
89
112
|
}, [moveMessages, messageId, undoTargetMailboxId]);
|
|
90
113
|
|
|
114
|
+
const handleUndoReport = useCallback(() => {
|
|
115
|
+
notSpam([messageId]);
|
|
116
|
+
}, [notSpam, messageId]);
|
|
117
|
+
|
|
118
|
+
if (spamReport) {
|
|
119
|
+
return {
|
|
120
|
+
show: true,
|
|
121
|
+
label: spamReportLabel,
|
|
122
|
+
onUndo: handleUndoReport,
|
|
123
|
+
isUndoing: isSpamUndoing,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const show = isAutoMoveInEffect(autoMoved, mailboxId, roleMailboxes);
|
|
91
128
|
if (!show || !autoMoved) {
|
|
92
129
|
return { show: false, label: "", isUndoing: false };
|
|
93
130
|
}
|
|
@@ -101,8 +138,8 @@ export const useAutoMovedBadge = ({
|
|
|
101
138
|
return {
|
|
102
139
|
show: true,
|
|
103
140
|
label: autoMovedLabel(autoMoved, sourceFolderName),
|
|
104
|
-
onUndo: undoTargetMailboxId ?
|
|
105
|
-
isUndoing:
|
|
141
|
+
onUndo: undoTargetMailboxId ? handleUndoMove : undefined,
|
|
142
|
+
isUndoing: isMoveUndoing,
|
|
106
143
|
...(autoMoved.filterId ? { filtersHref: "/settings/filters" } : {}),
|
|
107
144
|
};
|
|
108
145
|
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration: prove a per-message report-spam/not-spam failure resolves to a
|
|
3
|
+
* banner, never the full-screen fatal overlay, through the REAL global
|
|
4
|
+
* `MutationCache` wiring (`lib/query-error-handler.ts`, wired on the
|
|
5
|
+
* `QueryClient` in `shell/index.tsx`) — not just `ErrorBannerProvider`'s own
|
|
6
|
+
* `isAlwaysFatal` check.
|
|
7
|
+
*
|
|
8
|
+
* That distinction is the whole point of this file. `ErrorBannerProvider.
|
|
9
|
+
* pushError` refuses to banner an always-fatal error, but every mutation
|
|
10
|
+
* ALSO reports to the MutationCache's global `onError`, independent of
|
|
11
|
+
* whatever the per-mutation `onError` did — and `shouldEscalate` (what the
|
|
12
|
+
* global handler calls) escalates by DEFAULT for any non-5xx that didn't opt
|
|
13
|
+
* out via `meta.softError`. A test that only checked `isAlwaysFatal` passed
|
|
14
|
+
* while the real app crashed to the fatal overlay on the same error: this is
|
|
15
|
+
* exactly `query-escalation.integration.test.ts`'s pattern, applied to
|
|
16
|
+
* `useReportSpam`'s own mutation shape (mutationFn + meta), because that is
|
|
17
|
+
* the seam the earlier fix went through unexercised.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { afterEach, describe, it } from "node:test";
|
|
22
|
+
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
|
23
|
+
import { __resetFatalError, subscribeFatalError } from "../lib/fatal-error";
|
|
24
|
+
import {
|
|
25
|
+
handleMutationCacheError,
|
|
26
|
+
handleQueryCacheError,
|
|
27
|
+
} from "../lib/query-error-handler";
|
|
28
|
+
import { throwOnBulkFailure } from "./useReportSpam.js";
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
__resetFatalError();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const makeClient = () =>
|
|
35
|
+
new QueryClient({
|
|
36
|
+
queryCache: new QueryCache({ onError: handleQueryCacheError }),
|
|
37
|
+
mutationCache: new MutationCache({ onError: handleMutationCacheError }),
|
|
38
|
+
defaultOptions: { mutations: { retry: false } },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** The exact shape a failed report-spam/not-spam call resolves to on the wire (200, not a rejection) — see `throwOnBulkFailure`. */
|
|
42
|
+
const failedBulkResult = () => ({
|
|
43
|
+
successCount: 0,
|
|
44
|
+
failureCount: 1,
|
|
45
|
+
failures: [
|
|
46
|
+
{
|
|
47
|
+
messageId: "9m2k7x4vqz1jd0tn3wf8b6y5c",
|
|
48
|
+
reason:
|
|
49
|
+
"Message 9m2k7x4vqz1jd0tn3wf8b6y5c's move to Junk has not settled yet; try again in a moment.",
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("useReportSpam's mutations under the real MutationCache (#648 review)", () => {
|
|
55
|
+
it("with meta.softError, a per-message failure does NOT escalate to the fatal overlay (regression)", async () => {
|
|
56
|
+
const seen: string[] = [];
|
|
57
|
+
subscribeFatalError((fatal) => seen.push(fatal.message));
|
|
58
|
+
const client = makeClient();
|
|
59
|
+
|
|
60
|
+
const mutation = client.getMutationCache().build(client, {
|
|
61
|
+
mutationFn: async () => {
|
|
62
|
+
throwOnBulkFailure(failedBulkResult());
|
|
63
|
+
},
|
|
64
|
+
meta: { softError: true },
|
|
65
|
+
});
|
|
66
|
+
await mutation.execute(undefined).catch(() => {});
|
|
67
|
+
|
|
68
|
+
assert.deepEqual(
|
|
69
|
+
seen,
|
|
70
|
+
[],
|
|
71
|
+
"a designed, retryable per-message failure must not reach the fatal overlay",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("without meta.softError, the same failure DOES escalate — proving the opt-out is load-bearing, not a no-op", async () => {
|
|
76
|
+
const seen: string[] = [];
|
|
77
|
+
subscribeFatalError((fatal) => seen.push(fatal.message));
|
|
78
|
+
const client = makeClient();
|
|
79
|
+
|
|
80
|
+
const mutation = client.getMutationCache().build(client, {
|
|
81
|
+
mutationFn: async () => {
|
|
82
|
+
throwOnBulkFailure(failedBulkResult());
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
await mutation.execute(undefined).catch(() => {});
|
|
86
|
+
|
|
87
|
+
assert.equal(
|
|
88
|
+
seen.length,
|
|
89
|
+
1,
|
|
90
|
+
"this asserts the failure mode the fix removes — a non-5xx with no softError opt-out escalates by default",
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mounts the real hook against the real fetch seam to prove the pending state
|
|
3
|
+
* a press needs actually toggles (issue #648 review): with no optimistic
|
|
4
|
+
* cache patch, a press that never flips `isReporting`/`isRestoring` would be
|
|
5
|
+
* a genuinely dead control — nothing visible changes until the request lands.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { afterEach, describe, it } from "node:test";
|
|
10
|
+
import { createElement } from "react";
|
|
11
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
12
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
13
|
+
import { useReportSpam } from "./useReportSpam";
|
|
14
|
+
|
|
15
|
+
let harness: DomHarness | undefined;
|
|
16
|
+
let http: HttpMock;
|
|
17
|
+
|
|
18
|
+
const mountHook = <T>(useHook: () => T): (() => T) => {
|
|
19
|
+
let value: T | undefined;
|
|
20
|
+
const Probe = () => {
|
|
21
|
+
value = useHook();
|
|
22
|
+
return null;
|
|
23
|
+
};
|
|
24
|
+
harness = createDomHarness();
|
|
25
|
+
harness.renderApp(createElement(Probe));
|
|
26
|
+
return () => {
|
|
27
|
+
if (value === undefined) throw new Error("hook did not render");
|
|
28
|
+
return value;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
harness?.close();
|
|
34
|
+
harness = undefined;
|
|
35
|
+
http.restore();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Polls `predicate` until it's true, yielding real event-loop turns between
|
|
40
|
+
* attempts rather than spinning a fixed count of microtask flushes — the
|
|
41
|
+
* chain from a resolved fetch to a re-render crosses enough async boundaries
|
|
42
|
+
* (response parsing, `throwOnBulkFailure`, `onSuccess`'s invalidation, React's
|
|
43
|
+
* commit) that a fixed count reads as flaky under load instead of wrong.
|
|
44
|
+
*/
|
|
45
|
+
const waitFor = async (
|
|
46
|
+
predicate: () => boolean,
|
|
47
|
+
timeoutMs = 2000,
|
|
48
|
+
): Promise<void> => {
|
|
49
|
+
if (!harness) throw new Error("nothing mounted");
|
|
50
|
+
const deadline = Date.now() + timeoutMs;
|
|
51
|
+
while (!predicate()) {
|
|
52
|
+
if (Date.now() > deadline) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`waitFor: condition never became true within ${timeoutMs}ms`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
await harness.flush();
|
|
58
|
+
await harness.wait(5);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe("useReportSpam pending state (#648 review)", () => {
|
|
63
|
+
it("isReporting goes true for the duration of an in-flight report, then false", async () => {
|
|
64
|
+
let resolveRequest: (() => void) | undefined;
|
|
65
|
+
http = mockFetch(
|
|
66
|
+
() =>
|
|
67
|
+
new Promise((resolve) => {
|
|
68
|
+
resolveRequest = () => resolve({ successCount: 1, failureCount: 0 });
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
const hook = mountHook(() => useReportSpam({ mailboxId: "mbx-inbox" }));
|
|
72
|
+
|
|
73
|
+
assert.equal(hook().isReporting, false);
|
|
74
|
+
|
|
75
|
+
hook().reportSpam(["msg-1"]);
|
|
76
|
+
await waitFor(() => hook().isReporting === true);
|
|
77
|
+
|
|
78
|
+
assert.ok(resolveRequest, "the request never reached the mock");
|
|
79
|
+
resolveRequest?.();
|
|
80
|
+
await waitFor(() => hook().isReporting === false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("isRestoring goes true for the duration of an in-flight undo, then false", async () => {
|
|
84
|
+
let resolveRequest: (() => void) | undefined;
|
|
85
|
+
http = mockFetch(
|
|
86
|
+
() =>
|
|
87
|
+
new Promise((resolve) => {
|
|
88
|
+
resolveRequest = () => resolve({ successCount: 1, failureCount: 0 });
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
const hook = mountHook(() => useReportSpam({ mailboxId: "mbx-junk" }));
|
|
92
|
+
|
|
93
|
+
assert.equal(hook().isRestoring, false);
|
|
94
|
+
|
|
95
|
+
hook().notSpam(["msg-1"]);
|
|
96
|
+
await waitFor(() => hook().isRestoring === true);
|
|
97
|
+
|
|
98
|
+
resolveRequest?.();
|
|
99
|
+
await waitFor(() => hook().isRestoring === false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("sends the request to the report-spam endpoint with the pressed message id", async () => {
|
|
103
|
+
http = mockFetch(() => ({ successCount: 1, failureCount: 0 }));
|
|
104
|
+
const hook = mountHook(() => useReportSpam({ mailboxId: "mbx-inbox" }));
|
|
105
|
+
|
|
106
|
+
hook().reportSpam(["msg-1"]);
|
|
107
|
+
await waitFor(() => http.to("/messages/report-spam").length > 0);
|
|
108
|
+
|
|
109
|
+
const calls = http.to("/messages/report-spam");
|
|
110
|
+
assert.equal(calls.length, 1);
|
|
111
|
+
assert.deepEqual(calls[0].body, { messageIds: ["msg-1"] });
|
|
112
|
+
});
|
|
113
|
+
});
|