parent
8850feddf4
commit
01ee4f9f51
@ -8,6 +8,7 @@
|
||||
<!-- Load our CSS files -->
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
<link rel="stylesheet" href="./styles/event-list.css">
|
||||
<link rel="stylesheet" href="./styles/client-events-table.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation bar container - content will be injected by navbar.ts -->
|
||||
@ -108,6 +109,15 @@ User-Agent: Browser/1.0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client Events Table -->
|
||||
<div class="client-events-section">
|
||||
<h2>Outgoing Requests History</h2>
|
||||
<div class="info-box">
|
||||
<p>Below is a history of your outgoing KIND 21120 requests and their responses (KIND 21121). Click on a request to see details.</p>
|
||||
</div>
|
||||
<div id="clientEventsTableContainer"></div>
|
||||
</div>
|
||||
<!-- Include the webpack bundled JavaScript file with forced loading -->
|
||||
<script src="./client.bundle.js" onload="console.log('Client bundle loaded successfully')"></script>
|
||||
</body>
|
||||
|
76
client/client-events-implementation-plan.md
Normal file
76
client/client-events-implementation-plan.md
Normal file
@ -0,0 +1,76 @@
|
||||
# Client Events Implementation Plan
|
||||
|
||||
## Implementation Status
|
||||
|
||||
The implementation of the client-side event tracking system has been completed:
|
||||
|
||||
### 1. Core Services
|
||||
|
||||
- ✅ **ClientEventStore** - Tracks outgoing KIND 21120 events and incoming KIND 21121 responses
|
||||
- Stores events with metadata (status, timestamps, etc.)
|
||||
- Associates responses with their original requests via e tags
|
||||
- Provides methods for tracking, querying, and updating events
|
||||
|
||||
- ✅ **client-event-handler.ts** - Coordinates event tracking and relay subscriptions
|
||||
- Initializes the ClientEventStore
|
||||
- Sets up subscriptions to KIND 21121 responses
|
||||
- Provides methods for tracking outgoing events
|
||||
- Handles incoming response events
|
||||
|
||||
### 2. UI Components
|
||||
|
||||
- ✅ **ClientEventsTable** - Displays tracked events in a table format
|
||||
- Shows timestamps, target servers, and event status
|
||||
- Updates in real-time when events are added or updated
|
||||
- Provides detailed view through modal dialog
|
||||
- Displays both request and response content when available
|
||||
|
||||
### 3. Styling
|
||||
|
||||
- ✅ **client-events-table.css** - Provides styling for the events table and modal
|
||||
- Matches the look and feel of the existing application
|
||||
- Includes styles for the modal tabs and content views
|
||||
- Handles different event statuses with appropriate highlighting
|
||||
|
||||
### 4. Integration
|
||||
|
||||
- ✅ **1120_client.html** - Updated to include the client events table
|
||||
- Added container for the events table
|
||||
- Included link to the CSS file
|
||||
|
||||
- ✅ **client.ts** - Updated to initialize and use the event tracking system
|
||||
- Initializes client-event-handler with the relay service
|
||||
- Tracks outgoing events when published
|
||||
- Hooks into the existing event publishing flow
|
||||
|
||||
## Features
|
||||
|
||||
The implementation provides the following features:
|
||||
|
||||
1. **Event Tracking**: All outgoing 21120 events are automatically tracked
|
||||
2. **Response Association**: Incoming 21121 responses are associated with their original requests
|
||||
3. **Status Updates**: Event status is updated as they progress through their lifecycle
|
||||
4. **Visual Interface**: A table displays all events with their current status
|
||||
5. **Detailed View**: A modal dialog shows detailed information about events and their responses
|
||||
6. **Real-time Updates**: The UI updates automatically when events change or responses arrive
|
||||
|
||||
## Usage
|
||||
|
||||
The client events table is automatically initialized when the client page loads. It will:
|
||||
|
||||
1. Show all outgoing HTTP requests (KIND 21120 events)
|
||||
2. Update when responses (KIND 21121 events) are received
|
||||
3. Allow clicking on any event to see full details
|
||||
4. Display HTTP formatted content for easy reading
|
||||
|
||||
No additional user steps are required to use this functionality - it works automatically when sending HTTP requests through the client page.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Potential future enhancements could include:
|
||||
|
||||
1. Add filters to the event table (by status, target server, etc.)
|
||||
2. Implement persistence via local storage to maintain history between sessions
|
||||
3. Add export/import functionality for offline analysis
|
||||
4. Add ability to retry failed requests
|
||||
5. Improve visualization with charts or graphs of request/response patterns
|
217
client/src/client-event-handler.ts
Normal file
217
client/src/client-event-handler.ts
Normal file
@ -0,0 +1,217 @@
|
||||
/**
|
||||
* client-event-handler.ts
|
||||
*
|
||||
* Manages client-originated 21120 events and their 21121 responses.
|
||||
* This module is responsible for:
|
||||
* 1. Initializing the ClientEventStore to track outgoing events
|
||||
* 2. Setting up the ClientEventsTable for UI display
|
||||
* 3. Intercepting outgoing 21120 events for tracking
|
||||
* 4. Listening for incoming 21121 responses and associating them with requests
|
||||
*/
|
||||
|
||||
import { ClientEventStore, ClientEventStatus } from './services/ClientEventStore';
|
||||
import { ClientEventsTable } from './components/ClientEventsTable';
|
||||
import type { NostrEvent } from './relay';
|
||||
import { NostrRelayService } from './services/NostrRelayService';
|
||||
|
||||
// Create a singleton instance of the client event store
|
||||
const clientEventStore = new ClientEventStore();
|
||||
|
||||
// The table UI component (initialized in setup)
|
||||
let clientEventsTable: ClientEventsTable | null = null;
|
||||
|
||||
// Reference to the relay service for subscriptions
|
||||
let relayService: NostrRelayService | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the client event handler
|
||||
* @param relayServiceInstance The relay service to use for subscriptions
|
||||
*/
|
||||
export function initClientEventHandler(relayServiceInstance: NostrRelayService): void {
|
||||
// Store relay service reference
|
||||
relayService = relayServiceInstance;
|
||||
|
||||
// Initialize the client events table
|
||||
clientEventsTable = new ClientEventsTable(clientEventStore, 'clientEventsTableContainer');
|
||||
clientEventsTable.initialize();
|
||||
|
||||
// Set up subscription to KIND 21121 responses
|
||||
setupResponseSubscription();
|
||||
|
||||
console.log('Client event handler initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up subscription to KIND 21121 responses from relays
|
||||
* This allows us to associate responses with their original requests
|
||||
*/
|
||||
function setupResponseSubscription(): void {
|
||||
if (!relayService) {
|
||||
console.error('Cannot set up response subscription: relayService is null');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the active relay URL
|
||||
const activeRelayUrl = relayService.getActiveRelayUrl();
|
||||
if (!activeRelayUrl) {
|
||||
console.error('No active relay URL for subscription');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create filter for KIND 21121 responses
|
||||
const filter: { kinds: number[] } = { kinds: [21121] };
|
||||
|
||||
// Get the WebSocket manager to set up the subscription
|
||||
const wsManager = relayService.getWebSocketManager();
|
||||
|
||||
// Set up the websocket subscription
|
||||
wsManager.connect(activeRelayUrl, {
|
||||
timeout: 5000,
|
||||
onOpen: (ws) => {
|
||||
// Send a REQ message to subscribe
|
||||
const reqId = `client-events-21121-sub-${Date.now()}`;
|
||||
const reqMsg = JSON.stringify(["REQ", reqId, filter]);
|
||||
ws.send(reqMsg);
|
||||
console.log(`Subscribed to KIND 21121 responses on ${activeRelayUrl}`);
|
||||
},
|
||||
onMessage: (data) => {
|
||||
// Parse the incoming message
|
||||
try {
|
||||
// Type assertion for the received data
|
||||
const nostrData = data as unknown[];
|
||||
|
||||
// Handle EVENT messages
|
||||
if (Array.isArray(nostrData) && nostrData[0] === "EVENT" && nostrData.length >= 3) {
|
||||
const receivedEvent = nostrData[2] as NostrEvent;
|
||||
|
||||
// Process only KIND 21121 events
|
||||
if (receivedEvent && receivedEvent.kind === 21121) {
|
||||
handleIncomingResponse(receivedEvent);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing 21121 message:', error);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('WebSocket error in 21121 subscription:', error);
|
||||
},
|
||||
onClose: () => {
|
||||
console.log('21121 subscription connection closed');
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Failed to connect for 21121 subscription:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an outgoing KIND 21120 event
|
||||
* This should be called whenever a 21120 event is published
|
||||
*
|
||||
* @param event The KIND 21120 event being sent
|
||||
* @returns The event ID for reference
|
||||
*/
|
||||
export function trackOutgoingEvent(event: NostrEvent): string | null {
|
||||
if (!event || !event.id) {
|
||||
console.error('Cannot track invalid event');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (event.kind !== 21120) {
|
||||
console.warn(`Expected KIND 21120 event, got ${event.kind}`);
|
||||
}
|
||||
|
||||
// Add to the store
|
||||
return clientEventStore.addOutgoingEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a pending 21120 event before it's published
|
||||
* @param event The event being prepared
|
||||
*/
|
||||
export function markEventAsPending(event: NostrEvent): void {
|
||||
if (!event || !event.id) {
|
||||
console.error('Cannot mark invalid event as pending');
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = clientEventStore.addOutgoingEvent(event);
|
||||
if (eventId) {
|
||||
clientEventStore.updateEventStatus(eventId, ClientEventStatus.Pending);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an event as failed
|
||||
* @param eventId The ID of the event that failed
|
||||
*/
|
||||
export function markEventAsFailed(eventId: string): void {
|
||||
clientEventStore.updateEventStatus(eventId, ClientEventStatus.Failed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming KIND 21121 response
|
||||
* @param event The KIND 21121 response event
|
||||
* @returns true if the response was associated with a request
|
||||
*/
|
||||
export function handleIncomingResponse(event: NostrEvent): boolean {
|
||||
if (!event || !event.id) {
|
||||
console.error('Cannot handle invalid response event');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.kind !== 21121) {
|
||||
console.warn(`Expected KIND 21121 event, got ${event.kind}`);
|
||||
}
|
||||
|
||||
// Try to find the 'e' tag which should reference the original request
|
||||
const eTag = event.tags.find(tag => tag[0] === 'e');
|
||||
if (!eTag || eTag.length < 2) {
|
||||
console.warn('Response event missing valid e tag with request ID');
|
||||
return false;
|
||||
}
|
||||
|
||||
const requestId = eTag[1];
|
||||
|
||||
// Check if we have this request in our store
|
||||
const storedEvent = clientEventStore.getEvent(requestId);
|
||||
if (!storedEvent) {
|
||||
// This response doesn't match any of our tracked requests
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add the response to the store, linked to the original request
|
||||
return clientEventStore.addResponseEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client event store instance
|
||||
* Useful for direct access to the store from other modules
|
||||
* @returns The ClientEventStore instance
|
||||
*/
|
||||
export function getClientEventStore(): ClientEventStore {
|
||||
return clientEventStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of resources when module is unloaded
|
||||
* Call this when the application is shutting down or navigating away
|
||||
*/
|
||||
export function disposeClientEventHandler(): void {
|
||||
// Clean up the UI component
|
||||
if (clientEventsTable) {
|
||||
clientEventsTable.dispose();
|
||||
clientEventsTable = null;
|
||||
}
|
||||
|
||||
// Close WebSocket connections if needed
|
||||
if (relayService) {
|
||||
try {
|
||||
const wsManager = relayService.getWebSocketManager();
|
||||
wsManager.close();
|
||||
} catch (e) {
|
||||
console.error('Error closing WebSocket connection:', e);
|
||||
}
|
||||
relayService = null;
|
||||
}
|
||||
}
|
@ -40,6 +40,12 @@ import * as nostrTools from 'nostr-tools';
|
||||
import type { NostrEvent } from './converter';
|
||||
// Import functions from internal modules
|
||||
import { displayConvertedEvent } from './converter';
|
||||
// Import client events tracking system
|
||||
import {
|
||||
initClientEventHandler,
|
||||
trackOutgoingEvent,
|
||||
handleIncomingResponse
|
||||
} from './client-event-handler';
|
||||
import { publishToRelay, convertNpubToHex, verifyEvent } from './relay';
|
||||
// Import profile functions (not using direct imports since we'll load modules based on page)
|
||||
// This ensures all page modules are included in the bundle
|
||||
@ -135,6 +141,10 @@ const nostr31120Service = new Nostr31120Service(
|
||||
nostrService.getCacheService()
|
||||
);
|
||||
|
||||
// Initialize client event tracking
|
||||
// This needs to happen after nostrService is initialized
|
||||
initClientEventHandler(nostrService.getRelayService());
|
||||
|
||||
/**
|
||||
* Handle showing the server selection modal
|
||||
*/
|
||||
@ -774,6 +784,10 @@ async function handlePublishEvent(): Promise<void> {
|
||||
showLoading(publishResultDiv, 'Publishing to relay...');
|
||||
|
||||
try {
|
||||
// Track the event before publishing
|
||||
trackOutgoingEvent(event);
|
||||
|
||||
// Publish the event
|
||||
const result = await publishToRelay(event, relayUrl);
|
||||
showSuccess(publishResultDiv, result);
|
||||
} catch (publishError) {
|
||||
@ -848,6 +862,10 @@ async function handlePublishEvent(): Promise<void> {
|
||||
publishResultDiv.innerHTML += '<br><span>Attempting to publish...</span>';
|
||||
|
||||
try {
|
||||
// Track the event in our client store
|
||||
trackOutgoingEvent(event);
|
||||
|
||||
// Publish the event
|
||||
const result = await publishToRelay(event, relayUrl);
|
||||
showSuccess(publishResultDiv, result);
|
||||
} catch (publishError) {
|
||||
|
420
client/src/components/ClientEventsTable.ts
Normal file
420
client/src/components/ClientEventsTable.ts
Normal file
@ -0,0 +1,420 @@
|
||||
/**
|
||||
* ClientEventsTable.ts
|
||||
*
|
||||
* A component for displaying client-originated KIND 21120 events and their KIND 21121 responses
|
||||
* in a table format with a detailed view modal.
|
||||
*/
|
||||
|
||||
import { EventChangeType } from '../services/EventManager';
|
||||
import type { ClientEventStore, ClientStoredEvent } from '../services/ClientEventStore';
|
||||
import { ClientEventStatus } from '../services/ClientEventStore';
|
||||
import { HttpFormatter } from '../services/HttpFormatter';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
export class ClientEventsTable {
|
||||
private container: HTMLElement | null = null;
|
||||
private eventStore: ClientEventStore;
|
||||
private tableBody: HTMLElement | null = null;
|
||||
private unregisterListener: (() => void) | null = null;
|
||||
private modal: HTMLElement | null = null;
|
||||
private modalContent: HTMLElement | null = null;
|
||||
private currentEventId: string | null = null;
|
||||
|
||||
constructor(eventStore: ClientEventStore, containerId: string) {
|
||||
this.eventStore = eventStore;
|
||||
this.container = document.getElementById(containerId);
|
||||
}
|
||||
|
||||
public initialize(): void {
|
||||
if (!this.container) {
|
||||
console.error('Client events table container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the table structure
|
||||
this.createTableStructure();
|
||||
|
||||
// Create the modal dialog
|
||||
this.createModalDialog();
|
||||
|
||||
// Register for event changes
|
||||
this.unregisterListener = this.eventStore.registerListener((eventId, changeType) => {
|
||||
switch (changeType) {
|
||||
case EventChangeType.Added:
|
||||
this.renderEventRow(eventId);
|
||||
break;
|
||||
case EventChangeType.Updated:
|
||||
this.updateEventRow(eventId);
|
||||
this.refreshModalIfNeeded(eventId);
|
||||
break;
|
||||
case EventChangeType.Removed:
|
||||
this.removeEventRow(eventId);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Render existing events
|
||||
this.renderExistingEvents();
|
||||
}
|
||||
|
||||
private createTableStructure(): void {
|
||||
if (!this.container) { return; }
|
||||
|
||||
const tableHtml = `
|
||||
<table class="client-events-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Target Server</th>
|
||||
<th>Event ID</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="clientEventsTableBody">
|
||||
<tr class="table-empty-state">
|
||||
<td colspan="4">No outgoing HTTP requests yet. Use the form above to send requests.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
this.container.innerHTML = tableHtml;
|
||||
this.tableBody = document.getElementById('clientEventsTableBody');
|
||||
}
|
||||
|
||||
private renderExistingEvents(): void {
|
||||
if (!this.tableBody) { return; }
|
||||
|
||||
// Clear existing content
|
||||
this.tableBody.innerHTML = '';
|
||||
|
||||
// Get all events from the store
|
||||
const events = this.eventStore.getAllEvents();
|
||||
|
||||
// If no events, show the empty state
|
||||
if (events.length === 0) {
|
||||
this.tableBody.innerHTML = `
|
||||
<tr class="table-empty-state">
|
||||
<td colspan="4">No outgoing HTTP requests yet. Use the form above to send requests.</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort events by sent time (newest first)
|
||||
events.sort((a, b) => b.sentAt - a.sentAt);
|
||||
|
||||
// Render each event
|
||||
events.forEach(event => {
|
||||
this.renderEventRow(event.id);
|
||||
});
|
||||
}
|
||||
|
||||
private renderEventRow(eventId: string): HTMLElement | null {
|
||||
if (!this.tableBody) { return null; }
|
||||
|
||||
// Get the event from store
|
||||
const storedEvent = this.eventStore.getEvent(eventId);
|
||||
if (!storedEvent) { return null; }
|
||||
|
||||
// Check if row already exists
|
||||
const existingRow = document.getElementById(`client-event-row-${eventId}`);
|
||||
if (existingRow) {
|
||||
this.updateEventRow(eventId);
|
||||
return existingRow as HTMLElement;
|
||||
}
|
||||
|
||||
// Create a new row
|
||||
const row = document.createElement('tr');
|
||||
row.id = `client-event-row-${eventId}`;
|
||||
row.dataset.eventId = eventId;
|
||||
row.className = 'event-row';
|
||||
|
||||
// Format timestamp
|
||||
const timestamp = new Date(storedEvent.sentAt).toLocaleTimeString();
|
||||
|
||||
// Find p tag for target server
|
||||
const targetServerTag = storedEvent.event.tags.find(tag => tag[0] === 'p');
|
||||
let targetServer = 'Unknown';
|
||||
|
||||
if (targetServerTag && targetServerTag.length > 1) {
|
||||
try {
|
||||
const npub = nip19.npubEncode(targetServerTag[1]);
|
||||
targetServer = `${npub.substring(0, 8)}...${npub.substring(npub.length - 4)}`;
|
||||
} catch (e) {
|
||||
targetServer = targetServerTag[1].substring(0, 8) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
// Format event ID
|
||||
const shortEventId = eventId.substring(0, 8) + '...';
|
||||
|
||||
// Determine status indicator
|
||||
let statusHtml = this.getStatusHtml(storedEvent.status);
|
||||
|
||||
// Set row HTML
|
||||
row.innerHTML = `
|
||||
<td class="time-cell">${timestamp}</td>
|
||||
<td class="server-cell" title="${targetServerTag ? targetServerTag[1] : 'Unknown'}">${targetServer}</td>
|
||||
<td class="event-id-cell" title="${eventId}">${shortEventId}</td>
|
||||
<td class="status-cell">${statusHtml}</td>
|
||||
`;
|
||||
|
||||
// Add click handler
|
||||
row.addEventListener('click', () => {
|
||||
this.showModal(eventId);
|
||||
});
|
||||
|
||||
// Remove empty state if present
|
||||
const emptyStateRow = this.tableBody.querySelector('.table-empty-state');
|
||||
if (emptyStateRow) {
|
||||
emptyStateRow.remove();
|
||||
}
|
||||
|
||||
// Add to table at the top
|
||||
if (this.tableBody.firstChild) {
|
||||
this.tableBody.insertBefore(row, this.tableBody.firstChild);
|
||||
} else {
|
||||
this.tableBody.appendChild(row);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private getStatusHtml(status: ClientEventStatus): string {
|
||||
switch (status) {
|
||||
case ClientEventStatus.Sent:
|
||||
return '<span class="status-sent">Sent</span>';
|
||||
case ClientEventStatus.Responded:
|
||||
return '<span class="status-responded">Responded ✓</span>';
|
||||
case ClientEventStatus.Pending:
|
||||
return '<span class="status-pending">Pending...</span>';
|
||||
case ClientEventStatus.Failed:
|
||||
return '<span class="status-failed">Failed ✗</span>';
|
||||
default:
|
||||
return '<span>Unknown</span>';
|
||||
}
|
||||
}
|
||||
|
||||
private updateEventRow(eventId: string): void {
|
||||
const row = document.getElementById(`client-event-row-${eventId}`);
|
||||
if (!row) {
|
||||
// If row doesn't exist, create it
|
||||
this.renderEventRow(eventId);
|
||||
return;
|
||||
}
|
||||
|
||||
const storedEvent = this.eventStore.getEvent(eventId);
|
||||
if (!storedEvent) { return; }
|
||||
|
||||
// Update the status cell
|
||||
const statusCell = row.querySelector('.status-cell');
|
||||
if (statusCell) {
|
||||
statusCell.innerHTML = this.getStatusHtml(storedEvent.status);
|
||||
}
|
||||
|
||||
// Highlight row briefly to indicate update
|
||||
row.classList.add('updated');
|
||||
setTimeout(() => {
|
||||
row.classList.remove('updated');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
private removeEventRow(eventId: string): void {
|
||||
const row = document.getElementById(`client-event-row-${eventId}`);
|
||||
|
||||
if (row) { row.remove(); }
|
||||
|
||||
// Check if we need to show the empty state
|
||||
if (this.tableBody && !this.tableBody.querySelector('.event-row')) {
|
||||
this.tableBody.innerHTML = `
|
||||
<tr class="table-empty-state">
|
||||
<td colspan="4">No outgoing HTTP requests yet. Use the form above to send requests.</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
}
|
||||
private refreshModalIfNeeded(eventId: string): void {
|
||||
if (this.currentEventId === eventId) {
|
||||
this.loadEventData(eventId);
|
||||
}
|
||||
}
|
||||
|
||||
private loadEventData(eventId: string): void {
|
||||
const storedEvent = this.eventStore.getEvent(eventId);
|
||||
if (!storedEvent || !this.modal) return;
|
||||
|
||||
// Get tab content containers
|
||||
const jsonTab = document.getElementById('tab-21120-json-content');
|
||||
const httpRequestTab = document.getElementById('tab-21120-http-content');
|
||||
const httpResponseTab = document.getElementById('tab-21121-http-content');
|
||||
const jsonResponseTab = document.getElementById('tab-21121-json-content');
|
||||
|
||||
if (!jsonTab || !httpRequestTab || !httpResponseTab || !jsonResponseTab) return;
|
||||
|
||||
// Populate 21120 JSON tab
|
||||
jsonTab.innerHTML = `<pre>${JSON.stringify(storedEvent.event, null, 2)}</pre>`;
|
||||
|
||||
// Populate 21120 HTTP request tab
|
||||
try {
|
||||
httpRequestTab.innerHTML = HttpFormatter.formatHttpContent(storedEvent.event.content, true, true);
|
||||
} catch (e) {
|
||||
httpRequestTab.innerHTML = `<pre>${storedEvent.event.content}</pre>`;
|
||||
}
|
||||
|
||||
// Check if we have a response
|
||||
const responseEvent = storedEvent.responseEvent;
|
||||
if (responseEvent) {
|
||||
// Populate 21121 JSON response tab
|
||||
jsonResponseTab.innerHTML = `<pre>${JSON.stringify(responseEvent, null, 2)}</pre>`;
|
||||
|
||||
// Populate 21121 HTTP response tab
|
||||
try {
|
||||
httpResponseTab.innerHTML = HttpFormatter.formatHttpContent(responseEvent.content, false, true);
|
||||
} catch (e) {
|
||||
httpResponseTab.innerHTML = `<pre>${responseEvent.content}</pre>`;
|
||||
}
|
||||
} else {
|
||||
// No response yet
|
||||
jsonResponseTab.innerHTML = '<div class="empty-response">No response received yet</div>';
|
||||
httpResponseTab.innerHTML = '<div class="empty-response">No response received yet</div>';
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
// Remove event listener
|
||||
if (this.unregisterListener) {
|
||||
this.unregisterListener();
|
||||
this.unregisterListener = null;
|
||||
}
|
||||
|
||||
// Remove modal from DOM
|
||||
if (this.modal && this.modal.parentNode) {
|
||||
this.modal.parentNode.removeChild(this.modal);
|
||||
}
|
||||
|
||||
// Clear references
|
||||
this.container = null;
|
||||
this.tableBody = null;
|
||||
this.modal = null;
|
||||
this.modalContent = null;
|
||||
this.currentEventId = null;
|
||||
}
|
||||
|
||||
private createModalDialog(): void {
|
||||
// Create modal element
|
||||
this.modal = document.createElement('div');
|
||||
this.modal.className = 'client-events-modal';
|
||||
this.modal.style.display = 'none';
|
||||
|
||||
// Create close button
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.className = 'modal-close-button';
|
||||
closeButton.innerHTML = '×';
|
||||
closeButton.addEventListener('click', () => this.hideModal());
|
||||
|
||||
// Create modal content
|
||||
this.modalContent = document.createElement('div');
|
||||
this.modalContent.className = 'modal-content';
|
||||
|
||||
// Create tabs
|
||||
const tabsContainer = document.createElement('div');
|
||||
tabsContainer.className = 'modal-tabs';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'tab-21120-json', label: '21120 Raw JSON' },
|
||||
{ id: 'tab-21120-http', label: '21120 HTTP Request' },
|
||||
{ id: 'tab-21121-http', label: '21121 HTTP Response' },
|
||||
{ id: 'tab-21121-json', label: '21121 Raw JSON' }
|
||||
];
|
||||
|
||||
// Create tab elements
|
||||
tabs.forEach(tab => {
|
||||
const tabButton = document.createElement('button');
|
||||
tabButton.className = 'modal-tab';
|
||||
tabButton.id = tab.id;
|
||||
tabButton.textContent = tab.label;
|
||||
tabButton.dataset.tabId = tab.id + '-content';
|
||||
tabButton.addEventListener('click', (e) => this.switchTab(e));
|
||||
tabsContainer.appendChild(tabButton);
|
||||
});
|
||||
|
||||
// Create tab content containers
|
||||
const tabContentsContainer = document.createElement('div');
|
||||
tabContentsContainer.className = 'modal-tab-contents';
|
||||
|
||||
tabs.forEach(tab => {
|
||||
const tabContent = document.createElement('div');
|
||||
tabContent.className = 'modal-tab-content';
|
||||
tabContent.id = tab.id + '-content';
|
||||
tabContentsContainer.appendChild(tabContent);
|
||||
});
|
||||
|
||||
// Assemble the modal
|
||||
this.modalContent.appendChild(tabsContainer);
|
||||
this.modalContent.appendChild(tabContentsContainer);
|
||||
this.modal.appendChild(closeButton);
|
||||
this.modal.appendChild(this.modalContent);
|
||||
|
||||
// Add modal to document body
|
||||
document.body.appendChild(this.modal);
|
||||
|
||||
// Add event listener to close modal when clicking outside
|
||||
window.addEventListener('click', (event) => {
|
||||
if (event.target === this.modal) {
|
||||
this.hideModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showModal(eventId: string): void {
|
||||
if (!this.modal) return;
|
||||
|
||||
this.currentEventId = eventId;
|
||||
|
||||
// Load data for all tabs
|
||||
this.loadEventData(eventId);
|
||||
|
||||
// Show the modal
|
||||
this.modal.style.display = 'block';
|
||||
|
||||
// Select the first tab by default
|
||||
const firstTab = this.modal?.querySelector('.modal-tab') as HTMLElement;
|
||||
if (firstTab) {
|
||||
firstTab.click();
|
||||
}
|
||||
}
|
||||
|
||||
private hideModal(): void {
|
||||
if (!this.modal) return;
|
||||
|
||||
this.modal.style.display = 'none';
|
||||
this.currentEventId = null;
|
||||
}
|
||||
|
||||
private switchTab(event: Event): void {
|
||||
const clickedTab = event.currentTarget as HTMLElement;
|
||||
if (!clickedTab || !clickedTab.dataset.tabId) return;
|
||||
|
||||
// Hide all tab contents
|
||||
const tabContents = document.querySelectorAll('.modal-tab-content');
|
||||
tabContents.forEach(content => {
|
||||
(content as HTMLElement).style.display = 'none';
|
||||
});
|
||||
|
||||
// Remove active class from all tabs
|
||||
const tabs = document.querySelectorAll('.modal-tab');
|
||||
tabs.forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
|
||||
// Show selected tab content
|
||||
const selectedContent = document.getElementById(clickedTab.dataset.tabId);
|
||||
if (selectedContent) {
|
||||
selectedContent.style.display = 'block';
|
||||
}
|
||||
|
||||
// Add active class to clicked tab
|
||||
clickedTab.classList.add('active');
|
||||
}
|
||||
}
|
276
client/src/services/ClientEventStore.ts
Normal file
276
client/src/services/ClientEventStore.ts
Normal file
@ -0,0 +1,276 @@
|
||||
/**
|
||||
* ClientEventStore.ts
|
||||
*
|
||||
* A dedicated store for client-originated KIND 21120 events and their KIND 21121 responses.
|
||||
* This service tracks outgoing requests from the client, as well as any incoming responses,
|
||||
* and maintains the relationships between them.
|
||||
*/
|
||||
|
||||
import type { NostrEvent } from '../relay';
|
||||
import { EventChangeType } from './EventManager';
|
||||
|
||||
/**
|
||||
* Enum representing the possible states of a client event
|
||||
*/
|
||||
export enum ClientEventStatus {
|
||||
Sent, // Event was created and sent to a relay
|
||||
Responded, // A response has been received for this event
|
||||
Pending, // Event is being prepared or sent (not yet confirmed)
|
||||
Failed // There was an error sending the event
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface representing a stored client event with metadata
|
||||
*/
|
||||
export interface ClientStoredEvent {
|
||||
id: string; // Event ID
|
||||
event: NostrEvent; // The actual Nostr event (KIND 21120)
|
||||
sentAt: number; // Timestamp when the event was sent
|
||||
status: ClientEventStatus; // Current status of the event
|
||||
responseId?: string; // ID of the response event (if any)
|
||||
responseEvent?: NostrEvent; // The response event (KIND 21121, if received)
|
||||
responseReceivedAt?: number; // Timestamp when the response was received
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for client event change listeners
|
||||
*/
|
||||
export interface ClientEventChangeListener {
|
||||
(eventId: string, changeType: EventChangeType): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ClientEventStore class for managing client-side events
|
||||
*/
|
||||
export class ClientEventStore {
|
||||
// Primary storage for outgoing KIND 21120 events
|
||||
private outgoingEvents: Map<string, ClientStoredEvent> = new Map();
|
||||
|
||||
// Map to track relationships (KIND 21121 eventId -> KIND 21120 eventId)
|
||||
private responseMap: Map<string, string> = new Map();
|
||||
|
||||
// Event change listeners
|
||||
private listeners: ClientEventChangeListener[] = [];
|
||||
|
||||
/**
|
||||
* Add an outgoing KIND 21120 event to the store
|
||||
* @param event The Nostr event to add
|
||||
* @returns The ID of the added event, or null if invalid
|
||||
*/
|
||||
public addOutgoingEvent(event: NostrEvent): string | null {
|
||||
if (!event.id) {
|
||||
console.error('Event must have an ID');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (event.kind !== 21120) {
|
||||
console.warn(`Expected KIND 21120 event, got ${event.kind}`);
|
||||
}
|
||||
|
||||
const storedEvent: ClientStoredEvent = {
|
||||
id: event.id,
|
||||
event,
|
||||
sentAt: Date.now(),
|
||||
status: ClientEventStatus.Sent
|
||||
};
|
||||
|
||||
this.outgoingEvents.set(event.id, storedEvent);
|
||||
this.notifyListeners(event.id, EventChangeType.Added);
|
||||
return event.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a KIND 21121 response event to the store and associate it with its request
|
||||
* @param responseEvent The KIND 21121 response event
|
||||
* @returns True if successfully added, false otherwise
|
||||
*/
|
||||
public addResponseEvent(responseEvent: NostrEvent): boolean {
|
||||
if (!responseEvent.id) {
|
||||
console.error('Response event must have an ID');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (responseEvent.kind !== 21121) {
|
||||
console.warn(`Expected KIND 21121 event, got ${responseEvent.kind}`);
|
||||
}
|
||||
|
||||
// Extract request event ID from e tag
|
||||
const requestId = this.getRequestIdFromResponseEvent(responseEvent);
|
||||
if (!requestId) {
|
||||
console.error('Response event missing valid e tag with request ID');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we have the outgoing event
|
||||
const outgoingEvent = this.outgoingEvents.get(requestId);
|
||||
if (!outgoingEvent) {
|
||||
console.warn(`Response received for unknown request: ${requestId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the outgoing event with response data
|
||||
outgoingEvent.status = ClientEventStatus.Responded;
|
||||
outgoingEvent.responseId = responseEvent.id;
|
||||
outgoingEvent.responseEvent = responseEvent;
|
||||
outgoingEvent.responseReceivedAt = Date.now();
|
||||
|
||||
// Update the outgoing events map
|
||||
this.outgoingEvents.set(requestId, outgoingEvent);
|
||||
|
||||
// Update the response map
|
||||
this.responseMap.set(responseEvent.id, requestId);
|
||||
|
||||
// Notify listeners
|
||||
this.notifyListeners(requestId, EventChangeType.Updated);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status of an outgoing event
|
||||
* @param eventId The ID of the event to update
|
||||
* @param status The new status
|
||||
* @returns True if successful, false if event not found
|
||||
*/
|
||||
public updateEventStatus(eventId: string, status: ClientEventStatus): boolean {
|
||||
const storedEvent = this.outgoingEvents.get(eventId);
|
||||
if (!storedEvent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
storedEvent.status = status;
|
||||
this.outgoingEvents.set(eventId, storedEvent);
|
||||
this.notifyListeners(eventId, EventChangeType.Updated);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all outgoing events
|
||||
* @returns Array of all client stored events
|
||||
*/
|
||||
public getAllEvents(): ClientStoredEvent[] {
|
||||
return Array.from(this.outgoingEvents.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific event by ID
|
||||
* @param id The ID of the event to retrieve
|
||||
* @returns The client stored event or null if not found
|
||||
*/
|
||||
public getEvent(id: string): ClientStoredEvent | null {
|
||||
return this.outgoingEvents.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the response event for a request
|
||||
* @param requestId The ID of the request event
|
||||
* @returns The response event or null if no response yet
|
||||
*/
|
||||
public getResponseForEvent(requestId: string): NostrEvent | null {
|
||||
const storedEvent = this.outgoingEvents.get(requestId);
|
||||
return storedEvent?.responseEvent || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the request ID associated with a response
|
||||
* @param responseId The ID of the response event
|
||||
* @returns The request ID or null if not found
|
||||
*/
|
||||
public getRequestForResponse(responseId: string): string | null {
|
||||
return this.responseMap.get(responseId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a response event is associated with one of our requests
|
||||
* @param responseEvent The response event to check
|
||||
* @returns True if it's a response to one of our tracked requests
|
||||
*/
|
||||
public isResponseToOurRequest(responseEvent: NostrEvent): boolean {
|
||||
const requestId = this.getRequestIdFromResponseEvent(responseEvent);
|
||||
return requestId !== null && this.outgoingEvents.has(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an event and its response (if any)
|
||||
* @param eventId The ID of the event to remove
|
||||
* @returns True if removed, false if not found
|
||||
*/
|
||||
public removeEvent(eventId: string): boolean {
|
||||
const storedEvent = this.outgoingEvents.get(eventId);
|
||||
if (!storedEvent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If there's a response, remove it from the response map
|
||||
if (storedEvent.responseId) {
|
||||
this.responseMap.delete(storedEvent.responseId);
|
||||
}
|
||||
|
||||
// Remove from outgoing events
|
||||
this.outgoingEvents.delete(eventId);
|
||||
|
||||
// Notify listeners
|
||||
this.notifyListeners(eventId, EventChangeType.Removed);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all events from the store
|
||||
*/
|
||||
public clearAllEvents(): void {
|
||||
const eventIds = Array.from(this.outgoingEvents.keys());
|
||||
|
||||
// Clear storage
|
||||
this.outgoingEvents.clear();
|
||||
this.responseMap.clear();
|
||||
|
||||
// Notify listeners about each removed event
|
||||
for (const id of eventIds) {
|
||||
this.notifyListeners(id, EventChangeType.Removed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a listener for event changes
|
||||
* @param listener The listener function
|
||||
* @returns Function to unregister the listener
|
||||
*/
|
||||
public registerListener(listener: ClientEventChangeListener): () => void {
|
||||
this.listeners.push(listener);
|
||||
|
||||
// Return unregister function
|
||||
return () => {
|
||||
const index = this.listeners.indexOf(listener);
|
||||
if (index !== -1) {
|
||||
this.listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract request ID from a response event
|
||||
* @param responseEvent The response event
|
||||
* @returns The request ID or null if not found
|
||||
*/
|
||||
private getRequestIdFromResponseEvent(responseEvent: NostrEvent): string | null {
|
||||
// Look for an e tag that references the original request
|
||||
const eTag = responseEvent.tags.find(tag => tag[0] === 'e');
|
||||
return eTag && eTag.length > 1 ? eTag[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 client event change listener:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
190
client/styles/client-events-table.css
Normal file
190
client/styles/client-events-table.css
Normal file
@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Styles for the ClientEventsTable component
|
||||
* Contains styling for:
|
||||
* - Table structure and layout
|
||||
* - Status indicators
|
||||
* - Modal dialog and tab interface
|
||||
*/
|
||||
|
||||
/* Main table styles */
|
||||
.client-events-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.client-events-table th,
|
||||
.client-events-table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.client-events-table th {
|
||||
background-color: var(--bg-secondary);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.client-events-table tbody tr:hover {
|
||||
background-color: var(--bg-hover);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-events-table .table-empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status-sent {
|
||||
color: var(--color-info);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-responded {
|
||||
color: var(--color-success);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
color: var(--color-warning);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
color: var(--color-error);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Row updated highlight effect */
|
||||
.client-events-table tr.updated {
|
||||
animation: row-highlight 2s;
|
||||
}
|
||||
|
||||
@keyframes row-highlight {
|
||||
0% { background-color: rgba(var(--color-info-rgb), 0.2); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
|
||||
/* Modal dialog styles */
|
||||
.client-events-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.client-events-modal .modal-content {
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
width: 80%;
|
||||
max-width: 900px;
|
||||
max-height: 80%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.client-events-modal .modal-close-button {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
font-size: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Tab navigation */
|
||||
.client-events-modal .modal-tabs {
|
||||
display: flex;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.client-events-modal .modal-tab {
|
||||
padding: 10px 15px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.client-events-modal .modal-tab.active {
|
||||
border-bottom: 2px solid var(--color-accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.client-events-modal .modal-tab:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Tab content */
|
||||
.client-events-modal .modal-tab-contents {
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
max-height: calc(80vh - 50px);
|
||||
}
|
||||
|
||||
.client-events-modal .modal-tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* JSON content formatting */
|
||||
.client-events-modal pre.json-content {
|
||||
background-color: var(--bg-code);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
max-height: 350px;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* HTTP content formatting */
|
||||
.client-events-modal .http-content {
|
||||
background-color: var(--bg-code);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.client-events-modal .no-response {
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Update notification */
|
||||
.client-events-modal .modal-update-notification {
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
right: 20px;
|
||||
background-color: var(--color-success);
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
animation: fade-in-out 2s;
|
||||
}
|
||||
|
||||
@keyframes fade-in-out {
|
||||
0% { opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user