parent
07ad835a77
commit
edd5f9f254
client
1120_client.html1120_server.htmlanalysis.mdbillboard.htmlindex.htmlprofile.htmlserver-ui-refactoring-prompts.md
src
auth-manager.tsbillboard.tsclient.ts
styles.csscomponents
debug-events.jshttp-response-viewer.updated.tsnavbar-diagnostics.tsnavbar-init.tsnavbar.tsserver-ui.tsservices
EventDetailsRenderer.integration.mdEventDetailsRenderer.tsEventDetailsRenderer.updated.tsEventListRenderer.integration.mdEventListRenderer.updated.tsEventManager.README.mdEventManager.initialization.tsEventManager.test.tsEventManager.tsHttpService.tsNostrEventService.integration.mdNostrEventService.updated.tsNostrService.ts
styles
webpack.config.js@ -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
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>
|
||||
|
278
client/server-ui-refactoring-prompts.md
Normal file
278
client/server-ui-refactoring-prompts.md
Normal file
@ -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
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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
310
client/src/components/EventDetail.ts
Normal file
310
client/src/components/EventDetail.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
1147
client/src/components/EventList.ts
Normal file
1147
client/src/components/EventList.ts
Normal file
File diff suppressed because it is too large
Load Diff
228
client/src/components/HttpRequestExecutor.ts
Normal file
228
client/src/components/HttpRequestExecutor.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
372
client/src/components/Nostr21121Creator.ts
Normal file
372
client/src/components/Nostr21121Creator.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
488
client/src/components/ResponseViewer.ts
Normal file
488
client/src/components/ResponseViewer.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
294
client/src/components/ServerUI.ts
Normal file
294
client/src/components/ServerUI.ts
Normal file
@ -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;
|
||||
}
|
78
client/src/debug-events.js
Normal file
78
client/src/debug-events.js
Normal file
@ -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);
|
||||
});
|
308
client/src/http-response-viewer.updated.ts
Normal file
308
client/src/http-response-viewer.updated.ts
Normal file
@ -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');
|
||||
}
|
||||
}
|
148
client/src/navbar-diagnostics.ts
Normal file
148
client/src/navbar-diagnostics.ts
Normal file
@ -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
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);
|
||||
});
|
||||
});
|
||||
};
|
315
client/src/services/EventDetailsRenderer.integration.md
Normal file
315
client/src/services/EventDetailsRenderer.integration.md
Normal file
@ -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
|
||||
|
300
client/src/services/EventDetailsRenderer.updated.ts
Normal file
300
client/src/services/EventDetailsRenderer.updated.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
296
client/src/services/EventListRenderer.integration.md
Normal file
296
client/src/services/EventListRenderer.integration.md
Normal file
@ -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
|
303
client/src/services/EventListRenderer.updated.ts
Normal file
303
client/src/services/EventListRenderer.updated.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
237
client/src/services/EventManager.README.md
Normal file
237
client/src/services/EventManager.README.md
Normal file
@ -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
|
209
client/src/services/EventManager.initialization.ts
Normal file
209
client/src/services/EventManager.initialization.ts
Normal file
@ -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;
|
247
client/src/services/EventManager.test.ts
Normal file
247
client/src/services/EventManager.test.ts
Normal file
@ -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 };
|
538
client/src/services/EventManager.ts
Normal file
538
client/src/services/EventManager.ts
Normal file
@ -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);
|
||||
|
266
client/src/services/NostrEventService.integration.md
Normal file
266
client/src/services/NostrEventService.integration.md
Normal file
@ -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
|
343
client/src/services/NostrEventService.updated.ts
Normal file
343
client/src/services/NostrEventService.updated.ts
Normal file
@ -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');
|
370
client/styles/event-list.css
Normal file
370
client/styles/event-list.css
Normal file
@ -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;
|
||||
}
|
131
client/styles/response-creator.css
Normal file
131
client/styles/response-creator.css
Normal file
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user