@remit/web-client 0.0.185 → 0.0.186
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/DailyBrief.tsx +1 -0
- package/src/components/mail/DeleteConfirmDialog.render.test.ts +214 -0
- package/src/components/mail/DeleteConfirmDialog.tsx +95 -0
- package/src/components/mail/FlaggedList.tsx +1 -0
- package/src/components/mail/MessageList.run-reporting.test.ts +76 -14
- package/src/components/mail/MessageList.selection.test.ts +1 -1
- package/src/components/mail/MessageList.tsx +24 -37
- package/src/components/mail/ThreadListInteraction.test.ts +200 -20
- package/src/components/mail/ThreadListInteraction.tsx +67 -16
- package/src/hooks/useArchiveMailbox.ts +38 -8
- package/src/hooks/useDeleteOutcome.ts +35 -0
- package/src/lib/bulk-action-copy.ts +69 -3
- package/src/lib/format.test.ts +182 -0
- package/src/lib/format.ts +101 -5
- package/src/test-support/fixtures.ts +14 -0
package/src/lib/format.test.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
|
+
import type { DeleteTarget } from "./format.js";
|
|
3
4
|
import {
|
|
4
5
|
deleteConfirmationCopy,
|
|
6
|
+
deleteOutcomeFor,
|
|
5
7
|
formatDate,
|
|
6
8
|
formatDatePreset,
|
|
7
9
|
formatDeleteToTrashTitle,
|
|
@@ -155,3 +157,183 @@ describe("deleteConfirmationCopy", () => {
|
|
|
155
157
|
});
|
|
156
158
|
});
|
|
157
159
|
});
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Issue #855. The failure branch is the one that matters: TanStack sets
|
|
163
|
+
* `status: "error"` with `data` undefined, so a config read that failed leaves
|
|
164
|
+
* an empty Trash set behind. Reading that as "this folder is not Trash" hands an
|
|
165
|
+
* expired session a "Move to Trash?" dialog over an expunge — #845 reinstated on
|
|
166
|
+
* the error path.
|
|
167
|
+
*/
|
|
168
|
+
describe("deleteOutcomeFor", () => {
|
|
169
|
+
const trashByAccount = new Map([
|
|
170
|
+
["acct-1", "mbx-trash"],
|
|
171
|
+
["acct-2", undefined],
|
|
172
|
+
]);
|
|
173
|
+
const settled = { trashByAccount, hasAppointments: true, isError: false };
|
|
174
|
+
// No default for the account: a default parameter is applied to an explicit
|
|
175
|
+
// `undefined` too, so "the row names no account" silently became "acct-1"
|
|
176
|
+
// and the case asserting it read back as an ordinary move to Trash.
|
|
177
|
+
const target = (
|
|
178
|
+
mailboxId: string,
|
|
179
|
+
accountId: string | undefined,
|
|
180
|
+
): DeleteTarget => ({ accountId, mailboxId });
|
|
181
|
+
|
|
182
|
+
test("a row outside Trash is a reversible move", () => {
|
|
183
|
+
assert.strictEqual(
|
|
184
|
+
deleteOutcomeFor({
|
|
185
|
+
...settled,
|
|
186
|
+
targets: [target("mbx-inbox", "acct-1")],
|
|
187
|
+
}),
|
|
188
|
+
"trash",
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("a row inside its own account's Trash is an expunge", () => {
|
|
193
|
+
assert.strictEqual(
|
|
194
|
+
deleteOutcomeFor({
|
|
195
|
+
...settled,
|
|
196
|
+
targets: [target("mbx-trash", "acct-1")],
|
|
197
|
+
}),
|
|
198
|
+
"permanent",
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("one row inside Trash makes a mixed set permanent", () => {
|
|
203
|
+
assert.strictEqual(
|
|
204
|
+
deleteOutcomeFor({
|
|
205
|
+
...settled,
|
|
206
|
+
targets: [target("mbx-inbox", "acct-1"), target("mbx-trash", "acct-1")],
|
|
207
|
+
}),
|
|
208
|
+
"permanent",
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("an account that appoints no Trash is its own answer, not a move", () => {
|
|
213
|
+
assert.strictEqual(
|
|
214
|
+
deleteOutcomeFor({
|
|
215
|
+
...settled,
|
|
216
|
+
targets: [target("mbx-inbox", "acct-2")],
|
|
217
|
+
}),
|
|
218
|
+
"noTrash",
|
|
219
|
+
"the server refuses that delete rather than moving anything",
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("a refused account outranks an expunge in the same set", () => {
|
|
224
|
+
assert.strictEqual(
|
|
225
|
+
deleteOutcomeFor({
|
|
226
|
+
...settled,
|
|
227
|
+
targets: [target("mbx-trash", "acct-1"), target("mbx-inbox", "acct-2")],
|
|
228
|
+
}),
|
|
229
|
+
"noTrash",
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("another account's Trash is not this row's Trash", () => {
|
|
234
|
+
assert.strictEqual(
|
|
235
|
+
deleteOutcomeFor({
|
|
236
|
+
...settled,
|
|
237
|
+
trashByAccount: new Map([
|
|
238
|
+
["acct-1", "mbx-trash"],
|
|
239
|
+
["acct-2", "mbx-other-trash"],
|
|
240
|
+
]),
|
|
241
|
+
targets: [target("mbx-trash", "acct-2")],
|
|
242
|
+
}),
|
|
243
|
+
"trash",
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("appointments that have not arrived commit to neither wording", () => {
|
|
248
|
+
assert.strictEqual(
|
|
249
|
+
deleteOutcomeFor({
|
|
250
|
+
targets: [target("mbx-inbox", "acct-1")],
|
|
251
|
+
trashByAccount: new Map(),
|
|
252
|
+
hasAppointments: false,
|
|
253
|
+
isError: false,
|
|
254
|
+
}),
|
|
255
|
+
"unknown",
|
|
256
|
+
"a paused offline query reports neither loading nor error",
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("an account nothing is known about yet is unknown, not a move", () => {
|
|
261
|
+
assert.strictEqual(
|
|
262
|
+
deleteOutcomeFor({
|
|
263
|
+
...settled,
|
|
264
|
+
targets: [target("mbx-inbox", "acct-9")],
|
|
265
|
+
}),
|
|
266
|
+
"unknown",
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("a row with no account is unknown, not a move", () => {
|
|
271
|
+
assert.strictEqual(
|
|
272
|
+
deleteOutcomeFor({
|
|
273
|
+
...settled,
|
|
274
|
+
targets: [target("mbx-inbox", undefined)],
|
|
275
|
+
}),
|
|
276
|
+
"unknown",
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("a failed read refuses the delete rather than promising a move", () => {
|
|
281
|
+
assert.strictEqual(
|
|
282
|
+
deleteOutcomeFor({
|
|
283
|
+
targets: [target("mbx-inbox", "acct-1")],
|
|
284
|
+
trashByAccount: new Map(),
|
|
285
|
+
hasAppointments: false,
|
|
286
|
+
isError: true,
|
|
287
|
+
}),
|
|
288
|
+
"unavailable",
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("a failed read outranks a settled appointment set", () => {
|
|
293
|
+
assert.strictEqual(
|
|
294
|
+
deleteOutcomeFor({
|
|
295
|
+
...settled,
|
|
296
|
+
targets: [target("mbx-inbox", "acct-1")],
|
|
297
|
+
isError: true,
|
|
298
|
+
}),
|
|
299
|
+
"unavailable",
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("nothing pending is not an answer", () => {
|
|
304
|
+
assert.strictEqual(
|
|
305
|
+
deleteOutcomeFor({ ...settled, targets: [] }),
|
|
306
|
+
"unknown",
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("deleteConfirmationCopy — the refusal", () => {
|
|
312
|
+
test("states what failed and offers the way back in", () => {
|
|
313
|
+
assert.deepStrictEqual(deleteConfirmationCopy(1, "unavailable"), {
|
|
314
|
+
title: "Can't delete 1 message",
|
|
315
|
+
description:
|
|
316
|
+
"reader couldn't read this account's folder settings, so it can't say whether this would move the mail to Trash or erase it. Nothing has been deleted.",
|
|
317
|
+
confirmLabel: "Sign in again",
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("never offers the reversible wording on the failure path", () => {
|
|
322
|
+
const copy = deleteConfirmationCopy(3, "unavailable");
|
|
323
|
+
assert.ok(!copy.title.includes("Move"));
|
|
324
|
+
assert.ok(!copy.description.includes("restore"));
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("sends an unappointed Trash to the screen that appoints one", () => {
|
|
328
|
+
const copy = deleteConfirmationCopy(3, "noTrash");
|
|
329
|
+
assert.strictEqual(copy.title, "Can't delete 3 messages");
|
|
330
|
+
assert.match(copy.description, /appointed as Trash/);
|
|
331
|
+
assert.strictEqual(copy.confirmLabel, "Open folder settings");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("never promises a restore when no Trash is appointed", () => {
|
|
335
|
+
const copy = deleteConfirmationCopy(3, "noTrash");
|
|
336
|
+
assert.ok(!copy.title.includes("Move"));
|
|
337
|
+
assert.ok(!copy.description.includes("restore"));
|
|
338
|
+
});
|
|
339
|
+
});
|
package/src/lib/format.ts
CHANGED
|
@@ -168,12 +168,85 @@ export interface DeleteConfirmationCopy {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
/**
|
|
171
|
-
* What a delete will actually do to the selected mail.
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
171
|
+
* What a delete will actually do to the selected mail.
|
|
172
|
+
*
|
|
173
|
+
* `unknown` is the state before the account's Trash appointment has resolved — a
|
|
174
|
+
* real state on a cold open, and the one the copy must not guess at, because
|
|
175
|
+
* guessing "move to Trash" over an expunge is the dishonesty this whole flow
|
|
176
|
+
* exists to remove.
|
|
177
|
+
*
|
|
178
|
+
* `noTrash` is a resolved answer of "none": the account appoints no Trash, so
|
|
179
|
+
* the server refuses the delete outright (#846) rather than moving anything.
|
|
180
|
+
* It is a different fact from "this row is not in Trash" and must never share
|
|
181
|
+
* an outcome with it — one promises a restore that can happen, the other a
|
|
182
|
+
* delete that will not.
|
|
183
|
+
*
|
|
184
|
+
* `unavailable` is the appointment failing to resolve at all. A read that
|
|
185
|
+
* cannot answer is not an answer: treating an errored `/config` as "no Trash
|
|
186
|
+
* here" reinstates the same lie on the failure path, where an expired session
|
|
187
|
+
* would collect "Move to Trash?" over an expunge. The delete is refused and the
|
|
188
|
+
* failure is stated instead.
|
|
175
189
|
*/
|
|
176
|
-
export type DeleteOutcome =
|
|
190
|
+
export type DeleteOutcome =
|
|
191
|
+
| "trash"
|
|
192
|
+
| "permanent"
|
|
193
|
+
| "noTrash"
|
|
194
|
+
| "unknown"
|
|
195
|
+
| "unavailable";
|
|
196
|
+
|
|
197
|
+
/** A row about to be deleted, and the account whose Trash decides its fate. */
|
|
198
|
+
export interface DeleteTarget {
|
|
199
|
+
accountId: string | undefined;
|
|
200
|
+
mailboxId: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface DeleteOutcomeInput {
|
|
204
|
+
targets: readonly DeleteTarget[];
|
|
205
|
+
/**
|
|
206
|
+
* Each account's appointed Trash. A key present with `undefined` is an
|
|
207
|
+
* account that appoints none — an answer, not a gap. A key absent is an
|
|
208
|
+
* account nothing is known about yet.
|
|
209
|
+
*/
|
|
210
|
+
trashByAccount: ReadonlyMap<string, string | undefined>;
|
|
211
|
+
/**
|
|
212
|
+
* The appointments have actually arrived. Never `!isLoading`: React Query
|
|
213
|
+
* v5 leaves a paused offline query pending-but-not-fetching, which reads as
|
|
214
|
+
* loaded while `data` is still undefined.
|
|
215
|
+
*/
|
|
216
|
+
hasAppointments: boolean;
|
|
217
|
+
/** The read for them failed. */
|
|
218
|
+
isError: boolean;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The outcome of deleting `targets`.
|
|
223
|
+
*
|
|
224
|
+
* One row bound for an expunge makes the whole delete unrecoverable, so a mixed
|
|
225
|
+
* set is permanent: the wording may overstate what is destroyed, never what is
|
|
226
|
+
* kept. One row on an account with no Trash refuses the whole call, so that
|
|
227
|
+
* outranks both. Pure, so every branch — the failure ones above all — is
|
|
228
|
+
* testable without a DOM.
|
|
229
|
+
*/
|
|
230
|
+
export const deleteOutcomeFor = ({
|
|
231
|
+
targets,
|
|
232
|
+
trashByAccount,
|
|
233
|
+
hasAppointments,
|
|
234
|
+
isError,
|
|
235
|
+
}: DeleteOutcomeInput): DeleteOutcome => {
|
|
236
|
+
if (isError) return "unavailable";
|
|
237
|
+
if (!hasAppointments) return "unknown";
|
|
238
|
+
if (targets.length === 0) return "unknown";
|
|
239
|
+
|
|
240
|
+
let expunges = false;
|
|
241
|
+
for (const target of targets) {
|
|
242
|
+
if (target.accountId === undefined) return "unknown";
|
|
243
|
+
if (!trashByAccount.has(target.accountId)) return "unknown";
|
|
244
|
+
const trashMailboxId = trashByAccount.get(target.accountId);
|
|
245
|
+
if (trashMailboxId === undefined) return "noTrash";
|
|
246
|
+
if (trashMailboxId === target.mailboxId) expunges = true;
|
|
247
|
+
}
|
|
248
|
+
return expunges ? "permanent" : "trash";
|
|
249
|
+
};
|
|
177
250
|
|
|
178
251
|
/**
|
|
179
252
|
* The confirmation for a delete, worded for what the delete actually does.
|
|
@@ -181,6 +254,13 @@ export type DeleteOutcome = "trash" | "permanent" | "unknown";
|
|
|
181
254
|
* nothing survives that, so it is asked as a permanent delete — a dialog that
|
|
182
255
|
* says "Move to Trash" over an expunge collects an answer to a question the
|
|
183
256
|
* user was never asked.
|
|
257
|
+
*
|
|
258
|
+
* `noTrash` and `unavailable` are not confirmations at all but refusals:
|
|
259
|
+
* nothing is deleted, and the label names the way out rather than the delete.
|
|
260
|
+
* The caller wires each to its own remedy — folder settings for the missing
|
|
261
|
+
* appointment, re-authentication for the failed read, because an account read
|
|
262
|
+
* that fails is a session that ended under the reader far more often than it is
|
|
263
|
+
* anything else.
|
|
184
264
|
*/
|
|
185
265
|
export const deleteConfirmationCopy = (
|
|
186
266
|
count: number,
|
|
@@ -189,6 +269,22 @@ export const deleteConfirmationCopy = (
|
|
|
189
269
|
const quantity = count === 1 ? "1" : formatNumber(count);
|
|
190
270
|
const noun = count === 1 ? "message" : "messages";
|
|
191
271
|
|
|
272
|
+
if (outcome === "noTrash") {
|
|
273
|
+
return {
|
|
274
|
+
title: `Can't delete ${quantity} ${noun}`,
|
|
275
|
+
description:
|
|
276
|
+
"No folder on this account is appointed as Trash, so there is nowhere to move the mail — and deleting it would erase it from the server instead. Appoint a Trash folder to delete from here.",
|
|
277
|
+
confirmLabel: "Open folder settings",
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
if (outcome === "unavailable") {
|
|
281
|
+
return {
|
|
282
|
+
title: `Can't delete ${quantity} ${noun}`,
|
|
283
|
+
description:
|
|
284
|
+
"reader couldn't read this account's folder settings, so it can't say whether this would move the mail to Trash or erase it. Nothing has been deleted.",
|
|
285
|
+
confirmLabel: "Sign in again",
|
|
286
|
+
};
|
|
287
|
+
}
|
|
192
288
|
if (outcome === "unknown") {
|
|
193
289
|
return {
|
|
194
290
|
title: `Delete ${quantity} ${noun}?`,
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type {
|
|
8
8
|
RemitImapAccountResponse,
|
|
9
|
+
RemitImapConfigDescriptionResponse,
|
|
9
10
|
RemitImapMailboxResponse,
|
|
10
11
|
RemitImapThreadMessageResponse,
|
|
11
12
|
} from "@remit/api-http-client/types.gen.ts";
|
|
@@ -83,3 +84,16 @@ export const makeThreadMessage = (
|
|
|
83
84
|
updatedAt: 0,
|
|
84
85
|
...overrides,
|
|
85
86
|
});
|
|
87
|
+
|
|
88
|
+
export const makeConfig = (
|
|
89
|
+
accounts: RemitImapAccountResponse[],
|
|
90
|
+
): RemitImapConfigDescriptionResponse => ({
|
|
91
|
+
accountConfig: {
|
|
92
|
+
accountConfigId: "cfg-1",
|
|
93
|
+
userId: "user-1",
|
|
94
|
+
state: "active",
|
|
95
|
+
createdAt: 0,
|
|
96
|
+
updatedAt: 0,
|
|
97
|
+
},
|
|
98
|
+
accounts,
|
|
99
|
+
});
|