Scrapboard.org client
at main 293 lines 8.8 kB view raw
1import { useState, useEffect, useCallback, useMemo } from "react"; 2import { AtUri } from "@atproto/api"; 3import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 4import { Agent } from "@atproto/api"; 5import { getAllPosts } from "@/lib/records"; 6import { usePostsStore, BoardPostsData } from "@/lib/stores/posts"; 7 8import { BoardItem } from "../stores/boardItems"; 9 10export interface UseBoardPostsOptions { 11 itemsInBoard: [string, BoardItem][]; // [rkey, BoardItem] 12 agent: Agent | null; 13 pageSize: number; 14 enabled: boolean; 15 boardKey: string; 16} 17 18export interface UseBoardPostsReturn { 19 posts: [number, PostView][]; 20 isLoading: boolean; 21 isLoadingMore: boolean; 22 hasMore: boolean; 23 currentPage: number; 24 totalPages: number; 25 loadMore: () => Promise<void>; 26 refresh: () => Promise<void>; 27 error: Error | null; 28 isStale: boolean; 29} 30 31export function useBoardPosts({ 32 itemsInBoard, 33 agent, 34 pageSize, 35 enabled, 36 boardKey, 37}: UseBoardPostsOptions): UseBoardPostsReturn { 38 const [currentPage, setCurrentPage] = useState(0); 39 const [isLoading, setIsLoading] = useState(false); 40 const [isLoadingMore, setIsLoadingMore] = useState(false); 41 const [error, setError] = useState<Error | null>(null); 42 43 const { setBoardPosts, appendBoardPosts, checkCache, refreshCache } = 44 usePostsStore(); 45 46 // Subscribe to the cache entry so component re-renders when it changes 47 const cacheEntry = usePostsStore((s) => 48 boardKey ? s.boards.get(boardKey) ?? null : null 49 ); 50 51 // Check cache status 52 const cacheResult = useMemo(() => { 53 return boardKey ? checkCache(boardKey) : null; 54 }, [boardKey, checkCache]); 55 56 const isStale = cacheResult?.stale ?? false; 57 const isExpired = cacheResult?.expired ?? false; 58 59 // Get posts from cache for pages 0..currentPage 60 const posts = useMemo(() => { 61 if (!boardKey || !cacheEntry) return []; 62 63 const all = cacheEntry.data.posts; 64 const end = Math.min((currentPage + 1) * pageSize, all.length); 65 return all 66 .slice(0, end) 67 .map(({ post, index }) => [index, post] as [number, PostView]); 68 }, [boardKey, cacheEntry, currentPage, pageSize]); 69 70 // Calculate pagination info 71 const totalPages = useMemo(() => { 72 // Derive from live itemsInBoard length so we don't freeze totalPages at initial fetch 73 return Math.ceil(itemsInBoard.length / pageSize) || 0; 74 }, [itemsInBoard.length, pageSize]); 75 const hasMore = currentPage < totalPages - 1; 76 77 // Create fetch function for refreshCache 78 const createFetchFunction = useCallback( 79 (targetPage: number) => { 80 return async (): Promise<BoardPostsData | null> => { 81 if (!agent || !boardKey || itemsInBoard.length === 0) { 82 return null; 83 } 84 85 try { 86 // Calculate which items to load for this page 87 const startIndex = targetPage * pageSize; 88 const endIndex = Math.min(startIndex + pageSize, itemsInBoard.length); 89 const pageItems = itemsInBoard.slice(startIndex, endIndex); 90 91 if (pageItems.length === 0) { 92 return null; 93 } 94 95 // Extract canonical URIs and dedupe to minimize API calls 96 const canonicalUris = pageItems.map( 97 ([, item]) => item.url.split("?")[0] 98 ); 99 const uniqueAtUris = Array.from(new Set(canonicalUris)).map( 100 (u) => new AtUri(u) 101 ); 102 103 console.log( 104 `Fetching page ${targetPage}: ${canonicalUris.length} items (${uniqueAtUris.length} unique posts)` 105 ); 106 107 // Fetch unique posts 108 const fetchedPosts = await getAllPosts({ 109 posts: uniqueAtUris, 110 agent, 111 }); 112 113 // Build a map for quick lookup 114 const postByUri = new Map(fetchedPosts.map((p) => [p.uri, p])); 115 116 // Rebuild result preserving duplicates and per-image index from saved board item URL 117 const postsWithIndex = pageItems 118 .map(([, item]) => { 119 const cleanUrl = item.url.split("?")[0]; 120 const post = postByUri.get(cleanUrl); 121 if (!post) return null; 122 123 // Extract ?image=<n> from the saved URL (query on at:// string) 124 let imageIndex = 0; 125 const qs = item.url.split("?")[1]; 126 if (qs) { 127 const sp = new URLSearchParams(qs); 128 const v = sp.get("image"); 129 if (v != null) imageIndex = Number(v) || 0; 130 } 131 132 return { post, index: imageIndex }; 133 }) 134 .filter((v): v is { post: PostView; index: number } => v !== null); 135 136 return { 137 posts: postsWithIndex, 138 totalItems: itemsInBoard.length, 139 loadedPages: [targetPage], 140 }; 141 } catch (err) { 142 console.error(`Error fetching page ${targetPage}:`, err); 143 throw err; 144 } 145 }; 146 }, 147 [agent, boardKey, itemsInBoard, pageSize] 148 ); 149 150 // Load posts for a specific page 151 const loadPage = useCallback( 152 async (page: number, isInitial = false, force = false) => { 153 if (!enabled || !agent || !boardKey || itemsInBoard.length === 0) { 154 return; 155 } 156 157 // Check if page is already cached and not forcing refresh 158 const hasPage = cacheEntry?.data.loadedPages.includes(page) ?? false; 159 if (hasPage && !force && !isInitial) { 160 return; 161 } 162 163 const setLoadingState = isInitial ? setIsLoading : setIsLoadingMore; 164 setLoadingState(true); 165 setError(null); 166 167 try { 168 const shouldRefresh = page === 0 && (force || isExpired || !hasPage); 169 if (shouldRefresh) { 170 // Page 0 – replace cache via refresh 171 await refreshCache( 172 boardKey, 173 createFetchFunction(page), 174 cacheResult || undefined 175 ); 176 } else { 177 // Page > 0 – fetch and append 178 const fetchFunction = createFetchFunction(page); 179 const result = await fetchFunction(); 180 181 if (result && result.posts.length > 0) { 182 const postsWithIndex: [number, PostView][] = result.posts.map( 183 ({ post, index }) => [index, post] 184 ); 185 186 if (page === 0) { 187 setBoardPosts( 188 boardKey, 189 postsWithIndex, 190 page, 191 pageSize, 192 result.totalItems 193 ); 194 } else { 195 appendBoardPosts(boardKey, postsWithIndex, page); 196 } 197 } 198 } 199 } catch (err) { 200 console.error(`Error loading page ${page}:`, err); 201 setError( 202 err instanceof Error ? err : new Error("Failed to load posts") 203 ); 204 } finally { 205 setLoadingState(false); 206 } 207 }, 208 [ 209 enabled, 210 agent, 211 boardKey, 212 itemsInBoard, 213 pageSize, 214 cacheEntry?.data.loadedPages, 215 refreshCache, 216 createFetchFunction, 217 setBoardPosts, 218 appendBoardPosts, 219 cacheResult, 220 isExpired, 221 ] 222 ); 223 224 // Load more posts (next page) 225 const loadMore = useCallback(async () => { 226 const firstPageReady = cacheEntry?.data.loadedPages.includes(0) ?? false; 227 if (isLoadingMore || !hasMore || isLoading || !firstPageReady) return; 228 229 const nextPage = currentPage + 1; 230 await loadPage(nextPage); 231 setCurrentPage(nextPage); 232 }, [ 233 currentPage, 234 hasMore, 235 isLoadingMore, 236 isLoading, 237 cacheEntry?.data.loadedPages, 238 loadPage, 239 ]); 240 241 // Refresh all data 242 const refresh = useCallback(async () => { 243 if (!boardKey) return; 244 245 setCurrentPage(0); 246 setError(null); 247 await loadPage(0, true, true); // Force refresh 248 }, [boardKey, loadPage]); 249 250 // Load initial page when enabled 251 useEffect(() => { 252 if (enabled && boardKey) { 253 const needsLoad = 254 !cacheEntry || 255 isExpired || 256 !(cacheEntry.data.loadedPages || []).includes(0); 257 if (needsLoad) { 258 setCurrentPage(0); 259 loadPage(0, true, isExpired); 260 } 261 } 262 }, [enabled, boardKey, cacheEntry, isExpired, loadPage]); 263 264 // Auto-refresh stale data in background 265 useEffect(() => { 266 if (enabled && boardKey && isStale && !isLoading && !isLoadingMore) { 267 // Background refresh for stale data 268 loadPage(0, false, true); 269 } 270 }, [enabled, boardKey, isStale, isLoading, isLoadingMore, loadPage]); 271 272 // Update loading state 273 useEffect(() => { 274 if (enabled && boardKey && posts.length === 0 && !cacheEntry) { 275 setIsLoading(true); 276 } else if (!isLoading) { 277 setIsLoading(false); 278 } 279 }, [enabled, boardKey, posts.length, cacheEntry, isLoading]); 280 281 return { 282 posts, 283 isLoading, 284 isLoadingMore, 285 hasMore, 286 currentPage, 287 totalPages, 288 loadMore, 289 refresh, 290 error, 291 isStale, 292 }; 293}