Merge branch 'main' into complex/test

This commit is contained in:
complex 2025-04-08 16:17:53 +02:00
commit 38e9301510
15 changed files with 2636 additions and 150 deletions

@ -0,0 +1,31 @@
name: Release to Staging
on:
push:
branches:
- main
jobs:
build_and_release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v2
with:
node-version: 18
- name: build client folder
run: |
cd client
npm install
npm run build
- name: Release Build
run: |
npm -g install cloudron-surfer -y
surfer config --token ${{ secrets.CLOUDRON_SURFER_TOKEN }} --server 21120.ft.hn
surfer put client/dist/* /demo -d

@ -1,5 +1,7 @@
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
do not ask to "cd client"
all signing to be done using nostr-login

@ -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'
}
}
];

@ -11,22 +11,23 @@
<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>
<!-- Top Navigation Bar -->
<div class="top-nav">
<div class="nav-left">
<a href="./index.html" class="nav-link">CLIENT</a>
<a href="./receive.html" class="nav-link">SERVER</a>
</div>
<div class="nav-right">
<a href="./help.html" class="nav-link nav-icon active" title="Documentation"></a>
<a href="./profile.html" class="nav-link nav-icon" title="Profile">👤</a>
<button id="themeToggleBtn" class="theme-toggle-btn" title="Toggle Dark Mode">
<span id="themeIcon">🌙</span>
</button>
</div>
</div>
<h1>HTTP Messages</h1>
<!-- Navigation -->
<div class="navigation">
<a href="./index.html" class="nav-link">Home</a>
<a href="./help.html" class="nav-link active">Documentation</a>
</div>
<!-- Documentation Content -->
<div class="content">
<div class="info-box">

@ -5,7 +5,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 - CLIENT</title>
<!-- Load our CSS file -->
<link rel="stylesheet" href="./styles.css">
<!-- Include the Nostr extensions - these will be accessed via window.nostr -->
@ -13,20 +13,19 @@
</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 active">Home</a>
<a href="./help.html" class="nav-link">Documentation</a>
<!-- Top Navigation Bar -->
<div class="top-nav">
<div class="nav-left">
<a href="./index.html" class="nav-link active">CLIENT</a>
<a href="./receive.html" class="nav-link">SERVER</a>
</div>
<div class="nav-right">
<a href="./help.html" class="nav-link nav-icon" title="Documentation"></a>
<a href="./profile.html" class="nav-link nav-icon" title="Profile">👤</a>
<button id="themeToggleBtn" class="theme-toggle-btn" title="Toggle Dark Mode">
<span id="themeIcon">🌙</span>
</button>
</div>
</div>
<!-- Main Content (HTTP Request Converter) -->
@ -42,7 +41,7 @@
<label for="serverPubkey">Server Pubkey or Search Term:</label><br>
<div class="server-input-container">
<input type="text" id="serverPubkey" placeholder="npub, username, or NIP-05 identifier"
value="npub1r6knexka25dn9w9jnf5kf8xh6gfq7n3p38zfl7nn7cjjjsp4umcqnk0aun" class="server-input">
value="npub1thq3fzcw393c0tpy60sz0dvvjz4tjrgtrudxsa68sldkf78fznksgp34w8" class="server-input">
<button id="searchServerBtn" class="server-search-button">Search</button>
</div>
<div id="serverSearchResult" class="server-search-result" style="display: none;">
@ -67,7 +66,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>

116
client/profile.html Normal file

@ -0,0 +1,116 @@
<!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>
<!-- Top Navigation Bar -->
<div class="top-nav">
<div class="nav-left">
<a href="./index.html" class="nav-link">CLIENT</a>
<a href="./receive.html" class="nav-link">SERVER</a>
</div>
<div class="nav-right">
<a href="./help.html" class="nav-link nav-icon" title="Documentation"></a>
<a href="./profile.html" class="nav-link nav-icon active" title="Profile">👤</a>
<button id="themeToggleBtn" class="theme-toggle-btn" title="Toggle Dark Mode">
<span id="themeIcon">🌙</span>
</button>
</div>
</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>

69
client/receive.html Normal file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTTP Messages - SERVER</title>
<link rel="stylesheet" href="./styles.css">
<script defer src="./bundle.js"></script>
</head>
<body>
<!-- Top Navigation Bar -->
<div class="top-nav">
<div class="nav-left">
<a href="./index.html" class="nav-link">CLIENT</a>
<a href="./receive.html" class="nav-link active">SERVER</a>
</div>
<div class="nav-right">
<a href="./help.html" class="nav-link nav-icon" title="Documentation"></a>
<a href="./profile.html" class="nav-link nav-icon" title="Profile">👤</a>
<button id="themeToggleBtn" class="theme-toggle-btn" title="Toggle Dark Mode">
<span id="themeIcon">🌙</span>
</button>
</div>
</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.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>
<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.
</div>
<!-- Events will be displayed here -->
</div>
</div>
<div class="events-content">
<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>
</div>
</div>
</div>
<!-- Script will be provided by bundle.js -->
</body>
</html>

@ -2,7 +2,17 @@
// This follows strict CSP policies by avoiding inline scripts
// Import from Node.js built-ins & external modules
import * as nostrTools from 'nostr-tools';
// No longer need direct nostr-tools imports for this file
// On page load, always fetch the latest pubkey from window.nostr
if (window.nostr && typeof window.nostr.getPublicKey === 'function') {
window.nostr.getPublicKey().then(pubkey => {
console.log(`Page load: Retrieved pubkey from window.nostr: ${pubkey.slice(0, 8)}...`);
localStorage.setItem('userPublicKey', pubkey);
}).catch(err => {
console.warn("Page load: Failed to get pubkey from window.nostr:", err);
});
}
// Import type definitions
import type { NostrEvent } from './converter';
@ -10,6 +20,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,
@ -34,37 +48,44 @@ declare global {
* Initialize nostr-login
*/
function initNostrLogin(): void {
const loginContainer = document.querySelector('.login-container');
const loginStatusDiv = document.getElementById('loginStatus');
if (!loginContainer || !loginStatusDiv) {
return;
}
// Create a container for the NostrLogin button
const nostrLoginContainer = document.createElement('div');
nostrLoginContainer.id = 'nostr-login-container';
loginContainer.appendChild(nostrLoginContainer);
try {
// Initialize NostrLogin with the container
// Initialize NostrLogin without requiring UI elements
if (NostrLogin && NostrLogin.init) {
// Create a temporary container if needed
const tempContainer = document.createElement('div');
tempContainer.style.display = 'none';
document.body.appendChild(tempContainer);
console.log("Initializing NostrLogin...");
NostrLogin.init({
element: nostrLoginContainer,
element: tempContainer,
onConnect: (pubkey: string): void => {
const npub = nostrTools.nip19.npubEncode(pubkey);
loginStatusDiv.innerHTML = `<span style="color: #008800;">Connected as: ${npub.slice(0, 8)}...${npub.slice(-4)}</span>`;
console.log(`Connected to Nostr with pubkey: ${pubkey.slice(0, 8)}...`);
// Store pubkey in localStorage for other parts of the app
localStorage.setItem('userPublicKey', pubkey);
},
onDisconnect: (): void => {
loginStatusDiv.innerHTML = '<span>Disconnected</span>';
console.log('Disconnected from Nostr');
localStorage.removeItem('userPublicKey');
}
});
// Check if we can get an existing pubkey (already connected)
if (window.nostr) {
window.nostr.getPublicKey().then(pubkey => {
console.log(`Already connected with pubkey: ${pubkey.slice(0, 8)}...`);
localStorage.setItem('userPublicKey', pubkey);
}).catch(err => {
console.warn("Not connected to Nostr extension:", err);
});
}
} else {
loginStatusDiv.innerHTML = '<span style="color: #cc0000;">NostrLogin initialization unavailable</span>';
console.warn("NostrLogin initialization unavailable");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
loginStatusDiv.innerHTML = `<span style="color: #cc0000;">Error initializing Nostr login: ${errorMessage}</span>`;
console.error(`Error initializing Nostr login: ${errorMessage}`);
}
}
@ -360,7 +381,46 @@ 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
// Initialize Nostr login as early as possible, before DOM is ready
initNostrLogin();
document.addEventListener('DOMContentLoaded', function(): void {
// Set up the convert button click handler
const convertButton = document.getElementById('convertButton');
@ -379,8 +439,15 @@ document.addEventListener('DOMContentLoaded', function(): void {
publishButton.addEventListener('click', handlePublishEvent);
}
// Initialize Nostr login
initNostrLogin();
// Try to get pubkey again after DOM is ready
if (window.nostr) {
window.nostr.getPublicKey().then(pubkey => {
console.log(`DOM ready: Retrieved pubkey: ${pubkey.slice(0, 8)}...`);
localStorage.setItem('userPublicKey', pubkey);
}).catch(err => {
console.warn("DOM ready: Failed to get pubkey:", err);
});
}
// Set default HTTP request
setDefaultHttpRequest();
@ -424,6 +491,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 {
@ -25,7 +25,8 @@ interface NostrWindowExtension {
getPublicKey: () => Promise<string>;
signEvent: (event: NostrEvent) => Promise<NostrEvent>;
nip44?: {
encrypt: (content: string, pubkey: string) => string;
encrypt(pubkey: string, plaintext: string): Promise<string>;
decrypt(pubkey: string, ciphertext: string): Promise<string>;
};
}
@ -148,20 +149,11 @@ export async function convertToEvent(
if (!/^[0-9a-f]{64}$/i.test(serverPubkeyHex)) {
throw new Error("Invalid server pubkey format. Must be a 64-character hex string.");
}
// Create a payload object with the HTTP request
const payload = {
httpRequest: httpRequest,
timestamp: Date.now(),
serverPubkey: serverPubkeyHex
};
// Stringify the payload to encrypt
const payloadString = JSON.stringify(payload);
// Use Web Crypto API to encrypt the payload with decryptkey
console.log("Encrypting with Web Crypto API");
encryptedContent = await encryptWithWebCrypto(payloadString, decryptkey);
// Encrypt the HTTP request directly without wrapping it in JSON
// This ensures the decrypted content is the raw HTTP request
console.log("Encrypting raw HTTP request with Web Crypto API");
encryptedContent = await encryptWithWebCrypto(httpRequest, decryptkey);
console.log("Successfully encrypted HTTP content with Web Crypto API");
console.log("Successfully encrypted content with Web Crypto API");
} catch (error) {
console.error("Error in encryption:", error);
@ -173,25 +165,77 @@ 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' ?
encryptedContent : JSON.stringify(encryptedContent);
// Encrypt the decryption key using NIP-44 through nostr-signers
let encryptedKey = decryptkey;
// First ensure we're properly connected to nostr-signers
if (!window.nostr) {
console.warn("window.nostr not available - ensure a NIP-07 extension is installed and connected");
} else if (typeof window.nostr.nip44 !== 'object' || typeof window.nostr.nip44.encrypt !== 'function') {
console.warn("NIP-44 encryption not available - connect to a compatible NIP-07 extension");
// Log additional diagnostic information
console.log("Available nostr methods:", Object.keys(window.nostr).join(", "));
if (NostrLogin && typeof NostrLogin.getPublicKey === 'function') {
try {
// Try to explicitly connect using NostrLogin
const pubkey = await NostrLogin.getPublicKey();
console.log("Retrieved public key via NostrLogin:", pubkey);
// Sometimes this explicit call triggers the extension to connect properly
} catch (e) {
console.error("Failed to connect via NostrLogin:", e);
}
}
} else {
try {
console.log("Encrypting decryption key using window.nostr.nip44.encrypt");
// According to the NIP-07 spec, the first parameter is the pubkey (recipient)
// and the second parameter is the plaintext to encrypt
const encryptionPromise = window.nostr.nip44.encrypt(pTagValue, decryptkey);
// Since this is a Promise, we need to await it
encryptedKey = await encryptionPromise;
console.log("Successfully encrypted the decryption key with NIP-44");
} catch (encryptError) {
console.error("Failed to encrypt key with NIP-44:", encryptError instanceof Error ? encryptError.message : String(encryptError));
console.log("Using unencrypted key as fallback");
}
}
const event: NostrEvent = {
kind: 21120,
pubkey: pubkey,
@ -200,7 +244,7 @@ export async function convertToEvent(
tags: [
// Required tags per README specification
["p", pTagValue], // P tag with hex pubkey (converted from npub if needed)
["key", decryptkey], // Key for decryption
["key", encryptedKey], // Key for decryption, encrypted with NIP-44
["expiration", String(Math.floor(Date.now() / 1000) + appSettings.expirationTime)]
]
};
@ -212,8 +256,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);
});
}
});

779
client/src/receiver.ts Normal file

@ -0,0 +1,779 @@
// External dependencies
import * as nostrTools from 'nostr-tools';
// Internal imports
import { convertNpubToHex } from './relay';
import type { NostrEvent } from './relay';
// Import the NostrWindowExtension interface from converter
// Helper function for Web Crypto API decryption (mirror of encryptWithWebCrypto in converter.ts)
async function decryptWithWebCrypto(encryptedBase64: string, key: string): Promise<string> {
try {
// Convert base64 to byte array
const encryptedBytes = new Uint8Array(
atob(encryptedBase64)
.split('')
.map(char => char.charCodeAt(0))
);
// Extract IV (first 12 bytes)
const iv = encryptedBytes.slice(0, 12);
// Extract ciphertext (remaining bytes)
const ciphertext = encryptedBytes.slice(12);
// Create key material from the decryption key
const keyMaterial = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(key)
);
// Import the key for AES-GCM decryption
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyMaterial,
{ name: 'AES-GCM' },
false,
['decrypt']
);
// Decrypt the data
const decryptedData = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: iv
},
cryptoKey,
ciphertext
);
// Convert decrypted data to string
return new TextDecoder().decode(decryptedData);
} catch (error) {
throw new Error(`WebCrypto decryption failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
// 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;
/**
* Get the logged-in user's public key from localStorage
* This is populated by the nostr-login plugin
*/
function getLoggedInPubkey(): string | null {
const pubkey = localStorage.getItem('userPublicKey');
console.log("Retrieved pubkey from localStorage:", pubkey);
// If no pubkey in localStorage, try to get it from window.nostr directly
if (!pubkey && window.nostr && typeof window.nostr.getPublicKey === 'function') {
console.log("No pubkey in localStorage, trying to get from window.nostr");
// Note: This returns a promise, so we can't use it synchronously
// But we'll trigger the fetch so it might be available next time
window.nostr.getPublicKey()
.then(directPubkey => {
console.log("Retrieved pubkey directly from window.nostr:", directPubkey);
localStorage.setItem('userPublicKey', directPubkey);
return directPubkey;
})
.catch(err => {
console.error("Failed to get pubkey from window.nostr:", err);
return null;
});
}
return pubkey;
}
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
// Get the logged-in user's pubkey if available
const loggedInPubkey = getLoggedInPubkey();
console.log('Creating subscription for kind 21120 events addressed to the user');
// Define the filter type properly
interface NostrFilter {
kinds: number[];
'#p'?: string[];
authors?: string[];
}
// Create filter for kind 21120 events
const filter: NostrFilter = {
kinds: [21120], // HTTP Messages event kind
};
// If the user is logged in, filter for events addressed to them
if (loggedInPubkey) {
let pubkeyHex = loggedInPubkey;
// Convert npub to hex if needed
if (loggedInPubkey.startsWith('npub')) {
try {
const hexPubkey = convertNpubToHex(loggedInPubkey);
if (hexPubkey) {
pubkeyHex = hexPubkey;
}
} catch (error) {
console.error("Failed to convert npub to hex:", error);
}
}
// Add p-tag filter for events addressed to the logged-in user
filter['#p'] = [pubkeyHex];
console.log(`Filtering for events addressed to user: ${pubkeyHex}`);
} else {
console.log('No user logged in, showing all kind 21120 events');
}
// Log the filter we're using
console.log('Using filter:', JSON.stringify(filter));
// If a specific pubkey filter is provided in options, use it for p-tag filtering
// This replaces the logged-in user's pubkey filter with the specified one
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);
}
}
// Set the p-tag filter to the specified pubkey
// This replaces any existing p-tag filter set by the logged-in user
filter['#p'] = [pubkey];
console.log(`Overriding p-tag filter to: ${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;
}
// Event filtering is now handled at the subscription level
// 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 with improved styling
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 ? 'HTTP Request' : (hasE ? 'HTTP Response' : 'Unknown');
// Find recipient if available
let recipient = '';
const pTag = event.tags.find(tag => tag[0] === 'p');
if (pTag && pTag.length > 1) {
recipient = `| To: ${pTag[1].substring(0, 8)}...`;
}
// Format the event item
eventItem.innerHTML = `
<div class="event-header">
<span class="event-id">${eventIdForDisplay}...</span>
<span class="event-time">${new Date(event.created_at * 1000).toLocaleTimeString()}</span>
</div>
<div class="event-summary">
<span class="event-type">${eventType}</span> | From: ${event.pubkey.substring(0, 8)}... ${recipient}
</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');
// Get the decrypted or original content
// Since we now encrypt just the HTTP request directly, this is just the HTTP content
const httpContent = receivedEvent.decrypted ?
(receivedEvent.decryptedContent || event.content) :
event.content;
// Display the event details
eventDetails.innerHTML = `
<div class="event-detail-header">
<h3>${eventTypeLabel} (ID: ${eventIdForDisplay}...)</h3>
<div class="event-timestamp">${new Date(event.created_at * 1000).toLocaleString()}</div>
</div>
<div class="event-detail-metadata">
<div class="event-detail-item">
<strong>ID:</strong> <span class="metadata-value">${fullEventId}</span>
</div>
<div class="event-detail-item">
<strong>From:</strong> <span class="metadata-value">${event.pubkey}</span>
</div>
<div class="event-detail-item">
<strong>Created:</strong> <span class="metadata-value">${new Date(event.created_at * 1000).toLocaleString()}</span>
</div>
<div class="event-detail-item">
<strong>Tags:</strong>
<ul class="event-tags">${tagsHtml}</ul>
</div>
</div>
<div class="event-detail-content">
<div class="content-row">
<div class="event-metadata-column">
<div class="content-header">
<strong>Event Metadata</strong>
</div>
<div class="metadata-content">
<div>ID: ${fullEventId}</div>
<div>Pubkey: ${event.pubkey}</div>
<div>Created: ${new Date(event.created_at * 1000).toLocaleString()}</div>
${receivedEvent.decrypted ? '<div class="decrypted-badge">Decrypted</div>' : ''}
</div>
</div>
<div class="http-content-column">
<div class="content-header">
<strong>HTTP ${eventTypeLabel}</strong>
</div>
<pre class="http-content">${httpContent}</pre>
</div>
</div>
</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 {
const event = receivedEvent.event;
let decryptedContent: string;
// Look for a "key" tag in the event
const keyTag = event.tags.find(tag => tag[0] === 'key');
if (!keyTag || keyTag.length < 2) {
decryptedContent = `[No key tag found for decryption]\n${event.content}`;
} else {
try {
// Extract the encrypted key from the tag
const encryptedKey = keyTag[1];
// Check if window.nostr and nip44.decrypt are available
if (!window.nostr) {
throw new Error("window.nostr is not available - ensure a NIP-07 extension is installed");
}
if (!window.nostr.nip44 || !window.nostr.nip44.decrypt) {
console.warn("NIP-44 decryption not available - trying to connect to a compatible extension");
// Log diagnostic information
console.log("Available nostr methods:", Object.keys(window.nostr).join(", "));
// Check if we can trigger a connection via NostrLogin
if (typeof window.nostr.getPublicKey === 'function') {
try {
await window.nostr.getPublicKey();
// Check again after getting public key
if (typeof window.nostr.nip44 === 'object' &&
typeof window.nostr.nip44.decrypt === 'function') {
console.log("NIP-44 is now available after connecting");
} else {
throw new Error("NIP-44 decryption is still not available after connecting");
}
} catch (e) {
throw new Error(`Failed to connect to extension: ${e}`);
}
} else {
throw new Error("window.nostr.nip44.decrypt is not available");
}
}
// Use window.nostr to decrypt the key tag with NIP-44
console.log("Using window.nostr.nip44.decrypt for key decryption");
// Note that according to the NIP-07 spec, the first parameter is the pubkey (sender)
// and the second parameter is the ciphertext
// Declare decryptedKey outside the try block so it's available in the outer scope
let decryptedKey: string;
try {
// Log the actual values being passed to help debug
console.log(`Attempting to decrypt with pubkey: ${event.pubkey.substring(0, 8)}... and encrypted key: ${encryptedKey.substring(0, 10)}...`);
decryptedKey = await window.nostr.nip44.decrypt(
event.pubkey, // The pubkey that encrypted the key
encryptedKey // The encrypted key from the "key" tag
);
console.log("Decryption successful, decrypted key:", decryptedKey.substring(0, 5) + "...");
} catch (decryptKeyError) {
console.error("Error in direct decryption call:", decryptKeyError);
throw decryptKeyError; // Re-throw to be caught by the outer catch block
}
console.log("Key decryption successful");
// Now use the decrypted key to decrypt the content using Web Crypto API
try {
// Decrypt the content using Web Crypto API and the decrypted key
const decryptedEventContent = await decryptWithWebCrypto(
event.content,
decryptedKey
);
// The decrypted content is the direct HTTP request/response
// No need to try parsing as JSON anymore
console.log("Successfully decrypted HTTP content");
decryptedContent = decryptedEventContent;
} catch (contentDecryptError) {
console.error("Content decryption failed:", contentDecryptError instanceof Error ? contentDecryptError.message : String(contentDecryptError));
decryptedContent = `Key decryption successful, but content decryption failed: ${contentDecryptError instanceof Error ? contentDecryptError.message : String(contentDecryptError)}
Encrypted content: ${event.content.substring(0, 50)}...
Decrypted key: ${decryptedKey}`;
}
} catch (decryptError) {
console.error("Failed to decrypt with window.nostr.nip44:", decryptError instanceof Error ? decryptError.message : String(decryptError));
decryptedContent = `[NIP-44 decryption failed: ${decryptError instanceof Error ? decryptError.message : String(decryptError)}]\n${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(/(.+)\.\.\./)?.[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');
// Connect to relay and automatically start subscription for logged-in user
if (connectRelayBtn) {
connectRelayBtn.addEventListener('click', async () => {
if (!relayUrlInput) {
return;
}
// Check if we're logged in before connecting
const userPubkey = getLoggedInPubkey();
console.log("User pubkey when connecting to relay:", userPubkey);
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 as the default
const userPubkey = getLoggedInPubkey();
const pubkeyFilter = userPubkey || '';
// Log the pubkey we're using for subscription
if (userPubkey) {
console.log(`Using logged-in user's pubkey for subscription: ${userPubkey.substring(0, 8)}...`);
} else {
console.log('No user pubkey found, subscribing to all kind 21120 events');
}
// 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');
// No need to update subscription buttons since they're removed
}
// Successful subscription message
// Subscribed to kind 21120 events
// Add subscribed indication to the UI
updateRelayStatus('Connected and subscribed ✓', 'connected');
}
});
}
// No clear events functionality
});

@ -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}`));
@ -129,6 +129,7 @@ export async function publishToRelay(event: NostrEvent, relayUrl: string): Promi
try {
// Use the WebSocket API directly
<<<<<<< HEAD
ws = new WebSocket(relayUrl);
// eslint-disable-next-line no-undef
@ -139,6 +140,16 @@ export async function publishToRelay(event: NostrEvent, relayUrl: string): Promi
} catch {
// Ignore errors when closing WebSocket
}
=======
const ws = new WebSocket(relayUrl);
const wsTimeout = setTimeout(() => {
try {
ws.close();
} catch {
// Ignore errors when closing WebSocket
>>>>>>> main
}
reject(new Error("WebSocket connection timed out"));
}, 10000);
@ -156,8 +167,13 @@ export async function publishToRelay(event: NostrEvent, relayUrl: string): Promi
if (typeof msg.data === 'string' && msg.data.startsWith('["OK"')) {
responseHandled = true;
<<<<<<< HEAD
// eslint-disable-next-line no-undef
if (wsTimeout) clearTimeout(wsTimeout);
=======
clearTimeout(wsTimeout);
>>>>>>> main
resolve(`Event published successfully via WebSocket`);
try {
ws?.close();
@ -166,8 +182,13 @@ 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;
<<<<<<< HEAD
// eslint-disable-next-line no-undef
if (wsTimeout) clearTimeout(wsTimeout);
=======
clearTimeout(wsTimeout);
>>>>>>> main
reject(new Error(`Relay rejected event: ${msg.data}`));
try {
ws?.close();
@ -183,6 +204,7 @@ export async function publishToRelay(event: NostrEvent, relayUrl: string): Promi
}
responseHandled = true;
<<<<<<< HEAD
// eslint-disable-next-line no-undef
if (wsTimeout) clearTimeout(wsTimeout);
@ -190,6 +212,11 @@ export async function publishToRelay(event: NostrEvent, relayUrl: string): Promi
const errorMessage = 'WebSocket connection failed';
reject(new Error(`WebSocket error: ${errorMessage}`));
=======
clearTimeout(wsTimeout);
reject(new Error(`WebSocket error: ${String(error)}`));
>>>>>>> main
try {
ws?.close();
} catch {
@ -198,8 +225,13 @@ export async function publishToRelay(event: NostrEvent, relayUrl: string): Promi
};
ws.onclose = (): void => {
<<<<<<< HEAD
// eslint-disable-next-line no-undef
if (wsTimeout) clearTimeout(wsTimeout);
=======
clearTimeout(wsTimeout);
>>>>>>> main
};
} catch (wsError) {
// If WebSocket fails, try the regular method
@ -211,7 +243,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) {
@ -221,7 +253,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}`));

@ -69,52 +69,108 @@ h2 {
margin-bottom: 15px;
}
/* Theme toggle */
.theme-toggle {
/* Top Navigation Bar */
.top-nav {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
position: fixed;
top: 20px;
top: 0;
left: 0;
right: 0;
z-index: 1000;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
height: 50px;
display: flex;
width: 100%;
}
body {
padding-top: 70px; /* Added padding to account for fixed navbar */
}
.nav-left {
display: flex;
height: 100%;
margin-left: 20px;
}
.nav-right {
position: absolute;
top: 0;
right: 20px;
height: 50px;
display: flex;
align-items: center;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 30px;
padding: 5px 10px;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
z-index: 100;
}
.theme-toggle-icon {
font-size: 18px;
margin-right: 8px;
}
/* Newer button style toggle */
.theme-toggle-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 100;
gap: 15px;
}
/* Theme toggle button in the nav bar - aligned with icons */
.theme-toggle-btn {
display: flex;
align-items: center;
background-color: var(--bg-secondary);
justify-content: center;
background-color: transparent;
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 30px;
padding: 8px 12px;
border: none;
cursor: pointer;
font-size: 14px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
font-size: 16px;
transition: all 0.3s ease;
padding: 0;
margin: 0;
}
.theme-toggle-btn:hover {
color: var(--accent-color);
}
/* Ensure icons are properly sized */
#themeIcon {
font-size: 18px;
}
/* Navigation Links - Tab Style */
.nav-link {
text-decoration: none;
color: var(--text-secondary);
font-weight: 600;
transition: all 0.3s ease;
height: 100%;
display: flex;
align-items: center;
padding: 0 25px;
position: relative;
}
.nav-link:hover {
color: var(--accent-color);
}
.nav-link.active {
color: var(--accent-color);
}
.nav-link.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background-color: var(--accent-color);
color: white;
}
/* Icon navigation links */
.nav-icon {
padding: 0 10px;
}
/* Legacy theme toggle styles for backward compatibility */
.theme-toggle-container {
display: none; /* Hide the old toggle container */
}
.theme-toggle {
display: none; /* Hide the old toggle */
}
.theme-toggle-text {
@ -384,4 +440,35 @@ pre {
background-color: rgba(0, 136, 0, 0.1);
border-radius: 4px;
margin: 10px 0;
}
/* Responsive adjustments */
@media (max-width: 768px) {
body {
padding-top: 60px;
padding-left: 10px;
padding-right: 10px;
}
.top-nav {
height: 45px;
}
.nav-left {
margin-left: 5px;
}
.nav-right {
right: 5px;
height: 45px;
gap: 10px;
}
.nav-link {
padding: 0 15px;
}
.content {
padding: 10px 0;
}
}

@ -70,32 +70,75 @@ h3 {
margin-top: 20px;
margin-bottom: 10px;
}
/* Theme toggle */
.theme-toggle-container {
/* Top Navigation Bar */
.top-nav {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
position: fixed;
top: 20px;
right: 20px;
z-index: 100;
top: 0;
left: 0;
right: 0;
z-index: 1000;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
height: 50px;
display: flex;
width: 100%;
}
body {
padding-top: 70px; /* Added padding to account for fixed navbar */
}
.nav-left {
display: flex;
height: 100%;
margin-left: 20px;
}
.nav-right {
position: absolute;
top: 0;
right: 20px;
height: 50px;
display: flex;
align-items: center;
gap: 15px;
}
/* Theme toggle button in the nav bar - aligned with icons */
.theme-toggle-btn {
display: flex;
align-items: center;
background-color: var(--bg-secondary);
justify-content: center;
background-color: transparent;
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 30px;
padding: 8px 12px;
border: none;
cursor: pointer;
font-size: 14px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
font-size: 16px;
transition: all 0.3s ease;
padding: 0;
margin: 0;
}
.theme-toggle-btn:hover {
background-color: var(--accent-color);
color: white;
color: var(--accent-color);
}
/* Ensure icons are properly sized */
#themeIcon {
font-size: 18px;
}
/* Icon styling */
.nav-icon {
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.icon {
font-size: 18px;
}
/* Info box */
@ -220,41 +263,49 @@ footer {
/* Responsive adjustments */
@media (max-width: 768px) {
body {
padding: 15px;
padding-top: 60px;
padding-left: 10px;
padding-right: 10px;
}
.features-grid {
grid-template-columns: 1fr;
}
.navigation {
flex-direction: column;
.top-nav {
height: 45px;
}
.theme-toggle-container {
position: static;
display: flex;
justify-content: flex-end;
margin-bottom: 20px;
.nav-left {
margin-left: 5px;
}
.nav-right {
right: 5px;
height: 45px;
gap: 10px;
}
.nav-link {
padding: 0 15px;
}
.content {
padding: 10px 0;
}
}
/* Navigation */
.navigation {
display: flex;
margin-bottom: 25px;
border-bottom: 2px solid var(--border-color);
padding-bottom: 10px;
}
/* Navigation Links - Tab Style */
.nav-link {
padding: 10px 20px;
margin-right: 10px;
text-decoration: none;
color: var(--text-secondary);
font-weight: 500;
border-bottom: 2px solid transparent;
font-weight: 600;
transition: all 0.3s ease;
height: 100%;
display: flex;
align-items: center;
padding: 0 25px;
position: relative;
}
.nav-link:hover {
@ -263,7 +314,21 @@ footer {
.nav-link.active {
color: var(--accent-color);
border-bottom: 2px solid var(--accent-color);
}
.nav-link.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background-color: var(--accent-color);
}
/* Icon navigation links */
.nav-icon {
padding: 0 10px;
}
/* Tab Navigation (legacy) */
@ -382,4 +447,715 @@ 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 container for side-by-side layout */
.events-container {
display: flex;
gap: 20px;
margin-top: 15px;
}
.events-sidebar {
flex: 0 0 300px;
}
.events-content {
flex: 1;
}
.events-list {
max-height: 600px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: var(--bg-secondary);
background-color: var(--bg-secondary);
}
.event-item {
padding: 15px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.2s ease;
}
.event-item:last-child {
border-bottom: none;
}
.event-item:hover {
background-color: var(--bg-tertiary);
}
.event-item.selected {
background-color: var(--bg-tertiary);
border-left: 4px solid var(--accent-color);
}
.event-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 14px;
}
.event-id {
font-weight: bold;
color: var(--accent-color);
}
.event-time {
color: var(--text-tertiary);
}
.event-summary {
font-size: 13px;
margin-bottom: 10px;
color: var(--text-secondary);
line-height: 1.4;
}
.event-actions {
text-align: right;
}
.view-details-btn {
font-size: 12px;
padding: 4px 8px;
background-color: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
}
.view-details-btn:hover {
background-color: var(--accent-color);
color: white;
}
.event-details {
padding: 20px;
background-color: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border-color);
min-height: 600px;
}
.event-detail-item {
margin-bottom: 15px;
line-height: 1.5;
}
.event-detail-item strong {
color: var(--accent-color);
}
.event-detail-item ul {
padding-left: 20px;
margin-top: 8px;
}
.event-content {
margin-top: 10px;
padding: 15px;
border-radius: 4px;
background-color: var(--bg-tertiary);
max-height: 300px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.4;
white-space: pre-wrap;
}
.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);
}
/* Enhanced Event Details Styling */
.event-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-color);
}
.event-detail-header h3 {
margin: 0;
color: var(--accent-color);
}
.event-timestamp {
color: var(--text-tertiary);
font-size: 14px;
}
.event-detail-metadata {
margin-bottom: 20px;
padding: 15px;
background-color: var(--bg-tertiary);
border-radius: 4px;
}
.metadata-value {
font-family: 'Courier New', monospace;
font-size: 13px;
word-break: break-all;
}
.event-tags {
max-height: 150px;
overflow-y: auto;
}
.event-detail-content {
margin-top: 20px;
}
.content-header {
margin-bottom: 10px;
color: var(--accent-color);
}
.decrypted-badge {
background-color: var(--button-success);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
margin-left: 8px;
}
/* Content row for side-by-side display */
.content-row {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.event-metadata-column {
flex: 0 0 250px;
background-color: var(--bg-tertiary);
border-radius: 6px;
padding: 15px;
}
.http-content-column {
flex: 1;
min-width: 300px;
}
.metadata-content {
margin-top: 10px;
line-height: 1.6;
font-family: 'Courier New', monospace;
font-size: 13px;
word-break: break-all;
}
.http-content {
margin-top: 10px;
padding: 15px;
border-radius: 4px;
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;
white-space: pre-wrap;
border-left: 4px solid var(--accent-color);
}
.raw-content-column {
flex: 1 0 100%;
margin-top: 20px;
}
.raw-content {
margin-top: 10px;
padding: 15px;
border-radius: 4px;
background-color: var(--bg-tertiary);
max-height: 200px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 12px;
line-height: 1.4;
white-space: pre-wrap;
color: var(--text-tertiary);
}
.toggle-raw-btn {
float: right;
font-size: 12px;
padding: 2px 8px;
background-color: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
}
.toggle-raw-btn:hover {
background-color: var(--accent-color);
color: white;
}
.event-type {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.event-item:hover .event-type {
background-color: var(--accent-color);
color: white;
}
.copy-button:active {
background-color: var(--accent-color);
}
.copy-button.copied {
background-color: var(--button-success);
}
/* Dark mode is already handled by CSS variables */
/* Responsive adjustments for the events container */
@media (max-width: 768px) {
.events-container {
flex-direction: column;
}
.events-sidebar {
flex: auto;
width: 100%;
}
.events-list {
max-height: 300px;
}
.event-details {
min-height: 400px;
}
}
/* Clear events button */
.event-controls {
margin-bottom: 10px;
text-align: right;
}
.clear-events-button {
padding: 8px 15px;
background-color: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.clear-events-button:hover {
background-color: #c82333;
}

@ -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' },
],
}),
],