import { useCallback, useRef, useState } from 'react'; import type { TouchEvent } from 'react'; interface Options { threshold?: number; enabled?: boolean; maxPull?: number; } interface Handlers { onTouchStart: (e: TouchEvent) => void; onTouchMove: (e: TouchEvent) => void; onTouchEnd: () => void; pullDist: number; refreshing: boolean; } // Hand-rolled pull-to-refresh: records the initial Y on touchstart only if // the target is scrolled to the top, then tracks downward pull on touchmove. // On touchend, fires onRefresh if the pull exceeded the threshold. export function usePullToRefresh( onRefresh: () => void | Promise, { threshold = 80, enabled = true, maxPull = 120 }: Options = {}, ): Handlers { const [pullDist, setPullDist] = useState(0); const [refreshing, setRefreshing] = useState(false); const startYRef = useRef(null); const onTouchStart = useCallback( (e: TouchEvent) => { if (!enabled || refreshing) return; const target = e.currentTarget as HTMLElement; if (target.scrollTop > 0) return; const t = e.touches[0]; if (!t) return; startYRef.current = t.clientY; }, [enabled, refreshing], ); const onTouchMove = useCallback( (e: TouchEvent) => { if (!enabled || refreshing || startYRef.current === null) return; const t = e.touches[0]; if (!t) return; const delta = t.clientY - startYRef.current; if (delta > 0) { setPullDist(Math.min(delta, maxPull)); } else { setPullDist(0); } }, [enabled, refreshing, maxPull], ); const onTouchEnd = useCallback(() => { if (!enabled || refreshing) { startYRef.current = null; setPullDist(0); return; } const fired = pullDist >= threshold && startYRef.current !== null; startYRef.current = null; setPullDist(0); if (fired) { setRefreshing(true); Promise.resolve(onRefresh()) .catch(() => {}) .finally(() => { // Hold the indicator briefly so the action feels intentional. window.setTimeout(() => setRefreshing(false), 600); }); } }, [enabled, refreshing, pullDist, threshold, onRefresh]); return { onTouchStart, onTouchMove, onTouchEnd, pullDist, refreshing }; }