"use client"; import type { EventDTO } from "@dci/core"; import { ArrowUp, RefreshCw } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { EventList } from "@/components/event-list"; import { Button } from "@/components/ui/button"; import { cn } from "@/components/ui/cn"; import { SkeletonRows } from "@/components/ui/skeleton"; import { ErrorState } from "@/components/ui/states"; import { apiGet, clientApiBase, type Query } from "@/lib/api"; import { useInterval } from "@/lib/hooks"; interface Props { initial: EventDTO[]; initialTotal: number; perPage: number; /** the exact /events query (already includes dedupe / min_significance) */ apiQuery: Query; initialError?: string; } /** * Live feed 2.0: SSR first page, then polls /events?since= every 60 s with the SAME query (families, entity, * significance band, source kind, AI, dedupe), shows an "N new updates" pill and prepends on demand; "Load more" pages. */ export function LiveFeed2({ initial, initialTotal, perPage, apiQuery, initialError }: Props) { const [events, setEvents] = useState(initial); const [pending, setPending] = useState([]); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(initialError ?? null); const [exhausted, setExhausted] = useState(initial.length >= initialTotal || initial.length < perPage); const [fresh, setFresh] = useState>(new Set()); const sinceRef = useRef(initial[0]?.detectedAt ?? new Date().toISOString()); const seen = useRef(new Set(initial.map((e) => e.id))); const [syncedInitial, setSyncedInitial] = useState(initial); if (syncedInitial !== initial) { setSyncedInitial(initial); setEvents(initial); setPending([]); setExhausted(initial.length >= initialTotal || initial.length < perPage); setError(initialError ?? null); } useEffect(() => { sinceRef.current = initial[0]?.detectedAt ?? new Date().toISOString(); seen.current = new Set(initial.map((e) => e.id)); }, [initial]); const base = useMemo(() => clientApiBase(), []); const sig = JSON.stringify(apiQuery); const query = useMemo(() => JSON.parse(sig) as Query, [sig]); const poll = useCallback(async () => { if (typeof document !== "undefined" && document.hidden) return; const r = await apiGet("/events", { ...query, since: sinceRef.current, per_page: 100 }, { base, timeoutMs: 8000 }); if (!r.ok) return; const items = Array.isArray(r.data) ? r.data : (r.data.items ?? []); const news = items.filter((e) => !seen.current.has(e.id)); if (!news.length) { setError(null); return; } for (const e of news) seen.current.add(e.id); sinceRef.current = news.reduce((a, e) => (e.detectedAt > a ? e.detectedAt : a), sinceRef.current); setError(null); setPending((p) => [...news, ...p].sort((a, b) => (a.detectedAt < b.detectedAt ? 1 : -1))); }, [query, base]); useInterval(poll, 60_000); useEffect(() => { if (initialError) void poll(); }, [initialError, poll]); const reveal = () => { setEvents((ev) => [...pending, ...ev]); setFresh(new Set(pending.map((e) => e.id))); setPending([]); window.scrollTo({ top: 0, behavior: "smooth" }); setTimeout(() => setFresh(new Set()), 2500); }; const loadMore = async () => { setLoadingMore(true); const nextPage = Math.floor(events.length / perPage) + 1; const r = await apiGet("/events", { ...query, page: nextPage, per_page: perPage }, { base }); setLoadingMore(false); if (!r.ok) { setError(r.error); return; } const items = (Array.isArray(r.data) ? r.data : (r.data.items ?? [])).filter((e) => !seen.current.has(e.id)); for (const e of items) seen.current.add(e.id); setEvents((ev) => [...ev, ...items]); if (items.length < perPage) setExhausted(true); }; return (
{pending.length > 0 && (
)} {error && events.length === 0 ? ( ) : ( )}
{loadingMore ? ( ) : exhausted ? ( End of feed ) : ( )} {error && events.length > 0 && Could not load more ({error}).}
); }