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

linear threadded replies

rimar1337 9f8a63c5 92264fde

Changed files
+335 -54
src
components
routes
profile.$did
utils
+208 -39
src/components/UniversalPostRenderer.tsx
··· 11 11 useQueryIdentity, 12 12 useQueryPost, 13 13 useQueryProfile, 14 + yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks, 14 15 } from "~/utils/useQuery"; 15 16 16 17 function asTyped<T extends { $type: string }>(obj: T): $Typed<T> { ··· 33 34 ref?: React.Ref<HTMLDivElement>; 34 35 dataIndexPropPass?: number; 35 36 nopics?: boolean; 36 - lightboxCallback?: (d:LightboxProps) => void; 37 + lightboxCallback?: (d: LightboxProps) => void; 38 + maxReplies?: number; 37 39 } 38 40 39 41 // export async function cachedGetRecord({ ··· 143 145 dataIndexPropPass, 144 146 nopics, 145 147 lightboxCallback, 148 + maxReplies, 146 149 }: UniversalPostRendererATURILoaderProps) { 147 150 // /*mass comment*/ console.log("atUri", atUri); 148 151 //const { get, set } = usePersistentStore(); ··· 388 391 ); 389 392 }, [links]); 390 393 394 + // const { data: repliesData } = useQueryConstellation({ 395 + // method: "/links", 396 + // target: atUri, 397 + // collection: "app.bsky.feed.post", 398 + // path: ".reply.parent.uri", 399 + // }); 400 + 401 + const infinitequeryresults = useInfiniteQuery({ 402 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 403 + { 404 + method: "/links", 405 + target: atUri, 406 + collection: "app.bsky.feed.post", 407 + path: ".reply.parent.uri", 408 + } 409 + ), 410 + enabled: !!atUri && !!maxReplies, 411 + }); 412 + 413 + const { 414 + data: repliesData, 415 + // fetchNextPage, 416 + // hasNextPage, 417 + // isFetchingNextPage, 418 + } = infinitequeryresults; 419 + 420 + // auto-fetch all pages 421 + useEffect(() => { 422 + if (!maxReplies) return; 423 + if ( 424 + infinitequeryresults.hasNextPage && 425 + !infinitequeryresults.isFetchingNextPage 426 + ) { 427 + console.log("Fetching the next page..."); 428 + infinitequeryresults.fetchNextPage(); 429 + } 430 + }, [infinitequeryresults]); 431 + 432 + const replyAturis = repliesData 433 + ? repliesData.pages.flatMap((page) => 434 + page 435 + ? page.linking_records.map((record) => { 436 + const aturi = `at://${record.did}/${record.collection}/${record.rkey}`; 437 + return aturi; 438 + }) 439 + : [] 440 + ) 441 + : []; 442 + 443 + //const [oldestOpsReply, setOldestOpsReply] = useState<string | undefined>(undefined); 444 + 445 + const { oldestOpsReply, oldestOpsReplyElseNewestNonOpsReply } = (() => { 446 + if (!replyAturis || replyAturis.length === 0 || !maxReplies) 447 + return { 448 + oldestOpsReply: undefined, 449 + oldestOpsReplyElseNewestNonOpsReply: undefined, 450 + }; 451 + 452 + const opdid = new AtUri( 453 + //postQuery?.value.reply?.root.uri ?? postQuery?.uri ?? atUri 454 + atUri 455 + ).host; 456 + 457 + const opReplies = replyAturis.filter( 458 + (aturi) => new AtUri(aturi).host === opdid 459 + ); 460 + 461 + if (opReplies.length > 0) { 462 + const opreply = opReplies[opReplies.length - 1]; 463 + //setOldestOpsReply(opreply); 464 + return { 465 + oldestOpsReply: opreply, 466 + oldestOpsReplyElseNewestNonOpsReply: opreply, 467 + }; 468 + } else { 469 + return { 470 + oldestOpsReply: undefined, 471 + oldestOpsReplyElseNewestNonOpsReply: replyAturis[0], 472 + }; 473 + } 474 + })(); 475 + 391 476 // const navigateToProfile = (e: React.MouseEvent) => { 392 477 // e.stopPropagation(); 393 478 // if (resolved?.did) { ··· 403 488 } 404 489 405 490 return ( 406 - <UniversalPostRendererRawRecordShim 407 - detailed={detailed} 408 - postRecord={postQuery} 409 - profileRecord={opProfile} 410 - aturi={atUri} 411 - resolved={resolved} 412 - likesCount={likes} 413 - repostsCount={reposts} 414 - repliesCount={replies} 415 - bottomReplyLine={bottomReplyLine} 416 - topReplyLine={topReplyLine} 417 - bottomBorder={bottomBorder} 418 - feedviewpost={feedviewpost} 419 - repostedby={repostedby} 420 - style={style} 421 - ref={ref} 422 - dataIndexPropPass={dataIndexPropPass} 423 - nopics={nopics} 424 - lightboxCallback={lightboxCallback} 425 - /> 491 + <> 492 + {/* <span>uprrs {maxReplies} {!!maxReplies&&!!oldestOpsReplyElseNewestNonOpsReply ? "true" : "false"}</span> */} 493 + <UniversalPostRendererRawRecordShim 494 + detailed={detailed} 495 + postRecord={postQuery} 496 + profileRecord={opProfile} 497 + aturi={atUri} 498 + resolved={resolved} 499 + likesCount={likes} 500 + repostsCount={reposts} 501 + repliesCount={replies} 502 + bottomReplyLine={ 503 + maxReplies && oldestOpsReplyElseNewestNonOpsReply 504 + ? true 505 + : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 506 + ? false 507 + : bottomReplyLine 508 + } 509 + topReplyLine={topReplyLine} 510 + //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} 511 + bottomBorder={ 512 + maxReplies && oldestOpsReplyElseNewestNonOpsReply 513 + ? false 514 + : maxReplies === 0 515 + ? false 516 + : bottomBorder 517 + } 518 + feedviewpost={feedviewpost} 519 + repostedby={repostedby} 520 + //style={{...style, background: oldestOpsReply === atUri ? "Red" : undefined}} 521 + style={style} 522 + ref={ref} 523 + dataIndexPropPass={dataIndexPropPass} 524 + nopics={nopics} 525 + lightboxCallback={lightboxCallback} 526 + maxReplies={maxReplies} 527 + /> 528 + {oldestOpsReplyElseNewestNonOpsReply && ( 529 + <> 530 + {/* <span>hello {maxReplies}</span> */} 531 + <UniversalPostRendererATURILoader 532 + //detailed={detailed} 533 + atUri={oldestOpsReplyElseNewestNonOpsReply} 534 + bottomReplyLine={(maxReplies ?? 0) > 0} 535 + topReplyLine={(maxReplies ?? 0) > 1} 536 + bottomBorder={bottomBorder} 537 + feedviewpost={feedviewpost} 538 + repostedby={repostedby} 539 + style={style} 540 + ref={ref} 541 + dataIndexPropPass={dataIndexPropPass} 542 + nopics={nopics} 543 + lightboxCallback={lightboxCallback} 544 + maxReplies={ 545 + maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined 546 + } 547 + /> 548 + {maxReplies && maxReplies - 1 === 0 && ( 549 + <MoreReplies atUri={oldestOpsReplyElseNewestNonOpsReply} /> 550 + )} 551 + </> 552 + )} 553 + </> 554 + ); 555 + } 556 + 557 + function MoreReplies({ atUri }: { atUri: string }) { 558 + const navigate = useNavigate(); 559 + const aturio = new AtUri(atUri); 560 + return ( 561 + <div 562 + onClick={() => 563 + navigate({ 564 + to: "/profile/$did/post/$rkey", 565 + params: { did: aturio.host, rkey: aturio.rkey }, 566 + }) 567 + } 568 + className="border-b border-gray-300 dark:border-gray-800 flex flex-row px-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors" 569 + > 570 + <div className="w-[42px] h-12 flex flex-col items-center justify-center"> 571 + <div 572 + style={{ 573 + width: 2, 574 + height: "100%", 575 + backgroundImage: 576 + "repeating-linear-gradient(to bottom, var(--color-gray-500) 0, var(--color-gray-500) 4px, transparent 4px, transparent 8px)", 577 + opacity: 0.5, 578 + }} 579 + className="dark:bg-[repeating-linear-gradient(to_bottom,var(--color-gray-500)_0,var(--color-gray-400)_4px,transparent_4px,transparent_8px)]" 580 + //className="border-gray-400 dark:border-gray-500" 581 + /> 582 + </div> 583 + 584 + <div className="flex items-center pl-3 text-sm text-gray-500 dark:text-gray-400 select-none"> 585 + More Replies 586 + </div> 587 + </div> 426 588 ); 427 589 } 428 590 ··· 451 613 dataIndexPropPass, 452 614 nopics, 453 615 lightboxCallback, 616 + maxReplies, 454 617 }: { 455 618 postRecord: any; 456 619 profileRecord: any; ··· 469 632 ref?: React.Ref<HTMLDivElement>; 470 633 dataIndexPropPass?: number; 471 634 nopics?: boolean; 472 - lightboxCallback?: (d:LightboxProps) => void; 635 + lightboxCallback?: (d: LightboxProps) => void; 636 + maxReplies?: number; 473 637 }) { 474 638 // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`); 475 639 const navigate = useNavigate(); ··· 669 833 dataIndexPropPass={dataIndexPropPass} 670 834 nopics={nopics} 671 835 lightboxCallback={lightboxCallback} 836 + maxReplies={maxReplies} 672 837 /> 673 838 </> 674 839 ); ··· 982 1147 PostView, 983 1148 //ThreadViewPost, 984 1149 } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 1150 + import { useInfiniteQuery } from "@tanstack/react-query"; 985 1151 import { useEffect, useRef, useState } from "react"; 986 1152 import ReactPlayer from "react-player"; 987 1153 ··· 1115 1281 ref, 1116 1282 dataIndexPropPass, 1117 1283 nopics, 1118 - lightboxCallback 1284 + lightboxCallback, 1285 + maxReplies, 1119 1286 }: { 1120 1287 post: PostView; 1121 1288 // optional for now because i havent ported every use to this yet ··· 1139 1306 ref?: React.Ref<HTMLDivElement>; 1140 1307 dataIndexPropPass?: number; 1141 1308 nopics?: boolean; 1142 - lightboxCallback?: (d:LightboxProps) => void; 1309 + lightboxCallback?: (d: LightboxProps) => void; 1310 + maxReplies?: number; 1143 1311 }) { 1144 1312 const parsed = new AtUri(post.uri); 1145 1313 const navigate = useNavigate(); ··· 1496 1664 > 1497 1665 {fedi ? ( 1498 1666 <> 1499 - <span className="dangerousFediContent" 1667 + <span 1668 + className="dangerousFediContent" 1500 1669 dangerouslySetInnerHTML={{ 1501 1670 __html: DOMPurify.sanitize(fedi), 1502 1671 }} ··· 1728 1897 navigate, 1729 1898 postid, 1730 1899 nopics, 1731 - lightboxCallback 1900 + lightboxCallback, 1732 1901 }: { 1733 1902 embed?: Embed; 1734 1903 moderation?: ModerationDecision; ··· 1739 1908 navigate: (_: any) => void; 1740 1909 postid?: { did: string; rkey: string }; 1741 1910 nopics?: boolean; 1742 - lightboxCallback?: (d:LightboxProps) => void; 1911 + lightboxCallback?: (d: LightboxProps) => void; 1743 1912 }) { 1744 1913 //const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); 1745 - function setLightboxIndex(number:number) { 1914 + function setLightboxIndex(number: number) { 1746 1915 navigate({ 1747 - to: "/profile/$did/post/$rkey/image/$i", 1748 - params: { 1749 - did: postid?.did, 1750 - rkey: postid?.rkey, 1751 - i: number.toString(), 1752 - }, 1753 - }); 1916 + to: "/profile/$did/post/$rkey/image/$i", 1917 + params: { 1918 + did: postid?.did, 1919 + rkey: postid?.rkey, 1920 + i: number.toString(), 1921 + }, 1922 + }); 1754 1923 } 1755 1924 if ( 1756 1925 AppBskyEmbedRecordWithMedia.isView(embed) && ··· 1962 2131 src: img.fullsize, 1963 2132 alt: img.alt, 1964 2133 })); 1965 - console.log("rendering images") 2134 + console.log("rendering images"); 1966 2135 if (lightboxCallback) { 1967 - lightboxCallback({images: lightboxImages}) 1968 - console.log("rendering images") 1969 - }; 2136 + lightboxCallback({ images: lightboxImages }); 2137 + console.log("rendering images"); 2138 + } 1970 2139 1971 2140 if (nopics) return; 1972 2141
+72 -15
src/routes/profile.$did/post.$rkey.tsx
··· 1 - import { useQueryClient } from "@tanstack/react-query"; 1 + import { AtUri } from "@atproto/api"; 2 + import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 2 3 import { createFileRoute, Outlet } from "@tanstack/react-router"; 3 - import React, { useLayoutEffect } from "react"; 4 + import React, { useEffect, useLayoutEffect } from "react"; 4 5 5 6 import { Header } from "~/components/Header"; 6 7 import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 7 8 //import { usePersistentStore } from '~/providers/PersistentStoreProvider'; 8 9 import { 9 10 constructPostQuery, 10 - useQueryConstellation, 11 11 useQueryIdentity, 12 12 useQueryPost, 13 + yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks, 13 14 } from "~/utils/useQuery"; 14 15 15 16 import type { LightboxProps } from "./post.$rkey.image.$i"; ··· 198 199 199 200 const { data: mainPost } = useQueryPost(atUri); 200 201 201 - const { data: repliesData } = useQueryConstellation({ 202 - method: "/links", 203 - target: atUri, 204 - collection: "app.bsky.feed.post", 205 - path: ".reply.parent.uri", 202 + // const { data: repliesData } = useQueryConstellation({ 203 + // method: "/links", 204 + // target: atUri, 205 + // collection: "app.bsky.feed.post", 206 + // path: ".reply.parent.uri", 207 + // }); 208 + // const replies = repliesData?.linking_records.slice(0, 50) ?? []; 209 + const infinitequeryresults = useInfiniteQuery({ 210 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 211 + { 212 + method: "/links", 213 + target: atUri, 214 + collection: "app.bsky.feed.post", 215 + path: ".reply.parent.uri", 216 + } 217 + ), 218 + enabled: !!atUri, 206 219 }); 207 - const replies = repliesData?.linking_records.slice(0, 50) ?? []; 220 + 221 + const { 222 + data: repliesData, 223 + // fetchNextPage, 224 + // hasNextPage, 225 + // isFetchingNextPage, 226 + } = infinitequeryresults; 227 + 228 + // auto-fetch all pages 229 + useEffect(() => { 230 + if ( 231 + infinitequeryresults.hasNextPage && 232 + !infinitequeryresults.isFetchingNextPage 233 + ) { 234 + console.log("Fetching the next page..."); 235 + infinitequeryresults.fetchNextPage(); 236 + } 237 + }, [infinitequeryresults]); 238 + 239 + const replyAturis = repliesData 240 + ? repliesData.pages.flatMap((page) => 241 + page 242 + ? page.linking_records.map((record) => { 243 + const aturi = `at://${record.did}/${record.collection}/${record.rkey}`; 244 + return aturi; 245 + }) 246 + : [] 247 + ) 248 + : []; 249 + 250 + const opdid = new AtUri(atUri).host; 251 + 252 + // Find oldest OP reply 253 + const oldestOpsIndex = replyAturis.findIndex( 254 + (aturi) => new AtUri(aturi).host === opdid 255 + ); 256 + 257 + // Reorder: move oldest OP reply to the front 258 + if (oldestOpsIndex > 0) { 259 + const [oldestOpsReply] = replyAturis.splice(oldestOpsIndex, 1); 260 + replyAturis.unshift(oldestOpsReply); 261 + } 262 + 208 263 209 264 const [parents, setParents] = React.useState<any[]>([]); 210 265 const [parentsLoading, setParentsLoading] = React.useState(false); ··· 351 406 maxWidth: 600, 352 407 //margin: "0px auto 0", 353 408 padding: 0, 354 - minHeight: "100dvh", 409 + minHeight: "80dvh", 410 + paddingBottom: "20dvh" 355 411 }} 356 412 > 357 413 <div ··· 365 421 Replies 366 422 </div> 367 423 <div style={{ display: "flex", flexDirection: "column", gap: 0 }}> 368 - {replies.length > 0 && 369 - replies.map((reply) => { 370 - const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`; 424 + {replyAturis.length > 0 && 425 + replyAturis.map((reply) => { 426 + //const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`; 371 427 return ( 372 428 <UniversalPostRendererATURILoader 373 - key={replyAtUri} 374 - atUri={replyAtUri} 429 + key={reply} 430 + atUri={reply} 431 + maxReplies={4} 375 432 /> 376 433 ); 377 434 })}
+55
src/utils/useQuery.ts
··· 1 1 import * as ATPAPI from "@atproto/api"; 2 2 import { 3 + infiniteQueryOptions, 3 4 type QueryFunctionContext, 4 5 queryOptions, 5 6 useInfiniteQuery, ··· 614 615 refetchOnWindowFocus: false, 615 616 enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true), 616 617 }); 618 + } 619 + 620 + 621 + export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: { 622 + method: '/links' 623 + target?: string 624 + collection: string 625 + path: string 626 + }) { 627 + const constellationHost = 'constellation.microcosm.blue' 628 + console.log( 629 + 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 630 + query, 631 + ) 632 + 633 + return infiniteQueryOptions({ 634 + enabled: !!query?.target, 635 + queryKey: [ 636 + 'reddwarf_constellation', 637 + query?.method, 638 + query?.target, 639 + query?.collection, 640 + query?.path, 641 + ] as const, 642 + 643 + queryFn: async ({pageParam}: {pageParam?: string}) => { 644 + if (!query || !query?.target) return undefined 645 + 646 + const method = query.method 647 + const target = query.target 648 + const collection = query.collection 649 + const path = query.path 650 + const cursor = pageParam 651 + 652 + const res = await fetch( 653 + `https://${constellationHost}${method}?target=${encodeURIComponent(target)}${ 654 + collection ? `&collection=${encodeURIComponent(collection)}` : '' 655 + }${path ? `&path=${encodeURIComponent(path)}` : ''}${ 656 + cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' 657 + }`, 658 + ) 659 + 660 + if (!res.ok) throw new Error('Failed to fetch') 661 + 662 + return (await res.json()) as linksRecordsResponse 663 + }, 664 + 665 + getNextPageParam: lastPage => { 666 + return (lastPage as any)?.cursor ?? undefined 667 + }, 668 + initialPageParam: undefined, 669 + staleTime: 5 * 60 * 1000, 670 + gcTime: 5 * 60 * 1000, 671 + }) 617 672 }