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 8 ## running dev and build 9 9 in the `vite.config.ts` file you should change these values 10 10 ```ts 11 - const PROD_URL = "https://reddwarf.whey.party" 11 + const PROD_URL = "https://reddwarf.app" 12 12 const DEV_URL = "https://local3768forumtest.whey.party" 13 13 ``` 14 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
package-lock.json
··· 10 10 "@atproto/oauth-client-browser": "^0.3.33", 11 11 "@radix-ui/react-dialog": "^1.1.15", 12 12 "@radix-ui/react-dropdown-menu": "^2.1.16", 13 + "@radix-ui/react-hover-card": "^1.1.15", 13 14 "@radix-ui/react-slider": "^1.3.6", 14 15 "@tailwindcss/vite": "^4.0.6", 15 16 "@tanstack/query-sync-storage-persister": "^5.85.6", ··· 2402 2403 "version": "1.1.15", 2403 2404 "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", 2404 2405 "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", 2406 + "license": "MIT", 2405 2407 "dependencies": { 2406 2408 "@radix-ui/primitive": "1.1.3", 2407 2409 "@radix-ui/react-compose-refs": "1.1.2",
+1
package.json
··· 14 14 "@atproto/oauth-client-browser": "^0.3.33", 15 15 "@radix-ui/react-dialog": "^1.1.15", 16 16 "@radix-ui/react-dropdown-menu": "^2.1.16", 17 + "@radix-ui/react-hover-card": "^1.1.15", 17 18 "@radix-ui/react-slider": "^1.3.6", 18 19 "@tailwindcss/vite": "^4.0.6", 19 20 "@tanstack/query-sync-storage-persister": "^5.85.6",
+16 -1
src/components/Composer.tsx
··· 1 - import { RichText } from "@atproto/api"; 1 + import { AppBskyRichtextFacet, RichText } from "@atproto/api"; 2 2 import { useAtom } from "jotai"; 3 3 import { Dialog } from "radix-ui"; 4 4 import { useEffect, useRef, useState } from "react"; ··· 47 47 try { 48 48 const rt = new RichText({ text: postText }); 49 49 await rt.detectFacets(agent); 50 + 51 + if (rt.facets?.length) { 52 + rt.facets = rt.facets.filter((item) => { 53 + if (item.$type !== "app.bsky.richtext.facet") return true; 54 + if (!item.features?.length) return true; 55 + 56 + item.features = item.features.filter((feature) => { 57 + if (feature.$type !== "app.bsky.richtext.facet#mention") return true; 58 + const did = feature.$type === "app.bsky.richtext.facet#mention" ? (feature as AppBskyRichtextFacet.Mention)?.did : undefined; 59 + return typeof did === "string" && did.startsWith("did:"); 60 + }); 61 + 62 + return item.features.length > 0; 63 + }); 64 + } 50 65 51 66 const record: Record<string, unknown> = { 52 67 $type: "app.bsky.feed.post",
+150
src/components/Import.tsx
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { useNavigate, type UseNavigateResult } from "@tanstack/react-router"; 3 + import { useState } from "react"; 4 + 5 + /** 6 + * Basically the best equivalent to Search that i can do 7 + */ 8 + export function Import() { 9 + const [textInput, setTextInput] = useState<string | undefined>(); 10 + const navigate = useNavigate(); 11 + 12 + const handleEnter = () => { 13 + if (!textInput) return; 14 + handleImport({ 15 + text: textInput, 16 + navigate, 17 + }); 18 + }; 19 + 20 + return ( 21 + <div className="w-full relative"> 22 + <IconMaterialSymbolsSearch className="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" /> 23 + 24 + <input 25 + type="text" 26 + placeholder="Import..." 27 + value={textInput} 28 + onChange={(e) => setTextInput(e.target.value)} 29 + onKeyDown={(e) => { 30 + if (e.key === "Enter") handleEnter(); 31 + }} 32 + className="w-full h-12 pl-12 pr-4 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-500 box-border transition" 33 + /> 34 + </div> 35 + ); 36 + } 37 + 38 + function handleImport({ 39 + text, 40 + navigate, 41 + }: { 42 + text: string; 43 + navigate: UseNavigateResult<string>; 44 + }) { 45 + const trimmed = text.trim(); 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 53 + */ 54 + 55 + // 1. Check if itโ€™s a URL 56 + try { 57 + const url = new URL(text); 58 + const knownHosts = [ 59 + "bsky.app", 60 + "social.daniela.lol", 61 + "deer.social", 62 + "reddwarf.whey.party", 63 + "reddwarf.app", 64 + "main.bsky.dev", 65 + "catsky.social", 66 + "blacksky.community", 67 + "red-dwarf-social-app.whey.party", 68 + "zeppelin.social", 69 + ]; 70 + if (knownHosts.includes(url.hostname)) { 71 + // parse path to get URI or handle 72 + const path = decodeURIComponent(url.pathname.slice(1)); // remove leading / 73 + console.log("BSky URL path:", path); 74 + navigate({ 75 + to: `/${path}`, 76 + }); 77 + return; 78 + } 79 + } catch { 80 + // not a URL, continue 81 + } 82 + 83 + // 2. Check if text looks like an at-uri 84 + try { 85 + if (text.startsWith("at://")) { 86 + console.log("AT URI detected:", text); 87 + const aturi = new AtUri(text); 88 + switch (aturi.collection) { 89 + case "app.bsky.feed.post": { 90 + navigate({ 91 + to: "/profile/$did/post/$rkey", 92 + params: { 93 + did: aturi.host, 94 + rkey: aturi.rkey, 95 + }, 96 + }); 97 + return; 98 + } 99 + case "app.bsky.actor.profile": { 100 + navigate({ 101 + to: "/profile/$did", 102 + params: { 103 + did: aturi.host, 104 + }, 105 + }); 106 + return; 107 + } 108 + // todo add more handlers as more routes are added. like feeds, lists, etc etc thanks! 109 + default: { 110 + // continue 111 + } 112 + } 113 + } 114 + } catch { 115 + // continue 116 + } 117 + 118 + // 3. Plain handle (starts with @) 119 + try { 120 + if (text.startsWith("@")) { 121 + const handle = text.slice(1); 122 + console.log("Handle detected:", handle); 123 + navigate({ to: "/profile/$did", params: { did: handle } }); 124 + return; 125 + } 126 + } catch { 127 + // continue 128 + } 129 + 130 + // 4. Plain DID (starts with did:) 131 + try { 132 + if (text.startsWith("did:")) { 133 + console.log("did detected:", text); 134 + navigate({ to: "/profile/$did", params: { did: text } }); 135 + return; 136 + } 137 + } catch { 138 + // continue 139 + } 140 + 141 + // if all else fails 142 + 143 + // try { 144 + // // probably a user? 145 + // navigate({ to: "/profile/$did", params: { did: text } }); 146 + // return; 147 + // } catch { 148 + // // continue 149 + // } 150 + }
+32 -6
src/components/InfiniteCustomFeed.tsx
··· 1 + import { useQueryClient } from "@tanstack/react-query"; 1 2 import * as React from "react"; 2 3 3 4 //import { useInView } from "react-intersection-observer"; ··· 37 38 isFetchingNextPage, 38 39 refetch, 39 40 isRefetching, 41 + queryKey, 40 42 } = useInfiniteQueryFeedSkeleton({ 41 43 feedUri: feedUri, 42 44 agent: agent ?? undefined, ··· 44 46 pdsUrl: pdsUrl, 45 47 feedServiceDid: feedServiceDid, 46 48 }); 49 + const queryClient = useQueryClient(); 50 + 47 51 48 52 const handleRefresh = () => { 53 + queryClient.removeQueries({queryKey: queryKey}); 54 + //queryClient.invalidateQueries(["infinite-feed", feedUri] as const); 49 55 refetch(); 50 56 }; 51 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 + 52 76 //const { ref, inView } = useInView(); 53 77 54 78 // React.useEffect(() => { ··· 67 91 ); 68 92 } 69 93 70 - const allPosts = 71 - data?.pages.flatMap((page) => { 72 - if (page) return page.feed; 73 - }) ?? []; 94 + // const allPosts = 95 + // data?.pages.flatMap((page) => { 96 + // if (page) return page.feed; 97 + // }) ?? []; 74 98 75 99 if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) { 76 100 return ( ··· 116 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" 117 141 aria-label="Refresh feed" 118 142 > 119 - <RefreshIcon className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} /> 143 + <RefreshIcon 144 + className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} 145 + /> 120 146 </button> 121 147 </> 122 148 ); ··· 139 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" 140 166 ></path> 141 167 </svg> 142 - ); 168 + );
+3 -3
src/components/Login.tsx
··· 24 24 className={ 25 25 compact 26 26 ? "flex items-center justify-center p-1" 27 - : "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4 flex justify-center items-center h-[280px]" 27 + : "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-4 mx-4 flex justify-center items-center h-[280px]" 28 28 } 29 29 > 30 30 <span ··· 43 43 // Large view 44 44 if (!compact) { 45 45 return ( 46 - <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4"> 46 + <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4"> 47 47 <div className="flex flex-col items-center justify-center text-center"> 48 48 <p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100"> 49 49 You are logged in! ··· 77 77 if (!compact) { 78 78 // Large view renders the form directly in the card 79 79 return ( 80 - <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4"> 80 + <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4"> 81 81 <UnifiedLoginForm /> 82 82 </div> 83 83 );
+134 -58
src/components/UniversalPostRenderer.tsx
··· 2 2 import DOMPurify from "dompurify"; 3 3 import { useAtom } from "jotai"; 4 4 import { DropdownMenu } from "radix-ui"; 5 + import { HoverCard } from "radix-ui"; 5 6 import * as React from "react"; 6 7 import { type SVGProps } from "react"; 7 8 8 - import { composerAtom, constellationURLAtom, imgCDNAtom, likedPostsAtom } from "~/utils/atoms"; 9 + import { 10 + composerAtom, 11 + constellationURLAtom, 12 + imgCDNAtom, 13 + likedPostsAtom, 14 + } from "~/utils/atoms"; 9 15 import { useHydratedEmbed } from "~/utils/useHydrated"; 10 16 import { 11 17 useQueryConstellation, ··· 403 409 // path: ".reply.parent.uri", 404 410 // }); 405 411 406 - const [constellationurl] = useAtom(constellationURLAtom) 412 + const [constellationurl] = useAtom(constellationURLAtom); 407 413 408 414 const infinitequeryresults = useInfiniteQuery({ 409 415 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( ··· 512 518 ? true 513 519 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 514 520 ? false 515 - : bottomReplyLine 521 + : (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine 516 522 } 517 523 topReplyLine={topReplyLine} 518 524 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} ··· 534 540 maxReplies={maxReplies} 535 541 isQuote={isQuote} 536 542 /> 543 + <> 544 + {(maxReplies && maxReplies === 0 && replies && replies > 0) ? ( 545 + <> 546 + {/* <div>hello</div> */} 547 + <MoreReplies atUri={atUri} /> 548 + </> 549 + ) : (<></>)} 550 + </> 537 551 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 538 552 <> 539 553 {/* <span>hello {maxReplies}</span> */} ··· 558 572 maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined 559 573 } 560 574 /> 561 - {maxReplies && maxReplies - 1 === 0 && replies && replies > 0 && ( 562 - <MoreReplies atUri={oldestOpsReplyElseNewestNonOpsReply} /> 563 - )} 564 575 </> 565 576 )} 566 577 </> ··· 725 736 error: embedError, 726 737 } = useHydratedEmbed(postRecord?.value?.embed, resolved?.did); 727 738 728 - const [imgcdn] = useAtom(imgCDNAtom) 739 + const [imgcdn] = useAtom(imgCDNAtom); 729 740 730 741 const parsedaturi = new AtUri(aturi); //parseAtUri(aturi); 742 + 743 + const fakeprofileviewbasic = React.useMemo<AppBskyActorDefs.ProfileViewBasic>( 744 + () => ({ 745 + did: resolved?.did || "", 746 + handle: resolved?.handle || "", 747 + displayName: profileRecord?.value?.displayName || "", 748 + avatar: getAvatarUrl(profileRecord, resolved?.did, imgcdn) || "", 749 + viewer: undefined, 750 + labels: profileRecord?.labels || undefined, 751 + verification: undefined, 752 + }), 753 + [imgcdn, profileRecord, resolved?.did, resolved?.handle] 754 + ); 755 + 756 + const fakeprofileviewdetailed = 757 + React.useMemo<AppBskyActorDefs.ProfileViewDetailed>( 758 + () => ({ 759 + ...fakeprofileviewbasic, 760 + $type: "app.bsky.actor.defs#profileViewDetailed", 761 + description: profileRecord?.value?.description || undefined, 762 + }), 763 + [fakeprofileviewbasic, profileRecord?.value?.description] 764 + ); 731 765 732 766 const fakepost = React.useMemo<AppBskyFeedDefs.PostView>( 733 767 () => ({ 734 768 $type: "app.bsky.feed.defs#postView", 735 769 uri: aturi, 736 770 cid: postRecord?.cid || "", 737 - author: { 738 - did: resolved?.did || "", 739 - handle: resolved?.handle || "", 740 - displayName: profileRecord?.value?.displayName || "", 741 - avatar: getAvatarUrl(profileRecord, resolved?.did, imgcdn) || "", 742 - viewer: undefined, 743 - labels: profileRecord?.labels || undefined, 744 - verification: undefined, 745 - }, 771 + author: fakeprofileviewbasic, 746 772 record: postRecord?.value || {}, 747 773 embed: hydratedEmbed ?? undefined, 748 774 replyCount: repliesCount ?? 0, ··· 759 785 postRecord?.cid, 760 786 postRecord?.value, 761 787 postRecord?.labels, 762 - resolved?.did, 763 - resolved?.handle, 764 - profileRecord, 788 + fakeprofileviewbasic, 765 789 hydratedEmbed, 766 790 repliesCount, 767 791 repostsCount, 768 792 likesCount, 769 - imgcdn 770 793 ] 771 794 ); 772 795 ··· 839 862 } 840 863 }} 841 864 post={fakepost} 865 + uprrrsauthor={fakeprofileviewdetailed} 842 866 salt={aturi} 843 867 bottomReplyLine={bottomReplyLine} 844 868 topReplyLine={topReplyLine} ··· 1143 1167 //import Masonry from "@mui/lab/Masonry"; 1144 1168 import { 1145 1169 type $Typed, 1170 + AppBskyActorDefs, 1146 1171 AppBskyEmbedDefs, 1147 1172 AppBskyEmbedExternal, 1148 1173 AppBskyEmbedImages, ··· 1172 1197 1173 1198 import defaultpfp from "~/../public/favicon.png"; 1174 1199 import { useAuth } from "~/providers/UnifiedAuthProvider"; 1200 + import { FollowButton, Mutual } from "~/routes/profile.$did"; 1175 1201 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 1176 1202 // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; 1177 1203 // import type { ··· 1280 1306 1281 1307 function UniversalPostRenderer({ 1282 1308 post, 1309 + uprrrsauthor, 1283 1310 //setMainItem, 1284 1311 //isMainItem, 1285 1312 onPostClick, ··· 1304 1331 maxReplies, 1305 1332 }: { 1306 1333 post: PostView; 1334 + uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed; 1307 1335 // optional for now because i havent ported every use to this yet 1308 1336 // setMainItem?: React.Dispatch< 1309 1337 // React.SetStateAction<AppBskyFeedDefs.FeedViewPost> ··· 1487 1515 className="bg-gray-500 dark:bg-gray-400" 1488 1516 /> 1489 1517 )} 1490 - <div 1491 - style={{ 1492 - position: "absolute", 1493 - //top: isRepost ? "calc(16px + 1rem)" : 16, 1494 - //left: 16, 1495 - zIndex: 1, 1496 - top: isRepost 1497 - ? "calc(16px + 1rem)" 1498 - : isQuote 1499 - ? 12 1500 - : topReplyLine 1501 - ? 8 1502 - : 16, 1503 - left: isQuote ? 12 : 16, 1504 - }} 1505 - onClick={onProfileClick} 1506 - > 1507 - <img 1508 - src={post.author.avatar || defaultpfp} 1509 - alt="avatar" 1510 - // transition={{ 1511 - // type: "spring", 1512 - // stiffness: 260, 1513 - // damping: 20, 1514 - // }} 1515 - style={{ 1516 - borderRadius: "50%", 1517 - marginRight: 12, 1518 - objectFit: "cover", 1519 - //background: theme.border, 1520 - //border: `1px solid ${theme.border}`, 1521 - width: isQuote ? 16 : 42, 1522 - height: isQuote ? 16 : 42, 1523 - }} 1524 - className="border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600" 1525 - /> 1526 - </div> 1518 + <HoverCard.Root> 1519 + <HoverCard.Trigger asChild> 1520 + <div 1521 + className={`absolute`} 1522 + style={{ 1523 + top: isRepost 1524 + ? "calc(16px + 1rem)" 1525 + : isQuote 1526 + ? 12 1527 + : topReplyLine 1528 + ? 8 1529 + : 16, 1530 + left: isQuote ? 12 : 16, 1531 + }} 1532 + onClick={onProfileClick} 1533 + > 1534 + <img 1535 + src={post.author.avatar || defaultpfp} 1536 + alt="avatar" 1537 + className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`} 1538 + style={{ 1539 + width: isQuote ? 16 : 42, 1540 + height: isQuote ? 16 : 42, 1541 + }} 1542 + /> 1543 + </div> 1544 + </HoverCard.Trigger> 1545 + <HoverCard.Portal> 1546 + <HoverCard.Content 1547 + className="rounded-md p-4 w-72 bg-gray-50 dark:bg-gray-900 shadow-lg border border-gray-300 dark:border-gray-800 animate-slide-fade z-50" 1548 + side={"bottom"} 1549 + sideOffset={5} 1550 + onClick={onProfileClick} 1551 + > 1552 + <div className="flex flex-col gap-2"> 1553 + <div className="flex flex-row"> 1554 + <img 1555 + src={post.author.avatar || defaultpfp} 1556 + alt="avatar" 1557 + className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600" 1558 + /> 1559 + <div className=" flex-1 flex flex-row align-middle justify-end"> 1560 + <FollowButton targetdidorhandle={post.author.did} /> 1561 + </div> 1562 + </div> 1563 + <div className="flex flex-col gap-3"> 1564 + <div> 1565 + <div className="text-gray-900 dark:text-gray-100 font-medium text-md"> 1566 + {post.author.displayName || post.author.handle}{" "} 1567 + </div> 1568 + <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1"> 1569 + <Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "} 1570 + </div> 1571 + </div> 1572 + {uprrrsauthor?.description && ( 1573 + <div className="text-gray-700 dark:text-gray-300 text-sm text-left break-words line-clamp-3"> 1574 + {uprrrsauthor.description} 1575 + </div> 1576 + )} 1577 + {/* <div className="flex gap-4"> 1578 + <div className="flex gap-1"> 1579 + <div className="font-medium text-gray-900 dark:text-gray-100"> 1580 + 0 1581 + </div> 1582 + <div className="text-gray-500 dark:text-gray-400"> 1583 + Following 1584 + </div> 1585 + </div> 1586 + <div className="flex gap-1"> 1587 + <div className="font-medium text-gray-900 dark:text-gray-100"> 1588 + 2,900 1589 + </div> 1590 + <div className="text-gray-500 dark:text-gray-400"> 1591 + Followers 1592 + </div> 1593 + </div> 1594 + </div> */} 1595 + </div> 1596 + </div> 1597 + 1598 + {/* <HoverCard.Arrow className="fill-gray-50 dark:fill-gray-900" /> */} 1599 + </HoverCard.Content> 1600 + </HoverCard.Portal> 1601 + </HoverCard.Root> 1602 + 1527 1603 <div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}> 1528 1604 <div 1529 1605 style={{ ··· 2579 2655 return { start, end, feature: f.features[0] }; 2580 2656 }); 2581 2657 } 2582 - function renderTextWithFacets({ 2658 + export function renderTextWithFacets({ 2583 2659 text, 2584 2660 facets, 2585 2661 navigate,
+28 -26
src/routes/__root.tsx
··· 18 18 19 19 import { Composer } from "~/components/Composer"; 20 20 import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; 21 + import { Import } from "~/components/Import"; 21 22 import Login from "~/components/Login"; 22 23 import { NotFound } from "~/components/NotFound"; 23 24 import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; ··· 154 155 /> 155 156 156 157 <MaterialNavItem 158 + InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 159 + ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 160 + active={locationEnum === "search"} 161 + onClickCallbback={() => 162 + navigate({ 163 + to: "/search", 164 + //params: { did: agent.assertDid }, 165 + }) 166 + } 167 + text="Explore" 168 + /> 169 + <MaterialNavItem 157 170 InactiveIcon={ 158 171 <IconMaterialSymbolsNotificationsOutline className="w-6 h-6" /> 159 172 } ··· 180 193 }) 181 194 } 182 195 text="Feeds" 183 - /> 184 - <MaterialNavItem 185 - InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 186 - ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 187 - active={locationEnum === "search"} 188 - onClickCallbback={() => 189 - navigate({ 190 - to: "/search", 191 - //params: { did: agent.assertDid }, 192 - }) 193 - } 194 - text="Search" 195 196 /> 196 197 <MaterialNavItem 197 198 InactiveIcon={ ··· 389 390 390 391 <MaterialNavItem 391 392 small 393 + InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 394 + ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 395 + active={locationEnum === "search"} 396 + onClickCallbback={() => 397 + navigate({ 398 + to: "/search", 399 + //params: { did: agent.assertDid }, 400 + }) 401 + } 402 + text="Explore" 403 + /> 404 + <MaterialNavItem 405 + small 392 406 InactiveIcon={ 393 407 <IconMaterialSymbolsNotificationsOutline className="w-6 h-6" /> 394 408 } ··· 419 433 /> 420 434 <MaterialNavItem 421 435 small 422 - InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 423 - ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 424 - active={locationEnum === "search"} 425 - onClickCallbback={() => 426 - navigate({ 427 - to: "/search", 428 - //params: { did: agent.assertDid }, 429 - }) 430 - } 431 - text="Search" 432 - /> 433 - <MaterialNavItem 434 - small 435 436 InactiveIcon={ 436 437 <IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" /> 437 438 } ··· 498 499 </main> 499 500 500 501 <aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col"> 502 + <div className="px-4 pt-4"><Import /></div> 501 503 <Login /> 502 504 503 505 <div className="flex-1"></div> ··· 551 553 //params: { did: agent.assertDid }, 552 554 }) 553 555 } 554 - text="Search" 556 + text="Explore" 555 557 /> 556 558 {/* <Link 557 559 to="/search"
+1
src/routes/index.tsx
··· 418 418 419 419 {isReadyForAuthedFeed || isReadyForUnauthedFeed ? ( 420 420 <InfiniteCustomFeed 421 + key={selectedFeed!} 421 422 feedUri={selectedFeed!} 422 423 pdsUrl={identity?.pds} 423 424 feedServiceDid={feedServiceDid}
+191 -57
src/routes/profile.$did/index.tsx
··· 1 + import { RichText } from "@atproto/api"; 1 2 import { useQueryClient } from "@tanstack/react-query"; 2 - import { createFileRoute } from "@tanstack/react-router"; 3 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 3 4 import { useAtom } from "jotai"; 4 - import React from "react"; 5 + import React, { type ReactNode, useEffect, useState } from "react"; 5 6 6 7 import { Header } from "~/components/Header"; 7 - import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 8 + import { 9 + renderTextWithFacets, 10 + UniversalPostRendererATURILoader, 11 + } from "~/components/UniversalPostRenderer"; 8 12 import { useAuth } from "~/providers/UnifiedAuthProvider"; 9 - import { imgCDNAtom } from "~/utils/atoms"; 10 - import { toggleFollow, useGetFollowState } from "~/utils/followState"; 13 + import { aturiListServiceAtom, imgCDNAtom } from "~/utils/atoms"; 14 + import { 15 + toggleFollow, 16 + useGetFollowState, 17 + useGetOneToOneState, 18 + } from "~/utils/followState"; 11 19 import { 12 - useInfiniteQueryAuthorFeed, 20 + useInfiniteQueryAturiList, 13 21 useQueryIdentity, 14 22 useQueryProfile, 15 23 } from "~/utils/useQuery"; ··· 21 29 function ProfileComponent() { 22 30 // booo bad this is not always the did it might be a handle, use identity.did instead 23 31 const { did } = Route.useParams(); 32 + //const navigate = useNavigate(); 24 33 const queryClient = useQueryClient(); 25 - const { agent } = useAuth(); 26 34 const { 27 35 data: identity, 28 36 isLoading: isIdentityLoading, 29 37 error: identityError, 30 38 } = useQueryIdentity(did); 31 39 32 - const followRecords = useGetFollowState({ 33 - target: identity?.did || did, 34 - user: agent?.did, 35 - }); 36 - 37 40 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 38 41 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; 39 - const pdsUrl = identity?.pds; 42 + //const pdsUrl = identity?.pds; 40 43 41 44 const profileUri = resolvedDid 42 45 ? `at://${resolvedDid}/app.bsky.actor.profile/self` ··· 44 47 const { data: profileRecord } = useQueryProfile(profileUri); 45 48 const profile = profileRecord?.value; 46 49 50 + const [aturilistservice] = useAtom(aturiListServiceAtom); 51 + 47 52 const { 48 53 data: postsData, 49 54 fetchNextPage, 50 55 hasNextPage, 51 56 isFetchingNextPage, 52 57 isLoading: arePostsLoading, 53 - } = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl); 58 + } = useInfiniteQueryAturiList({ 59 + aturilistservice: aturilistservice, 60 + did: resolvedDid, 61 + collection: "app.bsky.feed.post", 62 + reverse: true 63 + }); 54 64 55 65 React.useEffect(() => { 56 66 if (postsData) { 57 67 postsData.pages.forEach((page) => { 58 - page.records.forEach((record) => { 68 + page.forEach((record) => { 59 69 if (!queryClient.getQueryData(["post", record.uri])) { 60 70 queryClient.setQueryData(["post", record.uri], record); 61 71 } ··· 65 75 }, [postsData, queryClient]); 66 76 67 77 const posts = React.useMemo( 68 - () => postsData?.pages.flatMap((page) => page.records) ?? [], 78 + () => postsData?.pages.flatMap((page) => page) ?? [], 69 79 [postsData] 70 80 ); 71 - 72 - const [imgcdn] = useAtom(imgCDNAtom) 81 + 82 + const [imgcdn] = useAtom(imgCDNAtom); 73 83 74 84 function getAvatarUrl(p: typeof profile) { 75 85 const link = p?.avatar?.ref?.["$link"]; ··· 166 176 also delay the backfill to be on demand because it would be pretty intense 167 177 also save it persistently 168 178 */} 169 - {identity?.did !== agent?.did ? ( 170 - <> 171 - {!(followRecords?.length && followRecords?.length > 0) ? ( 172 - <button 173 - onClick={() => 174 - toggleFollow({ 175 - agent: agent || undefined, 176 - targetDid: identity?.did, 177 - followRecords: followRecords, 178 - queryClient: queryClient, 179 - }) 180 - } 181 - className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]" 182 - > 183 - Follow 184 - </button> 185 - ) : ( 186 - <button 187 - onClick={() => 188 - toggleFollow({ 189 - agent: agent || undefined, 190 - targetDid: identity?.did, 191 - followRecords: followRecords, 192 - queryClient: queryClient, 193 - }) 194 - } 195 - className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]" 196 - > 197 - Unfollow 198 - </button> 199 - )} 200 - </> 201 - ) : ( 202 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 203 - Edit Profile 204 - </button> 205 - )} 179 + <FollowButton targetdidorhandle={did} /> 206 180 <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 207 181 ... {/* todo: icon */} 208 182 </button> ··· 211 185 {/* Info Card */} 212 186 <div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100"> 213 187 <div className="font-bold text-2xl">{displayName}</div> 214 - <div className="text-gray-500 dark:text-gray-400 text-base mb-3"> 188 + <div className="text-gray-500 dark:text-gray-400 text-base mb-3 flex flex-row gap-1"> 189 + <Mutual targetdidorhandle={did} /> 215 190 {handle} 216 191 </div> 217 192 {description && ( 218 193 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> 219 - {description} 194 + {/* {description} */} 195 + <RichTextRenderer key={did} description={description} /> 220 196 </div> 221 197 )} 222 198 </div> ··· 259 235 </> 260 236 ); 261 237 } 238 + 239 + export function FollowButton({ 240 + targetdidorhandle, 241 + }: { 242 + targetdidorhandle: string; 243 + }) { 244 + const { agent } = useAuth(); 245 + const { data: identity } = useQueryIdentity(targetdidorhandle); 246 + const queryClient = useQueryClient(); 247 + 248 + const followRecords = useGetFollowState({ 249 + target: identity?.did ?? targetdidorhandle, 250 + user: agent?.did, 251 + }); 252 + 253 + return ( 254 + <> 255 + {identity?.did !== agent?.did ? ( 256 + <> 257 + {!(followRecords?.length && followRecords?.length > 0) ? ( 258 + <button 259 + onClick={(e) => { 260 + e.stopPropagation(); 261 + toggleFollow({ 262 + agent: agent || undefined, 263 + targetDid: identity?.did, 264 + followRecords: followRecords, 265 + queryClient: queryClient, 266 + }); 267 + }} 268 + className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 269 + > 270 + Follow 271 + </button> 272 + ) : ( 273 + <button 274 + onClick={(e) => { 275 + e.stopPropagation(); 276 + toggleFollow({ 277 + agent: agent || undefined, 278 + targetDid: identity?.did, 279 + followRecords: followRecords, 280 + queryClient: queryClient, 281 + }); 282 + }} 283 + className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 284 + > 285 + Unfollow 286 + </button> 287 + )} 288 + </> 289 + ) : ( 290 + <button className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"> 291 + Edit Profile 292 + </button> 293 + )} 294 + </> 295 + ); 296 + } 297 + 298 + export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) { 299 + const { agent } = useAuth(); 300 + const { data: identity } = useQueryIdentity(targetdidorhandle); 301 + 302 + const theyFollowYouRes = useGetOneToOneState( 303 + agent?.did 304 + ? { 305 + target: agent?.did, 306 + user: identity?.did ?? targetdidorhandle, 307 + collection: "app.bsky.graph.follow", 308 + path: ".subject", 309 + } 310 + : undefined 311 + ); 312 + 313 + const youFollowThemRes = useGetFollowState({ 314 + target: identity?.did ?? targetdidorhandle, 315 + user: agent?.did, 316 + }); 317 + 318 + const theyFollowYou: boolean = 319 + !!theyFollowYouRes?.length && theyFollowYouRes.length > 0; 320 + const youFollowThem: boolean = 321 + !!youFollowThemRes?.length && youFollowThemRes.length > 0; 322 + 323 + return ( 324 + <> 325 + {/* if not self */} 326 + {identity?.did !== agent?.did ? ( 327 + <> 328 + {theyFollowYou ? ( 329 + <> 330 + {youFollowThem ? ( 331 + <div className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center"> 332 + mutuals 333 + </div> 334 + ) : ( 335 + <div className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center"> 336 + follows you 337 + </div> 338 + )} 339 + </> 340 + ) : ( 341 + <></> 342 + )} 343 + </> 344 + ) : ( 345 + // lmao can someone be mutuals with themselves ?? 346 + <></> 347 + )} 348 + </> 349 + ); 350 + } 351 + 352 + export function RichTextRenderer({ description }: { description: string }) { 353 + const [richDescription, setRichDescription] = useState<string | ReactNode[]>( 354 + description 355 + ); 356 + const { agent } = useAuth(); 357 + const navigate = useNavigate(); 358 + 359 + useEffect(() => { 360 + let mounted = true; 361 + 362 + // setRichDescription(description); 363 + 364 + async function processRichText() { 365 + try { 366 + if (!agent?.did) return; 367 + const rt = new RichText({ text: description }); 368 + await rt.detectFacets(agent); 369 + 370 + if (!mounted) return; 371 + 372 + if (rt.facets) { 373 + setRichDescription( 374 + renderTextWithFacets({ text: rt.text, facets: rt.facets, navigate }) 375 + ); 376 + } else { 377 + setRichDescription(rt.text); 378 + } 379 + } catch (error) { 380 + console.error("Failed to detect facets:", error); 381 + if (mounted) { 382 + setRichDescription(description); 383 + } 384 + } 385 + } 386 + 387 + processRichText(); 388 + 389 + return () => { 390 + mounted = false; 391 + }; 392 + }, [description, agent, navigate]); 393 + 394 + return <>{richDescription}</>; 395 + }
+50 -1
src/routes/search.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 3 + import { Header } from "~/components/Header"; 4 + import { Import } from "~/components/Import"; 5 + 3 6 export const Route = createFileRoute("/search")({ 4 7 component: Search, 5 8 }); 6 9 7 10 export function Search() { 8 - return <div className="p-6">Search page (coming soon)</div>; 11 + return ( 12 + <> 13 + <Header 14 + title="Explore" 15 + backButtonCallback={() => { 16 + if (window.history.length > 1) { 17 + window.history.back(); 18 + } else { 19 + window.location.assign("/"); 20 + } 21 + }} 22 + /> 23 + <div className=" flex flex-col items-center mt-4 mx-4 gap-4"> 24 + <Import /> 25 + <div className="flex flex-col"> 26 + <p className="text-gray-600 dark:text-gray-400"> 27 + Sorry we dont have search. But instead, you can load some of these 28 + types of content into Red Dwarf: 29 + </p> 30 + <ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400"> 31 + <li> 32 + Bluesky URLs from supported clients (like{" "} 33 + <code className="text-sm">bsky.app</code> or{" "} 34 + <code className="text-sm">deer.social</code>). 35 + </li> 36 + <li> 37 + AT-URIs (e.g.,{" "} 38 + <code className="text-sm">at://did:example/collection/item</code> 39 + ). 40 + </li> 41 + <li> 42 + Plain handles (like{" "} 43 + <code className="text-sm">@username.bsky.social</code>). 44 + </li> 45 + <li> 46 + Direct DIDs (Decentralized Identifiers, starting with{" "} 47 + <code className="text-sm">did:</code>). 48 + </li> 49 + </ul> 50 + <p className="mt-2 text-gray-600 dark:text-gray-400"> 51 + Simply paste one of these into the import field above and press 52 + Enter to load the content. 53 + </p> 54 + </div> 55 + </div> 56 + </> 57 + ); 9 58 }
+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, ··· 51 53 title={"Slingshot"} 52 54 description={"Customize the Slingshot instance to be used by Red Dwarf"} 53 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} 54 62 /> 55 63 <TextInputSetting 56 64 atom={imgCDNAtom}
+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";
+33
src/utils/followState.ts
··· 128 128 }; 129 129 }); 130 130 } 131 + 132 + 133 + 134 + export function useGetOneToOneState(params?: { 135 + target: string; 136 + user: string; 137 + collection: string; 138 + path: string; 139 + }): string[] | undefined { 140 + const { data: arbitrarydata } = useQueryConstellation( 141 + params && params.user 142 + ? { 143 + method: "/links", 144 + target: params.target, 145 + // @ts-expect-error overloading sucks so much 146 + collection: params.collection, 147 + path: params.path, 148 + dids: [params.user], 149 + } 150 + : { method: "undefined", target: "whatever" } 151 + // overloading sucks so much 152 + ) as { data: linksRecordsResponse | undefined }; 153 + if (!params || !params.user) return undefined; 154 + const data = arbitrarydata?.linking_records.slice(0, 50) ?? []; 155 + 156 + if (data.length > 0) { 157 + return data.map((linksRecord) => { 158 + return `at://${linksRecord.did}/${linksRecord.collection}/${linksRecord.rkey}`; 159 + }); 160 + } 161 + 162 + return undefined; 163 + }
+82 -2
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: { ··· 615 695 }) { 616 696 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 617 697 618 - return useInfiniteQuery({ 698 + return {...useInfiniteQuery({ 619 699 queryKey, 620 700 queryFn, 621 701 initialPageParam: undefined as never, ··· 623 703 staleTime: Infinity, 624 704 refetchOnWindowFocus: false, 625 705 enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true), 626 - }); 706 + }), queryKey: queryKey}; 627 707 } 628 708 629 709
+1 -1
vite.config.ts
··· 10 10 11 11 import { generateMetadataPlugin } from "./oauthdev.mts"; 12 12 13 - const PROD_URL = "https://reddwarf.whey.party" 13 + const PROD_URL = "https://reddwarf.app" 14 14 const DEV_URL = "https://local3768forumtest.whey.party" 15 15 16 16 function shp(url: string): string {