import React from "react"; import "./style.css"; import type { FC, SelectMeta } from "./lib/types"; import { REPORTS_URL } from "./lib/constants"; import { useFeeds } from "./hooks/useFeeds"; import { useSessionId } from "./hooks/useSessionId"; import { useUpdates } from "./hooks/useUpdates"; import { useChat } from "./hooks/useChat"; import MapCanvas from "./components/map/MapCanvas"; import SelectedLocationCard from "./components/sidebar/SelectedLocationCard"; import UpdatesPanel from "./components/sidebar/UpdatesPanel"; import { useProximityAlerts } from "./hooks/useProximityAlerts"; import ChatPanel from "./components/chat/ChatPanel"; import type { ReactionInfo, UpdateItem } from "./lib/types"; import { REACTIONS_URL, REACT_URL } from "./lib/constants"; import { useNearbyQueue } from "./hooks/useNearbyQueue"; import NearbyAlertModal from "./components/modals/NearbyAlertModal"; export default function App() { const [selectedLL, setSelectedLL] = React.useState<[number, number] | null>( null ); const [selectedMeta, setSelectedMeta] = React.useState( null ); const [reports, setReports] = React.useState({ type: "FeatureCollection", features: [], }); const [reactionsById, setReactionsById] = React.useState< Record >({}); const { nws, quakes, eonet, firms } = useFeeds(); const [myLL, setMyLL] = React.useState<[number, number] | null>(null); const sessionId = useSessionId(); const { activeTab, setActiveTab, localUpdates, globalUpdates, loadingLocal, loadingGlobal, } = useUpdates(myLL); const { messages, draft, setDraft, isStreaming, hasFirstToken, chatBodyRef, send, pendingPhotoUrl, setPendingPhotoUrl, isUploading, onFileChosen, } = useChat(sessionId, selectedLL); const fileInputRef = React.useRef(null); // Try to get user location once at startup (silent fail if denied) React.useEffect(() => { if (!("geolocation" in navigator)) return; navigator.geolocation.getCurrentPosition( (pos) => setMyLL([pos.coords.latitude, pos.coords.longitude]), () => {}, // ignore errors; panel won't show without myLL { enableHighAccuracy: false, maximumAge: 60_000, timeout: 8_000 } ); }, []); // Nearby alerts (2 miles, max 5) const { nearby, loading: loadingNearby, refetch: refetchNearby, setNearby, } = useProximityAlerts(myLL, { radiusMiles: 2, limit: 5, maxAgeHours: 48 }); console.log("myLL:", myLL); const loadReports = React.useCallback(async () => { const fc = await fetch(REPORTS_URL) .then((r) => r.json()) .catch(() => ({ type: "FeatureCollection", features: [] })); setReports(fc); }, []); // helper to hydrate reactions for the current lists const hydrateReactions = React.useCallback( async (items: UpdateItem[]) => { const ids = Array.from( new Set(items.map((u) => u.rid).filter(Boolean)) ) as string[]; if (ids.length === 0) return; const url = `${REACTIONS_URL}?ids=${ids.join( "," )}&session_id=${encodeURIComponent(sessionId)}`; const data = await fetch(url) .then((r) => r.json()) .catch(() => ({})); setReactionsById((prev) => ({ ...prev, ...data })); }, [sessionId] ); // when updates change, hydrate reactions React.useEffect(() => { // hydrate both tabs so Selected card has data no matter the tab hydrateReactions(localUpdates); hydrateReactions(globalUpdates); hydrateReactions(nearby); }, [localUpdates, globalUpdates, nearby, hydrateReactions]); React.useEffect(() => { loadReports(); }, [loadReports]); const selectPoint = React.useCallback( (ll: [number, number], meta: SelectMeta) => { if (meta?.kind === "mylocation") { setMyLL(ll); // anchor local updates to device location } setSelectedLL(ll); setSelectedMeta(meta); }, [] ); const pickPhoto = React.useCallback(() => fileInputRef.current?.click(), []); const onSend = React.useCallback(async () => { const res = await send(); if (res?.tool_used === "add_report") await loadReports(); }, [send, loadReports]); // toggle handler (optimistic) const reactOnReport = React.useCallback( async (rid: string, action: "verify" | "clear") => { setReactionsById((prev) => { const cur = prev[rid] || { verify_count: 0, clear_count: 0, me: { verified: false, cleared: false }, }; const want = action === "verify" ? !cur.me.verified : !cur.me.cleared; const next: ReactionInfo = JSON.parse(JSON.stringify(cur)); if (action === "verify") { if (want) { next.me.verified = true; next.verify_count += 1; if (next.me.cleared) { next.me.cleared = false; next.clear_count = Math.max(0, next.clear_count - 1); } } else { next.me.verified = false; next.verify_count = Math.max(0, next.verify_count - 1); } } else { if (want) { next.me.cleared = true; next.clear_count += 1; if (next.me.verified) { next.me.verified = false; next.verify_count = Math.max(0, next.verify_count - 1); } } else { next.me.cleared = false; next.clear_count = Math.max(0, next.clear_count - 1); } } return { ...prev, [rid]: next }; }); // commit to API; reconcile with truth try { const body = { action, value: true, session_id: sessionId }; // Ensure "value" matches our intended state (toggle) const current = reactionsById[rid]; const want = action === "verify" ? !current?.me.verified : !current?.me.cleared; body.value = want; // commit const j = await fetch(`${REACT_URL}/${encodeURIComponent(rid)}/react`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, value: want, session_id: sessionId }), }).then((r) => r.json()); setReactionsById((prev) => ({ ...prev, [rid]: j })); } catch { // fallback re-hydrate const j = await fetch( `${REACTIONS_URL}?ids=${rid}&session_id=${encodeURIComponent( sessionId )}` ) .then((r) => r.json()) .catch(() => null); if (j && j[rid]) setReactionsById((prev) => ({ ...prev, [rid]: j[rid] })); } }, [sessionId, reactionsById] ); const queue = useNearbyQueue( nearby, reactionsById, sessionId, reactOnReport, { limit: 5 } ); React.useEffect(() => { if (!queue.open && queue.total > 0) queue.openQueue(); }, [queue.open, queue.total]); const openedOnceRef = React.useRef(false); React.useEffect(() => { if (!openedOnceRef.current && queue.total > 0) { queue.openQueue(); openedOnceRef.current = true; } }, [queue.total, queue.openQueue]); return (
setPendingPhotoUrl(null)} isUploading={isUploading} /> {/* hidden file input lives here */} { const f = e.target.files?.[0]; if (f) onFileChosen(f); }} />
); }