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 ? (
+

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