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.

linear threadded replies

rimar1337 9f8a63c5 92264fde

+335 -54
+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 }