diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index a4db15371..43692f2c7 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -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, { 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( 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( ); 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( }; 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(
( 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 && ( +
+ +
+ )}
{/* Checkbox - only visible when in selection mode */} {selectedEmailIds.size > 0 && ( diff --git a/components/settings/reading-settings.tsx b/components/settings/reading-settings.tsx index 0c959e0d1..98057559d 100644 --- a/components/settings/reading-settings.tsx +++ b/components/settings/reading-settings.tsx @@ -23,6 +23,8 @@ export function ReadingSettings() { deleteAction, permanentlyDeleteJunk, returnToListAfterAction, + swipeRightAction, + swipeLeftAction, showPreview, mailLayout, disableThreading, @@ -213,6 +215,36 @@ export function ReadingSettings() { /> + + 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') }, + ]} + /> + + {!isSettingHidden('showPreview') && ( }, 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); + }); +}); diff --git a/hooks/use-swipe-actions.ts b/hooks/use-swipe-actions.ts new file mode 100644 index 000000000..928aff31a --- /dev/null +++ b/hooks/use-swipe-actions.ts @@ -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, + }; +} diff --git a/locales/en/common.json b/locales/en/common.json index 9280dc65f..b8ae68733 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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" diff --git a/stores/settings-store.ts b/stores/settings-store.ts index d770be922..2ba21a750 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -144,6 +144,8 @@ export type ProtocolOpenMode = 'active-session' | 'new-tab'; const DEVICE_LOCAL_SETTING_KEYS = new Set(['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()( hoverActions: state.hoverActions, hoverActionsMode: state.hoverActionsMode, hoverActionsCorner: state.hoverActionsCorner, + swipeRightAction: state.swipeRightAction, + swipeLeftAction: state.swipeLeftAction, disableThreading: state.disableThreading, trustedSenders: state.trustedSenders, autoSaveDraftInterval: state.autoSaveDraftInterval,