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.

reply indicator and parent chain

rimar1337 c64e32b7 1c939a15

+216 -94
+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 + }