Files
client/www/relays.html
T

1838 lines
67 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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" />
<link rel="stylesheet" href="./css/sidenav-sections.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="./js/vendor/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="divDiscoveredRelaysWrap" style="width:100%;margin:10px 0 0 0;overflow-x:auto;">
<div id="divDiscoveredRelaysTitle" style="font-size:120%;margin-bottom:6px;cursor:pointer;user-select:none;">▸ Discovered Relays (Outbox)</div>
<div id="divDiscoveredRelays" style="display:none;max-height:300px;overflow-y:auto;"></div>
</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-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
IMPORTS
================================================================ */
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getRelayData, getRelayStats, reconnectRelay, publishEvent, getVersion, updateVersionDisplay, ndkFetchEvents, setRelayEventLogging, getDiscoveredRelays } 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';
import {
loadBroadcastRelays,
addBroadcastRelay,
removeBroadcastRelay,
seedFromDiscovered,
unskipRelay,
onBroadcastRelaysChanged,
getActiveBroadcastRelays,
getSkippedBroadcastRelays,
} from './js/broadcast-relays.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 }
let connectionHistoryEnabled = false; // Toggled via sidenav; gates live relay event logging
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');
}
const historyEnabled = localStorage.getItem('relayConnectionHistory') === 'true';
connectionHistoryEnabled = historyEnabled;
divSideNavBody.innerHTML = `
<div id="divRelaySettings" style="display:flex;align-items:center;padding:4px 10px;cursor:pointer;font-size:80%;color:var(--primary-color);">
<span style="flex:1;">Show connection history</span>
<div id="divHistoryToggleCheckbox" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">${historyEnabled ? SVG_CHECKED : SVG_UNCHECKED}</div>
</div>
<div id="divBroadcastRelaysSettings" style="padding:4px 10px;font-size:80%;color:var(--primary-color);">
<div id="divBroadcastRelaysHeader" style="display:flex;align-items:center;cursor:pointer;">
<span style="flex:1;">Broadcast Relays
(<span id="spanBroadcastActive">0</span> active,
<span id="spanBroadcastSkipped">0</span> skipped)
</span>
<span id="divBroadcastToggleIcon" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">▸</span>
</div>
<div id="divBroadcastRelaysContent" style="display:none;margin-top:6px;">
<div style="display:flex;gap:4px;margin-bottom:6px;">
<input id="txtBroadcastAdd" placeholder="wss://relay.example"
style="flex:1;font-size:80%;padding:2px 4px;" />
<button id="btnBroadcastAdd" style="font-size:80%;">Add</button>
</div>
<button id="btnBroadcastSeed" style="font-size:80%;width:100%;margin-bottom:6px;">
Add all discovered relays
</button>
<div id="divBroadcastList" style="max-height:200px;overflow-y:auto;"></div>
<div id="divBroadcastSkippedList" style="margin-top:8px;opacity:0.6;max-height:150px;overflow-y:auto;">
<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div>
</div>
<button id="btnBroadcastSave" style="font-size:80%;width:100%;margin-top:6px;">
Save to Nostr (kind 10088)
</button>
<div id="divBroadcastStatus" style="font-size:75%;margin-top:4px;min-height:1em;"></div>
</div>
</div>
`;
// Wire up the connection history toggle
const divHistoryToggle = document.getElementById('divHistoryToggleCheckbox');
if (divHistoryToggle) {
const toggleHistory = () => {
const checked = !connectionHistoryEnabled;
connectionHistoryEnabled = checked;
localStorage.setItem('relayConnectionHistory', checked);
setRelayEventLogging(checked);
divHistoryToggle.innerHTML = checked ? SVG_CHECKED : SVG_UNCHECKED;
const wrap = document.getElementById('divRelayEventsWrap');
if (wrap) {
wrap.style.display = checked ? '' : 'none';
}
if (checked) {
if (selectedRelayUrl) {
loadRelayEventsFromDb(selectedRelayUrl);
}
renderRelayEventsForSelectedRelay();
} else {
if (divRelayEvents) {
divRelayEvents.innerHTML = '';
}
}
};
divHistoryToggle.addEventListener('click', (e) => {
e.stopPropagation();
toggleHistory();
});
document.getElementById('divRelaySettings').addEventListener('click', toggleHistory);
}
// Wire up the Broadcast Relays collapsible section.
initBroadcastRelaysSection();
// Refresh broadcast relay data from the worker every time the sidenav
// opens so the user sees the freshest active/skipped lists.
loadBroadcastRelays();
// 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();
}
}
/* ================================================================
BROADCAST RELAYS SECTION (kind 10088)
================================================================ */
let broadcastRelaysSectionWired = false;
function initBroadcastRelaysSection() {
// Toggle header click → show/hide content.
const header = document.getElementById('divBroadcastRelaysHeader');
const content = document.getElementById('divBroadcastRelaysContent');
const icon = document.getElementById('divBroadcastToggleIcon');
if (header && content) {
header.addEventListener('click', () => {
const isOpen = content.style.display !== 'none';
content.style.display = isOpen ? 'none' : 'block';
if (icon) icon.textContent = isOpen ? '▸' : '▾';
});
}
// Add button.
const btnAdd = document.getElementById('btnBroadcastAdd');
const txtAdd = document.getElementById('txtBroadcastAdd');
if (btnAdd && txtAdd) {
const doAdd = async () => {
const url = txtAdd.value.trim();
if (!url) return;
txtAdd.value = '';
setBroadcastStatus('Adding…');
await addBroadcastRelay(url);
setBroadcastStatus('Added');
setTimeout(() => setBroadcastStatus(''), 2000);
};
btnAdd.addEventListener('click', doAdd);
txtAdd.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); doAdd(); }
});
}
// Seed from discovered relays button.
const btnSeed = document.getElementById('btnBroadcastSeed');
if (btnSeed) {
btnSeed.addEventListener('click', async () => {
setBroadcastStatus('Seeding from discovered relays…');
try {
const added = await seedFromDiscovered();
setBroadcastStatus(added > 0
? `Added ${added} relay${added === 1 ? '' : 's'}`
: 'No new relays to add');
} catch (err) {
setBroadcastStatus('Seed failed: ' + (err?.message || err));
}
setTimeout(() => setBroadcastStatus(''), 4000);
});
}
// Save button (the worker auto-saves on add/remove, but this gives the
// user an explicit "flush to Nostr" action as the plan specifies).
const btnSave = document.getElementById('btnBroadcastSave');
if (btnSave) {
btnSave.addEventListener('click', async () => {
setBroadcastStatus('Saving to Nostr (kind 10088)…');
try {
// Re-save the current active list to force a fresh kind 10088
// publish (the worker deduplicates/merges with existing r-tags).
const { saveBroadcastRelays } = await import('./js/init-ndk.mjs');
await saveBroadcastRelays(getActiveBroadcastRelays());
setBroadcastStatus('Saved');
} catch (err) {
setBroadcastStatus('Save failed: ' + (err?.message || err));
}
setTimeout(() => setBroadcastStatus(''), 3000);
});
}
// Subscribe to changes from the wrapper module (re-render).
if (!broadcastRelaysSectionWired) {
broadcastRelaysSectionWired = true;
onBroadcastRelaysChanged(() => renderBroadcastRelaysSection());
// Cross-tab / cross-client sync: the worker forwards kind 10088
// subscription updates as this window event.
window.addEventListener('ndkBroadcastRelaysUpdated', () => {
loadBroadcastRelays();
});
}
renderBroadcastRelaysSection();
}
function setBroadcastStatus(text) {
const el = document.getElementById('divBroadcastStatus');
if (el) el.textContent = text || '';
}
function renderBroadcastRelaysSection() {
const spanActive = document.getElementById('spanBroadcastActive');
const spanSkipped = document.getElementById('spanBroadcastSkipped');
const listEl = document.getElementById('divBroadcastList');
const skippedEl = document.getElementById('divBroadcastSkippedList');
if (!listEl || !skippedEl) return;
const activeUrls = getActiveBroadcastRelays();
const skipped = getSkippedBroadcastRelays();
if (spanActive) spanActive.textContent = String(activeUrls.length);
if (spanSkipped) spanSkipped.textContent = String(skipped.length);
// Active list
if (activeUrls.length === 0) {
listEl.innerHTML = '<div style="font-size:75%;opacity:0.6;">No active broadcast relays.</div>';
} else {
listEl.innerHTML = activeUrls.map((url) => `
<div style="display:flex;align-items:center;gap:4px;padding:2px 0;font-size:75%;">
<span style="flex:1;word-break:break-all;">${escapeHtml(url)}</span>
<button data-broadcast-remove="${escapeHtml(url)}" style="font-size:75%;padding:0 4px;">×</button>
</div>
`).join('');
listEl.querySelectorAll('[data-broadcast-remove]').forEach((btn) => {
btn.addEventListener('click', () => removeBroadcastRelay(btn.getAttribute('data-broadcast-remove')));
});
}
// Skipped list
if (skipped.length === 0) {
skippedEl.innerHTML = '<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div><div style="font-size:75%;">None skipped.</div>';
} else {
skippedEl.innerHTML = '<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div>' +
skipped.map((item) => {
const ts = item.timestamp
? new Date(item.timestamp * 1000).toLocaleString()
: '';
return `
<div style="display:flex;align-items:center;gap:4px;padding:2px 0;font-size:75%;">
<span style="flex:1;word-break:break-all;">
${escapeHtml(item.url)}
<div style="font-size:70%;opacity:0.8;">${escapeHtml(item.reason || 'failed')}${ts ? ' · ' + escapeHtml(ts) : ''}</div>
</span>
<button data-broadcast-unskip="${escapeHtml(item.url)}" style="font-size:75%;padding:0 4px;">Unskip</button>
</div>
`;
}).join('');
skippedEl.querySelectorAll('[data-broadcast-unskip]').forEach((btn) => {
btn.addEventListener('click', () => unskipRelay(btn.getAttribute('data-broadcast-unskip')));
});
}
}
function escapeHtml(s) {
const map = { '&': 38, '<': 60, '>': 62, '"': 34, "'": 39 };
return String(s || '').replace(/[&<>"']/g, (c) =>
'&#' + map[c] + ';'
);
}
/* ================================================================
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);
if (connectionHistoryEnabled) {
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) {
// Incremental append instead of full re-render
const sideClass = side === 'client' ? 'client' : 'relay';
const label = side === 'client' ? 'CLIENT ➜ RELAY' : 'RELAY ➜ CLIENT';
const dataPart = summary ? `\n${summary}` : '';
const text = `[${timestamp}] ${label} ${eventType.toUpperCase()}${dataPart}`;
const row = document.createElement('div');
row.className = `relay-event-row ${sideClass}`;
const bubble = document.createElement('div');
bubble.className = 'relay-event-bubble';
bubble.textContent = text;
row.appendChild(bubble);
divRelayEvents.appendChild(row);
// Remove oldest if over 1000
while (divRelayEvents.children.length > 1000) {
divRelayEvents.removeChild(divRelayEvents.firstChild);
}
divRelayEvents.scrollTop = divRelayEvents.scrollHeight;
}
};
const shortNpub = (hex) => {
if (!hex || hex.length < 16) return hex || '-';
return `${hex.slice(0, 8)}${hex.slice(-4)}`;
};
let discoveredRelaysExpanded = false;
const renderDiscoveredRelays = (relays) => {
const container = document.getElementById('divDiscoveredRelays');
const title = document.getElementById('divDiscoveredRelaysTitle');
if (!container || !title) return;
if (!discoveredRelaysExpanded) {
container.style.display = 'none';
title.textContent = `▸ Discovered Relays (Outbox)${relays.length > 0 ? ` (${relays.length})` : ''}`;
return;
}
title.textContent = `▾ Discovered Relays (Outbox) (${relays.length})`;
container.style.display = '';
if (relays.length === 0) {
container.innerHTML = '<div style="padding:8px;color:var(--muted-color);font-size:80%;">No discovered relays. Load the feed page to populate outbox relays from your follows.</div>';
return;
}
// Filter out relays with no serving pubkeys — if no follows use this
// relay, there's no reason to show it in the discovered relays list.
const relaysWithFollows = relays.filter(r =>
Array.isArray(r.servingPubkeys) && r.servingPubkeys.length > 0
);
if (relaysWithFollows.length === 0) {
container.innerHTML = '<div style="padding:8px;color:var(--muted-color);font-size:80%;">No discovered relays with active follow data. Load the feed page to populate outbox relays from your follows.</div>';
return;
}
let html = '<table style="width:100%;font-size:70%;border-collapse:collapse;">';
html += '<thead><tr>';
html += '<th class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;">Relay</th>';
html += '<th class="tblCol tblColCenter" style="border:0.1px solid var(--muted-color);padding:4px;">Connected</th>';
html += '<th class="tblCol tblColRight" style="border:0.1px solid var(--muted-color);padding:4px;">Serving</th>';
html += '<th class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;">Follows</th>';
html += '</tr></thead><tbody>';
for (const relay of relaysWithFollows) {
const connected = relay.connected ? SVG_CHECKED : SVG_UNCHECKED;
const servingCount = Array.isArray(relay.servingPubkeys) ? relay.servingPubkeys.length : 0;
const followsList = Array.isArray(relay.servingNames) && relay.servingNames.length > 0
? relay.servingNames.join(', ')
: (Array.isArray(relay.servingPubkeys) ? relay.servingPubkeys.map(shortNpub).join(', ') : '-');
const rowClass = relay.connected ? 'tblRowConnected' : '';
html += `<tr class="${rowClass}">`;
html += `<td class="tblCol tblColLeft tblColRelay" style="border:0.1px solid var(--muted-color);padding:4px;" title="${relay.url}">${relay.url}</td>`;
html += `<td class="tblCol tblColCenter" style="border:0.1px solid var(--muted-color);padding:4px;">${connected}</td>`;
html += `<td class="tblCol tblColRight" style="border:0.1px solid var(--muted-color);padding:4px;">${servingCount}</td>`;
html += `<td class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;font-family:monospace;word-break:break-all;">${followsList}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
container.innerHTML = html;
};
const refreshDiscoveredRelays = async () => {
try {
const relays = await getDiscoveredRelays();
renderDiscoveredRelays(Array.isArray(relays) ? relays : []);
} catch (error) {
console.warn('[relays.html] Failed to fetch discovered relays:', error?.message || error);
renderDiscoveredRelays([]);
}
};
const initDiscoveredRelaysToggle = () => {
const title = document.getElementById('divDiscoveredRelaysTitle');
if (!title) return;
title.addEventListener('click', () => {
discoveredRelaysExpanded = !discoveredRelaysExpanded;
refreshDiscoveredRelays();
});
};
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();
// Load broadcast relays from the worker so the sidenav shows fresh
// data the first time it is opened.
loadBroadcastRelays();
// 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) => {
if (!connectionHistoryEnabled) return;
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();
// Initialize discovered relays (outbox) collapsible toggle
initDiscoveredRelaysToggle();
// 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);
// Discovered relays are fetched only on manual expand click — no
// auto-refresh interval, to avoid blocking the worker's message queue.
// Sync connection history toggle state from localStorage
connectionHistoryEnabled = localStorage.getItem('relayConnectionHistory') === 'true';
setRelayEventLogging(connectionHistoryEnabled);
if (!connectionHistoryEnabled) {
const wrap = document.getElementById('divRelayEventsWrap');
if (wrap) {
wrap.style.display = 'none';
}
}
// Stop the worker from broadcasting relay events when the page is closing
window.addEventListener('beforeunload', () => {
if (connectionHistoryEnabled) {
setRelayEventLogging(false);
}
});
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>