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
+32 -15
View File
@@ -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<number | undefined>(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<ToastDetail>).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 (
<div
key={toast.seq}
@@ -34,11 +46,16 @@ export function Toast({ mobile }: { mobile: boolean }) {
className={
mobile
? // Level with the Compose button on the other side of the screen.
"fixed bottom-[calc(4.375rem+env(safe-area-inset-bottom))] left-4 z-40 flex h-11 items-center rounded-xl bg-app-fg px-4 text-[15px] font-medium text-app-surface shadow-lg"
: "fixed bottom-4 right-4 z-50 flex w-80 items-center rounded-lg bg-app-fg px-4 py-2.5 text-sm text-app-surface shadow-lg"
"fixed bottom-[calc(4.375rem+env(safe-area-inset-bottom))] left-4 z-40 flex h-11 items-center gap-4 rounded-xl bg-app-fg px-4 text-[15px] font-medium text-app-surface shadow-lg"
: "fixed bottom-4 right-4 z-50 flex w-80 items-center justify-between rounded-lg bg-app-fg px-4 py-2.5 text-sm text-app-surface shadow-lg"
}
>
{toast.text}
<span>{toast.text}</span>
{toast.action && (
<button type="button" className="font-medium text-app-accent hover:underline" onClick={act}>
{toast.action.label}
</button>
)}
</div>
);
}
+104 -4
View File
@@ -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<HTMLDivElement>(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.
<div
className={`flex h-full items-center gap-3 rounded-lg px-3 ${
selected || checked ? "bg-app-active" : "group-hover:bg-app-hover"
aria-hidden
className={`absolute inset-x-2 bottom-0 flex items-center rounded-lg bg-red-600 pl-5 text-white ${
compact ? "top-5" : "top-[7px]"
}`}
>
<span style={{ opacity: Math.min(1, swipe.dx / 80) }}>
<Icon name="trash" size={22} />
</span>
</div>
)}
<div
style={
swipe.id === thread.id
? {
transform: `translateX(${swipe.dx}px)`,
transition: swipe.dragging ? "none" : "transform 180ms ease-out",
}
: undefined
}
className={`relative flex h-full items-center gap-3 rounded-lg px-3 ${
selected || checked
? "bg-app-active"
: // Only while it moves: covering the red behind it, in the list's own colour.
`group-hover:bg-app-hover ${swipe.id === thread.id ? "bg-app-surface" : ""}`
}`}
>
{/* Avatar, replaced by a selection checkbox on avatar-hover or once
@@ -423,3 +454,72 @@ export function MessageList({
</div>
);
}
// --- 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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,
};
}
@@ -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<HTMLDivElement>(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"
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold text-app-fg">{t("display.title")}</h3>
@@ -48,11 +55,17 @@ export function MailboxDisplay({ onClose }: { onClose: () => void }) {
</svg>
</button>
</div>
{layouts ? (
<>
<p className="mb-2 text-[13px] font-medium text-app-fg">{t("display.readingPane")}</p>
<ReadingPanePicker onPick={onClose} />
<div className="mt-4 border-t border-app-border pt-3">
<ListDividersOption />
</div>
</>
) : (
<ListDividersOption />
)}
</div>
);
}
+1
View File
@@ -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",
+50 -4
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,8 +1210,8 @@ export function MailLayout() {
>
{t("mail.markAllRead")}
</button>
{/* 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. */}
<button
className="flex w-full items-center px-3 py-2 text-left text-[13px] text-app-fg hover:bg-app-hover"
onClick={() => {
@@ -1177,11 +1221,10 @@ export function MailLayout() {
>
{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>