From 8cd565c6471483ea0e1631fd764550b41e891570 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 3 May 2025 23:51:17 +0200 Subject: [PATCH] infinite scroll --- src/pages/FeedView.tsx | 100 ++++++++++++++++++++++------------------- 1 file changed, 54 insertions(+), 46 deletions(-) diff --git a/src/pages/FeedView.tsx b/src/pages/FeedView.tsx index 5dc3f27..2353ec9 100644 --- a/src/pages/FeedView.tsx +++ b/src/pages/FeedView.tsx @@ -3,88 +3,96 @@ import PostsFeed from '../components/PostsFeed.tsx' import { useCallback, useEffect, useRef, useState } from 'react' import './FeedView.css' -// Stub for spinner component -const Spinner = () => ( -
-) - -const PageSize = 2 +const PageSize = 10 interface FeedViewProps { loadPosts: (cursor: string | null, amount: number) => Promise } + export default function FeedView({ loadPosts }: FeedViewProps) { const [pages, setPages] = useState([]) - const posts = pages.flat() const [hasMore, setHasMore] = useState(true) - const cursor = useRef(null) const [isLoading, setIsLoading] = useState(false) + const cursor = useRef(null) const loading = useRef(false) + const sentinelRef = useRef(null) + const loadNextPage = useCallback(async () => { - if (loading.current) return + if (loading.current || !hasMore) return loading.current = true + setIsLoading(true) try { - const page = await loadPosts(cursor.current, PageSize) + const delay = new Promise((resolve) => setTimeout(resolve, 500)) // minimum delay + const pagePromise = loadPosts(cursor.current, PageSize) - if (page.length < PageSize) { - setHasMore(false) - } + const [page] = await Promise.all([pagePromise, delay]) // wait for both + if (page.length < PageSize) setHasMore(false) cursor.current = page.at(-1)?.postId ?? null - setPages((prev) => [...prev, page]) } finally { loading.current = false setIsLoading(false) } - }, [loadPosts]) + }, [loadPosts, hasMore]) useEffect(() => { - const timeoutId = setTimeout(async () => { - await loadNextPage() - }) + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0] + if (entry.isIntersecting) { + loadNextPage() + } + }, + { + root: null, + rootMargin: '100px', + threshold: 0.1, + }, + ) + const sentinel = sentinelRef.current + if (sentinel) observer.observe(sentinel) return () => { - clearTimeout(timeoutId) + if (sentinel) observer.unobserve(sentinel) } }, [loadNextPage]) + // Ensure content fills viewport after initial render + useEffect(() => { + const checkContentHeight = async () => { + while ( + document.documentElement.scrollHeight <= window.innerHeight && + hasMore && + !loading.current + ) { + await loadNextPage() + } + } + + checkContentHeight() + }, [posts.length, loadNextPage, hasMore]) + return ( -
-
+
+
- {hasMore && ( - + {isLoading && ( +
+ +
)} +
) } -interface LoadMoreButtonProps { - state: 'ready' | 'loading' - onClick: () => void -} - -function LoadMoreButton({ state, onClick }: LoadMoreButtonProps) { - const buttonClasses = - 'w-full py-3 px-4 bg-gray-100 hover:bg-gray-200 text-gray-800 rounded-md flex items-center justify-center disabled:opacity-70' - switch (state) { - case 'loading': - return ( - - ) - case 'ready': - return ( - - ) - } -} +// Spinner component +const Spinner = () => ( +
+)