Files
client/www/keep-alive.html
T
2026-04-17 16:52:51 -04:00

1470 lines
55 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>KEEP ALIVE</title>
<link rel="stylesheet" href="./css/client.css" />
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark-mode');
if (document.body) document.body.classList.add('dark-mode');
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<style>
#divBody {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 14px 140px 14px;
overflow-y: auto;
scrollbar-gutter: stable;
width: 100%;
box-sizing: border-box;
height: auto;
min-height: calc(100% - 120px);
}
.kaCard {
border: 1px solid var(--muted-color);
border-radius: 8px;
padding: 10px;
background: var(--secondary-color);
width: 100%;
min-width: 0;
box-sizing: border-box;
overflow-x: hidden;
}
#kaControls {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.kaBtn {
border: 1px solid var(--muted-color);
color: var(--primary-color);
background: var(--background-color);
border-radius: 8px;
padding: 7px 10px;
font-family: var(--font-family);
cursor: pointer;
min-width: 148px;
text-align: left;
}
.kaBtn:hover { border-color: var(--accent-color); }
.kaBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.kaStatus {
display: inline-block;
width: 18px;
font-weight: bold;
}
#kaStats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 8px;
font-size: 90%;
}
.kaStatRow {
border: 1px dashed var(--muted-color);
border-radius: 6px;
padding: 8px;
background: var(--background-color);
}
#kaKindsTableWrap {
max-height: 180px;
overflow: auto;
border: 1px solid var(--muted-color);
border-radius: 6px;
}
#kaKindsTable {
width: 100%;
border-collapse: collapse;
font-size: 85%;
}
#kaKindsTable th,
#kaKindsTable td {
border-bottom: 1px solid var(--muted-color);
padding: 4px 6px;
text-align: left;
}
#kaLog {
border: 1px solid var(--muted-color);
border-radius: 8px;
background: var(--background-color);
color: var(--primary-color);
min-height: 240px;
max-height: 340px;
width: 100%;
max-width: 100%;
box-sizing: border-box;
overflow-y: auto;
overflow-x: auto;
scrollbar-gutter: stable;
padding: 8px;
font-family: monospace;
font-size: 12px;
white-space: pre-wrap;
line-height: 1.35;
}
#kaRelayJson {
border: 1px solid var(--muted-color);
border-radius: 8px;
background: var(--background-color);
color: var(--primary-color);
min-height: 180px;
max-height: 420px;
width: 100%;
max-width: 100%;
box-sizing: border-box;
overflow-y: auto;
overflow-x: auto;
scrollbar-gutter: stable;
padding: 8px;
font-family: monospace;
font-size: 12px;
white-space: pre;
line-height: 1.35;
}
</style>
</head>
<body>
<div id="divSvgHam" class="divHeaderButtons"></div>
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">KEEP ALIVE</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<div id="divBody">
<div class="kaCard" id="kaControls">
<button class="kaBtn" id="btnHalt"><span class="kaStatus" id="stHalt"></span>HALT Current Process</button>
<button class="kaBtn" id="btnLoadRegistry"><span class="kaStatus" id="stLoadRegistry"></span>Load Saved Relay Lists</button>
<button class="kaBtn" id="btnFindEvents"><span class="kaStatus" id="stFindEvents"></span>Find My Events</button>
<button class="kaBtn" id="btnDiagnose"><span class="kaStatus" id="stDiagnose"></span>Diagnose 7375/7376</button>
<button class="kaBtn" id="btnFindRelays" disabled><span class="kaStatus" id="stFindRelays"></span>Find Relays</button>
<button class="kaBtn" id="btnTestRelays" disabled><span class="kaStatus" id="stTestRelays"></span>Test Relays (kind 0)</button>
<button class="kaBtn" id="btnSaveRegistry" disabled><span class="kaStatus" id="stSaveRegistry"></span>Save Relay Registry</button>
<button class="kaBtn" id="btnSpreadCritical" disabled><span class="kaStatus" id="stSpreadCritical"></span>Spread Critical</button>
<button class="kaBtn" id="btnSpreadHigh" disabled><span class="kaStatus" id="stSpreadHigh"></span>Spread High</button>
<button class="kaBtn" id="btnSpreadMedium" disabled><span class="kaStatus" id="stSpreadMedium"></span>Spread Medium</button>
<button class="kaBtn" id="btnSpreadLow" disabled><span class="kaStatus" id="stSpreadLow"></span>Spread Low</button>
</div>
<div class="kaCard" id="kaStats">
<div class="kaStatRow" id="statEvents">Events: 0</div>
<div class="kaStatRow" id="statRelays">Discovered relays: 0</div>
<div class="kaStatRow" id="statAvailable">Available relays: 0</div>
<div class="kaStatRow" id="statSpread">Spread attempts: 0</div>
</div>
<div class="kaCard">
<div style="margin-bottom:6px; font-weight:bold;">Kinds summary</div>
<div id="kaKindsTableWrap">
<table id="kaKindsTable">
<thead><tr><th>Kind</th><th>Count</th><th>Priority</th></tr></thead>
<tbody id="kaKindsBody"></tbody>
</table>
</div>
</div>
<div class="kaCard">
<div style="margin-bottom:6px; font-weight:bold;">Activity log</div>
<div id="kaLog"></div>
</div>
<div class="kaCard">
<div style="margin-bottom:6px; font-weight:bold;">Relay registry JSON preview (to be saved as kind 30078 #t:relay-list)</div>
<pre id="kaRelayJson">{}</pre>
</div>
</div>
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<div id="divSideNav">
<div id="divSideNavHeader"></div>
<div id="divSideNavBody"><div id="divFiles"></div></div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList"><div id="divAiProvidersList">No saved providers yet.</div></div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">リレー</div>
<div id="divRelayList">Loading relays...</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">v0.0.1</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode"><div id="themeToggleHamburgerContainer"></div></button>
<button id="logoutButton" title="Logout"><div id="logoutHamburgerContainer"></div></button>
</div>
</div>
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script type="module">
import {
initNDKPage,
getPubkey,
injectHeaderAvatar,
disconnect,
getVersion,
updateVersionDisplay,
ndkFetchEvents,
fetchEvents,
publishEvent,
publishRawEvent,
getRelayData
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
const RELAY_LIST_TAG = 'relay-list';
const RELAY_LIST_D_PREFIX = 'relay-list-';
const CHUNK_SIZE = 100;
const RELAY_TEST_TIMEOUT_MS = 7000;
const SPREAD_DELAY_MS = 450;
const FIND_RELAYS_TEST_LIMIT = 100;
const PRIORITY_CRITICAL = new Set([0, 10002, 7375, 7376, 17375, 37375]);
const PRIORITY_HIGH = new Set([3, 10000, 30078, 30023]);
const PRIORITY_MEDIUM = new Set([30311, 30024, 1063, 9735]);
const state = {
updateIntervalId: null,
pubkey: '',
allEvents: [],
kind0Event: null,
eventsByKind: new Map(),
eventsByPriority: {
critical: [],
high: [],
medium: [],
low: []
},
discoveredRelays: new Set(),
relayMetadata: new Map(),
availableRelays: [],
spreadStats: {
attempts: 0,
success: 0,
failed: 0
},
haltRequested: false
};
let hamburgerInstance = null;
let isNavOpen = false;
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
const divBody = document.getElementById('divBody');
const divSideNav = document.getElementById('divSideNav');
const divFooterCenter = document.getElementById('divFooterCenter');
const divFooterRight = document.getElementById('divFooterRight');
const logEl = document.getElementById('kaLog');
const kindsBody = document.getElementById('kaKindsBody');
const relayJsonEl = document.getElementById('kaRelayJson');
const stepConfig = {
loadRegistry: { status: 'stLoadRegistry', button: 'btnLoadRegistry' },
findEvents: { status: 'stFindEvents', button: 'btnFindEvents' },
diagnose: { status: 'stDiagnose', button: 'btnDiagnose' },
findRelays: { status: 'stFindRelays', button: 'btnFindRelays' },
testRelays: { status: 'stTestRelays', button: 'btnTestRelays' },
saveRegistry: { status: 'stSaveRegistry', button: 'btnSaveRegistry' },
spreadCritical: { status: 'stSpreadCritical', button: 'btnSpreadCritical' },
spreadHigh: { status: 'stSpreadHigh', button: 'btnSpreadHigh' },
spreadMedium: { status: 'stSpreadMedium', button: 'btnSpreadMedium' },
spreadLow: { status: 'stSpreadLow', button: 'btnSpreadLow' }
};
function logLine(text, level = 'info') {
const ts = new Date().toISOString().slice(11, 19);
const marker = level === 'error' ? '✕' : level === 'ok' ? '✓' : level === 'warn' ? '⚠' : '•';
logEl.textContent += `[${ts}] ${marker} ${text}\n`;
logEl.scrollTop = logEl.scrollHeight;
}
function requestHalt() {
state.haltRequested = true;
logLine('HALT requested. Current process will stop at next safe checkpoint.', 'warn');
}
function resetHalt() {
state.haltRequested = false;
}
function throwIfHalted() {
if (state.haltRequested) {
throw new Error('HALTED_BY_USER');
}
}
function isReplaceableKind(kind) {
const n = Number(kind || 0);
return n === 0 || n === 3 || (n >= 10000 && n < 20000) || (n >= 30000 && n < 40000);
}
function extractRawEventCandidate(event) {
if (!event || typeof event !== 'object') return event || {};
try {
if (typeof event.rawEvent === 'function') {
const raw = event.rawEvent();
if (raw && typeof raw === 'object') return raw;
}
} catch {}
try {
if (typeof event.toNostrEvent === 'function') {
const raw = event.toNostrEvent();
if (raw && typeof raw === 'object') return raw;
}
} catch {}
if (event.rawEvent && typeof event.rawEvent === 'object') return event.rawEvent;
if (event.event && typeof event.event === 'object') return event.event;
if (event._event && typeof event._event === 'object') return event._event;
return event;
}
function canonicalizeNostrEvent(event) {
const source = extractRawEventCandidate(event);
const canonical = {
id: String(source?.id || ''),
pubkey: String(source?.pubkey || ''),
created_at: Number(source?.created_at || 0),
kind: Number(source?.kind || event?.kind || 0),
tags: Array.isArray(source?.tags)
? source.tags.map((t) => Array.isArray(t) ? t.map((v) => String(v)) : [String(t)])
: [],
content: String(source?.content || ''),
sig: String(source?.sig || '')
};
return canonical;
}
function validateEventSignatureLocally(event) {
const canonical = canonicalizeNostrEvent(event);
if (!canonical.id || !canonical.sig || !canonical.pubkey || !canonical.created_at) {
return { ok: false, reason: 'missing-id-sig-or-core-fields' };
}
const verifyFn = window?.NostrTools?.verifyEvent;
const hashFn = window?.NostrTools?.getEventHash;
if (typeof verifyFn !== 'function' || typeof hashFn !== 'function') {
return { ok: true, reason: 'skipped-local-verify-missing-nostrtools' };
}
try {
const hashable = {
pubkey: canonical.pubkey,
created_at: canonical.created_at,
kind: canonical.kind,
tags: canonical.tags,
content: canonical.content
};
const computedId = String(hashFn(hashable) || '');
if (!computedId || computedId !== canonical.id) {
return { ok: false, reason: 'id-hash-mismatch' };
}
const ok = Boolean(verifyFn(canonical));
return ok ? { ok: true, reason: 'verified' } : { ok: false, reason: 'bad-signature' };
} catch (error) {
return { ok: false, reason: `verify-error:${String(error?.message || error)}` };
}
}
async function maybeResignEvent(event) {
if (!event || !isReplaceableKind(event.kind)) return canonicalizeNostrEvent(event || {});
if (!window.nostr || typeof window.nostr.signEvent !== 'function') {
throw new Error('window.nostr.signEvent unavailable for re-signing replaceable events');
}
const draft = {
kind: Number(event.kind),
created_at: Math.floor(Date.now() / 1000),
tags: Array.isArray(event.tags) ? event.tags.map((t) => Array.isArray(t) ? [...t] : t) : [],
content: String(event.content || '')
};
const signed = await window.nostr.signEvent(draft);
return canonicalizeNostrEvent(signed);
}
async function prepareEventsForRelayPublishing(events) {
const prepared = [];
let skippedInvalid = 0;
for (const ev of events || []) {
throwIfHalted();
if (isReplaceableKind(ev.kind)) {
const signed = await maybeResignEvent(ev);
prepared.push(canonicalizeNostrEvent(signed));
} else {
const canonical = canonicalizeNostrEvent(ev);
const verify = validateEventSignatureLocally(canonical);
if (!verify.ok) {
skippedInvalid++;
logLine(`Skipping invalid event kind ${canonical.kind} (${String(canonical.id || '').slice(0, 8)}...): ${verify.reason}`, 'warn');
continue;
}
prepared.push(canonical);
}
}
if (skippedInvalid > 0) {
logLine(`Skipped ${skippedInvalid} non-replaceable events due to invalid signature/hash.`, 'warn');
}
return prepared;
}
function priorityForKind(kind) {
if (PRIORITY_CRITICAL.has(kind)) return 'critical';
if (PRIORITY_HIGH.has(kind)) return 'high';
if (PRIORITY_MEDIUM.has(kind)) return 'medium';
return 'low';
}
function setStepState(step, stateName) {
const statusEl = document.getElementById(stepConfig[step].status);
const buttonEl = document.getElementById(stepConfig[step].button);
if (!statusEl || !buttonEl) return;
if (stateName === 'idle') statusEl.textContent = '○';
if (stateName === 'busy') statusEl.textContent = '◐';
if (stateName === 'done') statusEl.textContent = '●';
if (stateName === 'error') statusEl.textContent = '✕';
}
function setEnabled(step, enabled) {
const buttonEl = document.getElementById(stepConfig[step].button);
if (buttonEl) buttonEl.disabled = !enabled;
}
function updateStats() {
document.getElementById('statEvents').textContent = `Events: ${state.allEvents.length}`;
document.getElementById('statRelays').textContent = `Discovered relays: ${state.discoveredRelays.size}`;
document.getElementById('statAvailable').textContent = `Available relays: ${state.availableRelays.length}`;
document.getElementById('statSpread').textContent = `Spread attempts: ${state.spreadStats.attempts} (ok ${state.spreadStats.success} / fail ${state.spreadStats.failed})`;
}
function renderKindsTable() {
const rows = Array.from(state.eventsByKind.entries()).sort((a, b) => b[1].length - a[1].length);
kindsBody.innerHTML = rows.map(([kind, events]) => {
const p = priorityForKind(Number(kind));
return `<tr><td>${kind}</td><td>${events.length}</td><td>${p}</td></tr>`;
}).join('');
}
function normalizeRelayUrl(url) {
const trimmed = String(url || '').trim();
if (!trimmed) return '';
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'wss:' && parsed.protocol !== 'ws:') return '';
parsed.hash = '';
parsed.search = '';
let normalized = parsed.toString();
if (normalized.endsWith('/')) normalized = normalized.slice(0, -1);
return normalized;
} catch {
return '';
}
}
function isPrivateOrLocalRelayUrl(relayUrl) {
try {
const parsed = new URL(relayUrl);
const host = String(parsed.hostname || '').toLowerCase();
if (!host) return true;
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return true;
if (host.endsWith('.local') || host.endsWith('.localdomain') || host.endsWith('.lan') || host.endsWith('.home') || host.endsWith('.internal')) return true;
if (host.endsWith('.ts.net')) return true; // Tailscale/private tailnet names
if (/^10\./.test(host)) return true;
if (/^127\./.test(host)) return true;
if (/^192\.168\./.test(host)) return true;
if (/^169\.254\./.test(host)) return true;
const m172 = host.match(/^172\.(\d{1,3})\./);
if (m172) {
const second = Number(m172[1]);
if (second >= 16 && second <= 31) return true;
}
if (host.startsWith('fc') || host.startsWith('fd') || host.startsWith('fe80:')) return true; // IPv6 ULA/link-local
return false;
} catch {
return true;
}
}
function relayToHttp(relayUrl) {
if (relayUrl.startsWith('wss://')) return relayUrl.replace('wss://', 'https://');
if (relayUrl.startsWith('ws://')) return relayUrl.replace('ws://', 'http://');
return relayUrl;
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function nowSec() {
return Math.floor(Date.now() / 1000);
}
function normalizeStoredRelayMetadata(input = {}, relayUrl = '') {
const tsNow = nowSec();
const source = (input && typeof input === 'object') ? input : {};
const url = normalizeRelayUrl(source.url || relayUrl);
const testedLegacy = Number(source.tested || 0);
const firstSeen = Number(source.firstSeen || testedLegacy || tsNow);
const lastTested = Number(source.lastTested || testedLegacy || 0);
const reachable = typeof source.reachable === 'boolean' ? source.reachable : Boolean(source.available);
const lastReachable = Number(source.lastReachable || (reachable ? (lastTested || tsNow) : 0));
const authRequired = Boolean(source.authRequired);
const latencyMs = Number.isFinite(Number(source.latencyMs)) ? Number(source.latencyMs) : null;
const nip11 = source.nip11 && typeof source.nip11 === 'object' ? source.nip11 : null;
const geo = source.geo && typeof source.geo === 'object' ? source.geo : null;
const responses = {};
if (source.responses && typeof source.responses === 'object') {
for (const [kind, response] of Object.entries(source.responses)) {
if (!response || typeof response !== 'object') continue;
responses[String(kind)] = {
ok: Boolean(response.ok),
msg: String(response.msg || response.reason || ''),
ts: Number(response.ts || response.tested || 0) || 0
};
}
}
if (Object.keys(responses).length === 0 && (source.response || source.testKind != null)) {
const key = String(Number(source.testKind || 0));
responses[key] = {
ok: reachable,
msg: String(source.response || ''),
ts: lastTested || tsNow
};
}
return {
url,
firstSeen,
lastTested,
lastReachable,
reachable,
authRequired,
latencyMs,
nip11,
geo,
responses
};
}
function recordRelayResponse(meta, kind, result, ts = nowSec()) {
const responseMap = (meta.responses && typeof meta.responses === 'object') ? meta.responses : {};
responseMap[String(Number(kind || 0))] = {
ok: Boolean(result?.ok),
msg: String(result?.reason || ''),
ts
};
meta.responses = responseMap;
return meta;
}
function serializeRelayMetadata(meta) {
return normalizeStoredRelayMetadata(meta, meta?.url || '');
}
async function fetchNip11(relayUrl) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 4000);
try {
const response = await fetch(relayToHttp(relayUrl), {
method: 'GET',
headers: { Accept: 'application/nostr+json' },
signal: controller.signal
});
if (!response.ok) return null;
return await response.json();
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
function shouldMarkAuthRequired(reason = '') {
const text = String(reason || '').toLowerCase();
return text.includes('auth') || text.includes('nip-42') || text.includes('restricted') || text.includes('permission denied');
}
function classifySkipSignal(reason = '', kind = null) {
const text = String(reason || '').toLowerCase();
const kindNum = kind == null ? null : Number(kind || 0);
const relayWidePolicy = [
'auth-required',
'only authorized users can write',
'private relay',
'account is timed out',
'web of trust',
'policy violated and pubkey is not in our web of trust'
].some((needle) => text.includes(needle));
if (relayWidePolicy) {
return { scope: 'relay', reason: text || 'relay-policy' };
}
const kindPolicy = [
'blocked kind',
'kind not allowed',
'event_too_large',
'event too large',
'too many tags'
].some((needle) => text.includes(needle));
if (kindPolicy) {
return { scope: 'kind', kind: kindNum, reason: text || 'kind-policy' };
}
if (text.includes('rate_limit_exceeded') || text.includes('rate limit')) {
return { scope: 'backoff', backoffMs: 1800, reason: text || 'rate-limit' };
}
return null;
}
function shouldSkipRelayFromHistory(relayMeta, kind = null) {
if (!relayMeta || typeof relayMeta !== 'object') return null;
if (relayMeta.authRequired) return { scope: 'relay', reason: 'auth-required(history)' };
const responses = relayMeta.responses && typeof relayMeta.responses === 'object' ? relayMeta.responses : {};
const kindKey = kind == null ? null : String(Number(kind || 0));
for (const [k, resp] of Object.entries(responses)) {
if (!resp || resp.ok) continue;
const signal = classifySkipSignal(resp.msg || '', Number(k));
if (!signal) continue;
if (signal.scope === 'relay') return { ...signal, source: 'history' };
if (signal.scope === 'kind' && kindKey != null && String(Number(signal.kind || k)) === kindKey) {
return { ...signal, source: 'history' };
}
}
return null;
}
async function sendEventToRelay(relayUrl, event, timeoutMs = RELAY_TEST_TIMEOUT_MS) {
return await new Promise((resolve) => {
let settled = false;
const startedAt = Date.now();
let ws;
const finish = (result) => {
if (settled) return;
settled = true;
try { ws?.close(); } catch {}
resolve(result);
};
const timeout = setTimeout(() => {
finish({ ok: false, reason: 'timeout', latencyMs: Date.now() - startedAt, authRequired: false });
}, timeoutMs);
try {
ws = new WebSocket(relayUrl);
} catch (error) {
clearTimeout(timeout);
finish({ ok: false, reason: `socket-error: ${error.message}`, latencyMs: Date.now() - startedAt, authRequired: false });
return;
}
ws.onopen = () => {
try {
ws.send(JSON.stringify(['EVENT', event]));
} catch (error) {
clearTimeout(timeout);
finish({ ok: false, reason: `send-error: ${error.message}`, latencyMs: Date.now() - startedAt, authRequired: false });
}
};
ws.onmessage = (messageEvent) => {
try {
const msg = JSON.parse(messageEvent.data);
if (!Array.isArray(msg)) return;
if (msg[0] === 'OK' && msg[1] === event.id) {
const reason = String(msg[3] || '');
const authRequired = !Boolean(msg[2]) && shouldMarkAuthRequired(reason);
clearTimeout(timeout);
finish({ ok: Boolean(msg[2]), reason, latencyMs: Date.now() - startedAt, authRequired });
}
if (msg[0] === 'AUTH') {
clearTimeout(timeout);
finish({ ok: false, reason: 'auth-required', latencyMs: Date.now() - startedAt, authRequired: true, authChallenge: String(msg[1] || '') });
}
if (msg[0] === 'NOTICE') {
const reason = String(msg[1] || 'notice');
clearTimeout(timeout);
finish({ ok: false, reason, latencyMs: Date.now() - startedAt, authRequired: shouldMarkAuthRequired(reason) });
}
} catch {
// Ignore non-json
}
};
ws.onerror = () => {
clearTimeout(timeout);
finish({ ok: false, reason: 'socket-error', latencyMs: Date.now() - startedAt, authRequired: false });
};
ws.onclose = () => {
if (!settled) {
clearTimeout(timeout);
finish({ ok: false, reason: 'closed-before-ok', latencyMs: Date.now() - startedAt, authRequired: false });
}
};
});
}
function getRelayMetadataSnapshot() {
return Array.from(state.relayMetadata.values()).sort((a, b) => String(a.url || '').localeCompare(String(b.url || '')));
}
function renderRelayJsonPreview() {
if (!relayJsonEl) return;
const chunks = buildRelayChunks();
const previewPayload = {
kind: 30078,
tag: RELAY_LIST_TAG,
totalRelays: state.relayMetadata.size,
chunkCount: chunks.length,
chunks: chunks.map((chunk, idx) => ({
d: `${RELAY_LIST_D_PREFIX}${pad3(idx + 1)}`,
count: chunk.length,
data: chunk
}))
};
relayJsonEl.textContent = JSON.stringify(previewPayload, null, 2);
}
function eventFitsRelay(event, relayMeta) {
const lim = relayMeta?.nip11?.limitation;
if (!lim) return true;
const maxTags = Number(lim.max_event_tags || 0);
const maxContent = Number(lim.max_content_length || 0);
if (maxTags > 0 && (event.tags || []).length > maxTags) return false;
if (maxContent > 0 && String(event.content || '').length > maxContent) return false;
return true;
}
async function stepDiagnose737x() {
setStepState('diagnose', 'busy');
resetHalt();
try {
logLine('Running diagnostics for kinds 7375/7376. Detailed output will be in browser console.', 'warn');
const events = await ndkFetchEvents({ authors: [state.pubkey], kinds: [7375, 7376], limit: 1000 });
const list = Array.isArray(events) ? events : [];
const missingCounts = {
id: 0,
sig: 0,
pubkey: 0,
created_at: 0,
kind: 0
};
const summary = {
total: list.length,
byKind: { '7375': 0, '7376': 0, other: 0 },
missingCore: 0,
invalidByLocalVerify: 0
};
const rows = [];
for (let i = 0; i < list.length; i++) {
throwIfHalted();
const original = list[i];
const raw = extractRawEventCandidate(original);
const canonical = canonicalizeNostrEvent(original);
const verify = validateEventSignatureLocally(canonical);
const kindNum = Number(canonical.kind || original?.kind || 0);
if (kindNum === 7375) summary.byKind['7375']++;
else if (kindNum === 7376) summary.byKind['7376']++;
else summary.byKind.other++;
const hasId = Boolean(canonical.id);
const hasSig = Boolean(canonical.sig);
const hasPubkey = Boolean(canonical.pubkey);
const hasCreatedAt = Boolean(Number(canonical.created_at || 0));
const hasKind = Boolean(Number(canonical.kind || 0));
const missingAny = !(hasId && hasSig && hasPubkey && hasCreatedAt && hasKind);
if (!hasId) missingCounts.id++;
if (!hasSig) missingCounts.sig++;
if (!hasPubkey) missingCounts.pubkey++;
if (!hasCreatedAt) missingCounts.created_at++;
if (!hasKind) missingCounts.kind++;
if (missingAny) summary.missingCore++;
if (!verify.ok) summary.invalidByLocalVerify++;
rows.push({
idx: i,
sourceKind: Number(original?.kind || 0),
canonicalKind: kindNum,
rawType: raw?.constructor?.name || typeof raw,
rawKeys: raw && typeof raw === 'object' ? Object.keys(raw).slice(0, 12).join(',') : '',
hasRawId: Boolean(raw?.id),
hasRawSig: Boolean(raw?.sig),
hasRawPubkey: Boolean(raw?.pubkey),
hasCanonicalId: hasId,
hasCanonicalSig: hasSig,
hasCanonicalPubkey: hasPubkey,
hasCanonicalCreatedAt: hasCreatedAt,
hasCanonicalKind: hasKind,
contentLen: String(canonical.content || '').length,
tagsLen: Array.isArray(canonical.tags) ? canonical.tags.length : 0,
verifyOk: verify.ok,
verifyReason: verify.reason,
idPrefix: String(canonical.id || '').slice(0, 12)
});
}
console.groupCollapsed(`[keep-alive] 737x diagnostics (${summary.total} events)`);
console.log('summary', summary);
console.log('missingCoreFieldCounts', missingCounts);
console.table(rows.slice(0, 120));
if (rows.length > 120) {
console.log(`rows truncated in console.table: showing first 120/${rows.length}`);
}
console.groupEnd();
logLine(`Diagnose complete: ${summary.total} events (missing core: ${summary.missingCore}, verify-fail: ${summary.invalidByLocalVerify}).`, summary.missingCore > 0 ? 'warn' : 'ok');
setStepState('diagnose', 'done');
} catch (error) {
if (error?.message === 'HALTED_BY_USER') {
logLine('Diagnostics halted by user.', 'warn');
setStepState('diagnose', 'idle');
return;
}
logLine(`Diagnostics failed: ${error.message}`, 'error');
setStepState('diagnose', 'error');
}
}
async function stepFindEvents() {
setStepState('findEvents', 'busy');
resetHalt();
try {
logLine('Fetching all events authored by current user...');
const events = await ndkFetchEvents({ authors: [state.pubkey], limit: 5000 });
state.allEvents = Array.isArray(events) ? events : [];
state.eventsByKind = new Map();
state.eventsByPriority = { critical: [], high: [], medium: [], low: [] };
for (const ev of state.allEvents) {
const k = Number(ev.kind || 0);
if (!state.eventsByKind.has(k)) state.eventsByKind.set(k, []);
state.eventsByKind.get(k).push(ev);
state.eventsByPriority[priorityForKind(k)].push(ev);
}
state.kind0Event = (state.eventsByKind.get(0) || []).sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0] || null;
if (!state.kind0Event) logLine('No kind 0 found. Relay testing step requires a profile event.', 'error');
renderKindsTable();
updateStats();
logLine(`Found ${state.allEvents.length} events across ${state.eventsByKind.size} kinds.`, 'ok');
setEnabled('findRelays', true);
setStepState('findEvents', 'done');
} catch (error) {
logLine(`Find events failed: ${error.message}`, 'error');
setStepState('findEvents', 'error');
}
}
async function loadStoredRelayRegistry() {
try {
const registryEvents = await ndkFetchEvents({ kinds: [30078], authors: [state.pubkey], '#t': [RELAY_LIST_TAG], limit: 200 });
const deduped = new Set();
for (const ev of registryEvents || []) {
try {
const arr = JSON.parse(String(ev.content || '[]'));
if (!Array.isArray(arr)) continue;
for (const item of arr) {
const n = normalizeRelayUrl(item?.url);
if (!n) continue;
if (isPrivateOrLocalRelayUrl(n)) continue;
deduped.add(n);
const normalizedMeta = normalizeStoredRelayMetadata(item, n);
if (!normalizedMeta.url) continue;
if (!state.relayMetadata.has(n)) {
state.relayMetadata.set(n, normalizedMeta);
} else {
const merged = {
...normalizeStoredRelayMetadata(state.relayMetadata.get(n), n),
...normalizedMeta,
responses: {
...normalizeStoredRelayMetadata(state.relayMetadata.get(n), n).responses,
...normalizedMeta.responses
}
};
state.relayMetadata.set(n, normalizeStoredRelayMetadata(merged, n));
}
}
} catch {}
}
if (deduped.size > 0) logLine(`Loaded ${deduped.size} relays from stored #t:${RELAY_LIST_TAG} registry.`, 'ok');
renderRelayJsonPreview();
return deduped;
} catch (error) {
logLine(`Could not load stored registry: ${error.message}`, 'error');
return new Set();
}
}
async function stepLoadRegistry() {
setStepState('loadRegistry', 'busy');
resetHalt();
try {
const fromRegistry = await loadStoredRelayRegistry();
for (const relay of fromRegistry) state.discoveredRelays.add(relay);
const sortedRelays = Array.from(state.discoveredRelays).sort((a, b) => a.localeCompare(b));
if (sortedRelays.length > FIND_RELAYS_TEST_LIMIT) {
state.discoveredRelays = new Set(sortedRelays.slice(0, FIND_RELAYS_TEST_LIMIT));
logLine(`Temporary test cap applied: using first ${FIND_RELAYS_TEST_LIMIT}/${sortedRelays.length} discovered relays.`, 'warn');
}
updateStats();
if (fromRegistry.size === 0) {
logLine(`No saved #t:${RELAY_LIST_TAG} registry entries found.`, 'warn');
}
setEnabled('testRelays', state.kind0Event != null && state.discoveredRelays.size > 0);
setStepState('loadRegistry', 'done');
} catch (error) {
if (error?.message === 'HALTED_BY_USER') {
logLine('Load registry halted by user.', 'warn');
setStepState('loadRegistry', 'idle');
return;
}
logLine(`Load registry failed: ${error.message}`, 'error');
setStepState('loadRegistry', 'error');
}
}
async function stepFindRelays() {
setStepState('findRelays', 'busy');
resetHalt();
try {
logLine('Reading contacts (kind 3) to get follow list...');
const contacts = await ndkFetchEvents({ kinds: [3], authors: [state.pubkey], limit: 5 });
const latestContacts = (contacts || []).sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
const follows = new Set();
for (const tag of latestContacts?.tags || []) {
if (tag[0] === 'p' && tag[1]) follows.add(String(tag[1]).trim());
}
logLine(`Found ${follows.size} follows. Fetching their kind 10002 relay lists...`);
let skippedPrivateRelays = 0;
const followPubkeys = Array.from(follows);
for (let i = 0; i < followPubkeys.length; i += 100) {
throwIfHalted();
const chunk = followPubkeys.slice(i, i + 100);
const relayListEvents = await ndkFetchEvents({ kinds: [10002], authors: chunk, limit: 3000 });
for (const ev of relayListEvents || []) {
for (const tag of ev.tags || []) {
if (tag[0] !== 'r' || !tag[1]) continue;
const normalized = normalizeRelayUrl(tag[1]);
if (!normalized) continue;
if (isPrivateOrLocalRelayUrl(normalized)) {
skippedPrivateRelays++;
continue;
}
state.discoveredRelays.add(normalized);
}
}
logLine(`Processed follows ${i + 1}-${Math.min(i + 100, followPubkeys.length)}.`);
}
const fromRegistry = await loadStoredRelayRegistry();
for (const relay of fromRegistry) state.discoveredRelays.add(relay);
const sortedRelays = Array.from(state.discoveredRelays).sort((a, b) => a.localeCompare(b));
if (sortedRelays.length > FIND_RELAYS_TEST_LIMIT) {
state.discoveredRelays = new Set(sortedRelays.slice(0, FIND_RELAYS_TEST_LIMIT));
logLine(`Temporary test cap applied: using first ${FIND_RELAYS_TEST_LIMIT}/${sortedRelays.length} discovered relays.`, 'warn');
}
updateStats();
logLine(`Discovered ${state.discoveredRelays.size} unique relays.`, 'ok');
if (skippedPrivateRelays > 0) {
logLine(`Skipped ${skippedPrivateRelays} local/private relay URLs (localhost, LAN, .local, .ts.net, RFC1918).`, 'warn');
}
setEnabled('testRelays', state.kind0Event != null && state.discoveredRelays.size > 0);
setStepState('findRelays', 'done');
} catch (error) {
if (error?.message === 'HALTED_BY_USER') {
logLine('Find relays halted by user.', 'warn');
setStepState('findRelays', 'idle');
return;
}
logLine(`Find relays failed: ${error.message}`, 'error');
setStepState('findRelays', 'error');
}
}
async function stepTestRelays() {
setStepState('testRelays', 'busy');
resetHalt();
try {
if (!state.kind0Event) throw new Error('No kind 0 event available for relay testing');
if (state.discoveredRelays.size === 0) throw new Error('No discovered relays to test');
state.availableRelays = [];
const relayList = Array.from(state.discoveredRelays);
logLine(`Testing ${relayList.length} relays using your kind 0 event...`);
logLine('Successes are marked with ✓ and failures with ✕. Many dead relays are expected.', 'warn');
for (let i = 0; i < relayList.length; i++) {
throwIfHalted();
const relayUrl = relayList[i];
const signedKind0 = canonicalizeNostrEvent(await maybeResignEvent(state.kind0Event));
const [testResult, nip11] = await Promise.all([
sendEventToRelay(relayUrl, signedKind0, RELAY_TEST_TIMEOUT_MS),
fetchNip11(relayUrl)
]);
const ts = nowSec();
const previousMeta = normalizeStoredRelayMetadata(state.relayMetadata.get(relayUrl), relayUrl);
const meta = {
...previousMeta,
url: relayUrl,
firstSeen: previousMeta.firstSeen || ts,
lastTested: ts,
lastReachable: testResult.ok ? ts : previousMeta.lastReachable,
reachable: Boolean(testResult.ok),
authRequired: previousMeta.authRequired || Boolean(testResult.authRequired),
latencyMs: Number.isFinite(Number(testResult.latencyMs)) ? Number(testResult.latencyMs) : previousMeta.latencyMs,
nip11: nip11 || previousMeta.nip11 || null,
geo: previousMeta.geo || null,
responses: { ...(previousMeta.responses || {}) }
};
recordRelayResponse(meta, 0, testResult, ts);
state.relayMetadata.set(relayUrl, serializeRelayMetadata(meta));
if (meta.reachable) state.availableRelays.push(relayUrl);
const marker = meta.reachable ? 'ok' : (meta.authRequired ? 'warn' : 'error');
logLine(`[${i + 1}/${relayList.length}] ${relayUrl} -> ${meta.reachable ? 'available' : (meta.authRequired ? 'auth-required' : 'unavailable')} (${testResult.reason || 'no-reason'})`, marker);
updateStats();
renderRelayJsonPreview();
await delay(100);
}
logLine(`Relay testing complete. Available: ${state.availableRelays.length}/${relayList.length}`, 'ok');
setEnabled('saveRegistry', state.relayMetadata.size > 0);
setEnabled('spreadCritical', state.availableRelays.length > 0);
setStepState('testRelays', 'done');
} catch (error) {
if (error?.message === 'HALTED_BY_USER') {
logLine('Relay testing halted by user.', 'warn');
setStepState('testRelays', 'idle');
return;
}
logLine(`Test relays failed: ${error.message}`, 'error');
setStepState('testRelays', 'error');
}
}
function buildRelayChunks() {
const allMeta = Array.from(state.relayMetadata.values())
.map((meta) => serializeRelayMetadata(meta))
.filter((meta) => Boolean(meta.url))
.sort((a, b) => a.url.localeCompare(b.url));
const chunks = [];
for (let i = 0; i < allMeta.length; i += CHUNK_SIZE) {
chunks.push(allMeta.slice(i, i + CHUNK_SIZE));
}
return chunks;
}
function pad3(n) {
return String(n).padStart(3, '0');
}
async function stepSaveRegistry() {
setStepState('saveRegistry', 'busy');
resetHalt();
try {
const chunks = buildRelayChunks();
if (chunks.length === 0) throw new Error('No relay metadata to save');
renderRelayJsonPreview();
logLine(`Publishing relay registry chunks with #t:${RELAY_LIST_TAG}...`);
const now = Math.floor(Date.now() / 1000);
for (let i = 0; i < chunks.length; i++) {
throwIfHalted();
const dTag = `${RELAY_LIST_D_PREFIX}${pad3(i + 1)}`;
const event = {
kind: 30078,
created_at: now,
tags: [
['d', dTag],
['t', RELAY_LIST_TAG],
['index', String(i + 1)],
['total', String(chunks.length)]
],
content: JSON.stringify(chunks[i])
};
await publishEvent(event);
logLine(`Published chunk ${i + 1}/${chunks.length} (${dTag}).`, 'ok');
await delay(250);
}
// Optional cleanup for older extra chunks
const existing = await ndkFetchEvents({ kinds: [30078], authors: [state.pubkey], '#t': [RELAY_LIST_TAG], limit: 500 });
const newestByD = new Map();
for (const ev of existing || []) {
const d = (ev.tags || []).find((t) => t[0] === 'd')?.[1];
if (!d || !d.startsWith(RELAY_LIST_D_PREFIX)) continue;
const prev = newestByD.get(d);
if (!prev || (ev.created_at || 0) > (prev.created_at || 0)) newestByD.set(d, ev);
}
const validD = new Set(chunks.map((_, i) => `${RELAY_LIST_D_PREFIX}${pad3(i + 1)}`));
const toDelete = Array.from(newestByD.values()).filter((ev) => {
const d = (ev.tags || []).find((t) => t[0] === 'd')?.[1];
return !validD.has(d);
});
if (toDelete.length > 0) {
const deleteEvent = {
kind: 5,
created_at: Math.floor(Date.now() / 1000),
tags: toDelete.map((ev) => ['e', ev.id]),
content: `keep-alive prune old ${RELAY_LIST_TAG} chunks`
};
await publishEvent(deleteEvent);
logLine(`Published deletion event for ${toDelete.length} obsolete registry chunks.`);
}
setEnabled('spreadCritical', state.availableRelays.length > 0);
setStepState('saveRegistry', 'done');
} catch (error) {
if (error?.message === 'HALTED_BY_USER') {
logLine('Save registry halted by user.', 'warn');
setStepState('saveRegistry', 'idle');
return;
}
logLine(`Save registry failed: ${error.message}`, 'error');
setStepState('saveRegistry', 'error');
}
}
async function spreadPriority(priorityName, stepName, nextStepName) {
setStepState(stepName, 'busy');
resetHalt();
try {
const events = state.eventsByPriority[priorityName] || [];
if (events.length === 0) {
logLine(`No ${priorityName} events to spread.`, 'ok');
setStepState(stepName, 'done');
if (nextStepName) setEnabled(nextStepName, true);
return;
}
if (state.availableRelays.length === 0) throw new Error('No available relays');
logLine(`Spreading ${events.length} ${priorityName} events to ${state.availableRelays.length} relays...`);
logLine('Preparing events: replaceable kinds will be re-signed with fresh created_at.', 'warn');
const preparedEvents = await prepareEventsForRelayPublishing(events);
for (const relayUrl of state.availableRelays) {
throwIfHalted();
const relayMeta = normalizeStoredRelayMetadata(state.relayMetadata.get(relayUrl), relayUrl);
let relayOk = 0;
let relayFail = 0;
let skippedByHistory = 0;
const blockedKindsThisRelay = new Set();
const relayHistorySkip = shouldSkipRelayFromHistory(relayMeta, null);
if (relayHistorySkip?.scope === 'relay') {
logLine(`SKIP ${relayUrl} from prior history: ${relayHistorySkip.reason}`, 'warn');
continue;
}
for (const ev of preparedEvents) {
throwIfHalted();
const ts = nowSec();
if (blockedKindsThisRelay.has(Number(ev.kind))) {
skippedByHistory++;
continue;
}
const historicalKindSkip = shouldSkipRelayFromHistory(relayMeta, ev.kind);
if (historicalKindSkip?.scope === 'kind') {
skippedByHistory++;
logLine(`SKIP ${relayUrl} kind ${ev.kind} from prior history: ${historicalKindSkip.reason}`, 'warn');
continue;
}
if (!eventFitsRelay(ev, relayMeta)) {
relayFail++;
state.spreadStats.failed++;
state.spreadStats.attempts++;
recordRelayResponse(relayMeta, ev.kind, { ok: false, reason: 'blocked-by-nip11-limit' }, ts);
relayMeta.lastTested = ts;
state.relayMetadata.set(relayUrl, serializeRelayMetadata(relayMeta));
blockedKindsThisRelay.add(Number(ev.kind));
continue;
}
const result = await sendEventToRelay(relayUrl, ev, 6000);
state.spreadStats.attempts++;
relayMeta.lastTested = ts;
relayMeta.reachable = Boolean(result.ok);
if (result.ok) {
relayOk++;
state.spreadStats.success++;
relayMeta.lastReachable = ts;
logLine(`SUCCESS ${relayUrl} accepted kind ${ev.kind} (${String(ev.id || '').slice(0, 8)}...)`, 'ok');
} else {
relayFail++;
state.spreadStats.failed++;
if (result.authRequired) relayMeta.authRequired = true;
logLine(`FAIL ${relayUrl} rejected kind ${ev.kind}: ${result.reason || 'unknown'}`, 'error');
}
if (Number.isFinite(Number(result.latencyMs))) relayMeta.latencyMs = Number(result.latencyMs);
recordRelayResponse(relayMeta, ev.kind, result, ts);
state.relayMetadata.set(relayUrl, serializeRelayMetadata(relayMeta));
const signal = result.ok ? null : classifySkipSignal(result.reason || '', ev.kind);
if (signal?.scope === 'relay') {
logLine(`STOP relay ${relayUrl} for this run: ${signal.reason}`, 'warn');
updateStats();
renderRelayJsonPreview();
break;
}
if (signal?.scope === 'kind' && Number.isFinite(Number(signal.kind))) {
blockedKindsThisRelay.add(Number(signal.kind));
logLine(`STOP kind ${signal.kind} on ${relayUrl} for this run: ${signal.reason}`, 'warn');
}
updateStats();
renderRelayJsonPreview();
const waitMs = signal?.scope === 'backoff'
? Math.max(SPREAD_DELAY_MS, Number(signal.backoffMs || SPREAD_DELAY_MS))
: SPREAD_DELAY_MS;
await delay(waitMs);
}
if (skippedByHistory > 0) {
logLine(`${priorityName} history-skip -> ${relayUrl}: skipped ${skippedByHistory}`, 'warn');
}
logLine(`${priorityName} summary -> ${relayUrl}: ok ${relayOk}, fail ${relayFail}`, relayOk > 0 ? 'ok' : 'warn');
}
logLine(`${priorityName} spread complete.`, 'ok');
setStepState(stepName, 'done');
if (nextStepName) setEnabled(nextStepName, true);
} catch (error) {
if (error?.message === 'HALTED_BY_USER') {
logLine(`Spread ${priorityName} halted by user.`, 'warn');
setStepState(stepName, 'idle');
return;
}
logLine(`Spread ${priorityName} failed: ${error.message}`, 'error');
setStepState(stepName, 'error');
}
}
async function updateFooter() {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
const relayData = await getRelayData();
divFooterCenter.textContent = state.pubkey ? state.pubkey.slice(0, 12) + '...' : '';
divFooterRight.textContent = `${relayData?.connectedCount || 0}/${relayData?.totalCount || 0} relays`;
} catch (error) {
console.warn('[keep-alive] Footer update skipped:', error.message);
}
}
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = 'clamp(400px, 50vw, 600px)';
isNavOpen = true;
if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left');
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
isDarkMode = localStorage.getItem('theme') === 'dark' || document.body.classList.contains('dark-mode');
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
}
function closeNav() {
divSideNav.style.width = '0vw';
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) hamburgerInstance.animateTo('burger');
}
function toggleNav() {
if (isNavOpen) closeNav(); else openNav();
}
async function logoutAll() {
if (state.updateIntervalId) clearInterval(state.updateIntervalId);
disconnect();
if (window.NOSTR_LOGIN_LITE?.logout) await window.NOSTR_LOGIN_LITE.logout();
localStorage.clear();
sessionStorage.clear();
location.reload();
}
function wireButtons() {
document.getElementById('btnHalt').addEventListener('click', requestHalt);
document.getElementById('btnLoadRegistry').addEventListener('click', stepLoadRegistry);
document.getElementById('btnFindEvents').addEventListener('click', stepFindEvents);
document.getElementById('btnDiagnose').addEventListener('click', stepDiagnose737x);
document.getElementById('btnFindRelays').addEventListener('click', stepFindRelays);
document.getElementById('btnTestRelays').addEventListener('click', stepTestRelays);
document.getElementById('btnSaveRegistry').addEventListener('click', stepSaveRegistry);
document.getElementById('btnSpreadCritical').addEventListener('click', () => spreadPriority('critical', 'spreadCritical', 'spreadHigh'));
document.getElementById('btnSpreadHigh').addEventListener('click', () => spreadPriority('high', 'spreadHigh', 'spreadMedium'));
document.getElementById('btnSpreadMedium').addEventListener('click', () => spreadPriority('medium', 'spreadMedium', 'spreadLow'));
document.getElementById('btnSpreadLow').addEventListener('click', () => spreadPriority('low', 'spreadLow', null));
}
(async function main() {
try {
const versionInfo = await getVersion();
console.log(`[keep-alive ${versionInfo.VERSION}] Loading...`);
initHamburgerMenu();
document.getElementById('divSvgHam')?.addEventListener('click', toggleNav);
document.getElementById('themeToggleButton')?.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
document.documentElement.classList.toggle('dark-mode', isDarkMode);
document.body.classList.toggle('dark-mode', isDarkMode);
if (themeToggleHamburger) {
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
});
document.getElementById('logoutButton')?.addEventListener('click', async () => {
try { await logoutAll(); } catch (error) { logLine(`Logout failed: ${error.message}`, 'error'); }
});
await initNDKPage();
state.pubkey = await getPubkey();
await injectHeaderAvatar(state.pubkey);
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await updateFooter();
state.updateIntervalId = setInterval(updateFooter, 1000);
wireButtons();
updateStats();
renderRelayJsonPreview();
await updateVersionDisplay();
logLine('Keep-alive ready. Start with: Find My Events');
} catch (error) {
console.error('[keep-alive] Initialization failed:', error);
divBody.innerHTML = `<div style="padding:20px;color:red;">Initialization failed: ${String(error.message || error)}</div>`;
}
})();
</script>
</body>
</html>