a tool for shared writing and social publishing

implement collapsing thread replies

Changed files
+59 -12
app
lish
[did]
[publication]
+59 -12
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
··· 1 1 "use client"; 2 + import { useEffect, useRef } from "react"; 2 3 import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 3 4 import useSWR, { preload } from "swr"; 4 5 import { PageWrapper } from "components/Pages/Page"; ··· 16 17 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 17 18 import { openPage, OpenPage } from "./PostPages"; 18 19 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 20 + import { useThreadState } from "src/useThreadState"; 21 + import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 19 22 20 23 type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; 21 24 type NotFoundPost = AppBskyFeedDefs.NotFoundPost; ··· 120 123 121 124 function ThreadContent(props: { thread: ThreadType; threadUri: string }) { 122 125 const { thread, threadUri } = props; 126 + const mainPostRef = useRef<HTMLDivElement>(null); 127 + 128 + // Scroll the main post into view when the thread loads 129 + useEffect(() => { 130 + if (mainPostRef.current) { 131 + mainPostRef.current.scrollIntoView({ 132 + behavior: "instant", 133 + block: "start", 134 + }); 135 + } 136 + }, []); 123 137 124 138 if (AppBskyFeedDefs.isNotFoundPost(thread)) { 125 139 return <PostNotAvailable />; ··· 160 174 ))} 161 175 162 176 {/* Main post */} 163 - <ThreadPost 164 - post={thread} 165 - isMainPost={true} 166 - showReplyLine={false} 167 - threadUri={threadUri} 168 - /> 177 + <div ref={mainPostRef}> 178 + <ThreadPost 179 + post={thread} 180 + isMainPost={true} 181 + showReplyLine={false} 182 + threadUri={threadUri} 183 + /> 184 + </div> 169 185 170 186 {/* Replies */} 171 187 {thread.replies && thread.replies.length > 0 && ( ··· 280 296 depth: number; 281 297 }) { 282 298 const { replies, threadUri, depth } = props; 299 + const collapsedThreads = useThreadState((s) => s.collapsedThreads); 300 + const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); 283 301 284 302 return ( 285 303 <div className="flex flex-col gap-0"> ··· 311 329 } 312 330 313 331 const hasReplies = reply.replies && reply.replies.length > 0; 332 + const isCollapsed = collapsedThreads.has(reply.post.uri); 333 + const replyCount = reply.replies?.length ?? 0; 314 334 315 335 return ( 316 336 <div key={reply.post.uri} className="flex flex-col"> ··· 321 341 threadUri={threadUri} 322 342 /> 323 343 {hasReplies && depth < 3 && ( 324 - <div className="ml-5 pl-5 border-l border-border-light"> 325 - <Replies 326 - replies={reply.replies as any[]} 327 - threadUri={threadUri} 328 - depth={depth + 1} 329 - /> 344 + <div className="ml-2 flex"> 345 + {/* Clickable collapse line - w-8 matches avatar width, centered line aligns with avatar center */} 346 + <button 347 + onClick={(e) => { 348 + e.stopPropagation(); 349 + toggleCollapsed(reply.post.uri); 350 + }} 351 + className="group w-8 flex justify-center cursor-pointer shrink-0" 352 + aria-label={ 353 + isCollapsed ? "Expand replies" : "Collapse replies" 354 + } 355 + > 356 + <div className="w-0.5 h-full bg-border-light group-hover:bg-accent-contrast group-hover:w-1 transition-all" /> 357 + </button> 358 + {isCollapsed ? ( 359 + <button 360 + onClick={(e) => { 361 + e.stopPropagation(); 362 + toggleCollapsed(reply.post.uri); 363 + }} 364 + className="text-xs text-accent-contrast hover:underline py-1 pl-1" 365 + > 366 + Show {replyCount} {replyCount === 1 ? "reply" : "replies"} 367 + </button> 368 + ) : ( 369 + <div className="grow"> 370 + <Replies 371 + replies={reply.replies as any[]} 372 + threadUri={threadUri} 373 + depth={depth + 1} 374 + /> 375 + </div> 376 + )} 330 377 </div> 331 378 )} 332 379 {hasReplies && depth >= 3 && (