From ae3813a45a42e2fde422959e327d51417bb125d1 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Sat, 20 Dec 2025 06:20:11 +0400 Subject: [PATCH] show quotes on bsky posts --- .../[rkey]/BlueskyQuotesPage.tsx | 105 +++++++ .../[publication]/[rkey]/BskyPostContent.tsx | 182 ++++++++++++ .../[rkey]/Interactions/Quotes.tsx | 38 ++- .../[did]/[publication]/[rkey]/PostLinks.tsx | 118 ++++++++ .../[did]/[publication]/[rkey]/PostPages.tsx | 25 +- .../[rkey]/PublishBskyPostBlock.tsx | 17 +- .../[did]/[publication]/[rkey]/ThreadPage.tsx | 267 ++++-------------- 7 files changed, 525 insertions(+), 227 deletions(-) create mode 100644 app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx create mode 100644 app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx create mode 100644 app/lish/[did]/[publication]/[rkey]/PostLinks.tsx diff --git a/app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx b/app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx new file mode 100644 index 00000000..299969a0 --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx @@ -0,0 +1,105 @@ +"use client"; +import { AppBskyFeedDefs } from "@atproto/api"; +import useSWR from "swr"; +import { PageWrapper } from "components/Pages/Page"; +import { useDrawerOpen } from "./Interactions/InteractionDrawer"; +import { DotLoader } from "components/utils/DotLoader"; +import { QuoteTiny } from "components/Icons/QuoteTiny"; +import { openPage } from "./PostPages"; +import { BskyPostContent } from "./BskyPostContent"; +import { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes } from "./PostLinks"; + +// Re-export for backwards compatibility +export { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes }; + +type PostView = AppBskyFeedDefs.PostView; + +export function BlueskyQuotesPage(props: { + postUri: string; + pageId: string; + pageOptions?: React.ReactNode; + hasPageBackground: boolean; +}) { + const { postUri, pageId, pageOptions } = props; + const drawer = useDrawerOpen(postUri); + + const { + data: quotesData, + isLoading, + error, + } = useSWR(postUri ? getQuotesKey(postUri) : null, () => fetchQuotes(postUri)); + + return ( + +
+
+ + Bluesky Quotes +
+ {isLoading ? ( +
+ loading quotes + +
+ ) : error ? ( +
+ Failed to load quotes +
+ ) : quotesData && quotesData.posts.length > 0 ? ( + + ) : ( +
+ No quotes yet +
+ )} +
+
+ ); +} + +function QuotesContent(props: { posts: PostView[]; postUri: string }) { + const { posts, postUri } = props; + + return ( +
+ {posts.map((post) => ( + + ))} +
+ ); +} + +function QuotePost(props: { + post: PostView; + quotesUri: string; +}) { + const { post, quotesUri } = props; + const parent = { type: "quotes" as const, uri: quotesUri }; + + return ( +
openPage(parent, { type: "thread", uri: post.uri })} + > + e.stopPropagation()} + onEmbedClick={(e) => e.stopPropagation()} + /> +
+ ); +} diff --git a/app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx b/app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx new file mode 100644 index 00000000..579a9255 --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx @@ -0,0 +1,182 @@ +"use client"; +import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; +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 } from "./PostPages"; +import { ThreadLink, QuotesLink } from "./PostLinks"; + +type PostView = AppBskyFeedDefs.PostView; + +export function BskyPostContent(props: { + post: PostView; + parent?: OpenPage; + linksEnabled?: boolean; + avatarSize?: "sm" | "md"; + showEmbed?: boolean; + showBlueskyLink?: boolean; + onEmbedClick?: (e: React.MouseEvent) => void; + onLinkClick?: (e: React.MouseEvent) => void; +}) { + const { + post, + parent, + linksEnabled = true, + avatarSize = "md", + showEmbed = true, + showBlueskyLink = true, + onEmbedClick, + onLinkClick, + } = props; + + const record = post.record as AppBskyFeedPost.Record; + const postId = post.uri.split("/")[4]; + const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; + + const avatarClass = avatarSize === "sm" ? "w-8 h-8" : "w-10 h-10"; + + return ( + <> +
+ {post.author.avatar ? ( + {`${post.author.displayName}'s + ) : ( +
+ )} +
+ +
+
+
+ {post.author.displayName} +
+ + @{post.author.handle} + +
+ +
+
+ +
+ {showEmbed && post.embed && ( +
+ +
+ )} +
+ +
+ + +
+
+ + ); +} + +function PostCounts(props: { + post: PostView; + parent?: OpenPage; + linksEnabled: boolean; + showBlueskyLink: boolean; + url: string; + onLinkClick?: (e: React.MouseEvent) => void; +}) { + const { post, parent, linksEnabled, showBlueskyLink, url, onLinkClick } = props; + + return ( +
+ {post.replyCount != null && post.replyCount > 0 && ( + <> + + {linksEnabled ? ( + + {post.replyCount} + + + ) : ( +
+ {post.replyCount} + +
+ )} + + )} + {post.quoteCount != null && post.quoteCount > 0 && ( + <> + + + {post.quoteCount} + + + + )} + {showBlueskyLink && ( + <> + + + + + + )} +
+ ); +} + +export const ClientDate = (props: { date?: string }) => { + const pageLoaded = useHasPageLoaded(); + const formattedDate = useLocalizedDate( + props.date || new Date().toISOString(), + { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", + hour12: true, + }, + ); + + if (!pageLoaded) return null; + + return
{formattedDate}
; +}; diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx index 1d30e991..5ea14575 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx @@ -23,7 +23,8 @@ import { openPage } from "../PostPages"; import useSWR, { mutate } from "swr"; import { DotLoader } from "components/utils/DotLoader"; import { CommentTiny } from "components/Icons/CommentTiny"; -import { ThreadLink } from "../ThreadPage"; +import { QuoteTiny } from "components/Icons/QuoteTiny"; +import { ThreadLink, QuotesLink } from "../PostLinks"; // Helper to get SWR key for quotes export function getQuotesSWRKey(uris: string[]) { @@ -138,6 +139,7 @@ export const Quotes = (props: { profile={pv.author} handle={pv.author.handle} replyCount={pv.replyCount} + quoteCount={pv.quoteCount} />
); @@ -161,6 +163,7 @@ export const Quotes = (props: { profile={pv.author} handle={pv.author.handle} replyCount={pv.replyCount} + quoteCount={pv.quoteCount} /> ); })} @@ -252,6 +255,7 @@ export const BskyPost = (props: { handle: string; profile: ProfileViewBasic; replyCount?: number; + quoteCount?: number; }) => { const handleOpenThread = () => { openPage(undefined, { type: "thread", uri: props.uri }); @@ -282,16 +286,28 @@ export const BskyPost = (props: {
{props.content}
- {props.replyCount != null && props.replyCount > 0 && ( - e.stopPropagation()} - className="flex items-center gap-1 text-tertiary text-xs mt-1 hover:text-accent-contrast" - > - - {props.replyCount} {props.replyCount === 1 ? "reply" : "replies"} - - )} +
+ {props.replyCount != null && props.replyCount > 0 && ( + e.stopPropagation()} + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" + > + + {props.replyCount} {props.replyCount === 1 ? "reply" : "replies"} + + )} + {props.quoteCount != null && props.quoteCount > 0 && ( + e.stopPropagation()} + className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" + > + + {props.quoteCount} {props.quoteCount === 1 ? "quote" : "quotes"} + + )} +
); diff --git a/app/lish/[did]/[publication]/[rkey]/PostLinks.tsx b/app/lish/[did]/[publication]/[rkey]/PostLinks.tsx new file mode 100644 index 00000000..80da034b --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/PostLinks.tsx @@ -0,0 +1,118 @@ +"use client"; +import { AppBskyFeedDefs } from "@atproto/api"; +import { preload } from "swr"; +import { openPage, OpenPage } from "./PostPages"; + +type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; +type NotFoundPost = AppBskyFeedDefs.NotFoundPost; +type BlockedPost = AppBskyFeedDefs.BlockedPost; +type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost; + +type PostView = AppBskyFeedDefs.PostView; + +export interface QuotesResponse { + uri: string; + cid?: string; + cursor?: string; + posts: PostView[]; +} + +// Thread fetching +export const getThreadKey = (uri: string) => `thread:${uri}`; + +export async function fetchThread(uri: string): Promise { + const params = new URLSearchParams({ uri }); + const response = await fetch(`/api/bsky/thread?${params.toString()}`); + + if (!response.ok) { + throw new Error("Failed to fetch thread"); + } + + return response.json(); +} + +export const prefetchThread = (uri: string) => { + preload(getThreadKey(uri), () => fetchThread(uri)); +}; + +// Quotes fetching +export const getQuotesKey = (uri: string) => `quotes:${uri}`; + +export async function fetchQuotes(uri: string): Promise { + const params = new URLSearchParams({ uri }); + const response = await fetch(`/api/bsky/quotes?${params.toString()}`); + + if (!response.ok) { + throw new Error("Failed to fetch quotes"); + } + + return response.json(); +} + +export const prefetchQuotes = (uri: string) => { + preload(getQuotesKey(uri), () => fetchQuotes(uri)); +}; + +// Link component for opening thread pages with prefetching +export function ThreadLink(props: { + threadUri: string; + parent?: OpenPage; + children: React.ReactNode; + className?: string; + onClick?: (e: React.MouseEvent) => void; +}) { + const { threadUri, parent, children, className, onClick } = props; + + const handleClick = (e: React.MouseEvent) => { + onClick?.(e); + if (e.defaultPrevented) return; + openPage(parent, { type: "thread", uri: threadUri }); + }; + + const handlePrefetch = () => { + prefetchThread(threadUri); + }; + + return ( + + ); +} + +// Link component for opening quotes pages with prefetching +export function QuotesLink(props: { + postUri: string; + parent?: OpenPage; + children: React.ReactNode; + className?: string; + onClick?: (e: React.MouseEvent) => void; +}) { + const { postUri, parent, children, className, onClick } = props; + + const handleClick = (e: React.MouseEvent) => { + onClick?.(e); + if (e.defaultPrevented) return; + openPage(parent, { type: "quotes", uri: postUri }); + }; + + const handlePrefetch = () => { + prefetchQuotes(postUri); + }; + + return ( + + ); +} diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx index 1476ad55..307628f2 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -25,15 +25,18 @@ import { PollData } from "./fetchPollData"; import { LinearDocumentPage } from "./LinearDocumentPage"; import { CanvasPage } from "./CanvasPage"; import { ThreadPage as ThreadPageComponent } from "./ThreadPage"; +import { BlueskyQuotesPage } from "./BlueskyQuotesPage"; // Page types export type DocPage = { type: "doc"; id: string }; export type ThreadPage = { type: "thread"; uri: string }; -export type OpenPage = DocPage | ThreadPage; +export type QuotesPage = { type: "quotes"; uri: string }; +export type OpenPage = DocPage | ThreadPage | QuotesPage; // Get a stable key for a page const getPageKey = (page: OpenPage): string => { if (page.type === "doc") return page.id; + if (page.type === "quotes") return `quotes:${page.uri}`; return `thread:${page.uri}`; }; @@ -292,6 +295,26 @@ export function PostPages({ ); } + // Handle quotes pages + if (openPage.type === "quotes") { + return ( + + + closePage(openPage)} + hasPageBackground={hasPageBackground} + /> + } + /> + + ); + } + // Handle document pages let page = record.pages.find( (p) => diff --git a/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx b/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx index db63ac6b..0f6ae045 100644 --- a/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx @@ -4,6 +4,8 @@ import { Separator } from "components/Layout"; import { useHasPageLoaded } from "components/InitialPageLoadProvider"; import { BlueskyTiny } from "components/Icons/BlueskyTiny"; import { CommentTiny } from "components/Icons/CommentTiny"; +import { QuoteTiny } from "components/Icons/QuoteTiny"; +import { ThreadLink, QuotesLink } from "./PostLinks"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; import { BlueskyEmbed, @@ -11,7 +13,6 @@ import { } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; import { openPage } from "./PostPages"; -import { ThreadLink } from "./ThreadPage"; export const PubBlueskyPostBlock = (props: { post: PostView; @@ -121,6 +122,20 @@ export const PubBlueskyPostBlock = (props: { )} + {post.quoteCount != null && post.quoteCount > 0 && ( + <> + e.stopPropagation()} + > + {post.quoteCount} + + + + + )} `thread:${uri}`; - -// Fetch thread from API route -export async function fetchThread(uri: string): Promise { - const params = new URLSearchParams({ uri }); - const response = await fetch(`/api/bsky/thread?${params.toString()}`); - - if (!response.ok) { - throw new Error("Failed to fetch thread"); - } - - return response.json(); -} - -// Prefetch thread data -export const prefetchThread = (uri: string) => { - preload(getThreadKey(uri), () => fetchThread(uri)); -}; - -// Link component for opening thread pages with prefetching -export function ThreadLink(props: { - threadUri: string; - parent?: OpenPage; - children: React.ReactNode; - className?: string; - onClick?: (e: React.MouseEvent) => void; -}) { - const { threadUri, parent, children, className, onClick } = props; - - const handleClick = (e: React.MouseEvent) => { - onClick?.(e); - if (e.defaultPrevented) return; - openPage(parent, { type: "thread", uri: threadUri }); - }; - - const handlePrefetch = () => { - prefetchThread(threadUri); - }; - - return ( - - ); -} - export function ThreadPage(props: { threadUri: string; pageId: string; @@ -93,7 +40,6 @@ export function ThreadPage(props: { } = useSWR(threadUri ? getThreadKey(threadUri) : null, () => fetchThread(threadUri), ); - let cardBorderHidden = useCardBorderHidden(null); return ( )} @@ -208,10 +155,7 @@ function ThreadPost(props: { }) { const { post, isMainPost, showReplyLine, threadUri } = props; const postView = post.post; - const record = postView.record as AppBskyFeedPost.Record; - - const postId = postView.uri.split("/")[4]; - const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`; + const parent = { type: "thread" as const, uri: threadUri }; return (
@@ -220,72 +164,13 @@ function ThreadPost(props: {
)} -
- {postView.author.avatar ? ( - {`${postView.author.displayName}'s - ) : ( -
- )} -
- -
- - -
-
- -
- {postView.embed && ( - - )} -
- -
- -
- {postView.replyCount != null && postView.replyCount > 0 && ( - <> - {isMainPost ? ( -
- {postView.replyCount} - -
- ) : ( - - {postView.replyCount} - - - )} - - - )} - - - -
-
-
+
); } @@ -294,14 +179,30 @@ function Replies(props: { replies: (ThreadViewPost | NotFoundPost | BlockedPost)[]; threadUri: string; depth: number; + parentAuthorDid?: string; }) { - const { replies, threadUri, depth } = props; + const { replies, threadUri, depth, parentAuthorDid } = 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 = parentAuthorDid + ? [...replies].sort((a, b) => { + const aIsAuthor = + AppBskyFeedDefs.isThreadViewPost(a) && + a.post.author.did === parentAuthorDid; + const bIsAuthor = + AppBskyFeedDefs.isThreadViewPost(b) && + b.post.author.did === parentAuthorDid; + if (aIsAuthor && !bIsAuthor) return -1; + if (!aIsAuthor && bIsAuthor) return 1; + return 0; + }) + : replies; + return (
- {replies.map((reply, index) => { + {sortedReplies.map((reply, index) => { if (AppBskyFeedDefs.isNotFoundPost(reply)) { return (
)} @@ -398,13 +300,8 @@ function ReplyPost(props: { isLast: boolean; threadUri: string; }) { - const { post, showReplyLine, isLast, threadUri } = props; + const { post, threadUri } = props; const postView = post.post; - const record = postView.record as AppBskyFeedPost.Record; - - const postId = postView.uri.split("/")[4]; - const url = `https://bsky.app/profile/${postView.author.handle}/post/${postId}`; - const parent = { type: "thread" as const, uri: threadUri }; return ( @@ -412,74 +309,16 @@ function ReplyPost(props: { className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer" onClick={() => openPage(parent, { type: "thread", uri: postView.uri })} > -
- {postView.author.avatar ? ( - {`${postView.author.displayName}'s - ) : ( -
- )} -
- -
-
-
- {postView.author.displayName} -
- e.stopPropagation()} - > - @{postView.author.handle} - -
- -
- -
- -
- - {postView.replyCount != null && postView.replyCount > 0 && ( - <> - - e.stopPropagation()} - > - {postView.replyCount} - - - - )} -
-
+ e.stopPropagation()} + onEmbedClick={(e) => e.stopPropagation()} + />
); } - -const ClientDate = (props: { date?: string }) => { - const pageLoaded = useHasPageLoaded(); - const formattedDate = useLocalizedDate( - props.date || new Date().toISOString(), - { - month: "short", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "numeric", - hour12: true, - }, - ); - - if (!pageLoaded) return null; - - return
{formattedDate}
; -}; -- 2.51.2