@remit/ui 0.0.1
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 +50 -0
- package/src/components/address-display.render.test.ts +93 -0
- package/src/components/address-display.stories.tsx +55 -0
- package/src/components/address-display.tsx +132 -0
- package/src/components/address-tag.render.test.ts +39 -0
- package/src/components/address-tag.stories.tsx +41 -0
- package/src/components/address-tag.tsx +38 -0
- package/src/components/app-password-hint.render.test.ts +23 -0
- package/src/components/app-password-hint.stories.tsx +19 -0
- package/src/components/app-password-hint.tsx +24 -0
- package/src/components/app-shell-slotted.render.test.ts +128 -0
- package/src/components/app-shell-slotted.tsx +267 -0
- package/src/components/app-shell-types.ts +328 -0
- package/src/components/app-shell.render.test.ts +415 -0
- package/src/components/app-shell.tsx +277 -0
- package/src/components/auth-card.render.test.ts +24 -0
- package/src/components/auth-card.stories.tsx +50 -0
- package/src/components/auth-card.tsx +32 -0
- package/src/components/auth-footer.render.test.ts +17 -0
- package/src/components/auth-footer.stories.tsx +23 -0
- package/src/components/auth-footer.tsx +14 -0
- package/src/components/auth-hero.render.test.ts +31 -0
- package/src/components/auth-hero.stories.tsx +19 -0
- package/src/components/auth-hero.tsx +41 -0
- package/src/components/auto-moved-badge.render.test.ts +42 -0
- package/src/components/auto-moved-badge.stories.tsx +48 -0
- package/src/components/auto-moved-badge.tsx +57 -0
- package/src/components/avatar.tsx +60 -0
- package/src/components/badge.tsx +49 -0
- package/src/components/banner.render.test.ts +58 -0
- package/src/components/banner.stories.tsx +92 -0
- package/src/components/banner.tsx +81 -0
- package/src/components/bottom-sheet.render.test.ts +48 -0
- package/src/components/bottom-sheet.stories.tsx +71 -0
- package/src/components/bottom-sheet.tsx +146 -0
- package/src/components/brief-section.render.test.ts +98 -0
- package/src/components/brief-section.stories.tsx +68 -0
- package/src/components/brief-section.tsx +102 -0
- package/src/components/brief-sections.render.test.ts +72 -0
- package/src/components/brief-sections.stories.tsx +178 -0
- package/src/components/brief-sections.tsx +201 -0
- package/src/components/button.tsx +48 -0
- package/src/components/card.tsx +48 -0
- package/src/components/category-badge.render.test.ts +40 -0
- package/src/components/category-badge.stories.tsx +37 -0
- package/src/components/category-badge.tsx +71 -0
- package/src/components/checkbox.render.test.ts +65 -0
- package/src/components/checkbox.stories.tsx +77 -0
- package/src/components/checkbox.tsx +78 -0
- package/src/components/compose-action-bar.render.test.ts +47 -0
- package/src/components/compose-action-bar.stories.tsx +35 -0
- package/src/components/compose-action-bar.tsx +95 -0
- package/src/components/compose-form-shell.render.test.ts +54 -0
- package/src/components/compose-form-shell.stories.tsx +96 -0
- package/src/components/compose-form-shell.tsx +49 -0
- package/src/components/danger-zone-section.tsx +36 -0
- package/src/components/dialog.render.test.ts +55 -0
- package/src/components/dialog.tsx +104 -0
- package/src/components/email-frame-css.ts +137 -0
- package/src/components/field-label.render.test.ts +16 -0
- package/src/components/field-label.tsx +19 -0
- package/src/components/filter-sheet.render.test.ts +76 -0
- package/src/components/filter-sheet.stories.tsx +146 -0
- package/src/components/filter-sheet.tsx +316 -0
- package/src/components/folder-role.tsx +66 -0
- package/src/components/input.tsx +44 -0
- package/src/components/intelligence-panel.stories.tsx +149 -0
- package/src/components/intelligence-panel.tsx +546 -0
- package/src/components/isolated-email-frame.render.test.ts +147 -0
- package/src/components/isolated-email-frame.stories.tsx +192 -0
- package/src/components/isolated-email-frame.tsx +265 -0
- package/src/components/kbd.tsx +17 -0
- package/src/components/keyboard-hint-bar.tsx +58 -0
- package/src/components/list-item.tsx +39 -0
- package/src/components/mail-action-toolbar.render.test.ts +43 -0
- package/src/components/mail-action-toolbar.tsx +180 -0
- package/src/components/mail-header.render.test.ts +69 -0
- package/src/components/mail-header.stories.tsx +233 -0
- package/src/components/mail-header.tsx +123 -0
- package/src/components/message-body-view.render.test.ts +59 -0
- package/src/components/message-body-view.stories.tsx +146 -0
- package/src/components/message-body-view.tsx +150 -0
- package/src/components/message-header.render.test.ts +58 -0
- package/src/components/message-header.stories.tsx +79 -0
- package/src/components/message-header.tsx +59 -0
- package/src/components/message-list-pane.render.test.ts +136 -0
- package/src/components/message-list-pane.stories.tsx +246 -0
- package/src/components/message-list-pane.tsx +248 -0
- package/src/components/message-list-state.render.test.ts +72 -0
- package/src/components/message-list-state.tsx +97 -0
- package/src/components/message-row.render.test.ts +48 -0
- package/src/components/message-row.stories.tsx +121 -0
- package/src/components/message-row.tsx +231 -0
- package/src/components/mobile-message-action-bar.render.test.ts +87 -0
- package/src/components/mobile-message-action-bar.stories.tsx +57 -0
- package/src/components/mobile-message-action-bar.tsx +175 -0
- package/src/components/mobile-reading-pane.render.test.ts +102 -0
- package/src/components/mobile-reading-pane.tsx +188 -0
- package/src/components/mobile-search-view.stories.tsx +247 -0
- package/src/components/mobile-search-view.tsx +100 -0
- package/src/components/move-mailbox-picker.render.test.ts +113 -0
- package/src/components/move-mailbox-picker.stories.tsx +76 -0
- package/src/components/move-mailbox-picker.tsx +324 -0
- package/src/components/nav-sidebar.render.test.ts +351 -0
- package/src/components/nav-sidebar.stories.tsx +216 -0
- package/src/components/nav-sidebar.tsx +625 -0
- package/src/components/outbox-row.render.test.ts +61 -0
- package/src/components/outbox-row.stories.tsx +53 -0
- package/src/components/outbox-row.tsx +120 -0
- package/src/components/outbox-status-badge.tsx +60 -0
- package/src/components/pane-layout.render.test.ts +105 -0
- package/src/components/popover-menu.render.test.ts +32 -0
- package/src/components/popover-menu.stories.tsx +57 -0
- package/src/components/popover-menu.tsx +110 -0
- package/src/components/primitives.stories.tsx +173 -0
- package/src/components/pull-to-refresh.render.test.ts +46 -0
- package/src/components/pull-to-refresh.stories.tsx +71 -0
- package/src/components/pull-to-refresh.tsx +54 -0
- package/src/components/quoted-text.render.test.ts +39 -0
- package/src/components/quoted-text.stories.tsx +67 -0
- package/src/components/quoted-text.tsx +67 -0
- package/src/components/reading-pane-empty.tsx +44 -0
- package/src/components/reading-pane.render.test.ts +148 -0
- package/src/components/reading-pane.stories.tsx +194 -0
- package/src/components/reading-pane.tsx +351 -0
- package/src/components/rescue-banner.render.test.ts +32 -0
- package/src/components/rescue-banner.stories.tsx +31 -0
- package/src/components/rescue-banner.tsx +50 -0
- package/src/components/rescue-candidate-row.render.test.ts +68 -0
- package/src/components/rescue-candidate-row.stories.tsx +108 -0
- package/src/components/rescue-candidate-row.tsx +94 -0
- package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
- package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
- package/src/components/rescue-from-spam-flow.tsx +342 -0
- package/src/components/resizable.tsx +55 -0
- package/src/components/role-appointment-list.render.test.ts +97 -0
- package/src/components/role-appointment-list.stories.tsx +138 -0
- package/src/components/role-appointment-list.tsx +227 -0
- package/src/components/row-actions.render.test.ts +83 -0
- package/src/components/row-actions.stories.tsx +87 -0
- package/src/components/row-actions.tsx +139 -0
- package/src/components/search-bar.render.test.ts +55 -0
- package/src/components/search-bar.stories.tsx +46 -0
- package/src/components/search-bar.tsx +118 -0
- package/src/components/search-result-row.tsx +139 -0
- package/src/components/search-results.render.test.ts +89 -0
- package/src/components/search-results.stories.tsx +126 -0
- package/src/components/search-results.tsx +215 -0
- package/src/components/search-token-chip.render.test.ts +42 -0
- package/src/components/search-token-chip.stories.tsx +42 -0
- package/src/components/search-token-chip.tsx +66 -0
- package/src/components/security-select.render.test.ts +33 -0
- package/src/components/security-select.tsx +53 -0
- package/src/components/segmented-control.tsx +70 -0
- package/src/components/select.tsx +33 -0
- package/src/components/selection-top-bar.render.test.ts +67 -0
- package/src/components/selection-top-bar.stories.tsx +39 -0
- package/src/components/selection-top-bar.tsx +95 -0
- package/src/components/sender-group-switch.tsx +63 -0
- package/src/components/sender-trust-indicator.render.test.ts +67 -0
- package/src/components/sender-trust-indicator.stories.tsx +58 -0
- package/src/components/sender-trust-indicator.tsx +87 -0
- package/src/components/settings-primitives.render.test.ts +160 -0
- package/src/components/settings-primitives.stories.tsx +80 -0
- package/src/components/settings-screen.tsx +354 -0
- package/src/components/swipeable-row.render.test.ts +106 -0
- package/src/components/swipeable-row.stories.tsx +117 -0
- package/src/components/swipeable-row.tsx +268 -0
- package/src/components/touch-list-body.render.test.ts +66 -0
- package/src/components/touch-list-body.stories.tsx +76 -0
- package/src/components/touch-list.tsx +87 -0
- package/src/components/wizard.render.test.ts +49 -0
- package/src/components/wizard.stories.tsx +113 -0
- package/src/components/wizard.tsx +321 -0
- package/src/filter-presets.test.ts +93 -0
- package/src/filter-presets.ts +101 -0
- package/src/index.ts +378 -0
- package/src/lib/cid-resolver.ts +35 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/email-layout-clamp.test.ts +109 -0
- package/src/lib/email-layout-clamp.ts +64 -0
- package/src/lib/email-render-treatment.test.ts +82 -0
- package/src/lib/email-render-treatment.ts +53 -0
- package/src/lib/email-sanitizer.test.ts +362 -0
- package/src/lib/email-sanitizer.ts +298 -0
- package/src/lib/row-keyboard.test.ts +41 -0
- package/src/lib/row-keyboard.ts +20 -0
- package/src/tokens.css +217 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { CheckCircle2, HelpCircle, ShieldCheck } from "lucide-react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Banner } from "./banner.js";
|
|
4
|
+
import { BottomSheet } from "./bottom-sheet.js";
|
|
5
|
+
import { Button } from "./button.js";
|
|
6
|
+
import {
|
|
7
|
+
type MoveMailboxOption,
|
|
8
|
+
MoveMailboxPicker,
|
|
9
|
+
} from "./move-mailbox-picker.js";
|
|
10
|
+
import {
|
|
11
|
+
type RescueCandidate,
|
|
12
|
+
RescueCandidateRow,
|
|
13
|
+
} from "./rescue-candidate-row.js";
|
|
14
|
+
|
|
15
|
+
type Step = "review" | "destination" | "done";
|
|
16
|
+
|
|
17
|
+
export interface RescueFromSpamFlowProps {
|
|
18
|
+
open: boolean;
|
|
19
|
+
/** Suspected-safe messages, already shaped and explained by the app. */
|
|
20
|
+
candidates: RescueCandidate[];
|
|
21
|
+
/** Where the move lands unless the user picks another folder. */
|
|
22
|
+
defaultDestinationId: string;
|
|
23
|
+
/** Folders the user can send the rescued mail to. */
|
|
24
|
+
availableFolders: MoveMailboxOption[];
|
|
25
|
+
/** Run the move. The flow shows the done state right after. */
|
|
26
|
+
onConfirmMove: (messageIds: string[], destinationId: string) => void;
|
|
27
|
+
/** Close the sheet without moving anything. */
|
|
28
|
+
onCancel: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const plural = (n: number): string => (n === 1 ? "message" : "messages");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Plain-language scope line shown before the move commits, so the user sees
|
|
35
|
+
* exactly what happens and that it is a one-off — never an ongoing rule.
|
|
36
|
+
*/
|
|
37
|
+
export const rescueMoveConsequence = (
|
|
38
|
+
count: number,
|
|
39
|
+
destinationLabel: string,
|
|
40
|
+
): string =>
|
|
41
|
+
`Moves ${count === 1 ? "this message" : `these ${count} ${plural(count)}`} out of Spam to ${destinationLabel} now. Nothing later.`;
|
|
42
|
+
|
|
43
|
+
const folderLabel = (folders: MoveMailboxOption[], id: string): string =>
|
|
44
|
+
folders.find((f) => f.id === id)?.label ?? "Inbox";
|
|
45
|
+
|
|
46
|
+
function ExplainWhy() {
|
|
47
|
+
return (
|
|
48
|
+
<Banner tone="info" variant="soft">
|
|
49
|
+
<p className="text-2xs text-fg-muted">
|
|
50
|
+
We list a message here only when we can confirm the sender is who they
|
|
51
|
+
say — usually someone you've emailed before, write to often, or a
|
|
52
|
+
service that consistently checks out. We never guess from words in the
|
|
53
|
+
subject. Anything we can't confirm stays in Spam.
|
|
54
|
+
</p>
|
|
55
|
+
</Banner>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ReviewStep({
|
|
60
|
+
candidates,
|
|
61
|
+
selected,
|
|
62
|
+
onToggle,
|
|
63
|
+
onSelectAll,
|
|
64
|
+
onSelectNone,
|
|
65
|
+
onContinue,
|
|
66
|
+
onCancel,
|
|
67
|
+
}: {
|
|
68
|
+
candidates: RescueCandidate[];
|
|
69
|
+
selected: Set<string>;
|
|
70
|
+
onToggle: (id: string) => void;
|
|
71
|
+
onSelectAll: () => void;
|
|
72
|
+
onSelectNone: () => void;
|
|
73
|
+
onContinue: () => void;
|
|
74
|
+
onCancel: () => void;
|
|
75
|
+
}) {
|
|
76
|
+
const [showWhy, setShowWhy] = useState(false);
|
|
77
|
+
const count = selected.size;
|
|
78
|
+
return (
|
|
79
|
+
<div className="flex min-h-0 flex-col">
|
|
80
|
+
<div className="px-row-inset pb-2 pt-1">
|
|
81
|
+
<div className="flex items-start justify-between gap-2">
|
|
82
|
+
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg">
|
|
83
|
+
<ShieldCheck className="size-4 text-positive" aria-hidden />
|
|
84
|
+
Rescue from Spam
|
|
85
|
+
</h2>
|
|
86
|
+
<Button
|
|
87
|
+
variant="ghost"
|
|
88
|
+
size="sm"
|
|
89
|
+
icon={<HelpCircle className="size-3.5" />}
|
|
90
|
+
onClick={() => setShowWhy((v) => !v)}
|
|
91
|
+
className="-mr-1.5 -mt-0.5"
|
|
92
|
+
>
|
|
93
|
+
Why these?
|
|
94
|
+
</Button>
|
|
95
|
+
</div>
|
|
96
|
+
<p className="mt-1 text-2xs text-fg-subtle">
|
|
97
|
+
These look safe — from senders we can verify. Uncheck anything that
|
|
98
|
+
belongs in Spam.
|
|
99
|
+
</p>
|
|
100
|
+
{showWhy && (
|
|
101
|
+
<div className="mt-2">
|
|
102
|
+
<ExplainWhy />
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div className="flex items-center justify-between px-row-inset pb-1.5">
|
|
108
|
+
<span className="text-2xs font-medium text-fg-muted">
|
|
109
|
+
{`${count} of ${candidates.length} selected`}
|
|
110
|
+
</span>
|
|
111
|
+
<span className="flex gap-1">
|
|
112
|
+
<Button variant="ghost" size="sm" onClick={onSelectAll}>
|
|
113
|
+
Select all
|
|
114
|
+
</Button>
|
|
115
|
+
<Button variant="ghost" size="sm" onClick={onSelectNone}>
|
|
116
|
+
Select none
|
|
117
|
+
</Button>
|
|
118
|
+
</span>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<div className="min-h-0 flex-1 divide-y divide-line overflow-y-auto border-y border-line">
|
|
122
|
+
{candidates.map((candidate) => (
|
|
123
|
+
<RescueCandidateRow
|
|
124
|
+
key={candidate.id}
|
|
125
|
+
candidate={candidate}
|
|
126
|
+
selected={selected.has(candidate.id)}
|
|
127
|
+
onToggle={() => onToggle(candidate.id)}
|
|
128
|
+
/>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<div className="space-y-2 border-t border-line bg-surface px-row-inset py-3">
|
|
133
|
+
<Button
|
|
134
|
+
variant="primary"
|
|
135
|
+
onClick={onContinue}
|
|
136
|
+
className="h-11 w-full font-semibold"
|
|
137
|
+
>
|
|
138
|
+
{`Continue with ${count} ${plural(count)}`}
|
|
139
|
+
</Button>
|
|
140
|
+
<Button variant="ghost" onClick={onCancel} className="h-11 w-full">
|
|
141
|
+
Cancel
|
|
142
|
+
</Button>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function DestinationStep({
|
|
149
|
+
count,
|
|
150
|
+
folders,
|
|
151
|
+
destination,
|
|
152
|
+
onPick,
|
|
153
|
+
onMove,
|
|
154
|
+
onBack,
|
|
155
|
+
}: {
|
|
156
|
+
count: number;
|
|
157
|
+
folders: MoveMailboxOption[];
|
|
158
|
+
destination: string;
|
|
159
|
+
onPick: (id: string) => void;
|
|
160
|
+
onMove: () => void;
|
|
161
|
+
onBack: () => void;
|
|
162
|
+
}) {
|
|
163
|
+
const [picking, setPicking] = useState(false);
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div className="flex min-h-0 flex-col">
|
|
167
|
+
<div className="px-row-inset pb-2 pt-1">
|
|
168
|
+
<h2 className="text-sm font-semibold text-fg">Where should they go?</h2>
|
|
169
|
+
<p className="mt-1 text-2xs text-fg-subtle">
|
|
170
|
+
Pick a folder, then we move them out of Spam.
|
|
171
|
+
</p>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-row-inset py-2">
|
|
175
|
+
{picking ? (
|
|
176
|
+
<div className="overflow-hidden rounded-xl border border-line bg-surface">
|
|
177
|
+
<MoveMailboxPicker
|
|
178
|
+
mailboxes={folders}
|
|
179
|
+
autoFocus
|
|
180
|
+
onSelect={(id) => {
|
|
181
|
+
onPick(id);
|
|
182
|
+
setPicking(false);
|
|
183
|
+
}}
|
|
184
|
+
onCancel={() => setPicking(false)}
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
187
|
+
) : (
|
|
188
|
+
<div className="rounded-xl border border-line bg-surface-sunken p-3">
|
|
189
|
+
<p className="text-2xs text-fg-subtle">Destination</p>
|
|
190
|
+
<p className="mt-0.5 text-sm font-medium text-fg">
|
|
191
|
+
{folderLabel(folders, destination)}
|
|
192
|
+
</p>
|
|
193
|
+
<Button
|
|
194
|
+
variant="ghost"
|
|
195
|
+
size="sm"
|
|
196
|
+
onClick={() => setPicking(true)}
|
|
197
|
+
className="mt-2 px-0"
|
|
198
|
+
>
|
|
199
|
+
Move to a different folder
|
|
200
|
+
</Button>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div className="space-y-2 border-t border-line bg-surface px-row-inset py-3">
|
|
206
|
+
<p className="text-2xs text-fg-subtle">
|
|
207
|
+
{rescueMoveConsequence(count, folderLabel(folders, destination))}
|
|
208
|
+
</p>
|
|
209
|
+
<Button
|
|
210
|
+
variant="primary"
|
|
211
|
+
onClick={onMove}
|
|
212
|
+
className="h-11 w-full font-semibold"
|
|
213
|
+
>
|
|
214
|
+
{`Move ${count} out of Spam`}
|
|
215
|
+
</Button>
|
|
216
|
+
<Button variant="ghost" onClick={onBack} className="h-11 w-full">
|
|
217
|
+
Back
|
|
218
|
+
</Button>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function DoneStep({
|
|
225
|
+
count,
|
|
226
|
+
destinationLabel,
|
|
227
|
+
onClose,
|
|
228
|
+
}: {
|
|
229
|
+
count: number;
|
|
230
|
+
destinationLabel: string;
|
|
231
|
+
onClose: () => void;
|
|
232
|
+
}) {
|
|
233
|
+
return (
|
|
234
|
+
<div className="flex min-h-0 flex-col">
|
|
235
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-row-inset py-10 text-center">
|
|
236
|
+
<CheckCircle2 className="size-10 text-positive" aria-hidden />
|
|
237
|
+
<p className="text-base font-semibold text-fg">
|
|
238
|
+
{`Moved ${count} ${plural(count)} out of Spam.`}
|
|
239
|
+
</p>
|
|
240
|
+
<p className="text-2xs text-fg-subtle">
|
|
241
|
+
{`You'll find them in ${destinationLabel}.`}
|
|
242
|
+
</p>
|
|
243
|
+
</div>
|
|
244
|
+
<div className="border-t border-line bg-surface px-row-inset py-3">
|
|
245
|
+
<Button
|
|
246
|
+
variant="primary"
|
|
247
|
+
onClick={onClose}
|
|
248
|
+
className="h-11 w-full font-semibold"
|
|
249
|
+
>
|
|
250
|
+
Done
|
|
251
|
+
</Button>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Review-and-rescue flow for messages that landed in Spam but come from senders
|
|
259
|
+
* we can verify. Presentational and fully driven: the app supplies candidates,
|
|
260
|
+
* folders and the move callback; the sheet owns only selection and step state.
|
|
261
|
+
*
|
|
262
|
+
* Pre-selects every candidate, supports select-all / none, explains "why these"
|
|
263
|
+
* on demand, states the move's scope as a one-off consequence, and always
|
|
264
|
+
* offers a way back before committing.
|
|
265
|
+
*/
|
|
266
|
+
export function RescueFromSpamFlow({
|
|
267
|
+
open,
|
|
268
|
+
candidates,
|
|
269
|
+
defaultDestinationId,
|
|
270
|
+
availableFolders,
|
|
271
|
+
onConfirmMove,
|
|
272
|
+
onCancel,
|
|
273
|
+
}: RescueFromSpamFlowProps) {
|
|
274
|
+
const [step, setStep] = useState<Step>("review");
|
|
275
|
+
const [selected, setSelected] = useState<Set<string>>(() =>
|
|
276
|
+
open ? new Set(candidates.map((c) => c.id)) : new Set(),
|
|
277
|
+
);
|
|
278
|
+
const [destination, setDestination] = useState(defaultDestinationId);
|
|
279
|
+
const [movedCount, setMovedCount] = useState(0);
|
|
280
|
+
|
|
281
|
+
const wasOpen = useRef(false);
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
if (open && !wasOpen.current) {
|
|
284
|
+
setStep("review");
|
|
285
|
+
setSelected(new Set(candidates.map((c) => c.id)));
|
|
286
|
+
setDestination(defaultDestinationId);
|
|
287
|
+
setMovedCount(0);
|
|
288
|
+
}
|
|
289
|
+
wasOpen.current = open;
|
|
290
|
+
}, [open, candidates, defaultDestinationId]);
|
|
291
|
+
|
|
292
|
+
const selectedCount = selected.size;
|
|
293
|
+
|
|
294
|
+
const toggle = (id: string) => {
|
|
295
|
+
setSelected((prev) => {
|
|
296
|
+
const next = new Set(prev);
|
|
297
|
+
if (next.has(id)) next.delete(id);
|
|
298
|
+
else next.add(id);
|
|
299
|
+
return next;
|
|
300
|
+
});
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const move = () => {
|
|
304
|
+
const ids = Array.from(selected);
|
|
305
|
+
setMovedCount(ids.length);
|
|
306
|
+
onConfirmMove(ids, destination);
|
|
307
|
+
setStep("done");
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<BottomSheet open={open} onClose={onCancel} dismissLabel="Close rescue">
|
|
312
|
+
{step === "review" && (
|
|
313
|
+
<ReviewStep
|
|
314
|
+
candidates={candidates}
|
|
315
|
+
selected={selected}
|
|
316
|
+
onToggle={toggle}
|
|
317
|
+
onSelectAll={() => setSelected(new Set(candidates.map((c) => c.id)))}
|
|
318
|
+
onSelectNone={() => setSelected(new Set())}
|
|
319
|
+
onContinue={() => setStep("destination")}
|
|
320
|
+
onCancel={onCancel}
|
|
321
|
+
/>
|
|
322
|
+
)}
|
|
323
|
+
{step === "destination" && (
|
|
324
|
+
<DestinationStep
|
|
325
|
+
count={selectedCount}
|
|
326
|
+
folders={availableFolders}
|
|
327
|
+
destination={destination}
|
|
328
|
+
onPick={setDestination}
|
|
329
|
+
onMove={move}
|
|
330
|
+
onBack={() => setStep("review")}
|
|
331
|
+
/>
|
|
332
|
+
)}
|
|
333
|
+
{step === "done" && (
|
|
334
|
+
<DoneStep
|
|
335
|
+
count={movedCount}
|
|
336
|
+
destinationLabel={folderLabel(availableFolders, destination)}
|
|
337
|
+
onClose={onCancel}
|
|
338
|
+
/>
|
|
339
|
+
)}
|
|
340
|
+
</BottomSheet>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Panel,
|
|
3
|
+
PanelGroup,
|
|
4
|
+
type PanelGroupProps,
|
|
5
|
+
type PanelProps,
|
|
6
|
+
PanelResizeHandle,
|
|
7
|
+
type PanelResizeHandleProps,
|
|
8
|
+
} from "react-resizable-panels";
|
|
9
|
+
import { cn } from "../lib/cn.js";
|
|
10
|
+
|
|
11
|
+
/* ------------------------------------------------------------------ */
|
|
12
|
+
/* Resizable panes — same library + wrapper pattern as the production */
|
|
13
|
+
/* web client (react-resizable-panels), restyled on the design */
|
|
14
|
+
/* tokens. The drag handle IS the hairline: a 1px --line divider with */
|
|
15
|
+
/* an invisible 6px hit area centered on it; it lifts to */
|
|
16
|
+
/* --line-strong on hover and --accent while dragging. */
|
|
17
|
+
/* ------------------------------------------------------------------ */
|
|
18
|
+
|
|
19
|
+
export function ResizablePanelGroup({ className, ...props }: PanelGroupProps) {
|
|
20
|
+
return (
|
|
21
|
+
<PanelGroup
|
|
22
|
+
className={cn(
|
|
23
|
+
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
|
24
|
+
className,
|
|
25
|
+
)}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const ResizablePanel = Panel;
|
|
32
|
+
|
|
33
|
+
export function ResizableHandle({
|
|
34
|
+
className,
|
|
35
|
+
...props
|
|
36
|
+
}: PanelResizeHandleProps) {
|
|
37
|
+
return (
|
|
38
|
+
<PanelResizeHandle
|
|
39
|
+
className={cn(
|
|
40
|
+
// the visible 1px hairline
|
|
41
|
+
"relative w-px shrink-0 bg-line transition-colors cursor-col-resize",
|
|
42
|
+
// invisible ~6px hit area centered on the line
|
|
43
|
+
"after:absolute after:inset-y-0 after:left-1/2 after:w-1.5 after:-translate-x-1/2",
|
|
44
|
+
// affordance: hairline lifts on hover, accent while dragging
|
|
45
|
+
"data-[resize-handle-state=hover]:bg-line-strong",
|
|
46
|
+
"data-[resize-handle-state=drag]:bg-accent",
|
|
47
|
+
"focus-visible:outline-none focus-visible:bg-accent",
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
{...props}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type { PanelGroupProps, PanelProps, PanelResizeHandleProps };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import {
|
|
6
|
+
type CandidateFolder,
|
|
7
|
+
RoleAppointmentList,
|
|
8
|
+
} from "./role-appointment-list.js";
|
|
9
|
+
|
|
10
|
+
const noop = () => {};
|
|
11
|
+
|
|
12
|
+
const folders: CandidateFolder[] = [
|
|
13
|
+
{ mailboxId: "mb-inbox", providerPath: "INBOX", messageCount: 4821 },
|
|
14
|
+
{
|
|
15
|
+
mailboxId: "mb-concepten",
|
|
16
|
+
providerPath: "INBOX/Concepten",
|
|
17
|
+
messageCount: 340,
|
|
18
|
+
},
|
|
19
|
+
{ mailboxId: "mb-drafts", providerPath: "INBOX/Drafts", messageCount: 0 },
|
|
20
|
+
{
|
|
21
|
+
mailboxId: "mb-news",
|
|
22
|
+
providerPath: "INBOX/Nieuwsbrieven",
|
|
23
|
+
messageCount: 2870,
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function render(
|
|
28
|
+
appointments: Record<string, string | null>,
|
|
29
|
+
displayNames: Record<string, string> = {},
|
|
30
|
+
): string {
|
|
31
|
+
return renderToString(
|
|
32
|
+
createElement(RoleAppointmentList, {
|
|
33
|
+
accountEmail: "you@example.com",
|
|
34
|
+
folders,
|
|
35
|
+
appointments,
|
|
36
|
+
displayNames,
|
|
37
|
+
onAppoint: noop,
|
|
38
|
+
onRename: noop,
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("RoleAppointmentList", () => {
|
|
44
|
+
it("titles the section with the account email", () => {
|
|
45
|
+
const html = render({ inbox: "mb-inbox" });
|
|
46
|
+
assert.match(html, /Folder roles —/);
|
|
47
|
+
assert.match(html, /you@example.com/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("renders a row for every appointable role", () => {
|
|
51
|
+
const html = render({});
|
|
52
|
+
for (const label of [
|
|
53
|
+
"Inbox",
|
|
54
|
+
"Drafts",
|
|
55
|
+
"Sent",
|
|
56
|
+
"Archive",
|
|
57
|
+
"Spam",
|
|
58
|
+
"Trash",
|
|
59
|
+
]) {
|
|
60
|
+
assert.match(html, new RegExp(`Folder for ${label}`));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("offers every folder with its message count as a picker option", () => {
|
|
65
|
+
const html = render({});
|
|
66
|
+
assert.match(html, /Concepten · 340 msgs/);
|
|
67
|
+
assert.match(html, /Drafts · 0 msgs/);
|
|
68
|
+
assert.match(html, /None/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("shows the appointed folder's path and count under the role", () => {
|
|
72
|
+
const html = render({ drafts: "mb-concepten" });
|
|
73
|
+
assert.match(html, /title="INBOX\/Concepten"/);
|
|
74
|
+
assert.match(html, /340/);
|
|
75
|
+
assert.match(html, /messages/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("renders a rename field only for an appointed role", () => {
|
|
79
|
+
const html = render({ drafts: "mb-concepten" });
|
|
80
|
+
assert.match(html, /Display name for Drafts/);
|
|
81
|
+
assert.doesNotMatch(html, /Display name for Sent/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("lists unappointed folders under Other folders", () => {
|
|
85
|
+
const html = render({ drafts: "mb-concepten", inbox: "mb-inbox" });
|
|
86
|
+
assert.match(html, /Other folders/);
|
|
87
|
+
assert.match(html, /Nieuwsbrieven/);
|
|
88
|
+
assert.match(html, /Drafts/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps an appointed folder out of the Other folders list", () => {
|
|
92
|
+
const html = render({ drafts: "mb-concepten" });
|
|
93
|
+
const otherIdx = html.indexOf("Other folders");
|
|
94
|
+
assert.ok(otherIdx >= 0);
|
|
95
|
+
assert.doesNotMatch(html.slice(otherIdx), /Concepten/);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import type { FolderRole } from "./folder-role.js";
|
|
4
|
+
import {
|
|
5
|
+
type CandidateFolder,
|
|
6
|
+
RoleAppointmentList,
|
|
7
|
+
} from "./role-appointment-list.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The user's real Hostnet account (440737+mvhenten@users.noreply.github.com). Every folder is nested
|
|
11
|
+
* directly under the personal-namespace root `INBOX/`. The pair that breaks the
|
|
12
|
+
* old per-folder model is here: the server flags `INBOX/Drafts` with `\Drafts`
|
|
13
|
+
* but it is EMPTY — the mail lives in `INBOX/Concepten`. Same story for
|
|
14
|
+
* `INBOX/Sent` vs `INBOX/Sent Messages`.
|
|
15
|
+
*
|
|
16
|
+
* NOTE: message counts are representative placeholders until live dev-stage
|
|
17
|
+
* numbers are wired in.
|
|
18
|
+
*/
|
|
19
|
+
const HOSTNET_FOLDERS: readonly CandidateFolder[] = [
|
|
20
|
+
{ mailboxId: "mb-inbox", providerPath: "INBOX", messageCount: 4821 },
|
|
21
|
+
{
|
|
22
|
+
mailboxId: "mb-archive",
|
|
23
|
+
providerPath: "INBOX/Archive",
|
|
24
|
+
messageCount: 19243,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
mailboxId: "mb-concepten",
|
|
28
|
+
providerPath: "INBOX/Concepten",
|
|
29
|
+
messageCount: 340,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
mailboxId: "mb-deleted",
|
|
33
|
+
providerPath: "INBOX/Deleted Messages",
|
|
34
|
+
messageCount: 512,
|
|
35
|
+
},
|
|
36
|
+
{ mailboxId: "mb-drafts", providerPath: "INBOX/Drafts", messageCount: 0 },
|
|
37
|
+
{
|
|
38
|
+
mailboxId: "mb-news",
|
|
39
|
+
providerPath: "INBOX/Nieuwsbrieven",
|
|
40
|
+
messageCount: 2870,
|
|
41
|
+
},
|
|
42
|
+
{ mailboxId: "mb-sent", providerPath: "INBOX/Sent", messageCount: 0 },
|
|
43
|
+
{
|
|
44
|
+
mailboxId: "mb-sent-messages",
|
|
45
|
+
providerPath: "INBOX/Sent Messages",
|
|
46
|
+
messageCount: 6105,
|
|
47
|
+
},
|
|
48
|
+
{ mailboxId: "mb-spam", providerPath: "INBOX/Spam", messageCount: 88 },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function Harness({
|
|
52
|
+
folders,
|
|
53
|
+
initial,
|
|
54
|
+
}: {
|
|
55
|
+
folders: readonly CandidateFolder[];
|
|
56
|
+
initial: Record<string, string | null>;
|
|
57
|
+
}) {
|
|
58
|
+
const [appointments, setAppointments] = useState(initial);
|
|
59
|
+
const [displayNames, setDisplayNames] = useState<Record<string, string>>({});
|
|
60
|
+
|
|
61
|
+
const handleAppoint = (role: FolderRole, mailboxId: string | null) => {
|
|
62
|
+
setAppointments((prev) => {
|
|
63
|
+
const next: Record<string, string | null> = {
|
|
64
|
+
...prev,
|
|
65
|
+
[role]: mailboxId,
|
|
66
|
+
};
|
|
67
|
+
// Exclusivity: appointing a folder to one role clears it from any other.
|
|
68
|
+
if (mailboxId) {
|
|
69
|
+
for (const r of Object.keys(next)) {
|
|
70
|
+
if (r !== role && next[r] === mailboxId) next[r] = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return next;
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const handleRename = (mailboxId: string, name: string) =>
|
|
78
|
+
setDisplayNames((prev) => ({ ...prev, [mailboxId]: name.trim() }));
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="max-w-3xl p-8">
|
|
82
|
+
<RoleAppointmentList
|
|
83
|
+
accountEmail="440737+mvhenten@users.noreply.github.com"
|
|
84
|
+
folders={folders}
|
|
85
|
+
appointments={appointments}
|
|
86
|
+
displayNames={displayNames}
|
|
87
|
+
onAppoint={handleAppoint}
|
|
88
|
+
onRename={handleRename}
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const meta: Meta<typeof Harness> = {
|
|
95
|
+
title: "Settings/RoleAppointmentList",
|
|
96
|
+
component: Harness,
|
|
97
|
+
};
|
|
98
|
+
export default meta;
|
|
99
|
+
|
|
100
|
+
type Story = StoryObj<typeof Harness>;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The intended end state: each role points at the folder that holds the mail —
|
|
104
|
+
* Drafts → Concepten · 340, not the empty `INBOX/Drafts`; Sent → Sent Messages.
|
|
105
|
+
* The empty look-alikes drop to "Other folders".
|
|
106
|
+
*/
|
|
107
|
+
export const Hostnet: Story = {
|
|
108
|
+
args: {
|
|
109
|
+
folders: HOSTNET_FOLDERS,
|
|
110
|
+
initial: {
|
|
111
|
+
inbox: "mb-inbox",
|
|
112
|
+
drafts: "mb-concepten",
|
|
113
|
+
sent: "mb-sent-messages",
|
|
114
|
+
archive: "mb-archive",
|
|
115
|
+
junk: "mb-spam",
|
|
116
|
+
trash: "mb-deleted",
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Flag-first proposal before the user corrects it: detection appointed the
|
|
123
|
+
* `\Drafts`-flagged but empty `INBOX/Drafts` (0) and the empty `INBOX/Sent`.
|
|
124
|
+
* The picker counts reveal the real folders so the user can re-appoint.
|
|
125
|
+
*/
|
|
126
|
+
export const ProposedDefaults: Story = {
|
|
127
|
+
args: {
|
|
128
|
+
folders: HOSTNET_FOLDERS,
|
|
129
|
+
initial: {
|
|
130
|
+
inbox: "mb-inbox",
|
|
131
|
+
drafts: "mb-drafts",
|
|
132
|
+
sent: "mb-sent",
|
|
133
|
+
archive: "mb-archive",
|
|
134
|
+
junk: "mb-spam",
|
|
135
|
+
trash: "mb-deleted",
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
};
|