From 01ee4f9f51e20b0c76992444b93d5e4a9bcdc07d Mon Sep 17 00:00:00 2001
From: n <n>
Date: Thu, 10 Apr 2025 13:59:03 +0100
Subject: [PATCH] client refactor

---
 client/1120_client.html                     |  10 +
 client/client-events-implementation-plan.md |  76 ++++
 client/src/client-event-handler.ts          | 217 ++++++++++
 client/src/client.ts                        |  18 +
 client/src/components/ClientEventsTable.ts  | 420 ++++++++++++++++++++
 client/src/services/ClientEventStore.ts     | 276 +++++++++++++
 client/styles/client-events-table.css       | 190 +++++++++
 7 files changed, 1207 insertions(+)
 create mode 100644 client/client-events-implementation-plan.md
 create mode 100644 client/src/client-event-handler.ts
 create mode 100644 client/src/components/ClientEventsTable.ts
 create mode 100644 client/src/services/ClientEventStore.ts
 create mode 100644 client/styles/client-events-table.css

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