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(); }, []); // Fix wheel scrolling over child elements useEffect(() => { const scrollWrapper = document.getElementById('scroll-wrapper'); if (!scrollWrapper) return; const viewport = scrollWrapper.querySelector('.mantine-ScrollArea-viewport') as HTMLElement; if (!viewport) return; const handleWheel = (e: WheelEvent) => { const target = e.target as HTMLElement; // Check if the target is inside a nested scrollable container let element = target; while (element && element !== viewport) { const overflow = window.getComputedStyle(element).overflow; const overflowY = window.getComputedStyle(element).overflowY; const overflowX = window.getComputedStyle(element).overflowX; // If we found a scrollable ancestor (not the main viewport), don't interfere if ( (overflow === 'auto' || overflow === 'scroll' || overflowY === 'auto' || overflowY === 'scroll' || overflowX === 'auto' || overflowX === 'scroll') && element !== viewport ) { // Check if this element can actually scroll in the wheel direction const canScrollY = element.scrollHeight > element.clientHeight; const canScrollX = element.scrollWidth > element.clientWidth; if ((e.deltaY !== 0 && canScrollY) || (e.deltaX !== 0 && canScrollX)) { return; // Let the nested scroller handle it } } element = element.parentElement as HTMLElement; } // No nested scroller found, scroll the main viewport viewport.scrollTop += e.deltaY; viewport.scrollLeft += e.deltaX; }; scrollWrapper.addEventListener('wheel', handleWheel, { passive: true }); return () => { scrollWrapper.removeEventListener('wheel', handleWheel); }; }, []); 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} styles={{ viewport: { '& > *': { pointerEvents: 'auto' } } }} > {children} ) } export default Pullable;