super_ball/web/superball.html

1856 lines
71 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Superball Builder</title>
<link rel="stylesheet" href="superball-shared.css">
</head>
<body>
<div id="login-section">
<h1>Superball Builder</h1>
<div id="login-container"></div>
</div>
<div id="profile-section" class="hidden">
<h1>Superball Builder</h1>
<img id="profile-picture">
<div id="profile-name"></div>
<div id="profile-pubkey"></div>
<div id="profile-about"></div>
</div>
<div id="event-builder" class="hidden">
<!-- FINAL EVENT SECTION -->
<div class="section">
<h2>Final Event (What gets posted at the end)</h2>
<div class="input-group">
<label for="final-content">Message Content:</label>
<textarea id="final-content" rows="3" placeholder="Enter your message content..."></textarea>
</div>
<button onclick="createFinalEvent()">Create Event That Will Be Published Publicly</button>
<div id="final-event-display" class="json-display"></div>
</div>
<!-- BOUNCES SECTION -->
<div id="bounces-container">
<!-- Bounce sections will be added here dynamically -->
</div>
<div class="section" id="bounce-controls" class="hidden">
<button id="add-bounce-btn" onclick="addBounce()" class="hidden">Add Bounce</button>
</div>
</div>
<!-- Thrower Discovery Section -->
<div id="thrower-discovery" class="section hidden">
<h2>Available Throwers</h2>
<p>Discover Throwers on your relay network. Throwers provide anonymity by routing your Superballs.</p>
<div style="margin-bottom: 15px;">
<button onclick="refreshThrowerList()" id="refresh-btn">🔄 Refresh List</button>
<span id="discovery-status" style="margin-left: 15px; font-size: 12px; color: #666;"></span>
</div>
<div id="thrower-list">
<div style="text-align: center; color: #666; font-style: italic;">
Click "Refresh List" to search your relay network for available Throwers.
</div>
</div>
</div>
<!-- Reset Builder Section - Bottom of page after Available Throwers -->
<div class="section hidden" id="reset-controls" style="display: none; text-align: right;">
<button onclick="resetBuilder()" style="background: #dc3545; color: white;">Reset Builder</button>
</div>
<!-- Load the official nostr-tools bundle first -->
<!-- <script src="./nostr.bundle.js"></script> -->
<script src="https://laantungir.net/nostr-login-lite/nostr.bundle.js"></script>
<!-- Load NOSTR_LOGIN_LITE main library -->
<script src="https://laantungir.net/nostr-login-lite/nostr-lite.js"></script>
<!-- <script src="./nostr-lite.js"></script> -->
<script>
// Global variables
let nlLite = null;
let userPubkey = null;
let relayUrl = 'wss://relay.laantungir.net';
let finalEvent = null;
let bounces = [];
let bounceCounter = 0;
let discoveredThrowers = [];
let userRelays = [];
// Initialize NOSTR_LOGIN_LITE
async function initializeApp() {
try {
await window.NOSTR_LOGIN_LITE.init({
persistence: true, // Enable persistent authentication (default: true)
isolateSession: true, // Use sessionStorage for per-tab isolation (default: false = localStorage)
theme: 'default',
darkMode: false,
methods: {
extension: true,
local: true,
seedphrase: true,
connect: true,
remote: false,
otp: false
},
debug: true
});
nlLite = window.NOSTR_LOGIN_LITE;
console.log('SUCCESS', 'NOSTR_LOGIN_LITE initialized successfully');
// Use embedded login instead of floating tab
window.NOSTR_LOGIN_LITE.embed('#login-container', {
seamless: true
});
window.addEventListener('nlMethodSelected', handleAuthEvent);
} catch (error) {
console.log('ERROR', `Initialization failed: ${error.message}`);
}
}
function handleAuthEvent(event) {
const { pubkey, method, error } = event.detail;
console.log('INFO', `Auth event received: method=${method}`);
if (method && pubkey) {
userPubkey = pubkey;
console.log('SUCCESS', `Login successful! Method: ${method}, Pubkey: ${pubkey}`);
// Hide login section and show main app
document.getElementById('login-section').classList.add('hidden');
document.getElementById('profile-section').classList.remove('hidden');
document.getElementById('thrower-discovery').classList.remove('hidden');
document.getElementById('event-builder').classList.remove('hidden');
loadUserProfile();
// Automatically discover throwers after login
setTimeout(() => {
discoverThrowers();
}, 1000);
} else if (error) {
console.log('ERROR', `Authentication error: ${error}`);
}
}
// Load user profile
async function loadUserProfile() {
if (!userPubkey) return;
console.log('INFO', `Loading profile for: ${userPubkey}`);
document.getElementById('profile-name').textContent = 'Loading profile...';
document.getElementById('profile-pubkey').textContent = userPubkey;
try {
const pool = new window.NostrTools.SimplePool();
const relays = [relayUrl, 'wss://relay.laantungir.net'];
const events = await pool.querySync(relays, {
kinds: [0],
authors: [userPubkey],
limit: 1
});
pool.close(relays);
if (events.length > 0) {
console.log('SUCCESS', 'Profile event received');
const profile = JSON.parse(events[0].content);
displayProfile(profile);
} else {
console.log('INFO', 'No profile found');
document.getElementById('profile-name').textContent = 'No profile found';
document.getElementById('profile-about').textContent = 'User has not set up a profile yet.';
}
} catch (error) {
console.log('ERROR', `Profile loading failed: ${error.message}`);
document.getElementById('profile-name').textContent = 'Error loading profile';
}
}
function displayProfile(profile) {
const name = profile.name || profile.display_name || profile.displayName || 'Anonymous User';
const about = profile.about || '';
const picture = profile.picture || '';
document.getElementById('profile-name').textContent = name;
if (picture) {
document.getElementById('profile-picture').src = picture;
}
console.log('SUCCESS', `Profile displayed: ${name}`);
}
// Load user's NIP-65 relay list
async function loadUserRelayList() {
if (!userPubkey) return [];
console.log('INFO', `Loading relay list for: ${userPubkey}`);
try {
const pool = new window.NostrTools.SimplePool();
const relays = [relayUrl, 'wss://relay.laantungir.net'];
const events = await pool.querySync(relays, {
kinds: [10002],
authors: [userPubkey],
limit: 1
});
pool.close(relays);
if (events.length > 0) {
console.log('SUCCESS', 'Relay list event received');
const relayTags = events[0].tags.filter(tag => tag[0] === 'r');
userRelays = relayTags.map(tag => ({
url: tag[1],
type: tag[2] || 'both' // both, read, write
}));
console.log('INFO', `Found ${userRelays.length} relays in user's relay list`);
return userRelays;
} else {
console.log('INFO', 'No relay list found, using defaults');
userRelays = [
{ url: 'wss://relay.laantungir.net', type: 'both' }
];
return userRelays;
}
} catch (error) {
console.log('ERROR', `Relay list loading failed: ${error.message}`);
userRelays = [{ url: 'wss://relay.laantungir.net', type: 'both' }];
return userRelays;
}
}
// Discover Throwers across user's relay network
async function discoverThrowers() {
console.log('INFO', 'Starting Thrower discovery...');
// Update UI state
document.getElementById('discovery-status').textContent = 'Loading relay list...';
document.getElementById('discovery-status').classList.add('loading');
document.getElementById('refresh-btn').disabled = true;
try {
// Load user's relay list first
const relays = await loadUserRelayList();
if (relays.length === 0) {
throw new Error('No relays found in user configuration');
}
document.getElementById('discovery-status').textContent = `Searching ${relays.length} relays for Throwers...`;
// Search each relay for kind 12222 events
const relayUrls = relays.map(r => r.url);
console.log('INFO', `Searching relays: ${relayUrls.join(', ')}`);
const pool = new window.NostrTools.SimplePool();
// Query for all kind 12222 Thrower Information Documents
const throwerEvents = await pool.querySync(relayUrls, {
kinds: [12222],
limit: 100 // Limit to prevent overwhelming results
});
pool.close(relayUrls);
console.log('INFO', `Found ${throwerEvents.length} Thrower Information Documents`);
// Process and deduplicate Throwers
const throwerMap = new Map();
throwerEvents.forEach(event => {
try {
const thrower = parseThrowerDocument(event);
// Use pubkey as unique identifier, keep most recent document
const existing = throwerMap.get(thrower.pubkey);
if (!existing || thrower.created_at > existing.created_at) {
throwerMap.set(thrower.pubkey, thrower);
}
} catch (error) {
console.log('WARN', `Failed to parse Thrower document ${event.id}: ${error.message}`);
}
});
discoveredThrowers = Array.from(throwerMap.values());
// Now fetch each Thrower's relay list (kind 10002)
document.getElementById('discovery-status').textContent = `Loading relay lists for ${discoveredThrowers.length} Throwers...`;
await loadThrowerRelayLists(discoveredThrowers, relayUrls, pool);
console.log('SUCCESS', `Discovered ${discoveredThrowers.length} unique Throwers`);
// Update UI
displayThrowers(discoveredThrowers);
document.getElementById('discovery-status').textContent = `Found ${discoveredThrowers.length} Throwers`;
// Update all existing thrower dropdowns with fresh status information
updateAllThrowerDropdowns();
} catch (error) {
console.log('ERROR', `Thrower discovery failed: ${error.message}`);
document.getElementById('discovery-status').textContent = `Discovery failed: ${error.message}`;
document.getElementById('thrower-list').innerHTML = `
<div style="text-align: center; color: #dc3545; font-style: italic;">
Discovery failed: ${error.message}
</div>
`;
} finally {
document.getElementById('discovery-status').classList.remove('loading');
document.getElementById('refresh-btn').disabled = false;
}
}
// Parse a Thrower Information Document (kind 12222)
function parseThrowerDocument(event) {
const thrower = {
pubkey: event.pubkey,
created_at: event.created_at,
name: 'Unnamed Thrower',
description: 'No description available',
banner: '',
icon: '',
adminPubkey: '',
contact: '',
supportedSups: '',
software: '',
version: '',
privacyPolicy: '',
termsOfService: '',
refreshRate: 60, // default 60 seconds
content: event.content || ''
};
// Parse tags
event.tags.forEach(tag => {
if (tag.length >= 2) {
switch (tag[0]) {
case 'name':
thrower.name = tag[1];
break;
case 'description':
thrower.description = tag[1];
break;
case 'banner':
thrower.banner = tag[1];
break;
case 'icon':
thrower.icon = tag[1];
break;
case 'pubkey':
thrower.adminPubkey = tag[1];
break;
case 'contact':
thrower.contact = tag[1];
break;
case 'supported_sups':
thrower.supportedSups = tag[1];
break;
case 'software':
thrower.software = tag[1];
break;
case 'version':
thrower.version = tag[1];
break;
case 'privacy_policy':
thrower.privacyPolicy = tag[1];
break;
case 'terms_of_service':
thrower.termsOfService = tag[1];
break;
case 'refresh_rate':
thrower.refreshRate = parseInt(tag[1]) || 60;
break;
}
}
});
return thrower;
}
// Load relay lists for all discovered Throwers
async function loadThrowerRelayLists(throwers, relayUrls, existingPool) {
console.log('INFO', `Loading relay lists for ${throwers.length} Throwers...`);
const pool = existingPool || new window.NostrTools.SimplePool();
const throwerPubkeys = throwers.map(t => t.pubkey);
try {
// Query for all Throwers' relay lists (kind 10002)
const relayListEvents = await pool.querySync(relayUrls, {
kinds: [10002],
authors: throwerPubkeys,
limit: throwers.length * 2 // Allow for multiple relay list versions
});
console.log('INFO', `Found ${relayListEvents.length} relay list events`);
// Create a map of pubkey -> most recent relay list
const relayListMap = new Map();
relayListEvents.forEach(event => {
const existing = relayListMap.get(event.pubkey);
if (!existing || event.created_at > existing.created_at) {
relayListMap.set(event.pubkey, event);
}
});
// Parse relay lists and attach to Throwers
throwers.forEach(thrower => {
const relayListEvent = relayListMap.get(thrower.pubkey);
if (relayListEvent) {
thrower.relayList = parseRelayList(relayListEvent);
console.log('INFO', `Loaded ${thrower.relayList.relays.length} relays for ${thrower.name}`);
} else {
thrower.relayList = { relays: [], lastUpdated: null };
console.log('WARN', `No relay list found for Thrower: ${thrower.name}`);
}
});
if (!existingPool) {
pool.close(relayUrls);
}
} catch (error) {
console.log('ERROR', `Failed to load Thrower relay lists: ${error.message}`);
// Set empty relay lists for all Throwers on error
throwers.forEach(thrower => {
thrower.relayList = { relays: [], lastUpdated: null };
});
if (!existingPool) {
pool.close(relayUrls);
}
}
}
// Parse a relay list event (kind 10002)
function parseRelayList(event) {
const relayList = {
relays: [],
lastUpdated: event.created_at
};
// Parse relay tags
event.tags.forEach(tag => {
if (tag[0] === 'r' && tag.length >= 2) {
const relay = {
url: tag[1],
type: tag.length >= 3 ? tag[2] : 'both' // 'read', 'write', or 'both' (default)
};
relayList.relays.push(relay);
}
});
return relayList;
}
// Check if a Thrower is currently online
function isThrowerOnline(thrower) {
const now = Math.floor(Date.now() / 1000);
const timeSinceUpdate = now - thrower.created_at;
return timeSinceUpdate <= thrower.refreshRate;
}
// Display discovered Throwers with filtering logic
function displayThrowers(throwers, showAll = false) {
const container = document.getElementById('thrower-list');
if (throwers.length === 0) {
container.innerHTML = `
<div style="text-align: center; color: #666; font-style: italic;">
No Throwers found on your relay network.
</div>
`;
return;
}
// Separate online and offline throwers
const onlineThrowers = throwers.filter(t => isThrowerOnline(t));
const offlineThrowers = throwers.filter(t => !isThrowerOnline(t));
// Sort online throwers by name
const sortedOnline = onlineThrowers.sort((a, b) => a.name.localeCompare(b.name));
// Sort offline throwers by most recent (created_at desc), then by name
const sortedOffline = offlineThrowers.sort((a, b) => {
if (b.created_at !== a.created_at) {
return b.created_at - a.created_at; // Most recent first
}
return a.name.localeCompare(b.name);
});
// Determine which throwers to display
let displayThrowers = [...sortedOnline];
let offlineCount = sortedOffline.length;
if (showAll) {
displayThrowers = [...sortedOnline, ...sortedOffline];
}
// By default, only show online throwers
// Generate HTML for displayed throwers
const throwersHtml = displayThrowers.map((thrower, index) => {
const isOnline = isThrowerOnline(thrower);
const statusClass = isOnline ? 'online' : 'offline';
const statusText = isOnline ? 'Online' : 'Offline';
const timeSinceUpdate = Math.floor(Date.now() / 1000) - thrower.created_at;
const lastSeen = formatTimestamp(thrower.created_at);
// Determine display name for collapsed view
const displayName = thrower.name && thrower.name !== 'Unnamed Thrower'
? thrower.name
: `...${thrower.pubkey.slice(-4)}`;
return `
<div class="thrower-item ${statusClass}">
<div class="thrower-header" onclick="toggleThrowerDetails(${index})">
<div class="thrower-header-left">
<div class="thrower-condensed-info">
<div class="thrower-status ${statusClass}"></div>
<div class="thrower-name">${escapeHtml(displayName)}</div>
</div>
</div>
<div class="expand-triangle" id="triangle-${index}"></div>
</div>
<div class="thrower-details-section collapsed" id="details-${index}">
${thrower.icon ? `
<div style="margin-bottom: 15px;">
<img src="${thrower.icon}" style="width: 50px; height: 50px; border-radius: var(--border-radius); border: var(--border-width) solid var(--primary-color); filter: grayscale(100%); transition: filter 0.3s ease;" onmouseover="this.style.filter='grayscale(0%) saturate(50%)'" onmouseout="this.style.filter='grayscale(100%)'">
</div>
` : ''}
<div class="thrower-pubkey">
<strong>Pubkey:</strong> ${thrower.pubkey}
</div>
<div class="thrower-description">
${escapeHtml(thrower.description)}
</div>
<div class="thrower-details">
<div class="thrower-detail">
<span><strong>Status:</strong></span>
<span>${statusText}</span>
</div>
<div class="thrower-detail">
<span><strong>Version:</strong></span>
<span>${escapeHtml(thrower.version || 'N/A')}</span>
</div>
<div class="thrower-detail">
<span><strong>SUPs:</strong></span>
<span>${escapeHtml(thrower.supportedSups || 'N/A')}</span>
</div>
<div class="thrower-detail">
<span><strong>Refresh Rate:</strong></span>
<span>${thrower.refreshRate}s</span>
</div>
<div class="thrower-detail">
<span><strong>Last Update:</strong></span>
<span>${lastSeen}</span>
</div>
</div>
${generateRelayListHtml(thrower)}
${thrower.content ? `
<div style="margin-top: 10px; font-size: 12px; color: #666; font-style: italic;">
${escapeHtml(thrower.content)}
</div>
` : ''}
</div>
</div>
`;
}).join('');
// Add "Show Offline Throwers" button if there are offline throwers and not showing all
const showOfflineButton = (!showAll && offlineCount > 0) ? `
<div class="thrower-item" style="background: var(--secondary-color); border: var(--border-width) solid var(--primary-color); cursor: pointer;" onclick="showAllThrowers()">
<div class="thrower-header">
<div class="thrower-header-left">
<div class="thrower-condensed-info">
<div style="font-weight: bold; color: var(--primary-color);">Show offline Throwers (${offlineCount})</div>
</div>
</div>
</div>
</div>
` : '';
container.innerHTML = throwersHtml + showOfflineButton;
}
// Show all throwers (including hidden offline ones)
function showAllThrowers() {
displayThrowers(discoveredThrowers, true);
}
// Generate HTML for Thrower's relay list
function generateRelayListHtml(thrower) {
if (!thrower.relayList || !thrower.relayList.relays || thrower.relayList.relays.length === 0) {
return `
<div style="margin-top: 15px; padding: 10px; background: #f8f9fa; border-radius: 5px; border: 1px solid #e9ecef;">
<div style="font-size: 12px; font-weight: bold; margin-bottom: 5px;">Relay Configuration:</div>
<div style="font-size: 11px; color: #666; font-style: italic;">No relay list found for this Thrower</div>
</div>
`;
}
const readRelays = thrower.relayList.relays.filter(r => r.type === 'read' || r.type === 'both');
const writeRelays = thrower.relayList.relays.filter(r => r.type === 'write' || r.type === 'both');
const lastUpdated = thrower.relayList.lastUpdated ? formatTimestamp(thrower.relayList.lastUpdated) : 'Unknown';
return `
<div style="margin-top: 15px; padding: 10px; background: #f8f9fa; border-radius: 5px; border: 1px solid #e9ecef;">
<div style="font-size: 12px; font-weight: bold; margin-bottom: 8px;">
Relay Configuration (${thrower.relayList.relays.length} relays, updated ${lastUpdated})
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; font-size: 11px;">
<div>
<div style="font-weight: bold; color: #28a745; margin-bottom: 5px;">
Can catch from (${readRelays.length})
</div>
<div style="max-height: 120px; overflow-y: auto;">
${readRelays.length > 0 ? readRelays.map(relay => `
<div style="margin-bottom: 3px; padding: 2px 4px; background: #e8f5e8; border-radius: 3px; word-break: break-all;">
${escapeHtml(relay.url)}
</div>
`).join('') : '<div style="color: #666; font-style: italic;">Cannot catch from any relays</div>'}
</div>
</div>
<div>
<div style="font-weight: bold; color: #dc3545; margin-bottom: 5px;">
Can throw to (${writeRelays.length})
</div>
<div style="max-height: 120px; overflow-y: auto;">
${writeRelays.length > 0 ? writeRelays.map(relay => `
<div style="margin-bottom: 3px; padding: 2px 4px; background: #f8d7da; border-radius: 3px; word-break: break-all;">
${escapeHtml(relay.url)}
</div>
`).join('') : '<div style="color: #666; font-style: italic;">Cannot throw to any relays</div>'}
</div>
</div>
</div>
${writeRelays.length === 0 ? `
<div style="margin-top: 8px; padding: 6px; background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 3px; font-size: 10px; color: #856404;">
Warning: This Thrower cannot throw to any relays and cannot complete Superball routes.
</div>
` : ''}
</div>
`;
}
// Refresh the Thrower list
async function refreshThrowerList() {
console.log('INFO', 'Refreshing Thrower list...');
await discoverThrowers();
}
// Utility function to escape HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Format timestamp for display
function formatTimestamp(timestamp) {
const date = new Date(timestamp * 1000);
const now = new Date();
const diffSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffSeconds < 60) {
return `${diffSeconds}s ago`;
} else if (diffSeconds < 3600) {
const minutes = Math.floor(diffSeconds / 60);
return `${minutes}m ago`;
} else if (diffSeconds < 86400) {
const hours = Math.floor(diffSeconds / 3600);
return `${hours}h ago`;
} else {
const days = Math.floor(diffSeconds / 86400);
return `${days}d ago`;
}
}
// Create final event (kind 1)
async function createFinalEvent() {
const content = document.getElementById('final-content').value.trim();
if (!content) {
alert('Please enter message content');
return;
}
try {
// Create the final event (kind 1) - pure message, no relay info
const eventTemplate = {
kind: 1,
content: content,
tags: [],
created_at: Math.floor(Date.now() / 1000)
};
// Sign the event using window.nostr (NIP-07)
finalEvent = await window.nostr.signEvent(eventTemplate);
// Display the final event (clean JSON without _targetRelays)
document.getElementById('final-event-display').textContent = JSON.stringify(finalEvent, null, 2);
console.log('SUCCESS', 'Final event created and signed');
// Show the Add Bounce button now that final event exists
document.getElementById('add-bounce-btn').classList.remove('hidden');
document.getElementById('bounce-controls').classList.remove('hidden');
document.getElementById('reset-controls').classList.remove('hidden');
document.getElementById('reset-controls').style.display = 'block';
// Reset bounces when final event is recreated
bounces = [];
document.getElementById('bounces-container').innerHTML = '';
} catch (error) {
console.log('ERROR', `Failed to create final event: ${error.message}`);
alert(`Failed to create final event: ${error.message}`);
}
}
// Generate random audit tag (looks like pubkey)
function generateAuditTag() {
// Use the same method as generateSecretKey but get the pubkey
const randomKey = window.NostrTools.generateSecretKey();
return window.NostrTools.getPublicKey(randomKey);
}
// Populate thrower dropdown with discovered throwers
function populateThrowerDropdown(bounceId) {
const select = document.getElementById(`thrower-select-${bounceId}`);
if (!select) return;
// Clear existing options except the first two (default and manual)
while (select.children.length > 2) {
select.removeChild(select.lastChild);
}
// Add discovered throwers
discoveredThrowers.forEach(thrower => {
const option = document.createElement('option');
option.value = thrower.pubkey;
const onlineStatus = isThrowerOnline(thrower) ? '🟢' : '🔴';
option.textContent = `${onlineStatus} ${thrower.name} (${thrower.pubkey.substring(0, 8)}...)`;
select.appendChild(option);
});
console.log('INFO', `Populated thrower dropdown for bounce ${bounceId} with ${discoveredThrowers.length} throwers`);
}
// Update all existing thrower dropdowns with current online status
function updateAllThrowerDropdowns() {
// Find all thrower select dropdowns in existing bounces
const throwerSelects = document.querySelectorAll('[id^="thrower-select-"]');
throwerSelects.forEach(select => {
// Extract bounce ID from the select element ID
const bounceId = select.id.replace('thrower-select-', '');
// Get all thrower options (skip the first two: default and manual)
const throwerOptions = Array.from(select.options).slice(2);
throwerOptions.forEach(option => {
// Find the thrower data for this option
const thrower = discoveredThrowers.find(t => t.pubkey === option.value);
if (thrower) {
// Update the status icon and text
const onlineStatus = isThrowerOnline(thrower) ? '🟢' : '🔴';
option.textContent = `${onlineStatus} ${thrower.name} (${thrower.pubkey.substring(0, 8)}...)`;
}
});
});
console.log('INFO', `Updated status icons in ${throwerSelects.length} thrower dropdowns`);
}
// Handle thrower selection from dropdown
function onThrowerSelect(bounceId) {
const select = document.getElementById(`thrower-select-${bounceId}`);
const input = document.getElementById(`thrower-pubkey-${bounceId}`);
const manualDiv = document.getElementById(`thrower-manual-${bounceId}`);
if (select.value === '__manual__') {
// Show manual input field
manualDiv.classList.remove('hidden');
input.value = '';
input.focus();
console.log('INFO', `Manual thrower input selected for bounce ${bounceId}`);
// Clear relay dropdown until manual input is provided
clearRelayDropdown(bounceId);
} else if (select.value) {
// Hide manual input and use selected thrower
manualDiv.classList.add('hidden');
input.value = select.value;
console.log('INFO', `Selected thrower for bounce ${bounceId}: ${select.value}`);
// Populate relay dropdown based on selected thrower
populateRelayDropdown(bounceId, select.value);
} else {
// No selection - hide manual input and clear relay dropdown
manualDiv.classList.add('hidden');
input.value = '';
clearRelayDropdown(bounceId);
}
}
// Handle manual thrower pubkey input
function onThrowerPubkeyInput(bounceId) {
const input = document.getElementById(`thrower-pubkey-${bounceId}`);
const select = document.getElementById(`thrower-select-${bounceId}`);
if (input.value) {
// Ensure dropdown stays on manual option
if (select.value !== '__manual__') {
select.value = '__manual__';
}
console.log('INFO', `Manual thrower input for bounce ${bounceId}: ${input.value}`);
// Try to populate relay dropdown based on manual input
let pubkey = input.value.trim();
// If it starts with npub, convert to hex for lookup
if (pubkey.startsWith('npub')) {
try {
pubkey = window.NostrTools.nip19.decode(pubkey).data;
} catch (error) {
clearRelayDropdown(bounceId);
return;
}
}
// Find thrower in discovered list and populate relays
if (pubkey && /^[0-9a-fA-F]{64}$/.test(pubkey)) {
populateRelayDropdown(bounceId, pubkey);
} else {
clearRelayDropdown(bounceId);
}
} else {
clearRelayDropdown(bounceId);
}
}
// Get thrower pubkey for a bounce (from dropdown or manual input)
function getThrowerPubkeyForBounce(bounceId) {
const select = document.getElementById(`thrower-select-${bounceId}`);
const input = document.getElementById(`thrower-pubkey-${bounceId}`);
let pubkey = '';
// Check if using manual input or dropdown selection
if (select.value === '__manual__') {
// Get from manual input field
pubkey = input.value.trim();
} else if (select.value) {
// Get from dropdown selection
pubkey = select.value;
} else {
return null;
}
// If it starts with npub, convert to hex
if (pubkey.startsWith('npub')) {
try {
pubkey = window.NostrTools.nip19.decode(pubkey).data;
console.log('INFO', `Converted npub to hex: ${pubkey}`);
} catch (error) {
console.log('ERROR', `Invalid npub format: ${error.message}`);
return null;
}
}
// Validate hex format
if (pubkey && /^[0-9a-fA-F]{64}$/.test(pubkey)) {
return pubkey;
}
return null;
}
// Populate relay dropdown with relays the selected thrower can read from
function populateRelayDropdown(bounceId, throwerPubkey) {
const relaySelect = document.getElementById(`relay-select-${bounceId}`);
const relayInput = document.getElementById(`bounce-relays-${bounceId}`);
const relayManualDiv = document.getElementById(`relay-manual-${bounceId}`);
if (!relaySelect) return;
// Clear existing options and reset to base state
relaySelect.innerHTML = '<option value="">-- Select a relay --</option>';
// Find the thrower by pubkey
const thrower = discoveredThrowers.find(t => t.pubkey === throwerPubkey);
if (!thrower || !thrower.relayList || !thrower.relayList.relays) {
relaySelect.innerHTML = '<option value="">-- No relay information for this thrower --</option><option value="__manual__">⊕ Add manually (enter relay URL)</option>';
// Show manual option since no relay info is available
const manualOption = relaySelect.querySelector('option[value="__manual__"]');
manualOption.classList.remove('hidden');
console.log('WARN', `No relay information found for thrower: ${throwerPubkey}`);
return;
}
// Get relays the thrower can read from (read or both)
const readableRelays = thrower.relayList.relays.filter(r => r.type === 'read' || r.type === 'both');
// Get relays the thrower can write to (write or both)
const writableRelays = thrower.relayList.relays.filter(r => r.type === 'write' || r.type === 'both');
if (readableRelays.length === 0) {
relaySelect.innerHTML = '<option value="">-- This thrower cannot read from any relays --</option><option value="__manual__">⊕ Add manually (enter relay URL)</option>';
// Show manual option since thrower has no readable relays
const manualOption = relaySelect.querySelector('option[value="__manual__"]');
manualOption.classList.remove('hidden');
console.log('WARN', `Thrower ${thrower.name} cannot read from any relays`);
return;
}
// Add "All available relays" option if thrower has writable relays
if (writableRelays.length > 0) {
const allRelaysOption = document.createElement('option');
allRelaysOption.value = '__all_relays__';
allRelaysOption.textContent = `📡 All available relays (${writableRelays.length})`;
relaySelect.appendChild(allRelaysOption);
}
// Add readable relays to dropdown
readableRelays.forEach(relay => {
const option = document.createElement('option');
option.value = relay.url;
option.textContent = relay.url;
relaySelect.appendChild(option);
});
// Add manual option at the end
const manualOption = document.createElement('option');
manualOption.value = '__manual__';
manualOption.textContent = '⊕ Add manually (enter relay URL)';
relaySelect.appendChild(manualOption);
// Clear manual input and hide manual div
relayInput.value = '';
relayManualDiv.classList.add('hidden');
console.log('INFO', `Populated relay dropdown for bounce ${bounceId} with ${readableRelays.length} readable relays and ${writableRelays.length} writable relays from ${thrower.name}`);
}
// Clear relay dropdown
function clearRelayDropdown(bounceId) {
const relaySelect = document.getElementById(`relay-select-${bounceId}`);
const relayManualDiv = document.getElementById(`relay-manual-${bounceId}`);
const relayInput = document.getElementById(`bounce-relays-${bounceId}`);
if (relaySelect) {
relaySelect.innerHTML = '<option value="">-- Select a thrower first --</option>';
}
if (relayManualDiv) {
relayManualDiv.classList.add('hidden');
}
if (relayInput) {
relayInput.value = '';
}
}
// Handle relay selection from dropdown
function onRelaySelect(bounceId) {
const select = document.getElementById(`relay-select-${bounceId}`);
const input = document.getElementById(`bounce-relays-${bounceId}`);
const manualDiv = document.getElementById(`relay-manual-${bounceId}`);
if (select.value === '__manual__') {
// Show manual input field
manualDiv.classList.remove('hidden');
input.value = '';
input.focus();
console.log('INFO', `Manual relay input selected for bounce ${bounceId}`);
} else if (select.value === '__all_relays__') {
// Hide manual input and populate with all writable relays
manualDiv.classList.add('hidden');
// Get the thrower pubkey for this bounce
const throwerPubkey = getThrowerPubkeyForBounce(bounceId);
if (throwerPubkey) {
const allRelaysValue = getAllWritableRelaysForThrower(throwerPubkey);
input.value = allRelaysValue;
console.log('INFO', `Selected all available relays for bounce ${bounceId}: ${allRelaysValue}`);
} else {
console.log('WARN', `Cannot get all relays - no thrower selected for bounce ${bounceId}`);
input.value = '';
}
} else if (select.value) {
// Hide manual input and use selected relay
manualDiv.classList.add('hidden');
input.value = select.value;
console.log('INFO', `Selected relay for bounce ${bounceId}: ${select.value}`);
} else {
// No selection - hide manual input
manualDiv.classList.add('hidden');
input.value = '';
}
}
// Handle manual relay input
function onRelayInput(bounceId) {
const input = document.getElementById(`bounce-relays-${bounceId}`);
const select = document.getElementById(`relay-select-${bounceId}`);
if (input.value) {
// Ensure dropdown stays on manual option
if (select.value !== '__manual__') {
select.value = '__manual__';
}
console.log('INFO', `Manual relay input for bounce ${bounceId}: ${input.value}`);
}
}
// Get all writable relays for a thrower as comma-separated string
function getAllWritableRelaysForThrower(throwerPubkey) {
const thrower = discoveredThrowers.find(t => t.pubkey === throwerPubkey);
if (!thrower || !thrower.relayList || !thrower.relayList.relays) {
console.log('WARN', `No relay information found for thrower: ${throwerPubkey}`);
return '';
}
// Get relays the thrower can write to (write or both)
const writableRelays = thrower.relayList.relays.filter(r => r.type === 'write' || r.type === 'both');
if (writableRelays.length === 0) {
console.log('WARN', `Thrower ${thrower.name} cannot write to any relays`);
return '';
}
// Return comma-separated list of relay URLs
const relayUrls = writableRelays.map(r => r.url);
console.log('INFO', `Found ${relayUrls.length} writable relays for thrower ${thrower.name}: ${relayUrls.join(', ')}`);
return relayUrls.join(', ');
}
// Decrypt and display bounce content for testing
function decryptBounce(bounceId) {
try {
// Find the bounce data
const bounce = bounces.find(b => b.id === bounceId);
if (!bounce) {
alert('Bounce data not found');
return;
}
// Get the routing event content (encrypted payload)
const routingEvent = bounce.routingEvent;
const encryptedContent = routingEvent.content;
// Get the ephemeral key used for encryption
const ephemeralKeyHex = bounce.ephemeralKey;
const throwerPubkey = bounce.throwerPubkey;
console.log('DEBUG: Decrypting bounce', bounceId);
console.log('DEBUG: Ephemeral key:', ephemeralKeyHex);
console.log('DEBUG: Thrower pubkey:', throwerPubkey);
console.log('DEBUG: Encrypted content length:', encryptedContent.length);
// Recreate conversation key
const conversationKey = window.NostrTools.nip44.v2.utils.getConversationKey(
ephemeralKeyHex,
throwerPubkey
);
// Decrypt the content
const decryptedJson = window.NostrTools.nip44.v2.decrypt(
encryptedContent,
conversationKey
);
// Parse and display the decrypted payload
const decryptedPayload = JSON.parse(decryptedJson);
document.getElementById(`bounce-${bounceId}-decrypted-content`).textContent = JSON.stringify(decryptedPayload, null, 2);
document.getElementById(`bounce-${bounceId}-decrypted`).style.display = 'block';
console.log('SUCCESS: Bounce decrypted successfully');
console.log('Decrypted payload:', decryptedPayload);
} catch (error) {
console.log('ERROR', `Failed to decrypt bounce ${bounceId}: ${error.message}`);
alert(`Failed to decrypt bounce ${bounceId}: ${error.message}`);
}
}
// Add a new bounce section
function addBounce() {
if (!finalEvent && bounces.length === 0) {
alert('Please create the final event first');
return;
}
// Hide the Add Bounce button until this bounce is created
document.getElementById('add-bounce-btn').classList.add('hidden');
bounceCounter++;
const bounceId = bounceCounter;
const bounceSection = document.createElement('div');
bounceSection.className = 'section bounce-section';
bounceSection.id = `bounce-${bounceId}`;
bounceSection.innerHTML = `
<h2 id="bounce-${bounceId}-header">Bounce ${bounceId} (Kind 22222 Routing Event)</h2>
<div class="input-group">
<label for="thrower-select-${bounceId}">Thrower:</label>
<select id="thrower-select-${bounceId}" onchange="onThrowerSelect(${bounceId})" style="width: 100%; margin-bottom: 10px;">
<option value="">-- Select from discovered throwers --</option>
<option value="__manual__">⊕ Add manually (enter pubkey)</option>
</select>
<div id="thrower-manual-${bounceId}" class="hidden" style="margin-top: 10px;">
<input type="text" id="thrower-pubkey-${bounceId}" placeholder="Enter npub... or hex pubkey" style="width: 100%;" oninput="onThrowerPubkeyInput(${bounceId})">
<small style="color: #666; font-size: 11px; display: block; margin-top: 5px;">Supports both npub... (bech32) and hex formats</small>
</div>
</div>
<div class="input-group">
<label for="relay-select-${bounceId}">Target Relay:</label>
<select id="relay-select-${bounceId}" onchange="onRelaySelect(${bounceId})" style="width: 100%; margin-bottom: 10px;">
<option value="">-- Select a thrower first --</option>
<option value="__manual__" class="hidden">⊕ Add manually (enter relay URL)</option>
</select>
<div id="relay-manual-${bounceId}" class="hidden" style="margin-top: 10px;">
<input type="text" id="bounce-relays-${bounceId}" placeholder="wss://relay.example.com" oninput="onRelayInput(${bounceId})">
<small style="color: #666; font-size: 11px; display: block; margin-top: 5px;">Enter the WebSocket URL of the relay</small>
</div>
</div>
<div class="input-group">
<label for="delay-${bounceId}">Delay (seconds):</label>
<input type="number" id="delay-${bounceId}" class="small-input" value="30" min="1">
</div>
<div class="input-group">
<label for="padding-${bounceId}">Add Padding Bytes to This Event:</label>
<input type="number" id="padding-${bounceId}" class="small-input" placeholder="150" min="0" step="1">
</div>
<div class="input-group" id="daemon-padding-${bounceId}">
<label for="add-padding-bytes-${bounceId}">Instruct Next Daemon to Add Padding (bytes):</label>
<input type="number" id="add-padding-bytes-${bounceId}" class="small-input" placeholder="256" min="0" step="1">
<small style="color: #666; font-size: 12px;">Only used when forwarding to another Superball</small>
</div>
<div class="input-group">
<label for="payment-${bounceId}">Payment (optional):</label>
<input type="text" id="payment-${bounceId}" placeholder="eCash token or payment info">
</div>
<div class="input-group">
<label for="audit-tag-${bounceId}">Audit Tag (auto-generated):</label>
<input type="text" id="audit-tag-${bounceId}" readonly style="background: #f5f5f5;">
</div>
<button onclick="createBounce(${bounceId})" id="create-bounce-btn-${bounceId}">Create Bounce ${bounceId}</button>
<div id="bounce-${bounceId}-display" class="json-display"></div>
<div style="text-align: right; margin-top: 5px;">
<button onclick="decryptBounce(${bounceId})" id="decrypt-btn-${bounceId}" style="display:none; width:auto; padding:8px 15px;">Decrypt Content</button>
</div>
<div id="bounce-${bounceId}-decrypted" class="json-display" style="display:none;">
<h4>Decrypted Payload:</h4>
<div id="bounce-${bounceId}-decrypted-content"></div>
</div>
`;
document.getElementById('bounces-container').appendChild(bounceSection);
// Automatically generate and fill in the audit tag
const auditTag = generateAuditTag();
document.getElementById(`audit-tag-${bounceId}`).value = auditTag;
// Hide daemon padding field for final bounce (first one created)
if (bounces.length === 0) {
// This is the final bounce - hide daemon padding field since it makes no sense
const daemonPaddingDiv = document.getElementById(`daemon-padding-${bounceId}`);
if (daemonPaddingDiv) {
daemonPaddingDiv.style.display = 'none';
}
}
// Populate thrower dropdown with discovered throwers
populateThrowerDropdown(bounceId);
// Update bounce labels to reflect execution order
updateBounceLabels();
}
// Update bounce labels to reflect execution order (newest bounce is Bounce 1, oldest is Bounce N)
function updateBounceLabels() {
// Get all existing bounce sections
const bounceContainer = document.getElementById('bounces-container');
const bounceSections = bounceContainer.querySelectorAll('.bounce-section');
// Update labels in reverse order (newest first gets Bounce 1)
bounceSections.forEach((section, index) => {
const bounceId = section.id.replace('bounce-', '');
const executionOrder = bounceSections.length - index; // Reverse the index
// Update the header
const header = document.getElementById(`bounce-${bounceId}-header`);
if (header) {
header.textContent = `Bounce ${executionOrder} (Kind 22222 Routing Event)`;
}
// Update the create button text
const createBtn = document.getElementById(`create-bounce-btn-${bounceId}`);
if (createBtn) {
createBtn.textContent = `Create Bounce ${executionOrder}`;
}
});
console.log('INFO: Updated bounce labels for execution order');
}
// Update all timeline absolute times continuously
function updateAllTimelineTimes() {
// Recalculate and update absolute times in the visualization timeline
if (bounces.length === 0 || !finalEvent) {
return;
}
// Recalculate the current event flow with updated timestamps
const currentEventFlow = calculateEventFlow();
// Update each timeline step
const timelineSteps = document.querySelectorAll('.step-time-absolute');
timelineSteps.forEach((element, index) => {
if (currentEventFlow[index]) {
const newTime = formatAbsoluteTime(currentEventFlow[index].time);
element.textContent = newTime;
}
});
}
// Start continuous time updates
function startTimeUpdates() {
// Update every second
setInterval(updateAllTimelineTimes, 1000);
}
// Create a bounce event
async function createBounce(bounceId) {
const throwerPubkey = getThrowerPubkeyForBounce(bounceId);
const relayInput = document.getElementById(`bounce-relays-${bounceId}`).value.trim();
// Parse comma-separated relay list
const bounceRelays = relayInput ? relayInput.split(',').map(r => r.trim()).filter(r => r.length > 0) : [];
const delay = parseInt(document.getElementById(`delay-${bounceId}`).value);
const builderPadding = parseInt(document.getElementById(`padding-${bounceId}`).value) || 0;
const addPaddingBytes = parseInt(document.getElementById(`add-padding-bytes-${bounceId}`).value) || 0;
const payment = document.getElementById(`payment-${bounceId}`).value.trim();
// Debug the input values
console.log('DEBUG: Input values:');
console.log(' - throwerPubkey:', throwerPubkey);
console.log(' - bounceRelays:', bounceRelays);
console.log(' - delay:', delay);
console.log(' - builderPadding:', builderPadding);
console.log(' - addPaddingBytes:', addPaddingBytes);
console.log(' - payment:', `"${payment}"`);
if (!throwerPubkey || throwerPubkey.length !== 64) {
alert('Please select a thrower or enter a valid thrower pubkey (64 hex characters)');
return;
}
if (!bounceRelays.length) {
alert('Please select a target relay or enter one manually');
return;
}
if (!delay || delay < 1) {
alert('Please enter a valid delay (minimum 1 second)');
return;
}
try {
// Determine what event to wrap
let eventToWrap;
let targetRelays;
let isLastBounce = (bounces.length === 0);
if (isLastBounce) {
// This is the first bounce - wrap the final event
eventToWrap = finalEvent;
targetRelays = bounceRelays; // Use the bounce relays for final posting
} else {
// This bounce wraps the previous bounce
eventToWrap = bounces[bounces.length - 1].routingEvent;
targetRelays = bounceRelays;
}
// Use the pre-generated audit tag from the field
const auditTag = document.getElementById(`audit-tag-${bounceId}`).value;
// Create routing instructions
const routingInstructions = {
relays: targetRelays,
delay: delay,
audit: auditTag
};
// Add daemon instruction to add padding when forwarding (if specified and not final bounce)
if (!isLastBounce && addPaddingBytes > 0) {
routingInstructions.add_padding_bytes = addPaddingBytes;
console.log('DEBUG: Added add_padding_bytes instruction:', addPaddingBytes);
}
if (payment) {
routingInstructions.payment = payment;
}
console.log('DEBUG: Final routing instructions:', routingInstructions);
// Only add 'p' field if this isn't the last bounce
if (!isLastBounce) {
// Get the thrower pubkey from the previous bounce (the next hop in the chain)
const prevBounce = bounces[bounces.length - 1];
routingInstructions.p = prevBounce.throwerPubkey;
}
// Note: If this IS the last bounce (first one created), no 'p' field means final posting
// Add builder padding to routing instructions (if specified)
if (builderPadding > 0) {
// Generate counting padding string (0123456789012...)
let paddingString = '';
for (let i = 0; i < builderPadding; i++) {
paddingString += (i % 10).toString();
}
routingInstructions.padding = paddingString;
console.log('DEBUG: Added', builderPadding, 'bytes of builder padding to routing instructions');
}
// Create the payload to encrypt
const payload = {
event: eventToWrap,
routing: routingInstructions
};
// Generate ephemeral key for this routing event
const ephemeralKey = window.NostrTools.generateSecretKey();
const ephemeralPubkey = window.NostrTools.getPublicKey(ephemeralKey);
// Encrypt payload using NIP-44
// Convert ephemeral key to hex - try different API methods
let ephemeralKeyHex;
if (window.NostrTools.utils && window.NostrTools.utils.bytesToHex) {
ephemeralKeyHex = window.NostrTools.utils.bytesToHex(ephemeralKey);
} else if (window.NostrTools.bytesToHex) {
ephemeralKeyHex = window.NostrTools.bytesToHex(ephemeralKey);
} else {
// Fallback: convert Uint8Array to hex manually
ephemeralKeyHex = Array.from(ephemeralKey).map(b => b.toString(16).padStart(2, '0')).join('');
}
console.log('DEBUG: Ephemeral key hex:', ephemeralKeyHex);
console.log('DEBUG: Thrower pubkey:', throwerPubkey);
console.log('DEBUG: Payload size:', JSON.stringify(payload).length);
const conversationKey = window.NostrTools.nip44.v2.utils.getConversationKey(
ephemeralKeyHex,
throwerPubkey
);
const encryptedContent = window.NostrTools.nip44.v2.encrypt(
JSON.stringify(payload),
conversationKey
);
console.log('DEBUG: Encryption successful, content length:', encryptedContent.length);
// Create routing event template
const routingEventTemplate = {
kind: 22222,
content: encryptedContent,
tags: [
['p', throwerPubkey],
['p', auditTag] // Audit tag disguised as p tag
],
created_at: Math.floor(Date.now() / 1000)
};
// Sign the routing event with ephemeral key
const routingEvent = window.NostrTools.finalizeEvent(routingEventTemplate, ephemeralKey);
// Store the bounce
const bounce = {
id: bounceId,
throwerPubkey: throwerPubkey,
auditTag: auditTag,
routingEvent: routingEvent,
payload: payload,
ephemeralKey: ephemeralKeyHex
};
bounces.push(bounce);
// Display the bounce event
document.getElementById(`bounce-${bounceId}-display`).textContent = JSON.stringify(routingEvent, null, 2);
// Show the decrypt button now that bounce is created
document.getElementById(`decrypt-btn-${bounceId}`).style.display = 'inline-block';
// Generate and show visualization
generateVisualization();
console.log('SUCCESS', `Bounce ${bounceId} created successfully`);
// Show the Add Bounce button again now that this bounce is completed
document.getElementById('add-bounce-btn').classList.remove('hidden');
// Update bounce labels after creation to reflect execution order
updateBounceLabels();
} catch (error) {
console.log('ERROR', `Failed to create bounce ${bounceId}: ${error.message}`);
alert(`Failed to create bounce ${bounceId}: ${error.message}`);
// Show the Add Bounce button again even on error so user can try again or add different bounce
document.getElementById('add-bounce-btn').classList.remove('hidden');
}
}
// Generate event flow visualization
function generateVisualization() {
if (bounces.length === 0 || !finalEvent) {
return;
}
// Remove existing visualization
const existingViz = document.querySelector('.visualization');
if (existingViz) {
existingViz.remove();
}
// Calculate event flow
const eventFlow = calculateEventFlow();
// Create visualization container
const vizContainer = document.createElement('div');
vizContainer.className = 'visualization';
vizContainer.innerHTML = `
<h2>Event Flow Visualization</h2>
<p>This shows how your message will travel through the Superball anonymity network:</p>
<div class="timeline">
${generateThrowButtonHtml()}
${generateTimelineHtml(eventFlow)}
</div>
`;
// Insert after bounces container
const bouncesContainer = document.getElementById('bounces-container');
bouncesContainer.parentNode.insertBefore(vizContainer, bouncesContainer.nextSibling);
}
// Generate the "Throw the Superball" button HTML
function generateThrowButtonHtml() {
return `
<div class="timeline-step throw-button" style="background: white; border: 4px solid #4a90e2; cursor: pointer;" onclick="throwSuperball()">
<div class="step-time">
<div class="step-time-relative">Ready</div>
<div class="step-time-absolute" style="font-size: 10px; color: #888;">Click to start</div>
</div>
<div class="step-actor" style="color: #2c5aa0; font-weight: bold;">Throw Superball</div>
<div class="step-action" style="font-weight: bold;">Publish your routing event to start the anonymity chain</div>
<div class="step-size">Click here</div>
</div>
`;
}
// Throw the Superball - publish the outermost routing event to start the chain
async function throwSuperball() {
if (!bounces.length || !finalEvent) {
alert('Please create your final event and at least one bounce before throwing the Superball');
return;
}
if (!userPubkey) {
alert('Please log in first');
return;
}
try {
// Get the last (outermost) bounce to publish - the most recently created bounce
const outermostBounce = bounces[bounces.length - 1];
const routingEvent = outermostBounce.routingEvent;
// Get relays to publish to - use the outermost bounce's target relays
const targetRelays = outermostBounce.payload.routing.relays;
if (!targetRelays || targetRelays.length === 0) {
alert('No target relays configured for the first bounce');
return;
}
console.log('INFO: Publishing Superball routing event to relays:', targetRelays);
console.log('DEBUG: Event to publish:', JSON.stringify(routingEvent, null, 2));
// Update the throw button to show it's in progress
const throwButton = document.querySelector('.throw-button');
const throwButtonText = throwButton.querySelector('.step-action');
const originalText = throwButtonText.textContent;
throwButtonText.textContent = 'Publishing routing event...';
throwButton.style.pointerEvents = 'none';
throwButton.style.opacity = '0.7';
// Create a pool for publishing
const pool = new window.NostrTools.SimplePool();
try {
// Publish to each relay individually for detailed per-relay feedback
console.log('DEBUG: Publishing to each relay individually for detailed feedback');
// Get the publish promises for all relays
const publishPromises = pool.publish(targetRelays, routingEvent);
// Wait for all publish attempts to complete
const results = await Promise.allSettled(publishPromises);
let successCount = 0;
let failureCount = 0;
// Process results and log per-relay status
results.forEach((result, index) => {
const relay = targetRelays[index];
if (result.status === 'fulfilled') {
console.log(`✅ SUCCESS: Published to ${relay}`);
successCount++;
} else {
const errorMsg = result.reason?.message || 'Unknown error';
console.log(`❌ FAILED: ${relay} - ${errorMsg}`);
failureCount++;
}
});
pool.close(targetRelays);
// Success if at least one relay accepted the event
if (successCount > 0) {
// Success! Update the UI
throwButtonText.innerHTML = '<strong>Superball Thrown Successfully!</strong><br><small>Your message is now in the anonymity network</small>';
throwButton.style.background = '#d4edda';
throwButton.style.borderColor = '#28a745';
throwButton.onclick = null; // Disable further clicks
console.log(`SUCCESS: Superball routing event published to ${successCount}/${targetRelays.length} relays`);
} else {
// All relays failed
const errorMessages = results.map((result, index) => {
const relay = targetRelays[index];
const errorMsg = result.reason?.message || 'Unknown error';
return `${relay}: ${errorMsg}`;
}).join(', ');
// Restore button state
throwButtonText.textContent = originalText;
throwButton.style.pointerEvents = '';
throwButton.style.opacity = '';
throw new Error('Failed to publish Superball to any relay: ' + errorMessages);
}
} catch (error) {
pool.close(targetRelays);
// Restore button state
throwButtonText.textContent = originalText;
throwButton.style.pointerEvents = '';
throwButton.style.opacity = '';
throw error;
}
} catch (error) {
console.log('ERROR', `Failed to throw Superball: ${error.message}`);
// Restore button if it was modified
const throwButton = document.querySelector('.throw-button');
if (throwButton) {
const throwButtonText = throwButton.querySelector('.step-action');
if (throwButtonText && throwButtonText.textContent.includes('Publishing')) {
throwButtonText.textContent = 'Publish your routing event to start the anonymity chain';
throwButton.style.pointerEvents = '';
throwButton.style.opacity = '';
}
}
alert(`Failed to throw Superball: ${error.message}\n\nPlease check your internet connection and relay configuration, then try again.`);
}
}
// Calculate the complete event flow with timing and sizes
function calculateEventFlow() {
const flow = [];
const baseTime = Date.now();
// Get the user's name from the profile section
const userNameElement = document.getElementById('profile-name');
const userName = userNameElement && userNameElement.textContent && userNameElement.textContent !== 'Loading profile...' && userNameElement.textContent !== 'No profile found' && userNameElement.textContent !== 'Error loading profile'
? userNameElement.textContent
: 'User';
// Start with user sending the outermost bounce
let currentTime = baseTime;
// Work forward through bounces (in order they were created)
bounces.forEach((bounce, index) => {
const bounceNumber = index + 1;
const isFirst = (index === 0);
const isLast = (index === bounces.length - 1);
if (isFirst) {
// User sends the outermost routing event (first bounce created)
const routingEventSize = JSON.stringify(bounce.routingEvent).length;
const relays = getRelaysForBounce(bounceNumber);
// Step 1: User sends to relay
flow.push({
time: currentTime,
actor: userName,
action: `Publishes routing event`,
size: routingEventSize
});
// Step 2: Relay propagates (immediate)
currentTime += 2000; // 2 seconds for relay propagation
flow.push({
time: currentTime,
actor: `Relay (${relays.join(', ')})`,
action: `Event available for Superball ${bounceNumber}`,
size: routingEventSize
});
}
// Add the delay for this superball to process
const delayMs = getDelayForBounce(bounceNumber) * 1000;
currentTime += delayMs;
// Superball processes and forwards
const paddingAdjustment = getPaddingAdjustmentForBounce(bounceNumber);
if (isLast) {
// Last bounce - posts final event
const finalEventSize = JSON.stringify(finalEvent).length + paddingAdjustment;
const finalRelays = getRelaysForBounce(bounceNumber);
// Step 3: Superball decrypts and sends final message
flow.push({
time: currentTime,
actor: `Superball ${bounceNumber}`,
action: `Decrypts and publishes your original message`,
size: Math.max(finalEventSize, 0)
});
// Step 4: Final relay makes message public
currentTime += 2000; // 2 seconds for relay propagation
flow.push({
time: currentTime,
actor: `Relay (${finalRelays.join(', ')})`,
action: `Your message is now publicly visible`,
size: Math.max(finalEventSize, 0)
});
} else {
// Intermediate bounce - forwards to next superball
const nextBounce = bounces[index + 1];
const nextRoutingSize = JSON.stringify(nextBounce.routingEvent).length + paddingAdjustment;
const nextRelays = getRelaysForBounce(bounceNumber + 1); // Next superball's relays
// Step 3: Superball forwards to next relay
flow.push({
time: currentTime,
actor: `Superball ${bounceNumber}`,
action: `Forwards routing event to next hop`,
size: Math.max(nextRoutingSize, 0)
});
// Step 4: Relay propagates to next superball
currentTime += 2000; // 2 seconds for relay propagation
flow.push({
time: currentTime,
actor: `Relay (${nextRelays.join(', ')})`,
action: `Event available for Superball ${bounceNumber + 1}`,
size: Math.max(nextRoutingSize, 0)
});
}
});
return flow;
}
// Generate HTML for timeline visualization
function generateTimelineHtml(eventFlow) {
return eventFlow.map((step, index) => `
<div class="timeline-step" id="timeline-step-${index}">
<div class="step-time">
<div class="step-time-relative">${formatTime(step.time)}</div>
<div class="step-time-absolute" style="font-size: 10px; color: #888;">${formatAbsoluteTime(step.time)}</div>
</div>
<div class="step-actor">${step.actor}</div>
<div class="step-action">${step.action}</div>
<div class="step-size">${step.size} bytes</div>
</div>
`).join('');
}
// Format absolute time (HH:MM:SS)
function formatAbsoluteTime(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
// Helper functions for bounce data extraction
function getRelaysForBounce(bounceNumber) {
const bounceIndex = bounceNumber - 1;
if (bounceIndex < 0 || bounceIndex >= bounces.length) return [];
// Get relays from the bounce's routing instructions
const bounce = bounces[bounceIndex];
if (bounce.payload && bounce.payload.routing && bounce.payload.routing.relays) {
return bounce.payload.routing.relays;
}
return ['Unknown relay'];
}
function getDelayForBounce(bounceNumber) {
const bounceIndex = bounceNumber - 1;
if (bounceIndex < 0 || bounceIndex >= bounces.length) return 30;
const bounce = bounces[bounceIndex];
if (bounce.payload && bounce.payload.routing && bounce.payload.routing.delay) {
return bounce.payload.routing.delay;
}
return 30; // default delay
}
function getPaddingAdjustmentForBounce(bounceNumber) {
const bounceIndex = bounceNumber - 1;
if (bounceIndex < 0 || bounceIndex >= bounces.length) return 0;
const bounce = bounces[bounceIndex];
if (bounce.payload && bounce.payload.routing && bounce.payload.routing.padding) {
const padding = bounce.payload.routing.padding;
if (typeof padding === 'string' && padding.match(/^[+-]\d+$/)) {
const match = padding.match(/^([+-])(\d+)$/);
const operation = match[1];
const bytes = parseInt(match[2]);
return operation === '+' ? bytes : -bytes;
} else if (typeof padding === 'string') {
// Actual padding string - return its length
return padding.length;
}
}
return 0;
}
// Format timestamp for display
function formatTime(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const diffSeconds = Math.floor((timestamp - now.getTime()) / 1000);
if (diffSeconds <= 0) {
return 'Now';
} else if (diffSeconds < 60) {
return `+${diffSeconds}s`;
} else if (diffSeconds < 3600) {
const minutes = Math.floor(diffSeconds / 60);
const seconds = diffSeconds % 60;
return `+${minutes}m ${seconds}s`;
} else {
const hours = Math.floor(diffSeconds / 3600);
const minutes = Math.floor((diffSeconds % 3600) / 60);
return `+${hours}h ${minutes}m`;
}
}
// Reset the builder to start over (keeps login session)
function resetBuilder() {
// Clear all global variables
finalEvent = null;
bounces = [];
bounceCounter = 0;
// Clear UI elements
document.getElementById('final-content').value = '';
document.getElementById('final-event-display').textContent = '';
document.getElementById('bounces-container').innerHTML = '';
// Hide the Add Bounce button since no final event exists
document.getElementById('add-bounce-btn').classList.add('hidden');
document.getElementById('bounce-controls').classList.add('hidden');
document.getElementById('reset-controls').classList.add('hidden');
document.getElementById('reset-controls').style.display = 'none';
// Remove visualization if it exists
const existingViz = document.querySelector('.visualization');
if (existingViz) {
existingViz.remove();
}
console.log('INFO: Builder reset successfully');
}
// Toggle thrower details visibility
function toggleThrowerDetails(index) {
const detailsSection = document.getElementById(`details-${index}`);
const triangle = document.getElementById(`triangle-${index}`);
if (detailsSection.classList.contains('collapsed')) {
// Expand
detailsSection.classList.remove('collapsed');
detailsSection.classList.add('expanded');
triangle.classList.add('expanded');
} else {
// Collapse
detailsSection.classList.remove('expanded');
detailsSection.classList.add('collapsed');
triangle.classList.remove('expanded');
}
}
document.addEventListener('DOMContentLoaded', () => {
setTimeout(initializeApp, 100);
// Start the continuous time updates
startTimeUpdates();
});
</script>
</body>
</html>