@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,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}
|
|
@@ -304,7 +304,11 @@ export const MessageList = ({
|
|
|
304
304
|
() => [{ accountId, mailboxId }],
|
|
305
305
|
[accountId, mailboxId],
|
|
306
306
|
);
|
|
307
|
-
const
|
|
307
|
+
const {
|
|
308
|
+
outcome: deleteOutcome,
|
|
309
|
+
trashIsUnconfirmed,
|
|
310
|
+
staleFolderLabel,
|
|
311
|
+
} = useDeleteOutcome(deleteScope);
|
|
308
312
|
|
|
309
313
|
// Selection state
|
|
310
314
|
const {
|
|
@@ -688,76 +692,75 @@ export const MessageList = ({
|
|
|
688
692
|
|
|
689
693
|
// Confirm handler: run the actual delete, then clear selection and move
|
|
690
694
|
// focus to a sensible neighbor (the row after the first deleted one).
|
|
691
|
-
const handleConfirmDelete = useCallback(
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
setPendingDelete(null);
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
const deletedSet = new Set(ids);
|
|
701
|
-
const firstDeletedIndex = threads.findIndex((t) =>
|
|
702
|
-
deletedSet.has(t.messageId),
|
|
703
|
-
);
|
|
704
|
-
// Next surviving row at or after the first deleted row, else the one
|
|
705
|
-
// before it. Computed against the pre-delete order.
|
|
706
|
-
let nextFocus: string | undefined;
|
|
707
|
-
for (let i = firstDeletedIndex + 1; i < threads.length; i++) {
|
|
708
|
-
if (!deletedSet.has(threads[i].messageId)) {
|
|
709
|
-
nextFocus = threads[i].messageId;
|
|
710
|
-
break;
|
|
695
|
+
const handleConfirmDelete = useCallback(
|
|
696
|
+
(ids: string[]) => {
|
|
697
|
+
if (ids.length === 0) {
|
|
698
|
+
setPendingDelete(null);
|
|
699
|
+
return;
|
|
711
700
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
701
|
+
|
|
702
|
+
const deletedSet = new Set(ids);
|
|
703
|
+
const firstDeletedIndex = threads.findIndex((t) =>
|
|
704
|
+
deletedSet.has(t.messageId),
|
|
705
|
+
);
|
|
706
|
+
// Next surviving row at or after the first deleted row, else the one
|
|
707
|
+
// before it. Computed against the pre-delete order.
|
|
708
|
+
let nextFocus: string | undefined;
|
|
709
|
+
for (let i = firstDeletedIndex + 1; i < threads.length; i++) {
|
|
715
710
|
if (!deletedSet.has(threads[i].messageId)) {
|
|
716
711
|
nextFocus = threads[i].messageId;
|
|
717
712
|
break;
|
|
718
713
|
}
|
|
719
714
|
}
|
|
720
|
-
|
|
715
|
+
if (nextFocus === undefined) {
|
|
716
|
+
for (let i = firstDeletedIndex - 1; i >= 0; i--) {
|
|
717
|
+
if (!deletedSet.has(threads[i].messageId)) {
|
|
718
|
+
nextFocus = threads[i].messageId;
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
721
723
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
724
|
+
onDeleteMessages?.(ids);
|
|
725
|
+
exitSelection();
|
|
726
|
+
focusBeforeConfirmRef.current = null;
|
|
727
|
+
setPendingDelete(null);
|
|
726
728
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
729
|
+
if (nextFocus !== undefined) {
|
|
730
|
+
// Same hand-back as cancelling, aimed at the surviving neighbour
|
|
731
|
+
// instead: confirming also closes a dialog that held DOM focus.
|
|
732
|
+
pendingDomFocusRef.current = nextFocus;
|
|
733
|
+
cursorMovedByPointerRef.current = false;
|
|
734
|
+
setFocusedMessageId(nextFocus);
|
|
735
|
+
// Desktop is two-pane: opening the neighbour fills the reading pane
|
|
736
|
+
// beside the list. On a single-pane mobile layout the same navigation
|
|
737
|
+
// replaces the list with a full-screen message, so a bulk delete looks
|
|
738
|
+
// like it opened a random neighbour instead of removing the rows (#202).
|
|
739
|
+
// Mobile keeps the cursor move but stays on the list.
|
|
740
|
+
if (isDesktop) {
|
|
741
|
+
openRow(nextFocus, { replace: true });
|
|
742
|
+
}
|
|
740
743
|
}
|
|
741
|
-
}
|
|
742
744
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
745
|
+
// Mobile keeps the list up, so it needs its own signal the delete landed
|
|
746
|
+
// (#202). On desktop the rows leaving the list beside the reading pane is
|
|
747
|
+
// signal enough.
|
|
748
|
+
if (!isDesktop) {
|
|
749
|
+
setCompletionBanner(
|
|
750
|
+
bulkActionCompletionText("delete", ids.length, deleteOutcome),
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
},
|
|
754
|
+
[
|
|
755
|
+
threads,
|
|
756
|
+
onDeleteMessages,
|
|
757
|
+
exitSelection,
|
|
758
|
+
openRow,
|
|
759
|
+
isDesktop,
|
|
760
|
+
setFocusedMessageId,
|
|
761
|
+
deleteOutcome,
|
|
762
|
+
],
|
|
763
|
+
);
|
|
761
764
|
|
|
762
765
|
// Every way out of the confirmation that isn't the delete — Escape, Cancel,
|
|
763
766
|
// the backdrop — arrives here, so this is the one place the keyboard has to
|
|
@@ -1343,8 +1346,14 @@ export const MessageList = ({
|
|
|
1343
1346
|
/>
|
|
1344
1347
|
<DeleteConfirmDialog
|
|
1345
1348
|
isOpen={pendingDelete !== null}
|
|
1346
|
-
|
|
1349
|
+
messageIds={pendingDelete ?? []}
|
|
1347
1350
|
outcome={deleteOutcome}
|
|
1351
|
+
accountId={accountId}
|
|
1352
|
+
// Every row here is filed in the open mailbox, so on an expunge that
|
|
1353
|
+
// mailbox is the Trash the copy has to name.
|
|
1354
|
+
trashFolderLabel={listTitle}
|
|
1355
|
+
staleFolderLabel={staleFolderLabel}
|
|
1356
|
+
trashIsUnconfirmed={trashIsUnconfirmed}
|
|
1348
1357
|
isDeleting={isDeleting}
|
|
1349
1358
|
onConfirm={handleConfirmDelete}
|
|
1350
1359
|
onCancel={handleCancelDelete}
|