This commit is contained in:
n 2025-04-07 15:53:01 +01:00
parent ad5babda43
commit bf471337b0
14 changed files with 6583 additions and 2180 deletions

@ -1,5 +1,6 @@
do everything in TypeScript (not vanilla Javascript)
don't try to modify files in the build folder (eg /dist)
don't put any css or javascript blocks in the index.html file
run the lint after every code update
don't ever use the alert function
npm run lint after every code update
don't ever use the alert function
assume you are in the client folder

@ -1,9 +1,10 @@
import tseslint from '@typescript-eslint/eslint-plugin';
import tsparser from '@typescript-eslint/parser';
import importPlugin from 'eslint-plugin-import';
import js from '@eslint/js';
// eslint.config.js - CommonJS format
const tseslint = require('@typescript-eslint/eslint-plugin');
const tsparser = require('@typescript-eslint/parser');
const importPlugin = require('eslint-plugin-import');
const js = require('@eslint/js');
export default [
module.exports = [
js.configs.recommended,
{
ignores: [
@ -33,7 +34,14 @@ export default [
navigator: 'readonly',
window: 'readonly',
console: 'readonly',
alert: 'readonly'
alert: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
localStorage: 'readonly',
atob: 'readonly',
btoa: 'readonly',
crypto: 'readonly',
WebSocket: 'readonly'
}
},
plugins: {
@ -44,7 +52,7 @@ export default [
// TypeScript specific rules
'@typescript-eslint/explicit-module-boundary-types': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
'@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_', 'varsIgnorePattern': '^_' }],
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
'@typescript-eslint/consistent-type-imports': 'error',
@ -69,5 +77,31 @@ export default [
'prefer-const': 'error',
'curly': 'error',
}
},
// JavaScript config for Node.js files
{
files: ['**/*.js'],
languageOptions: {
sourceType: 'commonjs',
globals: {
// Node globals
process: 'readonly',
module: 'writable',
exports: 'writable',
require: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
Buffer: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly'
}
},
rules: {
'no-undef': 'error',
'no-unused-vars': 'error'
}
}
];

@ -23,8 +23,10 @@
<!-- Navigation -->
<div class="navigation">
<a href="./index.html" class="nav-link">Home</a>
<a href="./index.html" class="nav-link">Send Events</a>
<a href="./help.html" class="nav-link active">Documentation</a>
<a href="./receive.html" class="nav-link">Receive Events</a>
<a href="./profile.html" class="nav-link">Profile</a>
</div>
<!-- Documentation Content -->

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Implement strict Content Security Policy -->
<title>HTTP Messages - Converter</title>
<title>HTTP Messages - Send Events</title>
<!-- Load our CSS file -->
<link rel="stylesheet" href="./styles.css">
<!-- Include the Nostr extensions - these will be accessed via window.nostr -->
@ -23,8 +23,10 @@
<!-- Navigation -->
<div class="navigation">
<a href="./index.html" class="nav-link active">Home</a>
<a href="./index.html" class="nav-link active">Send Events</a>
<a href="./help.html" class="nav-link">Documentation</a>
<a href="./receive.html" class="nav-link">Receive Events</a>
<a href="./profile.html" class="nav-link">Profile</a>
</div>
<!-- Main Content (HTTP Request Converter) -->
@ -63,7 +65,12 @@ User-Agent: Browser/1.0
<div id="output" hidden>
<h2>Converted Event:</h2>
<pre id="eventOutput"></pre>
<div class="event-output-container">
<pre id="eventOutput"></pre>
<button id="copyEventButton" class="copy-button" title="Copy to clipboard">
<span>Copy</span>
</button>
</div>
<div class="publish-container">
<h2>Publish to Relay:</h2>

6871
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

119
client/profile.html Normal file

@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTTP Messages - Profile</title>
<link rel="stylesheet" href="./styles.css">
<script defer src="./bundle.js"></script>
</head>
<body>
<!-- Theme toggle button -->
<div class="theme-toggle-container">
<button id="themeToggleBtn" class="theme-toggle-btn">
<span id="themeIcon">🌙</span>
<span id="themeText">Dark Mode</span>
</button>
</div>
<h1>HTTP Messages</h1>
<!-- Navigation -->
<div class="navigation">
<a href="./index.html" class="nav-link">Send Events</a>
<a href="./help.html" class="nav-link">Documentation</a>
<a href="./receive.html" class="nav-link">Receive Events</a>
<a href="./profile.html" class="nav-link active">Profile</a>
</div>
<!-- Main Content -->
<div class="content">
<div class="info-box">
<p>View and manage your Nostr profile information. Connect with a NIP-07 extension or enter your keys manually.</p>
</div>
<h2>Your Nostr Identity</h2>
<div class="profile-section connection-status">
<div id="connectionStatus" class="connection-status-indicator">
Not connected to any extension
</div>
<button id="connectButton" class="connect-button">Connect with Extension</button>
<div class="manual-entry">
<details>
<summary>Manual Key Entry</summary>
<div class="manual-key-entry">
<label for="manualPubkey">Public Key (hex or npub):</label>
<input type="text" id="manualPubkey" placeholder="npub or hex pubkey">
<button id="setManualPubkeyBtn">Set Key</button>
</div>
</details>
</div>
</div>
<div id="profileContainer" class="profile-container hidden">
<h2>Profile Information</h2>
<div class="profile-card">
<div class="profile-header">
<div id="profilePicture" class="profile-picture">
<!-- Default placeholder image -->
<div class="profile-placeholder">👤</div>
</div>
<div class="profile-basic-info">
<h3 id="profileName">Unknown User</h3>
<div id="profileNip05" class="profile-nip05"></div>
</div>
</div>
<div class="profile-details">
<div class="profile-detail-item">
<strong>Pubkey (hex):</strong>
<div id="profilePubkeyHex" class="profile-value profile-key"></div>
</div>
<div class="profile-detail-item">
<strong>Pubkey (npub):</strong>
<div id="profilePubkeyNpub" class="profile-value profile-key"></div>
</div>
<div class="profile-detail-item">
<strong>About:</strong>
<div id="profileAbout" class="profile-value"></div>
</div>
</div>
<div class="profile-footer">
<button id="refreshProfileBtn" class="refresh-profile-btn">Refresh Profile</button>
<button id="copyNpubBtn" class="copy-npub-btn">Copy npub</button>
</div>
</div>
<div class="profile-stats">
<h3>HTTP Messaging Stats</h3>
<div class="stats-container">
<div class="stat-item">
<div class="stat-value" id="requestsSent">0</div>
<div class="stat-label">Requests Sent</div>
</div>
<div class="stat-item">
<div class="stat-value" id="responsesReceived">0</div>
<div class="stat-label">Responses Received</div>
</div>
<div class="stat-item">
<div class="stat-value" id="relaysConnected">0</div>
<div class="stat-label">Relays</div>
</div>
</div>
</div>
<div class="qr-section">
<h3>Your npub QR Code</h3>
<div id="npubQrCode" class="npub-qr-code"></div>
<p class="qr-caption">Scan this QR code to share your Nostr public key</p>
</div>
</div>
</div>
<!-- Profile script will be loaded from bundle.js -->
</body>
</html>

78
client/receive.html Normal file

@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTTP Messages - Receive Events</title>
<link rel="stylesheet" href="./styles.css">
<script defer src="./bundle.js"></script>
</head>
<body>
<!-- Theme toggle button -->
<div class="theme-toggle-container">
<button id="themeToggleBtn" class="theme-toggle-btn">
<span id="themeIcon">🌙</span>
<span id="themeText">Dark Mode</span>
</button>
</div>
<h1>HTTP Messages</h1>
<!-- Navigation -->
<div class="navigation">
<a href="./index.html" class="nav-link">Send Events</a>
<a href="./help.html" class="nav-link">Documentation</a>
<a href="./receive.html" class="nav-link active">Receive Events</a>
<a href="./profile.html" class="nav-link">Profile</a>
</div>
<!-- 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>
</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.damus.io" 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>
<h2>Subscription Settings</h2>
<div class="subscription-settings">
<p class="info-text">Automatically shows all kind 21120 events that are p-tagged to the server npub.</p>
<button id="startSubscriptionBtn" class="start-subscription-button">Start Subscription</button>
<button id="stopSubscriptionBtn" class="stop-subscription-button" disabled>Stop Subscription</button>
</div>
<h2>Received Events</h2>
<div class="received-events">
<div class="event-controls">
<button id="clearEventsBtn" class="clear-events-button">Clear Events</button>
</div>
<div id="eventsList" class="events-list">
<div class="empty-state">
No events received yet. Connect to a relay and start a subscription.
</div>
<!-- Events will be displayed here -->
</div>
</div>
<h2>Event Details</h2>
<div id="eventDetails" class="event-details">
<div class="empty-state">
Select an event to view details
</div>
<!-- Selected event details will be shown here -->
</div>
</div>
<!-- Script will be provided by bundle.js -->
</body>
</html>

@ -10,6 +10,10 @@ import type { NostrEvent } from './converter';
import { displayConvertedEvent } from './converter';
import { publishToRelay, convertNpubToHex, verifyEvent } from './relay';
import { searchUsers } from './search';
// Import profile functions (not using direct imports since we'll load modules based on page)
// This ensures all page modules are included in the bundle
import './profile';
import './receiver'; // Import receiver module for relay connections and subscriptions
import {
sanitizeText,
setDefaultHttpRequest,
@ -360,6 +364,42 @@ function setupTabSwitching(): void {
});
}
/**
* Handle click on copy event button
*/
function handleCopyEvent(): void {
const copyButton = document.getElementById('copyEventButton');
const eventOutput = document.getElementById('eventOutput');
if (!copyButton || !eventOutput) {
return;
}
copyButton.addEventListener('click', () => {
if (!eventOutput.textContent) {
return;
}
// Copy text to clipboard
navigator.clipboard.writeText(eventOutput.textContent)
.then(() => {
// Visual feedback
copyButton.classList.add('copied');
const originalText = copyButton.innerHTML;
copyButton.innerHTML = '<span>Copied!</span>';
// Reset after 2 seconds
setTimeout(() => {
copyButton.classList.remove('copied');
copyButton.innerHTML = originalText;
}, 2000);
})
.catch((err) => {
console.error('Could not copy text: ', err);
});
});
}
// Initialize the event handlers when the DOM is loaded
document.addEventListener('DOMContentLoaded', function(): void {
// Set up the convert button click handler
@ -424,6 +464,9 @@ document.addEventListener('DOMContentLoaded', function(): void {
toggleElement.addEventListener('click', toggleTheme);
}
// Initialize copy button
handleCopyEvent();
// Setup tab switching
setupTabSwitching();
});

@ -5,7 +5,7 @@ import qrcode from 'qrcode-generator';
// Internal imports
import { defaultServerConfig, appSettings } from './config';
import { convertNpubToHex } from './relay';
import { processTags, showSuccess } from './utils';
import { showSuccess } from './utils';
// Define interface for Nostr event - making id and sig optional for event creation
export interface NostrEvent {
@ -173,20 +173,35 @@ export async function convertToEvent(
console.log("Final encryptedContent before creating event:",
encryptedContent.substring(0, 50) + "...");
// Convert serverPubkey to hex if it's an npub
// Ensure the serverPubkey is in valid hex format for the p tag
let pTagValue = serverPubkey;
if (serverPubkey.startsWith('npub')) {
// First check if it's already a valid hex string
if (/^[0-9a-f]{64}$/i.test(serverPubkey)) {
// Already a valid hex pubkey, use as is
console.log(`Server pubkey is already a valid hex string: ${serverPubkey}`);
}
// Try to convert from npub format if needed
else if (serverPubkey.startsWith('npub')) {
try {
console.log(`Converting p tag npub to hex: ${serverPubkey}`);
const decoded = nostrTools.nip19.decode(serverPubkey);
if (decoded.type === 'npub' && decoded.data) {
pTagValue = decoded.data as string;
console.log(`Converted to hex pubkey: ${pTagValue}`);
} else {
throw new Error("Failed to decode npub properly");
}
} catch (error) {
console.error(`Error decoding npub: ${serverPubkey}`, error);
throw new Error(`Invalid npub format: "${serverPubkey}". Please enter a valid npub (bech32 encoded public key) or a 64-character hex string.`);
}
}
// Not a valid hex pubkey or npub
else {
console.error(`Invalid server pubkey format: ${serverPubkey}`);
throw new Error(`Invalid server pubkey format: "${serverPubkey}". Server pubkey must be a valid npub or a 64-character hex string.`);
}
// Create the event with the proper structure
// Ensure encryptedContent is a string
const finalContent = typeof encryptedContent === 'string' ?
@ -212,8 +227,33 @@ export async function convertToEvent(
event.tags.push(["r", relayUrl]);
}
// Process tags to ensure proper format (convert any npub in tags to hex, etc.)
event.tags = processTags(event.tags);
// Double-check that all p tags are in hex format
for (let i = 0; i < event.tags.length; i++) {
const tag = event.tags[i];
if (tag[0] === 'p') {
// If it's still an npub, try to convert it again
if (tag[1].startsWith('npub')) {
try {
const hexPubkey = convertNpubToHex(tag[1]);
if (hexPubkey) {
event.tags[i][1] = hexPubkey;
console.log(`Forcibly converted p tag to hex: ${hexPubkey}`);
} else {
throw new Error(`Failed to convert p tag: ${tag[1]}`);
}
} catch (error) {
console.error(`Error in final p tag conversion: ${tag[1]}`, error);
throw new Error(`Invalid npub in p tag: ${tag[1]}`);
}
}
// Verify the tag is now in hex format
if (!/^[0-9a-f]{64}$/i.test(event.tags[i][1])) {
console.error(`Invalid hex format in p tag: ${event.tags[i][1]}`);
throw new Error(`P tag must be a 64-character hex string, got: ${event.tags[i][1]}`);
}
}
}
return JSON.stringify(event, null, 2);
}

415
client/src/profile.ts Normal file

@ -0,0 +1,415 @@
// External dependencies
import * as nostrTools from 'nostr-tools';
import qrcode from 'qrcode-generator';
// Interface for profile data
interface ProfileData {
name?: string;
about?: string;
picture?: string;
nip05?: string;
[key: string]: any;
}
// Element references
let connectionStatus: HTMLElement | null = null;
let profileContainer: HTMLElement | null = null;
let profileName: HTMLElement | null = null;
let profileNip05: HTMLElement | null = null;
let profilePubkeyHex: HTMLElement | null = null;
let profilePubkeyNpub: HTMLElement | null = null;
let profileAbout: HTMLElement | null = null;
let profilePicture: HTMLElement | null = null;
let npubQrCode: HTMLElement | null = null;
let statsRequestsSent: HTMLElement | null = null;
let statsResponsesReceived: HTMLElement | null = null;
let statsRelaysConnected: HTMLElement | null = null;
// Profile data
let currentPubkey: string | null = null;
// Initialize with default empty profile data so it's being used
let currentProfileData: ProfileData = {
name: '',
about: '',
picture: '',
nip05: ''
};
// Connect to extension
async function connectWithExtension(): Promise<void> {
try {
if (!window.nostr) {
updateConnectionStatus('No Nostr extension detected. Please install a NIP-07 compatible extension.', false);
return;
}
const pubkey = await window.nostr.getPublicKey();
if (pubkey) {
currentPubkey = pubkey;
updateConnectionStatus(`Connected with extension using pubkey: ${pubkey.substring(0, 8)}...`, true);
showProfile(pubkey);
} else {
updateConnectionStatus('Failed to get public key from extension', false);
}
} catch (err) {
console.error('Error connecting with extension:', err);
updateConnectionStatus(`Error: ${err instanceof Error ? err.message : String(err)}`, false);
}
}
// Set manual pubkey
function setManualPubkey(pubkeyInput: string): void {
try {
let hexPubkey: string;
// Handle npub format
if (pubkeyInput.startsWith('npub')) {
try {
const decoded = nostrTools.nip19.decode(pubkeyInput);
if (decoded.type === 'npub') {
hexPubkey = decoded.data as string;
} else {
throw new Error('Invalid npub format');
}
} catch (error) {
updateConnectionStatus(`Invalid npub format: ${error}`, false);
return;
}
} else {
// Assume hex format
if (!/^[0-9a-f]{64}$/i.test(pubkeyInput)) {
updateConnectionStatus('Invalid hex pubkey format. Must be 64 hex characters.', false);
return;
}
hexPubkey = pubkeyInput;
}
currentPubkey = hexPubkey;
updateConnectionStatus(`Using manually entered pubkey: ${hexPubkey.substring(0, 8)}...`, true);
showProfile(hexPubkey);
} catch (error) {
console.error('Error setting manual pubkey:', error);
updateConnectionStatus(`Error: ${error instanceof Error ? error.message : String(error)}`, false);
}
}
// Update connection status UI
function updateConnectionStatus(message: string, isConnected: boolean): void {
if (!connectionStatus) {return;}
connectionStatus.textContent = message;
connectionStatus.className = `connection-status-indicator ${isConnected ? 'connected' : 'not-connected'}`;
if (profileContainer) {
if (isConnected) {
profileContainer.classList.remove('hidden');
} else {
profileContainer.classList.add('hidden');
}
}
}
// Show profile
async function showProfile(pubkey: string): Promise<void> {
if (!profileContainer) {return;}
// Show container
profileContainer.classList.remove('hidden');
// Update pubkey displays
updatePubkeyDisplay(pubkey);
// Generate QR code
generateQrCode(pubkey);
// Update profile information
await fetchProfileData(pubkey);
// Update stats
updateStats();
}
// Update pubkey display
function updatePubkeyDisplay(pubkey: string): void {
if (!profilePubkeyHex || !profilePubkeyNpub) {return;}
// Display hex pubkey
profilePubkeyHex.textContent = pubkey;
// Convert and display npub
try {
const npub = nostrTools.nip19.npubEncode(pubkey);
profilePubkeyNpub.textContent = npub;
} catch (error) {
console.error('Error encoding npub:', error);
profilePubkeyNpub.textContent = 'Error encoding npub';
}
}
// Generate QR code
function generateQrCode(pubkey: string): void {
if (!npubQrCode) {return;}
try {
// Convert to npub for QR code
const npub = nostrTools.nip19.npubEncode(pubkey);
// Generate QR code
const qr = qrcode(10, 'M');
qr.addData(npub);
qr.make();
// Display QR code
npubQrCode.innerHTML = qr.createSvgTag({
cellSize: 4,
margin: 1,
});
} catch (error) {
console.error('Error generating QR code:', error);
npubQrCode.innerHTML = '<div class="error">Error generating QR code</div>';
}
}
// Fetch profile data
async function fetchProfileData(pubkey: string): Promise<void> {
try {
// Use direct WebSocket approach to fetch metadata
const relays = ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol'];
// Prepare the filter
const filter = {
kinds: [0],
authors: [pubkey],
limit: 1
};
// Create the subscription request
const requestId = Math.random().toString(36).substring(2, 15);
const subRequest = ["REQ", requestId, filter];
// Create a promise to collect events
const events: any[] = [];
// Try to connect to one of the relays
let connected = false;
// Try each relay until we get a response
for (const relayUrl of relays) {
if (connected) {break;}
try {
await new Promise<void>((resolve) => {
const ws = new WebSocket(relayUrl);
const timeout = setTimeout(() => {
try {
ws.close();
} catch {
// Ignore errors when closing WebSocket
}
resolve(); // Don't reject, just try next relay
}, 3000);
ws.onopen = () => {
// Send subscription request
ws.send(JSON.stringify(subRequest));
};
ws.onmessage = (msg) => {
if (typeof msg.data !== 'string') {return;}
try {
const data = JSON.parse(msg.data);
if (Array.isArray(data) && data[0] === 'EVENT' && data[1] === requestId) {
events.push(data[2]);
connected = true;
clearTimeout(timeout);
try {
ws.close();
} catch {
// Ignore errors when closing WebSocket
}
resolve();
} else if (data[0] === 'EOSE' && data[1] === requestId) {
clearTimeout(timeout);
try {
ws.close();
} catch {
// Ignore errors when closing WebSocket
}
resolve();
}
} catch (error) {
console.error('Error parsing message:', error);
}
};
ws.onerror = () => {
clearTimeout(timeout);
try {
ws.close();
} catch {
// Ignore errors when closing WebSocket
}
resolve(); // Don't reject, just try next relay
};
ws.onclose = () => {
clearTimeout(timeout);
resolve();
};
});
} catch (error) {
console.error(`Error connecting to ${relayUrl}:`, error);
}
}
if (events && events.length > 0) {
const profileEvent = events[0];
try {
// Just directly use currentProfileData
currentProfileData = JSON.parse(profileEvent.content);
updateProfileDisplay(currentProfileData);
} catch (parseError) {
console.error('Error parsing profile data:', parseError);
updateProfileWithDefaults();
}
} else {
console.log('No profile data found');
updateProfileWithDefaults();
}
} catch (error) {
console.error('Error fetching profile data:', error);
updateProfileWithDefaults();
}
}
// Update profile display
function updateProfileDisplay(profileData: ProfileData): void {
if (!profileName || !profileNip05 || !profileAbout || !profilePicture) {return;}
// Save to current profile data for later use
currentProfileData = profileData;
// Update name
profileName.textContent = profileData.name || 'Anonymous';
// Update NIP-05
if (profileData.nip05) {
profileNip05.textContent = profileData.nip05;
} else {
profileNip05.textContent = '';
}
// Update about
profileAbout.textContent = profileData.about || 'No information provided';
// Update picture
if (profileData.picture) {
profilePicture.innerHTML = `<img src="${profileData.picture}" alt="${profileData.name || 'User'}" />`;
} else {
profilePicture.innerHTML = '<div class="profile-placeholder">👤</div>';
}
}
// Update profile with defaults
function updateProfileWithDefaults(): void {
if (!profileName || !profileNip05 || !profileAbout || !profilePicture) {return;}
profileName.textContent = 'Anonymous User';
profileNip05.textContent = '';
profileAbout.textContent = 'No profile information available';
profilePicture.innerHTML = '<div class="profile-placeholder">👤</div>';
}
// Update stats
function updateStats(): void {
if (!statsRequestsSent || !statsResponsesReceived || !statsRelaysConnected) {return;}
// For this demo, just set some placeholder values
// In a real app, you would track these stats in localStorage or a database
statsRequestsSent.textContent = '0';
statsResponsesReceived.textContent = '0';
statsRelaysConnected.textContent = '0';
}
// Copy npub to clipboard
function copyNpubToClipboard(): void {
if (!profilePubkeyNpub) {return;}
const npub = profilePubkeyNpub.textContent;
if (!npub) {return;}
navigator.clipboard.writeText(npub)
.then(() => {
alert('npub copied to clipboard');
})
.catch(error => {
console.error('Error copying to clipboard:', error);
alert('Failed to copy npub to clipboard');
});
}
// Refresh profile
async function refreshProfile(): Promise<void> {
if (!currentPubkey) {return;}
await fetchProfileData(currentPubkey);
updateStats();
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
// Get DOM elements
connectionStatus = document.getElementById('connectionStatus');
profileContainer = document.getElementById('profileContainer');
profileName = document.getElementById('profileName');
profileNip05 = document.getElementById('profileNip05');
profilePubkeyHex = document.getElementById('profilePubkeyHex');
profilePubkeyNpub = document.getElementById('profilePubkeyNpub');
profileAbout = document.getElementById('profileAbout');
profilePicture = document.getElementById('profilePicture');
npubQrCode = document.getElementById('npubQrCode');
statsRequestsSent = document.getElementById('requestsSent');
statsResponsesReceived = document.getElementById('responsesReceived');
statsRelaysConnected = document.getElementById('relaysConnected');
// Connect button
const connectButton = document.getElementById('connectButton');
if (connectButton) {
connectButton.addEventListener('click', connectWithExtension);
}
// Manual pubkey button
const setManualPubkeyBtn = document.getElementById('setManualPubkeyBtn');
const manualPubkeyInput = document.getElementById('manualPubkey') as HTMLInputElement;
if (setManualPubkeyBtn && manualPubkeyInput) {
setManualPubkeyBtn.addEventListener('click', () => {
const pubkeyValue = manualPubkeyInput.value.trim();
if (pubkeyValue) {
setManualPubkey(pubkeyValue);
} else {
alert('Please enter a pubkey');
}
});
}
// Copy npub button
const copyNpubBtn = document.getElementById('copyNpubBtn');
if (copyNpubBtn) {
copyNpubBtn.addEventListener('click', copyNpubToClipboard);
}
// Refresh profile button
const refreshProfileBtn = document.getElementById('refreshProfileBtn');
if (refreshProfileBtn) {
refreshProfileBtn.addEventListener('click', refreshProfile);
}
// Try to connect automatically if extension is available
if (window.nostr) {
connectWithExtension().catch(error => {
console.error('Error auto-connecting:', error);
});
}
});

666
client/src/receiver.ts Normal file

@ -0,0 +1,666 @@
// External dependencies
import * as nostrTools from 'nostr-tools';
// Internal imports
import { convertNpubToHex } from './relay';
import type { NostrEvent } from './relay';
// Define interfaces for our application
interface NostrSubscription {
unsub: () => void;
}
interface ReceivedEvent {
id: string;
event: NostrEvent;
receivedAt: number;
decrypted: boolean;
decryptedContent?: string;
}
// Relay connection and subscription
let relayPool: any = null;
let activeSubscription: NostrSubscription | null = null;
let activeRelayUrl: string | null = null;
const receivedEvents = new Map<string, ReceivedEvent>();
// DOM Elements (populated on DOMContentLoaded)
let relayUrlInput: HTMLInputElement | null = null;
let relayStatus: HTMLElement | null = null;
let eventsList: HTMLElement | null = null;
let eventDetails: HTMLElement | null = null;
// Connect to a relay with a direct WebSocket approach for reliability
async function connectToRelay(relayUrl: string): Promise<boolean> {
try {
// Close existing relay pool if any
if (relayPool) {
if (activeRelayUrl) {
try {
await relayPool.close([activeRelayUrl]);
} catch (e) {
console.warn('Error closing previous relay connection:', e);
}
}
relayPool = null;
}
updateRelayStatus('Connecting to relay...', 'connecting');
try {
// Create a direct WebSocket connection to test connectivity first
const ws = new WebSocket(relayUrl);
let connected = false;
// Wait for connection to establish
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
if (!connected) {
ws.close();
reject(new Error('Connection timeout after 5 seconds'));
}
}, 5000);
ws.onopen = () => {
clearTimeout(timeout);
connected = true;
resolve();
};
ws.onerror = (err) => {
clearTimeout(timeout);
reject(new Error(`WebSocket error: ${err.toString()}`));
};
});
// Connection successful, close test connection
ws.close();
// Now create the relay pool for later use
relayPool = new nostrTools.SimplePool();
console.log(`Successfully connected to relay: ${relayUrl}`);
activeRelayUrl = relayUrl;
updateRelayStatus('Connected', 'connected');
return true;
} catch (connectionError) {
console.error(`Error connecting to relay: ${connectionError instanceof Error ? connectionError.message : String(connectionError)}`);
// Clean up
relayPool = null;
updateRelayStatus('Connection failed', 'error');
return false;
}
} catch (error) {
console.error(`Failed to connect to relay ${relayUrl}:`, error);
updateRelayStatus(`Error: ${error instanceof Error ? error.message : String(error)}`, 'error');
return false;
}
}
// Update relay status in UI
function updateRelayStatus(message: string, className: string): void {
if (relayStatus) {
relayStatus.textContent = message;
relayStatus.className = `relay-status ${className}`;
}
}
// Subscribe to events
async function subscribeToEvents(options: {
pubkeyFilter?: string;
}): Promise<void> {
if (!relayPool || !activeRelayUrl) {
console.error('Cannot subscribe: Relay pool or URL not set');
return;
}
// Unsubscribe if there's an active subscription
if (activeSubscription) {
try {
activeSubscription.unsub();
} catch (e) {
console.warn('Error unsubscribing from previous subscription:', e);
}
activeSubscription = null;
}
console.log('Creating subscription for kind 21120 events');
// For now, we're going to fetch ALL kind 21120 events from the relay
// to ensure we're getting data
console.log('Creating subscription for ALL kind 21120 events');
// Create filter for kind 21120 events
const filter: any = {
kinds: [21120], // HTTP Messages event kind
};
// Log the filter we're using
console.log('Using filter:', JSON.stringify(filter));
// Define the author filter type properly
interface AuthorFilter {
kinds: number[];
'#p': string[];
authors?: string[];
}
// We can also filter by author if specified
if (options.pubkeyFilter) {
let pubkey = options.pubkeyFilter;
// Convert npub to hex if needed
if (pubkey.startsWith('npub')) {
try {
const hexPubkey = convertNpubToHex(pubkey);
if (hexPubkey) {
pubkey = hexPubkey;
}
} catch (error) {
console.error("Failed to convert npub to hex:", error);
}
}
// Add optional author filter
(filter as AuthorFilter).authors = [pubkey];
}
// Skip using nostr-tools SimplePool for subscription to avoid issues
// Instead, create a direct WebSocket connection
console.log(`Creating direct WebSocket subscription to ${activeRelayUrl}`);
try {
// Create a direct WebSocket connection
// Make sure activeRelayUrl is not null before using it
if (!activeRelayUrl) {
throw new Error('Relay URL is not set');
}
const ws = new WebSocket(activeRelayUrl);
let connected = false;
// Set up event handlers
ws.onopen = () => {
console.log(`WebSocket connected to ${activeRelayUrl}`);
connected = true;
// Send a REQ message to subscribe
const reqId = `req-${Date.now()}`;
const reqMsg = JSON.stringify(["REQ", reqId, filter]);
console.log(`Sending subscription request: ${reqMsg}`);
// Make sure to log when messages are received
console.log('Waiting for events from relay...');
ws.send(reqMsg);
// Update status
updateRelayStatus('Subscription active ✓', 'connected');
};
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data as string);
// Handle different message types
if (Array.isArray(data)) {
console.log('Received message:', JSON.stringify(data).substring(0, 100) + '...');
if (data[0] === "EVENT" && data.length >= 3) {
console.log('Processing event:', data[2].id);
processEvent(data[2] as NostrEvent);
}
}
} catch (e) {
console.error('Error processing message:', e);
}
};
ws.onerror = (err) => {
console.error('WebSocket error:', err);
updateRelayStatus(`WebSocket error`, 'error');
};
ws.onclose = () => {
console.log('WebSocket connection closed');
if (connected) {
updateRelayStatus('Connection closed', 'error');
} else {
updateRelayStatus('Failed to connect', 'error');
}
};
// Wait for connection to establish
await new Promise<void>((resolve, reject) => {
// Set a timeout to prevent hanging
const timeout = setTimeout(() => {
if (!connected) {
reject(new Error('Connection timeout'));
} else {
resolve();
}
}, 5000);
// Resolve immediately if already connected
if (connected) {
clearTimeout(timeout);
resolve();
}
// Override onopen to resolve promise
const originalOnOpen = ws.onopen;
ws.onopen = (ev) => {
clearTimeout(timeout);
if (originalOnOpen) {
originalOnOpen.call(ws, ev);
}
resolve();
};
// Override onerror to reject promise
const originalOnError = ws.onerror;
ws.onerror = (ev) => {
clearTimeout(timeout);
if (originalOnError) {
originalOnError.call(ws, ev);
}
reject(new Error('WebSocket connection error'));
};
});
// Store the subscription for later unsubscription
activeSubscription = {
unsub: () => {
try {
ws.close();
} catch (e) {
console.error('Error closing WebSocket:', e);
}
}
};
console.log(`Successfully subscribed to ${activeRelayUrl} for kind 21120 events`);
} catch (error) {
console.error('Error creating subscription:', error);
updateRelayStatus(`Subscription error: ${error instanceof Error ? error.message : String(error)}`, 'error');
throw error; // Re-throw to handle in the calling code
}
}
// Process an incoming event
function processEvent(event: NostrEvent): void {
// Ensure event has an ID
if (!event.id) {
console.error('Received event with no ID, skipping');
return;
}
// Check if this is a new event
if (receivedEvents.has(event.id)) {
return;
}
// Store the event
const receivedEvent: ReceivedEvent = {
id: event.id,
event: event,
receivedAt: Date.now(),
decrypted: false
};
receivedEvents.set(event.id, receivedEvent);
// Add event to UI
addEventToUI(receivedEvent);
}
// Add event to UI
function addEventToUI(receivedEvent: ReceivedEvent): void {
if (!eventsList) {
return;
}
const event = receivedEvent.event;
// Ensure the event has an ID (should already be checked by processEvent)
if (!event.id) {
console.error('Event has no ID, cannot add to UI');
return;
}
// Create event item
const eventItem = document.createElement('div');
eventItem.className = 'event-item';
eventItem.dataset.id = event.id;
// Get event ID for display
const eventIdForDisplay = event.id.substring(0, 8);
// Determine if it's a request or response
const hasP = event.tags.some(tag => tag[0] === 'p');
const hasE = event.tags.some(tag => tag[0] === 'e');
const eventType = hasP ? 'Request' : (hasE ? 'Response' : 'Unknown');
// Format the event item
eventItem.innerHTML = `
<div class="event-header">
<span class="event-id">ID: ${eventIdForDisplay}...</span>
<span class="event-time">${new Date(event.created_at * 1000).toLocaleTimeString()}</span>
</div>
<div class="event-summary">Type: ${eventType} | From: ${event.pubkey.substring(0, 8)}...</div>
<div class="event-actions">
<button class="view-details-btn">View Details</button>
</div>
`;
// Add event listeners
const viewDetailsBtn = eventItem.querySelector('.view-details-btn');
if (viewDetailsBtn) {
viewDetailsBtn.addEventListener('click', () => {
showEventDetails(event.id as string);
// Also trigger decryption when viewing details
decryptEvent(event.id as string);
});
}
// Auto-decrypt the event when it's added to the UI
// Use timeout for better UI responsiveness
setTimeout(() => {
decryptEvent(event.id as string);
}, 100);
// Add to list
eventsList.appendChild(eventItem);
// Clear empty state if present
const emptyState = eventsList.querySelector('.empty-state');
if (emptyState) {
emptyState.remove();
}
}
// Show event details
function showEventDetails(eventId: string): void {
if (!eventDetails) {
return;
}
const receivedEvent = receivedEvents.get(eventId);
if (!receivedEvent) {
return;
}
const event = receivedEvent.event;
// Ensure event has an ID (should already be verified)
const eventIdForDisplay = event.id ? event.id.substring(0, 8) : 'unknown';
const fullEventId = event.id || 'unknown';
// Generate tags HTML with better formatting
let tagsHtml = '';
event.tags.forEach((tag: string[]) => {
if (tag.length >= 2) {
// For p and e tags, add additional explanation
if (tag[0] === 'p') {
tagsHtml += `<li><strong>p:</strong> ${tag[1]} (p-tag: recipient/target)</li>`;
} else if (tag[0] === 'e') {
tagsHtml += `<li><strong>e:</strong> ${tag[1]} (e-tag: reference to another event)</li>`;
} else {
tagsHtml += `<li><strong>${tag[0]}:</strong> ${tag[1]}</li>`;
}
} else if (tag.length === 1) {
tagsHtml += `<li><strong>${tag[0]}</strong></li>`;
}
});
// Determine if this is a request or response
const isRequest = event.tags.some(tag => tag[0] === 'p');
const isResponse = event.tags.some(tag => tag[0] === 'e');
const eventTypeLabel = isRequest ? 'HTTP Request' : (isResponse ? 'HTTP Response' : 'Unknown Type');
// Format content for display (if decrypted, use the decrypted content)
const displayContent = receivedEvent.decrypted ?
(receivedEvent.decryptedContent || event.content) :
event.content;
// Try to pretty-print JSON if the content appears to be JSON
let formattedContent = displayContent || '';
try {
if (formattedContent.trim().startsWith('{')) {
const jsonObj = JSON.parse(formattedContent);
formattedContent = JSON.stringify(jsonObj, null, 2);
}
} catch {
// Not valid JSON, use as-is
}
eventDetails.innerHTML = `
<h3>${eventTypeLabel} (ID: ${eventIdForDisplay}...)</h3>
<div class="event-detail-item">
<strong>ID:</strong> ${fullEventId}
</div>
<div class="event-detail-item">
<strong>From:</strong> ${event.pubkey}
</div>
<div class="event-detail-item">
<strong>Created:</strong> ${new Date(event.created_at * 1000).toLocaleString()}
</div>
<div class="event-detail-item">
<strong>Tags:</strong>
<ul>${tagsHtml}</ul>
</div>
<div class="event-detail-item">
<strong>Content${receivedEvent.decrypted ? ' (Decrypted)' : ''}:</strong>
<pre class="event-content">${formattedContent}</pre>
</div>
`;
}
// Auto-decrypt event using NIP-44
async function decryptEvent(eventId: string): Promise<void> {
const receivedEvent = receivedEvents.get(eventId);
if (!receivedEvent || receivedEvent.decrypted) {
return;
}
try {
// First try to parse as base64-encoded JSON (for unencrypted content)
let decryptedContent: string;
try {
// Try to parse as base64-encoded JSON
const decodedContent = atob(receivedEvent.event.content);
const parsedContent = JSON.parse(decodedContent);
decryptedContent = JSON.stringify(parsedContent, null, 2);
} catch {
// If that fails, try NIP-44 decryption with user's private key
try {
// Get user's private key from localStorage (would be set during login)
const userPrivateKey = localStorage.getItem('userPrivateKey');
if (userPrivateKey) {
// In a real implementation, we would use the nostr-tools or similar library
// to decrypt the content using NIP-44 and the user's private key
// For now, we'll just use the original content with a note
decryptedContent = `[Auto-decryption would happen here with NIP-44]\n${receivedEvent.event.content}`;
} else {
// No private key available
decryptedContent = `[No private key available for decryption]\n${receivedEvent.event.content}`;
}
} catch (decryptError) {
// If decryption fails, use the original content
console.error("Could not decrypt content:", decryptError);
decryptedContent = `[Decryption failed]\n${receivedEvent.event.content}`;
}
}
// Update the event
receivedEvent.decrypted = true;
receivedEvent.decryptedContent = decryptedContent;
receivedEvents.set(eventId, receivedEvent);
// Update UI if this event is currently being viewed
const selectedEventId = eventDetails?.querySelector('h3')?.textContent?.match(/Event (.+)\.\.\./)?.[1];
if (selectedEventId && `${selectedEventId}...` === `${eventId.substring(0, 8)}...`) {
showEventDetails(eventId);
}
} catch (error) {
console.error("Failed to process event:", error);
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
// Get DOM elements
relayUrlInput = document.getElementById('relayUrl') as HTMLInputElement;
relayStatus = document.getElementById('relayStatus');
eventsList = document.getElementById('eventsList');
eventDetails = document.getElementById('eventDetails');
const connectRelayBtn = document.getElementById('connectRelayBtn');
const startSubscriptionBtn = document.getElementById('startSubscriptionBtn');
const stopSubscriptionBtn = document.getElementById('stopSubscriptionBtn');
const clearEventsBtn = document.getElementById('clearEventsBtn');
const subscriptionPubkeyInput = document.getElementById('subscriptionPubkey') as HTMLInputElement;
/**
* Get the logged-in user's public key from localStorage
* This is populated by the nostr-login plugin
*/
function getLoggedInPubkey(): string | null {
return localStorage.getItem('userPublicKey');
}
// Connect to relay and automatically start subscription for logged-in user
if (connectRelayBtn) {
connectRelayBtn.addEventListener('click', async () => {
if (!relayUrlInput) {
return;
}
const relayUrl = relayUrlInput.value.trim();
if (!relayUrl || !relayUrl.startsWith('wss://')) {
updateRelayStatus('Invalid relay URL. Must start with wss://', 'error');
return;
}
const success = await connectToRelay(relayUrl);
if (success) {
// Get the logged-in user's pubkey from localStorage
let pubkeyFilter = subscriptionPubkeyInput?.value.trim();
// If no pubkey is specified in the input field, use the logged-in user's pubkey
if (!pubkeyFilter) {
const userPubkey = getLoggedInPubkey();
if (userPubkey) {
pubkeyFilter = userPubkey;
// Update the input field to show which pubkey we're using
if (subscriptionPubkeyInput) {
subscriptionPubkeyInput.value = userPubkey;
}
}
}
// Automatically subscribe to kind 21120 events
try {
// Update status to indicate subscription is in progress
updateRelayStatus('Subscribing...', 'connecting');
await subscribeToEvents({
pubkeyFilter
});
// If no error was thrown, the subscription was successful
console.log(`Subscription initiated to ${activeRelayUrl}`);
} catch (subError) {
console.error("Subscription error:", subError);
updateRelayStatus(`Subscription error: ${subError instanceof Error ? subError.message : String(subError)}`, 'error');
// Return subscription button to enabled state
if (startSubscriptionBtn && stopSubscriptionBtn) {
stopSubscriptionBtn.setAttribute('disabled', 'disabled');
startSubscriptionBtn.removeAttribute('disabled');
}
}
// Update UI to reflect active subscription
if (startSubscriptionBtn && stopSubscriptionBtn) {
startSubscriptionBtn.setAttribute('disabled', 'disabled');
stopSubscriptionBtn.removeAttribute('disabled');
}
console.log(`Subscribed to kind 21120 events for ${pubkeyFilter || 'all users'}`);
}
});
}
// Start subscription
if (startSubscriptionBtn && stopSubscriptionBtn) {
startSubscriptionBtn.addEventListener('click', async () => {
if (!relayPool || !activeRelayUrl) {
console.error('Please connect to a relay first');
return;
}
// Update status to indicate subscription is in progress
updateRelayStatus('Subscribing...', 'connecting');
try {
// Get pubkey if specified
const subscriptionPubkeyInput = document.getElementById('subscriptionPubkey') as HTMLInputElement;
const pubkeyFilter = subscriptionPubkeyInput?.value.trim();
await subscribeToEvents({
pubkeyFilter
});
// Update UI to reflect active subscription
if (startSubscriptionBtn && stopSubscriptionBtn) {
startSubscriptionBtn.setAttribute('disabled', 'disabled');
stopSubscriptionBtn.removeAttribute('disabled');
}
} catch (subError) {
console.error("Subscription error:", subError);
updateRelayStatus(`Subscription error: ${subError instanceof Error ? subError.message : String(subError)}`, 'error');
// Leave buttons in their current state if there's an error
if (startSubscriptionBtn) {
startSubscriptionBtn.removeAttribute('disabled');
}
}
});
}
// Stop subscription
if (stopSubscriptionBtn && startSubscriptionBtn) {
stopSubscriptionBtn.addEventListener('click', () => {
if (activeSubscription) {
activeSubscription.unsub();
activeSubscription = null;
}
if (stopSubscriptionBtn && startSubscriptionBtn) {
stopSubscriptionBtn.setAttribute('disabled', 'disabled');
startSubscriptionBtn.removeAttribute('disabled');
}
});
}
// Clear events
if (clearEventsBtn) {
clearEventsBtn.addEventListener('click', () => {
receivedEvents.clear();
if (eventsList) {
eventsList.innerHTML = '<div class="empty-state">No events received yet. Connect to a relay and start a subscription.</div>';
}
if (eventDetails) {
eventDetails.innerHTML = '<div class="empty-state">Select an event to view details</div>';
}
});
}
});

@ -77,7 +77,7 @@ export async function publishToRelay(event: NostrEvent, relayUrl: string): Promi
const relayPool = new nostrTools.SimplePool();
// Set a timeout for the publish operation
// eslint-disable-next-line no-undef
const timeout = setTimeout(() => {
relayPool.close([relayUrl]);
reject(new Error(`Timed out connecting to relay: ${relayUrl}`));
@ -127,7 +127,7 @@ export async function publishToRelay(event: NostrEvent, relayUrl: string): Promi
// Use the WebSocket API directly
const ws = new WebSocket(relayUrl);
// eslint-disable-next-line no-undef
const wsTimeout = setTimeout(() => {
try {
ws.close();
@ -153,7 +153,7 @@ export async function publishToRelay(event: NostrEvent, relayUrl: string): Promi
if (typeof msg.data === 'string' && msg.data.startsWith('["OK"')) {
responseHandled = true;
// eslint-disable-next-line no-undef
clearTimeout(wsTimeout);
resolve(`Event published successfully via WebSocket`);
try {
@ -163,7 +163,7 @@ export async function publishToRelay(event: NostrEvent, relayUrl: string): Promi
}
} else if (typeof msg.data === 'string' && (msg.data.includes('invalid') || msg.data.includes('error'))) {
responseHandled = true;
// eslint-disable-next-line no-undef
clearTimeout(wsTimeout);
reject(new Error(`Relay rejected event: ${msg.data}`));
try {
@ -180,7 +180,7 @@ export async function publishToRelay(event: NostrEvent, relayUrl: string): Promi
}
responseHandled = true;
// eslint-disable-next-line no-undef
clearTimeout(wsTimeout);
reject(new Error(`WebSocket error: ${String(error)}`));
try {
@ -191,7 +191,7 @@ export async function publishToRelay(event: NostrEvent, relayUrl: string): Promi
};
ws.onclose = (): void => {
// eslint-disable-next-line no-undef
clearTimeout(wsTimeout);
};
} catch {
@ -204,7 +204,7 @@ export async function publishToRelay(event: NostrEvent, relayUrl: string): Promi
// Use Promise.all to wait for all promises to resolve
Promise.all(publishPromises)
.then((relayResults: string[]) => {
// eslint-disable-next-line no-undef
clearTimeout(timeout);
relayPool.close([relayUrl]);
if (relayResults && relayResults.length > 0) {
@ -214,7 +214,7 @@ export async function publishToRelay(event: NostrEvent, relayUrl: string): Promi
}
})
.catch((error: Error) => {
// eslint-disable-next-line no-undef
clearTimeout(timeout);
relayPool.close([relayUrl]);
reject(new Error(`Failed to publish event: ${error.message}`));

@ -382,4 +382,434 @@ footer {
/* Hidden elements */
.hidden {
display: none !important;
}
}
/* Receiver page styles */
.relay-connection {
margin-bottom: 20px;
padding: 15px;
background-color: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border-color);
}
.relay-input-container {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.relay-input-container label {
margin-right: 10px;
min-width: 80px;
}
.relay-input-container input {
flex: 1;
margin-right: 10px;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
}
.relay-connect-button {
background-color: var(--button-primary);
color: white;
border: none;
border-radius: 4px;
padding: 8px 15px;
cursor: pointer;
}
.relay-connect-button:hover {
background-color: var(--button-hover);
}
.relay-status {
margin-top: 10px;
padding: 5px 10px;
border-radius: 4px;
display: inline-block;
}
.relay-status.connected {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.relay-status.connecting {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeeba;
}
.relay-status.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.subscription-settings {
margin-bottom: 20px;
padding: 15px;
background-color: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border-color);
}
.filter-options {
margin: 15px 0;
}
.filter-options label {
margin-right: 15px;
display: inline-block;
}
.key-input {
margin-bottom: 15px;
}
.key-input input {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
margin-top: 5px;
}
.help-text {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 5px;
display: block;
}
.start-subscription-button, .stop-subscription-button {
padding: 8px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
margin-right: 10px;
}
.start-subscription-button {
background-color: var(--button-primary);
color: white;
}
.start-subscription-button:hover {
background-color: var(--button-hover);
}
.stop-subscription-button {
background-color: #dc3545;
color: white;
}
.stop-subscription-button:hover {
background-color: #c82333;
}
/* Profile page styles */
.profile-container {
margin-top: 20px;
}
.profile-section {
margin-bottom: 20px;
padding: 15px;
background-color: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border-color);
}
.connection-status {
display: flex;
flex-direction: column;
gap: 15px;
}
.connection-status-indicator {
padding: 10px;
border-radius: 4px;
font-weight: 500;
}
.connection-status-indicator.connected {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.connection-status-indicator.not-connected {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.connect-button {
padding: 10px 15px;
background-color: var(--button-primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
}
.connect-button:hover {
background-color: var(--button-hover);
}
.manual-entry {
margin-top: 10px;
}
.manual-entry summary {
cursor: pointer;
color: var(--accent-color);
font-weight: 500;
}
.manual-key-entry {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.manual-key-entry input {
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
}
.manual-key-entry button {
align-self: flex-start;
padding: 8px 15px;
background-color: var(--button-primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.profile-card {
margin-top: 20px;
padding: 20px;
background-color: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border-color);
}
.profile-header {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.profile-picture {
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
margin-right: 20px;
background-color: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
}
.profile-picture img {
width: 100%;
height: auto;
}
.profile-placeholder {
font-size: 36px;
color: var(--text-tertiary);
}
.profile-basic-info {
flex: 1;
}
.profile-basic-info h3 {
margin: 0 0 10px 0;
color: var(--text-primary);
}
.profile-nip05 {
color: var(--accent-color);
font-size: 14px;
}
.profile-details {
margin-bottom: 20px;
}
.profile-detail-item {
margin-bottom: 15px;
}
.profile-value {
margin-top: 5px;
word-break: break-all;
}
.profile-key {
font-family: 'Courier New', monospace;
background-color: var(--bg-tertiary);
padding: 5px;
border-radius: 4px;
font-size: 12px;
}
.profile-footer {
display: flex;
gap: 10px;
}
.refresh-profile-btn, .copy-npub-btn {
padding: 8px 15px;
background-color: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
}
.refresh-profile-btn:hover, .copy-npub-btn:hover {
background-color: var(--bg-primary);
}
.profile-stats {
margin-top: 20px;
padding: 20px;
background-color: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border-color);
}
.stats-container {
display: flex;
justify-content: space-around;
margin-top: 15px;
}
.stat-item {
text-align: center;
flex: 1;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: var(--accent-color);
}
.stat-label {
margin-top: 5px;
color: var(--text-secondary);
font-size: 14px;
}
.qr-section {
margin-top: 20px;
padding: 20px;
background-color: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border-color);
text-align: center;
}
.npub-qr-code {
width: 200px;
height: 200px;
margin: 0 auto;
background-color: white;
padding: 10px;
border-radius: 4px;
}
.qr-caption {
margin-top: 10px;
color: var(--text-tertiary);
font-size: 14px;
font-style: italic;
}
.stop-subscription-button:disabled, .start-subscription-button:disabled {
background-color: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
.events-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--bg-secondary);
}
.event-item {
padding: 10px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
}
.event-item:hover {
background-color: var(--bg-tertiary);
}
.event-details {
margin-top: 20px;
padding: 15px;
background-color: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border-color);
}
.empty-state {
padding: 20px;
text-align: center;
color: var(--text-tertiary);
font-style: italic;
}
/* Event output container and copy button styles */
.event-output-container {
position: relative;
margin-bottom: 20px;
}
.copy-button {
position: absolute;
top: 10px;
right: 10px;
background-color: var(--button-primary);
color: white;
border: none;
border-radius: 4px;
padding: 5px 10px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.3s;
z-index: 10;
}
.copy-button:hover {
background-color: var(--button-hover);
}
.copy-button:active {
background-color: var(--accent-color);
}
.copy-button.copied {
background-color: var(--button-success);
}
/* Dark mode is already handled by CSS variables */

@ -1,6 +1,5 @@
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
const webpack = require('webpack');
// Polyfills for Node.js core modules in the browser
const NodePolyfillPlugin = require('node-polyfill-webpack-plugin');
@ -30,6 +29,8 @@ module.exports = {
{ from: 'http.png', to: 'http.png' },
{ from: 'index.html', to: 'index.html' },
{ from: 'help.html', to: 'help.html' },
{ from: 'receive.html', to: 'receive.html' },
{ from: 'profile.html', to: 'profile.html' },
],
}),
],