parent
8150c3ce1f
commit
3698288fc3
@ -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
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">×</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">×</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
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' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
Loading…
x
Reference in New Issue
Block a user