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