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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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
|
||||
className={`flex h-full items-center gap-3 rounded-lg px-3 ${
|
||||
selected || checked ? "bg-app-active" : "group-hover:bg-app-hover"
|
||||
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>
|
||||
<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">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user