-
+
+
{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: {
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 && (
+
+ )}
+
+ );
+}
+
+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.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.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) => (
-

{
+ const isSingle = imageEmbed.images.length === 1;
+ const aspectRatio = image.aspectRatio
+ ? image.aspectRatio.width / image.aspectRatio.height
+ : undefined;
+
+ return (
+

- ),
+ `}
+ />
+ );
+ },
)}
);
@@ -49,11 +68,11 @@ export const BlueskyEmbed = (props: {
let isGif = externalEmbed.external.uri.includes(".gif");
if (isGif) {
return (
-
+
);
@@ -66,13 +85,14 @@ export const BlueskyEmbed = (props: {
>
{externalEmbed.external.thumb === undefined ? null : (
<>
-

-
-
+
+

+
+
>
)}
@@ -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.51.2
From 0661999d0d11d5ea093754b6aad7589cd3478899 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 17 Dec 2025 21:38:49 +0400
Subject: [PATCH 3/3] 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,