@remit/web-client 0.0.190 → 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/DeleteConfirmDialog.render.test.ts +208 -26
- package/src/components/mail/DeleteConfirmDialog.tsx +53 -17
- 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/MessageList.tsx +71 -62
- package/src/components/mail/RoleAppointmentPromptProvider.render.test.ts +292 -0
- package/src/components/mail/RoleAppointmentPromptProvider.tsx +246 -0
- package/src/components/mail/ThreadListInteraction.test.ts +30 -21
- package/src/components/mail/ThreadListInteraction.tsx +18 -8
- package/src/components/mail/empty-trash-bar.stories.tsx +121 -0
- package/src/components/ui/folder-role-refusal.test.ts +94 -0
- package/src/components/ui/folder-role-refusal.ts +81 -0
- package/src/hooks/useArchiveMailbox.ts +21 -5
- package/src/hooks/useCurrentMailboxName.ts +33 -0
- package/src/hooks/useDeleteMessages.ts +41 -5
- package/src/hooks/useDeleteOutcome.ts +37 -7
- package/src/hooks/useEmptyTrash.render.test.ts +194 -0
- package/src/hooks/useEmptyTrash.ts +187 -0
- package/src/lib/format.test.ts +133 -11
- package/src/lib/format.ts +95 -17
- package/src/lib/move-options.ts +5 -0
- package/src/routes/__root.tsx +14 -11
- package/src/routes/settings/folders.tsx +12 -3
- package/src/test-support/dom.ts +6 -1
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
isFolderRoleRefusal,
|
|
5
|
+
isMailboxNotSettledRefusal,
|
|
6
|
+
} from "./folder-role-refusal.js";
|
|
7
|
+
|
|
8
|
+
const refusal = {
|
|
9
|
+
code: "folder_role_unresolved",
|
|
10
|
+
message: "No folder is appointed as Trash",
|
|
11
|
+
details: { role: "Trash", reason: "none", accountId: "acct-1" },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("isFolderRoleRefusal", () => {
|
|
15
|
+
it("carries the account and the reason the prompt needs", () => {
|
|
16
|
+
assert.deepEqual(isFolderRoleRefusal(refusal), {
|
|
17
|
+
reason: "none",
|
|
18
|
+
role: "Trash",
|
|
19
|
+
accountId: "acct-1",
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("reads every reason the API declares", () => {
|
|
24
|
+
for (const reason of ["none", "stale", "unconfirmed"]) {
|
|
25
|
+
assert.equal(
|
|
26
|
+
isFolderRoleRefusal({
|
|
27
|
+
...refusal,
|
|
28
|
+
details: { ...refusal.details, reason },
|
|
29
|
+
})?.reason,
|
|
30
|
+
reason,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not open the prompt for a 409 without the code", () => {
|
|
36
|
+
assert.equal(
|
|
37
|
+
isFolderRoleRefusal({
|
|
38
|
+
message: "No folder is appointed as Trash",
|
|
39
|
+
details: refusal.details,
|
|
40
|
+
}),
|
|
41
|
+
undefined,
|
|
42
|
+
);
|
|
43
|
+
assert.equal(
|
|
44
|
+
isFolderRoleRefusal({ ...refusal, code: "mailbox_not_settled" }),
|
|
45
|
+
undefined,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("never guesses at a message string", () => {
|
|
50
|
+
assert.equal(
|
|
51
|
+
isFolderRoleRefusal(new Error("folder_role_unresolved: Trash")),
|
|
52
|
+
undefined,
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("refuses a body missing anything the prompt has to have", () => {
|
|
57
|
+
assert.equal(isFolderRoleRefusal({ ...refusal, details: {} }), undefined);
|
|
58
|
+
assert.equal(
|
|
59
|
+
isFolderRoleRefusal({
|
|
60
|
+
...refusal,
|
|
61
|
+
details: { role: "Trash", reason: "sideways", accountId: "acct-1" },
|
|
62
|
+
}),
|
|
63
|
+
undefined,
|
|
64
|
+
);
|
|
65
|
+
assert.equal(
|
|
66
|
+
isFolderRoleRefusal({
|
|
67
|
+
...refusal,
|
|
68
|
+
details: { role: "Trash", reason: "none" },
|
|
69
|
+
}),
|
|
70
|
+
undefined,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("survives anything a network layer might throw", () => {
|
|
75
|
+
for (const value of [undefined, null, "boom", 409, []]) {
|
|
76
|
+
assert.equal(isFolderRoleRefusal(value), undefined);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("isMailboxNotSettledRefusal", () => {
|
|
82
|
+
it("matches only the appointment write's own refusal", () => {
|
|
83
|
+
assert.equal(
|
|
84
|
+
isMailboxNotSettledRefusal({
|
|
85
|
+
code: "mailbox_not_settled",
|
|
86
|
+
message: "Mailbox is still being created",
|
|
87
|
+
details: { mailboxId: "mbx-1", syncStatus: "pending" },
|
|
88
|
+
}),
|
|
89
|
+
true,
|
|
90
|
+
);
|
|
91
|
+
assert.equal(isMailboxNotSettledRefusal(refusal), false);
|
|
92
|
+
assert.equal(isMailboxNotSettledRefusal(new Error("pending")), false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coded 409 a destructive action is refused with when the folder role it
|
|
3
|
+
* needs is unsettled (#887). Read the `code`, never the message: the copy is
|
|
4
|
+
* free to change and a message-string match would silently start opening the
|
|
5
|
+
* appointment prompt over an unrelated conflict. A 409 without one of these
|
|
6
|
+
* codes is somebody else's error and keeps today's banner.
|
|
7
|
+
*/
|
|
8
|
+
import type {
|
|
9
|
+
ApiError,
|
|
10
|
+
RemitImapCanonicalMailboxRole,
|
|
11
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
12
|
+
import { CanonicalMailboxRole } from "@remit/domain-enums";
|
|
13
|
+
|
|
14
|
+
/** Why the role is unresolved, as the API's `details.reason` spells it. */
|
|
15
|
+
export type FolderRoleRefusalReason = "none" | "stale" | "unconfirmed";
|
|
16
|
+
|
|
17
|
+
/** `FolderRoleConflict`'s `details`, narrowed to the values the prompt needs. */
|
|
18
|
+
export interface FolderRoleRefusal {
|
|
19
|
+
reason: FolderRoleRefusalReason;
|
|
20
|
+
role: RemitImapCanonicalMailboxRole;
|
|
21
|
+
accountId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const REASONS: ReadonlySet<string> = new Set<FolderRoleRefusalReason>([
|
|
25
|
+
"none",
|
|
26
|
+
"stale",
|
|
27
|
+
"unconfirmed",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const ROLES: ReadonlySet<string> = new Set(Object.values(CanonicalMailboxRole));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The wire body as `handleError` emits it — flat, so `code` and `details` sit
|
|
34
|
+
* at the top level. Everything is re-checked at runtime: this is a network
|
|
35
|
+
* boundary, and the type only says what the contract promises.
|
|
36
|
+
*/
|
|
37
|
+
const bodyOf = (error: unknown): Partial<ApiError> | undefined =>
|
|
38
|
+
typeof error === "object" && error !== null
|
|
39
|
+
? (error as Partial<ApiError>)
|
|
40
|
+
: undefined;
|
|
41
|
+
|
|
42
|
+
const stringAt = (
|
|
43
|
+
details: ApiError["details"],
|
|
44
|
+
key: string,
|
|
45
|
+
): string | undefined => {
|
|
46
|
+
const value = details?.[key];
|
|
47
|
+
return typeof value === "string" ? value : undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The refusal, or `undefined` for every other failure. Every fact the prompt
|
|
52
|
+
* needs travels with it: the account to appoint on (the delete endpoint's body
|
|
53
|
+
* carries none), the role, and the reason, which decides the framing.
|
|
54
|
+
*/
|
|
55
|
+
export const isFolderRoleRefusal = (
|
|
56
|
+
error: unknown,
|
|
57
|
+
): FolderRoleRefusal | undefined => {
|
|
58
|
+
const body = bodyOf(error);
|
|
59
|
+
if (body?.code !== "folder_role_unresolved") return undefined;
|
|
60
|
+
const { details } = body;
|
|
61
|
+
if (typeof details !== "object" || details === null) return undefined;
|
|
62
|
+
const reason = stringAt(details, "reason");
|
|
63
|
+
const role = stringAt(details, "role");
|
|
64
|
+
const accountId = stringAt(details, "accountId");
|
|
65
|
+
if (!reason || !REASONS.has(reason)) return undefined;
|
|
66
|
+
if (!role || !ROLES.has(role) || !accountId) return undefined;
|
|
67
|
+
return {
|
|
68
|
+
reason: reason as FolderRoleRefusalReason,
|
|
69
|
+
role: role as RemitImapCanonicalMailboxRole,
|
|
70
|
+
accountId,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The appointment write's own refusal: the mailbox is still being created or
|
|
76
|
+
* deleted on the mail server, so it cannot hold a role yet. A different
|
|
77
|
+
* sentence with a different remedy from a network failure — waiting fixes this
|
|
78
|
+
* one, retrying does not.
|
|
79
|
+
*/
|
|
80
|
+
export const isMailboxNotSettledRefusal = (error: unknown): boolean =>
|
|
81
|
+
bodyOf(error)?.code === "mailbox_not_settled";
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
} from "@remit/api-http-client/types.gen.ts";
|
|
6
6
|
import { useQuery } from "@tanstack/react-query";
|
|
7
7
|
import { useMemo } from "react";
|
|
8
|
+
import type { TrashResolution } from "@/lib/format";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* RFC 032 exclusive-folder-appointment (#976): every "which mailbox plays
|
|
@@ -80,6 +81,17 @@ export const useJunkMailbox = (
|
|
|
80
81
|
return { junkMailboxId: mailboxId, isLoading };
|
|
81
82
|
};
|
|
82
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
|
+
|
|
83
95
|
/**
|
|
84
96
|
* Each account's appointed Trash mailbox, keyed by account. The delete
|
|
85
97
|
* confirmation needs it to tell a move-to-Trash apart from a delete inside
|
|
@@ -99,7 +111,7 @@ export const useJunkMailbox = (
|
|
|
99
111
|
* move over an expunge that would replay on reconnect.
|
|
100
112
|
*/
|
|
101
113
|
export const useTrashByAccount = (): {
|
|
102
|
-
trashByAccount: ReadonlyMap<string,
|
|
114
|
+
trashByAccount: ReadonlyMap<string, TrashResolution>;
|
|
103
115
|
hasAppointments: boolean;
|
|
104
116
|
isError: boolean;
|
|
105
117
|
} => {
|
|
@@ -109,12 +121,16 @@ export const useTrashByAccount = (): {
|
|
|
109
121
|
});
|
|
110
122
|
|
|
111
123
|
const trashByAccount = useMemo(() => {
|
|
112
|
-
const byAccount = new Map<string,
|
|
124
|
+
const byAccount = new Map<string, TrashResolution>();
|
|
113
125
|
for (const account of config?.accounts ?? []) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
account.folderAppointments.find((fa) => fa.role === "Trash")?.mailboxId,
|
|
126
|
+
const trash = account.folderAppointments.find(
|
|
127
|
+
(appointment) => appointment.role === "Trash",
|
|
117
128
|
);
|
|
129
|
+
byAccount.set(account.accountId, {
|
|
130
|
+
mailboxId: trash?.mailboxId,
|
|
131
|
+
source: trash?.source ?? "None",
|
|
132
|
+
staleFolderPath: trash?.staleAppointmentPath,
|
|
133
|
+
});
|
|
118
134
|
}
|
|
119
135
|
return byAccount;
|
|
120
136
|
}, [config]);
|
|
@@ -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
|
+
};
|
|
@@ -5,9 +5,11 @@ import {
|
|
|
5
5
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
6
6
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
7
7
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
8
|
-
import { useCallback } from "react";
|
|
8
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
9
|
+
import { useRoleAppointmentPrompt } from "@/components/mail/RoleAppointmentPromptProvider";
|
|
9
10
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
10
11
|
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
12
|
+
import { isFolderRoleRefusal } from "@/components/ui/folder-role-refusal";
|
|
11
13
|
import { resolveMailboxesForMessages } from "@/hooks/useMarkAsRead";
|
|
12
14
|
import { runChunkedMutation } from "@/lib/bulk-actions";
|
|
13
15
|
import {
|
|
@@ -75,6 +77,14 @@ export const useDeleteMessages = ({
|
|
|
75
77
|
}: UseDeleteMessagesOptions) => {
|
|
76
78
|
const queryClient = useQueryClient();
|
|
77
79
|
const { pushError } = useErrorBanners();
|
|
80
|
+
const { requestAppointment } = useRoleAppointmentPrompt();
|
|
81
|
+
|
|
82
|
+
// The refused chunk is not what the user asked for. The whole selection is
|
|
83
|
+
// held here so the appointment's confirm replays all of it (#887).
|
|
84
|
+
const selectionRef = useRef<string[]>([]);
|
|
85
|
+
const runRef = useRef<(messageIds: string[]) => Promise<void>>(
|
|
86
|
+
async () => {},
|
|
87
|
+
);
|
|
78
88
|
|
|
79
89
|
const { mutateAsync, isPending } = useMutation({
|
|
80
90
|
...messageBulkOperationsDeleteMessagesMutation(),
|
|
@@ -149,6 +159,21 @@ export const useDeleteMessages = ({
|
|
|
149
159
|
}
|
|
150
160
|
restoreThreadListQueries(queryClient, context.previousThreadsList);
|
|
151
161
|
}
|
|
162
|
+
// A provenance refusal is answered by the prompt, not by a banner: the
|
|
163
|
+
// generic banner is suppressed for this one error, and every other
|
|
164
|
+
// failure keeps today's.
|
|
165
|
+
const refusal = isFolderRoleRefusal(err);
|
|
166
|
+
if (refusal) {
|
|
167
|
+
const replay = selectionRef.current;
|
|
168
|
+
requestAppointment({
|
|
169
|
+
accountId: refusal.accountId,
|
|
170
|
+
role: refusal.role,
|
|
171
|
+
reason: refusal.reason,
|
|
172
|
+
action: { kind: "delete", count: replay.length },
|
|
173
|
+
onAppointed: () => runRef.current(replay),
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
152
177
|
const count = vars.body.messageIds?.length ?? 0;
|
|
153
178
|
pushError({
|
|
154
179
|
title:
|
|
@@ -177,14 +202,25 @@ export const useDeleteMessages = ({
|
|
|
177
202
|
},
|
|
178
203
|
});
|
|
179
204
|
|
|
205
|
+
const runSelection = useCallback(
|
|
206
|
+
(messageIds: string[]): Promise<void> =>
|
|
207
|
+
runChunkedMutation(messageIds, (chunk) =>
|
|
208
|
+
mutateAsync({ body: { messageIds: chunk } }),
|
|
209
|
+
),
|
|
210
|
+
[mutateAsync],
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
runRef.current = runSelection;
|
|
215
|
+
}, [runSelection]);
|
|
216
|
+
|
|
180
217
|
const deleteMessages = useCallback(
|
|
181
218
|
(messageIds: string[]) => {
|
|
182
219
|
if (messageIds.length === 0) return;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
);
|
|
220
|
+
selectionRef.current = messageIds;
|
|
221
|
+
void runSelection(messageIds);
|
|
186
222
|
},
|
|
187
|
-
[
|
|
223
|
+
[runSelection],
|
|
188
224
|
);
|
|
189
225
|
|
|
190
226
|
return { deleteMessages, isPending };
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* from several mailboxes and several accounts at once.
|
|
12
12
|
*
|
|
13
13
|
* The decision itself is `deleteOutcomeFor`, kept pure in `lib/format`; this is
|
|
14
|
-
* only the read that feeds it.
|
|
14
|
+
* only the read that feeds it. The two facts beside it are what the copy needs
|
|
15
|
+
* to name a folder: whether the Trash it resolved is a name match nobody
|
|
16
|
+
* confirmed (D4a), and the folder a stale appointment lost.
|
|
15
17
|
*/
|
|
16
18
|
import { useMemo } from "react";
|
|
17
19
|
import {
|
|
@@ -21,15 +23,43 @@ import {
|
|
|
21
23
|
} from "@/lib/format";
|
|
22
24
|
import { useTrashByAccount } from "./useArchiveMailbox";
|
|
23
25
|
|
|
26
|
+
export interface DeleteOutcomeResult {
|
|
27
|
+
outcome: DeleteOutcome;
|
|
28
|
+
/** The Trash these rows resolve to was matched by name, never confirmed. */
|
|
29
|
+
trashIsUnconfirmed: boolean;
|
|
30
|
+
/** The folder the user appointed, when it is gone from the mail server. */
|
|
31
|
+
staleFolderLabel?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
/** The outcome of deleting `targets`. */
|
|
25
35
|
export const useDeleteOutcome = (
|
|
26
36
|
targets: readonly DeleteTarget[],
|
|
27
|
-
):
|
|
37
|
+
): DeleteOutcomeResult => {
|
|
28
38
|
const { trashByAccount, hasAppointments, isError } = useTrashByAccount();
|
|
29
39
|
|
|
30
|
-
return useMemo(
|
|
31
|
-
(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
return useMemo(() => {
|
|
41
|
+
const outcome = deleteOutcomeFor({
|
|
42
|
+
targets,
|
|
43
|
+
trashByAccount,
|
|
44
|
+
hasAppointments,
|
|
45
|
+
isError,
|
|
46
|
+
});
|
|
47
|
+
const trashFor = (target: DeleteTarget) =>
|
|
48
|
+
target.accountId ? trashByAccount.get(target.accountId) : undefined;
|
|
49
|
+
return {
|
|
50
|
+
outcome,
|
|
51
|
+
// Only the rows the delete would actually expunge — a row filed
|
|
52
|
+
// somewhere else says nothing about the folder it is moving into.
|
|
53
|
+
trashIsUnconfirmed: targets.some((target) => {
|
|
54
|
+
const trash = trashFor(target);
|
|
55
|
+
return (
|
|
56
|
+
trash?.source === "Proposed" && trash.mailboxId === target.mailboxId
|
|
57
|
+
);
|
|
58
|
+
}),
|
|
59
|
+
// The account `deleteOutcomeFor` refused on, found the way it found it.
|
|
60
|
+
staleFolderLabel: targets
|
|
61
|
+
.map(trashFor)
|
|
62
|
+
.find((trash) => trash?.source === "Stale")?.staleFolderPath,
|
|
63
|
+
};
|
|
64
|
+
}, [targets, trashByAccount, hasAppointments, isError]);
|
|
35
65
|
};
|