From e9c36c47af9d8f8fc25f0c544e9e9f31b801e9fc Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Wed, 30 Sep 2026 19:48:12 +0200 Subject: [PATCH] Mail list: hide a row the moment its archive/delete button is clicked The hover actions waited for the server before the list reacted. The row now leaves the list at once and the request runs behind it; a failed request puts it back. In search the row greys out immediately instead, since it may still match the query. Co-Authored-By: Claude Opus 5.5 --- frontend/src/routes/MailLayout.tsx | 58 +++++++++++++++++------------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx index 1ac3350..d123ecf 100644 --- a/frontend/src/routes/MailLayout.tsx +++ b/frontend/src/routes/MailLayout.tsx @@ -90,6 +90,9 @@ export function MailLayout() { // Rows just archived/trashed/deleted from the list: greyed out and unclickable // for immediate feedback (search results don't always drop the row on refetch). const [dimmedIds, setDimmedIds] = useState>(new Set()); + // Rows a hover action is moving out of the folder: gone from the list the + // moment the button is clicked, while the request runs behind it. + const [hiddenIds, setHiddenIds] = useState>(new Set()); const toggleCheck = (id: string) => setCheckedIds((prev) => { const next = new Set(prev); @@ -223,8 +226,9 @@ export function MailLayout() { total = ordered.length; pageThreads = ordered.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE); } else { - pageThreads = threadsQuery.data?.threads ?? []; - total = threadsQuery.data?.total ?? 0; + const all = threadsQuery.data?.threads ?? []; + pageThreads = hiddenIds.size ? all.filter((th) => !hiddenIds.has(th.id)) : all; + total = Math.max(0, (threadsQuery.data?.total ?? 0) - (all.length - pageThreads.length)); } const pageStart = page * PAGE_SIZE; const pageEnd = pageStart + pageThreads.length; @@ -455,32 +459,38 @@ export function MailLayout() { // Per-thread actions for the mail-list hover buttons (act on a given id, not the // currently-open thread). Close the reading pane only if the acted thread is open. - const listAfterMove = (id: string) => { - // In a folder the refetch below removes the row (it left the folder's scope). - // In search the row may still match a global query, so grey it out for feedback. - if (searching) setDimmedIds((prev) => new Set(prev).add(id)); - queryClient.invalidateQueries({ queryKey: ["threads"] }); + // + // The feedback is immediate and the request runs behind it. In a folder the + // row is hidden straight away (it is leaving the folder's scope); in search it + // may still match a global query, so it is greyed out instead. A failed + // request puts the row back. + const listMove = async (id: string, action: (id: string) => Promise) => { + const mark = searching ? setDimmedIds : setHiddenIds; + const unmark = () => + mark((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + mark((prev) => new Set(prev).add(id)); + if (id === threadId) navigate("/mail"); + try { + await action(id); + } catch { + unmark(); + return; + } queryClient.invalidateQueries({ queryKey: ["search"] }); // keep search results in sync queryClient.invalidateQueries({ queryKey: ["quota"] }); queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); - if (id === threadId) navigate("/mail"); - }; - const listArchive = async (id: string) => { - await api.archiveThread(id); - listAfterMove(id); - }; - const listTrash = async (id: string) => { - await api.trashThread(id); - listAfterMove(id); - }; - const listRestore = async (id: string) => { - await api.restoreThread(id); - listAfterMove(id); - }; - const listDeletePermanent = async (id: string) => { - await api.deleteThreadPermanent(id); - listAfterMove(id); + // Once the list has refetched without the row, it no longer needs hiding. + await queryClient.invalidateQueries({ queryKey: ["threads"] }); + if (!searching) unmark(); }; + const listArchive = (id: string) => listMove(id, api.archiveThread); + const listTrash = (id: string) => listMove(id, api.trashThread); + const listRestore = (id: string) => listMove(id, api.restoreThread); + const listDeletePermanent = (id: string) => listMove(id, api.deleteThreadPermanent); const listTogglePin = async (id: string, pinned: boolean) => { await api.threadFlags(id, pinned ? [] : ["\\Flagged"], pinned ? ["\\Flagged"] : []); queryClient.invalidateQueries({ queryKey: ["threads"] });