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

1464 lines
51 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>RELAYS</title>
<link rel="stylesheet" href="./css/client.css" />
<!-- Initialize theme BEFORE any components load -->
<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" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<style media="screen">
/* ================================================================
PAGE-SPECIFIC STYLES
================================================================ */
#divBody {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
height: 90%;
width: 100%;
font-family: var(--font-family);
padding: 20px;
overflow-y: auto;
}
#divRelays {
padding-bottom: 0;
font-size: 200%;
width: 100%;
}
#tblRelays {
margin-top: 20px;
width: 95%;
font-size: 70%;
border-collapse: collapse;
margin-left: auto;
margin-right: auto;
}
.tblRelayRow:hover {
cursor: pointer;
background-color: var(--muted-color);
}
.tblConnected {
border: 0.1px solid var(--muted-color);
}
.tblDisconnected {
color: var(--muted-color);
border: 0.1px solid var(--muted-color);
}
td {
font-family: var(--font-family);
font-size: 100%;
border: 0.1px solid var(--muted-color);
}
th {
font-family: var(--font-family);
font-size: 100%;
border: 0.1px solid var(--muted-color);
background-color: var(--secondary-color);
}
.tblCol {
padding-left: 5px;
padding-right: 5px;
}
.tblColLeft {
text-align: left;
}
.tblColCenter {
text-align: center;
}
.tblColRight {
text-align: right;
}
.tblColRelay {
max-width: 30ch;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.svgBtn {
fill: none;
stroke: var(--button-color);
stroke-width: 1.98;
stroke-linecap: round;
stroke-linejoin: round;
transition: 0.2s;
height: 1.2em;
}
.svgBtn_unsel {
stroke: var(--muted-color);
}
.svgBtn_sel {
stroke: var(--button-color);
}
.divSvg:hover {
stroke: var(--button-hover-color);
cursor: pointer;
}
.divSvg:hover path {
stroke: var(--button-hover-color);
cursor: pointer;
}
.tblRowConnected {
font-weight: 900;
}
.tblRowSelected {
outline: 2px solid var(--accent-color);
outline-offset: -2px;
background-color: var(--secondary-color);
}
.tblRelayAddRow {
background-color: var(--secondary-color);
}
.relayInput {
width: 100%;
box-sizing: border-box;
background-color: var(--background-color);
color: var(--primary-color);
border: 1px solid var(--muted-color);
border-radius: 4px;
padding: 4px 6px;
font-family: var(--font-family);
font-size: 90%;
}
.relayInput:focus {
outline: none;
border-color: var(--accent-color);
}
#divRelayEventsWrap {
width: 95%;
margin: 6px auto 0 auto;
flex: 1;
min-height: 280px;
display: flex;
}
.relay-events-stream {
width: 100%;
height: 100%;
overflow-y: auto;
border: 1px solid var(--muted-color);
background-color: var(--secondary-color);
padding: 8px;
box-sizing: border-box;
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 6px;
}
.relay-event-row {
display: flex;
width: 100%;
}
.relay-event-row.client {
justify-content: flex-start;
}
.relay-event-row.relay {
justify-content: flex-end;
}
.relay-event-bubble {
max-width: 75%;
font-family: monospace;
font-size: 12px;
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
border: 1px solid var(--muted-color);
border-radius: 8px;
padding: 6px 8px;
background-color: var(--background-color);
color: var(--primary-color);
}
.relay-event-row.client .relay-event-bubble {
border-left: 3px solid var(--accent-color);
}
.relay-event-row.relay .relay-event-bubble {
border-right: 3px solid var(--button-color);
}
</style>
</head>
<body>
<!-- ================================================================
HAMBURGER BUTTON (Fixed, separate from header)
================================================================ -->
<div id="divSvgHam" class="divHeaderButtons">
<!-- HamburgerMorphing will be injected here -->
</div>
<!-- ================================================================
HEADER
================================================================ -->
<div id="divHeader">
<div id="divHeaderFlexLeft">
<!-- Hamburger is now separate fixed element -->
</div>
<div id="divHeaderFlexCenter">
</div>
<div id="divHeaderFlexRight">
<!-- No button in header right - logout is in sidenav footer -->
</div>
</div>
<!-- ================================================================
BODY
================================================================ -->
<div id="divBody" class="clsBodyFull">
<div id="divRelays">Loading relays...</div>
<div id="divRelayEventsWrap">
<div id="divRelayEvents" class="relay-events-stream"></div>
</div>
</div>
<!-- ================================================================
FOOTER
================================================================ -->
<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>
<!-- ================================================================
SIDENAV
================================================================ -->
<div id="divSideNav">
<div id="divSideNavHeader">
<!-- No close button - use main hamburger to close -->
</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>
<!-- ================================================================
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script type="module">
/* ================================================================
IMPORTS
================================================================ */
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getRelayData, getRelayStats, reconnectRelay, publishEvent, getVersion, updateVersionDisplay, ndkFetchEvents } from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[relays.html ${VERSION}] Loading...`);
/* ================================================================
SVG ICONS
================================================================ */
const SVG_UNCHECKED = `<svg class="svgBtn svgBtn_unsel" viewBox="0 0 10 10"><rect x="2" y="2" width="6" height="6" /></svg>`;
const SVG_CHECKED = `<svg class="svgBtn svgBtn_sel" viewBox="0 0 10 10"><path d="M2,6 L4,8 L8,2 " /></svg>`;
const SVG_CONNECTING = `<svg class="svgBtn" style="stroke: var(--muted-color); opacity: 0.5;" viewBox="0 0 10 10"><path d="M2,6 L4,8 L8,2 " /></svg>`;
const SVG_X_UNCHECKED = `<svg class="svgBtn svgBtn_unsel" viewBox="0 0 10 10"><path d="M2,2 L8,8 M8,2 L2,8" /></svg>`;
const SVG_X_PENDING = `<svg class="svgBtn svgBtn_sel" viewBox="0 0 10 10"><path d="M2,2 L8,8 M8,2 L2,8" /></svg>`;
/* ================================================================
GLOBAL VARIABLES
================================================================ */
let updateIntervalId = null;
let currentPubkey = null;
let reconnectingRelays = new Set(); // Track which relays are reconnecting
let currentRelayList = []; // Store current relay list with types
let pendingRemovalRelayUrl = null; // Two-click confirmation for relay deletion
let addRelayCanRead = true;
let addRelayCanWrite = true;
let addRelayDraftValue = ''; // Preserve typed add-relay input across table refreshes
let dmInboxRelays = new Set(); // normalized relay URLs included in kind 10050 DM inbox list
let addRelayDmInbox = false;
let selectedRelayUrl = null; // Relay row currently selected for debug event history
const relayEventHistory = new Map(); // relay.url -> [{ timestamp, rawTimestamp, eventType, side, summary }]
const relayUiStats = new Map(); // relay.url -> { readCount, writeCount }
const RELAY_EVENTS_DB_NAME = 'relay-events';
const RELAY_EVENTS_DB_VERSION = 1;
const RELAY_EVENTS_STORE = 'events';
// Hamburger menu
let hamburgerInstance = null;
let isNavOpen = false;
// Version bar buttons
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
/* ================================================================
DOM VARIABLES
================================================================ */
const divBody = document.getElementById("divBody");
const divSideNav = document.getElementById("divSideNav");
const divSideNavBody = document.getElementById("divSideNavBody");
const divRelays = document.getElementById("divRelays");
const divFooterCenter = document.getElementById("divFooterCenter");
const divFooterRight = document.getElementById("divFooterRight");
const divRelayEvents = document.getElementById("divRelayEvents");
/* ================================================================
HAMBURGER MENU
================================================================ */
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
/* ================================================================
SIDENAV FUNCTIONS
================================================================ */
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
isNavOpen = true;
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
divSideNavBody.innerHTML = "Relay management options coming soon...";
// Initialize version bar buttons when sidenav opens (lazy load)
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)'
});
// Determine current theme
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
const initialShape = isDarkMode ? 'moon' : 'circle';
themeToggleHamburger.animateTo(initialShape);
}
}
function closeNav() {
divSideNav.style.width = "0vw";
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) {
hamburgerInstance.animateTo('burger');
}
}
function toggleNav() {
if (isNavOpen) {
closeNav();
} else {
openNav();
}
}
/* ================================================================
RELAY TABLE FUNCTIONS
================================================================ */
const getStatusText = (status) => {
// NDK relay statuses:
// 0 = DISCONNECTED, 1 = DISCONNECTING, 2 = RECONNECTING
// 3 = FLAPPING, 4 = CONNECTING, 5 = CONNECTED
// 6 = AUTH_REQUIRED, 7 = AUTHENTICATING, 8 = AUTHENTICATED
const statusMap = {
0: 'Disconnected',
1: 'Disconnecting',
2: 'Reconnecting',
3: 'Flapping',
4: 'Connecting',
5: 'Connected',
6: 'Auth Required',
7: 'Authenticating',
8: 'Authenticated'
};
return statusMap[status] || 'Unknown';
};
const formatConnectionTime = (timestamp) => {
if (!timestamp || timestamp === 0) return '-';
const now = Math.floor(Date.now() / 1000);
const elapsed = now - timestamp;
if (elapsed < 0 || elapsed > 31536000) return '-'; // More than 1 year
if (elapsed < 60) return `${elapsed}s`;
if (elapsed < 3600) return `${Math.floor(elapsed / 60)}m`;
if (elapsed < 86400) return `${Math.floor(elapsed / 3600)}h`;
return `${Math.floor(elapsed / 86400)}d`;
};
const normalizeRelayUrlInput = (input) => {
const raw = (input || '').trim();
if (!raw) return null;
let candidate = raw;
if (!/^wss?:\/\//i.test(candidate)) {
candidate = `wss://${candidate}`;
}
try {
const parsed = new URL(candidate);
if (!['ws:', 'wss:'].includes(parsed.protocol)) return null;
const host = parsed.host.toLowerCase();
if (!host) return null;
const path = parsed.pathname && parsed.pathname !== '/'
? parsed.pathname.replace(/\/+$/, '')
: '';
return `wss://${host}${path}`;
} catch (error) {
return null;
}
};
const relayExists = (relayUrl) => {
return currentRelayList.some(relay => relay.url === relayUrl);
};
const getAddRelayType = () => {
if (addRelayCanRead && addRelayCanWrite) return 'both';
if (addRelayCanRead) return 'read';
if (addRelayCanWrite) return 'write';
return 'both';
};
const isDmInboxRelayEnabled = (relayUrl) => {
return dmInboxRelays.has(normalizeRelayUrlForDb(relayUrl));
};
const getDmInboxRelayUrlsForPublish = () => {
const selected = [];
for (const relay of currentRelayList) {
if (isDmInboxRelayEnabled(relay.url)) {
selected.push(relay.url);
}
}
return selected;
};
const createRelayTable = async (relays, options = {}) => {
const { preserveAddRelayDraft = true } = options;
const safeRelays = Array.isArray(relays) ? relays : [];
// Preserve add-relay input state before table re-render (unless explicitly disabled)
const previousInput = document.getElementById('txtNewRelayUrl');
const wasAddInputFocused = preserveAddRelayDraft && document.activeElement === previousInput;
const preservedSelectionStart = preserveAddRelayDraft ? (previousInput?.selectionStart ?? null) : null;
const preservedSelectionEnd = preserveAddRelayDraft ? (previousInput?.selectionEnd ?? null) : null;
if (preserveAddRelayDraft && previousInput) {
addRelayDraftValue = previousInput.value;
}
// Store current relay list for publishing
currentRelayList = safeRelays;
// Get relay stats for read/write counts
const relayStats = await getRelayStats();
const getMergedStats = (relayUrl) => {
const workerStats = relayStats?.[relayUrl] || { readCount: 0, writeCount: 0 };
const localStats = relayUiStats.get(relayUrl) || { readCount: 0, writeCount: 0 };
return {
readCount: Math.max(workerStats.readCount || 0, localStats.readCount || 0),
writeCount: Math.max(workerStats.writeCount || 0, localStats.writeCount || 0)
};
};
let html = '<table id="tblRelays">';
// Header
html += '<thead><tr>';
html += '<th class="tblCol tblColCenter"></th>';
html += '<th class="tblCol tblColLeft">Relay</th>';
html += '<th class="tblCol tblColCenter">Connected</th>';
html += '<th class="tblCol tblColCenter">Read</th>';
html += '<th class="tblCol tblColCenter">Write</th>';
html += '<th class="tblCol tblColCenter">DM Inbox</th>';
html += '<th class="tblCol tblColRight">Reads</th>';
html += '<th class="tblCol tblColRight">Writes</th>';
html += '<th class="tblCol tblColRight">Connection Time</th>';
html += '</tr></thead>';
// Body
html += '<tbody>';
if ((!selectedRelayUrl || !safeRelays.some(r => normalizeRelayUrlForDb(r.url) === normalizeRelayUrlForDb(selectedRelayUrl))) && safeRelays.length > 0) {
selectedRelayUrl = normalizeRelayUrlForDb(safeRelays[0].url);
}
for (let i = 0; i < safeRelays.length; i++) {
const relay = safeRelays[i];
const isConnected = relay.status >= 5; // 5+ = CONNECTED (includes auth states 6/7/8)
const isReconnecting = reconnectingRelays.has(relay.url);
const relayNormalizedUrl = normalizeRelayUrlForDb(relay.url);
const isSelected = relayNormalizedUrl === normalizeRelayUrlForDb(selectedRelayUrl);
const rowClass = [
'tblRelayRow',
isConnected ? 'tblRowConnected' : '',
isSelected ? 'tblRowSelected' : ''
].filter(Boolean).join(' ');
const stats = getMergedStats(relay.url);
const relayType = relay.type || 'both';
const removeIcon = pendingRemovalRelayUrl === relay.url ? SVG_X_PENDING : SVG_X_UNCHECKED;
// Determine which icon to show for connection status
let statusIcon;
if (isReconnecting) {
statusIcon = SVG_CONNECTING;
} else if (isConnected) {
statusIcon = SVG_CHECKED;
} else {
statusIcon = SVG_UNCHECKED;
}
// Determine read/write checkboxes
const canRead = relayType === 'read' || relayType === 'both';
const canWrite = relayType === 'write' || relayType === 'both';
const readCheckbox = canRead ? SVG_CHECKED : SVG_UNCHECKED;
const writeCheckbox = canWrite ? SVG_CHECKED : SVG_UNCHECKED;
const dmInboxCheckbox = isDmInboxRelayEnabled(relay.url) ? SVG_CHECKED : SVG_UNCHECKED;
html += `<tr class="${rowClass}">`;
html += `<td class="tblCol tblColCenter divSvg" data-remove-relay-url="${relay.url}" style="cursor: pointer;">${removeIcon}</td>`;
html += `<td class="tblCol tblColLeft tblColRelay" data-select-relay-url="${relayNormalizedUrl}" title="Click to show debug history for ${relay.url}" style="cursor: pointer;">${relay.url}</td>`;
html += `<td class="tblCol tblColCenter divSvg" data-relay-url="${relay.url}" style="cursor: pointer;">${statusIcon}</td>`;
html += `<td class="tblCol tblColCenter divSvg" data-relay-index="${i}" data-toggle-type="read" style="cursor: pointer;">${readCheckbox}</td>`;
html += `<td class="tblCol tblColCenter divSvg" data-relay-index="${i}" data-toggle-type="write" style="cursor: pointer;">${writeCheckbox}</td>`;
html += `<td class="tblCol tblColCenter divSvg" data-relay-index="${i}" data-toggle-type="dm-inbox" style="cursor: pointer;">${dmInboxCheckbox}</td>`;
html += `<td class="tblCol tblColRight">${stats.readCount}</td>`;
html += `<td class="tblCol tblColRight">${stats.writeCount}</td>`;
html += `<td class="tblCol tblColRight">${formatConnectionTime(relay.connectionTime)}</td>`;
html += '</tr>';
}
const addReadCheckbox = addRelayCanRead ? SVG_CHECKED : SVG_UNCHECKED;
const addWriteCheckbox = addRelayCanWrite ? SVG_CHECKED : SVG_UNCHECKED;
const addDmInboxCheckbox = addRelayDmInbox ? SVG_CHECKED : SVG_UNCHECKED;
html += '<tr class="tblRelayAddRow">';
html += '<td class="tblCol tblColCenter">-</td>';
html += '<td class="tblCol tblColLeft"><input id="txtNewRelayUrl" class="relayInput" type="text" placeholder="Add relay (example: relay.damus.io or wss://relay.damus.io)" title="Press Enter to add relay" /></td>';
html += '<td class="tblCol tblColCenter">-</td>';
html += `<td class="tblCol tblColCenter divSvg" data-add-toggle-type="read" style="cursor: pointer;">${addReadCheckbox}</td>`;
html += `<td class="tblCol tblColCenter divSvg" data-add-toggle-type="write" style="cursor: pointer;">${addWriteCheckbox}</td>`;
html += `<td class="tblCol tblColCenter divSvg" data-add-toggle-type="dm-inbox" style="cursor: pointer;">${addDmInboxCheckbox}</td>`;
html += '<td class="tblCol tblColRight">-</td>';
html += '<td class="tblCol tblColRight">-</td>';
html += '<td class="tblCol tblColRight">-</td>';
html += '</tr>';
html += '</tbody></table>';
divRelays.innerHTML = html;
// Add click handlers to remove icons
document.querySelectorAll('.divSvg[data-remove-relay-url]').forEach(cell => {
cell.addEventListener('click', async () => {
const relayUrl = cell.getAttribute('data-remove-relay-url');
await handleRelayRemoveClick(relayUrl);
});
});
// Add click handlers to connection status icons
document.querySelectorAll('.divSvg[data-relay-url]').forEach(cell => {
cell.addEventListener('click', () => {
const relayUrl = cell.getAttribute('data-relay-url');
handleRelayReconnect(relayUrl);
});
});
// Add click handlers to read/write toggle checkboxes
document.querySelectorAll('.divSvg[data-relay-index]').forEach(cell => {
cell.addEventListener('click', () => {
const relayIndex = parseInt(cell.getAttribute('data-relay-index'));
const toggleType = cell.getAttribute('data-toggle-type');
if (toggleType === 'dm-inbox') {
handleRelayDmInboxToggle(relayIndex);
return;
}
handleRelayTypeToggle(relayIndex, toggleType);
});
});
// Add click handlers to relay URL cells (select relay for debug event history)
document.querySelectorAll('[data-select-relay-url]').forEach(cell => {
cell.addEventListener('click', () => {
const relayUrl = cell.getAttribute('data-select-relay-url');
handleRelayRowSelection(relayUrl);
});
});
// Add click handlers to add-row read/write toggle checkboxes
document.querySelectorAll('.divSvg[data-add-toggle-type]').forEach(cell => {
cell.addEventListener('click', async () => {
const toggleType = cell.getAttribute('data-add-toggle-type');
if (toggleType === 'dm-inbox') {
await handleAddRelayDmInboxToggle();
return;
}
await handleAddRelayTypeToggle(toggleType);
});
});
// Add handlers for relay input
const txtNewRelayUrl = document.getElementById('txtNewRelayUrl');
if (txtNewRelayUrl) {
// Restore draft value that may have been typed before auto-refresh
txtNewRelayUrl.value = addRelayDraftValue;
// Keep draft in sync while typing
txtNewRelayUrl.addEventListener('input', () => {
addRelayDraftValue = txtNewRelayUrl.value;
});
txtNewRelayUrl.addEventListener('keydown', async (event) => {
if (event.key === 'Enter') {
event.preventDefault();
await handleAddRelaySubmit(txtNewRelayUrl.value);
}
});
// Restore focus/caret when refresh happened during typing
if (wasAddInputFocused) {
txtNewRelayUrl.focus();
if (preservedSelectionStart !== null && preservedSelectionEnd !== null) {
txtNewRelayUrl.setSelectionRange(preservedSelectionStart, preservedSelectionEnd);
}
}
}
// Update footer stats
const connectedCount = safeRelays.filter(r => r.status >= 5).length;
divFooterRight.innerHTML = `Total: ${safeRelays.length} | Connected: ${connectedCount}`;
renderRelayEventsForSelectedRelay();
};
const shortHex = (value) => {
const str = String(value || '');
if (!str) return '-';
if (str.length <= 16) return str;
return `${str.slice(0, 8)}${str.slice(-6)}`;
};
const extractEventObject = (payload) => {
if (!payload) return null;
if (typeof payload === 'string') {
try {
return extractEventObject(JSON.parse(payload));
} catch (_error) {
return null;
}
}
if (Array.isArray(payload)) {
for (const item of payload) {
const evt = extractEventObject(item);
if (evt) return evt;
}
return null;
}
if (typeof payload === 'object' && payload.kind !== undefined && payload.id && payload.pubkey) {
return payload;
}
return null;
};
const summarizePayload = (payload) => {
const evt = extractEventObject(payload);
if (evt) {
const pTags = Array.isArray(evt.tags)
? evt.tags.filter(t => Array.isArray(t) && t[0] === 'p').map(t => t[1]).filter(Boolean)
: [];
const pubkeys = [evt.pubkey, ...pTags].map(shortHex).join('|');
return `kind:${evt.kind} pubkeys:${pubkeys} id:${shortHex(evt.id)}`;
}
if (payload === undefined || payload === null || payload === '') return '';
const raw = typeof payload === 'string' ? payload : JSON.stringify(payload);
return raw.length > 220 ? `${raw.slice(0, 220)}…` : raw;
};
const normalizeRelayUrlForDb = (relayUrl) => {
if (!relayUrl) return null;
try {
return new URL(relayUrl).toString();
} catch (_error) {
return String(relayUrl);
}
};
const openRelayEventsDb = async () => {
return await new Promise((resolve, reject) => {
const req = indexedDB.open(RELAY_EVENTS_DB_NAME, RELAY_EVENTS_DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
let store;
if (!db.objectStoreNames.contains(RELAY_EVENTS_STORE)) {
store = db.createObjectStore(RELAY_EVENTS_STORE, { keyPath: 'id', autoIncrement: true });
} else {
store = req.transaction.objectStore(RELAY_EVENTS_STORE);
}
if (!store.indexNames.contains('relayUrl')) {
store.createIndex('relayUrl', 'relayUrl', { unique: false });
}
if (!store.indexNames.contains('timestamp')) {
store.createIndex('timestamp', 'timestamp', { unique: false });
}
if (!store.indexNames.contains('relayUrl_timestamp')) {
store.createIndex('relayUrl_timestamp', ['relayUrl', 'timestamp'], { unique: false });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
};
const parseStoredRelayEventData = (value) => {
if (value === null || value === undefined) return value;
if (typeof value !== 'string') return value;
const trimmed = value.trim();
if (!trimmed) return '';
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
return JSON.parse(trimmed);
} catch (_error) {
return value;
}
}
return value;
};
const formatRelayEventTimestamp = (input) => {
const ms = typeof input === 'number' ? input : Number(input);
const time = Number.isFinite(ms) ? ms : Date.now();
return new Date(time).toISOString().split('T')[1].slice(0, -1);
};
const loadRelayEventsFromDb = async (relayUrl) => {
const normalizedUrl = normalizeRelayUrlForDb(relayUrl);
if (!normalizedUrl) return [];
try {
const db = await openRelayEventsDb();
const rows = await new Promise((resolve, reject) => {
const tx = db.transaction(RELAY_EVENTS_STORE, 'readonly');
const store = tx.objectStore(RELAY_EVENTS_STORE);
if (!store.indexNames.contains('relayUrl')) {
resolve([]);
return;
}
const idx = store.index('relayUrl');
const req = idx.getAll(IDBKeyRange.only(normalizedUrl));
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
db.close();
rows.sort((a, b) => Number(a?.timestamp || 0) - Number(b?.timestamp || 0));
const localCounts = { readCount: 0, writeCount: 0 };
const mapped = rows.map((row) => {
const eventType = row?.eventType || 'message';
const side = row?.side === 'client' ? 'client' : 'relay';
const parsedData = parseStoredRelayEventData(row?.data);
const summary = summarizePayload(parsedData);
if (eventType === 'send') localCounts.writeCount += 1;
if (eventType === 'recv') localCounts.readCount += 1;
return {
timestamp: formatRelayEventTimestamp(row?.timestamp),
rawTimestamp: Number(row?.timestamp || Date.now()),
eventType,
side,
summary
};
});
const existingCounts = relayUiStats.get(normalizedUrl) || { readCount: 0, writeCount: 0 };
relayUiStats.set(normalizedUrl, {
readCount: Math.max(existingCounts.readCount || 0, localCounts.readCount),
writeCount: Math.max(existingCounts.writeCount || 0, localCounts.writeCount)
});
relayEventHistory.set(normalizedUrl, mapped);
return mapped;
} catch (error) {
console.warn('[relays.html] Failed loading relay events from IndexedDB:', error?.message || error);
return relayEventHistory.get(normalizedUrl) || [];
}
};
const renderRelayEventsForSelectedRelay = () => {
if (!divRelayEvents) return;
if (!selectedRelayUrl) {
divRelayEvents.innerHTML = `<div class="relay-event-row relay"><div class="relay-event-bubble">Select a relay row to inspect its event history.</div></div>`;
return;
}
const events = relayEventHistory.get(selectedRelayUrl) || [];
if (events.length === 0) {
divRelayEvents.innerHTML = `<div class="relay-event-row relay"><div class="relay-event-bubble">[No relay events captured yet for ${selectedRelayUrl}]</div></div>`;
return;
}
// Show newest at bottom (chat-like)
divRelayEvents.innerHTML = events.map((entry) => {
const sideClass = entry.side === 'client' ? 'client' : 'relay';
const label = entry.side === 'client' ? 'CLIENT ➜ RELAY' : 'RELAY ➜ CLIENT';
const dataPart = entry.summary ? `\n${entry.summary}` : '';
const text = `[${entry.timestamp}] ${label} ${entry.eventType.toUpperCase()}${dataPart}`;
return `<div class="relay-event-row ${sideClass}"><div class="relay-event-bubble">${text}</div></div>`;
}).join('');
divRelayEvents.scrollTop = divRelayEvents.scrollHeight;
};
const handleRelayRowSelection = async (relayUrl) => {
if (!relayUrl) return;
selectedRelayUrl = normalizeRelayUrlForDb(relayUrl);
await createRelayTable(currentRelayList);
await loadRelayEventsFromDb(selectedRelayUrl);
renderRelayEventsForSelectedRelay();
};
const handleRelayTypeToggle = async (relayIndex, toggleType) => {
if (relayIndex < 0 || relayIndex >= currentRelayList.length) return;
const relay = currentRelayList[relayIndex];
const currentType = relay.type || 'both';
console.log(`[relays.html] Toggling ${toggleType} for ${relay.url}, current type: ${currentType}`);
// Determine new type based on toggle
let newType = currentType;
if (toggleType === 'read') {
if (currentType === 'both') {
newType = 'write'; // Remove read, keep write
} else if (currentType === 'write') {
newType = 'both'; // Add read to write
} else if (currentType === 'read') {
newType = ''; // Remove read (relay becomes inactive)
} else {
newType = 'read'; // Add read
}
} else if (toggleType === 'write') {
if (currentType === 'both') {
newType = 'read'; // Remove write, keep read
} else if (currentType === 'read') {
newType = 'both'; // Add write to read
} else if (currentType === 'write') {
newType = ''; // Remove write (relay becomes inactive)
} else {
newType = 'write'; // Add write
}
}
// Don't allow empty type - must have at least read or write
if (!newType) {
console.warn('[relays.html] Cannot remove both read and write from relay');
return;
}
// Update local relay list
relay.type = newType;
// Refresh table to show updated checkboxes
await createRelayTable(currentRelayList);
// Publish updated relay list to Nostr
await publishRelayList();
};
const handleRelayDmInboxToggle = async (relayIndex) => {
if (relayIndex < 0 || relayIndex >= currentRelayList.length) return;
const relay = currentRelayList[relayIndex];
const normalizedRelayUrl = normalizeRelayUrlForDb(relay.url);
if (dmInboxRelays.has(normalizedRelayUrl)) {
dmInboxRelays.delete(normalizedRelayUrl);
} else {
dmInboxRelays.add(normalizedRelayUrl);
}
await createRelayTable(currentRelayList);
await publishDmInboxRelayList();
};
const handleRelayRemoveClick = async (relayUrl) => {
if (!relayUrl) return;
if (pendingRemovalRelayUrl === relayUrl) {
currentRelayList = currentRelayList.filter(relay => relay.url !== relayUrl);
dmInboxRelays.delete(normalizeRelayUrlForDb(relayUrl));
pendingRemovalRelayUrl = null;
await createRelayTable(currentRelayList);
if (normalizeRelayUrlForDb(selectedRelayUrl) === normalizeRelayUrlForDb(relayUrl)) {
selectedRelayUrl = currentRelayList[0] ? normalizeRelayUrlForDb(currentRelayList[0].url) : null;
}
renderRelayEventsForSelectedRelay();
await publishRelayList();
await publishDmInboxRelayList();
return;
}
pendingRemovalRelayUrl = relayUrl;
await createRelayTable(currentRelayList);
};
const handleAddRelayDmInboxToggle = async () => {
addRelayDmInbox = !addRelayDmInbox;
await createRelayTable(currentRelayList);
};
const handleAddRelayTypeToggle = async (toggleType) => {
if (toggleType === 'read') {
if (!addRelayCanRead && !addRelayCanWrite) {
addRelayCanRead = true;
} else {
addRelayCanRead = !addRelayCanRead;
}
} else if (toggleType === 'write') {
if (!addRelayCanRead && !addRelayCanWrite) {
addRelayCanWrite = true;
} else {
addRelayCanWrite = !addRelayCanWrite;
}
}
if (!addRelayCanRead && !addRelayCanWrite) {
// At least one of read/write must remain enabled
if (toggleType === 'read') {
addRelayCanRead = true;
} else {
addRelayCanWrite = true;
}
}
await createRelayTable(currentRelayList);
};
const handleAddRelaySubmit = async (inputValue) => {
const normalizedRelayUrl = normalizeRelayUrlInput(inputValue);
if (!normalizedRelayUrl) {
divFooterCenter.innerHTML = '❌ Invalid relay URL';
return;
}
if (relayExists(normalizedRelayUrl)) {
divFooterCenter.innerHTML = '⚠️ Relay already exists in your kind 10002 list';
return;
}
const newType = getAddRelayType();
currentRelayList.push({
url: normalizedRelayUrl,
status: 0,
connectionTime: 0,
type: newType
});
pendingRemovalRelayUrl = null;
if (addRelayDmInbox) {
dmInboxRelays.add(normalizeRelayUrlForDb(normalizedRelayUrl));
}
addRelayDraftValue = '';
await createRelayTable(currentRelayList, { preserveAddRelayDraft: false });
await publishRelayList();
await publishDmInboxRelayList();
};
const publishRelayList = async () => {
if (!currentPubkey) {
console.error('[relays.html] No pubkey available');
return;
}
try {
// Build kind 10002 event
const event = {
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: ''
};
// Add relay tags with read/write markers
for (const relay of currentRelayList) {
const tag = ['r', relay.url];
const relayType = relay.type || 'both';
if (relayType !== 'both') {
tag.push(relayType); // Add 'read' or 'write' marker
}
// If 'both', don't add third element (default behavior)
event.tags.push(tag);
}
console.log('[relays.html] Publishing relay list:', event);
const result = await publishEvent(event);
console.log('[relays.html] Relay list published to:', result.relayResults.successful);
// Show success message in footer
divFooterCenter.innerHTML = `✅ Published to ${result.relayResults.successful.length} relays`;
setTimeout(() => {
if (currentPubkey) {
divFooterCenter.innerHTML = `${currentPubkey.slice(0, 8)}...${currentPubkey.slice(-8)}`;
}
}, 3000);
} catch (error) {
console.error('[relays.html] Failed to publish relay list:', error);
divFooterCenter.innerHTML = `❌ Publish failed: ${error.message}`;
}
};
const loadCurrentDmInboxRelayList = async () => {
if (!currentPubkey) return;
try {
const events = await ndkFetchEvents({
kinds: [10050],
authors: [currentPubkey],
limit: 20
});
const list = Array.isArray(events) ? events : [];
if (list.length === 0) {
dmInboxRelays = new Set();
return;
}
list.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
const latest = list[0];
const nextSet = new Set();
for (const tag of (latest.tags || [])) {
if (Array.isArray(tag) && tag[0] === 'relay' && tag[1]) {
nextSet.add(normalizeRelayUrlForDb(tag[1]));
}
}
dmInboxRelays = nextSet;
} catch (error) {
console.warn('[relays.html] Failed to load kind 10050 DM inbox relays:', error?.message || error);
}
};
const publishDmInboxRelayList = async () => {
if (!currentPubkey) {
console.error('[relays.html] No pubkey available for kind 10050 publish');
return;
}
try {
const dmRelayUrls = getDmInboxRelayUrlsForPublish();
const event = {
kind: 10050,
created_at: Math.floor(Date.now() / 1000),
tags: dmRelayUrls.map((relayUrl) => ['relay', relayUrl]),
content: ''
};
await publishEvent(event);
} catch (error) {
console.error('[relays.html] Failed to publish DM inbox relay list:', error);
}
};
const handleRelayReconnect = (relayUrl) => {
console.log('[relays.html] Reconnecting relay:', relayUrl);
// Add to reconnecting set
reconnectingRelays.add(relayUrl);
// Trigger reconnect
reconnectRelay(relayUrl);
// Refresh table to show connecting state
refreshRelayData();
// Remove from reconnecting set after 5 seconds (timeout)
setTimeout(() => {
reconnectingRelays.delete(relayUrl);
refreshRelayData();
}, 5000);
};
/* ================================================================
RELAY EVENT LOGGING
================================================================ */
const logRelayEvent = (relayUrl, eventType, data, eventTimestamp = Date.now(), eventSide = undefined) => {
if (!relayUrl || !eventType) return;
const normalizedRelayUrl = normalizeRelayUrlForDb(relayUrl);
if (!normalizedRelayUrl) return;
const rawTimestamp = typeof eventTimestamp === 'number' ? eventTimestamp : Number(eventTimestamp || Date.now());
const timestamp = formatRelayEventTimestamp(rawTimestamp);
const side = eventSide === 'client' ? 'client' : (eventSide === 'relay' ? 'relay' : (eventType === 'send' ? 'client' : 'relay'));
const summary = summarizePayload(data);
const currentCounts = relayUiStats.get(normalizedRelayUrl) || { readCount: 0, writeCount: 0 };
if (eventType === 'send') {
currentCounts.writeCount += 1;
} else if (eventType === 'recv') {
currentCounts.readCount += 1;
}
relayUiStats.set(normalizedRelayUrl, currentCounts);
if (!relayEventHistory.has(normalizedRelayUrl)) {
relayEventHistory.set(normalizedRelayUrl, []);
}
const relayEvents = relayEventHistory.get(normalizedRelayUrl);
const nextEntry = {
timestamp,
rawTimestamp,
eventType,
side,
summary
};
const lastEntry = relayEvents[relayEvents.length - 1];
const isDuplicate = lastEntry
&& lastEntry.rawTimestamp === nextEntry.rawTimestamp
&& lastEntry.eventType === nextEntry.eventType
&& lastEntry.summary === nextEntry.summary;
if (!isDuplicate) {
relayEvents.push(nextEntry);
}
// Keep bounded per relay to avoid unbounded memory growth
if (relayEvents.length > 1000) {
relayEvents.splice(0, relayEvents.length - 1000);
}
if (!selectedRelayUrl) {
selectedRelayUrl = normalizedRelayUrl;
}
if (selectedRelayUrl === normalizedRelayUrl) {
renderRelayEventsForSelectedRelay();
}
};
const refreshRelayData = async () => {
console.log('[relay2.html] Refreshing relay data...');
try {
const relays = await getRelayData();
await createRelayTable(relays);
// Keep relay debug history stable between polls; live updates are appended via ndkRelayEvent.
} catch (error) {
console.error('[relay2.html] Error getting relay data:', error);
divRelays.innerHTML = '<div style="text-align: center; padding: 50px;">Error loading relay data</div>';
}
};
/* ================================================================
UPDATE FOOTER
================================================================ */
const UpdateFooter = async () => {
try {
// Update relay status visuals in footer and sidenav
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
// Update center section with pubkey
if (currentPubkey) {
divFooterCenter.innerHTML = `${currentPubkey.slice(0, 8)}...${currentPubkey.slice(-8)}`;
} else {
divFooterCenter.innerHTML = 'Not authenticated';
}
} catch (error) {
console.error('[relay2.html] Error updating footer:', error);
}
};
/* ================================================================
LOGOUT
================================================================ */
const Logout = async () => {
console.log("[relay2.html] Starting logout process...");
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
disconnect();
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
localStorage.clear();
sessionStorage.clear();
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) {
if (db.name) {
window.indexedDB.deleteDatabase(db.name);
}
}
}
console.log("[relay2.html] Logged out, reloading page");
location.reload(true);
};
/* ================================================================
EVENT LISTENERS
================================================================ */
/* ================================================================
INITIALIZATION
================================================================ */
(async function main() {
console.log("[relay2.html] Starting initialization...");
try {
// Initialize hamburger menu first
initHamburgerMenu();
// Add click handler to hamburger
const divSvgHam = document.getElementById('divSvgHam');
if (divSvgHam) {
divSvgHam.addEventListener('click', toggleNav);
}
// Initialize version bar buttons
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
if (themeToggleButton) {
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');
}
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
await Logout();
} catch (error) {
console.error('Logout failed:', error);
}
});
}
// Initialize NDK (handles authentication automatically)
await initNDKPage();
// Get authenticated pubkey
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
console.log("[relay2.html] Authenticated as:", currentPubkey);
// Initialize relay UI components
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
// Listen for relay events from worker (for debug logging)
// Note: We need to listen on the worker port, not window
// This will be set up via a custom event from init-ndk.mjs
window.addEventListener('ndkRelayEvent', (event) => {
const { relayUrl, event: eventType, data, timestamp, side } = event.detail;
logRelayEvent(relayUrl, eventType, data, timestamp, side);
});
// Listen for relay activity broadcasts from worker
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
console.log(`[relay2.html] Relay activity: ${relayUrl} - ${activity}`, stats);
setRelayActivityState(relayUrl, activity);
if (relayUrl) {
const currentCounts = relayUiStats.get(relayUrl) || { readCount: 0, writeCount: 0 };
relayUiStats.set(relayUrl, {
readCount: Math.max(currentCounts.readCount || 0, stats?.readCount || 0),
writeCount: Math.max(currentCounts.writeCount || 0, stats?.writeCount || 0)
});
}
});
// Listen for relay activity broadcasts from worker (alternative message format)
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'relayActivity') {
const { relayUrl, activity } = event.data;
console.log(`[relay2.html] Relay activity: ${relayUrl} - ${activity}`);
setRelayActivityState(relayUrl, activity);
}
});
await loadCurrentDmInboxRelayList();
// Get initial relay data
await refreshRelayData();
// Start update loops
updateIntervalId = setInterval(UpdateFooter, 1000);
// Update version display
await updateVersionDisplay();
// Refresh relay table every 5 seconds
setInterval(refreshRelayData, 5000);
console.log('[relay2.html] Initialization complete');
} catch (error) {
console.error('[relay2.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Authentication Error</div>
<div style="font-size: 16px; color: #666;">${error.message}</div>
<div style="margin-top: 20px;">
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
</div>
</div>`;
}
})();
</script>
</body>
</html>