Phone swipe-to-delete survives a refresh or folder change

A swipe's delete waited out the Undo window in a page timer: a refresh lost
it, and changing folder un-hid the row. Pending swipes are now remembered in
the browser and sent on the next load (plus a keepalive request as the page
closes), and swiped rows stay hidden in every folder until sent or undone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 19:46:19 +02:00
co-authored by Claude Opus 5.5
parent b88855931e
commit 30b806e7da
2 changed files with 78 additions and 20 deletions
+7 -2
View File
@@ -373,8 +373,13 @@ export const api = {
}),
archiveThread: (id: string) =>
apiFetch<{ status: string }>(`/threads/${id}/archive`, { method: "POST" }),
trashThread: (id: string) =>
apiFetch<{ status: string }>(`/threads/${id}/trash`, { method: "POST" }),
// keepalive: still delivered when sent as the page closes (a swipe's
// pending delete on refresh).
trashThread: (id: string, opts?: { keepalive?: boolean }) =>
apiFetch<{ status: string }>(`/threads/${id}/trash`, {
method: "POST",
keepalive: opts?.keepalive,
}),
restoreThread: (id: string) =>
apiFetch<{ status: string }>(`/threads/${id}/restore`, { method: "POST" }),
deleteThreadPermanent: (id: string) =>
+71 -18
View File
@@ -82,6 +82,27 @@ const FOLDERS: { view: string; icon: string; label: MessageKey }[] = [
{ view: "folder:trash", icon: "trash", label: "nav.trash" },
];
// Phone swipes still in their Undo window, remembered across a refresh: the
// page's timer dies with it, so the next load sends what is left.
const PENDING_SWIPES_KEY = "mail.pendingSwipes";
function storedSwipes(): string[] {
try {
const v: unknown = JSON.parse(localStorage.getItem(PENDING_SWIPES_KEY) ?? "[]");
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
}
function storeSwipes(ids: Iterable<string>) {
try {
const list = [...ids];
if (list.length) localStorage.setItem(PENDING_SWIPES_KEY, JSON.stringify(list));
else localStorage.removeItem(PENDING_SWIPES_KEY);
} catch {
// Storage blocked: the swipe still works within the page.
}
}
export function MailLayout() {
const t = useT();
const navigate = useNavigate();
@@ -133,6 +154,10 @@ export function MailLayout() {
// 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());
// Phone: rows swiped away, waiting out their Undo — hidden in every folder
// until then (unlike hiddenIds, not reset when the folder changes).
const [swipedIds, setSwipedIds] = useState<Set<string>>(new Set());
const isHidden = (id: string) => hiddenIds.has(id) || swipedIds.has(id);
const toggleCheck = (id: string) =>
setCheckedIds((prev) => {
const next = new Set(prev);
@@ -335,7 +360,7 @@ export function MailLayout() {
let pageThreads: ThreadSummary[];
let total: number;
if (searching) {
const shown = searchThreads.filter((th) => !hiddenIds.has(th.id));
const shown = searchThreads.filter((th) => !isHidden(th.id));
const ordered = listOrder === "newest" ? shown : [...shown].reverse();
// The server's count of matching conversations; without one (or while
// still loading pages), what has been loaded.
@@ -347,7 +372,7 @@ export function MailLayout() {
pageThreads = ordered.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE);
} else {
const all = threadsQuery.data?.threads ?? [];
pageThreads = hiddenIds.size ? all.filter((th) => !hiddenIds.has(th.id)) : all;
pageThreads = hiddenIds.size || swipedIds.size ? all.filter((th) => !isHidden(th.id)) : all;
total = Math.max(0, (threadsQuery.data?.total ?? 0) - (all.length - pageThreads.length));
}
const pageStart = page * PAGE_SIZE;
@@ -682,14 +707,22 @@ export function MailLayout() {
// again (moving it back from Trash could race the server's move).
const SWIPE_UNDO_MS = 5000;
const pendingSwipes = useRef(new Map<string, number>());
const unswipe = (id: string) =>
setSwipedIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
const swipeTrash = (id: string) => {
setHiddenIds((prev) => new Set(prev).add(id));
setSwipedIds((prev) => new Set(prev).add(id));
if (id === threadId) navigate("/mail");
const timer = window.setTimeout(() => {
pendingSwipes.current.delete(id);
void listMove(id, api.trashThread);
storeSwipes(pendingSwipes.current.keys());
void listMove(id, api.trashThread).finally(() => unswipe(id));
}, SWIPE_UNDO_MS);
pendingSwipes.current.set(id, timer);
storeSwipes(pendingSwipes.current.keys());
window.dispatchEvent(
new CustomEvent("webmail:toast", {
detail: {
@@ -700,27 +733,36 @@ export function MailLayout() {
run: () => {
window.clearTimeout(pendingSwipes.current.get(id));
pendingSwipes.current.delete(id);
setHiddenIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
storeSwipes(pendingSwipes.current.keys());
unswipe(id);
},
},
},
}),
);
};
// Leaving before the toast is gone still deletes what was swiped.
useEffect(
() => () => {
for (const [id, timer] of pendingSwipes.current) {
window.clearTimeout(timer);
void api.trashThread(id);
// Leaving before the toast is gone still deletes what was swiped. Leaving
// the mail view sends it now. A refresh or closing the page outlives no
// timer: a keepalive request is tried, and the swipe stays remembered for the
// next load to send (trashing it twice is harmless).
useEffect(() => {
const pending = pendingSwipes.current;
const onPageHide = () => {
for (const id of pending.keys()) {
api.trashThread(id, { keepalive: true }).catch(() => undefined);
}
},
[],
);
};
window.addEventListener("pagehide", onPageHide);
return () => {
window.removeEventListener("pagehide", onPageHide);
for (const [id, timer] of pending) {
window.clearTimeout(timer);
api.trashThread(id).catch(() => undefined);
}
pending.clear();
storeSwipes([]);
};
}, []);
const listRestore = (id: string) => listMove(id, api.restoreThread);
const listDeletePermanent = (id: string) => listMove(id, api.deleteThreadPermanent);
const listTogglePin = async (id: string, pinned: boolean) => {
@@ -734,6 +776,17 @@ export function MailLayout() {
useEffect(() => setCheckedIds(new Set()), [view]);
useEffect(() => setDimmedIds(new Set()), [view, debouncedTerm]);
useEffect(() => setHiddenIds(new Set()), [view]);
// Swipes a refresh cut short (above): sent now, their rows kept hidden. After
// the reset above, so the hiding sticks.
useEffect(() => {
const left = storedSwipes();
if (!left.length) return;
storeSwipes([]);
setSwipedIds((prev) => new Set([...prev, ...left]));
for (const id of left) void listMove(id, api.trashThread).finally(() => unswipe(id));
// Once, on load.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Bulk actions run the given operation over every selected thread, then clear the
// selection and refresh (navigating away if the open thread was among them).