diff --git a/client/1120_client.html b/client/1120_client.html index 5750862..b24d27a 100644 --- a/client/1120_client.html +++ b/client/1120_client.html @@ -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> diff --git a/client/client-events-implementation-plan.md b/client/client-events-implementation-plan.md new file mode 100644 index 0000000..9f28b23 --- /dev/null +++ b/client/client-events-implementation-plan.md @@ -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 \ No newline at end of file diff --git a/client/src/client-event-handler.ts b/client/src/client-event-handler.ts new file mode 100644 index 0000000..237d562 --- /dev/null +++ b/client/src/client-event-handler.ts @@ -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; + } +} \ No newline at end of file diff --git a/client/src/client.ts b/client/src/client.ts index 8636e7b..bce2aa3 100644 --- a/client/src/client.ts +++ b/client/src/client.ts @@ -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) { diff --git a/client/src/components/ClientEventsTable.ts b/client/src/components/ClientEventsTable.ts new file mode 100644 index 0000000..53d25d2 --- /dev/null +++ b/client/src/components/ClientEventsTable.ts @@ -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'); + } +} diff --git a/client/src/services/ClientEventStore.ts b/client/src/services/ClientEventStore.ts new file mode 100644 index 0000000..865e7dc --- /dev/null +++ b/client/src/services/ClientEventStore.ts @@ -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); + } + } + } +} \ No newline at end of file diff --git a/client/styles/client-events-table.css b/client/styles/client-events-table.css new file mode 100644 index 0000000..8287896 --- /dev/null +++ b/client/styles/client-events-table.css @@ -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; } +} \ No newline at end of file