Files
client/www/js/relay-ui.mjs
T
2026-04-17 16:52:51 -04:00

620 lines
21 KiB
JavaScript

/**
* Relay UI Module
* Shared relay visualization components for footer and sidenav
*
* VISUALIZATION RULES:
* + indicates a connected relay that is enabled for both reading and writing.
* up_arrow indicates a connected relay that is enabled for writing.
* down_arrow indicates a connected relay that is enabled for reading.
* up_carrot is a temporary indication that a relay is being written to.
* down_carrot is a temporary indication that a relay is being read from.
* circle with muted-color is a relay that is not currently connected.
*
*/
import { getRelayData, getRelayStats, reconnectRelay, walletGetBalance } from './init-ndk.mjs';
import { HamburgerMorphing } from "../hamburger_morphing/hamburger.mjs";
const VERSION = "0.0.2";
// Relay status visualization (footer)
let relayStatusInstances = new Map(); // relay URL -> HamburgerMorphing instance
let relayActivityStates = new Map(); // relay URL -> { activity: 'read'|'write', timeout: id }
let lastRelayShapes = new Map(); // relay URL -> last rendered shape
// Relay status visualization (sidenav)
let sidenavRelayInstances = new Map(); // relay URL -> HamburgerMorphing instance
let lastRelayListSignature = ''; // Track when relay list changes
let footerBalanceInitialized = false;
let footerBalanceLastValue = null;
let footerBalanceListenersAttached = false;
let relaySectionToggleInitialized = false;
let relaySectionToggleIcon = null;
let walletBootstrapRequested = false;
let walletBootstrapRetryTimer = null;
let footerBalanceHydrationInFlight = null;
let footerBalanceLastHydrationAt = 0;
function formatSats(amount, { approximate = false } = {}) {
const value = Number(amount || 0);
const prefix = approximate ? '~' : '';
return `${prefix}${value.toLocaleString()} sats`;
}
function ensureFooterBalanceElement() {
const divFooter = document.getElementById('divFooter');
if (!divFooter) return null;
let divFooterBalance = document.getElementById('divFooterBalance');
if (!divFooterBalance) {
divFooterBalance = document.createElement('div');
divFooterBalance.id = 'divFooterBalance';
divFooterBalance.className = 'divFooterBox';
divFooterBalance.textContent = '0 sats';
divFooter.appendChild(divFooterBalance);
}
return divFooterBalance;
}
function setFooterBalanceValue(rawAmount, meta = {}) {
const isApproximate = Boolean(meta?.isApproximate) || String(meta?.balanceSource || '') === 'lightweight';
const nextValue = formatSats(rawAmount || 0, { approximate: isApproximate });
const divFooterBalance = ensureFooterBalanceElement();
if (!divFooterBalance) return;
if (footerBalanceLastValue !== nextValue) {
divFooterBalance.textContent = nextValue;
footerBalanceLastValue = nextValue;
}
}
async function hydrateFooterBalanceFromWorker(force = false) {
const now = Date.now();
if (!force && now - footerBalanceLastHydrationAt < 4000) return;
if (footerBalanceHydrationInFlight) return footerBalanceHydrationInFlight;
footerBalanceHydrationInFlight = (async () => {
try {
const payload = await walletGetBalance();
if (typeof payload?.balance === 'number') {
setFooterBalanceValue(payload.balance, payload || {});
}
} catch (error) {
console.warn('[relay-ui] Footer balance hydration skipped:', error?.message || error);
} finally {
footerBalanceLastHydrationAt = Date.now();
footerBalanceHydrationInFlight = null;
}
})();
return footerBalanceHydrationInFlight;
}
export function initFooterBalance() {
ensureFooterBalanceElement();
setFooterBalanceValue(0);
if (!footerBalanceListenersAttached) {
window.addEventListener('ndkWalletBalance', (event) => {
const payload = event?.detail || {};
setFooterBalanceValue(payload?.balance || 0, payload);
});
window.addEventListener('ndkWalletStatus', (event) => {
if (event?.detail?.hasWallet === false) {
setFooterBalanceValue(0);
return;
}
if (typeof event?.detail?.balance === 'number') {
setFooterBalanceValue(event.detail.balance, event?.detail || {});
}
});
footerBalanceListenersAttached = true;
}
footerBalanceInitialized = true;
hydrateFooterBalanceFromWorker(true);
}
export function updateFooterBalance() {
ensureFooterBalanceElement();
if (!footerBalanceLastValue || footerBalanceLastValue === '0 sats' || footerBalanceLastValue === '~0 sats') {
hydrateFooterBalanceFromWorker();
}
}
function bootstrapWalletForFooter() {
if (walletBootstrapRequested) return;
walletBootstrapRequested = true;
// Lightweight wallet balance is now computed in worker startup flow.
// Footer will update via ndkWalletBalance events.
setTimeout(() => {
walletBootstrapRequested = false;
}, 1000);
}
/**
* Set temporary activity state for a relay (shows animation for 3 seconds)
*/
export function setRelayActivityState(relayUrl, activity) {
// Clear any existing timeout for this relay
const existingState = relayActivityStates.get(relayUrl);
if (existingState && existingState.timeout) {
clearTimeout(existingState.timeout);
}
relayActivityStates.set(relayUrl, {
activity: activity,
timeout: setTimeout(() => {
relayActivityStates.delete(relayUrl);
// Trigger visual update
updateFooterRelayStatus();
updateSidenavRelaySection();
}, 3000)
});
// Trigger immediate visual update
updateFooterRelayStatus();
updateSidenavRelaySection();
}
/**
* Compute visualization data for a single relay
* This centralizes the logic for determining shape and color
*
* @param {Object} relay - Relay data from getRelayData()
* @param {Object} stats - Stats for this relay from getRelayStats()
* @param {Object} activityState - Temporary activity state (if any)
* @returns {Object} { shape, color, isConnected, domain, tooltip }
*/
function computeRelayVisualization(relay, stats, activityState) {
const isConnected = relay.status >= 5; // 5+ = CONNECTED (includes auth states 6/7/8)
const relayType = relay.type || 'both'; // 'read', 'write', or 'both'
const domain = relay.url.replace('wss://', '').replace('ws://', '').split('/')[0];
// Helper to resolve CSS variables
const resolveCssVar = (cssVar) => {
if (cssVar && cssVar.startsWith('var(')) {
const match = cssVar.match(/var\(\s*(--[^,\s)]+)/);
if (match) {
const value = getComputedStyle(document.documentElement).getPropertyValue(match[1]).trim();
return value || cssVar;
}
}
return cssVar;
};
let shape = 'circle';
let color = resolveCssVar('var(--muted-color)');
if (isConnected) {
// Check for temporary activity states first (highest priority)
if (activityState) {
shape = activityState.activity === 'write' ? 'carrot_up' : 'carrot_down';
color = resolveCssVar('var(--accent-color)');
}
// Then use relay type to determine base shape
else if (relayType === 'both') {
shape = 'plus';
color = resolveCssVar('var(--primary-color)');
} else if (relayType === 'write') {
shape = 'arrow_up';
color = resolveCssVar('var(--primary-color)');
} else if (relayType === 'read') {
shape = 'arrow_down';
color = resolveCssVar('var(--primary-color)');
}
}
// Disconnected relays show as circle with muted-color (already set as default)
const statusText = isConnected ? 'Connected' : 'Disconnected';
const tooltip = `${relay.url}\nStatus: ${statusText}\nType: ${relayType}\nReads: ${stats.readCount}\nWrites: ${stats.writeCount}`;
return {
shape,
color,
isConnected,
domain,
tooltip,
relayType,
stats
};
}
/**
* Prepare relay visualization data for all relays
*
* @returns {Promise<Array>} Array of relay visualization objects
*/
async function prepareRelayVisualizationData() {
const relayData = await getRelayData();
const relayStats = await getRelayStats();
if (!relayData || relayData.length === 0) {
return [];
}
// Sort relays: connected first, then by URL
const sortedRelays = [...relayData].sort((a, b) => {
if (a.status >= 5 && b.status < 5) return -1;
if (a.status < 5 && b.status >= 5) return 1;
return a.url.localeCompare(b.url);
});
// Compute visualization data for each relay
return sortedRelays.map(relay => {
const stats = relayStats[relay.url] || { readCount: 0, writeCount: 0 };
const activityState = relayActivityStates.get(relay.url);
const vizData = computeRelayVisualization(relay, stats, activityState);
return {
url: relay.url,
status: relay.status,
type: relay.type,
...vizData
};
});
}
/**
* Render footer relay section
*
* @param {Array} relayVizData - Array of relay visualization objects from prepareRelayVisualizationData()
*/
function drawFooterSection(relayVizData) {
const divFooterLeft = document.getElementById('divFooterLeft');
if (!divFooterLeft) return;
// Track which relays we currently have visuals for
const currentVisualRelays = new Set();
relayVizData.forEach((vizData) => {
const svgId = `relay-status-${vizData.url.replace(/[^a-zA-Z0-9]/g, '-')}`;
let svgContainer = document.getElementById(svgId);
// Create container if it doesn't exist
if (!svgContainer) {
const relayContainer = document.createElement('div');
relayContainer.className = 'relay-status';
relayContainer.style.cssText = `
width: 16px;
height: 16px;
cursor: pointer;
position: relative;
`;
svgContainer = document.createElement('div');
svgContainer.id = svgId;
svgContainer.style.cssText = `
width: 100%;
height: 100%;
`;
relayContainer.appendChild(svgContainer);
divFooterLeft.appendChild(relayContainer);
}
// Create or update hamburger morphing instance
let instance = relayStatusInstances.get(vizData.url);
if (!instance) {
instance = new HamburgerMorphing(`#${svgId}`, {
size: 16,
foreground: vizData.color,
background: 'transparent',
hover: vizData.color
});
relayStatusInstances.set(vizData.url, instance);
instance.animateTo(vizData.shape);
lastRelayShapes.set(vizData.url, vizData.shape);
} else {
// Update color if changed
instance.options.foreground = vizData.color;
instance.options.hover = vizData.color;
// Animate only when shape changes
const prevShape = lastRelayShapes.get(vizData.url);
if (prevShape !== vizData.shape) {
instance.animateTo(vizData.shape);
lastRelayShapes.set(vizData.url, vizData.shape);
}
}
// Update tooltip
const relayContainer = svgContainer.parentElement;
relayContainer.title = vizData.tooltip;
currentVisualRelays.add(vizData.url);
});
// Clean up instances for relays that are no longer in the list
for (const [relayUrl, instance] of relayStatusInstances) {
if (!currentVisualRelays.has(relayUrl)) {
const svgContainer = document.getElementById(`relay-status-${relayUrl.replace(/[^a-zA-Z0-9]/g, '-')}`);
if (svgContainer && svgContainer.parentElement) {
svgContainer.parentElement.remove();
}
instance.destroy();
relayStatusInstances.delete(relayUrl);
lastRelayShapes.delete(relayUrl);
}
}
}
/**
* Render sidenav relay section
*
* @param {Array} relayVizData - Array of relay visualization objects from prepareRelayVisualizationData()
*/
function drawRelaySidebarSection(relayVizData) {
const divRelayList = document.getElementById('divRelayList');
if (!divRelayList) {
console.warn('[relay-ui] divRelayList element not found!');
return;
}
if (!relayVizData || relayVizData.length === 0) {
divRelayList.innerHTML = '<div style="color: var(--muted-color); font-style: italic; padding: 5px;">No relays configured</div>';
return;
}
// Create signature of current relay list (URLs + statuses + types)
const currentSignature = relayVizData.map(r => `${r.url}:${r.status}:${r.type}`).join('|');
// Only rebuild if relay list changed OR if divRelayList is empty (first load)
const isEmpty = divRelayList.children.length === 0;
const needsRebuild = currentSignature !== lastRelayListSignature || isEmpty;
if (needsRebuild) {
lastRelayListSignature = currentSignature;
// Clear and rebuild relay list
divRelayList.innerHTML = '';
// Clean up all existing instances since we're rebuilding
for (const [relayUrl, instance] of sidenavRelayInstances) {
instance.destroy();
}
sidenavRelayInstances.clear();
// Build relay list HTML first
for (const vizData of relayVizData) {
const fontWeight = vizData.isConnected ? 'bold' : 'normal';
const textColor = vizData.isConnected ? 'var(--primary-color)' : 'var(--muted-color)';
// Create relay row
const relayRow = document.createElement('div');
relayRow.className = 'sidenavRowEntry';
const relayRowInner = document.createElement('div');
relayRowInner.className = 'sidenavRow';
// Non-functional indent spacer to align with blossom drag grip
const indentSpacer = document.createElement('span');
indentSpacer.className = 'sidenavRowIndent relayRowIndent';
indentSpacer.setAttribute('aria-hidden', 'true');
indentSpacer.textContent = '≡';
// Relay name
const relayName = document.createElement('span');
relayName.className = 'sidenavRowName';
relayName.style.fontWeight = fontWeight;
relayName.style.color = textColor;
relayName.title = vizData.url;
relayName.textContent = vizData.domain;
// Read/Write counts
const countsSpan = document.createElement('span');
const countsId = `sidenav-counts-${vizData.url.replace(/[^a-zA-Z0-9]/g, '-')}`;
countsSpan.id = countsId;
countsSpan.className = 'sidenavRowMeta';
countsSpan.style.color = textColor;
countsSpan.textContent = `↓${vizData.stats.readCount} ↑${vizData.stats.writeCount}`;
// Status indicator container
const statusContainer = document.createElement('div');
const statusId = `sidenav-relay-${vizData.url.replace(/[^a-zA-Z0-9]/g, '-')}`;
statusContainer.id = statusId;
statusContainer.className = 'sidenavRowToggle';
// Add click handler for disconnected relays to attempt reconnection
if (!vizData.isConnected) {
statusContainer.style.cursor = 'pointer';
statusContainer.title = `${vizData.url}\nStatus: Disconnected\nClick to reconnect`;
statusContainer.addEventListener('click', () => {
console.log(`[relay-ui] Attempting to reconnect to relay: ${vizData.url}`);
reconnectRelay(vizData.url);
});
statusContainer.dataset.reconnectHandler = 'true';
}
relayRowInner.appendChild(indentSpacer);
relayRowInner.appendChild(relayName);
relayRowInner.appendChild(countsSpan);
relayRowInner.appendChild(statusContainer);
relayRow.appendChild(relayRowInner);
divRelayList.appendChild(relayRow);
}
// Capture relayVizData for the setTimeout callback
const vizDataToProcess = relayVizData;
// Now create HamburgerMorphing instances after DOM is ready
setTimeout(() => {
for (const vizData of vizDataToProcess) {
const statusId = `sidenav-relay-${vizData.url.replace(/[^a-zA-Z0-9]/g, '-')}`;
const statusContainer = document.getElementById(statusId);
if (!statusContainer) {
console.warn(`[relay-ui] Status container not found for ${vizData.url}, id: ${statusId}`);
continue;
}
try {
// Create HamburgerMorphing instance
const instance = new HamburgerMorphing(`#${statusId}`, {
size: 16,
foreground: vizData.color,
background: 'transparent'
});
sidenavRelayInstances.set(vizData.url, instance);
instance.animateTo(vizData.shape);
// Ensure SVG allows pointer events to pass through to container for disconnected relays
const svg = statusContainer.querySelector('svg');
if (svg && !vizData.isConnected) {
svg.style.pointerEvents = 'none';
}
} catch (error) {
console.error(`[relay-ui] Error creating HamburgerMorphing for ${vizData.url}:`, error);
}
}
}, 100);
} else {
// Relay list hasn't changed, just update existing instances and counts
for (const vizData of relayVizData) {
const instance = sidenavRelayInstances.get(vizData.url);
// Update counts display
const countsId = `sidenav-counts-${vizData.url.replace(/[^a-zA-Z0-9]/g, '-')}`;
const countsSpan = document.getElementById(countsId);
if (countsSpan) {
countsSpan.textContent = `↓${vizData.stats.readCount} ↑${vizData.stats.writeCount}`;
}
// Update click handler for disconnected relays
const statusId = `sidenav-relay-${vizData.url.replace(/[^a-zA-Z0-9]/g, '-')}`;
const statusContainer = document.getElementById(statusId);
if (statusContainer) {
const svg = statusContainer.querySelector('svg');
if (!vizData.isConnected) {
statusContainer.style.cursor = 'pointer';
statusContainer.title = `${vizData.url}\nStatus: Disconnected\nClick to reconnect`;
// Only add click listener if not already added (check for data attribute)
if (!statusContainer.dataset.reconnectHandler) {
statusContainer.addEventListener('click', () => {
console.log(`[relay-ui] Attempting to reconnect to relay: ${vizData.url}`);
reconnectRelay(vizData.url);
});
statusContainer.dataset.reconnectHandler = 'true';
}
// Ensure SVG allows pointer events to pass through to container
if (svg) svg.style.pointerEvents = 'none';
} else {
// Relay is connected - remove cursor and update tooltip
statusContainer.style.cursor = 'default';
statusContainer.title = `${vizData.url}\nStatus: Connected\nType: ${vizData.relayType}`;
// Re-enable pointer events on SVG for connected relays
if (svg) svg.style.pointerEvents = 'auto';
}
}
if (!instance) continue;
// Update color on the instance
instance.options.foreground = vizData.color;
instance.options.hover = vizData.color;
instance.animateTo(vizData.shape);
}
}
}
/**
* Initialize footer relay status visualization
* Call this once during page initialization
*/
export function initFooterRelayStatus() {
console.log(`[relay-ui v${VERSION}] Initializing footer relay status`);
initFooterBalance();
bootstrapWalletForFooter();
}
/**
* Update footer relay status visuals
* Call this periodically (e.g., every second) to update relay indicators
*/
export async function updateFooterRelayStatus() {
try {
if (!footerBalanceInitialized) initFooterBalance();
bootstrapWalletForFooter();
const relayVizData = await prepareRelayVisualizationData();
drawFooterSection(relayVizData);
updateFooterBalance();
} catch (error) {
console.error('[relay-ui] Error updating footer relay status:', error);
}
}
/**
* Initialize sidenav relay section
* Call this once during page initialization
*/
export function initSidenavRelaySection() {
console.log(`[relay-ui v${VERSION}] Initializing sidenav relay section`);
if (relaySectionToggleInitialized) return;
const sectionEl = document.getElementById('divRelaySection');
const titleEl = document.getElementById('divRelaySectionTitle');
if (!sectionEl || !titleEl) return;
const collapsedKey = 'sidenav-relay-collapsed';
if (localStorage.getItem(collapsedKey) === 'true') {
sectionEl.classList.add('collapsed');
}
let iconEl = document.getElementById('divRelaySectionToggleIcon');
if (!iconEl) {
iconEl = document.createElement('div');
iconEl.id = 'divRelaySectionToggleIcon';
iconEl.className = 'sidenavSectionToggleIcon';
titleEl.appendChild(iconEl);
}
try {
relaySectionToggleIcon = new HamburgerMorphing('#divRelaySectionToggleIcon', {
size: 16,
foreground: 'var(--muted-color)',
background: 'transparent',
hover: 'var(--primary-color)'
});
relaySectionToggleIcon.animateTo(sectionEl.classList.contains('collapsed') ? 'burger' : 'arrow_down');
} catch (error) {
console.warn('[relay-ui] Failed to initialize relay section toggle icon:', error);
}
titleEl.addEventListener('click', () => {
sectionEl.classList.toggle('collapsed');
const isCollapsed = sectionEl.classList.contains('collapsed');
localStorage.setItem(collapsedKey, isCollapsed ? 'true' : 'false');
if (relaySectionToggleIcon) {
relaySectionToggleIcon.animateTo(isCollapsed ? 'burger' : 'arrow_down');
}
});
relaySectionToggleInitialized = true;
}
/**
* Update sidenav relay section
* Call this periodically (e.g., every second) to update relay list
*/
export async function updateSidenavRelaySection() {
try {
const relayVizData = await prepareRelayVisualizationData();
drawRelaySidebarSection(relayVizData);
} catch (error) {
console.error('[relay-ui] Error updating sidenav relay section:', error);
}
}
console.log(`[relay-ui v${VERSION}] Module loaded`);