From d3b30722115a673621e906e24f2b8ec5e8efd838 Mon Sep 17 00:00:00 2001 From: celine Date: Sat, 30 May 2026 01:32:46 -0400 Subject: [PATCH] tabbing replies and quotes in the bluesky thread viewer, fixing a ton of styling --- .../[rkey]/BlueskyQuotesPage.tsx | 2 +- .../[publication]/[rkey]/BskyPostContent.tsx | 123 ++++--- .../[rkey]/Interactions/Comments/index.tsx | 32 +- .../[did]/[publication]/[rkey]/ThreadPage.tsx | 340 ++++++++++++------ components/CollapsibleReplies.tsx | 39 ++ components/Tabs.tsx | 41 +++ 6 files changed, 391 insertions(+), 186 deletions(-) create mode 100644 components/CollapsibleReplies.tsx create mode 100644 components/Tabs.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx index 95ba6444..3e45e21e 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx @@ -71,7 +71,7 @@ function QuotesContent(props: { posts: PostView[]; postUri: string }) { const { posts, postUri } = props; return ( -
+
{posts.map((post, index) => { const parent = { type: "quotes" as const, uri: postUri }; return ( diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx index baab0488..6f1fb5d5 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx @@ -4,11 +4,12 @@ import { BlueskyEmbed } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; import { BlueskyTiny } from "components/Icons/BlueskyTiny"; import { CommentTiny } from "components/Icons/CommentTiny"; +import { QuoteTiny } from "components/Icons/QuoteTiny"; import { Separator } from "components/Layout"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; import { useHasPageLoaded } from "components/InitialPageLoadProvider"; import { OpenPage, openPage } from "./postPageState"; -import { ThreadLink } from "./PostLinks"; +import { ThreadLink, QuotesLink } from "./PostLinks"; import { BlueskyLinkTiny } from "components/Icons/BlueskyLinkTiny"; import { Avatar } from "components/Avatar"; import { timeAgo } from "src/utils/timeAgo"; @@ -26,6 +27,7 @@ export function BskyPostContent(props: { showEmbed?: boolean; compactEmbed?: boolean; showBlueskyLink?: boolean; + showInteractions?: boolean; quoteEnabled?: boolean; replyEnabled?: boolean; replyOnClick?: (e: React.MouseEvent) => void; @@ -43,6 +45,7 @@ export function BskyPostContent(props: { showEmbed = true, compactEmbed = false, showBlueskyLink = true, + showInteractions = true, quoteEnabled, replyEnabled, replyOnClick, @@ -54,14 +57,20 @@ export function BskyPostContent(props: { const postId = post.uri.split("/")[4]; const url = `https://${clientHost}/profile/${post.author.handle}/post/${postId}`; + // Only allow opening the thread page when there's a discussion to show + const hasThreadContent = + (post.replyCount ?? 0) > 0 || (post.quoteCount ?? 0) > 0; + return (
-
- {/* Replies */} - {post.replies && post.replies.length > 0 && ( -
- + {/* Replies and quote posts */} + +
+ ); +} + +// Tabbed section under the main post showing its replies and quote posts. +// When only one of the two has content, a header is shown instead of tabs. +function ThreadInteractions(props: { + post: ThreadViewPost; + rootAuthorDid: string; + documentUrls: string[]; + docDid: string; +}) { + const { post, rootAuthorDid, documentUrls, docDid } = props; + + const replies = (post.replies as any[]) ?? []; + const replyCount = post.post.replyCount ?? replies.length; + const quoteCount = post.post.quoteCount ?? 0; + const hasReplies = replies.length > 0; + const hasQuotes = quoteCount > 0; + const showTabs = hasReplies && hasQuotes; + + const [activeTab, setActiveTab] = useState<"replies" | "quotes">( + hasReplies ? "replies" : "quotes", + ); + + if (!hasReplies && !hasQuotes) return null; + + // Default to whichever tab actually has content + const tab = !hasReplies ? "quotes" : !hasQuotes ? "replies" : activeTab; + + return ( +
+ {showTabs ? ( + setActiveTab(value)} + options={[ + { value: "replies", label: `Replies (${replyCount})` }, + { value: "quotes", label: `Quote Posts (${quoteCount})` }, + ]} + /> + ) : ( +
+ {hasReplies + ? `Replies (${replyCount})` + : `Quote Posts (${quoteCount})`} +
)} + + {tab === "replies" ? ( + + ) : ( + + )} +
+ ); +} + +// Fetches and renders the posts that quote the main post +function ThreadQuotes(props: { postUri: string; pageUri: string }) { + const { + data: quotesData, + isLoading, + error, + } = useSWR(getQuotesKey(props.postUri), () => fetchQuotes(props.postUri)); + + if (isLoading) { + return ( +
+ loading quotes + +
+ ); + } + + if (error) { + return ( +
+ Failed to load quotes +
+ ); + } + + if (!quotesData || quotesData.posts.length === 0) { + return ( +
+ No quotes yet +
+ ); + } + + return ( +
+ {quotesData.posts.map((post, index) => { + const parent = { type: "thread" as const, uri: props.pageUri }; + // let isPinnedPost = post.uri === + return ( + <> + + +
+ + ); + })}
); } @@ -257,6 +377,7 @@ function ThreadPost(props: { parent={page} avatarSize="large" showBlueskyLink={true} + showInteractions={false} showEmbed={true} compactEmbed quoteEnabled @@ -300,9 +421,6 @@ function Replies(props: { documentUrls, docDid, } = props; - const collapsedThreads = useThreadState((s) => s.collapsedThreads); - const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); - // Sort replies so that replies from the parent author come first const sortedReplies = useMemo( () => @@ -323,7 +441,11 @@ function Replies(props: { ); return ( -
+
{sortedReplies.map((reply, index) => { if (AppBskyFeedDefs.isNotFoundPost(reply)) { return ( @@ -352,47 +474,79 @@ function Replies(props: { } const hasReplies = reply.replies && reply.replies.length > 0; - const isCollapsed = collapsedThreads.has(reply.post.uri); return ( - + <> + + {props.depth === 0 && ( +
+ )} + ); })}
); } +// Wraps a nested reply list in the same indented thread-line + collapse +// affordance used by document comment replies (Interactions/Comments), and +// animates its height when it opens/closes so threads collapse with the same +// motion as comments. +function NestedReplies(props: { + open: boolean; + onCollapse: () => void; + children: React.ReactNode; +}) { + return ( + +
+ {/* the thread line itself is non-interactive; a transparent button is + overlaid on top of it (z-10) so clicking the line collapses the + thread. The button has to sit over the line (left-[28px]) rather + than in the empty gutter, otherwise clicks land on the post's + absolute-inset overlay underneath and open the thread instead. */} +
+
+ + ); +} + const ReplyPost = (props: { post: ThreadViewPost; isLast: boolean; pageUri: string; - parentPostUri: string; - toggleCollapsed: (uri: string) => void; - isCollapsed: boolean; depth: number; rootAuthorDid: string; documentUrls: string[]; docDid: string; }) => { - const { post, pageUri, parentPostUri, rootAuthorDid, documentUrls, docDid } = - props; + const { post, pageUri, rootAuthorDid, documentUrls, docDid } = props; + const collapsedThreads = useThreadState((s) => s.collapsedThreads); + const toggleCollapsed = useThreadState((s) => s.toggleCollapsed); // Flatten same-author chains const chain = flattenSameAuthorChain(post, rootAuthorDid); const lastInChain = chain[chain.length - 1]; const hasReplies = lastInChain.replies && lastInChain.replies.length > 0; + const isCollapsed = collapsedThreads.has(lastInChain.post.uri); const isTruncated = !hasReplies && lastInChain.post.replyCount != null && @@ -400,95 +554,51 @@ const ReplyPost = (props: { return (
- {props.depth > 0 && ( - <> -
-
-
- + ); + })} +
+
+
+ ); +} -- 2.51.2