"use client"; import { useEffect, useState, useSyncExternalStore } from "react"; import { createPortal } from "react-dom"; import { Loader2 } from "@/components/icons"; import { cn } from "@/lib/utils"; /** * `pulling` finger is dragging the indicator down, below the trigger threshold * `ready` pulled past the threshold — releasing now starts a refresh * `refreshing` refresh in flight, indicator parked at the threshold and spinning * `cancelling` snapping back after an incomplete pull * `finishing` snapping back after the refresh resolved */ export type PullToRefreshPhase = "idle" | "pulling" | "ready" | "refreshing" | "cancelling" | "finishing"; export interface PullToRefreshState { phase: PullToRefreshPhase; /** How far the indicator has travelled from the top edge, in CSS pixels. */ distance: number; } export interface PullToRefreshStore { subscribe: (listener: () => void) => () => void; getSnapshot: () => PullToRefreshState; set: (next: PullToRefreshState) => void; } const IDLE_STATE: PullToRefreshState = { phase: "idle", distance: 0 }; /** * A minimal external store for the pull state. * * The gesture updates on every touchmove, so this deliberately lives outside * React state: the hook that drives it is called from large app shells * (mail/calendar/contacts/files) and re-rendering those at touch frequency * would drop frames. Only the indicator subscribes. */ export function createPullToRefreshStore(): PullToRefreshStore { let state = IDLE_STATE; const listeners = new Set<() => void>(); return { subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }, getSnapshot() { return state; }, set(next) { if (next.phase === state.phase && next.distance === state.distance) return; state = next.phase === "idle" ? IDLE_STATE : next; listeners.forEach((listener) => listener()); }, }; } export interface PullToRefreshLabels { pull: string; release: string; refreshing: string; } interface PullToRefreshIndicatorProps { store: PullToRefreshStore; /** Pull distance at which releasing triggers a refresh. */ threshold: number; labels: PullToRefreshLabels; } const RING_RADIUS = 9; const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; export function PullToRefreshIndicator({ store, threshold, labels }: PullToRefreshIndicatorProps) { const { phase, distance } = useSyncExternalStore(store.subscribe, store.getSnapshot, () => IDLE_STATE); // The indicator is portalled into document.body, which does not exist during // the server render or the hydration pass. const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); if (!mounted || phase === "idle") return null; const progress = threshold > 0 ? Math.min(1, distance / threshold) : 0; const settling = phase === "cancelling" || phase === "finishing"; const spinning = phase === "refreshing"; const label = phase === "pulling" || phase === "cancelling" ? labels.pull : phase === "ready" ? labels.release : labels.refreshing; return createPortal(