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.
2270 lines
88 KiB
HTML
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> |