import { useCallback, useMemo, useState } from 'react'; import { useNostr } from '@nostrify/react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { COORDINATOR_STORAGE_KEY, CORDN_RELAY_URL, coordinatorNameFromEvent, type CoordinatorConfig, } from '@/lib/coordinator'; /** The user's chosen Cordn coordinator, persisted device-locally. */ export function useCoordinator() { const [stored, setStored] = useLocalStorage( COORDINATOR_STORAGE_KEY, null, ); const coordinator = stored && stored.pubkey ? stored : null; const setCoordinator = useCallback( (config: Omit) => { setStored({ pubkey: config.pubkey, relays: config.relays, addedAt: Date.now(), }); }, [setStored], ); const removeCoordinator = useCallback(() => setStored(null), [setStored]); return { coordinator, setCoordinator, removeCoordinator }; } export type CoordinatorHealthStatus = | 'idle' | 'checking' | 'online' | 'stale' | 'offline' | 'error'; export interface CoordinatorHealth { status: CoordinatorHealthStatus; /** Unix seconds of the newest event authored by the coordinator. */ lastSeen: number | null; eventCount: number; /** Display name parsed from the announcement, when we can find one. */ name: string | null; error: string | null; checkedAt: number | null; } /** * Same freshness rule as the CVM server probe: ContextVM servers re-announce on * a heartbeat, so allow a few missed beats before calling it stale. */ const STALE_AFTER_MS = 45 * 60 * 1000; const INITIAL: CoordinatorHealth = { status: 'idle', lastSeen: null, eventCount: 0, name: null, error: null, checkedAt: null, }; /** * Presence probe for a coordinator. * * A Cordn coordinator is a ContextVM server, so "is it alive?" is answerable * without speaking Cordn's RPC: query the relays it advertises for events it * authored (its announcement + heartbeat). A private/unannounced coordinator * legitimately shows nothing here — the copy says so rather than alarming. */ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) { const { nostr } = useNostr(); const [result, setResult] = useState(INITIAL); const relays = useMemo( () => (coordinator?.relays.length ? coordinator.relays : [CORDN_RELAY_URL]), [coordinator], ); const check = useCallback(async () => { if (!coordinator) return; setResult((r) => ({ ...r, status: 'checking', error: null })); try { const signal = AbortSignal.timeout(8000); const batches = await Promise.all( relays.map(async (url) => { try { return await nostr.relay(url).query( [{ authors: [coordinator.pubkey], limit: 25 }], { signal }, ); } catch { // One relay failing must not stop the probe. return []; } }), ); const events = batches.flat(); if (events.length === 0) { setResult({ status: 'offline', lastSeen: null, eventCount: 0, name: null, error: null, checkedAt: Date.now(), }); return; } const newest = events.reduce((a, b) => (b.created_at > a.created_at ? b : a)); const ageMs = Date.now() - newest.created_at * 1000; setResult({ status: ageMs <= STALE_AFTER_MS ? 'online' : 'stale', lastSeen: newest.created_at, eventCount: events.length, name: coordinatorNameFromEvent(newest), error: null, checkedAt: Date.now(), }); } catch (err) { setResult({ status: 'error', lastSeen: null, eventCount: 0, name: null, error: err instanceof Error ? err.message : 'Could not reach the relays', checkedAt: Date.now(), }); } }, [coordinator, relays, nostr]); return { ...result, check }; }