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:
@@ -90,6 +90,9 @@ export function MailLayout() {
|
|||||||
// Rows just archived/trashed/deleted from the list: greyed out and unclickable
|
// 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).
|
// for immediate feedback (search results don't always drop the row on refetch).
|
||||||
const [dimmedIds, setDimmedIds] = useState<Set<string>>(new Set());
|
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) =>
|
const toggleCheck = (id: string) =>
|
||||||
setCheckedIds((prev) => {
|
setCheckedIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -223,8 +226,9 @@ export function MailLayout() {
|
|||||||
total = ordered.length;
|
total = ordered.length;
|
||||||
pageThreads = ordered.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE);
|
pageThreads = ordered.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE);
|
||||||
} else {
|
} else {
|
||||||
pageThreads = threadsQuery.data?.threads ?? [];
|
const all = threadsQuery.data?.threads ?? [];
|
||||||
total = threadsQuery.data?.total ?? 0;
|
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 pageStart = page * PAGE_SIZE;
|
||||||
const pageEnd = pageStart + pageThreads.length;
|
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
|
// 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.
|
// 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).
|
// The feedback is immediate and the request runs behind it. In a folder the
|
||||||
// In search the row may still match a global query, so grey it out for feedback.
|
// row is hidden straight away (it is leaving the folder's scope); in search it
|
||||||
if (searching) setDimmedIds((prev) => new Set(prev).add(id));
|
// may still match a global query, so it is greyed out instead. A failed
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
// 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: ["search"] }); // keep search results in sync
|
||||||
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||||
if (id === threadId) navigate("/mail");
|
// Once the list has refetched without the row, it no longer needs hiding.
|
||||||
};
|
await queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||||
const listArchive = async (id: string) => {
|
if (!searching) unmark();
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
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) => {
|
const listTogglePin = async (id: string, pinned: boolean) => {
|
||||||
await api.threadFlags(id, pinned ? [] : ["\\Flagged"], pinned ? ["\\Flagged"] : []);
|
await api.threadFlags(id, pinned ? [] : ["\\Flagged"], pinned ? ["\\Flagged"] : []);
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||||
|
|||||||
Reference in New Issue
Block a user