SkateSpots stays a Cordn *client*: it points at a coordinator the user chooses or runs, and hands phone-hosting off to Cordn's own app. No embedded coordinator, no default SkateSpots coordinator (deliberate: liability). - src/lib/coordinator.ts: Cordn's own formats — an nprofile pointer, the canonical add-link https://cordn.net/chat/coordinators?c=<nprofile> (from Cordn packages/server/src/main.ts), default relay wss://relay.contextvm.org, the docker one-liner. Parses nprofile/npub/hex/link; encodes an add-link. - src/hooks/useCoordinator.ts: device-local store + presence probe. Health is NOT Cordn RPC — it queries the coordinator's relays for authors:[pubkey] (its ContextVM announcement/heartbeat), so a private/unannounced coordinator honestly reads 'Not seen' rather than failing. - src/components/CoordinatorSettingsCard.tsx + Settings -> Coordinator: empty/connected states, add by paste or QR scan, add-link copy + QR, and a 'Run your own' hand-off (open Cordn, scan its QR, copy the docker command). - smoke test now boots /settings?section=coordinator.
144 lines
3.9 KiB
TypeScript
144 lines
3.9 KiB
TypeScript
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<CoordinatorConfig | null>(
|
|
COORDINATOR_STORAGE_KEY,
|
|
null,
|
|
);
|
|
|
|
const coordinator = stored && stored.pubkey ? stored : null;
|
|
|
|
const setCoordinator = useCallback(
|
|
(config: Omit<CoordinatorConfig, 'addedAt'>) => {
|
|
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<CoordinatorHealth>(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 };
|
|
}
|