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 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 19:48:12 +02:00
co-authored by Claude Opus 5.5
parent 6e65664766
commit e9c36c47af
+34 -24
View File
@@ -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<Set<string>>(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<Set<string>>(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<unknown>) => {
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"] });