an appview-less Bluesky client using Constellation and PDS Queries - https://reddwarf.whey.party/

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

rimar1337 5293a5db bab14a9c

+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(