refactoring

This commit is contained in:
n 2025-04-10 01:40:19 +01:00
parent 07ad835a77
commit edd5f9f254
39 changed files with 8727 additions and 159 deletions

@ -5,8 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Implement strict Content Security Policy -->
<title>HTTP Messages - CLIENT</title>
<!-- Load our CSS file -->
<!-- Load our CSS files -->
<link rel="stylesheet" href="./styles.css">
<link rel="stylesheet" href="./styles/event-list.css">
</head>
<body>
<!-- Navigation bar container - content will be injected by navbar.ts -->
@ -108,6 +109,6 @@ User-Agent: Browser/1.0
</div>
</div>
<!-- Include the webpack bundled JavaScript file with forced loading -->
<script src="./main.bundle.js" onload="console.log('Main bundle loaded successfully')"></script>
<script src="./client.bundle.js" onload="console.log('Client bundle loaded successfully')"></script>
</body>
</html>

@ -5,7 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTTP Messages - SERVER</title>
<link rel="stylesheet" href="./styles.css">
<script defer src="./main.bundle.js"></script>
<link rel="stylesheet" href="./styles/event-list.css">
<script defer src="./server.bundle.js"></script>
<!-- Additional chunks will be loaded automatically -->
</head>
<body>

243
client/analysis.md Normal file

@ -0,0 +1,243 @@
# Server UI Architecture Analysis for 21120/21121 Events
## Overview
The server UI architecture handles HTTP over Nostr events (specifically kind 21120 requests and kind 21121 responses) through a collection of interconnected components. This analysis identifies the core workflows, component responsibilities, and potential issues.
## Component Responsibilities
### 1. EventManager (src/services/EventManager.ts)
**Primary Responsibilities:**
- Central store for all event data
- Manages relationships between request and response events
- Handles event selection and filtering
- Validates 21121 response events against 21120 request events
**Interactions:**
- Used by NostrEventService to store and retrieve events
- Core dependency for UI components (EventList, EventDetail)
- Provides validation for 21121 events
**Potential Issues:**
- Tightly coupled to specific event kinds (21120/21121)
- Combines storage, validation, and relationship management
### 2. NostrEventService (src/services/NostrEventService.ts)
**Primary Responsibilities:**
- Connects to Nostr relays
- Subscribes to events based on filters
- Processes incoming events and adds them to EventManager
- Creates filters for 21120 events
**Interactions:**
- Uses RelayService for WebSocket connections
- Uses CacheService for event persistence
- Provides processed events to EventManager
**Potential Issues:**
- Handles both data and UI concerns (status updates)
- Distributed responsibilities across multiple services
### 3. EventListRenderer (src/services/EventListRenderer.ts)
**Primary Responsibilities:**
- Renders the list of 21120 events
- Updates UI when events are added/removed/selected
- Handles event filtering and sorting
**Interactions:**
- Observes EventManager for changes
- Used by UI components for rendering events list
**Potential Issues:**
- Direct DOM manipulation
- Might have overlap with EventList component
### 4. EventDetailsRenderer (src/services/EventDetailsRenderer.ts)
**Primary Responsibilities:**
- Renders detailed view of selected events
- Displays HTTP request content with formatting
- Shows related events (responses)
**Interactions:**
- Observes EventManager for selection changes
- Used by UI components for rendering event details
**Potential Issues:**
- Direct DOM manipulation
- Mixed formatting and rendering concerns
### 5. HttpClient (src/services/HttpClient.ts)
**Primary Responsibilities:**
- Handles HTTP request execution
- Parses raw HTTP request content
- Formats HTTP responses
**Interactions:**
- Uses HttpService for parsing requests
- Used by HttpRequestExecutor for executing requests
**Code:**
- Simple and focused on a single responsibility
### 6. HttpService (src/services/HttpService.ts)
**Primary Responsibilities:**
- Parses HTTP request content
- Extracts URLs, headers, and methods
- Creates fetch options from HTTP requests
**Interactions:**
- Used by HttpClient to parse requests
**Potential Issues:**
- Some overlapping responsibilities with HttpFormatter
### 7. Nostr21121Service (src/services/Nostr21121Service.ts)
**Primary Responsibilities:**
- Creates and publishes 21121 response events
- Handles encryption of response content
- Updates event relationships
**Interactions:**
- Uses RelayService to publish events
- Uses CacheService to store events
- Integration with HttpClient for responses
**Potential Issues:**
- Mixed responsibilities (encryption, event creation, publishing)
- Tightly coupled with multiple services
## Additional Components
### 8. HttpRequestExecutor (src/components/HttpRequestExecutor.ts)
**Primary Responsibilities:**
- Executes HTTP requests from 21120 events
- Dispatches execution results
- Parses URLs from HTTP requests
**Interactions:**
- Uses HttpClient for request execution
- Dispatches custom events for results
### 9. ResponseViewer (src/components/ResponseViewer.ts)
**Primary Responsibilities:**
- Displays HTTP responses in a modal
- Handles creating 21121 response events
- Manages UI tabs and formatting options
**Interactions:**
- Uses Nostr21121Creator for response creation
- Uses HttpFormatter for content formatting
### 10. ServerUI (src/components/ServerUI.ts)
**Primary Responsibilities:**
- Main component coordinating all UI components
- Initializes services and components
- Manages relay connections and subscriptions
**Interactions:**
- Initializes and connects all components
- Handles server identity and relay connections
## Core Workflows
### 1. HTTP Request Execution Workflow:
1. User selects a 21120 event in the UI
2. User initiates request execution
3. HttpRequestExecutor parses the request and uses HttpClient
4. HttpClient uses HttpService to parse the request
5. HTTP fetch is performed and response collected
6. Result dispatched as a custom event
7. ResponseViewer displays the formatted response
### 2. 21121 Response Creation Workflow:
1. User views a 21120 request and response
2. User initiates 21121 response creation
3. ResponseViewer shows options dialog
4. Nostr21121Creator validates and formats the response
5. Event is created and published to relay
6. UI updates to show relationship between events
## Architectural Issues
1. **Tight Coupling:**
- Many components are tightly coupled to specific implementations
- Difficult to replace or mock components for testing
2. **Code Duplication:**
- HTTP parsing and formatting logic spread across multiple classes
- Event handling code duplicated between components
3. **Separation of Concerns:**
- Some components mix UI rendering and data management
- Direct DOM manipulation from service classes
4. **Responsibility Distribution:**
- Some components have too many responsibilities
- Unclear boundaries between service and component classes
## Recommendations
1. **Create Clearer Service Boundaries:**
- Separate HTTP parsing/formatting into dedicated services
- Extract relay communication into a more abstract service
2. **Improve Component Architecture:**
- Use a cleaner component hierarchy
- Reduce direct DOM manipulation from services
3. **Better Event Handling:**
- Implement a more consistent event bus pattern
- Standardize event payloads and handling
4. **Refactor Service Dependencies:**
- Use dependency injection more consistently
- Create interfaces for services to allow better testing
5. **Simplify Responsibility Flow:**
- Create clearer workflows with fewer steps
- Document component responsibilities better
## Architectural Diagram
```
┌─────────────────┐ ┌─────────────────┐
│ │ │ │
│ ServerUI │◄─────────►│ EventManager │
│ │ │ │
└───────┬─────────┘ └────┬─────┬──────┘
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌─────────────┐ ┌───────────────┐
│ NostrEvent │ │ EventList │ │ EventDetail │
│ Service │◄───►│ Renderer │ │ Renderer │
└─────┬─────────┘ └─────────────┘ └───────────────┘
│ ▲ ▲
▼ │ │
┌─────────────┐ ┌────────┴───────┐ ┌──┴────────────┐
│ RelayService│ │ HttpRequest │ │ ResponseViewer│
└──────┬──────┘ │ Executor │ └────────┬───────┘
│ └────────┬───────┘ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌─────────────┐
│ WebSocket │ │ HttpClient │ │ Nostr21121 │
│ Manager │ └───────┬──────┘ │ Service │
└──────────────┘ │ └─────────────┘
┌──────────────┐
│ HttpService │
└──────────────┘
```
## Conclusion
The current architecture successfully handles 21120/21121 events but has several areas that could be improved for better maintainability and testability. The main issues are tight coupling between components, unclear responsibility boundaries, and direct DOM manipulation from service classes. By addressing these issues, the codebase would be more maintainable and easier to extend.

@ -5,7 +5,7 @@
<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="./main.bundle.js"></script>
<script defer src="./client.bundle.js"></script>
<!-- Additional chunks will be loaded automatically -->
</head>
<body>

@ -8,7 +8,7 @@
<!-- Load our CSS file -->
<link rel="stylesheet" href="./styles.css">
<!-- Include the webpack bundled JavaScript files -->
<script defer src="./main.bundle.js"></script>
<script defer src="./client.bundle.js"></script>
<!-- Additional chunks will be loaded automatically -->
</head>
<body>

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTTP Messages - Profile</title>
<link rel="stylesheet" href="./styles.css">
<script defer src="./main.bundle.js"></script>
<script defer src="./client.bundle.js"></script>
<!-- Additional chunks will be loaded automatically -->
</head>
<body>

@ -0,0 +1,278 @@
# Server UI Refactoring: AI Agent Prompts
This document contains a series of prompts to guide an AI agent through refactoring the server page UI for handling 21120/21121 events. Each prompt focuses on a specific aspect of the refactoring process, with detailed instructions and context.
## 1. Analyze Current Architecture
**Prompt:**
```
Analyze the current server UI architecture for handling 21120/21121 events. Examine the following files and identify the core workflows, component responsibilities, and potential issues:
- 1120_server.html
- src/services/EventDetailsRenderer.ts
- src/services/EventListRenderer.ts
- src/http-response-viewer.ts
- src/services/Nostr21121Service.ts
- src/services/HttpClient.ts
- src/services/HttpService.ts
For each component, identify:
1. Its primary responsibilities
2. How it interacts with other components
3. Any code duplication or tight coupling
4. Areas where separation of concerns could be improved
```
## 2. Design the EventManager Service
**Prompt:**
```
Create a new EventManager service that will centralize event data management. This service should:
1. Maintain a collection of all 21120 and 21121 events
2. Track relationships between request and response events
3. Provide methods for adding, retrieving, and updating events
4. Handle event filtering and searching
5. Manage the selection state of events
Implement this as a TypeScript class in src/services/EventManager.ts with the following features:
- Store events in a Map with event ID as the key
- Track relationships in a separate Map
- Include methods for all necessary event operations
- Implement event filtering capabilities
- Use TypeScript interfaces to define the event data structures
The service should decouple event data management from UI rendering.
```
## 3. Implement Component-Based UI Structure
**Prompt:**
```
Refactor the UI into modular components following these steps:
1. Create a src/components directory to house the new UI components
2. Implement an EventList component (src/components/EventList.ts) that:
- Renders the list of events in the sidebar
- Handles event selection
- Communicates with the EventManager to get event data
- Supports filtering and searching
3. Implement an EventDetail component (src/components/EventDetail.ts) that:
- Displays the details of a selected event
- Manages different view tabs (summary, raw, formatted)
- Shows related events
- Provides controls for event actions
4. Update 1120_server.html to use the new component structure
Ensure all components have clean interfaces and minimal dependencies.
```
## 4. Refactor HTTP Request/Response Handling
**Prompt:**
```
Create dedicated components for HTTP request execution and response handling:
1. Implement an HttpRequestExecutor component (src/components/HttpRequestExecutor.ts) that:
- Extracts HTTP request content from 21120 events
- Delegates execution to the HttpClient
- Displays execution progress and errors
- Triggers the response handling flow
2. Implement a ResponseViewer component (src/components/ResponseViewer.ts) that:
- Displays HTTP responses in both raw and formatted views
- Provides an interface for creating 21121 events from responses
- Handles the relationship between requests and responses
3. Refactor HttpService to eliminate duplicate code and provide a clean API for:
- Parsing HTTP requests
- Executing requests
- Formatting responses
Ensure proper error handling throughout the flow.
```
## 5. Improve Event Relationship Management
**Prompt:**
```
Enhance the handling of relationships between 21120 and 21121 events:
1. Update the EventManager to provide robust methods for:
- Associating response events with request events
- Retrieving all responses for a given request
- Finding the request that a response relates to
2. Implement UI improvements to clearly show relationships:
- Visual indicators for events with responses
- Easy navigation between related events
- Filters to view event chains
3. Create a helper method in the EventManager to validate that a 21121 event correctly references its 21120 parent before saving
```
## 6. Streamline the 21121 Event Creation Flow
**Prompt:**
```
Improve the process of creating 21121 response events:
1. Create a dedicated Nostr21121Creator component (src/components/Nostr21121Creator.ts) that:
- Takes an HTTP response and a request event ID as input
- Handles the creation of properly formatted 21121 events
- Manages the encryption of response content if needed
- Publishes the event to appropriate relays
2. Integrate this component with the ResponseViewer to provide a seamless flow:
- Add a "Create 21121 Event" button in the response viewer
- Show creation status and results
- Automatically update the UI when a new response event is created
3. Implement proper validation to ensure:
- The event references the correct request event
- The content is properly formatted
- Required tags are included
```
## 7. Enhance HTTP Content Visualization
**Prompt:**
```
Improve the visualization of HTTP content in both requests and responses:
1. Refactor the HttpFormatter service to provide better formatting for:
- Headers (with syntax highlighting)
- JSON bodies (with collapsible sections)
- HTML bodies (with preview option)
- Other content types
2. Create a tabbed interface for viewing HTTP content with:
- Raw view (plain text)
- Formatted view (with syntax highlighting)
- Headers-only view
- Body-only view
3. Add copy buttons for different sections of the content
4. Implement diff highlighting when comparing request and response headers
```
## 8. Implement Event Loading Optimizations
**Prompt:**
```
Optimize the loading and display of events, especially for large numbers:
1. Implement virtual scrolling in the EventList component:
- Only render events that are visible in the viewport
- Efficiently handle scrolling through many events
- Maintain performance with 1000+ events
2. Add progressive loading of event details:
- Load basic event metadata immediately
- Load and decrypt content asynchronously
- Show loading indicators during content processing
3. Implement caching strategies:
- Store processed events in memory for quick access
- Persist events to localStorage with expiration
- Implement a cache invalidation strategy
```
## 9. Improve UI/UX for Event Interaction
**Prompt:**
```
Enhance the overall user experience for interacting with events:
1. Redesign the event list items to:
- Show more context about each event
- Clearly distinguish between request and response events
- Indicate encryption status and related event count
- Support keyboard navigation
2. Improve the event details view to:
- Show a breadcrumb trail for navigating related events
- Provide contextual actions based on event type
- Include better visualizations of event metadata
- Support collapsible sections for better space utilization
3. Add search and filter capabilities:
- Search by content, ID, or metadata
- Filter by event type, time range, or relationship status
- Save and recall common filters
```
## 10. Testing and Documentation
**Prompt:**
```
Create comprehensive tests and documentation for the refactored components:
1. Implement unit tests for:
- EventManager service
- HTTP-related components
- UI components (using testing library)
2. Add integration tests for:
- The complete request/response flow
- Event relationship management
- UI interactions
3. Document the new architecture:
- Create a component diagram showing relationships
- Document the APIs for each service and component
- Provide usage examples for each component
- Include performance considerations and best practices
4. Create user documentation explaining the improved UI
```
## 11. Migration Strategy
**Prompt:**
```
Develop a strategy for migrating from the old implementation to the new one:
1. Create a migration plan that:
- Identifies components to replace first
- Establishes temporary adapters between old and new components
- Defines a sequence for replacing components
- Includes fallback mechanisms
2. Implement a feature flag system to:
- Toggle between old and new implementations
- Enable gradual rollout of new components
- Support A/B testing of the new UI
3. Design a data migration approach for:
- Moving cached events to the new structure
- Preserving user preferences and settings
- Handling in-flight operations during migration
```
## 12. Final Integration
**Prompt:**
```
Integrate all refactored components into a cohesive system:
1. Update the main application entry point to use the new components:
- Initialize the EventManager
- Set up the component hierarchy
- Configure event handling
2. Ensure proper lifecycle management:
- Initialize components in the correct order
- Handle cleanup when components are unmounted
- Manage resources efficiently
3. Verify the complete flows:
- Receiving and displaying 21120 events
- Executing HTTP requests
- Creating and linking 21121 events
- Navigating between related events
4. Perform end-to-end testing of all user workflows

180
client/src/auth-manager.ts Normal file

@ -0,0 +1,180 @@
/**
* auth-manager.ts
* Centralized authentication manager for the entire application.
* Prevents network requests until the user is properly authenticated.
*/
// Global authentication state
let userAuthenticated = false;
// Queue for operations that should run after authentication
const postAuthQueue: Array<() => void> = [];
// Event name for authentication state changes
const AUTH_STATE_CHANGED_EVENT = 'auth-state-changed';
/**
* Check if the user is currently authenticated
*/
export function isAuthenticated(): boolean {
// Check localStorage first in case auth state was set in another page
const savedPubkey = localStorage.getItem('userPublicKey');
if (savedPubkey && !userAuthenticated) {
userAuthenticated = true;
}
return userAuthenticated;
}
/**
* Set the authentication state
*/
export function setAuthenticated(authenticated: boolean, pubkey?: string): void {
const previousState = userAuthenticated;
userAuthenticated = authenticated;
// Update localStorage if a pubkey is provided
if (authenticated && pubkey) {
localStorage.setItem('userPublicKey', pubkey);
} else if (!authenticated) {
localStorage.removeItem('userPublicKey');
}
// Execute queued operations if becoming authenticated
if (!previousState && authenticated) {
executePostAuthQueue();
}
// Dispatch an event so other parts of the app can react
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent(AUTH_STATE_CHANGED_EVENT, {
detail: { authenticated, pubkey }
})
);
}
}
/**
* Add an operation to the post-authentication queue
* This ensures the operation only runs after successful authentication
*/
export function addPostAuthOperation(operation: () => void): void {
if (isAuthenticated()) {
// If already authenticated, run immediately
operation();
} else {
// Otherwise, add to queue
postAuthQueue.push(operation);
}
}
/**
* Execute all queued operations
* This is called automatically when authentication state changes
*/
function executePostAuthQueue(): void {
console.log(`[Auth] Executing ${postAuthQueue.length} queued operations after authentication`);
// Process all queued operations
while (postAuthQueue.length > 0) {
try {
const operation = postAuthQueue.shift();
if (operation) {
operation();
}
} catch (error) {
console.error('[Auth] Error executing post-auth operation:', error);
}
}
}
/**
* Listen for authentication state changes
*/
export function onAuthStateChanged(callback: (authenticated: boolean, pubkey?: string) => void): () => void {
const handler = (event: Event) => {
const authEvent = event as CustomEvent;
callback(
authEvent.detail.authenticated,
authEvent.detail.pubkey
);
};
window.addEventListener(AUTH_STATE_CHANGED_EVENT, handler);
// Return a function to remove the listener
return () => {
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, handler);
};
}
/**
* Clear authentication state (sign out)
*/
export function clearAuthState(): void {
userAuthenticated = false;
localStorage.removeItem('userPublicKey');
sessionStorage.removeItem('nostrLoginInitialized');
// Clear any temporary auth state in sessionStorage
[
'nostrAuthInProgress',
'nostrLoginState',
'nostrAuthPending',
'nostrLoginStarted'
].forEach(key => {
sessionStorage.removeItem(key);
});
// Notify listeners
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent(AUTH_STATE_CHANGED_EVENT, {
detail: { authenticated: false }
})
);
}
}
/**
* Update authentication state from localStorage
* This should be called on page load
*/
export function updateAuthStateFromStorage(): void {
const savedPubkey = localStorage.getItem('userPublicKey');
if (savedPubkey) {
userAuthenticated = true;
// Notify listeners
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent(AUTH_STATE_CHANGED_EVENT, {
detail: { authenticated: true, pubkey: savedPubkey }
})
);
}
}
}
// Initialize authentication state from localStorage
// This ensures the auth state is set correctly when the module is loaded
updateAuthStateFromStorage();
// Listen for page unload to cleanup authentication state
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
// Don't clear authentication state, just session-specific flags
sessionStorage.removeItem('nostrLoginInitialized');
// Clear any temporary auth state in sessionStorage
[
'nostrAuthInProgress',
'nostrLoginState',
'nostrAuthPending',
'nostrLoginStarted'
].forEach(key => {
sessionStorage.removeItem(key);
});
});
}

@ -6,6 +6,7 @@ import * as nostrTools from 'nostr-tools';
import { defaultServerConfig } from './config';
import { NostrService } from './services/NostrService';
import { toggleTheme } from './theme-utils';
import * as authManager from './auth-manager';
// Module-level variables
let nostrService: NostrService;
@ -124,6 +125,24 @@ function updateRelayStatus(message: string, className: string): void {
* Handle relay connection button click
*/
async function handleConnectRelay(): Promise<void> {
// Check if user is authenticated
if (!authManager.isAuthenticated()) {
updateRelayStatus('Authentication required to connect', 'error');
// Display an authentication prompt
const billboardContent = document.getElementById('billboardContent');
if (billboardContent) {
billboardContent.innerHTML = `
<div class="auth-required-message">
<h3>Authentication Required</h3>
<p>You need to be logged in to connect to relays.</p>
<p>Please visit the <a href="profile.html">Profile page</a> to log in with your Nostr extension.</p>
</div>
`;
}
return;
}
const relayUrlInput = document.getElementById('billboardRelayUrl') as HTMLInputElement;
if (!relayUrlInput) {
return;
@ -137,22 +156,30 @@ async function handleConnectRelay(): Promise<void> {
updateRelayStatus('Connecting to relay...', 'connecting');
// Connect to relay
const success = await nostrService.connectToRelay(relayUrl);
if (success) {
try {
// Get the checkbox state
const showAllServerEventsCheckbox = document.getElementById('showAllServerEvents') as HTMLInputElement;
const showAllEvents = showAllServerEventsCheckbox ? showAllServerEventsCheckbox.checked : false;
// Subscribe to kind 31120 events with filter state
await subscribeToKind31120Events(showAllEvents);
} catch (error) {
updateRelayStatus(
`Subscription error: ${error instanceof Error ? error.message : String(error)}`,
'error'
);
try {
// Connect to relay
const success = await nostrService.connectToRelay(relayUrl);
if (success) {
try {
// Get the checkbox state
const showAllServerEventsCheckbox = document.getElementById('showAllServerEvents') as HTMLInputElement;
const showAllEvents = showAllServerEventsCheckbox ? showAllServerEventsCheckbox.checked : false;
// Subscribe to kind 31120 events with filter state
await subscribeToKind31120Events(showAllEvents);
} catch (error) {
updateRelayStatus(
`Subscription error: ${error instanceof Error ? error.message : String(error)}`,
'error'
);
}
}
} catch (error) {
// Handle connection errors
updateRelayStatus(
`Connection error: ${error instanceof Error ? error.message : String(error)}`,
'error'
);
}
}
@ -361,10 +388,16 @@ async function handleSaveBillboard(e: Event): Promise<void> {
e.preventDefault();
try {
// Check if user is logged in
// Check if user is logged in using auth manager
if (!authManager.isAuthenticated()) {
alert('You need to be logged in to publish a billboard. Please visit the Profile page to log in.');
return;
}
// Get user's pubkey
const loggedInPubkey = nostrService.getLoggedInPubkey();
if (!loggedInPubkey) {
alert('You need to be logged in to publish a billboard. Please visit the Profile page to log in.');
alert('Could not retrieve your public key. Please try logging in again.');
return;
}
@ -653,7 +686,7 @@ function processServerEvent(event: nostrTools.Event): void {
}
/**
* Auto-connect to the default relay
* Auto-connect to the default relay only if authenticated
*/
async function autoConnectToDefaultRelay(): Promise<void> {
const relayUrlInput = document.getElementById('billboardRelayUrl') as HTMLInputElement;
@ -670,10 +703,29 @@ async function autoConnectToDefaultRelay(): Promise<void> {
showAllServerEventsCheckbox.checked = false;
}
// Trigger connect button click
const connectButton = document.getElementById('billboardConnectBtn');
if (connectButton) {
connectButton.click();
// Check if the user is authenticated before connecting
if (authManager.isAuthenticated()) {
console.log('User is authenticated, connecting to relay...');
// Trigger connect button click
const connectButton = document.getElementById('billboardConnectBtn');
if (connectButton) {
connectButton.click();
}
} else {
console.log('User is not authenticated, showing login prompt...');
// Show a login prompt instead of connecting
const billboardContent = document.getElementById('billboardContent');
if (billboardContent) {
billboardContent.innerHTML = `
<div class="auth-required-message">
<h3>Authentication Required</h3>
<p>You need to be logged in to view and manage billboards.</p>
<p>Please visit the <a href="profile.html">Profile page</a> to log in with your Nostr extension.</p>
</div>
`;
}
// Update the relay status
updateRelayStatus('Authentication required', 'warning');
}
}
}

@ -3,6 +3,7 @@
// IMPORTANT: Immediately import all critical modules and execute them
// This ensures they are included in the bundle and executed immediately
import './navbar-diagnostics'; // Import diagnostics first
import './navbar';
import './navbar-init';
@ -50,6 +51,7 @@ import { NostrService } from './services/NostrService';
import { Nostr31120Service } from './services/Nostr31120Service'; // Import our new dedicated service
import { getUserPubkey } from './services/NostrUtils';
import { initializeNavbar } from './navbar';
import * as authManager from './auth-manager';
// Immediately initialize the navbar to ensure it's visible on page load
try {
@ -140,6 +142,19 @@ const nostr31120Service = new Nostr31120Service(
* Search a relay for 31120 events
*/
async function handleRelaySearch(): Promise<void> {
// Check if user is authenticated
if (!authManager.isAuthenticated()) {
console.log('Cannot search relay: User not authenticated');
// Display authentication prompt instead of performing search
await retryAuthentication();
// Check if authentication was successful
if (!authManager.isAuthenticated()) {
console.log('Authentication failed or cancelled, aborting relay search');
return;
}
}
const relayUrlInput = document.getElementById('relay') as HTMLInputElement;
const serverSelectionContainer = document.getElementById('serverSelectionContainer');
const serverList = document.getElementById('serverList');
@ -1110,6 +1125,8 @@ async function safeAuthenticate(): Promise<string | null> {
if (savedPubkey) {
console.log(`Found saved pubkey: ${savedPubkey.substring(0, 8)}...`);
updateClientPubkeyDisplay(savedPubkey);
// Set authentication state in the central auth manager
authManager.setAuthenticated(true, savedPubkey);
return savedPubkey;
}
@ -1126,6 +1143,8 @@ async function safeAuthenticate(): Promise<string | null> {
console.log(`Authentication successful, pubkey: ${pubkey.substring(0, 8)}...`);
localStorage.setItem('userPublicKey', pubkey);
updateClientPubkeyDisplay(pubkey);
// Set authentication state in the central auth manager
authManager.setAuthenticated(true, pubkey);
return pubkey;
}
} else {
@ -1142,13 +1161,20 @@ async function safeAuthenticate(): Promise<string | null> {
}
}
// Initialize authentication as early as possible, before DOM is ready
// We use an IIFE to allow async/await with the top-level code
(async function() {
// Set up auth state based on stored credentials but don't auto-authenticate
// This prevents automatic network requests on page load
(function() {
try {
await safeAuthenticate();
const savedPubkey = localStorage.getItem('userPublicKey');
if (savedPubkey) {
console.log(`Found saved pubkey: ${savedPubkey.substring(0, 8)}...`);
updateClientPubkeyDisplay(savedPubkey);
// Update the auth state but don't make network requests yet
authManager.updateAuthStateFromStorage();
}
} catch (error) {
console.error("Error during startup authentication:", error);
console.error("Error checking stored authentication:", error);
}
})();
@ -1502,26 +1528,86 @@ document.addEventListener('DOMContentLoaded', function(): void {
// Add auth UI after a short delay to ensure other elements are loaded
setTimeout(addAuthUI, 500);
// Auto-connect to the relay on page load
setTimeout(autoConnectToRelay, 500);
// Do NOT auto-connect to the relay on page load - this prevents unauthorized network requests
// User must explicitly click the search button after authenticating
// Initialize raw event parsing functionality
const parseRawEventBtn = document.getElementById('parseRawEventBtn');
if (parseRawEventBtn) {
parseRawEventBtn.addEventListener('click', handleParseRawEvent);
}
// Search relay button event listener
// Search relay button event listener with authentication check
const searchRelayBtn = document.getElementById('searchRelayBtn');
if (searchRelayBtn) {
searchRelayBtn.addEventListener('click', handleRelaySearch);
// Update the button style to indicate authentication needed
searchRelayBtn.addEventListener('click', async () => {
try {
if (!authManager.isAuthenticated()) {
// Show authentication prompt
const authPrompt = document.createElement('div');
authPrompt.className = 'auth-prompt';
authPrompt.textContent = 'Please sign in to connect to relays';
authPrompt.style.color = 'red';
authPrompt.style.padding = '10px';
const relayContainer = searchRelayBtn.closest('.server-input-container');
if (relayContainer) {
relayContainer.appendChild(authPrompt);
setTimeout(() => {
if (authPrompt.parentNode) {
authPrompt.parentNode.removeChild(authPrompt);
}
}, 3000);
}
// Attempt authentication
await retryAuthentication();
// If authentication succeeded, proceed with search
if (authManager.isAuthenticated()) {
await handleRelaySearch();
}
} else {
// Already authenticated, proceed with search
await handleRelaySearch();
}
} catch (error) {
console.error('Error during relay search:', error);
}
});
}
// Refresh button to clear cache and fetch fresh data
// Refresh button to clear cache and fetch fresh data with authentication check
const refreshRelayBtn = document.getElementById('refreshRelayBtn');
if (refreshRelayBtn) {
refreshRelayBtn.addEventListener('click', async () => {
clearEventCache(); // Clear the cache
await handleRelaySearch(); // Fetch fresh data
// Check authentication first
if (!authManager.isAuthenticated()) {
// Show auth prompt
const authPrompt = document.createElement('div');
authPrompt.className = 'auth-prompt';
authPrompt.textContent = 'Please sign in to refresh data';
authPrompt.style.color = 'red';
authPrompt.style.padding = '10px';
const relayContainer = refreshRelayBtn.closest('.server-input-container');
if (relayContainer) {
relayContainer.appendChild(authPrompt);
setTimeout(() => {
if (authPrompt.parentNode) {
authPrompt.parentNode.removeChild(authPrompt);
}
}, 3000);
}
// Attempt authentication
await retryAuthentication();
}
if (authManager.isAuthenticated()) {
clearEventCache(); // Clear the cache
await handleRelaySearch(); // Fetch fresh data
}
});
}

@ -0,0 +1,310 @@
/**
* EventDetail Component
* Modular UI component for rendering detailed information about a selected event
*/
import { NostrEvent } from '../relay';
import { EventManager, EventChangeType } from '../services/EventManager';
import { HttpFormatter } from '../services/HttpFormatter';
/**
* Options for initializing the EventDetail component
*/
export interface EventDetailOptions {
container: string | HTMLElement;
className?: string;
emptyText?: string;
}
/**
* Class representing a modular EventDetail component
*/
export class EventDetail {
private container: HTMLElement | null = null;
private eventManager: EventManager;
private unregisterListener: (() => void) | null = null;
private options: EventDetailOptions;
/**
* Create a new EventDetail component
*/
constructor(eventManager: EventManager, options: EventDetailOptions) {
this.eventManager = eventManager;
this.options = {
emptyText: 'Select an event to view details',
className: 'event-detail',
...options
};
}
/**
* Initialize the component and render the initial UI
*/
public initialize(): void {
// Get the container element
if (typeof this.options.container === 'string') {
this.container = document.getElementById(this.options.container);
} else {
this.container = this.options.container;
}
if (!this.container) {
console.error('Event detail container not found');
return;
}
// Add event detail class if provided
if (this.options.className) {
this.container.classList.add(this.options.className);
}
// Register for event changes
this.unregisterListener = this.eventManager.registerListener((eventId, changeType) => {
if (changeType === EventChangeType.Selected) {
this.renderEventDetail();
}
else if (changeType === EventChangeType.Updated) {
// If the updated event is the currently selected event, re-render
const selectedEvent = this.eventManager.getSelectedEvent();
if (selectedEvent && selectedEvent.id === eventId) {
this.renderEventDetail();
}
}
else if (changeType === EventChangeType.Removed) {
// If the removed event is the currently selected event, show empty state
const selectedEvent = this.eventManager.getSelectedEvent();
if (selectedEvent && selectedEvent.id === eventId) {
this.showEmptyState();
}
}
});
// Initial render if there's already a selected event
const selectedEvent = this.eventManager.getSelectedEvent();
if (selectedEvent) {
this.renderEventDetail();
} else {
this.showEmptyState();
}
}
/**
* Show empty state when no event is selected
*/
private showEmptyState(): void {
if (!this.container) return;
this.container.innerHTML = `
<div class="empty-state">
${this.options.emptyText}
</div>
`;
}
/**
* Render the details of the currently selected event
*/
private renderEventDetail(): void {
if (!this.container) return;
// Get the selected event from the EventManager
const managedEvent = this.eventManager.getSelectedEvent();
if (!managedEvent) {
this.showEmptyState();
return;
}
const event = managedEvent.event;
// Determine if it's a request or response
const isRequest = event.kind === 21120;
const isResponse = event.kind === 21121;
// Determine the content to display
let httpContent = managedEvent.decrypted ?
managedEvent.decryptedContent || event.content :
event.content;
// Find related events
let relatedEventsHtml = '';
// Get related events from the EventManager
const relatedEvents = this.eventManager.getRelatedEvents(managedEvent.id);
if (relatedEvents.length > 0) {
relatedEventsHtml = `
<div class="related-events">
<h3>Related ${isRequest ? 'Responses' : 'Request'}</h3>
<ul class="related-events-list">
${relatedEvents.map(relatedEvent => {
const relatedType = relatedEvent.event.kind === 21120 ? 'Request' : 'Response';
return `
<li>
<a href="#" class="related-event-link" data-id="${relatedEvent.id}">
${relatedType} (${relatedEvent.id.substring(0, 8)}...)
</a>
</li>
`;
}).join('')}
</ul>
</div>
`;
}
// Format based on event type
const eventTime = new Date(event.created_at * 1000).toLocaleString();
// Action buttons for request events
const execRequestBtn = isRequest ?
`<button class="execute-http-request-btn" data-id="${managedEvent.id}">Execute HTTP Request</button>` : '';
// Create response button for requests that don't have responses yet
const createResponseBtn = (isRequest && relatedEvents.length === 0) ?
`<button class="create-response-btn" data-id="${managedEvent.id}">Create NIP-21121 Response</button>` : '';
this.container.innerHTML = `
<div class="event-detail-header">
<h2>Event Details</h2>
<span class="event-id-display">ID: ${event.id?.substring(0, 8) || 'Unknown'}...</span>
</div>
<div class="event-type-info">
<span class="event-kind">Kind: ${event.kind}</span>
<span class="event-type">${isRequest ? 'HTTP Request' : (isResponse ? 'HTTP Response' : 'Unknown')}</span>
<span class="event-time">Time: ${eventTime}</span>
</div>
<div class="event-metadata">
<div class="pubkey">Pubkey: ${event.pubkey}</div>
<div class="tags">
<h3>Tags</h3>
<pre>${JSON.stringify(event.tags, null, 2)}</pre>
</div>
</div>
${relatedEventsHtml}
<div class="http-actions">
${execRequestBtn}
${createResponseBtn}
</div>
<div class="http-content-tabs">
<div class="tab-buttons">
<button class="tab-btn" data-tab="raw-http">Raw HTTP</button>
<button class="tab-btn active" data-tab="formatted-http">Formatted HTTP</button>
</div>
<div class="tab-content" id="raw-http">
<pre class="http-content">${httpContent}</pre>
${!managedEvent.decrypted ?
'<div class="decryption-status error">Decryption failed or not attempted</div>' :
'<div class="decryption-status success">Decryption successful ✓</div>'}
</div>
<div class="tab-content active" id="formatted-http">
<div class="http-formatted-container">
${HttpFormatter.formatHttpContent(httpContent, isRequest, isResponse)}
</div>
${!managedEvent.decrypted ?
'<div class="decryption-status error">Decryption failed or not attempted</div>' :
'<div class="decryption-status success">Decryption successful ✓</div>'}
</div>
</div>
`;
// Set up tab buttons
this.setupTabButtons();
// Set up related event links
this.setupEventListeners();
}
/**
* Set up tab buttons for switching between raw and formatted views
*/
private setupTabButtons(): void {
if (!this.container) return;
const tabButtons = this.container.querySelectorAll('.tab-btn');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
// Remove active class from all buttons and content
tabButtons.forEach(btn => btn.classList.remove('active'));
const tabContents = this.container!.querySelectorAll('.tab-content');
tabContents.forEach(content => content.classList.remove('active'));
// Add active class to clicked button
button.classList.add('active');
// Show corresponding content
const tabId = (button as HTMLElement).dataset.tab || '';
const tabContent = this.container!.querySelector(`#${tabId}`);
if (tabContent) {
tabContent.classList.add('active');
}
});
});
}
/**
* Set up event listeners for related events and action buttons
*/
private setupEventListeners(): void {
if (!this.container) return;
// Related event links
const relatedLinks = this.container.querySelectorAll('.related-event-link');
relatedLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const eventId = (link as HTMLElement).dataset.id;
if (eventId) {
// Use the EventManager to select the related event
this.eventManager.selectEvent(eventId);
}
});
});
// Execute HTTP request buttons
const executeButtons = this.container.querySelectorAll('.execute-http-request-btn');
executeButtons.forEach(button => {
button.addEventListener('click', () => {
const eventId = (button as HTMLElement).dataset.id;
if (eventId) {
// Dispatch a custom event that can be handled by the http-response-viewer module
const event = new CustomEvent('execute-http-request', {
detail: { eventId }
});
document.dispatchEvent(event);
}
});
});
// Create response buttons
const createResponseButtons = this.container.querySelectorAll('.create-response-btn');
createResponseButtons.forEach(button => {
button.addEventListener('click', () => {
const eventId = (button as HTMLElement).dataset.id;
if (eventId) {
// Dispatch a custom event that can be handled by the http-response-viewer module
const event = new CustomEvent('create-21121-response', {
detail: { requestEventId: eventId }
});
document.dispatchEvent(event);
}
});
});
}
/**
* Clean up resources when component is destroyed
*/
public dispose(): void {
if (this.unregisterListener) {
this.unregisterListener();
this.unregisterListener = null;
}
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,228 @@
/**
* HttpRequestExecutor Component
*
* Executes HTTP requests from 21120 events using the HttpClient
* and publishes the results.
*/
import { NostrEvent } from '../relay';
import { HttpClient } from '../services/HttpClient';
import { ToastNotifier } from '../services/ToastNotifier';
import { EventManager } from '../services/EventManager';
// Result of an HTTP request execution
export interface ExecutionResult {
success: boolean;
response: string;
error?: string;
duration?: number;
url?: string;
}
// Options for initializing the HttpRequestExecutor
export interface HttpRequestExecutorOptions {
eventManager: EventManager;
httpClient: HttpClient;
httpService?: any; // For backward compatibility
}
/**
* Component responsible for executing HTTP requests
*/
export class HttpRequestExecutor {
private eventManager: EventManager;
private httpClient: HttpClient;
/**
* Constructor
* @param options Component initialization options
*/
constructor(options: HttpRequestExecutorOptions) {
this.eventManager = options.eventManager;
this.httpClient = options.httpClient;
}
/**
* Initialize the component and set up event listeners
*/
public initialize(): void {
// Listen for execute-http-request events
document.addEventListener('execute-http-request', (e: Event) => {
const customEvent = e as CustomEvent;
const { eventId } = customEvent.detail;
if (eventId) {
this.executeRequest(eventId);
}
});
console.log('HttpRequestExecutor initialized');
}
/**
* Parse URL from HTTP request
* @param httpRequest The HTTP request string
* @returns The URL or null if not found
*/
private parseUrlFromRequest(httpRequest: string): string | null {
// Get the first line
const firstLine = httpRequest.split('\n')[0];
// Extract the URL part between METHOD and HTTP/x.x
const match = firstLine.match(/^[A-Z]+ (.+) HTTP\/\d\.\d$/);
if (match && match[1]) {
let url = match[1].trim();
// If the URL doesn't start with a protocol, assume http://
if (!url.startsWith('http://') && !url.startsWith('https://')) {
// Check if there's a Host header to determine the full URL
const hostMatch = httpRequest.match(/Host:\s*([^\r\n]+)/i);
if (hostMatch && hostMatch[1]) {
const host = hostMatch[1].trim();
url = `http://${host}${url.startsWith('/') ? url : `/${url}`}`;
} else {
url = `http://localhost${url.startsWith('/') ? url : `/${url}`}`;
}
}
return url;
}
return null;
}
/**
* Execute an HTTP request based on a 21120 event
* @param eventId The ID of the 21120 event containing the HTTP request
*/
public async executeRequest(eventId: string): Promise<void> {
try {
// Get the event
const managedEvent = this.eventManager.getEvent(eventId);
if (!managedEvent) {
ToastNotifier.show(`Event with ID ${eventId} not found`, 'error');
return;
}
// Make sure it's a 21120 HTTP request event
if (managedEvent.event.kind !== 21120) {
ToastNotifier.show('Not a valid HTTP request event', 'error');
return;
}
// Get the HTTP request content
const httpRequest = managedEvent.decrypted && managedEvent.decryptedContent
? managedEvent.decryptedContent
: managedEvent.event.content;
// Parse the URL from the request
const url = this.parseUrlFromRequest(httpRequest);
if (!url) {
this.handleExecutionError(eventId, 'Failed to parse URL from HTTP request');
return;
}
// Show progress
ToastNotifier.show(`Executing request to ${url}...`, 'info');
// Execute the request
const startTime = Date.now();
try {
const response = await this.httpClient.sendHttpRequest(httpRequest);
const endTime = Date.now();
const duration = endTime - startTime;
// Build the result
const result: ExecutionResult = {
success: true,
response,
duration,
url
};
// Dispatch success event
this.dispatchExecutionEvent(eventId, result);
// Show success toast
ToastNotifier.show(`Request executed in ${duration}ms`, 'success');
} catch (error) {
const endTime = Date.now();
const duration = endTime - startTime;
// Format error as HTTP response
const errorMessage = error instanceof Error ? error.message : String(error);
const errorResponse = `HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nError: ${errorMessage}`;
// Build error result
const result: ExecutionResult = {
success: false,
response: errorResponse,
error: errorMessage,
duration,
url
};
// Dispatch error event
this.dispatchExecutionErrorEvent(eventId, result);
// Show error toast
ToastNotifier.show(`Request failed: ${errorMessage}`, 'error');
}
} catch (error) {
// Handle general execution errors
this.handleExecutionError(eventId, error instanceof Error ? error.message : String(error));
}
}
/**
* Handle execution errors
* @param eventId The event ID that failed
* @param errorMessage The error message
*/
private handleExecutionError(eventId: string, errorMessage: string): void {
console.error(`Execution error: ${errorMessage}`);
// Format error as HTTP response
const errorResponse = `HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nError: ${errorMessage}`;
// Build error result
const result: ExecutionResult = {
success: false,
response: errorResponse,
error: errorMessage
};
// Dispatch error event
this.dispatchExecutionErrorEvent(eventId, result);
// Show error toast
ToastNotifier.show(`Request failed: ${errorMessage}`, 'error');
}
/**
* Dispatch a successful execution event
* @param eventId The event ID
* @param result The execution result
*/
private dispatchExecutionEvent(eventId: string, result: ExecutionResult): void {
const event = new CustomEvent('http-request-executed', {
detail: { eventId, result }
});
document.dispatchEvent(event);
}
/**
* Dispatch an execution error event
* @param eventId The event ID
* @param result The execution result with error
*/
private dispatchExecutionErrorEvent(eventId: string, result: ExecutionResult): void {
const event = new CustomEvent('http-request-execution-error', {
detail: { eventId, result }
});
document.dispatchEvent(event);
}
}

@ -0,0 +1,372 @@
/**
* Nostr21121Creator Component
*
* A dedicated component for creating and publishing NIP-21121 HTTP response events.
* This component handles:
* - Proper formatting of 21121 events
* - Encryption of response content if needed
* - Event validation
* - Publishing to relays
*/
import { NostrEvent } from '../relay';
import * as nostrTools from 'nostr-tools';
import { EventManager } from '../services/EventManager';
import { ToastNotifier } from '../services/ToastNotifier';
/**
* Result of a 21121 event creation attempt
*/
export interface Creation21121Result {
success: boolean;
message: string;
eventId?: string;
event?: NostrEvent;
}
/**
* Options for initializing the Nostr21121Creator
*/
export interface Nostr21121CreatorOptions {
eventManager: EventManager;
relayUrls?: string[];
}
/**
* Class for creating and publishing NIP-21121 HTTP response events
*/
export class Nostr21121Creator {
private eventManager: EventManager;
private relayUrls: string[] = [];
/**
* Constructor
* @param options Options for initializing the component
*/
constructor(options: Nostr21121CreatorOptions) {
this.eventManager = options.eventManager;
this.relayUrls = options.relayUrls || [];
}
/**
* Set relay URLs
* @param relayUrls Array of relay URLs
*/
public setRelayUrls(relayUrls: string[]): void {
this.relayUrls = relayUrls;
}
/**
* Add a relay URL
* @param relayUrl Relay URL to add
*/
public addRelayUrl(relayUrl: string): void {
if (!this.relayUrls.includes(relayUrl)) {
this.relayUrls.push(relayUrl);
}
}
/**
* Validate a 21120 request event
* @param requestEvent The request event to validate
* @returns Validation result object with success status and message
*/
public validateRequestEvent(requestEvent: NostrEvent): { valid: boolean; message: string } {
// Check if event exists and has an ID
if (!requestEvent || !requestEvent.id) {
return { valid: false, message: 'Invalid request event: Missing ID' };
}
// Check event kind
if (requestEvent.kind !== 21120) {
return { valid: false, message: `Invalid event kind: ${requestEvent.kind} (expected 21120)` };
}
// Check for valid content
if (!requestEvent.content || requestEvent.content.trim() === '') {
return { valid: false, message: 'Invalid request event: Empty content' };
}
// Check if it has proper HTTP formatting
if (!this.isValidHttpRequest(requestEvent.content)) {
return { valid: false, message: 'Invalid HTTP request format in event content' };
}
return { valid: true, message: 'Request event is valid' };
}
/**
* Validate HTTP response format
* @param response HTTP response string to validate
* @returns Validation result object with success status and message
*/
public validateHttpResponse(response: string): { valid: boolean; message: string } {
if (!response || response.trim() === '') {
return { valid: false, message: 'Response content is empty' };
}
// Check if it starts with HTTP status line
const firstLine = response.split('\n')[0].trim();
if (!firstLine.match(/^HTTP\/\d\.\d\s+\d{3}\s+.+$/i)) {
return { valid: false, message: 'Invalid HTTP response: Missing or invalid status line' };
}
// Check for header/body separation
if (!response.includes('\n\n') && !response.includes('\r\n\r\n')) {
return {
valid: false,
message: 'Invalid HTTP response: Missing header/body separation (double newline)'
};
}
return { valid: true, message: 'HTTP response format is valid' };
}
/**
* Check if content has valid HTTP request format
* @param content Content to check
* @returns True if valid HTTP request format
*/
private isValidHttpRequest(content: string): boolean {
const firstLine = content.split('\n')[0].trim();
// Basic check: First line should contain method and path
return /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+\S+\s+HTTP\/\d\.\d$/i.test(firstLine);
}
/**
* Create a 21121 event without publishing
* @param requestEvent The original 21120 request event
* @param responseContent The HTTP response content
* @param privateKey The server's private key (hex or nsec)
* @returns The created event or null if creation failed
*/
public async createEvent(
requestEvent: NostrEvent,
responseContent: string,
privateKey: string
): Promise<Creation21121Result> {
try {
// Validate the request event
const requestValidation = this.validateRequestEvent(requestEvent);
if (!requestValidation.valid) {
return {
success: false,
message: requestValidation.message
};
}
// Validate the HTTP response
const responseValidation = this.validateHttpResponse(responseContent);
if (!responseValidation.valid) {
return {
success: false,
message: responseValidation.message
};
}
// Convert nsec to hex if needed
let privateKeyHex = '';
if (privateKey.startsWith('nsec')) {
try {
const decoded = nostrTools.nip19.decode(privateKey);
privateKeyHex = decoded.data as string;
} catch (e) {
return {
success: false,
message: 'Invalid nsec key format'
};
}
} else {
privateKeyHex = privateKey;
}
// Get the public key from the private key
const privateKeyBytes = Buffer.from(privateKeyHex, 'hex');
const pubKey = nostrTools.getPublicKey(privateKeyBytes);
// Initialize tags array
let tags: string[][] = [];
// Always add reference to the request event
if (requestEvent.id) {
tags.push(['e', requestEvent.id, '', 'reply']);
}
// Add kind reference
tags.push(['k', '21120']);
// Check if the original event has a p tag (recipient)
const pTag = requestEvent.tags.find(tag => tag[0] === 'p');
let finalContent = responseContent;
if (pTag && pTag[1]) {
// Add p tag to reference the recipient
tags.push(['p', pTag[1], '']);
// Encrypt the content if it's directed to a specific recipient
// Note: In a real implementation, we would use actual encryption
// This is simplified for demo purposes
try {
console.log(`Would encrypt content for recipient: ${pTag[1]}`);
// Simulate encryption - in a real app we would use:
// finalContent = nostrTools.nip04.encrypt(privateKeyHex, pTag[1], responseContent);
} catch (e) {
console.error('Encryption failed:', e);
return {
success: false,
message: 'Failed to encrypt response content'
};
}
}
// Create the event data
const eventData = {
kind: 21121,
created_at: Math.floor(Date.now() / 1000),
tags: tags,
content: finalContent,
pubkey: pubKey
};
// Sign the event - simplified for demo
// In a real implementation, we would use proper signing from nostr-tools
const id = nostrTools.getEventHash(eventData);
const sig = 'simulated_signature_for_demo';
const signedEvent: NostrEvent = {
...eventData,
id,
sig
};
return {
success: true,
message: 'Event created successfully',
eventId: signedEvent.id,
event: signedEvent
};
} catch (error) {
console.error('Error creating 21121 event:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Error creating event: ${errorMessage}`
};
}
}
/**
* Create and publish a 21121 event
* @param requestEvent The original 21120 request event
* @param responseContent The HTTP response content
* @param privateKey The server's private key (hex or nsec)
* @param relayUrl Optional specific relay URL to publish to
* @returns Result object with success status, message, and event details
*/
public async createAndPublish(
requestEvent: NostrEvent,
responseContent: string,
privateKey: string,
relayUrl?: string
): Promise<Creation21121Result> {
// Create the event
const result = await this.createEvent(requestEvent, responseContent, privateKey);
if (!result.success || !result.event) {
return result;
}
// Determine which relay URLs to use
const targetRelays = relayUrl
? [relayUrl]
: (this.relayUrls.length > 0 ? this.relayUrls : ['wss://relay.damus.io']);
try {
// Begin publishing status
ToastNotifier.show(`Publishing to ${targetRelays.length} relay(s)...`, 'info');
// For demonstration purposes - simulate relay publishing
console.log(`Would publish event to relays: ${targetRelays.join(', ')}`);
console.log('Event:', result.event);
// Simulate successful relay publishing
const successCount = targetRelays.length;
// In a real implementation, we would use:
// const relayPool = new nostrTools.SimplePool();
// const pubPromises = targetRelays.map(url => relayPool.publish([url], result.event!));
// const publishResults = await Promise.allSettled(pubPromises);
// const successCount = publishResults.filter(res => res.status === 'fulfilled').length;
// relayPool.close(targetRelays);
// Add event to local event manager if it has an ID
if (result.event && result.event.id) {
this.eventManager.addEvent(result.event);
// Dispatch an event to notify about new response
const creationEvent = new CustomEvent('21121-event-created', {
detail: {
requestId: requestEvent.id,
responseId: result.event.id,
event: result.event
}
});
document.dispatchEvent(creationEvent);
}
return {
success: successCount > 0,
message: `Event published to ${successCount}/${targetRelays.length} relays`,
eventId: result.event?.id,
event: result.event
};
} catch (error) {
console.error('Error publishing 21121 event:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Publishing error: ${errorMessage}`,
eventId: result.event?.id,
event: result.event
};
}
}
/**
* Generate a sample HTTP response based on a request
* @param requestEvent The 21120 request event
* @returns A sample HTTP response string
*/
public generateSampleResponse(requestEvent: NostrEvent): string {
if (!requestEvent || !requestEvent.content) {
return 'HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nInvalid request';
}
// Extract method from request
const firstLine = requestEvent.content.split('\n')[0].trim();
const methodMatch = firstLine.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/i);
const method = methodMatch ? methodMatch[1].toUpperCase() : 'UNKNOWN';
// Generate appropriate response based on method
switch (method) {
case 'GET':
return 'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nServer: Nostr 21121 Server\r\n\r\n{"status":"success","message":"This is a sample response"}';
case 'POST':
return 'HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nServer: Nostr 21121 Server\r\n\r\n{"status":"created","id":"sample-id-123"}';
case 'PUT':
return 'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nServer: Nostr 21121 Server\r\n\r\n{"status":"updated","message":"Resource updated successfully"}';
case 'DELETE':
return 'HTTP/1.1 204 No Content\r\nServer: Nostr 21121 Server\r\n\r\n';
case 'HEAD':
return 'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 42\r\nServer: Nostr 21121 Server\r\n\r\n';
default:
return 'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nServer: Nostr 21121 Server\r\n\r\nGeneric response for method: ' + method;
}
}
}

@ -0,0 +1,488 @@
/**
* ResponseViewer Component
* Handles HTTP response display and 21121 response event creation
*/
import { EventManager } from '../services/EventManager';
import { HttpFormatter } from '../services/HttpFormatter';
import { ToastNotifier } from '../services/ToastNotifier';
import { ExecutionResult } from './HttpRequestExecutor';
import { Nostr21121Creator, Creation21121Result } from './Nostr21121Creator';
/**
* Options for initializing the ResponseViewer component
*/
export interface ResponseViewerOptions {
modalId: string;
eventManager: EventManager;
relayUrls?: string[];
}
/**
* Class for displaying HTTP responses and creating 21121 events
*/
export class ResponseViewer {
private modalId: string;
private modalElement: HTMLElement | null = null;
private eventManager: EventManager;
private creator21121: Nostr21121Creator;
private currentRequestEventId: string | null = null;
private currentResponse: string | null = null;
private creationStatus: HTMLElement | null = null;
/**
* Constructor
* @param options Component options
*/
constructor(options: ResponseViewerOptions) {
this.modalId = options.modalId;
this.eventManager = options.eventManager;
// Initialize the Nostr21121Creator
this.creator21121 = new Nostr21121Creator({
eventManager: this.eventManager,
relayUrls: options.relayUrls
});
}
/**
* Initialize the component and set up event listeners
*/
public initialize(): void {
console.log('Initializing ResponseViewer...');
this.modalElement = document.getElementById(this.modalId);
if (!this.modalElement) {
console.error(`Modal element with ID ${this.modalId} not found`);
return;
}
// Set up event listeners for modal interactions
this.setupModalEventListeners();
// Setup event listener for 21121 event creation
document.addEventListener('21121-event-created', (e: Event) => {
const customEvent = e as CustomEvent;
const { requestId, responseId } = customEvent.detail;
// Update status if needed
if (this.creationStatus && this.currentRequestEventId === requestId) {
this.creationStatus.className = 'creation-status success';
this.creationStatus.textContent = 'Response event created successfully!';
// Add button to view the new event
const viewButton = document.createElement('button');
viewButton.className = 'view-response-event-btn';
viewButton.textContent = 'View Response Event';
viewButton.addEventListener('click', () => {
// Close modal
if (this.modalElement) {
this.modalElement.style.display = 'none';
}
// Select the new event
if (responseId) {
this.eventManager.selectEvent(responseId);
}
});
this.creationStatus.appendChild(document.createElement('br'));
this.creationStatus.appendChild(viewButton);
}
});
// Listen for HTTP request execution events
document.addEventListener('http-request-executed', (e: Event) => {
const customEvent = e as CustomEvent;
const { eventId, result } = customEvent.detail;
if (eventId && result) {
this.displayResponse(result, eventId);
}
});
// Listen for HTTP request execution errors
document.addEventListener('http-request-execution-error', (e: Event) => {
const customEvent = e as CustomEvent;
const { eventId, result } = customEvent.detail;
if (eventId && result) {
this.displayResponse(result, eventId);
}
});
// Listen for create response events
document.addEventListener('create-21121-response', (e: Event) => {
const customEvent = e as CustomEvent;
const { requestEventId } = customEvent.detail;
if (requestEventId) {
this.promptCreateResponse(requestEventId);
}
});
console.log('ResponseViewer initialized');
}
/**
* Display an HTTP response in the modal
* @param result The execution result containing the HTTP response
* @param requestEventId The ID of the request event
*/
public displayResponse(result: ExecutionResult, requestEventId: string): void {
if (!this.modalElement) {
console.error('Modal element not found');
return;
}
// Store the current response and request event ID
this.currentResponse = result.response;
this.currentRequestEventId = requestEventId;
// Get the response containers
const formattedContainer = this.modalElement.querySelector('#formatted-response .http-formatted-container');
const rawContainer = this.modalElement.querySelector('#raw-response pre');
if (!formattedContainer || !rawContainer) {
console.error('Response containers not found in modal');
return;
}
// Update the modal content
formattedContainer.innerHTML = HttpFormatter.formatHttpContent(result.response, false, true);
rawContainer.textContent = result.response;
// Add a create 21121 button if not already there
this.addCreate21121Button();
// Show the modal
this.modalElement.style.display = 'block';
}
/**
* Add a button to create a 21121 response event if not already present
*/
private addCreate21121Button(): void {
if (!this.modalElement) return;
// Create or update status element
if (!this.creationStatus) {
this.creationStatus = document.createElement('div');
this.creationStatus.className = 'creation-status';
this.creationStatus.style.display = 'none';
// Find a place to add it
const responseContent = this.modalElement.querySelector('.http-response-content');
if (responseContent) {
responseContent.insertBefore(this.creationStatus, responseContent.firstChild);
}
}
// Check if button already exists
const existingButton = this.modalElement.querySelector('.create-21121-btn');
if (existingButton) return;
// Get the modal header
const modalHeader = this.modalElement.querySelector('.http-response-header');
if (!modalHeader) return;
// Create button
const button = document.createElement('button');
button.className = 'create-21121-btn';
button.textContent = 'Create 21121 Response';
button.addEventListener('click', () => {
if (this.currentRequestEventId && this.currentResponse) {
// Show options dialog
this.showCreateResponseDialog(this.currentRequestEventId, this.currentResponse);
}
});
// Add to header
modalHeader.appendChild(button);
}
/**
* Set up event listeners for modal interactions
*/
private setupModalEventListeners(): void {
if (!this.modalElement) return;
// Handle close button click
const closeBtn = this.modalElement.querySelector('.close-modal-btn');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
if (this.modalElement) {
this.modalElement.style.display = 'none';
}
});
}
// Handle tab switching
const tabButtons = this.modalElement.querySelectorAll('.tab-btn');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
// Get the tab ID
const tabId = (button as HTMLElement).dataset.tab;
if (!tabId) return;
// Remove active class from all buttons and content
tabButtons.forEach(btn => btn.classList.remove('active'));
const tabContents = this.modalElement!.querySelectorAll('.tab-content');
tabContents.forEach(content => content.classList.remove('active'));
// Add active class to clicked button
button.classList.add('active');
// Show corresponding content
const tabContent = this.modalElement!.querySelector(`#${tabId}`);
if (tabContent) {
tabContent.classList.add('active');
}
});
});
// Handle clicking outside the modal content to close
this.modalElement.addEventListener('click', (e: Event) => {
if (e.target === this.modalElement) {
this.modalElement!.style.display = 'none';
}
});
}
/**
* Prompt the user to create a 21121 response for a request event
* @param requestEventId The ID of the request event to respond to
*/
public async promptCreateResponse(requestEventId: string): Promise<void> {
const managedEvent = this.eventManager.getEvent(requestEventId);
if (!managedEvent) {
ToastNotifier.show(`Event with ID ${requestEventId} not found`, 'error');
return;
}
// Make sure it's a request event
if (managedEvent.event.kind !== 21120) {
ToastNotifier.show('Not a valid HTTP request event', 'error');
return;
}
// Check if we already have a related response
const relatedEvents = this.eventManager.getRelatedEvents(requestEventId);
const hasResponse = relatedEvents.some(event => event.event.kind === 21121);
if (hasResponse) {
const shouldOverwrite = confirm('A response already exists for this request. Create another one?');
if (!shouldOverwrite) return;
}
// Generate a sample response
const sampleResponse = this.creator21121.generateSampleResponse(managedEvent.event);
// Prompt for manual response entry
const responseText = prompt('Enter the HTTP response or click Cancel to execute the request first:', sampleResponse);
if (responseText === null) {
// User cancelled - suggest executing the request
const shouldExecute = confirm('Would you like to execute the HTTP request to generate a response?');
if (shouldExecute) {
// Dispatch an event to execute the request
const event = new CustomEvent('execute-http-request', {
detail: { eventId: requestEventId }
});
document.dispatchEvent(event);
}
return;
}
// Validate the response format
const validation = this.creator21121.validateHttpResponse(responseText);
if (!validation.valid) {
const tryAgain = confirm(`Invalid HTTP response format: ${validation.message}\n\nTry again?`);
if (tryAgain) {
this.promptCreateResponse(requestEventId);
}
return;
}
// Create and publish the response event
await this.createAndPublish21121Response(requestEventId, responseText);
}
/**
* Show a dialog with options for creating a 21121 response
*/
private showCreateResponseDialog(requestEventId: string, responseContent: string): void {
if (!this.modalElement || !this.creationStatus) return;
// Update and show the status element
this.creationStatus.className = 'creation-status info';
this.creationStatus.innerHTML = `
<h4>Create 21121 Response Event</h4>
<p>This will create and publish a NIP-21121 HTTP response event for the selected request.</p>
<label class="response-label">
<input type="checkbox" id="encryptResponse" checked>
Encrypt response (if recipient is specified)
</label>
<div class="relay-selection">
<label>Publish to relay:</label>
<input type="text" id="responseRelayUrl" value="wss://relay.damus.io" placeholder="wss://...">
</div>
<div class="creation-buttons">
<button id="createResponseBtn" class="primary-button">Create & Publish</button>
<button id="cancelResponseBtn" class="secondary-button">Cancel</button>
</div>
`;
this.creationStatus.style.display = 'block';
// Add event listeners to the buttons
const createBtn = this.creationStatus.querySelector('#createResponseBtn');
const cancelBtn = this.creationStatus.querySelector('#cancelResponseBtn');
const relayInput = this.creationStatus.querySelector('#responseRelayUrl') as HTMLInputElement;
if (createBtn) {
createBtn.addEventListener('click', async () => {
await this.createAndPublish21121Response(requestEventId, responseContent, relayInput?.value);
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
if (this.creationStatus) {
this.creationStatus.style.display = 'none';
}
});
}
}
/**
* Create and publish a 21121 response event
* @param requestEventId The ID of the request event to respond to
* @param responseContent The HTTP response content
* @param relayUrl Optional relay URL to publish to
*/
public async createAndPublish21121Response(
requestEventId: string,
responseContent: string,
relayUrl?: string
): Promise<void> {
try {
// Update status
if (this.creationStatus) {
this.creationStatus.className = 'creation-status loading';
this.creationStatus.innerHTML = '<p>Creating and publishing response event...</p>';
}
// Get the server's private key
const serverNsec = localStorage.getItem('serverNsec');
if (!serverNsec) {
if (this.creationStatus) {
this.creationStatus.className = 'creation-status error';
this.creationStatus.textContent = 'Server private key (nsec) not found. Please set up a server identity first.';
}
ToastNotifier.show('Server private key (nsec) not found. Please set up a server identity first.', 'error');
return;
}
// Get the request event
const managedEvent = this.eventManager.getEvent(requestEventId);
if (!managedEvent) {
if (this.creationStatus) {
this.creationStatus.className = 'creation-status error';
this.creationStatus.textContent = `Request event with ID ${requestEventId} not found`;
}
ToastNotifier.show(`Request event with ID ${requestEventId} not found`, 'error');
return;
}
// If no relay URL was provided, get from UI or use default
if (!relayUrl) {
const relayUrlInput = document.getElementById('relayUrl') as HTMLInputElement;
relayUrl = relayUrlInput?.value || 'wss://relay.damus.io';
}
// Show progress
ToastNotifier.show('Creating and publishing 21121 response...', 'info');
// Create and publish the response using our specialized component
const result = await this.creator21121.createAndPublish(
managedEvent.event,
responseContent,
serverNsec,
relayUrl
);
// Handle the result
if (!result.success) {
if (this.creationStatus) {
this.creationStatus.className = 'creation-status error';
this.creationStatus.textContent = `Error: ${result.message}`;
}
ToastNotifier.show(`Failed to create 21121 response: ${result.message}`, 'error');
return;
}
// Success!
ToastNotifier.show(result.message, 'success');
// Update status element
if (this.creationStatus && result.eventId) {
this.creationStatus.className = 'creation-status success';
this.creationStatus.innerHTML = `
<p>${result.message}</p>
<button class="view-response-event-btn">View Response Event</button>
`;
// Add click handler to the view button
const viewBtn = this.creationStatus.querySelector('.view-response-event-btn');
if (viewBtn) {
viewBtn.addEventListener('click', () => {
// Close modal
if (this.modalElement) {
this.modalElement.style.display = 'none';
}
// Select the event
this.eventManager.selectEvent(result.eventId!);
});
}
}
// Don't close the modal automatically - let the user see the status
} catch (error) {
console.error('Error creating 21121 response:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
if (this.creationStatus) {
this.creationStatus.className = 'creation-status error';
this.creationStatus.textContent = `Error: ${errorMessage}`;
}
ToastNotifier.show(`Error: ${errorMessage}`, 'error');
}
}
/**
* Update UI to show the relationship between events
* @param requestEventId The request event ID
* @param responseEventId The response event ID
*/
private updateUIAfterResponse(requestEventId: string, responseEventId: string): void {
// Add response indicator to the request event in the event list
const eventItem = document.querySelector(`.event-item[data-id="${requestEventId}"]`);
if (eventItem && !eventItem.querySelector('.related-indicator')) {
const responseIndicator = document.createElement('div');
responseIndicator.className = 'related-indicator';
responseIndicator.innerHTML = '🔗';
responseIndicator.title = 'Has related events';
// Find a place to add it (next to the event time)
const eventHeader = eventItem.querySelector('.event-header');
if (eventHeader) {
eventHeader.appendChild(responseIndicator);
} else {
eventItem.appendChild(responseIndicator);
}
}
}
}

@ -0,0 +1,294 @@
/**
* ServerUI Component
* Main component that integrates all UI components for the 1120 server interface
*/
import { EventManager } from '../services/EventManager';
import { NostrEventService } from '../services/NostrEventService.updated';
import { NostrRelayService } from '../services/NostrRelayService';
import { NostrCacheService } from '../services/NostrCacheService';
import { HttpService } from '../services/HttpService';
import { HttpClient } from '../services/HttpClient';
import { ToastNotifier } from '../services/ToastNotifier';
import { HttpRequestExecutor } from './HttpRequestExecutor';
import { ResponseViewer } from './ResponseViewer';
import { EventList } from './EventList';
import { EventDetail } from './EventDetail';
/**
* Options for initializing the ServerUI component
*/
export interface ServerUIOptions {
eventListContainer: string;
eventDetailContainer: string;
relayUrlInput: string;
connectButton: string;
relayStatusContainer: string;
}
/**
* Class representing the main server UI component
*/
export class ServerUI {
private options: ServerUIOptions;
// Core services
private eventManager: EventManager;
private relayService: NostrRelayService;
private cacheService: NostrCacheService;
private nostrEventService: NostrEventService;
private httpService: HttpService;
private httpClient: HttpClient;
// UI components
private eventList: EventList;
private eventDetail: EventDetail;
private httpRequestExecutor: HttpRequestExecutor;
private responseViewer: ResponseViewer;
/**
* Create a new ServerUI component
*/
constructor(options: ServerUIOptions) {
this.options = options;
// Initialize services
this.eventManager = new EventManager();
this.relayService = new NostrRelayService();
this.cacheService = new NostrCacheService();
this.httpService = new HttpService();
this.httpClient = new HttpClient(this.httpService);
// Create status update callback
const updateStatusCallback = (statusMessage: string, statusClass: string) => {
const relayStatus = document.getElementById(this.options.relayStatusContainer);
if (relayStatus) {
relayStatus.textContent = statusMessage;
relayStatus.className = 'relay-status ' + statusClass;
}
};
// Create NostrEventService with EventManager
this.nostrEventService = new NostrEventService(
this.relayService,
this.cacheService,
this.eventManager,
updateStatusCallback
);
// Initialize UI components
this.eventList = new EventList(this.eventManager, {
container: this.options.eventListContainer
});
this.eventDetail = new EventDetail(this.eventManager, {
container: this.options.eventDetailContainer
});
// Initialize HTTP components
this.httpRequestExecutor = new HttpRequestExecutor({
eventManager: this.eventManager,
httpClient: this.httpClient
});
// Get active relay URL
const relayUrl = this.relayService.getActiveRelayUrl() || 'wss://relay.damus.io';
this.responseViewer = new ResponseViewer({
modalId: 'httpResponseModal',
eventManager: this.eventManager,
relayUrls: [relayUrl]
});
}
/**
* Initialize the UI and set up event listeners
*/
public initialize(): void {
console.log('Initializing Server UI...');
// Initialize UI components
this.eventList.initialize();
this.eventDetail.initialize();
this.httpRequestExecutor.initialize();
this.responseViewer.initialize();
// Set up event listeners
this.setupEventListeners();
// Load server identity if available
this.loadServerIdentity();
console.log('Server UI initialized');
}
/**
* Set up event listeners for UI interactions
*/
private setupEventListeners(): void {
// Connect to relay button
const connectButton = document.getElementById(this.options.connectButton);
if (connectButton) {
connectButton.addEventListener('click', () => {
this.connectToRelay();
});
}
// Listen for filter changes from EventList
document.addEventListener('event-filter-changed', (e: Event) => {
const customEvent = e as CustomEvent;
const showAllEvents = customEvent.detail?.showAllEvents;
if (typeof showAllEvents === 'boolean') {
this.updateRelaySubscription(showAllEvents);
}
});
// Enter key in relay URL input
const relayUrlInput = document.getElementById(this.options.relayUrlInput) as HTMLInputElement;
if (relayUrlInput) {
relayUrlInput.addEventListener('keyup', (event) => {
if (event.key === 'Enter') {
this.connectToRelay();
}
});
}
}
/**
* Connect to the specified relay
*/
private connectToRelay(): void {
const relayUrlInput = document.getElementById(this.options.relayUrlInput) as HTMLInputElement;
if (!relayUrlInput) return;
const relayUrl = relayUrlInput.value.trim() || 'wss://relay.degmods.com';
if (!relayUrl) {
ToastNotifier.show('Please enter a relay URL', 'error');
return;
}
// Get show all events state from the UI
const showAllEventsCheckbox = document.getElementById('showAllEvents') as HTMLInputElement;
const showAllEvents = showAllEventsCheckbox?.checked || true;
// Update the relay URL input with the actual URL used
relayUrlInput.value = relayUrl;
// Connect to relay and subscribe
this.connectToRelayAndSubscribe(relayUrl, showAllEvents);
}
/**
* Connect to a relay and subscribe to events
*/
private async connectToRelayAndSubscribe(relayUrl: string, showAllEvents: boolean): Promise<void> {
try {
// Create a filter for HTTP message events (kinds 21120 and 21121)
const filter = this.nostrEventService.createHttpMessageFilter(showAllEvents);
// Connect to the relay service
await this.relayService.connectToRelay(relayUrl);
// Subscribe to events with the filter
await this.nostrEventService.subscribeToEvents(filter);
// Show success notification
ToastNotifier.show(`Connected to ${relayUrl}`, 'success');
} catch (error) {
console.error('Error connecting to relay:', error);
ToastNotifier.show(`Error connecting to relay: ${error instanceof Error ? error.message : String(error)}`, 'error');
}
}
/**
* Update the relay subscription with a new filter
*/
private async updateRelaySubscription(showAllEvents: boolean): Promise<void> {
const activeRelayUrl = this.relayService.getActiveRelayUrl();
if (!activeRelayUrl) {
console.warn('Cannot update subscription - no active relay');
return;
}
try {
// Create a filter for HTTP message events (kinds 21120 and 21121)
const filter = this.nostrEventService.createHttpMessageFilter(showAllEvents);
// Subscribe to events with the filter (this will replace the current subscription)
await this.nostrEventService.subscribeToEvents(filter);
} catch (error) {
console.error('Error updating relay subscription:', error);
ToastNotifier.show(`Error updating subscription: ${error instanceof Error ? error.message : String(error)}`, 'error');
}
}
/**
* Load server identity from localStorage
*/
private loadServerIdentity(): void {
const serverNsec = localStorage.getItem('serverNsec');
if (serverNsec) {
console.log('Server identity loaded from localStorage');
} else {
console.log('No server identity found in localStorage');
}
}
/**
* Clean up resources when component is destroyed
*/
public dispose(): void {
// Clean up UI components
this.eventList.dispose();
this.eventDetail.dispose();
// No dispose method needed for HttpRequestExecutor and ResponseViewer
// as they don't have persistent resources to clean up
// Close any active connections
if (this.relayService.isConnected() && this.relayService.getRelayPool()) {
const activeRelayUrl = this.relayService.getActiveRelayUrl();
if (activeRelayUrl) {
this.relayService.getRelayPool()?.close([activeRelayUrl]);
}
}
// Close WebSocket connections
this.relayService.getWebSocketManager().close();
}
/**
* Get the EventManager instance
*/
public getEventManager(): EventManager {
return this.eventManager;
}
/**
* Get the NostrEventService instance
*/
public getNostrEventService(): NostrEventService {
return this.nostrEventService;
}
}
/**
* Initialize the server UI with default element IDs
*/
export function initServerUI(): ServerUI {
const serverUI = new ServerUI({
eventListContainer: 'eventsList', // Fixed: match the actual ID in HTML
eventDetailContainer: 'eventDetails', // Fixed: match the actual ID in HTML
relayUrlInput: 'relayUrl',
connectButton: 'connectRelayBtn',
relayStatusContainer: 'relayStatus'
});
serverUI.initialize();
// Make available globally for debugging
(window as any).__serverUI = serverUI;
return serverUI;
}

@ -0,0 +1,78 @@
/**
* Debug Events
* This file adds debugging outputs to help trace event filtering and display issues
*/
// Wait for DOM to be ready
document.addEventListener('DOMContentLoaded', function() {
console.log('[DEBUG] Setting up event debugging hooks...');
// Intercept EventManager to log events
setTimeout(() => {
if (window.__eventManager) {
const originalAddEvent = window.__eventManager.addEvent;
// Monkey patch the addEvent method to log events as they are added
window.__eventManager.addEvent = function(event, decrypted, decryptedContent) {
console.log(`[DEBUG] Event being added to manager:`, {
kind: event.kind,
id: event.id ? event.id.substring(0, 8) + '...' : 'unknown',
tags: event.tags.length,
decrypted
});
return originalAddEvent.call(this, event, decrypted, decryptedContent);
};
// Expose helper to manually check filter state
window.checkEventFilters = function() {
// Find all event type filter checkboxes
const filters = document.querySelectorAll('.event-type-filter');
console.log('[DEBUG] Current event type filters:');
filters.forEach(checkbox => {
console.log(`- ${checkbox.value}: ${checkbox.checked}`);
});
// Get all events and filtered events from the EventList
if (window.__serverUI) {
const eventList = window.__serverUI.getEventManager().getAllEvents();
console.log(`[DEBUG] Total events in manager: ${eventList.length}`);
// Count by kind
const kinds = {};
eventList.forEach(event => {
const kind = event.event.kind;
kinds[kind] = (kinds[kind] || 0) + 1;
});
console.log('[DEBUG] Events by kind:', kinds);
}
};
// Add a debug button to the UI
const container = document.querySelector('.events-container');
if (container) {
const debugBtn = document.createElement('button');
debugBtn.textContent = 'Debug Events';
debugBtn.style.marginBottom = '10px';
debugBtn.style.padding = '5px 10px';
debugBtn.style.backgroundColor = '#dc3545';
debugBtn.style.color = 'white';
debugBtn.style.border = 'none';
debugBtn.style.borderRadius = '4px';
debugBtn.style.cursor = 'pointer';
debugBtn.addEventListener('click', function() {
window.checkEventFilters();
});
container.prepend(debugBtn);
}
console.log('[DEBUG] Event debugging hooks installed. Use window.checkEventFilters() to check filter state');
} else {
console.warn('[DEBUG] Event manager not found in window object');
}
}, 1000);
});

@ -0,0 +1,308 @@
/**
* HTTP Response Viewer module
* Handles displaying HTTP responses and 21121 integration
* Refactored to use EventManager for centralized event data management
*/
import { NostrEvent } from './relay';
import { HttpFormatter } from './services/HttpFormatter';
import { ToastNotifier } from './services/ToastNotifier';
import { EventManager, EventKind, EventChangeType } from './services/EventManager';
import { HttpService } from './services/HttpService';
import { HttpClient } from './services/HttpClient';
// Services that will be dynamically imported to avoid circular dependencies
let nostrService: any = null;
let nostr21121Service: any = null;
/**
* Initialize the HTTP response viewer functionality
* @param eventManager The centralized EventManager instance
* @param httpService Optional HttpService instance for HTTP operations
*/
export function initHttpResponseViewer(
eventManager: EventManager,
httpService?: HttpService
): void {
console.log('Initializing HTTP response viewer...');
// Use provided HttpService or create a new one
const httpServiceInstance = httpService || new HttpService();
const httpClient = new HttpClient(httpServiceInstance);
// Add event listener for tab switching in the HTTP response modal
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
// Handle tab switching
if (target && target.classList.contains('tab-btn')) {
const tabContainer = target.closest('.http-response-tabs, .event-detail-tabs');
if (!tabContainer) return;
// Get all tab buttons and content in this container
const tabButtons = tabContainer.querySelectorAll('.tab-btn');
// Find the tab content container (parent or sibling depending on structure)
let tabContentContainer = tabContainer.nextElementSibling;
if (!tabContentContainer || !tabContentContainer.querySelector('.tab-content')) {
// If not a sibling, try to find a parent that contains the tab content
tabContentContainer = tabContainer.closest('.modal-content, .event-details');
}
if (!tabContentContainer) return;
const tabContents = tabContentContainer.querySelectorAll('.tab-content');
// Remove active class from all tabs and content
tabButtons.forEach(btn => btn.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
// Add active class to clicked tab
target.classList.add('active');
// Find the corresponding content
const tabId = target.getAttribute('data-tab');
if (tabId) {
const tabContent = document.getElementById(tabId) ||
tabContentContainer.querySelector(`#${tabId}`);
if (tabContent) {
tabContent.classList.add('active');
}
}
}
// Handle close modal button
if (target && (
target.classList.contains('close-modal-btn') ||
target.closest('.close-modal-btn')
)) {
const modal = target.closest('.http-response-modal');
if (modal) {
(modal as HTMLElement).style.display = 'none';
}
}
// Handle clicking outside the modal to close it
if (target && target.classList.contains('http-response-modal')) {
(target as HTMLElement).style.display = 'none';
}
});
// Listen for custom events from EventDetailsRenderer
document.addEventListener('execute-http-request', async (e: Event) => {
const customEvent = e as CustomEvent;
const eventId = customEvent.detail?.eventId;
if (eventId) {
// Get the event from EventManager
const managedEvent = eventManager.getEvent(eventId);
if (managedEvent && managedEvent.event.kind === EventKind.HttpRequest) {
// Execute the HTTP request
await executeHttpRequest(
managedEvent.event,
eventManager,
httpClient
);
}
}
});
document.addEventListener('create-21121-response', async (e: Event) => {
const customEvent = e as CustomEvent;
const requestEventId = customEvent.detail?.requestEventId;
if (requestEventId) {
// Get the event from EventManager
const requestEvent = eventManager.getEvent(requestEventId);
if (requestEvent && requestEvent.event.kind === EventKind.HttpRequest) {
// Create and publish a 21121 response
await create21121Response(
requestEvent.event,
eventManager
);
}
}
});
// Register for button clicks in the UI
document.addEventListener('click', async (event) => {
const target = event.target as HTMLElement;
// Handle execute HTTP request button outside custom events
if (target && (
target.classList.contains('execute-http-request-btn') ||
target.closest('.execute-http-request-btn')
)) {
// Prevent multiple clicks
const button = target.classList.contains('execute-http-request-btn') ?
target : target.closest('.execute-http-request-btn');
if (button && !button.hasAttribute('disabled')) {
// Get the selected event from EventManager
const selectedEvent = eventManager.getSelectedEvent();
if (selectedEvent && selectedEvent.event.kind === EventKind.HttpRequest) {
await executeHttpRequest(
selectedEvent.event,
eventManager,
httpClient
);
} else {
ToastNotifier.show('No HTTP request selected', 'error');
}
}
}
});
}
/**
* Execute an HTTP request
* @param requestEvent The HTTP request event
* @param eventManager The EventManager instance
* @param httpClient The HttpClient for sending requests
*/
async function executeHttpRequest(
requestEvent: NostrEvent,
eventManager: EventManager,
httpClient: HttpClient
): Promise<void> {
// Find the button if any
const button = document.querySelector('.execute-http-request-btn') as HTMLElement;
// Store the original button text
const originalText = button ? button.textContent || 'Execute HTTP Request' : '';
// Update button to show it's working
if (button) {
button.textContent = 'Executing...';
button.setAttribute('disabled', 'true');
}
try {
// Get the HTTP content directly from the event
const httpContent = requestEvent.content;
if (!httpContent.trim()) {
throw new Error('Empty HTTP content');
}
// Execute the HTTP request
const response = await httpClient.sendHttpRequest(httpContent);
// Display the response
displayHttpResponse(response);
// Ask if the user wants to create a 21121 response event
if (confirm('Do you want to create and publish a NIP-21121 response event?')) {
await create21121Response(requestEvent, eventManager, response);
}
} catch (error) {
// Show error
console.error('Error executing HTTP request:', error);
ToastNotifier.show(`Error: ${error instanceof Error ? error.message : String(error)}`, 'error');
const errorResponse = `HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\n\r\nError: ${error instanceof Error ? error.message : String(error)}`;
displayHttpResponse(errorResponse);
} finally {
// Restore button state
if (button) {
button.textContent = originalText;
button.removeAttribute('disabled');
}
}
}
/**
* Display HTTP response in the modal
* @param response The HTTP response content
*/
function displayHttpResponse(response: string): void {
// Get the modal
const modal = document.getElementById('httpResponseModal');
if (!modal) {
console.error('HTTP response modal not found');
return;
}
// Update the modal content
const formattedContainer = modal.querySelector('#formatted-response .http-formatted-container');
const rawContainer = modal.querySelector('#raw-response pre');
if (formattedContainer) {
formattedContainer.innerHTML = HttpFormatter.formatHttpContent(response, false, true);
}
if (rawContainer) {
rawContainer.textContent = response;
}
// Show the modal
(modal as HTMLElement).style.display = 'block';
}
/**
* Create and publish a 21121 response event
* @param requestEvent The 21120 request event
* @param eventManager The EventManager instance
* @param responseContent Optional HTTP response content
*/
async function create21121Response(
requestEvent: NostrEvent,
eventManager: EventManager,
responseContent?: string
): Promise<void> {
try {
// Get the server's private key
const serverNsec = localStorage.getItem('serverNsec');
if (!serverNsec) {
ToastNotifier.show('Server private key (nsec) not found. Please set up a server identity first.', 'error');
return;
}
// Dynamically import services to avoid circular dependencies
if (!nostrService) {
const { NostrService } = await import('./services/NostrService');
nostrService = new NostrService();
}
if (!nostr21121Service) {
const { Nostr21121Service } = await import('./services/Nostr21121Service');
const relayService = nostrService.getRelayService();
const cacheService = nostrService.getCacheService();
nostr21121Service = new Nostr21121Service(relayService, cacheService);
}
// Get the relay URL from the UI
const relayUrlInput = document.getElementById('relayUrl') as HTMLInputElement;
const relayUrl = relayUrlInput?.value || 'wss://relay.degmods.com';
// If we don't have response content, use a placeholder
const content = responseContent || 'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nSuccessful response';
// Create and publish the 21121 event
const responseEvent = await nostr21121Service.createAndPublish21121Event(
requestEvent,
content,
serverNsec,
relayUrl
);
if (responseEvent) {
ToastNotifier.show('NIP-21121 response event published successfully!', 'success');
// Add the response to EventManager
eventManager.addEvent(responseEvent, true);
// Show a visual indicator in the UI
const eventItem = document.querySelector(`.event-item[data-id="${requestEvent.id}"]`);
if (eventItem && !eventItem.querySelector('.response-indicator')) {
const responseIndicator = document.createElement('div');
responseIndicator.className = 'response-indicator';
responseIndicator.innerHTML = '<span class="response-available">21121 Response Available</span>';
eventItem.appendChild(responseIndicator);
}
} else {
ToastNotifier.show('Failed to publish NIP-21121 response event', 'error');
}
} catch (error) {
console.error('Error creating 21121 response:', error);
ToastNotifier.show(`Error: ${error instanceof Error ? error.message : String(error)}`, 'error');
}
}

@ -0,0 +1,148 @@
/**
* navbar-diagnostics.ts
* Diagnostic tool to help debug navbar issues
*/
// Simple IIFE to run diagnostics and attempt to fix navbar issues
(function() {
// Create a diagnostic function that's accessible from the console
function runNavbarDiagnostics() {
console.log('%c[NAVBAR DIAGNOSTICS]', 'background: #ff0000; color: white; padding: 5px; font-size: 16px;');
// Check if the container exists
const navbarContainer = document.getElementById('navbarContainer');
console.log('1. Navbar container exists:', !!navbarContainer);
// If no container, this is a critical issue
if (!navbarContainer) {
console.error('CRITICAL: Navbar container not found!');
console.log('Attempting to create navbar container...');
// Create a new navbar container
const newContainer = document.createElement('div');
newContainer.id = 'navbarContainer';
newContainer.className = 'top-nav';
// Insert it at the top of the body
document.body.insertBefore(newContainer, document.body.firstChild);
console.log('Created new navbar container:', !!document.getElementById('navbarContainer'));
}
// Check if navbarContainer has content
const navbarContent = document.querySelector('.nav-left, .nav-right');
console.log('2. Navbar has content:', !!navbarContent);
if (!navbarContent) {
console.log('Navbar has no content. Attempting to fix...');
// Try to initialize the navbar
if (window.hasOwnProperty('initializeNavbar')) {
console.log('Calling initializeNavbar function...');
try {
(window as any).initializeNavbar();
} catch (error) {
console.error('Error calling initializeNavbar:', error);
}
} else {
console.error('initializeNavbar function not found globally!');
// Try to manually import and call
console.log('Attempting manual import...');
try {
// Get the current page
const currentPage = window.location.pathname.split('/').pop() || 'index.html';
// Create emergency navbar content
const emergencyNavbar = document.getElementById('navbarContainer');
if (emergencyNavbar) {
const navbarHtml = `
<div class="nav-left">
<a href="./index.html" class="nav-link${currentPage === 'index.html' ? ' active' : ''}">HOME</a>
<a href="./1120_client.html" class="nav-link${currentPage === '1120_client.html' ? ' active' : ''}">CLIENT</a>
<a href="./1120_server.html" class="nav-link${currentPage === '1120_server.html' ? ' active' : ''}">SERVER</a>
<a href="./billboard.html" class="nav-link${currentPage === 'billboard.html' ? ' active' : ''}">BILLBOARD</a>
</div>
<div class="nav-right">
<a href="./index.html" class="nav-link nav-icon${currentPage === 'index.html' ? ' active' : ''}" title="Documentation"></a>
<a href="./profile.html" class="nav-link nav-icon${currentPage === 'profile.html' ? ' active' : ''}" title="Profile">👤</a>
<button id="nuclearResetBtn" class="nuclear-reset-btn" title="Reset All Data">💣</button>
<button id="themeToggleBtn" class="theme-toggle-btn" title="Toggle Dark Mode">
<span id="themeIcon">🌙</span>
</button>
</div>`;
emergencyNavbar.innerHTML = navbarHtml;
console.log('Added emergency navbar HTML!');
}
} catch (error) {
console.error('Error creating emergency navbar:', error);
}
}
}
// Check CSS visibility issues
const navbar = document.getElementById('navbarContainer');
if (navbar) {
const styles = window.getComputedStyle(navbar);
console.log('3. Navbar CSS visibility check:');
console.log(' - display:', styles.display);
console.log(' - visibility:', styles.visibility);
console.log(' - opacity:', styles.opacity);
console.log(' - height:', styles.height);
// Fix any CSS issues
if (styles.display === 'none') {
console.log('Fixing display:none issue...');
navbar.style.display = 'flex';
}
if (styles.visibility === 'hidden') {
console.log('Fixing visibility:hidden issue...');
navbar.style.visibility = 'visible';
}
if (styles.opacity === '0') {
console.log('Fixing opacity:0 issue...');
navbar.style.opacity = '1';
}
if (parseFloat(styles.height) === 0) {
console.log('Fixing zero height issue...');
navbar.style.height = 'auto';
}
}
// Final check
const navbarContentAfterFix = document.querySelector('.nav-left, .nav-right');
console.log('4. Navbar fixed successfully:', !!navbarContentAfterFix);
// Return result of diagnostics
return {
containerExists: !!navbarContainer,
hasContent: !!navbarContentAfterFix
};
}
// Make the diagnostics function globally available
if (typeof window !== 'undefined') {
(window as any).runNavbarDiagnostics = runNavbarDiagnostics;
}
// Run diagnostics on page load
window.addEventListener('load', function() {
console.log('Running navbar diagnostics on window.load...');
setTimeout(runNavbarDiagnostics, 1000);
});
// Also run it after DOM content is loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
console.log('Running navbar diagnostics on DOMContentLoaded...');
runNavbarDiagnostics();
});
} else {
// If already loaded, run it now
console.log('Document already loaded, running navbar diagnostics immediately...');
runNavbarDiagnostics();
}
})();
// Export a dummy function so TypeScript treats this as a module
export function navbarDiagnostics(): void {
console.log('Navbar diagnostics module loaded');
}

@ -73,6 +73,12 @@ function setupThemeToggle(): void {
// Add click handler - skip in this module since navbar.ts already handles it
// This prevents duplicate event listeners
console.log('[NAVBAR-INIT] Skipping duplicate theme toggle event listener (handled by navbar.ts)');
// Actually add a click handler in case navbar.ts didn't
themeToggleBtn.addEventListener('click', () => {
console.log('[NAVBAR-INIT] Theme toggle button clicked');
themeUtils.toggleTheme();
});
}
}
@ -83,23 +89,6 @@ function toggleTheme(): void {
// Call the imported function instead
themeUtils.toggleTheme();
return;
// DEPRECATED implementation below - kept for reference
const body = document.body;
const isDarkMode = body.getAttribute('data-theme') === 'dark';
if (isDarkMode) {
// Switch to light theme
body.removeAttribute('data-theme');
localStorage.setItem('theme', 'light');
} else {
// Switch to dark theme
body.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark');
}
// Update the theme icon
updateThemeIcon();
}
/**
@ -109,17 +98,6 @@ function updateThemeIcon(): void {
// Call the imported function instead
themeUtils.updateThemeIcon();
return;
// DEPRECATED implementation below - kept for reference
/*
const isDarkMode = document.body.getAttribute('data-theme') === 'dark';
const themeIcon = document.getElementById('themeIcon');
if (themeIcon) {
themeIcon.textContent = isDarkMode ? '☀️' : '🌙';
console.log('[NAVBAR-INIT] Updated theme icon to:', themeIcon.textContent);
}
*/
}
/**
@ -139,11 +117,37 @@ function updateThemeDisplay(theme: string | null): void {
themeUtils.updateThemeIcon();
}
// Add logging for debugging
function checkNavbarStatus(): void {
console.log('%c[NAVBAR-INIT DIAGNOSIS]', 'background: #0000ff; color: white; padding: 5px; font-size: 14px;');
const navbarContainer = document.getElementById('navbarContainer');
console.log('Navbar container found:', !!navbarContainer);
if (navbarContainer) {
console.log('Navbar content:', navbarContainer.innerHTML.substring(0, 50) + '...');
console.log('Nav-left exists:', !!navbarContainer.querySelector('.nav-left'));
console.log('Nav-right exists:', !!navbarContainer.querySelector('.nav-right'));
} else {
console.error('NAVBAR CONTAINER NOT FOUND - this is why the navbar is not visible!');
}
console.log('Document ready state:', document.readyState);
}
// Add an immediate execution for the module
(function() {
console.log('[NAVBAR-INIT] Module self-executing function running');
// Add a document ready check to ensure the DOM is loaded
console.log('[NAVBAR-INIT] Module execution detected. Browser info:',
navigator.userAgent);
// Check if the DOM is truly available
if (!(document && document.body)) {
console.error('[NAVBAR-INIT] Document or body not available!');
}
if (document.readyState === 'loading') {
console.log('[NAVBAR-INIT] Document still loading, waiting for DOMContentLoaded');
document.addEventListener('DOMContentLoaded', initializeNavbar);
@ -153,6 +157,9 @@ function updateThemeDisplay(theme: string | null): void {
initializeNavbar();
}
// Check navbar status after a short delay
setTimeout(checkNavbarStatus, 500);
// Add direct DOM manipulation as a guaranteed fallback
window.addEventListener('load', function() {
console.log('[NAVBAR-INIT] Window load event triggered - checking navbar');
@ -165,9 +172,14 @@ function updateThemeDisplay(theme: string | null): void {
}
});
// Set up an additional timeout-based initialization as a fallback
// Use a longer timeout to ensure the DOM is definitely ready
// Make the diagnose function available globally
if (typeof window !== 'undefined') {
(window as any).checkNavbarStatus = checkNavbarStatus;
}
})();
// Set up an additional timeout-based initialization as a fallback
console.log('[NAVBAR-INIT] Setting up fallback initialization with timeout');
setTimeout(function() {
console.log('[NAVBAR-INIT] Fallback initialization after timeout');
@ -179,8 +191,80 @@ setTimeout(function() {
} else {
console.log('[NAVBAR-INIT] Navbar content already exists, skipping fallback initialization');
}
// Run a diagnostic check to see what's happening
checkNavbarStatus();
}, 1000);
// Add one more super-late initialization attempt as a last resort
setTimeout(function() {
console.log('[NAVBAR-INIT] Last resort initialization after extended timeout');
// Check if navbar still doesn't exist
const navbarContent = document.querySelector('.nav-left, .nav-right');
if (!navbarContent) {
console.log('[NAVBAR-INIT] No navbar content found after extended delay, performing last-resort initialization');
// Force initialization regardless of flags
try {
// Directly create and inject the navbar without using the regular init function
const navbarContainer = document.getElementById('navbarContainer');
if (navbarContainer) {
console.log('[NAVBAR-INIT] Found navbar container, emergency populating content');
// Get the current page URL
const currentPageUrl = window.location.pathname;
const currentPage = currentPageUrl.split('/').pop() || 'index.html';
// Create minimal navbar HTML
const emergencyNavbarHtml = `
<div class="nav-left">
<a href="./index.html" class="nav-link${currentPage === 'index.html' ? ' active' : ''}">HOME</a>
<a href="./1120_client.html" class="nav-link${currentPage === '1120_client.html' ? ' active' : ''}">CLIENT</a>
<a href="./1120_server.html" class="nav-link${currentPage === '1120_server.html' ? ' active' : ''}">SERVER</a>
<a href="./billboard.html" class="nav-link${currentPage === 'billboard.html' ? ' active' : ''}">BILLBOARD</a>
</div>
<div class="nav-right">
<a href="./index.html" class="nav-link nav-icon${currentPage === 'index.html' ? ' active' : ''}" title="Documentation"></a>
<a href="./profile.html" class="nav-link nav-icon${currentPage === 'profile.html' ? ' active' : ''}" title="Profile">👤</a>
<button id="nuclearResetBtn" class="nuclear-reset-btn" title="Reset All Data">💣</button>
<button id="themeToggleBtn" class="theme-toggle-btn" title="Toggle Dark Mode">
<span id="themeIcon">🌙</span>
</button>
</div>`;
navbarContainer.innerHTML = emergencyNavbarHtml;
console.log('[NAVBAR-INIT] Emergency navbar content injected');
// Also set up event handlers for the emergency navbar
setupThemeToggle();
setupNuclearReset();
} else {
console.error('[NAVBAR-INIT] Critical error: Navbar container not found even in emergency mode');
// Last-ditch effort: create the navbar container if it doesn't exist
const newNavbarContainer = document.createElement('div');
newNavbarContainer.id = 'navbarContainer';
newNavbarContainer.className = 'top-nav';
if (document.body) {
// Insert at the beginning of the document
document.body.insertBefore(newNavbarContainer, document.body.firstChild);
console.log('[NAVBAR-INIT] Created navbar container as last resort');
// Now initialize it
initializeNavbar();
}
}
} catch (error) {
console.error('[NAVBAR-INIT] Error during emergency navbar injection:', error);
}
}
// Run a final diagnostic check
checkNavbarStatus();
}, 2000);
/**
* Sets up the nuclear reset button
*/
@ -234,5 +318,6 @@ export {
initializeNavbar,
toggleTheme,
updateThemeDisplay,
resetAllData
resetAllData,
checkNavbarStatus
};

@ -55,6 +55,38 @@ export function initializeNavbar(): void {
}
}
// Export a diagnostic function we can call from browser console
export function diagnoseNavbar(): void {
console.log('%c[NAVBAR DIAGNOSIS]', 'background: #ff0000; color: white; padding: 5px; font-size: 14px;');
console.log('Navbar initialized flag:', navbarInitialized);
const navbarContainer = document.getElementById('navbarContainer');
console.log('Navbar container exists:', !!navbarContainer);
if (navbarContainer) {
console.log('Navbar container HTML:', navbarContainer.innerHTML);
console.log('Navbar container visibility:', window.getComputedStyle(navbarContainer).display);
console.log('Navbar container has children:', navbarContainer.children.length > 0);
const navLeft = navbarContainer.querySelector('.nav-left');
const navRight = navbarContainer.querySelector('.nav-right');
console.log('Nav left exists:', !!navLeft);
console.log('Nav right exists:', !!navRight);
}
console.log('Document ready state:', document.readyState);
console.log('Attempting to force navbar initialization...');
// Try to re-initialize the navbar
navbarInitialized = false;
initializeNavbar();
}
// Make it available globally for browser console debugging
if (typeof window !== 'undefined') {
(window as any).diagnoseNavbar = diagnoseNavbar;
}
/**
* Updates the existing navbar in the HTML files
*/

227
client/src/server-ui.ts Normal file

@ -0,0 +1,227 @@
/**
* server-ui.ts
* Entry point for the 1120 server UI
*/
import './navbar-diagnostics'; // Import diagnostics first
import './navbar'; // Import navbar component
import './navbar-init'; // Import navbar initialization
import * as nostrTools from 'nostr-tools';
import { initServerUI } from './components/ServerUI';
import './debug-events'; // Import debug script
// DOM content loaded event listener
document.addEventListener('DOMContentLoaded', () => {
console.log('Initializing 1120 server UI...');
// Initialize the server UI
const serverUI = initServerUI();
// Set up tab navigation
setupTabNavigation();
// Set up QR code scanner
setupQRCodeScanner(serverUI.getEventManager());
// Set up raw event input handling
setupRawEventInput(serverUI.getEventManager());
// Set up server identity management
setupServerIdentityManager();
console.log('1120 server UI initialized');
});
/**
* Set up tab navigation
*/
function setupTabNavigation(): void {
const tabButtons = document.querySelectorAll('.tab-button');
const tabSections = document.querySelectorAll('[id$="-section"]');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
// Get the target tab section
const targetId = button.getAttribute('data-tab');
// Remove active class from all buttons and sections
tabButtons.forEach(btn => btn.classList.remove('active'));
tabSections.forEach(section => section.classList.remove('active'));
// Add active class to clicked button and corresponding section
button.classList.add('active');
if (targetId) {
const targetSection = document.getElementById(targetId);
if (targetSection) {
targetSection.classList.add('active');
}
}
});
});
}
// These are placeholder services that would need to be implemented
// Define QRScannerService (placeholder)
function setupQRCodeScanner(eventManager: any): void {
console.log('Setting up QR code scanner...');
const startScanBtn = document.getElementById('startScanBtn');
const stopScanBtn = document.getElementById('stopScanBtn');
const qrStatus = document.getElementById('qrStatus');
if (!startScanBtn || !stopScanBtn || !qrStatus) {
console.error('QR scanner UI elements not found');
return;
}
startScanBtn.addEventListener('click', () => {
console.log('Starting QR scanner...');
// This would be implemented in a real QRScannerService
qrStatus.textContent = 'Camera active. Point at a QR code containing a Nostr event.';
startScanBtn.setAttribute('disabled', 'true');
stopScanBtn.removeAttribute('disabled');
});
stopScanBtn.addEventListener('click', () => {
console.log('Stopping QR scanner...');
// This would be implemented in a real QRScannerService
qrStatus.textContent = 'Camera inactive. Click Start Camera to begin scanning.';
stopScanBtn.setAttribute('disabled', 'true');
startScanBtn.removeAttribute('disabled');
});
};
// Define RawEventInputService (placeholder)
function setupRawEventInput(eventManager: any): void {
console.log('Setting up raw event input...');
const parseRawEventBtn = document.getElementById('parseRawEventBtn');
const rawEventInput = document.getElementById('rawEventInput') as HTMLTextAreaElement;
const rawInputStatus = document.getElementById('rawInputStatus');
if (!parseRawEventBtn || !rawEventInput || !rawInputStatus) {
console.error('Raw event input UI elements not found');
return;
}
parseRawEventBtn.addEventListener('click', () => {
console.log('Parsing raw event...');
const rawInput = rawEventInput.value.trim();
if (!rawInput) {
rawInputStatus.textContent = 'Error: Empty input';
rawInputStatus.className = 'status-message error';
return;
}
try {
const eventData = JSON.parse(rawInput);
// In a real implementation, this would validate and process the event
console.log('Parsed event:', eventData);
if (eventData.kind === 21120) {
// Add the event to the EventManager
eventManager.addEvent(eventData);
rawInputStatus.textContent = 'Event parsed and added successfully!';
rawInputStatus.className = 'status-message success';
} else {
rawInputStatus.textContent = 'Error: Not a kind 21120 event';
rawInputStatus.className = 'status-message error';
}
} catch (error) {
console.error('Error parsing raw event:', error);
rawInputStatus.textContent = `Error: ${error instanceof Error ? error.message : String(error)}`;
rawInputStatus.className = 'status-message error';
}
});
};
// Define ServerIdentityManager (placeholder)
function setupServerIdentityManager(): void {
console.log('Setting up server identity manager...');
const serverNpubInput = document.getElementById('serverNpub') as HTMLInputElement;
const toggleFormatBtn = document.getElementById('toggleFormatBtn');
const copyServerNpubBtn = document.getElementById('copyServerNpubBtn');
const formatIndicator = document.getElementById('formatIndicator');
const formatBtnText = document.getElementById('formatBtnText');
if (!serverNpubInput || !toggleFormatBtn || !copyServerNpubBtn || !formatIndicator || !formatBtnText) {
console.error('Server identity UI elements not found');
return;
}
// Load the server identity from localStorage
const serverNsec = localStorage.getItem('serverNsec');
let serverPubkeyHex = '';
let serverPubkeyNpub = '';
let isShowingNpub = true;
if (serverNsec) {
try {
// Extract the server pubkey from the saved nsec
const decoded = nostrTools.nip19.decode(serverNsec);
if (decoded.type === 'nsec') {
// Use 'as any' to bypass type checking due to library type definition issues
const privateKeyBytes = decoded.data as any;
serverPubkeyHex = nostrTools.getPublicKey(privateKeyBytes);
serverPubkeyNpub = nostrTools.nip19.npubEncode(serverPubkeyHex);
console.log(`Server key loaded successfully! Public key: ${serverPubkeyHex.substring(0, 8)}...`);
} else {
throw new Error('Invalid nsec format');
}
// Display the server pubkey in npub format
serverNpubInput.value = serverPubkeyNpub;
formatIndicator.textContent = 'Currently showing: NPUB format';
console.log(`Server key loaded successfully! Public key: ${serverPubkeyHex.substring(0, 8)}...`);
} catch (error) {
console.error('Error processing server private key:', error);
serverNpubInput.value = 'Error processing server key';
}
} else {
serverNpubInput.value = 'No server identity configured';
toggleFormatBtn.setAttribute('disabled', 'true');
copyServerNpubBtn.setAttribute('disabled', 'true');
}
// Toggle format button
toggleFormatBtn.addEventListener('click', () => {
if (!serverNsec || !serverPubkeyHex || !serverPubkeyNpub) return;
isShowingNpub = !isShowingNpub;
if (isShowingNpub) {
serverNpubInput.value = serverPubkeyNpub;
formatIndicator.textContent = 'Currently showing: NPUB format';
formatBtnText.textContent = 'HEX';
} else {
serverNpubInput.value = serverPubkeyHex;
formatIndicator.textContent = 'Currently showing: HEX format';
formatBtnText.textContent = 'NPUB';
}
});
// Copy button
copyServerNpubBtn.addEventListener('click', () => {
if (!serverNsec) return;
navigator.clipboard.writeText(serverNpubInput.value)
.then(() => {
const copyBtnText = document.getElementById('copyBtnText');
if (copyBtnText) {
copyBtnText.textContent = 'Copied!';
setTimeout(() => {
copyBtnText.textContent = 'Copy';
}, 2000);
}
})
.catch(err => {
console.error('Could not copy text: ', err);
});
});
};

@ -0,0 +1,315 @@
# Integrating EventManager with EventDetailsRenderer
This document outlines how to refactor the EventDetailsRenderer to use the new EventManager service, focusing it on UI rendering while delegating data management to the centralized EventManager.
## Overview
The current EventDetailsRenderer class handles both rendering event details and managing event data. By integrating with the EventManager service, we can:
1. Eliminate the need for direct access to event data maps
2. Remove event relationship tracking from the renderer
3. Focus the renderer solely on presenting event details
4. React to event selection changes through the Observer pattern
## Implementation Steps
### 1. Update the Constructor to Accept EventManager
```typescript
import { EventManager, EventChangeType, ManagedEvent } from './EventManager';
import { NostrEvent } from '../relay';
import { HttpFormatter } from './HttpFormatter';
export class EventDetailsRenderer {
private eventDetails: HTMLElement | null = null;
private eventManager: EventManager;
private unregisterListener: (() => void) | null = null;
constructor(eventManager: EventManager) {
this.eventManager = eventManager;
}
// Rest of the class...
}
```
### 2. Register for Event Changes During Initialization
```typescript
public initialize(): void {
this.eventDetails = document.getElementById('eventDetails');
if (!this.eventDetails) {
console.error('EventDetails element not found');
return;
}
// Register as a listener for event changes
this.unregisterListener = this.eventManager.registerListener((eventId, changeType) => {
if (changeType === EventChangeType.Selected) {
this.renderEventDetails();
}
else if (changeType === EventChangeType.Updated) {
// If the updated event is the currently selected event, re-render
const selectedEvent = this.eventManager.getSelectedEvent();
if (selectedEvent && selectedEvent.id === eventId) {
this.renderEventDetails();
}
}
});
// Initial render if there's already a selected event
const selectedEvent = this.eventManager.getSelectedEvent();
if (selectedEvent) {
this.renderEventDetails();
} else {
this.showEmptyState();
}
}
```
### 3. Create Empty State Method
```typescript
private showEmptyState(): void {
if (!this.eventDetails) return;
this.eventDetails.innerHTML = `
<div class="empty-state">
Select an event to view details
</div>
`;
}
```
### 4. Reimplement the Details Rendering Method
```typescript
private renderEventDetails(): void {
if (!this.eventDetails) return;
// Get the selected event from the EventManager
const managedEvent = this.eventManager.getSelectedEvent();
if (!managedEvent) {
this.showEmptyState();
return;
}
const event = managedEvent.event;
// Determine if it's a request or response
const isRequest = event.kind === 21120;
const isResponse = event.kind === 21121;
// Determine the content to display
let httpContent = managedEvent.decrypted ?
managedEvent.decryptedContent || event.content :
event.content;
// Find related events
let relatedEventsHtml = '';
// Get related events from the EventManager
const relatedEvents = this.eventManager.getRelatedEvents(managedEvent.id);
if (relatedEvents.length > 0) {
relatedEventsHtml = `
<div class="related-events">
<h3>Related ${isRequest ? 'Responses' : 'Request'}</h3>
<ul class="related-events-list">
${relatedEvents.map(relatedEvent => {
const relatedType = relatedEvent.event.kind === 21120 ? 'Request' : 'Response';
return `
<li>
<a href="#" class="related-event-link" data-id="${relatedEvent.id}">
${relatedType} (${relatedEvent.id.substring(0, 8)}...)
</a>
</li>
`;
}).join('')}
</ul>
</div>
`;
}
// Format based on event type
const eventTime = new Date(event.created_at * 1000).toLocaleString();
// Action buttons for request events
const execRequestBtn = isRequest ? `<button class="execute-http-request-btn">Execute HTTP Request</button>` : '';
// Create response button for requests that don't have responses yet
const createResponseBtn = (isRequest && relatedEvents.length === 0) ?
`<button class="create-response-btn">Create NIP-21121 Response</button>` : '';
// Render the event details
this.eventDetails.innerHTML = `
<div class="event-details-header">
<h2>Event Details</h2>
<span class="event-id-display">ID: ${event.id?.substring(0, 8) || 'Unknown'}...</span>
</div>
<div class="event-type-info">
<span class="event-kind">Kind: ${event.kind}</span>
<span class="event-type">${isRequest ? 'HTTP Request' : (isResponse ? 'HTTP Response' : 'Unknown')}</span>
<span class="event-time">Time: ${eventTime}</span>
</div>
<div class="event-metadata">
<div class="pubkey">Pubkey: ${event.pubkey}</div>
<div class="tags">
<h3>Tags</h3>
<pre>${JSON.stringify(event.tags, null, 2)}</pre>
</div>
</div>
${relatedEventsHtml}
<div class="http-actions">
${execRequestBtn}
${createResponseBtn}
</div>
<div class="http-content-tabs">
<div class="tab-buttons">
<button class="tab-btn" data-tab="raw-http">Raw HTTP</button>
<button class="tab-btn active" data-tab="formatted-http">Formatted HTTP</button>
</div>
<div class="tab-content" id="raw-http">
${isRequest ?
`<div class="http-content-header">
<button class="execute-http-request-btn">Execute HTTP Request</button>
</div>` :
''
}
<pre class="http-content">${httpContent}</pre>
${!managedEvent.decrypted ?
'<div class="decryption-status error" id="decryption-status-' + managedEvent.id + '">Decryption failed - NIP-44 implementation may be incompatible or missing. Check console for errors.</div>' :
'<div class="decryption-status success" id="decryption-status-' + managedEvent.id + '">Decryption successful ✓</div>'}
</div>
<div class="tab-content active" id="formatted-http">
${isRequest ?
`<div class="http-content-header">
<button class="execute-http-request-btn">Execute HTTP Request</button>
</div>` :
''
}
<div class="http-formatted-container">
${HttpFormatter.formatHttpContent(httpContent, isRequest, isResponse)}
</div>
${!managedEvent.decrypted ?
'<div class="decryption-status error" id="decryption-status-' + managedEvent.id + '">Decryption failed - NIP-44 implementation may be incompatible or missing. Check console for errors.</div>' :
'<div class="decryption-status success" id="decryption-status-' + managedEvent.id + '">Decryption successful ✓</div>'}
</div>
</div>
`;
// Set up tab buttons
this.setupTabButtons();
// Set up related event links
this.setupRelatedEventLinks();
}
```
### 5. Add Helper Methods for UI Interaction
```typescript
private setupTabButtons(): void {
if (!this.eventDetails) return;
const tabButtons = this.eventDetails.querySelectorAll('.tab-btn');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
// Remove active class from all buttons and content
tabButtons.forEach(btn => btn.classList.remove('active'));
const tabContents = this.eventDetails!.querySelectorAll('.tab-content');
tabContents.forEach(content => content.classList.remove('active'));
// Add active class to clicked button
button.classList.add('active');
// Show corresponding content
const tabId = (button as HTMLElement).dataset.tab || '';
const tabContent = this.eventDetails!.querySelector(`#${tabId}`);
if (tabContent) {
tabContent.classList.add('active');
}
});
});
}
private setupRelatedEventLinks(): void {
if (!this.eventDetails) return;
const relatedLinks = this.eventDetails.querySelectorAll('.related-event-link');
relatedLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const eventId = (link as HTMLElement).dataset.id;
if (eventId) {
// Use the EventManager to select the related event
this.eventManager.selectEvent(eventId);
}
});
});
}
```
### 6. Add Cleanup Method
```typescript
public dispose(): void {
// Clean up event listener when component is disposed
if (this.unregisterListener) {
this.unregisterListener();
this.unregisterListener = null;
}
}
```
## Usage Example
```typescript
// Initialize the services
const eventManager = new EventManager();
const eventDetailsRenderer = new EventDetailsRenderer(eventManager);
// Initialize the renderer
eventDetailsRenderer.initialize();
// When a user selects an event (e.g., in EventListRenderer),
// EventManager will notify EventDetailsRenderer which will update automatically
// Clean up when done
eventDetailsRenderer.dispose();
```
## Benefits
1. **Separation of Concerns**: The renderer focuses only on UI rendering, not data management
2. **Reactive Updates**: The UI automatically updates when event selection changes
3. **Centralized Relationships**: Event relationships are managed by the EventManager
4. **Simplified Code**: No need to pass around event maps and relationship maps
5. **Better Testability**: The renderer can be tested with a mock EventManager
## Migration Strategy
1. Create the EventManager service
2. Refactor the EventDetailsRenderer to use the EventManager
3. Update component initialization to inject the EventManager
4. Test the integration to ensure proper rendering
5. Gradually update other components that interact with events
## Integration with http-response-viewer.ts
The http-response-viewer.ts module would also need to be updated to use the EventManager for:
1. Finding the selected event when executing HTTP requests
2. Creating and tracking relationships between request and response events
3. Sharing event data with other components
This would further simplify the current architecture by centralizing all event data management in one service.

@ -14,6 +14,8 @@ export class EventDetailsRenderer {
private eventDetails: HTMLElement | null = null;
private receivedEvents: Map<string, ReceivedEvent>;
private relatedEvents: Map<string, string[]>;
private loadingTasks: Map<string, Promise<void>> = new Map();
private currentEventId: string | null = null;
/**
* Constructor
@ -36,11 +38,17 @@ export class EventDetailsRenderer {
* Show event details for a given event ID
* @param eventId The event ID to display details for
*/
/**
* Show event details for a given event ID with progressive loading
* @param eventId The event ID to display details for
*/
public showEventDetails(eventId: string): void {
if (!this.eventDetails || !eventId) {
return;
}
this.currentEventId = eventId;
// Get the received event from our map
const receivedEvent = this.receivedEvents.get(eventId);
if (!receivedEvent || !receivedEvent.event) {
@ -56,50 +64,27 @@ export class EventDetailsRenderer {
const isResponse = event.kind === 21121;
const is21121Event = event.kind === 21121;
// Determine the content to display
let httpContent = receivedEvent.decrypted ?
receivedEvent.decryptedContent || event.content :
event.content;
// Find related events (responses for requests, or request for responses)
let relatedEventsHtml = '';
let relatedIds: string[] = [];
if (event.id) {
// Get related events from the map
relatedIds = this.relatedEvents.get(event.id) || [];
if (relatedIds.length > 0) {
relatedEventsHtml = `
<div class="related-events">
<h3>Related ${isRequest ? 'Responses' : 'Request'}</h3>
<ul class="related-events-list">
${relatedIds.map(id => {
const relatedEvent = this.receivedEvents.get(id)?.event;
const relatedType = relatedEvent?.kind === 21120 ? 'Request' : 'Response';
return `
<li>
<a href="#" class="related-event-link" data-id="${id}">
${relatedType} (${id.substring(0, 8)}...)
</a>
</li>
`;
}).join('')}
</ul>
</div>
`;
}
}
// Format based on event type
// Format basic event type info - fast rendering
const eventTime = new Date(event.created_at * 1000).toLocaleString();
// Handle for "Execute HTTP Request" button for request events
const execRequestBtn = isRequest ? `<button class="execute-http-request-btn">Execute HTTP Request</button>` : '';
// Initial render with just the metadata and placeholders
this.renderInitialDetails(eventId, event, isRequest, isResponse, eventTime);
// Create response button for requests that don't have responses yet
const createResponseBtn = (isRequest && relatedIds.length === 0) ?
`<button class="create-response-btn">Create NIP-21121 Response</button>` : '';
// Start the async loading
this.loadEventDetailsProgressively(eventId, receivedEvent, event, isRequest, isResponse, is21121Event);
}
/**
* Render initial event details with just metadata
*/
private renderInitialDetails(
eventId: string,
event: NostrEvent,
isRequest: boolean,
isResponse: boolean,
eventTime: string
): void {
if (!this.eventDetails) return;
this.eventDetails.innerHTML = `
<div class="event-details-header">
@ -121,11 +106,15 @@ export class EventDetailsRenderer {
</div>
</div>
${relatedEventsHtml}
<div id="related-events-container">
<div class="loading-indicator">
<div class="spinner"></div>
<span>Loading related events...</span>
</div>
</div>
<div class="http-actions">
${execRequestBtn}
${createResponseBtn}
<div class="http-actions" id="http-actions-${eventId}">
<!-- Action buttons will be added here -->
</div>
<div class="http-content-tabs">
@ -135,35 +124,41 @@ export class EventDetailsRenderer {
</div>
<div class="tab-content" id="raw-http">
${isRequest ?
`<div class="http-content-header">
<button class="execute-http-request-btn">Execute HTTP Request</button>
</div>` :
''
}
<pre class="http-content">${httpContent}</pre>
${!receivedEvent.decrypted ?
'<div class="decryption-status error" id="decryption-status-' + eventId + '">Decryption failed - NIP-44 implementation may be incompatible or missing. Check console for errors.</div>' :
'<div class="decryption-status success" id="decryption-status-' + eventId + '">Decryption successful ✓</div>'}
<div class="loading-container" id="raw-loading-${eventId}">
<div class="spinner"></div>
<span>Loading content...</span>
</div>
<pre class="http-content" id="raw-content-${eventId}" style="display: none;"></pre>
<div class="decryption-status" id="decryption-status-raw-${eventId}">
<div class="spinner"></div>
<span>Processing encryption...</span>
</div>
</div>
<div class="tab-content active" id="formatted-http">
${isRequest ?
`<div class="http-content-header">
<button class="execute-http-request-btn">Execute HTTP Request</button>
</div>` :
''
}
<div class="http-formatted-container">
${HttpFormatter.formatHttpContent(httpContent, isRequest, isResponse || is21121Event)}
<div class="loading-container" id="formatted-loading-${eventId}">
<div class="spinner"></div>
<span>Formatting content...</span>
</div>
<div class="http-formatted-container" id="formatted-content-${eventId}" style="display: none;">
</div>
<div class="decryption-status" id="decryption-status-formatted-${eventId}">
<div class="spinner"></div>
<span>Processing encryption...</span>
</div>
${!receivedEvent.decrypted ?
'<div class="decryption-status error" id="decryption-status-' + eventId + '">Decryption failed - NIP-44 implementation may be incompatible or missing. Check console for errors.</div>' :
'<div class="decryption-status success" id="decryption-status-' + eventId + '">Decryption successful ✓</div>'}
</div>
</div>
`;
// Set up tab buttons
this.setupTabButtons();
}
/**
* Set up tab button click handlers
*/
private setupTabButtons(): void {
if (!this.eventDetails) return;
const tabButtons = this.eventDetails.querySelectorAll('.tab-btn');
tabButtons.forEach(button => {
button.addEventListener('click', (e) => {
@ -186,6 +181,212 @@ export class EventDetailsRenderer {
});
}
/**
* Load and render event details progressively
*/
private async loadEventDetailsProgressively(
eventId: string,
receivedEvent: ReceivedEvent,
event: NostrEvent,
isRequest: boolean,
isResponse: boolean,
is21121Event: boolean
): Promise<void> {
try {
// Skip if the user has navigated to another event
if (this.currentEventId !== eventId) return;
// 1. Load related events first (fast operation)
await this.loadRelatedEvents(eventId, event, isRequest);
// Skip if the user has navigated to another event
if (this.currentEventId !== eventId) return;
// 2. Set up action buttons
this.setupActionButtons(eventId, event, isRequest);
// Skip if the user has navigated to another event
if (this.currentEventId !== eventId) return;
// 3. Get the HTTP content
const httpContent = receivedEvent.decrypted ?
receivedEvent.decryptedContent || event.content :
event.content;
// 4. Update raw content
this.updateRawContent(eventId, httpContent, receivedEvent.decrypted);
// Skip if the user has navigated to another event
if (this.currentEventId !== eventId) return;
// 5. Update formatted content (most expensive operation)
this.updateFormattedContent(eventId, httpContent, isRequest, isResponse || is21121Event, receivedEvent.decrypted);
} catch (error) {
console.error("Error loading event details:", error);
this.showErrorState(eventId, String(error));
}
}
/**
* Load and display related events
*/
private async loadRelatedEvents(
eventId: string,
event: NostrEvent,
isRequest: boolean
): Promise<void> {
const relatedEventsContainer = document.getElementById('related-events-container');
if (!relatedEventsContainer) return;
// Get related events
const relatedIds = event.id ? (this.relatedEvents.get(event.id) || []) : [];
// Update UI
if (relatedIds.length > 0) {
const relatedEventsHtml = `
<div class="related-events">
<h3>Related ${isRequest ? 'Responses' : 'Request'}</h3>
<ul class="related-events-list">
${relatedIds.map(id => {
const relatedEvent = this.receivedEvents.get(id)?.event;
const relatedType = relatedEvent?.kind === 21120 ? 'Request' : 'Response';
return `
<li>
<a href="#" class="related-event-link" data-id="${id}">
${relatedType} (${id.substring(0, 8)}...)
</a>
</li>
`;
}).join('')}
</ul>
</div>
`;
relatedEventsContainer.innerHTML = relatedEventsHtml;
// Set up click handlers for related event links
const links = relatedEventsContainer.querySelectorAll('.related-event-link');
links.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const id = (link as HTMLElement).dataset.id;
if (id) {
this.showEventDetails(id);
}
});
});
} else {
// No related events
relatedEventsContainer.innerHTML = '';
}
}
/**
* Set up action buttons for the event
*/
private setupActionButtons(
eventId: string,
event: NostrEvent,
isRequest: boolean
): void {
const actionsContainer = document.getElementById(`http-actions-${eventId}`);
if (!actionsContainer) return;
// Get related events count
const relatedIds = event.id ? (this.relatedEvents.get(event.id) || []) : [];
// Create buttons
const execRequestBtn = isRequest ?
`<button class="execute-http-request-btn">Execute HTTP Request</button>` : '';
const createResponseBtn = (isRequest && relatedIds.length === 0) ?
`<button class="create-response-btn">Create NIP-21121 Response</button>` : '';
// Update UI
actionsContainer.innerHTML = `
${execRequestBtn}
${createResponseBtn}
`;
}
/**
* Update raw content display
*/
private updateRawContent(
eventId: string,
content: string,
decrypted: boolean
): void {
// Get elements
const loadingElement = document.getElementById(`raw-loading-${eventId}`);
const contentElement = document.getElementById(`raw-content-${eventId}`);
const statusElement = document.getElementById(`decryption-status-raw-${eventId}`);
if (!loadingElement || !contentElement || !statusElement) return;
// Update content
contentElement.textContent = content;
// Update encryption status
if (decrypted) {
statusElement.innerHTML = '<div class="decryption-status success">Decryption successful ✓</div>';
} else {
statusElement.innerHTML = '<div class="decryption-status error">Decryption failed or not attempted</div>';
}
// Hide loading, show content
loadingElement.style.display = 'none';
contentElement.style.display = 'block';
}
/**
* Update formatted content display
*/
private updateFormattedContent(
eventId: string,
content: string,
isRequest: boolean,
isResponse: boolean,
decrypted: boolean
): void {
// Get elements
const loadingElement = document.getElementById(`formatted-loading-${eventId}`);
const contentElement = document.getElementById(`formatted-content-${eventId}`);
const statusElement = document.getElementById(`decryption-status-formatted-${eventId}`);
if (!loadingElement || !contentElement || !statusElement) return;
// Format and update content
contentElement.innerHTML = HttpFormatter.formatHttpContent(content, isRequest, isResponse);
// Update encryption status
if (decrypted) {
statusElement.innerHTML = '<div class="decryption-status success">Decryption successful ✓</div>';
} else {
statusElement.innerHTML = '<div class="decryption-status error">Decryption failed or not attempted</div>';
}
// Hide loading, show content
loadingElement.style.display = 'none';
contentElement.style.display = 'block';
}
/**
* Show error state when loading fails
*/
private showErrorState(eventId: string, errorMessage: string): void {
// Update content containers with error message
const rawLoading = document.getElementById(`raw-loading-${eventId}`);
const formattedLoading = document.getElementById(`formatted-loading-${eventId}`);
if (rawLoading) {
rawLoading.innerHTML = `<div class="error-message">Error: ${errorMessage}</div>`;
}
if (formattedLoading) {
formattedLoading.innerHTML = `<div class="error-message">Error: ${errorMessage}</div>`;
}
}
/**
* Get the event details element
* @returns The event details element or null

@ -0,0 +1,300 @@
/**
* EventDetailsRenderer.ts
* Component for rendering detailed event information
* Refactored to use EventManager for centralized event data management
*/
import { NostrEvent } from '../relay';
import { EventManager, EventChangeType, ManagedEvent } from './EventManager';
import { HttpFormatter } from './HttpFormatter';
/**
* Class for rendering event details in the UI
*/
export class EventDetailsRenderer {
private eventDetails: HTMLElement | null = null;
private eventManager: EventManager;
private unregisterListener: (() => void) | null = null;
/**
* Constructor
* @param eventManager The EventManager instance for centralized event management
*/
constructor(eventManager: EventManager) {
this.eventManager = eventManager;
}
/**
* Initialize the event details element
*/
public initialize(): void {
this.eventDetails = document.getElementById('eventDetails');
if (!this.eventDetails) {
console.error('EventDetails element not found');
return;
}
// Register for event changes
this.unregisterListener = this.eventManager.registerListener((eventId, changeType) => {
if (changeType === EventChangeType.Selected) {
this.renderEventDetails();
}
else if (changeType === EventChangeType.Updated) {
// If the updated event is the currently selected event, re-render
const selectedEvent = this.eventManager.getSelectedEvent();
if (selectedEvent && selectedEvent.id === eventId) {
this.renderEventDetails();
}
}
else if (changeType === EventChangeType.Removed) {
// If the removed event is the currently selected event, show empty state
const selectedEvent = this.eventManager.getSelectedEvent();
if (selectedEvent && selectedEvent.id === eventId) {
this.showEmptyState();
}
}
});
// Initial render if there's already a selected event
const selectedEvent = this.eventManager.getSelectedEvent();
if (selectedEvent) {
this.renderEventDetails();
} else {
this.showEmptyState();
}
}
/**
* Show empty state when no event is selected
*/
private showEmptyState(): void {
if (!this.eventDetails) return;
this.eventDetails.innerHTML = `
<div class="empty-state">
Select an event to view details
</div>
`;
}
/**
* Render the details of the currently selected event
*/
private renderEventDetails(): void {
if (!this.eventDetails) return;
// Get the selected event from the EventManager
const managedEvent = this.eventManager.getSelectedEvent();
if (!managedEvent) {
this.showEmptyState();
return;
}
const event = managedEvent.event;
// Determine if it's a request or response
const isRequest = event.kind === 21120;
const isResponse = event.kind === 21121;
const is21121Event = event.kind === 21121;
// Determine the content to display
let httpContent = managedEvent.decrypted ?
managedEvent.decryptedContent || event.content :
event.content;
// Find related events
let relatedEventsHtml = '';
// Get related events from the EventManager
const relatedEvents = this.eventManager.getRelatedEvents(managedEvent.id);
if (relatedEvents.length > 0) {
relatedEventsHtml = `
<div class="related-events">
<h3>Related ${isRequest ? 'Responses' : 'Request'}</h3>
<ul class="related-events-list">
${relatedEvents.map(relatedEvent => {
const relatedType = relatedEvent.event.kind === 21120 ? 'Request' : 'Response';
return `
<li>
<a href="#" class="related-event-link" data-id="${relatedEvent.id}">
${relatedType} (${relatedEvent.id.substring(0, 8)}...)
</a>
</li>
`;
}).join('')}
</ul>
</div>
`;
}
// Format based on event type
const eventTime = new Date(event.created_at * 1000).toLocaleString();
// Handle for "Execute HTTP Request" button for request events
const execRequestBtn = isRequest ? `<button class="execute-http-request-btn">Execute HTTP Request</button>` : '';
// Create response button for requests that don't have responses yet
const createResponseBtn = (isRequest && relatedEvents.length === 0) ?
`<button class="create-response-btn">Create NIP-21121 Response</button>` : '';
this.eventDetails.innerHTML = `
<div class="event-details-header">
<h2>Event Details</h2>
<span class="event-id-display">ID: ${event.id?.substring(0, 8) || 'Unknown'}...</span>
</div>
<div class="event-type-info">
<span class="event-kind">Kind: ${event.kind}</span>
<span class="event-type">${isRequest ? 'HTTP Request' : (isResponse ? 'HTTP Response' : 'Unknown')}</span>
<span class="event-time">Time: ${eventTime}</span>
</div>
<div class="event-metadata">
<div class="pubkey">Pubkey: ${event.pubkey}</div>
<div class="tags">
<h3>Tags</h3>
<pre>${JSON.stringify(event.tags, null, 2)}</pre>
</div>
</div>
${relatedEventsHtml}
<div class="http-actions">
${execRequestBtn}
${createResponseBtn}
</div>
<div class="http-content-tabs">
<div class="tab-buttons">
<button class="tab-btn" data-tab="raw-http">Raw HTTP</button>
<button class="tab-btn active" data-tab="formatted-http">Formatted HTTP</button>
</div>
<div class="tab-content" id="raw-http">
${isRequest ?
`<div class="http-content-header">
<button class="execute-http-request-btn">Execute HTTP Request</button>
</div>` :
''
}
<pre class="http-content">${httpContent}</pre>
${!managedEvent.decrypted ?
'<div class="decryption-status error" id="decryption-status-' + managedEvent.id + '">Decryption failed - NIP-44 implementation may be incompatible or missing. Check console for errors.</div>' :
'<div class="decryption-status success" id="decryption-status-' + managedEvent.id + '">Decryption successful ✓</div>'}
</div>
<div class="tab-content active" id="formatted-http">
${isRequest ?
`<div class="http-content-header">
<button class="execute-http-request-btn">Execute HTTP Request</button>
</div>` :
''
}
<div class="http-formatted-container">
${HttpFormatter.formatHttpContent(httpContent, isRequest, isResponse || is21121Event)}
</div>
${!managedEvent.decrypted ?
'<div class="decryption-status error" id="decryption-status-' + managedEvent.id + '">Decryption failed - NIP-44 implementation may be incompatible or missing. Check console for errors.</div>' :
'<div class="decryption-status success" id="decryption-status-' + managedEvent.id + '">Decryption successful ✓</div>'}
</div>
</div>
`;
// Set up tab buttons
this.setupTabButtons();
// Set up related event links
this.setupRelatedEventLinks();
}
/**
* Set up tab buttons for switching between raw and formatted views
*/
private setupTabButtons(): void {
if (!this.eventDetails) return;
const tabButtons = this.eventDetails.querySelectorAll('.tab-btn');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
// Remove active class from all buttons and content
tabButtons.forEach(btn => btn.classList.remove('active'));
const tabContents = this.eventDetails!.querySelectorAll('.tab-content');
tabContents.forEach(content => content.classList.remove('active'));
// Add active class to clicked button
button.classList.add('active');
// Show corresponding content
const tabId = (button as HTMLElement).dataset.tab || '';
const tabContent = this.eventDetails!.querySelector(`#${tabId}`);
if (tabContent) {
tabContent.classList.add('active');
}
});
});
}
/**
* Set up links to related events
*/
private setupRelatedEventLinks(): void {
if (!this.eventDetails) return;
const relatedLinks = this.eventDetails.querySelectorAll('.related-event-link');
relatedLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const eventId = (link as HTMLElement).dataset.id;
if (eventId) {
// Use the EventManager to select the related event
this.eventManager.selectEvent(eventId);
}
});
});
// Set up execute HTTP request buttons
const executeButtons = this.eventDetails.querySelectorAll('.execute-http-request-btn');
executeButtons.forEach(button => {
button.addEventListener('click', () => {
// Dispatch a custom event that can be handled by the http-response-viewer module
const event = new CustomEvent('execute-http-request', {
detail: { eventId: this.eventManager.getSelectedEvent()?.id }
});
document.dispatchEvent(event);
});
});
// Set up create response buttons
const createResponseButtons = this.eventDetails.querySelectorAll('.create-response-btn');
createResponseButtons.forEach(button => {
button.addEventListener('click', () => {
// Dispatch a custom event that can be handled by the http-response-viewer module
const event = new CustomEvent('create-21121-response', {
detail: { requestEventId: this.eventManager.getSelectedEvent()?.id }
});
document.dispatchEvent(event);
});
});
}
/**
* Clean up resources when this component is disposed
*/
public dispose(): void {
if (this.unregisterListener) {
this.unregisterListener();
this.unregisterListener = null;
}
}
/**
* Get the event details element
* @returns The event details element or null
*/
public getEventDetailsElement(): HTMLElement | null {
return this.eventDetails;
}
}

@ -0,0 +1,296 @@
# Integrating EventManager with EventListRenderer
This document outlines how to refactor the EventListRenderer to use the new EventManager service, decoupling UI rendering from event data management.
## Overview
The current EventListRenderer class directly manages event items in the UI, without a clear separation of data and presentation. By integrating with the EventManager service, we can:
1. Remove event data management from the renderer
2. Use the Observer pattern to react to event changes
3. Focus the renderer solely on UI presentation
4. Improve testability and maintainability
## Implementation Steps
### 1. Update the Constructor to Accept EventManager
```typescript
import { EventManager, EventChangeType, ManagedEvent } from './EventManager';
import { NostrEvent } from '../relay';
export class EventListRenderer {
private eventsList: HTMLElement | null = null;
private eventManager: EventManager;
private unregisterListener: (() => void) | null = null;
constructor(eventManager: EventManager) {
this.eventManager = eventManager;
}
// Rest of the class...
}
```
### 2. Register for Event Changes During Initialization
```typescript
public initialize(): void {
this.eventsList = document.getElementById('eventsList');
if (!this.eventsList) {
console.error('EventList element not found');
return;
}
// Register as a listener for event changes
this.unregisterListener = this.eventManager.registerListener((eventId, changeType) => {
switch (changeType) {
case EventChangeType.Added:
this.renderEventItem(eventId);
break;
case EventChangeType.Removed:
this.removeEventItem(eventId);
break;
case EventChangeType.Updated:
this.updateEventItem(eventId);
break;
case EventChangeType.Selected:
this.highlightSelectedEvent(eventId);
break;
}
});
// Initial render of existing events
this.renderAllEvents();
}
```
### 3. Add Methods to Render, Update, Remove, and Highlight Events
```typescript
private renderAllEvents(): void {
// Clear existing events
if (this.eventsList) {
this.eventsList.innerHTML = '';
// Get all events from the manager
const events = this.eventManager.getAllEvents();
// Sort by received time (newest first)
events.sort((a, b) => b.receivedAt - a.receivedAt);
// Render each event
for (const event of events) {
this.renderEventItem(event.id);
}
}
}
private renderEventItem(eventId: string): void {
if (!this.eventsList) return;
const managedEvent = this.eventManager.getEvent(eventId);
if (!managedEvent) return;
const event = managedEvent.event;
// Create a container for the event
const eventItem = document.createElement('div');
eventItem.className = 'event-item';
eventItem.dataset.id = eventId;
// Format event ID for display
const eventIdForDisplay = eventId.substring(0, 8);
// Set event type
let eventType = 'Unknown';
if (event.kind === 21120) {
eventType = 'HTTP Request';
} else if (event.kind === 21121) {
eventType = 'HTTP Response';
}
// Check if this message is addressed to our server
const isToServer = this.checkIfToServer(event);
// Format the event item HTML
eventItem.innerHTML = `
<div class="event-item-container">
<div class="event-avatar" data-pubkey="${event.pubkey}">
<div class="avatar-placeholder">👤</div>
</div>
<div class="event-content-wrapper">
<div class="event-header">
<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}... ${this.getRecipientDisplay(event)} ${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);
} else {
this.eventsList.appendChild(eventItem);
}
// Add click handler
eventItem.addEventListener('click', () => {
this.eventManager.selectEvent(eventId);
});
// If this event is already selected, highlight it
if (managedEvent.selected) {
eventItem.classList.add('selected');
}
}
private updateEventItem(eventId: string): void {
if (!this.eventsList) return;
// Find the existing event item
const eventItem = this.eventsList.querySelector(`.event-item[data-id="${eventId}"]`);
if (!eventItem) {
// If not found, render it
this.renderEventItem(eventId);
return;
}
// Otherwise, remove and re-render it for simplicity
// In a more optimized implementation, you might update just the changed parts
eventItem.remove();
this.renderEventItem(eventId);
}
private removeEventItem(eventId: string): void {
if (!this.eventsList) return;
const eventItem = this.eventsList.querySelector(`.event-item[data-id="${eventId}"]`);
if (eventItem) {
eventItem.remove();
}
}
private highlightSelectedEvent(eventId: string): void {
if (!this.eventsList) return;
// Remove highlight from all events
const allEventItems = this.eventsList.querySelectorAll('.event-item');
allEventItems.forEach(item => {
item.classList.remove('selected');
});
// Add highlight to the selected event
const selectedItem = this.eventsList.querySelector(`.event-item[data-id="${eventId}"]`);
if (selectedItem) {
selectedItem.classList.add('selected');
// Scroll into view if needed
selectedItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
```
### 4. Add Helper Methods for UI Formatting
```typescript
private checkIfToServer(event: NostrEvent): boolean {
// Check for p tag to identify recipient
const pTag = event.tags.find(tag => tag[0] === 'p');
if (!pTag || pTag.length <= 1) return false;
// Get the server pubkey from EventManager
const serverPubkey = this.eventManager.getServerPubkey();
if (!serverPubkey) return false;
// Check if the p tag matches our server pubkey
return (pTag[1] === serverPubkey);
}
private getRecipientDisplay(event: NostrEvent): string {
// Find recipient if any
const pTag = event.tags.find(tag => tag[0] === 'p');
if (!pTag || pTag.length <= 1) return '';
return `<div class="recipient">To: ${pTag[1].substring(0, 8)}...</div>`;
}
```
### 5. Update Filter Method to Use EventManager
```typescript
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';
}
});
}
```
### 6. Add Cleanup Method
```typescript
public dispose(): void {
// Clean up event listener when component is disposed
if (this.unregisterListener) {
this.unregisterListener();
this.unregisterListener = null;
}
}
```
## Usage Example
```typescript
// Initialize the services
const eventManager = new EventManager();
const eventListRenderer = new EventListRenderer(eventManager);
// Initialize the renderer
eventListRenderer.initialize();
// Now any events added to the EventManager will automatically appear in the UI
eventManager.addEvent(someEvent);
// When user selects an event in the UI, it will automatically update the EventManager's selected event
// This will trigger the EventDetailsRenderer to update its view as well
// Clean up when done
eventListRenderer.dispose();
```
## Benefits
1. **Separation of Concerns**: The renderer now only handles UI rendering, not event data management
2. **Reactive Updates**: The UI automatically updates when events change
3. **Centralized Data**: All event data is managed by the EventManager
4. **Improved Event Selection**: Selection state is managed by the EventManager
5. **Better Testability**: The renderer can be tested with a mock EventManager
## Migration Strategy
1. Create the EventManager service
2. Refactor the EventListRenderer to use the EventManager
3. Update component initialization to inject the EventManager
4. Test the integration to ensure proper rendering
5. Gradually update other components that interact with events

@ -0,0 +1,303 @@
/**
* EventListRenderer.ts
* Component for rendering events in the UI list
* Refactored to use EventManager for centralized event data management
*/
import * as nostrTools from 'nostr-tools';
import { NostrEvent } from '../relay';
import { EventManager, EventChangeType, ManagedEvent } from './EventManager';
/**
* Class for rendering events in the UI list
*/
export class EventListRenderer {
private eventsList: HTMLElement | null = null;
private eventManager: EventManager;
private unregisterListener: (() => void) | null = null;
/**
* Constructor
* @param eventManager The EventManager instance for centralized event management
*/
constructor(eventManager: EventManager) {
this.eventManager = eventManager;
}
/**
* Initialize the event list element
*/
public initialize(): void {
this.eventsList = document.getElementById('eventsList');
if (!this.eventsList) {
console.error('EventsList element not found');
return;
}
// Check if we have existing events that need rendering
this.renderExistingEvents();
// Register for event changes
this.unregisterListener = this.eventManager.registerListener((eventId, changeType) => {
switch (changeType) {
case EventChangeType.Added:
this.renderEventItem(eventId);
break;
case EventChangeType.Removed:
this.removeEventItem(eventId);
break;
case EventChangeType.Updated:
this.updateEventItem(eventId);
break;
case EventChangeType.Selected:
this.highlightSelectedEvent(eventId);
break;
}
});
}
/**
* Render existing events from the EventManager
*/
private renderExistingEvents(): void {
if (!this.eventsList) return;
// Clear any existing content
this.eventsList.innerHTML = '';
// Get all events from the EventManager
const events = this.eventManager.getAllEvents();
// If no events, show empty state
if (events.length === 0) {
this.eventsList.innerHTML = `
<div class="empty-state">
No events received yet. Use one of the methods above to receive events.
</div>
`;
return;
}
// Sort events by received time (newest first)
const sortedEvents = [...events].sort((a, b) => b.receivedAt - a.receivedAt);
// Render each event
for (const event of sortedEvents) {
this.renderEventItem(event.id);
}
}
/**
* Render a single event item
* @param eventId The ID of the event to render
* @returns The created HTML element or null if failed
*/
private renderEventItem(eventId: string): HTMLElement | null {
if (!this.eventsList) {
return null;
}
// Get the event from EventManager
const managedEvent = this.eventManager.getEvent(eventId);
if (!managedEvent) {
return null;
}
const event = managedEvent.event;
// Create a container for the event
const eventItem = document.createElement('div');
eventItem.className = 'event-item';
eventItem.dataset.id = eventId;
// Format event ID for display
const eventIdForDisplay = eventId.substring(0, 8);
// Set event type
let eventType = 'Unknown';
if (event.kind === 21120) {
eventType = 'HTTP Request';
} else if (event.kind === 21121) {
eventType = 'HTTP Response';
}
// Check if this message is addressed to our server
const isToServer = this.checkIfToServer(event);
// Set HTML content
eventItem.innerHTML = `
<div class="event-item-container">
<div class="event-avatar" data-pubkey="${event.pubkey}">
<div class="avatar-placeholder">👤</div>
</div>
<div class="event-content-wrapper">
<div class="event-header">
<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}... ${this.getRecipientDisplay(event)} ${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 click handler to select this event
eventItem.addEventListener('click', () => {
this.eventManager.selectEvent(eventId);
});
// If this is the currently selected event, highlight it
if (managedEvent.selected) {
eventItem.classList.add('selected');
}
// Add to list at the top for new events
if (this.eventsList.firstChild) {
this.eventsList.insertBefore(eventItem, this.eventsList.firstChild);
} else {
this.eventsList.appendChild(eventItem);
}
return eventItem;
}
/**
* Update an existing event item in the UI
* @param eventId The ID of the event to update
*/
private updateEventItem(eventId: string): void {
if (!this.eventsList) return;
// Find the existing event item
const existingItem = this.eventsList.querySelector(`.event-item[data-id="${eventId}"]`);
if (existingItem) {
// Remove it
existingItem.remove();
}
// Render a new item with updated data
this.renderEventItem(eventId);
}
/**
* Remove an event item from the UI
* @param eventId The ID of the event to remove
*/
private removeEventItem(eventId: string): void {
if (!this.eventsList) return;
const eventItem = this.eventsList.querySelector(`.event-item[data-id="${eventId}"]`);
if (eventItem) {
eventItem.remove();
}
// If no more events, show empty state
if (this.eventsList.children.length === 0) {
this.eventsList.innerHTML = `
<div class="empty-state">
No events received yet. Use one of the methods above to receive events.
</div>
`;
}
}
/**
* Highlight the selected event in the UI
* @param eventId The ID of the event to highlight
*/
private highlightSelectedEvent(eventId: string): void {
if (!this.eventsList) return;
// Remove selected class from all items
const allItems = this.eventsList.querySelectorAll('.event-item');
allItems.forEach(item => item.classList.remove('selected'));
// Add selected class to the target item
const selectedItem = this.eventsList.querySelector(`.event-item[data-id="${eventId}"]`);
if (selectedItem) {
selectedItem.classList.add('selected');
// Scroll into view if needed
selectedItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
/**
* Check if an event is addressed to our server
* @param event The event to check
* @returns True if the event is addressed to our server
*/
private checkIfToServer(event: NostrEvent): boolean {
// Get server pubkey from EventManager
const serverPubkey = this.eventManager.getServerPubkey();
if (!serverPubkey) return false;
// Check for p tag to identify recipient
const pTag = event.tags.find(tag => tag[0] === 'p');
if (!pTag || pTag.length <= 1) return false;
// Check if the p tag matches our server pubkey
return (pTag[1] === serverPubkey);
}
/**
* Get HTML for displaying the recipient
* @param event The event to get recipient for
* @returns HTML string with recipient display
*/
private getRecipientDisplay(event: NostrEvent): string {
// Find recipient if any
const pTag = event.tags.find(tag => tag[0] === 'p');
if (!pTag || pTag.length <= 1) return '';
return `<div class="recipient">To: ${pTag[1].substring(0, 8)}...</div>`;
}
/**
* 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';
}
});
}
/**
* Clean up resources when this component is disposed
*/
public dispose(): void {
if (this.unregisterListener) {
this.unregisterListener();
this.unregisterListener = null;
}
}
/**
* Get the events list element
* @returns The events list element or null
*/
public getEventsList(): HTMLElement | null {
return this.eventsList;
}
}

@ -0,0 +1,237 @@
# EventManager Service
## Overview
The EventManager service centralizes the management of Nostr 21120 (HTTP request) and 21121 (HTTP response) events in the application. It addresses several issues identified in the architecture analysis by:
1. Creating a single source of truth for event data
2. Decoupling event data management from UI rendering
3. Providing a consistent interface for event operations
4. Handling relationships between request and response events
5. Improving type safety with proper TypeScript interfaces
## Key Features
- Maintains events in a Map with event ID as the key
- Tracks relationships between request and response events
- Provides filtering capabilities for events
- Manages event selection state
- Implements the Observer pattern for notifying components of changes
## Integration Guide
### Basic Usage
```typescript
// Create an instance of EventManager, optionally with server pubkey
const eventManager = new EventManager(serverPubkey);
// Add events
const eventId = eventManager.addEvent(nostrEvent, isDecrypted, decryptedContent);
// Get an event by ID
const event = eventManager.getEvent(eventId);
// Get all events
const allEvents = eventManager.getAllEvents();
// Get filtered events
const httpRequests = eventManager.getFilteredEvents({
kind: EventKind.HttpRequest,
toServer: true
});
// Select an event
eventManager.selectEvent(eventId);
// Get related events
const relatedEvents = eventManager.getRelatedEvents(eventId);
```
### Observer Pattern
The EventManager implements the Observer pattern, allowing components to register listeners for event changes:
```typescript
// Register a listener for event changes
const unregister = eventManager.registerListener((eventId, changeType) => {
switch (changeType) {
case EventChangeType.Added:
console.log(`Event ${eventId} was added`);
break;
case EventChangeType.Updated:
console.log(`Event ${eventId} was updated`);
break;
case EventChangeType.Removed:
console.log(`Event ${eventId} was removed`);
break;
case EventChangeType.Selected:
console.log(`Event ${eventId} was selected`);
break;
}
});
// Later, unregister the listener when no longer needed
unregister();
```
## Integration with Existing Components
### EventListRenderer
The EventListRenderer should be refactored to:
1. Receive events from the EventManager rather than storing them itself
2. Register as a listener for event changes to update the UI
3. Delegate event selection to the EventManager
Example refactoring:
```typescript
// In EventListRenderer.ts
export class EventListRenderer {
private eventsList: HTMLElement | null = null;
private eventManager: EventManager;
private unregisterListener: (() => void) | null = null;
constructor(eventManager: EventManager) {
this.eventManager = eventManager;
}
public initialize(): void {
this.eventsList = document.getElementById('eventsList');
// Register as a listener for event changes
this.unregisterListener = this.eventManager.registerListener((eventId, changeType) => {
if (changeType === EventChangeType.Added) {
this.renderEventItem(eventId);
} else if (changeType === EventChangeType.Removed) {
this.removeEventItem(eventId);
} else if (changeType === EventChangeType.Selected) {
this.highlightSelectedEvent(eventId);
}
});
// Initial render of existing events
this.renderAllEvents();
}
private renderAllEvents(): void {
// Clear existing events
if (this.eventsList) {
this.eventsList.innerHTML = '';
// Get all events from the manager
const events = this.eventManager.getAllEvents();
// Sort by received time (newest first)
events.sort((a, b) => b.receivedAt - a.receivedAt);
// Render each event
for (const event of events) {
this.renderEventItem(event.id);
}
}
}
private renderEventItem(eventId: string): void {
// Implementation to render a single event item
// ...
}
// Other methods
// ...
public dispose(): void {
// Clean up listener when component is disposed
if (this.unregisterListener) {
this.unregisterListener();
this.unregisterListener = null;
}
}
}
```
### EventDetailsRenderer
Similarly, the EventDetailsRenderer should be refactored to:
1. Receive the selected event from the EventManager
2. Register as a listener for event selection changes
3. Leverage the EventManager's relationship tracking
Example refactoring:
```typescript
// In EventDetailsRenderer.ts
export class EventDetailsRenderer {
private eventDetails: HTMLElement | null = null;
private eventManager: EventManager;
private unregisterListener: (() => void) | null = null;
constructor(eventManager: EventManager) {
this.eventManager = eventManager;
}
public initialize(): void {
this.eventDetails = document.getElementById('eventDetails');
// Register as a listener for event changes
this.unregisterListener = this.eventManager.registerListener((eventId, changeType) => {
if (changeType === EventChangeType.Selected) {
this.renderEventDetails(eventId);
}
});
}
private renderEventDetails(eventId: string): void {
if (!this.eventDetails) {
return;
}
const managedEvent = this.eventManager.getEvent(eventId);
if (!managedEvent) {
this.showEmptyState();
return;
}
// Render event details
// ...
// Get related events from EventManager
const relatedEvents = this.eventManager.getRelatedEvents(eventId);
// Render related events
// ...
}
// Other methods
// ...
public dispose(): void {
// Clean up listener when component is disposed
if (this.unregisterListener) {
this.unregisterListener();
this.unregisterListener = null;
}
}
}
```
## Benefits
1. **Single Source of Truth**: All event data is managed in one place, eliminating inconsistencies.
2. **Decoupled Components**: UI components are decoupled from data management, focusing solely on rendering.
3. **Improved Type Safety**: Proper TypeScript interfaces for events and event operations.
4. **Centralized Filtering**: Event filtering logic is consolidated in one service.
5. **Observer Pattern**: Components are notified of changes through a consistent pattern.
6. **Testability**: The service can be easily mocked for testing UI components.
## Implementation Notes
When integrating this service:
1. Pass the EventManager instance to components that need event data
2. Refactor components to use the EventManager for event operations
3. Update UI components to register as listeners for event changes
4. Remove duplicate event storage from components

@ -0,0 +1,209 @@
/**
* Example initialization code for EventManager and related components
* This file demonstrates how to set up and integrate the EventManager service
* with UI components and event services.
*/
import { EventManager } from './EventManager';
import { EventListRenderer } from './EventListRenderer.updated';
import { EventDetailsRenderer } from './EventDetailsRenderer.updated';
import { NostrRelayService } from './NostrRelayService';
import { NostrCacheService } from './NostrCacheService';
import { NostrEventService } from './NostrEventService.updated';
import { RelayStatusManager } from './RelayStatusManager';
/**
* Initialize all services and UI components with EventManager
*/
export function initializeWithEventManager(): void {
console.log('Initializing event management system...');
// Create the EventManager as the central service
const eventManager = new EventManager();
// Initialize the server pubkey if available (from localStorage)
const serverNsec = localStorage.getItem('serverNsec');
if (serverNsec) {
try {
const serverPubkey = getServerPubkeyFromNsec(serverNsec);
if (serverPubkey) {
eventManager.setServerPubkey(serverPubkey);
console.log('Server pubkey set in EventManager');
}
} catch (error) {
console.error('Error initializing server pubkey:', error);
}
}
// Initialize services
const relayService = new NostrRelayService();
const cacheService = new NostrCacheService();
// Status callback for relay connection updates
const updateStatusCallback = (statusMessage: string, statusClass: string) => {
const relayStatus = document.getElementById('relayStatus');
if (relayStatus) {
relayStatus.textContent = statusMessage;
relayStatus.className = 'relay-status ' + statusClass;
}
};
// Create NostrEventService with EventManager
const nostrEventService = new NostrEventService(
relayService,
cacheService,
eventManager,
updateStatusCallback
);
// Initialize RelayStatusManager
const relayStatusManager = new RelayStatusManager();
relayStatusManager.initialize();
// Initialize UI components with EventManager
const eventListRenderer = new EventListRenderer(eventManager);
eventListRenderer.initialize();
const eventDetailsRenderer = new EventDetailsRenderer(eventManager);
eventDetailsRenderer.initialize();
// Setup event handlers for UI interactions
setupUIEventHandlers(eventManager, nostrEventService, eventListRenderer);
console.log('Event management system initialized');
// Store references in window for debugging/access from other modules
(window as any).__eventManager = eventManager;
(window as any).__nostrEventService = nostrEventService;
}
/**
* Set up UI event handlers for interactions
*/
function setupUIEventHandlers(
eventManager: EventManager,
nostrEventService: NostrEventService,
eventListRenderer: EventListRenderer
): void {
// Handle the "Show all events" checkbox
const showAllEventsCheckbox = document.getElementById('showAllEvents') as HTMLInputElement;
if (showAllEventsCheckbox) {
showAllEventsCheckbox.addEventListener('change', () => {
const showAll = showAllEventsCheckbox.checked;
eventListRenderer.filterEventsInUI(showAll);
// Update the relay subscription with the new filter
updateRelaySubscription(nostrEventService, showAll);
});
}
// Handle connect relay button
const connectRelayBtn = document.getElementById('connectRelayBtn');
if (connectRelayBtn) {
connectRelayBtn.addEventListener('click', () => {
const relayUrlInput = document.getElementById('relayUrl') as HTMLInputElement;
const relayUrl = relayUrlInput?.value || 'wss://relay.degmods.com';
// Get show all events state
const showAll = showAllEventsCheckbox?.checked || false;
// Connect to relay and subscribe
connectToRelayAndSubscribe(nostrEventService, relayUrl, showAll);
});
}
// Listen for custom events from EventDetailsRenderer
document.addEventListener('execute-http-request', (e: Event) => {
const customEvent = e as CustomEvent;
const eventId = customEvent.detail?.eventId;
if (eventId) {
const event = eventManager.getEvent(eventId);
if (event && event.event.kind === 21120) {
executeHttpRequest(event.event.content);
}
}
});
document.addEventListener('create-21121-response', (e: Event) => {
const customEvent = e as CustomEvent;
const requestEventId = customEvent.detail?.requestEventId;
if (requestEventId) {
const event = eventManager.getEvent(requestEventId);
if (event && event.event.kind === 21120) {
create21121Response(event.event);
}
}
});
}
/**
* Connect to a relay and subscribe to events
*/
async function connectToRelayAndSubscribe(
nostrEventService: NostrEventService,
relayUrl: string,
showAllEvents: boolean
): Promise<void> {
try {
// Create a filter for HTTP message events (kinds 21120 and 21121)
const filter = nostrEventService.createHttpMessageFilter(showAllEvents);
// Subscribe to events with the filter
await nostrEventService.subscribeToEvents(filter);
} catch (error) {
console.error('Error connecting to relay:', error);
}
}
/**
* Update the relay subscription with a new filter
*/
async function updateRelaySubscription(
nostrEventService: NostrEventService,
showAllEvents: boolean
): Promise<void> {
try {
// Create a filter for HTTP message events (kinds 21120 and 21121)
const filter = nostrEventService.createHttpMessageFilter(showAllEvents);
// Subscribe to events with the filter (this will replace the current subscription)
await nostrEventService.subscribeToEvents(filter);
} catch (error) {
console.error('Error updating relay subscription:', error);
}
}
/**
* Execute HTTP request (placeholder for actual implementation)
*/
function executeHttpRequest(httpContent: string): void {
console.log('Would execute HTTP request:', httpContent);
// In a real implementation, this would call the http-response-viewer module
// which would execute the HTTP request and display the response
}
/**
* Create a 21121 response (placeholder for actual implementation)
*/
function create21121Response(requestEvent: any): void {
console.log('Would create 21121 response for request:', requestEvent);
// In a real implementation, this would call the Nostr21121Service
// which would create and publish a 21121 response event
}
/**
* Helper function to get server pubkey from nsec (placeholder)
*/
function getServerPubkeyFromNsec(nsec: string): string | null {
// In a real implementation, this would use nostr-tools to decode the nsec
// and get the public key
console.log('Would decode nsec to get pubkey');
return null;
}
// Export the initialization function for use in other modules
export default initializeWithEventManager;

@ -0,0 +1,247 @@
/**
* EventManager.test.ts
* Test cases and examples for the EventManager service
*/
import { EventManager, EventKind, EventChangeType } from './EventManager';
import { NostrEvent } from '../relay';
/**
* Create a sample NostrEvent for testing
* @param kind The event kind (21120 or 21121)
* @param id Optional ID override
* @param requestId For 21121 events, the ID of the request event
* @returns A sample NostrEvent
*/
function createSampleEvent(kind: EventKind, id?: string, requestId?: string): NostrEvent {
const tags: string[][] = [];
// For response events, add e tag to reference the request
if (kind === EventKind.HttpResponse && requestId) {
tags.push(['e', requestId, '']);
tags.push(['k', '21120']);
}
return {
id: id || `event-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`,
pubkey: 'sample-pubkey-123456789',
created_at: Math.floor(Date.now() / 1000),
kind,
tags,
content: kind === EventKind.HttpRequest
? 'GET /example HTTP/1.1\nHost: example.com\n\n'
: 'HTTP/1.1 200 OK\nContent-Type: text/html\n\n<html><body>Hello</body></html>'
};
}
/**
* Run tests for the EventManager
*/
function runTests() {
console.log('Running EventManager tests...');
let testsPassed = 0;
let testsFailed = 0;
// Helper for assertions
function assert(condition: boolean, message: string): void {
if (condition) {
console.log(`${message}`);
testsPassed++;
} else {
console.error(`${message}`);
testsFailed++;
}
}
// Test 1: Creating and retrieving events
(function testEventCreationAndRetrieval() {
const manager = new EventManager();
const event = createSampleEvent(EventKind.HttpRequest);
const eventId = manager.addEvent(event);
assert(eventId !== null, 'Event ID should not be null');
assert(eventId === event.id, 'Event ID should match the added event');
if (eventId) {
const retrievedEvent = manager.getEvent(eventId);
assert(retrievedEvent !== null, 'Retrieved event should not be null');
assert(retrievedEvent?.event.id === event.id, 'Retrieved event ID should match original');
assert(manager.getAllEvents().length === 1, 'Manager should have 1 event');
}
})();
// Test 2: Event filtering
(function testEventFiltering() {
const manager = new EventManager('server-pubkey-123');
// Add request events
const request1 = createSampleEvent(EventKind.HttpRequest, 'req-1');
request1.tags.push(['p', 'server-pubkey-123', '']);
const request2 = createSampleEvent(EventKind.HttpRequest, 'req-2');
request2.tags.push(['p', 'different-pubkey', '']);
// Add response event
const response1 = createSampleEvent(EventKind.HttpResponse, 'resp-1', 'req-1');
const req1Id = manager.addEvent(request1);
const req2Id = manager.addEvent(request2);
const respId = manager.addEvent(response1);
assert(req1Id !== null, 'Request 1 ID should not be null');
assert(req2Id !== null, 'Request 2 ID should not be null');
assert(respId !== null, 'Response ID should not be null');
// Filter by kind
const requests = manager.getFilteredEvents({ kind: EventKind.HttpRequest });
assert(requests.length === 2, 'Should find 2 request events');
const responses = manager.getFilteredEvents({ kind: EventKind.HttpResponse });
assert(responses.length === 1, 'Should find 1 response event');
// Filter by toServer
const serverEvents = manager.getFilteredEvents({ toServer: true });
assert(serverEvents.length === 1, 'Should find 1 event addressed to server');
assert(serverEvents[0].id === 'req-1', 'Server event should be req-1');
})();
// Test 3: Relationships between events
(function testEventRelationships() {
const manager = new EventManager();
// Add request event
const request = createSampleEvent(EventKind.HttpRequest, 'req-rel-1');
const reqId = manager.addEvent(request);
assert(reqId !== null, 'Request ID should not be null');
// Initially no related events
assert(manager.getRelatedEventIds('req-rel-1').length === 0, 'New request should have no related events');
// Add response event
const response = createSampleEvent(EventKind.HttpResponse, 'resp-rel-1', 'req-rel-1');
const respId = manager.addEvent(response);
assert(respId !== null, 'Response ID should not be null');
// Check relationships
const requestRelated = manager.getRelatedEventIds('req-rel-1');
assert(requestRelated.length === 1, 'Request should have 1 related event');
assert(requestRelated[0] === 'resp-rel-1', 'Related event should be the response');
const responseRelated = manager.getRelatedEventIds('resp-rel-1');
assert(responseRelated.length === 1, 'Response should have 1 related event');
assert(responseRelated[0] === 'req-rel-1', 'Related event should be the request');
})();
// Test 4: Event selection
(function testEventSelection() {
const manager = new EventManager();
// Add events
const event1 = createSampleEvent(EventKind.HttpRequest, 'sel-1');
const event2 = createSampleEvent(EventKind.HttpRequest, 'sel-2');
const id1 = manager.addEvent(event1);
const id2 = manager.addEvent(event2);
assert(id1 !== null, 'Event 1 ID should not be null');
assert(id2 !== null, 'Event 2 ID should not be null');
// Initially no selection
assert(manager.getSelectedEvent() === null, 'Initially no event should be selected');
// Select event 1
manager.selectEvent('sel-1');
const selected1 = manager.getSelectedEvent();
assert(selected1 !== null, 'Selected event should not be null');
assert(selected1?.id === 'sel-1', 'Selected event ID should match');
// Select event 2
manager.selectEvent('sel-2');
const selected2 = manager.getSelectedEvent();
assert(selected2?.id === 'sel-2', 'New selected event ID should match');
// Event 1 should no longer be selected
const event1After = manager.getEvent('sel-1');
assert(event1After?.selected !== true, 'Event 1 should no longer be selected');
})();
// Test 5: Observer pattern
(function testObserverPattern() {
const manager = new EventManager();
// Track notifications
const notifications: {eventId: string, changeType: EventChangeType}[] = [];
// Register listener
const unregister = manager.registerListener((eventId, changeType) => {
notifications.push({eventId, changeType});
});
// Add event
const event = createSampleEvent(EventKind.HttpRequest, 'obs-1');
const eventId = manager.addEvent(event);
assert(eventId !== null, 'Event ID should not be null');
// Select event
manager.selectEvent('obs-1');
// Update event
manager.updateDecryptionStatus('obs-1', true, 'decrypted content');
// Remove event
manager.removeEvent('obs-1');
// Check notifications
assert(notifications.length === 4, 'Should receive 4 notifications');
assert(notifications[0].eventId === 'obs-1', 'First notification event ID should match');
assert(notifications[0].changeType === EventChangeType.Added, 'First notification should be Added');
assert(notifications[1].eventId === 'obs-1', 'Second notification event ID should match');
assert(notifications[1].changeType === EventChangeType.Selected, 'Second notification should be Selected');
assert(notifications[2].eventId === 'obs-1', 'Third notification event ID should match');
assert(notifications[2].changeType === EventChangeType.Updated, 'Third notification should be Updated');
assert(notifications[3].eventId === 'obs-1', 'Fourth notification event ID should match');
assert(notifications[3].changeType === EventChangeType.Removed, 'Fourth notification should be Removed');
// Unregister and ensure no more notifications
unregister();
const event2 = createSampleEvent(EventKind.HttpRequest, 'obs-2');
const event2Id = manager.addEvent(event2);
assert(event2Id !== null, 'Event 2 ID should not be null');
assert(notifications.length === 4, 'Should still have 4 notifications after unregister');
})();
// Final report
console.log(`\nTests completed: ${testsPassed + testsFailed}`);
console.log(`Passed: ${testsPassed}`);
console.log(`Failed: ${testsFailed}`);
if (testsFailed === 0) {
console.log('✅ All tests passed');
} else {
console.log('❌ Some tests failed');
}
}
// Run the tests if this file is executed directly
if (typeof window !== 'undefined' && window.document) {
// Browser environment - create a button to run tests
document.addEventListener('DOMContentLoaded', () => {
const button = document.createElement('button');
button.textContent = 'Run EventManager Tests';
button.onclick = runTests;
document.body.appendChild(button);
});
} else {
// Node.js environment or direct execution
runTests();
}
// Export for potential external use
export { runTests, createSampleEvent };

@ -0,0 +1,538 @@
/**
* EventManager.ts
* Centralizes event data management for 21120 and 21121 events
*/
import { NostrEvent } from '../relay';
// Event types we're managing
export enum EventKind {
HttpRequest = 21120,
HttpResponse = 21121
}
// Interface for a received event with metadata
export interface ManagedEvent {
id: string;
event: NostrEvent;
receivedAt: number;
decrypted: boolean;
decryptedContent?: string;
selected?: boolean;
}
// Interface for event filter options
export interface EventFilterOptions {
kind?: EventKind;
pubkey?: string;
toServer?: boolean;
since?: number;
until?: number;
}
// Event change notification types
export enum EventChangeType {
Added,
Updated,
Removed,
Selected
}
// Interface for event change listeners
export interface EventChangeListener {
(eventId: string, changeType: EventChangeType): void;
}
/**
* Class for centralized event data management
*/
export class EventManager {
// Primary storage for events
private events: Map<string, ManagedEvent> = new Map();
// Map to track relationships between events (requestId -> responseIds[])
private relationships: Map<string, string[]> = new Map();
// Currently selected event ID
private selectedEventId: string | null = null;
// Event change listeners
private listeners: EventChangeListener[] = [];
// Server pubkey for filtering
private serverPubkey: string | null = null;
/**
* Constructor
* @param serverPubkey Optional server pubkey for filtering events
*/
constructor(serverPubkey?: string) {
this.serverPubkey = serverPubkey || null;
}
/**
* Add a new event to the manager
* @param event The Nostr event to add
* @param decrypted Whether the event was successfully decrypted
* @param decryptedContent Optional decrypted content
* @param validateRelationships Whether to validate relationships for 21121 events
* @returns The ID of the added event, or null if validation failed
*/
public addEvent(
event: NostrEvent,
decrypted: boolean = false,
decryptedContent?: string,
validateRelationships: boolean = true
): string | null {
if (!event.id) {
throw new Error('Event ID is required');
}
const id = event.id;
const now = Date.now();
// Validate 21121 event if required
if (validateRelationships && event.kind === EventKind.HttpResponse) {
const isValid = this.validate21121Event(event);
if (!isValid) {
console.warn(`Invalid 21121 event: ${id} - Missing or invalid e tag reference`);
return null;
}
}
// Create managed event object
const managedEvent: ManagedEvent = {
id,
event,
receivedAt: now,
decrypted,
decryptedContent
};
// Add to primary storage
this.events.set(id, managedEvent);
// Update relationships if this is a response event (21121)
if (event.kind === EventKind.HttpResponse) {
this.updateRelationshipsForResponse(event);
}
// Notify listeners
this.notifyListeners(id, EventChangeType.Added);
return id;
}
/**
* Get an event by ID
* @param id The ID of the event to retrieve
* @returns The managed event or null if not found
*/
public getEvent(id: string): ManagedEvent | null {
return this.events.get(id) || null;
}
/**
* Get all events
* @returns Array of all managed events
*/
public getAllEvents(): ManagedEvent[] {
return Array.from(this.events.values());
}
/**
* Get filtered events based on criteria
* @param options Filter options
* @returns Array of filtered events
*/
public getFilteredEvents(options: EventFilterOptions = {}): ManagedEvent[] {
const events = this.getAllEvents();
return events.filter(managedEvent => {
const { event } = managedEvent;
// Filter by kind
if (options.kind && event.kind !== options.kind) {
return false;
}
// Filter by pubkey
if (options.pubkey && event.pubkey !== options.pubkey) {
return false;
}
// Filter by time range
if (options.since && event.created_at < options.since) {
return false;
}
if (options.until && event.created_at > options.until) {
return false;
}
// Filter for events addressed to server
if (options.toServer === true && this.serverPubkey) {
const pTag = event.tags.find(tag => tag[0] === 'p');
if (!pTag || pTag[1] !== this.serverPubkey) {
return false;
}
}
return true;
});
}
/**
* Update the decryption status of an event
* @param id The ID of the event to update
* @param decrypted Whether the event was successfully decrypted
* @param decryptedContent The decrypted content
* @returns True if the update was successful, false otherwise
*/
public updateDecryptionStatus(
id: string,
decrypted: boolean,
decryptedContent?: string
): boolean {
const managedEvent = this.events.get(id);
if (!managedEvent) {
return false;
}
managedEvent.decrypted = decrypted;
managedEvent.decryptedContent = decryptedContent;
// Notify listeners
this.notifyListeners(id, EventChangeType.Updated);
return true;
}
/**
* Select an event by ID
* @param id The ID of the event to select
* @returns True if the event was found and selected, false otherwise
*/
public selectEvent(id: string): boolean {
// Deselect current selection if any
if (this.selectedEventId) {
const previousSelected = this.events.get(this.selectedEventId);
if (previousSelected) {
previousSelected.selected = false;
this.notifyListeners(this.selectedEventId, EventChangeType.Updated);
}
}
const managedEvent = this.events.get(id);
if (!managedEvent) {
this.selectedEventId = null;
return false;
}
// Select the new event
managedEvent.selected = true;
this.selectedEventId = id;
// Notify listeners
this.notifyListeners(id, EventChangeType.Selected);
return true;
}
/**
* Get the currently selected event
* @returns The currently selected event or null if none is selected
*/
public getSelectedEvent(): ManagedEvent | null {
return this.selectedEventId ? this.events.get(this.selectedEventId) || null : null;
}
/**
* Get related events for a given event ID
* @param id The ID of the event to get related events for
* @returns Array of related event IDs
*/
public getRelatedEventIds(id: string): string[] {
// For request events (21120), get responses
if (this.relationships.has(id)) {
return [...this.relationships.get(id)!];
}
// For response events (21121), find the request they respond to
const event = this.events.get(id)?.event;
if (event && event.kind === EventKind.HttpResponse) {
// Find the request event this response is for
const requestId = this.getRequestIdForResponse(event);
if (requestId) {
return [requestId];
}
}
return [];
}
/**
* Get related events as full managed events
* @param id The ID of the event to get related events for
* @returns Array of related managed events
*/
public getRelatedEvents(id: string): ManagedEvent[] {
const relatedIds = this.getRelatedEventIds(id);
return relatedIds
.map(relatedId => this.events.get(relatedId))
.filter((event): event is ManagedEvent => event !== undefined);
}
/**
* Check if an event has related events
* @param id The ID of the event to check
* @returns True if the event has related events, false otherwise
*/
public hasRelatedEvents(id: string): boolean {
return this.getRelatedEventIds(id).length > 0;
}
/**
* Remove an event by ID
* @param id The ID of the event to remove
* @returns True if the event was found and removed, false otherwise
*/
public removeEvent(id: string): boolean {
if (!this.events.has(id)) {
return false;
}
// Remove from primary storage
this.events.delete(id);
// Remove from relationships
this.relationships.delete(id);
// Remove references to this event in other relationships
for (const [requestId, responseIds] of this.relationships.entries()) {
const index = responseIds.indexOf(id);
if (index !== -1) {
responseIds.splice(index, 1);
// If no more responses, remove the relationship entry
if (responseIds.length === 0) {
this.relationships.delete(requestId);
}
}
}
// If this was the selected event, clear selection
if (id === this.selectedEventId) {
this.selectedEventId = null;
}
// Notify listeners
this.notifyListeners(id, EventChangeType.Removed);
return true;
}
/**
* Clear all events
*/
public clearAllEvents(): void {
const eventIds = Array.from(this.events.keys());
// Clear all storage
this.events.clear();
this.relationships.clear();
this.selectedEventId = null;
// Notify listeners about each removed event
for (const id of eventIds) {
this.notifyListeners(id, EventChangeType.Removed);
}
}
/**
* Set the server pubkey for filtering
* @param pubkey The server pubkey
*/
public setServerPubkey(pubkey: string): void {
this.serverPubkey = pubkey;
}
/**
* Get the server pubkey
* @returns The server pubkey or null if not set
*/
public getServerPubkey(): string | null {
return this.serverPubkey;
}
/**
* Register a listener for event changes
* @param listener The listener function to register
* @returns A function to unregister the listener
*/
public registerListener(listener: EventChangeListener): () => void {
this.listeners.push(listener);
// Return unregister function
return () => {
const index = this.listeners.indexOf(listener);
if (index !== -1) {
this.listeners.splice(index, 1);
}
};
}
/**
* Update relationships for a response event
* @param event The response event
*/
private updateRelationshipsForResponse(event: NostrEvent): void {
// Find e tag which references the request event
const requestId = this.getRequestIdForResponse(event);
if (requestId && event.id) {
const responseId = event.id;
// Get existing responses or create new array
const responses = this.relationships.get(requestId) || [];
// Add this response if not already there
if (!responses.includes(responseId)) {
responses.push(responseId);
this.relationships.set(requestId, responses);
}
}
}
/**
* Validate a 21121 response event
* Ensures it has a proper e tag referencing a 21120 request event
* @param event The event to validate
* @returns True if valid, false otherwise
*/
public validate21121Event(event: NostrEvent): boolean {
// Check if it's a 21121 event
if (event.kind !== EventKind.HttpResponse) {
return false;
}
// Find the e tag that should reference the request
const requestId = this.getRequestIdForResponse(event);
if (!requestId) {
return false;
}
// If we're validating against existing events, check if the referenced request exists
const requestEvent = this.events.get(requestId);
if (requestEvent) {
// Verify it's a 21120 request event
return requestEvent.event.kind === EventKind.HttpRequest;
}
// If we don't have the request event in our store yet, just validate the tag exists
return true;
}
/**
* Get the request ID that a response event refers to
* @param event The response event or event ID
* @returns The request event ID or null if not found
*/
public getRequestIdForResponse(event: NostrEvent | string): string | null {
// If string provided, get the event
let responseEvent: NostrEvent | undefined;
if (typeof event === 'string') {
responseEvent = this.events.get(event)?.event;
} else {
responseEvent = event;
}
if (!responseEvent) {
return null;
}
// Find e tag which references the request event
const eTag = responseEvent.tags.find(tag => tag[0] === 'e');
return eTag && eTag[1] ? eTag[1] : null;
}
/**
* Get all responses for a request event
* @param requestId The ID of the request event
* @returns Array of response events
*/
public getResponsesForRequest(requestId: string): ManagedEvent[] {
// Get related events (these are responses)
return this.getRelatedEvents(requestId);
}
/**
* Associate a response event with a request event
* @param responseId The ID of the response event
* @param requestId The ID of the request event
* @returns True if successfully associated, false otherwise
*/
public associateResponseWithRequest(responseId: string, requestId: string): boolean {
// Check if both events exist
const responseEvent = this.events.get(responseId);
const requestEvent = this.events.get(requestId);
if (!responseEvent || !requestEvent) {
return false;
}
// Check if request is actually a 21120 event
if (requestEvent.event.kind !== EventKind.HttpRequest) {
return false;
}
// Check if response is actually a 21121 event
if (responseEvent.event.kind !== EventKind.HttpResponse) {
return false;
}
// Get existing responses or create new array
const responses = this.relationships.get(requestId) || [];
// Add this response if not already there
if (!responses.includes(responseId)) {
responses.push(responseId);
this.relationships.set(requestId, responses);
return true;
}
// Already associated
return true;
}
/**
* Check if a response event is associated with a request event
* @param responseId The ID of the response event
* @param requestId The ID of the request event
* @returns True if associated, false otherwise
*/
public isResponseAssociatedWithRequest(responseId: string, requestId: string): boolean {
const responses = this.relationships.get(requestId) || [];
return responses.includes(responseId);
}
/**
* Notify all listeners about an event change
* @param eventId The ID of the event that changed
* @param changeType The type of change
*/
private notifyListeners(eventId: string, changeType: EventChangeType): void {
for (const listener of this.listeners) {
try {
listener(eventId, changeType);
} catch (error) {
console.error('Error in event change listener:', error);
}
}
}
}

@ -1,26 +1,45 @@
/**
* HttpService.ts
* Handles HTTP-related operations for the HTTP-to-Nostr application
* This is a refactored version that combines functionality from the original
* HttpService and HttpClient classes.
*/
// Import crypto utilities
import { encryptWithWebCrypto, decryptWithWebCrypto } from '../utils/crypto-utils';
import { ToastNotifier } from './ToastNotifier';
// Interface for HTTP request options
// Interface definitions
export interface HttpRequestOptions {
method: string;
headers: Record<string, string>;
body?: string;
}
export interface ParsedHttpRequest {
url: string;
options: HttpRequestOptions;
originalContent: string;
}
export interface HttpResponse {
status: number;
statusText: string;
headers: Record<string, string>;
body: string;
rawResponse: string;
}
/**
* Class for handling HTTP requests and responses
*/
export class HttpService {
/**
* Parse a raw HTTP request text into its components
* @param httpRequestText Raw HTTP request text to parse
* @returns Parsed request object or null if parsing fails
*/
public parseHttpRequest(httpRequestText: string): { url: string; options: HttpRequestOptions } | null {
public parseHttpRequest(httpRequestText: string): ParsedHttpRequest | null {
try {
// Parse the HTTP request
const lines = httpRequestText.split('\n');
@ -73,46 +92,107 @@ export class HttpService {
method,
headers,
...(body && method !== 'GET' && method !== 'HEAD' ? { body } : {})
}
},
originalContent: httpRequestText
};
} catch {
} catch (error) {
console.error('Error parsing HTTP request:', error);
return null;
}
}
/**
* Execute an HTTP request
* Execute an HTTP request based on raw content
* @param httpContent The raw HTTP content (request)
* @returns Promise resolving to the HTTP response
*/
public async executeHttpRequest(httpRequestText: string): Promise<string> {
public async executeHttpRequest(httpContent: string): Promise<HttpResponse> {
try {
const parsedRequest = this.parseHttpRequest(httpRequestText);
// Parse the HTTP request
const parsedRequest = this.parseHttpRequest(httpContent);
if (!parsedRequest) {
return 'Error: Could not parse HTTP request';
throw new Error('Failed to parse HTTP request');
}
const { url, options } = parsedRequest;
const response = await window.fetch(url, options);
const { method, headers, body } = options;
// Prepare the response text
let responseText = `HTTP/1.1 ${response.status} ${response.statusText}\n`;
// Add headers and body
response.headers.forEach((value: string, key: string) => {
responseText += `${key}: ${value}\n`;
// Perform fetch
console.log(`Executing HTTP ${method} request to ${url}`);
const response = await fetch(url, {
method,
headers,
body: body ? body.trim() : undefined,
});
responseText += '\n';
responseText += await response.text();
// Get response data
const responseBody = await response.text();
return responseText;
// Build response string
let responseText = `HTTP/1.1 ${response.status} ${response.statusText}\n`;
// Convert headers to a standard object
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseText += `${key}: ${value}\n`;
responseHeaders[key] = value;
});
// Add body to response string
responseText += '\n' + responseBody;
// Return structured response
return {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
body: responseBody,
rawResponse: responseText
};
} catch (error) {
return `Error executing HTTP request: ${error instanceof Error ? error.message : String(error)}`;
console.error('Error executing HTTP request:', error);
// Generate error response
const errorMessage = error instanceof Error ? error.message : String(error);
const errorResponseText = `HTTP/1.1 500 Internal Server Error\nContent-Type: text/plain\n\nError: ${errorMessage}`;
// Return error as HTTP response
return {
status: 500,
statusText: 'Internal Server Error',
headers: { 'Content-Type': 'text/plain' },
body: `Error: ${errorMessage}`,
rawResponse: errorResponseText
};
}
}
/**
* Format a response object to raw HTTP format
* @param response The HTTP response object to format
* @returns Formatted HTTP response string
*/
public formatResponseToRawHttp(response: HttpResponse): string {
let rawHttp = `HTTP/1.1 ${response.status} ${response.statusText}\n`;
// Add headers
Object.entries(response.headers).forEach(([key, value]) => {
rawHttp += `${key}: ${value}\n`;
});
// Add body
rawHttp += '\n' + response.body;
return rawHttp;
}
/**
* Crypto utility: Encrypt data using Web Crypto API
* Uses the shared crypto-utils implementation
* @param plaintext The text to encrypt
* @param key The encryption key
* @returns The encrypted data as a base64 string
*/
public async encryptData(plaintext: string, key: string): Promise<string> {
return encryptWithWebCrypto(plaintext, key);
@ -121,6 +201,9 @@ export class HttpService {
/**
* Crypto utility: Decrypt data using Web Crypto API
* Uses the shared crypto-utils implementation
* @param encryptedBase64 The encrypted base64 data
* @param key The decryption key
* @returns The decrypted plaintext
*/
public async decryptData(encryptedBase64: string, key: string): Promise<string> {
return decryptWithWebCrypto(encryptedBase64, key);

@ -0,0 +1,266 @@
# Integrating EventManager with NostrEventService
This document outlines how to integrate the new EventManager service with the existing NostrEventService to centralize event data management.
## Overview
The integration involves:
1. Adding EventManager as a dependency to NostrEventService
2. Delegating event storage and management to EventManager
3. Using EventManager for event relationship tracking
4. Adapting methods to work with EventManager instead of direct event handling
## Implementation Steps
### 1. Modify NostrEventService Constructor
```typescript
export class NostrEventService {
private relayService: NostrRelayService;
private cacheService: NostrCacheService;
private eventManager: EventManager; // New dependency
private statusCallback: ((statusMessage: string, statusClass: string) => void) | null = null;
constructor(
relayService: NostrRelayService,
cacheService: NostrCacheService,
eventManager: EventManager, // Add EventManager parameter
statusCallback?: ((statusMessage: string, statusClass: string) => void)
) {
this.relayService = relayService;
this.cacheService = cacheService;
this.eventManager = eventManager;
this.statusCallback = statusCallback || null;
// Initialize server pubkey in EventManager
this.initializeServerPubkey();
}
// Initialize server pubkey from localStorage
private initializeServerPubkey(): void {
const serverNsec = localStorage.getItem('serverNsec');
if (serverNsec) {
try {
const decoded = nostrTools.nip19.decode(serverNsec);
if (decoded.type === 'nsec') {
const serverPubkey = nostrTools.getPublicKey(decoded.data as any);
this.eventManager.setServerPubkey(serverPubkey);
}
} catch (error) {
console.error('Error initializing server pubkey:', error);
}
}
}
}
```
### 2. Replace Event Handler with Process Method
```typescript
// Replace the old event handler callback approach
// private eventHandler: ((receivedEvent: NostrEvent) => void) | null = null;
// With a method that adds events to the EventManager
public processEvent(
event: NostrEvent,
decrypted: boolean = false,
decryptedContent?: string
): string {
// Add to EventManager
const eventId = this.eventManager.addEvent(event, decrypted, decryptedContent);
// Also cache the event for historical purposes
this.cacheService.cacheEvents(this.relayService.getActiveRelayUrl() || 'memory', [event]);
return eventId;
}
```
### 3. Update the Event Subscription Logic
```typescript
public async subscribeToEvents(filter: NostrFilter): Promise<NostrSubscription> {
const activeRelayUrl = this.relayService.getActiveRelayUrl();
if (!activeRelayUrl) {
throw new Error('No active relay URL');
}
this.updateStatus('Creating subscription...', 'connecting');
try {
const wsManager = this.relayService.getWebSocketManager();
await wsManager.connect(activeRelayUrl, {
timeout: 5000,
onOpen: (ws) => {
// Send a REQ message to subscribe
const reqId = `req-${Date.now()}`;
const reqMsg = JSON.stringify(["REQ", reqId, filter]);
ws.send(reqMsg);
this.updateStatus('Subscription active ✓', 'connected');
},
onMessage: (data) => {
// Type assertion for the received data
const nostrData = data as unknown[];
// Handle different message types
if (Array.isArray(nostrData) && nostrData[0] === "EVENT" && nostrData.length >= 3) {
const receivedEvent = nostrData[2] as NostrEvent;
// Process the event if it has a valid ID
if (receivedEvent.id) {
// Process with the EventManager instead of using a callback handler
this.processEvent(receivedEvent);
}
}
},
// Rest of the handlers remain the same
});
// Return a subscription object
return {
unsub: () => {
wsManager.close();
}
};
} catch (error) {
// Error handling remains the same
}
}
```
### 4. Add Method to Find Events in Cache
Since NostrCacheService doesn't have a direct method to get an event by ID, create a helper method:
```typescript
private findEventInCache(eventId: string, relayUrl?: string): NostrEvent | null {
// If relay URL is provided, look there first
if (relayUrl) {
const events = this.cacheService.getCachedEvents(relayUrl);
if (events) {
const event = events.find(e => e.id === eventId);
if (event) return event;
}
}
// If still not found, check cached events from known relays
// This is a simplification - in a real implementation you might need a more
// sophisticated approach to track which relays have been cached
const relays = ['memory']; // Start with at least the memory relay
if (relayUrl) relays.push(relayUrl);
for (const relay of relays) {
const events = this.cacheService.getCachedEvents(relay);
if (events) {
const event = events.find(e => e.id === eventId);
if (event) return event;
}
}
return null;
}
```
### 5. Update Event Retrieval Methods
```typescript
public getEvent(eventId: string): NostrEvent | null {
// First check the EventManager
const managedEvent = this.eventManager.getEvent(eventId);
if (managedEvent) {
return managedEvent.event;
}
// Then check the cache
return this.findEventInCache(eventId);
}
public async getEventById(relayUrl: string, eventId: string): Promise<NostrEvent | null> {
// First check if we already have this event in our EventManager
const existingEvent = this.eventManager.getEvent(eventId);
if (existingEvent) {
return existingEvent.event;
}
// Then check the cache
const cachedEvent = this.findEventInCache(eventId, relayUrl);
if (cachedEvent) {
return cachedEvent;
}
// If not found, fetch from relay (implementation remains mostly the same)
// ...
}
```
### 6. Update Filter Creation
```typescript
public createKind21120Filter(showAllEvents: boolean): NostrFilter {
// Create filter for kind 21120 events
const filter: NostrFilter = {
kinds: [21120], // HTTP Messages event kind
};
// If "Show all events" is not checked, filter only for events addressed to the server
if (!showAllEvents) {
// Get the server pubkey from the EventManager
const serverPubkey = this.eventManager.getServerPubkey();
// Add p-tag filter for events addressed to the server
if (serverPubkey) {
filter['#p'] = [serverPubkey];
}
}
return filter;
}
```
### 7. Add Method to Get EventManager
```typescript
public getEventManager(): EventManager {
return this.eventManager;
}
```
## Usage in Application
When initializing the service, create an EventManager instance first:
```typescript
// Initialize services
const relayService = new NostrRelayService();
const cacheService = new NostrCacheService();
const eventManager = new EventManager(); // Create the EventManager
// Create the NostrEventService with the EventManager
const nostrEventService = new NostrEventService(
relayService,
cacheService,
eventManager,
updateStatusCallback
);
// UI components can now use the EventManager directly
const eventListRenderer = new EventListRenderer(eventManager);
const eventDetailsRenderer = new EventDetailsRenderer(eventManager);
```
## Benefits of This Integration
1. **Centralized Event Management**: All event data is now managed in a single place
2. **Improved Event Relationships**: EventManager explicitly tracks relationships between request and response events
3. **Observer Pattern**: UI components can now subscribe to event changes
4. **Reduced Duplication**: Removes duplicated event storage and management logic
5. **Better Testability**: Components can be tested individually with a mocked EventManager
## Migration Strategy
1. Implement the EventManager first
2. Update NostrEventService to use EventManager
3. Update UI components to use EventManager for rendering events
4. Gradually phase out direct event access in favor of EventManager methods

@ -0,0 +1,343 @@
/**
* NostrEventService.ts
* Handles event-specific operations for Nostr protocol
* Integrated with EventManager for centralized event data management
*/
// External imports
import * as nostrTools from 'nostr-tools';
// Project imports
import type { NostrEvent } from '../relay';
import type { NostrCacheService, ProfileData } from './NostrCacheService';
import type { NostrRelayService } from './NostrRelayService';
import { EventManager, EventKind, EventChangeType } from './EventManager';
// Interface for a Nostr subscription
export interface NostrSubscription {
unsub: () => void;
}
// Interface for Nostr filter
export interface NostrFilter {
kinds: number[];
'#p'?: string[];
authors?: string[];
since?: number;
until?: number;
limit?: number;
ids?: string[];
[key: string]: unknown;
}
/**
* Class for managing Nostr event operations
* Integrated with EventManager for centralized event data management
*/
export class NostrEventService {
private relayService: NostrRelayService;
private cacheService: NostrCacheService;
private eventManager: EventManager;
private statusCallback: ((statusMessage: string, statusClass: string) => void) | null = null;
private activeSubscription: NostrSubscription | null = null;
/**
* Constructor
* @param relayService Service for relay operations
* @param cacheService Service for caching operations
* @param eventManager Manager for centralized event data
* @param statusCallback Optional callback for status updates
*/
constructor(
relayService: NostrRelayService,
cacheService: NostrCacheService,
eventManager: EventManager,
statusCallback?: ((statusMessage: string, statusClass: string) => void)
) {
this.relayService = relayService;
this.cacheService = cacheService;
this.eventManager = eventManager;
this.statusCallback = statusCallback || null;
// Set server pubkey in EventManager if available
this.initializeServerPubkey();
}
/**
* Initialize server pubkey from localStorage for event filtering
*/
private initializeServerPubkey(): void {
const serverNsec = localStorage.getItem('serverNsec');
if (serverNsec) {
try {
const decoded = nostrTools.nip19.decode(serverNsec);
if (decoded.type === 'nsec') {
// Get server pubkey from the private key
const serverPubkey = nostrTools.getPublicKey(decoded.data as any);
// Set it in the EventManager
this.eventManager.setServerPubkey(serverPubkey);
}
} catch (error) {
console.error('Error initializing server pubkey:', error);
}
}
}
/**
* Process a new Nostr event by adding it to EventManager and cache
* @param event The Nostr event to process
* @param decrypted Whether the event has been successfully decrypted
* @param decryptedContent Optional decrypted content
* @returns The ID of the processed event or null if validation failed
*/
public processEvent(
event: NostrEvent,
decrypted: boolean = false,
decryptedContent?: string
): string | null {
// First, add to EventManager for centralized management
const eventId = this.eventManager.addEvent(event, decrypted, decryptedContent);
// Also cache by relay URL for persistence
const relayUrl = this.relayService.getActiveRelayUrl() || 'memory';
this.cacheService.cacheEvents(relayUrl, [event]);
return eventId;
}
/**
* Subscribe to events with the given filter
* @param filter The filter to apply to events
* @returns Promise resolving to a subscription object
*/
public async subscribeToEvents(filter: NostrFilter): Promise<NostrSubscription> {
const activeRelayUrl = this.relayService.getActiveRelayUrl();
if (!activeRelayUrl) {
throw new Error('No active relay URL');
}
this.updateStatus('Creating subscription...', 'connecting');
try {
// Close any existing subscription
if (this.activeSubscription) {
this.activeSubscription.unsub();
this.activeSubscription = null;
}
// Connect to the relay and subscribe to events
const wsManager = this.relayService.getWebSocketManager();
await wsManager.connect(activeRelayUrl, {
timeout: 5000,
onOpen: (ws) => {
// Send a REQ message to subscribe
const reqId = `req-${Date.now()}`;
const reqMsg = JSON.stringify(["REQ", reqId, filter]);
ws.send(reqMsg);
this.updateStatus('Subscription active ✓', 'connected');
},
onMessage: async (data) => {
const nostrData = data as unknown[];
// Handle different message types
if (Array.isArray(nostrData) && nostrData[0] === "EVENT" && nostrData.length >= 3) {
const receivedEvent = nostrData[2] as NostrEvent;
// Log the event kind for debugging
console.log(`Received event of kind: ${receivedEvent.kind}`, {
id: receivedEvent.id?.substring(0, 8) + '...',
tags: receivedEvent.tags.length
});
// Process the event if it has a valid ID
if (receivedEvent.id) {
let decrypted = false;
let decryptedContent: string | undefined = undefined;
// Attempt to decrypt 21120 events
if (receivedEvent.kind === 21120) {
try {
// Extract the 'key' tag which contains the encrypted decryption key
const keyTag = receivedEvent.tags.find(tag => tag[0] === 'key');
if (keyTag && keyTag[1]) {
const encryptedKey = keyTag[1];
// Get server's private key from localStorage
const serverNsec = localStorage.getItem('serverNsec');
if (serverNsec) {
console.log("Attempting to decrypt 21120 event content", {
eventId: receivedEvent.id?.substring(0, 8) + '...',
encryptedKeyLength: encryptedKey.length,
hasServerNsec: !!serverNsec
});
try {
// Import crypto utilities dynamically
const cryptoUtils = await import('../utils/crypto-utils');
// Decrypt the key with nostr-tools using server nsec
const clientPubkey = receivedEvent.pubkey;
console.log("Using client pubkey for decryption:",
clientPubkey.substring(0, 8) + "..."
);
const decryptionKey = await cryptoUtils.decryptKeyWithNostrTools(
encryptedKey,
serverNsec,
clientPubkey
);
console.log("Successfully decrypted key with length:", decryptionKey.length);
// Decrypt the content using WebCrypto
decryptedContent = await cryptoUtils.decryptWithWebCrypto(
receivedEvent.content,
decryptionKey
);
// Update decryption status
decrypted = true;
console.log("Successfully decrypted 21120 event content", {
contentLength: decryptedContent?.length,
eventId: receivedEvent.id?.substring(0, 8) + '...'
});
} catch (decryptError) {
console.error("Failed to decrypt event content:", decryptError);
console.error("Decryption error details:", {
eventId: receivedEvent.id?.substring(0, 8) + '...',
encryptedKeyLength: encryptedKey.length,
clientPubkey: receivedEvent.pubkey.substring(0, 8) + '...',
contentLength: receivedEvent.content.length,
errorMessage: decryptError instanceof Error ? decryptError.message : String(decryptError)
});
}
} else {
console.warn("Server nsec not found - cannot decrypt event");
}
} else {
console.warn("No key tag found in 21120 event");
}
} catch (error) {
console.error("Error attempting to decrypt event:", error);
}
}
// Process with EventManager and cache (with decryption info if successful)
this.processEvent(receivedEvent, decrypted, decryptedContent);
}
}
},
onError: () => {
this.updateStatus('WebSocket error', 'error');
},
onClose: () => {
this.updateStatus('Connection closed', 'error');
}
});
// Create subscription object
const subscription: NostrSubscription = {
unsub: () => {
wsManager.close();
this.updateStatus('Subscription closed', 'warning');
}
};
// Store as active subscription
this.activeSubscription = subscription;
return subscription;
} catch (error) {
this.updateStatus(
`Subscription error: ${error instanceof Error ? error.message : String(error)}`,
'error'
);
throw error;
}
}
/**
* Find an event in the cache by ID
* @param eventId The ID of the event to find
* @param relayUrl Optional relay URL to search in
* @returns The event if found, or null
*/
private findEventInCache(eventId: string, relayUrl?: string): NostrEvent | null {
// If relay URL is provided, look there first
if (relayUrl) {
const events = this.cacheService.getCachedEvents(relayUrl);
if (events) {
const event = events.find(e => e.id === eventId);
if (event) return event;
}
}
// If still not found or no relay URL provided, check memory cache
const events = this.cacheService.getCachedEvents('memory');
if (events) {
const event = events.find(e => e.id === eventId);
if (event) return event;
}
return null;
}
/**
* Get an event by ID from EventManager or cache
* @param eventId The ID of the event to retrieve
* @returns The event or null if not found
*/
public getEvent(eventId: string): NostrEvent | null {
// First check EventManager
const managedEvent = this.eventManager.getEvent(eventId);
if (managedEvent) {
return managedEvent.event;
}
// Then check cache
return this.findEventInCache(eventId);
}
/**
* Create a filter for HTTP message events (kinds 21120 and 21121)
* @param showAllEvents Whether to show all events or just those for the server
* @returns A filter for HTTP message events
*/
public createHttpMessageFilter(showAllEvents: boolean): NostrFilter {
const filter: NostrFilter = {
kinds: [21120, 21121], // Both HTTP Request and Response event kinds
};
// If "Show all events" is not checked, filter only for events addressed to the server
if (!showAllEvents) {
// Get the server pubkey from EventManager
const serverPubkey = this.eventManager.getServerPubkey();
// Add p-tag filter for events addressed to the server
if (serverPubkey) {
filter['#p'] = [serverPubkey];
}
}
return filter;
}
/**
* Get the EventManager instance used by this service
* @returns The EventManager instance
*/
public getEventManager(): EventManager {
return this.eventManager;
}
/**
* Update the status via callback if set
* @param statusMessage The status message
* @param statusClass The CSS class for styling the status
*/
private updateStatus(statusMessage: string, statusClass: string): void {
if (this.statusCallback) {
this.statusCallback(statusMessage, statusClass);
}
}
}

@ -2,10 +2,12 @@
* NostrService.ts
* Main service that coordinates Nostr protocol functionality by integrating specialized services
*/
// Project imports
import type { NostrEvent } from '../relay';
// Import auth manager to gate network requests
import * as authManager from '../auth-manager';
import type { ProfileData } from './NostrCacheService';
import { NostrCacheService } from './NostrCacheService';
import type { NostrFilter, NostrSubscription } from './NostrEventService';
@ -59,8 +61,15 @@ export class NostrService {
* Connect to a relay
* @param relayUrl The relay URL to connect to
* @returns A promise that resolves to true if connected successfully
* @throws Error if not authenticated
*/
public async connectToRelay(relayUrl: string): Promise<boolean> {
// Check authentication before allowing network requests
if (!authManager.isAuthenticated()) {
console.warn('Cannot connect to relay: User not authenticated');
throw new Error('Authentication required to connect to relay');
}
return this.relayService.connectToRelay(relayUrl);
}
@ -89,6 +98,12 @@ export class NostrService {
* @returns A promise that resolves to a NostrSubscription
*/
public async subscribeToEvents(filter: NostrFilter): Promise<NostrSubscription> {
// Check authentication before allowing network requests
if (!authManager.isAuthenticated()) {
console.warn('Cannot subscribe to events: User not authenticated');
throw new Error('Authentication required to subscribe to events');
}
return this.eventService.subscribeToEvents(filter);
}
@ -99,6 +114,13 @@ export class NostrService {
* @returns Promise resolving to the found event or null if not found
*/
public async queryFor31120Event(relayUrl: string, authorPubkey?: string | null): Promise<NostrEvent | null> {
// Check authentication before allowing network requests
if (!authManager.isAuthenticated()) {
console.warn('Cannot query for 31120 event: User not authenticated');
// Return null instead of throwing to maintain API compatibility
return null;
}
return this.eventService.queryFor31120Event(relayUrl, authorPubkey);
}
@ -108,6 +130,13 @@ export class NostrService {
* @returns Promise resolving to an array of matching events
*/
public async queryForAll31120Events(relayUrl: string): Promise<NostrEvent[]> {
// Check authentication before allowing network requests
if (!authManager.isAuthenticated()) {
console.warn('Cannot query for 31120 events: User not authenticated');
// Return empty array instead of throwing to maintain API compatibility
return [];
}
// Use the dedicated 31120 service instead of the event service
return this.service31120.queryForAll31120Events(relayUrl);
}
@ -120,6 +149,12 @@ export class NostrService {
*/
public async getEventById(relayUrl: string, eventId: string): Promise<NostrEvent | null> {
try {
// Check authentication before allowing network requests
if (!authManager.isAuthenticated()) {
console.warn('Cannot get event by ID: User not authenticated');
return null;
}
// Ensure we have an active connection
if (!this.relayService.isConnected() || this.relayService.getActiveRelayUrl() !== relayUrl) {
const connected = await this.relayService.connectToRelay(relayUrl);
@ -168,6 +203,12 @@ export class NostrService {
existingEventId?: string,
customServerPubkey?: string
): Promise<{ event: NostrEvent; serverNsec?: string } | null> {
// Check authentication before allowing network requests
if (!authManager.isAuthenticated()) {
console.warn('Cannot create/update 31120 event: User not authenticated');
return null;
}
// Use the dedicated 31120 service for this operation
return this.service31120.createOrUpdate31120Event(
relayUrl,
@ -208,6 +249,12 @@ export class NostrService {
privateKey: Uint8Array | string,
relayUrl: string
): Promise<NostrEvent | null> {
// Check authentication before allowing network requests
if (!authManager.isAuthenticated()) {
console.warn('Cannot create/publish 21121 event: User not authenticated');
return null;
}
return this.service21121.createAndPublish21121Event(
requestEvent,
responseContent,
@ -223,6 +270,12 @@ export class NostrService {
* @returns Promise resolving to the response event or null if not found
*/
public async findResponseForRequest(requestEventId: string, relayUrl: string): Promise<NostrEvent | null> {
// Check authentication before allowing network requests
if (!authManager.isAuthenticated()) {
console.warn('Cannot find response for request: User not authenticated');
return null;
}
return this.service21121.findResponseForRequest(requestEventId, relayUrl);
}
@ -243,6 +296,12 @@ export class NostrService {
* @returns A promise that resolves to ProfileData or null
*/
public async fetchProfileData(pubkey: string): Promise<ProfileData | null> {
// Check authentication before allowing network requests
if (!authManager.isAuthenticated()) {
console.warn('Cannot fetch profile data: User not authenticated');
return null;
}
return this.eventService.fetchProfileData(pubkey);
}

@ -1631,6 +1631,112 @@ footer {
line-height: 1.4;
}
/* Related events styling */
.related-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 8px;
color: var(--accent-color);
font-size: 16px;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: var(--bg-tertiary);
transition: all 0.2s ease;
}
.event-item:hover .related-indicator {
background-color: var(--accent-color);
color: white;
transform: scale(1.1);
}
.related-link-container {
margin-top: 8px;
padding: 5px 0;
}
.request-link {
display: inline-block;
color: var(--accent-color);
font-size: 12px;
text-decoration: none;
padding: 3px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--bg-tertiary);
transition: all 0.2s ease;
}
.request-link:hover {
background-color: var(--accent-color);
color: white;
}
.responses-container {
margin-top: 8px;
padding: 5px 0;
position: relative;
}
.responses-indicator {
display: inline-block;
position: relative;
}
.responses-count {
display: inline-block;
color: var(--button-success);
font-size: 12px;
padding: 3px 8px;
border: 1px solid var(--button-success);
border-radius: 4px;
background-color: rgba(40, 167, 69, 0.1);
cursor: pointer;
transition: all 0.2s ease;
}
.responses-count:hover {
background-color: var(--button-success);
color: white;
}
.responses-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0;
width: 220px;
max-height: 200px;
overflow-y: auto;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
z-index: 100;
padding: 8px 0;
margin-top: 5px;
}
.responses-indicator:hover .responses-dropdown {
display: block;
}
.response-link {
display: block;
padding: 6px 12px;
color: var(--text-primary);
text-decoration: none;
font-size: 12px;
transition: background-color 0.2s ease;
}
.response-link:hover {
background-color: var(--bg-tertiary);
color: var(--accent-color);
}
.event-actions {
text-align: right;
}
@ -2631,4 +2737,7 @@ footer {
.decryption-status.error::before {
content: '✗';
}
}
/* Import styles for the 21121 Response Creator component */
@import url('./styles/response-creator.css');

@ -0,0 +1,370 @@
/* Enhanced EventList Filters Styling */
/* Search container with icon */
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
margin-bottom: 10px;
width: 100%;
}
.search-icon {
position: absolute;
left: 10px;
font-size: 16px;
color: var(--text-tertiary);
}
.search-input {
flex: 1;
padding: 8px 8px 8px 34px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 14px;
background-color: var(--bg-secondary);
color: var(--text-primary);
}
.clear-search-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 16px;
padding: 0 8px;
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
}
.clear-search-btn:hover {
color: var(--accent-color);
}
/* Filter toggle button */
.filter-toggle-btn {
background-color: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
cursor: pointer;
margin-bottom: 10px;
}
.filter-toggle-btn:hover {
background-color: var(--accent-color);
color: white;
}
/* Collapsible filters container */
.filters-container {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
background-color: var(--bg-secondary);
border-radius: 4px;
border: 1px solid var(--border-color);
margin-bottom: 10px;
}
.filters-container.expanded {
max-height: 500px; /* Adjust as needed */
padding: 15px;
}
/* Filter options */
.filter-option {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid var(--border-color);
}
.filter-option:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.filter-label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: var(--text-secondary);
}
.filter-options {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.filter-options label {
display: flex;
align-items: center;
cursor: pointer;
}
.filter-options input[type="checkbox"] {
margin-right: 6px;
}
/* Saved filters section */
.saved-filters {
margin-top: 20px;
}
.saved-filters-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.saved-filters-header span {
font-weight: 600;
color: var(--text-secondary);
}
.save-filter-btn {
background: none;
border: none;
color: var(--accent-color);
cursor: pointer;
font-size: 18px;
}
.save-filter-btn:hover {
transform: scale(1.1);
}
.saved-filters-list {
max-height: 150px;
overflow-y: auto;
background-color: var(--bg-tertiary);
border-radius: 4px;
padding: 8px;
}
.empty-saved-filters {
color: var(--text-tertiary);
font-style: italic;
font-size: 13px;
text-align: center;
padding: 10px;
}
.saved-filter-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-radius: 4px;
margin-bottom: 5px;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.saved-filter-item:last-child {
margin-bottom: 0;
}
.saved-filter-link {
color: var(--text-primary);
text-decoration: none;
flex: 1;
}
.saved-filter-link:hover {
color: var(--accent-color);
}
.delete-filter-btn {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
font-size: 14px;
}
.delete-filter-btn:hover {
color: #e74c3c;
}
/* Enhanced event item with icons */
.event-item-container {
display: flex;
align-items: flex-start;
gap: 12px;
}
.event-icon-wrapper {
position: relative;
width: 40px;
height: 40px;
flex-shrink: 0;
}
.event-icon {
width: 40px;
height: 40px;
background-color: var(--bg-tertiary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: var(--text-secondary);
}
.event-item-container.request .event-icon {
color: var(--accent-color);
background-color: rgba(13, 110, 253, 0.1);
}
.event-item-container.response .event-icon {
color: var(--button-success);
background-color: rgba(40, 167, 69, 0.1);
}
.server-indicator {
position: absolute;
bottom: -2px;
right: -2px;
width: 18px;
height: 18px;
background-color: var(--accent-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: white;
border: 2px solid var(--bg-secondary);
}
.event-content-wrapper {
flex: 1;
min-width: 0; /* Prevent content from overflowing */
}
.event-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.event-type-badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
background-color: var(--bg-tertiary);
color: var(--text-secondary);
}
.event-type-badge.request {
background-color: rgba(13, 110, 253, 0.1);
color: var(--accent-color);
}
.event-type-badge.response {
background-color: rgba(40, 167, 69, 0.1);
color: var(--button-success);
}
.event-time {
font-size: 12px;
color: var(--text-tertiary);
}
.event-indicators {
display: flex;
gap: 6px;
}
.related-indicator {
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
background-color: var(--bg-tertiary);
color: var(--accent-color);
display: flex;
align-items: center;
}
.related-count {
margin-right: 4px;
font-weight: bold;
}
.encryption-indicator {
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
}
.encryption-indicator.encrypted {
background-color: rgba(231, 76, 60, 0.1);
color: #e74c3c;
}
.encryption-indicator.decrypted {
background-color: rgba(40, 167, 69, 0.1);
color: var(--button-success);
}
.event-id, .event-pubkey {
font-size: 12px;
margin-bottom: 6px;
color: var(--text-secondary);
}
.id-label, .from-label {
color: var(--text-tertiary);
margin-right: 4px;
}
.recipient {
display: inline-block;
margin-left: 10px;
color: var(--accent-color);
font-size: 12px;
}
.http-preview {
font-family: monospace;
font-size: 12px;
padding: 5px 8px;
margin: 5px 0;
background-color: var(--bg-tertiary);
border-radius: 4px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-success {
background-color: rgba(40, 167, 69, 0.1);
color: var(--button-success);
}
.status-redirect {
background-color: rgba(255, 193, 7, 0.1);
color: #ffc107;
}
.status-client-error {
background-color: rgba(231, 76, 60, 0.1);
color: #e74c3c;
}
.status-server-error {
background-color: rgba(156, 39, 176, 0.1);
color: #9c27b0;
}

@ -0,0 +1,131 @@
/* Styles for the 21121 Response Creation UI */
.creation-status {
margin-bottom: 20px;
padding: 15px;
border-radius: 8px;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
transition: all 0.3s ease;
}
.creation-status h4 {
margin-top: 0;
margin-bottom: 10px;
color: var(--accent-color);
font-size: 16px;
}
.creation-status p {
margin-bottom: 15px;
line-height: 1.5;
}
.creation-status.info {
border-left: 4px solid var(--accent-color);
}
.creation-status.loading {
border-left: 4px solid var(--accent-color);
background-color: var(--bg-tertiary);
position: relative;
}
.creation-status.loading::after {
content: '';
position: absolute;
left: 0;
bottom: 0;
height: 3px;
width: 50%;
background-color: var(--accent-color);
animation: loading-bar 1.5s infinite ease-in-out;
}
@keyframes loading-bar {
0% { width: 0; left: 0; }
50% { width: 70%; left: 15%; }
100% { width: 0; left: 100%; }
}
.creation-status.success {
border-left: 4px solid var(--button-success);
background-color: rgba(40, 167, 69, 0.1);
}
.creation-status.error {
border-left: 4px solid #e74c3c;
background-color: rgba(231, 76, 60, 0.1);
color: #e74c3c;
}
.response-label {
display: flex;
align-items: center;
margin-bottom: 15px;
font-weight: 500;
}
.response-label input[type="checkbox"] {
margin-right: 8px;
}
.relay-selection {
margin-bottom: 15px;
}
.relay-selection label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.relay-selection input {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--bg-secondary);
color: var(--text-primary);
}
.creation-buttons {
display: flex;
gap: 10px;
margin-top: 15px;
}
.view-response-event-btn {
display: inline-block;
margin-top: 10px;
padding: 8px 15px;
background-color: var(--button-success);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s ease;
}
.view-response-event-btn:hover {
background-color: var(--button-success-hover);
transform: translateY(-2px);
}
.create-21121-btn {
padding: 5px 10px;
background-color: var(--button-primary);
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
margin-left: 10px;
transition: all 0.2s ease;
}
.create-21121-btn:hover {
background-color: var(--button-hover);
transform: translateY(-2px);
}

@ -5,7 +5,10 @@ const NodePolyfillPlugin = require('node-polyfill-webpack-plugin');
module.exports = {
mode: 'development',
entry: './src/client.ts',
entry: {
client: './src/client.ts',
server: './src/server-ui.ts'
},
// Ensure webpack creates browser-compatible output
target: 'web',
// Add detailed error reporting