feat(mobile): swipe list rows left/right for quick actions

Adds Gmail-style horizontal swipe actions to message rows on touch
devices. Swiping a row past a threshold fires a configurable action and
snaps back; a coloured background with an icon is revealed under the
finger and grows when the release would commit.

- New `useSwipeActions` hook: claims a touch as a swipe only once the
  horizontal travel dominates (vertical stays native list scrolling via
  `touch-action: pan-y`, no preventDefault), resists past the commit
  distance, and suppresses the tap that would otherwise follow.
- Two settings (Settings -> reading/email behavior): "Swipe right
  action" (default Archive) and "Swipe left action" (default Delete),
  each one of none / archive / delete / toggle read / toggle star /
  spam. A direction set to an action the current folder does not expose
  simply does nothing.
- Composes with the existing long-press (context menu) handlers; desktop
  is unchanged (hover actions + right-click).
- Scoped to single-message rows; unit tests cover commit/short-drag/
  no-action-side/vertical-scroll/tap-suppression.
This commit is contained in:
Shuki Vaknin
2026-08-19 02:18:01 +03:00
parent 58ddd01cf7
commit 78c84f19a5
6 changed files with 357 additions and 5 deletions
+71 -5
View File
@@ -5,7 +5,8 @@ import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder, Archive, Trash2, MailOpen, ShieldAlert } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
@@ -16,6 +17,8 @@ import { useTagDisplay } from "@/hooks/use-tag-display";
import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { useSwipeActions } from "@/hooks/use-swipe-actions";
import type { SwipeAction } from "@/stores/settings-store";
import { ThreadEmailItem } from "./thread-email-item";
import { EmailHoverActions } from "./email-hover-actions";
import { useTranslations } from "next-intl";
@@ -97,6 +100,16 @@ interface SingleEmailItemProps {
onUndoSpam?: () => void;
}
// Visual + behaviour metadata for each mobile swipe action. Keyed by the
// SwipeAction values that map to a row callback ('none' is handled inline).
const SWIPE_ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string }> = {
archive: { icon: Archive, bg: 'bg-emerald-600' },
delete: { icon: Trash2, bg: 'bg-red-600' },
markRead: { icon: MailOpen, bg: 'bg-sky-600' },
star: { icon: Star, bg: 'bg-amber-500' },
spam: { icon: ShieldAlert, bg: 'bg-orange-600' },
};
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam }, ref) {
const t = useTranslations('email_viewer');
@@ -155,6 +168,50 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
);
const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel };
// Mobile swipe actions (Gmail-style): drag a row left/right to fire the
// configured action. Desktop keeps hover actions + context menu.
const swipeRightAction = useSettingsStore((state) => state.swipeRightAction);
const swipeLeftAction = useSettingsStore((state) => state.swipeLeftAction);
const runSwipeAction = useCallback((action: SwipeAction) => {
switch (action) {
case 'archive': onArchive?.(); break;
case 'delete': onDelete?.(); break;
case 'markRead': onMarkAsRead?.(isUnread); break; // unread -> read, read -> unread
case 'star': onToggleStar?.(); break;
case 'spam': onMarkAsSpam?.(); break;
default: break;
}
}, [onArchive, onDelete, onMarkAsRead, onToggleStar, onMarkAsSpam, isUnread]);
const isActionable = (action: SwipeAction): boolean => {
switch (action) {
case 'archive': return !!onArchive;
case 'delete': return !!onDelete;
case 'markRead': return !!onMarkAsRead;
case 'star': return !!onToggleStar;
case 'spam': return !!onMarkAsSpam;
default: return false;
}
};
const hasRight = isActionable(swipeRightAction);
const hasLeft = isActionable(swipeLeftAction);
const swipeEnabled = isMobile && (hasLeft || hasRight);
const { swipeHandlers, offsetX, side: swipeSide, willCommit, consumeTap } = useSwipeActions({
enabled: swipeEnabled,
hasLeft,
hasRight,
onCommit: (s) => runSwipeAction(s === 'right' ? swipeRightAction : swipeLeftAction),
});
const swipeActiveAction = swipeSide === 'right' ? swipeRightAction : swipeSide === 'left' ? swipeLeftAction : 'none';
const swipeMeta = swipeActiveAction !== 'none' ? SWIPE_ACTION_META[swipeActiveAction] : null;
const touchHandlers = swipeEnabled
? {
onTouchStart: (e: React.TouchEvent) => { longPressHandlers.onTouchStart(e); swipeHandlers.onTouchStart(e); },
onTouchMove: (e: React.TouchEvent) => { longPressHandlers.onTouchMove(e); swipeHandlers.onTouchMove(e); },
onTouchEnd: (e: React.TouchEvent) => { longPressHandlers.onTouchEnd(e); swipeHandlers.onTouchEnd(); },
onTouchCancel: () => { longPressHandlers.onTouchCancel(); swipeHandlers.onTouchCancel(); },
}
: longPressHandlers;
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (e.shiftKey) {
@@ -169,6 +226,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
};
const handleClick = (e: React.MouseEvent) => {
if (swipeEnabled && consumeTap()) { return; }
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
toggleEmailSelection(email.id);
@@ -185,7 +243,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
<div
ref={ref}
{...dragHandlers}
{...longPressHandlers}
{...touchHandlers}
data-testid="email-list-item"
data-email-id={email.id}
data-subject={email.subject || ''}
@@ -217,11 +275,19 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
onDoubleClick();
}}
onContextMenu={handleContextMenu}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)', touchAction: swipeEnabled ? 'pan-y' : undefined }}
>
{swipeEnabled && swipeMeta && (
<div
aria-hidden
className={cn('absolute inset-0 z-0 flex items-center px-5 text-white', swipeMeta.bg, swipeSide === 'right' ? 'justify-start' : 'justify-end')}
>
<swipeMeta.icon className={cn('h-5 w-5 transition-transform', willCommit ? 'scale-110' : 'scale-90 opacity-80')} />
</div>
)}
<div
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start', swipeEnabled && 'relative z-10 bg-inherit')}
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)', transform: swipeEnabled && offsetX ? `translateX(${offsetX}px)` : undefined, transition: swipeEnabled && offsetX === 0 ? 'transform 200ms ease-out' : undefined }}
>
{/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
+32
View File
@@ -23,6 +23,8 @@ export function ReadingSettings() {
deleteAction,
permanentlyDeleteJunk,
returnToListAfterAction,
swipeRightAction,
swipeLeftAction,
showPreview,
mailLayout,
disableThreading,
@@ -213,6 +215,36 @@ export function ReadingSettings() {
/>
</SettingItem>
<SettingItem label={t('swipe_right_action.label')} description={t('swipe_right_action.description')}>
<Select
value={swipeRightAction}
onChange={(value) => updateSetting('swipeRightAction', value as typeof swipeRightAction)}
options={[
{ value: 'none', label: t('swipe_actions.none') },
{ value: 'archive', label: t('swipe_actions.archive') },
{ value: 'delete', label: t('swipe_actions.delete') },
{ value: 'markRead', label: t('swipe_actions.mark_read') },
{ value: 'star', label: t('swipe_actions.star') },
{ value: 'spam', label: t('swipe_actions.spam') },
]}
/>
</SettingItem>
<SettingItem label={t('swipe_left_action.label')} description={t('swipe_left_action.description')}>
<Select
value={swipeLeftAction}
onChange={(value) => updateSetting('swipeLeftAction', value as typeof swipeLeftAction)}
options={[
{ value: 'none', label: t('swipe_actions.none') },
{ value: 'archive', label: t('swipe_actions.archive') },
{ value: 'delete', label: t('swipe_actions.delete') },
{ value: 'markRead', label: t('swipe_actions.mark_read') },
{ value: 'star', label: t('swipe_actions.star') },
{ value: 'spam', label: t('swipe_actions.spam') },
]}
/>
</SettingItem>
{!isSettingHidden('showPreview') && (
<SettingItem
label={t('show_preview.label')}
+77
View File
@@ -0,0 +1,77 @@
import { act, renderHook } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { useSwipeActions, SWIPE_COMMIT_DISTANCE } from "@/hooks/use-swipe-actions";
/** Build a minimal React.TouchEvent-like object with one touch point. */
function touch(x: number, y: number) {
return { touches: [{ clientX: x, clientY: y }] } as unknown as React.TouchEvent;
}
function drag(result: { current: ReturnType<typeof useSwipeActions> }, from: number, to: number, y = 0) {
act(() => result.current.swipeHandlers.onTouchStart(touch(from, y)));
act(() => result.current.swipeHandlers.onTouchMove(touch(to, y)));
act(() => result.current.swipeHandlers.onTouchEnd());
}
describe("useSwipeActions", () => {
it("commits the right action when dragged right past the threshold", () => {
const onCommit = vi.fn();
const { result } = renderHook(() =>
useSwipeActions({ enabled: true, hasLeft: true, hasRight: true, onCommit }),
);
drag(result, 0, SWIPE_COMMIT_DISTANCE + 20);
expect(onCommit).toHaveBeenCalledWith("right");
});
it("commits the left action when dragged left past the threshold", () => {
const onCommit = vi.fn();
const { result } = renderHook(() =>
useSwipeActions({ enabled: true, hasLeft: true, hasRight: true, onCommit }),
);
drag(result, 200, 200 - (SWIPE_COMMIT_DISTANCE + 20));
expect(onCommit).toHaveBeenCalledWith("left");
});
it("does not commit a short drag", () => {
const onCommit = vi.fn();
const { result } = renderHook(() =>
useSwipeActions({ enabled: true, hasLeft: true, hasRight: true, onCommit }),
);
drag(result, 0, 30);
expect(onCommit).not.toHaveBeenCalled();
expect(result.current.offsetX).toBe(0);
});
it("does not commit toward a side with no action", () => {
const onCommit = vi.fn();
const { result } = renderHook(() =>
useSwipeActions({ enabled: true, hasLeft: false, hasRight: true, onCommit }),
);
// Big drag to the LEFT, but hasLeft is false.
drag(result, 200, 200 - (SWIPE_COMMIT_DISTANCE + 40));
expect(onCommit).not.toHaveBeenCalled();
});
it("ignores a mostly-vertical drag (list scroll)", () => {
const onCommit = vi.fn();
const { result } = renderHook(() =>
useSwipeActions({ enabled: true, hasLeft: true, hasRight: true, onCommit }),
);
act(() => result.current.swipeHandlers.onTouchStart(touch(0, 0)));
act(() => result.current.swipeHandlers.onTouchMove(touch(8, 60)));
act(() => result.current.swipeHandlers.onTouchEnd());
expect(onCommit).not.toHaveBeenCalled();
expect(result.current.offsetX).toBe(0);
});
it("reports a tap to consume after a committed swipe", () => {
const onCommit = vi.fn();
const { result } = renderHook(() =>
useSwipeActions({ enabled: true, hasLeft: true, hasRight: true, onCommit }),
);
drag(result, 0, SWIPE_COMMIT_DISTANCE + 20);
expect(result.current.consumeTap()).toBe(true);
// Only once.
expect(result.current.consumeTap()).toBe(false);
});
});
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { useCallback, useRef, useState } from "react";
export type SwipeSide = "left" | "right";
/** Horizontal travel before a touch is claimed as a swipe rather than a tap/scroll. */
const ACTIVATION = 12;
/** Release past this offset (px) fires the action. */
export const SWIPE_COMMIT_DISTANCE = 96;
/** The row stops following the finger past this distance. */
const MAX_OFFSET = 140;
/**
* Dampen travel past the commit distance so the row feels elastic instead of
* sliding off under the finger.
*/
function resist(dx: number): number {
const sign = Math.sign(dx);
const abs = Math.abs(dx);
if (abs <= SWIPE_COMMIT_DISTANCE) return dx;
return sign * Math.min(MAX_OFFSET, SWIPE_COMMIT_DISTANCE + (abs - SWIPE_COMMIT_DISTANCE) * 0.3);
}
export interface UseSwipeActionsOptions {
/** Off on desktop / when neither side has an action. */
enabled?: boolean;
/** Whether a left-swipe (finger moves left) has an action configured. */
hasLeft: boolean;
/** Whether a right-swipe (finger moves right) has an action configured. */
hasRight: boolean;
/** Fired once on release when the row is dragged past the commit distance. */
onCommit: (side: SwipeSide) => void;
}
export interface UseSwipeActionsResult {
swipeHandlers: {
onTouchStart: (e: React.TouchEvent) => void;
onTouchMove: (e: React.TouchEvent) => void;
onTouchEnd: () => void;
onTouchCancel: () => void;
};
/** Current horizontal offset of the row, in px. 0 when idle. */
offsetX: number;
/** The side currently being revealed, or null when idle. */
side: SwipeSide | null;
/** True once the finger is past the commit distance (release would fire). */
willCommit: boolean;
/** True for a beat after a committed swipe so the caller can suppress the tap. */
consumeTap: () => boolean;
}
/**
* Gmail-style horizontal swipe actions for a list row. Pair with
* `touch-action: pan-y` on the swiped element so vertical scrolling stays
* native while horizontal drags are handled here (no preventDefault needed).
*
* A right-swipe (finger moves right, `offsetX > 0`) maps to the caller's
* "right" action; a left-swipe maps to "left". A side with no configured
* action resists hard so the row barely moves.
*/
export function useSwipeActions({
enabled = true,
hasLeft,
hasRight,
onCommit,
}: UseSwipeActionsOptions): UseSwipeActionsResult {
const start = useRef<{ x: number; y: number } | null>(null);
const claimed = useRef(false);
const justSwiped = useRef(false);
const [offsetX, setOffsetX] = useState(0);
const canGo = useCallback(
(dx: number) => (dx < 0 ? hasLeft : hasRight),
[hasLeft, hasRight],
);
const reset = useCallback(() => {
start.current = null;
claimed.current = false;
setOffsetX(0);
}, []);
const onTouchStart = useCallback(
(e: React.TouchEvent) => {
if (!enabled) return;
const t = e.touches[0];
start.current = { x: t.clientX, y: t.clientY };
claimed.current = false;
},
[enabled],
);
const onTouchMove = useCallback(
(e: React.TouchEvent) => {
if (!start.current) return;
const t = e.touches[0];
const dx = t.clientX - start.current.x;
const dy = t.clientY - start.current.y;
if (!claimed.current) {
if (Math.abs(dx) > ACTIVATION && Math.abs(dx) > Math.abs(dy)) {
claimed.current = true;
} else if (Math.abs(dy) > ACTIVATION) {
// Vertical scroll — hand it back to the list.
start.current = null;
return;
} else {
return;
}
}
// A swipe toward a side with no action resists to a token nudge.
setOffsetX(canGo(dx) ? resist(dx) : dx * 0.15);
},
[canGo],
);
const onTouchEnd = useCallback(() => {
if (
claimed.current &&
Math.abs(offsetX) >= SWIPE_COMMIT_DISTANCE &&
canGo(offsetX)
) {
justSwiped.current = true;
onCommit(offsetX > 0 ? "right" : "left");
} else if (claimed.current) {
// A claimed-but-not-committed drag still swallows the tap that follows.
justSwiped.current = true;
}
reset();
}, [offsetX, canGo, onCommit, reset]);
const consumeTap = useCallback(() => {
if (justSwiped.current) {
justSwiped.current = false;
return true;
}
return false;
}, []);
return {
swipeHandlers: {
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel: reset,
},
offsetX,
side: offsetX === 0 ? null : offsetX > 0 ? "right" : "left",
willCommit: Math.abs(offsetX) >= SWIPE_COMMIT_DISTANCE && canGo(offsetX),
consumeTap,
};
}
+16
View File
@@ -1389,6 +1389,22 @@
"label": "Return to list after delete or mark unread",
"description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message."
},
"swipe_right_action": {
"label": "Swipe right action (mobile)",
"description": "On touch devices, the action performed when you swipe a message to the right in the list."
},
"swipe_left_action": {
"label": "Swipe left action (mobile)",
"description": "On touch devices, the action performed when you swipe a message to the left in the list."
},
"swipe_actions": {
"none": "None",
"archive": "Archive",
"delete": "Delete",
"mark_read": "Toggle read",
"star": "Toggle star",
"spam": "Mark as spam"
},
"rtl_editing": {
"label": "Right-to-left editing support",
"description": "Adds a direction button to the composer toolbar so you can set paragraphs left-to-right or right-to-left"
+8
View File
@@ -144,6 +144,8 @@ export type ProtocolOpenMode = 'active-session' | 'new-tab';
const DEVICE_LOCAL_SETTING_KEYS = new Set<string>(['proInterface']);
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
/** Action fired by a mobile list-row swipe. 'none' disables that direction. */
export type SwipeAction = 'none' | 'archive' | 'delete' | 'markRead' | 'star' | 'spam';
export type HoverActionsMode = 'inline' | 'floating';
export type HoverActionsCorner = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
@@ -324,6 +326,8 @@ interface SettingsState {
hoverActions: HoverAction[]; // Quick actions shown on hover in mail list
hoverActionsMode: HoverActionsMode; // Display mode: inline (current) or floating corner
hoverActionsCorner: HoverActionsCorner; // Corner for floating mode
swipeRightAction: SwipeAction; // Mobile: action when a list row is swiped right
swipeLeftAction: SwipeAction; // Mobile: action when a list row is swiped left
// Composer
autoSaveDraftInterval: number; // milliseconds
@@ -555,6 +559,8 @@ const DEFAULT_SETTINGS = {
hoverActions: ['delete', 'star', 'markRead', 'archive'] as HoverAction[],
hoverActionsMode: 'inline' as HoverActionsMode,
hoverActionsCorner: 'top-right' as HoverActionsCorner,
swipeRightAction: 'archive' as SwipeAction,
swipeLeftAction: 'delete' as SwipeAction,
// Composer
autoSaveDraftInterval: 60000, // 1 minute
@@ -774,6 +780,8 @@ export const useSettingsStore = create<SettingsState>()(
hoverActions: state.hoverActions,
hoverActionsMode: state.hoverActionsMode,
hoverActionsCorner: state.hoverActionsCorner,
swipeRightAction: state.swipeRightAction,
swipeLeftAction: state.swipeLeftAction,
disableThreading: state.disableThreading,
trustedSenders: state.trustedSenders,
autoSaveDraftInterval: state.autoSaveDraftInterval,