"use client"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { ArrowUp, Pause, Play, SlidersHorizontal, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { liveToEvent, type EventItem, type EventQuery, type LiveEvent, eventQueryString } from "@/lib/api"; import { CHANNELS, GROUP_LABELS, SAVED_VIEWS, agoIso, feedHref } from "@/lib/format"; import type { FeedFilters } from "@/lib/feed-filters"; import { publicFetch } from "@/lib/owner"; import { useLive, type LiveStatus } from "@/lib/use-live"; import { EventRow } from "./event-row"; import { DensityToggle, usePrefs } from "./prefs"; import { Chip, Empty, Kbd } from "./ui"; const MAX_ROWS = 400; export function LiveDot({ status, paused = false }: { status: LiveStatus; paused?: boolean }) { const label = paused ? "PAUSED" : status === "live" ? "LIVE" : status === "connecting" ? "CONNECTING" : status === "reconnecting" ? "RECONNECTING" : "OFFLINE"; const color = paused ? "bg-warn" : status === "live" ? "bg-signal animate-pulse-dot" : status === "offline" ? "bg-danger" : "bg-warn"; return ( {label} ); } export type { FeedFilters } from "@/lib/feed-filters"; function matchesFilters(ev: EventItem, f: FeedFilters, fixedQuery?: EventQuery): boolean { const sig = ev.signal_score ?? ev.importance; if (f.signal_min !== undefined && sig < f.signal_min) return false; if (f.importance_min !== undefined && ev.importance < f.importance_min) return false; if (f.silent_change && !ev.silent_change) return false; if (f.first_party && ev.first_party === false) return false; if (f.confirmed && ev.evidence_label !== "CONFIRMED") return false; if (f.country && ev.country !== f.country) return false; if (f.language && ev.language !== f.language) return false; if (f.category && !ev.categories.includes(f.category)) return false; if (f.event_type && !f.event_type.split(",").includes(ev.event_type)) return false; if (f.q) return false; // text search results are not extended live if (fixedQuery?.source && ev.source_id !== fixedQuery.source) return false; if (fixedQuery?.entity && !ev.entities.some((x) => x.id === fixedQuery.entity)) return false; if (fixedQuery?.category && !ev.categories.includes(fixedQuery.category)) return false; if (fixedQuery?.silent_change && !ev.silent_change) return false; return true; } /** * The live feed v2 (spec §31, §97–99): initial REST page, WebSocket prepends, URL-synced filters, * pause, "↑ N new events" without scroll jumps, density modes, cursor pagination, drawer on click. * `fixed` restricts the feed to one channel (used by /breaking, /silent, /category/*). */ export function LiveFeed({ initial, initialCursor, fixed, showTabs = true, title = "LIVE WEB", extraQuery, syncUrl = false, initialFilters, showFilters = true, compactHeader = false }: { initial: EventItem[]; initialCursor: string | null; fixed?: string; showTabs?: boolean; title?: string; extraQuery?: EventQuery; syncUrl?: boolean; initialFilters?: FeedFilters; showFilters?: boolean; compactHeader?: boolean }) { const router = useRouter(); const pathname = usePathname(); const sp = useSearchParams(); const { paused, setPaused } = usePrefs(); const [tab, setTab] = useState(fixed ?? initialFilters?.category ?? "all"); const [filters, setFilters] = useState(initialFilters ?? {}); const [items, setItems] = useState(initial); const [cursor, setCursor] = useState(initialCursor); const [loading, setLoading] = useState(false); const [fresh, setFresh] = useState>(new Set()); const [pending, setPending] = useState([]); const [atTop, setAtTop] = useState(true); const [now, setNow] = useState(() => Date.now()); const [showFilterBar, setShowFilterBar] = useState(Boolean(initialFilters && Object.keys(initialFilters).length)); const seen = useRef(new Set(initial.map((i) => i.id))); const topRef = useRef(null); const chan = useMemo(() => CHANNELS.find((c) => c.key === tab) ?? CHANNELS[0]!, [tab]); // sync URL ← filters (spec §99) useEffect(() => { if (!syncUrl) return; const href = feedHref({ ...filters, category: tab !== "all" && tab !== "breaking" && tab !== "silent" ? tab : filters.category, silent_change: tab === "silent" ? true : filters.silent_change, signal_min: tab === "breaking" ? Math.max(80, filters.signal_min ?? 0) : filters.signal_min }, pathname); const current = `${pathname}${sp.toString() ? `?${sp.toString()}` : ""}`; if (href !== current) router.replace(href, { scroll: false }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [filters, tab, syncUrl, pathname]); useEffect(() => { const t = setInterval(() => setNow(Date.now()), 10_000); return () => clearInterval(t); }, []); // Is the top of the list visible? If not, new events are buffered (spec §97). useEffect(() => { const el = topRef.current; if (!el) return; const io = new IntersectionObserver(([e]) => setAtTop(Boolean(e?.isIntersecting)), { rootMargin: "80px 0px 0px 0px" }); io.observe(el); return () => io.disconnect(); }, []); const query = useMemo(() => { const q: EventQuery = { ...(extraQuery ?? {}), ...(chan.query as EventQuery), ...filters, limit: 60 }; if (chan.key === "breaking") { q.order = q.order ?? "signal"; q.signal_min = Math.max(80, q.signal_min ?? 0); q.after = agoIso(48 * 3600e3); } return q; }, [chan, extraQuery, filters]); const load = useCallback( async (reset: boolean, c: string | null) => { setLoading(true); try { const page = await publicFetch<{ items: EventItem[]; nextCursor: string | null }>(`/api/v1/events${eventQueryString({ ...query, cursor: reset ? undefined : (c ?? undefined) })}`); setItems((prev) => { const base = reset ? [] : prev; const ids = new Set(base.map((i) => i.id)); const merged = [...base, ...page.items.filter((i) => !ids.has(i.id))]; seen.current = new Set(merged.map((i) => i.id)); return merged; }); setCursor(page.nextCursor); if (reset) setPending([]); } catch { // keep current list } finally { setLoading(false); } }, [query], ); const first = useRef(true); useEffect(() => { if (first.current) { first.current = false; return; } void load(true, null); }, [load]); const flushPending = useCallback(() => { setPending((p) => { if (!p.length) return p; setItems((prev) => [...p, ...prev].slice(0, MAX_ROWS)); setFresh((prevF) => new Set([...prevF, ...p.map((x) => x.id)])); setTimeout(() => setFresh(new Set()), 1800); return []; }); topRef.current?.scrollIntoView({ block: "start", behavior: "smooth" }); }, []); const onEvent = useCallback( (e: LiveEvent) => { if (seen.current.has(e.id)) return; const ev = liveToEvent(e); if (!matchesFilters(ev, filters, extraQuery)) return; if (chan.key === "breaking" && (ev.signal_score ?? ev.importance) < 80) return; if (chan.key === "silent" && !ev.silent_change) return; seen.current.add(e.id); if (paused || !atTop || query.order === "signal" || query.order === "importance") { setPending((p) => [ev, ...p].slice(0, 200)); return; } setItems((prev) => [ev, ...prev].slice(0, MAX_ROWS)); setFresh((prev) => new Set([...prev, e.id])); setTimeout( () => setFresh((prev) => { const n = new Set(prev); n.delete(e.id); return n; }), 1800, ); }, [filters, extraQuery, chan.key, paused, atTop, query.order], ); const wsChannel = extraQuery?.source ? `source:${extraQuery.source}` : extraQuery?.entity ? `entity:${extraQuery.entity}` : filters.country && chan.key === "all" ? `country:${filters.country}` : filters.group && chan.key === "all" ? `group:${filters.group}` : chan.ws; const wsChannels = useMemo(() => [wsChannel], [wsChannel]); const status = useLive(wsChannels, onEvent); const setF = (patch: Partial): void => setFilters((f) => { const n = { ...f, ...patch }; for (const k of Object.keys(n) as (keyof FeedFilters)[]) if (n[k] === undefined || n[k] === false || n[k] === "") delete n[k]; return n; }); const activeFilterCount = Object.keys(filters).length; return (

{title}

{items.length} events {showFilters && ( )}
{showTabs && !fixed && (
{CHANNELS.map((c) => ( ))}
)} {showFilters && showFilterBar && (
setF({ silent_change: !filters.silent_change || undefined })} tone="silent">Silent setF({ first_party: !filters.first_party || undefined })} tone="signal">First-party setF({ confirmed: !filters.confirmed || undefined })} tone="ok">Confirmed setF({ country: e.target.value.toUpperCase().slice(0, 3) || undefined })} placeholder="CC" maxLength={3} className="h-6 w-12 rounded-md border border-line bg-panel px-1.5 font-mono text-[11.5px] uppercase placeholder:text-fg-subtle" title="Country (ISO code)" /> {filters.event_type && type: {filters.event_type.replace(/_/g, " ")} } {filters.q && “{filters.q}” } views: {SAVED_VIEWS.slice(0, 4).map((v) => ( {v.label} ))} {activeFilterCount > 0 && ( )}
)}
{pending.length > 0 && (
)}
{items.length === 0 && !loading && ( {activeFilterCount ? "No events match these filters. Live events that match will appear here." : "No events in this channel yet. Sensors are being checked continuously — meaningful changes will appear here the moment they are detected."} )} {items.map((ev) => ( ))}
Showing {items.length}{items.length >= MAX_ROWS ? ` (capped at ${MAX_ROWS})` : ""} ⌘K search {cursor ? ( ) : ( Explore → )}
); } function Toggle({ on, onClick, children, tone }: { on: boolean; onClick: () => void; children: React.ReactNode; tone: "silent" | "signal" | "ok" }) { const onCls = tone === "silent" ? "border-silent/50 bg-silent-soft text-silent" : tone === "ok" ? "border-ok/50 bg-ok/10 text-ok" : "border-signal/50 bg-signal-soft text-signal"; return ( ); }