TypeScript 55.4%
Python 43.2%
SQL 1.2%
1"use client";23import Link from "next/link";4import { usePathname, useRouter, useSearchParams } from "next/navigation";5import { ArrowUp, Pause, Play, SlidersHorizontal, X } from "lucide-react";6import { useCallback, useEffect, useMemo, useRef, useState } from "react";7import { liveToEvent, type EventItem, type EventQuery, type LiveEvent, eventQueryString } from "@/lib/api";8import { CHANNELS, GROUP_LABELS, SAVED_VIEWS, agoIso, feedHref } from "@/lib/format";9import type { FeedFilters } from "@/lib/feed-filters";10import { publicFetch } from "@/lib/owner";11import { useLive, type LiveStatus } from "@/lib/use-live";12import { EventRow } from "./event-row";13import { DensityToggle, usePrefs } from "./prefs";14import { Chip, Empty, Kbd } from "./ui";1516const MAX_ROWS = 400;1718export function LiveDot({ status, paused = false }: { status: LiveStatus; paused?: boolean }) {19 const label = paused ? "PAUSED" : status === "live" ? "LIVE" : status === "connecting" ? "CONNECTING" : status === "reconnecting" ? "RECONNECTING" : "OFFLINE";20 const color = paused ? "bg-warn" : status === "live" ? "bg-signal animate-pulse-dot" : status === "offline" ? "bg-danger" : "bg-warn";21 return (22 <span className="inline-flex items-center gap-1.5 font-mono text-[11px] font-semibold tracking-wider text-fg-muted" aria-live="polite">23 <span className={`inline-block size-2 rounded-full ${color}`} />24 {label}25 </span>26 );27}2829export type { FeedFilters } from "@/lib/feed-filters";3031function matchesFilters(ev: EventItem, f: FeedFilters, fixedQuery?: EventQuery): boolean {32 const sig = ev.signal_score ?? ev.importance;33 if (f.signal_min !== undefined && sig < f.signal_min) return false;34 if (f.importance_min !== undefined && ev.importance < f.importance_min) return false;35 if (f.silent_change && !ev.silent_change) return false;36 if (f.first_party && ev.first_party === false) return false;37 if (f.confirmed && ev.evidence_label !== "CONFIRMED") return false;38 if (f.country && ev.country !== f.country) return false;39 if (f.language && ev.language !== f.language) return false;40 if (f.category && !ev.categories.includes(f.category)) return false;41 if (f.event_type && !f.event_type.split(",").includes(ev.event_type)) return false;42 if (f.q) return false; // text search results are not extended live43 if (fixedQuery?.source && ev.source_id !== fixedQuery.source) return false;44 if (fixedQuery?.entity && !ev.entities.some((x) => x.id === fixedQuery.entity)) return false;45 if (fixedQuery?.category && !ev.categories.includes(fixedQuery.category)) return false;46 if (fixedQuery?.silent_change && !ev.silent_change) return false;47 return true;48}4950/**51 * The live feed v2 (spec §31, §97–99): initial REST page, WebSocket prepends, URL-synced filters,52 * pause, "↑ N new events" without scroll jumps, density modes, cursor pagination, drawer on click.53 * `fixed` restricts the feed to one channel (used by /breaking, /silent, /category/*).54 */55export 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 }) {56 const router = useRouter();57 const pathname = usePathname();58 const sp = useSearchParams();59 const { paused, setPaused } = usePrefs();60 const [tab, setTab] = useState(fixed ?? initialFilters?.category ?? "all");61 const [filters, setFilters] = useState<FeedFilters>(initialFilters ?? {});62 const [items, setItems] = useState<EventItem[]>(initial);63 const [cursor, setCursor] = useState<string | null>(initialCursor);64 const [loading, setLoading] = useState(false);65 const [fresh, setFresh] = useState<Set<string>>(new Set());66 const [pending, setPending] = useState<EventItem[]>([]);67 const [atTop, setAtTop] = useState(true);68 const [now, setNow] = useState(() => Date.now());69 const [showFilterBar, setShowFilterBar] = useState(Boolean(initialFilters && Object.keys(initialFilters).length));70 const seen = useRef(new Set(initial.map((i) => i.id)));71 const topRef = useRef<HTMLDivElement>(null);72 const chan = useMemo(() => CHANNELS.find((c) => c.key === tab) ?? CHANNELS[0]!, [tab]);7374 // sync URL ← filters (spec §99)75 useEffect(() => {76 if (!syncUrl) return;77 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);78 const current = `${pathname}${sp.toString() ? `?${sp.toString()}` : ""}`;79 if (href !== current) router.replace(href, { scroll: false });80 // eslint-disable-next-line react-hooks/exhaustive-deps81 }, [filters, tab, syncUrl, pathname]);8283 useEffect(() => {84 const t = setInterval(() => setNow(Date.now()), 10_000);85 return () => clearInterval(t);86 }, []);87 // Is the top of the list visible? If not, new events are buffered (spec §97).88 useEffect(() => {89 const el = topRef.current;90 if (!el) return;91 const io = new IntersectionObserver(([e]) => setAtTop(Boolean(e?.isIntersecting)), { rootMargin: "80px 0px 0px 0px" });92 io.observe(el);93 return () => io.disconnect();94 }, []);9596 const query = useMemo<EventQuery>(() => {97 const q: EventQuery = { ...(extraQuery ?? {}), ...(chan.query as EventQuery), ...filters, limit: 60 };98 if (chan.key === "breaking") {99 q.order = q.order ?? "signal";100 q.signal_min = Math.max(80, q.signal_min ?? 0);101 q.after = agoIso(48 * 3600e3);102 }103 return q;104 }, [chan, extraQuery, filters]);105106 const load = useCallback(107 async (reset: boolean, c: string | null) => {108 setLoading(true);109 try {110 const page = await publicFetch<{ items: EventItem[]; nextCursor: string | null }>(`/api/v1/events${eventQueryString({ ...query, cursor: reset ? undefined : (c ?? undefined) })}`);111 setItems((prev) => {112 const base = reset ? [] : prev;113 const ids = new Set(base.map((i) => i.id));114 const merged = [...base, ...page.items.filter((i) => !ids.has(i.id))];115 seen.current = new Set(merged.map((i) => i.id));116 return merged;117 });118 setCursor(page.nextCursor);119 if (reset) setPending([]);120 } catch {121 // keep current list122 } finally {123 setLoading(false);124 }125 },126 [query],127 );128129 const first = useRef(true);130 useEffect(() => {131 if (first.current) {132 first.current = false;133 return;134 }135 void load(true, null);136 }, [load]);137138 const flushPending = useCallback(() => {139 setPending((p) => {140 if (!p.length) return p;141 setItems((prev) => [...p, ...prev].slice(0, MAX_ROWS));142 setFresh((prevF) => new Set([...prevF, ...p.map((x) => x.id)]));143 setTimeout(() => setFresh(new Set()), 1800);144 return [];145 });146 topRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });147 }, []);148149 const onEvent = useCallback(150 (e: LiveEvent) => {151 if (seen.current.has(e.id)) return;152 const ev = liveToEvent(e);153 if (!matchesFilters(ev, filters, extraQuery)) return;154 if (chan.key === "breaking" && (ev.signal_score ?? ev.importance) < 80) return;155 if (chan.key === "silent" && !ev.silent_change) return;156 seen.current.add(e.id);157 if (paused || !atTop || query.order === "signal" || query.order === "importance") {158 setPending((p) => [ev, ...p].slice(0, 200));159 return;160 }161 setItems((prev) => [ev, ...prev].slice(0, MAX_ROWS));162 setFresh((prev) => new Set([...prev, e.id]));163 setTimeout(164 () =>165 setFresh((prev) => {166 const n = new Set(prev);167 n.delete(e.id);168 return n;169 }),170 1800,171 );172 },173 [filters, extraQuery, chan.key, paused, atTop, query.order],174 );175 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;176 const wsChannels = useMemo(() => [wsChannel], [wsChannel]);177 const status = useLive(wsChannels, onEvent);178179 const setF = (patch: Partial<FeedFilters>): void => setFilters((f) => {180 const n = { ...f, ...patch };181 for (const k of Object.keys(n) as (keyof FeedFilters)[]) if (n[k] === undefined || n[k] === false || n[k] === "") delete n[k];182 return n;183 });184 const activeFilterCount = Object.keys(filters).length;185186 return (187 <section className="panel overflow-hidden" aria-label={title}>188 <header className={`flex flex-wrap items-center gap-2 border-b border-line px-3 ${compactHeader ? "py-1.5" : "py-2"}`}>189 <h2 className="label mr-1 !text-fg">{title}</h2>190 <LiveDot status={status} paused={paused} />191 <span className="hidden font-mono text-[11px] text-fg-subtle tabular sm:inline">{items.length} events</span>192 <span className="ml-auto flex items-center gap-1">193 {showFilters && (194 <button type="button" onClick={() => setShowFilterBar((v) => !v)} aria-expanded={showFilterBar} className={`inline-flex h-7 items-center gap-1 rounded-md border border-line px-2 text-[11.5px] ${activeFilterCount ? "border-signal/50 text-signal" : "text-fg-muted hover:text-fg"}`} title="Filters">195 <SlidersHorizontal className="size-3.5" /> <span className="hidden sm:inline">Filters</span>196 {activeFilterCount > 0 && <span className="font-mono">{activeFilterCount}</span>}197 </button>198 )}199 <button type="button" onClick={() => setPaused(!paused)} aria-pressed={paused} className={`inline-flex h-7 items-center gap-1 rounded-md border border-line px-2 text-[11.5px] ${paused ? "border-warn/50 text-warn" : "text-fg-muted hover:text-fg"}`} title={paused ? "Resume live" : "Pause live (backend keeps ingesting)"}>200 {paused ? <Play className="size-3.5" /> : <Pause className="size-3.5" />} <span className="hidden sm:inline">{paused ? "Resume" : "Pause"}</span>201 </button>202 <DensityToggle compact />203 </span>204 </header>205 {showTabs && !fixed && (206 <div className="flex gap-1 overflow-x-auto border-b border-line px-2 py-1.5 no-scrollbar" role="tablist" aria-label="Channels">207 {CHANNELS.map((c) => (208 <button key={c.key} type="button" role="tab" aria-selected={tab === c.key} onClick={() => setTab(c.key)} className={`whitespace-nowrap rounded-md px-2 py-1 text-[12px] ${tab === c.key ? "bg-panel-2 text-fg" : "text-fg-muted hover:text-fg"} ${c.key === "silent" ? "text-silent" : c.key === "breaking" ? "text-hot" : ""}`}>209 {c.label}210 </button>211 ))}212 </div>213 )}214 {showFilters && showFilterBar && (215 <div className="flex flex-wrap items-center gap-1.5 border-b border-line bg-panel-2/40 px-3 py-2 text-[12px]">216 <Toggle on={Boolean(filters.silent_change)} onClick={() => setF({ silent_change: !filters.silent_change || undefined })} tone="silent">Silent</Toggle>217 <Toggle on={Boolean(filters.first_party)} onClick={() => setF({ first_party: !filters.first_party || undefined })} tone="signal">First-party</Toggle>218 <Toggle on={Boolean(filters.confirmed)} onClick={() => setF({ confirmed: !filters.confirmed || undefined })} tone="ok">Confirmed</Toggle>219 <select aria-label="Minimum signal" value={filters.signal_min ?? ""} onChange={(e) => setF({ signal_min: e.target.value ? Number(e.target.value) : undefined })} className="h-6 rounded-md border border-line bg-panel px-1.5 text-[11.5px]">220 <option value="">signal ≥ any</option>221 <option value="50">signal ≥ 50</option>222 <option value="65">signal ≥ 65</option>223 <option value="80">signal ≥ 80</option>224 <option value="90">signal ≥ 90</option>225 </select>226 <select aria-label="Event group" value={filters.group ?? ""} onChange={(e) => setF({ group: e.target.value || undefined })} className="h-6 max-w-[11rem] rounded-md border border-line bg-panel px-1.5 text-[11.5px]">227 <option value="">all groups</option>228 {Object.entries(GROUP_LABELS).map(([k, v]) => (229 <option key={k} value={k}>{v}</option>230 ))}231 </select>232 <select aria-label="Order" value={filters.order ?? "recent"} onChange={(e) => setF({ order: e.target.value === "recent" ? undefined : (e.target.value as FeedFilters["order"]) })} className="h-6 rounded-md border border-line bg-panel px-1.5 text-[11.5px]">233 <option value="recent">newest first</option>234 <option value="signal">highest signal</option>235 <option value="importance">highest importance</option>236 </select>237 <input aria-label="Country" value={filters.country ?? ""} onChange={(e) => 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)" />238 {filters.event_type && <Chip tone="info">type: {filters.event_type.replace(/_/g, " ")} <button type="button" aria-label="Clear type" onClick={() => setF({ event_type: undefined })}><X className="size-3" /></button></Chip>}239 {filters.q && <Chip tone="info">“{filters.q}” <button type="button" aria-label="Clear search" onClick={() => setF({ q: undefined })}><X className="size-3" /></button></Chip>}240 <span className="ml-auto flex items-center gap-1">241 <span className="text-[11px] text-fg-subtle">views:</span>242 {SAVED_VIEWS.slice(0, 4).map((v) => (243 <Link key={v.key} href={feedHref(v.query, "/live")} className="rounded-sm border border-line px-1.5 py-px text-[10.5px] text-fg-muted hover:text-fg">{v.label}</Link>244 ))}245 {activeFilterCount > 0 && (246 <button type="button" onClick={() => setFilters({})} className="rounded-sm px-1.5 py-px text-[10.5px] text-fg-subtle hover:text-fg">clear</button>247 )}248 </span>249 </div>250 )}251 <div ref={topRef} aria-hidden className="h-px" />252 {pending.length > 0 && (253 <div className="sticky top-12 z-20 flex justify-center py-1">254 <button type="button" onClick={flushPending} className="inline-flex items-center gap-1.5 rounded-full border border-signal/50 bg-panel px-3 py-1 text-[12px] font-medium text-signal shadow-lg hover:bg-signal-soft">255 <ArrowUp className="size-3.5" /> {pending.length} new event{pending.length === 1 ? "" : "s"}{paused ? " · paused" : ""}256 </button>257 </div>258 )}259 <div role="feed" aria-busy={loading}>260 {items.length === 0 && !loading && (261 <Empty>262 {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."}263 </Empty>264 )}265 {items.map((ev) => (266 <EventRow key={ev.id} ev={ev} fresh={fresh.has(ev.id)} now={now} replayed={Boolean((ev as EventItem & { replayed?: boolean }).replayed)} />267 ))}268 </div>269 <footer className="flex items-center justify-between px-3 py-2 text-[12px] text-fg-subtle">270 <span className="inline-flex items-center gap-2">271 Showing {items.length}{items.length >= MAX_ROWS ? ` (capped at ${MAX_ROWS})` : ""}272 <span className="hidden items-center gap-1 sm:inline-flex"><Kbd>⌘K</Kbd> search</span>273 </span>274 {cursor ? (275 <button type="button" disabled={loading} onClick={() => load(false, cursor)} className="rounded-md border border-line bg-panel-2 px-2.5 py-1 text-[12px] text-fg hover:border-line-strong disabled:opacity-50">276 {loading ? "Loading…" : "Load more"}277 </button>278 ) : (279 <Link href="/explore" className="hover:text-fg">Explore →</Link>280 )}281 </footer>282 </section>283 );284}285286function Toggle({ on, onClick, children, tone }: { on: boolean; onClick: () => void; children: React.ReactNode; tone: "silent" | "signal" | "ok" }) {287 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";288 return (289 <button type="button" aria-pressed={on} onClick={onClick} className={`h-6 rounded-md border px-2 text-[11.5px] ${on ? onCls : "border-line text-fg-muted hover:text-fg"}`}>290 {children}291 </button>292 );293}294