"use client" import { memo, useCallback, useRef, type ReactNode, type TouchEvent } from "react" import { Archive, Star, Tag, Trash2 } from "lucide-react" import { cn } from "@/lib/utils" const SNAP_MS = 200 const SNAP_EASE = "cubic-bezier(0.25, 0.46, 0.45, 0.94)" const LEFT_PANEL_PX = 240 const RIGHT_PANEL_PX = 100 const OPEN_THRESHOLD_RATIO = 0.15 const OPEN_VELOCITY = 0.28 type Props = { enabled: boolean emailId: string isOpen: boolean onOpenChange: (open: boolean) => void onArchive: () => void onDelete: () => void onStar: () => void onLabel: () => void className?: string children: ReactNode } function MailListSwipeRowInner({ enabled, emailId, isOpen, onOpenChange, onArchive, onDelete, onStar, onLabel, className, children, }: Props) { const rowRef = useRef(null) const fgRef = useRef(null) const leftPanelRef = useRef(null) const rightPanelRef = useRef(null) const offsetRef = useRef(0) const directionRef = useRef<"none" | "left" | "right">("none") const touchRef = useRef<{ startX: number startY: number startOffset: number active: boolean axis: "none" | "x" | "y" startTime: number prevX: number prevT: number } | null>(null) const suppressClickUntilRef = useRef(0) const applyOffset = useCallback((px: number) => { offsetRef.current = px const fg = fgRef.current if (!fg) return fg.style.transform = `translate3d(${px}px,0,0)` const lp = leftPanelRef.current const rp = rightPanelRef.current if (lp) lp.style.visibility = px > 0 ? "visible" : "hidden" if (rp) rp.style.visibility = px < 0 ? "visible" : "hidden" }, []) const animateTo = useCallback( (px: number) => { const fg = fgRef.current if (!fg) return fg.style.transition = `transform ${SNAP_MS}ms ${SNAP_EASE}` applyOffset(px) window.setTimeout(() => { if (fg) fg.style.transition = "none" }, SNAP_MS + 10) }, [applyOffset] ) const close = useCallback(() => { animateTo(0) directionRef.current = "none" if (isOpen) onOpenChange(false) }, [animateTo, isOpen, onOpenChange]) const onTouchStart = useCallback( (e: TouchEvent) => { if (!enabled) return const t = e.touches[0] if (!t) return const fg = fgRef.current if (fg) fg.style.transition = "none" touchRef.current = { startX: t.clientX, startY: t.clientY, startOffset: offsetRef.current, active: true, axis: "none", startTime: performance.now(), prevX: t.clientX, prevT: performance.now(), } }, [enabled] ) const onTouchMove = useCallback( (e: TouchEvent) => { const tr = touchRef.current if (!tr?.active) return const t = e.touches[0] if (!t) return const dx = t.clientX - tr.startX const dy = t.clientY - tr.startY if (tr.axis === "none") { if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return if (Math.abs(dy) > Math.abs(dx)) { tr.axis = "y" tr.active = false return } tr.axis = "x" } if (tr.axis !== "x") return e.preventDefault() let raw = tr.startOffset + dx // Clamp to panel width (no overswipe) if (raw > LEFT_PANEL_PX) raw = LEFT_PANEL_PX if (raw < -RIGHT_PANEL_PX) raw = -RIGHT_PANEL_PX tr.prevX = t.clientX tr.prevT = performance.now() applyOffset(raw) }, [applyOffset] ) const onTouchEnd = useCallback(() => { const tr = touchRef.current touchRef.current = null if (!tr?.active || tr.axis !== "x") return suppressClickUntilRef.current = performance.now() + 350 const offset = offsetRef.current const elapsed = Math.max(1, performance.now() - tr.startTime) const instantVelocity = (tr.prevX - tr.startX) / elapsed // If panel was open and user swiped opposite direction → just close if (directionRef.current === "right" && offset <= 0) { animateTo(0) directionRef.current = "none" if (isOpen) onOpenChange(false) return } if (directionRef.current === "left" && offset >= 0) { animateTo(0) directionRef.current = "none" if (isOpen) onOpenChange(false) return } // Open/close based on threshold + velocity // Closing requires less energy than opening (lower threshold & velocity) const closing = isOpen && ( (directionRef.current === "right" && offset < LEFT_PANEL_PX) || (directionRef.current === "left" && -offset < RIGHT_PANEL_PX) ) const closeThreshold = 0.08 const closeVelocity = 0.12 if (offset > 0) { if (closing) { const shouldStayOpen = offset >= LEFT_PANEL_PX * (1 - closeThreshold) && instantVelocity >= -closeVelocity if (shouldStayOpen) { animateTo(LEFT_PANEL_PX) } else { animateTo(0) directionRef.current = "none" onOpenChange(false) } } else { const shouldOpen = offset >= LEFT_PANEL_PX * OPEN_THRESHOLD_RATIO || (instantVelocity > OPEN_VELOCITY && offset > 20) if (shouldOpen) { animateTo(LEFT_PANEL_PX) directionRef.current = "right" if (!isOpen) onOpenChange(true) } else { animateTo(0) directionRef.current = "none" if (isOpen) onOpenChange(false) } } } else if (offset < 0) { if (closing) { const shouldStayOpen = -offset >= RIGHT_PANEL_PX * (1 - closeThreshold) && instantVelocity <= closeVelocity if (shouldStayOpen) { animateTo(-RIGHT_PANEL_PX) } else { animateTo(0) directionRef.current = "none" onOpenChange(false) } } else { const shouldOpen = -offset >= RIGHT_PANEL_PX * OPEN_THRESHOLD_RATIO || (instantVelocity < -OPEN_VELOCITY && offset < -20) if (shouldOpen) { animateTo(-RIGHT_PANEL_PX) directionRef.current = "left" if (!isOpen) onOpenChange(true) } else { animateTo(0) directionRef.current = "none" if (isOpen) onOpenChange(false) } } } else { directionRef.current = "none" if (isOpen) onOpenChange(false) } }, [ animateTo, isOpen, onOpenChange, ]) const onTouchCancel = useCallback(() => { const tr = touchRef.current touchRef.current = null if (!tr?.active || tr.axis !== "x") return animateTo(0) directionRef.current = "none" if (isOpen) onOpenChange(false) }, [animateTo, isOpen, onOpenChange]) const handleClickCapture = useCallback( (e: React.MouseEvent) => { if (!enabled) return if (performance.now() < suppressClickUntilRef.current) { e.preventDefault() e.stopPropagation() return } if (offsetRef.current !== 0) { e.preventDefault() e.stopPropagation() close() } }, [close, enabled] ) // Close when isOpen flips to false externally (another row opened) if (!isOpen && offsetRef.current !== 0 && !touchRef.current?.active) { const fg = fgRef.current if (fg) { fg.style.transition = `transform ${SNAP_MS}ms ${SNAP_EASE}` applyOffset(0) directionRef.current = "none" window.setTimeout(() => { if (fg) fg.style.transition = "none" }, SNAP_MS + 10) } } if (!enabled) { return
{children}
} return (
{/* Left actions (swipe right to reveal) */}
{/* Right actions (swipe left to reveal) */}
{/* Foreground (row content) */}
{children}
) } export const MailListSwipeRow = memo(MailListSwipeRowInner)