an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
1import * as ATPAPI from "@atproto/api";
2import {
3 infiniteQueryOptions,
4 type QueryFunctionContext,
5 queryOptions,
6 useInfiniteQuery,
7 useQuery,
8 type UseQueryResult,
9} from "@tanstack/react-query";
10import { useAtom } from "jotai";
11
12import { useAuth } from "~/providers/UnifiedAuthProvider";
13
14import { constellationURLAtom, lycanURLAtom, slingshotURLAtom } from "./atoms";
15
16export function constructIdentityQuery(
17 didorhandle?: string,
18 slingshoturl?: string,
19) {
20 return queryOptions({
21 queryKey: ["identity", didorhandle],
22 queryFn: async () => {
23 if (!didorhandle) return undefined as undefined;
24 const res = await fetch(
25 `https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`,
26 );
27 if (!res.ok) throw new Error("Failed to fetch post");
28 try {
29 return (await res.json()) as {
30 did: string;
31 handle: string;
32 pds: string;
33 signing_key: string;
34 };
35 } catch (_e) {
36 return undefined;
37 }
38 },
39 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
40 gcTime: /*0//*/ 5 * 60 * 1000,
41 });
42}
43export function useQueryIdentity(didorhandle: string): UseQueryResult<
44 {
45 did: string;
46 handle: string;
47 pds: string;
48 signing_key: string;
49 },
50 Error
51>;
52export function useQueryIdentity(): UseQueryResult<undefined, Error>;
53export 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>;
63export function useQueryIdentity(didorhandle?: string) {
64 const [slingshoturl] = useAtom(slingshotURLAtom);
65 return useQuery(constructIdentityQuery(didorhandle, slingshoturl));
66}
67
68export function constructPostQuery(uri?: string, slingshoturl?: string) {
69 return queryOptions({
70 queryKey: ["post", uri],
71 queryFn: async () => {
72 if (!uri) return undefined as undefined;
73 const res = await fetch(
74 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`,
75 );
76 let data: any;
77 try {
78 data = await res.json();
79 } catch {
80 return undefined;
81 }
82 if (res.status === 400) return undefined;
83 if (
84 data?.error === "InvalidRequest" &&
85 data.message?.includes("Could not find repo")
86 ) {
87 return undefined; // cache “not found”
88 }
89 try {
90 if (!res.ok) throw new Error("Failed to fetch post");
91 return data as {
92 uri: string;
93 cid: string;
94 value: any;
95 };
96 } catch (_e) {
97 return undefined;
98 }
99 },
100 retry: (failureCount, error) => {
101 // dont retry 400 errors
102 if ((error as any)?.message?.includes("400")) return false;
103 return failureCount < 2;
104 },
105 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
106 gcTime: /*0//*/ 5 * 60 * 1000,
107 });
108}
109export function useQueryPost(uri: string): UseQueryResult<
110 {
111 uri: string;
112 cid: string;
113 value: ATPAPI.AppBskyFeedPost.Record;
114 },
115 Error
116>;
117export function useQueryPost(): UseQueryResult<undefined, Error>;
118export function useQueryPost(uri?: string): UseQueryResult<
119 | {
120 uri: string;
121 cid: string;
122 value: ATPAPI.AppBskyFeedPost.Record;
123 }
124 | undefined,
125 Error
126>;
127export function useQueryPost(uri?: string) {
128 const [slingshoturl] = useAtom(slingshotURLAtom);
129 return useQuery(constructPostQuery(uri, slingshoturl));
130}
131
132export function constructProfileQuery(uri?: string, slingshoturl?: string) {
133 return queryOptions({
134 queryKey: ["profile", uri],
135 queryFn: async () => {
136 if (!uri) return undefined as undefined;
137 const res = await fetch(
138 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`,
139 );
140 let data: any;
141 try {
142 data = await res.json();
143 } catch {
144 return undefined;
145 }
146 if (res.status === 400) return undefined;
147 if (
148 data?.error === "InvalidRequest" &&
149 data.message?.includes("Could not find repo")
150 ) {
151 return undefined; // cache “not found”
152 }
153 try {
154 if (!res.ok) throw new Error("Failed to fetch post");
155 return data as {
156 uri: string;
157 cid: string;
158 value: any;
159 };
160 } catch (_e) {
161 return undefined;
162 }
163 },
164 retry: (failureCount, error) => {
165 // dont retry 400 errors
166 if ((error as any)?.message?.includes("400")) return false;
167 return failureCount < 2;
168 },
169 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
170 gcTime: /*0//*/ 5 * 60 * 1000,
171 });
172}
173export function useQueryProfile(uri: string): UseQueryResult<
174 {
175 uri: string;
176 cid: string;
177 value: ATPAPI.AppBskyActorProfile.Record;
178 },
179 Error
180>;
181export function useQueryProfile(): UseQueryResult<undefined, Error>;
182export function useQueryProfile(uri?: string): UseQueryResult<
183 | {
184 uri: string;
185 cid: string;
186 value: ATPAPI.AppBskyActorProfile.Record;
187 }
188 | undefined,
189 Error
190>;
191export function useQueryProfile(uri?: string) {
192 const [slingshoturl] = useAtom(slingshotURLAtom);
193 return useQuery(constructProfileQuery(uri, slingshoturl));
194}
195
196// export function constructConstellationQuery(
197// method: "/links",
198// target: string,
199// collection: string,
200// path: string,
201// cursor?: string
202// ): QueryOptions<linksRecordsResponse, Error>;
203// export function constructConstellationQuery(
204// method: "/links/distinct-dids",
205// target: string,
206// collection: string,
207// path: string,
208// cursor?: string
209// ): QueryOptions<linksDidsResponse, Error>;
210// export function constructConstellationQuery(
211// method: "/links/count",
212// target: string,
213// collection: string,
214// path: string,
215// cursor?: string
216// ): QueryOptions<linksCountResponse, Error>;
217// export function constructConstellationQuery(
218// method: "/links/count/distinct-dids",
219// target: string,
220// collection: string,
221// path: string,
222// cursor?: string
223// ): QueryOptions<linksCountResponse, Error>;
224// export function constructConstellationQuery(
225// method: "/links/all",
226// target: string
227// ): QueryOptions<linksAllResponse, Error>;
228export function constructConstellationQuery(query?: {
229 constellation: string;
230 method:
231 | "/links"
232 | "/links/distinct-dids"
233 | "/links/count"
234 | "/links/count/distinct-dids"
235 | "/links/all"
236 | "undefined";
237 target: string;
238 collection?: string;
239 path?: string;
240 cursor?: string;
241 dids?: string[];
242 customkey?: string;
243}) {
244 // : QueryOptions<
245 // | linksRecordsResponse
246 // | linksDidsResponse
247 // | linksCountResponse
248 // | linksAllResponse
249 // | undefined,
250 // Error
251 // >
252 return queryOptions({
253 queryKey: [
254 "constellation",
255 query?.method,
256 query?.target,
257 query?.collection,
258 query?.path,
259 query?.cursor,
260 query?.dids,
261 query?.customkey,
262 ] as const,
263 queryFn: async () => {
264 if (!query || query.method === "undefined") return undefined as undefined;
265 const method = query.method;
266 const target = query.target;
267 const collection = query?.collection;
268 const path = query?.path;
269 const cursor = query.cursor;
270 const dids = query?.dids;
271 const res = await fetch(
272 `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("") : ""}`,
273 );
274 if (!res.ok) throw new Error("Failed to fetch post");
275 try {
276 switch (method) {
277 case "/links":
278 return (await res.json()) as linksRecordsResponse;
279 case "/links/distinct-dids":
280 return (await res.json()) as linksDidsResponse;
281 case "/links/count":
282 return (await res.json()) as linksCountResponse;
283 case "/links/count/distinct-dids":
284 return (await res.json()) as linksCountResponse;
285 case "/links/all":
286 return (await res.json()) as linksAllResponse;
287 default:
288 return undefined;
289 }
290 } catch (_e) {
291 return undefined;
292 }
293 },
294 // enforce short lifespan
295 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
296 gcTime: /*0//*/ 5 * 60 * 1000,
297 });
298}
299// todo do more of these instead of overloads since overloads sucks so much apparently
300export function useQueryConstellationLinksCountDistinctDids(query?: {
301 method: "/links/count/distinct-dids";
302 target: string;
303 collection: string;
304 path: string;
305 cursor?: string;
306}): UseQueryResult<linksCountResponse, Error> | undefined {
307 //if (!query) return;
308 const [constellationurl] = useAtom(constellationURLAtom);
309 const queryres = useQuery(
310 constructConstellationQuery(
311 query && { constellation: constellationurl, ...query },
312 ),
313 ) as unknown as UseQueryResult<linksCountResponse, Error>;
314 if (!query) {
315 return undefined as undefined;
316 }
317 return queryres as UseQueryResult<linksCountResponse, Error>;
318}
319
320export function useQueryConstellation(query: {
321 method: "/links";
322 target: string;
323 collection: string;
324 path: string;
325 cursor?: string;
326 dids?: string[];
327 customkey?: string;
328}): UseQueryResult<linksRecordsResponse, Error>;
329export function useQueryConstellation(query: {
330 method: "/links/distinct-dids";
331 target: string;
332 collection: string;
333 path: string;
334 cursor?: string;
335 customkey?: string;
336}): UseQueryResult<linksDidsResponse, Error>;
337export function useQueryConstellation(query: {
338 method: "/links/count";
339 target: string;
340 collection: string;
341 path: string;
342 cursor?: string;
343 customkey?: string;
344}): UseQueryResult<linksCountResponse, Error>;
345export function useQueryConstellation(query: {
346 method: "/links/count/distinct-dids";
347 target: string;
348 collection: string;
349 path: string;
350 cursor?: string;
351 customkey?: string;
352}): UseQueryResult<linksCountResponse, Error>;
353export function useQueryConstellation(query: {
354 method: "/links/all";
355 target: string;
356 customkey?: string;
357}): UseQueryResult<linksAllResponse, Error>;
358export function useQueryConstellation(): undefined;
359export function useQueryConstellation(query: {
360 method: "undefined";
361 target: string;
362 customkey?: string;
363}): undefined;
364export function useQueryConstellation(query?: {
365 method:
366 | "/links"
367 | "/links/distinct-dids"
368 | "/links/count"
369 | "/links/count/distinct-dids"
370 | "/links/all"
371 | "undefined";
372 target: string;
373 collection?: string;
374 path?: string;
375 cursor?: string;
376 dids?: string[];
377 customkey?: string;
378}):
379 | UseQueryResult<
380 | linksRecordsResponse
381 | linksDidsResponse
382 | linksCountResponse
383 | linksAllResponse
384 | undefined,
385 Error
386 >
387 | undefined {
388 //if (!query) return;
389 const [constellationurl] = useAtom(constellationURLAtom);
390 return useQuery(
391 constructConstellationQuery(
392 query && { constellation: constellationurl, ...query },
393 ),
394 );
395}
396
397export type linksRecord = {
398 did: string;
399 collection: string;
400 rkey: string;
401};
402export type linksRecordsResponse = {
403 total: string;
404 linking_records: linksRecord[];
405 cursor?: string;
406};
407type linksDidsResponse = {
408 total: string;
409 linking_dids: string[];
410 cursor?: string;
411};
412type linksCountResponse = {
413 total: string;
414};
415export type linksAllResponse = {
416 links: Record<
417 string,
418 Record<
419 string,
420 {
421 records: number;
422 distinct_dids: number;
423 }
424 >
425 >;
426};
427
428export function constructFeedSkeletonQuery(options?: {
429 feedUri: string;
430 agent?: ATPAPI.Agent;
431 isAuthed: boolean;
432 pdsUrl?: string;
433 feedServiceDid?: string;
434}) {
435 return queryOptions({
436 // The query key includes all dependencies to ensure it refetches when they change
437 queryKey: [
438 "feedSkeleton",
439 options?.feedUri,
440 { isAuthed: options?.isAuthed, did: options?.agent?.did },
441 ],
442 queryFn: async () => {
443 if (!options) return undefined as undefined;
444 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
445 if (isAuthed) {
446 // Authenticated flow
447 if (!agent || !pdsUrl || !feedServiceDid) {
448 throw new Error(
449 "Missing required info for authenticated feed fetch.",
450 );
451 }
452 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
453 const res = await agent.fetchHandler(url, {
454 method: "GET",
455 headers: {
456 "atproto-proxy": `${feedServiceDid}#bsky_fg`,
457 "Content-Type": "application/json",
458 },
459 });
460 if (!res.ok)
461 throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
462 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
463 } else {
464 // Unauthenticated flow (using a public PDS/AppView)
465 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
466 const res = await fetch(url);
467 if (!res.ok)
468 throw new Error(`Public feed fetch failed: ${res.statusText}`);
469 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
470 }
471 },
472 //enabled: !!feedUri && (isAuthed ? !!agent && !!pdsUrl && !!feedServiceDid : true),
473 });
474}
475
476export function useQueryFeedSkeleton(options?: {
477 feedUri: string;
478 agent?: ATPAPI.Agent;
479 isAuthed: boolean;
480 pdsUrl?: string;
481 feedServiceDid?: string;
482}) {
483 return useQuery(constructFeedSkeletonQuery(options));
484}
485
486export function constructRecordQuery(
487 did?: string,
488 collection?: string,
489 rkey?: string,
490 pdsUrl?: string,
491) {
492 return queryOptions({
493 queryKey: ["record", did, collection, rkey],
494 queryFn: async () => {
495 if (!did || !collection || !rkey || !pdsUrl)
496 return undefined as undefined;
497 const url = `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`;
498 const res = await fetch(url);
499 if (!res.ok) throw new Error("Failed to fetch record");
500 try {
501 return (await res.json()) as {
502 uri: string;
503 cid: string;
504 value: any;
505 };
506 } catch (_e) {
507 return undefined;
508 }
509 },
510 staleTime: 5 * 60 * 1000, // 5 minutes
511 gcTime: 5 * 60 * 1000,
512 });
513}
514
515export function useQueryRecord(
516 did?: string,
517 collection?: string,
518 rkey?: string,
519 pdsUrl?: string,
520) {
521 return useQuery(constructRecordQuery(did, collection, rkey, pdsUrl));
522}
523
524export function constructPreferencesQuery(
525 agent?: ATPAPI.Agent | undefined,
526 pdsUrl?: string | undefined,
527) {
528 return queryOptions({
529 queryKey: ["preferences", agent?.did],
530 queryFn: async () => {
531 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available");
532 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`;
533 const res = await agent.fetchHandler(url, { method: "GET" });
534 if (!res.ok) throw new Error("Failed to fetch preferences");
535 return res.json();
536 },
537 });
538}
539export function useQueryPreferences(options: {
540 agent?: ATPAPI.Agent | undefined;
541 pdsUrl?: string | undefined;
542}) {
543 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl));
544}
545
546export function constructArbitraryQuery(uri?: string, slingshoturl?: string) {
547 return queryOptions({
548 queryKey: ["arbitrary", uri],
549 queryFn: async () => {
550 if (!uri) return undefined as undefined;
551 const res = await fetch(
552 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`,
553 );
554 let data: any;
555 try {
556 data = await res.json();
557 } catch {
558 return undefined;
559 }
560 if (res.status === 400) return undefined;
561 if (
562 data?.error === "InvalidRequest" &&
563 data.message?.includes("Could not find repo")
564 ) {
565 return undefined; // cache “not found”
566 }
567 try {
568 if (!res.ok) throw new Error("Failed to fetch post");
569 return data as {
570 uri: string;
571 cid: string;
572 value: any;
573 };
574 } catch (_e) {
575 return undefined;
576 }
577 },
578 retry: (failureCount, error) => {
579 // dont retry 400 errors
580 if ((error as any)?.message?.includes("400")) return false;
581 return failureCount < 2;
582 },
583 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
584 gcTime: /*0//*/ 5 * 60 * 1000,
585 });
586}
587export function useQueryArbitrary(uri: string): UseQueryResult<
588 {
589 uri: string;
590 cid: string;
591 value: any;
592 },
593 Error
594>;
595export function useQueryArbitrary(): UseQueryResult<undefined, Error>;
596export function useQueryArbitrary(uri?: string): UseQueryResult<
597 | {
598 uri: string;
599 cid: string;
600 value: any;
601 }
602 | undefined,
603 Error
604>;
605export function useQueryArbitrary(uri?: string) {
606 const [slingshoturl] = useAtom(slingshotURLAtom);
607 return useQuery(constructArbitraryQuery(uri, slingshoturl));
608}
609
610export function constructFallbackNothingQuery() {
611 return queryOptions({
612 queryKey: ["nothing"],
613 queryFn: async () => {
614 return undefined;
615 },
616 });
617}
618
619type ListRecordsResponse = {
620 cursor?: string;
621 records: {
622 uri: string;
623 cid: string;
624 value: ATPAPI.AppBskyFeedPost.Record;
625 }[];
626};
627
628export function constructAuthorFeedQuery(
629 did: string,
630 pdsUrl: string,
631 collection: string = "app.bsky.feed.post",
632) {
633 return queryOptions({
634 queryKey: ["authorFeed", did, collection],
635 queryFn: async ({ pageParam }: QueryFunctionContext) => {
636 const limit = 25;
637
638 const cursor = pageParam as string | undefined;
639 const cursorParam = cursor ? `&cursor=${cursor}` : "";
640
641 const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`;
642
643 const res = await fetch(url);
644 if (!res.ok) throw new Error("Failed to fetch author's posts");
645
646 return res.json() as Promise<ListRecordsResponse>;
647 },
648 });
649}
650
651export function useInfiniteQueryAuthorFeed(
652 did: string | undefined,
653 pdsUrl: string | undefined,
654 collection?: string,
655) {
656 const { queryKey, queryFn } = constructAuthorFeedQuery(
657 did!,
658 pdsUrl!,
659 collection,
660 );
661
662 return useInfiniteQuery({
663 queryKey,
664 queryFn,
665 initialPageParam: undefined as never, // ???? what is this shit
666 getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
667 enabled: !!did && !!pdsUrl,
668 });
669}
670
671type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
672
673export function constructInfiniteFeedSkeletonQuery(options: {
674 feedUri: string;
675 agent?: ATPAPI.Agent;
676 isAuthed: boolean;
677 pdsUrl?: string;
678 feedServiceDid?: string;
679 // todo the hell is a unauthedfeedurl
680 unauthedfeedurl?: string;
681}) {
682 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } =
683 options;
684
685 return queryOptions({
686 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }],
687
688 queryFn: async ({
689 pageParam,
690 }: QueryFunctionContext): Promise<FeedSkeletonPage> => {
691 const cursorParam = pageParam ? `&cursor=${pageParam}` : "";
692
693 if (isAuthed && !unauthedfeedurl) {
694 if (!agent || !pdsUrl || !feedServiceDid) {
695 throw new Error(
696 "Missing required info for authenticated feed fetch.",
697 );
698 }
699 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
700 const res = await agent.fetchHandler(url, {
701 method: "GET",
702 headers: {
703 "atproto-proxy": `${feedServiceDid}#bsky_fg`,
704 "Content-Type": "application/json",
705 },
706 });
707 if (!res.ok)
708 throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
709 return (await res.json()) as FeedSkeletonPage;
710 } else {
711 const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
712 const res = await fetch(url);
713 if (!res.ok)
714 throw new Error(`Public feed fetch failed: ${res.statusText}`);
715 return (await res.json()) as FeedSkeletonPage;
716 }
717 },
718 });
719}
720
721export function useInfiniteQueryFeedSkeleton(options: {
722 feedUri: string;
723 agent?: ATPAPI.Agent;
724 isAuthed: boolean;
725 pdsUrl?: string;
726 feedServiceDid?: string;
727 unauthedfeedurl?: string;
728}) {
729 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
730
731 return {
732 ...useInfiniteQuery({
733 queryKey,
734 queryFn,
735 initialPageParam: undefined as never,
736 getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
737 staleTime: Infinity,
738 refetchOnWindowFocus: false,
739 enabled:
740 !!options.feedUri &&
741 (options.isAuthed
742 ? ((!!options.agent && !!options.pdsUrl) ||
743 !!options.unauthedfeedurl) &&
744 !!options.feedServiceDid
745 : true),
746 }),
747 queryKey: queryKey,
748 };
749}
750
751export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: {
752 constellation: string;
753 method: "/links";
754 target?: string;
755 collection: string;
756 path: string;
757 staleMult?: number;
758}) {
759 const safemult = query?.staleMult ?? 1;
760 // console.log(
761 // 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
762 // query,
763 // )
764
765 return infiniteQueryOptions({
766 enabled: !!query?.target,
767 queryKey: [
768 "reddwarf_constellation",
769 query?.method,
770 query?.target,
771 query?.collection,
772 query?.path,
773 ] as const,
774
775 queryFn: async ({ pageParam }: { pageParam?: string }) => {
776 if (!query || !query?.target) return undefined;
777
778 const method = query.method;
779 const target = query.target;
780 const collection = query.collection;
781 const path = query.path;
782 const cursor = pageParam;
783
784 const res = await fetch(
785 `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${
786 collection ? `&collection=${encodeURIComponent(collection)}` : ""
787 }${path ? `&path=${encodeURIComponent(path)}` : ""}${
788 cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""
789 }`,
790 );
791
792 if (!res.ok) throw new Error("Failed to fetch");
793
794 return (await res.json()) as linksRecordsResponse;
795 },
796
797 getNextPageParam: (lastPage) => {
798 return (lastPage as any)?.cursor ?? undefined;
799 },
800 initialPageParam: undefined,
801 staleTime: 5 * 60 * 1000 * safemult,
802 gcTime: 5 * 60 * 1000 * safemult,
803 });
804}
805
806export function useQueryLycanStatus() {
807 const [lycanurl] = useAtom(lycanURLAtom);
808 const { agent, status } = useAuth();
809 const { data: identity } = useQueryIdentity(agent?.did);
810 return useQuery(
811 constructLycanStatusCheckQuery({
812 agent: agent || undefined,
813 isAuthed: status === "signedIn",
814 pdsUrl: identity?.pds,
815 feedServiceDid: "did:web:" + lycanurl,
816 }),
817 );
818}
819
820export function constructLycanStatusCheckQuery(options: {
821 agent?: ATPAPI.Agent;
822 isAuthed: boolean;
823 pdsUrl?: string;
824 feedServiceDid?: string;
825}) {
826 const { agent, isAuthed, pdsUrl, feedServiceDid } = options;
827
828 return queryOptions({
829 queryKey: ["lycanStatus", { isAuthed, did: agent?.did }],
830
831 queryFn: async () => {
832 if (isAuthed && agent && pdsUrl && feedServiceDid) {
833 const url = `${pdsUrl}/xrpc/blue.feeds.lycan.getImportStatus`;
834 const res = await agent.fetchHandler(url, {
835 method: "GET",
836 headers: {
837 "atproto-proxy": `${feedServiceDid}#lycan`,
838 "Content-Type": "application/json",
839 },
840 });
841 if (!res.ok)
842 throw new Error(
843 `Authenticated lycan status fetch failed: ${res.statusText}`,
844 );
845 return (await res.json()) as statuschek;
846 }
847 return undefined;
848 },
849 });
850}
851
852type statuschek = {
853 [key: string]: unknown;
854 error?: "MethodNotImplemented";
855 message?: "Method Not Implemented";
856 status?: "finished" | "in_progress";
857 position?: string;
858 progress?: number;
859};
860
861//{"status":"in_progress","position":"2025-08-30T06:53:18Z","progress":0.0878319661441268}
862type importtype = {
863 message?: "Import has already started" | "Import has been scheduled";
864};
865
866export function constructLycanRequestIndexQuery(options: {
867 agent?: ATPAPI.Agent;
868 isAuthed: boolean;
869 pdsUrl?: string;
870 feedServiceDid?: string;
871}) {
872 const { agent, isAuthed, pdsUrl, feedServiceDid } = options;
873
874 return queryOptions({
875 queryKey: ["lycanIndex", { isAuthed, did: agent?.did }],
876
877 queryFn: async () => {
878 if (isAuthed && agent && pdsUrl && feedServiceDid) {
879 const url = `${pdsUrl}/xrpc/blue.feeds.lycan.startImport`;
880 const res = await agent.fetchHandler(url, {
881 method: "POST",
882 headers: {
883 "atproto-proxy": `${feedServiceDid}#lycan`,
884 "Content-Type": "application/json",
885 },
886 });
887 if (!res.ok)
888 throw new Error(
889 `Authenticated lycan status fetch failed: ${res.statusText}`,
890 );
891 return (await res.json()) as importtype;
892 }
893 return undefined;
894 },
895 });
896}
897
898type LycanSearchPage = {
899 terms: string[];
900 posts: string[];
901 cursor?: string;
902};
903
904export function useInfiniteQueryLycanSearch(options: {
905 query: string;
906 type: "likes" | "pins" | "reposts" | "quotes";
907}) {
908 const [lycanurl] = useAtom(lycanURLAtom);
909 const { agent, status } = useAuth();
910 const { data: identity } = useQueryIdentity(agent?.did);
911
912 const { queryKey, queryFn } = constructLycanSearchQuery({
913 agent: agent || undefined,
914 isAuthed: status === "signedIn",
915 pdsUrl: identity?.pds,
916 feedServiceDid: "did:web:" + lycanurl,
917 query: options.query,
918 type: options.type,
919 });
920
921 return {
922 ...useInfiniteQuery({
923 queryKey,
924 queryFn,
925 initialPageParam: undefined as never,
926 getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined,
927 //staleTime: Infinity,
928 refetchOnWindowFocus: false,
929 // enabled:
930 // !!options.feedUri &&
931 // (options.isAuthed
932 // ? ((!!options.agent && !!options.pdsUrl) ||
933 // !!options.unauthedfeedurl) &&
934 // !!options.feedServiceDid
935 // : true),
936 }),
937 queryKey: queryKey,
938 };
939}
940
941export function constructLycanSearchQuery(options: {
942 agent?: ATPAPI.Agent;
943 isAuthed: boolean;
944 pdsUrl?: string;
945 feedServiceDid?: string;
946 type: "likes" | "pins" | "reposts" | "quotes";
947 query: string;
948}) {
949 const { agent, isAuthed, pdsUrl, feedServiceDid, type, query } = options;
950
951 return infiniteQueryOptions({
952 queryKey: ["lycanSearch", query, type, { isAuthed, did: agent?.did }],
953
954 queryFn: async ({
955 pageParam,
956 }: QueryFunctionContext): Promise<LycanSearchPage | undefined> => {
957 if (isAuthed && agent && pdsUrl && feedServiceDid) {
958 const url = `${pdsUrl}/xrpc/blue.feeds.lycan.searchPosts?query=${query}&collection=${type}${pageParam ? `&cursor=${pageParam}` : ""}`;
959 const res = await agent.fetchHandler(url, {
960 method: "GET",
961 headers: {
962 "atproto-proxy": `${feedServiceDid}#lycan`,
963 "Content-Type": "application/json",
964 },
965 });
966 if (!res.ok)
967 throw new Error(
968 `Authenticated lycan status fetch failed: ${res.statusText}`,
969 );
970 return (await res.json()) as LycanSearchPage;
971 }
972 return undefined;
973 },
974 initialPageParam: undefined as never,
975 getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined,
976 });
977}