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

reply indicator and parent chain

rimar1337 c64e32b7 1c939a15

Changed files
+216 -94
src
+1 -1
index.html
··· 12 12 <link rel="apple-touch-icon" href="/redstar.png" /> 13 13 <link rel="manifest" href="/manifest.json" /> 14 14 <link rel="stylesheet" href="/src/styles/app.css" /> 15 - <title>Red Dwarf lite</title> 15 + <title>Red Dwarf</title> 16 16 </head> 17 17 <body> 18 18 <div id="app"></div>
+101 -29
src/components/UniversalPostRenderer.tsx
··· 14 14 atUri: string; 15 15 onConstellation?: (data: any) => void; 16 16 detailed?: boolean; 17 + bottomReplyLine?: boolean; 18 + topReplyLine?: boolean; 19 + bottomBorder?:boolean; 20 + feedviewpost?:boolean; 17 21 } 18 22 19 23 export async function cachedGetRecord({ ··· 113 117 atUri, 114 118 onConstellation, 115 119 detailed = false, 120 + bottomReplyLine, 121 + topReplyLine, 122 + bottomBorder= true, 123 + feedviewpost = false, 116 124 }: UniversalPostRendererATURILoaderProps) { 117 125 console.log("atUri", atUri); 118 126 const { get, set } = usePersistentStore(); ··· 359 367 likesCount={likes} 360 368 repostsCount={reposts} 361 369 repliesCount={replies} 370 + bottomReplyLine={bottomReplyLine} 371 + topReplyLine={topReplyLine} 372 + bottomBorder={bottomBorder} 373 + feedviewpost={feedviewpost} 362 374 /> 363 375 ); 364 376 } ··· 372 384 repostsCount, 373 385 repliesCount, 374 386 detailed = false, 387 + bottomReplyLine = false, 388 + topReplyLine = false, 389 + bottomBorder= true, 390 + feedviewpost= false, 375 391 }: { 376 392 postRecord: any; 377 393 profileRecord: any; ··· 381 397 repostsCount?: number | null; 382 398 repliesCount?: number | null; 383 399 detailed?: boolean; 400 + bottomReplyLine?: boolean; 401 + topReplyLine?: boolean; 402 + bottomBorder?: boolean; 403 + feedviewpost?: boolean; 384 404 }) { 385 405 const navigate = useNavigate(); 386 406 ··· 458 478 459 479 const parsedaturi = parseAtUri(aturi); 460 480 481 + const fakepost = React.useMemo<AppBskyFeedDefs.PostView>(() => ({ 482 + $type: "app.bsky.feed.defs#postView", 483 + uri: aturi, 484 + cid: postRecord?.cid || "", 485 + author: { 486 + did: resolved?.did || "", 487 + handle: resolved?.handle || "", 488 + displayName: profileRecord?.value?.displayName || "", 489 + avatar: getAvatarUrl(profileRecord) || "", 490 + viewer: undefined, 491 + labels: profileRecord?.labels || undefined, 492 + verification: undefined, 493 + }, 494 + record: postRecord?.value || {}, 495 + embed: hydratedEmbed ?? undefined, 496 + replyCount: repliesCount ?? 0, 497 + repostCount: repostsCount ?? 0, 498 + likeCount: likesCount ?? 0, 499 + quoteCount: 0, 500 + indexedAt: postRecord?.value?.createdAt || "", 501 + viewer: undefined, 502 + labels: postRecord?.labels || undefined, 503 + threadgate: undefined, 504 + }), [ 505 + aturi, 506 + postRecord, 507 + profileRecord, 508 + hydratedEmbed, 509 + repliesCount, 510 + repostsCount, 511 + likesCount, 512 + resolved, 513 + ]); 514 + 515 + const [feedviewpostreplyhandle, setFeedviewpostreplyhandle] = useState<string | undefined>(undefined); 516 + 517 + useEffect(() => { 518 + if(!feedviewpost) return; 519 + let cancelled = false; 520 + 521 + const run = async () => { 522 + const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent?.uri; 523 + const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined; 524 + 525 + if (feedviewpostreplydid) { 526 + const opi = await cachedResolveIdentity({ 527 + didOrHandle: feedviewpostreplydid, 528 + get, 529 + set, 530 + }); 531 + 532 + if (!cancelled) { 533 + setFeedviewpostreplyhandle(opi?.handle); 534 + } 535 + } 536 + }; 537 + 538 + run(); 539 + 540 + return () => { 541 + cancelled = true; 542 + }; 543 + }, [fakepost, get, set]); 544 + 461 545 return ( 462 546 <> 463 547 {/* <p> ··· 484 568 }); 485 569 } 486 570 }} 487 - post={{ 488 - $type: "app.bsky.feed.defs#postView", 489 - uri: aturi, 490 - cid: postRecord?.cid || "", 491 - author: { 492 - did: resolved?.did || "", 493 - handle: resolved?.handle || "", 494 - displayName: profileRecord?.value?.displayName || "", 495 - avatar: getAvatarUrl(profileRecord) || "", 496 - viewer: undefined, 497 - labels: profileRecord?.labels || undefined, 498 - verification: undefined, 499 - }, 500 - record: postRecord?.value || {}, 501 - embed: hydratedEmbed ?? undefined, 502 - replyCount: repliesCount ?? 0, 503 - repostCount: repostsCount ?? 0, 504 - likeCount: likesCount ?? 0, 505 - quoteCount: 0, 506 - indexedAt: postRecord?.value?.createdAt || "", 507 - viewer: undefined, 508 - labels: postRecord?.labels || undefined, 509 - threadgate: undefined, 510 - }} 571 + post={fakepost} 511 572 salt={aturi} 573 + bottomReplyLine={bottomReplyLine} 574 + topReplyLine={topReplyLine} 575 + bottomBorder={bottomBorder} 576 + //extraOptionalItemInfo={{reply: postRecord?.value?.reply as AppBskyFeedDefs.ReplyRef, post: fakepost}} 577 + feedviewpostreplyhandle={feedviewpostreplyhandle} 512 578 /> 513 579 </> 514 580 ); ··· 1071 1137 AppBskyFeedDefs, 1072 1138 AppBskyFeedPost, 1073 1139 AppBskyGraphDefs, 1140 + AtUri, 1074 1141 //AppBskyLabelerDefs, 1075 1142 //AtUri, 1076 1143 //ComAtprotoRepoStrongRef, ··· 1171 1238 topReplyLine, 1172 1239 salt, 1173 1240 bottomBorder = true, 1241 + feedviewpostreplyhandle, 1174 1242 }: { 1175 1243 post: PostView; 1176 1244 // optional for now because i havent ported every use to this yet ··· 1187 1255 topReplyLine?: boolean; 1188 1256 salt: string; 1189 1257 bottomBorder?: boolean; 1258 + feedviewpostreplyhandle?: string; 1190 1259 }) { 1191 1260 const navigate = useNavigate(); 1192 1261 const [hasRetweeted, setHasRetweeted] = useState<Boolean>( ··· 1319 1388 //opacity: 0.5, 1320 1389 // no flex here 1321 1390 }} 1391 + className="bg-gray-500 dark:bg-gray-400" 1322 1392 /> 1323 1393 )} 1324 1394 <div ··· 1375 1445 //background: theme.textSecondary, 1376 1446 opacity: 0.5, 1377 1447 // no flex here 1448 + //color: "Red", 1449 + //zIndex: 99 1378 1450 }} 1379 - className="text-gray-500 dark:text-gray-400" 1451 + className="bg-gray-500 dark:bg-gray-400" 1380 1452 /> 1381 1453 )} 1382 1454 {/* <div ··· 1482 1554 </div> 1483 1555 </div> 1484 1556 {/* reply indicator */} 1485 - {false && isReply && ( 1557 + {!!feedviewpostreplyhandle && ( 1486 1558 <div 1487 1559 style={{ 1488 1560 display: "flex", ··· 1494 1566 gap: 4, 1495 1567 alignItems: "center", 1496 1568 //marginLeft: 36, 1497 - height: !(expanded || isQuote) && isReply ? "1rem" : 0, 1498 - opacity: !(expanded || isQuote) && isReply ? 1 : 0, 1569 + height: !(expanded || isQuote) && !!feedviewpostreplyhandle ? "1rem" : 0, 1570 + opacity: !(expanded || isQuote) && !!feedviewpostreplyhandle ? 1 : 0, 1499 1571 }} 1500 1572 className="text-gray-500 dark:text-gray-400" 1501 1573 > 1502 - <MdiReply /> Reply to some other post lmao 1574 + <MdiReply /> Reply to {feedviewpostreplyhandle} 1503 1575 </div> 1504 1576 )} 1505 1577 <div
+3 -3
src/main.tsx
··· 31 31 const root = ReactDOM.createRoot(rootElement); 32 32 root.render( 33 33 // double queries annoys me 34 - //<StrictMode> 35 - <RouterProvider router={router} />, 36 - //</StrictMode>, 34 + <StrictMode> 35 + <RouterProvider router={router} /> 36 + </StrictMode> 37 37 ); 38 38 } 39 39
+13 -6
src/routes/__root.tsx
··· 176 176 <img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" /> 177 177 <span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100"> 178 178 Red Dwarf{" "} 179 - <span className="text-gray-500 dark:text-gray-400 text-sm"> 179 + {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> 180 180 lite 181 - </span> 181 + </span> */} 182 182 </span> 183 183 </div> 184 184 <Link ··· 277 277 </button> 278 278 <div className="flex-1"></div> 279 279 <a 280 + href="https://tangled.sh/@whey.party/red-dwarf" 281 + target="_blank" 282 + rel="noopener noreferrer" 283 + className="mt-1 text-xs text-gray-400 dark:text-gray-500 text-center hover:underline" 284 + > 285 + git repo 286 + </a> 287 + <a 280 288 href="https://whey.party/" 281 289 target="_blank" 282 290 rel="noopener noreferrer" ··· 339 347 340 348 <div className="flex-1"></div> 341 349 <p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4"> 342 - Red Dwarf lite is a bluesky client that uses Constellation and 343 - direct PDS queries. Red Dwarf (without the lite) would be a 344 - self-hosted bluesky "instance". Stay tuned for the "without the 345 - lite" version. 350 + Red Dwarf is a bluesky client that uses Constellation and 351 + direct PDS queries. Skylite would be a 352 + self-hosted bluesky "instance". Stay tuned for the release of Skylite. 346 353 </p> 347 354 </aside> 348 355 </div>
+1
src/routes/profile.$did/index.tsx
··· 404 404 <UniversalPostRendererATURILoader 405 405 key={post.uri} 406 406 atUri={post.uri} 407 + feedviewpost={true} 407 408 /> 408 409 ); 409 410 })}
+97 -55
src/routes/profile.$did/post.$rkey.tsx
··· 1 - import { createFileRoute, Link } from "@tanstack/react-router"; 2 - import React from "react"; 3 - import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 4 - import { usePersistentStore } from "~/providers/PersistentStoreProvider"; 1 + import { createFileRoute, Link } from '@tanstack/react-router'; 2 + import React from 'react'; 3 + import { UniversalPostRendererATURILoader, cachedGetRecord } from '~/components/UniversalPostRenderer'; 4 + import { usePersistentStore } from '~/providers/PersistentStoreProvider'; 5 5 6 6 const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour 7 7 8 - export const Route = createFileRoute("/profile/$did/post/$rkey")({ 8 + export const Route = createFileRoute('/profile/$did/post/$rkey')({ 9 9 component: RouterWrapper, 10 10 }); 11 11 12 12 function RouterWrapper() { 13 13 const { did, rkey } = Route.useParams(); 14 14 15 - return ( 16 - <ProfilePostComponent 17 - key={`/profile/${did}/post/${rkey}`} 18 - did={did} 19 - rkey={rkey} 20 - /> 21 - ); 15 + return <ProfilePostComponent key={`/profile/${did}/post/${rkey}`} did={did} rkey={rkey} />; 22 16 } 23 17 24 18 function ProfilePostComponent({ did, rkey }: { did: string; rkey: string }) { ··· 26 20 const [resolvedDid, setResolvedDid] = React.useState<string | null>(null); 27 21 const [loading, setLoading] = React.useState(false); 28 22 const [error, setError] = React.useState<string | null>(null); 23 + 24 + const [mainPost, setMainPost] = React.useState<any | null>(null); 25 + const [parents, setParents] = React.useState<any[]>([]); 26 + const [parentsLoading, setParentsLoading] = React.useState(false); 29 27 const [replies, setReplies] = React.useState<any[]>([]); 30 28 31 29 React.useEffect(() => { ··· 35 33 setResolvedDid(null); 36 34 return; 37 35 } 38 - if (did.startsWith("did:")) { 36 + if (did.startsWith('did:')) { 39 37 setResolvedDid(did); 40 38 return; 41 39 } ··· 44 42 const cacheKey = `handleDid:${did}`; 45 43 const now = Date.now(); 46 44 const cached = await get(cacheKey); // <-- await here 47 - if ( 48 - cached && 49 - cached.value && 50 - cached.time && 51 - now - cached.time < HANDLE_DID_CACHE_TIMEOUT 52 - ) { 45 + if (cached && cached.value && cached.time && now - cached.time < HANDLE_DID_CACHE_TIMEOUT) { 53 46 try { 54 47 const data = JSON.parse(cached.value); 55 48 if (!ignore) setResolvedDid(data.did); ··· 60 53 try { 61 54 const url = `https://free-fly-24.deno.dev/?handle=${encodeURIComponent(did)}`; 62 55 const res = await fetch(url); 63 - if (!res.ok) throw new Error("Failed to resolve handle"); 56 + if (!res.ok) throw new Error('Failed to resolve handle'); 64 57 const data = await res.json(); 65 58 await set(cacheKey, JSON.stringify(data)); // <-- await here 66 59 if (!ignore) setResolvedDid(data.did); 67 60 } catch (e: any) { 68 - if (!ignore) setError("Failed to resolve handle: " + (e?.message || e)); 61 + if (!ignore) setError('Failed to resolve handle: ' + (e?.message || e)); 69 62 } finally { 70 63 setLoading(false); 71 64 } ··· 76 69 }; 77 70 }, [did, get, set]); 78 71 79 - const atUri = 80 - resolvedDid && rkey 81 - ? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}` 82 - : ""; 72 + const atUri = resolvedDid && rkey ? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}` : ''; 83 73 84 - const handleConstellation = React.useCallback((data: any) => {}, []); 74 + React.useEffect(() => { 75 + if (!atUri) return; 76 + let ignore = false; 77 + async function fetchMainPost() { 78 + try { 79 + const postData = await cachedGetRecord({ atUri, get, set }); 80 + if (!ignore) { 81 + setMainPost(postData); 82 + } 83 + } catch (e) { 84 + console.error('Failed to fetch main post record:', e); 85 + } 86 + } 87 + fetchMainPost(); 88 + return () => { 89 + ignore = true; 90 + }; 91 + }, [atUri, get, set]); 92 + 93 + React.useEffect(() => { 94 + if (!mainPost) return; 95 + let ignore = false; 96 + async function fetchParents() { 97 + setParentsLoading(true); 98 + const parentChain: any[] = []; 99 + let currentParentUri = mainPost.value?.reply?.parent?.uri; 100 + const MAX_PARENTS = 25; // Important to know theres a limit 101 + let safetyCounter = 0; 102 + 103 + while (currentParentUri && safetyCounter < MAX_PARENTS) { 104 + try { 105 + const parentPost = await cachedGetRecord({ atUri: currentParentUri, get, set }); 106 + if (!parentPost) break; 107 + parentChain.push(parentPost); 108 + currentParentUri = parentPost.value?.reply?.parent?.uri; 109 + safetyCounter++; 110 + } catch (error) { 111 + console.error('Failed to fetch a parent post:', error); 112 + break; 113 + } 114 + } 115 + 116 + if (!ignore) { 117 + setParents(parentChain.reverse()); 118 + setParentsLoading(false); 119 + } 120 + } 121 + 122 + fetchParents(); 123 + return () => { 124 + ignore = true; 125 + }; 126 + }, [mainPost, get, set]); 85 127 86 128 React.useEffect(() => { 87 129 if (!atUri) return; 88 130 let ignore = false; 89 131 async function fetchReplies() { 90 132 try { 91 - const url = `https://constellation.microcosm.blue/links?target=${encodeURIComponent(atUri)}&collection=app.bsky.feed.post&path=.reply.parent.uri`; 133 + const url = `https://constellation.microcosm.blue/links?target=${encodeURIComponent( 134 + atUri, 135 + )}&collection=app.bsky.feed.post&path=.reply.parent.uri`; 92 136 const res = await fetch(url); 93 - if (!res.ok) throw new Error("Failed to fetch replies"); 137 + if (!res.ok) throw new Error('Failed to fetch replies'); 94 138 const data = await res.json(); 95 139 if (!ignore && data.linking_records) { 96 140 setReplies(data.linking_records.slice(0, 50)); ··· 107 151 108 152 if (!did || !rkey) return <div>Invalid post URI</div>; 109 153 if (loading) return <div>Resolving handle...</div>; 110 - if (error) return <div style={{ color: "red" }}>{error}</div>; 154 + if (error) return <div style={{ color: 'red' }}>{error}</div>; 111 155 if (!atUri) return <div>Invalid post URI</div>; 112 - 113 - console.log("atUri", atUri); 114 156 115 157 return ( 116 158 <> ··· 118 160 <Link 119 161 to=".." 120 162 className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg" 121 - onClick={(e) => { 163 + onClick={e => { 122 164 e.preventDefault(); 123 - window.history.length > 1 124 - ? window.history.back() 125 - : window.location.assign("/"); 165 + window.history.length > 1 ? window.history.back() : window.location.assign('/'); 126 166 }} 127 167 aria-label="Go back" 128 168 > ··· 130 170 </Link> 131 171 <span className="text-xl font-bold ml-2">Post</span> 132 172 </div> 133 - <UniversalPostRendererATURILoader 134 - atUri={atUri} 135 - onConstellation={handleConstellation} 136 - detailed={true} 137 - /> 173 + 174 + {parentsLoading && <div className="p-4 text-center text-gray-500 dark:text-gray-400">Loading conversation...</div>} 175 + 176 + {/* we should use the reply lines here thats provided by UPR*/} 177 + <div style={{ maxWidth: 600, margin: '0px auto 0', padding: 0 }}> 178 + {parents.map((parent, index) => ( 179 + <UniversalPostRendererATURILoader key={parent.uri} atUri={parent.uri} 180 + topReplyLine={index > 0} 181 + bottomReplyLine={true} 182 + bottomBorder={false} 183 + /> 184 + ))} 185 + </div> 186 + 187 + <UniversalPostRendererATURILoader atUri={atUri} detailed={true} topReplyLine={parents.length > 0} /> 188 + 138 189 {replies.length > 0 && ( 139 - <div style={{ maxWidth: 600, margin: "0px auto 0", padding: 0 }}> 190 + <div style={{ maxWidth: 600, margin: '0px auto 0', padding: 0 }}> 140 191 <div 141 192 className="text-gray-500 dark:text-gray-400 text-sm font-bold" 142 - style={{ 143 - fontSize: 18, 144 - margin: "12px 16px 12px 16px", 145 - fontWeight: 600, 146 - }} 193 + style={{ fontSize: 18, margin: '12px 16px 12px 16px', fontWeight: 600 }} 147 194 > 148 195 Replies 149 196 </div> 150 - <div style={{ display: "flex", flexDirection: "column", gap: 0 }}> 151 - {replies.map((reply, i) => { 197 + <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}> 198 + {replies.map(reply => { 152 199 const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`; 153 - return ( 154 - <UniversalPostRendererATURILoader 155 - key={replyAtUri} 156 - atUri={replyAtUri} 157 - /> 158 - ); 200 + return <UniversalPostRendererATURILoader key={replyAtUri} atUri={replyAtUri} />; 159 201 })} 160 202 </div> 161 203 </div> 162 204 )} 163 205 </> 164 206 ); 165 - } 207 + }