a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

tabbing replies and quotes in the bluesky thread viewer, fixing a ton of styling

celine (May 30, 2026, 1:32 AM EDT) d3b30722 6b4d3160

+391 -186
+1 -1
app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx
··· 71 71 const { posts, postUri } = props; 72 72 73 73 return ( 74 - <div className="flex flex-col gap-0"> 74 + <div className="flex flex-col gap-0 pt-3"> 75 75 {posts.map((post, index) => { 76 76 const parent = { type: "quotes" as const, uri: postUri }; 77 77 return (
+83 -40
app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
··· 4 4 import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 5 5 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 6 6 import { CommentTiny } from "components/Icons/CommentTiny"; 7 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 7 8 import { Separator } from "components/Layout"; 8 9 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 9 10 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 10 11 import { OpenPage, openPage } from "./postPageState"; 11 - import { ThreadLink } from "./PostLinks"; 12 + import { ThreadLink, QuotesLink } from "./PostLinks"; 12 13 import { BlueskyLinkTiny } from "components/Icons/BlueskyLinkTiny"; 13 14 import { Avatar } from "components/Avatar"; 14 15 import { timeAgo } from "src/utils/timeAgo"; ··· 26 27 showEmbed?: boolean; 27 28 compactEmbed?: boolean; 28 29 showBlueskyLink?: boolean; 30 + showInteractions?: boolean; 29 31 quoteEnabled?: boolean; 30 32 replyEnabled?: boolean; 31 33 replyOnClick?: (e: React.MouseEvent) => void; ··· 43 45 showEmbed = true, 44 46 compactEmbed = false, 45 47 showBlueskyLink = true, 48 + showInteractions = true, 46 49 quoteEnabled, 47 50 replyEnabled, 48 51 replyOnClick, ··· 54 57 const postId = post.uri.split("/")[4]; 55 58 const url = `https://${clientHost}/profile/${post.author.handle}/post/${postId}`; 56 59 60 + // Only allow opening the thread page when there's a discussion to show 61 + const hasThreadContent = 62 + (post.replyCount ?? 0) > 0 || (post.quoteCount ?? 0) > 0; 63 + 57 64 return ( 58 65 <div className={`bskyPost relative flex flex-col w-full `}> 59 - <button 60 - className="absolute inset-0" 61 - onClick={() => { 62 - openPage(parent, { type: "thread", uri: post.uri }); 63 - }} 64 - /> 66 + {hasThreadContent && ( 67 + <button 68 + className="absolute inset-0" 69 + onClick={() => { 70 + openPage(parent, { type: "thread", uri: post.uri }); 71 + }} 72 + /> 73 + )} 65 74 {/*{props.parent?.type === "thread" && props.parent.uri && ( 66 75 <div className="text-xs flex gap-2 px-1 text-tertiary"> 67 76 <div className="flex flex-col shrink-0"> ··· 126 135 )} 127 136 </div> 128 137 {props.showBlueskyLink || 129 - (props.post.quoteCount && props.post.quoteCount > 0) || 130 - (props.post.replyCount && props.post.replyCount > 0) ? ( 138 + (showInteractions && 139 + ((props.post.quoteCount && props.post.quoteCount > 0) || 140 + (props.post.replyCount && props.post.replyCount > 0))) ? ( 131 141 <div 132 142 className={`postCountsAndLink flex gap-2 items-center justify-between pointer-events-auto`} 133 143 > 134 - <PostCounts 135 - post={post} 136 - parent={parent} 137 - replyEnabled={replyEnabled} 138 - replyOnClick={replyOnClick} 139 - quoteEnabled={quoteEnabled} 140 - showBlueskyLink={showBlueskyLink} 141 - url={url} 142 - /> 144 + {showInteractions ? ( 145 + <PostCounts 146 + post={post} 147 + parent={parent} 148 + replyEnabled={replyEnabled} 149 + replyOnClick={replyOnClick} 150 + quoteEnabled={quoteEnabled} 151 + showBlueskyLink={showBlueskyLink} 152 + url={url} 153 + /> 154 + ) : ( 155 + <div /> 156 + )} 143 157 144 158 <div className="flex gap-3 items-center"> 145 159 {showBlueskyLink && ( ··· 184 198 const postId = post.uri.split("/")[4]; 185 199 const url = `https://${clientHost}/profile/${post.author.handle}/post/${postId}`; 186 200 201 + // Only allow opening the thread page when there's a discussion to show 202 + const hasThreadContent = 203 + (post.replyCount ?? 0) > 0 || (post.quoteCount ?? 0) > 0; 204 + 187 205 return ( 188 206 <div className="bskyPost relative flex flex-col w-full"> 189 - <button 190 - className="absolute inset-0 " 191 - onClick={() => { 192 - openPage(parent, { type: "thread", uri: post.uri }); 193 - }} 194 - /> 207 + {hasThreadContent && ( 208 + <button 209 + className="absolute inset-0 " 210 + onClick={() => { 211 + openPage(parent, { type: "thread", uri: post.uri }); 212 + }} 213 + /> 214 + )} 195 215 <div className={`flex gap-2 text-left w-full ${props.className}`}> 196 216 <Avatar 197 217 src={post.author.avatar} ··· 200 220 /> 201 221 <div className={`flex flex-col min-w-0 w-full`}> 202 222 <button 203 - className="bskyPostTextContent flex flex-col grow mt-0.5 text-left text-xs text-tertiary" 204 - onClick={() => { 205 - openPage(parent, { type: "thread", uri: post.uri }); 206 - }} 223 + className={`bskyPostTextContent flex flex-col grow mt-0.5 text-left text-xs text-tertiary ${hasThreadContent ? "" : "cursor-default"}`} 224 + onClick={ 225 + hasThreadContent 226 + ? () => { 227 + openPage(parent, { type: "thread", uri: post.uri }); 228 + } 229 + : undefined 230 + } 207 231 > 208 232 <PostInfo 209 233 displayName={post.author.displayName} ··· 284 308 showBlueskyLink: boolean; 285 309 url: string; 286 310 }) { 287 - const total = (props.post.replyCount ?? 0) + (props.post.quoteCount ?? 0); 311 + const replyContent = props.post.replyCount != null && 312 + props.post.replyCount > 0 && ( 313 + <div className="postRepliesCount flex items-center gap-1 text-xs"> 314 + <CommentTiny /> 315 + {props.post.replyCount} 316 + </div> 317 + ); 288 318 289 - const countContent = total > 0 && ( 290 - <div className="postDiscussionsCount flex items-center gap-1 text-xs"> 291 - <CommentTiny /> 292 - {total} 293 - </div> 294 - ); 319 + const quoteContent = props.post.quoteCount != null && 320 + props.post.quoteCount > 0 && ( 321 + <div className="postQuoteCount flex items-center gap-1 text-xs"> 322 + <QuoteTiny /> 323 + {props.post.quoteCount} 324 + </div> 325 + ); 295 326 296 327 return ( 297 328 <div className="postCounts flex gap-2 items-center w-full text-tertiary mb-1"> 298 - {countContent && 299 - (props.replyEnabled || props.quoteEnabled ? ( 329 + {replyContent && 330 + (props.replyEnabled ? ( 300 331 <ThreadLink 301 332 postUri={props.post.uri} 302 333 parent={props.parent} 303 - className="relative postDiscussionsLink hover:text-accent-contrast" 334 + className="relative postRepliesLink hover:text-accent-contrast" 304 335 onClick={props.replyOnClick} 305 336 > 306 - {countContent} 337 + {replyContent} 307 338 </ThreadLink> 308 339 ) : ( 309 - countContent 340 + replyContent 341 + ))} 342 + {quoteContent && 343 + (props.quoteEnabled ? ( 344 + <QuotesLink 345 + postUri={props.post.uri} 346 + parent={props.parent} 347 + className="relative hover:text-accent-contrast" 348 + > 349 + {quoteContent} 350 + </QuotesLink> 351 + ) : ( 352 + quoteContent 310 353 ))} 311 354 </div> 312 355 );
+2 -30
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 5 5 import { Json } from "supabase/database.types"; 6 6 import { PubLeafletComment } from "lexicons/api"; 7 7 import { BaseTextBlock } from "../../Blocks/BaseTextBlock"; 8 - import { useEffect, useMemo, useRef, useState } from "react"; 9 - import { useSpring, animated } from "@react-spring/web"; 10 - import useMeasure from "react-use-measure"; 8 + import { useMemo, useState } from "react"; 9 + import { CollapsibleReplies } from "components/CollapsibleReplies"; 11 10 import { CommentTiny } from "components/Icons/CommentTiny"; 12 11 import { Separator } from "components/Layout"; 13 12 import { Popover } from "components/Popover"; ··· 294 293 </CollapsibleReplies> 295 294 )} 296 295 </> 297 - ); 298 - }; 299 - 300 - // Animates the replies list height when it opens/closes instead of snapping 301 - // it in and out of the DOM. 302 - const CollapsibleReplies = (props: { 303 - open: boolean; 304 - children: React.ReactNode; 305 - }) => { 306 - let [ref, { height }] = useMeasure(); 307 - // Skip the spring on the first render with a real height so replies that 308 - // are open by default don't animate in on page load. 309 - let measured = useRef(false); 310 - let style = useSpring({ 311 - height: props.open ? height : 0, 312 - opacity: props.open ? 1 : 0, 313 - immediate: !measured.current, 314 - config: { tension: 280, friction: 30 }, 315 - }); 316 - useEffect(() => { 317 - if (height > 0) measured.current = true; 318 - }, [height]); 319 - 320 - return ( 321 - <animated.div style={{ ...style }}> 322 - <div ref={ref}>{props.children}</div> 323 - </animated.div> 324 296 ); 325 297 }; 326 298
+225 -115
app/(app)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
··· 1 1 "use client"; 2 - import { useEffect, useMemo, useRef } from "react"; 2 + import { Fragment, useEffect, useMemo, useRef, useState } from "react"; 3 3 import { 4 4 AppBskyFeedDefs, 5 5 AppBskyFeedPost, ··· 23 23 getThreadKey, 24 24 fetchThread, 25 25 prefetchThread, 26 + getQuotesKey, 27 + fetchQuotes, 26 28 } from "./PostLinks"; 29 + import { Tabs } from "components/Tabs"; 30 + import { CollapsibleReplies } from "components/CollapsibleReplies"; 27 31 import { useDocument } from "contexts/DocumentContext"; 28 32 import { QuoteContent } from "./Interactions/Quotes"; 29 33 import { ··· 221 225 <ThreadPost post={post} isMainPost={true} pageUri={parentUri} /> 222 226 </div> 223 227 224 - {/* Replies */} 225 - {post.replies && post.replies.length > 0 && ( 226 - <div className="threadReplies flex flex-col mt-4 pt-2 border-t border-border-light w-full"> 227 - <Replies 228 - replies={post.replies as any[]} 229 - pageUri={post.post.uri} 230 - parentPostUri={post.post.uri} 231 - depth={0} 232 - parentAuthorDid={post.post.author.did} 233 - rootAuthorDid={rootAuthorDid} 234 - documentUrls={documentUrls} 235 - docDid={docDid} 236 - /> 228 + {/* Replies and quote posts */} 229 + <ThreadInteractions 230 + post={post} 231 + rootAuthorDid={rootAuthorDid} 232 + documentUrls={documentUrls} 233 + docDid={docDid} 234 + /> 235 + </div> 236 + ); 237 + } 238 + 239 + // Tabbed section under the main post showing its replies and quote posts. 240 + // When only one of the two has content, a header is shown instead of tabs. 241 + function ThreadInteractions(props: { 242 + post: ThreadViewPost; 243 + rootAuthorDid: string; 244 + documentUrls: string[]; 245 + docDid: string; 246 + }) { 247 + const { post, rootAuthorDid, documentUrls, docDid } = props; 248 + 249 + const replies = (post.replies as any[]) ?? []; 250 + const replyCount = post.post.replyCount ?? replies.length; 251 + const quoteCount = post.post.quoteCount ?? 0; 252 + const hasReplies = replies.length > 0; 253 + const hasQuotes = quoteCount > 0; 254 + const showTabs = hasReplies && hasQuotes; 255 + 256 + const [activeTab, setActiveTab] = useState<"replies" | "quotes">( 257 + hasReplies ? "replies" : "quotes", 258 + ); 259 + 260 + if (!hasReplies && !hasQuotes) return null; 261 + 262 + // Default to whichever tab actually has content 263 + const tab = !hasReplies ? "quotes" : !hasQuotes ? "replies" : activeTab; 264 + 265 + return ( 266 + <div className="threadInteractions flex flex-col mt-4 w-full"> 267 + {showTabs ? ( 268 + <Tabs 269 + value={tab} 270 + onChange={(value) => setActiveTab(value)} 271 + options={[ 272 + { value: "replies", label: `Replies (${replyCount})` }, 273 + { value: "quotes", label: `Quote Posts (${quoteCount})` }, 274 + ]} 275 + /> 276 + ) : ( 277 + <div className="text-tertiary text-sm font-bold"> 278 + {hasReplies 279 + ? `Replies (${replyCount})` 280 + : `Quote Posts (${quoteCount})`} 281 + <hr className="border-border-light mt-[6px]" /> 237 282 </div> 238 283 )} 284 + 285 + {tab === "replies" ? ( 286 + <Replies 287 + replies={replies} 288 + pageUri={post.post.uri} 289 + parentPostUri={post.post.uri} 290 + depth={0} 291 + parentAuthorDid={post.post.author.did} 292 + rootAuthorDid={rootAuthorDid} 293 + documentUrls={documentUrls} 294 + docDid={docDid} 295 + /> 296 + ) : ( 297 + <ThreadQuotes postUri={post.post.uri} pageUri={post.post.uri} /> 298 + )} 299 + </div> 300 + ); 301 + } 302 + 303 + // Fetches and renders the posts that quote the main post 304 + function ThreadQuotes(props: { postUri: string; pageUri: string }) { 305 + const { 306 + data: quotesData, 307 + isLoading, 308 + error, 309 + } = useSWR(getQuotesKey(props.postUri), () => fetchQuotes(props.postUri)); 310 + 311 + if (isLoading) { 312 + return ( 313 + <div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8"> 314 + <span>loading quotes</span> 315 + <DotLoader /> 316 + </div> 317 + ); 318 + } 319 + 320 + if (error) { 321 + return ( 322 + <div className="text-tertiary italic text-sm text-center py-8"> 323 + Failed to load quotes 324 + </div> 325 + ); 326 + } 327 + 328 + if (!quotesData || quotesData.posts.length === 0) { 329 + return ( 330 + <div className="text-tertiary italic text-sm text-center py-8"> 331 + No quotes yet 332 + </div> 333 + ); 334 + } 335 + 336 + return ( 337 + <div className="flex flex-col gap-0 pt-4"> 338 + {quotesData.posts.map((post, index) => { 339 + const parent = { type: "thread" as const, uri: props.pageUri }; 340 + // let isPinnedPost = post.uri === 341 + return ( 342 + <> 343 + <BskyPostContent 344 + key={post.uri} 345 + post={post} 346 + parent={parent} 347 + showEmbed 348 + compactEmbed 349 + showBlueskyLink 350 + quoteEnabled 351 + replyEnabled 352 + className="relative rounded text-sm" 353 + /> 354 + 355 + <hr className="last:hidden border-border-light my-4" /> 356 + </> 357 + ); 358 + })} 239 359 </div> 240 360 ); 241 361 } ··· 257 377 parent={page} 258 378 avatarSize="large" 259 379 showBlueskyLink={true} 380 + showInteractions={false} 260 381 showEmbed={true} 261 382 compactEmbed 262 383 quoteEnabled ··· 300 421 documentUrls, 301 422 docDid, 302 423 } = props; 303 - const collapsedThreads = useThreadState((s) => s.collapsedThreads); 304 - const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); 305 - 306 424 // Sort replies so that replies from the parent author come first 307 425 const sortedReplies = useMemo( 308 426 () => ··· 323 441 ); 324 442 325 443 return ( 326 - <div className="replies flex flex-col gap-0 w-full"> 444 + <div 445 + className={`replies flex flex-col w-full pt-4 ${ 446 + props.depth === 0 ? "gap-0" : "gap-8" 447 + }`} 448 + > 327 449 {sortedReplies.map((reply, index) => { 328 450 if (AppBskyFeedDefs.isNotFoundPost(reply)) { 329 451 return ( ··· 352 474 } 353 475 354 476 const hasReplies = reply.replies && reply.replies.length > 0; 355 - const isCollapsed = collapsedThreads.has(reply.post.uri); 356 477 357 478 return ( 358 - <ReplyPost 359 - key={reply.post.uri} 360 - post={reply} 361 - isLast={index === replies.length - 1 && !hasReplies} 362 - pageUri={pageUri} 363 - parentPostUri={parentPostUri} 364 - toggleCollapsed={toggleCollapsed} 365 - isCollapsed={isCollapsed} 366 - depth={props.depth} 367 - rootAuthorDid={rootAuthorDid} 368 - documentUrls={documentUrls} 369 - docDid={docDid} 370 - /> 479 + <> 480 + <ReplyPost 481 + key={reply.post.uri} 482 + post={reply} 483 + isLast={index === replies.length - 1 && !hasReplies} 484 + pageUri={pageUri} 485 + depth={props.depth} 486 + rootAuthorDid={rootAuthorDid} 487 + documentUrls={documentUrls} 488 + docDid={docDid} 489 + /> 490 + {props.depth === 0 && ( 491 + <hr className="border-border-light my-4 last:hidden" /> 492 + )} 493 + </> 371 494 ); 372 495 })} 373 496 </div> 374 497 ); 375 498 } 376 499 500 + // Wraps a nested reply list in the same indented thread-line + collapse 501 + // affordance used by document comment replies (Interactions/Comments), and 502 + // animates its height when it opens/closes so threads collapse with the same 503 + // motion as comments. 504 + function NestedReplies(props: { 505 + open: boolean; 506 + onCollapse: () => void; 507 + children: React.ReactNode; 508 + }) { 509 + return ( 510 + <CollapsibleReplies open={props.open}> 511 + <div className="repliesWrapper relative pt-1 pl-[26px] "> 512 + {/* the thread line itself is non-interactive; a transparent button is 513 + overlaid on top of it (z-10) so clicking the line collapses the 514 + thread. The button has to sit over the line (left-[28px]) rather 515 + than in the empty gutter, otherwise clicks land on the post's 516 + absolute-inset overlay underneath and open the thread instead. */} 517 + <div className="absolute top-0 bottom-0 left-[38px] w-[2px] bg-border-light pointer-events-none" /> 518 + <button 519 + className="repliesCollapse absolute top-0 bottom-0 left-[28px] w-[20px] z-10" 520 + onClick={(e) => { 521 + e.preventDefault(); 522 + e.stopPropagation(); 523 + props.onCollapse(); 524 + }} 525 + /> 526 + {props.children} 527 + </div> 528 + </CollapsibleReplies> 529 + ); 530 + } 531 + 377 532 const ReplyPost = (props: { 378 533 post: ThreadViewPost; 379 534 isLast: boolean; 380 535 pageUri: string; 381 - parentPostUri: string; 382 - toggleCollapsed: (uri: string) => void; 383 - isCollapsed: boolean; 384 536 depth: number; 385 537 rootAuthorDid: string; 386 538 documentUrls: string[]; 387 539 docDid: string; 388 540 }) => { 389 - const { post, pageUri, parentPostUri, rootAuthorDid, documentUrls, docDid } = 390 - props; 541 + const { post, pageUri, rootAuthorDid, documentUrls, docDid } = props; 542 + const collapsedThreads = useThreadState((s) => s.collapsedThreads); 543 + const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); 391 544 392 545 // Flatten same-author chains 393 546 const chain = flattenSameAuthorChain(post, rootAuthorDid); 394 547 const lastInChain = chain[chain.length - 1]; 395 548 const hasReplies = lastInChain.replies && lastInChain.replies.length > 0; 549 + const isCollapsed = collapsedThreads.has(lastInChain.post.uri); 396 550 const isTruncated = 397 551 !hasReplies && 398 552 lastInChain.post.replyCount != null && ··· 400 554 401 555 return ( 402 556 <div className="flex h-fit relative"> 403 - {props.depth > 0 && ( 404 - <> 405 - <div className="absolute replyLine top-0 bottom-0 left-0 w-6 pointer-events-none "> 406 - <div className="bg-border-light w-[2px] h-full mx-auto" /> 407 - </div> 408 - <button 409 - className="absolute top-0 bottom-0 left-0 w-6 z-10" 410 - onClick={(e) => { 411 - e.preventDefault(); 412 - e.stopPropagation(); 413 - props.toggleCollapsed(parentPostUri); 414 - }} 415 - /> 416 - </> 417 - )} 418 - <div 419 - className={`reply relative flex flex-col w-full ${props.depth === 0 && "mb-3"}`} 420 - > 421 - {/* Render chain: intermediate posts compact, last post full */} 422 - {chain.length > 1 ? ( 423 - <> 424 - {chain.slice(0, -1).map((chainPost) => ( 425 - <div 426 - key={chainPost.post.uri} 427 - className="flex gap-2 relative w-full pl-[6px] pb-2" 428 - > 429 - <div className="absolute top-0 bottom-0 left-[6px] w-5"> 430 - <div className="bg-border-light w-[2px] h-full mx-auto" /> 431 - </div> 432 - <ReplyPostContent 433 - post={chainPost.post} 434 - pageUri={pageUri} 435 - documentUrls={documentUrls} 436 - docDid={docDid} 437 - compact 438 - /> 439 - </div> 440 - ))} 441 - <ReplyPostContent 442 - post={lastInChain.post} 557 + <div className={`reply relative flex flex-col w-full `}> 558 + <ReplyPostContent 559 + post={post.post} 560 + pageUri={pageUri} 561 + documentUrls={documentUrls} 562 + docDid={docDid} 563 + toggleCollapsed={() => toggleCollapsed(post.post.uri)} 564 + /> 565 + 566 + {/* Render child replies, styled like replies to a comment */} 567 + {hasReplies && props.depth < 10 && ( 568 + <NestedReplies 569 + open={!isCollapsed} 570 + onCollapse={() => toggleCollapsed(lastInChain.post.uri)} 571 + > 572 + <Replies 443 573 pageUri={pageUri} 574 + parentPostUri={lastInChain.post.uri} 575 + replies={lastInChain.replies as any[]} 576 + depth={props.depth + 1} 577 + parentAuthorDid={lastInChain.post.author.did} 578 + rootAuthorDid={rootAuthorDid} 444 579 documentUrls={documentUrls} 445 580 docDid={docDid} 446 - compact 447 - toggleCollapsed={() => 448 - props.toggleCollapsed(lastInChain.post.uri) 449 - } 450 581 /> 451 - </> 452 - ) : ( 453 - <ReplyPostContent 454 - post={post.post} 455 - pageUri={pageUri} 456 - documentUrls={documentUrls} 457 - docDid={docDid} 458 - toggleCollapsed={() => props.toggleCollapsed(post.post.uri)} 459 - /> 582 + </NestedReplies> 460 583 )} 461 584 462 - {/* Render child replies */} 463 - {hasReplies && props.depth < 10 && ( 464 - <div className="ml-[28px] flex grow"> 465 - {!props.isCollapsed && ( 466 - <Replies 585 + {/* Auto-load truncated replies */} 586 + {isTruncated && props.depth < 10 && ( 587 + <NestedReplies 588 + open={!isCollapsed} 589 + onCollapse={() => toggleCollapsed(lastInChain.post.uri)} 590 + > 591 + {!isCollapsed && ( 592 + <SubThread 593 + postUri={lastInChain.post.uri} 467 594 pageUri={pageUri} 468 - parentPostUri={lastInChain.post.uri} 469 - replies={lastInChain.replies as any[]} 470 - depth={props.depth + 1} 471 - parentAuthorDid={lastInChain.post.author.did} 595 + depth={props.depth} 472 596 rootAuthorDid={rootAuthorDid} 473 597 documentUrls={documentUrls} 474 598 docDid={docDid} 475 599 /> 476 600 )} 477 - </div> 478 - )} 479 - 480 - {/* Auto-load truncated replies */} 481 - {isTruncated && props.depth < 10 && !props.isCollapsed && ( 482 - <div className="ml-[28px] flex grow"> 483 - <SubThread 484 - postUri={lastInChain.post.uri} 485 - pageUri={pageUri} 486 - depth={props.depth} 487 - rootAuthorDid={rootAuthorDid} 488 - documentUrls={documentUrls} 489 - docDid={docDid} 490 - /> 491 - </div> 601 + </NestedReplies> 492 602 )} 493 603 494 604 {/* Safety fallback at extreme depth */} ··· 569 679 } 570 680 : undefined 571 681 } 572 - className=" text-sm pt-4" 682 + className=" text-sm" 573 683 /> 574 684 </div> 575 685 );
+39
components/CollapsibleReplies.tsx
··· 1 + "use client"; 2 + import { useEffect, useRef } from "react"; 3 + import { useSpring, animated } from "@react-spring/web"; 4 + import useMeasure from "react-use-measure"; 5 + 6 + // Animates a replies list's height when it opens/closes instead of snapping 7 + // it in and out of the DOM. Shared by document comments and Bluesky thread 8 + // replies so both collapse with the same motion. 9 + export const CollapsibleReplies = (props: { 10 + open: boolean; 11 + children: React.ReactNode; 12 + }) => { 13 + let [ref, { height }] = useMeasure(); 14 + // Skip the spring on the first render with a real height so replies that 15 + // are open by default don't animate in on page load. 16 + let measured = useRef(false); 17 + let style = useSpring({ 18 + height: props.open ? height : 0, 19 + opacity: props.open ? 1 : 0, 20 + immediate: !measured.current, 21 + config: { tension: 280, friction: 30 }, 22 + }); 23 + useEffect(() => { 24 + if (height > 0) measured.current = true; 25 + }, [height]); 26 + 27 + // overflow:hidden is needed to animate height, but it also clips horizontally. 28 + // Reply rows pull their avatar slightly left (negative margins) to sit on the 29 + // thread line, so extend the clip box leftward (pl-2 + matching -ml-2 keeps the 30 + // content in place) to keep avatars from getting shaved off. 31 + return ( 32 + <animated.div 33 + className="pl-2 -ml-2" 34 + style={{ ...style, overflow: "hidden" }} 35 + > 36 + <div ref={ref}>{props.children}</div> 37 + </animated.div> 38 + ); 39 + };
+41
components/Tabs.tsx
··· 1 + import { type ReactNode } from "react"; 2 + 3 + export function Tabs<T extends string>(props: { 4 + value: T; 5 + onChange: (value: T, e?: React.MouseEvent) => void; 6 + options: { value: T; label: ReactNode }[]; 7 + className?: string; 8 + optionClassName?: string; 9 + selectedOptionClassName?: string; 10 + }) { 11 + return ( 12 + <div className="w-full"> 13 + <div 14 + className={`tabs flex gap-4 justify-start w-full ${props.className || ""}`} 15 + > 16 + {props.options.map((option) => { 17 + const selected = props.value === option.value; 18 + return ( 19 + <button 20 + key={option.value} 21 + type="button" 22 + className={`tab text-sm font-bold pb-0.5 px-1 border-b-3 ${ 23 + selected 24 + ? `text-accent-contrast border-accent-contrast ${props.selectedOptionClassName || ""}` 25 + : "text-tertiary border-transparent" 26 + } ${props.optionClassName || ""}`} 27 + onClick={(e) => { 28 + e.preventDefault(); 29 + e.stopPropagation(); 30 + props.onChange(option.value, e); 31 + }} 32 + > 33 + {option.label} 34 + </button> 35 + ); 36 + })} 37 + </div> 38 + <hr className="border-border-light" /> 39 + </div> 40 + ); 41 + }