diff --git a/client/1120_server.html b/client/1120_server.html index af8fc72..cfd462b 100644 --- a/client/1120_server.html +++ b/client/1120_server.html @@ -6,10 +6,11 @@ <title>HTTP Messages - SERVER</title> <link rel="stylesheet" href="./styles.css"> <link rel="stylesheet" href="./styles/event-list.css"> + <link rel="stylesheet" href="./styles/http-messages-table.css"> <script defer src="./server.bundle.js"></script> <!-- Additional chunks will be loaded automatically --> </head> -<body> +<body class="server-page"> <!-- Navigation bar container - content will be injected by navbar.ts --> <div id="navbarContainer" class="top-nav"> <!-- Navbar content will be injected here --> @@ -95,50 +96,57 @@ <div id="rawInputStatus" class="status-message"></div> </div> </div> - - <h2>Received Events</h2> + <h2>HTTP Messages</h2> <div class="received-events"> - <div class="events-container"> - <div class="events-sidebar"> - <div id="eventsList" class="events-list"> - <div class="empty-state"> - No events received yet. Use one of the methods above to receive events. - </div> - <!-- Events will be displayed here --> + <!-- Table-based view for HTTP messages with expandable rows --> + <div id="httpMessagesTableContainer"></div> + + <!-- Keep the event details and responses sections for compatibility with existing code --> + <div style="display: none;"> + <div id="eventDetails" class="event-details"> + <div class="empty-state"> + Select a request to view details </div> </div> - <div class="events-content"> - <div id="eventDetails" class="event-details"> + <div id="relatedResponses" class="related-responses-section"> + <h3>Responses</h3> + <div id="responsesList" class="responses-list"> <div class="empty-state"> - Select an event to view details + No responses available </div> - <!-- Selected event details will be shown here --> </div> - - <!-- HTTP Response Modal --> - <div id="httpResponseModal" class="http-response-modal" style="display: none;"> - <div class="http-response-container"> - <div class="http-response-header"> - <h3>HTTP Response</h3> - <button class="close-modal-btn">×</button> - </div> - <div class="http-response-tabs"> - <button class="tab-btn active" data-tab="formatted-response">Formatted</button> - <button class="tab-btn" data-tab="raw-response">Raw</button> - </div> - <div class="http-response-content"> - <div class="tab-content active" id="formatted-response"> - <div class="http-formatted-container"> - <!-- Formatted HTTP response will be shown here --> - </div> - </div> - <div class="tab-content" id="raw-response"> - <pre><!-- Raw HTTP response will be shown here --></pre> - </div> - </div> + </div> + </div> + </div> + + <!-- Move the 21121 Response JSON container to be completely hidden --> + <div id="response21121Json" class="response-json-container" style="display: none; position: absolute; visibility: hidden; z-index: -9999;"> + <pre class="json-content"> + <!-- 21121 response JSON content will be stored here but displayed in the modal --> + </pre> + </div> + + <!-- HTTP Response Modal --> + <div id="httpResponseModal" class="http-response-modal" style="display: none;"> + <div class="http-response-container"> + <div class="http-response-header"> + <h3>HTTP Response</h3> + <button class="close-modal-btn">×</button> + </div> + <div class="http-response-tabs"> + <button class="tab-btn active" data-tab="formatted-response">Formatted</button> + <button class="tab-btn" data-tab="raw-response">Raw</button> + </div> + <div class="http-response-content"> + <div class="tab-content active" id="formatted-response"> + <div class="http-formatted-container"> + <!-- Formatted HTTP response will be shown here --> </div> </div> + <div class="tab-content" id="raw-response"> + <pre><!-- Raw HTTP response will be shown here --></pre> + </div> </div> </div> </div> diff --git a/client/src/components/EventList.ts b/client/src/components/EventList.ts index e655228..9238be3 100644 --- a/client/src/components/EventList.ts +++ b/client/src/components/EventList.ts @@ -71,6 +71,15 @@ export class EventList { this.container.classList.add(this.options.className); } + // Check if this is the requests-only sidebar + const isRequestsSidebar = this.container.classList.contains('requests-only'); + if (isRequestsSidebar) { + console.log('Initializing EventList in requests-only mode'); + // Force filtering to only show 21120 events + this.eventTypeFilters.set('21120', true); + this.eventTypeFilters.set('21121', false); + } + // Create the UI structure this.createUIStructure(); @@ -805,6 +814,7 @@ export class EventList { const eventItem = document.createElement('div'); eventItem.className = 'event-item'; eventItem.dataset.id = eventId; + eventItem.dataset.kind = event.kind.toString(); // Add tabindex for keyboard navigation eventItem.tabIndex = 0; @@ -968,6 +978,11 @@ export class EventList { // Add click handler to select this event eventItem.addEventListener('click', () => { this.eventManager.selectEvent(eventId); + + // If this is a 21120 event (request), also show its responses + if (event.kind === 21120) { + this.displayResponsesForRequest(eventId); + } }); // Add keyboard handling for accessibility @@ -976,6 +991,11 @@ export class EventList { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.eventManager.selectEvent(eventId); + + // If this is a 21120 event (request), also show its responses + if (event.kind === 21120) { + this.displayResponsesForRequest(eventId); + } } // Navigate with arrow keys @@ -1144,4 +1164,105 @@ export class EventList { this.allEventIds = []; this.filteredEventIds = []; } -} + + /** + * Display response events for a given request event + * @param requestId The ID of the 21120 request event + */ + private displayResponsesForRequest(requestId: string): void { + // Find the responses list container + const responsesListContainer = document.getElementById('responsesList'); + if (!responsesListContainer) return; + + // Get related response events from EventManager + const responses = this.eventManager.getResponsesForRequest(requestId); + + // Clear existing content + responsesListContainer.innerHTML = ''; + + // Show empty state if no responses + if (responses.length === 0) { + responsesListContainer.innerHTML = ` + <div class="empty-state"> + No responses available for this request + </div> + `; + return; + } + + // Create a response item for each related response + responses.forEach(response => { + const responseItem = document.createElement('div'); + responseItem.className = 'response-item'; + responseItem.dataset.id = response.id; + + // Format timestamp + const timestamp = new Date(response.event.created_at * 1000).toLocaleTimeString(); + + // Check if decrypted + const isDecrypted = response.decrypted; + + // Extract status code if decrypted + let statusInfo = ''; + if (isDecrypted && response.decryptedContent) { + const statusMatch = response.decryptedContent.match(/^HTTP\/[\d.]+ (\d+)/); + if (statusMatch) { + const statusCode = statusMatch[1]; + let statusClass = ''; + + if (statusCode.startsWith('2')) statusClass = 'status-success'; + else if (statusCode.startsWith('3')) statusClass = 'status-redirect'; + else if (statusCode.startsWith('4')) statusClass = 'status-client-error'; + else if (statusCode.startsWith('5')) statusClass = 'status-server-error'; + + statusInfo = `<span class="status-code ${statusClass}">Status: ${statusCode}</span>`; + } + } + + // Set HTML content + responseItem.innerHTML = ` + <div class="response-header"> + <div class="response-time">${timestamp}</div> + ${statusInfo} + <div class="encryption-status ${isDecrypted ? 'decrypted' : 'encrypted'}"> + ${isDecrypted ? '🔓 Decrypted' : '🔒 Encrypted'} + </div> + </div> + <div class="response-id">ID: ${response.id.substring(0, 8)}...</div> + `; + + // Add click handler to select this response event + responseItem.addEventListener('click', () => { + this.eventManager.selectEvent(response.id); + }); + + // Add to container + responsesListContainer.appendChild(responseItem); + }); + + // Also update JSON display for most recent response + if (responses.length > 0) { + const mostRecentResponse = responses[0]; + this.displayResponseJson(mostRecentResponse.event); + } + } + + /** + * Display raw JSON for a 21121 response event + * @param event The 21121 response event + */ + private displayResponseJson(event: NostrEvent): void { + const jsonContainer = document.getElementById('response21121Json'); + if (!jsonContainer) return; + + // Make container visible + jsonContainer.style.display = 'block'; + + // Get the pre element + const pre = jsonContainer.querySelector('pre.json-content'); + if (!pre) return; + + // Format JSON with indentation + pre.textContent = JSON.stringify(event, null, 2); + } + } diff --git a/client/src/components/HttpMessagesTable.ts b/client/src/components/HttpMessagesTable.ts new file mode 100644 index 0000000..a4503e2 --- /dev/null +++ b/client/src/components/HttpMessagesTable.ts @@ -0,0 +1,593 @@ +/** + * HttpMessagesTable Component + * Handles the table view for HTTP messages with simple rows + * Shows only basic information (sender npub, timestamp, event ID) in each row + * Includes a modal dialog to show detailed information when a row is clicked + */ + +import { EventChangeType, EventKind } from '../services/EventManager'; +import type { EventManager } from '../services/EventManager'; +import { nip19 } from 'nostr-tools'; +import { HttpFormatter } from '../services/HttpFormatter'; + +export class HttpMessagesTable { + private container: HTMLElement | null = null; + private eventManager: EventManager; + 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; + + /** + * Create a new HttpMessagesTable component + */ + constructor(eventManager: EventManager, containerId: string) { + this.eventManager = eventManager; + this.container = document.getElementById(containerId); + } + + /** + * Initialize the component and render the initial UI + */ + public initialize(): void { + if (!this.container) { + console.error('HTTP messages table container not found'); + return; + } + + // Create the table structure + this.createTableStructure(); + + // Create the modal dialog + this.createModalDialog(); + + // Register for event changes + this.unregisterListener = this.eventManager.registerListener((eventId, changeType) => { + const event = this.eventManager.getEvent(eventId); + + switch (changeType) { + case EventChangeType.Added: + // If it's a 21120 event, render it + if (event && event.event.kind === EventKind.HttpRequest) { + this.renderEventRow(eventId); + } + // If it's a 21121 event, update indicators for related request + if (event && event.event.kind === EventKind.HttpResponse) { + this.updateResponseIndicators(); + // If the modal is currently open, refresh its content + this.refreshModalIfNeeded(eventId); + } + break; + case EventChangeType.Removed: + this.removeEventRow(eventId); + break; + case EventChangeType.Updated: + this.updateEventRow(eventId); + // If it's a 21121 event update, refresh indicators + if (event && event.event.kind === EventKind.HttpResponse) { + this.updateResponseIndicators(); + // If the modal is currently open, refresh its content + this.refreshModalIfNeeded(eventId); + } + break; + case EventChangeType.Selected: + this.highlightSelectedRow(eventId); + break; + } + }); + + // Render existing events + this.renderExistingEvents(); + } + + /** + * Create the table structure + */ + private createTableStructure(): void { + if (!this.container) { return; } + + const tableHtml = ` + <table class="http-messages-table"> + <thead> + <tr> + <th>Sender</th> + <th>Time</th> + <th>Event ID</th> + <th>Response</th> + </tr> + </thead> + <tbody id="httpMessagesTableBody"> + <tr class="table-empty-state"> + <td colspan="4">No HTTP messages received yet. Use one of the methods above to receive events.</td> + </tr> + </tbody> + </table> + `; + + this.container.innerHTML = tableHtml; + this.tableBody = document.getElementById('httpMessagesTableBody'); + } + + /** + * Render all existing events from the EventManager + */ + private renderExistingEvents(): void { + if (!this.tableBody) { return; } + + // Clear existing content + this.tableBody.innerHTML = ''; + + // Get all events from the EventManager + const events = this.eventManager.getAllEvents(); + + // Filter to only show kind 21120 events + const filteredEvents = events.filter(e => e.event.kind === 21120); + + // If no events, show the empty state + if (filteredEvents.length === 0) { + this.tableBody.innerHTML = ` + <tr class="table-empty-state"> + <td colspan="4">No HTTP messages received yet. Use one of the methods above to receive events.</td> + </tr> + `; + return; + } + + // Sort events by received time (newest first) + filteredEvents.sort((a, b) => b.receivedAt - a.receivedAt); + + // Render each event + filteredEvents.forEach(event => { + this.renderEventRow(event.id); + }); + } + + /** + * Render a single event row + */ + private renderEventRow(eventId: string): HTMLElement | null { + if (!this.tableBody) { return null; } + + // Get the event from EventManager + const managedEvent = this.eventManager.getEvent(eventId); + if (!managedEvent || managedEvent.event.kind !== 21120) { return null; } + + const event = managedEvent.event; + + // Check if the row already exists + const existingRow = document.getElementById(`event-row-${eventId}`); + if (existingRow) { + // If it exists, just update it + this.updateEventRow(eventId); + return existingRow as HTMLElement; + } + + // Create a new row for the event + const row = document.createElement('tr'); + row.id = `event-row-${eventId}`; + row.dataset.eventId = eventId; + row.className = 'event-row'; + // Format sender (pubkey) as npub + const senderPubkey = event.pubkey; + let npubSender; + + try { + // Use nip19 from nostr-tools to encode the pubkey to npub format + const fullNpub = nip19.npubEncode(senderPubkey); + // Shorten the npub for display + npubSender = `${fullNpub.substring(0, 8)}...${fullNpub.substring(fullNpub.length - 4)}`; + } catch (e) { + console.error('Error encoding npub:', e); + npubSender = senderPubkey.substring(0, 8) + '...'; + } + + // Format timestamp + const timestamp = new Date(event.created_at * 1000).toLocaleTimeString(); + + // Format event ID + const shortEventId = event.id ? (event.id.substring(0, 8) + '...') : 'Unknown ID'; + + // Set row HTML + // Check if this event has responses + const hasResponses = this.eventManager.hasRelatedEvents(eventId); + const responseIndicator = hasResponses + ? '<span class="response-indicator has-response" title="Has 21121 response">●</span>' + : '<span class="response-indicator no-response" title="No 21121 response">○</span>'; + + row.innerHTML = ` + <td class="sender-cell" title="${senderPubkey}">${npubSender}</td> + <td class="time-cell">${timestamp}</td> + <td class="event-id-cell" title="${event.id}">${shortEventId}</td> + <td class="response-cell">${responseIndicator}</td> + `; + + // Add click handler for row selection + row.addEventListener('click', () => { + // Select the event in the event manager and highlight the row + this.eventManager.selectEvent(eventId); + this.highlightSelectedRow(eventId); + + // Show the modal with event details + this.showModal(eventId); + }); + + // Remove any empty state row if present + const emptyStateRow = this.tableBody.querySelector('.table-empty-state'); + if (emptyStateRow) { + emptyStateRow.remove(); + } + + // Add to table at the top for new events + if (this.tableBody.firstChild) { + this.tableBody.insertBefore(row, this.tableBody.firstChild); + } else { + this.tableBody.appendChild(row); + } + + return row; + } + + /** + * Create the modal dialog structure + */ + private createModalDialog(): void { + // Create modal element + this.modal = document.createElement('div'); + this.modal.className = 'http-messages-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(); + } + }); + } + + /** + * Show the modal dialog with event details + * @param eventId The ID of the event to show details for + */ + 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(); + } + } + + /** + * Hide the modal dialog + */ + private hideModal(): void { + if (!this.modal) return; + + this.modal.style.display = 'none'; + this.currentEventId = null; + } + + /** + * Switch between tabs in the modal + * @param event The click event + */ + 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'); + } + + /** + * Load event data for the modal tabs + * @param eventId The ID of the 21120 event to load data for + */ + private loadEventData(eventId: string): void { + // Get the 21120 event data + const requestEvent = this.eventManager.getEvent(eventId); + if (!requestEvent || requestEvent.event.kind !== EventKind.HttpRequest) { + console.error('Invalid or missing request event'); + return; + } + + // Load 21120 Raw JSON tab content + const requestJsonTab = document.getElementById('tab-21120-json-content'); + if (requestJsonTab) { + requestJsonTab.innerHTML = `<pre class="json-content">${JSON.stringify(requestEvent.event, null, 2)}</pre>`; + } + + // Load 21120 HTTP Request tab content + const requestHttpTab = document.getElementById('tab-21120-http-content'); + if (requestHttpTab) { + // Get the HTTP request content from the event + const requestContent = requestEvent.decryptedContent || requestEvent.event.content; + const formattedRequest = HttpFormatter.formatHttpContent(requestContent, true, false); + requestHttpTab.innerHTML = formattedRequest; + } + + // Try to find related 21121 response event + const responseEventIds = this.eventManager.getRelatedEventIds(eventId); + + if (responseEventIds.length > 0) { + // Get the first response + const responseEvent = this.eventManager.getEvent(responseEventIds[0]); + + if (responseEvent && responseEvent.event.kind === EventKind.HttpResponse) { + // Load 21121 HTTP Response tab content + const responseHttpTab = document.getElementById('tab-21121-http-content'); + if (responseHttpTab) { + const responseContent = responseEvent.decryptedContent || responseEvent.event.content; + const formattedResponse = HttpFormatter.formatHttpContent(responseContent, false, true); + responseHttpTab.innerHTML = formattedResponse; + } + + // Load 21121 Raw JSON tab content + const responseJsonTab = document.getElementById('tab-21121-json-content'); + if (responseJsonTab) { + // First check if there's a pre-processed JSON in the response21121Json container + const responseJsonContainer = document.getElementById('response21121Json'); + const preElement = responseJsonContainer?.querySelector('pre.json-content'); + + if (preElement && preElement.textContent && preElement.textContent.trim() !== '') { + // Use the pre-processed JSON if available + responseJsonTab.innerHTML = `<pre class="json-content">${preElement.textContent}</pre>`; + } else { + // Otherwise, format it here + responseJsonTab.innerHTML = `<pre class="json-content">${JSON.stringify(responseEvent.event, null, 2)}</pre>`; + } + } + } else { + this.setNoResponseContent(); + } + } else { + this.setNoResponseContent(); + } + } + + /** + * Set content for when no response is available + */ + private setNoResponseContent(): void { + // Set empty content for response tabs + const responseHttpTab = document.getElementById('tab-21121-http-content'); + if (responseHttpTab) { + responseHttpTab.innerHTML = '<div class="no-response">No HTTP response available for this request</div>'; + } + + const responseJsonTab = document.getElementById('tab-21121-json-content'); + if (responseJsonTab) { + responseJsonTab.innerHTML = '<div class="no-response">No 21121 event available for this request</div>'; + } + } + + /** + * Update an existing event row + */ + private updateEventRow(eventId: string): void { + const row = document.getElementById(`event-row-${eventId}`); + if (!row) { + // If row doesn't exist, create it + this.renderEventRow(eventId); + return; + } + + const managedEvent = this.eventManager.getEvent(eventId); + if (!managedEvent || managedEvent.event.kind !== 21120) { return; } + + const event = managedEvent.event; + + // Update timestamp + const timeCell = row.querySelector('.time-cell'); + if (timeCell) { + timeCell.textContent = new Date(event.created_at * 1000).toLocaleTimeString(); + } + + // Update response indicator + const responseCell = row.querySelector('.response-cell'); + if (responseCell) { + const hasResponses = this.eventManager.hasRelatedEvents(eventId); + const responseIndicator = hasResponses + ? '<span class="response-indicator has-response" title="Has 21121 response">●</span>' + : '<span class="response-indicator no-response" title="No 21121 response">○</span>'; + responseCell.innerHTML = responseIndicator; + } + } + + /** + * Remove an event row + */ + private removeEventRow(eventId: string): void { + const row = document.getElementById(`event-row-${eventId}`); + + if (row) { row.remove(); } + + // No need to handle expandable rows or expandedRowId anymore + + // 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 HTTP messages received yet. Use one of the methods above to receive events.</td> + </tr> + `; + } + } + + /** + * Highlight the selected row + */ + private highlightSelectedRow(eventId: string): void { + // Remove selected class from all rows + const rows = document.querySelectorAll('.event-row'); + rows.forEach(row => row.classList.remove('selected')); + + // Add selected class to the specified row + const selectedRow = document.getElementById(`event-row-${eventId}`); + if (selectedRow) { + selectedRow.classList.add('selected'); + // This highlights the row to indicate selection + // Detailed content will be shown in a separate panel by the event manager + } + } + + /** + * Clean up resources when component is destroyed + */ + public dispose(): void { + // Unregister event manager listener + if (this.unregisterListener) { + this.unregisterListener(); + this.unregisterListener = null; + } + + // Remove modal from document + if (this.modal && document.body.contains(this.modal)) { + document.body.removeChild(this.modal); + this.modal = null; + } + } + + /** + * Refresh modal content if it's currently open and the event is related to the displayed request + * @param responseEventId The ID of the response event that was added or updated + */ + private refreshModalIfNeeded(responseEventId: string): void { + // Only proceed if modal is open and we have a current event + if (!this.modal || this.modal.style.display === 'none' || !this.currentEventId) { + return; + } + + // Get the request event ID that this response is for + const requestEventId = this.eventManager.getRequestIdForResponse(responseEventId); + + // If the response is for the currently displayed request, refresh the modal + if (requestEventId === this.currentEventId) { + this.loadEventData(this.currentEventId); + + // Show notification in the modal that content was updated + const notification = document.createElement('div'); + notification.className = 'modal-update-notification'; + notification.textContent = 'Response data updated!'; + this.modalContent?.appendChild(notification); + + // Remove notification after 3 seconds + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, 3000); + } + } + + /** + * Update response indicators for all rows + * This can be called when a new 21121 response is received + */ + public updateResponseIndicators(): void { + if (!this.tableBody) return; + + const rows = this.tableBody.querySelectorAll('.event-row'); + rows.forEach(row => { + const eventId = row.getAttribute('data-event-id'); + if (eventId) { + const hasResponses = this.eventManager.hasRelatedEvents(eventId); + const indicatorElement = row.querySelector('.response-indicator'); + + if (indicatorElement) { + if (hasResponses) { + indicatorElement.classList.add('has-response'); + indicatorElement.classList.remove('no-response'); + indicatorElement.setAttribute('title', 'Has 21121 response'); + indicatorElement.textContent = '●'; + } else { + indicatorElement.classList.add('no-response'); + indicatorElement.classList.remove('has-response'); + indicatorElement.setAttribute('title', 'No 21121 response'); + indicatorElement.textContent = '○'; + } + } + } + }); + } +} \ No newline at end of file diff --git a/client/src/components/ServerUI.ts b/client/src/components/ServerUI.ts index 4486669..447d159 100644 --- a/client/src/components/ServerUI.ts +++ b/client/src/components/ServerUI.ts @@ -14,6 +14,9 @@ import { HttpRequestExecutor } from './HttpRequestExecutor'; import { ResponseViewer } from './ResponseViewer'; import { EventList } from './EventList'; import { EventDetail } from './EventDetail'; +import { HttpMessagesTable } from './HttpMessagesTable'; +import { Nostr21121EventHandler } from '../services/Nostr21121EventHandler'; +import { NostrService } from '../services/NostrService'; /** @@ -38,14 +41,17 @@ export class ServerUI { private relayService: NostrRelayService; private cacheService: NostrCacheService; private nostrEventService: NostrEventService; + private nostrService: NostrService; private httpService: HttpService; private httpClient: HttpClient; + private nostr21121EventHandler: Nostr21121EventHandler; // UI components private eventList: EventList; private eventDetail: EventDetail; private httpRequestExecutor: HttpRequestExecutor; private responseViewer: ResponseViewer; + private httpMessagesTable: HttpMessagesTable; /** * Create a new ServerUI component @@ -77,6 +83,16 @@ export class ServerUI { updateStatusCallback ); + // Create a NostrService instance for the 21121 event handler + this.nostrService = new NostrService(updateStatusCallback); + + // Initialize the Nostr21121EventHandler for automatic responses + this.nostr21121EventHandler = new Nostr21121EventHandler( + this.nostrService, + this.eventManager, + this.httpClient + ); + // Initialize UI components this.eventList = new EventList(this.eventManager, { container: this.options.eventListContainer @@ -86,6 +102,9 @@ export class ServerUI { container: this.options.eventDetailContainer }); + // Initialize the HTTP Messages Table + this.httpMessagesTable = new HttpMessagesTable(this.eventManager, 'httpMessagesTableContainer'); + // Initialize HTTP components this.httpRequestExecutor = new HttpRequestExecutor({ eventManager: this.eventManager, @@ -111,9 +130,14 @@ export class ServerUI { // Initialize UI components this.eventList.initialize(); this.eventDetail.initialize(); + this.httpMessagesTable.initialize(); this.httpRequestExecutor.initialize(); this.responseViewer.initialize(); + // Initialize the 21121 event handler for automatic responses + this.nostr21121EventHandler.initialize(); + console.log('Nostr21121EventHandler initialized for automatic responses'); + // Set up event listeners this.setupEventListeners(); @@ -304,6 +328,7 @@ export class ServerUI { // Clean up UI components this.eventList.dispose(); this.eventDetail.dispose(); + this.httpMessagesTable.dispose(); // No dispose method needed for HttpRequestExecutor and ResponseViewer // as they don't have persistent resources to clean up @@ -317,6 +342,9 @@ export class ServerUI { } // Close WebSocket connections this.relayService.getWebSocketManager().close(); + + // Anything specific to the 21121 event handler cleanup could go here + // Currently there's no explicit dispose method needed } /** diff --git a/client/src/services/EventDetailsRenderer.ts b/client/src/services/EventDetailsRenderer.ts index e1c19d3..d97e2c3 100644 --- a/client/src/services/EventDetailsRenderer.ts +++ b/client/src/services/EventDetailsRenderer.ts @@ -92,13 +92,13 @@ export class EventDetailsRenderer { <span class="event-id-display">ID: ${event.id?.substring(0, 8) || 'Unknown'}...</span> </div> - <div class="event-type-info"> + <div class="event-type-info" style="padding-left: 15px;"> <span class="event-kind">Kind: ${event.kind}</span> <span class="event-type">${isRequest ? 'HTTP Request' : (isResponse ? 'HTTP Response' : 'Unknown')}</span> <span class="event-time">Time: ${eventTime}</span> </div> - <div class="event-metadata"> + <div class="event-metadata" style="padding-left: 15px;"> <div class="pubkey">Pubkey: ${event.pubkey}</div> <div class="tags"> <h3>Tags</h3> @@ -106,46 +106,98 @@ export class EventDetailsRenderer { </div> </div> - <div id="related-events-container"> + <div id="related-events-container" style="padding-left: 15px;"> <div class="loading-indicator"> <div class="spinner"></div> <span>Loading related events...</span> </div> </div> - <div class="http-actions" id="http-actions-${eventId}"> + <div class="http-actions" id="http-actions-${eventId}" style="padding-left: 15px;"> <!-- Action buttons will be added here --> </div> - <div class="http-content-tabs"> - <div class="tab-buttons"> - <button class="tab-btn" data-tab="raw-http">Raw HTTP</button> - <button class="tab-btn active" data-tab="formatted-http">Formatted HTTP</button> + <!-- Enhanced tabbed interface for event details --> + <div class="event-details-tabs" style="margin-top: 20px;"> + <div class="tab-buttons" style="border-bottom: 2px solid var(--border-color); margin-bottom: 15px; padding-bottom: 2px;"> + ${isRequest ? ` + <button class="tab-btn" data-tab="request-json" style="padding: 8px 15px; border: none; background: none; border-bottom: 2px solid transparent; cursor: pointer; font-weight: 500; margin-right: 10px; margin-bottom: -2px; transition: all 0.3s ease;">21120 Raw JSON</button> + <button class="tab-btn active" data-tab="request-http" style="padding: 8px 15px; border: none; background: none; border-bottom: 2px solid var(--accent-color); cursor: pointer; font-weight: 500; margin-right: 10px; margin-bottom: -2px; transition: all 0.3s ease; color: var(--accent-color);">21120 HTTP Request</button> + ` : ''} + ${isResponse ? ` + <button class="tab-btn" data-tab="response-json" style="padding: 8px 15px; border: none; background: none; border-bottom: 2px solid transparent; cursor: pointer; font-weight: 500; margin-right: 10px; margin-bottom: -2px; transition: all 0.3s ease;">21121 Raw JSON</button> + <button class="tab-btn active" data-tab="response-http" style="padding: 8px 15px; border: none; background: none; border-bottom: 2px solid var(--accent-color); cursor: pointer; font-weight: 500; margin-right: 10px; margin-bottom: -2px; transition: all 0.3s ease; color: var(--accent-color);">21121 HTTP Response</button> + ` : ''} + ${!isRequest && !isResponse ? ` + <button class="tab-btn active" data-tab="event-json" style="padding: 8px 15px; border: none; background: none; border-bottom: 2px solid var(--accent-color); cursor: pointer; font-weight: 500; margin-right: 10px; margin-bottom: -2px; transition: all 0.3s ease; color: var(--accent-color);">Event Raw JSON</button> + ` : ''} </div> - <div class="tab-content" id="raw-http"> - <div class="loading-container" id="raw-loading-${eventId}"> + <!-- Request JSON tab content --> + ${isRequest ? ` + <div class="tab-content" id="request-json"> + <h3 style="padding-left: 20px; margin-top: 20px; margin-bottom: 15px; color: var(--accent-color);">21120 Raw JSON</h3> + <pre class="json-content" style="margin: 20px; padding: 15px; background-color: var(--bg-tertiary); border-left: 4px solid var(--accent-color); border-radius: 4px;"> +${JSON.stringify(event, null, 2)} + </pre> + </div> + ` : ''} + + <!-- Request HTTP tab content --> + ${isRequest ? ` + <div class="tab-content active" id="request-http"> + <h3 style="padding-left: 20px; margin-top: 20px; margin-bottom: 15px; color: var(--accent-color);">21120 HTTP Request</h3> + <div class="loading-container" id="raw-loading-${eventId}" style="padding: 20px; text-align: center;"> <div class="spinner"></div> <span>Loading content...</span> </div> - <pre class="http-content" id="raw-content-${eventId}" style="display: none;"></pre> - <div class="decryption-status" id="decryption-status-raw-${eventId}"> + <pre class="http-content" id="raw-content-${eventId}" style="display: none; margin: 20px; padding: 15px; border-left: 4px solid var(--accent-color); border-radius: 4px; background-color: var(--bg-tertiary);"></pre> + <div class="http-formatted-container" id="formatted-content-${eventId}" style="display: none; margin: 20px; padding: 15px; border-radius: 4px; background-color: var(--bg-tertiary);"> + </div> + <div class="decryption-status" id="decryption-status-raw-${eventId}" style="margin: 20px; padding: 10px; text-align: center; border-radius: 4px;"> <div class="spinner"></div> <span>Processing encryption...</span> </div> </div> - <div class="tab-content active" id="formatted-http"> - <div class="loading-container" id="formatted-loading-${eventId}"> + ` : ''} + + <!-- Response JSON tab content --> + ${isResponse ? ` + <div class="tab-content" id="response-json"> + <h3 style="padding-left: 20px; margin-top: 20px; margin-bottom: 15px; color: var(--accent-color);">21121 Raw JSON</h3> + <pre class="json-content" style="margin: 20px; padding: 15px; background-color: var(--bg-tertiary); border-left: 4px solid var(--accent-color); border-radius: 4px;"> +${JSON.stringify(event, null, 2)} + </pre> + </div> + ` : ''} + + <!-- Response HTTP tab content --> + ${isResponse ? ` + <div class="tab-content active" id="response-http"> + <h3 style="padding-left: 20px; margin-top: 20px; margin-bottom: 15px; color: var(--accent-color);">21121 HTTP Response</h3> + <div class="loading-container" id="raw-loading-${eventId}" style="padding: 20px; text-align: center;"> <div class="spinner"></div> - <span>Formatting content...</span> + <span>Loading content...</span> </div> - <div class="http-formatted-container" id="formatted-content-${eventId}" style="display: none;"> + <pre class="http-content" id="raw-content-${eventId}" style="display: none; margin: 20px; padding: 15px; border-left: 4px solid var(--accent-color); border-radius: 4px; background-color: var(--bg-tertiary);"></pre> + <div class="http-formatted-container" id="formatted-content-${eventId}" style="display: none; margin: 20px; padding: 15px; border-radius: 4px; background-color: var(--bg-tertiary);"> </div> - <div class="decryption-status" id="decryption-status-formatted-${eventId}"> + <div class="decryption-status" id="decryption-status-raw-${eventId}" style="margin: 20px; padding: 10px; text-align: center; border-radius: 4px;"> <div class="spinner"></div> <span>Processing encryption...</span> </div> </div> + ` : ''} + + <!-- Generic event JSON tab content --> + ${!isRequest && !isResponse ? ` + <div class="tab-content active" id="event-json"> + <h3 style="padding-left: 20px; margin-top: 20px; margin-bottom: 15px; color: var(--accent-color);">Event Raw JSON</h3> + <pre class="json-content" style="margin: 20px; padding: 15px; background-color: var(--bg-tertiary); border-left: 4px solid var(--accent-color); border-radius: 4px;"> +${JSON.stringify(event, null, 2)} + </pre> + </div> + ` : ''} </div> `; @@ -160,25 +212,63 @@ export class EventDetailsRenderer { if (!this.eventDetails) return; const tabButtons = this.eventDetails.querySelectorAll('.tab-btn'); + if (tabButtons.length === 0) return; + tabButtons.forEach(button => { button.addEventListener('click', (e) => { - // Remove active class from all buttons and content - tabButtons.forEach(btn => btn.classList.remove('active')); + // Remove active class from all buttons in the same tab group + const tabGroup = button.closest('.tab-buttons'); + if (tabGroup) { + const groupButtons = tabGroup.querySelectorAll('.tab-btn'); + groupButtons.forEach(btn => btn.classList.remove('active')); + } else { + // Fallback to the old behavior + tabButtons.forEach(btn => btn.classList.remove('active')); + } - const tabContents = this.eventDetails!.querySelectorAll('.tab-content'); - tabContents.forEach(content => content.classList.remove('active')); + // Find the parent tab container + let tabContainer = button.closest('.event-details-tabs'); + if (!tabContainer) { + tabContainer = button.closest('.http-content-tabs'); + } - // Add active class to clicked button - button.classList.add('active'); - - // Show corresponding content - const tabId = (button as HTMLElement).dataset.tab || ''; - const tabContent = this.eventDetails!.querySelector(`#${tabId}`); - if (tabContent) { - tabContent.classList.add('active'); + if (tabContainer) { + // Remove active class from all tab contents in this container + const tabContents = tabContainer.querySelectorAll('.tab-content'); + tabContents.forEach(content => content.classList.remove('active')); + + // Add active class to clicked button + button.classList.add('active'); + + // Show corresponding content + const tabId = (button as HTMLElement).dataset.tab || ''; + const tabContent = tabContainer.querySelector(`#${tabId}`); + if (tabContent) { + tabContent.classList.add('active'); + } } }); }); + + // Ensure at least one tab is active in each tab group + const tabGroups = this.eventDetails.querySelectorAll('.tab-buttons'); + tabGroups.forEach(group => { + const hasActiveButton = group.querySelector('.tab-btn.active'); + if (!hasActiveButton) { + const firstButton = group.querySelector('.tab-btn'); + if (firstButton) { + firstButton.classList.add('active'); + const tabId = (firstButton as HTMLElement).dataset.tab || ''; + const tabContainer = group.closest('.event-details-tabs') || group.closest('.http-content-tabs'); + if (tabContainer) { + const tabContent = tabContainer.querySelector(`#${tabId}`); + if (tabContent) { + tabContent.classList.add('active'); + } + } + } + } + }); } /** @@ -221,6 +311,22 @@ export class EventDetailsRenderer { // 5. Update formatted content (most expensive operation) this.updateFormattedContent(eventId, httpContent, isRequest, isResponse || is21121Event, receivedEvent.decrypted); + + // 6. For 21121 response events, display the raw JSON + if (is21121Event) { + this.displayResponse21121Json(event); + } else if (isRequest) { + // For request events, check if there are related responses + const relatedIds = event.id ? (this.relatedEvents.get(event.id) || []) : []; + if (relatedIds.length > 0) { + // Get the first related response event + const responseId = relatedIds[0]; + const responseEvent = this.receivedEvents.get(responseId)?.event; + if (responseEvent && responseEvent.kind === 21121) { + this.displayResponse21121Json(responseEvent); + } + } + } } catch (error) { console.error("Error loading event details:", error); this.showErrorState(eventId, String(error)); @@ -316,26 +422,37 @@ export class EventDetailsRenderer { content: string, decrypted: boolean ): void { - // Get elements - const loadingElement = document.getElementById(`raw-loading-${eventId}`); - const contentElement = document.getElementById(`raw-content-${eventId}`); - const statusElement = document.getElementById(`decryption-status-raw-${eventId}`); + // Get elements - look for multiple possible IDs due to new tabbed structure + const loadingElements = document.querySelectorAll(`[id^="raw-loading-${eventId}"]`); + const contentElements = document.querySelectorAll(`[id^="raw-content-${eventId}"]`); + const statusElements = document.querySelectorAll(`[id^="decryption-status-raw-${eventId}"]`); - if (!loadingElement || !contentElement || !statusElement) return; - - // Update content - contentElement.textContent = content; - - // Update encryption status - if (decrypted) { - statusElement.innerHTML = '<div class="decryption-status success">Decryption successful ✓</div>'; - } else { - statusElement.innerHTML = '<div class="decryption-status error">Decryption failed or not attempted</div>'; + if (loadingElements.length === 0 || contentElements.length === 0 || statusElements.length === 0) { + console.warn('Could not find all required elements for updating raw content'); + return; } - // Hide loading, show content - loadingElement.style.display = 'none'; - contentElement.style.display = 'block'; + // Update all instances of the content + contentElements.forEach(element => { + element.textContent = content; + (element as HTMLElement).style.display = 'block'; + }); + + // Update all encryption status elements + statusElements.forEach(element => { + if (decrypted) { + element.innerHTML = '<div class="decryption-status success">Decryption successful ✓</div>'; + } else { + element.innerHTML = '<div class="decryption-status error">Decryption failed or not attempted</div>'; + } + }); + + // Hide all loading elements + loadingElements.forEach(element => { + (element as HTMLElement).style.display = 'none'; + }); + + console.log(`[EventDetailsRenderer] Updated raw content for event ${eventId}`); } /** @@ -348,26 +465,56 @@ export class EventDetailsRenderer { isResponse: boolean, decrypted: boolean ): void { - // Get elements - const loadingElement = document.getElementById(`formatted-loading-${eventId}`); - const contentElement = document.getElementById(`formatted-content-${eventId}`); - const statusElement = document.getElementById(`decryption-status-formatted-${eventId}`); + // Get elements - look for multiple possible IDs due to new tabbed structure + const loadingElements = document.querySelectorAll(`[id^="formatted-loading-${eventId}"]`); + const contentElements = document.querySelectorAll(`[id^="formatted-content-${eventId}"]`); + const statusElements = document.querySelectorAll(`[id^="decryption-status-formatted-${eventId}"]`); - if (!loadingElement || !contentElement || !statusElement) return; - - // Format and update content - contentElement.innerHTML = HttpFormatter.formatHttpContent(content, isRequest, isResponse); - - // Update encryption status - if (decrypted) { - statusElement.innerHTML = '<div class="decryption-status success">Decryption successful ✓</div>'; - } else { - statusElement.innerHTML = '<div class="decryption-status error">Decryption failed or not attempted</div>'; + // If we don't find the standard elements, look for the ones in the new structure + if (contentElements.length === 0) { + console.log('[EventDetailsRenderer] Using fallback to update formatted content'); + + // Try to find the formatted container in the HTTP tab + let tabContainer; + if (isRequest) { + tabContainer = document.getElementById('request-http'); + } else if (isResponse) { + tabContainer = document.getElementById('response-http'); + } + + if (tabContainer) { + const formattedContainer = tabContainer.querySelector('.http-formatted-container'); + if (formattedContainer) { + formattedContainer.innerHTML = HttpFormatter.formatHttpContent(content, isRequest, isResponse); + (formattedContainer as HTMLElement).style.display = 'block'; + console.log('[EventDetailsRenderer] Updated formatted content using fallback'); + } + } + + return; } - // Hide loading, show content - loadingElement.style.display = 'none'; - contentElement.style.display = 'block'; + // Format and update all instances of the content + contentElements.forEach(element => { + element.innerHTML = HttpFormatter.formatHttpContent(content, isRequest, isResponse); + (element as HTMLElement).style.display = 'block'; + }); + + // Update all encryption status elements + statusElements.forEach(element => { + if (decrypted) { + element.innerHTML = '<div class="decryption-status success">Decryption successful ✓</div>'; + } else { + element.innerHTML = '<div class="decryption-status error">Decryption failed or not attempted</div>'; + } + }); + + // Hide all loading elements + loadingElements.forEach(element => { + (element as HTMLElement).style.display = 'none'; + }); + + console.log(`[EventDetailsRenderer] Updated formatted content for event ${eventId}`); } /** @@ -387,6 +534,30 @@ export class EventDetailsRenderer { } } + /** + * Display 21121 response event's raw JSON + * @param event The 21121 response event + */ + private displayResponse21121Json(event: NostrEvent): void { + // Get the JSON container + const jsonContainer = document.getElementById('response21121Json'); + if (!jsonContainer) return; + + // Get the pre element within the container + const preElement = jsonContainer.querySelector('pre.json-content'); + if (!preElement) return; + + // Format the JSON prettily + const formattedJson = JSON.stringify(event, null, 2); + preElement.textContent = formattedJson; + + // Show the container + jsonContainer.style.display = 'block'; + + // Add a log for debugging + console.log('[EventDetailsRenderer] Displayed 21121 response JSON', event.id); + } + /** * Get the event details element * @returns The event details element or null diff --git a/client/src/services/Nostr21121EventHandler.ts b/client/src/services/Nostr21121EventHandler.ts index 6beae7e..966dc66 100644 --- a/client/src/services/Nostr21121EventHandler.ts +++ b/client/src/services/Nostr21121EventHandler.ts @@ -1,24 +1,173 @@ /** * Nostr21121EventHandler.ts * Handles NIP-21121 HTTP response events + * Automatically processes incoming 21120 requests to generate 21121 responses */ import { NostrEvent } from '../relay'; import { NostrService } from './NostrService'; +import { EventManager, EventChangeType, EventKind } from './EventManager'; +import { HttpClient } from './HttpClient'; +import { ToastNotifier } from './ToastNotifier'; +import { Nostr21121Service } from './Nostr21121Service'; /** * Class for handling NIP-21121 HTTP response events */ export class Nostr21121EventHandler { private nostrService: NostrService; + private eventManager: EventManager; + private httpClient: HttpClient; + private nostr21121Service: Nostr21121Service; private responseEventMap: Map<string, string[]> = new Map(); /** * Constructor * @param nostrService The NostrService instance + * @param eventManager The EventManager instance + * @param httpClient The HttpClient instance */ - constructor(nostrService: NostrService) { + constructor( + nostrService: NostrService, + eventManager: EventManager, + httpClient: HttpClient + ) { this.nostrService = nostrService; + this.eventManager = eventManager; + this.httpClient = httpClient; + + // Initialize the 21121 service + // We pass the nostrService as relayService and null as cacheService + this.nostr21121Service = new Nostr21121Service(this.nostrService, null); + } + + /** + * Initialize the handler + */ + public initialize(): void { + console.log('[Nostr21121EventHandler] Initialized - Auto-response mode enabled'); + + // Set up event listeners + this.setupEventListeners(); + } + + /** + * Set up event listeners + */ + private setupEventListeners(): void { + // Listen for new 21120 events + this.eventManager.registerListener((eventId, changeType) => { + if (changeType === EventChangeType.Added) { + this.handleNewEvent(eventId); + } + }); + } + + /** + * Handle a new event + * @param eventId The ID of the new event + */ + private async handleNewEvent(eventId: string): Promise<void> { + const managedEvent = this.eventManager.getEvent(eventId); + if (!managedEvent) return; + + const event = managedEvent.event; + + // Check if this is a 21120 event + if (event.kind === EventKind.HttpRequest) { + console.log(`[Nostr21121EventHandler] Processing new 21120 event: ${eventId}`); + + // Check if we already have a 21121 response for this request + const existingResponses = this.eventManager.getResponsesForRequest(eventId); + if (existingResponses.length > 0) { + console.log(`[Nostr21121EventHandler] Found existing 21121 response for ${eventId}. Skipping.`); + return; + } + + // Process the 21120 event to generate a 21121 response + this.processRequestEvent(event); + } + } + + /** + * Process a 21120 request event to generate a 21121 response + * @param event The 21120 request event + */ + private async processRequestEvent(event: NostrEvent): Promise<void> { + try { + if (!event.id) { + console.error(`[Nostr21121EventHandler] Event has no ID, cannot process`); + return; + } + + console.log(`[Nostr21121EventHandler] Processing 21120 request event ${event.id}`); + + // Check if the event is decrypted by checking for decryptedContent + const managedEvent = this.eventManager.getEvent(event.id); + if (!managedEvent || !managedEvent.decrypted || !managedEvent.decryptedContent) { + console.log(`[Nostr21121EventHandler] Event ${event.id} is not decrypted yet. Cannot process.`); + return; + } + + const httpRequest = managedEvent.decryptedContent; + console.log(`[Nostr21121EventHandler] Executing HTTP request for event ${event.id}`); + console.log(`[Nostr21121EventHandler] Request: ${httpRequest.split('\n')[0]}`); + + // Execute the HTTP request + const httpResponse = await this.httpClient.sendHttpRequest(httpRequest); + console.log(`[Nostr21121EventHandler] Got HTTP response: ${httpResponse.split('\n')[0]}`); + + // Get relay information for publishing + const relayService = this.nostrService.getRelayService(); + const relayUrl = relayService.getActiveRelayUrl(); + if (!relayUrl) { + console.error('[Nostr21121EventHandler] No active relay URL available'); + ToastNotifier.show('Failed to create 21121 response: No active relay', 'error', 5000); + return; + } + + // Get server private key (in a real implementation) + // Here we just use a placeholder as the actual key handling is beyond scope + const serverPrivateKey = 'placeholder_private_key'; + + // Create a 21121 response event + console.log(`[Nostr21121EventHandler] Creating 21121 response for request ${event.id}`); + + const response = await this.nostr21121Service.createAndPublish21121Event( + event, + httpResponse, + serverPrivateKey, + relayUrl + ); + + if (!response || !response.id) { + console.error('[Nostr21121EventHandler] Failed to create response event'); + ToastNotifier.show('Failed to create 21121 response event', 'error', 5000); + return; + } + + console.log(`[Nostr21121EventHandler] Created 21121 response: ${response.id}`); + + // Add the relationship to our map + this.addRelatedEvent(event.id, response.id); + + // Add the event to EventManager + this.eventManager.addEvent(response); + + // Display the raw JSON in the response JSON container + this.displayRawResponseJson(response); + + // Display success notification + ToastNotifier.show('Created 21121 response event', 'success', 3000); + + // Debug log the response event + console.log(`[Nostr21121EventHandler] Response event:`, JSON.stringify(response, null, 2)); + + } catch (error: any) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`[Nostr21121EventHandler] Error processing request:`, error); + ToastNotifier.show(`Failed to create 21121 response: ${errorMessage}`, 'error', 5000); + } } /** @@ -33,6 +182,16 @@ export class Nostr21121EventHandler { return null; } + // First check our local map + const related = this.getRelatedEvents(requestEventId); + if (related.length > 0) { + const responseId = related[0]; // Get the first response + const managedEvent = this.eventManager.getEvent(responseId); + if (managedEvent) { + return managedEvent.event; + } + } + // Get active relay const relayService = this.nostrService.getRelayService(); const relayUrl = relayService.getActiveRelayUrl(); @@ -42,16 +201,8 @@ export class Nostr21121EventHandler { return null; } - // Simplified implementation - instead of actual relay queries - console.log(`Searching for response event for request ${requestEventId} on relay ${relayUrl}`); - - // Simulate searching for events - in a real implementation this would query relays - return new Promise((resolve) => { - setTimeout(() => { - console.log('No response event found - this is a simplified implementation'); - resolve(null); - }, 1000); - }); + // Try to find a response event using the 21121 service + return await this.nostr21121Service.findResponseForRequest(requestEventId, relayUrl); } catch (error) { console.error('Error finding 21121 response:', error); return null; @@ -80,6 +231,37 @@ export class Nostr21121EventHandler { return this.responseEventMap.get(eventId) || []; } + /** + * Display raw response JSON in the UI + * @param response The 21121 response event to display + */ + private displayRawResponseJson(response: NostrEvent): void { + // Get the JSON container + const jsonContainer = document.getElementById('response21121Json'); + if (!jsonContainer) { + console.warn('[Nostr21121EventHandler] response21121Json container not found'); + return; + } + + // Get the pre element within the container + const preElement = jsonContainer.querySelector('pre.json-content'); + if (!preElement) { + console.warn('[Nostr21121EventHandler] json-content element not found'); + return; + } + + // Format the JSON prettily + const formattedJson = JSON.stringify(response, null, 2); + preElement.textContent = formattedJson; + + // We don't automatically show the container anymore as it appears in the wrong place + // The response should be shown in the modal when a row is clicked instead + // jsonContainer.style.display = 'block'; + + // Add a log for debugging + console.log('[Nostr21121EventHandler] Updated 21121 response JSON content', response.id); + } + /** * Check if an event has related 21121 responses * @param eventId The event ID to check diff --git a/client/src/services/NostrEventService.updated.ts b/client/src/services/NostrEventService.updated.ts index 4ca2d2a..d6edec5 100644 --- a/client/src/services/NostrEventService.updated.ts +++ b/client/src/services/NostrEventService.updated.ts @@ -201,6 +201,10 @@ export class NostrEventService { contentLength: decryptedContent?.length, eventId: receivedEvent.id?.substring(0, 8) + '...' }); + + // After successful decryption, check if we already have a 21121 response + // If not, create one by executing the HTTP request + this.checkAndCreateResponse(receivedEvent, decryptedContent); } catch (decryptError) { console.error("Failed to decrypt event content:", decryptError); console.error("Decryption error details:", { @@ -335,6 +339,225 @@ export class NostrEventService { * @param statusMessage The status message * @param statusClass The CSS class for styling the status */ + /** + * Check if we already have a 21121 response for a 21120 request + * If not, automatically create one by executing the HTTP request + * @param requestEvent The 21120 request event + * @param decryptedContent The decrypted HTTP request content + */ + private async checkAndCreateResponse(requestEvent: NostrEvent, decryptedContent?: string): Promise<void> { + if (!requestEvent.id || !decryptedContent) { + console.log("Cannot process request: missing ID or decrypted content"); + return; + } + + try { + console.log(`Checking for existing 21121 response for request ${requestEvent.id.substring(0, 8)}...`); + + // Check if we already have a response for this request in the EventManager + const hasResponse = this.eventManager.hasRelatedEvents(requestEvent.id); + + if (hasResponse) { + console.log(`Found existing 21121 response for request ${requestEvent.id.substring(0, 8)}`); + return; + } + + console.log(`No existing 21121 response found for request ${requestEvent.id.substring(0, 8)}, executing HTTP request...`); + + // Get server's private key for signing the response + const serverNsec = localStorage.getItem('serverNsec'); + if (!serverNsec) { + console.error("Cannot create 21121 response: Server private key (nsec) not found"); + return; + } + + // Get the relay URL + const relayUrl = this.relayService.getActiveRelayUrl(); + if (!relayUrl) { + console.error("Cannot create 21121 response: No active relay connection"); + return; + } + + // Create HttpClient dynamically + const { HttpService } = await import('./HttpService'); + const { HttpClient } = await import('./HttpClient'); + const httpService = new HttpService(); + const httpClient = new HttpClient(httpService); + + try { + // Execute the HTTP request + console.log("Executing HTTP request..."); + const httpResponse = await httpClient.sendHttpRequest(decryptedContent); + console.log("HTTP request executed successfully, creating 21121 response..."); + + // Import crypto utilities for encryption + const cryptoUtils = await import('../utils/crypto-utils'); + const nostrTools = await import('nostr-tools'); + + // Generate a random key for content encryption + const randomKey = Array.from(crypto.getRandomValues(new Uint8Array(32))) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + // Encrypt the response content with the random key + const encryptedContent = await cryptoUtils.encryptWithWebCrypto(httpResponse, randomKey); + + // Get the server pubkey from the nsec + const decoded = nostrTools.nip19.decode(serverNsec); + if (decoded.type !== 'nsec') { + throw new Error("Invalid server nsec format"); + } + + const serverPrivateKeyBytes = decoded.data as Uint8Array; + const serverPubkey = nostrTools.getPublicKey(serverPrivateKeyBytes); + + // Create tags for the 21121 event + const tags: string[][] = [ + ["e", requestEvent.id as string], + ["key", randomKey], // Store the key directly for now, we'll handle encryption differently + ["expiration", (Math.floor(Date.now() / 1000) + 3600).toString()] // 1 hour expiration + ]; + + // Create the 21121 event + const eventBody = { + kind: EventKind.HttpResponse, + created_at: Math.floor(Date.now() / 1000), + tags: tags, + content: encryptedContent, + pubkey: serverPubkey + }; + + // Compute the event ID (hash) + const id = nostrTools.getEventHash(eventBody as any); + + // Sign the event + let sig: string; + + // Try to use window.nostr if available, otherwise use a simulated signature + if (window.nostr && window.nostr.signEvent) { + try { + const signResult = await window.nostr.signEvent(eventBody); + sig = typeof signResult === 'object' && signResult.sig + ? signResult.sig + : 'simulated_signature_for_21121_response'; + } catch (signError) { + console.error("Error signing with window.nostr:", signError); + sig = 'simulated_signature_for_21121_response'; + } + } else { + sig = 'simulated_signature_for_21121_response'; + } + + // Create the complete signed event + const responseEvent: NostrEvent = { + ...eventBody, + id, + sig + }; + + // Ensure ID is defined for logging + const shortId = responseEvent.id ? responseEvent.id.substring(0, 8) + '...' : 'undefined'; + + console.log("Created 21121 response event:", { + id: shortId, + tags: responseEvent.tags.length, + contentLength: responseEvent.content.length + }); + + // Display the raw JSON in the console for debugging + console.log("21121 raw JSON:", JSON.stringify(responseEvent, null, 2)); + + // Create a DOM element to display the raw JSON in the UI + this.displayRawJsonInUI(responseEvent); + + // Publish the event to the relay if we have valid IDs + if (responseEvent.id && requestEvent.id) { + const relayPool = this.relayService.getRelayPool(); + if (relayPool) { + try { + // Use relayPool.send instead of publish for better type compatibility + const pub = relayPool.publish([relayUrl], responseEvent as any); + await pub; + console.log(`Published 21121 response event to ${relayUrl}`); + + // Add the event to our EventManager for tracking + this.processEvent(responseEvent); + + // Store relationship between request and response + this.eventManager.associateResponseWithRequest(responseEvent.id, requestEvent.id); + } catch (pubError) { + console.error("Error publishing 21121 response:", pubError); + } + } else { + console.error("Cannot publish 21121 response: Relay pool not available"); + } + } + + // Show success notification + const event = new CustomEvent('21121-response-created', { + detail: { + requestId: requestEvent.id, + responseId: responseEvent.id, + responseJson: JSON.stringify(responseEvent, null, 2) + } + }); + document.dispatchEvent(event); + } catch (error) { + console.error("Error creating 21121 response:", error); + } + } catch (error) { + console.error("Error checking for existing 21121 response:", error); + } + } + + /** + * Display the raw JSON of a 21121 event in the UI + * @param event The 21121 event to display + */ + private displayRawJsonInUI(event: NostrEvent): void { + try { + // Create or get a container for displaying the JSON + let jsonContainer = document.getElementById('response21121Json'); + + if (!jsonContainer) { + // Create the container if it doesn't exist + jsonContainer = document.createElement('div'); + jsonContainer.id = 'response21121Json'; + jsonContainer.className = 'response-json-container'; + + // Add a header + const header = document.createElement('h3'); + header.textContent = '21121 Response JSON'; + jsonContainer.appendChild(header); + + // Create a pre element for the JSON + const pre = document.createElement('pre'); + pre.className = 'json-content'; + jsonContainer.appendChild(pre); + + // Add to the page in a suitable location (find a good container) + const container = document.querySelector('.event-details') || + document.querySelector('.content') || + document.body; + + if (container) { + container.appendChild(jsonContainer); + } + } + + // Update the JSON content + const pre = jsonContainer.querySelector('pre'); + if (pre) { + pre.textContent = JSON.stringify(event, null, 2); + } + + // Make the container visible + jsonContainer.style.display = 'block'; + } catch (uiError) { + console.error("Error displaying 21121 JSON in UI:", uiError); + } + } + private updateStatus(statusMessage: string, statusClass: string): void { if (this.statusCallback) { this.statusCallback(statusMessage, statusClass); diff --git a/client/styles.css b/client/styles.css index 0189a64..2176fd6 100644 --- a/client/styles.css +++ b/client/styles.css @@ -403,13 +403,27 @@ body[data-theme="dark"] { /* General layout */ body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - max-width: 900px; - margin: 0 auto; - padding: 20px; + margin: 0; + padding: 0; background-color: var(--bg-primary); color: var(--text-primary); line-height: 1.6; transition: all 0.3s ease; + width: 100%; + min-height: 100vh; + max-width: 100vw; + overflow-x: hidden; + border: none; +} + +/* Content container for wrapping everything except the navbar */ +.content { + padding: 0 20px; + width: 100%; + max-width: 100%; + box-sizing: border-box; + border: none; + background-color: var(--bg-primary); } /* Make sure all elements using CSS vars also transition smoothly */ @@ -448,11 +462,13 @@ h3 { box-shadow: 0 2px 4px rgba(0,0,0,0.05); height: 50px; display: flex; - width: 100%; + width: 100vw; + max-width: 100%; } body { padding-top: 70px; /* Added padding to account for fixed navbar */ + overflow-x: hidden; /* Prevent horizontal scrolling */ } .nav-left { @@ -634,7 +650,7 @@ code { /* Content */ .content { - margin-bottom: 40px; + margin-bottom: 0; } /* Footer */ @@ -649,7 +665,7 @@ footer { /* Responsive adjustments */ @media (max-width: 768px) { body { - padding-top: 60px; + padding-top: 45px; padding-left: 10px; padding-right: 10px; } @@ -1095,40 +1111,54 @@ footer { display: none !important; } -/* Receiver page styles */ +/* Receiver page styles - Enhanced */ .relay-connection { - margin-bottom: 20px; - padding: 15px; + margin-bottom: 25px; + padding: 20px; background-color: var(--bg-secondary); display: flex; flex-direction: column; - gap: 15px; + gap: 18px; + border-radius: 10px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); + border: 1px solid var(--border-color); } -/* Server info styles */ +/* Server info styles - Enhanced */ .server-info-container { - margin-bottom: 10px; - padding-bottom: 15px; + margin-bottom: 15px; + padding-bottom: 18px; border-bottom: 1px solid var(--border-color); + transition: all 0.3s ease; } .server-npub-container { display: flex; align-items: center; flex-wrap: wrap; - gap: 10px; + gap: 12px; + padding: 5px; + background-color: var(--bg-tertiary); + border-radius: 6px; } -/* Server section styles */ +/* Server section styles - Enhanced */ .server-section { - margin-bottom: 20px; - padding: 15px; + margin-bottom: 25px; + padding: 20px; background-color: var(--bg-secondary); - border-radius: 8px; + border-radius: 10px; border: 1px solid var(--border-color); display: flex; flex-direction: column; - gap: 15px; + gap: 18px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.server-section:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.12); } .server-npub-container label { @@ -1192,64 +1222,88 @@ footer { .relay-input-container { display: flex; align-items: center; - margin-bottom: 10px; + margin-bottom: 15px; + background-color: var(--bg-tertiary); + border-radius: 8px; + padding: 12px 15px; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05); } .relay-input-container label { - margin-right: 10px; - min-width: 80px; + margin-right: 15px; + min-width: 85px; + font-weight: 600; + color: var(--text-secondary); } .relay-input-container input { flex: 1; - margin-right: 10px; - padding: 8px; + margin-right: 15px; + padding: 10px 12px; border: 1px solid var(--border-color); - border-radius: 4px; + border-radius: 6px; + background-color: var(--bg-secondary); + font-size: 0.95em; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.relay-input-container input:focus { + border-color: var(--accent-color); + box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb, 13, 110, 253), 0.25); + outline: none; } .relay-connect-button { background-color: var(--button-primary); color: white; border: none; - border-radius: 4px; - padding: 8px 15px; + border-radius: 6px; + padding: 10px 18px; cursor: pointer; + font-weight: 500; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .relay-connect-button:hover { background-color: var(--button-hover); + transform: translateY(-2px); + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15); } .relay-status { - margin-top: 10px; - padding: 5px 10px; - border-radius: 4px; + margin-top: 12px; + padding: 8px 15px; + border-radius: 6px; display: inline-block; + font-weight: 500; + text-align: center; + transition: all 0.3s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .relay-status.connected { - background-color: var(--bg-info); + background-color: rgba(40, 167, 69, 0.15); color: var(--button-success); border: 1px solid var(--button-success); } .relay-status.connecting { - background-color: var(--bg-info); - color: var(--text-secondary); - border: 1px solid var(--border-color); + background-color: rgba(255, 193, 7, 0.15); + color: #ffc107; + border: 1px solid #ffc107; } .relay-status.error { - background-color: var(--bg-info); + background-color: rgba(231, 76, 60, 0.15); color: #e74c3c; border: 1px solid #e74c3c; } .relay-status.notice { background-color: var(--bg-info); - color: var(--text-secondary); - border: 1px solid var(--border-color); + color: var(--accent-color); + border: 1px solid var(--accent-color); } .subscription-settings { @@ -1262,27 +1316,39 @@ footer { .filter-options { margin: 15px 0; - padding: 8px 10px; + padding: 12px 15px; background-color: var(--bg-tertiary); - border-radius: 4px; + border-radius: 8px; display: flex; align-items: center; + border: 1px solid var(--border-color); + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05); } .filter-options label { margin-right: 15px; display: inline-block; cursor: pointer; + font-weight: 500; + transition: color 0.2s ease; +} + +.filter-options label:hover { + color: var(--accent-color); } .filter-checkbox { display: flex; align-items: center; - gap: 8px; + gap: 10px; } .filter-checkbox input[type="checkbox"] { margin-right: 5px; + width: 18px; + height: 18px; + accent-color: var(--accent-color); + cursor: pointer; } .key-input { @@ -1565,29 +1631,114 @@ footer { color: var(--text-tertiary); cursor: not-allowed; } -/* Events container for side-by-side layout */ -.events-container { +/* Main App Layout with sidebar */ +.app-layout { display: flex; - gap: 20px; - margin-top: 15px; + gap: 0; + margin: 0; + width: 100%; + background-color: var(--bg-secondary); + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border-color); + max-width: 100%; } -.events-sidebar { +/* Sidebar for 21120 requests */ +.requests-sidebar { flex: 0 0 300px; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border-color); + border-radius: 0; + background-color: var(--bg-secondary); + overflow: hidden; } -.events-content { +.sidebar-header { + padding: 15px; + border-bottom: 1px solid var(--border-color); + background-color: var(--bg-tertiary); + display: flex; + justify-content: space-between; + align-items: center; +} + +.sidebar-header h3 { + margin: 0; + font-size: 16px; + color: var(--text-primary); +} + +.sidebar-controls { + display: flex; + gap: 10px; +} + +.icon-button { + background: none; + border: none; + font-size: 16px; + cursor: pointer; + color: var(--text-secondary); + transition: color 0.2s; +} + +.icon-button:hover { + color: var(--accent-color); +} + +/* Main content area */ +.main-content { flex: 1; + display: flex; + flex-direction: column; + padding: 0 20px; + gap: 20px; + overflow-y: auto; + padding-right: 10px; } -.events-list { - max-height: 600px; - overflow-y: auto; +/* Related responses section */ +.related-responses-section { border: 1px solid var(--border-color); border-radius: 8px; background-color: var(--bg-secondary); + overflow: hidden; + margin-top: 20px; +} + +.related-responses-section h3 { + margin: 0; + padding: 15px; + border-bottom: 1px solid var(--border-color); + background-color: var(--bg-tertiary); + font-size: 16px; + color: var(--text-primary); +} + +.responses-list { + padding: 15px; + max-height: 300px; + overflow-y: auto; +} + +/* Events list styling - updated for sidebar */ +.events-list { + flex: 1; + overflow-y: auto; background-color: var(--bg-secondary); } + +/* When events list is in the requests-only mode */ +.events-list.requests-only .event-item:not([data-kind="21120"]) { + display: none; +} + +.events-list.requests-only { + max-height: none; /* Allow it to fill the sidebar */ +} + .event-item { padding: 15px; border-bottom: 1px solid var(--border-color); @@ -1903,26 +2054,51 @@ footer { .json-content { margin-top: 10px; - padding: 15px; - border-radius: 4px; + padding: 20px; + border-radius: 6px; background-color: var(--bg-tertiary); min-height: 200px; max-height: 400px; overflow-y: auto; - font-family: 'Courier New', monospace; - font-size: 13px; - line-height: 1.4; + font-family: 'Consolas', 'Courier New', monospace; + font-size: 14px; + line-height: 1.5; white-space: pre-wrap; border-left: 4px solid var(--accent-color); + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); + color: var(--text-primary); +} + +/* Syntax highlighting for JSON */ +.json-string { + color: #25c2a0; +} + +.json-number { + color: #f5a623; +} + +.json-boolean { + color: #7e57c2; +} + +.json-null { + color: #bc4749; +} + +.json-key { + color: #0088cc; + font-weight: 500; } /* Event debug output for 31120 events */ .event-debug-output { margin: 15px 0; - padding: 15px; + padding: 18px; background-color: var(--bg-tertiary); - border-radius: 8px; + border-radius: 10px; border: 1px solid var(--accent-color); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } .event-debug-output h3 { @@ -2108,24 +2284,30 @@ footer { } /* Dark mode is already handled by CSS variables */ -/* Responsive adjustments for the events container */ +/* Responsive adjustments for the app layout */ @media (max-width: 768px) { - .events-container { + .app-layout { flex-direction: column; + height: auto; } - .events-sidebar { + .requests-sidebar { flex: auto; width: 100%; + height: 300px; } - .events-list { - max-height: 300px; + .main-content { + width: 100%; } .event-details { min-height: 400px; } + + .responses-list { + max-height: 200px; + } } /* Clear events button */ diff --git a/client/styles/event-list.css b/client/styles/event-list.css index 91f9615..0f8b61c 100644 --- a/client/styles/event-list.css +++ b/client/styles/event-list.css @@ -367,4 +367,116 @@ .status-server-error { background-color: rgba(156, 39, 176, 0.1); color: #9c27b0; +} + +/* Response Items in the related responses section */ +.response-item { + padding: 12px; + border-bottom: 1px solid var(--border-color); + cursor: pointer; + transition: background-color 0.2s ease; +} + +.response-item:last-child { + border-bottom: none; +} + +.response-item:hover { + background-color: var(--bg-tertiary); +} + +.response-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.response-time { + font-size: 12px; + color: var(--text-secondary); +} + +.response-id { + font-size: 12px; + color: var(--accent-color); + font-family: monospace; +} + +.status-code { + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; + background-color: var(--bg-tertiary); + font-weight: bold; +} + +.status-code.status-success { + background-color: rgba(40, 167, 69, 0.1); + color: #28a745; +} + +.status-code.status-redirect { + background-color: rgba(255, 193, 7, 0.1); + color: #ffc107; +} + +.status-code.status-client-error { + background-color: rgba(255, 152, 0, 0.1); + color: #ff9800; +} + +.status-code.status-server-error { + background-color: rgba(231, 76, 60, 0.1); + color: #e74c3c; +} + +.encryption-status { + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; +} + +.encryption-status.encrypted { + background-color: rgba(255, 152, 0, 0.1); + color: #ff9800; +} + +.encryption-status.decrypted { + background-color: rgba(40, 167, 69, 0.1); + color: #28a745; +} + +/* 21121 Response JSON Container */ +.response-json-container { + margin: 15px 0; + padding: 15px; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--bg-secondary); + max-width: 100%; + overflow: auto; +} + +.response-json-container h3 { + margin-top: 0; + color: var(--text-primary); + font-size: 16px; + border-bottom: 1px solid var(--border-color); + padding-bottom: 8px; + margin-bottom: 12px; +} + +.response-json-container pre.json-content { + background-color: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 3px; + padding: 10px; + max-height: 300px; + overflow: auto; + font-family: monospace; + font-size: 13px; + white-space: pre-wrap; + word-break: break-word; + color: var(--text-primary); } \ No newline at end of file diff --git a/client/styles/http-messages-table.css b/client/styles/http-messages-table.css new file mode 100644 index 0000000..39d5507 --- /dev/null +++ b/client/styles/http-messages-table.css @@ -0,0 +1,566 @@ +/* Table styles for HTTP Messages section */ +.http-messages-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + margin-bottom: 25px; + background-color: var(--bg-secondary); + border-radius: 10px; + overflow: hidden; + border: 1px solid var(--border-color); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.http-messages-table thead th { + background-color: var(--accent-color); + color: white; + font-weight: 600; + text-align: left; + padding: 14px 18px; + border-bottom: 2px solid rgba(255, 255, 255, 0.1); + position: relative; +} + +/* Add subtle highlight to table header */ +.http-messages-table thead:after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 1px; + background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.3), transparent); +} + +.http-messages-table tbody tr { + border-bottom: 1px solid var(--border-color); + transition: background-color 0.2s ease; + cursor: pointer; +} + +.http-messages-table tbody tr:hover { + background-color: var(--bg-tertiary); +} + +.http-messages-table tbody tr.selected { + background-color: var(--bg-tertiary); + border-left: 4px solid var(--accent-color); +} + +.http-messages-table tbody tr:last-child { + border-bottom: none; +} + +.http-messages-table td { + padding: 14px 18px; + vertical-align: middle; + transition: all 0.25s ease; +} + +.http-messages-table tbody tr:hover td { + color: var(--text-primary); +} + +.http-messages-table .sender-cell { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: 'Consolas', 'Courier New', monospace; + color: var(--text-secondary); + background-color: rgba(0, 0, 0, 0.02); + transition: all 0.25s ease; +} + +.http-messages-table tr:hover .sender-cell { + color: var(--accent-color); +} + +.http-messages-table .time-cell { + white-space: nowrap; + color: var(--text-tertiary); + font-size: 0.9em; +} + +.http-messages-table .event-id-cell { + font-family: 'Consolas', 'Courier New', monospace; + color: var(--accent-color); + font-size: 0.9em; + font-weight: 500; + padding: 6px 8px; + background-color: rgba(var(--accent-color-rgb, 13, 110, 253), 0.05); + border-radius: 4px; + display: inline-block; +} + +.http-messages-table .response-cell { + text-align: center; + width: 80px; +} + +.response-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + width: 24px; + height: 24px; + line-height: 1; + text-align: center; + border-radius: 50%; + transition: all 0.3s ease; +} + +.response-indicator.has-response { + color: white; + background-color: var(--accent-color); + font-weight: bold; + box-shadow: 0 2px 5px rgba(var(--accent-color-rgb, 13, 110, 253), 0.4); +} + +.response-indicator.no-response { + color: var(--text-tertiary); + opacity: 0.7; + background-color: var(--bg-tertiary); + border: 1px solid var(--border-color); +} + +.http-messages-table tr:hover .response-indicator.has-response { + transform: scale(1.1); +} + +/* Expandable content styles */ +.expandable-content { + display: none; + padding: 0; + background-color: var(--bg-tertiary); + border-top: 1px solid var(--border-color); + box-shadow: inset 0 4px 6px rgba(0, 0, 0, 0.05); + transition: all 0.3s ease; +} + +.expandable-content.expanded { + display: table-row; + animation: expandFade 0.3s ease-in-out; +} + +@keyframes expandFade { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.expandable-cell { + padding: 0 !important; +} + +.expandable-inner { + padding: 20px; +} + +/* Tab styles inside expandable content */ +.details-tabs { + border-bottom: 1px solid var(--border-color); + display: flex; + padding: 10px 20px 0; + background-color: var(--bg-tertiary); + flex-wrap: wrap; + gap: 5px; +} + +.details-tab { + padding: 10px 15px; + cursor: pointer; + border: 1px solid var(--border-color); + border-bottom: none; + margin-right: 5px; + transition: all 0.3s ease; + border-radius: 4px 4px 0 0; + background-color: var(--bg-secondary); + font-weight: 500; + position: relative; + bottom: -1px; + outline: none; +} + +.details-tab:hover { + color: var(--accent-color); + background-color: var(--bg-tertiary); +} + +.details-tab.active { + color: var(--accent-color); + border-bottom-color: var(--bg-tertiary); + background-color: var(--bg-tertiary); + z-index: 2; +} +.details-tab-content { + display: none; + padding: 20px; + border-top: 1px solid var(--border-color); + background-color: var(--bg-tertiary); +} + +.details-tab-content.active { + display: block; +} + +/* Styling for the HTTP Response and Request tabs */ +.details-tab-content h3 { + margin-top: 0; + color: var(--accent-color); + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-color); +} + +.details-tab-content pre { + background-color: var(--bg-secondary); + padding: 18px; + border-radius: 6px; + max-height: 300px; + overflow-y: auto; + border: 1px solid var(--border-color); + font-family: 'Consolas', 'Courier New', monospace; + font-size: 14px; + line-height: 1.5; + color: var(--text-primary); + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05); +} + +/* Loading indicator and no responses message */ +.loading-response, .no-responses { + padding: 20px; + text-align: center; + color: var(--text-tertiary); + background-color: var(--bg-secondary); + border-radius: 8px; + border: 1px solid var(--border-color); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); + font-style: italic; +} + +/* Empty state */ +.table-empty-state { + padding: 30px; + text-align: center; + color: var(--text-tertiary); + font-style: italic; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .http-messages-table { + display: block; + overflow-x: auto; + } + + .http-messages-table td.event-id-cell { + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +/* Modal styles */ +.http-messages-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; + overflow: auto; +} + +.modal-content { + position: relative; + background-color: var(--bg-secondary); + margin: 5% auto; + padding: 0; + width: 90%; + max-width: 1000px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + max-height: 85vh; + display: flex; + flex-direction: column; +} + +.modal-close-button { + position: absolute; + top: 10px; + right: 15px; + font-size: 24px; + font-weight: bold; + color: var(--text-tertiary); + background: none; + border: none; + cursor: pointer; + z-index: 2; +} + +.modal-close-button:hover { + color: var(--accent-color); +} + +/* Modal tabs */ +.modal-tabs { + display: flex; + padding: 15px 15px 0; + border-bottom: 1px solid var(--border-color); + background-color: var(--bg-tertiary); + border-radius: 8px 8px 0 0; + flex-wrap: wrap; + gap: 5px; +} + +.modal-tab { + padding: 10px 15px; + cursor: pointer; + border: 1px solid var(--border-color); + border-bottom: none; + margin-right: 5px; + transition: all 0.3s ease; + border-radius: 4px 4px 0 0; + background-color: var(--bg-secondary); + color: var(--text-primary); + font-weight: 500; + position: relative; + bottom: -1px; + outline: none; +} + +.modal-tab:hover { + color: var(--accent-color); + background-color: var(--bg-tertiary); +} + +.modal-tab.active { + color: var(--accent-color); + border-bottom-color: var(--bg-tertiary); + background-color: var(--bg-tertiary); + z-index: 2; +} + +/* Modal tab contents */ +.modal-tab-contents { + padding: 20px; + background-color: var(--bg-tertiary); + overflow-y: auto; + flex: 1; + border-radius: 0 0 8px 8px; +} + +.modal-tab-content { + display: none; + max-height: 60vh; + overflow-y: auto; +} + +.modal-tab-content pre { + background-color: var(--bg-secondary); + padding: 15px; + border-radius: 4px; + overflow-x: auto; + border: 1px solid var(--border-color); + margin: 0; +} + +.json-content { + font-family: 'Consolas', 'Courier New', monospace; + font-size: 14px; + white-space: pre-wrap; + line-height: 1.5; + color: var(--text-primary); +} + +/* Add syntax highlighting for JSON content */ +.json-content .string { color: #25c2a0; } +.json-content .number { color: #f5a623; } +.json-content .boolean { color: #7e57c2; } +.json-content .null { color: #bc4749; } +.json-content .key { color: #0088cc; font-weight: 500; } + +/* Modal update notification */ +.modal-update-notification { + position: absolute; + top: 15px; + left: 50%; + transform: translateX(-50%); + background-color: var(--accent-color); + color: white; + padding: 8px 16px; + border-radius: 4px; + font-weight: 500; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + z-index: 3; + animation: fadeInOut 3s ease-in-out; +} + +@keyframes fadeInOut { + 0% { opacity: 0; transform: translateX(-50%) translateY(-10px); } + 15% { opacity: 1; transform: translateX(-50%) translateY(0); } + 85% { opacity: 1; transform: translateX(-50%) translateY(0); } + 100% { opacity: 0; transform: translateX(-50%) translateY(-10px); } +} + +.no-response { + padding: 15px; + text-align: center; + color: var(--text-tertiary); + background-color: var(--bg-secondary); + border-radius: 4px; + border: 1px solid var(--border-color); + margin: 20px 0; +} + +/* HTTP content formatting - Enhanced */ +.http-first-line { + font-weight: bold; + margin-bottom: 12px; + padding: 12px 15px; + border-bottom: 1px solid var(--border-color); + background-color: rgba(var(--accent-color-rgb, 13, 110, 253), 0.05); + border-radius: 6px 6px 0 0; +} + +.http-method { + color: #0088cc; + font-weight: bold; + margin-right: 12px; + background-color: rgba(0, 136, 204, 0.1); + padding: 2px 8px; + border-radius: 4px; +} + +.http-path { + color: var(--text-primary); + margin-right: 12px; + font-family: 'Consolas', 'Courier New', monospace; +} + +.http-version { + color: var(--text-tertiary); + background-color: var(--bg-tertiary); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.9em; +} + +.http-status { + padding: 3px 8px; + border-radius: 6px; + margin: 0 12px; + font-weight: 600; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.http-status.success { + background-color: rgba(40, 167, 69, 0.9); + color: white; +} + +.http-status.error { + background-color: rgba(220, 53, 69, 0.9); + color: white; +} + +.http-status.redirect { + background-color: rgba(253, 126, 20, 0.9); + color: white; +} + +.http-status-text { + color: var(--text-primary); + font-weight: 500; +} + +.http-headers { + margin-bottom: 20px; + padding: 15px; + border-bottom: 1px dashed var(--border-color); + background-color: var(--bg-secondary); + border-radius: 4px; +} + +.http-header { + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px dotted rgba(var(--border-color-rgb, 222, 226, 230), 0.5); + display: flex; + flex-wrap: wrap; +} + +.http-header:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.http-header-name { + color: #0088cc; + font-weight: 600; + margin-right: 10px; + min-width: 120px; + font-family: 'Consolas', 'Courier New', monospace; +} + +.http-header-value { + color: var(--text-primary); + flex: 1; + font-family: 'Consolas', 'Courier New', monospace; + word-break: break-all; +} + +.http-body { + font-family: 'Consolas', 'Courier New', monospace; + white-space: pre-wrap; + word-break: break-all; + padding: 15px; + background-color: var(--bg-secondary); + border-radius: 4px; + border: 1px solid var(--border-color); + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.http-body.json { + color: var(--accent-color); +} + +/* Add a label before the body content */ +.http-body-label { + display: block; + margin-bottom: 10px; + font-weight: 600; + color: var(--text-secondary); + border-bottom: 1px dotted var(--border-color); + padding-bottom: 8px; +} + +/* Responsive modal */ +@media (max-width: 768px) { + .modal-content { + width: 95%; + margin: 10% auto; + } + + .modal-tabs { + flex-direction: column; + padding: 10px 10px 0; + } + + .modal-tab { + margin-bottom: 5px; + width: 100%; + text-align: left; + } +} \ No newline at end of file