From c11d747919592e9f66c32c8a4a4e5bd70aa2a0e7 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 30 Jun 2026 21:53:43 +0200 Subject: [PATCH] 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 --- frontend/src/api/client.js | 15 +++++++++++++ frontend/src/components/BulkToolbar.jsx | 30 +++++++++++++++++++++++++ frontend/src/components/EmailRow.jsx | 7 +++++- frontend/src/hooks/useSelection.js | 26 +++++++++++++++++++++ frontend/src/pages/FolderView.jsx | 19 +++++++++++++++- frontend/src/pages/SearchResults.jsx | 19 +++++++++++++++- frontend/src/pages/Senders.jsx | 24 ++++++++++++++++++-- 7 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/BulkToolbar.jsx create mode 100644 frontend/src/hooks/useSelection.js diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 14ab996..8329a08 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -51,6 +51,21 @@ export const CleanupApi = { 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 = { detect: () => api.post('/unsubscribe/detect'), safeList: () => api.get('/unsubscribe/safe-list').then((r) => r.data), diff --git a/frontend/src/components/BulkToolbar.jsx b/frontend/src/components/BulkToolbar.jsx new file mode 100644 index 0000000..ef2bcfe --- /dev/null +++ b/frontend/src/components/BulkToolbar.jsx @@ -0,0 +1,30 @@ +import { BulkApi } from '../api/client.js'; + +/// +/// 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). +/// +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 ( +
+ {count} selected +
+ + + + + + +
+ ); +} diff --git a/frontend/src/components/EmailRow.jsx b/frontend/src/components/EmailRow.jsx index 8fc63fb..d7819b5 100644 --- a/frontend/src/components/EmailRow.jsx +++ b/frontend/src/components/EmailRow.jsx @@ -16,7 +16,7 @@ const fmtSize = (b) => { 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 [acting, setActing] = useState(false); @@ -72,6 +72,11 @@ export default function EmailRow({ email: initial, onRemove }) { onClick={openInGmail} title="Open in Gmail" > + {onToggleSelect && ( + e.stopPropagation()}> + onToggleSelect(email.id)} /> + + )} {email.isUnread && } {email.senderDisplayName || email.senderAddress} diff --git a/frontend/src/hooks/useSelection.js b/frontend/src/hooks/useSelection.js new file mode 100644 index 0000000..9fd6c4b --- /dev/null +++ b/frontend/src/hooks/useSelection.js @@ -0,0 +1,26 @@ +import { useCallback, useState } from 'react'; + +/// Tracks a set of selected row IDs for bulk actions. Resets on `resetKey` change. +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] }; +} diff --git a/frontend/src/pages/FolderView.jsx b/frontend/src/pages/FolderView.jsx index 2d0107c..fb56f64 100644 --- a/frontend/src/pages/FolderView.jsx +++ b/frontend/src/pages/FolderView.jsx @@ -2,6 +2,8 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { useParams } from 'react-router-dom'; import { SearchApi } from '../api/client.js'; import EmailRow from '../components/EmailRow.jsx'; +import BulkToolbar from '../components/BulkToolbar.jsx'; +import useSelection from '../hooks/useSelection.js'; const FOLDER_META = { inbox: { icon: '📥', label: 'Inbox' }, @@ -43,6 +45,7 @@ export default function FolderView() { const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); // Reset when the folder changes useEffect(() => { @@ -51,7 +54,8 @@ export default function FolderView() { setPage(1); setHasMore(true); setError(null); - }, [slug]); + clear(); + }, [slug, clear]); // Fetch a page and append results const fetchPage = useCallback((p) => { @@ -98,6 +102,17 @@ export default function FolderView() { {error &&
{error}
} + { + if (action === 'trash' || action === 'archive') { + setEmails((prev) => prev.filter((x) => !ids.includes(x.id))); + removeIds(ids); + } + }} + /> + {!loading && !error && emails.length === 0 && (
No emails in this folder.
)} @@ -109,6 +124,8 @@ export default function FolderView() { setEmails((prev) => prev.filter((x) => x.id !== id))} /> ))} diff --git a/frontend/src/pages/SearchResults.jsx b/frontend/src/pages/SearchResults.jsx index 56c01c9..fb501fe 100644 --- a/frontend/src/pages/SearchResults.jsx +++ b/frontend/src/pages/SearchResults.jsx @@ -2,6 +2,8 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import { SearchApi } from '../api/client.js'; import EmailRow from '../components/EmailRow.jsx'; +import BulkToolbar from '../components/BulkToolbar.jsx'; +import useSelection from '../hooks/useSelection.js'; const PAGE_SIZE = 50; @@ -16,6 +18,7 @@ export default function SearchResults() { const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); useEffect(() => { setEmails([]); @@ -23,7 +26,8 @@ export default function SearchResults() { setPage(1); setHasMore(true); setError(null); - }, [q]); + clear(); + }, [q, clear]); const fetchPage = useCallback((p) => { if (!q.trim()) return; @@ -70,6 +74,17 @@ export default function SearchResults() {
No results for "{q}".
)} + { + if (action === 'trash' || action === 'archive') { + setEmails((prev) => prev.filter((x) => !ids.includes(x.id))); + removeIds(ids); + } + }} + /> + {emails.length > 0 && ( @@ -77,6 +92,8 @@ export default function SearchResults() { setEmails((prev) => prev.filter((x) => x.id !== id))} /> ))} diff --git a/frontend/src/pages/Senders.jsx b/frontend/src/pages/Senders.jsx index 688b53d..e432dc3 100644 --- a/frontend/src/pages/Senders.jsx +++ b/frontend/src/pages/Senders.jsx @@ -1,5 +1,7 @@ import { useEffect, useCallback, useRef, useState } from 'react'; import { AnalyticsApi, SearchApi, EmailApi } from '../api/client.js'; +import BulkToolbar from '../components/BulkToolbar.jsx'; +import useSelection from '../hooks/useSelection.js'; // ── helpers ────────────────────────────────────────────────────────────────── @@ -123,7 +125,7 @@ function EmailDetail({ email: summary, onBack }) { // ── 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 [acting, setActing] = useState(false); @@ -150,6 +152,9 @@ function EmailListRow({ email: initial, onRemove, onOpen }) { title="View email" style={{ cursor: 'pointer' }} > +
e.stopPropagation()}> + onToggleSelect(email.id)} /> + {email.isUnread && } {email.subject || '(no subject)'} @@ -195,6 +200,7 @@ function SenderEmails({ sender }) { const [loading, setLoading] = useState(false); const [openEmail, setOpenEmail] = useState(null); const sentinelRef = useRef(null); + const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); // Reset when sender changes useEffect(() => { @@ -203,7 +209,8 @@ function SenderEmails({ sender }) { setPage(1); setHasMore(true); setOpenEmail(null); - }, [sender.senderId]); + clear(); + }, [sender.senderId, clear]); const fetchPage = useCallback((p) => { setLoading(true); @@ -245,6 +252,17 @@ function SenderEmails({ sender }) { )} + { + if (action === 'trash' || action === 'archive') { + setEmails((prev) => prev.filter((x) => !ids.includes(x.id))); + removeIds(ids); + } + }} + /> + {emails.length > 0 && ( @@ -252,6 +270,8 @@ function SenderEmails({ sender }) { setEmails((prev) => prev.filter((x) => x.id !== id))} />