Files
super_ball/web/superball.html
ciajaf 59b0461bad Added custom events and updated reply events handling
Implemented support for creating custom events. Also integrated functionality to generate reply events by retrieving them from relays first, with compatibility for multiple eventId formats like nevent, note, and hexadecimal strings.
2025-11-22 11:24:50 +00:00

2270 lines
88 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">
<div class="tabs">
<div class="tab active" data-tab="tab1">Post</div>
<div class="tab" data-tab="tab2">Reply</div>
<div class="tab" data-tab="tab3">Create/Edit Profile</div>
<div class="tab" data-tab="tab4">Custom Event</div>
</div>
<div class="tab-content active" id="tab1">
<h3>Post</h3>
<label for="post-content">Message Content:</label>
<textarea id="post-content" rows="3" placeholder="Enter your message content..."></textarea>
</div>
<div class="tab-content" id="tab2">
<h3>Reply</h3>
<label for="reply-id">EventId/NoteId/Nevent:</label>
<textarea id="reply-id" placeholder="Enter the nevent for the note..."></textarea>
<label for="reply-content">Message Content:</label>
<textarea id="reply-content" rows="3" placeholder="Enter your message content..."></textarea>
</div>
<div class="tab-content" id="tab3">
<h3>Create Profile</h3>
<label for="name">Name:</label>
<textarea id="name" placeholder="Enter your name..."></textarea>
<label for="about">About:</label>
<textarea id="about" rows="2" placeholder="A short bio..."></textarea>
<label for="profile-pic">Profile Picture:</label>
<textarea id="profile-pic" placeholder="URL of your profile pic..."></textarea>
<label for="display-name">Display Name:</label>
<textarea id="display-name" placeholder="Enter your display name..."></textarea>
<label for="website">Website:</label>
<textarea id="website" placeholder="Web URL..."></textarea>
<label for="banner">Banner:</label>
<textarea id="banner" placeholder="Enter your bannerm a (~1024x768) wide picture url..."></textarea>
<label for="nip05">NIP05:</label>
<textarea id="nip05" placeholder="Enter your nip05 in the format username@domain.com..."></textarea>
<label for="lud16">Lightning Address:</label>
<textarea id="lud16" placeholder="Enter your lightning address..."></textarea>
</div>
<div class="tab-content" id="tab4">
<h3>Custom Event</h3>
<label for="kind">Event Kind:</label>
<textarea id="kind" placeholder="Enter your event kind. Ex: 0, 1, 30000..."></textarea>
<label for="content">Content:</label>
<textarea id="content" rows="3" placeholder="A short bio..."></textarea>
<label for="tags">Tags:</label>
<textarea id="tags" rows="3" placeholder="Tags in format [['p', 'pubkey...'], ['e', 'event_id', wss://nos.lol, 'pubkey...']]"></textarea>
</div>
</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>
document.addEventListener('DOMContentLoaded', function() {
const tabs = document.querySelectorAll('.tab');
const tabContents = document.querySelectorAll('.tab-content');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
// Remove active class from all tabs and contents
tabs.forEach(t => t.classList.remove('active'));
tabContents.forEach(c => c.classList.remove('active'));
// Add active class to clicked tab
tab.classList.add('active');
// Show corresponding content
const tabId = tab.getAttribute('data-tab');
document.getElementById(tabId).classList.add('active');
});
});
});
// 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,
readonly: false,
connect: true,
remote: true,
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' },
{ url: 'wss://relay.primal.net', type: 'both' },
{ url: 'wss://nos.lol', type: 'both' },
{ url: 'wss://relay.damus.io', type: 'both' },
{ url: 'wss://offchain.pub', 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
maxDelay: 86460, // default 1 day + 60 seconds maximum delay
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;
case 'max_delay':
thrower.maxDelay = parseInt(tag[1]) || 86460;
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; object-fit: cover; object-position: center;" 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>Max Delay:</strong></span>
<span>${thrower.maxDelay}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`;
}
}
// Load event from id
async function loadReplyTagsForEvent(id, knownRelays) {
if (!id) return;
console.log('INFO', `Loading event`, id);
try {
const pool = new window.NostrTools.SimplePool();
const relays = [relayUrl, 'wss://relay.laantungir.net', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.damus.io', 'wss://relay.nostr.band'].concat(knownRelays);
// Enable tracking
pool.trackRelays = true;
// Query for an event
const events = await pool.querySync(relays, {
ids: [id],
limit: 1
});
const event = events[0];
let returnEventTags = [];
let relaysSeenOn = [];
if (event) {
const seenRelays = pool.seenOn.get(event.id);
relaysSeenOn = seenRelays ? Array.from(seenRelays).map(r => r.url) : [];
}
pool.close(relays);
if (events.length > 0) {
// event is a nostr event with tags
const refs = window.NostrTools.nip10.parse(events[0])
// get the root event of the thread
if (refs.root) {
const relay = refs.root.relays.length > 0 ? refs.root.relays[0] : '';
if (refs.root.author) {
returnEventTags.push(['e', refs.root.id, relay, 'root', refs.root.author]);
}
else {
returnEventTags.push(['e', refs.root.id, relay, 'root']);
}
returnEventTags.push(['e', id, relaysSeenOn[0], 'reply', event.pubkey]);
if (refs.root.author)
returnEventTags.push(['p', refs.root.author, relay]);
returnEventTags.push(['p', event.pubkey, relaysSeenOn[0]]);
} else {
returnEventTags.push(['e', id, relaysSeenOn[0], 'root']);
returnEventTags.push(['p', event.pubkey]);
}
// get any referenced profiles
for (let profile of refs.profiles) {
if (!returnEventTags.some(tag => tag[0] === 'p' && tag[1] === profile.pubkey)) {
if (profile.relays.length > 0) {
returnEventTags.push(['p', profile.pubkey, profile.relays[0]]);
}
else {
returnEventTags.push(['p', profile.pubkey]);
}
}
}
return [events[0], returnEventTags];
} else {
console.log('INFO', 'Event not found');
return null;
}
} catch (error) {
console.log('ERROR', `Profile loading failed: ${error.message}`);
}
}
// Create final event (kind 1)
async function createFinalEvent() {
// Get the active tab
const activeTab = document.querySelector('.tab.active').getAttribute('data-tab');
// Get content based on active tab
let content = '';
let replyEventId = '';
let replyEvent = {};
let replyTags = [];
let name = '';
let about = '';
let profilePic = '';
let displayName = '';
let website = '';
let banner = '';
let nip05 = '';
let lud16 = '';
let tags = '';
let kind = '';
switch(activeTab) {
case 'tab1': // Post
content = document.getElementById('post-content').value.trim();
break;
case 'tab2': // Reply
content = document.getElementById('reply-content').value.trim();
replyEventId = document.getElementById('reply-id').value.trim();
break;
case 'tab3': // Create Profile
name = document.getElementById('name').value.trim();
about = document.getElementById('about').value.trim();
profilePic = document.getElementById('profile-pic').value.trim();
displayName = document.getElementById('display-name').value.trim();
website = document.getElementById('website').value.trim();
banner = document.getElementById('banner').value.trim();
nip05 = document.getElementById('nip05').value.trim();
lud16 = document.getElementById('lud16').value.trim();
break;
case 'tab4': // Custom Event
kind = document.getElementById('kind').value.trim();
content = document.getElementById('content').value.trim();
tags = document.getElementById('tags').value.trim();
break;
}
// Validate content based on tab
if (activeTab === 'tab1') {
if (!content) {
alert('Please enter message content');
return;
}
} else if (activeTab === 'tab2') {
if (!content) {
alert('Please enter message content');
return;
}
let eventId = '';
let knownRelays = [];
try {
if (replyEventId.startsWith('nevent')) {
const replyEventData = window.NostrTools.nip19.decode(replyEventId).data;
eventId = replyEventData.id;
knownRelays = replyEventData.relays;
} else if (replyEventId.startsWith('note')) {
eventId = window.NostrTools.nip19.decode(replyEventId).data;
} else {
eventId = replyEventId;
}
} catch (error) {
console.error(error);
alert('Error decoding nevent string', error.message);
return;
}
const regex = /^[0-9a-f]{64}$/;
if (!regex.test(eventId)) {
alert('Invalid event ID');
}
try {
[ replyEvent, replyTags ] = await loadReplyTagsForEvent(eventId, knownRelays);
} catch (error) {
alert('Error fetching reply event', error.message);
return;
}
} else if (activeTab === 'tab3') {
if (!name) {
alert('Please enter your name');
return;
}
} else if (activeTab === 'tab4') {
if (!kind) {
alert('Please enter the event kind');
return;
}
if (!/^-?\d+$/.test(kind)) {
alert("Please enter a valid integer for event kind.");
return; // Prevent form submission
}
try {
kind = Number(kind);
tags = JSON.parse(tags.replace(/'/g, '"'))
} catch (error) {
alert('Error parsing tags', error.message);
return;
}
}
try {
let eventTemplate = {};
switch(activeTab) {
case 'tab1': // Post
eventTemplate = {
kind: 1,
content: content,
tags: [],
created_at: Math.floor(Date.now() / 1000)
};
break;
case 'tab2': // Reply
eventTemplate = {
kind: 1,
content: content,
tags: replyTags,
created_at: Math.floor(Date.now() / 1000)
};
break;
case 'tab3': // Create Profile
eventTemplate = {
kind: 0,
content: JSON.stringify({
name: name,
about: about,
picture: profilePic,
display_name: displayName,
website: website,
banner: banner,
nip05: nip05,
lud16: lud16
}),
tags: [],
created_at: Math.floor(Date.now() / 1000)
};
break;
case 'tab4': // Create Profile
eventTemplate = {
kind: kind,
content: content,
tags: tags,
created_at: Math.floor(Date.now() / 1000)
};
break;
}
// Your existing event publishing logic here
console.log('Event to publish:', eventTemplate);
// ... rest of your publishing code
// 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 with real-time online status check
discoveredThrowers.forEach(thrower => {
const option = document.createElement('option');
option.value = thrower.pubkey;
// Always check current online status when populating
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 (with current online status)`);
}
// 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);
// Reset delay constraints for manual input
updateDelayConstraints(bounceId, null);
} 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);
// Update delay constraints based on selected thrower
updateDelayConstraints(bounceId, select.value);
} else {
// No selection - hide manual input and clear relay dropdown
manualDiv.classList.add('hidden');
input.value = '';
clearRelayDropdown(bounceId);
// Reset delay constraints
updateDelayConstraints(bounceId, null);
}
// Check if bounce inputs are valid and update button state
updateCreateBounceButtonState(bounceId);
}
// Check if bounce is ready to be created and update button state
function updateCreateBounceButtonState(bounceId) {
const button = document.getElementById(`create-bounce-btn-${bounceId}`);
if (!button) return;
const throwerPubkey = getThrowerPubkeyForBounce(bounceId);
const relayInput = document.getElementById(`bounce-relays-${bounceId}`);
const hasRelays = relayInput && relayInput.value.trim().length > 0;
const isValid = throwerPubkey && hasRelays;
if (isValid) {
// Enable button
button.disabled = false;
button.style.color = '';
button.style.borderColor = '';
button.style.cursor = '';
} else {
// Disable button
button.disabled = true;
button.style.color = 'var(--muted-color)';
button.style.borderColor = 'var(--muted-color)';
button.style.cursor = 'not-allowed';
}
}
// 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);
updateDelayConstraints(bounceId, null);
return;
}
}
// Find thrower in discovered list and populate relays
if (pubkey && /^[0-9a-fA-F]{64}$/.test(pubkey)) {
populateRelayDropdown(bounceId, pubkey);
updateDelayConstraints(bounceId, pubkey);
} else {
clearRelayDropdown(bounceId);
updateDelayConstraints(bounceId, null);
}
} else {
clearRelayDropdown(bounceId);
updateDelayConstraints(bounceId, null);
}
// Update button state after thrower input change
updateCreateBounceButtonState(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 throw to (write to)
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 write to (write or both) - these are the relays it can throw to
const writableRelays = thrower.relayList.relays.filter(r => r.type === 'write' || r.type === 'both');
if (writableRelays.length === 0) {
relaySelect.innerHTML = '<option value="">-- This thrower cannot throw to any relays --</option><option value="__manual__">⊕ Add manually (enter relay URL)</option>';
// Show manual option since thrower has no writable relays
const manualOption = relaySelect.querySelector('option[value="__manual__"]');
manualOption.classList.remove('hidden');
console.log('WARN', `Thrower ${thrower.name} cannot throw to 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 writable relays to dropdown (these are the relays the thrower can throw to)
writableRelays.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 ${writableRelays.length} writable relays that ${thrower.name} can throw to`);
}
// 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 = '';
}
// Update button state after relay selection change
updateCreateBounceButtonState(bounceId);
}
// 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}`);
}
// Update button state after manual relay input change
updateCreateBounceButtonState(bounceId);
}
// 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(', ');
}
// Update delay input constraints based on selected thrower's maximum delay
function updateDelayConstraints(bounceId, throwerPubkey) {
const delayInput = document.getElementById(`delay-${bounceId}`);
if (!delayInput) return;
if (!throwerPubkey) {
// No thrower selected - reset to default constraints
delayInput.max = '';
delayInput.title = 'Enter delay in seconds (minimum 1 second)';
console.log('INFO', `Reset delay constraints for bounce ${bounceId} - no thrower selected`);
return;
}
// Find the thrower and get its maximum delay
const thrower = discoveredThrowers.find(t => t.pubkey === throwerPubkey);
if (thrower && thrower.maxDelay) {
delayInput.max = thrower.maxDelay;
delayInput.title = `Enter delay in seconds (minimum 1, maximum ${thrower.maxDelay} for this thrower)`;
// If current value exceeds max, reset to max
const currentValue = parseInt(delayInput.value);
if (currentValue > thrower.maxDelay) {
delayInput.value = thrower.maxDelay;
console.log('INFO', `Reduced delay from ${currentValue}s to ${thrower.maxDelay}s (thrower maximum) for bounce ${bounceId}`);
}
console.log('INFO', `Set maximum delay constraint to ${thrower.maxDelay}s for bounce ${bounceId} (thrower: ${thrower.name})`);
} else {
// Thrower found but no max delay info - reset constraints
delayInput.max = '';
delayInput.title = 'Enter delay in seconds (minimum 1 second) - thrower maximum delay unknown';
console.log('INFO', `No maximum delay info available for bounce ${bounceId} thrower`);
}
}
// 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">Add a bounce (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}">Thrower throws to this relay(s):</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}" disabled style="color: var(--muted-color); border-color: var(--muted-color); cursor: not-allowed;">Create Bounce</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);
// Labels are now generic, no need to update numbering
}
// Bounce labels are now generic - no numbering needed
// 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;
}
// Validate delay against thrower's maximum delay
const thrower = discoveredThrowers.find(t => t.pubkey === throwerPubkey);
if (thrower && thrower.maxDelay && delay > thrower.maxDelay) {
alert(`Delay of ${delay}s exceeds this thrower's maximum delay of ${thrower.maxDelay}s. Please reduce the delay.`);
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');
// Labels are generic, no update needed
} 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 align-left" style="background: white; border: 4px solid #4a90e2; cursor: pointer; width: 60%; margin-left: 0; margin-right: auto;" 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';
// Helper function to get thrower name
function getThrowerName(bounce, bounceNumber) {
const throwerPubkey = bounce.throwerPubkey;
const thrower = discoveredThrowers.find(t => t.pubkey === throwerPubkey);
if (thrower && thrower.name !== 'Unnamed Thrower') {
return thrower.name;
}
// Fallback to generic naming
return `Superball ${bounceNumber}`;
}
// Start with user sending the outermost bounce
let currentTime = baseTime;
// Work forward through bounces (in EXECUTION order - reverse of creation order)
const reversedBounces = [...bounces].reverse();
reversedBounces.forEach((bounce, index) => {
const bounceNumber = bounces.length - index; // Original bounce number
const isFirst = (index === 0); // First in execution (last created)
const isLast = (index === reversedBounces.length - 1); // Last in execution (first created)
const throwerName = getThrowerName(bounce, bounceNumber);
if (isFirst) {
// For the first bounce, we skip the user publishing step since it's handled by the button
// Start directly with relay propagation after the button click
const routingEventSize = JSON.stringify(bounce.routingEvent).length;
// Get the relays from the current bounce's routing instructions (where user publishes to)
const relays = bounce.payload?.routing?.relays || [];
// Step 1: Relay propagates (immediate after button click)
currentTime += 2000; // 2 seconds for relay propagation
flow.push({
time: currentTime,
actor: `Relay (${relays.join(', ')})`,
action: `Event available for ${throwerName}`,
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;
// Get the relays from the current bounce's routing instructions (where final event gets posted)
const finalRelays = bounce.payload?.routing?.relays || [];
const delaySeconds = getDelayForBounce(bounceNumber);
const paddingAdded = getPaddingAdjustmentForBounce(bounceNumber);
// Create detailed final action description
let actionDescription = `Grabs message, waits ${delaySeconds} seconds`;
if (paddingAdded > 0) {
actionDescription += `, adds ${paddingAdded} bytes of padding`;
}
actionDescription += `, and publishes your original message to: ${finalRelays.join(', ')}`;
// Step 3: Superball decrypts and sends final message
flow.push({
time: currentTime,
actor: throwerName,
action: actionDescription,
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 = reversedBounces[index + 1];
const nextBounceNumber = bounces.length - (index + 1);
const nextThrowerName = getThrowerName(nextBounce, nextBounceNumber);
const nextRoutingSize = JSON.stringify(nextBounce.routingEvent).length + paddingAdjustment;
// Get the relays from the current bounce's routing instructions (where this thrower forwards to)
const currentBounce = reversedBounces[index];
const forwardingRelays = currentBounce.payload?.routing?.relays || [];
const delaySeconds = getDelayForBounce(bounceNumber);
const paddingAdded = getPaddingAdjustmentForBounce(bounceNumber);
// Create detailed forwarding action description
let actionDescription = `Grabs message, waits ${delaySeconds} seconds`;
if (paddingAdded > 0) {
actionDescription += `, adds ${paddingAdded} bytes of padding`;
}
actionDescription += `, and forwards to: ${forwardingRelays.join(', ')}`;
// Step 3: Superball forwards to next relay
flow.push({
time: currentTime,
actor: throwerName,
action: actionDescription,
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 (${forwardingRelays.join(', ')})`,
action: `Event available for ${nextThrowerName}`,
size: Math.max(nextRoutingSize, 0)
});
}
});
return flow;
}
// Generate HTML for timeline visualization
function generateTimelineHtml(eventFlow) {
return eventFlow.map((step, index) => {
// Determine alignment based on actor type
const isRelay = step.actor.startsWith('Relay (');
const alignmentClass = isRelay ? 'align-right' : 'align-left';
const alignmentStyle = isRelay
? 'width: 60%; margin-left: auto; margin-right: 0;'
: 'width: 60%; margin-left: 0; margin-right: auto;';
return `
<div class="timeline-step ${alignmentClass}" id="timeline-step-${index}" style="${alignmentStyle}">
<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) {
// Convert execution order bounce number to array index (reverse lookup)
const bounceIndex = bounces.length - bounceNumber;
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) {
// Convert execution order bounce number to array index (reverse lookup)
const bounceIndex = bounces.length - bounceNumber;
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) {
// Convert execution order bounce number to array index (reverse lookup)
const bounceIndex = bounces.length - bounceNumber;
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('post-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();
}
// Refresh thrower status after reset so new dropdowns have current availability
setTimeout(() => {
discoverThrowers();
}, 500);
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>