"use client"; // Data source for TranscriptSearch. In single-site mode this is a thin wrapper // over the same summaries/subs hooks the component used to call directly, so // behavior is byte-for-byte unchanged. In hub mode (added when the hub page // lands) a MultiSiteDataProvider merges these feeds across many origins and // hands the component one unified, origin-qualified view — so TranscriptSearch // itself stays mode-agnostic and reads everything from this context. import { createContext, useCallback, useContext, useMemo, type ReactNode, } from "react"; import { useQueries, useQueryClient } from "@tanstack/react-query"; import { useSummaries, type SummariesState } from "./summariesCache"; import { useSubsManifest } from "./subsCache"; import { usePostsManifest } from "./postsCache"; import { useSearchAliases, fetchAliases } from "./aliasesCache"; import { useCuratedTags } from "./tagsCache"; import type { PublishedTag } from "../lib/curatedTags"; import { mergeAliases, type SearchAlias } from "../lib/searchAliases"; import { makeId } from "./originId"; import type { DisplaySummary } from "../lib/transcripts"; import type { Manifest, SubsManifest } from "../lib/manifest"; import type { PostsManifest } from "../lib/posts"; import { readerFor } from "../lib/archive/readers"; import { ArchiveHttpError } from "../lib/archive/reader"; import { DEFAULT_GROUP_FALLBACK_ID, FALLBACK_GROUP, resolveChannelGroupId, type ChannelGroup, } from "../lib/channelGroups"; // One selectable channel in the filter UI. `key` is the STABLE selection // identity that filter state, share links, and saved profiles store: // single-site: key === the display name (back-compat with name-keyed state) // hub: key === makeId(origin, channelSlug) (collision-free across sites) // `name` is always the human label shown in the UI. export type ChannelOption = { key: string; name: string; groupId: string; }; // Minimal shape of a summary needed to derive its channel key. type ChannelKeyable = { channel: string; channelSlug: string }; export type SearchDataValue = { // Summaries + cross-channel manifest. Merged across origins in hub mode. summariesState: SummariesState; // Cross-channel subs manifest (merged across origins in hub mode), or null. subsManifest: SubsManifest | null; // Cross-channel social-posts manifest (which channels carry posts, and how // many). Merged across origins in hub mode. Null / empty channel list when // this site ships no posts corpus. postsManifest: PostsManifest | null; // The two manifests above have answered (or failed), so a null or an empty // one means "this site has none", not "not answered yet". The "Search in" // row waits for it before it refuses a Search or runs one: until then a // stored row cannot be told apart from an empty one. Hub mode: the merge // covers the ready archives only, and an archive is ready only once both // its manifests have settled, so it is `summariesReady`. manifestsSettled: boolean; // The channel selection model, resolved mode-appropriately. channels: ChannelOption[]; groups: ChannelGroup[]; defaultGroupId: string; // Map a summary to its channel selection key (matches ChannelOption.key). channelKeyOf: (t: ChannelKeyable) => string; // Provenance accent for a content origin ("" = same-origin). Returns a color // only in hub mode (each federated site's accent); undefined in single-site // mode, so no provenance marker renders. Stable reference in single-site mode. accentOf: (origin: string) => string | undefined; // Known search-alias dictionary for the leaf suggestion chip. Single-site: the // site's own list. Hub: merged across federated origins. Empty when unauthored. aliases: SearchAlias[]; // The curated tags THIS SITE publishes, with its own per-site counts // (/tags.json). Empty when the site publishes none — which is both "nothing // curated yet" and "built before corpus spec 4", and in either case means the // filter panel offers no tag chips at all. Read from the page's own origin in // both modes: a hub's counts are the hub's, and summing member documents here // would put a number in front of someone that no site can reproduce. curatedTags: PublishedTag[]; // Hub mode only (absent single-site): the per-archive state of the // federation — which archives are in scope, loading, ready or failed — and a // Retry per archive. Drives the scope chips and the "N of M archives // answered." line. federation?: FederationState; // Hub mode only: the source archive's title for a content origin, so a result // card can name where it came from in text, not by its accent alone. siteTitleOf?: (origin: string) => string | undefined; }; // One federated archive's state, as the hub shows it on its scope chip. // off — the visitor took it out of the search; nothing is fetched. // loading — its manifest or pages are still arriving (or being retried). // ready — every summaries page arrived; its records are in the search. // failed — its manifest or a summaries page could not be read; its records // are NOT in the search until a Retry succeeds. export type FederatedSiteStatus = "loading" | "ready" | "failed" | "off"; export type FederatedSiteState = { origin: string; siteTitle: string; accent?: string; status: FederatedSiteStatus; // The archive's transcript count (its summaries manifest's totalCount), once // the manifest has arrived. count?: number; // Why it failed, when it did. error?: string; // Ready only: its live-chat (subs) manifest could not be read, even after its // retry, so its videos are searched WITHOUT their live chat. `retrying` while // a live-chat Retry (FederationState.retryLiveChat) is in flight; `error` is // the last failure's reason. liveChatMissing?: { retrying: boolean; error?: string }; }; export type FederationState = { // In registry order, off archives included. sites: FederatedSiteState[]; // Whether `sites` is the hub's whole list (the provider's `listed` prop): // false while the hub's own list of archives is still arriving, so an empty // `sites` then means "not yet", not "none". listed: boolean; // Refetch everything of this archive that failed. retry: (origin: string) => void; // Refetch this archive's live-chat (subs) manifest alone — the Retry beside // a ready archive's `liveChatMissing` note. Its videos stay in the search // while it runs. retryLiveChat: (origin: string) => void; }; // Single-site mode has no provenance accents; a module constant keeps the // reference stable so downstream memos (e.g. result groups) don't churn. const NO_ACCENT = (): undefined => undefined; const SearchDataContext = createContext(null); export function useSearchData(): SearchDataValue { const value = useContext(SearchDataContext); if (!value) { throw new Error("useSearchData must be used within a SearchData provider"); } return value; } // Single-site data source: the built-in export site (same-origin, origin=""). // The channel key IS the display name here, so all name-keyed filter state, // share links, and saved profiles round-trip exactly as before. export function SingleSiteDataProvider({ children }: { children: ReactNode }) { const summariesState = useSummaries(""); const subsQuery = useSubsManifest(""); const postsQuery = usePostsManifest(""); const subsManifest = subsQuery.data ?? null; const postsManifest = postsQuery.data ?? null; // Settled at the first failure, not after the retry: a site with no live // chat ships no subs manifest, and its 404 must not hold a search for the // retry's second. A retry that does answer later still arrives (the row then // re-runs), as a late manifest always did. const manifestsSettled = (subsQuery.data !== undefined || subsQuery.isError || subsQuery.failureCount > 0) && (postsQuery.data !== undefined || postsQuery.isError || postsQuery.failureCount > 0); const aliases = useSearchAliases(""); const curatedTags = useCuratedTags(""); const manifest = summariesState.manifest; const groups = useMemo(() => { const src = manifest?.groups; return src && src.length > 0 ? src : [{ ...FALLBACK_GROUP }]; }, [manifest]); const defaultGroupId = useMemo(() => { const dg = manifest?.defaultGroupId; if (typeof dg === "string" && groups.some((g) => g.id === dg)) return dg; return groups[0]?.id ?? DEFAULT_GROUP_FALLBACK_ID; }, [manifest, groups]); const channels = useMemo(() => { if (manifest?.channels.length) { return manifest.channels .filter((c) => c.name) .map((c) => ({ key: c.name, name: c.name, groupId: resolveChannelGroupId(c.groupId, groups, defaultGroupId), })) .sort((a, b) => a.name.localeCompare(b.name)); } // Fallback: no manifest channel list — derive names from loaded summaries. const names = new Set(); for (const t of summariesState.summaries) if (t.channel) names.add(t.channel); return Array.from(names) .sort((a, b) => a.localeCompare(b)) .map((name) => ({ key: name, name, groupId: resolveChannelGroupId(undefined, groups, defaultGroupId), })); }, [manifest, groups, defaultGroupId, summariesState.summaries]); const value = useMemo( () => ({ summariesState, subsManifest, postsManifest, manifestsSettled, channels, groups, defaultGroupId, channelKeyOf: (t) => t.channel, accentOf: NO_ACCENT, aliases, curatedTags, }), [ summariesState, subsManifest, postsManifest, manifestsSettled, channels, groups, defaultGroupId, aliases, curatedTags, ], ); return ( {children} ); } // A federated origin the hub reads from. `origin` is "" for the hub's own // same-origin pool, else a full origin ("https://x.com"). `siteTitle` labels // its channel group; `accent` is carried for provenance in the UI. `enabled` // false takes it out of the search: none of its feeds are fetched and nothing // already cached from it is merged (default true). export type FederatedSite = { origin: string; siteTitle: string; accent?: string; enabled?: boolean; }; // At most this many summaries-page fetches in flight per archive, so one big // member (Jeralyzer: dozens of pages) cannot take every connection the browser // has and starve the small ones. Per ORIGIN, not global: different origins do // not share a connection pool, so a global cap would only slow the whole hub. const PAGE_FETCHES_PER_SITE = 6; // The per-archive feeds this provider fetches — what a Retry refetches. const FEDERATED_KEYS = new Set([ "manifest", "summaries-page", "subs-manifest", "posts-manifest", "search-aliases", ]); type Gate = { active: number; queue: Array<() => void> }; const pageGates = new Map(); async function gated(origin: string, fn: () => Promise): Promise { let g = pageGates.get(origin); if (!g) { g = { active: 0, queue: [] }; pageGates.set(origin, g); } if (g.active >= PAGE_FETCHES_PER_SITE) { // The releasing fetch hands its slot straight to us (active unchanged). await new Promise((resolve) => g.queue.push(resolve)); } else { g.active++; } try { return await fn(); } finally { const next = g.queue.shift(); if (next) next(); else g.active--; } } function errorText(e: unknown): string | undefined { if (!e) return undefined; return e instanceof Error ? e.message : String(e); } // Multi-site data source for the hub: fans out the same summaries/subs feeds // across many origins and merges them into ONE origin-qualified view, so // TranscriptSearch stays mode-agnostic (it reads exactly the same context shape // as in single-site mode). // // Identity: every cross-origin summary's `slug` and `channelSlug` are rewritten // to makeId(origin, …) at merge time, so result ids, the channelKey selection // model, and downstream fetches (fetchTranscript decodes the origin back out) // never collide across sites. Grouping falls out of the existing grouped- // checkbox UI: one ChannelGroup per site (id = origin, label = siteTitle). // // Per archive, in order: its summaries manifest, then — only once that has // arrived, and only while the archive is in scope — its pages, at most // PAGE_FETCHES_PER_SITE at a time. An archive's records join the merged list // only when ALL its pages are in (status "ready"), so the list grows one whole // archive at a time and a search re-runs once per archive, not once per page. // A failed archive contributes nothing and says so (`federation`). // // `progressive`: summariesReady turns true as soon as ONE in-scope archive is // ready (the hub's search page, where results should not wait for the slowest // member). Without it, summariesReady waits until every in-scope archive has // settled, ready or failed (the /ask chat, which grounds in what it was given). // // `listed` (default true): whether `sites` is the hub's WHOLE list yet. /ask // passes false until the hub's own list (`/hub-sites.json`) has been answered: // the archives a browser added are known at once, the hub's own a moment later, // and a non-progressive surface is not ready over the first few alone. Its // archives are fetched meanwhile; only readiness waits. `federation.listed` // carries it, so an empty list can be told apart from one not in yet. export function MultiSiteDataProvider({ sites, progressive = false, listed = true, children, }: { sites: FederatedSite[]; progressive?: boolean; listed?: boolean; children: ReactNode; }) { const queryClient = useQueryClient(); const inScope = (s: FederatedSite) => s.enabled !== false; // 1. Per-origin summaries manifests (channels/groups/pageCount/freshness). const manifestQueries = useQueries({ queries: sites.map((s) => ({ queryKey: ["manifest", s.origin], queryFn: () => readerFor(s.origin).readSummariesManifest(), enabled: inScope(s), })), }); // 2. Page descriptors for every in-scope origin whose manifest has arrived. // Keyed by a string of (origin, pageCount) so the list's identity moves // only when an archive's manifest lands or the scope changes. const pagePlanKey = sites .map((s, i) => { const m = manifestQueries[i]?.data; return inScope(s) && m ? `${s.origin}\n${m.pageCount}` : ""; }) .join("\u0000"); const pageDescriptors = useMemo<{ origin: string; index: number }[]>(() => { const out: { origin: string; index: number }[] = []; sites.forEach((s, i) => { if (!inScope(s)) return; const pc = manifestQueries[i]?.data?.pageCount ?? 0; for (let p = 0; p < pc; p++) out.push({ origin: s.origin, index: p }); }); return out; // manifestQueries identity churns; pagePlanKey is its content. // eslint-disable-next-line react-hooks/exhaustive-deps }, [pagePlanKey]); const pageQueries = useQueries({ queries: pageDescriptors.map((d) => ({ queryKey: ["summaries-page", d.origin, d.index], // Consuming `signal` lets TanStack cancel a page whose archive was // switched off mid-load; a page still waiting at the gate then bails // before it is fetched. queryFn: ({ signal }: { signal: AbortSignal }) => gated(d.origin, () => { signal.throwIfAborted(); return readerFor(d.origin).readSummariesPage(d.index); }), })), }); // Each archive's subs + posts manifests. Fetched alongside its summaries, // merged below only once the archive is ready, so all of an archive's // contributions land in the search in one re-run. A posts 404 (no posts // corpus) resolves to an empty manifest, not a failure. // // A subs 404 is the same answer — a member with no live chat has no subs // manifest — so it resolves to an empty one at once. It used to throw, and // the client's retry held that archive at "loading" for another second. Any // OTHER error (network, 5xx, or a 404 served without CORS, which the browser // reports as a network error — `serve` on a self-hosted archive does that) // is retried once and then counts as SETTLED: the archive is ready without // its live chat. Live chat is the auxiliary layer; its failure never takes an // archive's videos out of the search or makes it read as "did not answer". // The archive's chip notes it instead (`liveChatMissing`), with a Retry for // the live chat alone, and the archive stays ready while that runs (the rule // is in step 3). const subsQueries = useQueries({ queries: sites.map((s) => ({ queryKey: ["subs-manifest", s.origin], queryFn: () => readerFor(s.origin) .readSubsSiteManifest() .catch((err: unknown): SubsManifest => { if (err instanceof ArchiveHttpError && err.status === 404) { return { version: 0, channels: [], totalCount: 0, liveChatTotalCount: 0, generatedAt: "", }; } throw err; }), enabled: inScope(s), retry: 1, })), }); const postsQueries = useQueries({ queries: sites.map((s) => ({ queryKey: ["posts-manifest", s.origin], queryFn: () => readerFor(s.origin) .readPostsSiteManifest() .catch( (): PostsManifest => ({ version: 0, channels: [], totalCount: 0, generatedAt: "", }), ), enabled: inScope(s), })), }); // 3. Each archive's state, from its manifest, page, subs and posts queries. const pagesByOrigin = new Map(); pageQueries.forEach((q, i) => { const origin = pageDescriptors[i]?.origin; if (origin === undefined) return; const list = pagesByOrigin.get(origin); if (list) list.push(q); else pagesByOrigin.set(origin, [q]); }); const rawStates: FederatedSiteState[] = sites.map((s, i) => { const base = { origin: s.origin, siteTitle: s.siteTitle, ...(s.accent ? { accent: s.accent } : {}), }; if (!inScope(s)) return { ...base, status: "off" }; const m = manifestQueries[i]; const pages = pagesByOrigin.get(s.origin) ?? []; const count = m?.data?.totalCount; const withCount = count === undefined ? base : { ...base, count }; // Subs and posts manifests resolve a 404 to an empty manifest; posts never // errors. A subs query that still errors after its retry counts as settled // (the archive is ready without its live chat) and never as a failure. // // "Has errored" is `errorUpdateCount > 0`, not `isError`: the count moves // only once the query's own retry is spent, and it survives a refetch — // TanStack puts an errored query with no data back to `pending` while it // refetches, so `isError` would take the archive (and its videos) out of // the search for the length of a live-chat Retry. With the count, the // archive stays ready through it, still noted as missing its live chat. // // That holds for the PROGRESSIVE front page only. The count lives in the // shared query client, so it also survives into a later refetch the front // page never asked for: /ask's own mount (an errored query with no data is // refetched when a new observer mounts), a scope toggle off and on, a // whole-archive Retry. /ask grounds in what it was given, so it waits for // such a refetch to settle rather than answer without live chat that is // about to arrive. const posts = postsQueries[i]; const postsSettled = !!posts && (posts.isSuccess || posts.isError); const subs = subsQueries[i]; const subsErrored = !!subs && subs.data === undefined && subs.errorUpdateCount > 0; const subsSettled = !!subs && (subs.data !== undefined || (subsErrored && (progressive || !subs.isFetching))); if ( m?.isSuccess && pages.length === m.data.pageCount && pages.every((p) => p.isSuccess) && subsSettled && postsSettled ) { if (subsErrored) { const why = errorText(subs.error); return { ...withCount, status: "ready", liveChatMissing: { retrying: subs.isFetching, ...(why ? { error: why } : {}), }, }; } return { ...withCount, status: "ready" }; } const fetching = !!m?.isFetching || pages.some((p) => p.isFetching); const failed = m?.isError ? m : pages.find((p) => p.isError); if (failed && !fetching) { return { ...withCount, status: "failed", ...(errorText(failed.error) ? { error: errorText(failed.error) } : {}), }; } return { ...withCount, status: "loading" }; }); const statusKey = rawStates .map((s) => { const chat = s.liveChatMissing; const chatKey = chat ? `${chat.retrying ? "retrying" : "missing"}:${chat.error ?? ""}` : ""; return `${s.origin}|${s.siteTitle}|${s.accent ?? ""}|${s.status}|${s.count ?? ""}|${s.error ?? ""}|${chatKey}`; }) .join("\n"); const siteStates = useMemo( () => rawStates, // eslint-disable-next-line react-hooks/exhaustive-deps [statusKey], ); const readyOrigins = useMemo( () => new Set(siteStates.filter((s) => s.status === "ready").map((s) => s.origin)), [siteStates], ); const readyKey = Array.from(readyOrigins).join("\u0000"); const scoped = siteStates.filter((s) => s.status !== "off"); // Nothing in scope — every archive switched off, the hub's list not arrived // yet, or a hub with no archives at all — is NOT ready for a surface that // answers from the whole federation (not `progressive`: the /ask chat). There // is nothing to ground a question in, and "ready" there would send one over // zero records. Nor is a list that is not yet the whole list (`listed` false: // the archives this browser added are in, the hub's own still arriving). /ask // says which it is once the list is in (`federation.listed`). The progressive // front page settles on an empty scope instead, so its results read "no // videos" rather than loading forever; its chips and "Searching 0 archives" // say why. const allSettled = listed && (progressive || scoped.length > 0) && scoped.every((s) => s.status === "ready" || s.status === "failed"); const summariesReady = allSettled || (progressive && readyOrigins.size > 0); const loadedPages = pageQueries.filter((q) => q.data).length; // 4. Merge the READY archives' summaries, rewriting ids to be // origin-qualified, newest first. const summaries = useMemo(() => { const out: DisplaySummary[] = []; pageQueries.forEach((q, i) => { const origin = pageDescriptors[i]?.origin ?? ""; if (!q.data || !readyOrigins.has(origin)) return; for (const t of q.data) { out.push( origin ? { ...t, slug: makeId(origin, t.slug), channelSlug: makeId(origin, t.channelSlug), } : t, ); } }); out.sort( (a, b) => b.uploadDate.localeCompare(a.uploadDate) || a.channelSlug.localeCompare(b.channelSlug) || a.id.localeCompare(b.id), ); return out; // Only the ready set: a ready archive's pages never change, so a manifest // landing elsewhere (a new pageDescriptors) must not hand the session a new // list — every new identity re-runs the current search. // eslint-disable-next-line react-hooks/exhaustive-deps }, [readyKey]); // 5. One channel group per in-scope site; channels keyed by // makeId(origin, slug), from every in-scope manifest that has arrived. const groups = useMemo( () => sites.filter(inScope).map((s, i) => ({ id: s.origin, name: s.siteTitle, selectedByDefault: true, order: i, ...(s.accent ? { accent: s.accent } : {}), })), // eslint-disable-next-line react-hooks/exhaustive-deps [sites], ); const defaultGroupId = sites.find(inScope)?.origin ?? sites[0]?.origin ?? DEFAULT_GROUP_FALLBACK_ID; const channels = useMemo(() => { const out: ChannelOption[] = []; sites.forEach((s, i) => { if (!inScope(s)) return; const manifest = manifestQueries[i]?.data; if (!manifest) return; for (const c of manifest.channels) { if (!c.slug || !c.name) continue; out.push({ key: makeId(s.origin, c.slug), name: c.name, groupId: s.origin, }); } }); return out.sort( (a, b) => a.groupId.localeCompare(b.groupId) || a.name.localeCompare(b.name), ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sites, pagePlanKey]); // 6. Merge subs manifests: concat channels (slug → origin-qualified) so the // chat scope resolves per-origin; sum the live-chat/total counts. Merged // over the READY archives only (a failed archive contributes nothing). const subsKey = sites .map((s, i) => (readyOrigins.has(s.origin) && subsQueries[i]?.data ? s.origin : "")) .join("\u0000"); const subsManifest = useMemo(() => { const loaded = sites .map((s, i) => ({ origin: s.origin, data: readyOrigins.has(s.origin) ? subsQueries[i]?.data : undefined, })) .filter((e): e is { origin: string; data: SubsManifest } => !!e.data); if (loaded.length === 0) return null; const channelsOut: SubsManifest["channels"] = []; let totalCount = 0; let liveChatTotalCount = 0; let generatedAt = ""; for (const { origin, data } of loaded) { for (const c of data.channels) { channelsOut.push({ ...c, slug: makeId(origin, c.slug) }); } totalCount += data.totalCount ?? 0; liveChatTotalCount += data.liveChatTotalCount ?? 0; if (data.generatedAt > generatedAt) generatedAt = data.generatedAt; } return { version: loaded[0].data.version, channels: channelsOut, totalCount, liveChatTotalCount, generatedAt, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [subsKey]); // 6b. Merge posts manifests, same origin-qualification as subs. A member site // with no posts corpus 404s; treat that as an empty contribution so one // video-only origin can't blank the hub's posts scope (and it is not a // failure of that archive). const postsKey = sites .map((s, i) => (readyOrigins.has(s.origin) && postsQueries[i]?.data ? s.origin : "")) .join("\u0000"); const postsManifest = useMemo(() => { const loaded = sites .map((s, i) => ({ origin: s.origin, data: readyOrigins.has(s.origin) ? postsQueries[i]?.data : undefined, })) .filter((e): e is { origin: string; data: PostsManifest } => !!e.data); if (loaded.length === 0) return null; const channelsOut: PostsManifest["channels"] = []; let totalCount = 0; let generatedAt = ""; for (const { origin, data } of loaded) { for (const c of data.channels) { channelsOut.push({ ...c, slug: makeId(origin, c.slug) }); } totalCount += data.totalCount ?? 0; if (data.generatedAt > generatedAt) generatedAt = data.generatedAt; } return { version: loaded[0].data.version, channels: channelsOut, totalCount, generatedAt, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [postsKey]); // Alias dictionaries per federated origin, merged into one list (later origins // shadow earlier ones by id). Missing files resolve to [] — additive only. const aliasQueries = useQueries({ queries: sites.map((s) => ({ queryKey: ["search-aliases", s.origin], queryFn: () => fetchAliases(s.origin), staleTime: Infinity, enabled: inScope(s), })), }); const aliasesKey = sites .map((s, i) => (readyOrigins.has(s.origin) && aliasQueries[i]?.data ? s.origin : "")) .join("\u0000"); const aliases = useMemo(() => { let merged: SearchAlias[] = []; sites.forEach((s, i) => { const data = readyOrigins.has(s.origin) ? aliasQueries[i]?.data : undefined; if (data) merged = mergeAliases(merged, data); }); return merged; // eslint-disable-next-line react-hooks/exhaustive-deps }, [aliasesKey]); // The hub's OWN /tags.json, not a merge of its members'. See the field note // on SearchDataValue: a federated count nobody can reproduce is worse than no // chip, so until a hub build writes one, hub mode offers no tag chips. const curatedTags = useCuratedTags(""); // Synthetic merged summaries manifest over the READY archives — what the // search actually covers. TranscriptSearch reads channels/groups from the // context (above), not from here, but the field is part of the SummariesState // contract, so provide a coherent merged view. const mergedManifest = useMemo(() => { const loaded = sites .map((s, i) => readyOrigins.has(s.origin) ? manifestQueries[i]?.data : undefined, ) .filter((m): m is Manifest => !!m); if (loaded.length === 0) return null; return { version: loaded[0].version, totalCount: loaded.reduce((n, m) => n + (m.totalCount ?? 0), 0), pageSize: loaded[0].pageSize, pageCount: pageDescriptors.length, generatedAt: loaded.reduce( (max, m) => (m.generatedAt > max ? m.generatedAt : max), "", ), channels: [], groups, defaultGroupId, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [readyKey, groups, defaultGroupId, pageDescriptors]); // Every in-scope archive failed: say so to the consumers that read `error` // (the /ask chat). One failure among several is the federation line's job. const allFailed = scoped.length > 0 && scoped.every((s) => s.status === "failed"); const summariesError = useMemo( () => allFailed ? new Error( scoped.length === 1 ? `${scoped[0].siteTitle} did not answer.` : "None of the archives answered.", ) : null, // eslint-disable-next-line react-hooks/exhaustive-deps [allFailed, statusKey], ); const summariesState = useMemo( () => ({ manifest: mergedManifest, summaries, loadedPages, pageCount: pageDescriptors.length, summariesReady, error: summariesError, }), [ mergedManifest, summaries, loadedPages, pageDescriptors, summariesReady, summariesError, ], ); // origin → accent / title lookups for provenance in results. const accentByOrigin = useMemo(() => { const m = new Map(); for (const s of sites) if (s.accent) m.set(s.origin, s.accent); return m; }, [sites]); const accentOf = useCallback( (origin: string) => accentByOrigin.get(origin), [accentByOrigin], ); const titleByOrigin = useMemo( () => new Map(sites.map((s) => [s.origin, s.siteTitle])), [sites], ); const siteTitleOf = useCallback( (origin: string) => titleByOrigin.get(origin), [titleByOrigin], ); // Retry: refetch every query of this origin that ended in error — its // manifest, failed pages, and any other feed of it that failed. const retry = useCallback( (origin: string) => { void queryClient.refetchQueries({ predicate: (q) => FEDERATED_KEYS.has(String(q.queryKey[0])) && q.queryKey[1] === origin && q.state.status === "error", }); }, [queryClient], ); // The live chat alone: a ready archive's subs manifest, which settled in // error. Nothing else of the archive is refetched, and it stays ready. const retryLiveChat = useCallback( (origin: string) => { void queryClient.refetchQueries({ queryKey: ["subs-manifest", origin], exact: true, }); }, [queryClient], ); const federation = useMemo( () => ({ sites: siteStates, listed, retry, retryLiveChat }), [siteStates, listed, retry, retryLiveChat], ); const value = useMemo( () => ({ summariesState, subsManifest, postsManifest, manifestsSettled: summariesState.summariesReady, channels, groups, defaultGroupId, // channelSlug was rewritten to the origin-qualified key above, so it IS // the selection key (matches ChannelOption.key). channelKeyOf: (t) => t.channelSlug, accentOf, aliases, curatedTags, federation, siteTitleOf, }), [ summariesState, subsManifest, postsManifest, channels, groups, defaultGroupId, accentOf, aliases, curatedTags, federation, siteTitleOf, ], ); return ( {children} ); }