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