@remit/web-client 0.0.273 → 0.0.274
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/BulkRunProvider.tsx +14 -1
- package/src/components/mail/MessageCard.blocked-sender.test.ts +7 -0
- package/src/components/mail/MessageList.run-reporting.test.ts +1 -0
- package/src/components/mail/MessageListItem.test.ts +59 -3
- package/src/components/mail/MessageSettlementAlert.render.test.ts +182 -0
- package/src/components/mail/MessageSettlementAlert.tsx +77 -19
- package/src/components/mail/bulk-run-state.test.ts +1 -0
- package/src/components/mail/conversation-reply-identity.render.test.ts +1 -0
- package/src/components/mail/conversation-reply-reach.render.test.ts +1 -0
- package/src/components/mail/selection-wizard-run-controls.test.ts +1 -0
- package/src/components/mail/two-window-listeners-one-key.render.test.ts +1 -0
- package/src/components/ui/placement-refusal.test.ts +7 -20
- package/src/components/ui/placement-refusal.ts +10 -12
- package/src/hooks/useDeleteMessages.ts +19 -0
- package/src/hooks/useMailboxAccount.ts +5 -0
- package/src/hooks/useToggleStar.test.ts +1 -0
- package/src/hooks/useToggleTrusted.test.ts +1 -0
- package/src/lib/brief.test.ts +1 -0
- package/src/lib/bulk-actions.test.ts +29 -1
- package/src/lib/bulk-actions.ts +59 -16
- package/src/lib/dedupe-thread-messages.test.ts +1 -0
- package/src/lib/row-settlement.ts +21 -8
- package/src/lib/thread-search-tokens.test.ts +1 -0
- package/src/test-support/fixtures.ts +1 -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.274",
|
|
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": {
|
|
@@ -149,7 +149,7 @@ export const BulkRunProvider = ({ children }: { children: ReactNode }) => {
|
|
|
149
149
|
// itself that does not make `start` its own dependency.
|
|
150
150
|
const startRef = useRef<BulkRunContextValue["start"]>(async () => ({
|
|
151
151
|
kind: "ran",
|
|
152
|
-
outcome: { done: 0, failedIds: [], cancelled: false },
|
|
152
|
+
outcome: { done: 0, failedIds: [], refused: 0, cancelled: false },
|
|
153
153
|
}));
|
|
154
154
|
|
|
155
155
|
/**
|
|
@@ -282,6 +282,19 @@ export const BulkRunProvider = ({ children }: { children: ReactNode }) => {
|
|
|
282
282
|
outcome.error,
|
|
283
283
|
),
|
|
284
284
|
);
|
|
285
|
+
} else if (outcome.refused > 0) {
|
|
286
|
+
// One banner for the run, not one per chunk: a select-all is walked a
|
|
287
|
+
// hundred ids at a time, and a refusal in each would stack banners
|
|
288
|
+
// until the surface caps them. The count is the run's own total.
|
|
289
|
+
pushError({
|
|
290
|
+
severity: "warning",
|
|
291
|
+
title:
|
|
292
|
+
outcome.refused > 1
|
|
293
|
+
? `Couldn't delete ${outcome.refused} of ${request.matched} messages yet`
|
|
294
|
+
: "Couldn't delete this message yet",
|
|
295
|
+
detail:
|
|
296
|
+
"Something else moved them while the delete was being prepared. They are back where they now sit — try again.",
|
|
297
|
+
});
|
|
285
298
|
}
|
|
286
299
|
// Nobody is left showing this run, so the ending is said where the user
|
|
287
300
|
// now is. A screen still reporting on it says it in place instead.
|
|
@@ -160,6 +160,13 @@ const mount = async (flags: RemitImapAddressFlags): Promise<DomHarness> => {
|
|
|
160
160
|
if (call.path.endsWith(`/messages/${MESSAGE_ID}`)) {
|
|
161
161
|
return describeMessage(flags);
|
|
162
162
|
}
|
|
163
|
+
// `/config` answers its own shape. `accounts` is required by the contract,
|
|
164
|
+
// so a mock that hands back `{items: []}` for it is a body the API cannot
|
|
165
|
+
// produce, and anything reading it faithfully throws on a fixture rather
|
|
166
|
+
// than on a fault.
|
|
167
|
+
if (call.path.endsWith("/config")) {
|
|
168
|
+
return { accountConfig: {}, accounts: [] };
|
|
169
|
+
}
|
|
163
170
|
return { items: [] };
|
|
164
171
|
});
|
|
165
172
|
|
|
@@ -21,6 +21,7 @@ const baseThread = (
|
|
|
21
21
|
star: "None",
|
|
22
22
|
isDeleted: false,
|
|
23
23
|
senderTrust: "unknown",
|
|
24
|
+
abandonedMutation: "none",
|
|
24
25
|
createdAt: 0,
|
|
25
26
|
updatedAt: 0,
|
|
26
27
|
...overrides,
|
|
@@ -47,11 +48,58 @@ describe("threadToRowData — labels", () => {
|
|
|
47
48
|
describe("threadToRowData — settlement (issue #1002)", () => {
|
|
48
49
|
it("marks a row whose delete gave up and came back", () => {
|
|
49
50
|
const row = threadToRowData(
|
|
50
|
-
baseThread({
|
|
51
|
+
baseThread({
|
|
52
|
+
status: "active",
|
|
53
|
+
syncStatus: "abandoned",
|
|
54
|
+
abandonedMutation: "delete",
|
|
55
|
+
}),
|
|
51
56
|
);
|
|
52
57
|
assert.equal(row.settlement, "delete_failed");
|
|
53
58
|
});
|
|
54
59
|
|
|
60
|
+
// Issue #1229. Both give-ups hand the row back the same way, so before the
|
|
61
|
+
// row named the mutation this rendered "Not deleted" over a move.
|
|
62
|
+
it("marks a row whose move gave up as a move, not a delete", () => {
|
|
63
|
+
const row = threadToRowData(
|
|
64
|
+
baseThread({
|
|
65
|
+
status: "active",
|
|
66
|
+
syncStatus: "abandoned",
|
|
67
|
+
abandonedMutation: "move",
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
assert.equal(row.settlement, "move_failed");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("says nothing about a copy that gave up, whose row no listing carries", () => {
|
|
74
|
+
const row = threadToRowData(
|
|
75
|
+
baseThread({
|
|
76
|
+
status: "deleted",
|
|
77
|
+
syncStatus: "abandoned",
|
|
78
|
+
abandonedMutation: "copy",
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
assert.equal(row.settlement, undefined);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// `syncStatus` is the gate: a marker a later settle left behind says nothing.
|
|
85
|
+
it("says nothing about a settled row still carrying a stale marker", () => {
|
|
86
|
+
const row = threadToRowData(
|
|
87
|
+
baseThread({
|
|
88
|
+
status: "active",
|
|
89
|
+
syncStatus: "synced",
|
|
90
|
+
abandonedMutation: "delete",
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
assert.equal(row.settlement, undefined);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("says nothing about a transient attempt on an active row", () => {
|
|
97
|
+
const row = threadToRowData(
|
|
98
|
+
baseThread({ status: "active", syncStatus: "failed" }),
|
|
99
|
+
);
|
|
100
|
+
assert.equal(row.settlement, undefined);
|
|
101
|
+
});
|
|
102
|
+
|
|
55
103
|
it("says nothing about a move mid-retry, which writes the same failed flag", () => {
|
|
56
104
|
const row = threadToRowData(
|
|
57
105
|
baseThread({ status: "moving", syncStatus: "failed" }),
|
|
@@ -77,14 +125,22 @@ describe("threadToRowData — settlement (issue #1002)", () => {
|
|
|
77
125
|
describe("the mobile and drafts rows carry the same mark", () => {
|
|
78
126
|
it("marks the swipeable mobile row", () => {
|
|
79
127
|
const row = swipeableRowData(
|
|
80
|
-
baseThread({
|
|
128
|
+
baseThread({
|
|
129
|
+
status: "active",
|
|
130
|
+
syncStatus: "abandoned",
|
|
131
|
+
abandonedMutation: "delete",
|
|
132
|
+
}),
|
|
81
133
|
);
|
|
82
134
|
assert.equal(row.settlement, "delete_failed");
|
|
83
135
|
});
|
|
84
136
|
|
|
85
137
|
it("marks the drafts row", () => {
|
|
86
138
|
const row = toImapDraftRowData(
|
|
87
|
-
baseThread({
|
|
139
|
+
baseThread({
|
|
140
|
+
status: "active",
|
|
141
|
+
syncStatus: "abandoned",
|
|
142
|
+
abandonedMutation: "delete",
|
|
143
|
+
}),
|
|
88
144
|
);
|
|
89
145
|
assert.equal(row.settlement, "delete_failed");
|
|
90
146
|
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reading-pane notice names the mutation that gave up and offers the way
|
|
3
|
+
* out for that one (#1229).
|
|
4
|
+
*
|
|
5
|
+
* A delete retries through the ordinary delete endpoint. A move cannot — the
|
|
6
|
+
* destination the give-up discarded is on no row — so its retry is the folder
|
|
7
|
+
* picker, which needs an account to scope its list to. The caller's `accountId`
|
|
8
|
+
* is absent on rows from per-mailbox endpoints and for as long as the
|
|
9
|
+
* conversation's own lookup is in flight, and a notice that names a failure and
|
|
10
|
+
* then offers nothing is the same dead end as one that offers the wrong thing.
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
14
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
15
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
16
|
+
import { createElement } from "react";
|
|
17
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
18
|
+
import { type HttpMock, mockFetch } from "../../test-support/http";
|
|
19
|
+
import { ErrorBannerProvider } from "../ui/ErrorBannerProvider";
|
|
20
|
+
import { MessageSettlementAlert } from "./MessageSettlementAlert";
|
|
21
|
+
import { RoleAppointmentPromptProvider } from "./RoleAppointmentPromptProvider";
|
|
22
|
+
|
|
23
|
+
const ACCOUNT = "acc-1";
|
|
24
|
+
const MAILBOX = "mbx-inbox";
|
|
25
|
+
|
|
26
|
+
let harness: DomHarness | undefined;
|
|
27
|
+
let http: HttpMock;
|
|
28
|
+
|
|
29
|
+
const CONFIG = {
|
|
30
|
+
accounts: [{ accountId: ACCOUNT, email: "a@example.com" }],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const MAILBOXES = {
|
|
34
|
+
items: [
|
|
35
|
+
{
|
|
36
|
+
mailboxId: MAILBOX,
|
|
37
|
+
accountId: ACCOUNT,
|
|
38
|
+
fullPath: "INBOX",
|
|
39
|
+
hierarchyDelimiter: "/",
|
|
40
|
+
messageCount: 1,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const respond = (path: string): unknown => {
|
|
46
|
+
if (path.endsWith("/config")) return CONFIG;
|
|
47
|
+
if (path.endsWith("/mailboxes")) return MAILBOXES;
|
|
48
|
+
return {};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const thread = (
|
|
52
|
+
over: Partial<RemitImapThreadMessageResponse>,
|
|
53
|
+
): RemitImapThreadMessageResponse =>
|
|
54
|
+
({
|
|
55
|
+
threadMessageId: "tm-1",
|
|
56
|
+
threadId: "th-1",
|
|
57
|
+
messageId: "msg-1",
|
|
58
|
+
accountConfigId: "cfg-1",
|
|
59
|
+
mailboxId: MAILBOX,
|
|
60
|
+
sentDate: 0,
|
|
61
|
+
isRead: true,
|
|
62
|
+
hasAttachment: false,
|
|
63
|
+
hasStars: false,
|
|
64
|
+
star: "None",
|
|
65
|
+
isDeleted: false,
|
|
66
|
+
senderTrust: "unknown",
|
|
67
|
+
category: "uncategorized",
|
|
68
|
+
status: "active",
|
|
69
|
+
syncStatus: "abandoned",
|
|
70
|
+
abandonedMutation: "none",
|
|
71
|
+
createdAt: 0,
|
|
72
|
+
updatedAt: 0,
|
|
73
|
+
...over,
|
|
74
|
+
}) as RemitImapThreadMessageResponse;
|
|
75
|
+
|
|
76
|
+
const mount = (
|
|
77
|
+
threadMessage: RemitImapThreadMessageResponse,
|
|
78
|
+
accountId: string | undefined,
|
|
79
|
+
): void => {
|
|
80
|
+
harness = createDomHarness();
|
|
81
|
+
harness.render(
|
|
82
|
+
createElement(
|
|
83
|
+
QueryClientProvider,
|
|
84
|
+
{ client: harness.queryClient },
|
|
85
|
+
createElement(
|
|
86
|
+
ErrorBannerProvider,
|
|
87
|
+
null,
|
|
88
|
+
createElement(
|
|
89
|
+
RoleAppointmentPromptProvider,
|
|
90
|
+
null,
|
|
91
|
+
createElement(MessageSettlementAlert, { threadMessage, accountId }),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const settle = async (done: () => boolean = () => false): Promise<void> => {
|
|
99
|
+
if (!harness) throw new Error("nothing mounted");
|
|
100
|
+
for (let round = 0; round < 40; round += 1) {
|
|
101
|
+
await harness.flush();
|
|
102
|
+
await harness.wait(0);
|
|
103
|
+
if (done()) return;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const labels = (): string[] =>
|
|
108
|
+
(harness?.queryAll("button") ?? []).map(
|
|
109
|
+
(button) => button.getAttribute("aria-label") ?? button.textContent ?? "",
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const notice = (): string => harness?.text() ?? "";
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
http = mockFetch((call) => respond(call.path));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterEach(() => {
|
|
119
|
+
harness?.close();
|
|
120
|
+
harness = undefined;
|
|
121
|
+
http.restore();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("MessageSettlementAlert", () => {
|
|
125
|
+
it("says nothing about a row no mutation gave up on", async () => {
|
|
126
|
+
mount(
|
|
127
|
+
thread({ syncStatus: "synced", abandonedMutation: "delete" }),
|
|
128
|
+
ACCOUNT,
|
|
129
|
+
);
|
|
130
|
+
await settle();
|
|
131
|
+
|
|
132
|
+
assert.equal(notice(), "");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("offers Delete again for a delete that gave up", async () => {
|
|
136
|
+
mount(thread({ abandonedMutation: "delete" }), ACCOUNT);
|
|
137
|
+
await settle();
|
|
138
|
+
|
|
139
|
+
assert.match(notice(), /was not deleted/i);
|
|
140
|
+
assert.ok(labels().some((label) => /delete again/i.test(label)));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The account lookup fans out to `/config` and then to every account's
|
|
145
|
+
* mailbox list. Asking for it from each expanded message put that behind
|
|
146
|
+
* every open row in the product — and it did, until it was scoped to the one
|
|
147
|
+
* case that needs it. Pinned on the wire, because the cost is a request and
|
|
148
|
+
* only the request proves it is not being made.
|
|
149
|
+
*/
|
|
150
|
+
it("asks for no configuration at all unless a move gave up", async () => {
|
|
151
|
+
mount(thread({ abandonedMutation: "delete" }), ACCOUNT);
|
|
152
|
+
await settle();
|
|
153
|
+
|
|
154
|
+
assert.deepEqual(http.to("/config"), []);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("asks for none on a healthy row either", async () => {
|
|
158
|
+
mount(thread({ syncStatus: "synced" }), undefined);
|
|
159
|
+
await settle();
|
|
160
|
+
|
|
161
|
+
assert.deepEqual(http.to("/config"), []);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("offers a folder picker for a move that gave up, never Delete again", async () => {
|
|
165
|
+
mount(thread({ abandonedMutation: "move" }), ACCOUNT);
|
|
166
|
+
await settle(() => labels().some((label) => /move again/i.test(label)));
|
|
167
|
+
|
|
168
|
+
assert.match(notice(), /was not moved/i);
|
|
169
|
+
assert.ok(labels().some((label) => /move again/i.test(label)));
|
|
170
|
+
assert.ok(!labels().some((label) => /delete again/i.test(label)));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("resolves the account from the row's mailbox when the caller has none", async () => {
|
|
174
|
+
mount(thread({ abandonedMutation: "move" }), undefined);
|
|
175
|
+
await settle(() => labels().some((label) => /move again/i.test(label)));
|
|
176
|
+
|
|
177
|
+
assert.ok(
|
|
178
|
+
labels().some((label) => /move again/i.test(label)),
|
|
179
|
+
"a notice that names a failure always carries its way out",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { abandonedMutationOf } from "@remit/data-ports/message-settlement";
|
|
3
|
+
import { MessageMutation } from "@remit/domain-enums";
|
|
3
4
|
import { MessageSettlementNotice, messageSettlementCopy } from "@remit/ui";
|
|
4
5
|
import { useCallback, useMemo } from "react";
|
|
5
6
|
import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
7
|
+
import { useMailboxAccount } from "@/hooks/useMailboxAccount";
|
|
8
|
+
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
6
9
|
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
10
|
+
import { MoveToTrigger } from "./MoveToTrigger";
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
|
-
* States, on the open message, that Remit
|
|
10
|
-
* the failure the list can only chip
|
|
13
|
+
* States, on the open message, that Remit gave up on a mutation (issue #1002) —
|
|
14
|
+
* the failure the list can only chip — and offers the way out for THAT
|
|
15
|
+
* mutation, never a different one (issue #1229).
|
|
11
16
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
17
|
+
* A delete retries through the ordinary delete endpoint: giving up put `status`
|
|
18
|
+
* back to `active`, so `settledPlacement` passes the row through and
|
|
14
19
|
* `POST /messages/delete` re-drives it against the folder the server actually
|
|
15
|
-
* holds it in.
|
|
16
|
-
*
|
|
20
|
+
* holds it in. A move cannot retry that way — the destination the give-up
|
|
21
|
+
* discarded is on no row — so its retry is the same folder picker every other
|
|
22
|
+
* move goes through, which asks for one. Every other give-up gets no treatment;
|
|
23
|
+
* `abandonedMutationOf` says which and why.
|
|
24
|
+
*
|
|
25
|
+
* The picker needs an account to scope its folder list to, and the caller's
|
|
26
|
+
* `accountId` is absent on every row that came from a per-mailbox endpoint and
|
|
27
|
+
* for as long as the conversation's own lookup is in flight. It is resolved
|
|
28
|
+
* from the row's mailbox here rather than left out, because a notice that names
|
|
29
|
+
* a failure and offers no way to undo it is the same defect as one that offers
|
|
30
|
+
* the wrong way (#1229) — the resolver is the one `MessageActionMenu` already
|
|
31
|
+
* uses for exactly this.
|
|
17
32
|
*/
|
|
18
33
|
export function MessageSettlementAlert({
|
|
19
34
|
threadMessage,
|
|
@@ -24,43 +39,86 @@ export function MessageSettlementAlert({
|
|
|
24
39
|
accountId: string | undefined;
|
|
25
40
|
className?: string;
|
|
26
41
|
}) {
|
|
27
|
-
const abandoned =
|
|
28
|
-
const
|
|
42
|
+
const abandoned = abandonedMutationOf(threadMessage);
|
|
43
|
+
const settlement =
|
|
44
|
+
abandoned === MessageMutation.delete
|
|
45
|
+
? ("delete_failed" as const)
|
|
46
|
+
: abandoned === MessageMutation.move
|
|
47
|
+
? ("move_failed" as const)
|
|
48
|
+
: undefined;
|
|
49
|
+
|
|
50
|
+
// Only a move that gave up, and only where the caller has no account of its
|
|
51
|
+
// own, has anything to resolve — so every other row subscribes to nothing.
|
|
52
|
+
// Asking unconditionally would pull `/config` and every account's mailbox
|
|
53
|
+
// list into each expanded message, for a case almost none of them are.
|
|
54
|
+
const { accountId: mailboxAccountId } = useMailboxAccount(
|
|
55
|
+
settlement === "move_failed" && !accountId
|
|
56
|
+
? threadMessage.mailboxId
|
|
57
|
+
: undefined,
|
|
58
|
+
);
|
|
59
|
+
const scopedAccountId = accountId ?? mailboxAccountId;
|
|
60
|
+
|
|
61
|
+
const { deleteMessages, isPending: isDeleting } = useDeleteMessages({
|
|
29
62
|
mailboxId: threadMessage.mailboxId,
|
|
30
63
|
threadId: threadMessage.threadId,
|
|
31
|
-
accountId,
|
|
64
|
+
accountId: scopedAccountId,
|
|
32
65
|
messages: [threadMessage],
|
|
33
66
|
});
|
|
67
|
+
const { moveMessages, isPending: isMoving } = useMoveMessages({
|
|
68
|
+
mailboxId: threadMessage.mailboxId,
|
|
69
|
+
threadId: threadMessage.threadId,
|
|
70
|
+
accountId: scopedAccountId,
|
|
71
|
+
});
|
|
34
72
|
|
|
35
|
-
const
|
|
73
|
+
const onDeleteAgain = useCallback(() => {
|
|
36
74
|
deleteMessages([threadMessage.messageId]);
|
|
37
75
|
}, [deleteMessages, threadMessage.messageId]);
|
|
38
76
|
|
|
77
|
+
const onMoveAgain = useCallback(
|
|
78
|
+
(destinationMailboxId: string) => {
|
|
79
|
+
moveMessages([threadMessage.messageId], destinationMailboxId);
|
|
80
|
+
},
|
|
81
|
+
[moveMessages, threadMessage.messageId],
|
|
82
|
+
);
|
|
83
|
+
|
|
39
84
|
const reportHref = useMemo(
|
|
40
85
|
() =>
|
|
41
|
-
|
|
86
|
+
settlement
|
|
42
87
|
? buildGitHubIssueUrl(
|
|
43
88
|
buildBugReportContext({
|
|
44
|
-
title: messageSettlementCopy.
|
|
45
|
-
errorMessage: `Message ${threadMessage.messageId}: status=${threadMessage.status} syncStatus=${threadMessage.syncStatus}`,
|
|
89
|
+
title: messageSettlementCopy[settlement].title,
|
|
90
|
+
errorMessage: `Message ${threadMessage.messageId}: status=${threadMessage.status} syncStatus=${threadMessage.syncStatus} abandonedMutation=${threadMessage.abandonedMutation}`,
|
|
46
91
|
}),
|
|
47
92
|
)
|
|
48
93
|
: undefined,
|
|
49
94
|
[
|
|
50
|
-
|
|
95
|
+
settlement,
|
|
51
96
|
threadMessage.messageId,
|
|
52
97
|
threadMessage.status,
|
|
53
98
|
threadMessage.syncStatus,
|
|
99
|
+
threadMessage.abandonedMutation,
|
|
54
100
|
],
|
|
55
101
|
);
|
|
56
102
|
|
|
57
|
-
if (!
|
|
103
|
+
if (!settlement) return null;
|
|
58
104
|
|
|
59
105
|
return (
|
|
60
106
|
<MessageSettlementNotice
|
|
61
|
-
settlement=
|
|
62
|
-
onRetry={
|
|
63
|
-
|
|
107
|
+
settlement={settlement}
|
|
108
|
+
onRetry={settlement === "delete_failed" ? onDeleteAgain : undefined}
|
|
109
|
+
action={
|
|
110
|
+
settlement === "move_failed" && scopedAccountId ? (
|
|
111
|
+
<MoveToTrigger
|
|
112
|
+
accountId={scopedAccountId}
|
|
113
|
+
currentMailboxId={threadMessage.mailboxId}
|
|
114
|
+
onMove={onMoveAgain}
|
|
115
|
+
disabled={isMoving}
|
|
116
|
+
variant="compact"
|
|
117
|
+
label={messageSettlementCopy.move_failed.retryLabel}
|
|
118
|
+
/>
|
|
119
|
+
) : undefined
|
|
120
|
+
}
|
|
121
|
+
retryPending={settlement === "delete_failed" ? isDeleting : isMoving}
|
|
64
122
|
reportHref={reportHref}
|
|
65
123
|
className={className}
|
|
66
124
|
/>
|
|
@@ -56,16 +56,6 @@ describe("placementRefusalBanner", () => {
|
|
|
56
56
|
assert.match(banner.detail ?? "", /try again in a moment/i);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
it("tells an abandoned move to resync, not to wait", () => {
|
|
60
|
-
const banner = placementRefusalBanner(
|
|
61
|
-
{ reason: "unverified", messageId: "msg-1" },
|
|
62
|
-
1,
|
|
63
|
-
"delete",
|
|
64
|
-
);
|
|
65
|
-
assert.match(banner.detail ?? "", /sync the folder/i);
|
|
66
|
-
assert.doesNotMatch(banner.detail ?? "", /try again in a moment/i);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
59
|
it("counts the selection in the title", () => {
|
|
70
60
|
assert.match(
|
|
71
61
|
placementRefusalBanner(
|
|
@@ -90,22 +80,19 @@ describe("placementRefusalBanner", () => {
|
|
|
90
80
|
|
|
91
81
|
const detail =
|
|
92
82
|
placementRefusalBanner(
|
|
93
|
-
{ reason: "
|
|
83
|
+
{ reason: "in_flight", messageId: "msg-1" },
|
|
94
84
|
1,
|
|
95
85
|
"move",
|
|
96
86
|
).detail ?? "";
|
|
97
|
-
assert.match(detail, /move it again/i);
|
|
98
87
|
assert.doesNotMatch(detail, /delete/i);
|
|
99
88
|
});
|
|
100
89
|
|
|
101
90
|
it("never repeats the server's uuid at the user", () => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
assert.doesNotMatch(`${banner.title} ${banner.detail}`, /msg-1/);
|
|
109
|
-
}
|
|
91
|
+
const banner = placementRefusalBanner(
|
|
92
|
+
{ reason: "in_flight", messageId: "msg-1" },
|
|
93
|
+
1,
|
|
94
|
+
"delete",
|
|
95
|
+
);
|
|
96
|
+
assert.doesNotMatch(`${banner.title} ${banner.detail}`, /msg-1/);
|
|
110
97
|
});
|
|
111
98
|
});
|
|
@@ -9,11 +9,13 @@ import type { PushErrorInput } from "@/components/ui/error-banners";
|
|
|
9
9
|
import { type CodedApiErrorBody, codedApiErrorBody } from "@/lib/api";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* One reason, because one state produces the refusal: a mutation is still in
|
|
13
|
+
* flight and the pair clears on its own, so the copy words a wait. A mutation
|
|
14
|
+
* that gave up hands the row back to a placement the mail server confirmed
|
|
15
|
+
* before it stops (imap-mutations R3), and that row is acted on rather than
|
|
16
|
+
* refused — there is no second reason left to word.
|
|
15
17
|
*/
|
|
16
|
-
export type PlacementRefusalReason = "in_flight"
|
|
18
|
+
export type PlacementRefusalReason = "in_flight";
|
|
17
19
|
|
|
18
20
|
export interface PlacementRefusal {
|
|
19
21
|
reason: PlacementRefusalReason;
|
|
@@ -22,7 +24,6 @@ export interface PlacementRefusal {
|
|
|
22
24
|
|
|
23
25
|
const REASONS: ReadonlySet<string> = new Set<PlacementRefusalReason>([
|
|
24
26
|
"in_flight",
|
|
25
|
-
"unverified",
|
|
26
27
|
]);
|
|
27
28
|
|
|
28
29
|
const stringAt = (
|
|
@@ -53,11 +54,11 @@ export const isPlacementRefusal = (
|
|
|
53
54
|
export type PlacementRefusalAction = "delete" | "move";
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
|
-
* The banner copy.
|
|
57
|
-
*
|
|
57
|
+
* The banner copy. It says what happened and what to do, is not a dead end, and
|
|
58
|
+
* does not repeat the server's uuid at the user.
|
|
58
59
|
*/
|
|
59
60
|
export const placementRefusalBanner = (
|
|
60
|
-
|
|
61
|
+
_refusal: PlacementRefusal,
|
|
61
62
|
count: number,
|
|
62
63
|
action: PlacementRefusalAction,
|
|
63
64
|
): PushErrorInput => ({
|
|
@@ -66,8 +67,5 @@ export const placementRefusalBanner = (
|
|
|
66
67
|
count > 1
|
|
67
68
|
? `Couldn't ${action} ${count} messages yet`
|
|
68
69
|
: `Couldn't ${action} this message yet`,
|
|
69
|
-
detail:
|
|
70
|
-
refusal.reason === "in_flight"
|
|
71
|
-
? "It is still being moved on the mail server. Try again in a moment."
|
|
72
|
-
: `An earlier move never finished, so where this message sits is unknown. Sync the folder, then ${action} it again.`,
|
|
70
|
+
detail: "It is still being moved on the mail server. Try again in a moment.",
|
|
73
71
|
});
|
|
@@ -161,6 +161,25 @@ export const useDeleteMessages = ({
|
|
|
161
161
|
previousThreadsList,
|
|
162
162
|
};
|
|
163
163
|
},
|
|
164
|
+
// A batch that partly applied answers 200 with a non-zero `failureCount`:
|
|
165
|
+
// the rows whose placement changed under it were never claimed
|
|
166
|
+
// (imap-mutations R3). `onSettled` invalidates, so those rows come back on
|
|
167
|
+
// their own — this is what stops them coming back unexplained, which is
|
|
168
|
+
// the dead-button failure wearing a different face (#1229).
|
|
169
|
+
onSuccess: (data, vars) => {
|
|
170
|
+
const refused = data?.failureCount ?? 0;
|
|
171
|
+
if (refused === 0) return;
|
|
172
|
+
const asked = vars.body.messageIds?.length ?? 0;
|
|
173
|
+
pushError({
|
|
174
|
+
severity: "warning",
|
|
175
|
+
title:
|
|
176
|
+
refused > 1
|
|
177
|
+
? `Couldn't delete ${refused} of ${asked} messages yet`
|
|
178
|
+
: "Couldn't delete this message yet",
|
|
179
|
+
detail:
|
|
180
|
+
"Something else moved it while the delete was being prepared. It is back where it now sits — try again.",
|
|
181
|
+
});
|
|
182
|
+
},
|
|
164
183
|
onError: (err, vars, context) => {
|
|
165
184
|
if (context) {
|
|
166
185
|
for (const entry of context.previousThreadMessages) {
|
|
@@ -25,9 +25,14 @@ interface MailboxAccountResolution {
|
|
|
25
25
|
export const useMailboxAccount = (
|
|
26
26
|
mailboxId: string | undefined,
|
|
27
27
|
): MailboxAccountResolution => {
|
|
28
|
+
// Disabled where there is no mailbox to resolve, the way the fan-out below
|
|
29
|
+
// already is. A caller that already knows its account passes `undefined` and
|
|
30
|
+
// this subscribes to nothing — which keeps the hook from pulling `/config`
|
|
31
|
+
// into trees that never asked for it.
|
|
28
32
|
const { data: config, isLoading: isConfigLoading } = useQuery({
|
|
29
33
|
...configOperationsGetConfigOptions(),
|
|
30
34
|
staleTime: Infinity,
|
|
35
|
+
enabled: !!mailboxId,
|
|
31
36
|
});
|
|
32
37
|
|
|
33
38
|
const accountIds = useMemo(
|
package/src/lib/brief.test.ts
CHANGED
|
@@ -132,6 +132,28 @@ describe("chunkTargets", () => {
|
|
|
132
132
|
describe("runChunkedAction", () => {
|
|
133
133
|
const noopProgress = () => undefined;
|
|
134
134
|
|
|
135
|
+
// A select-all is walked a hundred ids at a time. Summed here so the caller
|
|
136
|
+
// states the refusals once for the run, rather than pushing a banner per
|
|
137
|
+
// chunk until the surface caps them.
|
|
138
|
+
test("sums the rows the server refused across every chunk", async () => {
|
|
139
|
+
const outcome = await runChunkedAction(
|
|
140
|
+
Array.from({ length: 250 }, (_, i) => ({
|
|
141
|
+
id: `m${i}`,
|
|
142
|
+
accountId: "acc-1",
|
|
143
|
+
})),
|
|
144
|
+
async (chunk) => ({
|
|
145
|
+
successCount: chunk.length - 1,
|
|
146
|
+
failureCount: 1,
|
|
147
|
+
}),
|
|
148
|
+
noopProgress,
|
|
149
|
+
neverAborted(),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
assert.equal(outcome.refused, 3, "one per chunk, over three chunks");
|
|
153
|
+
assert.equal(outcome.done, 250, "every id was reached");
|
|
154
|
+
assert.deepEqual(outcome.failedIds, [], "and none was left unattempted");
|
|
155
|
+
});
|
|
156
|
+
|
|
135
157
|
test("zero ids does nothing and reports done=0", async () => {
|
|
136
158
|
const calls: string[][] = [];
|
|
137
159
|
const outcome = await runChunkedAction(
|
|
@@ -146,6 +168,7 @@ describe("runChunkedAction", () => {
|
|
|
146
168
|
assert.deepEqual(outcome, {
|
|
147
169
|
done: 0,
|
|
148
170
|
failedIds: [],
|
|
171
|
+
refused: 0,
|
|
149
172
|
cancelled: false,
|
|
150
173
|
});
|
|
151
174
|
assert.equal(calls.length, 0);
|
|
@@ -356,7 +379,12 @@ describe("runPredicateAction", () => {
|
|
|
356
379
|
noopProgress,
|
|
357
380
|
neverAborted(),
|
|
358
381
|
);
|
|
359
|
-
assert.deepEqual(outcome, {
|
|
382
|
+
assert.deepEqual(outcome, {
|
|
383
|
+
done: 0,
|
|
384
|
+
failedIds: [],
|
|
385
|
+
refused: 0,
|
|
386
|
+
cancelled: false,
|
|
387
|
+
});
|
|
360
388
|
});
|
|
361
389
|
|
|
362
390
|
test("exactly 100 matches — a page size's worth — resolves in a single page with no continuation", async () => {
|
package/src/lib/bulk-actions.ts
CHANGED
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
*
|
|
11
11
|
* The endpoints enqueue the IMAP write and return; they do not apply it. So a
|
|
12
12
|
* returned call means every id in it was accepted, not that the mail server
|
|
13
|
-
* applied it
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* applied it. Delete is the one action that can accept some of a batch and not
|
|
14
|
+
* the rest — a row whose placement changed under it is refused and counted in
|
|
15
|
+
* the response (imap-mutations R3) — and it reports that itself, on the call,
|
|
16
|
+
* rather than here: this layer only ever hands ids back, and an id this run did
|
|
17
|
+
* reach is not one it should re-offer. The only failure it observes is a thrown
|
|
18
|
+
* call: an infrastructure failure (auth, the write, or the enqueue) that takes
|
|
19
|
+
* out the whole batch and stops the run.
|
|
17
20
|
*
|
|
18
21
|
* Cancellation is an `AbortSignal` the caller owns, handed to every call the run
|
|
19
22
|
* makes (#113). Aborting ends the request in flight rather than waiting for it,
|
|
@@ -165,10 +168,19 @@ export interface BulkActionOutcome {
|
|
|
165
168
|
* An aborted batch is counted here too: aborting the request does not
|
|
166
169
|
* un-send what the server already accepted, so its ids are "may not have
|
|
167
170
|
* landed" rather than "did not", and every action here is idempotent.
|
|
168
|
-
*
|
|
169
|
-
*
|
|
171
|
+
* A returned batch call counts every id in it as reached. Delete's own
|
|
172
|
+
* per-row refusals ride its response and are surfaced by the caller, not
|
|
173
|
+
* folded in here (see the module header).
|
|
170
174
|
*/
|
|
171
175
|
failedIds: string[];
|
|
176
|
+
/**
|
|
177
|
+
* Rows the server reached and refused: their placement changed under the
|
|
178
|
+
* run, so the action never claimed them (imap-mutations R3). Summed across
|
|
179
|
+
* every chunk so the caller states it once for the whole run rather than
|
|
180
|
+
* once per hundred ids. Not folded into `failedIds` — those were never
|
|
181
|
+
* reached, and these were.
|
|
182
|
+
*/
|
|
183
|
+
refused: number;
|
|
172
184
|
cancelled: boolean;
|
|
173
185
|
/** Set when a batch call threw — an infrastructure failure, not a
|
|
174
186
|
* per-item failure. The run stops at the point it was raised. */
|
|
@@ -198,26 +210,34 @@ export const runChunkedAction = async (
|
|
|
198
210
|
const chunks = chunkTargets(targets);
|
|
199
211
|
const total = targets.length;
|
|
200
212
|
let done = 0;
|
|
213
|
+
let refused = 0;
|
|
201
214
|
const failedIds: string[] = [];
|
|
202
215
|
|
|
203
216
|
for (let i = 0; i < chunks.length; i++) {
|
|
204
217
|
if (signal.aborted) {
|
|
205
218
|
failedIds.push(...chunks.slice(i).flat());
|
|
206
|
-
return { done, failedIds, cancelled: true };
|
|
219
|
+
return { done, failedIds, refused, cancelled: true };
|
|
207
220
|
}
|
|
208
221
|
const chunk = chunks[i];
|
|
209
222
|
const attempted = await attempt(applyBatch(chunk, signal));
|
|
210
223
|
if (!attempted.ok) {
|
|
211
224
|
failedIds.push(...chunks.slice(i).flat());
|
|
212
|
-
if (signal.aborted) return { done, failedIds, cancelled: true };
|
|
225
|
+
if (signal.aborted) return { done, failedIds, refused, cancelled: true };
|
|
213
226
|
onProgress({ done, total });
|
|
214
|
-
return {
|
|
227
|
+
return {
|
|
228
|
+
done,
|
|
229
|
+
failedIds,
|
|
230
|
+
refused,
|
|
231
|
+
cancelled: false,
|
|
232
|
+
error: attempted.error,
|
|
233
|
+
};
|
|
215
234
|
}
|
|
235
|
+
refused += attempted.value?.failureCount ?? 0;
|
|
216
236
|
done += chunk.length;
|
|
217
237
|
onProgress({ done, total });
|
|
218
238
|
}
|
|
219
239
|
|
|
220
|
-
return { done, failedIds, cancelled: false };
|
|
240
|
+
return { done, failedIds, refused, cancelled: false };
|
|
221
241
|
};
|
|
222
242
|
|
|
223
243
|
/**
|
|
@@ -290,27 +310,42 @@ export const runPredicateAction = async (
|
|
|
290
310
|
signal: AbortSignal,
|
|
291
311
|
): Promise<BulkActionOutcome> => {
|
|
292
312
|
let done = 0;
|
|
313
|
+
let refused = 0;
|
|
293
314
|
const failedIds: string[] = [];
|
|
294
315
|
let token: string | undefined;
|
|
295
316
|
|
|
296
317
|
do {
|
|
297
318
|
if (signal.aborted) {
|
|
298
|
-
return { done, failedIds, cancelled: true };
|
|
319
|
+
return { done, failedIds, refused, cancelled: true };
|
|
299
320
|
}
|
|
300
321
|
|
|
301
322
|
const fetched = await attempt(fetchIdsPage(token, signal));
|
|
302
323
|
if (!fetched.ok) {
|
|
303
|
-
if (signal.aborted) return { done, failedIds, cancelled: true };
|
|
304
|
-
return {
|
|
324
|
+
if (signal.aborted) return { done, failedIds, refused, cancelled: true };
|
|
325
|
+
return {
|
|
326
|
+
done,
|
|
327
|
+
failedIds,
|
|
328
|
+
refused,
|
|
329
|
+
cancelled: false,
|
|
330
|
+
error: fetched.error,
|
|
331
|
+
};
|
|
305
332
|
}
|
|
306
333
|
const page = fetched.value;
|
|
307
334
|
|
|
308
335
|
if (page.ids.length > 0) {
|
|
309
336
|
const attempted = await attempt(applyBatch(page.ids, signal));
|
|
310
337
|
if (!attempted.ok) {
|
|
311
|
-
if (signal.aborted)
|
|
312
|
-
|
|
338
|
+
if (signal.aborted)
|
|
339
|
+
return { done, failedIds, refused, cancelled: true };
|
|
340
|
+
return {
|
|
341
|
+
done,
|
|
342
|
+
failedIds,
|
|
343
|
+
refused,
|
|
344
|
+
cancelled: false,
|
|
345
|
+
error: attempted.error,
|
|
346
|
+
};
|
|
313
347
|
}
|
|
348
|
+
refused += attempted.value?.failureCount ?? 0;
|
|
314
349
|
done += page.ids.length;
|
|
315
350
|
onProgress({ done, total });
|
|
316
351
|
}
|
|
@@ -318,7 +353,7 @@ export const runPredicateAction = async (
|
|
|
318
353
|
token = page.continuationToken;
|
|
319
354
|
} while (token);
|
|
320
355
|
|
|
321
|
-
return { done, failedIds, cancelled: false };
|
|
356
|
+
return { done, failedIds, refused, cancelled: false };
|
|
322
357
|
};
|
|
323
358
|
|
|
324
359
|
/**
|
|
@@ -342,6 +377,14 @@ export const honestProgress = (
|
|
|
342
377
|
export interface BulkRunOutcome {
|
|
343
378
|
done: number;
|
|
344
379
|
failedIds: string[];
|
|
380
|
+
/**
|
|
381
|
+
* Rows the server reached and refused: their placement changed under the
|
|
382
|
+
* run, so the action never claimed them (imap-mutations R3). Summed across
|
|
383
|
+
* every chunk so the caller states it once for the whole run rather than
|
|
384
|
+
* once per hundred ids. Not folded into `failedIds` — those were never
|
|
385
|
+
* reached, and these were.
|
|
386
|
+
*/
|
|
387
|
+
refused: number;
|
|
345
388
|
cancelled: boolean;
|
|
346
389
|
error?: unknown;
|
|
347
390
|
}
|
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { abandonedMutationOf } from "@remit/data-ports/message-settlement";
|
|
3
|
+
import { MessageMutation } from "@remit/domain-enums";
|
|
3
4
|
import type { ThreadRowData } from "@remit/ui";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* The row treatment for a message whose
|
|
7
|
-
* client decides nothing: `
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* The row treatment for a message whose mutation Remit gave up on (issue
|
|
8
|
+
* #1002). The client decides nothing: `abandonedMutationOf` owns which values
|
|
9
|
+
* prove a terminal give-up and which operation it was. A copy that gave up
|
|
10
|
+
* leaves a `deleted` row no listing carries, so it reaches no row here; any
|
|
11
|
+
* other row — settled, mid-retry, or in flight — carries no key at all and
|
|
10
12
|
* renders exactly as it always has.
|
|
11
13
|
*/
|
|
12
14
|
export const rowSettlement = (
|
|
13
|
-
thread: Pick<
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
thread: Pick<
|
|
16
|
+
RemitImapThreadMessageResponse,
|
|
17
|
+
"status" | "syncStatus" | "abandonedMutation"
|
|
18
|
+
>,
|
|
19
|
+
): Pick<ThreadRowData, "settlement"> => {
|
|
20
|
+
switch (abandonedMutationOf(thread)) {
|
|
21
|
+
case MessageMutation.delete:
|
|
22
|
+
return { settlement: "delete_failed" };
|
|
23
|
+
case MessageMutation.move:
|
|
24
|
+
return { settlement: "move_failed" };
|
|
25
|
+
default:
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
};
|