"use client" import { useCallback, useEffect, useRef, useState } from "react" export const PULL_HOLD_HEIGHT = 48 export const PULL_REFRESH_THRESHOLD = 56 export const PULL_REFRESH_MAX = 112 export const PULL_SNAP_BACK_TRANSITION = "transform 0.24s cubic-bezier(0.32, 0.72, 0, 1)" export const REFRESH_SPIN_CLASS = "animate-[spin_0.55s_linear_infinite]" export const PULL_ICON_FADE_MS = 120 export const PULL_SPINNER_REVEAL_OFFSET = 26 export function computePullOffset(delta: number): number { if (delta <= 0) return 0 const damped = delta * 0.48 const capped = Math.min(PULL_REFRESH_MAX, damped) const ratio = capped / PULL_REFRESH_MAX return capped * (1 - ratio * 0.12) } export function computeSpinnerRevealProgress(y: number): number { if (y <= PULL_SPINNER_REVEAL_OFFSET) return 0 const range = Math.max(1, PULL_REFRESH_THRESHOLD - PULL_SPINNER_REVEAL_OFFSET) return Math.min(1, ((y - PULL_SPINNER_REVEAL_OFFSET) / range) * 1.35) } export function useMailListPullRefresh(opts: { enabled: boolean isViewMode: boolean onRefresh: () => void | Promise }) { const { enabled, isViewMode, onRefresh } = opts const [isRefreshing, setIsRefreshing] = useState(false) const pullYRef = useRef(0) const pullActiveRef = useRef(false) const pullContentRef = useRef(null) const pullIconRef = useRef(null) const listViewportRef = useRef(null) const applyPullVisual = useCallback((y: number, animate: boolean) => { const el = pullContentRef.current const icon = pullIconRef.current if (!el) return const transition = animate ? PULL_SNAP_BACK_TRANSITION : "none" el.style.transition = transition el.style.transform = y > 0 ? `translateY(${y}px)` : "" if (icon) { const progress = computeSpinnerRevealProgress(y) icon.style.opacity = String(progress) icon.style.transform = `rotate(${progress * 360}deg)` icon.style.transition = animate ? `opacity ${PULL_ICON_FADE_MS}ms ease-out, transform ${PULL_ICON_FADE_MS}ms ease-out` : "none" } }, []) const resetPullVisual = useCallback(() => { pullYRef.current = 0 applyPullVisual(0, true) }, [applyPullVisual]) const releasePull = useCallback(async () => { const offset = pullYRef.current pullActiveRef.current = false if (offset >= PULL_REFRESH_THRESHOLD) { pullYRef.current = PULL_HOLD_HEIGHT applyPullVisual(PULL_HOLD_HEIGHT, false) setIsRefreshing(true) try { await onRefresh() } finally { setIsRefreshing(false) pullYRef.current = 0 applyPullVisual(0, true) } } else { resetPullVisual() } }, [applyPullVisual, onRefresh, resetPullVisual]) useEffect(() => { if (isViewMode || !enabled || isRefreshing) return const root = listViewportRef.current if (!root) return let startY = 0 const onTouchStart = (e: TouchEvent) => { if (root.scrollTop > 0 || isRefreshing) return startY = e.touches[0]?.clientY ?? 0 pullActiveRef.current = true } const onTouchMove = (e: TouchEvent) => { if (!pullActiveRef.current || isRefreshing) return const y = e.touches[0]?.clientY ?? 0 const delta = y - startY if (delta <= 0) { pullYRef.current = 0 applyPullVisual(0, false) return } if (root.scrollTop > 0) return e.preventDefault() const offset = computePullOffset(delta) pullYRef.current = offset applyPullVisual(offset, false) } const onTouchEnd = () => { if (!pullActiveRef.current) return void releasePull() } root.addEventListener("touchstart", onTouchStart, { passive: true }) root.addEventListener("touchmove", onTouchMove, { passive: false }) root.addEventListener("touchend", onTouchEnd) root.addEventListener("touchcancel", onTouchEnd) return () => { root.removeEventListener("touchstart", onTouchStart) root.removeEventListener("touchmove", onTouchMove) root.removeEventListener("touchend", onTouchEnd) root.removeEventListener("touchcancel", onTouchEnd) } }, [enabled, isRefreshing, isViewMode, applyPullVisual, releasePull]) return { isRefreshing, setIsRefreshing, listViewportRef, pullContentRef, pullIconRef, resetPullVisual, } }