feat: bulk actions for email lists

Select multiple rows (checkbox per row) in FolderView, SearchResults,
and the Senders email panel. A toolbar appears with mark read/unread,
star, archive, and trash, applied to the whole selection via
CleanupService.ExecuteAsync (already scoped to UserId).

Shared via useSelection hook and BulkToolbar component to avoid
duplicating selection state across the three list views.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-06-30 21:53:43 +02:00
parent 8c92263cc3
commit c11d747919
7 changed files with 135 additions and 5 deletions
+15
View File
@@ -51,6 +51,21 @@ export const CleanupApi = {
execute: (req) => api.post('/cleanup/execute', req).then((r) => r.data) execute: (req) => api.post('/cleanup/execute', req).then((r) => r.data)
}; };
// Bulk actions over an explicit set of email IDs (selection-driven, always confirmed —
// the user already opted in by selecting rows and clicking the action).
// Numeric values must match CleanupActionType in Domain/Enums/Enums.cs (no string
// enum converter is configured on the API, so plain numbers are required here).
const CLEANUP_ACTION = { Archive: 0, Trash: 1, MarkRead: 5, MarkUnread: 6, Star: 7, Unstar: 8 };
export const BulkApi = {
markRead: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.MarkRead, emailIds: ids, confirmed: true }),
markUnread: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.MarkUnread, emailIds: ids, confirmed: true }),
star: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.Star, emailIds: ids, confirmed: true }),
unstar: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.Unstar, emailIds: ids, confirmed: true }),
archive: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.Archive, emailIds: ids, confirmed: true }),
trash: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.Trash, emailIds: ids, confirmed: true }),
};
export const UnsubscribeApi = { export const UnsubscribeApi = {
detect: () => api.post('/unsubscribe/detect'), detect: () => api.post('/unsubscribe/detect'),
safeList: () => api.get('/unsubscribe/safe-list').then((r) => r.data), safeList: () => api.get('/unsubscribe/safe-list').then((r) => r.data),
+30
View File
@@ -0,0 +1,30 @@
import { BulkApi } from '../api/client.js';
/// <summary>
/// Toolbar shown above an email list when one or more rows are selected.
/// `onDone` is called with the action key after a successful bulk call so the
/// caller can update local state (e.g. remove trashed/archived rows).
/// </summary>
export default function BulkToolbar({ selectedIds, onDone, onClear }) {
const count = selectedIds.length;
if (count === 0) return null;
const run = (fn, action) => async () => {
await fn(selectedIds);
onDone(action, selectedIds);
onClear();
};
return (
<div className="bulk-toolbar">
<span>{count} selected</span>
<div className="bulk-toolbar-spacer" />
<button className="bulk-btn" onClick={run(BulkApi.markRead, 'read')}>Mark read</button>
<button className="bulk-btn" onClick={run(BulkApi.markUnread, 'unread')}>Mark unread</button>
<button className="bulk-btn" onClick={run(BulkApi.star, 'star')}>Star</button>
<button className="bulk-btn" onClick={run(BulkApi.archive, 'archive')}>Archive</button>
<button className="bulk-btn bulk-btn--danger" onClick={run(BulkApi.trash, 'trash')}>Trash</button>
<button className="bulk-btn" onClick={onClear}>Cancel</button>
</div>
);
}
+6 -1
View File
@@ -16,7 +16,7 @@ const fmtSize = (b) => {
return `${(b / 1048576).toFixed(1)} MB`; return `${(b / 1048576).toFixed(1)} MB`;
}; };
export default function EmailRow({ email: initial, onRemove }) { export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect }) {
const [email, setEmail] = useState(initial); const [email, setEmail] = useState(initial);
const [acting, setActing] = useState(false); const [acting, setActing] = useState(false);
@@ -72,6 +72,11 @@ export default function EmailRow({ email: initial, onRemove }) {
onClick={openInGmail} onClick={openInGmail}
title="Open in Gmail" title="Open in Gmail"
> >
{onToggleSelect && (
<td className="el-select" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" checked={!!selected} onChange={() => onToggleSelect(email.id)} />
</td>
)}
<td className="el-unread">{email.isUnread && <span className="unread-dot" />}</td> <td className="el-unread">{email.isUnread && <span className="unread-dot" />}</td>
<td className="el-sender" title={email.senderAddress}> <td className="el-sender" title={email.senderAddress}>
{email.senderDisplayName || email.senderAddress} {email.senderDisplayName || email.senderAddress}
+26
View File
@@ -0,0 +1,26 @@
import { useCallback, useState } from 'react';
/// <summary>Tracks a set of selected row IDs for bulk actions. Resets on `resetKey` change.</summary>
export default function useSelection() {
const [selected, setSelected] = useState(() => new Set());
const toggle = useCallback((id) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const clear = useCallback(() => setSelected(new Set()), []);
const removeIds = useCallback((ids) => {
setSelected((prev) => {
const next = new Set(prev);
ids.forEach((id) => next.delete(id));
return next;
});
}, []);
return { selected, toggle, clear, removeIds, selectedIds: [...selected] };
}
+18 -1
View File
@@ -2,6 +2,8 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { SearchApi } from '../api/client.js'; import { SearchApi } from '../api/client.js';
import EmailRow from '../components/EmailRow.jsx'; import EmailRow from '../components/EmailRow.jsx';
import BulkToolbar from '../components/BulkToolbar.jsx';
import useSelection from '../hooks/useSelection.js';
const FOLDER_META = { const FOLDER_META = {
inbox: { icon: '📥', label: 'Inbox' }, inbox: { icon: '📥', label: 'Inbox' },
@@ -43,6 +45,7 @@ export default function FolderView() {
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
// Reset when the folder changes // Reset when the folder changes
useEffect(() => { useEffect(() => {
@@ -51,7 +54,8 @@ export default function FolderView() {
setPage(1); setPage(1);
setHasMore(true); setHasMore(true);
setError(null); setError(null);
}, [slug]); clear();
}, [slug, clear]);
// Fetch a page and append results // Fetch a page and append results
const fetchPage = useCallback((p) => { const fetchPage = useCallback((p) => {
@@ -98,6 +102,17 @@ export default function FolderView() {
{error && <div className="fv-error">{error}</div>} {error && <div className="fv-error">{error}</div>}
<BulkToolbar
selectedIds={selectedIds}
onClear={clear}
onDone={(action, ids) => {
if (action === 'trash' || action === 'archive') {
setEmails((prev) => prev.filter((x) => !ids.includes(x.id)));
removeIds(ids);
}
}}
/>
{!loading && !error && emails.length === 0 && ( {!loading && !error && emails.length === 0 && (
<div className="fv-empty">No emails in this folder.</div> <div className="fv-empty">No emails in this folder.</div>
)} )}
@@ -109,6 +124,8 @@ export default function FolderView() {
<EmailRow <EmailRow
key={e.id} key={e.id}
email={e} email={e}
selected={selected.has(e.id)}
onToggleSelect={toggle}
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))} onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
/> />
))} ))}
+18 -1
View File
@@ -2,6 +2,8 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { SearchApi } from '../api/client.js'; import { SearchApi } from '../api/client.js';
import EmailRow from '../components/EmailRow.jsx'; import EmailRow from '../components/EmailRow.jsx';
import BulkToolbar from '../components/BulkToolbar.jsx';
import useSelection from '../hooks/useSelection.js';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -16,6 +18,7 @@ export default function SearchResults() {
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
useEffect(() => { useEffect(() => {
setEmails([]); setEmails([]);
@@ -23,7 +26,8 @@ export default function SearchResults() {
setPage(1); setPage(1);
setHasMore(true); setHasMore(true);
setError(null); setError(null);
}, [q]); clear();
}, [q, clear]);
const fetchPage = useCallback((p) => { const fetchPage = useCallback((p) => {
if (!q.trim()) return; if (!q.trim()) return;
@@ -70,6 +74,17 @@ export default function SearchResults() {
<div className="fv-empty">No results for "{q}".</div> <div className="fv-empty">No results for "{q}".</div>
)} )}
<BulkToolbar
selectedIds={selectedIds}
onClear={clear}
onDone={(action, ids) => {
if (action === 'trash' || action === 'archive') {
setEmails((prev) => prev.filter((x) => !ids.includes(x.id)));
removeIds(ids);
}
}}
/>
{emails.length > 0 && ( {emails.length > 0 && (
<table className="email-list"> <table className="email-list">
<tbody> <tbody>
@@ -77,6 +92,8 @@ export default function SearchResults() {
<EmailRow <EmailRow
key={e.id} key={e.id}
email={e} email={e}
selected={selected.has(e.id)}
onToggleSelect={toggle}
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))} onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
/> />
))} ))}
+22 -2
View File
@@ -1,5 +1,7 @@
import { useEffect, useCallback, useRef, useState } from 'react'; import { useEffect, useCallback, useRef, useState } from 'react';
import { AnalyticsApi, SearchApi, EmailApi } from '../api/client.js'; import { AnalyticsApi, SearchApi, EmailApi } from '../api/client.js';
import BulkToolbar from '../components/BulkToolbar.jsx';
import useSelection from '../hooks/useSelection.js';
// ── helpers ────────────────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────────────────
@@ -123,7 +125,7 @@ function EmailDetail({ email: summary, onBack }) {
// ── EmailListRow (row inside sender email list) ─────────────────────────────── // ── EmailListRow (row inside sender email list) ───────────────────────────────
function EmailListRow({ email: initial, onRemove, onOpen }) { function EmailListRow({ email: initial, onRemove, onOpen, selected, onToggleSelect }) {
const [email, setEmail] = useState(initial); const [email, setEmail] = useState(initial);
const [acting, setActing] = useState(false); const [acting, setActing] = useState(false);
@@ -150,6 +152,9 @@ function EmailListRow({ email: initial, onRemove, onOpen }) {
title="View email" title="View email"
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
<td className="el-select" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" checked={!!selected} onChange={() => onToggleSelect(email.id)} />
</td>
<td className="el-unread">{email.isUnread && <span className="unread-dot" />}</td> <td className="el-unread">{email.isUnread && <span className="unread-dot" />}</td>
<td className="el-subject"> <td className="el-subject">
<span className="el-subj-text">{email.subject || '(no subject)'}</span> <span className="el-subj-text">{email.subject || '(no subject)'}</span>
@@ -195,6 +200,7 @@ function SenderEmails({ sender }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [openEmail, setOpenEmail] = useState(null); const [openEmail, setOpenEmail] = useState(null);
const sentinelRef = useRef(null); const sentinelRef = useRef(null);
const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
// Reset when sender changes // Reset when sender changes
useEffect(() => { useEffect(() => {
@@ -203,7 +209,8 @@ function SenderEmails({ sender }) {
setPage(1); setPage(1);
setHasMore(true); setHasMore(true);
setOpenEmail(null); setOpenEmail(null);
}, [sender.senderId]); clear();
}, [sender.senderId, clear]);
const fetchPage = useCallback((p) => { const fetchPage = useCallback((p) => {
setLoading(true); setLoading(true);
@@ -245,6 +252,17 @@ function SenderEmails({ sender }) {
)} )}
</div> </div>
<BulkToolbar
selectedIds={selectedIds}
onClear={clear}
onDone={(action, ids) => {
if (action === 'trash' || action === 'archive') {
setEmails((prev) => prev.filter((x) => !ids.includes(x.id)));
removeIds(ids);
}
}}
/>
{emails.length > 0 && ( {emails.length > 0 && (
<table className="email-list"> <table className="email-list">
<tbody> <tbody>
@@ -252,6 +270,8 @@ function SenderEmails({ sender }) {
<EmailListRow <EmailListRow
key={e.id} key={e.id}
email={e} email={e}
selected={selected.has(e.id)}
onToggleSelect={toggle}
onOpen={setOpenEmail} onOpen={setOpenEmail}
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))} onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
/> />