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

desktop hover profile

rimar1337 24dd0e22 fa673e49

Changed files
+256 -100
src
components
routes
profile.$did
utils
+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 + }