1838 lines
67 KiB
HTML
1838 lines
67 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" />
|
||
<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>
|