Scrapboard.org client

feat: implement infinite scrolling for feeds and enhance feed fetching logic

+209 -54
+42 -32
src/app/page.tsx
··· 3 3 import { Feed, feedAsMap } from "@/components/Feed"; 4 4 import { useFetchTimeline } from "@/lib/hooks/useTimeline"; 5 5 import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; 6 - import { useRef, useEffect, useState } from "react"; 6 + import { useEffect, useState } from "react"; 7 7 import { useFeedStore } from "@/lib/stores/feeds"; 8 8 import { useFeeds } from "@/lib/hooks/useFeeds"; 9 9 import { LoaderCircle } from "lucide-react"; 10 10 import { useFeedDefsStore } from "@/lib/stores/feedDefs"; 11 - import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; 12 11 import { useAuth } from "@/lib/hooks/useAuth"; 13 - import { useBoards } from "@/lib/hooks/useBoards"; 12 + import { InfiniteScrollWrapper } from "@/components/InfiniteScrollWrapper"; 14 13 15 14 export default function Home() { 16 15 const { fetchFeed } = useFetchTimeline(); ··· 18 17 const { isLoading } = useFeeds(); 19 18 const { feeds, defaultFeed, setDefaultFeed } = useFeedDefsStore(); 20 19 const { session, loading } = useAuth(); 21 - const sentinelRef = useRef<HTMLDivElement>(null); 22 20 const [feed, setFeed] = useState<"timeline" | string>( 23 21 defaultFeed ?? "timeline" 24 22 ); 25 23 26 - useEffect(() => { 27 - const observer = new IntersectionObserver( 28 - (entries) => { 29 - if (entries[0].isIntersecting) { 30 - fetchFeed(feed); 31 - } 32 - }, 33 - { rootMargin: "200px" } 34 - ); 35 - 36 - const sentinel = sentinelRef.current; 37 - if (sentinel) observer.observe(sentinel); 38 - return () => { 39 - if (sentinel) observer.unobserve(sentinel); 40 - }; 41 - }, [fetchFeed, feed]); 24 + const loadMore = async () => { 25 + await fetchFeed(feed); 26 + }; 42 27 43 28 useEffect(() => { 44 - fetchFeed(feed); 29 + console.log(`Loading feed: ${feed}`); 30 + loadMore(); 45 31 }, [feed]); 46 32 47 33 if (session == null) { ··· 64 50 65 51 const triggerClass = 66 52 "shrink-0 cursor-pointer dark:hover:bg-white/5 hover:bg-black/5 transition-colors"; 53 + 54 + const currentFeedData = 55 + feed === "timeline" ? feedStore.timeline : feedStore.customFeeds[feed]; 56 + 57 + const hasMore = 58 + currentFeedData?.cursor !== null && currentFeedData?.cursor !== undefined; 59 + const isLoadingMore = currentFeedData?.isLoading || false; 60 + 67 61 return ( 68 62 <main className="px-5"> 69 63 <Tabs defaultValue={defaultFeed} className="w-full"> 70 64 <TabsList 71 - className="overflow-x-auto w-full justify-start" //"flex w-full overflow-x-auto whitespace-nowrap no-scrollbar pl-10 pr-4 space-x-4" 65 + className="overflow-x-auto w-full justify-start" 72 66 style={{ justifyItems: "unset" }} 73 67 > 74 68 <TabsTrigger ··· 97 91 </TabsList> 98 92 99 93 <TabsContent value="timeline"> 100 - <Feed 101 - feed={feedAsMap(feedStore.timeline.posts.map((it) => it.post))} 102 - isLoading={feedStore.timeline.isLoading} 103 - /> 94 + <InfiniteScrollWrapper 95 + hasMore={hasMore} 96 + onLoadMore={loadMore} 97 + isLoadingMore={isLoadingMore} 98 + > 99 + <Feed 100 + feed={feedAsMap(feedStore.timeline.posts.map((it) => it.post))} 101 + isLoading={ 102 + feedStore.timeline.isLoading && 103 + feedStore.timeline.posts.length === 0 104 + } 105 + /> 106 + </InfiniteScrollWrapper> 104 107 </TabsContent> 105 108 106 109 {Object.entries(feeds) 107 110 .filter((it) => feedStore.customFeeds?.[it[0]] != null) 108 111 .map(([value]) => ( 109 112 <TabsContent key={value} value={value}> 110 - <Feed 111 - feed={feedAsMap(feedStore.customFeeds[value].posts)} 112 - isLoading={feedStore.customFeeds[value].isLoading} 113 - /> 113 + <InfiniteScrollWrapper 114 + hasMore={hasMore} 115 + onLoadMore={loadMore} 116 + isLoadingMore={isLoadingMore} 117 + > 118 + <Feed 119 + feed={feedAsMap(feedStore.customFeeds[value].posts)} 120 + isLoading={ 121 + feedStore.customFeeds[value].isLoading && 122 + feedStore.customFeeds[value].posts.length === 0 123 + } 124 + /> 125 + </InfiniteScrollWrapper> 114 126 </TabsContent> 115 127 ))} 116 128 </Tabs> 117 - 118 - <div ref={sentinelRef} className="h-1" /> 119 129 </main> 120 130 ); 121 131 }
+28 -2
src/lib/hooks/useFeeds.tsx
··· 3 3 import { useFeedDefsStore } from "../stores/feedDefs"; 4 4 import { AtUri } from "@atproto/api"; 5 5 6 + // How long the cache stays fresh (30 minutes by default) 7 + const CACHE_DURATION = 30 * 60 * 1000; 8 + 6 9 export function useFeeds() { 7 10 const { agent } = useAuth(); 8 11 const store = useFeedDefsStore(); ··· 10 13 11 14 useEffect(() => { 12 15 if (agent == null) return; 16 + 17 + // Use cached data immediately if available 18 + if (store.feeds != null) { 19 + setLoading(false); 20 + } 21 + 22 + // Check if we need to refresh the data 23 + const lastFetchedAt = localStorage.getItem("feedsLastFetchedAt"); 24 + const now = Date.now(); 25 + const isStale = 26 + !lastFetchedAt || now - parseInt(lastFetchedAt) > CACHE_DURATION; 27 + 28 + // Skip fetching if data is fresh 29 + if (!isStale && store.feeds != null) return; 30 + 13 31 const loadFeeds = async () => { 32 + // Only show loading state if we have no cached data 33 + const isBackgroundRefresh = store.feeds != null; 34 + if (!isBackgroundRefresh) { 35 + setLoading(true); 36 + } 37 + 14 38 try { 15 39 const prefs = await agent.getPreferences(); 16 40 if (prefs?.savedFeeds == null) return; ··· 19 43 if (!feed.value.startsWith("at")) continue; 20 44 const urip = AtUri.make(feed.value); 21 45 22 - console.log("host", urip.host); 23 46 if (!urip.host.startsWith("did:")) { 24 47 const res = await agent.resolveHandle({ handle: urip.host }); 25 48 urip.host = res.data.did; 26 49 } 27 50 28 - console.log("Fetching feed defs", feed); 29 51 if (feed.type == "feed") { 30 52 const feedDef = await agent.app.bsky.feed.getFeedGenerators({ 31 53 feeds: [urip.toString()], ··· 42 64 }); 43 65 } 44 66 } 67 + 68 + // Update the last fetched timestamp 69 + localStorage.setItem("feedsLastFetchedAt", now.toString()); 45 70 } finally { 46 71 setLoading(false); 47 72 } 48 73 }; 74 + 49 75 loadFeeds(); 50 76 }, [agent]); 51 77
+139 -20
src/lib/hooks/useTimeline.tsx
··· 1 1 // lib/hooks/useFetchTimeline.ts 2 - import { useEffect, useRef, useCallback, Ref } from "react"; 3 - import { AppBskyEmbedImages, AtUri } from "@atproto/api"; 2 + import { useEffect, useRef, useCallback, useState } from "react"; 3 + import { AppBskyEmbedImages } from "@atproto/api"; 4 4 import { useAuth } from "@/lib/hooks/useAuth"; 5 5 import { useFeedStore } from "../stores/feeds"; 6 6 import { FeedViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; ··· 33 33 appendCustomFeed, 34 34 } = useFeedStore(); 35 35 const seenImageUrls = useRef<Set<string>>(new Set()); 36 + const [isInitialized, setIsInitialized] = useState(false); 37 + const [isReady, setIsReady] = useState(false); 38 + const pendingFeedRequests = useRef<Set<string>>(new Set()); 39 + // Use a ref to track the latest agent value 40 + const agentRef = useRef(agent); 41 + // Track processing state to avoid loops 42 + const isProcessingRequests = useRef(false); 43 + const processingRetries = useRef(0); 44 + 45 + // Update the ref when agent changes 46 + useEffect(() => { 47 + agentRef.current = agent; 48 + }, [agent]); 36 49 37 50 const fetchFeed = useCallback( 38 51 async (feed?: string | undefined) => { 39 52 console.log("loading", feed); 40 - if (!agent || timeline.isLoading) return; 53 + 54 + // Use the latest agent from the ref 55 + const currentAgent = agentRef.current; 56 + 57 + // If not ready, queue the request for later 58 + if (!currentAgent) { 59 + console.log( 60 + "Agent not available, queuing feed request for later:", 61 + feed || "main" 62 + ); 63 + pendingFeedRequests.current.add(feed || "main"); 64 + return; 65 + } 66 + 67 + // Check if we're already loading the timeline 68 + if ( 69 + (feed && customFeeds?.[feed]?.isLoading) || 70 + (!feed && timeline.isLoading) 71 + ) { 72 + console.log("Already loading feed, skipping fetch"); 73 + return; 74 + } 75 + 41 76 if (feed) { 42 77 setCustomFeedLoading(feed, true); 43 78 } else setTimelineLoading(true); 79 + 44 80 try { 45 81 if (feed && feed != "timeline") { 46 82 const cursor = customFeeds?.[feed]?.cursor ?? undefined; 83 + console.log(`Fetching ${feed} with cursor ${cursor}`); 84 + 47 85 const response = feed.includes("list") 48 - ? await agent.app.bsky.feed.getListFeed({ 86 + ? await currentAgent.app.bsky.feed.getListFeed({ 49 87 cursor: cursor, 50 88 limit: 100, 51 89 list: feed, 52 90 }) 53 - : await agent.app.bsky.feed.getFeed({ 91 + : await currentAgent.app.bsky.feed.getFeed({ 54 92 cursor: cursor, 55 93 limit: 100, 56 94 feed: feed, ··· 61 99 response.data.feed, 62 100 seenImageUrls.current 63 101 ).map((it) => it.post); 64 - console.log("feed", filtered); 102 + console.log(`Feed ${feed} loaded with ${filtered.length} items`); 65 103 appendCustomFeed(feed, filtered, response.data.cursor); 66 104 } else { 67 - const response = await agent.getTimeline({ 105 + const response = await currentAgent.getTimeline({ 68 106 cursor: timeline.cursor, 69 107 limit: 100, 70 108 }); ··· 87 125 } else setTimelineLoading(false); 88 126 } 89 127 }, 90 - [agent, timeline, customFeeds] 128 + [ 129 + timeline, 130 + customFeeds, 131 + appendCustomFeed, 132 + appendTimeline, 133 + setTimelineLoading, 134 + setCustomFeedLoading, 135 + ] 91 136 ); 92 137 138 + // Process any queued feed requests 139 + const processPendingRequests = useCallback(async () => { 140 + // Prevent reentrant processing 141 + if (isProcessingRequests.current) { 142 + console.log("Already processing requests, skipping"); 143 + return; 144 + } 145 + 146 + // If agent isn't ready, try again later 147 + if (!agentRef.current) { 148 + if (processingRetries.current < 5) { 149 + processingRetries.current++; 150 + console.log( 151 + `Agent not ready for processing, will retry (${processingRetries.current}/5)` 152 + ); 153 + setTimeout(() => processPendingRequests(), 1000); 154 + } else { 155 + console.log("Max retries reached, clearing pending requests"); 156 + pendingFeedRequests.current.clear(); 157 + processingRetries.current = 0; 158 + } 159 + return; 160 + } 161 + 162 + // Reset retries counter 163 + processingRetries.current = 0; 164 + 165 + const requests = Array.from(pendingFeedRequests.current); 166 + if (requests.length === 0) return; 167 + 168 + console.log(`Processing ${requests.length} pending feed requests`); 169 + 170 + // Mark as processing and clear the queue 171 + isProcessingRequests.current = true; 172 + pendingFeedRequests.current.clear(); 173 + 174 + try { 175 + for (const feed of requests) { 176 + await fetchFeed(feed === "main" ? undefined : feed); 177 + } 178 + } finally { 179 + isProcessingRequests.current = false; 180 + } 181 + }, [fetchFeed]); 182 + 183 + // Effect to initialize feed once agent is available 93 184 useEffect(() => { 94 - const loadMinimum = async () => { 95 - while ( 96 - timeline.posts.flatMap( 97 - (p) => (p.post.embed as AppBskyEmbedImages.View)?.images || [] 98 - ).length < 30 99 - ) { 100 - await fetchFeed(); 101 - if (!timeline.cursor) break; 185 + if (!agent) { 186 + console.log("Waiting for agent to become available"); 187 + return; 188 + } 189 + 190 + console.log("Agent detected, setting ready state"); 191 + setIsReady(true); 192 + 193 + // Give a small delay for agent to fully initialize 194 + setTimeout(() => { 195 + if (isInitialized) { 196 + // Process any pending requests when agent becomes available 197 + processPendingRequests(); 198 + return; 102 199 } 103 - }; 104 - loadMinimum(); 105 - }, [agent]); 200 + 201 + const loadMinimum = async () => { 202 + setIsInitialized(true); 203 + console.log("Agent available, loading initial feed"); 204 + 205 + // Process any pending requests first 206 + await processPendingRequests(); 207 + 208 + // Then load the minimum required images 209 + let attempts = 0; 210 + while ( 211 + timeline.posts.flatMap( 212 + (p) => (p.post.embed as AppBskyEmbedImages.View)?.images || [] 213 + ).length < 30 && 214 + attempts < 3 215 + ) { 216 + await fetchFeed(); 217 + attempts++; 218 + if (!timeline.cursor) break; 219 + } 220 + }; 221 + 222 + loadMinimum(); 223 + }, 500); // Small delay to ensure agent is fully initialized 224 + }, [agent, isInitialized, processPendingRequests, fetchFeed, timeline.posts]); 106 225 107 - return { fetchFeed }; 226 + return { fetchFeed, isReady }; 108 227 }