From 30b806e7da15c810c6cd0bccf45daae1fb2e473d Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Mon, 12 Oct 2026 19:46:19 +0200 Subject: [PATCH] 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 --- frontend/src/lib/api.ts | 9 ++- frontend/src/routes/MailLayout.tsx | 89 ++++++++++++++++++++++++------ 2 files changed, 78 insertions(+), 20 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 82cda72..2231e38 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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) => diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx index e7cead4..1d3c319 100644 --- a/frontend/src/routes/MailLayout.tsx +++ b/frontend/src/routes/MailLayout.tsx @@ -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) { + 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>(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>(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()); + 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).