- Replace single-assignment `let timer` with a `timers[]` array in citrine.ts and relayProbe.ts so stricter prefer-const builds pass (the newer ESLint pulled in by `npm audit fix` flagged the let as never-reassigned). - Drop two now-redundant `eslint-disable react-hooks/exhaustive-deps` directives in NostrProvider and MapPage. Newer react-hooks no longer reports those lines, so the directives were flagged as unused errors under reportUnusedDisableDirectives; removing them is safe in both old (warning only) and new (silent) ESLint. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
229 lines
8.8 KiB
TypeScript
229 lines
8.8 KiB
TypeScript
/**
|
||
* citrine — integration helpers for a local on-device Nostr relay.
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* Citrine (github.com/greenart7c3/Citrine) is an Android app by the author of
|
||
* Amber that runs a full Nostr relay locally on the phone, storing events in an
|
||
* on-device SQLite database and listening on a localhost WebSocket. Because it
|
||
* is "just a relay URL", it slots into our existing nostr.relay()/group() and
|
||
* useSkateRelays machinery with no new protocol.
|
||
*
|
||
* Why we want it (SkateSpots-specific):
|
||
* - Offline-in-the-field: read spots/tours/events at a spot with no signal, and
|
||
* capture writes locally to publish when connectivity returns.
|
||
* - Instant reads: a localhost query returns in ~1ms vs. 100–800ms remote.
|
||
* - Resilience/backup: a durable local copy of the user's own events survives
|
||
* public-relay outages and addressable-event eviction.
|
||
* - Privacy: local-first event classes (e.g. Stash) can avoid public relays.
|
||
*
|
||
* Hard constraints (must be respected everywhere Citrine is used):
|
||
* - Android-only. iOS/desktop web get none of this — always platform-gate.
|
||
* - Opt-in only. Never route data through Citrine without an explicit setting.
|
||
* - Local ≠ encrypted. Citrine stores whatever we give it verbatim; sensitive
|
||
* data (Stash, DMs) must stay NIP-44 encrypted regardless of destination.
|
||
*/
|
||
|
||
/** Citrine's default localhost relay endpoint. */
|
||
export const CITRINE_RELAY_URL = 'ws://127.0.0.1:4869';
|
||
|
||
/**
|
||
* Endpoints to try, in order. Citrine listens on the loopback interface, but
|
||
* depending on the device/WebView the reachable form can be the numeric IP or
|
||
* the `localhost` hostname — so we probe both and use whichever answers rather
|
||
* than betting the feature on one address form.
|
||
*/
|
||
export const CITRINE_RELAY_CANDIDATES = [
|
||
'ws://127.0.0.1:4869',
|
||
'ws://localhost:4869',
|
||
];
|
||
|
||
/** Forcible UI states for the dev preview flag. */
|
||
export type CitrinePreviewState = 'off' | 'searching' | 'live' | 'waiting';
|
||
const PREVIEW_KEY = 'citrine:preview';
|
||
|
||
/**
|
||
* DEV PREVIEW ONLY — lets the (normally Android-only) Citrine UI render in a
|
||
* browser so the design can be reviewed without an APK build.
|
||
*
|
||
* Set in the browser console:
|
||
* localStorage.setItem('citrine:preview', '1') // show the card, real behaviour
|
||
* localStorage.setItem('citrine:preview', 'live') // force the "connected" state
|
||
* localStorage.setItem('citrine:preview', 'waiting') // force "not detected"
|
||
* localStorage.setItem('citrine:preview', 'searching')// force "searching…"
|
||
* localStorage.removeItem('citrine:preview') // back to normal
|
||
*
|
||
* CRITICAL: this unlocks RENDERING ONLY. It never makes the local relay
|
||
* `active` and never yields a relay `url`, so no query or publish is ever routed
|
||
* anywhere different — production relay behaviour is completely unchanged. A
|
||
* forced state is purely cosmetic.
|
||
*/
|
||
export function citrinePreview(): CitrinePreviewState | null {
|
||
try {
|
||
const raw = (globalThis as { localStorage?: Storage }).localStorage?.getItem(PREVIEW_KEY);
|
||
if (!raw) return null;
|
||
if (raw === 'off' || raw === 'searching' || raw === 'live' || raw === 'waiting') {
|
||
return raw;
|
||
}
|
||
// Any other truthy value ("1"/"true") = show the card with real behaviour.
|
||
return 'off';
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** True if the dev preview flag is set at all (any value). */
|
||
export function isCitrinePreview(): boolean {
|
||
return citrinePreview() !== null;
|
||
}
|
||
|
||
/**
|
||
* True when running inside a Capacitor native Android shell. Citrine is an
|
||
* Android app, so the local relay can only exist there. This never throws in a
|
||
* plain browser (window.Capacitor is simply undefined).
|
||
*/
|
||
export function isAndroidNative(): boolean {
|
||
const cap = (globalThis as {
|
||
Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string };
|
||
}).Capacitor;
|
||
if (!cap?.isNativePlatform?.()) return false;
|
||
return cap.getPlatform?.() === 'android';
|
||
}
|
||
|
||
/**
|
||
* True if the Citrine UI/feature should be offered on this device — native
|
||
* Android, OR the dev preview flag is set (browser design review). Actual
|
||
* reachability is still confirmed separately by probeCitrine(), which fails
|
||
* cleanly in a browser, so preview never routes real traffic.
|
||
*/
|
||
export function isCitrinePossible(): boolean {
|
||
return isAndroidNative() || isCitrinePreview();
|
||
}
|
||
|
||
/** Result of a probe attempt — a boolean plus a human-readable diagnostic. */
|
||
export interface CitrineProbeResult {
|
||
ok: boolean;
|
||
/** Short reason for the outcome, surfaced in the UI for debugging. */
|
||
reason: string;
|
||
/** The endpoint that answered (on success), so routing uses the right one. */
|
||
url?: string;
|
||
}
|
||
|
||
/**
|
||
* Probe whether a Citrine relay is actually running AND speaking Nostr at `url`.
|
||
*
|
||
* A bare "socket opened" check is not enough: any process squatting on the port
|
||
* would open cleanly and we'd then wrongly route reads — and, worse, published
|
||
* events — at a non-Nostr peer. So we complete a minimal Nostr handshake:
|
||
* open → send a tiny bounded REQ → require a protocol-shaped reply
|
||
* (EVENT / EOSE / NOTICE / CLOSED) referencing our subscription id.
|
||
*
|
||
* Returns { ok, reason }. `reason` captures which stage failed so the UI can
|
||
* show it on-device (no USB/adb needed to diagnose). Never rejects, never
|
||
* throws. Short-circuits off-Android (and outside preview).
|
||
*/
|
||
export function probeCitrine(
|
||
url: string = CITRINE_RELAY_URL,
|
||
timeoutMs = 3000,
|
||
): Promise<CitrineProbeResult> {
|
||
if (!isCitrinePossible()) {
|
||
return Promise.resolve({ ok: false, reason: 'not-android' });
|
||
}
|
||
|
||
return new Promise<CitrineProbeResult>((resolve) => {
|
||
let settled = false;
|
||
let opened = false;
|
||
let ws: WebSocket | undefined;
|
||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||
|
||
const done = (ok: boolean, reason: string) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
for (const t of timers) clearTimeout(t);
|
||
try {
|
||
ws?.close();
|
||
} catch {
|
||
// ignore close errors
|
||
}
|
||
resolve({ ok, reason, url: ok ? url : undefined });
|
||
};
|
||
|
||
try {
|
||
ws = new WebSocket(url);
|
||
} catch (err) {
|
||
// Constructor throwing usually means the WebView blocked the URL outright
|
||
// (e.g. mixed content: https page → insecure ws://).
|
||
resolve({
|
||
ok: false,
|
||
reason: `blocked: ${err instanceof Error ? err.message : String(err)}`,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const subId = `citrine-probe-${Math.random().toString(36).slice(2, 10)}`;
|
||
|
||
timers.push(setTimeout(() => {
|
||
// Distinguish "never opened" (blocked/refused) from "opened but silent".
|
||
done(false, opened ? 'opened-but-no-nostr-reply' : 'timeout-no-open');
|
||
}, timeoutMs));
|
||
|
||
ws.onopen = () => {
|
||
opened = true;
|
||
try {
|
||
ws!.send(JSON.stringify(['REQ', subId, { limit: 1 }]));
|
||
} catch (err) {
|
||
done(false, `send-failed: ${err instanceof Error ? err.message : String(err)}`);
|
||
}
|
||
};
|
||
|
||
ws.onmessage = (ev) => {
|
||
try {
|
||
const data = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
|
||
if (Array.isArray(data) && typeof data[0] === 'string') {
|
||
const verb = data[0];
|
||
const matchesSub = data[1] === subId;
|
||
if (
|
||
((verb === 'EVENT' || verb === 'EOSE' || verb === 'CLOSED') && matchesSub) ||
|
||
verb === 'NOTICE'
|
||
) {
|
||
done(true, 'ok');
|
||
return;
|
||
}
|
||
}
|
||
} catch {
|
||
// Non-JSON frame → wait for timeout.
|
||
}
|
||
};
|
||
|
||
ws.onerror = () => {
|
||
// If we never opened, this is a connection-level failure (blocked /
|
||
// refused / cleartext-not-permitted). If we had opened, it's a mid-stream
|
||
// error.
|
||
done(false, opened ? 'error-after-open' : 'connect-error (blocked/refused/cleartext)');
|
||
};
|
||
ws.onclose = (ev) => {
|
||
done(false, opened ? 'closed-after-open' : `closed-before-open (code ${ev.code})`);
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Probe each candidate endpoint in order, returning the first that answers as a
|
||
* real Nostr relay (with the winning `url`). If none answer, returns the last
|
||
* failure's reason so the UI can still show a meaningful diagnostic. Tries them
|
||
* sequentially (not in parallel) — the list is tiny and the first candidate
|
||
* almost always wins, so this avoids opening several sockets at once.
|
||
*/
|
||
export async function probeCitrineEndpoints(
|
||
candidates: readonly string[] = CITRINE_RELAY_CANDIDATES,
|
||
timeoutMs = 3000,
|
||
): Promise<CitrineProbeResult> {
|
||
if (!isCitrinePossible()) {
|
||
return { ok: false, reason: 'not-android' };
|
||
}
|
||
|
||
let last: CitrineProbeResult = { ok: false, reason: 'no-endpoints' };
|
||
for (const candidate of candidates) {
|
||
last = await probeCitrine(candidate, timeoutMs);
|
||
if (last.ok) return last;
|
||
}
|
||
return last;
|
||
}
|