diff --git a/app/api/bsky/hydrate/route.ts b/app/api/bsky/hydrate/route.ts new file mode 100644 index 00000000..171bcc81 --- /dev/null +++ b/app/api/bsky/hydrate/route.ts @@ -0,0 +1,75 @@ +import { Agent, lexToJson } from "@atproto/api"; +import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; +import { NextRequest } from "next/server"; + +export const runtime = "nodejs"; + +export async function GET(req: NextRequest) { + try { + const searchParams = req.nextUrl.searchParams; + const urisParam = searchParams.get("uris"); + + if (!urisParam) { + return Response.json( + { error: "uris parameter is required" }, + { status: 400 }, + ); + } + + // Parse URIs from JSON string + let uris: string[]; + try { + uris = JSON.parse(urisParam); + } catch (e) { + return Response.json( + { error: "uris must be valid JSON array" }, + { status: 400 }, + ); + } + + if (!Array.isArray(uris)) { + return Response.json({ error: "uris must be an array" }, { status: 400 }); + } + + if (uris.length === 0) { + return Response.json([], { + headers: { + "Cache-Control": "public, s-maxage=600, stale-while-revalidate=3600", + }, + }); + } + + // Hydrate Bluesky URIs with post data + let agent = new Agent({ + service: "https://public.api.bsky.app", + }); + + // 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, + ); + + const posts = lexToJson(allPosts) as PostView[]; + + return Response.json(posts, { + headers: { + // Cache for 1 hour on CDN, allow stale content for 24 hours while revalidating + "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400", + }, + }); + } catch (error) { + console.error("Error hydrating Bluesky posts:", error); + return Response.json({ error: "Failed to hydrate posts" }, { status: 500 }); + } +} diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx index d0d81841..ad84750f 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -11,6 +11,7 @@ import { PostPageContext } from "../PostPageContext"; import { scrollIntoView } from "src/utils/scrollIntoView"; import { PostPageData } from "../getPostPageData"; import { PubLeafletComment } from "lexicons/api"; +import { prefetchQuotesData } from "./Quotes"; export type InteractionState = { drawerOpen: undefined | boolean; @@ -110,6 +111,12 @@ export const Interactions = (props: { let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); + const handleQuotePrefetch = () => { + if (data?.quotesAndMentions) { + prefetchQuotesData(data.quotesAndMentions); + } + }; + return (
{props.quotesCount}{" "} diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx index 5372e845..0621d14a 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx @@ -20,10 +20,43 @@ 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 useSWR, { mutate } from "swr"; import { DotLoader } from "components/utils/DotLoader"; +// Helper to get SWR key for quotes +export function getQuotesSWRKey(uris: string[]) { + if (uris.length === 0) return null; + const params = new URLSearchParams({ + uris: JSON.stringify(uris), + }); + return `/api/bsky/hydrate?${params.toString()}`; +} + +// Fetch posts from API route +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(); +} + +// Prefetch quotes data +export function prefetchQuotesData(quotesAndMentions: { uri: string; link?: string }[]) { + const uris = quotesAndMentions.map((q) => q.uri); + const key = getQuotesSWRKey(uris); + if (key) { + // Start fetching without blocking + mutate(key, fetchBskyPosts(uris), { revalidate: false }); + } +} + export const Quotes = (props: { quotesAndMentions: { uri: string; link?: string }[]; did: string; @@ -35,9 +68,10 @@ export const Quotes = (props: { // Fetch Bluesky post data for all URIs const uris = props.quotesAndMentions.map((q) => q.uri); + const key = getQuotesSWRKey(uris); const { data: bskyPosts, isLoading } = useSWR( - uris.length > 0 ? JSON.stringify(uris) : null, - () => hydrateBlueskyPosts(uris), + key, + () => fetchBskyPosts(uris), ); // Separate quotes with links (quoted content) from direct mentions diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/getBlueskyMentions.ts b/app/lish/[did]/[publication]/[rkey]/Interactions/getBlueskyMentions.ts deleted file mode 100644 index 5d35e69d..00000000 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/getBlueskyMentions.ts +++ /dev/null @@ -1,80 +0,0 @@ -"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]/getPostPageData.ts b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts index ea74f6e1..9936c4a1 100644 --- a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts +++ b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts @@ -1,6 +1,5 @@ import { supabaseServerClient } from "supabase/serverClient"; import { AtUri } from "@atproto/syntax"; -import { getConstellationBacklinks } from "./Interactions/getBlueskyMentions"; import { PubLeafletPublication } from "lexicons/api"; export async function getPostPageData(uri: string) { @@ -46,3 +45,40 @@ export async function getPostPageData(uri: string) { } export type PostPageData = Awaited>; + +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 })); +} + +type ConstellationResponse = { + records: { did: string; collection: string; rkey: string }[]; +};