@remit/web-client 0.0.191 → 0.0.192
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/EmptyTrashBar.render.test.ts +133 -0
- package/src/components/mail/EmptyTrashBar.tsx +181 -0
- package/src/components/mail/MailboxPane.tsx +39 -1
- package/src/components/mail/empty-trash-bar.stories.tsx +121 -0
- package/src/hooks/useArchiveMailbox.ts +11 -0
- package/src/hooks/useCurrentMailboxName.ts +33 -0
- package/src/hooks/useEmptyTrash.render.test.ts +194 -0
- package/src/hooks/useEmptyTrash.ts +187 -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.192",
|
|
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": {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Empty Trash strip and its three refusals (#847). Rendered as the app
|
|
3
|
+
* renders it — no providers, because every fact it shows arrives as a prop and
|
|
4
|
+
* the 409 is what decides which one.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, it } from "node:test";
|
|
9
|
+
import React, { createElement } from "react";
|
|
10
|
+
import { renderToString } from "react-dom/server";
|
|
11
|
+
import {
|
|
12
|
+
EmptyTrashBar,
|
|
13
|
+
type EmptyTrashBarProps,
|
|
14
|
+
emptyTrashConfirmCopy,
|
|
15
|
+
} from "@/components/mail/EmptyTrashBar";
|
|
16
|
+
import { deleteConfirmationCopy } from "@/lib/format";
|
|
17
|
+
|
|
18
|
+
(globalThis as { React?: typeof React }).React = React;
|
|
19
|
+
|
|
20
|
+
const noop = () => {};
|
|
21
|
+
|
|
22
|
+
const propsFor = (
|
|
23
|
+
overrides: Partial<EmptyTrashBarProps>,
|
|
24
|
+
): EmptyTrashBarProps => ({
|
|
25
|
+
messageCount: 128,
|
|
26
|
+
isEmptying: false,
|
|
27
|
+
onEmpty: noop,
|
|
28
|
+
onRepair: noop,
|
|
29
|
+
children: createElement("div", { id: "list" }, "list"),
|
|
30
|
+
...overrides,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const render = (overrides: Partial<EmptyTrashBarProps>): string =>
|
|
34
|
+
renderToString(createElement(EmptyTrashBar, propsFor(overrides)));
|
|
35
|
+
|
|
36
|
+
const text = (html: string): string =>
|
|
37
|
+
html
|
|
38
|
+
.replace(/<[^>]*>/g, " ")
|
|
39
|
+
.replace(/'/g, "'")
|
|
40
|
+
.replace(///g, "/")
|
|
41
|
+
.replace(/"/g, '"')
|
|
42
|
+
.replace(/&/g, "&")
|
|
43
|
+
.replace(/\s+/g, " ")
|
|
44
|
+
.trim();
|
|
45
|
+
|
|
46
|
+
describe("EmptyTrashBar", () => {
|
|
47
|
+
it("offers the verb when the open Trash folder holds mail", () => {
|
|
48
|
+
const html = render({});
|
|
49
|
+
assert.match(text(html), /Empty Trash/);
|
|
50
|
+
assert.match(html, /id="list"/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("offers nothing over an empty folder, and still renders the list", () => {
|
|
54
|
+
const html = render({ messageCount: 0 });
|
|
55
|
+
assert.doesNotMatch(text(html), /Empty Trash/);
|
|
56
|
+
assert.match(html, /id="list"/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("disables the button while the empty is in flight", () => {
|
|
60
|
+
const html = render({ isEmptying: true });
|
|
61
|
+
assert.match(text(html), /Emptying/);
|
|
62
|
+
assert.match(html, /disabled/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("reports the service's own count once the run finishes", () => {
|
|
66
|
+
const html = render({ messageCount: 0, deletedCount: 128 });
|
|
67
|
+
assert.match(text(html), /128 messages erased from the mail server\./);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("keeps the refusal standing after the folder count drops to zero", () => {
|
|
71
|
+
const html = render({ messageCount: 0, refusalReason: "none" });
|
|
72
|
+
assert.match(text(html), /No folder on this account is set as Trash\./);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("words `unconfirmed` with deleteConfirmationCopy's own sentence", () => {
|
|
76
|
+
const copy = deleteConfirmationCopy(0, "unconfirmed", {
|
|
77
|
+
trashFolderLabel: "Deleted Items",
|
|
78
|
+
});
|
|
79
|
+
const html = text(
|
|
80
|
+
render({
|
|
81
|
+
refusalReason: "unconfirmed",
|
|
82
|
+
trashFolderLabel: "Deleted Items",
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
assert.match(html, new RegExp(literal(copy.title)));
|
|
86
|
+
assert.match(html, new RegExp(literal(copy.description)));
|
|
87
|
+
assert.match(html, new RegExp(literal(copy.confirmLabel)));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("names the folder a stale appointment lost", () => {
|
|
91
|
+
const html = text(
|
|
92
|
+
render({ refusalReason: "stale", staleFolderLabel: "Archive/Bin" }),
|
|
93
|
+
);
|
|
94
|
+
assert.match(html, /Nothing was emptied\./);
|
|
95
|
+
assert.match(html, /Archive\/Bin — is gone from the mail server\./);
|
|
96
|
+
assert.match(html, /Pick another folder/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("drops the folder clause when the lost folder has no name", () => {
|
|
100
|
+
const html = text(render({ refusalReason: "stale" }));
|
|
101
|
+
assert.match(
|
|
102
|
+
html,
|
|
103
|
+
/The folder you chose for Trash is gone from the mail server\./,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("offers a folder to pick when the account appoints no Trash", () => {
|
|
108
|
+
const html = text(render({ refusalReason: "none" }));
|
|
109
|
+
assert.match(html, /No folder on this account is set as Trash\./);
|
|
110
|
+
assert.match(html, /Pick a folder/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("asks the confirmation as the expunge it is", () => {
|
|
114
|
+
const copy = emptyTrashConfirmCopy(128);
|
|
115
|
+
assert.equal(copy.title, "Empty Trash?");
|
|
116
|
+
assert.equal(
|
|
117
|
+
copy.description,
|
|
118
|
+
"128 messages are erased from the mail server and cannot be restored.",
|
|
119
|
+
);
|
|
120
|
+
assert.equal(copy.confirmLabel, "Empty Trash");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("counts a single message in the singular", () => {
|
|
124
|
+
assert.equal(
|
|
125
|
+
emptyTrashConfirmCopy(1).description,
|
|
126
|
+
"1 message is erased from the mail server and cannot be restored.",
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
function literal(value: string): string {
|
|
132
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
133
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { Banner, Button, ConfirmDialog } from "@remit/ui";
|
|
2
|
+
import { Trash2 } from "lucide-react";
|
|
3
|
+
import { type ReactNode, useState } from "react";
|
|
4
|
+
import type { FolderRoleRefusalReason } from "@/components/ui/folder-role-refusal";
|
|
5
|
+
import { deleteConfirmationCopy, formatNumber } from "@/lib/format";
|
|
6
|
+
|
|
7
|
+
const quantified = (count: number): string =>
|
|
8
|
+
count === 1 ? "1 message" : `${formatNumber(count)} messages`;
|
|
9
|
+
|
|
10
|
+
export interface EmptyTrashConfirmCopy {
|
|
11
|
+
title: string;
|
|
12
|
+
description: string;
|
|
13
|
+
confirmLabel: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The confirmation the button opens, worded for an expunge. */
|
|
17
|
+
export const emptyTrashConfirmCopy = (
|
|
18
|
+
count: number,
|
|
19
|
+
): EmptyTrashConfirmCopy => ({
|
|
20
|
+
title: "Empty Trash?",
|
|
21
|
+
description: `${quantified(count)} ${count === 1 ? "is" : "are"} erased from the mail server and cannot be restored.`,
|
|
22
|
+
confirmLabel: "Empty Trash",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export interface EmptyTrashRefusalCopy {
|
|
26
|
+
headline: string;
|
|
27
|
+
body: string;
|
|
28
|
+
actionLabel: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface EmptyTrashRefusalContext {
|
|
32
|
+
/** The folder reader guessed, for `unconfirmed`. */
|
|
33
|
+
trashFolderLabel?: string;
|
|
34
|
+
/** The folder that vanished, for `stale`. */
|
|
35
|
+
staleFolderLabel?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What the strip says when the server refused. `unconfirmed` borrows
|
|
40
|
+
* `deleteConfirmationCopy`'s words so the dialog and this strip cannot drift
|
|
41
|
+
* apart on the one refusal both can raise (#887 F4).
|
|
42
|
+
*/
|
|
43
|
+
export const emptyTrashRefusalCopy = (
|
|
44
|
+
reason: FolderRoleRefusalReason,
|
|
45
|
+
context: EmptyTrashRefusalContext = {},
|
|
46
|
+
): EmptyTrashRefusalCopy => {
|
|
47
|
+
const { trashFolderLabel, staleFolderLabel } = context;
|
|
48
|
+
|
|
49
|
+
if (reason === "unconfirmed") {
|
|
50
|
+
const copy = deleteConfirmationCopy(0, "unconfirmed", { trashFolderLabel });
|
|
51
|
+
return {
|
|
52
|
+
headline: copy.title,
|
|
53
|
+
body: copy.description,
|
|
54
|
+
actionLabel: copy.confirmLabel,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (reason === "stale") {
|
|
58
|
+
return {
|
|
59
|
+
headline: "Nothing was emptied.",
|
|
60
|
+
body: staleFolderLabel
|
|
61
|
+
? `The folder you chose for Trash — ${staleFolderLabel} — is gone from the mail server.`
|
|
62
|
+
: "The folder you chose for Trash is gone from the mail server.",
|
|
63
|
+
actionLabel: "Pick another folder",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
headline: "Nothing was emptied.",
|
|
68
|
+
body: "No folder on this account is set as Trash.",
|
|
69
|
+
actionLabel: "Pick a folder",
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export interface EmptyTrashBarProps extends EmptyTrashRefusalContext {
|
|
74
|
+
/** Messages the open Trash folder holds. */
|
|
75
|
+
messageCount: number;
|
|
76
|
+
isEmptying: boolean;
|
|
77
|
+
/** The service's own count, from the run that just finished. */
|
|
78
|
+
deletedCount?: number;
|
|
79
|
+
/** The reason the server refused, kept until the user acts on it. */
|
|
80
|
+
refusalReason?: FolderRoleRefusalReason;
|
|
81
|
+
onEmpty: () => void;
|
|
82
|
+
/** Opens the appointment prompt for the standing refusal. */
|
|
83
|
+
onRepair: () => void;
|
|
84
|
+
children: ReactNode;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The Empty Trash strip above the Trash folder's list, and the refusals the
|
|
89
|
+
* server answers it with (#847). The button always acts — it is never
|
|
90
|
+
* pre-refused from what the client thinks the appointment is, because the 409
|
|
91
|
+
* is the authority and a warning over a folder nobody has tried to empty is
|
|
92
|
+
* noise.
|
|
93
|
+
*/
|
|
94
|
+
export function EmptyTrashBar({
|
|
95
|
+
messageCount,
|
|
96
|
+
isEmptying,
|
|
97
|
+
deletedCount,
|
|
98
|
+
refusalReason,
|
|
99
|
+
trashFolderLabel,
|
|
100
|
+
staleFolderLabel,
|
|
101
|
+
onEmpty,
|
|
102
|
+
onRepair,
|
|
103
|
+
children,
|
|
104
|
+
}: EmptyTrashBarProps) {
|
|
105
|
+
const [confirming, setConfirming] = useState(false);
|
|
106
|
+
|
|
107
|
+
// The folder is emptied and nothing was refused: there is no verb left to
|
|
108
|
+
// offer. A standing refusal or a report of what went outlives the rows,
|
|
109
|
+
// so the user is never left to guess what the press did.
|
|
110
|
+
if (
|
|
111
|
+
messageCount < 1 &&
|
|
112
|
+
refusalReason === undefined &&
|
|
113
|
+
deletedCount === undefined
|
|
114
|
+
)
|
|
115
|
+
return <>{children}</>;
|
|
116
|
+
|
|
117
|
+
const refusal = refusalReason
|
|
118
|
+
? emptyTrashRefusalCopy(refusalReason, {
|
|
119
|
+
trashFolderLabel,
|
|
120
|
+
staleFolderLabel,
|
|
121
|
+
})
|
|
122
|
+
: undefined;
|
|
123
|
+
const confirm = emptyTrashConfirmCopy(messageCount);
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className="relative flex h-full min-h-0 flex-col">
|
|
127
|
+
<div className="shrink-0 px-row-inset pt-2">
|
|
128
|
+
<div className="flex flex-col gap-2 rounded-md border border-line px-3 py-2">
|
|
129
|
+
<div className="flex items-center justify-end gap-2">
|
|
130
|
+
{deletedCount !== undefined && (
|
|
131
|
+
<p className="mr-auto text-2xs text-fg-subtle" role="status">
|
|
132
|
+
{`${quantified(deletedCount)} erased from the mail server.`}
|
|
133
|
+
</p>
|
|
134
|
+
)}
|
|
135
|
+
<Button
|
|
136
|
+
variant="ghost"
|
|
137
|
+
size="sm"
|
|
138
|
+
icon={<Trash2 className="size-3.5" aria-hidden />}
|
|
139
|
+
disabled={isEmptying || messageCount < 1}
|
|
140
|
+
aria-busy={isEmptying}
|
|
141
|
+
onClick={() => setConfirming(true)}
|
|
142
|
+
>
|
|
143
|
+
{isEmptying ? "Emptying…" : "Empty Trash"}
|
|
144
|
+
</Button>
|
|
145
|
+
</div>
|
|
146
|
+
{refusal && (
|
|
147
|
+
<Banner tone="warning" variant="soft">
|
|
148
|
+
<div className="flex flex-col gap-2">
|
|
149
|
+
<p className="font-semibold text-fg">{refusal.headline}</p>
|
|
150
|
+
<p className="text-sm">{refusal.body}</p>
|
|
151
|
+
<Button
|
|
152
|
+
variant="primary"
|
|
153
|
+
size="sm"
|
|
154
|
+
className="self-start"
|
|
155
|
+
onClick={onRepair}
|
|
156
|
+
>
|
|
157
|
+
{refusal.actionLabel}
|
|
158
|
+
</Button>
|
|
159
|
+
</div>
|
|
160
|
+
</Banner>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
<div className="min-h-0 flex-1">{children}</div>
|
|
165
|
+
<ConfirmDialog
|
|
166
|
+
isOpen={confirming}
|
|
167
|
+
title={confirm.title}
|
|
168
|
+
description={confirm.description}
|
|
169
|
+
confirmLabel={confirm.confirmLabel}
|
|
170
|
+
destructive
|
|
171
|
+
// The dialog hands the decision over and closes; the in-flight state
|
|
172
|
+
// lives on the strip's own button, which is where the verb is.
|
|
173
|
+
onConfirm={() => {
|
|
174
|
+
setConfirming(false);
|
|
175
|
+
onEmpty();
|
|
176
|
+
}}
|
|
177
|
+
onCancel={() => setConfirming(false)}
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
} from "react";
|
|
48
48
|
import { ConversationView } from "@/components/mail/ConversationView";
|
|
49
49
|
import { DraftsView } from "@/components/mail/DraftsView";
|
|
50
|
+
import { EmptyTrashBar } from "@/components/mail/EmptyTrashBar";
|
|
50
51
|
import { IntelligenceDrawer } from "@/components/mail/IntelligenceDrawer";
|
|
51
52
|
import { IntelligencePane } from "@/components/mail/IntelligencePane";
|
|
52
53
|
import {
|
|
@@ -60,8 +61,11 @@ import {
|
|
|
60
61
|
useArchiveMailbox,
|
|
61
62
|
useDraftsMailbox,
|
|
62
63
|
useJunkMailbox,
|
|
64
|
+
useTrashByAccount,
|
|
65
|
+
useTrashMailbox,
|
|
63
66
|
} from "@/hooks/useArchiveMailbox";
|
|
64
67
|
import {
|
|
68
|
+
useCurrentMailboxMessageCount,
|
|
65
69
|
useCurrentMailboxName,
|
|
66
70
|
useCurrentMailboxUnseenCount,
|
|
67
71
|
} from "@/hooks/useCurrentMailboxName";
|
|
@@ -69,6 +73,7 @@ import {
|
|
|
69
73
|
dropDeletedThreads,
|
|
70
74
|
useDeleteMessages,
|
|
71
75
|
} from "@/hooks/useDeleteMessages";
|
|
76
|
+
import { useEmptyTrash } from "@/hooks/useEmptyTrash";
|
|
72
77
|
import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
|
|
73
78
|
import { useIntelligenceData } from "@/hooks/useIntelligenceData";
|
|
74
79
|
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
@@ -886,6 +891,18 @@ function MailboxList() {
|
|
|
886
891
|
const listTitle = mailboxName ?? "Inbox";
|
|
887
892
|
const preset = useMemo(() => inboxFilterConfig(), []);
|
|
888
893
|
|
|
894
|
+
// Empty Trash, on the same test the Spam rescue strip uses: the open mailbox
|
|
895
|
+
// is the one this account appoints to the role. Whether it may be emptied is
|
|
896
|
+
// never decided here — the press goes to the server and the 409 answers it.
|
|
897
|
+
const { trashMailboxId } = useTrashMailbox(mailboxAccountId);
|
|
898
|
+
const trashMessageCount = useCurrentMailboxMessageCount({ accounts });
|
|
899
|
+
const { trashByAccount } = useTrashByAccount();
|
|
900
|
+
const emptyTrash = useEmptyTrash({
|
|
901
|
+
accountId: mailboxAccountId,
|
|
902
|
+
mailboxId,
|
|
903
|
+
});
|
|
904
|
+
const isTrashFolder = trashMailboxId != null && trashMailboxId === mailboxId;
|
|
905
|
+
|
|
889
906
|
// The account owning this folder — undefined for the instant before
|
|
890
907
|
// `useMailboxAccount` resolves it, which simply means there is nothing to
|
|
891
908
|
// refresh yet.
|
|
@@ -1025,7 +1042,7 @@ function MailboxList() {
|
|
|
1025
1042
|
messageList
|
|
1026
1043
|
);
|
|
1027
1044
|
|
|
1028
|
-
const
|
|
1045
|
+
const spamBody =
|
|
1029
1046
|
isSpamFolder && rescueCandidates.length > 0 && mailboxAccountId ? (
|
|
1030
1047
|
<SpamRescue
|
|
1031
1048
|
accountId={mailboxAccountId}
|
|
@@ -1039,6 +1056,27 @@ function MailboxList() {
|
|
|
1039
1056
|
listBody
|
|
1040
1057
|
);
|
|
1041
1058
|
|
|
1059
|
+
const body = isTrashFolder ? (
|
|
1060
|
+
<EmptyTrashBar
|
|
1061
|
+
messageCount={trashMessageCount}
|
|
1062
|
+
isEmptying={emptyTrash.isEmptying}
|
|
1063
|
+
deletedCount={emptyTrash.deletedCount}
|
|
1064
|
+
refusalReason={emptyTrash.refusal?.reason}
|
|
1065
|
+
trashFolderLabel={mailboxName ?? undefined}
|
|
1066
|
+
staleFolderLabel={
|
|
1067
|
+
mailboxAccountId
|
|
1068
|
+
? trashByAccount.get(mailboxAccountId)?.staleFolderPath
|
|
1069
|
+
: undefined
|
|
1070
|
+
}
|
|
1071
|
+
onEmpty={emptyTrash.emptyTrash}
|
|
1072
|
+
onRepair={emptyTrash.repair}
|
|
1073
|
+
>
|
|
1074
|
+
{spamBody}
|
|
1075
|
+
</EmptyTrashBar>
|
|
1076
|
+
) : (
|
|
1077
|
+
spamBody
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1042
1080
|
return (
|
|
1043
1081
|
<MailViewChrome
|
|
1044
1082
|
title={listTitle}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { EmptyTrashBar } from "@/components/mail/EmptyTrashBar";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Emptying Trash from the mailbox pane, and the three refusals the server can
|
|
6
|
+
* answer with (#847).
|
|
7
|
+
*
|
|
8
|
+
* The strip sits where the Spam rescue banner sits — above the list, in flow —
|
|
9
|
+
* and the button always acts. Nothing here is pre-refused from what the client
|
|
10
|
+
* believes the Trash appointment is: the press reaches the server and the 409
|
|
11
|
+
* is the authority, so a folder nobody has tried to empty carries no warning.
|
|
12
|
+
* A refusal is a standing fact about the account rather than an event, so it
|
|
13
|
+
* lands under the button and stays there until the user repairs it.
|
|
14
|
+
*/
|
|
15
|
+
const meta: Meta<typeof EmptyTrashBar> = {
|
|
16
|
+
title: "Flows/Mail/Empty Trash",
|
|
17
|
+
component: EmptyTrashBar,
|
|
18
|
+
parameters: { layout: "fullscreen" },
|
|
19
|
+
};
|
|
20
|
+
export default meta;
|
|
21
|
+
|
|
22
|
+
type Story = StoryObj<typeof EmptyTrashBar>;
|
|
23
|
+
|
|
24
|
+
const noop = () => {};
|
|
25
|
+
|
|
26
|
+
const TrashList = () => (
|
|
27
|
+
<ul className="divide-y divide-line">
|
|
28
|
+
{[
|
|
29
|
+
["Re: invoice 4421", "Bookkeeping"],
|
|
30
|
+
["Your parcel could not be delivered", "PostNL"],
|
|
31
|
+
["Weekly digest", "Hacker Newsletter"],
|
|
32
|
+
].map(([subject, from]) => (
|
|
33
|
+
<li key={subject} className="px-row-inset py-3">
|
|
34
|
+
<p className="text-sm text-fg">{subject}</p>
|
|
35
|
+
<p className="text-2xs text-fg-subtle">{from}</p>
|
|
36
|
+
</li>
|
|
37
|
+
))}
|
|
38
|
+
</ul>
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const base = {
|
|
42
|
+
messageCount: 128,
|
|
43
|
+
isEmptying: false,
|
|
44
|
+
trashFolderLabel: "Deleted Items",
|
|
45
|
+
onEmpty: noop,
|
|
46
|
+
onRepair: noop,
|
|
47
|
+
children: <TrashList />,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const Frame = ({ children }: { children: React.ReactNode }) => (
|
|
51
|
+
<div className="h-[520px] w-full max-w-2xl overflow-hidden border border-line bg-canvas">
|
|
52
|
+
{children}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/** The folder holds mail, so the verb is offered — quietly, and to the right. */
|
|
57
|
+
export const Idle: Story = {
|
|
58
|
+
render: () => (
|
|
59
|
+
<Frame>
|
|
60
|
+
<EmptyTrashBar {...base} />
|
|
61
|
+
</Frame>
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The press is in flight: the button says so and refuses a second one. */
|
|
66
|
+
export const Emptying: Story = {
|
|
67
|
+
render: () => (
|
|
68
|
+
<Frame>
|
|
69
|
+
<EmptyTrashBar {...base} isEmptying />
|
|
70
|
+
</Frame>
|
|
71
|
+
),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What the run reported, straight from the service. A second press re-marks
|
|
76
|
+
* the same rows and honestly reports the same N — never 0 over an expunge.
|
|
77
|
+
*/
|
|
78
|
+
export const Emptied: Story = {
|
|
79
|
+
render: () => (
|
|
80
|
+
<Frame>
|
|
81
|
+
<EmptyTrashBar {...base} deletedCount={128} />
|
|
82
|
+
</Frame>
|
|
83
|
+
),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* reader matched the folder by name and nobody confirmed it. The words are
|
|
88
|
+
* `deleteConfirmationCopy`'s, so the dialog and this strip say the same thing.
|
|
89
|
+
*/
|
|
90
|
+
export const RefusedUnconfirmed: Story = {
|
|
91
|
+
name: "Refused (unconfirmed)",
|
|
92
|
+
render: () => (
|
|
93
|
+
<Frame>
|
|
94
|
+
<EmptyTrashBar {...base} refusalReason="unconfirmed" />
|
|
95
|
+
</Frame>
|
|
96
|
+
),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The folder the user chose is gone from the mail server, and is named. */
|
|
100
|
+
export const RefusedStale: Story = {
|
|
101
|
+
name: "Refused (stale)",
|
|
102
|
+
render: () => (
|
|
103
|
+
<Frame>
|
|
104
|
+
<EmptyTrashBar
|
|
105
|
+
{...base}
|
|
106
|
+
refusalReason="stale"
|
|
107
|
+
staleFolderLabel="Archive/Bin"
|
|
108
|
+
/>
|
|
109
|
+
</Frame>
|
|
110
|
+
),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** Nothing on this account is set as Trash, so there is nothing to empty. */
|
|
114
|
+
export const RefusedNone: Story = {
|
|
115
|
+
name: "Refused (none)",
|
|
116
|
+
render: () => (
|
|
117
|
+
<Frame>
|
|
118
|
+
<EmptyTrashBar {...base} refusalReason="none" />
|
|
119
|
+
</Frame>
|
|
120
|
+
),
|
|
121
|
+
};
|
|
@@ -81,6 +81,17 @@ export const useJunkMailbox = (
|
|
|
81
81
|
return { junkMailboxId: mailboxId, isLoading };
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Returns the account's appointed Trash mailbox id, which is how the mailbox
|
|
86
|
+
* pane tells it is looking at Trash and may offer to empty it (#847).
|
|
87
|
+
*/
|
|
88
|
+
export const useTrashMailbox = (
|
|
89
|
+
accountId: string | undefined,
|
|
90
|
+
): { trashMailboxId: string | undefined; isLoading: boolean } => {
|
|
91
|
+
const { mailboxId, isLoading } = useFolderRoleMailbox(accountId, "Trash");
|
|
92
|
+
return { trashMailboxId: mailboxId, isLoading };
|
|
93
|
+
};
|
|
94
|
+
|
|
84
95
|
/**
|
|
85
96
|
* Each account's appointed Trash mailbox, keyed by account. The delete
|
|
86
97
|
* confirmation needs it to tell a move-to-Trash apart from a delete inside
|
|
@@ -97,3 +97,36 @@ export const useCurrentMailboxUnseenCount = ({
|
|
|
97
97
|
|
|
98
98
|
return null;
|
|
99
99
|
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The current mailbox's total message count, from the same warm-cache mailbox
|
|
103
|
+
* query. `0` while it is unresolvable, so a surface gated on "holds at least
|
|
104
|
+
* one message" — Empty Trash (#847) — stays down rather than offering a verb
|
|
105
|
+
* over a folder nothing is known about yet.
|
|
106
|
+
*/
|
|
107
|
+
export const useCurrentMailboxMessageCount = ({
|
|
108
|
+
accounts,
|
|
109
|
+
}: UseCurrentMailboxNameOptions): number => {
|
|
110
|
+
const params = useParams({ strict: false });
|
|
111
|
+
const mailboxId = (params as { mailboxId?: string }).mailboxId;
|
|
112
|
+
|
|
113
|
+
const queries = useQueries({
|
|
114
|
+
queries: accounts.map((account) => ({
|
|
115
|
+
...mailboxOperationsListMailboxesOptions({
|
|
116
|
+
path: { accountId: account.accountId },
|
|
117
|
+
}),
|
|
118
|
+
staleTime: Infinity,
|
|
119
|
+
})),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (!mailboxId) return 0;
|
|
123
|
+
|
|
124
|
+
for (const query of queries) {
|
|
125
|
+
const items = query.data?.items;
|
|
126
|
+
if (!items) continue;
|
|
127
|
+
const match = items.find((mailbox) => mailbox.mailboxId === mailboxId);
|
|
128
|
+
if (match) return match.messageCount;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return 0;
|
|
132
|
+
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a refusal and a report are *about* (#847). The mailbox pane never
|
|
3
|
+
* remounts on a route change, so both are scoped: account A's standing refusal
|
|
4
|
+
* must not render over account B's Trash, and a count from a folder that was
|
|
5
|
+
* emptied must not outlive the folder it counted. The empty is also issued
|
|
6
|
+
* against the Trash `/config` names at that moment, so the replay after a
|
|
7
|
+
* repair invalidates the folder it actually emptied.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { afterEach, describe, it } from "node:test";
|
|
12
|
+
import { configOperationsGetConfigQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
13
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
14
|
+
import { act, createElement } from "react";
|
|
15
|
+
import { RoleAppointmentPromptProvider } from "@/components/mail/RoleAppointmentPromptProvider";
|
|
16
|
+
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
17
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
18
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
19
|
+
import { type EmptyTrashState, useEmptyTrash } from "./useEmptyTrash";
|
|
20
|
+
|
|
21
|
+
const ACCOUNT_A = "acc-a";
|
|
22
|
+
const ACCOUNT_B = "acc-b";
|
|
23
|
+
const TRASH_A = "mbx-trash-a";
|
|
24
|
+
const TRASH_B = "mbx-trash-b";
|
|
25
|
+
|
|
26
|
+
let harness: DomHarness | undefined;
|
|
27
|
+
let http: HttpMock | undefined;
|
|
28
|
+
let state: EmptyTrashState | undefined;
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
harness?.close();
|
|
32
|
+
harness = undefined;
|
|
33
|
+
http?.restore();
|
|
34
|
+
http = undefined;
|
|
35
|
+
state = undefined;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const CONFIG = {
|
|
39
|
+
accounts: [
|
|
40
|
+
{
|
|
41
|
+
accountId: ACCOUNT_A,
|
|
42
|
+
email: "a@example.com",
|
|
43
|
+
folderAppointments: [
|
|
44
|
+
{ role: "Trash", mailboxId: TRASH_A, source: "Proposed" },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
accountId: ACCOUNT_B,
|
|
49
|
+
email: "b@example.com",
|
|
50
|
+
folderAppointments: [
|
|
51
|
+
{ role: "Trash", mailboxId: TRASH_B, source: "Appointed" },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Fresh every call — a `Response` body can only be read once. */
|
|
58
|
+
const refusal = (): Response =>
|
|
59
|
+
new Response(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
status: 409,
|
|
62
|
+
message: "Trash is not confirmed",
|
|
63
|
+
code: "folder_role_unresolved",
|
|
64
|
+
details: { reason: "unconfirmed", role: "Trash", accountId: ACCOUNT_A },
|
|
65
|
+
}),
|
|
66
|
+
{ status: 409, headers: { "content-type": "application/json" } },
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/** Account A refuses the empty; account B allows it. */
|
|
70
|
+
const respond = (path: string): unknown => {
|
|
71
|
+
if (path.endsWith("/config")) return CONFIG;
|
|
72
|
+
if (path.endsWith(`/accounts/${ACCOUNT_A}/trash/empty`)) return refusal();
|
|
73
|
+
if (path.endsWith(`/accounts/${ACCOUNT_B}/trash/empty`))
|
|
74
|
+
return { deletedCount: 7 };
|
|
75
|
+
return {};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const Probe = ({
|
|
79
|
+
accountId,
|
|
80
|
+
mailboxId,
|
|
81
|
+
}: {
|
|
82
|
+
accountId: string;
|
|
83
|
+
mailboxId: string;
|
|
84
|
+
}) => {
|
|
85
|
+
state = useEmptyTrash({ accountId, mailboxId });
|
|
86
|
+
return null;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const tree = (accountId: string, mailboxId: string) =>
|
|
90
|
+
createElement(
|
|
91
|
+
QueryClientProvider,
|
|
92
|
+
{ client: harness?.queryClient as never },
|
|
93
|
+
createElement(
|
|
94
|
+
ErrorBannerProvider,
|
|
95
|
+
null,
|
|
96
|
+
createElement(
|
|
97
|
+
RoleAppointmentPromptProvider,
|
|
98
|
+
null,
|
|
99
|
+
createElement(Probe, { accountId, mailboxId }),
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const settle = async (done: () => boolean = () => false): Promise<void> => {
|
|
105
|
+
if (!harness) throw new Error("nothing mounted");
|
|
106
|
+
for (let round = 0; round < 40; round += 1) {
|
|
107
|
+
await harness.flush();
|
|
108
|
+
await harness.wait(0);
|
|
109
|
+
if (done()) return;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const mount = async (accountId: string, mailboxId: string): Promise<void> => {
|
|
114
|
+
http = mockFetch((call) => respond(call.path));
|
|
115
|
+
harness = createDomHarness();
|
|
116
|
+
await harness.renderAsync(tree(accountId, mailboxId));
|
|
117
|
+
await settle(() => state !== undefined);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** The same mounted pane, now looking at another folder — never a remount. */
|
|
121
|
+
const openInstead = async (
|
|
122
|
+
accountId: string,
|
|
123
|
+
mailboxId: string,
|
|
124
|
+
): Promise<void> => {
|
|
125
|
+
if (!harness) throw new Error("nothing mounted");
|
|
126
|
+
await harness.renderAsync(tree(accountId, mailboxId));
|
|
127
|
+
await settle();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const press = async (done: () => boolean): Promise<void> => {
|
|
131
|
+
await act(async () => {
|
|
132
|
+
state?.emptyTrash();
|
|
133
|
+
});
|
|
134
|
+
await settle(done);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
describe("useEmptyTrash scope", () => {
|
|
138
|
+
it("drops a refusal raised on one mailbox when another is opened", async () => {
|
|
139
|
+
await mount(ACCOUNT_A, TRASH_A);
|
|
140
|
+
await press(() => state?.refusal !== undefined);
|
|
141
|
+
assert.equal(state?.refusal?.reason, "unconfirmed");
|
|
142
|
+
assert.equal(state?.refusal?.accountId, ACCOUNT_A);
|
|
143
|
+
|
|
144
|
+
await openInstead(ACCOUNT_B, TRASH_B);
|
|
145
|
+
assert.equal(state?.refusal, undefined);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("drops a report of what was emptied when another mailbox is opened", async () => {
|
|
149
|
+
await mount(ACCOUNT_B, TRASH_B);
|
|
150
|
+
await press(() => state?.deletedCount !== undefined);
|
|
151
|
+
assert.equal(state?.deletedCount, 7);
|
|
152
|
+
|
|
153
|
+
await openInstead(ACCOUNT_A, TRASH_A);
|
|
154
|
+
assert.equal(state?.deletedCount, undefined);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("issues the empty against the account the pane is on", async () => {
|
|
158
|
+
await mount(ACCOUNT_A, TRASH_A);
|
|
159
|
+
await press(() => state?.refusal !== undefined);
|
|
160
|
+
await openInstead(ACCOUNT_B, TRASH_B);
|
|
161
|
+
await press(() => state?.deletedCount !== undefined);
|
|
162
|
+
|
|
163
|
+
const empties = (http?.calls ?? []).filter((call) =>
|
|
164
|
+
call.path.endsWith("/trash/empty"),
|
|
165
|
+
);
|
|
166
|
+
assert.equal(empties.length, 2);
|
|
167
|
+
assert.ok(empties[0].path.endsWith(`/accounts/${ACCOUNT_A}/trash/empty`));
|
|
168
|
+
assert.ok(empties[1].path.endsWith(`/accounts/${ACCOUNT_B}/trash/empty`));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("drops the refusal once the account's Trash resolution changes", async () => {
|
|
172
|
+
await mount(ACCOUNT_A, TRASH_A);
|
|
173
|
+
await press(() => state?.refusal !== undefined);
|
|
174
|
+
assert.ok(state?.refusal);
|
|
175
|
+
|
|
176
|
+
// A repair made anywhere else — the settings pane, another tab — lands
|
|
177
|
+
// here as a new `/config`, and the refusal it answered is spent.
|
|
178
|
+
await act(async () => {
|
|
179
|
+
harness?.queryClient.setQueryData(configOperationsGetConfigQueryKey(), {
|
|
180
|
+
accounts: [
|
|
181
|
+
{
|
|
182
|
+
...CONFIG.accounts[0],
|
|
183
|
+
folderAppointments: [
|
|
184
|
+
{ role: "Trash", mailboxId: TRASH_A, source: "Appointed" },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
CONFIG.accounts[1],
|
|
188
|
+
],
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
await settle(() => state?.refusal === undefined);
|
|
192
|
+
assert.equal(state?.refusal, undefined);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emptying an account's Trash, and the refusal the server answers it with.
|
|
3
|
+
*
|
|
4
|
+
* The press always reaches the server (#847): the client's `/config` is a read
|
|
5
|
+
* that can be stale, so a folder-role refusal is the server's to make, and the
|
|
6
|
+
* 409 is the only authority the surface listens to. On that refusal the strip
|
|
7
|
+
* states it in place and the repair opens the appointment prompt, which replays
|
|
8
|
+
* the empty once a folder is confirmed (#887 D16 item 1).
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
configOperationsGetConfigQueryKey,
|
|
12
|
+
mailboxOperationsListMailboxesQueryKey,
|
|
13
|
+
trashOperationsEmptyTrashMutation,
|
|
14
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
15
|
+
import type { ConfigOperationsGetConfigResponse } from "@remit/api-http-client/types.gen.ts";
|
|
16
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
17
|
+
import { useCallback, useRef, useState } from "react";
|
|
18
|
+
import { useRoleAppointmentPrompt } from "@/components/mail/RoleAppointmentPromptProvider";
|
|
19
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
20
|
+
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
21
|
+
import {
|
|
22
|
+
type FolderRoleRefusal,
|
|
23
|
+
isFolderRoleRefusal,
|
|
24
|
+
} from "@/components/ui/folder-role-refusal";
|
|
25
|
+
import {
|
|
26
|
+
invalidateThreadListQueries,
|
|
27
|
+
threadListCacheKeys,
|
|
28
|
+
} from "@/lib/thread-list-cache";
|
|
29
|
+
import { useTrashByAccount } from "./useArchiveMailbox";
|
|
30
|
+
|
|
31
|
+
interface UseEmptyTrashOptions {
|
|
32
|
+
accountId: string | undefined;
|
|
33
|
+
/** The open mailbox the strip is mounted over. */
|
|
34
|
+
mailboxId: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EmptyTrashState {
|
|
38
|
+
emptyTrash: () => void;
|
|
39
|
+
isEmptying: boolean;
|
|
40
|
+
/** What the last finished run reported, straight from the service. */
|
|
41
|
+
deletedCount: number | undefined;
|
|
42
|
+
refusal: FolderRoleRefusal | undefined;
|
|
43
|
+
repair: () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What a report or a refusal is about. Both are facts about one account's
|
|
48
|
+
* Trash as it resolved at the time, so neither may survive the mailbox or the
|
|
49
|
+
* account changing under a pane that never remounts — nor a resolution
|
|
50
|
+
* repaired elsewhere, which is the "or the resolution changes" the refusal
|
|
51
|
+
* persists until.
|
|
52
|
+
*/
|
|
53
|
+
const scopeOf = (
|
|
54
|
+
accountId: string | undefined,
|
|
55
|
+
mailboxId: string,
|
|
56
|
+
trashMailboxId: string | undefined,
|
|
57
|
+
source: string | undefined,
|
|
58
|
+
): string =>
|
|
59
|
+
`${accountId ?? ""}|${mailboxId}|${trashMailboxId ?? ""}|${source ?? ""}`;
|
|
60
|
+
|
|
61
|
+
interface EmptyTrashRun {
|
|
62
|
+
scope: string;
|
|
63
|
+
deletedCount?: number;
|
|
64
|
+
refusal?: FolderRoleRefusal;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface EmptyTrashContext {
|
|
68
|
+
scope: string;
|
|
69
|
+
/** The folder the empty was issued against, whose listing it invalidates. */
|
|
70
|
+
listMailboxId: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const useEmptyTrash = ({
|
|
74
|
+
accountId,
|
|
75
|
+
mailboxId,
|
|
76
|
+
}: UseEmptyTrashOptions): EmptyTrashState => {
|
|
77
|
+
const queryClient = useQueryClient();
|
|
78
|
+
const { pushError } = useErrorBanners();
|
|
79
|
+
const { requestAppointment } = useRoleAppointmentPrompt();
|
|
80
|
+
const { trashByAccount } = useTrashByAccount();
|
|
81
|
+
|
|
82
|
+
const trash = accountId ? trashByAccount.get(accountId) : undefined;
|
|
83
|
+
const scope = scopeOf(accountId, mailboxId, trash?.mailboxId, trash?.source);
|
|
84
|
+
|
|
85
|
+
const [run, setRun] = useState<EmptyTrashRun>({ scope });
|
|
86
|
+
if (run.scope !== scope) setRun({ scope });
|
|
87
|
+
const current = run.scope === scope ? run : undefined;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The Trash as `/config` reads *now*, straight from the cache. After a
|
|
91
|
+
* repair the appointment names a different folder, and the replay must
|
|
92
|
+
* invalidate that folder's listing rather than the one this pane opened —
|
|
93
|
+
* a render has not necessarily flushed by the time the replay is called.
|
|
94
|
+
*/
|
|
95
|
+
const listMailboxIdNow = useCallback((): string => {
|
|
96
|
+
const config = queryClient.getQueryData<ConfigOperationsGetConfigResponse>(
|
|
97
|
+
configOperationsGetConfigQueryKey(),
|
|
98
|
+
);
|
|
99
|
+
const account = config?.accounts.find((one) => one.accountId === accountId);
|
|
100
|
+
const appointed = account?.folderAppointments.find(
|
|
101
|
+
(one) => one.role === "Trash",
|
|
102
|
+
)?.mailboxId;
|
|
103
|
+
return appointed ?? mailboxId;
|
|
104
|
+
}, [queryClient, accountId, mailboxId]);
|
|
105
|
+
|
|
106
|
+
// What the run in flight is about, for the same reason: a press on one
|
|
107
|
+
// folder must not read as a press on the next one the pane opens.
|
|
108
|
+
const inFlight = useRef<string>(undefined);
|
|
109
|
+
|
|
110
|
+
const { mutateAsync, isPending } = useMutation({
|
|
111
|
+
...trashOperationsEmptyTrashMutation(),
|
|
112
|
+
onMutate: (): EmptyTrashContext => {
|
|
113
|
+
inFlight.current = scope;
|
|
114
|
+
return { scope, listMailboxId: listMailboxIdNow() };
|
|
115
|
+
},
|
|
116
|
+
onSuccess: (data, _variables, context) => {
|
|
117
|
+
if (context.scope !== scope) return;
|
|
118
|
+
// The service's count, never a local tally. A second press re-marks the
|
|
119
|
+
// same rows and reports N again — that is what the folder still holds,
|
|
120
|
+
// and reporting 0 over an expunge is the failure #887 is about.
|
|
121
|
+
setRun({ scope, deletedCount: data.deletedCount });
|
|
122
|
+
},
|
|
123
|
+
onError: (error, _variables, context) => {
|
|
124
|
+
const refused = isFolderRoleRefusal(error);
|
|
125
|
+
if (!refused) {
|
|
126
|
+
pushError({
|
|
127
|
+
title: "Couldn't empty Trash",
|
|
128
|
+
detail: formatErrorDetail(error),
|
|
129
|
+
error,
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// A refusal that outlived what it was about states somebody else's
|
|
134
|
+
// problem over this folder.
|
|
135
|
+
if (context?.scope !== scope) return;
|
|
136
|
+
setRun({ scope, refusal: refused });
|
|
137
|
+
},
|
|
138
|
+
onSettled: (_data, _error, _variables, context) => {
|
|
139
|
+
if (inFlight.current === context?.scope) inFlight.current = undefined;
|
|
140
|
+
invalidateThreadListQueries(
|
|
141
|
+
queryClient,
|
|
142
|
+
threadListCacheKeys([context?.listMailboxId ?? mailboxId]),
|
|
143
|
+
);
|
|
144
|
+
if (!accountId) return;
|
|
145
|
+
queryClient.invalidateQueries({
|
|
146
|
+
queryKey: mailboxOperationsListMailboxesQueryKey({
|
|
147
|
+
path: { accountId },
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// `onError` above has already stated the failure; the rejection reaching the
|
|
154
|
+
// caller would only report it a second time.
|
|
155
|
+
const issue = useCallback((): Promise<void> => {
|
|
156
|
+
if (!accountId) return Promise.resolve();
|
|
157
|
+
setRun({ scope });
|
|
158
|
+
return mutateAsync({ path: { accountId } }).then(
|
|
159
|
+
() => {},
|
|
160
|
+
() => {},
|
|
161
|
+
);
|
|
162
|
+
}, [accountId, scope, mutateAsync]);
|
|
163
|
+
|
|
164
|
+
const emptyTrash = useCallback(() => {
|
|
165
|
+
void issue();
|
|
166
|
+
}, [issue]);
|
|
167
|
+
|
|
168
|
+
const repair = useCallback(() => {
|
|
169
|
+
const refusal = current?.refusal;
|
|
170
|
+
if (!refusal) return;
|
|
171
|
+
requestAppointment({
|
|
172
|
+
accountId: refusal.accountId,
|
|
173
|
+
role: refusal.role,
|
|
174
|
+
reason: refusal.reason,
|
|
175
|
+
action: { kind: "emptyTrash" },
|
|
176
|
+
onAppointed: issue,
|
|
177
|
+
});
|
|
178
|
+
}, [current, requestAppointment, issue]);
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
emptyTrash,
|
|
182
|
+
isEmptying: isPending && inFlight.current === scope,
|
|
183
|
+
deletedCount: current?.deletedCount,
|
|
184
|
+
refusal: current?.refusal,
|
|
185
|
+
repair,
|
|
186
|
+
};
|
|
187
|
+
};
|