Files
client/build/relays-module-check.mjs
2026-04-17 16:52:51 -04:00

713 lines
27 KiB
JavaScript

/* ================================================================
IMPORTS
================================================================ */
import { initNDKPage, getPubkey, disconnect, getRelayData, getRelayStats, reconnectRelay, publishEvent, getVersion, updateVersionDisplay } from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-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;
// 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 txtRelayEvents = document.getElementById("txtRelayEvents");
/* ================================================================
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 = "50vw";
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 createRelayTable = async (relays) => {
const safeRelays = Array.isArray(relays) ? relays : [];
// Store current relay list for publishing
currentRelayList = safeRelays;
// Get relay stats for read/write counts
const relayStats = await getRelayStats();
let html = '<table id="tblRelays">';
// Header
html += '<thead><tr>';
html += '<th class="tblCol tblColCenter">X</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 tblColRight">Reads</th>';
html += '<th class="tblCol tblColRight">Writes</th>';
html += '<th class="tblCol tblColRight">Connection Time</th>';
html += '</tr></thead>';
// Body
html += '<tbody>';
for (let i = 0; i < safeRelays.length; i++) {
const relay = safeRelays[i];
const isConnected = relay.status === 5; // 5 = CONNECTED
const isReconnecting = reconnectingRelays.has(relay.url);
const rowClass = isConnected ? 'tblRelayRow tblRowConnected' : 'tblRelayRow';
const stats = relayStats[relay.url] || { readCount: 0, writeCount: 0 };
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;
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" title="${relay.url}">${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 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;
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 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');
handleRelayTypeToggle(relayIndex, toggleType);
});
});
// 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');
await handleAddRelayTypeToggle(toggleType);
});
});
// Add Enter handler for relay input
const txtNewRelayUrl = document.getElementById('txtNewRelayUrl');
if (txtNewRelayUrl) {
txtNewRelayUrl.addEventListener('keydown', async (event) => {
if (event.key === 'Enter') {
event.preventDefault();
await handleAddRelaySubmit(txtNewRelayUrl.value);
}
});
}
// Update footer stats
const connectedCount = safeRelays.filter(r => r.status === 5).length;
divFooterRight.innerHTML = `Total: ${safeRelays.length} | Connected: ${connectedCount}`;
};
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 handleRelayRemoveClick = async (relayUrl) => {
if (!relayUrl) return;
if (pendingRemovalRelayUrl === relayUrl) {
currentRelayList = currentRelayList.filter(relay => relay.url !== relayUrl);
pendingRemovalRelayUrl = null;
await createRelayTable(currentRelayList);
await publishRelayList();
return;
}
pendingRemovalRelayUrl = relayUrl;
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;
await createRelayTable(currentRelayList);
await publishRelayList();
};
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 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 FILTER_RELAY = 'wss://relay.laantungir.net/'; // Only show events from this relay
const logRelayEvent = (relayUrl, eventType, data) => {
// Filter: only log events from the specified relay
if (relayUrl !== FILTER_RELAY) return;
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1); // HH:MM:SS.mmm
let logLine = `[${timestamp}] ${relayUrl} - ${eventType.toUpperCase()}`;
if (data) {
if (typeof data === 'string') {
logLine += `: ${data}`;
} else {
logLine += `: ${JSON.stringify(data)}`;
}
}
// Add to top of textarea
const currentText = txtRelayEvents.value;
txtRelayEvents.value = logLine + '\n' + currentText;
// Limit to last 1000 lines to prevent memory issues
const lines = txtRelayEvents.value.split('\n');
if (lines.length > 1000) {
txtRelayEvents.value = lines.slice(0, 1000).join('\n');
}
};
const refreshRelayData = async () => {
console.log('[relay2.html] Refreshing relay data...');
try {
const relays = await getRelayData();
createRelayTable(relays);
} 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();
// 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;
if (isDarkMode) {
localStorage.setItem('theme', 'dark');
} else {
localStorage.setItem('theme', 'light');
}
// Save sidenav state before reload
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
window.location.reload();
});
}
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();
console.log("[relay2.html] Authenticated as:", currentPubkey);
// Initialize relay UI components
initFooterRelayStatus();
initSidenavRelaySection();
// 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 } = event.detail;
logRelayEvent(relayUrl, eventType, data);
});
// 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);
});
// 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);
}
});
// Get initial relay data
await refreshRelayData();
// Restore sidenav state if it was open before theme toggle
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
// 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>`;
}
})();