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 }[];
+};