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

Compare changes

Choose any two refs to compare.

Changed files
+107 -7
src
routes
profile.$did
utils
+14 -7
src/routes/profile.$did/index.tsx
··· 10 10 UniversalPostRendererATURILoader, 11 11 } from "~/components/UniversalPostRenderer"; 12 12 import { useAuth } from "~/providers/UnifiedAuthProvider"; 13 - import { imgCDNAtom } from "~/utils/atoms"; 13 + import { aturiListServiceAtom, imgCDNAtom } from "~/utils/atoms"; 14 14 import { 15 15 toggleFollow, 16 16 useGetFollowState, 17 17 useGetOneToOneState, 18 18 } from "~/utils/followState"; 19 19 import { 20 - useInfiniteQueryAuthorFeed, 20 + useInfiniteQueryAturiList, 21 21 useQueryIdentity, 22 22 useQueryProfile, 23 23 } from "~/utils/useQuery"; ··· 29 29 function ProfileComponent() { 30 30 // booo bad this is not always the did it might be a handle, use identity.did instead 31 31 const { did } = Route.useParams(); 32 - const navigate = useNavigate(); 32 + //const navigate = useNavigate(); 33 33 const queryClient = useQueryClient(); 34 34 const { 35 35 data: identity, ··· 39 39 40 40 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 41 41 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; 42 - const pdsUrl = identity?.pds; 42 + //const pdsUrl = identity?.pds; 43 43 44 44 const profileUri = resolvedDid 45 45 ? `at://${resolvedDid}/app.bsky.actor.profile/self` ··· 47 47 const { data: profileRecord } = useQueryProfile(profileUri); 48 48 const profile = profileRecord?.value; 49 49 50 + const [aturilistservice] = useAtom(aturiListServiceAtom); 51 + 50 52 const { 51 53 data: postsData, 52 54 fetchNextPage, 53 55 hasNextPage, 54 56 isFetchingNextPage, 55 57 isLoading: arePostsLoading, 56 - } = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl); 58 + } = useInfiniteQueryAturiList({ 59 + aturilistservice: aturilistservice, 60 + did: resolvedDid, 61 + collection: "app.bsky.feed.post", 62 + reverse: true 63 + }); 57 64 58 65 React.useEffect(() => { 59 66 if (postsData) { 60 67 postsData.pages.forEach((page) => { 61 - page.records.forEach((record) => { 68 + page.forEach((record) => { 62 69 if (!queryClient.getQueryData(["post", record.uri])) { 63 70 queryClient.setQueryData(["post", record.uri], record); 64 71 } ··· 68 75 }, [postsData, queryClient]); 69 76 70 77 const posts = React.useMemo( 71 - () => postsData?.pages.flatMap((page) => page.records) ?? [], 78 + () => postsData?.pages.flatMap((page) => page) ?? [], 72 79 [postsData] 73 80 ); 74 81
+8
src/routes/settings.tsx
··· 5 5 import { Header } from "~/components/Header"; 6 6 import Login from "~/components/Login"; 7 7 import { 8 + aturiListServiceAtom, 8 9 constellationURLAtom, 10 + defaultaturilistservice, 9 11 defaultconstellationURL, 10 12 defaulthue, 11 13 defaultImgCDN, ··· 52 54 description={"Customize the Slingshot instance to be used by Red Dwarf"} 53 55 init={defaultslingshotURL} 54 56 /> 57 + <TextInputSetting 58 + atom={aturiListServiceAtom} 59 + title={"AtUriListService"} 60 + description={"Customize the AtUriListService instance to be used by Red Dwarf"} 61 + init={defaultaturilistservice} 62 + /> 55 63 <TextInputSetting 56 64 atom={imgCDNAtom} 57 65 title={"Image CDN"}
+5
src/utils/atoms.ts
··· 32 32 "slingshotURL", 33 33 defaultslingshotURL 34 34 ); 35 + export const defaultaturilistservice = "aturilistservice.reddwarf.app"; 36 + export const aturiListServiceAtom = atomWithStorage<string>( 37 + "aturilistservice", 38 + defaultaturilistservice 39 + ); 35 40 export const defaultImgCDN = "cdn.bsky.app"; 36 41 export const imgCDNAtom = atomWithStorage<string>("imgcdnurl", defaultImgCDN); 37 42 export const defaultVideoCDN = "video.bsky.app";
+80
src/utils/useQuery.ts
··· 565 565 }); 566 566 } 567 567 568 + export const ATURI_PAGE_LIMIT = 100; 569 + 570 + export interface AturiDirectoryAturisItem { 571 + uri: string; 572 + cid: string; 573 + rkey: string; 574 + } 575 + 576 + export type AturiDirectoryAturis = AturiDirectoryAturisItem[]; 577 + 578 + export function constructAturiListQuery(aturilistservice: string, did: string, collection: string, reverse?: boolean) { 579 + return queryOptions({ 580 + // A unique key for this query, including all parameters that affect the data. 581 + queryKey: ["aturiList", did, collection, { reverse }], 582 + 583 + // The function that fetches the data. 584 + queryFn: async ({ pageParam }: QueryFunctionContext) => { 585 + const cursor = pageParam as string | undefined; 586 + 587 + // Use URLSearchParams for safe and clean URL construction. 588 + const params = new URLSearchParams({ 589 + did, 590 + collection, 591 + }); 592 + 593 + if (cursor) { 594 + params.set("cursor", cursor); 595 + } 596 + 597 + // Add the reverse parameter if it's true 598 + if (reverse) { 599 + params.set("reverse", "true"); 600 + } 601 + 602 + const url = `https://${aturilistservice}/aturis?${params.toString()}`; 603 + 604 + const res = await fetch(url); 605 + if (!res.ok) { 606 + // You can add more specific error handling here 607 + throw new Error(`Failed to fetch AT-URI list for ${did}`); 608 + } 609 + 610 + return res.json() as Promise<AturiDirectoryAturis>; 611 + }, 612 + }); 613 + } 614 + 615 + export function useInfiniteQueryAturiList({aturilistservice, did, collection, reverse}:{aturilistservice: string, did: string | undefined, collection: string | undefined, reverse?: boolean}) { 616 + // We only enable the query if both `did` and `collection` are provided. 617 + const isEnabled = !!did && !!collection; 618 + 619 + const { queryKey, queryFn } = constructAturiListQuery(aturilistservice, did!, collection!, reverse); 620 + 621 + return useInfiniteQuery({ 622 + queryKey, 623 + queryFn, 624 + initialPageParam: undefined as never, // ???? what is this shit 625 + 626 + // @ts-expect-error i wouldve used as null | undefined, anyways 627 + getNextPageParam: (lastPage: AturiDirectoryAturis) => { 628 + // If the last page returned no records, we're at the end. 629 + if (!lastPage || lastPage.length === 0) { 630 + return undefined; 631 + } 632 + 633 + // If the number of records is less than our page limit, it must be the last page. 634 + if (lastPage.length < ATURI_PAGE_LIMIT) { 635 + return undefined; 636 + } 637 + 638 + // The cursor for the next page is the `rkey` of the last item we received. 639 + const lastItem = lastPage[lastPage.length - 1]; 640 + return lastItem.rkey; 641 + }, 642 + 643 + enabled: isEnabled, 644 + }); 645 + } 646 + 647 + 568 648 type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 569 649 570 650 export function constructInfiniteFeedSkeletonQuery(options: {