diff --git a/app/(home-pages)/reader/InteractionDrawers.tsx b/app/(home-pages)/reader/InteractionDrawers.tsx
index 7425cd18..c63eca52 100644
--- a/app/(home-pages)/reader/InteractionDrawers.tsx
+++ b/app/(home-pages)/reader/InteractionDrawers.tsx
@@ -8,6 +8,11 @@ import { CommentsDrawerContent } from "app/lish/[did]/[publication]/[rkey]/Inter
import { CloseTiny } from "components/Icons/CloseTiny";
import { SpeedyLink } from "components/SpeedyLink";
import { GoToArrow } from "components/Icons/GoToArrow";
+import { DotLoader } from "components/utils/DotLoader";
+import { ReaderMentionsContent } from "./ReaderMentionsContent";
+import { callRPC } from "app/api/rpc/client";
+import useSWR from "swr";
+import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
export const MobileInteractionPreviewDrawer = () => {
let selectedPost = useSelectedPostListing((s) => s.selectedPostListing);
@@ -36,45 +41,67 @@ export const DesktopInteractionPreviewDrawer = () => {
const PreviewDrawerContent = (props: {
selectedPost: SelectedPostListing | null;
}) => {
- if (!props.selectedPost || !props.selectedPost.document) return;
+ const documentUri = props.selectedPost?.document_uri || null;
+ const drawer = props.selectedPost?.drawer || null;
- if (props.selectedPost.drawer === "quotes") {
- return (
- <>
- {/**/}
- >
- );
- } else
- return (
- <>
-
-
- Comments for {props.selectedPost.document.title}
-
-
-
-
+ const { data, isLoading } = useSWR(
+ documentUri ? ["get_document_interactions", documentUri] : null,
+ async () => {
+ const res = await callRPC("get_document_interactions", {
+ document_uri: documentUri!,
+ });
+ return res;
+ },
+ );
+
+ if (!props.selectedPost || !props.selectedPost.document) return null;
+
+ const postUrl = getDocumentURL(
+ props.selectedPost.document,
+ props.selectedPost.document_uri,
+ );
+
+ const drawerTitle =
+ drawer === "quotes"
+ ? `Mentions of ${props.selectedPost.document.title}`
+ : `Comments for ${props.selectedPost.document.title}`;
+
+ return (
+ <>
+
+
{drawerTitle}
+
+
+
See Full Post
+
+ {isLoading ? (
+
+ loading
+
+
+ ) : drawer === "quotes" ? (
+
+
+
+ ) : (
- >
- );
+ )}
+ >
+ );
};
diff --git a/app/(home-pages)/reader/ReaderMentionsContent.tsx b/app/(home-pages)/reader/ReaderMentionsContent.tsx
new file mode 100644
index 00000000..0b1dd2f8
--- /dev/null
+++ b/app/(home-pages)/reader/ReaderMentionsContent.tsx
@@ -0,0 +1,72 @@
+"use client";
+import useSWR from "swr";
+import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
+import { BskyPostContent } from "app/lish/[did]/[publication]/[rkey]/BskyPostContent";
+import { DotLoader } from "components/utils/DotLoader";
+
+async function fetchBskyPosts(uris: string[]): Promise {
+ const params = new URLSearchParams({
+ uris: JSON.stringify(uris),
+ });
+ const response = await fetch(`/api/bsky/hydrate?${params.toString()}`);
+ if (!response.ok) throw new Error("Failed to fetch Bluesky posts");
+ return response.json();
+}
+
+export function ReaderMentionsContent(props: {
+ quotesAndMentions: { uri: string; link?: string }[];
+}) {
+ const uris = props.quotesAndMentions.map((q) => q.uri);
+ const key = uris.length > 0
+ ? `/api/bsky/hydrate?${new URLSearchParams({ uris: JSON.stringify(uris) }).toString()}`
+ : null;
+
+ const { data: bskyPosts, isLoading } = useSWR(key, () =>
+ fetchBskyPosts(uris),
+ );
+
+ if (props.quotesAndMentions.length === 0) {
+ return (
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+ loading
+
+
+ );
+ }
+
+ const postViewMap = new Map();
+ bskyPosts?.forEach((pv) => postViewMap.set(pv.uri, pv));
+
+ return (
+
+ {props.quotesAndMentions.map((q, index) => {
+ const post = postViewMap.get(q.uri);
+ if (!post) return null;
+ return (
+
+
+ {index < props.quotesAndMentions.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/app/(home-pages)/reader/enrichPost.ts b/app/(home-pages)/reader/enrichPost.ts
new file mode 100644
index 00000000..de71f468
--- /dev/null
+++ b/app/(home-pages)/reader/enrichPost.ts
@@ -0,0 +1,88 @@
+import { AtUri } from "@atproto/api";
+import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
+import { getConstellationBacklinks } from "app/lish/[did]/[publication]/[rkey]/getPostPageData";
+import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
+import {
+ normalizeDocumentRecord,
+ normalizePublicationRecord,
+} from "src/utils/normalizeRecords";
+import { idResolver } from "./idResolver";
+import type { Post } from "./getReaderFeed";
+
+type RawDocument = {
+ data: unknown;
+ uri: string;
+ sort_date: string;
+ comments_on_documents: { count: number }[];
+ document_mentions_in_bsky: { count: number }[];
+ recommends_on_documents: { count: number }[];
+ documents_in_publications: {
+ publications: {
+ uri: string;
+ record: unknown;
+ name: string | null;
+ [key: string]: unknown;
+ } | null;
+ }[];
+};
+
+export async function enrichDocumentToPost(
+ doc: RawDocument,
+): Promise {
+ const pub = doc.documents_in_publications?.[0]?.publications;
+ const uri = new AtUri(doc.uri);
+ const handle = await idResolver.did.resolve(uri.host);
+
+ const normalizedData = normalizeDocumentRecord(doc.data, doc.uri);
+ if (!normalizedData) return null;
+
+ const normalizedPubRecord = pub
+ ? normalizePublicationRecord(pub.record)
+ : null;
+
+ const mentionsCount = await getAccurateMentionsCount(
+ normalizedData,
+ doc.uri,
+ normalizedPubRecord,
+ doc.document_mentions_in_bsky?.[0]?.count || 0,
+ );
+
+ return {
+ publication: pub
+ ? {
+ href: getPublicationURL(pub),
+ pubRecord: normalizedPubRecord,
+ uri: pub.uri || "",
+ }
+ : undefined,
+ author: handle?.alsoKnownAs?.[0]
+ ? `@${handle.alsoKnownAs[0].slice(5)}`
+ : null,
+ documents: {
+ comments_on_documents: doc.comments_on_documents,
+ document_mentions_in_bsky: doc.document_mentions_in_bsky,
+ recommends_on_documents: doc.recommends_on_documents,
+ mentionsCount,
+ data: normalizedData,
+ uri: doc.uri,
+ sort_date: doc.sort_date,
+ },
+ };
+}
+
+async function getAccurateMentionsCount(
+ normalizedData: NonNullable>,
+ docUri: string,
+ normalizedPubRecord: ReturnType,
+ dbMentionsCount: number,
+): Promise {
+ const postUrl = getDocumentURL(normalizedData, docUri, normalizedPubRecord);
+ const absoluteUrl = postUrl.startsWith("/")
+ ? `https://leaflet.pub${postUrl}`
+ : postUrl;
+ const constellationBacklinks = await getConstellationBacklinks(absoluteUrl);
+ const uniqueBacklinkCount = new Set(
+ constellationBacklinks.map((b) => b.uri),
+ ).size;
+ return dbMentionsCount + uniqueBacklinkCount;
+}
diff --git a/app/(home-pages)/reader/getHotFeed.ts b/app/(home-pages)/reader/getHotFeed.ts
index 071e7510..ada1e68a 100644
--- a/app/(home-pages)/reader/getHotFeed.ts
+++ b/app/(home-pages)/reader/getHotFeed.ts
@@ -5,13 +5,8 @@ import { sql } from "drizzle-orm";
import { pool } from "supabase/pool";
import Client from "ioredis";
import { AtUri } from "@atproto/api";
-import { idResolver } from "./idResolver";
-import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
-import {
- normalizeDocumentRecord,
- normalizePublicationRecord,
-} from "src/utils/normalizeRecords";
import { supabaseServerClient } from "supabase/serverClient";
+import { enrichDocumentToPost } from "./enrichPost";
import type { Post } from "./getReaderFeed";
let redisClient: Client | null = null;
@@ -90,40 +85,7 @@ export async function getHotFeed(): Promise<{ posts: Post[] }> {
// Enrich into Post[]
const posts = (
await Promise.all(
- orderedDocs.map(async (doc) => {
- const pub = doc.documents_in_publications?.[0]?.publications;
- const uri = new AtUri(doc.uri);
- const handle = await idResolver.did.resolve(uri.host);
-
- const normalizedData = normalizeDocumentRecord(doc.data, doc.uri);
- if (!normalizedData) return null;
-
- const normalizedPubRecord = pub
- ? normalizePublicationRecord(pub.record)
- : null;
-
- const post: Post = {
- publication: pub
- ? {
- href: getPublicationURL(pub),
- pubRecord: normalizedPubRecord,
- uri: pub.uri || "",
- }
- : undefined,
- author: handle?.alsoKnownAs?.[0]
- ? `@${handle.alsoKnownAs[0].slice(5)}`
- : null,
- documents: {
- comments_on_documents: doc.comments_on_documents,
- document_mentions_in_bsky: doc.document_mentions_in_bsky,
- recommends_on_documents: doc.recommends_on_documents,
- data: normalizedData,
- uri: doc.uri,
- sort_date: doc.sort_date,
- },
- };
- return post;
- }),
+ orderedDocs.map((doc) => enrichDocumentToPost(doc as any)),
)
).filter((post): post is Post => post !== null);
diff --git a/app/(home-pages)/reader/getNewFeed.ts b/app/(home-pages)/reader/getNewFeed.ts
index 41d1497f..cbbe54ff 100644
--- a/app/(home-pages)/reader/getNewFeed.ts
+++ b/app/(home-pages)/reader/getNewFeed.ts
@@ -1,14 +1,8 @@
"use server";
-import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
import { supabaseServerClient } from "supabase/serverClient";
-import { AtUri } from "@atproto/api";
-import { idResolver } from "./idResolver";
-import {
- normalizeDocumentRecord,
- normalizePublicationRecord,
-} from "src/utils/normalizeRecords";
import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords";
+import { enrichDocumentToPost } from "./enrichPost";
import type { Cursor, Post } from "./getReaderFeed";
export async function getNewFeed(
@@ -42,38 +36,7 @@ export async function getNewFeed(
const feed = deduplicateByUriOrdered(rawFeed || []);
let posts = (
- await Promise.all(
- feed.map(async (post) => {
- let pub = post.documents_in_publications[0]?.publications!;
- let uri = new AtUri(post.uri);
- let handle = await idResolver.did.resolve(uri.host);
-
- const normalizedData = normalizeDocumentRecord(post.data, post.uri);
- if (!normalizedData) return null;
-
- const normalizedPubRecord = normalizePublicationRecord(pub?.record);
-
- let p: Post = {
- publication: {
- href: getPublicationURL(pub),
- pubRecord: normalizedPubRecord,
- uri: pub?.uri || "",
- },
- author: handle?.alsoKnownAs?.[0]
- ? `@${handle.alsoKnownAs[0].slice(5)}`
- : null,
- documents: {
- comments_on_documents: post.comments_on_documents,
- document_mentions_in_bsky: post.document_mentions_in_bsky,
- recommends_on_documents: post.recommends_on_documents,
- data: normalizedData,
- uri: post.uri,
- sort_date: post.sort_date,
- },
- };
- return p;
- }) || [],
- )
+ await Promise.all(feed.map((post) => enrichDocumentToPost(post as any)))
).filter((post): post is Post => post !== null);
const nextCursor =
diff --git a/app/(home-pages)/reader/getReaderFeed.ts b/app/(home-pages)/reader/getReaderFeed.ts
index 4ff16591..cf2703b3 100644
--- a/app/(home-pages)/reader/getReaderFeed.ts
+++ b/app/(home-pages)/reader/getReaderFeed.ts
@@ -1,20 +1,13 @@
"use server";
import { getIdentityData } from "actions/getIdentityData";
-import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
import { supabaseServerClient } from "supabase/serverClient";
-import { IdResolver } from "@atproto/identity";
-import type { DidCache, CacheResult, DidDocument } from "@atproto/identity";
-import Client from "ioredis";
-import { AtUri } from "@atproto/api";
-import { idResolver } from "./idResolver";
-import {
- normalizeDocumentRecord,
- normalizePublicationRecord,
- type NormalizedDocument,
- type NormalizedPublication,
+import type {
+ NormalizedDocument,
+ NormalizedPublication,
} from "src/utils/normalizeRecords";
import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords";
+import { enrichDocumentToPost } from "./enrichPost";
export type Cursor = {
timestamp: string;
@@ -53,40 +46,9 @@ export async function getReaderFeed(
const feed = deduplicateByUriOrdered(rawFeed || []);
let posts = (
- await Promise.all(
- feed.map(async (post) => {
- let pub = post.documents_in_publications[0].publications!;
- let uri = new AtUri(post.uri);
- let handle = await idResolver.did.resolve(uri.host);
-
- // Normalize records - filter out unrecognized formats
- const normalizedData = normalizeDocumentRecord(post.data, post.uri);
- if (!normalizedData) return null;
-
- const normalizedPubRecord = normalizePublicationRecord(pub?.record);
-
- let p: Post = {
- publication: {
- href: getPublicationURL(pub),
- pubRecord: normalizedPubRecord,
- uri: pub?.uri || "",
- },
- author: handle?.alsoKnownAs?.[0]
- ? `@${handle.alsoKnownAs[0].slice(5)}`
- : null,
- documents: {
- comments_on_documents: post.comments_on_documents,
- document_mentions_in_bsky: post.document_mentions_in_bsky,
- recommends_on_documents: post.recommends_on_documents,
- data: normalizedData,
- uri: post.uri,
- sort_date: post.sort_date,
- },
- };
- return p;
- }) || [],
- )
+ await Promise.all(feed.map((post) => enrichDocumentToPost(post as any)))
).filter((post): post is Post => post !== null);
+
const nextCursor =
posts.length > 0
? {
@@ -115,5 +77,6 @@ export type Post = {
comments_on_documents: { count: number }[] | undefined;
document_mentions_in_bsky: { count: number }[] | undefined;
recommends_on_documents: { count: number }[] | undefined;
+ mentionsCount?: number;
};
};
diff --git a/app/api/rpc/[command]/get_document_interactions.ts b/app/api/rpc/[command]/get_document_interactions.ts
new file mode 100644
index 00000000..da5bf774
--- /dev/null
+++ b/app/api/rpc/[command]/get_document_interactions.ts
@@ -0,0 +1,90 @@
+import { z } from "zod";
+import { makeRoute } from "../lib";
+import type { Env } from "./route";
+import { getConstellationBacklinks } from "app/lish/[did]/[publication]/[rkey]/getPostPageData";
+import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
+import {
+ normalizeDocumentRecord,
+ normalizePublicationRecord,
+} from "src/utils/normalizeRecords";
+
+export const get_document_interactions = makeRoute({
+ route: "get_document_interactions",
+ input: z.object({
+ document_uri: z.string(),
+ }),
+ handler: async (
+ { document_uri },
+ { supabase }: Pick,
+ ) => {
+ let { data: document } = await supabase
+ .from("documents")
+ .select(
+ `
+ data,
+ uri,
+ comments_on_documents(*, bsky_profiles(*)),
+ document_mentions_in_bsky(*),
+ documents_in_publications(publications(*))
+ `,
+ )
+ .eq("uri", document_uri)
+ .limit(1)
+ .single();
+
+ if (!document) {
+ return { comments: [], quotesAndMentions: [], totalMentionsCount: 0 };
+ }
+
+ const normalizedData = normalizeDocumentRecord(
+ document.data,
+ document.uri,
+ );
+
+ const pub = document.documents_in_publications?.[0]?.publications;
+ const normalizedPubRecord = pub
+ ? normalizePublicationRecord(pub.record)
+ : null;
+
+ // Compute document URL for constellation lookup
+ let absoluteUrl = "";
+ if (normalizedData) {
+ const postUrl = getDocumentURL(
+ normalizedData,
+ document.uri,
+ normalizedPubRecord,
+ );
+ absoluteUrl = postUrl.startsWith("/")
+ ? `https://leaflet.pub${postUrl}`
+ : postUrl;
+ }
+
+ // Fetch constellation backlinks
+ const constellationBacklinks = absoluteUrl
+ ? await getConstellationBacklinks(absoluteUrl)
+ : [];
+
+ // Deduplicate constellation backlinks internally
+ const uniqueBacklinks = Array.from(
+ new Map(constellationBacklinks.map((b) => [b.uri, b])).values(),
+ );
+
+ // Combine DB mentions and constellation backlinks, deduplicating by URI
+ const dbMentionUris = new Set(
+ document.document_mentions_in_bsky.map((m) => m.uri),
+ );
+ const quotesAndMentions: { uri: string; link?: string }[] = [
+ ...document.document_mentions_in_bsky.map((m) => ({
+ uri: m.uri,
+ link: m.link,
+ })),
+ ...uniqueBacklinks.filter((b) => !dbMentionUris.has(b.uri)),
+ ];
+
+ return {
+ comments: document.comments_on_documents,
+ quotesAndMentions,
+ totalMentionsCount: quotesAndMentions.length,
+ };
+ },
+});
diff --git a/app/api/rpc/[command]/get_hot_feed.ts b/app/api/rpc/[command]/get_hot_feed.ts
index 1e0219b7..c7811086 100644
--- a/app/api/rpc/[command]/get_hot_feed.ts
+++ b/app/api/rpc/[command]/get_hot_feed.ts
@@ -1,27 +1,9 @@
import { z } from "zod";
import { makeRoute } from "../lib";
import type { Env } from "./route";
-import { drizzle } from "drizzle-orm/node-postgres";
-import { sql } from "drizzle-orm";
-import { pool } from "supabase/pool";
-import Client from "ioredis";
-import { AtUri } from "@atproto/api";
-import { idResolver } from "app/(home-pages)/reader/idResolver";
-import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
-import {
- normalizeDocumentRecord,
- normalizePublicationRecord,
-} from "src/utils/normalizeRecords";
+import { getHotFeed } from "app/(home-pages)/reader/getHotFeed";
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
-let redisClient: Client | null = null;
-if (process.env.REDIS_URL && process.env.NODE_ENV === "production") {
- redisClient = new Client(process.env.REDIS_URL);
-}
-
-const CACHE_KEY = "hot_feed_v1";
-const CACHE_TTL = 300; // 5 minutes
-
export type GetHotFeedReturnType = Awaited<
ReturnType<(typeof get_hot_feed)["handler"]>
>;
@@ -29,120 +11,7 @@ export type GetHotFeedReturnType = Awaited<
export const get_hot_feed = makeRoute({
route: "get_hot_feed",
input: z.object({}),
- handler: async ({}, { supabase }: Pick) => {
- // Check Redis cache
- if (redisClient) {
- const cached = await redisClient.get(CACHE_KEY);
- if (cached) {
- return JSON.parse(cached) as { posts: Post[] };
- }
- }
-
- // Run ranked SQL query to get top 50 URIs
- const client = await pool.connect();
- const db = drizzle(client);
-
- let uris: string[];
- try {
- const ranked = await db.execute(sql`
- SELECT uri
- FROM documents
- WHERE indexed = true
- AND sort_date > now() - interval '7 days'
- ORDER BY
- (bsky_like_count + recommend_count * 5)::numeric
- / power(extract(epoch from (now() - sort_date)) / 3600 + 2, 1.5) DESC
- LIMIT 50
- `);
- uris = ranked.rows.map((row: any) => row.uri as string);
- } finally {
- client.release();
- }
-
- if (uris.length === 0) {
- return { posts: [] as Post[] };
- }
-
- // Batch-fetch documents with publication joins and interaction counts
- const { data: documents } = await supabase
- .from("documents")
- .select(
- `*,
- comments_on_documents(count),
- document_mentions_in_bsky(count),
- recommends_on_documents(count),
- documents_in_publications(publications(*))`,
- )
- .in("uri", uris);
-
- // Build lookup map for enrichment
- const docMap = new Map(
- (documents || []).map((d) => [d.uri, d]),
- );
-
- // Process in ranked order, deduplicating by identity key (DID/rkey)
- const seen = new Set();
- const orderedDocs: (typeof documents extends (infer T)[] | null ? T : never)[] = [];
- for (const uri of uris) {
- try {
- const parsed = new AtUri(uri);
- const identityKey = `${parsed.host}/${parsed.rkey}`;
- if (seen.has(identityKey)) continue;
- seen.add(identityKey);
- } catch {
- // invalid URI, skip dedup check
- }
- const doc = docMap.get(uri);
- if (doc) orderedDocs.push(doc);
- }
-
- // Enrich into Post[]
- const posts = (
- await Promise.all(
- orderedDocs.map(async (doc) => {
- const pub = doc.documents_in_publications?.[0]?.publications;
- const uri = new AtUri(doc.uri);
- const handle = await idResolver.did.resolve(uri.host);
-
- const normalizedData = normalizeDocumentRecord(doc.data, doc.uri);
- if (!normalizedData) return null;
-
- const normalizedPubRecord = pub
- ? normalizePublicationRecord(pub.record)
- : null;
-
- const post: Post = {
- publication: pub
- ? {
- href: getPublicationURL(pub),
- pubRecord: normalizedPubRecord,
- uri: pub.uri || "",
- }
- : undefined,
- author: handle?.alsoKnownAs?.[0]
- ? `@${handle.alsoKnownAs[0].slice(5)}`
- : null,
- documents: {
- comments_on_documents: doc.comments_on_documents,
- document_mentions_in_bsky: doc.document_mentions_in_bsky,
- recommends_on_documents: doc.recommends_on_documents,
- data: normalizedData,
- uri: doc.uri,
- sort_date: doc.sort_date,
- },
- };
- return post;
- }),
- )
- ).filter((post): post is Post => post !== null);
-
- const response = { posts };
-
- // Cache in Redis
- if (redisClient) {
- await redisClient.setex(CACHE_KEY, CACHE_TTL, JSON.stringify(response));
- }
-
- return response;
+ handler: async ({}, {}: Pick) => {
+ return await getHotFeed();
},
});
diff --git a/app/api/rpc/[command]/route.ts b/app/api/rpc/[command]/route.ts
index af834c9d..ebb2af14 100644
--- a/app/api/rpc/[command]/route.ts
+++ b/app/api/rpc/[command]/route.ts
@@ -16,6 +16,7 @@ import { search_publication_documents } from "./search_publication_documents";
import { get_profile_data } from "./get_profile_data";
import { get_user_recommendations } from "./get_user_recommendations";
import { get_hot_feed } from "./get_hot_feed";
+import { get_document_interactions } from "./get_document_interactions";
let supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
@@ -45,6 +46,7 @@ let Routes = [
get_profile_data,
get_user_recommendations,
get_hot_feed,
+ get_document_interactions,
];
export async function POST(
req: Request,
diff --git a/components/PostListing.tsx b/components/PostListing.tsx
index eab53063..cf713d56 100644
--- a/components/PostListing.tsx
+++ b/components/PostListing.tsx
@@ -74,7 +74,7 @@ export const PostListing = (props: Post) => {
pubRecord?.preferences,
);
- let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0;
+ let quotes = props.documents.mentionsCount ?? props.documents.document_mentions_in_bsky?.[0]?.count ?? 0;
let comments =
mergedPrefs.showComments === false
? 0