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

lycansubscribe

rimar1337 1414d177 665413c9

Changed files
+629 -174
src
+27 -4
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"; 4 5 6 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 7 + import { lycanURLAtom } from "~/utils/atoms"; 8 + import { useQueryLycanStatus } from "~/utils/useQuery"; 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({optionaltextstring}: {optionaltextstring?: string}) { 14 + const [textInput, setTextInput] = useState<string | undefined>(optionaltextstring); 10 15 const navigate = useNavigate(); 11 16 17 + const { status } = useAuth(); 18 + const [lycandomain] = useAtom(lycanURLAtom); 19 + const lycanExists = lycandomain !== ""; 20 + const { data: lycanstatusdata } = useQueryLycanStatus(); 21 + const lycanIndexed = lycanstatusdata?.status === "finished" || false; 22 + const authed = status === "signedIn"; 23 + 24 + const lycanReady = lycanExists && lycanIndexed && authed; 25 + 12 26 const handleEnter = () => { 13 27 if (!textInput) return; 14 28 handleImport({ 15 29 text: textInput, 16 30 navigate, 31 + lycanReady: lycanReady, 17 32 }); 18 33 }; 19 34 35 + const placeholder = lycanReady ? "Search..." : "Import..."; 36 + 20 37 return ( 21 38 <div className="w-full relative"> 22 39 <IconMaterialSymbolsSearch className="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" /> 23 40 24 41 <input 25 42 type="text" 26 - placeholder="Import..." 43 + placeholder={placeholder} 27 44 value={textInput} 28 45 onChange={(e) => setTextInput(e.target.value)} 29 46 onKeyDown={(e) => { ··· 38 55 function handleImport({ 39 56 text, 40 57 navigate, 58 + lycanReady, 41 59 }: { 42 60 text: string; 43 61 navigate: UseNavigateResult<string>; 62 + lycanReady?: boolean; 44 63 }) { 45 64 const trimmed = text.trim(); 46 65 // parse text ··· 147 166 // } catch { 148 167 // // continue 149 168 // } 150 - } 169 + 170 + if (lycanReady) { 171 + navigate({ to: "/search", search: { q: text} }) 172 + } 173 + }
+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 )}
+189 -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 { 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"; 5 24 6 25 export const Route = createFileRoute("/search")({ 7 26 component: Search, 8 27 }); 9 28 10 29 export function Search() { 30 + const queryClient = useQueryClient(); 31 + const { agent, status } = useAuth(); 32 + const { data: identity } = useQueryIdentity(agent?.did); 33 + const [lycandomain] = useAtom(lycanURLAtom); 34 + const lycanExists = lycandomain !== ""; 35 + const { data: lycanstatusdata } = useQueryLycanStatus(); 36 + const lycanIndexed = lycanstatusdata?.status === "finished" || false; 37 + const authed = status === "signedIn"; 38 + 39 + const lycanReady = lycanExists && lycanIndexed && authed; 40 + 41 + const { q }: { q: string } = useSearch({ from: "/search" }); 42 + 43 + //const lycanIndexed = useQuery(); 44 + 45 + const maintext = !lycanExists 46 + ? "Sorry we dont have search. But instead, you can load some of these types of content into Red Dwarf:" 47 + : authed 48 + ? lycanReady 49 + ? "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:" 50 + : "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:" 51 + : "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:"; 52 + 53 + async function index(opts: { 54 + agent?: Agent; 55 + isAuthed: boolean; 56 + pdsUrl?: string; 57 + feedServiceDid?: string; 58 + }) { 59 + renderSnack({ 60 + title: "Registering account...", 61 + }); 62 + try { 63 + const response = await queryClient.fetchQuery( 64 + constructLycanRequestIndexQuery(opts) 65 + ); 66 + if ( 67 + response?.message !== "Import has already started" || 68 + response?.message !== "Import has already started" 69 + ) { 70 + renderSnack({ 71 + title: "Registration failed!", 72 + description: "Unknown server error (2)", 73 + }); 74 + } else { 75 + renderSnack({ 76 + title: "Succesfully sent registration request!", 77 + description: "Please wait for the server to index your account", 78 + }); 79 + } 80 + } catch { 81 + renderSnack({ 82 + title: "Registration failed!", 83 + description: "Unknown server error (1)", 84 + }); 85 + } 86 + } 87 + 11 88 return ( 12 89 <> 13 90 <Header ··· 21 98 }} 22 99 /> 23 100 <div className=" flex flex-col items-center mt-4 mx-4 gap-4"> 24 - <Import /> 101 + <Import optionaltextstring={q} /> 25 102 <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> 103 + <p className="text-gray-600 dark:text-gray-400">{maintext}</p> 30 104 <ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400"> 31 105 <li> 32 - Bluesky URLs from supported clients (like{" "} 106 + Bluesky URLs (from supported clients) (like{" "} 33 107 <code className="text-sm">bsky.app</code> or{" "} 34 108 <code className="text-sm">deer.social</code>). 35 109 </li> ··· 39 113 ). 40 114 </li> 41 115 <li> 42 - Plain handles (like{" "} 116 + User Handles (like{" "} 43 117 <code className="text-sm">@username.bsky.social</code>). 44 118 </li> 45 119 <li> 46 - Direct DIDs (Decentralized Identifiers, starting with{" "} 120 + DIDs (Decentralized Identifiers, starting with{" "} 47 121 <code className="text-sm">did:</code>). 48 122 </li> 49 123 </ul> ··· 51 125 Simply paste one of these into the import field above and press 52 126 Enter to load the content. 53 127 </p> 128 + 129 + {lycanExists && authed && !lycanReady ? ( 130 + <div className="mt-4 mx-auto"> 131 + <button 132 + onClick={() => 133 + index({ 134 + agent: agent || undefined, 135 + isAuthed: status === "signedIn", 136 + pdsUrl: identity?.pds, 137 + feedServiceDid: "did:web:" + lycandomain, 138 + }) 139 + } 140 + className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 141 + text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition" 142 + > 143 + Index my Account 144 + </button> 145 + </div> 146 + ) : ( 147 + <></> 148 + )} 54 149 </div> 55 150 </div> 151 + {q ? <SearchTabs query={q} /> : <></>} 56 152 </> 57 153 ); 58 154 } 155 + 156 + function SearchTabs({ query }: { query: string }) { 157 + return ( 158 + <div> 159 + <ReusableTabRoute 160 + route={`search` + query} 161 + tabs={{ 162 + Likes: <LycanTab query={query} type={"likes"} key={"likes"} />, 163 + Reposts: <LycanTab query={query} type={"reposts"} key={"reposts"} />, 164 + Quotes: <LycanTab query={query} type={"quotes"} key={"quotes"} />, 165 + Pins: <LycanTab query={query} type={"pins"} key={"pins"} />, 166 + }} 167 + /> 168 + </div> 169 + ); 170 + } 171 + 172 + function LycanTab({ 173 + query, 174 + type, 175 + }: { 176 + query: string; 177 + type: "likes" | "pins" | "reposts" | "quotes"; 178 + }) { 179 + useReusableTabScrollRestore("search" + query); 180 + 181 + const { 182 + data: postsData, 183 + fetchNextPage, 184 + hasNextPage, 185 + isFetchingNextPage, 186 + isLoading: arePostsLoading, 187 + } = useInfiniteQueryLycanSearch({ query: query, type: type }); 188 + 189 + const posts = useMemo( 190 + () => 191 + postsData?.pages.flatMap((page) => { 192 + if (page) { 193 + return page.posts; 194 + } else { 195 + return []; 196 + } 197 + }) ?? [], 198 + [postsData] 199 + ); 200 + 201 + return ( 202 + <> 203 + {/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 204 + Posts 205 + </div> */} 206 + <div> 207 + {posts.map((post) => ( 208 + <UniversalPostRendererATURILoader 209 + key={post} 210 + atUri={post} 211 + feedviewpost={true} 212 + /> 213 + ))} 214 + </div> 215 + 216 + {/* Loading and "Load More" states */} 217 + {arePostsLoading && posts.length === 0 && ( 218 + <div className="p-4 text-center text-gray-500">Loading posts...</div> 219 + )} 220 + {isFetchingNextPage && ( 221 + <div className="p-4 text-center text-gray-500">Loading more...</div> 222 + )} 223 + {hasNextPage && !isFetchingNextPage && ( 224 + <button 225 + onClick={() => fetchNextPage()} 226 + 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" 227 + > 228 + Load More Posts 229 + </button> 230 + )} 231 + {posts.length === 0 && !arePostsLoading && ( 232 + <div className="p-4 text-center text-gray-500">No posts found.</div> 233 + )} 234 + </> 235 + ); 236 + 237 + return <></>; 238 + }
+8
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" />
+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
+380 -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 - 473 - 474 498 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}; 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 + }; 649 702 } 650 - 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"; 810 + }; 811 + 812 + type importtype = { 813 + message?: "Import has already started" | "Import has been scheduled" 814 + } 815 + 816 + export function constructLycanRequestIndexQuery(options: { 817 + agent?: ATPAPI.Agent; 818 + isAuthed: boolean; 819 + pdsUrl?: string; 820 + feedServiceDid?: string; 821 + }) { 822 + const { agent, isAuthed, pdsUrl, feedServiceDid } = options; 823 + 824 + return queryOptions({ 825 + queryKey: ["lycanIndex", { isAuthed, did: agent?.did }], 826 + 827 + queryFn: async () => { 828 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 829 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.startImport`; 830 + const res = await agent.fetchHandler(url, { 831 + method: "POST", 832 + headers: { 833 + "atproto-proxy": `${feedServiceDid}#lycan`, 834 + "Content-Type": "application/json", 835 + }, 836 + }); 837 + if (!res.ok) 838 + throw new Error( 839 + `Authenticated lycan status fetch failed: ${res.statusText}` 840 + ); 841 + return await res.json() as importtype; 842 + } 843 + return undefined; 844 + }, 845 + }); 846 + } 847 + 848 + type LycanSearchPage = { 849 + terms: string[]; 850 + posts: string[]; 851 + cursor?: string; 852 + }; 853 + 854 + 855 + export function useInfiniteQueryLycanSearch(options: { query: string, type: "likes" | "pins" | "reposts" | "quotes"}) { 856 + 857 + 858 + const [lycanurl] = useAtom(lycanURLAtom); 859 + const { agent, status } = useAuth(); 860 + const { data: identity } = useQueryIdentity(agent?.did); 861 + 862 + const { queryKey, queryFn } = constructLycanSearchQuery({ 863 + agent: agent || undefined, 864 + isAuthed: status === "signedIn", 865 + pdsUrl: identity?.pds, 866 + feedServiceDid: "did:web:"+lycanurl, 867 + query: options.query, 868 + type: options.type, 869 + }) 870 + 871 + return { 872 + ...useInfiniteQuery({ 873 + queryKey, 874 + queryFn, 875 + initialPageParam: undefined as never, 876 + getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined, 877 + //staleTime: Infinity, 878 + refetchOnWindowFocus: false, 879 + // enabled: 880 + // !!options.feedUri && 881 + // (options.isAuthed 882 + // ? ((!!options.agent && !!options.pdsUrl) || 883 + // !!options.unauthedfeedurl) && 884 + // !!options.feedServiceDid 885 + // : true), 886 + }), 887 + queryKey: queryKey, 888 + }; 889 + } 890 + 891 + 892 + export function constructLycanSearchQuery(options: { 893 + agent?: ATPAPI.Agent; 894 + isAuthed: boolean; 895 + pdsUrl?: string; 896 + feedServiceDid?: string; 897 + type: "likes" | "pins" | "reposts" | "quotes"; 898 + query: string; 899 + }) { 900 + const { agent, isAuthed, pdsUrl, feedServiceDid, type, query } = options; 901 + 902 + return infiniteQueryOptions({ 903 + queryKey: ["lycanSearch", query, type, { isAuthed, did: agent?.did }], 904 + 905 + queryFn: async ({ 906 + pageParam, 907 + }: QueryFunctionContext): Promise<LycanSearchPage | undefined> => { 908 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 909 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.searchPosts?query=${query}&collection=${type}${pageParam ? `&cursor=${pageParam}` : ""}`; 910 + const res = await agent.fetchHandler(url, { 911 + method: "GET", 912 + headers: { 913 + "atproto-proxy": `${feedServiceDid}#lycan`, 914 + "Content-Type": "application/json", 915 + }, 916 + }); 917 + if (!res.ok) 918 + throw new Error( 919 + `Authenticated lycan status fetch failed: ${res.statusText}` 920 + ); 921 + return (await res.json()) as LycanSearchPage; 922 + } 923 + return undefined; 924 + }, 925 + initialPageParam: undefined as never, 926 + getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined, 927 + }); 928 + }