From ef4542c792a47dd991984cfb85cafb5041cafae4 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Sun, 2 Nov 2025 09:04:07 -0500 Subject: [PATCH] get bluesky quotes and mentions, fetch data on client --- .../[did]/[publication]/[rkey]/CanvasPage.tsx | 2 + .../[rkey]/Interactions/InteractionDrawer.tsx | 9 ++- .../[rkey]/Interactions/Interactions.tsx | 23 ++++-- .../[rkey]/Interactions/Quotes.tsx | 63 +++++++++++++-- .../[rkey]/Interactions/getBlueskyMentions.ts | 80 +++++++++++++++++++ .../[rkey]/PostHeader/PostHeader.tsx | 23 ++---- .../[did]/[publication]/[rkey]/PostPages.tsx | 5 +- .../[publication]/[rkey]/getPostPageData.ts | 34 +++++++- app/lish/[did]/[publication]/[rkey]/page.tsx | 18 +++-- 9 files changed, 210 insertions(+), 47 deletions(-) create mode 100644 app/lish/[did]/[publication]/[rkey]/Interactions/getBlueskyMentions.ts diff --git a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx index 3d6024cd..7602cdf3 100644 --- a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx @@ -53,6 +53,8 @@ export function CanvasPage({ fullPageScroll: boolean; pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; }) { + if (!document) return null; + let hasPageBackground = !!pubRecord.theme?.showPageBackground; let isSubpage = !!pageId; let drawer = useDrawerOpen(document_uri); diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx index dee2361c..a9df0ad5 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx @@ -10,7 +10,7 @@ import { decodeQuotePosition } from "../quotePosition"; export const InteractionDrawer = (props: { document_uri: string; - quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; + quotesAndMentions: { uri: string; link?: string }[]; comments: Comment[]; did: string; pageId?: string; @@ -23,10 +23,11 @@ export const InteractionDrawer = (props: { (c) => (c.record as any)?.onPage === props.pageId, ); - const filteredQuotes = props.quotes.filter((q) => { + const filteredQuotesAndMentions = props.quotesAndMentions.filter((q) => { + if (!q.link) return !props.pageId; // Direct mentions without quote context go to main page const url = new URL(q.link); const quoteParam = url.pathname.split("/l-quote/")[1]; - if (!quoteParam) return null; + if (!quoteParam) return !props.pageId; const quotePosition = decodeQuotePosition(quoteParam); return quotePosition?.pageId === props.pageId; }); @@ -40,7 +41,7 @@ export const InteractionDrawer = (props: { className="opaque-container rounded-l-none! rounded-r-lg! h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] " > {drawer.drawer === "quotes" ? ( - + ) : ( - q.link.includes(pageId), - ).length; - else - return document.document_mentions_in_bsky.filter((q) => { +export function getQuoteCountFromArray( + quotesAndMentions: { uri: string; link?: string }[], + pageId?: string, +) { + if (pageId) { + return quotesAndMentions.filter((q) => { + if (!q.link) return false; + return q.link.includes(pageId); + }).length; + } else { + return quotesAndMentions.filter((q) => { + if (!q.link) return true; // Direct mentions go to main page const url = new URL(q.link); const quoteParam = url.pathname.split("/l-quote/")[1]; - if (!quoteParam) return null; + if (!quoteParam) return true; const quotePosition = decodeQuotePosition(quoteParam); return !quotePosition?.pageId; }).length; + } } export function getCommentCount(document: PostPageData, pageId?: string) { diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx index e64a550d..5372e845 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx @@ -5,7 +5,6 @@ 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 { Json } from "supabase/database.types"; import { PostPageContext } from "../PostPageContext"; import { PubLeafletBlocksText, @@ -21,9 +20,12 @@ import { PostContent } from "../PostContent"; import { ProfileViewBasic } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { flushSync } from "react-dom"; import { openPage } from "../PostPages"; +import useSWR from "swr"; +import { hydrateBlueskyPosts } from "./getBlueskyMentions"; +import { DotLoader } from "components/utils/DotLoader"; export const Quotes = (props: { - quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; + quotesAndMentions: { uri: string; link?: string }[]; did: string; }) => { let data = useContext(PostPageContext); @@ -31,6 +33,23 @@ export const Quotes = (props: { if (!document_uri) throw new Error("document_uri not available in PostPageContext"); + // Fetch Bluesky post data for all URIs + const uris = props.quotesAndMentions.map((q) => q.uri); + const { data: bskyPosts, isLoading } = useSWR( + uris.length > 0 ? JSON.stringify(uris) : null, + () => hydrateBlueskyPosts(uris), + ); + + // Separate quotes with links (quoted content) from direct mentions + const quotesWithLinks = props.quotesAndMentions.filter((q) => q.link); + const directMentions = props.quotesAndMentions.filter((q) => !q.link); + + // Create a map of URIs to post views for easy lookup + const postViewMap = new Map(); + bskyPosts?.forEach((pv) => { + postViewMap.set(pv.uri, pv); + }); + return (
@@ -44,22 +63,29 @@ export const Quotes = (props: {
- {props.quotes.length === 0 ? ( + {props.quotesAndMentions.length === 0 ? (
no quotes yet!
highlight any part of this post to quote it
+ ) : isLoading ? ( +
+ loading + +
) : (
- {props.quotes.map((q, index) => { - let pv = q.bsky_posts?.post_view as unknown as PostView; + {/* Quotes with links (quoted content) */} + {quotesWithLinks.map((q, index) => { + const pv = postViewMap.get(q.uri); + if (!pv || !q.link) return null; const url = new URL(q.link); const quoteParam = url.pathname.split("/l-quote/")[1]; if (!quoteParam) return null; const quotePosition = decodeQuotePosition(quoteParam); if (!quotePosition) return null; return ( -
+
); })} + + {/* Direct post mentions (without quoted content) */} + {directMentions.length > 0 && ( +
+

Post Mentions

+
+ {directMentions.map((q, index) => { + const pv = postViewMap.get(q.uri); + if (!pv) return null; + return ( + + ); + })} +
+
+ )}
)}
@@ -154,7 +203,7 @@ export const QuoteContent = (props: { ); }; -const BskyPost = (props: { +export const BskyPost = (props: { rkey: string; content: string; user: string; diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/getBlueskyMentions.ts b/app/lish/[did]/[publication]/[rkey]/Interactions/getBlueskyMentions.ts new file mode 100644 index 00000000..5d35e69d --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/getBlueskyMentions.ts @@ -0,0 +1,80 @@ +"use server"; + +import { AtUri, Agent, lexToJson } from "@atproto/api"; +import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; + +type ConstellationResponse = { + records: { did: string; collection: string; rkey: string }[]; +}; + +const headers = { + "Content-type": "application/json", + "user-agent": "leaflet.pub", +}; + +// Fetch constellation backlinks without hydrating with Bluesky post data +export async function getConstellationBacklinks( + url: string, +): Promise<{ uri: string }[]> { + let baseURL = `https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(url)}`; + let externalEmbeds = new URL( + `${baseURL}&source=${encodeURIComponent("app.bsky.feed.post:embed.external.uri")}`, + ); + let linkFacets = new URL( + `${baseURL}&source=${encodeURIComponent("app.bsky.feed.post:facets[].features[app.bsky.richtext.facet#link].uri")}`, + ); + + let [links, embeds] = (await Promise.all([ + fetch(linkFacets, { headers, next: { revalidate: 3600 } }).then((req) => + req.json(), + ), + fetch(externalEmbeds, { headers, next: { revalidate: 3600 } }).then((req) => + req.json(), + ), + ])) as ConstellationResponse[]; + + let uris = [...links.records, ...embeds.records].map((i) => + AtUri.make(i.did, i.collection, i.rkey).toString(), + ); + + return uris.map((uri) => ({ uri })); +} + +// Hydrate Bluesky URIs with post data +export async function hydrateBlueskyPosts(uris: string[]): Promise { + if (uris.length === 0) return []; + + let agent = new Agent({ + service: "https://public.api.bsky.app", + fetch: (...args) => + fetch(args[0], { + ...args[1], + next: { revalidate: 3600 }, + }), + }); + + // Process URIs in batches of 25 + let allPostRequests = []; + for (let i = 0; i < uris.length; i += 25) { + let batch = uris.slice(i, i + 25); + let batchPosts = agent.getPosts( + { + uris: batch, + }, + { headers: {} }, + ); + allPostRequests.push(batchPosts); + } + let allPosts = (await Promise.all(allPostRequests)).flatMap( + (r) => r.data.posts, + ); + + return lexToJson(allPosts) as PostView[]; +} + +// Legacy function - kept for backwards compatibility if needed +export async function getMentions(url: string) { + let backlinks = await getConstellationBacklinks(url); + let uris = backlinks.map((b) => b.uri); + return hydrateBlueskyPosts(uris); +} diff --git a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx index b59daecd..ebea43ff 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx @@ -5,13 +5,16 @@ import { PubLeafletPublication, } from "lexicons/api"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; -import { Interactions } from "../Interactions/Interactions"; +import { + Interactions, + getQuoteCount, + getCommentCount, +} from "../Interactions/Interactions"; import { PostPageData } from "../getPostPageData"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { useIdentityData } from "components/IdentityProvider"; import { EditTiny } from "components/Icons/EditTiny"; import { SpeedyLink } from "components/SpeedyLink"; -import { decodeQuotePosition } from "../quotePosition"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; export function PostHeader(props: { @@ -95,20 +98,8 @@ export function PostHeader(props: { { - const url = new URL(q.link); - const quoteParam = url.pathname.split("/l-quote/")[1]; - if (!quoteParam) return null; - const quotePosition = decodeQuotePosition(quoteParam); - return !quotePosition?.pageId; - }).length - } - commentsCount={ - document.comments_on_documents.filter( - (c) => !(c.record as PubLeafletComment.Record)?.onPage, - ).length - } + quotesCount={getQuoteCount(document) || 0} + commentsCount={getCommentCount(document) || 0} />
diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx index 840647c9..00b061b2 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -129,6 +129,7 @@ export function PostPages({ let hasPageBackground = !!pubRecord.theme?.showPageBackground; let record = document.data as PubLeafletDocument.Record; + let quotesAndMentions = document.quotesAndMentions; let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0; return ( @@ -156,7 +157,7 @@ export function PostPages({ ? [] : document.comments_on_documents } - quotes={document.document_mentions_in_bsky} + quotesAndMentions={quotesAndMentions} did={did} /> )} @@ -232,7 +233,7 @@ export function PostPages({ ? [] : document.comments_on_documents } - quotes={document.document_mentions_in_bsky} + quotesAndMentions={quotesAndMentions} did={did} /> )} diff --git a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts index 22a5bedf..ea74f6e1 100644 --- a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts +++ b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts @@ -1,6 +1,8 @@ import { supabaseServerClient } from "supabase/serverClient"; +import { AtUri } from "@atproto/syntax"; +import { getConstellationBacklinks } from "./Interactions/getBlueskyMentions"; +import { PubLeafletPublication } from "lexicons/api"; -export type PostPageData = Awaited>; export async function getPostPageData(uri: string) { let { data: document } = await supabaseServerClient .from("documents") @@ -10,11 +12,37 @@ export async function getPostPageData(uri: string) { uri, comments_on_documents(*, bsky_profiles(*)), documents_in_publications(publications(*, publication_subscriptions(*))), - document_mentions_in_bsky(*, bsky_posts(*)), + document_mentions_in_bsky(*), leaflets_in_publications(*) `, ) .eq("uri", uri) .single(); - return document; + + if (!document) return null; + + // Fetch constellation backlinks for mentions + const pubRecord = document.documents_in_publications[0]?.publications + ?.record as PubLeafletPublication.Record; + const rkey = new AtUri(uri).rkey; + const postUrl = `https://${pubRecord?.base_path}/${rkey}`; + const constellationBacklinks = await getConstellationBacklinks(postUrl); + + // Combine database mentions and constellation backlinks + const quotesAndMentions: { uri: string; link?: string }[] = [ + // Database mentions (quotes with link to quoted content) + ...document.document_mentions_in_bsky.map((m) => ({ + uri: m.uri, + link: m.link, + })), + // Constellation backlinks (direct post mentions without quote context) + ...constellationBacklinks, + ]; + + return { + ...document, + quotesAndMentions, + }; } + +export type PostPageData = Awaited>; diff --git a/app/lish/[did]/[publication]/[rkey]/page.tsx b/app/lish/[did]/[publication]/[rkey]/page.tsx index e9880247..45200924 100644 --- a/app/lish/[did]/[publication]/[rkey]/page.tsx +++ b/app/lish/[did]/[publication]/[rkey]/page.tsx @@ -67,7 +67,6 @@ export default async function Post(props: { fetch: (...args) => fetch(args[0], { ...args[1], - cache: "no-store", next: { revalidate: 3600 }, }), }); @@ -132,11 +131,17 @@ export default async function Post(props: { // Extract poll blocks and fetch vote data let pollBlocks = record.pages.flatMap((p) => { let page = p as PubLeafletPagesLinearDocument.Main; - return page.blocks?.filter( - (b) => b.block.$type === ids.PubLeafletBlocksPoll, - ) || []; + return ( + page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) || + [] + ); }); - let pollData = await fetchPollData(pollBlocks.map(b => (b.block as any).pollRef.uri)); + let pollData = await fetchPollData( + pollBlocks.map((b) => (b.block as any).pollRef.uri), + ); + + let pubRecord = document.documents_in_publications[0]?.publications + .record as PubLeafletPublication.Record; let firstPage = record.pages[0]; let blocks: PubLeafletPagesLinearDocument.Block[] = []; @@ -144,9 +149,6 @@ export default async function Post(props: { blocks = firstPage.blocks || []; } - let pubRecord = document.documents_in_publications[0]?.publications - .record as PubLeafletPublication.Record; - let prerenderedCodeBlocks = await extractCodeBlocks(blocks); return ( -- 2.51.2