an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm
at main 18 kB view raw
1import { createFileRoute } from "@tanstack/react-router"; 2import { useAtom } from "jotai"; 3import * as React from "react"; 4import { useLayoutEffect, useState } from "react"; 5 6import { Header } from "~/components/Header"; 7import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed"; 8import { useAuth } from "~/providers/UnifiedAuthProvider"; 9import { 10 feedScrollPositionsAtom, 11 isAtTopAtom, 12 quickAuthAtom, 13 selectedFeedUriAtom, 14} from "~/utils/atoms"; 15//import { usePersistentStore } from "~/providers/PersistentStoreProvider"; 16import { 17 //constructArbitraryQuery, 18 //constructIdentityQuery, 19 //constructInfiniteFeedSkeletonQuery, 20 //constructPostQuery, 21 useQueryArbitrary, 22 useQueryIdentity, 23 useQueryPreferences, 24} from "~/utils/useQuery"; 25 26export const Route = createFileRoute("/")({ 27 // loader: async ({ context }) => { 28 // const { queryClient } = context; 29 // const atomauth = store.get(authedAtom); 30 // const atomagent = store.get(agentAtom); 31 32 // let identitypds: string | undefined; 33 // const initialselectedfeed = store.get(selectedFeedUriAtom); 34 // if (atomagent && atomauth && atomagent?.did) { 35 // const identityopts = constructIdentityQuery(atomagent.did); 36 // const identityresultmaybe = 37 // await queryClient.ensureQueryData(identityopts); 38 // identitypds = identityresultmaybe?.pds; 39 // } 40 41 // const arbitraryopts = constructArbitraryQuery( 42 // initialselectedfeed ?? 43 // "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot" 44 // ); 45 // const feedGengetrecordquery = 46 // await queryClient.ensureQueryData(arbitraryopts); 47 // const feedServiceDid = (feedGengetrecordquery?.value as any)?.did; 48 // //queryClient.ensureInfiniteQueryData() 49 50 // const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery({ 51 // feedUri: 52 // initialselectedfeed ?? 53 // "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot", 54 // agent: atomagent ?? undefined, 55 // isAuthed: atomauth ?? false, 56 // pdsUrl: identitypds, 57 // feedServiceDid: feedServiceDid, 58 // }); 59 60 // const res = await queryClient.ensureInfiniteQueryData({ 61 // queryKey, 62 // queryFn, 63 // initialPageParam: undefined as never, 64 // getNextPageParam: (lastPage: any) => lastPage.cursor as null | undefined, 65 // staleTime: Infinity, 66 // //refetchOnWindowFocus: false, 67 // //enabled: true, 68 // }); 69 // await Promise.all( 70 // res.pages.map(async (page) => { 71 // await Promise.all( 72 // page.feed.map(async (feedviewpost) => { 73 // if (!feedviewpost.post) return; 74 // // /*mass comment*/ console.log("preloading: ", feedviewpost.post); 75 // const opts = constructPostQuery(feedviewpost.post); 76 // try { 77 // await queryClient.ensureQueryData(opts); 78 // } catch (e) { 79 // // /*mass comment*/ console.log(" failed:", e); 80 // } 81 // }) 82 // ); 83 // }) 84 // ); 85 // }, 86 component: Home, 87 pendingComponent: PendingHome, // PendingHome, 88 staticData: { keepAlive: true }, 89}); 90function PendingHome() { 91 return <div>loading... (prefetching your timeline)</div>; 92} 93 94//function Homer() { 95// return <div></div> 96//} 97export function Home({ hidden = false }: { hidden?: boolean }) { 98 const { 99 agent, 100 status, 101 authMethod, 102 loginWithPassword, 103 loginWithOAuth, 104 logout, 105 } = useAuth(); 106 const authed = !!agent?.did; 107 108 // i dont remember why this is even here 109 // useEffect(() => { 110 // if (agent?.did) { 111 // store.set(authedAtom, true); 112 // } else { 113 // store.set(authedAtom, false); 114 // } 115 // }, [status, agent, authed]); 116 // useEffect(() => { 117 // if (agent) { 118 // // eslint-disable-next-line @typescript-eslint/ban-ts-comment 119 // // @ts-ignore is it just me or is the type really weird here it should be Agent not AtpAgent 120 // store.set(agentAtom, agent); 121 // } else { 122 // store.set(agentAtom, null); 123 // } 124 // }, [status, agent, authed]); 125 126 //const { get, set } = usePersistentStore(); 127 // const [feed, setFeed] = React.useState<any[]>([]); 128 // const [loading, setLoading] = React.useState(true); 129 // const [error, setError] = React.useState<string | null>(null); 130 131 // const [prefs, setPrefs] = React.useState<any>({}); 132 // React.useEffect(() => { 133 // if (!loadering && authed && agent && agent.did) { 134 // const run = async () => { 135 // try { 136 // if (!agent.did) return; 137 // const prefs = await cachedGetPrefs({ 138 // did: agent.did, 139 // agent, 140 // get, 141 // set, 142 // }); 143 144 // // /*mass comment*/ console.log("alistoffeeds", prefs); 145 // setPrefs(prefs || {}); 146 // } catch (err) { 147 // console.error("alistoffeeds Fetch error in preferences effect:", err); 148 // } 149 // }; 150 151 // run(); 152 // } 153 // }, [loadering, authed, agent]); 154 155 // const savedFeedsPref = React.useMemo(() => { 156 // if (!prefs?.preferences) return null; 157 // return prefs.preferences.find( 158 // (p: any) => p?.$type === "app.bsky.actor.defs#savedFeedsPrefV2", 159 // ); 160 // }, [prefs]); 161 162 // const savedFeeds = savedFeedsPref?.items || []; 163 164 const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 165 const isAuthRestoring = quickAuth ? status === "loading" : false; 166 167 const identityresultmaybe = useQueryIdentity(!isAuthRestoring ? agent?.did : undefined); 168 const identity = identityresultmaybe?.data; 169 170 const prefsresultmaybe = useQueryPreferences({ 171 agent: !isAuthRestoring ? (agent ?? undefined) : undefined, 172 pdsUrl: !isAuthRestoring ? (identity?.pds) : undefined, 173 }); 174 const prefs = prefsresultmaybe?.data; 175 176 const savedFeeds = React.useMemo(() => { 177 const savedFeedsPref = prefs?.preferences?.find( 178 (p: any) => p?.$type === "app.bsky.actor.defs#savedFeedsPrefV2" 179 ); 180 return savedFeedsPref?.items || []; 181 }, [prefs]); 182 183 const [persistentSelectedFeed, setPersistentSelectedFeed] = useAtom(selectedFeedUriAtom); 184 const [unauthedSelectedFeed, setUnauthedSelectedFeed] = useState(persistentSelectedFeed); 185 const selectedFeed = agent?.did 186 ? persistentSelectedFeed 187 : unauthedSelectedFeed; 188 const setSelectedFeed = agent?.did 189 ? setPersistentSelectedFeed 190 : setUnauthedSelectedFeed; 191 192 // /*mass comment*/ console.log("my selectedFeed is: ", selectedFeed); 193 React.useEffect(() => { 194 const fallbackFeed = 195 "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot"; 196 if (authed) { 197 if (selectedFeed) return; 198 if (savedFeeds.length > 0) { 199 setSelectedFeed((prev) => 200 prev && savedFeeds.some((f: any) => f.value === prev) 201 ? prev 202 : savedFeeds[0].value 203 ); 204 } else { 205 if (selectedFeed) return; 206 setSelectedFeed(fallbackFeed); 207 } 208 } else { 209 if (selectedFeed) return; 210 setSelectedFeed(fallbackFeed); 211 } 212 }, [savedFeeds, authed, setSelectedFeed]); 213 214 // React.useEffect(() => { 215 // if (loadering || !selectedFeed) return; 216 217 // let ignore = false; 218 219 // const run = async () => { 220 // setLoading(true); 221 // setError(null); 222 223 // try { 224 // if (authed && agent) { 225 // if (!agent.did) return; 226 227 // const pdsurl = await cachedResolveIdentity({ 228 // didOrHandle: agent.did, 229 // get, 230 // set, 231 // }); 232 233 // const fetchstringcomplex = `${pdsurl.pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${selectedFeed}`; 234 // // /*mass comment*/ console.log("fetching feed authed: " + fetchstringcomplex); 235 236 // const feeddef = await cachedGetRecord({ 237 // atUri: selectedFeed, 238 // get, 239 // set, 240 // }); 241 242 // const feedservicedid = feeddef.value.did; 243 244 // const res = await agent.fetchHandler(fetchstringcomplex, { 245 // method: "GET", 246 // headers: { 247 // "atproto-proxy": `${feedservicedid}#bsky_fg`, 248 // "Content-Type": "application/json", 249 // }, 250 // }); 251 252 // if (!res.ok) throw new Error("Failed to fetch feed"); 253 // const data = await res.json(); 254 255 // if (!ignore) setFeed(data.feed || []); 256 // } else { 257 // // /*mass comment*/ console.log("falling back"); 258 // // always use fallback feed for not logged in 259 // const fallbackFeed = 260 // "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot"; 261 // // const feeddef = await cachedGetRecord({ 262 // // atUri: fallbackFeed, 263 // // get, 264 // // set, 265 // // }); 266 267 // //const feedservicedid = "did:web:discover.bsky.app" //feeddef.did; 268 // const fetchstringsimple = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${fallbackFeed}`; 269 // // /*mass comment*/ console.log("fetching feed unauthed: " + fetchstringsimple); 270 271 // const res = await fetch(fetchstringsimple); 272 // if (!res.ok) throw new Error("Failed to fetch feed"); 273 // const data = await res.json(); 274 275 // if (!ignore) setFeed(data.feed || []); 276 // } 277 // } catch (e) { 278 // if (!ignore) { 279 // if (e instanceof Error) { 280 // setError(e.message); 281 // } else { 282 // setError("Unknown error"); 283 // } 284 // } 285 // } finally { 286 // if (!ignore) setLoading(false); 287 // } 288 // }; 289 290 // run(); 291 292 // return () => { 293 // ignore = true; 294 // }; 295 // }, [authed, agent, loadering, selectedFeed, get, set]); 296 297 const [scrollPositions, setScrollPositions] = useAtom( 298 feedScrollPositionsAtom 299 ); 300 301 const scrollPositionsRef = React.useRef(scrollPositions); 302 303 React.useEffect(() => { 304 scrollPositionsRef.current = scrollPositions; 305 }, [scrollPositions]); 306 307 useLayoutEffect(() => { 308 if (isAuthRestoring) return; 309 const savedPosition = scrollPositions[selectedFeed ?? "null"] ?? 0; 310 311 window.scrollTo({ top: savedPosition, behavior: "instant" }); 312 // eslint-disable-next-line react-hooks/exhaustive-deps 313 }, [selectedFeed, isAuthRestoring]); 314 315 useLayoutEffect(() => { 316 if (!selectedFeed || isAuthRestoring) return; 317 318 const handleScroll = () => { 319 scrollPositionsRef.current = { 320 ...scrollPositionsRef.current, 321 [selectedFeed]: window.scrollY, 322 }; 323 }; 324 325 window.addEventListener("scroll", handleScroll, { passive: true }); 326 return () => { 327 window.removeEventListener("scroll", handleScroll); 328 329 setScrollPositions(scrollPositionsRef.current); 330 }; 331 }, [isAuthRestoring, selectedFeed, setScrollPositions]); 332 333 const feedGengetrecordquery = useQueryArbitrary(!isAuthRestoring ? selectedFeed ?? undefined : undefined); 334 const feedServiceDid = !isAuthRestoring ? (feedGengetrecordquery?.data?.value as any)?.did as string | undefined : undefined; 335 336 // const { 337 // data: feedData, 338 // isLoading: isFeedLoading, 339 // error: feedError, 340 // } = useQueryFeedSkeleton({ 341 // feedUri: selectedFeed!, 342 // agent: agent ?? undefined, 343 // isAuthed: authed ?? false, 344 // pdsUrl: identity?.pds, 345 // feedServiceDid: feedServiceDid, 346 // }); 347 348 // const feed = feedData?.feed || []; 349 350 const isReadyForAuthedFeed = !isAuthRestoring && authed && agent && identity?.pds && feedServiceDid; 351 const isReadyForUnauthedFeed = !isAuthRestoring && !authed && selectedFeed; 352 353 354 const [isAtTop] = useAtom(isAtTopAtom); 355 356 return ( 357 <div 358 className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"}`} 359 > 360 {!isAuthRestoring && savedFeeds.length > 0 ? ( 361 <div className={`flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] ${!isAtTop && "shadow-sm"} sm:shadow-none sm:bg-white sm:dark:bg-gray-950 z-10 border-0 sm:border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin`}> 362 {savedFeeds.map((item: any, idx: number) => {return <FeedTabOnTop key={item} item={item} idx={idx} />})} 363 </div> 364 ) : ( 365 // <span className="text-xl font-bold ml-2">Home</span> 366 <Header title="Home" /> 367 )} 368 {/* {isFeedLoading && <div className="p-4 text-gray-500">Loading...</div>} 369 {feedError && <div className="p-4 text-red-500">{feedError.message}</div>} 370 {!isFeedLoading && !feedError && feed.length === 0 && ( 371 <div className="p-4 text-gray-500">No posts found.</div> 372 )} */} 373 {/* {feed.map((item, i) => ( 374 <UniversalPostRendererATURILoader 375 key={item.post || i} 376 atUri={item.post} 377 /> 378 ))} */} 379 380 {isAuthRestoring || authed && (!identity?.pds || !feedServiceDid) && ( 381 <div className="p-4 text-center text-gray-500"> 382 Preparing your feed... 383 </div> 384 )} 385 386 {!isAuthRestoring && (isReadyForAuthedFeed || isReadyForUnauthedFeed) ? ( 387 <InfiniteCustomFeed 388 key={selectedFeed!} 389 feedUri={selectedFeed!} 390 pdsUrl={identity?.pds} 391 feedServiceDid={feedServiceDid} 392 /> 393 ) : ( 394 <div className="p-4 text-center text-gray-500"> 395 Loading....... 396 </div> 397 )} 398 {/* {false && restoringScrollPosition && ( 399 <div className="fixed top-1/2 left-1/2 right-1/2"> 400 restoringScrollPosition 401 </div> 402 )} */} 403 </div> 404 ); 405} 406 407 408// todo please use types this is dangerous very dangerous. 409// todo fix this whenever proper preferences is handled 410function FeedTabOnTop({item, idx}:{item: any, idx: number}) { 411 const [persistentSelectedFeed, setPersistentSelectedFeed] = useAtom(selectedFeedUriAtom); 412 const selectedFeed = persistentSelectedFeed 413 const setSelectedFeed = setPersistentSelectedFeed 414 const rkey = item.value.split("/").pop() || item.value; 415 const isActive = selectedFeed === item.value; 416 const { data: feedrecord } = useQueryArbitrary(item.value) 417 const label = feedrecord?.value?.displayName || rkey 418 return ( 419 <button 420 key={item.value || idx} 421 className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${ 422 isActive 423 ? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600" 424 : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800" 425 // ? "bg-gray-500 text-white" 426 // : item.pinned 427 // ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200" 428 // : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200" 429 }`} 430 onClick={() => setSelectedFeed(item.value)} 431 title={item.value} 432 > 433 {label} 434 {item.pinned && ( 435 <span 436 className={`ml-1 text-xs ${ 437 isActive 438 ? "text-gray-900 dark:text-gray-100" 439 : "text-gray-600 dark:text-gray-400" 440 }`} 441 > 442 443 </span> 444 )} 445 </button> 446 ); 447} 448 449// not even used lmaooo 450 451// export async function cachedResolveDIDWEBDOC({ 452// didweb, 453// cacheTimeout = CACHE_TIMEOUT, 454// get, 455// set, 456// }: { 457// didweb: string; 458// cacheTimeout?: number; 459// get: (key: string) => any; 460// set: (key: string, value: string) => void; 461// }): Promise<any> { 462// const isDidInput = didweb.startsWith("did:web:"); 463// const cacheKey = `didwebdoc:${didweb}`; 464// const now = Date.now(); 465// const cached = get(cacheKey); 466// if ( 467// cached && 468// cached.value && 469// cached.time && 470// now - cached.time < cacheTimeout 471// ) { 472// try { 473// return JSON.parse(cached.value); 474// } catch (_e) {/* whatever*/ } 475// } 476// const url = `https://free-fly-24.deno.dev/resolve-did-web?did=${encodeURIComponent( 477// didweb 478// )}`; 479// const res = await fetch(url); 480// if (!res.ok) throw new Error("Failed to resolve didwebdoc"); 481// const data = await res.json(); 482// set(cacheKey, JSON.stringify(data)); 483// if (!isDidInput && data.did) { 484// set(`didwebdoc:${data.did}`, JSON.stringify(data)); 485// } 486// return data; 487// } 488 489// export async function cachedGetPrefs({ 490// did, 491// agent, 492// get, 493// set, 494// cacheTimeout = CACHE_TIMEOUT, 495// }: { 496// did: string; 497// agent: any; // or type properly if available 498// get: (key: string) => any; 499// set: (key: string, value: string) => void; 500// cacheTimeout?: number; 501// }): Promise<any> { 502// const cacheKey = `prefs:${did}`; 503// const cached = get(cacheKey); 504// const now = Date.now(); 505 506// if ( 507// cached && 508// cached.value && 509// cached.time && 510// now - cached.time < cacheTimeout 511// ) { 512// try { 513// return JSON.parse(cached.value); 514// } catch { 515// // fall through to fetch 516// } 517// } 518 519// const resolved = await cachedResolveIdentity({ 520// didOrHandle: did, 521// get, 522// set, 523// }); 524 525// if (!resolved?.pdsUrl) throw new Error("Missing resolved PDS info"); 526 527// const fetchUrl = `${resolved.pdsUrl}/xrpc/app.bsky.actor.getPreferences`; 528 529// const res = await agent.fetchHandler(fetchUrl, { 530// method: "GET", 531// headers: { 532// "Content-Type": "application/json", 533// }, 534// }); 535 536// if (!res.ok) throw new Error(`Failed to fetch preferences: ${res.status}`); 537 538// const text = await res.text(); 539 540// let data: any; 541// try { 542// data = JSON.parse(text); 543// } catch (err) { 544// console.error("Failed to parse preferences JSON:", err); 545// throw err; 546// } 547 548// set(cacheKey, JSON.stringify(data)); 549// return data; 550// }