Scrapboard.org client
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}