From 2672d2c4575eb8c15e625180c87d60a9d7c6a20b Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 17 Dec 2025 21:31:58 +0400 Subject: [PATCH] add basic thread viewer component --- app/api/bsky/thread/route.ts | 44 ++ .../[rkey]/Interactions/Quotes.tsx | 50 +- .../[publication]/[rkey]/PostContent.tsx | 2 +- .../[did]/[publication]/[rkey]/PostPages.tsx | 101 +++- .../[rkey]/PublishBskyPostBlock.tsx | 51 +- .../[rkey]/PublishedPageBlock.tsx | 25 +- .../[did]/[publication]/[rkey]/ThreadPage.tsx | 439 ++++++++++++++++++ .../Blocks/BlueskyPostBlock/BlueskyEmbed.tsx | 78 ++-- components/Blocks/BlueskyPostBlock/index.tsx | 2 +- 9 files changed, 709 insertions(+), 83 deletions(-) create mode 100644 app/api/bsky/thread/route.ts create mode 100644 app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx diff --git a/app/api/bsky/thread/route.ts b/app/api/bsky/thread/route.ts new file mode 100644 index 00000000..fafbcf42 --- /dev/null +++ b/app/api/bsky/thread/route.ts @@ -0,0 +1,44 @@ +import { Agent, lexToJson } from "@atproto/api"; +import { ThreadViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; +import { NextRequest } from "next/server"; + +export const runtime = "nodejs"; + +export async function GET(req: NextRequest) { + try { + const searchParams = req.nextUrl.searchParams; + const uri = searchParams.get("uri"); + const depth = searchParams.get("depth"); + const parentHeight = searchParams.get("parentHeight"); + + if (!uri) { + return Response.json( + { error: "uri parameter is required" }, + { status: 400 }, + ); + } + + // Fetch thread from Bluesky + let agent = new Agent({ + service: "https://public.api.bsky.app", + }); + + const response = await agent.getPostThread({ + uri, + depth: depth ? parseInt(depth, 10) : 6, + parentHeight: parentHeight ? parseInt(parentHeight, 10) : 80, + }); + + const thread = lexToJson(response.data.thread); + + return Response.json(thread, { + headers: { + // Cache for 5 minutes on CDN, allow stale content for 1 hour while revalidating + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=3600", + }, + }); + } catch (error) { + console.error("Error fetching Bluesky thread:", error); + return Response.json({ error: "Failed to fetch thread" }, { status: 500 }); + } +} diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx index 65cb79d2..1d30e991 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx @@ -4,7 +4,7 @@ import { useContext } from "react"; import { useIsMobile } from "src/hooks/isMobile"; import { setInteractionState } from "./Interactions"; import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; -import { AtUri } from "@atproto/api"; +import { AtUri, AppBskyFeedPost } from "@atproto/api"; import { PostPageContext } from "../PostPageContext"; import { PubLeafletBlocksText, @@ -22,6 +22,8 @@ import { flushSync } from "react-dom"; 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"; // Helper to get SWR key for quotes export function getQuotesSWRKey(uris: string[]) { @@ -129,11 +131,13 @@ export const Quotes = (props: {
); @@ -150,11 +154,13 @@ export const Quotes = (props: { return ( ); })} @@ -201,7 +207,7 @@ export const QuoteContent = (props: { className="quoteSectionQuote text-secondary text-sm text-left hover:cursor-pointer" onClick={(e) => { if (props.position.pageId) - flushSync(() => openPage(undefined, props.position.pageId!)); + flushSync(() => openPage(undefined, { type: "doc", id: props.position.pageId! })); let scrollMargin = isMobile ? 16 : e.currentTarget.getBoundingClientRect().top; @@ -239,33 +245,55 @@ export const QuoteContent = (props: { }; export const BskyPost = (props: { + uri: string; rkey: string; content: string; user: string; handle: string; profile: ProfileViewBasic; + replyCount?: number; }) => { + const handleOpenThread = () => { + openPage(undefined, { type: "thread", uri: props.uri }); + }; + return ( - {props.profile.avatar && ( {props.profile.displayName} )} -
-
+
+
{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"} + + )}
- +
); }; diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index e9ce69cf..40a341c8 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -173,7 +173,7 @@ export let Block = ({ let uri = b.block.postRef.uri; let post = bskyPostData.find((p) => p.uri === uri); if (!post) return
no prefetched post rip
; - return ; + return ; } case PubLeafletBlocksIframe.isMain(b.block): { return ( diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx index 05576804..420c201f 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -19,25 +19,46 @@ import { CloseTiny } from "components/Icons/CloseTiny"; import { Fragment, useEffect } from "react"; import { flushSync } from "react-dom"; import { scrollIntoView } from "src/utils/scrollIntoView"; -import { useParams } from "next/navigation"; +import { useParams, useSearchParams } from "next/navigation"; import { decodeQuotePosition } from "./quotePosition"; import { PollData } from "./fetchPollData"; import { LinearDocumentPage } from "./LinearDocumentPage"; import { CanvasPage } from "./CanvasPage"; +import { ThreadPage as ThreadPageComponent } from "./ThreadPage"; + +// Page types +export type DocPage = { type: "doc"; id: string }; +export type ThreadPage = { type: "thread"; uri: string }; +export type OpenPage = DocPage | ThreadPage; + +// Get a stable key for a page +const getPageKey = (page: OpenPage): string => { + if (page.type === "doc") return page.id; + return `thread:${page.uri}`; +}; const usePostPageUIState = create(() => ({ - pages: [] as string[], + pages: [] as OpenPage[], initialized: false, })); -export const useOpenPages = () => { +export const useOpenPages = (): OpenPage[] => { const { quote } = useParams(); const state = usePostPageUIState((s) => s); + const searchParams = useSearchParams(); + const pageParam = searchParams.get("page"); - if (!state.initialized && quote) { - const decodedQuote = decodeQuotePosition(quote as string); - if (decodedQuote?.pageId) { - return [decodedQuote.pageId]; + if (!state.initialized) { + // Check for page search param first (for comment links) + if (pageParam) { + return [{ type: "doc", id: pageParam }]; + } + // Then check for quote param + if (quote) { + const decodedQuote = decodeQuotePosition(quote as string); + if (decodedQuote?.pageId) { + return [{ type: "doc", id: decodedQuote.pageId }]; + } } } @@ -46,15 +67,27 @@ export const useOpenPages = () => { export const useInitializeOpenPages = () => { const { quote } = useParams(); + const searchParams = useSearchParams(); + const pageParam = searchParams.get("page"); useEffect(() => { const state = usePostPageUIState.getState(); if (!state.initialized) { + // Check for page search param first (for comment links) + if (pageParam) { + usePostPageUIState.setState({ + pages: [{ type: "doc", id: pageParam }], + initialized: true, + }); + return; + } + + // Then check for quote param if (quote) { const decodedQuote = decodeQuotePosition(quote as string); if (decodedQuote?.pageId) { usePostPageUIState.setState({ - pages: [decodedQuote.pageId], + pages: [{ type: "doc", id: decodedQuote.pageId }], initialized: true, }); return; @@ -67,13 +100,18 @@ export const useInitializeOpenPages = () => { }; export const openPage = ( - parent: string | undefined, - page: string, + parent: OpenPage | undefined, + page: OpenPage, options?: { scrollIntoView?: boolean }, ) => { + const pageKey = getPageKey(page); + const parentKey = parent ? getPageKey(parent) : undefined; + flushSync(() => { usePostPageUIState.setState((state) => { - let parentPosition = state.pages.findIndex((s) => s == parent); + let parentPosition = state.pages.findIndex( + (s) => getPageKey(s) === parentKey, + ); return { pages: parentPosition === -1 @@ -85,18 +123,22 @@ export const openPage = ( }); if (options?.scrollIntoView !== false) { - scrollIntoView(`post-page-${page}`); + scrollIntoView(`post-page-${pageKey}`); } }; -export const closePage = (page: string) => +export const closePage = (page: OpenPage) => { + const pageKey = getPageKey(page); usePostPageUIState.setState((state) => { - let parentPosition = state.pages.findIndex((s) => s == page); + let parentPosition = state.pages.findIndex( + (s) => getPageKey(s) === pageKey, + ); return { pages: state.pages.slice(0, parentPosition), initialized: true, }; }); +}; // Shared props type for both page components export type SharedPageProps = { @@ -228,14 +270,37 @@ export function PostPages({ /> )} - {openPageIds.map((pageId) => { + {openPageIds.map((openPage) => { + const pageKey = getPageKey(openPage); + + // Handle thread pages + if (openPage.type === "thread") { + return ( + + + closePage(openPage)} + hasPageBackground={hasPageBackground} + /> + } + /> + + ); + } + + // Handle document pages let page = record.pages.find( (p) => ( p as | PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main - ).id === pageId, + ).id === openPage.id, ) as | PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main @@ -244,7 +309,7 @@ export function PostPages({ if (!page) return null; return ( - + closePage(page.id!)} + onClick={() => closePage(openPage)} hasPageBackground={hasPageBackground} /> } diff --git a/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx b/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx index 596e12e2..db63ac6b 100644 --- a/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx @@ -1,11 +1,5 @@ import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; -import { useEntitySetContext } from "components/EntitySetProvider"; -import { useEffect, useState } from "react"; -import { useEntity } from "src/replicache"; -import { useUIState } from "src/useUIState"; -import { elementId } from "src/utils/elementId"; -import { focusBlock } from "src/utils/focusBlock"; -import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; +import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; import { Separator } from "components/Layout"; import { useHasPageLoaded } from "components/InitialPageLoadProvider"; import { BlueskyTiny } from "components/Icons/BlueskyTiny"; @@ -16,12 +10,23 @@ import { PostNotAvailable, } 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; className: string; + pageId?: string; }) => { let post = props.post; + + const handleOpenThread = () => { + openPage( + props.pageId ? { type: "doc", id: props.pageId } : undefined, + { type: "thread", uri: post.uri }, + ); + }; + switch (true) { case AppBskyFeedDefs.isBlockedPost(post) || AppBskyFeedDefs.isBlockedAuthor(post) || @@ -34,13 +39,10 @@ export const PubBlueskyPostBlock = (props: { case AppBskyFeedDefs.validatePostView(post).success: let record = post.record as AppBskyFeedDefs.PostView["record"]; - let facets = record.facets; // silliness to get the text and timestamp from the record with proper types - let text: string | null = null; let timestamp: string | undefined = undefined; if (AppBskyFeedPost.isRecord(record)) { - text = (record as AppBskyFeedPost.Record).text; timestamp = (record as AppBskyFeedPost.Record).createdAt; } @@ -48,13 +50,17 @@ export const PubBlueskyPostBlock = (props: { let postId = post.uri.split("/")[4]; let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; + const parent = props.pageId ? { type: "doc" as const, id: props.pageId } : undefined; + return (
{post.author && record && ( @@ -75,6 +81,7 @@ export const PubBlueskyPostBlock = (props: { className="text-xs text-tertiary hover:underline" target="_blank" href={`https://bsky.app/profile/${post.author?.handle}`} + onClick={(e) => e.stopPropagation()} > @{post.author?.handle} @@ -90,7 +97,9 @@ export const PubBlueskyPostBlock = (props: {
{post.embed && ( - +
e.stopPropagation()}> + +
)}
@@ -98,21 +107,27 @@ export const PubBlueskyPostBlock = (props: {
- {post.replyCount && post.replyCount > 0 && ( + {post.replyCount != null && post.replyCount > 0 && ( <> - e.stopPropagation()} > {post.replyCount} - + )} - + e.stopPropagation()} + >
diff --git a/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx b/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx index a671285c..8a3b0965 100644 --- a/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx @@ -40,7 +40,9 @@ export function PublishedPageLinkBlock(props: { }) { //switch to use actually state let openPages = useOpenPages(); - let isOpen = openPages.includes(props.pageId); + let isOpen = openPages.some( + (p) => p.type === "doc" && p.id === props.pageId, + ); return (
{props.isCanvas ? ( @@ -213,9 +218,11 @@ const Interactions = (props: { pageId: string; parentPageId?: string }) => { onClick={(e) => { e.preventDefault(); e.stopPropagation(); - openPage(props.parentPageId, props.pageId, { - scrollIntoView: false, - }); + openPage( + props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined, + { type: "doc", id: props.pageId }, + { scrollIntoView: false }, + ); if (!drawerOpen || drawer !== "quotes") openInteractionDrawer("quotes", document_uri, props.pageId); else setInteractionState(document_uri, { drawerOpen: false }); @@ -231,9 +238,11 @@ const Interactions = (props: { pageId: string; parentPageId?: string }) => { onClick={(e) => { e.preventDefault(); e.stopPropagation(); - openPage(props.parentPageId, props.pageId, { - scrollIntoView: false, - }); + openPage( + props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined, + { type: "doc", id: props.pageId }, + { scrollIntoView: false }, + ); if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) openInteractionDrawer("comments", document_uri, props.pageId); else setInteractionState(document_uri, { drawerOpen: false }); diff --git a/app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx b/app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx new file mode 100644 index 00000000..73175688 --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx @@ -0,0 +1,439 @@ +"use client"; +import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; +import useSWR, { preload } from "swr"; +import { PageWrapper } from "components/Pages/Page"; +import { useDrawerOpen } from "./Interactions/InteractionDrawer"; +import { DotLoader } from "components/utils/DotLoader"; +import { + BlueskyEmbed, + PostNotAvailable, +} 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 { Separator } from "components/Layout"; +import { useLocalizedDate } from "src/hooks/useLocalizedDate"; +import { useHasPageLoaded } from "components/InitialPageLoadProvider"; +import { openPage, OpenPage } from "./PostPages"; +import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; + +type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; +type NotFoundPost = AppBskyFeedDefs.NotFoundPost; +type BlockedPost = AppBskyFeedDefs.BlockedPost; +type ThreadType = ThreadViewPost | NotFoundPost | BlockedPost; + +// SWR key for thread data +export const getThreadKey = (uri: string) => `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; + pageOptions?: React.ReactNode; + hasPageBackground: boolean; +}) { + const { threadUri, pageId, pageOptions } = props; + const drawer = useDrawerOpen(threadUri); + + const { + data: thread, + isLoading, + error, + } = useSWR(threadUri ? getThreadKey(threadUri) : null, () => + fetchThread(threadUri), + ); + let cardBorderHidden = useCardBorderHidden(null); + + return ( + +
+ {isLoading ? ( +
+ loading thread + +
+ ) : error ? ( +
+ Failed to load thread +
+ ) : thread ? ( + + ) : null} +
+
+ ); +} + +function ThreadContent(props: { thread: ThreadType; threadUri: string }) { + const { thread, threadUri } = props; + + if (AppBskyFeedDefs.isNotFoundPost(thread)) { + return ; + } + + if (AppBskyFeedDefs.isBlockedPost(thread)) { + return ( +
+ This post is blocked +
+ ); + } + + if (!AppBskyFeedDefs.isThreadViewPost(thread)) { + return ; + } + + // Collect all parent posts in order (oldest first) + const parents: ThreadViewPost[] = []; + let currentParent = thread.parent; + while (currentParent && AppBskyFeedDefs.isThreadViewPost(currentParent)) { + parents.unshift(currentParent); + currentParent = currentParent.parent; + } + + return ( +
+ {/* Parent posts */} + {parents.map((parent, index) => ( +
+ +
+ ))} + + {/* Main post */} + + + {/* Replies */} + {thread.replies && thread.replies.length > 0 && ( +
+
+ Replies +
+ +
+ )} +
+ ); +} + +function ThreadPost(props: { + post: ThreadViewPost; + isMainPost: boolean; + showReplyLine: boolean; + threadUri: string; +}) { + 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}`; + + return ( +
+ {/* Reply line connector */} + {showReplyLine && ( +
+ )} + +
+ {postView.author.avatar ? ( + {`${postView.author.displayName}'s + ) : ( +
+ )} +
+ +
+
+
+ {postView.author.displayName} +
+ + @{postView.author.handle} + +
+ +
+
+ +
+ {postView.embed && ( + + )} +
+ +
+ +
+ {postView.replyCount != null && postView.replyCount > 0 && ( + <> + {isMainPost ? ( +
+ {postView.replyCount} + +
+ ) : ( + + {postView.replyCount} + + + )} + + + )} + + + +
+
+
+
+ ); +} + +function Replies(props: { + replies: (ThreadViewPost | NotFoundPost | BlockedPost)[]; + threadUri: string; + depth: number; +}) { + const { replies, threadUri, depth } = props; + + return ( +
+ {replies.map((reply, index) => { + if (AppBskyFeedDefs.isNotFoundPost(reply)) { + return ( +
+ Post not found +
+ ); + } + + if (AppBskyFeedDefs.isBlockedPost(reply)) { + return ( +
+ Post blocked +
+ ); + } + + if (!AppBskyFeedDefs.isThreadViewPost(reply)) { + return null; + } + + const hasReplies = reply.replies && reply.replies.length > 0; + + return ( +
+ + {hasReplies && depth < 3 && ( +
+ +
+ )} + {hasReplies && depth >= 3 && ( + + View more replies + + )} +
+ ); + })} +
+ ); +} + +function ReplyPost(props: { + post: ThreadViewPost; + showReplyLine: boolean; + isLast: boolean; + threadUri: string; +}) { + const { post, showReplyLine, isLast, 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 ( +
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} + + + + )} +
+
+
+ ); +} + +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/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx b/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx index 68873ded..4f961b43 100644 --- a/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx +++ b/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx @@ -23,24 +23,43 @@ export const BlueskyEmbed = (props: { return (
{imageEmbed.images.map( - (image: { fullsize: string; alt?: string }, i: number) => ( - {image.alt { + const isSingle = imageEmbed.images.length === 1; + const aspectRatio = image.aspectRatio + ? image.aspectRatio.width / image.aspectRatio.height + : undefined; + + return ( + {image.alt - ), + `} + /> + ); + }, )}
); @@ -49,11 +68,11 @@ export const BlueskyEmbed = (props: { let isGif = externalEmbed.external.uri.includes(".gif"); if (isGif) { return ( -
+
{externalEmbed.external.title}
); @@ -66,13 +85,14 @@ export const BlueskyEmbed = (props: { > {externalEmbed.external.thumb === undefined ? null : ( <> - {externalEmbed.external.title} - -
+
+ {externalEmbed.external.title} +
+
)}
@@ -91,16 +111,22 @@ export const BlueskyEmbed = (props: { ); case AppBskyEmbedVideo.isView(props.embed): let videoEmbed = props.embed; + const videoAspectRatio = videoEmbed.aspectRatio + ? videoEmbed.aspectRatio.width / videoEmbed.aspectRatio.height + : 16 / 9; return ( -
+
{ -
+
diff --git a/components/Blocks/BlueskyPostBlock/index.tsx b/components/Blocks/BlueskyPostBlock/index.tsx index bb04a7e3..a19a0775 100644 --- a/components/Blocks/BlueskyPostBlock/index.tsx +++ b/components/Blocks/BlueskyPostBlock/index.tsx @@ -130,7 +130,7 @@ export const BlueskyPostBlock = (props: BlockProps & { preview?: boolean }) => {
{timestamp && } -
+
-- 2.43.0 From 0661999d0d11d5ea093754b6aad7589cd3478899 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 17 Dec 2025 21:38:49 +0400 Subject: [PATCH] use authed bsky agent for threads if available --- app/api/bsky/thread/route.ts | 42 ++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/app/api/bsky/thread/route.ts b/app/api/bsky/thread/route.ts index fafbcf42..4247b49f 100644 --- a/app/api/bsky/thread/route.ts +++ b/app/api/bsky/thread/route.ts @@ -1,9 +1,40 @@ import { Agent, lexToJson } from "@atproto/api"; import { ThreadViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; +import { cookies } from "next/headers"; import { NextRequest } from "next/server"; +import { createOauthClient } from "src/atproto-oauth"; +import { supabaseServerClient } from "supabase/serverClient"; export const runtime = "nodejs"; +async function getAuthenticatedAgent(): Promise { + try { + const cookieStore = await cookies(); + const authToken = + cookieStore.get("auth_token")?.value || + cookieStore.get("external_auth_token")?.value; + + if (!authToken || authToken === "null") return null; + + const { data } = await supabaseServerClient + .from("email_auth_tokens") + .select("identities(atp_did)") + .eq("id", authToken) + .eq("confirmed", true) + .single(); + + const did = data?.identities?.atp_did; + if (!did) return null; + + const oauthClient = await createOauthClient(); + const session = await oauthClient.restore(did); + return new Agent(session); + } catch (error) { + console.error("Failed to get authenticated agent:", error); + return null; + } +} + export async function GET(req: NextRequest) { try { const searchParams = req.nextUrl.searchParams; @@ -18,10 +49,13 @@ export async function GET(req: NextRequest) { ); } - // Fetch thread from Bluesky - let agent = new Agent({ - service: "https://public.api.bsky.app", - }); + // Try to use authenticated agent if user is logged in, otherwise fall back to public API + let agent = await getAuthenticatedAgent(); + if (!agent) { + agent = new Agent({ + service: "https://public.api.bsky.app", + }); + } const response = await agent.getPostThread({ uri, -- 2.43.0