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 ( +
+
no mentions yet!
+
+ ); + } + + 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