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