an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
92
fork

Configure Feed

Select the types of activity you want to include in your feed.

lycansubscribe

rimar1337 1414d177 665413c9

+629 -174
+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 + }