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:
@@ -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) =>
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user