client refactor

This commit is contained in:
n 2025-04-10 13:59:03 +01:00
parent 8850feddf4
commit 01ee4f9f51
7 changed files with 1207 additions and 0 deletions

@ -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>

@ -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

@ -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) {

@ -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 = '&times;';
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');
}
}

@ -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);
}
}
}
}

@ -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; }
}