Scrapboard.org client
at labels 269 lines 7.6 kB view raw
1import { create } from "zustand"; 2import { persist } from "zustand/middleware"; 3import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 4import { createMapStorage } from "../utils/mapStorage"; 5 6// Cache policy 7const STALE_AFTER = 5 * 60 * 1000; // 5 minutes 8const EXPIRE_AFTER = 60 * 60 * 1000; // 1 hour 9 10export interface PostWithIndex { 11 post: PostView; 12 // 'index' is the image index to render, NOT a sort key. Do not sort by this. 13 index: number; 14} 15 16export interface BoardPostsData { 17 posts: PostWithIndex[]; 18 totalItems: number; 19 loadedPages: number[]; 20} 21 22export interface CacheResult { 23 boardKey: string; 24 data: BoardPostsData; 25 updatedAt: number; 26 stale: boolean; 27 expired: boolean; 28} 29 30export interface PostsCache { 31 boards: Map<string, CacheResult>; 32 setBoardPosts: ( 33 boardKey: string, 34 posts: [number, PostView][], 35 page: number, 36 pageSize: number, 37 totalItems: number 38 ) => CacheResult; 39 getBoardPosts: ( 40 boardKey: string, 41 page: number, 42 pageSize: number 43 ) => [number, PostView][]; 44 checkCache: (boardKey: string) => CacheResult | null; 45 refreshCache: ( 46 boardKey: string, 47 fetchFn: () => Promise<BoardPostsData | null>, 48 prev?: CacheResult 49 ) => Promise<void>; 50 appendBoardPosts: ( 51 boardKey: string, 52 posts: [number, PostView][], 53 page: number 54 ) => CacheResult | null; 55 hasCachedPage: (boardKey: string, page: number) => boolean; 56 getTotalPages: (boardKey: string, pageSize: number) => number; 57 clearEntry: (boardKey: string) => void; 58 clear: () => void; 59} 60 61function makeKey(p: PostWithIndex) { 62 return `${p.post.uri}#${p.index}`; 63} 64 65export const usePostsStore = create<PostsCache>()( 66 persist( 67 (set, get) => ({ 68 boards: new Map(), 69 70 setBoardPosts: (boardKey, posts, page, pageSize, totalItems) => { 71 const now = Date.now(); 72 const newPosts = posts.map(([index, post]) => ({ post, index })); 73 74 const existingEntry = get().boards.get(boardKey); 75 let combined: PostWithIndex[] = []; 76 let loadedPages: number[] = []; 77 78 if (!existingEntry) { 79 combined = [...newPosts]; 80 loadedPages = [page]; 81 } else if (page === 0) { 82 // Replace only page 0 segment: prepend new posts, keep existing non-duplicated after 83 const existing = existingEntry.data.posts; 84 const seenNew = new Set(newPosts.map((p) => makeKey(p))); 85 const rest = existing.filter((p) => !seenNew.has(makeKey(p))); 86 combined = [...newPosts, ...rest]; 87 loadedPages = Array.from( 88 new Set([0, ...(existingEntry.data.loadedPages || [])]) 89 ); 90 } else { 91 const existing = existingEntry.data.posts; 92 const seen = new Set(existing.map((p) => makeKey(p))); 93 const dedupNew = newPosts.filter((p) => !seen.has(makeKey(p))); 94 combined = [...existing, ...dedupNew]; 95 loadedPages = Array.from( 96 new Set([...(existingEntry.data.loadedPages || []), page]) 97 ); 98 } 99 100 const entry: CacheResult = { 101 boardKey, 102 data: { 103 posts: combined, 104 totalItems, 105 loadedPages, 106 }, 107 updatedAt: now, 108 stale: false, 109 expired: false, 110 }; 111 112 set((state) => ({ 113 boards: new Map(state.boards).set(boardKey, entry), 114 })); 115 116 return entry; 117 }, 118 119 appendBoardPosts: (boardKey, posts, page) => { 120 const existingEntry = get().boards.get(boardKey); 121 if (!existingEntry) return null; 122 123 const now = Date.now(); 124 const newPosts = posts.map(([index, post]) => ({ post, index })); 125 126 // Preserve order and dedupe by (uri, index) 127 const existing = existingEntry.data.posts; 128 const seen = new Set(existing.map((p) => makeKey(p))); 129 const toAdd = newPosts.filter((p) => !seen.has(makeKey(p))); 130 const combined = [...existing, ...toAdd]; 131 132 const loadedPages = Array.from( 133 new Set([...(existingEntry.data.loadedPages || []), page]) 134 ); 135 136 const entry: CacheResult = { 137 ...existingEntry, 138 data: { 139 ...existingEntry.data, 140 posts: combined, 141 loadedPages, 142 }, 143 updatedAt: now, 144 stale: false, 145 expired: false, 146 }; 147 148 set((state) => ({ 149 boards: new Map(state.boards).set(boardKey, entry), 150 })); 151 152 return entry; 153 }, 154 155 getBoardPosts: (boardKey, page, pageSize) => { 156 const entry = get().boards.get(boardKey); 157 if (!entry) return []; 158 159 const startIndex = page * pageSize; 160 const endIndex = startIndex + pageSize; 161 162 return entry.data.posts 163 .slice(startIndex, endIndex) 164 .map(({ post, index }) => [index, post] as [number, PostView]); 165 }, 166 167 checkCache: (boardKey) => { 168 const entry = get().boards.get(boardKey); 169 if (!entry) return null; 170 171 const now = Date.now(); 172 const age = now - entry.updatedAt; 173 const stale = age > STALE_AFTER; 174 const expired = age > EXPIRE_AFTER; 175 176 if (stale !== entry.stale || expired !== entry.expired) { 177 const updated: CacheResult = { 178 ...entry, 179 stale, 180 expired, 181 }; 182 183 set((state) => ({ 184 boards: new Map(state.boards).set(boardKey, updated), 185 })); 186 187 return updated; 188 } 189 190 return entry; 191 }, 192 193 refreshCache: async ( 194 boardKey: string, 195 fetchFn: () => Promise<BoardPostsData | null>, 196 prev?: CacheResult 197 ) => { 198 try { 199 const data = await fetchFn(); 200 if (data) { 201 const now = Date.now(); 202 const entry: CacheResult = { 203 boardKey, 204 data, 205 updatedAt: now, 206 stale: false, 207 expired: false, 208 }; 209 210 set((state) => ({ 211 boards: new Map(state.boards).set(boardKey, entry), 212 })); 213 } else if (prev) { 214 // Keep existing but mark as expired 215 set((state) => { 216 const updated: CacheResult = { 217 ...prev, 218 stale: true, 219 expired: true, 220 }; 221 return { 222 boards: new Map(state.boards).set(boardKey, updated), 223 }; 224 }); 225 } else { 226 get().clearEntry(boardKey); 227 } 228 } catch { 229 // Network or validation failure — don't overwrite unless necessary 230 } 231 }, 232 233 hasCachedPage: (boardKey, page) => { 234 try { 235 const entry = get().boards.get(boardKey); 236 return entry?.data.loadedPages.includes(page) ?? false; 237 } catch (err) { 238 console.error("Failed to check cached page", err); 239 return false; 240 } 241 }, 242 243 getTotalPages: (boardKey, pageSize) => { 244 const entry = get().boards.get(boardKey); 245 if (!entry) return 0; 246 return Math.ceil(entry.data.totalItems / pageSize); 247 }, 248 249 clearEntry: (boardKey) => { 250 set((state) => { 251 const map = new Map(state.boards); 252 map.delete(boardKey); 253 return { boards: map }; 254 }); 255 }, 256 257 clear: () => { 258 set(() => ({ boards: new Map() })); 259 }, 260 }), 261 { 262 name: "posts", 263 partialize: (state) => ({ 264 boards: state.boards, 265 }), 266 storage: createMapStorage("boards"), 267 } 268 ) 269);