Merge branch 'main' into complex/test
This commit is contained in:
commit
38e9301510
31
.gitea/workflows/release-production.yaml
Normal file
31
.gitea/workflows/release-production.yaml
Normal file
@ -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
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
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
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
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' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
Loading…
x
Reference in New Issue
Block a user