@remit/web-client 0.0.185 → 0.0.187
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/MessageActionMenu.tsx +3 -20
- 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/useDeleteMessages.ts +3 -19
- package/src/hooks/useDeleteOutcome.ts +35 -0
- package/src/hooks/useMarkAsRead.ts +3 -19
- package/src/hooks/useMoveMessages.ts +3 -19
- package/src/hooks/useToggleStar.ts +3 -19
- package/src/hooks/useToggleTrusted.ts +2 -6
- 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/lib/thread-list-cache.ts +24 -2
- package/src/test-support/fixtures.ts +14 -0
|
@@ -11,19 +11,15 @@ import type {
|
|
|
11
11
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
12
12
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
13
13
|
import { buildMutationErrorBanner } from "@/components/ui/error-banners";
|
|
14
|
+
import type { SnapshotEntry } from "@/lib/thread-list-cache";
|
|
14
15
|
|
|
15
16
|
interface UseToggleTrustedOptions {
|
|
16
17
|
messageId: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
interface SnapshotEntry {
|
|
20
|
-
queryKey: readonly unknown[];
|
|
21
|
-
data: RemitImapDescribeMessageResponse;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
20
|
interface ToggleTrustedContext {
|
|
25
21
|
describePrefix: readonly unknown[];
|
|
26
|
-
previous: SnapshotEntry[];
|
|
22
|
+
previous: SnapshotEntry<RemitImapDescribeMessageResponse>[];
|
|
27
23
|
}
|
|
28
24
|
|
|
29
25
|
export const buildTrustedFlag = (
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ErrorBannerSeverity } from "@/components/ui/error-banners";
|
|
2
|
+
import type { BulkRunOutcome } from "@/lib/bulk-actions";
|
|
3
|
+
import { type DeleteOutcome, formatNumber } from "@/lib/format";
|
|
4
|
+
|
|
5
|
+
/** A run ending, as the list banners it. */
|
|
6
|
+
export interface RunEndingBanner {
|
|
7
|
+
severity: ErrorBannerSeverity;
|
|
8
|
+
title: string;
|
|
9
|
+
detail?: string;
|
|
10
|
+
}
|
|
2
11
|
|
|
3
12
|
/**
|
|
4
13
|
* Wording for the three bulk actions a selection can run (#114). One place
|
|
@@ -23,6 +32,16 @@ const pastTense: Record<BulkActionKind, string> = {
|
|
|
23
32
|
markRead: "marked as read",
|
|
24
33
|
};
|
|
25
34
|
|
|
35
|
+
/**
|
|
36
|
+
* What the run did, in the past tense. A delete inside Trash expunges rather
|
|
37
|
+
* than moves (#855), and that holds for a run that stopped halfway exactly as
|
|
38
|
+
* it does for one that finished — the half that ran is still erased.
|
|
39
|
+
*/
|
|
40
|
+
const pastTenseFor = (kind: BulkActionKind, outcome: DeleteOutcome): string =>
|
|
41
|
+
kind === "delete" && outcome === "permanent"
|
|
42
|
+
? "permanently deleted"
|
|
43
|
+
: pastTense[kind];
|
|
44
|
+
|
|
26
45
|
const negated: Record<BulkActionKind, string> = {
|
|
27
46
|
delete: "couldn't be deleted",
|
|
28
47
|
move: "couldn't be moved",
|
|
@@ -52,12 +71,18 @@ export const bulkActionProgressLabel = (
|
|
|
52
71
|
* Shown once a run finishes with nothing left over. The second sentence is
|
|
53
72
|
* the honest part: the bulk endpoints enqueue the IMAP write, so the mail
|
|
54
73
|
* server is still applying it when this appears.
|
|
74
|
+
*
|
|
75
|
+
* A delete inside Trash expunges rather than moves (#855), so the run that just
|
|
76
|
+
* finished is named by its outcome — telling a reader their mail is "moved to
|
|
77
|
+
* Trash" after it was erased is the same lie the confirmation stopped telling,
|
|
78
|
+
* one screen later.
|
|
55
79
|
*/
|
|
56
80
|
export const bulkActionCompletionText = (
|
|
57
81
|
kind: BulkActionKind,
|
|
58
82
|
done: number,
|
|
83
|
+
outcome: DeleteOutcome = "trash",
|
|
59
84
|
): string =>
|
|
60
|
-
`${formatNumber(done)} ${
|
|
85
|
+
`${formatNumber(done)} ${pastTenseFor(kind, outcome)}. Your mail server is still catching up.`;
|
|
61
86
|
|
|
62
87
|
/**
|
|
63
88
|
* Shown when a run ended before it covered what it was started against. The
|
|
@@ -72,8 +97,9 @@ export const bulkActionStoppedDetail = (
|
|
|
72
97
|
kind: BulkActionKind,
|
|
73
98
|
done: number,
|
|
74
99
|
total: number,
|
|
100
|
+
outcome: DeleteOutcome = "trash",
|
|
75
101
|
): string =>
|
|
76
|
-
`${formatNumber(done)} of ${formatNumber(total)} ${
|
|
102
|
+
`${formatNumber(done)} of ${formatNumber(total)} ${pastTenseFor(kind, outcome)}. Nothing was sent for the rest, so they are untouched.`;
|
|
77
103
|
|
|
78
104
|
/** Error-banner title for a run stopped by an infrastructure failure. */
|
|
79
105
|
export const bulkActionFailureTitle = (
|
|
@@ -91,3 +117,43 @@ export const bulkActionFailureDetail = (kind: BulkActionKind): string =>
|
|
|
91
117
|
export const bulkActionProgressTone = (
|
|
92
118
|
kind: BulkActionKind,
|
|
93
119
|
): "danger" | "info" => (kind === "delete" ? "danger" : "info");
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* How a run that has already ended is announced, or `null` when it announces
|
|
123
|
+
* itself elsewhere.
|
|
124
|
+
*
|
|
125
|
+
* The run screen invites the user to close it and keeps going past that, so by
|
|
126
|
+
* the time a run ends there is often no screen of its own left to say how it
|
|
127
|
+
* went (#521) — the list says it instead. Three endings, and they are not the
|
|
128
|
+
* same news: a run stopped short is a warning, because mail the user asked to
|
|
129
|
+
* be acted on was left untouched; a run that covered everything is a passing
|
|
130
|
+
* note; and a run stopped by a thrown batch already bannered where it threw, so
|
|
131
|
+
* saying it twice is the one wrong answer.
|
|
132
|
+
*
|
|
133
|
+
* Pure, so the severity of each ending is pinned by its result rather than by
|
|
134
|
+
* the shape of the caller that produces it.
|
|
135
|
+
*/
|
|
136
|
+
export const runEndingBanner = (
|
|
137
|
+
kind: BulkActionKind,
|
|
138
|
+
matched: number,
|
|
139
|
+
outcome: BulkRunOutcome,
|
|
140
|
+
deleteOutcome: DeleteOutcome,
|
|
141
|
+
): RunEndingBanner | null => {
|
|
142
|
+
if (outcome.error !== undefined) return null;
|
|
143
|
+
if (outcome.cancelled) {
|
|
144
|
+
return {
|
|
145
|
+
severity: "warning",
|
|
146
|
+
title: bulkActionStoppedTitle(outcome.done),
|
|
147
|
+
detail: bulkActionStoppedDetail(
|
|
148
|
+
kind,
|
|
149
|
+
outcome.done,
|
|
150
|
+
matched,
|
|
151
|
+
deleteOutcome,
|
|
152
|
+
),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
severity: "info",
|
|
157
|
+
title: bulkActionCompletionText(kind, outcome.done, deleteOutcome),
|
|
158
|
+
};
|
|
159
|
+
};
|
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}?`,
|
|
@@ -7,9 +7,31 @@ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/type
|
|
|
7
7
|
import type { QueryClient } from "@tanstack/react-query";
|
|
8
8
|
import { patchThreadListCache, type ThreadListCache } from "./thread-cache.js";
|
|
9
9
|
|
|
10
|
-
export interface
|
|
10
|
+
export interface SnapshotEntry<T> {
|
|
11
11
|
queryKey: readonly unknown[];
|
|
12
|
-
data:
|
|
12
|
+
data: T;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ThreadListSnapshotEntry = SnapshotEntry<ThreadListCache>;
|
|
16
|
+
|
|
17
|
+
/** A cached page of one thread's messages, as the thread-detail endpoint serves it. */
|
|
18
|
+
export interface ThreadMessagesData {
|
|
19
|
+
items: RemitImapThreadMessageResponse[];
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The rollback state an optimistic thread mutation captures in `onMutate`.
|
|
25
|
+
*
|
|
26
|
+
* The shape belongs to the snapshot/restore helpers below rather than to any
|
|
27
|
+
* one mutation, so mark-read, star, delete and move all carry the same one
|
|
28
|
+
* (#868).
|
|
29
|
+
*/
|
|
30
|
+
export interface ThreadMutationContext {
|
|
31
|
+
threadMessagesPrefix: readonly unknown[];
|
|
32
|
+
listPrefixes: ReadonlyArray<readonly unknown[]>;
|
|
33
|
+
previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
|
|
34
|
+
previousThreadsList: ThreadListSnapshotEntry[];
|
|
13
35
|
}
|
|
14
36
|
|
|
15
37
|
/**
|
|
@@ -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
|
+
});
|