diff --git a/client/receive.html b/client/receive.html index 120afa8..b45e2a1 100644 --- a/client/receive.html +++ b/client/receive.html @@ -26,27 +26,64 @@ <!-- Main Content --> <div class="content"> <div class="info-box"> - <p>This tool allows you to receive and view HTTP events (kind 21120) from Nostr relays. Connect to a relay, subscribe to events, and decrypt messages.</p> + <p>This tool allows you to receive and view HTTP events (kind 21120) from Nostr relays, QR codes, or raw text input. Choose your preferred method using the tabs below.</p> </div> - <h2>Relay Connection</h2> - <div class="relay-connection"> - <div class="relay-input-container"> - <label for="relayUrl">Relay URL:</label> - <input type="text" id="relayUrl" value="wss://relay.degmods.com" placeholder="wss://relay.example.com"> - <button id="connectRelayBtn" class="relay-connect-button">Connect</button> + <!-- Tab Navigation - Updated data-tab attributes to match section IDs --> + <div class="tabs"> + <button class="tab-button active" data-tab="relay-connection-section">From Relay</button> + <button class="tab-button" data-tab="qr-code-scanner-section">From QR Scanner</button> + <button class="tab-button" data-tab="raw-event-input-section">From Raw Text</button> + </div> + + <!-- Tab Content - Each section gets a distinct ID --> + <!-- Relay Tab Content --> + <div id="relay-connection-section" class="active"> + <h2>Relay Connection</h2> + <div class="relay-connection"> + <div class="relay-input-container"> + <label for="relayUrl">Relay URL:</label> + <input type="text" id="relayUrl" value="wss://relay.degmods.com" placeholder="wss://relay.example.com"> + <button id="connectRelayBtn" class="relay-connect-button">Connect</button> + </div> + <div id="relayStatus" class="relay-status">Not connected</div> + </div> + </div> + + <!-- QR Scanner Tab Content --> + <div id="qr-code-scanner-section"> + <h2>QR Code Scanner</h2> + <div class="qr-scanner-container"> + <div class="qr-viewport"> + <video id="qrVideo"></video> + <canvas id="qrCanvas" style="display: none;"></canvas> + </div> + <div class="qr-controls"> + <button id="startScanBtn" class="action-button">Start Camera</button> + <button id="stopScanBtn" class="action-button" disabled>Stop Camera</button> + </div> + <div id="qrStatus" class="status-message">Camera inactive. Click Start Camera to begin scanning.</div> + </div> + </div> + + <!-- Raw Text Tab Content --> + <div id="raw-event-input-section"> + <h2>Raw Event Input</h2> + <div class="raw-input-container"> + <p>Paste a raw Nostr event JSON below:</p> + <textarea id="rawEventInput" placeholder='{"id": "...", "pubkey": "...", "created_at": 1234567890, "kind": 21120, "tags": [], "content": "..."}' rows="10"></textarea> + <button id="parseRawEventBtn" class="action-button">Parse Event</button> + <div id="rawInputStatus" class="status-message"></div> </div> - <div id="relayStatus" class="relay-status">Not connected</div> </div> <h2>Received Events</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. Connect to a relay to start receiving events. + No events received yet. Use one of the methods above to receive events. </div> <!-- Events will be displayed here --> </div> diff --git a/client/src/receiver.ts b/client/src/receiver.ts index 327ee96..388eac1 100644 --- a/client/src/receiver.ts +++ b/client/src/receiver.ts @@ -1,4 +1,5 @@ // External dependencies +import jsQR from 'jsqr'; import * as nostrTools from 'nostr-tools'; // Internal imports @@ -706,6 +707,271 @@ Decrypted key: ${decryptedKey}`; } } +// Setup tab navigation +function setupTabNavigation(): void { + const tabButtons = document.querySelectorAll('.tab-button'); + const contentSections = document.querySelectorAll('#relay-connection-section, #qr-code-scanner-section, #raw-event-input-section'); + + // Add click event listeners to tab buttons + tabButtons.forEach(button => { + button.addEventListener('click', () => { + // Get the target tab + const targetTabId = (button as HTMLElement).dataset.tab; + if (!targetTabId) { + return; + } + + // Remove active class from all buttons and content sections + tabButtons.forEach(btn => btn.classList.remove('active')); + contentSections.forEach(section => section.classList.remove('active')); + + // Add active class to clicked button and corresponding content section + button.classList.add('active'); + const targetSection = document.getElementById(targetTabId); + if (targetSection) { + targetSection.classList.add('active'); + } + + // Log for debugging + console.log(`Tab switched to: ${targetTabId}, active sections: ${document.querySelectorAll('.active').length}`); + }); + }); +} + +// Variables for QR scanner +let qrScanning = false; +let videoStream: MediaStream | null = null; + +// Setup QR code scanner +function setupQRScanner(): void { + const startScanBtn = document.getElementById('startScanBtn'); + const stopScanBtn = document.getElementById('stopScanBtn'); + const qrVideo = document.getElementById('qrVideo') as HTMLVideoElement; + const qrCanvas = document.getElementById('qrCanvas') as HTMLCanvasElement; + const qrStatus = document.getElementById('qrStatus'); + + // Function to start the QR scanner + async function startQRScanner() { + // Already scanning + if (qrScanning) { + return; + } + + try { + // Get access to the camera + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'environment' } + }); + + // Update status + if (qrStatus) { + qrStatus.textContent = 'Camera active. Point at a QR code.'; + qrStatus.className = 'status-message'; + } + + // Set video source to camera stream + if (qrVideo) { + qrVideo.srcObject = stream; + qrVideo.play(); + videoStream = stream; + } + + // Enable/disable buttons + if (startScanBtn) { + startScanBtn.setAttribute('disabled', 'true'); + } + if (stopScanBtn) { + stopScanBtn.removeAttribute('disabled'); + } + + // Start scanning + qrScanning = true; + scanQRCode(); + } catch (error) { + // Handle errors + console.error('Error accessing camera:', error); + if (qrStatus) { + qrStatus.textContent = `Error: ${error instanceof Error ? error.message : String(error)}`; + qrStatus.className = 'status-message error'; + } + } + } + + // Function to stop the QR scanner + function stopQRScanner() { + if (!qrScanning) { + return; + } + + // Stop all tracks + if (videoStream) { + videoStream.getTracks().forEach(track => track.stop()); + videoStream = null; + } + + // Update status + if (qrStatus) { + qrStatus.textContent = 'Camera inactive. Click Start Camera to begin scanning.'; + qrStatus.className = 'status-message'; + } + + // Enable/disable buttons + if (startScanBtn) { + startScanBtn.removeAttribute('disabled'); + } + if (stopScanBtn) { + stopScanBtn.setAttribute('disabled', 'true'); + } + + qrScanning = false; + } + + // Add event listeners + if (startScanBtn) { + startScanBtn.addEventListener('click', startQRScanner); + } + + if (stopScanBtn) { + stopScanBtn.addEventListener('click', stopQRScanner); + } + + // Function to scan for QR codes + function scanQRCode() { + if (!qrScanning) { + return; + } + + // Request animation frame for next scan + window.requestAnimationFrame(scanQRCode); + + if (!qrVideo || !qrCanvas) { + return; + } + + // Skip if video isn't playing + if (qrVideo.readyState !== qrVideo.HAVE_ENOUGH_DATA) { + return; + } + + // Get canvas context + const context = qrCanvas.getContext('2d'); + if (!context) { + return; + } + + // Set canvas dimensions to match video + qrCanvas.width = qrVideo.videoWidth; + qrCanvas.height = qrVideo.videoHeight; + + // Draw current video frame to canvas + context.drawImage(qrVideo, 0, 0, qrCanvas.width, qrCanvas.height); + + // Get image data for QR detection + const imageData = context.getImageData(0, 0, qrCanvas.width, qrCanvas.height); + + // Process with jsQR + const code = jsQR(imageData.data, imageData.width, imageData.height, { + inversionAttempts: 'dontInvert' + }); + + // Process QR code if found + if (code) { + console.log('QR code detected:', code.data); + if (qrStatus) { + qrStatus.textContent = 'QR code found! Processing...'; + } + + // Try to parse as a Nostr event + try { + const eventData = JSON.parse(code.data); + + // Check if this is a valid Nostr event + if (eventData && eventData.id && eventData.pubkey && + eventData.created_at && eventData.kind === 21120) { + + // Stop scanning after successful detection + stopQRScanner(); + + if (qrStatus) { + qrStatus.textContent = 'Valid Nostr event found! Event processed.'; + } + + // Process the event + processEvent(eventData); + } else { + if (qrStatus) { + qrStatus.textContent = 'QR code found, but not a valid kind 21120 Nostr event.'; + } + } + } catch (error) { + console.error('Error parsing QR code data:', error); + if (qrStatus) { + qrStatus.textContent = `Error parsing QR data: ${error instanceof Error ? error.message : String(error)}`; + } + } + } + } + + // Add event listeners + if (startScanBtn) { + startScanBtn.addEventListener('click', startQRScanner); + } + + if (stopScanBtn) { + stopScanBtn.addEventListener('click', stopQRScanner); + } +} + +// Setup raw text input +function setupRawTextInput(): void { + const parseRawEventBtn = document.getElementById('parseRawEventBtn'); + const rawEventInput = document.getElementById('rawEventInput') as HTMLTextAreaElement; + const rawInputStatus = document.getElementById('rawInputStatus'); + + if (parseRawEventBtn && rawEventInput) { + parseRawEventBtn.addEventListener('click', () => { + const rawText = rawEventInput.value.trim(); + + if (!rawText) { + if (rawInputStatus) { + rawInputStatus.textContent = 'Please enter a raw Nostr event.'; + } + return; + } + + try { + // Parse the JSON data + const eventData = JSON.parse(rawText); + + // Validate as Nostr event + if (eventData && eventData.id && eventData.pubkey && + eventData.created_at && eventData.kind === 21120) { + + // Process the event + processEvent(eventData); + + if (rawInputStatus) { + rawInputStatus.textContent = 'Event successfully processed!'; + } + + // Clear the input for convenience + rawEventInput.value = ''; + } else { + if (rawInputStatus) { + rawInputStatus.textContent = 'Not a valid kind 21120 Nostr event.'; + } + } + } catch (error) { + console.error('Error parsing raw event:', error); + if (rawInputStatus) { + rawInputStatus.textContent = `Error parsing: ${error instanceof Error ? error.message : String(error)}`; + } + } + }); + } +} + + // Initialize document.addEventListener('DOMContentLoaded', () => { // Get DOM elements @@ -716,6 +982,15 @@ document.addEventListener('DOMContentLoaded', () => { const connectRelayBtn = document.getElementById('connectRelayBtn'); + // Tab functionality + setupTabNavigation(); + + // QR code scanner setup + setupQRScanner(); + + // Raw text input setup + setupRawTextInput(); + // Connect to relay and automatically start subscription for logged-in user if (connectRelayBtn) { connectRelayBtn.addEventListener('click', async () => { diff --git a/client/src/styles.css b/client/src/styles.css index 81e3b12..0f2fd84 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -471,4 +471,131 @@ pre { .content { padding: 10px 0; } +} + +/* Tab Interface Styles */ +.tabs { + display: flex; + border-bottom: 2px solid var(--border-color); + margin-bottom: 20px; +} + +.tab-button { + padding: 10px 20px; + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + font-weight: 500; + color: var(--text-secondary); + margin-right: 10px; + margin-bottom: -2px; + transition: all 0.3s ease; +} + +.tab-button:hover { + color: var(--accent-color); +} + +.tab-button.active { + color: var(--accent-color); + border-bottom: 2px solid var(--accent-color); +} + +/* Tab content sections */ +#relay-connection-section, +#qr-code-scanner-section, +#raw-event-input-section { + display: none; + margin-top: 20px; + margin-bottom: 20px; +} + +#relay-connection-section.active, +#qr-code-scanner-section.active, +#raw-event-input-section.active { + display: block; +} + +/* QR Scanner Styles */ +.qr-scanner-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; +} + +.qr-viewport { + width: 100%; + max-width: 500px; + height: 300px; + position: relative; + overflow: hidden; + border: 3px solid var(--accent-color); + border-radius: 8px; + background-color: var(--bg-tertiary); +} + +.qr-viewport video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.qr-controls { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.action-button { + padding: 8px 15px; + background-color: var(--button-primary); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.action-button:hover { + background-color: var(--button-hover); +} + +.action-button:disabled { + background-color: var(--bg-tertiary); + color: var(--text-tertiary); + cursor: not-allowed; +} + +.status-message { + margin-top: 10px; + padding: 8px 12px; + border-radius: 4px; + background-color: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 14px; +} + +/* Raw Input Styles */ +.raw-input-container { + display: flex; + flex-direction: column; + gap: 10px; +} + +.raw-input-container textarea { + width: 100%; + min-height: 200px; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--bg-tertiary); + color: var(--text-primary); + font-family: 'Courier New', monospace; + font-size: 14px; + resize: vertical; +} + +.raw-input-container button { + align-self: flex-start; } \ No newline at end of file diff --git a/client/styles.css b/client/styles.css index 8bafbe6..c6cd1a5 100644 --- a/client/styles.css +++ b/client/styles.css @@ -1158,4 +1158,131 @@ footer { .clear-events-button:hover { background-color: #c82333; +} + +/* Tab Interface Styles */ +.tabs { + display: flex; + border-bottom: 2px solid var(--border-color); + margin-bottom: 20px; +} + +.tab-button { + padding: 10px 20px; + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + font-weight: 500; + color: var(--text-secondary); + margin-right: 10px; + margin-bottom: -2px; + transition: all 0.3s ease; +} + +.tab-button:hover { + color: var(--accent-color); +} + +.tab-button.active { + color: var(--accent-color); + border-bottom: 2px solid var(--accent-color); +} + +/* Tab content sections */ +#relay-connection-section, +#qr-code-scanner-section, +#raw-event-input-section { + display: none; + margin-top: 20px; + margin-bottom: 20px; +} + +#relay-connection-section.active, +#qr-code-scanner-section.active, +#raw-event-input-section.active { + display: block; +} + +/* QR Scanner Styles */ +.qr-scanner-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; +} + +.qr-viewport { + width: 100%; + max-width: 500px; + height: 300px; + position: relative; + overflow: hidden; + border: 3px solid var(--accent-color); + border-radius: 8px; + background-color: var(--bg-tertiary); +} + +.qr-viewport video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.qr-controls { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.action-button { + padding: 8px 15px; + background-color: var(--button-primary); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.action-button:hover { + background-color: var(--button-hover); +} + +.action-button:disabled { + background-color: var(--bg-tertiary); + color: var(--text-tertiary); + cursor: not-allowed; +} + +.status-message { + margin-top: 10px; + padding: 8px 12px; + border-radius: 4px; + background-color: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 14px; +} + +/* Raw Input Styles */ +.raw-input-container { + display: flex; + flex-direction: column; + gap: 10px; +} + +.raw-input-container textarea { + width: 100%; + min-height: 200px; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--bg-tertiary); + color: var(--text-primary); + font-family: 'Courier New', monospace; + font-size: 14px; + resize: vertical; +} + +.raw-input-container button { + align-self: flex-start; } \ No newline at end of file diff --git a/client/webpack.config.js b/client/webpack.config.js index 67e1084..25bafa5 100644 --- a/client/webpack.config.js +++ b/client/webpack.config.js @@ -25,7 +25,7 @@ module.exports = { new NodePolyfillPlugin(), new CopyPlugin({ patterns: [ - { from: 'src/styles.css', to: 'styles.css' }, + { from: 'styles.css', to: 'styles.css' }, { from: 'http.png', to: 'http.png' }, { from: 'index.html', to: 'index.html' }, { from: 'help.html', to: 'help.html' }, diff --git a/package-lock.json b/package-lock.json index b1258ca..1941a34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "jsqr": "^1.4.0", "qrcode": "^1.5.4" } }, @@ -126,6 +127,12 @@ "node": ">=8" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", diff --git a/package.json b/package.json index 672f71b..cba74fe 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "author": "", "license": "MIT", "dependencies": { + "jsqr": "^1.4.0", "qrcode": "^1.5.4" } }