an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app

persistent data, persistent scroll position, reload feeds, home screen loader

rimar1337 5293a5db bab14a9c

+333 -37
+46
package-lock.json
··· 8 8 "dependencies": { 9 9 "@atproto/api": "^0.16.6", 10 10 "@tailwindcss/vite": "^4.0.6", 11 + "@tanstack/query-sync-storage-persister": "^5.85.6", 11 12 "@tanstack/react-devtools": "^0.2.2", 12 13 "@tanstack/react-query": "^5.85.6", 14 + "@tanstack/react-query-persist-client": "^5.85.6", 13 15 "@tanstack/react-router": "^1.130.2", 14 16 "@tanstack/react-router-devtools": "^1.131.5", 15 17 "@tanstack/router-plugin": "^1.121.2", ··· 1894 1896 "url": "https://github.com/sponsors/tannerlinsley" 1895 1897 } 1896 1898 }, 1899 + "node_modules/@tanstack/query-persist-client-core": { 1900 + "version": "5.85.6", 1901 + "resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.85.6.tgz", 1902 + "integrity": "sha512-wUdoEurIC0YCNZzR020Xcg3OsJeF4SXmEPqlNwZ6EaGKgWeNjU17hVdK+X4ZeirUm+h0muiEQx+aIQU1lk7roQ==", 1903 + "license": "MIT", 1904 + "dependencies": { 1905 + "@tanstack/query-core": "5.85.6" 1906 + }, 1907 + "funding": { 1908 + "type": "github", 1909 + "url": "https://github.com/sponsors/tannerlinsley" 1910 + } 1911 + }, 1912 + "node_modules/@tanstack/query-sync-storage-persister": { 1913 + "version": "5.85.6", 1914 + "resolved": "https://registry.npmjs.org/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-5.85.6.tgz", 1915 + "integrity": "sha512-Gj/p0paYsdzj3IbRn6SjMMNdjZ0nVQWszn17qbHLiu3Mt6H0b/YbLL3g9uRWcoyYcaB004RawgM0MuA+xJt5iw==", 1916 + "license": "MIT", 1917 + "dependencies": { 1918 + "@tanstack/query-core": "5.85.6", 1919 + "@tanstack/query-persist-client-core": "5.85.6" 1920 + }, 1921 + "funding": { 1922 + "type": "github", 1923 + "url": "https://github.com/sponsors/tannerlinsley" 1924 + } 1925 + }, 1897 1926 "node_modules/@tanstack/react-devtools": { 1898 1927 "version": "0.2.2", 1899 1928 "resolved": "https://registry.npmjs.org/@tanstack/react-devtools/-/react-devtools-0.2.2.tgz", ··· 1929 1958 "url": "https://github.com/sponsors/tannerlinsley" 1930 1959 }, 1931 1960 "peerDependencies": { 1961 + "react": "^18 || ^19" 1962 + } 1963 + }, 1964 + "node_modules/@tanstack/react-query-persist-client": { 1965 + "version": "5.85.6", 1966 + "resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.85.6.tgz", 1967 + "integrity": "sha512-zLUfm8JlI6/s0AqvX5l5CcazdHwj5gwcv0mWYOaJJvADyFzl2wwQKqB/H4nYSeygUtrepBgPwVQKNqH9ZwlZpQ==", 1968 + "license": "MIT", 1969 + "dependencies": { 1970 + "@tanstack/query-persist-client-core": "5.85.6" 1971 + }, 1972 + "funding": { 1973 + "type": "github", 1974 + "url": "https://github.com/sponsors/tannerlinsley" 1975 + }, 1976 + "peerDependencies": { 1977 + "@tanstack/react-query": "^5.85.6", 1932 1978 "react": "^18 || ^19" 1933 1979 } 1934 1980 },
+2
package.json
··· 12 12 "dependencies": { 13 13 "@atproto/api": "^0.16.6", 14 14 "@tailwindcss/vite": "^4.0.6", 15 + "@tanstack/query-sync-storage-persister": "^5.85.6", 15 16 "@tanstack/react-devtools": "^0.2.2", 16 17 "@tanstack/react-query": "^5.85.6", 18 + "@tanstack/react-query-persist-client": "^5.85.6", 17 19 "@tanstack/react-router": "^1.130.2", 18 20 "@tanstack/react-router-devtools": "^1.131.5", 19 21 "@tanstack/router-plugin": "^1.121.2",
+37 -2
src/components/InfiniteCustomFeed.tsx
··· 33 33 hasNextPage, 34 34 fetchNextPage, 35 35 isFetchingNextPage, 36 + refetch, 37 + isRefetching, 36 38 } = useInfiniteQueryFeedSkeleton({ 37 39 feedUri: feedUri, 38 40 agent: agent ?? undefined, ··· 40 42 pdsUrl: pdsUrl, 41 43 feedServiceDid: feedServiceDid, 42 44 }); 45 + 46 + const handleRefresh = () => { 47 + refetch(); 48 + }; 43 49 44 50 //const { ref, inView } = useInView(); 45 51 ··· 99 105 Load More Posts 100 106 </button> 101 107 )} 102 - {!hasNextPage && <div className="p-4 text-center text-gray-500">End of feed.</div>} 108 + {!hasNextPage && ( 109 + <div className="p-4 text-center text-gray-500">End of feed.</div> 110 + )} 111 + <button 112 + onClick={handleRefresh} 113 + disabled={isRefetching} 114 + className="sticky lg:bottom-6 bottom-24 ml-4 w-[42px] h-[42px] z-10 bg-gray-500 hover:bg-gray-600 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:bg-gray-400 disabled:cursor-not-allowed" 115 + aria-label="Refresh feed" 116 + > 117 + {isRefetching ? <RefreshIcon className="h-6 w-6 animate-spin" /> : <RefreshIcon className="h-6 w-6" />} 118 + </button> 103 119 </> 104 120 ); 105 - } 121 + } 122 + 123 + const RefreshIcon = (props: React.SVGProps<SVGSVGElement>) => ( 124 + <svg 125 + xmlns="http://www.w3.org/2000/svg" 126 + //width={360} 127 + //height={360} 128 + viewBox="0 0 24 24" 129 + {...props} 130 + > 131 + <path 132 + fill="none" 133 + stroke="currentColor" 134 + strokeLinecap="round" 135 + strokeLinejoin="round" 136 + strokeWidth={2} 137 + d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" 138 + ></path> 139 + </svg> 140 + );
+24 -4
src/main.tsx
··· 7 7 8 8 import "~/styles/app.css"; 9 9 import reportWebVitals from "./reportWebVitals.ts"; 10 - import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 10 + import { QueryClient, QueryClientProvider, } from "@tanstack/react-query"; 11 + import { 12 + persistQueryClient, 13 + } from "@tanstack/react-query-persist-client"; 14 + import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; 11 15 12 - const queryClient = new QueryClient(); 16 + 17 + const queryClient = new QueryClient({ 18 + defaultOptions: { 19 + queries: { 20 + gcTime: 1000 * 60 * 60 * 24 * 24, // 24 days 21 + }, 22 + }, 23 + }); 24 + const localStoragePersister = createSyncStoragePersister({ 25 + storage: window.localStorage, 26 + }); 27 + 28 + persistQueryClient({ 29 + queryClient, 30 + persister: localStoragePersister, 31 + }) 32 + 13 33 // Create a new router instance 14 34 const router = createRouter({ 15 35 routeTree, ··· 33 53 const root = ReactDOM.createRoot(rootElement); 34 54 root.render( 35 55 // double queries annoys me 36 - <StrictMode> 56 + // <StrictMode> 37 57 <QueryClientProvider client={queryClient}> 38 58 <RouterProvider router={router} /> 39 59 </QueryClientProvider> 40 - </StrictMode> 60 + // </StrictMode> 41 61 ); 42 62 } 43 63
+5 -1
src/routes/__root.tsx
··· 10 10 Outlet, 11 11 Scripts, 12 12 createRootRoute, 13 + createRootRouteWithContext, 13 14 useLocation, 14 15 useNavigate, 15 16 } from "@tanstack/react-router"; ··· 23 24 import { AuthProvider, useAuth } from "~/providers/PassAuthProvider"; 24 25 import { PersistentStoreProvider } from "~/providers/PersistentStoreProvider"; 25 26 import type AtpAgent from "@atproto/api"; 27 + import type { QueryClient } from "@tanstack/react-query"; 26 28 27 - export const Route = createRootRoute({ 29 + export const Route = createRootRouteWithContext<{ 30 + queryClient: QueryClient; 31 + }>()({ 28 32 head: () => ({ 29 33 meta: [ 30 34 {
+192 -26
src/routes/index.tsx
··· 13 13 useQueryPost, 14 14 useQueryFeedSkeleton, 15 15 useQueryPreferences, 16 - useQueryArbitrary 16 + useQueryArbitrary, 17 + constructInfiniteFeedSkeletonQuery, 18 + constructArbitraryQuery, 19 + constructIdentityQuery, 20 + constructPostQuery, 17 21 } from "~/utils/useQuery"; 18 22 import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed"; 23 + import { useAtom, useSetAtom } from "jotai"; 24 + import { 25 + selectedFeedUriAtom, 26 + store, 27 + agentAtom, 28 + authedAtom, 29 + feedScrollPositionsAtom, 30 + } from "~/utils/atoms"; 31 + import { useEffect, useLayoutEffect } from "react"; 19 32 20 33 export const Route = createFileRoute("/")({ 34 + loader: async ({ context }) => { 35 + const { queryClient } = context; 36 + const atomauth = store.get(authedAtom); 37 + const atomagent = store.get(agentAtom); 38 + 39 + let identitypds: string | undefined; 40 + const initialselectedfeed = store.get(selectedFeedUriAtom); 41 + if (atomagent && atomauth && atomagent?.did) { 42 + const identityopts = constructIdentityQuery(atomagent.did); 43 + const identityresultmaybe = 44 + await queryClient.ensureQueryData(identityopts); 45 + identitypds = identityresultmaybe?.pds; 46 + } 47 + 48 + const arbitraryopts = constructArbitraryQuery( 49 + initialselectedfeed ?? 50 + "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot" 51 + ); 52 + const feedGengetrecordquery = 53 + await queryClient.ensureQueryData(arbitraryopts); 54 + const feedServiceDid = (feedGengetrecordquery?.value as any)?.did; 55 + //queryClient.ensureInfiniteQueryData() 56 + 57 + const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery({ 58 + feedUri: 59 + initialselectedfeed ?? 60 + "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot", 61 + agent: atomagent ?? undefined, 62 + isAuthed: atomauth ?? false, 63 + pdsUrl: identitypds, 64 + feedServiceDid: feedServiceDid, 65 + }); 66 + 67 + const res = await queryClient.ensureInfiniteQueryData({ 68 + queryKey, 69 + queryFn, 70 + initialPageParam: undefined as never, 71 + getNextPageParam: (lastPage: any) => lastPage.cursor as null | undefined, 72 + staleTime: Infinity, 73 + //refetchOnWindowFocus: false, 74 + //enabled: true, 75 + }); 76 + await Promise.all( 77 + res.pages.map(async (page) => { 78 + await Promise.all( 79 + page.feed.map(async (feedviewpost) => { 80 + if (!feedviewpost.post) return; 81 + console.log("preloading: ", feedviewpost.post); 82 + const opts = constructPostQuery(feedviewpost.post); 83 + try { 84 + await queryClient.ensureQueryData(opts); 85 + } catch (e) { 86 + console.log(" failed:", e); 87 + } 88 + }) 89 + ); 90 + }) 91 + ); 92 + }, 21 93 component: Home, 94 + pendingComponent: PendingHome, 22 95 }); 23 - 96 + function PendingHome() { 97 + return <div>loading... (prefetching your timeline)</div>; 98 + } 24 99 function Home() { 25 100 const { 26 101 agent, ··· 30 105 loading: loadering, 31 106 authed, 32 107 } = useAuth(); 108 + 109 + useEffect(() => { 110 + if (agent?.did) { 111 + store.set(authedAtom, true); 112 + } else { 113 + store.set(authedAtom, false); 114 + } 115 + }, [loginStatus, agent, authed]); 116 + useEffect(() => { 117 + if (agent) { 118 + store.set(agentAtom, agent); 119 + } else { 120 + store.set(agentAtom, null); 121 + } 122 + }, [loginStatus, agent, authed]); 123 + 33 124 //const { get, set } = usePersistentStore(); 34 125 // const [feed, setFeed] = React.useState<any[]>([]); 35 126 // const [loading, setLoading] = React.useState(true); ··· 67 158 // }, [prefs]); 68 159 69 160 // const savedFeeds = savedFeedsPref?.items || []; 70 - 161 + 71 162 const identityresultmaybe = useQueryIdentity(agent?.did); 72 - const identity = identityresultmaybe?.data 163 + const identity = identityresultmaybe?.data; 73 164 74 - const prefsresultmaybe = useQueryPreferences({agent: agent ?? undefined, pdsUrl: identity?.pds}); 75 - const prefs = prefsresultmaybe?.data 76 - 165 + const prefsresultmaybe = useQueryPreferences({ 166 + agent: agent ?? undefined, 167 + pdsUrl: identity?.pds, 168 + }); 169 + const prefs = prefsresultmaybe?.data; 170 + 77 171 const savedFeeds = React.useMemo(() => { 78 172 const savedFeedsPref = prefs?.preferences?.find( 79 173 (p: any) => p?.$type === "app.bsky.actor.defs#savedFeedsPrefV2" ··· 81 175 return savedFeedsPref?.items || []; 82 176 }, [prefs]); 83 177 84 - 178 + const [persistentSelectedFeed, setPersistentSelectedFeed] = 179 + useAtom(selectedFeedUriAtom); // React.useState<string | null>(null); 180 + const [unauthedSelectedFeed, setUnauthedSelectedFeed] = React.useState( 181 + persistentSelectedFeed 182 + ); // React.useState<string | null>(null); 183 + const selectedFeed = agent?.did 184 + ? persistentSelectedFeed 185 + : unauthedSelectedFeed; 186 + const setSelectedFeed = agent?.did 187 + ? setPersistentSelectedFeed 188 + : setUnauthedSelectedFeed; 85 189 86 - const [selectedFeed, setSelectedFeed] = React.useState<string | null>(null); 87 - 190 + console.log("my selectedFeed is: ", selectedFeed); 88 191 React.useEffect(() => { 89 192 const fallbackFeed = 90 193 "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot"; 91 194 if (authed) { 195 + if (selectedFeed) return; 92 196 if (savedFeeds.length > 0) { 93 197 setSelectedFeed((prev) => 94 198 prev && savedFeeds.some((f: any) => f.value === prev) 95 199 ? prev 96 - : savedFeeds[0].value, 200 + : savedFeeds[0].value 97 201 ); 98 202 } else { 203 + if (selectedFeed) return; 99 204 setSelectedFeed(fallbackFeed); 100 205 } 101 206 } else { 207 + if (selectedFeed) return; 102 208 setSelectedFeed(fallbackFeed); 103 209 } 104 - }, [savedFeeds, authed]); 210 + }, [savedFeeds, authed, setSelectedFeed]); 105 211 106 212 // React.useEffect(() => { 107 213 // if (loadering || !selectedFeed) return; ··· 185 291 // ignore = true; 186 292 // }; 187 293 // }, [authed, agent, loadering, selectedFeed, get, set]); 188 - 294 + 295 + const [scrollPositions, setScrollPositions] = useAtom( 296 + feedScrollPositionsAtom 297 + ); 298 + 299 + const scrollRef = React.useRef<Record<string, number>>({}); 300 + 301 + useEffect(() => { 302 + const onScroll = () => { 303 + //if (!selectedFeed) return; 304 + scrollRef.current[selectedFeed ?? "null"] = window.scrollY; 305 + }; 306 + window.addEventListener("scroll", onScroll, { passive: true }); 307 + return () => window.removeEventListener("scroll", onScroll); 308 + }, [selectedFeed]); 309 + const [donerestored, setdonerestored] = React.useState(false); 310 + 311 + useEffect(() => { 312 + return () => { 313 + if (!donerestored) return; 314 + console.log("FEEDSCROLLSHIT saving at uhhh: ", scrollRef.current); 315 + //if (!selectedFeed) return; 316 + setScrollPositions((prev) => ({ 317 + ...prev, 318 + [selectedFeed ?? "null"]: 319 + scrollRef.current[selectedFeed ?? "null"] ?? 0, 320 + })); 321 + }; 322 + }, [selectedFeed, setScrollPositions, donerestored]); 323 + 324 + const [restoringScrollPosition, setRestoringScrollPosition] = 325 + React.useState(false); 326 + 327 + useLayoutEffect(() => { 328 + setRestoringScrollPosition(true); 329 + const savedPosition = scrollPositions[selectedFeed ?? "null"] ?? 0; 330 + 331 + let raf = requestAnimationFrame(() => { 332 + // setRestoringScrollPosition(true); 333 + // raf = requestAnimationFrame(() => { 334 + // window.scrollTo({ top: savedPosition, behavior: "instant" }); 335 + // setRestoringScrollPosition(false); 336 + // setdonerestored(true); 337 + // }); 338 + window.scrollTo({ top: savedPosition, behavior: "instant" }); 339 + setRestoringScrollPosition(false); 340 + setdonerestored(true); 341 + }); 342 + 343 + return () => cancelAnimationFrame(raf); 344 + }, [selectedFeed, scrollPositions]); 189 345 190 - const feedGengetrecordquery = useQueryArbitrary(selectedFeed??undefined); 346 + const feedGengetrecordquery = useQueryArbitrary(selectedFeed ?? undefined); 191 347 const feedServiceDid = (feedGengetrecordquery?.data?.value as any)?.did; 192 348 193 349 // const { ··· 204 360 205 361 // const feed = feedData?.feed || []; 206 362 207 - const isReadyForAuthedFeed = authed && agent && identity?.pds && feedServiceDid; 363 + const isReadyForAuthedFeed = 364 + authed && agent && identity?.pds && feedServiceDid; 208 365 const isReadyForUnauthedFeed = !authed && selectedFeed; 209 366 210 367 return ( 211 - <div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800"> 368 + <div className="relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800"> 212 369 <div className="flex items-center gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin"> 213 370 {savedFeeds.length > 0 ? ( 214 371 savedFeeds.map((item: any, idx: number) => { ··· 252 409 /> 253 410 ))} */} 254 411 255 - {(authed && (!identity?.pds || !feedServiceDid)) && ( 256 - <div className="p-4 text-center text-gray-500">Preparing your feed...</div> 412 + {authed && (!identity?.pds || !feedServiceDid) && ( 413 + <div className="p-4 text-center text-gray-500"> 414 + Preparing your feed... 415 + </div> 257 416 )} 258 417 259 - {(isReadyForAuthedFeed || isReadyForUnauthedFeed) ? ( 260 - <InfiniteCustomFeed 261 - feedUri={selectedFeed!} 262 - pdsUrl={identity?.pds} 263 - feedServiceDid={feedServiceDid} 264 - /> 418 + {isReadyForAuthedFeed || isReadyForUnauthedFeed ? ( 419 + <InfiniteCustomFeed 420 + feedUri={selectedFeed!} 421 + pdsUrl={identity?.pds} 422 + feedServiceDid={feedServiceDid} 423 + /> 265 424 ) : ( 266 - <div className="p-4 text-center text-gray-500">Select a feed to get started.</div> 425 + <div className="p-4 text-center text-gray-500"> 426 + Select a feed to get started. 427 + </div> 428 + )} 429 + {false && restoringScrollPosition && ( 430 + <div className="fixed top-1/2 left-1/2 right-1/2"> 431 + restoringScrollPosition 432 + </div> 267 433 )} 268 434 </div> 269 435 ); ··· 295 461 } catch {} 296 462 } 297 463 const url = `https://free-fly-24.deno.dev/resolve-did-web?did=${encodeURIComponent( 298 - didweb, 464 + didweb 299 465 )}`; 300 466 const res = await fetch(url); 301 467 if (!res.ok) throw new Error("Failed to resolve didwebdoc");
+23 -3
src/utils/atoms.ts
··· 1 - import { atom } from "jotai"; 1 + import type AtpAgent from "@atproto/api"; 2 + import { atom, createStore } from "jotai"; 3 + import { atomWithStorage } from 'jotai/utils'; 4 + 5 + export const store = createStore(); 2 6 3 - export const selectedFeedUriAtom = atom<string | null>(null); 7 + export const selectedFeedUriAtom = atomWithStorage<string | null>( 8 + 'selectedFeedUri', 9 + null 10 + ); 4 11 5 - export const feedScrollPositionsAtom = atom<Record<string, number>>({}); 12 + //export const feedScrollPositionsAtom = atom<Record<string, number>>({}); 13 + 14 + export const feedScrollPositionsAtom = atomWithStorage<Record<string, number>>( 15 + 'feedscrollpositions', 16 + {} 17 + ); 18 + 19 + export const likedPostsAtom = atomWithStorage<Record<string, boolean>>( 20 + 'likedPosts', 21 + {} 22 + ); 23 + 24 + export const agentAtom = atom<AtpAgent|null>(null); 25 + export const authedAtom = atom<boolean>(false);
+4 -1
src/utils/useQuery.ts
··· 234 234 return undefined; 235 235 } 236 236 }, 237 + // enforce short lifespan 238 + staleTime: 5 * 60 * 1000, // 5 minutes 239 + gcTime: 5 * 60 * 1000, 237 240 }); 238 241 } 239 242 export function useQueryConstellation(query: { ··· 399 402 400 403 export function constructArbitraryQuery(uri?: string) { 401 404 return queryOptions({ 402 - queryKey: ["post", uri], 405 + queryKey: ["arbitrary", uri], 403 406 queryFn: async () => { 404 407 if (!uri) return undefined as undefined 405 408 const res = await fetch(