an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm
at main 8.5 kB view raw
1import type { Agent } from "@atproto/api"; 2import { useQueryClient } from "@tanstack/react-query"; 3import { createFileRoute, useSearch } from "@tanstack/react-router"; 4import { useAtom } from "jotai"; 5import { useEffect,useMemo } from "react"; 6 7import { Header } from "~/components/Header"; 8import { Import } from "~/components/Import"; 9import { 10 ReusableTabRoute, 11 useReusableTabScrollRestore, 12} from "~/components/ReusableTabRoute"; 13import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 14import { useAuth } from "~/providers/UnifiedAuthProvider"; 15import { lycanURLAtom } from "~/utils/atoms"; 16import { 17 constructLycanRequestIndexQuery, 18 useInfiniteQueryLycanSearch, 19 useQueryIdentity, 20 useQueryLycanStatus, 21} from "~/utils/useQuery"; 22 23import { renderSnack } from "./__root"; 24import { SliderPrimitive } from "./settings"; 25 26export const Route = createFileRoute("/search")({ 27 component: Search, 28}); 29 30export 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 105 return ( 106 <> 107 <Header 108 title="Explore" 109 backButtonCallback={() => { 110 if (window.history.length > 1) { 111 window.history.back(); 112 } else { 113 window.location.assign("/"); 114 } 115 }} 116 /> 117 <div className=" flex flex-col items-center mt-4 mx-4 gap-4"> 118 <Import optionaltextstring={q} /> 119 <div className="flex flex-col"> 120 <p className="text-gray-600 dark:text-gray-400">{maintext}</p> 121 <ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400"> 122 <li> 123 Bluesky URLs (from supported clients) (like{" "} 124 <code className="text-sm">bsky.app</code> or{" "} 125 <code className="text-sm">deer.social</code>). 126 </li> 127 <li> 128 AT-URIs (e.g.,{" "} 129 <code className="text-sm">at://did:example/collection/item</code> 130 ). 131 </li> 132 <li> 133 User Handles (like{" "} 134 <code className="text-sm">@username.bsky.social</code>). 135 </li> 136 <li> 137 DIDs (Decentralized Identifiers, starting with{" "} 138 <code className="text-sm">did:</code>). 139 </li> 140 </ul> 141 <p className="mt-2 text-gray-600 dark:text-gray-400"> 142 Simply paste one of these into the import field above and press 143 Enter to load the content. 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 )} 177 </div> 178 </div> 179 {q ? <SearchTabs query={q} /> : <></>} 180 </> 181 ); 182} 183 184function 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 200function 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}