import { ActionIcon, Box, Button, Flex, ScrollArea } from "@mantine/core"; import { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from "react"; import useAppShellHeight from "@/hooks/use-appshell-height"; import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react"; import { useQueryClient } from "@tanstack/react-query"; import useRouterConfig from "../hooks/use-router-config"; import { useLocation } from "@tanstack/react-router"; const THRESHOLD = 80; interface PullableProps extends PropsWithChildren { scrollPosition: { x: number, y: number }; onScrollPositionChange: (position: { x: number, y: number }) => void; } /** * Pullable is a component that allows the user to pull down to refresh the page */ const Pullable: React.FC = ({ children, scrollPosition, onScrollPositionChange }) => { const height = useAppShellHeight(); const [isRefreshing, setIsRefreshing] = useState(false); const [scrolling, setScrolling] = useState(false); const { refresh } = useRouterConfig(); const queryClient = useQueryClient(); const location = useLocation(); const scrollAreaRef = useRef(null); const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]); const onTrigger = useCallback(async () => { setIsRefreshing(true); if (refresh.length > 0) { // TODO: Remove this after testing - or does the delay help ux? await new Promise(resolve => setTimeout(resolve, 1000)); refresh.forEach(async (queryKey) => { const keyArray = Array.isArray(queryKey) ? queryKey : [queryKey]; await queryClient.refetchQueries({ queryKey: keyArray, exact: true }); }); } setIsRefreshing(false); }, [refresh]); useEffect(() => { if (!isRefreshing && scrollY > THRESHOLD) { onTrigger(); } }, [scrollY, isRefreshing, onTrigger]); const iconOpacity = useMemo(() => { if (isRefreshing) return 1; if (refresh.length === 0) return 0; const clampedValue = Math.max(5, Math.min(THRESHOLD, scrollY)); const min = 5; const max = THRESHOLD; const range = max - min; return (clampedValue - min) / range; }, [scrollY, isRefreshing]) useEffect(() => { const scrollWrapper = document.getElementById('scroll-wrapper'); if (scrollWrapper) { scrollWrapper.addEventListener('touchstart', () => { setScrolling(true); }); scrollWrapper.addEventListener('touchend', () => { setScrolling(false); }); } }, []); useEffect(() => { if (typeof window === 'undefined') return; const ac = new AbortController(); const options = { passive: true, signal: ac.signal }; window.addEventListener('touchstart', () => setScrolling(true), options); window.addEventListener('touchend', () => setScrolling(false), options); return () => void ac.abort(); }, []); useEffect(() => { const timeoutId = setTimeout(() => { if (scrollAreaRef.current) { const viewport = scrollAreaRef.current.querySelector('.mantine-ScrollArea-viewport') as HTMLElement; if (viewport) { viewport.scrollTop = 0; viewport.scrollLeft = 0; } } onScrollPositionChange({ x: 0, y: 0 }); }, 10); return () => clearTimeout(timeoutId); }, [location.pathname, onScrollPositionChange]); return ( <> 20 || isRefreshing ? 'flex' : 'none'} opacity={iconOpacity} style={{ zIndex: 10 }} > 40) || !isRefreshing ? 0 : 40 - scrollY} > {children} ) } export default Pullable;