@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.
@@ -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 body =
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 deleteOutcome = useDeleteOutcome(deleteScope);
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
- if (!pendingDelete) return;
693
-
694
- const ids = pendingDelete;
695
- if (ids.length === 0) {
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
- if (nextFocus === undefined) {
714
- for (let i = firstDeletedIndex - 1; i >= 0; i--) {
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
- onDeleteMessages?.(ids);
723
- exitSelection();
724
- focusBeforeConfirmRef.current = null;
725
- setPendingDelete(null);
724
+ onDeleteMessages?.(ids);
725
+ exitSelection();
726
+ focusBeforeConfirmRef.current = null;
727
+ setPendingDelete(null);
726
728
 
727
- if (nextFocus !== undefined) {
728
- // Same hand-back as cancelling, aimed at the surviving neighbour
729
- // instead: confirming also closes a dialog that held DOM focus.
730
- pendingDomFocusRef.current = nextFocus;
731
- cursorMovedByPointerRef.current = false;
732
- setFocusedMessageId(nextFocus);
733
- // Desktop is two-pane: opening the neighbour fills the reading pane
734
- // beside the list. On a single-pane mobile layout the same navigation
735
- // replaces the list with a full-screen message, so a bulk delete looks
736
- // like it opened a random neighbour instead of removing the rows (#202).
737
- // Mobile keeps the cursor move but stays on the list.
738
- if (isDesktop) {
739
- openRow(nextFocus, { replace: true });
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
- // Mobile keeps the list up, so it needs its own signal the delete landed
744
- // (#202). On desktop the rows leaving the list beside the reading pane is
745
- // signal enough.
746
- if (!isDesktop) {
747
- setCompletionBanner(
748
- bulkActionCompletionText("delete", ids.length, deleteOutcome),
749
- );
750
- }
751
- }, [
752
- pendingDelete,
753
- threads,
754
- onDeleteMessages,
755
- exitSelection,
756
- openRow,
757
- isDesktop,
758
- setFocusedMessageId,
759
- deleteOutcome,
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
- count={pendingDelete?.length ?? 0}
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}