1856 lines
71 KiB
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> |