caching frontend

This commit is contained in:
n 2025-04-09 00:14:32 +01:00
parent 8150c3ce1f
commit 3698288fc3
13 changed files with 2785 additions and 224 deletions

@ -4,4 +4,5 @@ don't put any css or javascript blocks in the index.html file
npm run lint after every code update
don't ever use the alert function
do not ask to "cd client"
all signing to be done using nostr-login
all signing to be done using nostr-login
don't ask to npm run dev (only npm run build)

96
client/billboard.html Normal file

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTTP Messages - BILLBOARD</title>
<link rel="stylesheet" href="./styles.css">
<script defer src="./bundle.js"></script>
</head>
<body>
<!-- Top Navigation Bar -->
<div class="top-nav">
<div class="nav-left">
<a href="./index.html" class="nav-link">CLIENT</a>
<a href="./receive.html" class="nav-link">SERVER</a>
<a href="./billboard.html" class="nav-link active">BILLBOARD</a>
</div>
<div class="nav-right">
<a href="./help.html" class="nav-link nav-icon" title="Documentation"></a>
<a href="./profile.html" class="nav-link nav-icon" title="Profile">👤</a>
<button id="themeToggleBtn" class="theme-toggle-btn" title="Toggle Dark Mode">
<span id="themeIcon">🌙</span>
</button>
</div>
</div>
<!-- Main Content -->
<div class="content">
<div class="info-box">
<p>This page displays server registration events (kind 31120) that advertise HTTP-over-Nostr servers. These events help clients discover available servers and establish connections.</p>
</div>
<h2>Server Registrations</h2>
<div class="billboard-section">
<div class="relay-connection">
<!-- Relay Connection Controls -->
<div class="relay-input-container">
<label for="billboardRelayUrl">Relay URL:</label>
<input type="text" id="billboardRelayUrl" value="wss://relay.degmods.com" placeholder="wss://relay.example.com">
<button id="billboardConnectBtn" class="relay-connect-button">Connect</button>
</div>
<div id="billboardRelayStatus" class="relay-status">Not connected</div>
</div>
<div class="billboard-actions">
<button id="createBillboardBtn" class="primary-button">Create New Billboard</button>
</div>
<div class="billboard-container">
<div id="billboardContent" class="billboard-content">
<div class="empty-state">
No 31120 events found yet. Connect to a relay to view server registrations.
</div>
</div>
</div>
<!-- Modal for creating/editing billboards -->
<div id="billboardModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">Create New Billboard</h3>
<span class="close-modal">&times;</span>
</div>
<div class="modal-body">
<form id="billboardForm">
<input type="hidden" id="editEventId" value="">
<div class="form-group">
<label for="billboardDescription">Description:</label>
<input type="text" id="billboardDescription" placeholder="HTTP-over-Nostr server">
</div>
<div class="form-group">
<label for="billboardRelays">Relays (one per line):</label>
<textarea id="billboardRelays" rows="3" placeholder="wss://relay.example.com"></textarea>
</div>
<div class="form-group">
<label for="billboardExpiry">Expiry (hours):</label>
<input type="number" id="billboardExpiry" value="24" min="1" max="720">
</div>
<div class="form-actions">
<button type="button" id="cancelBillboard" class="secondary-button">Cancel</button>
<button type="submit" id="saveBillboard" class="primary-button">Save</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Script will be provided by bundle.js -->
</body>
</html>

@ -16,6 +16,7 @@
<div class="nav-left">
<a href="./index.html" class="nav-link">CLIENT</a>
<a href="./receive.html" class="nav-link">SERVER</a>
<a href="./billboard.html" class="nav-link">BILLBOARD</a>
</div>
<div class="nav-right">
<a href="./help.html" class="nav-link nav-icon active" title="Documentation"></a>

@ -16,6 +16,7 @@
<div class="nav-left">
<a href="./index.html" class="nav-link active">CLIENT</a>
<a href="./receive.html" class="nav-link">SERVER</a>
<a href="./billboard.html" class="nav-link">BILLBOARD</a>
</div>
<div class="nav-right">
<a href="./help.html" class="nav-link nav-icon" title="Documentation"></a>
@ -35,13 +36,29 @@
<h2>Server Information:</h2>
<div style="margin-bottom: 15px;">
<div style="margin-bottom: 10px;">
<label for="serverPubkey">Server Pubkey or Search Term:</label><br>
<label for="relay">Relay to search for servers:</label><br>
<div class="server-input-container">
<input type="text" id="serverPubkey" placeholder="npub, username, or NIP-05 identifier" class="server-input">
<button id="searchServerBtn" class="server-search-button">Search</button>
<input type="text" id="relay" value="wss://relay.degmods.com" class="server-input" style="border-radius: 4px 0 0 4px;">
<button id="searchRelayBtn" class="server-search-button">Search Relay</button>
<button id="refreshRelayBtn" class="server-refresh-button" title="Clear cache and fetch fresh data">🔄</button>
</div>
<div id="serverSearchResult" class="server-search-result" style="display: none;">
<!-- Search results will be shown here -->
</div>
<div style="margin-bottom: 10px;">
<label for="serverSelection">Choose a server:</label><br>
<div class="server-input-container">
<input type="text" id="serverPubkey" placeholder="Selected server pubkey (d-tag) or enter manually" class="server-input">
<button id="selectServerBtn" class="server-select-button">Select Server</button>
</div>
<div id="serverSelectionContainer" class="server-selection-container" style="display: none;">
<div class="selection-header">
<input type="text" id="serverSearchInput" placeholder="Search by operator name/pubkey" class="server-search-input">
<button id="closeSelectionBtn" class="close-selection-btn">&times;</button>
</div>
<div id="serverList" class="server-list">
<!-- Server list will be populated here -->
<div class="server-list-loading">Loading servers...</div>
</div>
</div>
</div>

@ -13,6 +13,7 @@
<div class="nav-left">
<a href="./index.html" class="nav-link">CLIENT</a>
<a href="./receive.html" class="nav-link">SERVER</a>
<a href="./billboard.html" class="nav-link">BILLBOARD</a>
</div>
<div class="nav-right">
<a href="./help.html" class="nav-link nav-icon" title="Documentation"></a>

@ -13,6 +13,7 @@
<div class="nav-left">
<a href="./index.html" class="nav-link">CLIENT</a>
<a href="./receive.html" class="nav-link active">SERVER</a>
<a href="./billboard.html" class="nav-link">BILLBOARD</a>
</div>
<div class="nav-right">
<a href="./help.html" class="nav-link nav-icon" title="Documentation"></a>
@ -44,12 +45,18 @@
<!-- Server npub display -->
<div class="server-info-container">
<div class="server-npub-container">
<label>Server NPUB:</label>
<label>Server Key:</label>
<input type="text" id="serverNpub" readonly class="server-npub-input">
<button id="copyServerNpubBtn" class="copy-btn" title="Copy NPUB">
<button id="toggleFormatBtn" class="toggle-format-btn" title="Toggle format">
<span id="formatBtnText">HEX</span>
</button>
<button id="copyServerNpubBtn" class="copy-btn" title="Copy Key">
<span id="copyBtnText">Copy</span>
</button>
</div>
<div class="format-indicator">
<small id="formatIndicator">Currently showing: NPUB format</small>
</div>
</div>
<div class="relay-input-container">

571
client/src/billboard.ts Normal file

@ -0,0 +1,571 @@
// billboard.ts - Functionality for the BILLBOARD page
// Displays and manages 31120 server registration events
import * as nostrTools from 'nostr-tools';
import { defaultServerConfig } from './config';
import { NostrService } from './services/NostrService';
// Module-level variables
let nostrService: NostrService;
let relayStatusElement: HTMLElement | null;
let modalElement: HTMLElement | null;
let currentRelayUrl: string = '';
/**
* Initialize the BILLBOARD page
*/
document.addEventListener('DOMContentLoaded', () => {
console.log('Initializing BILLBOARD page...');
// Initialize services
// Create NostrService with a status update callback
nostrService = new NostrService((message: string, className: string) => {
updateRelayStatus(message, className);
});
// Initialize UI elements
setupUIElements();
// Auto-connect to the default relay after a brief delay
setTimeout(autoConnectToDefaultRelay, 500);
});
/**
* Set up UI elements and event listeners
*/
function setupUIElements(): void {
// Get DOM elements
relayStatusElement = document.getElementById('billboardRelayStatus');
modalElement = document.getElementById('billboardModal');
// Set up connect button event listener
const connectButton = document.getElementById('billboardConnectBtn');
if (connectButton) {
connectButton.addEventListener('click', handleConnectRelay);
}
// Create billboard button
const createBillboardBtn = document.getElementById('createBillboardBtn');
if (createBillboardBtn) {
createBillboardBtn.addEventListener('click', handleCreateBillboard);
}
// Modal close button
const closeModalBtn = document.querySelector('.close-modal');
if (closeModalBtn) {
closeModalBtn.addEventListener('click', closeModal);
}
// Cancel button in modal
const cancelBtn = document.getElementById('cancelBillboard');
if (cancelBtn) {
cancelBtn.addEventListener('click', closeModal);
}
// Form submission
const billboardForm = document.getElementById('billboardForm');
if (billboardForm) {
billboardForm.addEventListener('submit', handleSaveBillboard);
}
// Set dark mode based on localStorage
const savedTheme = window.localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.body.setAttribute('data-theme', 'dark');
// Update theme toggle button
const themeIcon = document.getElementById('themeIcon');
const themeText = document.getElementById('themeText');
if (themeIcon) {
themeIcon.textContent = '☀️';
}
if (themeText) {
themeText.textContent = 'Light Mode';
}
}
// Set up theme toggle
const themeToggleBtn = document.getElementById('themeToggleBtn');
if (themeToggleBtn) {
themeToggleBtn.addEventListener('click', toggleTheme);
}
}
/**
* Update the relay status display
*/
function updateRelayStatus(message: string, className: string): void {
if (relayStatusElement) {
relayStatusElement.textContent = message;
relayStatusElement.className = `relay-status ${className}`;
}
}
/**
* Handle relay connection button click
*/
async function handleConnectRelay(): Promise<void> {
const relayUrlInput = document.getElementById('billboardRelayUrl') as HTMLInputElement;
if (!relayUrlInput) {
return;
}
const relayUrl = relayUrlInput.value.trim();
if (!relayUrl || !relayUrl.startsWith('wss://')) {
updateRelayStatus('Invalid relay URL. Must start with wss://', 'error');
return;
}
updateRelayStatus('Connecting to relay...', 'connecting');
// Connect to relay
const success = await nostrService.connectToRelay(relayUrl);
if (success) {
try {
// Subscribe to kind 31120 events
await subscribeToKind31120Events();
} catch (error) {
updateRelayStatus(
`Subscription error: ${error instanceof Error ? error.message : String(error)}`,
'error'
);
}
}
}
/**
* Subscribe to kind 31120 events
*/
async function subscribeToKind31120Events(): Promise<void> {
// Create filter for kind 31120 events
const filter = {
kinds: [31120], // HTTP Messages server advertisements
};
updateRelayStatus('Subscribing to server advertisements...', 'connecting');
try {
// Subscribe to events
await nostrService.subscribeToEvents(filter);
// Set event handler
nostrService.setEventHandler((event) => {
if (event.kind === 31120 && event.id) {
processServerEvent(event as nostrTools.Event);
}
});
updateRelayStatus('Connected and listening for server events ✓', 'connected');
} catch (error) {
throw new Error(`Failed to subscribe: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Process a server advertisement event (kind 31120)
*/
/**
* Open the modal for creating a new billboard
*/
function handleCreateBillboard(): void {
// Reset form
resetBillboardForm();
// Set title to create mode
const modalTitle = document.getElementById('modalTitle');
if (modalTitle) {
modalTitle.textContent = 'Create New Billboard';
}
// Clear the edit event ID
const editEventId = document.getElementById('editEventId') as HTMLInputElement;
if (editEventId) {
editEventId.value = '';
}
// Set current relay URL as default
const relayUrlInput = document.getElementById('billboardRelayUrl') as HTMLInputElement;
if (relayUrlInput) {
currentRelayUrl = relayUrlInput.value.trim();
const billboardRelays = document.getElementById('billboardRelays') as HTMLTextAreaElement;
if (billboardRelays) {
billboardRelays.value = currentRelayUrl;
}
}
// Open the modal
openModal();
}
/**
* Open the modal for editing an existing billboard
*/
function handleEditBillboard(event: nostrTools.Event): void {
resetBillboardForm();
// Set title to edit mode
const modalTitle = document.getElementById('modalTitle');
if (modalTitle) {
modalTitle.textContent = 'Edit Billboard';
}
// Store the event ID
const editEventId = document.getElementById('editEventId') as HTMLInputElement;
if (editEventId && event.id) {
editEventId.value = event.id;
}
// Set current relay URL
const relayUrlInput = document.getElementById('billboardRelayUrl') as HTMLInputElement;
if (relayUrlInput) {
currentRelayUrl = relayUrlInput.value.trim();
}
// Set description
const descriptionInput = document.getElementById('billboardDescription') as HTMLInputElement;
if (descriptionInput) {
descriptionInput.value = event.content || '';
}
// Set relays
const relayTags = event.tags.filter(tag => tag[0] === 'relay');
const relaysList = relayTags.map(tag => tag[1]).join('\n');
const billboardRelays = document.getElementById('billboardRelays') as HTMLTextAreaElement;
if (billboardRelays) {
billboardRelays.value = relaysList || currentRelayUrl;
}
// Set expiry
const expiryTag = event.tags.find(tag => tag[0] === 'expiry');
if (expiryTag && expiryTag.length > 1) {
try {
const expiryTime = parseInt(expiryTag[1]);
const currentTime = Math.floor(Date.now() / 1000);
const hoursRemaining = Math.max(1, Math.ceil((expiryTime - currentTime) / 3600));
const expiryInput = document.getElementById('billboardExpiry') as HTMLInputElement;
if (expiryInput && !isNaN(hoursRemaining)) {
expiryInput.value = hoursRemaining.toString();
}
} catch {
// Use default expiry if parsing fails
}
}
// Open the modal
openModal();
}
/**
* Handle saving a billboard (create or update)
*/
async function handleSaveBillboard(e: Event): Promise<void> {
e.preventDefault();
// Get form values
const editEventId = (document.getElementById('editEventId') as HTMLInputElement)?.value;
const description = (document.getElementById('billboardDescription') as HTMLInputElement)?.value || 'HTTP-over-Nostr server';
const relaysText = (document.getElementById('billboardRelays') as HTMLTextAreaElement)?.value || '';
const expiryHours = parseInt((document.getElementById('billboardExpiry') as HTMLInputElement)?.value || '24');
// Parse relay URLs
const relays = relaysText
.split('\n')
.map(line => line.trim())
.filter(line => line && line.startsWith('wss://'));
// Validate input
if (relays.length === 0) {
alert('Please provide at least one valid relay URL (starting with wss://)');
return;
}
// Get the current relay URL for publishing
const relayUrlInput = document.getElementById('billboardRelayUrl') as HTMLInputElement;
const relayUrl = relayUrlInput?.value.trim() || relays[0];
try {
// Create or update the billboard event
const event = await nostrService.createOrUpdate31120Event(
relayUrl,
description,
relays,
expiryHours,
editEventId || undefined
);
if (event) {
// Add the event to the UI
processServerEvent(event as nostrTools.Event);
// Close the modal
closeModal();
}
} catch (error) {
console.error('Error saving billboard:', error);
alert(`Failed to save billboard: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Reset the billboard form
*/
function resetBillboardForm(): void {
const form = document.getElementById('billboardForm') as HTMLFormElement;
if (form) {
form.reset();
}
// Set default expiry
const expiryInput = document.getElementById('billboardExpiry') as HTMLInputElement;
if (expiryInput) {
expiryInput.value = '24';
}
}
/**
* Open the modal
*/
function openModal(): void {
if (modalElement) {
modalElement.style.display = 'block';
}
}
/**
* Close the modal
*/
function closeModal(): void {
if (modalElement) {
modalElement.style.display = 'none';
}
}
/**
* Process a server advertisement event (kind 31120)
*/
function processServerEvent(event: nostrTools.Event): void {
console.log('Received 31120 event:', event);
// Get the billboard container
const billboardContent = document.getElementById('billboardContent');
if (!billboardContent) {
return;
}
// Clear "empty state" message if this is the first event
if (billboardContent.querySelector('.empty-state')) {
billboardContent.innerHTML = '';
}
// Check if we already have this event displayed (by id)
const existingEvent = document.getElementById(`event-${event.id}`);
if (existingEvent) {
// Update the existing event card instead of adding a new one
const updatedElement = existingEvent.querySelector('.event-updated');
if (updatedElement) {
updatedElement.setAttribute('data-time', new Date(event.created_at * 1000).toISOString());
updatedElement.textContent = `Updated: ${new Date(event.created_at * 1000).toLocaleString()}`;
}
return;
}
// Get pubkey in npub format
let pubkey = event.pubkey;
try {
pubkey = nostrTools.nip19.npubEncode(event.pubkey);
} catch {
// If conversion fails, use the hex format
}
// Get server pubkey from the d tag
const dTag = event.tags.find(tag => tag[0] === 'd');
let serverPubkey = dTag && dTag.length > 1 ? dTag[1] : 'Not specified';
try {
if (dTag && dTag.length > 1) {
serverPubkey = nostrTools.nip19.npubEncode(dTag[1]);
}
} catch {
// If conversion fails, use the original format
}
// Get relay information
const relayTags = event.tags.filter(tag => tag[0] === 'relay');
let relayList = '';
if (relayTags.length > 0) {
relayList = relayTags.map(tag => `<li>${tag[1]}</li>`).join('');
} else {
relayList = '<li>No relays specified</li>';
}
// Get expiry information
const expiryTag = event.tags.find(tag => tag[0] === 'expiry');
let expiryInfo = 'No expiry set';
if (expiryTag && expiryTag.length > 1) {
try {
const expiryTime = parseInt(expiryTag[1]);
if (!isNaN(expiryTime) && expiryTime > 0) {
expiryInfo = new Date(expiryTime * 1000).toLocaleString();
}
} catch {
// If parsing fails, use the default message
}
}
// Create a card for the event
const eventCard = document.createElement('div');
eventCard.className = 'billboard-card';
eventCard.id = `event-${event.id}`;
eventCard.innerHTML = `
<div class="billboard-card-header">
<h3 class="billboard-title">Server Registration</h3>
<div class="billboard-timestamp">
<span class="event-created">Created: ${new Date(event.created_at * 1000).toLocaleString()}</span>
<span class="event-updated" data-time="${new Date(event.created_at * 1000).toISOString()}">Updated: ${new Date(event.created_at * 1000).toLocaleString()}</span>
</div>
</div>
<div class="billboard-card-content">
<div class="billboard-detail">
<strong>Operator:</strong> <span class="operator-pubkey">${pubkey}</span>
</div>
<div class="billboard-detail">
<strong>Server Pubkey:</strong> <span class="server-pubkey">${serverPubkey}</span>
</div>
<div class="billboard-detail">
<strong>Description:</strong> <span class="server-description">${event.content || "No description provided"}</span>
</div>
<div class="billboard-detail">
<strong>Relays:</strong>
<ul class="relay-list">
${relayList}
</ul>
</div>
<div class="billboard-detail">
<strong>Expires:</strong> <span class="expiry-time">${expiryInfo}</span>
</div>
</div>
<div class="billboard-card-footer">
<button class="view-raw-btn" data-id="${event.id}">View Raw JSON</button>
<button class="billboard-edit-btn" data-id="${event.id}">Edit</button>
</div>
<div class="raw-json-content hidden" id="raw-${event.id}">
<pre>${JSON.stringify(event, null, 2)}</pre>
</div>
`;
// Add to the billboard content
billboardContent.insertBefore(eventCard, billboardContent.firstChild);
// Add event listener for the "View Raw JSON" button
const viewRawBtn = eventCard.querySelector('.view-raw-btn');
if (viewRawBtn) {
viewRawBtn.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const eventId = target.getAttribute('data-id');
if (eventId) {
const rawJsonContent = document.getElementById(`raw-${eventId}`);
if (rawJsonContent) {
rawJsonContent.classList.toggle('hidden');
target.textContent = rawJsonContent.classList.contains('hidden')
? 'View Raw JSON'
: 'Hide Raw JSON';
}
}
});
}
// Add event listener for the "Edit" button
const editBtn = eventCard.querySelector('.billboard-edit-btn');
if (editBtn) {
editBtn.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const eventId = target.getAttribute('data-id');
if (eventId) {
// Check if the user is the creator of this event
const loggedInPubkey = nostrService.getLoggedInPubkey();
if (!loggedInPubkey) {
alert('You need to be logged in to edit a billboard. Please visit the Profile page to log in.');
return;
}
// Convert pubkeys to hex if needed for comparison
let userPubkeyHex = loggedInPubkey;
if (loggedInPubkey.startsWith('npub')) {
try {
const hexPubkey = nostrTools.nip19.decode(loggedInPubkey).data as string;
if (hexPubkey) {
userPubkeyHex = hexPubkey;
}
} catch {
// Keep original if conversion fails
}
}
// Only allow editing if user is the creator
if (userPubkeyHex !== event.pubkey) {
alert('You can only edit billboards that you created');
return;
}
// Open edit modal
handleEditBillboard(event);
}
});
}
}
/**
* Auto-connect to the default relay
*/
async function autoConnectToDefaultRelay(): Promise<void> {
const relayUrlInput = document.getElementById('billboardRelayUrl') as HTMLInputElement;
if (relayUrlInput) {
// Set the default relay URL if not already set
if (!relayUrlInput.value) {
relayUrlInput.value = defaultServerConfig.defaultRelay;
}
// Trigger connect button click
const connectButton = document.getElementById('billboardConnectBtn');
if (connectButton) {
connectButton.click();
}
}
}
/**
* Toggle between light and dark theme
*/
function toggleTheme(): void {
const body = document.body;
const themeIcon = document.getElementById('themeIcon');
const themeText = document.getElementById('themeText');
const isDarkMode = body.getAttribute('data-theme') === 'dark';
if (isDarkMode) {
// Switch to light theme
body.removeAttribute('data-theme');
window.localStorage.setItem('theme', 'light');
if (themeIcon) {
themeIcon.textContent = '🌙';
}
if (themeText) {
themeText.textContent = 'Dark Mode';
}
} else {
// Switch to dark theme
body.setAttribute('data-theme', 'dark');
window.localStorage.setItem('theme', 'dark');
if (themeIcon) {
themeIcon.textContent = '☀️';
}
if (themeText) {
themeText.textContent = 'Light Mode';
}
}
}

@ -2,7 +2,7 @@
// This follows strict CSP policies by avoiding inline scripts
// Import from Node.js built-ins & external modules
// No longer need direct nostr-tools imports for this file
import * as nostrTools from 'nostr-tools';
// On page load, always fetch the latest pubkey from window.nostr
if (window.nostr && typeof window.nostr.getPublicKey === 'function') {
@ -19,11 +19,12 @@ import type { NostrEvent } from './converter';
// Import functions from internal modules
import { displayConvertedEvent } from './converter';
import { publishToRelay, convertNpubToHex, verifyEvent } from './relay';
import { searchUsers } from './search';
// Import profile functions (not using direct imports since we'll load modules based on page)
// This ensures all page modules are included in the bundle
import './profile';
import './receiver'; // Import receiver module for relay connections and subscriptions
import './billboard'; // Import billboard module for server registration display
import { NostrService } from './services/NostrService';
import {
sanitizeText,
setDefaultHttpRequest,
@ -44,6 +45,365 @@ declare global {
}
}
// Add the NostrService instance for handling 31120 events
const nostrService = new NostrService();
/**
* Handle showing the server selection modal
*/
/**
* Search a relay for 31120 events
*/
async function handleRelaySearch(): Promise<void> {
const relayUrlInput = document.getElementById('relay') as HTMLInputElement;
const serverSelectionContainer = document.getElementById('serverSelectionContainer');
const serverList = document.getElementById('serverList');
if (!relayUrlInput || !serverSelectionContainer || !serverList) {
return;
}
const relayUrl = relayUrlInput.value.trim() || 'wss://relay.degmods.com';
// Show the selection container
serverSelectionContainer.style.display = 'block';
// Set loading state
serverList.innerHTML = '<div class="server-list-loading">Searching relay for servers...</div>';
try {
// Connect to the relay if not already connected
if (!nostrService.isConnected()) {
await nostrService.connectToRelay(relayUrl);
}
// Query for all kind 31120 events (server advertisements)
const events = await fetch31120Events(relayUrl);
if (!events || Object.keys(events).length === 0) {
serverList.innerHTML = `
<div class="server-list-empty">
<p>No servers found on this relay.</p>
<p>You can manually enter a server pubkey (npub or NIP-05) in the input field below.</p>
</div>`;
return;
}
// Render the server list
renderServerList(serverList, events);
} catch (error) {
serverList.innerHTML = `<div class="server-list-loading">Error loading servers: ${error instanceof Error ? error.message : String(error)}</div>`;
}
}
/**
* Handle showing the server selection modal
*/
async function handleServerSelection(): Promise<void> {
const serverPubkeyInput = document.getElementById('serverPubkey') as HTMLInputElement;
const serverSelectionContainer = document.getElementById('serverSelectionContainer');
const serverList = document.getElementById('serverList');
if (!serverPubkeyInput || !serverSelectionContainer || !serverList) {
return;
}
// If there's already text in the server pubkey input, it might be a manual entry
const pubkeyText = serverPubkeyInput.value.trim();
if (pubkeyText) {
// Check if it's a valid npub
if (pubkeyText.startsWith('npub')) {
try {
const hexPubkey = convertNpubToHex(pubkeyText);
if (hexPubkey) {
// Valid npub, use it directly
selectServer(hexPubkey, "Manually Entered Server");
return;
}
} catch {
// Not a valid npub, continue with search
}
}
}
// Show the selection container
serverSelectionContainer.style.display = 'block';
// Set loading state
serverList.innerHTML = '<div class="server-list-loading">Loading servers...</div>';
try {
// Fetch 31120 events from the currently specified relay
const relayUrlInput = document.getElementById('relay') as HTMLInputElement;
const relayUrl = relayUrlInput.value.trim() || 'wss://relay.degmods.com';
// Connect to the relay if not already connected
if (!nostrService.isConnected()) {
await nostrService.connectToRelay(relayUrl);
}
// Query for all kind 31120 events (server advertisements)
const events = await fetch31120Events(relayUrl);
if (!events || Object.keys(events).length === 0) {
serverList.innerHTML = '<div class="server-list-loading">No servers found. Try a different relay.</div>';
return;
}
// Render the server list
renderServerList(serverList, events);
} catch (error) {
serverList.innerHTML = `<div class="server-list-loading">Error loading servers: ${error instanceof Error ? error.message : String(error)}</div>`;
}
}
// We're now using handleServerSelection instead of handleServerSearch
/**
* Fetch all kind 31120 events from the specified relay
* This function leverages the NostrService's built-in caching for 31120 events
*/
async function fetch31120Events(relayUrl: string): Promise<{[key: string]: NostrEvent}> {
try {
// Create an object to store events by server pubkey
const eventsMap: {[key: string]: NostrEvent} = {};
// Query for all kind 31120 events using our method that supports caching
const events = await nostrService.queryForAll31120Events(relayUrl);
// Process collected events and organize by server pubkey
for (const event of events) {
// Find the d tag which contains the server pubkey
const dTag = event.tags.find((tag: string[]) => tag[0] === 'd');
if (dTag && dTag.length > 1) {
const serverPubkey = dTag[1];
// Store the event with the server pubkey as the key
eventsMap[serverPubkey] = event;
}
}
console.log(`Found ${Object.keys(eventsMap).length} unique server registrations`);
return eventsMap;
} catch (error) {
console.error('Error fetching 31120 events:', error);
throw error;
}
}
/**
* Render the server list in the UI
*/
async function renderServerList(container: HTMLElement, events: {[key: string]: NostrEvent}): Promise<void> {
// Clear the container
container.innerHTML = '';
// If no events, show a message
if (Object.keys(events).length === 0) {
container.innerHTML = '<div class="server-list-loading">No servers found.</div>';
return;
}
// Create a fragment to build the list
const fragment = document.createDocumentFragment();
// Sort the events by created_at (newest first)
const sortedEvents = Object.values(events).sort((a, b) => b.created_at - a.created_at);
// Process each event
for (const event of sortedEvents) {
// Find the d tag which contains the server pubkey
const dTag = event.tags.find((tag: string[]) => tag[0] === 'd');
if (!dTag || dTag.length < 2) {
continue;
}
const serverPubkey = dTag[1];
// Find the expiry tag
const expiryTag = event.tags.find((tag: string[]) => tag[0] === 'expiry');
let expiryInfo = 'No expiry set';
if (expiryTag && expiryTag.length > 1) {
try {
const expiryTime = parseInt(expiryTag[1]);
if (!isNaN(expiryTime) && expiryTime > 0) {
// Check if expired
const now = Math.floor(Date.now() / 1000);
if (expiryTime < now) {
// Skip expired events
continue;
}
expiryInfo = `Expires: ${new Date(expiryTime * 1000).toLocaleString()}`;
}
} catch {
// If parsing fails, use the default message
}
}
// Create the server item
const serverItem = document.createElement('div');
serverItem.className = 'server-item';
serverItem.dataset.serverPubkey = serverPubkey;
// Operator pubkey (the event author)
const operatorPubkey = event.pubkey;
// Try to fetch the operator's profile info
let operatorName = "Unknown Operator";
let operatorPicture = null;
try {
const profileData = await nostrService.fetchProfileData(operatorPubkey);
if (profileData) {
operatorName = profileData.name || "Unknown Operator";
operatorPicture = profileData.picture || null;
}
} catch (error) {
console.error('Error fetching operator profile:', error);
}
// Format the HTML for the server item
serverItem.innerHTML = `
<div class="operator-avatar">
${operatorPicture ?
`<img src="${operatorPicture}" alt="${operatorName}" />` :
`<div class="operator-avatar-placeholder">👤</div>`}
</div>
<div class="server-details">
<div class="server-name">${operatorName}'s Server</div>
<div class="server-description">${event.content || "No description provided"}</div>
<div class="operator-pubkey">Operator: ${operatorPubkey.substring(0, 10)}...</div>
<div class="server-pubkey">Server: ${serverPubkey.substring(0, 10)}...</div>
<div class="server-expiry">${expiryInfo}</div>
</div>
`;
// Add click handler to select this server
serverItem.addEventListener('click', () => {
selectServer(serverPubkey, operatorName);
});
// Add to the fragment
fragment.appendChild(serverItem);
}
// Add the fragment to the container
container.appendChild(fragment);
// Add search functionality
const searchInput = document.getElementById('serverSearchInput') as HTMLInputElement;
if (searchInput) {
searchInput.addEventListener('input', () => {
const searchTerm = searchInput.value.toLowerCase();
const serverItems = container.querySelectorAll('.server-item');
serverItems.forEach(item => {
const serverDetails = item.querySelector('.server-details');
if (!serverDetails) {
return;
}
const text = serverDetails.textContent?.toLowerCase() || '';
if (text.includes(searchTerm)) {
(item as HTMLElement).style.display = '';
} else {
(item as HTMLElement).style.display = 'none';
}
});
});
}
}
/**
* LocalStorage keys
*/
const LOCAL_STORAGE_KEYS = {
SELECTED_SERVER: 'selected_server_pubkey',
SELECTED_SERVER_NAME: 'selected_server_name'
};
/**
* Select a server and update the UI
*/
function selectServer(serverPubkey: string, operatorName: string): void {
const serverPubkeyInput = document.getElementById('serverPubkey') as HTMLInputElement;
const serverSelectionContainer = document.getElementById('serverSelectionContainer');
if (serverPubkeyInput) {
// Make sure we're using consistent format - convert to npub if it's a hex pubkey
try {
// If it doesn't start with npub, it's probably a hex format
if (!serverPubkey.startsWith('npub')) {
const npub = nostrTools.nip19.npubEncode(serverPubkey);
serverPubkeyInput.value = npub;
} else {
serverPubkeyInput.value = serverPubkey;
}
} catch (error) {
// If conversion fails, just use the original value
serverPubkeyInput.value = serverPubkey;
console.error('Error converting pubkey:', error);
}
}
if (serverSelectionContainer) {
serverSelectionContainer.style.display = 'none';
}
// Store the selected server in localStorage
try {
// Only store it if it's valid
if (serverPubkey) {
localStorage.setItem(LOCAL_STORAGE_KEYS.SELECTED_SERVER, serverPubkeyInput?.value || serverPubkey);
localStorage.setItem(LOCAL_STORAGE_KEYS.SELECTED_SERVER_NAME, operatorName);
}
} catch (error) {
console.error('Error storing selected server in localStorage:', error);
}
// Show a notification
const selectButton = document.getElementById('selectServerBtn');
if (selectButton) {
const originalText = selectButton.textContent || 'Select Server';
selectButton.textContent = `Selected ${operatorName}'s Server`;
selectButton.style.backgroundColor = 'var(--button-success)';
// Reset the button after 2 seconds
setTimeout(() => {
selectButton.textContent = originalText;
selectButton.style.backgroundColor = '';
}, 2000);
}
}
/**
* Load the previously selected server from localStorage
*/
function loadSavedServer(): void {
try {
const savedPubkey = localStorage.getItem(LOCAL_STORAGE_KEYS.SELECTED_SERVER);
const savedOperatorName = localStorage.getItem(LOCAL_STORAGE_KEYS.SELECTED_SERVER_NAME) || 'Unknown';
if (savedPubkey) {
const serverPubkeyInput = document.getElementById('serverPubkey') as HTMLInputElement;
if (serverPubkeyInput) {
serverPubkeyInput.value = savedPubkey;
// Show indicator that we're using a saved selection
const selectButton = document.getElementById('selectServerBtn');
if (selectButton) {
selectButton.textContent = `Using ${savedOperatorName}'s Server`;
selectButton.style.backgroundColor = 'var(--button-success)';
// Reset the button after 2 seconds
setTimeout(() => {
selectButton.textContent = 'Select Server';
selectButton.style.backgroundColor = '';
}, 2000);
}
}
}
} catch (error) {
console.error('Error loading saved server from localStorage:', error);
}
}
/**
* Initialize nostr-login
*/
@ -89,89 +449,6 @@ function initNostrLogin(): void {
}
}
/**
* Handle the server search button click
*/
async function handleServerSearch(): Promise<void> {
const serverPubkeyInput = document.getElementById('serverPubkey') as HTMLInputElement;
const resultDiv = document.getElementById('serverSearchResult');
if (!serverPubkeyInput || !resultDiv) {
return;
}
const searchTerm = serverPubkeyInput.value.trim();
if (!searchTerm) {
showError(resultDiv, 'Please enter a search term');
return;
}
// If it's a valid npub, no need to search
if (searchTerm.startsWith('npub')) {
try {
const hexPubkey = convertNpubToHex(searchTerm);
if (hexPubkey) {
// It's a valid npub, hide any existing results
resultDiv.style.display = 'none';
return;
}
} catch {
// Not a valid npub, continue with search
}
}
// Display loading state
showLoading(resultDiv, 'Searching relays...');
try {
const results = await searchUsers(searchTerm);
if (results.length > 0) {
// If there's only one result and it's a valid npub, use it directly
if (results.length === 1 && results[0].name === 'Valid npub') {
serverPubkeyInput.value = results[0].npub;
resultDiv.style.display = 'none';
return;
}
// Create the results list
let resultsHtml = '<div class="search-results-list">';
results.forEach(result => {
const truncatedNpub = `${result.npub.substring(0, 10)}...${result.npub.substring(result.npub.length - 5)}`;
resultsHtml += `
<div class="search-result-item" data-npub="${result.npub}">
<div class="result-name">${result.name}</div>
<div class="result-npub">${truncatedNpub}</div>
${result.nip05 ? `<div class="result-nip05">${result.nip05}</div>` : ''}
<button class="use-npub-btn">Use</button>
</div>
`;
});
resultsHtml += '</div>';
resultDiv.innerHTML = resultsHtml;
// Add click handlers for the "Use" buttons
document.querySelectorAll('.use-npub-btn').forEach(button => {
button.addEventListener('click', (e) => {
const resultItem = (e.target as HTMLElement).closest('.search-result-item');
if (resultItem) {
const npub = resultItem.getAttribute('data-npub');
if (npub) {
serverPubkeyInput.value = npub;
resultDiv.innerHTML += '<br><span style="color: #008800;">✓ Applied!</span>';
}
}
});
});
} else {
showError(resultDiv, 'No users found matching your search term');
}
} catch (error) {
showError(resultDiv, String(error));
}
}
/**
* Handle the publish button click
@ -350,35 +627,72 @@ function toggleTheme(): void {
}
}
/**
* Auto-connect to the relay on page load
*/
async function autoConnectToRelay(): Promise<void> {
const relayUrlInput = document.getElementById('relay') as HTMLInputElement;
if (!relayUrlInput) {
return;
}
const relayUrl = relayUrlInput.value.trim() || 'wss://relay.degmods.com';
try {
// Connect to the relay if not already connected
if (!nostrService.isConnected()) {
await nostrService.connectToRelay(relayUrl);
console.log('Auto-connected to relay:', relayUrl);
// After connecting, search for servers
await handleRelaySearch();
}
} catch (error) {
console.error('Auto-connect error:', error);
}
}
/**
* Handle tab switching
*/
function setupTabSwitching(): void {
const tabs = document.querySelectorAll('.tab-btn');
const tabPanes = document.querySelectorAll('.tab-pane');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
// Remove active class from all tabs
tabs.forEach(t => t.classList.remove('active'));
// Add active class to clicked tab
tab.classList.add('active');
// Hide all tab panes
tabPanes.forEach(pane => {
pane.classList.add('hidden');
pane.classList.remove('active');
});
// Show the corresponding tab pane
const targetPane = document.getElementById((tab as HTMLElement).dataset.tab as string);
if (targetPane) {
targetPane.classList.remove('hidden');
targetPane.classList.add('active');
}
});
const tabs = document.querySelectorAll('.tab-btn');
const tabPanes = document.querySelectorAll('.tab-pane');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
// Remove active class from all tabs
tabs.forEach(t => t.classList.remove('active'));
// Add active class to clicked tab
tab.classList.add('active');
// Hide all tab panes
tabPanes.forEach(pane => {
pane.classList.add('hidden');
pane.classList.remove('active');
});
// Show the corresponding tab pane
const targetPane = document.getElementById((tab as HTMLElement).dataset.tab as string);
if (targetPane) {
targetPane.classList.remove('hidden');
targetPane.classList.add('active');
}
});
});
}
/**
* Clear the 31120 events cache for the current relay
*/
function clearEventCache(): void {
const relayUrlInput = document.getElementById('relay') as HTMLInputElement;
if (relayUrlInput) {
const relayUrl = relayUrlInput.value.trim() || 'wss://relay.degmods.com';
nostrService.clearEventsCache(relayUrl);
console.log(`Cleared 31120 events cache for ${relayUrl}`);
}
}
/**
@ -422,42 +736,74 @@ function handleCopyEvent(): void {
initNostrLogin();
document.addEventListener('DOMContentLoaded', function(): void {
// Add event listener for "Enter" key on the serverPubkey input field
const serverPubkeyInput = document.getElementById('serverPubkey') as HTMLInputElement;
if (serverPubkeyInput) {
serverPubkeyInput.addEventListener('keydown', async (event) => {
if (event.key === 'Enter') {
event.preventDefault();
await handleServerSearch();
// After search, check if we found a NIP-05 address that resolved to exactly one result
const resultDiv = document.getElementById('serverSearchResult');
if (resultDiv && resultDiv.querySelector('.search-results-list')) {
const resultItems = resultDiv.querySelectorAll('.search-result-item');
if (resultItems.length === 1) {
const npub = resultItems[0].getAttribute('data-npub');
if (npub) {
serverPubkeyInput.value = npub;
resultDiv.style.display = 'none';
}
}
}
// Load previously selected server, if any
loadSavedServer();
// Auto-connect to the relay on page load
setTimeout(autoConnectToRelay, 500);
// Search relay button event listener
const searchRelayBtn = document.getElementById('searchRelayBtn');
if (searchRelayBtn) {
searchRelayBtn.addEventListener('click', handleRelaySearch);
}
// Refresh button to clear cache and fetch fresh data
const refreshRelayBtn = document.getElementById('refreshRelayBtn');
if (refreshRelayBtn) {
refreshRelayBtn.addEventListener('click', async () => {
clearEventCache(); // Clear the cache
await handleRelaySearch(); // Fetch fresh data
});
}
// Server selection button event listener
const selectServerBtn = document.getElementById('selectServerBtn');
if (selectServerBtn) {
selectServerBtn.addEventListener('click', handleServerSelection);
}
// Close server selection container
const closeSelectionBtn = document.getElementById('closeSelectionBtn');
if (closeSelectionBtn) {
closeSelectionBtn.addEventListener('click', () => {
const serverSelectionContainer = document.getElementById('serverSelectionContainer');
if (serverSelectionContainer) {
serverSelectionContainer.style.display = 'none';
}
});
}
// Search functionality for server selection
const serverSearchInput = document.getElementById('serverSearchInput') as HTMLInputElement;
if (serverSearchInput) {
serverSearchInput.addEventListener('input', () => {
const searchTerm = serverSearchInput.value.toLowerCase();
const serverList = document.getElementById('serverList');
if (serverList) {
const serverItems = serverList.querySelectorAll('.server-item');
serverItems.forEach((item) => {
const serverDetails = item.querySelector('.server-details');
if (serverDetails) {
const text = serverDetails.textContent?.toLowerCase() || '';
if (text.includes(searchTerm)) {
(item as HTMLElement).style.display = '';
} else {
(item as HTMLElement).style.display = 'none';
}
}
});
}
});
}
// Set up the convert button click handler
const convertButton = document.getElementById('convertButton');
const searchButton = document.getElementById('searchServerBtn');
const publishButton = document.getElementById('publishButton');
if (convertButton) {
convertButton.addEventListener('click', displayConvertedEvent);
}
if (searchButton) {
searchButton.addEventListener('click', handleServerSearch);
}
if (publishButton) {
publishButton.addEventListener('click', handlePublishEvent);
}

@ -143,11 +143,20 @@ async function handleFilterChange(): Promise<void> {
}
/**
* Initialize server npub field if present
* Initialize server npub field if present and check for/create 31120 event
*/
async function initializeServerNpub(): Promise<void> {
const serverNpubInput = document.getElementById('serverNpub') as HTMLInputElement;
const copyServerNpubBtn = document.getElementById('copyServerNpubBtn');
const toggleFormatBtn = document.getElementById('toggleFormatBtn');
const formatIndicator = document.getElementById('formatIndicator');
const relayUrlInput = document.getElementById('relayUrl') as HTMLInputElement;
const relayStatusElement = document.getElementById('relayStatus');
// For storing both formats of the server pubkey
let serverPubkeyHex = '';
let serverPubkeyNpub = '';
let currentFormat = 'npub'; // Track current display format
if (serverNpubInput) {
try {
@ -158,21 +167,135 @@ async function initializeServerNpub(): Promise<void> {
const pubkeyHex = await window.nostr.getPublicKey();
// Check if it's already in npub format or needs conversion
let npub;
if (pubkeyHex.startsWith('npub1')) {
// Already in npub format
serverNpubInput.value = pubkeyHex;
npub = pubkeyHex;
console.log("Server NPUB set from nostr-login (already in npub format):", pubkeyHex);
} else {
// Convert hex to npub format
try {
// Use imported nostrTools for conversion
const npub = nostrTools.nip19.npubEncode(pubkeyHex);
serverNpubInput.value = npub;
npub = nostrTools.nip19.npubEncode(pubkeyHex);
console.log("Server NPUB converted from hex to npub:", npub);
} catch (conversionError) {
console.error("Failed to convert hex to npub:", conversionError);
// If conversion fails, display original format as fallback
serverNpubInput.value = pubkeyHex;
npub = pubkeyHex;
}
}
// Set the initial value of the server NPUB field
serverNpubInput.value = npub;
// Display "Checking..." message
if (relayStatusElement) {
relayStatusElement.textContent = "Checking for server registration...";
relayStatusElement.className = "relay-status connecting";
}
// Get the relay URL from the input field
const relayUrl = relayUrlInput?.value || defaultServerConfig.defaultRelay;
// Query for existing 31120 event
try {
console.log("Querying relay for 31120 event...");
// Pass the user's pubkey to look for 31120 events published by this user
const userPubkey = nostrService.getLoggedInPubkey();
const existingEvent = await nostrService.queryFor31120Event(relayUrl, userPubkey);
if (existingEvent) {
console.log("Found existing 31120 event:", existingEvent);
// Look for the d tag to find the server pubkey
const dTag = existingEvent.tags.find(tag => tag[0] === 'd');
if (dTag && dTag.length > 1) {
// Display the server pubkey
// Store both formats
serverPubkeyHex = dTag[1];
try {
serverPubkeyNpub = nostrTools.nip19.npubEncode(dTag[1]);
serverNpubInput.value = serverPubkeyNpub;
console.log("Using server pubkey from existing 31120 event:", serverPubkeyNpub);
} catch (error) {
console.error("Error encoding server pubkey:", error);
serverNpubInput.value = dTag[1];
serverPubkeyNpub = dTag[1]; // Fallback to hex if conversion fails
}
}
// Display the raw JSON for debugging
console.log("31120 Event JSON:", JSON.stringify(existingEvent, null, 2));
if (relayStatusElement) {
relayStatusElement.textContent = "Server registration found ✓";
relayStatusElement.className = "relay-status connected";
}
} else {
console.log("No 31120 event found, creating one...");
if (relayStatusElement) {
relayStatusElement.textContent = "Creating server registration...";
relayStatusElement.className = "relay-status connecting";
}
// Create a new 31120 event
const newEvent = await nostrService.create31120Event(relayUrl);
if (newEvent) {
console.log("Created new 31120 event:", newEvent);
// Look for the d tag to find the server pubkey
const dTag = newEvent.tags.find(tag => tag[0] === 'd');
if (dTag && dTag.length > 1) {
// Display the server pubkey
// Store both formats
serverPubkeyHex = dTag[1];
try {
serverPubkeyNpub = nostrTools.nip19.npubEncode(dTag[1]);
serverNpubInput.value = serverPubkeyNpub;
console.log("Using server pubkey from new 31120 event:", serverPubkeyNpub);
} catch (error) {
console.error("Error encoding server pubkey:", error);
serverNpubInput.value = dTag[1];
serverPubkeyNpub = dTag[1]; // Fallback to hex if conversion fails
}
}
// Display the raw JSON for debugging
console.log("31120 Event JSON:", JSON.stringify(newEvent, null, 2));
// Create a debug output area to show the JSON
const eventDebugOutput = document.createElement('div');
eventDebugOutput.className = 'event-debug-output';
eventDebugOutput.innerHTML = `
<h3>31120 Event JSON (Debug)</h3>
<pre>${JSON.stringify(newEvent, null, 2)}</pre>
`;
// Insert it after the server info container
const serverInfoContainer = document.querySelector('.server-info-container');
if (serverInfoContainer) {
serverInfoContainer.parentNode?.insertBefore(eventDebugOutput, serverInfoContainer.nextSibling);
}
if (relayStatusElement) {
relayStatusElement.textContent = "Server registration created ✓";
relayStatusElement.className = "relay-status connected";
}
} else {
console.error("Failed to create 31120 event");
if (relayStatusElement) {
relayStatusElement.textContent = "Failed to create server registration";
relayStatusElement.className = "relay-status error";
}
}
}
} catch (eventError) {
console.error("Error handling 31120 event:", eventError);
if (relayStatusElement) {
relayStatusElement.textContent = `Error: ${eventError instanceof Error ? eventError.message : String(eventError)}`;
relayStatusElement.className = "relay-status error";
}
}
} catch (nostrError) {
@ -189,6 +312,25 @@ async function initializeServerNpub(): Promise<void> {
}
}
// Add toggle format button functionality
if (toggleFormatBtn && serverNpubInput && formatIndicator) {
toggleFormatBtn.addEventListener('click', () => {
if (currentFormat === 'npub') {
// Switch to hex format
serverNpubInput.value = serverPubkeyHex;
currentFormat = 'hex';
formatIndicator.textContent = 'Currently showing: HEX format';
document.getElementById('formatBtnText')!.textContent = 'NPUB';
} else {
// Switch to npub format
serverNpubInput.value = serverPubkeyNpub;
currentFormat = 'npub';
formatIndicator.textContent = 'Currently showing: NPUB format';
document.getElementById('formatBtnText')!.textContent = 'HEX';
}
});
}
// Add copy button functionality
if (copyServerNpubBtn && serverNpubInput) {
copyServerNpubBtn.addEventListener('click', () => {

@ -8,7 +8,7 @@ import * as nostrTools from 'nostr-tools';
// Project imports
import type { NostrEvent } from '../relay';
import { convertNpubToHex } from '../relay';
import { convertNpubToHex, publishToRelay } from '../relay';
import { WebSocketManager } from './WebSocketManager';
@ -54,6 +54,12 @@ export class NostrService {
private relayPool: nostrTools.SimplePool | null = null;
private wsManager = new WebSocketManager();
private activeRelayUrl: string | null = null;
// Local memory cache (for immediate access during the session)
private events31120Cache = new Map<string, [number, {[key: string]: NostrEvent}]>();
// Cache expiration time in milliseconds (5 minutes)
private cacheExpiryTime = 5 * 60 * 1000;
// localStorage key prefix
private localStorageCachePrefix = 'nostr_31120_cache_';
// eslint-disable-next-line no-unused-vars
private eventHandler: ((event: NostrEvent) => void) | null = null;
// eslint-disable-next-line no-unused-vars
@ -188,8 +194,452 @@ export class NostrService {
}
/**
* Create a filter for kind 21120 events, optionally filtered for a specific user
* @param showAllEvents Whether to show all events or only those for the logged-in user
* Query relay for existing kind 31120 events
* @param relayUrl The relay URL to query
* @param authorPubkey Optional pubkey to filter by author (if null, fetch all 31120 events)
* @returns Promise resolving to the found event or null if not found
*/
public async queryFor31120Event(relayUrl: string, authorPubkey?: string | null): Promise<NostrEvent | null> {
console.log('Querying for 31120 event...');
// Prepare filter for kind 31120 events
const filter: any = {
kinds: [31120]
};
// If authorPubkey is provided, add it to the filter
if (authorPubkey) {
// Convert to hex if needed
let pubkeyHex = authorPubkey;
if (authorPubkey.startsWith('npub')) {
try {
const hexPubkey = convertNpubToHex(authorPubkey);
if (hexPubkey) {
pubkeyHex = hexPubkey;
}
} catch (error) {
console.error('Error converting npub to hex:', error);
return null;
}
}
// Add author filter
filter.authors = [pubkeyHex];
}
try {
// Connect to relay if not already connected
if (!this.isConnected() || this.activeRelayUrl !== relayUrl) {
const connected = await this.connectToRelay(relayUrl);
if (!connected) {
throw new Error(`Failed to connect to relay: ${relayUrl}`);
}
}
// Query for events
this.updateStatus('Querying for 31120 events...', 'connecting');
// Use a promise to wait for the query result
return new Promise((resolve, reject) => {
const requestId = `query-31120-${Date.now()}`;
const wsTimeout = setTimeout(() => {
this.wsManager.close();
reject(new Error('Query timeout'));
}, 10000);
this.wsManager.connect(relayUrl, {
timeout: 5000,
onOpen: (ws) => {
// Send a REQ message to query
const reqMsg = JSON.stringify(["REQ", requestId, filter]);
ws.send(reqMsg);
this.updateStatus('Querying relay...', 'connecting');
},
onMessage: (data) => {
// Parse the message
const nostrData = data as unknown[];
// Check if it's an EVENT message
if (Array.isArray(nostrData) && nostrData[0] === "EVENT" && nostrData[1] === requestId) {
const event = nostrData[2] as NostrEvent;
// Check if this is a 31120 event
if (event.kind === 31120) {
// If we're looking for specific author, check it matches
if (authorPubkey && filter.authors && event.pubkey !== filter.authors[0]) {
return; // Continue looking for events with matching pubkey
}
clearTimeout(wsTimeout);
this.wsManager.close();
resolve(event);
}
}
// If it's an EOSE message, we've received all events
if (Array.isArray(nostrData) && nostrData[0] === "EOSE" && nostrData[1] === requestId) {
clearTimeout(wsTimeout);
this.wsManager.close();
resolve(null); // No matching event found
}
},
onError: (error) => {
clearTimeout(wsTimeout);
reject(new Error(`WebSocket error: ${error}`));
},
onClose: () => {
clearTimeout(wsTimeout);
}
}).catch(error => {
clearTimeout(wsTimeout);
reject(error);
});
});
} catch (error) {
this.updateStatus(`Query error: ${error instanceof Error ? error.message : String(error)}`, 'error');
return null;
}
}
/**
* Query relay for all kind 31120 events
* @param relayUrl The relay URL to query
* @returns Promise resolving to an array of matching events
*/
public async queryForAll31120Events(relayUrl: string): Promise<NostrEvent[]> {
console.log('Querying for all 31120 events...');
// First, try to get from in-memory cache (fastest)
const cachedData = this.events31120Cache.get(relayUrl);
const now = Date.now();
if (cachedData) {
const [timestamp, eventsMap] = cachedData;
// If memory cache is still valid (less than cacheExpiryTime milliseconds old)
if (now - timestamp < this.cacheExpiryTime) {
console.log(`Using in-memory cached 31120 events for ${relayUrl} (${Object.keys(eventsMap).length} events)`);
return Object.values(eventsMap);
}
}
// Second, try localStorage (persists between page navigations)
const localStorageKey = this.localStorageCachePrefix + relayUrl;
const storedCache = localStorage.getItem(localStorageKey);
if (storedCache) {
try {
const parsedCache = JSON.parse(storedCache) as [number, {[key: string]: NostrEvent}];
const [timestamp, eventsMap] = parsedCache;
// If localStorage cache is still valid
if (now - timestamp < this.cacheExpiryTime) {
console.log(`Using localStorage cached 31120 events for ${relayUrl} (${Object.keys(eventsMap).length} events)`);
// Update in-memory cache too
this.events31120Cache.set(relayUrl, parsedCache);
return Object.values(eventsMap);
}
console.log(`localStorage cache expired for ${relayUrl}, fetching fresh data...`);
} catch (error) {
console.error('Error parsing localStorage cache:', error);
// Continue to fetch from network
}
} else {
console.log(`No localStorage cache available for ${relayUrl}, fetching data...`);
}
try {
// Connect to relay if not already connected
if (!this.isConnected() || this.activeRelayUrl !== relayUrl) {
const connected = await this.connectToRelay(relayUrl);
if (!connected) {
throw new Error(`Failed to connect to relay: ${relayUrl}`);
}
}
// Prepare filter for kind 31120 events
const filter = {
kinds: [31120]
};
// Query for events
this.updateStatus('Querying for all 31120 events...', 'connecting');
// Use a promise to wait for all query results
return new Promise((resolve, reject) => {
const collectedEvents: NostrEvent[] = [];
const requestId = `query-all-31120-${Date.now()}`;
const wsTimeout = setTimeout(() => {
this.wsManager.close();
if (collectedEvents.length > 0) {
// Store in cache before resolving
this.cacheEvents(relayUrl, collectedEvents);
resolve(collectedEvents); // Return whatever we have if we hit timeout
} else {
reject(new Error('Query timeout'));
}
}, 10000);
this.wsManager.connect(relayUrl, {
timeout: 5000,
onOpen: (ws) => {
// Send a REQ message to query
const reqMsg = JSON.stringify(["REQ", requestId, filter]);
ws.send(reqMsg);
this.updateStatus('Querying relay for all servers...', 'connecting');
},
onMessage: (data) => {
// Parse the message
const nostrData = data as unknown[];
// Check if it's an EVENT message
if (Array.isArray(nostrData) && nostrData[0] === "EVENT" && nostrData[1] === requestId) {
const event = nostrData[2] as NostrEvent;
// Check if this is a 31120 event
if (event.kind === 31120) {
console.log('Found 31120 event:', event.id);
collectedEvents.push(event);
}
}
// If it's an EOSE message, we've received all events
if (Array.isArray(nostrData) && nostrData[0] === "EOSE" && nostrData[1] === requestId) {
clearTimeout(wsTimeout);
this.wsManager.close();
console.log(`Found ${collectedEvents.length} 31120 events`);
// Cache the events before resolving
this.cacheEvents(relayUrl, collectedEvents);
resolve(collectedEvents);
}
},
onError: (error) => {
clearTimeout(wsTimeout);
reject(new Error(`WebSocket error: ${error}`));
},
onClose: () => {
clearTimeout(wsTimeout);
}
}).catch(error => {
clearTimeout(wsTimeout);
reject(error);
});
});
} catch (error) {
this.updateStatus(`Query error: ${error instanceof Error ? error.message : String(error)}`, 'error');
return [];
}
}
/**
* Create or update a 31120 event
* @param relayUrl The relay URL to publish to
* @param content The content/description of the server
* @param relays Array of relay URLs to include as tags
* @param expiryHours Number of hours until expiry
* @param existingEventId Optional ID of an existing event to update
* @returns Promise resolving to the created/updated event
*/
public async createOrUpdate31120Event(
relayUrl: string,
content: string,
relays: string[],
expiryHours: number,
existingEventId?: string
): Promise<NostrEvent | null> {
console.log(`${existingEventId ? 'Updating' : 'Creating'} 31120 event...`);
try {
// Get the user's pubkey
const pubkey = this.getLoggedInPubkey();
if (!pubkey) {
throw new Error('No user pubkey available, cannot create/update 31120 event');
}
// Convert to hex if needed
let pubkeyHex = pubkey;
if (pubkey.startsWith('npub')) {
try {
const hexPubkey = convertNpubToHex(pubkey);
if (hexPubkey) {
pubkeyHex = hexPubkey;
}
} catch (error) {
throw new Error(`Error converting npub to hex: ${error}`);
}
}
// Set expiry to current time + specified hours (in seconds)
const now = Math.floor(Date.now() / 1000);
const expiry = now + (expiryHours * 60 * 60);
// For existing events, we'll need to get the d-tag value for the server pubkey
let serverPubkey: string;
if (existingEventId) {
// Get the existing event
const existingEvent = await this.getEventById(relayUrl, existingEventId);
if (!existingEvent) {
throw new Error('Could not find the event to update');
}
// Get the d tag value (server pubkey)
const dTag = existingEvent.tags.find(tag => tag[0] === 'd');
if (!dTag || dTag.length < 2) {
throw new Error('Server pubkey not found in existing event');
}
serverPubkey = dTag[1];
} else {
// Generate a new server key pair for new events
const serverKeyPair = nostrTools.generateSecretKey();
serverPubkey = nostrTools.getPublicKey(serverKeyPair);
const serverNsec = nostrTools.nip19.nsecEncode(serverKeyPair);
// Store the server nsec locally
localStorage.setItem('serverNsec', serverNsec);
}
// Create relay tags
const relayTags = relays.map(url => ["relay", url]);
// Create the 31120 event
const event: NostrEvent = {
kind: 31120,
pubkey: pubkeyHex,
created_at: now,
content: content || "HTTP-over-Nostr server",
tags: [
["d", serverPubkey], // Server pubkey
...relayTags,
["expiry", expiry.toString()]
]
};
// Sign the event using window.nostr
if (!window.nostr || typeof window.nostr.signEvent !== 'function') {
throw new Error('No Nostr extension available for signing');
}
try {
// Request signing from the extension
const signedEvent = await window.nostr.signEvent(event);
// Publish the signed event
this.updateStatus(`Publishing ${existingEventId ? 'updated' : 'new'} 31120 event...`, 'connecting');
await publishToRelay(signedEvent, relayUrl);
console.log('31120 event published successfully');
this.updateStatus(`Server ${existingEventId ? 'updated' : 'registered'}`, 'connected');
return signedEvent;
} catch (error) {
throw new Error(`Error signing or publishing event: ${error}`);
}
} catch (error) {
this.updateStatus(`Error with 31120 event: ${error instanceof Error ? error.message : String(error)}`, 'error');
return null;
}
}
/**
* Get a specific event by ID
* @param relayUrl The relay URL to query
* @param eventId The event ID to look for
* @returns Promise resolving to the event or null if not found
*/
public async getEventById(relayUrl: string, eventId: string): Promise<NostrEvent | null> {
console.log(`Fetching event with ID: ${eventId}`);
try {
// Connect to relay if not already connected
if (!this.isConnected() || this.activeRelayUrl !== relayUrl) {
const connected = await this.connectToRelay(relayUrl);
if (!connected) {
throw new Error(`Failed to connect to relay: ${relayUrl}`);
}
}
// Create a filter for the specific event ID
const filter = {
ids: [eventId]
};
// Query for the event
return new Promise((resolve, reject) => {
const requestId = `query-event-${Date.now()}`;
const wsTimeout = setTimeout(() => {
this.wsManager.close();
reject(new Error('Query timeout'));
}, 10000);
this.wsManager.connect(relayUrl, {
timeout: 5000,
onOpen: (ws) => {
// Send a REQ message to query
const reqMsg = JSON.stringify(["REQ", requestId, filter]);
ws.send(reqMsg);
},
onMessage: (data) => {
const nostrData = data as unknown[];
// Check if it's an EVENT message
if (Array.isArray(nostrData) && nostrData[0] === "EVENT" && nostrData[1] === requestId) {
const event = nostrData[2] as NostrEvent;
if (event.id === eventId) {
clearTimeout(wsTimeout);
this.wsManager.close();
resolve(event);
}
}
// If it's an EOSE message, we've received all events
if (Array.isArray(nostrData) && nostrData[0] === "EOSE" && nostrData[1] === requestId) {
clearTimeout(wsTimeout);
this.wsManager.close();
resolve(null); // Event not found
}
},
onError: (error) => {
clearTimeout(wsTimeout);
reject(new Error(`WebSocket error: ${error}`));
},
onClose: () => {
clearTimeout(wsTimeout);
}
}).catch(error => {
clearTimeout(wsTimeout);
reject(error);
});
});
} catch (error) {
console.error('Error fetching event:', error);
return null;
}
}
/**
* Create and publish a new 31120 event (legacy method)
* @param relayUrl The relay URL to publish to
* @returns Promise resolving to the created event
*/
public async create31120Event(relayUrl: string): Promise<NostrEvent | null> {
// Use the new method with default values
return this.createOrUpdate31120Event(
relayUrl,
"HTTP-over-Nostr server",
[relayUrl],
24 // Default expiry of 24 hours
);
}
/**
* Create a filter for kind 21120 events, optionally filtered for a specific server
* @param showAllEvents Whether to show all events or only those for the server
* @returns A NostrFilter for the subscription
*/
public createKind21120Filter(showAllEvents: boolean): NostrFilter {
@ -198,27 +648,33 @@ export class NostrService {
kinds: [21120], // HTTP Messages event kind
};
// If "Show all events" is not checked and the user is logged in, filter for events addressed to them
// If "Show all events" is not checked, filter only for events addressed to the server
if (!showAllEvents) {
const loggedInPubkey = this.getLoggedInPubkey();
// Get the server pubkey from localStorage (set during server registration)
const serverNsec = localStorage.getItem('serverNsec');
let serverPubkey = null;
if (loggedInPubkey) {
let pubkeyHex = loggedInPubkey;
// Convert npub to hex if needed
if (loggedInPubkey.startsWith('npub')) {
try {
const hexPubkey = convertNpubToHex(loggedInPubkey);
if (hexPubkey) {
pubkeyHex = hexPubkey;
}
} catch {
// Ignore conversion errors
if (serverNsec) {
try {
// Decode the nsec to get the private key
const decoded = nostrTools.nip19.decode(serverNsec);
if (decoded.type === 'nsec') {
// Get the server pubkey from the private key
// Convert Uint8Array to hex string
const privateKeyHex = Array.from(decoded.data as Uint8Array)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
const privateKeyBytes = new Uint8Array(privateKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
serverPubkey = nostrTools.getPublicKey(privateKeyBytes);
}
} catch (error) {
console.error('Error getting server pubkey:', error);
}
// Add p-tag filter for events addressed to the logged-in user
filter['#p'] = [pubkeyHex];
}
// Add p-tag filter for events addressed to the server
if (serverPubkey) {
filter['#p'] = [serverPubkey];
}
}
@ -348,4 +804,68 @@ export class NostrService {
callback(message, className);
}
}
/**
* Cache 31120 events by relay URL
* @param relayUrl The relay URL used as cache key
* @param events Array of 31120 events to cache
*/
private cacheEvents(relayUrl: string, events: NostrEvent[]): void {
// Create a map of events by server pubkey
const eventsMap: {[key: string]: NostrEvent} = {};
for (const event of events) {
// Find the d tag which contains the server pubkey
const dTag = event.tags.find((tag: string[]) => tag[0] === 'd');
if (dTag && dTag.length > 1) {
const serverPubkey = dTag[1];
// Store the event with the server pubkey as the key
eventsMap[serverPubkey] = event;
}
}
const timestamp = Date.now();
const cacheData: [number, {[key: string]: NostrEvent}] = [timestamp, eventsMap];
// Store in memory cache
this.events31120Cache.set(relayUrl, cacheData);
// Store in localStorage for persistence between page navigations
try {
localStorage.setItem(this.localStorageCachePrefix + relayUrl, JSON.stringify(cacheData));
console.log(`Cached ${Object.keys(eventsMap).length} 31120 events for ${relayUrl} in memory and localStorage`);
} catch (error) {
console.error('Failed to store cache in localStorage:', error);
// Still keep the in-memory cache even if localStorage fails
}
}
/**
* Clear the 31120 events cache for a specific relay or all relays
* @param relayUrl Optional relay URL to clear cache for. If not provided, clears all caches.
*/
public clearEventsCache(relayUrl?: string): void {
if (relayUrl) {
// Clear in-memory cache
this.events31120Cache.delete(relayUrl);
// Clear localStorage cache
localStorage.removeItem(this.localStorageCachePrefix + relayUrl);
console.log(`Cleared cache for ${relayUrl} (memory and localStorage)`);
} else {
// Clear all in-memory caches
this.events31120Cache.clear();
// Clear all localStorage caches
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(this.localStorageCachePrefix)) {
localStorage.removeItem(key);
}
}
console.log('Cleared all event caches (memory and localStorage)');
}
}
}

@ -4,6 +4,7 @@
*/
import jsQR from 'jsqr';
import * as nostrTools from 'nostr-tools';
import type { NostrEvent } from '../relay';
@ -53,6 +54,14 @@ export class UiService {
// Set event handler for Nostr events
this.nostrService.setEventHandler((event) => this.processEvent(event));
// Apply initial filtering based on checkbox state
setTimeout(() => {
const showAllEventsCheckbox = document.getElementById('showAllEvents') as HTMLInputElement;
if (showAllEventsCheckbox) {
this.filterEventsInUI(showAllEventsCheckbox.checked);
}
}, 500); // Short delay to ensure UI is fully initialized
}
/**
@ -277,6 +286,9 @@ export class UiService {
// Resubscribe with new filter
const filter = this.nostrService.createKind21120Filter(showAllEventsCheckbox.checked);
await this.nostrService.subscribeToEvents(filter);
// Also filter existing events in the UI
this.filterEventsInUI(showAllEventsCheckbox.checked);
} catch (error) {
alert(`Error updating subscription: ${error instanceof Error ? error.message : String(error)}`);
}
@ -345,11 +357,35 @@ export class UiService {
const hasE = event.tags.some(tag => tag[0] === 'e');
const eventType = hasP ? 'HTTP Request' : (hasE ? 'HTTP Response' : 'Unknown');
// Find recipient if available
// Find recipient if available and check if it's addressed to our server
let recipient = '';
let isToServer = false;
const pTag = event.tags.find(tag => tag[0] === 'p');
if (pTag && pTag.length > 1) {
recipient = `| To: ${pTag[1].substring(0, 8)}...`;
// Check if this message is addressed to our server
try {
// Get the server pubkey from localStorage
const serverNsec = localStorage.getItem('serverNsec');
if (serverNsec) {
const decoded = nostrTools.nip19.decode(serverNsec);
if (decoded.type === 'nsec') {
// Convert Uint8Array to hex string
const privateKeyHex = Array.from(decoded.data as Uint8Array)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
const privateKeyBytes = new Uint8Array(privateKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
const serverPubkey = nostrTools.getPublicKey(privateKeyBytes);
// Check if the p tag matches our server pubkey
isToServer = (pTag[1] === serverPubkey);
}
}
} catch {
// Ignore errors when checking
}
}
// Format the event item
@ -363,12 +399,15 @@ export class UiService {
<div class="event-type ${eventType === 'HTTP Request' ? 'request' : 'response'}">${eventType}</div>
<div class="event-time">${new Date(event.created_at * 1000).toLocaleTimeString()}</div>
</div>
<div class="event-id">ID: ${eventIdForDisplay}... ${recipient}</div>
<div class="event-id">ID: ${eventIdForDisplay}... ${recipient} ${isToServer ? '<span class="server-match">✓</span>' : ''}</div>
<div class="event-pubkey">From: ${event.pubkey.substring(0, 8)}...</div>
</div>
</div>
`;
// Set a data attribute to indicate if this event is addressed to our server
eventItem.dataset.toServer = isToServer.toString();
// Add to list at the top
if (this.eventsList.firstChild) {
this.eventsList.insertBefore(eventItem, this.eventsList.firstChild);
@ -474,11 +513,28 @@ export class UiService {
''
}
<pre class="http-content">${httpContent}</pre>
${!receivedEvent.decrypted ? '<div class="decryption-status">Attempting decryption...</div>' : ''}
${!receivedEvent.decrypted ? '<div class="decryption-status" id="decryption-status-' + eventId + '">Attempting decryption...</div>' : ''}
</div>
</div>
`;
// If the event isn't decrypted yet, trigger decryption
if (!receivedEvent.decrypted && event.id) {
const eventId = event.id; // Store in a const to make TypeScript happy
console.log(`Triggering decryption for event ${eventId.substring(0, 8)}...`);
// Use setTimeout to allow the UI to render first
setTimeout(() => {
this.decryptEvent(eventId).catch(error => {
console.error(`Error in decryption process:`, error);
const decryptionStatus = document.getElementById(`decryption-status-${eventId}`);
if (decryptionStatus) {
decryptionStatus.textContent = `Decryption error: ${error.message || 'Unknown error'}`;
decryptionStatus.classList.add('error');
}
});
}, 100);
}
// Add event listeners for tab buttons
const tabButtons = this.eventDetails.querySelectorAll('.tab-btn');
tabButtons.forEach(button => {
@ -575,12 +631,74 @@ export class UiService {
* @param eventId The ID of the event to decrypt
*/
public async decryptEvent(eventId: string): Promise<void> {
console.log(`==== DECRYPTION PROCESS STARTED FOR EVENT ${eventId.substring(0, 8)}... ====`);
// Check if we have the event
const receivedEvent = this.receivedEvents.get(eventId);
if (!receivedEvent || receivedEvent.decrypted) {
if (!receivedEvent) {
console.error(`❌ Event with ID ${eventId.substring(0, 8)}... not found in receivedEvents Map!`);
return;
}
// Check if already decrypted
if (receivedEvent.decrypted) {
console.log(` Event ${eventId.substring(0, 8)}... is already decrypted, skipping`);
return;
}
// Check if this event is addressed to our server by finding p tag
const event = receivedEvent.event;
const pTag = event.tags.find(tag => tag[0] === 'p');
let isAddressedToServer = false;
if (pTag && pTag.length > 1) {
// Get the server pubkey from localStorage
const serverNsec = localStorage.getItem('serverNsec');
if (serverNsec) {
try {
const decoded = nostrTools.nip19.decode(serverNsec);
if (decoded.type === 'nsec') {
// Convert Uint8Array to hex string
const privateKeyHex = Array.from(decoded.data as Uint8Array)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
const privateKeyBytes = new Uint8Array(privateKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
const serverPubkey = nostrTools.getPublicKey(privateKeyBytes);
// Check if the p tag matches our server pubkey
isAddressedToServer = (pTag[1] === serverPubkey);
}
} catch (error) {
console.error('Error checking if event is addressed to server:', error);
}
}
}
// If not addressed to our server, mark as "decrypted" but with a note
if (!isAddressedToServer) {
console.log(`⚠️ Event ${eventId.substring(0, 8)}... is not addressed to this server, skipping decryption`);
receivedEvent.decrypted = true;
receivedEvent.decryptedContent = "[This message is not addressed to this server]";
this.receivedEvents.set(eventId, receivedEvent);
// Update UI
const decryptionStatus = document.getElementById(`decryption-status-${eventId}`);
if (decryptionStatus) {
decryptionStatus.textContent = 'Not addressed to this server';
decryptionStatus.classList.add('info');
}
// Update UI if this event is currently being viewed
const selectedEventId = this.eventDetails?.querySelector('h3')?.textContent?.match(/(.+)\.\.\./)?.[1];
if (selectedEventId && `${selectedEventId}...` === `${eventId.substring(0, 8)}...`) {
this.showEventDetails(eventId);
}
return;
}
try {
console.log(`✓ Found event ${eventId.substring(0, 8)}..., proceeding with decryption`);
const event = receivedEvent.event;
let decryptedContent: string;
@ -594,66 +712,258 @@ export class UiService {
// Extract the encrypted key from the tag
const encryptedKey = keyTag[1];
// Check if window.nostr and nip44.decrypt are available
if (!window.nostr) {
throw new Error("window.nostr is not available - ensure a NIP-07 extension is installed");
}
console.log(`Starting decryption process for event ${eventId.substring(0, 8)}...`);
if (!window.nostr.nip44 || !window.nostr.nip44.decrypt) {
console.warn("NIP-44 decryption not available - trying to connect to a compatible extension");
// Check if we can trigger a connection via NostrLogin
if (typeof window.nostr.getPublicKey === 'function') {
try {
await window.nostr.getPublicKey();
// Check again after getting public key
if (typeof window.nostr.nip44 === 'object' &&
typeof window.nostr.nip44.decrypt === 'function') {
console.log("NIP-44 is now available after connecting");
} else {
throw new Error("NIP-44 decryption is still not available after connecting");
}
} catch (e) {
throw new Error(`Failed to connect to extension: ${e}`);
}
} else {
throw new Error("window.nostr.nip44.decrypt is not available");
}
}
// Declare decryptedKey outside the try block so it's available in the outer scope
// Declare variable to hold the decrypted key
let decryptedKey: string;
try {
// Use window.nostr to decrypt the key tag with NIP-44
decryptedKey = await window.nostr.nip44.decrypt(
event.pubkey, // The pubkey that encrypted the key
encryptedKey // The encrypted key from the "key" tag
);
} catch (decryptKeyError) {
console.error("Error in direct decryption call:", decryptKeyError);
throw decryptKeyError; // Re-throw to be caught by the outer catch block
// Update the decryption status UI
const decryptionStatus = document.getElementById(`decryption-status-${eventId}`);
if (decryptionStatus) {
decryptionStatus.textContent = 'Getting server private key from storage...';
}
// Get the server private key from localStorage
console.log('🔑 Looking for server nsec in localStorage...');
const serverNsec = localStorage.getItem('serverNsec');
if (!serverNsec) {
console.error('❌ Server private key (nsec) not found in localStorage');
console.log('💡 This should be set when server is registered in NostrService.createOrUpdate31120Event');
// Check all localStorage keys to help debug
console.log('📋 Available localStorage keys:');
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) {
console.log(` - ${key}: ${localStorage.getItem(key)?.substring(0, 20)}...`);
}
}
if (decryptionStatus) {
decryptionStatus.textContent = 'Server private key (nsec) not found in localStorage';
decryptionStatus.classList.add('error');
}
throw new Error('Server private key (nsec) not found in localStorage');
}
// Now use the decrypted key to decrypt the content using Web Crypto API
console.log('✓ Server nsec found in localStorage:', serverNsec.substring(0, 10) + '...');
try {
// Decrypt the content using Web Crypto API and the decrypted key
const decryptedEventContent = await this.httpService.decryptWithWebCrypto(
event.content,
decryptedKey
);
// Make sure nostr-tools is available
if (!nostrTools || !nostrTools.nip19 || !nostrTools.nip44) {
console.error('Required nostr-tools libraries not available');
if (decryptionStatus) {
decryptionStatus.textContent = 'Required nostr-tools libraries not available';
decryptionStatus.classList.add('error');
}
throw new Error('Required nostr-tools libraries not available');
}
// The decrypted content is the direct HTTP request/response
decryptedContent = decryptedEventContent;
} catch (contentDecryptError) {
decryptedContent = `Key decryption successful, but content decryption failed: ${contentDecryptError instanceof Error ? contentDecryptError.message : String(contentDecryptError)}
console.log('Decoding nsec to get the raw private key');
// Let's try a completely different approach to extract the private key from nsec
console.log('🔑 Extracting private key from server nsec using a different method');
let privateKeyHex: string;
try {
// Example shown in the documentation uses a direct hex string
// The nsec format should decode to the raw private key
const decoded = nostrTools.nip19.decode(serverNsec);
if (decoded.type !== 'nsec') {
throw new Error(`Expected nsec but got ${decoded.type}`);
}
// Log the raw data for debugging
console.log('Raw decoded data type:', typeof decoded.data);
console.log('Raw decoded data length:', (decoded.data as Uint8Array).length);
// Direct conversion to hex
// Convert Uint8Array to hex string
privateKeyHex = Array.from(decoded.data as Uint8Array)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
console.log('✅ Successfully decoded private key from buffer:', privateKeyHex.substring(0, 8) + '...');
// Check if the private key looks valid (should be 64 hex chars)
if (privateKeyHex.length !== 64) {
console.warn(`⚠️ Private key has unexpected length: ${privateKeyHex.length} (expected 64)`);
}
// Check if the private key looks valid (should be 64 hex chars)
if (privateKeyHex.length !== 64) {
console.warn(`⚠️ Private key has unexpected length: ${privateKeyHex.length} (expected 64)`);
}
} catch (decodeError: unknown) {
console.error('❌ Failed to decode nsec:', decodeError);
if (decryptionStatus) {
decryptionStatus.textContent = 'Failed to decode server key';
decryptionStatus.classList.add('error');
}
throw new Error(`Failed to decode server key: ${decodeError instanceof Error ? decodeError.message : String(decodeError)}`);
}
// Log available utilities in nostrTools for debugging
console.log('📚 Available nostrTools methods:', Object.keys(nostrTools).join(', '));
// Convert hex to Uint8Array manually
const privateKeyBytes = new Uint8Array(privateKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
// Get server pubkey from the private key for display/logging
const serverPubkey = nostrTools.getPublicKey(privateKeyBytes);
const serverNpub = nostrTools.nip19.npubEncode(serverPubkey);
console.log(`Server pubkey: ${serverPubkey.substring(0, 8)}... (npub: ${serverNpub.substring(0, 8)}...)`);
if (decryptionStatus) {
decryptionStatus.textContent = `Attempting decryption of key using server npub: ${serverNpub.substring(0, 8)}...`;
}
// Attempt NIP-44 decryption with the server's private key
try {
console.log('🔓 Attempting NIP-44 decryption with server key');
console.log('📄 Encrypted key from event:', encryptedKey);
console.log('🔑 Using server key (first 8 bytes):', privateKeyHex.substring(0, 16));
// Check if NIP-44 is available
const nip44Any = nostrTools.nip44 as any;
if (!nip44Any) {
console.error('❌ nostrTools.nip44 is undefined!');
console.log('📋 Available nostrTools modules:', Object.keys(nostrTools).join(', '));
throw new Error('NIP-44 module not available in nostr-tools');
}
if (typeof nip44Any.decrypt !== 'function') {
console.error('❌ nostrTools.nip44.decrypt is not a function!');
console.log('📋 nip44 object properties:', Object.keys(nip44Any).join(', '));
throw new Error('NIP-44 decrypt function not available in nostr-tools');
}
// Perform the decryption using only nostr-tools (no window.nostr!)
console.log('⏳ Calling nip44.decrypt with the correct parameters...');
console.log('📄 Ciphertext:', encryptedKey);
console.log('🔑 Private key (hex):', privateKeyHex.substring(0, 16) + '...');
console.log('👤 Sender pubkey:', event.pubkey);
// Calculate the shared secret (conversation key) between server private key and sender pubkey
// This is needed for NIP-44 decryption
const privateKeyBytes = new Uint8Array(privateKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
const conversationKey = nostrTools.nip44.getConversationKey(privateKeyBytes, event.pubkey);
// Use the correct parameters for nip44.decrypt (payload, conversationKey)
decryptedKey = nip44Any.decrypt(encryptedKey, conversationKey);
console.log('✅ NIP-44 key decryption succeeded!');
console.log('🔑 Decrypted key:', decryptedKey.substring(0, 10) + '...');
if (decryptionStatus) {
decryptionStatus.textContent = `Successfully decrypted key with server npub: ${serverNpub.substring(0, 8)}...`;
decryptionStatus.classList.add('success');
}
} catch (nip44Error) {
console.error('NIP-44 decryption error:', nip44Error);
if (decryptionStatus) {
decryptionStatus.textContent = `Decryption failed with server npub: ${serverNpub.substring(0, 8)}...`;
decryptionStatus.classList.add('error');
}
// Fallback to using the encrypted key directly
console.warn('Falling back to using encrypted key directly');
decryptedKey = encryptedKey;
}
// Now use the decrypted key to decrypt the content using Web Crypto API
try {
console.log('🔒 Attempting to decrypt content with Web Crypto API');
console.log('📄 Encrypted content (first 50 chars):', event.content.substring(0, 50) + '...');
console.log('🔑 Using decrypted key:', decryptedKey.substring(0, 10) + '...');
// Check if the content is a valid Base64 string before attempting decryption
// This is important for Web Crypto API which expects properly formatted input
let contentToDecrypt = event.content;
// Try to detect if the content is already in Base64 format
// If not, we need to encode it properly
try {
// Check if it's a valid base64 string by trying to decode it
atob(contentToDecrypt);
console.log('Content appears to be in Base64 format already');
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
} catch (_) {
// If decoding fails, content is not in Base64 format
// Encode it properly for the crypto API
console.log('Content is not in Base64 format, converting...');
contentToDecrypt = btoa(contentToDecrypt);
}
console.time('contentDecryption');
const decryptedEventContent = await this.httpService.decryptWithWebCrypto(
contentToDecrypt,
decryptedKey
);
console.timeEnd('contentDecryption');
console.log('✅ Content decryption succeeded!');
console.log('📋 Decrypted content (first 100 chars):',
decryptedEventContent.substring(0, 100).replace(/\n/g, '\\n') + '...');
decryptedContent = decryptedEventContent;
if (decryptionStatus) {
decryptionStatus.textContent = `Decryption complete using server npub: ${serverNpub.substring(0, 8)}...`;
decryptionStatus.classList.add('success');
}
} catch (contentDecryptError) {
console.error('❌ Content decryption failed:', contentDecryptError);
console.error('📦 Error object:', JSON.stringify(contentDecryptError, Object.getOwnPropertyNames(contentDecryptError)));
console.log('🔑 Using decrypted key:', decryptedKey.substring(0, 10) + '...');
console.log('📄 Content format correct?', event.content.startsWith('Aes') ? 'Yes (starts with Aes)' : 'No');
// Try direct decryption with NIP-44 as a fallback
try {
console.log('Trying direct content decryption with NIP-44 as fallback...');
const nip44Any = nostrTools.nip44 as any;
// Calculate the shared secret (conversation key) between server private key and sender pubkey
// This is needed for NIP-44 decryption
const privateKeyBytes = new Uint8Array(privateKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
const conversationKey = nostrTools.nip44.getConversationKey(privateKeyBytes, event.pubkey);
// Use the correct parameters for nip44.decrypt (payload, conversationKey)
const directDecryptedContent = nip44Any.decrypt(event.content, conversationKey);
console.log('Direct NIP-44 content decryption succeeded!');
decryptedContent = directDecryptedContent;
if (decryptionStatus) {
decryptionStatus.textContent = `Direct decryption succeeded with NIP-44`;
decryptionStatus.classList.add('success');
}
} catch (directDecryptError) {
console.error('❌ Direct NIP-44 content decryption also failed:', directDecryptError);
decryptedContent = `Key decryption successful, but content decryption failed: ${contentDecryptError instanceof Error ? contentDecryptError.message : String(contentDecryptError)}
Encrypted content: ${event.content.substring(0, 50)}...
Decrypted key: ${decryptedKey}`;
if (decryptionStatus) {
decryptionStatus.textContent = 'Content decryption failed (key decryption was successful)';
decryptionStatus.classList.add('error');
}
}
}
} catch (error) {
console.error('Error during decryption process:', error);
decryptedKey = encryptedKey; // Fallback
decryptedContent = `[Decryption error: ${error instanceof Error ? error.message : String(error)}]\n${event.content}`;
if (decryptionStatus) {
decryptionStatus.textContent = `Decryption error: ${error instanceof Error ? error.message : String(error)}`;
decryptionStatus.classList.add('error');
}
}
} catch (decryptError) {
console.error("Failed to decrypt with window.nostr.nip44:", decryptError instanceof Error ? decryptError.message : String(decryptError));
decryptedContent = `[NIP-44 decryption failed: ${decryptError instanceof Error ? decryptError.message : String(decryptError)}]\n${event.content}`;
} catch (outerError) {
console.error("Outer decryption error:", outerError);
decryptedContent = `[NIP-44 decryption failed: ${outerError instanceof Error ? outerError.message : String(outerError)}]\n${event.content}`;
}
}
@ -663,9 +973,17 @@ Decrypted key: ${decryptedKey}`;
this.receivedEvents.set(eventId, receivedEvent);
// Update UI if this event is currently being viewed
const selectedEventId = this.eventDetails?.querySelector('h3')?.textContent?.match(/(.+)\.\.\./)?.[1];
if (selectedEventId && `${selectedEventId}...` === `${eventId.substring(0, 8)}...`) {
// Since there might be issues extracting the ID from the header text with regex,
// let's use a more direct approach to force the UI refresh
if (this.eventDetails) {
// Always refresh the UI to show the decrypted content
this.showEventDetails(eventId);
// Explicitly update the http-content element to show the decrypted content
const httpContentElement = this.eventDetails.querySelector('#http-content .http-content');
if (httpContentElement) {
httpContentElement.textContent = decryptedContent;
}
}
} catch (error) {
console.error("Failed to process event:", error);
@ -683,4 +1001,30 @@ Decrypted key: ${decryptedKey}`;
this.relayStatus.className = `relay-status ${className}`;
}
}
/**
* Filter events in the UI based on the showAllEvents checkbox state
* @param showAllEvents Whether to show all events or only those for the server
*/
public filterEventsInUI(showAllEvents: boolean): void {
if (!this.eventsList) {
return;
}
// Get all event items
const eventItems = this.eventsList.querySelectorAll('.event-item');
// Iterate through each event item
eventItems.forEach((item) => {
const isToServer = item.getAttribute('data-to-server') === 'true';
if (showAllEvents) {
// Show all events
(item as HTMLElement).style.display = '';
} else {
// Only show events addressed to the server
(item as HTMLElement).style.display = isToServer ? '' : 'none';
}
});
}
}

@ -1,3 +1,137 @@
/* Modal styles */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: var(--background-color);
margin: 10% auto;
padding: 20px;
border: 1px solid var(--border-color);
width: 80%;
max-width: 600px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h3 {
margin: 0;
color: var(--text-color);
}
.close-modal {
color: var(--text-secondary);
font-size: 24px;
font-weight: bold;
cursor: pointer;
}
.close-modal:hover {
color: var(--text-color);
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: var(--text-color);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--input-background);
color: var(--text-color);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.primary-button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.secondary-button {
background-color: transparent;
color: var(--text-color);
border: 1px solid var(--border-color);
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.primary-button:hover {
background-color: var(--primary-hover);
}
.secondary-button:hover {
background-color: var(--hover-color);
}
/* Billboard specific styles */
.billboard-actions {
margin: 20px 0;
display: flex;
justify-content: flex-end;
}
.billboard-edit-btn,
.billboard-delete-btn {
background: none;
border: none;
cursor: pointer;
margin-left: 10px;
font-size: 14px;
padding: 4px 8px;
border-radius: 4px;
}
.billboard-edit-btn {
color: var(--primary-color);
}
.billboard-delete-btn {
color: #e74c3c;
}
.billboard-edit-btn:hover,
.billboard-delete-btn:hover {
background-color: var(--hover-color);
}
/* Styles for the HTTP Messages Project Homepage */
/* CSS Variables for themes */
@ -337,6 +471,13 @@ footer {
border-bottom: 2px solid var(--border-color);
}
/* Server match checkmark */
.server-match {
color: var(--color-success);
font-weight: bold;
margin-left: 4px;
}
.tab-buttons {
display: flex;
flex-wrap: wrap;
@ -380,11 +521,27 @@ footer {
font-weight: 500;
transition: background-color 0.2s;
}
.server-search-button:hover {
background-color: var(--button-hover);
}
.server-refresh-button {
background-color: var(--bg-tertiary);
color: var(--text-secondary);
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
margin-left: 5px;
}
.server-refresh-button:hover {
background-color: var(--bg-info);
transform: rotate(180deg);
}
.server-search-result {
margin-top: 10px;
padding: 10px;
@ -393,6 +550,168 @@ footer {
border-radius: 4px;
}
/* Server selection styles */
.server-select-button {
background-color: var(--button-primary);
color: white;
border: none;
border-radius: 4px 0 0 4px;
padding: 8px 15px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
}
.server-select-button:hover {
background-color: var(--button-hover);
}
.server-selection-container {
margin-top: 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--bg-secondary);
max-height: 400px;
overflow-y: auto;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.selection-header {
padding: 10px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.server-search-input {
flex: 1;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 14px;
}
.close-selection-btn {
background: none;
border: none;
font-size: 20px;
color: var(--text-secondary);
cursor: pointer;
margin-left: 10px;
}
.close-selection-btn:hover {
color: var(--text-primary);
}
.server-list {
padding: 10px;
}
.server-list-loading, .server-list-empty {
text-align: center;
padding: 20px;
color: var(--text-tertiary);
font-style: italic;
}
.server-list-empty {
font-style: normal;
line-height: 1.5;
color: var(--text-secondary);
border: 1px dashed var(--border-color);
border-radius: 4px;
margin: 10px;
background-color: var(--bg-tertiary);
}
.server-list-empty p {
margin: 10px 0;
}
.server-list-empty p:first-child {
font-weight: bold;
}
.server-item {
padding: 10px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
cursor: pointer;
transition: background-color 0.2s;
}
.server-item:last-child {
border-bottom: none;
}
.server-item:hover {
background-color: var(--bg-tertiary);
}
.operator-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
margin-right: 15px;
flex-shrink: 0;
background-color: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
}
.operator-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.operator-avatar-placeholder {
font-size: 20px;
color: var(--text-tertiary);
}
.server-details {
flex: 1;
}
.server-name {
font-weight: bold;
margin-bottom: 3px;
color: var(--text-primary);
}
.server-description {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 5px;
}
.operator-pubkey {
font-size: 12px;
color: var(--text-tertiary);
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.server-pubkey {
font-size: 12px;
color: var(--accent-color);
font-family: monospace;
}
.server-expiry {
font-size: 11px;
color: var(--text-tertiary);
margin-top: 2px;
}
/* HTTP Request textarea */
#httpRequest {
width: 100%;
@ -490,7 +809,7 @@ footer {
min-width: 250px;
}
.copy-btn {
.toggle-format-btn, .copy-btn {
padding: 8px 12px;
background-color: var(--button-primary);
color: white;
@ -501,6 +820,18 @@ footer {
transition: background-color 0.2s;
}
.toggle-format-btn {
background-color: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
margin-right: 5px;
}
.toggle-format-btn:hover {
background-color: var(--accent-color);
color: white;
}
.copy-btn:hover {
background-color: var(--button-hover);
}
@ -511,6 +842,14 @@ footer {
border: 1px solid var(--border-color);
}
.format-indicator {
margin-top: 5px;
font-size: 12px;
color: var(--text-tertiary);
text-align: right;
padding-right: 5px;
}
.relay-input-container {
display: flex;
align-items: center;
@ -1126,6 +1465,31 @@ footer {
border-left: 4px solid var(--accent-color);
}
/* Event debug output for 31120 events */
.event-debug-output {
margin: 15px 0;
padding: 15px;
background-color: var(--bg-tertiary);
border-radius: 8px;
border: 1px solid var(--accent-color);
}
.event-debug-output h3 {
color: var(--accent-color);
margin-top: 0;
margin-bottom: 10px;
}
.event-debug-output pre {
max-height: 300px;
overflow-y: auto;
background-color: var(--bg-secondary);
padding: 10px;
border-radius: 4px;
border-left: 4px solid var(--accent-color);
margin: 0;
}
.decryption-status {
margin-top: 10px;
padding: 8px;
@ -1135,6 +1499,20 @@ footer {
border: 1px solid #ffeeba;
text-align: center;
font-style: italic;
font-size: 14px;
font-weight: 500;
}
.decryption-status.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.decryption-status.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.content-header {
@ -1512,6 +1890,142 @@ footer {
cursor: not-allowed;
}
/* Billboard Page Styles */
.billboard-section {
margin-top: 20px;
}
.billboard-container {
margin-top: 20px;
}
.billboard-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.billboard-card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.billboard-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.billboard-card-header {
padding: 15px 20px;
background-color: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.billboard-title {
margin: 0;
color: var(--accent-color);
font-size: 18px;
}
.billboard-timestamp {
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 12px;
color: var(--text-tertiary);
}
.billboard-card-content {
padding: 20px;
}
.billboard-detail {
margin-bottom: 15px;
line-height: 1.5;
}
.billboard-detail strong {
color: var(--text-secondary);
font-weight: 600;
}
.billboard-detail:last-child {
margin-bottom: 0;
}
.operator-pubkey,
.server-pubkey {
font-family: 'Courier New', monospace;
font-size: 13px;
word-break: break-all;
background-color: var(--bg-tertiary);
padding: 3px 6px;
border-radius: 4px;
display: inline-block;
margin-top: 5px;
}
.relay-list {
margin: 8px 0 0 20px;
padding: 0;
}
.relay-list li {
font-family: 'Courier New', monospace;
font-size: 13px;
margin-bottom: 6px;
}
.billboard-card-footer {
padding: 15px 20px;
background-color: var(--bg-tertiary);
border-top: 1px solid var(--border-color);
text-align: right;
}
.view-raw-btn {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 6px 12px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
.view-raw-btn:hover {
background-color: var(--button-primary);
color: white;
}
.raw-json-content {
padding: 15px;
background-color: var(--bg-primary);
border-top: 1px solid var(--border-color);
}
.raw-json-content pre {
margin: 0;
max-height: 300px;
overflow-y: auto;
padding: 10px;
border-radius: 4px;
background-color: var(--bg-tertiary);
border-left: 4px solid var(--accent-color);
}
.hidden {
display: none;
}
.status-message {
margin-top: 10px;
padding: 8px 12px;

@ -31,6 +31,7 @@ module.exports = {
{ from: 'help.html', to: 'help.html' },
{ from: 'receive.html', to: 'receive.html' },
{ from: 'profile.html', to: 'profile.html' },
{ from: 'billboard.html', to: 'billboard.html' },
],
}),
],