an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm

Compare changes

Choose any two refs to compare.

Changed files
+704 -173
src
+37 -3
src/components/Import.tsx
··· 1 1 import { AtUri } from "@atproto/api"; 2 2 import { useNavigate, type UseNavigateResult } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 3 4 import { useState } from "react"; 5 + 6 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 7 + import { lycanURLAtom } from "~/utils/atoms"; 8 + import { useQueryLycanStatus } from "~/utils/useQuery"; 4 9 5 10 /** 6 11 * Basically the best equivalent to Search that i can do 7 12 */ 8 - export function Import() { 9 - const [textInput, setTextInput] = useState<string | undefined>(); 13 + export function Import({ 14 + optionaltextstring, 15 + }: { 16 + optionaltextstring?: string; 17 + }) { 18 + const [textInput, setTextInput] = useState<string | undefined>( 19 + optionaltextstring 20 + ); 10 21 const navigate = useNavigate(); 11 22 23 + const { status } = useAuth(); 24 + const [lycandomain] = useAtom(lycanURLAtom); 25 + const lycanExists = lycandomain !== ""; 26 + const { data: lycanstatusdata } = useQueryLycanStatus(); 27 + const lycanIndexed = lycanstatusdata?.status === "finished" || false; 28 + const lycanIndexing = lycanstatusdata?.status === "in_progress" || false; 29 + const lycanIndexingProgress = lycanIndexing 30 + ? lycanstatusdata?.progress 31 + : undefined; 32 + const authed = status === "signedIn"; 33 + 34 + const lycanReady = lycanExists && lycanIndexed && authed; 35 + 12 36 const handleEnter = () => { 13 37 if (!textInput) return; 14 38 handleImport({ 15 39 text: textInput, 16 40 navigate, 41 + lycanReady: 42 + lycanReady || (!!lycanIndexingProgress && lycanIndexingProgress > 0), 17 43 }); 18 44 }; 45 + 46 + const placeholder = lycanReady ? "Search..." : "Import..."; 19 47 20 48 return ( 21 49 <div className="w-full relative"> ··· 23 51 24 52 <input 25 53 type="text" 26 - placeholder="Import..." 54 + placeholder={placeholder} 27 55 value={textInput} 28 56 onChange={(e) => setTextInput(e.target.value)} 29 57 onKeyDown={(e) => { ··· 38 66 function handleImport({ 39 67 text, 40 68 navigate, 69 + lycanReady, 41 70 }: { 42 71 text: string; 43 72 navigate: UseNavigateResult<string>; 73 + lycanReady?: boolean; 44 74 }) { 45 75 const trimmed = text.trim(); 46 76 // parse text ··· 147 177 // } catch { 148 178 // // continue 149 179 // } 180 + 181 + if (lycanReady) { 182 + navigate({ to: "/search", search: { q: text } }); 183 + } 150 184 }
+19 -4
src/components/UniversalPostRenderer.tsx
··· 1252 1252 1253 1253 import defaultpfp from "~/../public/favicon.png"; 1254 1254 import { useAuth } from "~/providers/UnifiedAuthProvider"; 1255 + import { renderSnack } from "~/routes/__root"; 1255 1256 import { 1256 1257 FeedItemRenderAturiLoader, 1257 1258 FollowButton, ··· 1491 1492 ? tags 1492 1493 .map((tag) => { 1493 1494 const encoded = encodeURIComponent(tag); 1494 - return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(' ','-')}</a>`; 1495 + return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`; 1495 1496 }) 1496 1497 .join("<br>") 1497 1498 : ""; ··· 2012 2013 "/post/" + 2013 2014 post.uri.split("/").pop() 2014 2015 ); 2016 + renderSnack({ 2017 + title: "Copied to clipboard!", 2018 + }); 2015 2019 } catch (_e) { 2016 2020 // idk 2021 + renderSnack({ 2022 + title: "Failed to copy link", 2023 + }); 2017 2024 } 2018 2025 }} 2019 2026 style={{ ··· 2022 2029 > 2023 2030 <MdiShareVariant /> 2024 2031 </HitSlopButton> 2025 - <span style={btnstyle}> 2026 - <MdiMoreHoriz /> 2027 - </span> 2032 + <HitSlopButton 2033 + onClick={() => { 2034 + renderSnack({ 2035 + title: "Not implemented yet...", 2036 + }); 2037 + }} 2038 + > 2039 + <span style={btnstyle}> 2040 + <MdiMoreHoriz /> 2041 + </span> 2042 + </HitSlopButton> 2028 2043 </div> 2029 2044 </div> 2030 2045 )}
+217 -9
src/routes/search.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 1 + import type { Agent } from "@atproto/api"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { createFileRoute, useSearch } from "@tanstack/react-router"; 4 + import { useAtom } from "jotai"; 5 + import { useEffect,useMemo } from "react"; 2 6 3 7 import { Header } from "~/components/Header"; 4 8 import { Import } from "~/components/Import"; 9 + import { 10 + ReusableTabRoute, 11 + useReusableTabScrollRestore, 12 + } from "~/components/ReusableTabRoute"; 13 + import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 14 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 15 + import { lycanURLAtom } from "~/utils/atoms"; 16 + import { 17 + constructLycanRequestIndexQuery, 18 + useInfiniteQueryLycanSearch, 19 + useQueryIdentity, 20 + useQueryLycanStatus, 21 + } from "~/utils/useQuery"; 22 + 23 + import { renderSnack } from "./__root"; 24 + import { SliderPrimitive } from "./settings"; 5 25 6 26 export const Route = createFileRoute("/search")({ 7 27 component: Search, 8 28 }); 9 29 10 30 export function Search() { 31 + const queryClient = useQueryClient(); 32 + const { agent, status } = useAuth(); 33 + const { data: identity } = useQueryIdentity(agent?.did); 34 + const [lycandomain] = useAtom(lycanURLAtom); 35 + const lycanExists = lycandomain !== ""; 36 + const { data: lycanstatusdata, refetch } = useQueryLycanStatus(); 37 + const lycanIndexed = lycanstatusdata?.status === "finished" || false; 38 + const lycanIndexing = lycanstatusdata?.status === "in_progress" || false; 39 + const lycanIndexingProgress = lycanIndexing 40 + ? lycanstatusdata?.progress 41 + : undefined; 42 + 43 + const authed = status === "signedIn"; 44 + 45 + const lycanReady = lycanExists && lycanIndexed && authed; 46 + 47 + const { q }: { q: string } = useSearch({ from: "/search" }); 48 + 49 + // auto-refetch Lycan status until ready 50 + useEffect(() => { 51 + if (!lycanExists || !authed) return; 52 + if (lycanReady) return; 53 + 54 + const interval = setInterval(() => { 55 + refetch(); 56 + }, 3000); 57 + 58 + return () => clearInterval(interval); 59 + }, [lycanExists, authed, lycanReady, refetch]); 60 + 61 + const maintext = !lycanExists 62 + ? "Sorry we dont have search. But instead, you can load some of these types of content into Red Dwarf:" 63 + : authed 64 + ? lycanReady 65 + ? "Lycan Search is enabled and ready! Type to search posts you've interacted with in the past. You can also load some of these types of content into Red Dwarf:" 66 + : "Sorry, while Lycan Search is enabled, you are not indexed. Index below please. You can load some of these types of content into Red Dwarf:" 67 + : "Sorry, while Lycan Search is enabled, you are unauthed. Please log in to use Lycan. You can load some of these types of content into Red Dwarf:"; 68 + 69 + async function index(opts: { 70 + agent?: Agent; 71 + isAuthed: boolean; 72 + pdsUrl?: string; 73 + feedServiceDid?: string; 74 + }) { 75 + renderSnack({ 76 + title: "Registering account...", 77 + }); 78 + try { 79 + const response = await queryClient.fetchQuery( 80 + constructLycanRequestIndexQuery(opts) 81 + ); 82 + if ( 83 + response?.message !== "Import has already started" && 84 + response?.message !== "Import has been scheduled" 85 + ) { 86 + renderSnack({ 87 + title: "Registration failed!", 88 + description: "Unknown server error (2)", 89 + }); 90 + } else { 91 + renderSnack({ 92 + title: "Succesfully sent registration request!", 93 + description: "Please wait for the server to index your account", 94 + }); 95 + refetch(); 96 + } 97 + } catch { 98 + renderSnack({ 99 + title: "Registration failed!", 100 + description: "Unknown server error (1)", 101 + }); 102 + } 103 + } 104 + 11 105 return ( 12 106 <> 13 107 <Header ··· 21 115 }} 22 116 /> 23 117 <div className=" flex flex-col items-center mt-4 mx-4 gap-4"> 24 - <Import /> 118 + <Import optionaltextstring={q} /> 25 119 <div className="flex flex-col"> 26 - <p className="text-gray-600 dark:text-gray-400"> 27 - Sorry we dont have search. But instead, you can load some of these 28 - types of content into Red Dwarf: 29 - </p> 120 + <p className="text-gray-600 dark:text-gray-400">{maintext}</p> 30 121 <ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400"> 31 122 <li> 32 - Bluesky URLs from supported clients (like{" "} 123 + Bluesky URLs (from supported clients) (like{" "} 33 124 <code className="text-sm">bsky.app</code> or{" "} 34 125 <code className="text-sm">deer.social</code>). 35 126 </li> ··· 39 130 ). 40 131 </li> 41 132 <li> 42 - Plain handles (like{" "} 133 + User Handles (like{" "} 43 134 <code className="text-sm">@username.bsky.social</code>). 44 135 </li> 45 136 <li> 46 - Direct DIDs (Decentralized Identifiers, starting with{" "} 137 + DIDs (Decentralized Identifiers, starting with{" "} 47 138 <code className="text-sm">did:</code>). 48 139 </li> 49 140 </ul> ··· 51 142 Simply paste one of these into the import field above and press 52 143 Enter to load the content. 53 144 </p> 145 + 146 + {lycanExists && authed && !lycanReady ? ( 147 + !lycanIndexing ? ( 148 + <div className="mt-4 mx-auto"> 149 + <button 150 + onClick={() => 151 + index({ 152 + agent: agent || undefined, 153 + isAuthed: status === "signedIn", 154 + pdsUrl: identity?.pds, 155 + feedServiceDid: "did:web:" + lycandomain, 156 + }) 157 + } 158 + className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 159 + text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition" 160 + > 161 + Index my Account 162 + </button> 163 + </div> 164 + ) : ( 165 + <div className="mt-4 gap-2 flex flex-col"> 166 + <span>indexing...</span> 167 + <SliderPrimitive 168 + value={lycanIndexingProgress || 0} 169 + min={0} 170 + max={1} 171 + /> 172 + </div> 173 + ) 174 + ) : ( 175 + <></> 176 + )} 54 177 </div> 55 178 </div> 179 + {q ? <SearchTabs query={q} /> : <></>} 56 180 </> 57 181 ); 58 182 } 183 + 184 + function SearchTabs({ query }: { query: string }) { 185 + return ( 186 + <div> 187 + <ReusableTabRoute 188 + route={`search` + query} 189 + tabs={{ 190 + Likes: <LycanTab query={query} type={"likes"} key={"likes"} />, 191 + Reposts: <LycanTab query={query} type={"reposts"} key={"reposts"} />, 192 + Quotes: <LycanTab query={query} type={"quotes"} key={"quotes"} />, 193 + Pins: <LycanTab query={query} type={"pins"} key={"pins"} />, 194 + }} 195 + /> 196 + </div> 197 + ); 198 + } 199 + 200 + function LycanTab({ 201 + query, 202 + type, 203 + }: { 204 + query: string; 205 + type: "likes" | "pins" | "reposts" | "quotes"; 206 + }) { 207 + useReusableTabScrollRestore("search" + query); 208 + 209 + const { 210 + data: postsData, 211 + fetchNextPage, 212 + hasNextPage, 213 + isFetchingNextPage, 214 + isLoading: arePostsLoading, 215 + } = useInfiniteQueryLycanSearch({ query: query, type: type }); 216 + 217 + const posts = useMemo( 218 + () => 219 + postsData?.pages.flatMap((page) => { 220 + if (page) { 221 + return page.posts; 222 + } else { 223 + return []; 224 + } 225 + }) ?? [], 226 + [postsData] 227 + ); 228 + 229 + return ( 230 + <> 231 + {/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 232 + Posts 233 + </div> */} 234 + <div> 235 + {posts.map((post) => ( 236 + <UniversalPostRendererATURILoader 237 + key={post} 238 + atUri={post} 239 + feedviewpost={true} 240 + /> 241 + ))} 242 + </div> 243 + 244 + {/* Loading and "Load More" states */} 245 + {arePostsLoading && posts.length === 0 && ( 246 + <div className="p-4 text-center text-gray-500">Loading posts...</div> 247 + )} 248 + {isFetchingNextPage && ( 249 + <div className="p-4 text-center text-gray-500">Loading more...</div> 250 + )} 251 + {hasNextPage && !isFetchingNextPage && ( 252 + <button 253 + onClick={() => fetchNextPage()} 254 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 255 + > 256 + Load More Posts 257 + </button> 258 + )} 259 + {posts.length === 0 && !arePostsLoading && ( 260 + <div className="p-4 text-center text-gray-500">No posts found.</div> 261 + )} 262 + </> 263 + ); 264 + 265 + return <></>; 266 + }
+41
src/routes/settings.tsx
··· 10 10 defaultconstellationURL, 11 11 defaulthue, 12 12 defaultImgCDN, 13 + defaultLycanURL, 13 14 defaultslingshotURL, 14 15 defaultVideoCDN, 15 16 enableBitesAtom, ··· 17 18 enableWafrnTextAtom, 18 19 hueAtom, 19 20 imgCDNAtom, 21 + lycanURLAtom, 20 22 slingshotURLAtom, 21 23 videoCDNAtom, 22 24 } from "~/utils/atoms"; ··· 110 112 title={"Video CDN"} 111 113 description={"Customize the Slingshot instance to be used by Red Dwarf"} 112 114 init={defaultVideoCDN} 115 + /> 116 + <TextInputSetting 117 + atom={lycanURLAtom} 118 + title={"Lycan Search"} 119 + description={"Enable text search across posts you've interacted with"} 120 + init={defaultLycanURL} 113 121 /> 114 122 115 123 <SettingHeading title="Experimental" /> ··· 317 325 </Slider.Root> 318 326 ); 319 327 }; 328 + 329 + 330 + interface SliderPProps { 331 + value: number; 332 + min?: number; 333 + max?: number; 334 + step?: number; 335 + } 336 + 337 + 338 + export const SliderPrimitive: React.FC<SliderPProps> = ({ 339 + value, 340 + min = 0, 341 + max = 100, 342 + step = 1, 343 + }) => { 344 + 345 + return ( 346 + <Slider.Root 347 + className="relative flex items-center w-full h-4" 348 + value={[value]} 349 + min={min} 350 + max={max} 351 + step={step} 352 + onValueChange={(v: number[]) => {}} 353 + > 354 + <Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full"> 355 + <Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" /> 356 + </Slider.Track> 357 + <Slider.Thumb className=" hidden shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" /> 358 + </Slider.Root> 359 + ); 360 + };
+6
src/utils/atoms.ts
··· 92 92 defaultVideoCDN 93 93 ); 94 94 95 + export const defaultLycanURL = ""; 96 + export const lycanURLAtom = atomWithStorage<string>( 97 + "lycanURL", 98 + defaultLycanURL 99 + ); 100 + 95 101 export const defaulthue = 28; 96 102 export const hueAtom = atomWithStorage<number>("hue", defaulthue); 97 103
+384 -157
src/utils/useQuery.ts
··· 5 5 queryOptions, 6 6 useInfiniteQuery, 7 7 useQuery, 8 - type UseQueryResult} from "@tanstack/react-query"; 8 + type UseQueryResult, 9 + } from "@tanstack/react-query"; 9 10 import { useAtom } from "jotai"; 10 11 11 - import { constellationURLAtom, slingshotURLAtom } from "./atoms"; 12 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 13 + 14 + import { constellationURLAtom, lycanURLAtom, slingshotURLAtom } from "./atoms"; 12 15 13 - export function constructIdentityQuery(didorhandle?: string, slingshoturl?: string) { 16 + export function constructIdentityQuery( 17 + didorhandle?: string, 18 + slingshoturl?: string 19 + ) { 14 20 return queryOptions({ 15 21 queryKey: ["identity", didorhandle], 16 22 queryFn: async () => { 17 - if (!didorhandle) return undefined as undefined 23 + if (!didorhandle) return undefined as undefined; 18 24 const res = await fetch( 19 25 `https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}` 20 26 ); ··· 31 37 } 32 38 }, 33 39 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 34 - gcTime: /*0//*/5 * 60 * 1000, 40 + gcTime: /*0//*/ 5 * 60 * 1000, 35 41 }); 36 42 } 37 43 export function useQueryIdentity(didorhandle: string): UseQueryResult< ··· 43 49 }, 44 50 Error 45 51 >; 46 - export function useQueryIdentity(): UseQueryResult< 47 - undefined, 48 - Error 49 - > 50 - export function useQueryIdentity(didorhandle?: string): 51 - UseQueryResult< 52 - { 53 - did: string; 54 - handle: string; 55 - pds: string; 56 - signing_key: string; 57 - } | undefined, 58 - Error 59 - > 52 + export function useQueryIdentity(): UseQueryResult<undefined, Error>; 53 + export function useQueryIdentity(didorhandle?: string): UseQueryResult< 54 + | { 55 + did: string; 56 + handle: string; 57 + pds: string; 58 + signing_key: string; 59 + } 60 + | undefined, 61 + Error 62 + >; 60 63 export function useQueryIdentity(didorhandle?: string) { 61 - const [slingshoturl] = useAtom(slingshotURLAtom) 64 + const [slingshoturl] = useAtom(slingshotURLAtom); 62 65 return useQuery(constructIdentityQuery(didorhandle, slingshoturl)); 63 66 } 64 67 ··· 66 69 return queryOptions({ 67 70 queryKey: ["post", uri], 68 71 queryFn: async () => { 69 - if (!uri) return undefined as undefined 72 + if (!uri) return undefined as undefined; 70 73 const res = await fetch( 71 74 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 72 75 ); ··· 77 80 return undefined; 78 81 } 79 82 if (res.status === 400) return undefined; 80 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 83 + if ( 84 + data?.error === "InvalidRequest" && 85 + data.message?.includes("Could not find repo") 86 + ) { 81 87 return undefined; // cache โ€œnot foundโ€ 82 88 } 83 89 try { 84 90 if (!res.ok) throw new Error("Failed to fetch post"); 85 - return (data) as { 91 + return data as { 86 92 uri: string; 87 93 cid: string; 88 94 value: any; ··· 97 103 return failureCount < 2; 98 104 }, 99 105 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 100 - gcTime: /*0//*/5 * 60 * 1000, 106 + gcTime: /*0//*/ 5 * 60 * 1000, 101 107 }); 102 108 } 103 109 export function useQueryPost(uri: string): UseQueryResult< ··· 108 114 }, 109 115 Error 110 116 >; 111 - export function useQueryPost(): UseQueryResult< 112 - undefined, 113 - Error 114 - > 115 - export function useQueryPost(uri?: string): 116 - UseQueryResult< 117 - { 118 - uri: string; 119 - cid: string; 120 - value: ATPAPI.AppBskyFeedPost.Record; 121 - } | undefined, 122 - Error 123 - > 117 + export function useQueryPost(): UseQueryResult<undefined, Error>; 118 + export function useQueryPost(uri?: string): UseQueryResult< 119 + | { 120 + uri: string; 121 + cid: string; 122 + value: ATPAPI.AppBskyFeedPost.Record; 123 + } 124 + | undefined, 125 + Error 126 + >; 124 127 export function useQueryPost(uri?: string) { 125 - const [slingshoturl] = useAtom(slingshotURLAtom) 128 + const [slingshoturl] = useAtom(slingshotURLAtom); 126 129 return useQuery(constructPostQuery(uri, slingshoturl)); 127 130 } 128 131 ··· 130 133 return queryOptions({ 131 134 queryKey: ["profile", uri], 132 135 queryFn: async () => { 133 - if (!uri) return undefined as undefined 136 + if (!uri) return undefined as undefined; 134 137 const res = await fetch( 135 138 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 136 139 ); ··· 141 144 return undefined; 142 145 } 143 146 if (res.status === 400) return undefined; 144 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 147 + if ( 148 + data?.error === "InvalidRequest" && 149 + data.message?.includes("Could not find repo") 150 + ) { 145 151 return undefined; // cache โ€œnot foundโ€ 146 152 } 147 153 try { 148 154 if (!res.ok) throw new Error("Failed to fetch post"); 149 - return (data) as { 155 + return data as { 150 156 uri: string; 151 157 cid: string; 152 158 value: any; ··· 161 167 return failureCount < 2; 162 168 }, 163 169 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 164 - gcTime: /*0//*/5 * 60 * 1000, 170 + gcTime: /*0//*/ 5 * 60 * 1000, 165 171 }); 166 172 } 167 173 export function useQueryProfile(uri: string): UseQueryResult< ··· 172 178 }, 173 179 Error 174 180 >; 175 - export function useQueryProfile(): UseQueryResult< 176 - undefined, 177 - Error 178 - >; 179 - export function useQueryProfile(uri?: string): 180 - UseQueryResult< 181 - { 181 + export function useQueryProfile(): UseQueryResult<undefined, Error>; 182 + export function useQueryProfile(uri?: string): UseQueryResult< 183 + | { 182 184 uri: string; 183 185 cid: string; 184 186 value: ATPAPI.AppBskyActorProfile.Record; 185 - } | undefined, 186 - Error 187 - > 187 + } 188 + | undefined, 189 + Error 190 + >; 188 191 export function useQueryProfile(uri?: string) { 189 - const [slingshoturl] = useAtom(slingshotURLAtom) 192 + const [slingshoturl] = useAtom(slingshotURLAtom); 190 193 return useQuery(constructProfileQuery(uri, slingshoturl)); 191 194 } 192 195 ··· 222 225 // method: "/links/all", 223 226 // target: string 224 227 // ): QueryOptions<linksAllResponse, Error>; 225 - export function constructConstellationQuery(query?:{ 226 - constellation: string, 228 + export function constructConstellationQuery(query?: { 229 + constellation: string; 227 230 method: 228 231 | "/links" 229 232 | "/links/distinct-dids" 230 233 | "/links/count" 231 234 | "/links/count/distinct-dids" 232 235 | "/links/all" 233 - | "undefined", 234 - target: string, 235 - collection?: string, 236 - path?: string, 237 - cursor?: string, 238 - dids?: string[] 239 - } 240 - ) { 236 + | "undefined"; 237 + target: string; 238 + collection?: string; 239 + path?: string; 240 + cursor?: string; 241 + dids?: string[]; 242 + }) { 241 243 // : QueryOptions< 242 244 // | linksRecordsResponse 243 245 // | linksDidsResponse ··· 247 249 // Error 248 250 // > 249 251 return queryOptions({ 250 - queryKey: ["constellation", query?.method, query?.target, query?.collection, query?.path, query?.cursor, query?.dids] as const, 252 + queryKey: [ 253 + "constellation", 254 + query?.method, 255 + query?.target, 256 + query?.collection, 257 + query?.path, 258 + query?.cursor, 259 + query?.dids, 260 + ] as const, 251 261 queryFn: async () => { 252 - if (!query || query.method === "undefined") return undefined as undefined 253 - const method = query.method 254 - const target = query.target 255 - const collection = query?.collection 256 - const path = query?.path 257 - const cursor = query.cursor 258 - const dids = query?.dids 262 + if (!query || query.method === "undefined") return undefined as undefined; 263 + const method = query.method; 264 + const target = query.target; 265 + const collection = query?.collection; 266 + const path = query?.path; 267 + const cursor = query.cursor; 268 + const dids = query?.dids; 259 269 const res = await fetch( 260 270 `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}` 261 271 ); ··· 281 291 }, 282 292 // enforce short lifespan 283 293 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 284 - gcTime: /*0//*/5 * 60 * 1000, 294 + gcTime: /*0//*/ 5 * 60 * 1000, 285 295 }); 286 296 } 287 297 // todo do more of these instead of overloads since overloads sucks so much apparently ··· 293 303 cursor?: string; 294 304 }): UseQueryResult<linksCountResponse, Error> | undefined { 295 305 //if (!query) return; 296 - const [constellationurl] = useAtom(constellationURLAtom) 306 + const [constellationurl] = useAtom(constellationURLAtom); 297 307 const queryres = useQuery( 298 - constructConstellationQuery(query && {constellation: constellationurl, ...query}) 308 + constructConstellationQuery( 309 + query && { constellation: constellationurl, ...query } 310 + ) 299 311 ) as unknown as UseQueryResult<linksCountResponse, Error>; 300 312 if (!query) { 301 - return undefined as undefined; 313 + return undefined as undefined; 302 314 } 303 315 return queryres as UseQueryResult<linksCountResponse, Error>; 304 316 } ··· 365 377 > 366 378 | undefined { 367 379 //if (!query) return; 368 - const [constellationurl] = useAtom(constellationURLAtom) 380 + const [constellationurl] = useAtom(constellationURLAtom); 369 381 return useQuery( 370 - constructConstellationQuery(query && {constellation: constellationurl, ...query}) 382 + constructConstellationQuery( 383 + query && { constellation: constellationurl, ...query } 384 + ) 371 385 ); 372 386 } 373 387 ··· 411 425 }) { 412 426 return queryOptions({ 413 427 // The query key includes all dependencies to ensure it refetches when they change 414 - queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }], 428 + queryKey: [ 429 + "feedSkeleton", 430 + options?.feedUri, 431 + { isAuthed: options?.isAuthed, did: options?.agent?.did }, 432 + ], 415 433 queryFn: async () => { 416 - if (!options) return undefined as undefined 434 + if (!options) return undefined as undefined; 417 435 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 418 436 if (isAuthed) { 419 437 // Authenticated flow 420 438 if (!agent || !pdsUrl || !feedServiceDid) { 421 - throw new Error("Missing required info for authenticated feed fetch."); 439 + throw new Error( 440 + "Missing required info for authenticated feed fetch." 441 + ); 422 442 } 423 443 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 424 444 const res = await agent.fetchHandler(url, { ··· 428 448 "Content-Type": "application/json", 429 449 }, 430 450 }); 431 - if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 451 + if (!res.ok) 452 + throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 432 453 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 433 454 } else { 434 455 // Unauthenticated flow (using a public PDS/AppView) 435 456 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 436 457 const res = await fetch(url); 437 - if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 458 + if (!res.ok) 459 + throw new Error(`Public feed fetch failed: ${res.statusText}`); 438 460 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 439 461 } 440 462 }, ··· 452 474 return useQuery(constructFeedSkeletonQuery(options)); 453 475 } 454 476 455 - export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) { 477 + export function constructPreferencesQuery( 478 + agent?: ATPAPI.Agent | undefined, 479 + pdsUrl?: string | undefined 480 + ) { 456 481 return queryOptions({ 457 - queryKey: ['preferences', agent?.did], 482 + queryKey: ["preferences", agent?.did], 458 483 queryFn: async () => { 459 484 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available"); 460 485 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`; ··· 465 490 }); 466 491 } 467 492 export function useQueryPreferences(options: { 468 - agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined 493 + agent?: ATPAPI.Agent | undefined; 494 + pdsUrl?: string | undefined; 469 495 }) { 470 496 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl)); 471 497 } 472 498 473 - 474 - 475 499 export function constructArbitraryQuery(uri?: string, slingshoturl?: string) { 476 500 return queryOptions({ 477 501 queryKey: ["arbitrary", uri], 478 502 queryFn: async () => { 479 - if (!uri) return undefined as undefined 503 + if (!uri) return undefined as undefined; 480 504 const res = await fetch( 481 505 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 482 506 ); ··· 487 511 return undefined; 488 512 } 489 513 if (res.status === 400) return undefined; 490 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 514 + if ( 515 + data?.error === "InvalidRequest" && 516 + data.message?.includes("Could not find repo") 517 + ) { 491 518 return undefined; // cache โ€œnot foundโ€ 492 519 } 493 520 try { 494 521 if (!res.ok) throw new Error("Failed to fetch post"); 495 - return (data) as { 522 + return data as { 496 523 uri: string; 497 524 cid: string; 498 525 value: any; ··· 507 534 return failureCount < 2; 508 535 }, 509 536 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 510 - gcTime: /*0//*/5 * 60 * 1000, 537 + gcTime: /*0//*/ 5 * 60 * 1000, 511 538 }); 512 539 } 513 540 export function useQueryArbitrary(uri: string): UseQueryResult< ··· 518 545 }, 519 546 Error 520 547 >; 521 - export function useQueryArbitrary(): UseQueryResult< 522 - undefined, 523 - Error 524 - >; 548 + export function useQueryArbitrary(): UseQueryResult<undefined, Error>; 525 549 export function useQueryArbitrary(uri?: string): UseQueryResult< 526 - { 527 - uri: string; 528 - cid: string; 529 - value: any; 530 - } | undefined, 550 + | { 551 + uri: string; 552 + cid: string; 553 + value: any; 554 + } 555 + | undefined, 531 556 Error 532 557 >; 533 558 export function useQueryArbitrary(uri?: string) { 534 - const [slingshoturl] = useAtom(slingshotURLAtom) 559 + const [slingshoturl] = useAtom(slingshotURLAtom); 535 560 return useQuery(constructArbitraryQuery(uri, slingshoturl)); 536 561 } 537 562 538 - export function constructFallbackNothingQuery(){ 563 + export function constructFallbackNothingQuery() { 539 564 return queryOptions({ 540 565 queryKey: ["nothing"], 541 566 queryFn: async () => { 542 - return undefined 567 + return undefined; 543 568 }, 544 569 }); 545 570 } ··· 553 578 }[]; 554 579 }; 555 580 556 - export function constructAuthorFeedQuery(did: string, pdsUrl: string, collection: string = "app.bsky.feed.post") { 581 + export function constructAuthorFeedQuery( 582 + did: string, 583 + pdsUrl: string, 584 + collection: string = "app.bsky.feed.post" 585 + ) { 557 586 return queryOptions({ 558 - queryKey: ['authorFeed', did, collection], 587 + queryKey: ["authorFeed", did, collection], 559 588 queryFn: async ({ pageParam }: QueryFunctionContext) => { 560 589 const limit = 25; 561 - 590 + 562 591 const cursor = pageParam as string | undefined; 563 - const cursorParam = cursor ? `&cursor=${cursor}` : ''; 564 - 592 + const cursorParam = cursor ? `&cursor=${cursor}` : ""; 593 + 565 594 const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`; 566 - 595 + 567 596 const res = await fetch(url); 568 597 if (!res.ok) throw new Error("Failed to fetch author's posts"); 569 - 598 + 570 599 return res.json() as Promise<ListRecordsResponse>; 571 600 }, 572 601 }); 573 602 } 574 603 575 - export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined, collection?: string) { 576 - const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!, collection); 577 - 604 + export function useInfiniteQueryAuthorFeed( 605 + did: string | undefined, 606 + pdsUrl: string | undefined, 607 + collection?: string 608 + ) { 609 + const { queryKey, queryFn } = constructAuthorFeedQuery( 610 + did!, 611 + pdsUrl!, 612 + collection 613 + ); 614 + 578 615 return useInfiniteQuery({ 579 616 queryKey, 580 617 queryFn, ··· 595 632 // todo the hell is a unauthedfeedurl 596 633 unauthedfeedurl?: string; 597 634 }) { 598 - const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = options; 599 - 635 + const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = 636 + options; 637 + 600 638 return queryOptions({ 601 639 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }], 602 - 603 - queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 640 + 641 + queryFn: async ({ 642 + pageParam, 643 + }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 604 644 const cursorParam = pageParam ? `&cursor=${pageParam}` : ""; 605 - 645 + 606 646 if (isAuthed && !unauthedfeedurl) { 607 647 if (!agent || !pdsUrl || !feedServiceDid) { 608 - throw new Error("Missing required info for authenticated feed fetch."); 648 + throw new Error( 649 + "Missing required info for authenticated feed fetch." 650 + ); 609 651 } 610 652 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 611 653 const res = await agent.fetchHandler(url, { ··· 615 657 "Content-Type": "application/json", 616 658 }, 617 659 }); 618 - if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 660 + if (!res.ok) 661 + throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 619 662 return (await res.json()) as FeedSkeletonPage; 620 663 } else { 621 664 const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 622 665 const res = await fetch(url); 623 - if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 666 + if (!res.ok) 667 + throw new Error(`Public feed fetch failed: ${res.statusText}`); 624 668 return (await res.json()) as FeedSkeletonPage; 625 669 } 626 670 }, ··· 636 680 unauthedfeedurl?: string; 637 681 }) { 638 682 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 639 - 640 - return {...useInfiniteQuery({ 641 - queryKey, 642 - queryFn, 643 - initialPageParam: undefined as never, 644 - getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 645 - staleTime: Infinity, 646 - refetchOnWindowFocus: false, 647 - enabled: !!options.feedUri && (options.isAuthed ? (!!options.agent && !!options.pdsUrl || !!options.unauthedfeedurl) && !!options.feedServiceDid : true), 648 - }), queryKey: queryKey}; 649 - } 650 683 684 + return { 685 + ...useInfiniteQuery({ 686 + queryKey, 687 + queryFn, 688 + initialPageParam: undefined as never, 689 + getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 690 + staleTime: Infinity, 691 + refetchOnWindowFocus: false, 692 + enabled: 693 + !!options.feedUri && 694 + (options.isAuthed 695 + ? ((!!options.agent && !!options.pdsUrl) || 696 + !!options.unauthedfeedurl) && 697 + !!options.feedServiceDid 698 + : true), 699 + }), 700 + queryKey: queryKey, 701 + }; 702 + } 651 703 652 704 export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: { 653 - constellation: string, 654 - method: '/links' 655 - target?: string 656 - collection: string 657 - path: string, 658 - staleMult?: number 705 + constellation: string; 706 + method: "/links"; 707 + target?: string; 708 + collection: string; 709 + path: string; 710 + staleMult?: number; 659 711 }) { 660 712 const safemult = query?.staleMult ?? 1; 661 713 // console.log( ··· 666 718 return infiniteQueryOptions({ 667 719 enabled: !!query?.target, 668 720 queryKey: [ 669 - 'reddwarf_constellation', 721 + "reddwarf_constellation", 670 722 query?.method, 671 723 query?.target, 672 724 query?.collection, 673 725 query?.path, 674 726 ] as const, 675 727 676 - queryFn: async ({pageParam}: {pageParam?: string}) => { 677 - if (!query || !query?.target) return undefined 728 + queryFn: async ({ pageParam }: { pageParam?: string }) => { 729 + if (!query || !query?.target) return undefined; 678 730 679 - const method = query.method 680 - const target = query.target 681 - const collection = query.collection 682 - const path = query.path 683 - const cursor = pageParam 731 + const method = query.method; 732 + const target = query.target; 733 + const collection = query.collection; 734 + const path = query.path; 735 + const cursor = pageParam; 684 736 685 737 const res = await fetch( 686 738 `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${ 687 - collection ? `&collection=${encodeURIComponent(collection)}` : '' 688 - }${path ? `&path=${encodeURIComponent(path)}` : ''}${ 689 - cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' 690 - }`, 691 - ) 739 + collection ? `&collection=${encodeURIComponent(collection)}` : "" 740 + }${path ? `&path=${encodeURIComponent(path)}` : ""}${ 741 + cursor ? `&cursor=${encodeURIComponent(cursor)}` : "" 742 + }` 743 + ); 692 744 693 - if (!res.ok) throw new Error('Failed to fetch') 745 + if (!res.ok) throw new Error("Failed to fetch"); 694 746 695 - return (await res.json()) as linksRecordsResponse 747 + return (await res.json()) as linksRecordsResponse; 696 748 }, 697 749 698 - getNextPageParam: lastPage => { 699 - return (lastPage as any)?.cursor ?? undefined 750 + getNextPageParam: (lastPage) => { 751 + return (lastPage as any)?.cursor ?? undefined; 700 752 }, 701 753 initialPageParam: undefined, 702 754 staleTime: 5 * 60 * 1000 * safemult, 703 755 gcTime: 5 * 60 * 1000 * safemult, 704 - }) 705 - } 756 + }); 757 + } 758 + 759 + export function useQueryLycanStatus() { 760 + const [lycanurl] = useAtom(lycanURLAtom); 761 + const { agent, status } = useAuth(); 762 + const { data: identity } = useQueryIdentity(agent?.did); 763 + return useQuery( 764 + constructLycanStatusCheckQuery({ 765 + agent: agent || undefined, 766 + isAuthed: status === "signedIn", 767 + pdsUrl: identity?.pds, 768 + feedServiceDid: "did:web:"+lycanurl, 769 + }) 770 + ); 771 + } 772 + 773 + export function constructLycanStatusCheckQuery(options: { 774 + agent?: ATPAPI.Agent; 775 + isAuthed: boolean; 776 + pdsUrl?: string; 777 + feedServiceDid?: string; 778 + }) { 779 + const { agent, isAuthed, pdsUrl, feedServiceDid } = options; 780 + 781 + return queryOptions({ 782 + queryKey: ["lycanStatus", { isAuthed, did: agent?.did }], 783 + 784 + queryFn: async () => { 785 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 786 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.getImportStatus`; 787 + const res = await agent.fetchHandler(url, { 788 + method: "GET", 789 + headers: { 790 + "atproto-proxy": `${feedServiceDid}#lycan`, 791 + "Content-Type": "application/json", 792 + }, 793 + }); 794 + if (!res.ok) 795 + throw new Error( 796 + `Authenticated lycan status fetch failed: ${res.statusText}` 797 + ); 798 + return (await res.json()) as statuschek; 799 + } 800 + return undefined; 801 + }, 802 + }); 803 + } 804 + 805 + type statuschek = { 806 + [key: string]: unknown; 807 + error?: "MethodNotImplemented"; 808 + message?: "Method Not Implemented"; 809 + status?: "finished" | "in_progress"; 810 + position?: string, 811 + progress?: number, 812 + 813 + }; 814 + 815 + //{"status":"in_progress","position":"2025-08-30T06:53:18Z","progress":0.0878319661441268} 816 + type importtype = { 817 + message?: "Import has already started" | "Import has been scheduled" 818 + } 819 + 820 + export function constructLycanRequestIndexQuery(options: { 821 + agent?: ATPAPI.Agent; 822 + isAuthed: boolean; 823 + pdsUrl?: string; 824 + feedServiceDid?: string; 825 + }) { 826 + const { agent, isAuthed, pdsUrl, feedServiceDid } = options; 827 + 828 + return queryOptions({ 829 + queryKey: ["lycanIndex", { isAuthed, did: agent?.did }], 830 + 831 + queryFn: async () => { 832 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 833 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.startImport`; 834 + const res = await agent.fetchHandler(url, { 835 + method: "POST", 836 + headers: { 837 + "atproto-proxy": `${feedServiceDid}#lycan`, 838 + "Content-Type": "application/json", 839 + }, 840 + }); 841 + if (!res.ok) 842 + throw new Error( 843 + `Authenticated lycan status fetch failed: ${res.statusText}` 844 + ); 845 + return await res.json() as importtype; 846 + } 847 + return undefined; 848 + }, 849 + }); 850 + } 851 + 852 + type LycanSearchPage = { 853 + terms: string[]; 854 + posts: string[]; 855 + cursor?: string; 856 + }; 857 + 858 + 859 + export function useInfiniteQueryLycanSearch(options: { query: string, type: "likes" | "pins" | "reposts" | "quotes"}) { 860 + 861 + 862 + const [lycanurl] = useAtom(lycanURLAtom); 863 + const { agent, status } = useAuth(); 864 + const { data: identity } = useQueryIdentity(agent?.did); 865 + 866 + const { queryKey, queryFn } = constructLycanSearchQuery({ 867 + agent: agent || undefined, 868 + isAuthed: status === "signedIn", 869 + pdsUrl: identity?.pds, 870 + feedServiceDid: "did:web:"+lycanurl, 871 + query: options.query, 872 + type: options.type, 873 + }) 874 + 875 + return { 876 + ...useInfiniteQuery({ 877 + queryKey, 878 + queryFn, 879 + initialPageParam: undefined as never, 880 + getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined, 881 + //staleTime: Infinity, 882 + refetchOnWindowFocus: false, 883 + // enabled: 884 + // !!options.feedUri && 885 + // (options.isAuthed 886 + // ? ((!!options.agent && !!options.pdsUrl) || 887 + // !!options.unauthedfeedurl) && 888 + // !!options.feedServiceDid 889 + // : true), 890 + }), 891 + queryKey: queryKey, 892 + }; 893 + } 894 + 895 + 896 + export function constructLycanSearchQuery(options: { 897 + agent?: ATPAPI.Agent; 898 + isAuthed: boolean; 899 + pdsUrl?: string; 900 + feedServiceDid?: string; 901 + type: "likes" | "pins" | "reposts" | "quotes"; 902 + query: string; 903 + }) { 904 + const { agent, isAuthed, pdsUrl, feedServiceDid, type, query } = options; 905 + 906 + return infiniteQueryOptions({ 907 + queryKey: ["lycanSearch", query, type, { isAuthed, did: agent?.did }], 908 + 909 + queryFn: async ({ 910 + pageParam, 911 + }: QueryFunctionContext): Promise<LycanSearchPage | undefined> => { 912 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 913 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.searchPosts?query=${query}&collection=${type}${pageParam ? `&cursor=${pageParam}` : ""}`; 914 + const res = await agent.fetchHandler(url, { 915 + method: "GET", 916 + headers: { 917 + "atproto-proxy": `${feedServiceDid}#lycan`, 918 + "Content-Type": "application/json", 919 + }, 920 + }); 921 + if (!res.ok) 922 + throw new Error( 923 + `Authenticated lycan status fetch failed: ${res.statusText}` 924 + ); 925 + return (await res.json()) as LycanSearchPage; 926 + } 927 + return undefined; 928 + }, 929 + initialPageParam: undefined as never, 930 + getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined, 931 + }); 932 + }