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.

+1 -1
README.md
··· 8 ## running dev and build 9 in the `vite.config.ts` file you should change these values 10 ```ts 11 - const PROD_URL = "https://reddwarf.whey.party" 12 const DEV_URL = "https://local3768forumtest.whey.party" 13 ``` 14 the PROD_URL is what will compile your oauth client metadata so it is very important to change that. same for DEV_URL if you are using a tunnel for dev work
··· 8 ## running dev and build 9 in the `vite.config.ts` file you should change these values 10 ```ts 11 + const PROD_URL = "https://reddwarf.app" 12 const DEV_URL = "https://local3768forumtest.whey.party" 13 ``` 14 the PROD_URL is what will compile your oauth client metadata so it is very important to change that. same for DEV_URL if you are using a tunnel for dev work
+2 -1
src/components/Import.tsx
··· 46 // parse text 47 /** 48 * text might be 49 - * 1. bsky dot app url (deer.social, reddwarf.whey.party, main.bsky.dev, catskys.social) (reddwarf link segments might be uri encoded,) 50 * 2. aturi 51 * 3. plain handle 52 * 4. plain did ··· 60 "social.daniela.lol", 61 "deer.social", 62 "reddwarf.whey.party", 63 "main.bsky.dev", 64 "catsky.social", 65 "blacksky.community",
··· 46 // parse text 47 /** 48 * text might be 49 + * 1. bsky dot app url (reddwarf link segments might be uri encoded,) 50 * 2. aturi 51 * 3. plain handle 52 * 4. plain did ··· 60 "social.daniela.lol", 61 "deer.social", 62 "reddwarf.whey.party", 63 + "reddwarf.app", 64 "main.bsky.dev", 65 "catsky.social", 66 "blacksky.community",
+32 -6
src/components/InfiniteCustomFeed.tsx
··· 1 import * as React from "react"; 2 3 //import { useInView } from "react-intersection-observer"; ··· 37 isFetchingNextPage, 38 refetch, 39 isRefetching, 40 } = useInfiniteQueryFeedSkeleton({ 41 feedUri: feedUri, 42 agent: agent ?? undefined, ··· 44 pdsUrl: pdsUrl, 45 feedServiceDid: feedServiceDid, 46 }); 47 48 const handleRefresh = () => { 49 refetch(); 50 }; 51 52 //const { ref, inView } = useInView(); 53 54 // React.useEffect(() => { ··· 67 ); 68 } 69 70 - const allPosts = 71 - data?.pages.flatMap((page) => { 72 - if (page) return page.feed; 73 - }) ?? []; 74 75 if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) { 76 return ( ··· 116 className="sticky lg:bottom-4 bottom-22 ml-4 w-[42px] h-[42px] z-10 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:dark:bg-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed" 117 aria-label="Refresh feed" 118 > 119 - <RefreshIcon className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} /> 120 </button> 121 </> 122 ); ··· 139 d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" 140 ></path> 141 </svg> 142 - );
··· 1 + import { useQueryClient } from "@tanstack/react-query"; 2 import * as React from "react"; 3 4 //import { useInView } from "react-intersection-observer"; ··· 38 isFetchingNextPage, 39 refetch, 40 isRefetching, 41 + queryKey, 42 } = useInfiniteQueryFeedSkeleton({ 43 feedUri: feedUri, 44 agent: agent ?? undefined, ··· 46 pdsUrl: pdsUrl, 47 feedServiceDid: feedServiceDid, 48 }); 49 + const queryClient = useQueryClient(); 50 + 51 52 const handleRefresh = () => { 53 + queryClient.removeQueries({queryKey: queryKey}); 54 + //queryClient.invalidateQueries(["infinite-feed", feedUri] as const); 55 refetch(); 56 }; 57 58 + const allPosts = React.useMemo(() => { 59 + const flattenedPosts = data?.pages.flatMap((page) => page?.feed) ?? []; 60 + 61 + const seenUris = new Set<string>(); 62 + 63 + return flattenedPosts.filter((item) => { 64 + if (!item?.post) return false; 65 + 66 + if (seenUris.has(item.post)) { 67 + return false; 68 + } 69 + 70 + seenUris.add(item.post); 71 + 72 + return true; 73 + }); 74 + }, [data]); 75 + 76 //const { ref, inView } = useInView(); 77 78 // React.useEffect(() => { ··· 91 ); 92 } 93 94 + // const allPosts = 95 + // data?.pages.flatMap((page) => { 96 + // if (page) return page.feed; 97 + // }) ?? []; 98 99 if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) { 100 return ( ··· 140 className="sticky lg:bottom-4 bottom-22 ml-4 w-[42px] h-[42px] z-10 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:dark:bg-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed" 141 aria-label="Refresh feed" 142 > 143 + <RefreshIcon 144 + className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} 145 + /> 146 </button> 147 </> 148 ); ··· 165 d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" 166 ></path> 167 </svg> 168 + );
+1 -1
src/components/UniversalPostRenderer.tsx
··· 518 ? true 519 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 520 ? false 521 - : !(maxReplies && maxReplies === 0 && replies && replies > 0) ? false : bottomReplyLine 522 } 523 topReplyLine={topReplyLine} 524 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
··· 518 ? true 519 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 520 ? false 521 + : (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine 522 } 523 topReplyLine={topReplyLine} 524 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
+1
src/routes/index.tsx
··· 418 419 {isReadyForAuthedFeed || isReadyForUnauthedFeed ? ( 420 <InfiniteCustomFeed 421 feedUri={selectedFeed!} 422 pdsUrl={identity?.pds} 423 feedServiceDid={feedServiceDid}
··· 418 419 {isReadyForAuthedFeed || isReadyForUnauthedFeed ? ( 420 <InfiniteCustomFeed 421 + key={selectedFeed!} 422 feedUri={selectedFeed!} 423 pdsUrl={identity?.pds} 424 feedServiceDid={feedServiceDid}
+14 -7
src/routes/profile.$did/index.tsx
··· 10 UniversalPostRendererATURILoader, 11 } from "~/components/UniversalPostRenderer"; 12 import { useAuth } from "~/providers/UnifiedAuthProvider"; 13 - import { imgCDNAtom } from "~/utils/atoms"; 14 import { 15 toggleFollow, 16 useGetFollowState, 17 useGetOneToOneState, 18 } from "~/utils/followState"; 19 import { 20 - useInfiniteQueryAuthorFeed, 21 useQueryIdentity, 22 useQueryProfile, 23 } from "~/utils/useQuery"; ··· 29 function ProfileComponent() { 30 // booo bad this is not always the did it might be a handle, use identity.did instead 31 const { did } = Route.useParams(); 32 - const navigate = useNavigate(); 33 const queryClient = useQueryClient(); 34 const { 35 data: identity, ··· 39 40 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 41 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; 42 - const pdsUrl = identity?.pds; 43 44 const profileUri = resolvedDid 45 ? `at://${resolvedDid}/app.bsky.actor.profile/self` ··· 47 const { data: profileRecord } = useQueryProfile(profileUri); 48 const profile = profileRecord?.value; 49 50 const { 51 data: postsData, 52 fetchNextPage, 53 hasNextPage, 54 isFetchingNextPage, 55 isLoading: arePostsLoading, 56 - } = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl); 57 58 React.useEffect(() => { 59 if (postsData) { 60 postsData.pages.forEach((page) => { 61 - page.records.forEach((record) => { 62 if (!queryClient.getQueryData(["post", record.uri])) { 63 queryClient.setQueryData(["post", record.uri], record); 64 } ··· 68 }, [postsData, queryClient]); 69 70 const posts = React.useMemo( 71 - () => postsData?.pages.flatMap((page) => page.records) ?? [], 72 [postsData] 73 ); 74
··· 10 UniversalPostRendererATURILoader, 11 } from "~/components/UniversalPostRenderer"; 12 import { useAuth } from "~/providers/UnifiedAuthProvider"; 13 + import { aturiListServiceAtom, imgCDNAtom } from "~/utils/atoms"; 14 import { 15 toggleFollow, 16 useGetFollowState, 17 useGetOneToOneState, 18 } from "~/utils/followState"; 19 import { 20 + useInfiniteQueryAturiList, 21 useQueryIdentity, 22 useQueryProfile, 23 } from "~/utils/useQuery"; ··· 29 function ProfileComponent() { 30 // booo bad this is not always the did it might be a handle, use identity.did instead 31 const { did } = Route.useParams(); 32 + //const navigate = useNavigate(); 33 const queryClient = useQueryClient(); 34 const { 35 data: identity, ··· 39 40 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 41 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; 42 + //const pdsUrl = identity?.pds; 43 44 const profileUri = resolvedDid 45 ? `at://${resolvedDid}/app.bsky.actor.profile/self` ··· 47 const { data: profileRecord } = useQueryProfile(profileUri); 48 const profile = profileRecord?.value; 49 50 + const [aturilistservice] = useAtom(aturiListServiceAtom); 51 + 52 const { 53 data: postsData, 54 fetchNextPage, 55 hasNextPage, 56 isFetchingNextPage, 57 isLoading: arePostsLoading, 58 + } = useInfiniteQueryAturiList({ 59 + aturilistservice: aturilistservice, 60 + did: resolvedDid, 61 + collection: "app.bsky.feed.post", 62 + reverse: true 63 + }); 64 65 React.useEffect(() => { 66 if (postsData) { 67 postsData.pages.forEach((page) => { 68 + page.forEach((record) => { 69 if (!queryClient.getQueryData(["post", record.uri])) { 70 queryClient.setQueryData(["post", record.uri], record); 71 } ··· 75 }, [postsData, queryClient]); 76 77 const posts = React.useMemo( 78 + () => postsData?.pages.flatMap((page) => page) ?? [], 79 [postsData] 80 ); 81
+8
src/routes/settings.tsx
··· 5 import { Header } from "~/components/Header"; 6 import Login from "~/components/Login"; 7 import { 8 constellationURLAtom, 9 defaultconstellationURL, 10 defaulthue, 11 defaultImgCDN, ··· 51 title={"Slingshot"} 52 description={"Customize the Slingshot instance to be used by Red Dwarf"} 53 init={defaultslingshotURL} 54 /> 55 <TextInputSetting 56 atom={imgCDNAtom}
··· 5 import { Header } from "~/components/Header"; 6 import Login from "~/components/Login"; 7 import { 8 + aturiListServiceAtom, 9 constellationURLAtom, 10 + defaultaturilistservice, 11 defaultconstellationURL, 12 defaulthue, 13 defaultImgCDN, ··· 53 title={"Slingshot"} 54 description={"Customize the Slingshot instance to be used by Red Dwarf"} 55 init={defaultslingshotURL} 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 /> 63 <TextInputSetting 64 atom={imgCDNAtom}
+5
src/utils/atoms.ts
··· 32 "slingshotURL", 33 defaultslingshotURL 34 ); 35 export const defaultImgCDN = "cdn.bsky.app"; 36 export const imgCDNAtom = atomWithStorage<string>("imgcdnurl", defaultImgCDN); 37 export const defaultVideoCDN = "video.bsky.app";
··· 32 "slingshotURL", 33 defaultslingshotURL 34 ); 35 + export const defaultaturilistservice = "aturilistservice.reddwarf.app"; 36 + export const aturiListServiceAtom = atomWithStorage<string>( 37 + "aturilistservice", 38 + defaultaturilistservice 39 + ); 40 export const defaultImgCDN = "cdn.bsky.app"; 41 export const imgCDNAtom = atomWithStorage<string>("imgcdnurl", defaultImgCDN); 42 export const defaultVideoCDN = "video.bsky.app";
+82 -2
src/utils/useQuery.ts
··· 565 }); 566 } 567 568 type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 569 570 export function constructInfiniteFeedSkeletonQuery(options: { ··· 615 }) { 616 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 617 618 - return useInfiniteQuery({ 619 queryKey, 620 queryFn, 621 initialPageParam: undefined as never, ··· 623 staleTime: Infinity, 624 refetchOnWindowFocus: false, 625 enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true), 626 - }); 627 } 628 629
··· 565 }); 566 } 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 + 648 type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 649 650 export function constructInfiniteFeedSkeletonQuery(options: { ··· 695 }) { 696 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 697 698 + return {...useInfiniteQuery({ 699 queryKey, 700 queryFn, 701 initialPageParam: undefined as never, ··· 703 staleTime: Infinity, 704 refetchOnWindowFocus: false, 705 enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true), 706 + }), queryKey: queryKey}; 707 } 708 709
+1 -1
vite.config.ts
··· 10 11 import { generateMetadataPlugin } from "./oauthdev.mts"; 12 13 - const PROD_URL = "https://reddwarf.whey.party" 14 const DEV_URL = "https://local3768forumtest.whey.party" 15 16 function shp(url: string): string {
··· 10 11 import { generateMetadataPlugin } from "./oauthdev.mts"; 12 13 + const PROD_URL = "https://reddwarf.app" 14 const DEV_URL = "https://local3768forumtest.whey.party" 15 16 function shp(url: string): string {