+59
-12
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
+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 && (