an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

desktop hover profile

rimar1337 24dd0e22 fa673e49

+256 -100
+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",
+123 -53
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( ··· 725 731 error: embedError, 726 732 } = useHydratedEmbed(postRecord?.value?.embed, resolved?.did); 727 733 728 - const [imgcdn] = useAtom(imgCDNAtom) 734 + const [imgcdn] = useAtom(imgCDNAtom); 729 735 730 736 const parsedaturi = new AtUri(aturi); //parseAtUri(aturi); 731 737 738 + const fakeprofileviewbasic = React.useMemo<AppBskyActorDefs.ProfileViewBasic>( 739 + () => ({ 740 + did: resolved?.did || "", 741 + handle: resolved?.handle || "", 742 + displayName: profileRecord?.value?.displayName || "", 743 + avatar: getAvatarUrl(profileRecord, resolved?.did, imgcdn) || "", 744 + viewer: undefined, 745 + labels: profileRecord?.labels || undefined, 746 + verification: undefined, 747 + }), 748 + [imgcdn, profileRecord, resolved?.did, resolved?.handle] 749 + ); 750 + 751 + const fakeprofileviewdetailed = 752 + React.useMemo<AppBskyActorDefs.ProfileViewDetailed>( 753 + () => ({ 754 + ...fakeprofileviewbasic, 755 + $type: "app.bsky.actor.defs#profileViewDetailed", 756 + description: profileRecord?.value?.description || undefined, 757 + }), 758 + [fakeprofileviewbasic, profileRecord?.value?.description] 759 + ); 760 + 732 761 const fakepost = React.useMemo<AppBskyFeedDefs.PostView>( 733 762 () => ({ 734 763 $type: "app.bsky.feed.defs#postView", 735 764 uri: aturi, 736 765 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 - }, 766 + author: fakeprofileviewbasic, 746 767 record: postRecord?.value || {}, 747 768 embed: hydratedEmbed ?? undefined, 748 769 replyCount: repliesCount ?? 0, ··· 759 780 postRecord?.cid, 760 781 postRecord?.value, 761 782 postRecord?.labels, 762 - resolved?.did, 763 - resolved?.handle, 764 - profileRecord, 783 + fakeprofileviewbasic, 765 784 hydratedEmbed, 766 785 repliesCount, 767 786 repostsCount, 768 787 likesCount, 769 - imgcdn 770 788 ] 771 789 ); 772 790 ··· 839 857 } 840 858 }} 841 859 post={fakepost} 860 + uprrrsauthor={fakeprofileviewdetailed} 842 861 salt={aturi} 843 862 bottomReplyLine={bottomReplyLine} 844 863 topReplyLine={topReplyLine} ··· 1143 1162 //import Masonry from "@mui/lab/Masonry"; 1144 1163 import { 1145 1164 type $Typed, 1165 + AppBskyActorDefs, 1146 1166 AppBskyEmbedDefs, 1147 1167 AppBskyEmbedExternal, 1148 1168 AppBskyEmbedImages, ··· 1172 1192 1173 1193 import defaultpfp from "~/../public/favicon.png"; 1174 1194 import { useAuth } from "~/providers/UnifiedAuthProvider"; 1195 + import { FollowButton, Mutual } from "~/routes/profile.$did"; 1175 1196 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 1176 1197 // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; 1177 1198 // import type { ··· 1280 1301 1281 1302 function UniversalPostRenderer({ 1282 1303 post, 1304 + uprrrsauthor, 1283 1305 //setMainItem, 1284 1306 //isMainItem, 1285 1307 onPostClick, ··· 1304 1326 maxReplies, 1305 1327 }: { 1306 1328 post: PostView; 1329 + uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed; 1307 1330 // optional for now because i havent ported every use to this yet 1308 1331 // setMainItem?: React.Dispatch< 1309 1332 // React.SetStateAction<AppBskyFeedDefs.FeedViewPost> ··· 1487 1510 className="bg-gray-500 dark:bg-gray-400" 1488 1511 /> 1489 1512 )} 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> 1513 + <HoverCard.Root> 1514 + <HoverCard.Trigger asChild> 1515 + <div 1516 + className={`absolute`} 1517 + style={{ 1518 + top: isRepost 1519 + ? "calc(16px + 1rem)" 1520 + : isQuote 1521 + ? 12 1522 + : topReplyLine 1523 + ? 8 1524 + : 16, 1525 + left: isQuote ? 12 : 16, 1526 + }} 1527 + onClick={onProfileClick} 1528 + > 1529 + <img 1530 + src={post.author.avatar || defaultpfp} 1531 + alt="avatar" 1532 + className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`} 1533 + style={{ 1534 + width: isQuote ? 16 : 42, 1535 + height: isQuote ? 16 : 42, 1536 + }} 1537 + /> 1538 + </div> 1539 + </HoverCard.Trigger> 1540 + <HoverCard.Portal> 1541 + <HoverCard.Content 1542 + 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" 1543 + side={"bottom"} 1544 + sideOffset={5} 1545 + > 1546 + <div className="flex flex-col gap-2"> 1547 + <div className="flex flex-row"> 1548 + <img 1549 + src={post.author.avatar || defaultpfp} 1550 + alt="avatar" 1551 + className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600" 1552 + /> 1553 + <div className=" flex-1 flex flex-row align-middle justify-end"> 1554 + <FollowButton targetdidorhandle={post.author.did} /> 1555 + </div> 1556 + </div> 1557 + <div className="flex flex-col gap-3"> 1558 + <div> 1559 + <div className="text-gray-900 dark:text-gray-100 font-medium text-md"> 1560 + {post.author.displayName || post.author.handle}{" "} 1561 + </div> 1562 + <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1"> 1563 + <Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "} 1564 + </div> 1565 + </div> 1566 + {uprrrsauthor?.description && ( 1567 + <div className="text-gray-700 dark:text-gray-300 text-sm text-left break-words line-clamp-3"> 1568 + {uprrrsauthor.description} 1569 + </div> 1570 + )} 1571 + {/* <div className="flex gap-4"> 1572 + <div className="flex gap-1"> 1573 + <div className="font-medium text-gray-900 dark:text-gray-100"> 1574 + 0 1575 + </div> 1576 + <div className="text-gray-500 dark:text-gray-400"> 1577 + Following 1578 + </div> 1579 + </div> 1580 + <div className="flex gap-1"> 1581 + <div className="font-medium text-gray-900 dark:text-gray-100"> 1582 + 2,900 1583 + </div> 1584 + <div className="text-gray-500 dark:text-gray-400"> 1585 + Followers 1586 + </div> 1587 + </div> 1588 + </div> */} 1589 + </div> 1590 + </div> 1591 + 1592 + {/* <HoverCard.Arrow className="fill-gray-50 dark:fill-gray-900" /> */} 1593 + </HoverCard.Content> 1594 + </HoverCard.Portal> 1595 + </HoverCard.Root> 1596 + 1527 1597 <div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}> 1528 1598 <div 1529 1599 style={{
+97 -47
src/routes/profile.$did/index.tsx
··· 7 7 import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 8 8 import { useAuth } from "~/providers/UnifiedAuthProvider"; 9 9 import { imgCDNAtom } from "~/utils/atoms"; 10 - import { toggleFollow, useGetFollowState } from "~/utils/followState"; 10 + import { toggleFollow, useGetFollowState, useGetOneToOneState } from "~/utils/followState"; 11 11 import { 12 12 useInfiniteQueryAuthorFeed, 13 13 useQueryIdentity, ··· 22 22 // booo bad this is not always the did it might be a handle, use identity.did instead 23 23 const { did } = Route.useParams(); 24 24 const queryClient = useQueryClient(); 25 - const { agent } = useAuth(); 26 25 const { 27 26 data: identity, 28 27 isLoading: isIdentityLoading, 29 28 error: identityError, 30 29 } = useQueryIdentity(did); 31 - 32 - const followRecords = useGetFollowState({ 33 - target: identity?.did || did, 34 - user: agent?.did, 35 - }); 36 30 37 31 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 38 32 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; ··· 68 62 () => postsData?.pages.flatMap((page) => page.records) ?? [], 69 63 [postsData] 70 64 ); 71 - 72 - const [imgcdn] = useAtom(imgCDNAtom) 65 + 66 + const [imgcdn] = useAtom(imgCDNAtom); 73 67 74 68 function getAvatarUrl(p: typeof profile) { 75 69 const link = p?.avatar?.ref?.["$link"]; ··· 166 160 also delay the backfill to be on demand because it would be pretty intense 167 161 also save it persistently 168 162 */} 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 - )} 163 + <FollowButton targetdidorhandle={did} /> 206 164 <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 207 165 ... {/* todo: icon */} 208 166 </button> ··· 211 169 {/* Info Card */} 212 170 <div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100"> 213 171 <div className="font-bold text-2xl">{displayName}</div> 214 - <div className="text-gray-500 dark:text-gray-400 text-base mb-3"> 172 + <div className="text-gray-500 dark:text-gray-400 text-base mb-3 flex flex-row gap-1"> 173 + <Mutual targetdidorhandle={did} /> 215 174 {handle} 216 175 </div> 217 176 {description && ( ··· 259 218 </> 260 219 ); 261 220 } 221 + 222 + export function FollowButton({targetdidorhandle}:{targetdidorhandle: string}) { 223 + const {agent} = useAuth() 224 + const {data: identity} = useQueryIdentity(targetdidorhandle); 225 + const queryClient = useQueryClient(); 226 + 227 + const followRecords = useGetFollowState({ 228 + target: identity?.did ?? targetdidorhandle, 229 + user: agent?.did, 230 + }); 231 + 232 + return ( 233 + <> 234 + {identity?.did !== agent?.did ? ( 235 + <> 236 + {!(followRecords?.length && followRecords?.length > 0) ? ( 237 + <button 238 + onClick={(e) => 239 + { 240 + e.stopPropagation(); 241 + toggleFollow({ 242 + agent: agent || undefined, 243 + targetDid: identity?.did, 244 + followRecords: followRecords, 245 + queryClient: queryClient, 246 + }) 247 + } 248 + } 249 + 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]" 250 + > 251 + Follow 252 + </button> 253 + ) : ( 254 + <button 255 + onClick={(e) => 256 + { 257 + e.stopPropagation(); 258 + toggleFollow({ 259 + agent: agent || undefined, 260 + targetDid: identity?.did, 261 + followRecords: followRecords, 262 + queryClient: queryClient, 263 + }) 264 + } 265 + } 266 + 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]" 267 + > 268 + Unfollow 269 + </button> 270 + )} 271 + </> 272 + ) : ( 273 + <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]"> 274 + Edit Profile 275 + </button> 276 + )} 277 + </> 278 + ); 279 + } 280 + 281 + 282 + export function Mutual({targetdidorhandle}:{targetdidorhandle: string}) { 283 + const {agent} = useAuth() 284 + const {data: identity} = useQueryIdentity(targetdidorhandle); 285 + 286 + const mutualfollows = useGetOneToOneState(agent?.did ? { 287 + target: agent?.did, 288 + user: identity?.did ?? targetdidorhandle, 289 + collection: "app.bsky.graph.follow", 290 + path: ".subject" 291 + }:undefined); 292 + 293 + const ismutual: boolean = (!!mutualfollows?.length && mutualfollows.length > 0) 294 + 295 + return ( 296 + <> 297 + {identity?.did !== agent?.did ? ( 298 + <> 299 + {!(ismutual) ? ( 300 + <></> 301 + ) : ( 302 + <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">mutuals</div> 303 + )} 304 + </> 305 + ) : ( 306 + // lmao can someone be mutuals with themselves ?? 307 + <></> 308 + )} 309 + </> 310 + ); 311 + }
+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 + }