an appview-less Bluesky client using Constellation and PDS Queries
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
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// }