"use client"; // Client-side cache for the social-post corpus, mirroring subsCache.ts: a // per-channel manifest hook plus lazily-fetched pages, keyed by OriginId so a // federating hub can hold several origins' posts at once without collisions. import { useQueries, useQuery } from "@tanstack/react-query"; import { postConversation, type ChannelPostsManifest, type Post, type PostsManifest, } from "../lib/posts"; import { PromiseMap } from "../lib/archive/reader"; import { channelRef, readerFor } from "../lib/archive/readers"; import { makeId, splitId } from "./originId"; const resolved = new Map(); const inFlight = new Map>(); // Two memos the reader does NOT provide, for two different reasons. The // manifest one wants a THROW where the reader's tolerant `postsManifest` wants // a null (see subsCache). The page one exists because the reader caches subs // and transcript pages but not posts pages, and fetchThread below walks EVERY // page of a channel — without a memo, opening two posts in a thread would // re-download the whole channel. const channelManifests = new PromiseMap(); const pagePromises = new PromiseMap(); export type PostsManifestRef = { channelSlug: string; origin?: string }; // `id` is an OriginId: bare `/` same-origin, // origin-prefixed cross-origin. export function fetchPost(id: string): Promise { const hit = resolved.get(id); if (hit) return Promise.resolve(hit); const flying = inFlight.get(id); if (flying) return flying; const p = load(id).then((post) => { resolved.set(id, post); inFlight.delete(id); return post; }); p.catch(() => inFlight.delete(id)); inFlight.set(id, p); return p; } // Synchronously readable cache hit — used by result cards that already pulled // the page during search and shouldn't re-suspend to render. export function peekPost(id: string): Post | undefined { return resolved.get(id); } export function fetchChannelPostsManifest( channelSlug: string, origin = "", ): Promise { return channelManifests.take(makeId(origin, channelSlug), () => readerFor(origin).readChannelPostsManifest(channelSlug), ); } export function fetchPostsPage( channelSlug: string, pageIndex: number, origin = "", ): Promise { return pagePromises.take( `${makeId(origin, channelSlug)}:${pageIndex}`, async () => { const page = await readerFor(origin).postsPage( channelRef(channelSlug, origin), pageIndex, ); // Warm the by-id cache so a later fetchPost() for any post on this page // is synchronous. for (const entry of page) { resolved.set(makeId(origin, entry.slug), entry); } return page; }, ); } async function load(id: string): Promise { const { origin, slug } = splitId(id); const slashIdx = slug.indexOf("/"); if (slashIdx < 0) throw new Error(`Malformed post slug: ${slug}`); const channelSlug = slug.slice(0, slashIdx); const postId = slug.slice(slashIdx + 1); const manifest = await fetchChannelPostsManifest(channelSlug, origin); const pageIndex = manifest.slugToPage[postId]; if (pageIndex === undefined) throw new Error(`Unknown post slug: ${slug}`); const page = await fetchPostsPage(channelSlug, pageIndex, origin); const found = page.find((entry) => entry.slug === slug); if (!found) throw new Error(`Post ${slug} missing from page ${pageIndex}`); return found; } // The thread a post belongs to (root + every archived reply), oldest first. // This is the post-corpus analogue of a transcript's ±45s cue window: the // natural "more context around this hit" unit. export async function fetchThread(id: string): Promise { const { origin, slug } = splitId(id); const slashIdx = slug.indexOf("/"); if (slashIdx < 0) throw new Error(`Malformed post slug: ${slug}`); const channelSlug = slug.slice(0, slashIdx); const post = await fetchPost(id); const threadId = post.threadId || post.id; const forum = post.platform === "xenforo"; // A thread can straddle pages, so scan every page of the channel. Pages are // cached, and a channel's page count is small (byte-capped shards). A forum // post's thread is its conversation (postConversation): the channel IS the // forum thread, so every page is a candidate. const manifest = await fetchChannelPostsManifest(channelSlug, origin); const candidates: Post[] = []; for (let i = 0; i < manifest.pageCount; i++) { for (const entry of await fetchPostsPage(channelSlug, i, origin)) { if (forum || (entry.threadId || entry.id) === threadId) candidates.push(entry); } } return postConversation(post, candidates); } export function usePostsManifest(origin = "") { return useQuery({ queryKey: ["posts-manifest", origin], queryFn: () => readerFor(origin) .readPostsSiteManifest() // A site with no social channels ships no posts manifest; treat that as // an empty corpus rather than an error, so the UI degrades quietly. .catch( (): PostsManifest => ({ version: 0, channels: [], totalCount: 0, generatedAt: "", }), ), }); } export function useChannelPostsManifests( refs: Array, ) { return useQueries({ queries: refs.map((ref) => { const { channelSlug, origin = "" } = typeof ref === "string" ? { channelSlug: ref, origin: "" } : ref; return { queryKey: ["channel-posts-manifest", origin, channelSlug], queryFn: () => fetchChannelPostsManifest(channelSlug, origin), }; }), }); }