From 9f278626c0a54f0c6116229fc126d57125e6dfe6 Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Wed, 7 Oct 2026 21:27:53 +0200 Subject: [PATCH] Phone: swipe right to delete, Mailbox display with the dividing lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/components/Toast.tsx | 47 +++++--- frontend/src/components/list/MessageList.tsx | 108 +++++++++++++++++- .../src/components/mail/MailboxDisplay.tsx | 25 +++- frontend/src/i18n/en.ts | 1 + frontend/src/routes/MailLayout.tsx | 72 +++++++++--- 5 files changed, 215 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx index 4fe973c..d06c439 100644 --- a/frontend/src/components/Toast.tsx +++ b/frontend/src/components/Toast.tsx @@ -1,32 +1,44 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; -// A short confirmation in the corner — the Undo-send toast's look, without the -// button. Raised by anything (core or a plugin bundle) with +// A short confirmation in the corner — the Undo-send toast's look. Raised by +// anything (core or a plugin bundle) with // window.dispatchEvent(new CustomEvent("webmail:toast", { detail: { text } })) -// e.g. "Reply sent" after answering an invitation. Gone after a few seconds. +// e.g. "Reply sent" after answering an invitation, optionally with one action +// (`action: { label, run }`, e.g. Undo) and its own duration (`ms`). const SHOW_MS = 3500; +interface ToastDetail { + text?: string; + ms?: number; + action?: { label: string; run: () => void }; +} + export function Toast({ mobile }: { mobile: boolean }) { - const [toast, setToast] = useState<{ text: string; seq: number } | null>(null); + const [toast, setToast] = useState<(ToastDetail & { seq: number }) | null>(null); + const timer = useRef(undefined); useEffect(() => { - let timer: number | undefined; let seq = 0; const onToast = (e: Event) => { - const text = (e as CustomEvent<{ text?: string }>).detail?.text; - if (!text) return; - setToast({ text, seq: ++seq }); - window.clearTimeout(timer); - timer = window.setTimeout(() => setToast(null), SHOW_MS); + const detail = (e as CustomEvent).detail; + if (!detail?.text) return; + setToast({ ...detail, seq: ++seq }); + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setToast(null), detail.ms ?? SHOW_MS); }; window.addEventListener("webmail:toast", onToast); return () => { window.removeEventListener("webmail:toast", onToast); - window.clearTimeout(timer); + window.clearTimeout(timer.current); }; }, []); if (!toast) return null; + const act = () => { + window.clearTimeout(timer.current); + setToast(null); + toast.action?.run(); + }; return (
- {toast.text} + {toast.text} + {toast.action && ( + + )}
); } diff --git a/frontend/src/components/list/MessageList.tsx b/frontend/src/components/list/MessageList.tsx index ced0be6..02f2bb6 100644 --- a/frontend/src/components/list/MessageList.tsx +++ b/frontend/src/components/list/MessageList.tsx @@ -1,6 +1,6 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { useT } from "../../i18n"; import { api } from "../../lib/api"; @@ -96,6 +96,7 @@ export function MessageList({ dimmedIds, onToggleCheck, compact = false, + onSwipeDelete, }: { threads: ThreadSummary[]; /** Which page of the list this is. Changing it scrolls back to the top. */ @@ -113,9 +114,12 @@ export function MessageList({ onToggleCheck: (id: string) => void; /** Phone layout: sender, subject and snippet stacked; no hover-only actions. */ compact?: boolean; + /** Phone: swiping a row to the right deletes it (absent: no swipe). */ + onSwipeDelete?: (id: string) => void; }) { const t = useT(); const parentRef = useRef(null); + const swipe = useSwipeToDelete(onSwipeDelete); // In Trash/Spam/Archive the middle action restores; elsewhere it archives. const isRemovalFolder = folder === "trash" || folder === "junk" || folder === "archive"; @@ -193,7 +197,8 @@ export function MessageList({ role="button" tabIndex={dimmed ? -1 : 0} aria-disabled={dimmed} - onClick={() => !dimmed && onSelect(thread.id)} + onClick={() => !dimmed && !swipe.justSwiped() && onSelect(thread.id)} + {...(onSwipeDelete && !dimmed ? swipe.handlers(thread.id) : {})} onKeyDown={(e) => { if (!dimmed && (e.key === "Enter" || e.key === " ")) onSelect(thread.id); }} @@ -204,6 +209,8 @@ export function MessageList({ width: "100%", height: item.size, transform: `translateY(${item.start}px)`, + // Sideways drags are the swipe; up and down still scroll the list. + ...(onSwipeDelete ? { touchAction: "pan-y" } : {}), }} // A row leaving the list lets the ones below glide up into its place. className={`group absolute inset-x-0 px-2 ${compact ? "pt-5" : "pt-[7px]"} motion-safe:transition-transform motion-safe:duration-200 motion-safe:ease-out ${ @@ -219,9 +226,33 @@ export function MessageList({ }`} /> )} + {swipe.id === thread.id && swipe.dx > 0 && ( + // Behind the row as it moves: where it is going. +
+ + + +
+ )}
{/* Avatar, replaced by a selection checkbox on avatar-hover or once @@ -423,3 +454,72 @@ export function MessageList({
); } + + +// --- phone: swipe a row right to delete it ---------------------------------------- + +// Past this share of the row's width, letting go deletes; short of it the row +// springs back. A drag must also start clearly sideways, or it is a scroll. +const SWIPE_DELETE_SHARE = 0.35; +const SWIPE_START_PX = 12; + +function useSwipeToDelete(onDelete?: (id: string) => void) { + const [state, setState] = useState<{ id: string; dx: number; dragging: boolean } | null>(null); + const start = useRef<{ id: string; x: number; y: number; width: number; active: boolean } | null>( + null, + ); + const swipedAt = useRef(0); + + const handlers = (id: string) => ({ + onPointerDown: (e: React.PointerEvent) => { + if (e.pointerType === "mouse" || !onDelete) return; + start.current = { id, x: e.clientX, y: e.clientY, width: e.currentTarget.offsetWidth, active: false }; + }, + onPointerMove: (e: React.PointerEvent) => { + const s = start.current; + if (!s || s.id !== id) return; + const dx = e.clientX - s.x; + const dy = e.clientY - s.y; + if (!s.active) { + if (Math.abs(dy) > SWIPE_START_PX && Math.abs(dy) > Math.abs(dx)) { + start.current = null; // a scroll + return; + } + if (dx < SWIPE_START_PX || dx < Math.abs(dy) * 1.5) return; + s.active = true; + e.currentTarget.setPointerCapture(e.pointerId); + } + setState({ id, dx: Math.max(0, dx), dragging: true }); + }, + onPointerUp: (e: React.PointerEvent) => { + const s = start.current; + start.current = null; + if (!s || !s.active || s.id !== id) return; + swipedAt.current = Date.now(); + const dx = Math.max(0, e.clientX - s.x); + if (dx > s.width * SWIPE_DELETE_SHARE) { + setState({ id, dx: s.width, dragging: false }); // off it goes + window.setTimeout(() => { + setState(null); + onDelete?.(id); + }, 180); + } else { + setState({ id, dx: 0, dragging: false }); // back it comes + window.setTimeout(() => setState((cur) => (cur && cur.id === id && cur.dx === 0 ? null : cur)), 200); + } + }, + onPointerCancel: () => { + start.current = null; + setState((cur) => (cur && cur.id === id ? { id, dx: 0, dragging: false } : cur)); + }, + }); + + return { + id: state?.id ?? null, + dx: state?.dx ?? 0, + dragging: state?.dragging ?? false, + handlers, + // The click that ends a swipe is not a tap on the row. + justSwiped: () => Date.now() - swipedAt.current < 400, + }; +} diff --git a/frontend/src/components/mail/MailboxDisplay.tsx b/frontend/src/components/mail/MailboxDisplay.tsx index 6fb6ef0..ba7b001 100644 --- a/frontend/src/components/mail/MailboxDisplay.tsx +++ b/frontend/src/components/mail/MailboxDisplay.tsx @@ -10,7 +10,14 @@ import { ReadingPanePicker } from "./ReadingPanePicker"; * closes the popover; the dividers switch applies at once and leaves it open; * ✕, Escape or a click elsewhere closes it. */ -export function MailboxDisplay({ onClose }: { onClose: () => void }) { +export function MailboxDisplay({ + onClose, + layouts = true, +}: { + onClose: () => void; + /** The reading-pane layouts; off on a phone, which always uses one panel. */ + layouts?: boolean; +}) { const t = useT(); const ref = useRef(null); @@ -32,7 +39,7 @@ export function MailboxDisplay({ onClose }: { onClose: () => void }) { ref={ref} role="dialog" aria-label={t("display.title")} - className="absolute right-0 top-full z-30 mt-1 w-[360px] rounded-xl border border-app-border bg-app-elevated p-4 shadow-2xl" + className="absolute right-0 top-full z-30 mt-1 w-[360px] max-w-[calc(100vw-1.5rem)] rounded-xl border border-app-border bg-app-elevated p-4 shadow-2xl" >

{t("display.title")}

@@ -48,11 +55,17 @@ export function MailboxDisplay({ onClose }: { onClose: () => void }) {
-

{t("display.readingPane")}

- -
+ {layouts ? ( + <> +

{t("display.readingPane")}

+ +
+ +
+ + ) : ( -
+ )} ); } diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 49a86da..af64b9c 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -184,6 +184,7 @@ export const en = { "panel.noAttachments": "No attachments", "panel.download": "Download", "mail.emptyConfirm": "Permanently delete everything in this folder? This can't be undone.", + "mail.movedToTrash": "Moved to Trash", "mail.loadFailed": "Couldn't load this message.", "mail.imagesBlocked": "Images from this sender are blocked.", "mail.loadImages": "Load images", diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx index 3fd15c9..56dacc4 100644 --- a/frontend/src/routes/MailLayout.tsx +++ b/frontend/src/routes/MailLayout.tsx @@ -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()); + 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")} - {/* A phone always reads in a single panel: nothing to choose. */} - {!mobile && ( - - )} + {/* On a phone it holds only the dividing lines: it always reads in a + single panel, so there is no layout to choose. */} + )} - {displayOpen && } + {displayOpen && } @@ -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} /> )}