SkateSpots/src/hooks/useCoordinator.ts
SkateSpots 7b33d544b1 feat(coordinator): Cordn client slice — pick/add a coordinator + run-your-own hand-off
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.
2026-09-29 15:11:36 +07:00

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 };
}