Phone: swipe right to delete, Mailbox display with the dividing lines

On a phone, a conversation dragged to the right shows a red bin behind it;
let go past a third of the row and it goes to Trash, short of that it
springs back. Vertical drags still scroll (touch-action: pan-y), and a swipe
never opens the conversation. "Moved to Trash · Undo" shows for 5 s and the
move is made only after that, like Undo send — so Undo just shows the row
again; leaving the page still deletes. Not in Trash (it would be for good)
and not offline.

The ⋯ menu on a phone gains Mailbox display, holding only "Show a dividing
line between rows" (a phone has no layouts to choose).

The corner toast takes an optional action (label + run) and duration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 21:27:53 +02:00
co-authored by Claude Opus 5.5
parent 1982e8964b
commit 9f278626c0
5 changed files with 215 additions and 38 deletions
+59 -13
View File
@@ -581,6 +581,50 @@ export function MailLayout() {
}, [threadsQuery.data]);
const listArchive = (id: string) => listMove(id, api.archiveThread);
const listTrash = (id: string) => listMove(id, api.trashThread);
// Phone: a conversation swiped right goes to Trash — after a short Undo
// window, like Undo send. Until then it is only hidden, so Undo just shows it
// 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 swipeTrash = (id: string) => {
setHiddenIds((prev) => new Set(prev).add(id));
if (id === threadId) navigate("/mail");
const timer = window.setTimeout(() => {
pendingSwipes.current.delete(id);
void listMove(id, api.trashThread);
}, SWIPE_UNDO_MS);
pendingSwipes.current.set(id, timer);
window.dispatchEvent(
new CustomEvent("webmail:toast", {
detail: {
text: t("mail.movedToTrash"),
ms: SWIPE_UNDO_MS,
action: {
label: t("compose.undo"),
run: () => {
window.clearTimeout(pendingSwipes.current.get(id));
pendingSwipes.current.delete(id);
setHiddenIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
},
},
},
}),
);
};
// 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);
}
},
[],
);
const listRestore = (id: string) => listMove(id, api.restoreThread);
const listDeletePermanent = (id: string) => listMove(id, api.deleteThreadPermanent);
const listTogglePin = async (id: string, pinned: boolean) => {
@@ -1166,22 +1210,21 @@ export function MailLayout() {
>
{t("mail.markAllRead")}
</button>
{/* A phone always reads in a single panel: nothing to choose. */}
{!mobile && (
<button
className="flex w-full items-center px-3 py-2 text-left text-[13px] text-app-fg hover:bg-app-hover"
onClick={() => {
setMoreOpen(false);
setDisplayOpen(true);
}}
>
{t("display.title")}
</button>
)}
{/* On a phone it holds only the dividing lines: it always reads in a
single panel, so there is no layout to choose. */}
<button
className="flex w-full items-center px-3 py-2 text-left text-[13px] text-app-fg hover:bg-app-hover"
onClick={() => {
setMoreOpen(false);
setDisplayOpen(true);
}}
>
{t("display.title")}
</button>
</div>
</>
)}
{displayOpen && <MailboxDisplay onClose={closeDisplay} />}
{displayOpen && <MailboxDisplay onClose={closeDisplay} layouts={!mobile} />}
</div>
</div>
</div>
@@ -1247,6 +1290,9 @@ export function MailLayout() {
dimmedIds={dimmedIds}
onToggleCheck={toggleCheck}
compact={mobile}
// Phone: swipe right to delete — not in Trash (that would be for
// good) and not offline (nothing could be moved).
onSwipeDelete={mobile && currentFolder !== "trash" && !offline ? swipeTrash : undefined}
/>
)}
</div>