From 20b25e27db7d6d00b17d949a9fdfaf8bd3095594 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:59:52 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Nostr=20relay=20status=20panel=20?= =?UTF-8?q?=E2=80=94=20sovereign=20relay=20connections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a bottom-left HUD panel that monitors WebSocket connections to five sovereign Nostr relays in real time. Each relay shows a colored dot (green/amber/red) and status label (OK / … / OFF). Reconnects automatically every 30 s on disconnect. Panel is collapsible. Fixes #274 Co-Authored-By: Claude Sonnet 4.6 --- app.js | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 9 ++++ style.css | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 267 insertions(+) diff --git a/app.js b/app.js index e5ea2f0..5b257f9 100644 --- a/app.js +++ b/app.js @@ -1379,6 +1379,133 @@ window.addEventListener('pr-notification', (/** @type {CustomEvent} */ event) => } }); +// === NOSTR RELAY STATUS PANEL === +/** + * Sovereign Nostr relays to monitor. + * Each entry is a full WebSocket URL (wss://). + */ +const NOSTR_RELAYS = [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.nostr.band', + 'wss://nostr.wine', + 'wss://relay.snort.social', +]; + +/** + * @typedef {'connected'|'connecting'|'disconnected'} RelayStatus + * @type {Map} + */ +const relayStatusMap = new Map(NOSTR_RELAYS.map(url => [url, 'connecting'])); + +/** @type {Map} */ +const relaySockets = new Map(); + +const relayPanel = document.getElementById('relay-panel'); +const relayPanelBody = document.getElementById('relay-panel-body'); +const relayPanelToggle = document.getElementById('relay-panel-toggle'); +const relayPanelHeader = document.getElementById('relay-panel-header'); + +/** + * Returns the hostname portion of a relay URL for display. + * @param {string} url + * @returns {string} + */ +function relayDisplayName(url) { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +/** Re-renders the relay panel body from current relayStatusMap state. */ +function renderRelayPanel() { + if (!relayPanelBody) return; + relayPanelBody.innerHTML = ''; + for (const [url, status] of relayStatusMap) { + const row = document.createElement('div'); + row.className = 'relay-row'; + + const dot = document.createElement('span'); + dot.className = `relay-dot ${status}`; + + const host = document.createElement('span'); + host.className = 'relay-host'; + host.textContent = relayDisplayName(url); + host.title = url; + + const label = document.createElement('span'); + label.className = `relay-status-label ${status}`; + label.textContent = status === 'connected' ? 'OK' : status === 'connecting' ? '…' : 'OFF'; + + row.appendChild(dot); + row.appendChild(host); + row.appendChild(label); + relayPanelBody.appendChild(row); + } +} + +/** + * Opens (or reopens) a WebSocket connection to a relay to probe its status. + * On open → status = connected; on close/error → status = disconnected. + * Reconnect attempt scheduled after 30 s. + * @param {string} url + */ +function connectRelay(url) { + const existing = relaySockets.get(url); + if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) { + return; + } + + relayStatusMap.set(url, 'connecting'); + renderRelayPanel(); + + let ws; + try { + ws = new WebSocket(url); + } catch { + relayStatusMap.set(url, 'disconnected'); + renderRelayPanel(); + setTimeout(() => connectRelay(url), 30000); + return; + } + + relaySockets.set(url, ws); + + ws.addEventListener('open', () => { + relayStatusMap.set(url, 'connected'); + renderRelayPanel(); + }); + + ws.addEventListener('close', () => { + relayStatusMap.set(url, 'disconnected'); + renderRelayPanel(); + // Schedule reconnect + setTimeout(() => connectRelay(url), 30000); + }); + + ws.addEventListener('error', () => { + // close event will follow and handle the state + }); +} + +// Toggle collapse/expand +if (relayPanelHeader) { + relayPanelHeader.addEventListener('click', () => { + relayPanel && relayPanel.classList.toggle('collapsed'); + if (relayPanelToggle) { + relayPanelToggle.textContent = relayPanel && relayPanel.classList.contains('collapsed') ? '▶' : '▼'; + } + }); +} + +// Initial render and connect +renderRelayPanel(); +for (const url of NOSTR_RELAYS) { + connectRelay(url); +} + // === SOVEREIGNTY EASTER EGG === const SOVEREIGNTY_WORD = 'sovereignty'; let sovereigntyBuffer = ''; diff --git a/index.html b/index.html index a5715f5..c704882 100644 --- a/index.html +++ b/index.html @@ -56,6 +56,15 @@ [Esc] or double-click to exit + +
+
+ ⚡ NOSTR RELAYS + +
+
+
+