diff --git a/app/web/src/App.svelte b/app/web/src/App.svelte
index 8493e03..be23a71 100644
--- a/app/web/src/App.svelte
+++ b/app/web/src/App.svelte
@@ -2,6 +2,7 @@
import LoginModal from './LoginModal.svelte';
import { initializeNostrClient, fetchUserProfile, fetchAllEvents, fetchUserEvents, searchEvents, fetchEventById, fetchDeleteEventsByTarget, nostrClient, NostrClient } from './nostr.js';
import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
+ import { publishEventWithAuth } from './websocket-auth.js';
let isDarkTheme = false;
let showLoginModal = false;
@@ -58,6 +59,9 @@
let sprocketEnabled = false;
let sprocketUploadFile = null;
+ // Compose tab state
+ let composeEventJson = '';
+
// Kind name mapping based on repository kind definitions
const kindNames = {
0: "ProfileMetadata",
@@ -170,6 +174,51 @@
expandedEvents = expandedEvents; // Trigger reactivity
}
+ async function copyEventToClipboard(eventData, clickEvent) {
+ try {
+ // Create minified JSON (no indentation)
+ const minifiedJson = JSON.stringify(eventData);
+ await navigator.clipboard.writeText(minifiedJson);
+
+ // Show temporary feedback
+ const button = clickEvent.target.closest('.copy-json-btn');
+ if (button) {
+ const originalText = button.textContent;
+ button.textContent = '✅';
+ button.style.backgroundColor = '#4CAF50';
+ setTimeout(() => {
+ button.textContent = originalText;
+ button.style.backgroundColor = '';
+ }, 2000);
+ }
+ } catch (error) {
+ console.error('Failed to copy to clipboard:', error);
+ // Fallback for older browsers
+ try {
+ const textArea = document.createElement('textarea');
+ textArea.value = JSON.stringify(eventData);
+ document.body.appendChild(textArea);
+ textArea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textArea);
+
+ const button = clickEvent.target.closest('.copy-json-btn');
+ if (button) {
+ const originalText = button.textContent;
+ button.textContent = '✅';
+ button.style.backgroundColor = '#4CAF50';
+ setTimeout(() => {
+ button.textContent = originalText;
+ button.style.backgroundColor = '';
+ }, 2000);
+ }
+ } catch (fallbackError) {
+ console.error('Fallback copy also failed:', fallbackError);
+ alert('Failed to copy to clipboard. Please copy manually.');
+ }
+ }
+ }
+
async function handleToggleChange() {
// Toggle state is already updated by bind:checked
console.log('Toggle changed, showOnlyMyEvents:', showOnlyMyEvents);
@@ -238,6 +287,21 @@
console.log('Signed delete event pubkey:', signedDeleteEvent.pubkey);
console.log('Delete event tags:', signedDeleteEvent.tags);
+ // Publish to the ORLY relay using WebSocket authentication
+ const relayUrl = `wss://${window.location.host}`;
+
+ try {
+ const result = await publishEventWithAuth(relayUrl, signedDeleteEvent, userSigner, userPubkey);
+
+ if (result.success) {
+ console.log('Delete event published successfully to ORLY relay');
+ } else {
+ console.error('Failed to publish delete event:', result.reason);
+ }
+ } catch (error) {
+ console.error('Error publishing delete event:', error);
+ }
+
// Determine if we should publish to external relays
// Only publish to external relays if:
// 1. User is deleting their own event, OR
@@ -820,6 +884,7 @@
{id: 'export', icon: '📤', label: 'Export'},
{id: 'import', icon: '💾', label: 'Import', requiresAdmin: true},
{id: 'events', icon: '📡', label: 'Events'},
+ {id: 'compose', icon: '✏️', label: 'Compose'},
{id: 'sprocket', icon: '⚙️', label: 'Sprocket', requiresOwner: true},
];
@@ -1478,6 +1543,106 @@
return base64Event;
}
+
+ // Compose tab functions
+ function reformatJson() {
+ try {
+ if (!composeEventJson.trim()) {
+ alert('Please enter some JSON to reformat');
+ return;
+ }
+ const parsed = JSON.parse(composeEventJson);
+ composeEventJson = JSON.stringify(parsed, null, 2);
+ } catch (error) {
+ alert('Invalid JSON: ' + error.message);
+ }
+ }
+
+ async function signEvent() {
+ try {
+ if (!composeEventJson.trim()) {
+ alert('Please enter an event to sign');
+ return;
+ }
+
+ if (!isLoggedIn || !userPubkey) {
+ alert('Please log in to sign events');
+ return;
+ }
+
+ if (!userSigner) {
+ alert('No signer available. Please log in with a valid authentication method.');
+ return;
+ }
+
+ const event = JSON.parse(composeEventJson);
+
+ // Update event with current user's pubkey and timestamp
+ event.pubkey = userPubkey;
+ event.created_at = Math.floor(Date.now() / 1000);
+
+ // Remove any existing id and sig to ensure fresh signing
+ delete event.id;
+ delete event.sig;
+
+ // Sign the event using the real signer
+ const signedEvent = await userSigner.signEvent(event);
+
+ // Update the compose area with the signed event
+ composeEventJson = JSON.stringify(signedEvent, null, 2);
+
+ // Show success feedback
+ alert('Event signed successfully!');
+ } catch (error) {
+ console.error('Error signing event:', error);
+ alert('Error signing event: ' + error.message);
+ }
+ }
+
+ async function publishEvent() {
+ try {
+ if (!composeEventJson.trim()) {
+ alert('Please enter an event to publish');
+ return;
+ }
+
+ if (!isLoggedIn) {
+ alert('Please log in to publish events');
+ return;
+ }
+
+ if (!userSigner) {
+ alert('No signer available. Please log in with a valid authentication method.');
+ return;
+ }
+
+ const event = JSON.parse(composeEventJson);
+
+ // Validate that the event has required fields
+ if (!event.id || !event.sig) {
+ alert('Event must be signed before publishing. Please click "Sign" first.');
+ return;
+ }
+
+ // Publish to the ORLY relay using WebSocket (same address as current page)
+ const relayUrl = `wss://${window.location.host}`;
+
+ // Use the authentication module to publish the event
+ const result = await publishEventWithAuth(relayUrl, event, userSigner, userPubkey);
+
+ if (result.success) {
+ alert('Event published successfully to ORLY relay!');
+ // Optionally clear the editor after successful publish
+ // composeEventJson = '';
+ } else {
+ alert(`Event publishing failed: ${result.reason || 'Unknown error'}`);
+ }
+
+ } catch (error) {
+ console.error('Error publishing event:', error);
+ alert('Error publishing event: ' + error.message);
+ }
+ }
@@ -1650,7 +1815,12 @@
{#if expandedEvents.has(event.id)}
-
{JSON.stringify(event, null, 2)}
+
+
{JSON.stringify(event, null, 2)}
+
+
{/if}
@@ -1709,6 +1879,22 @@
{/if}
+ {:else if selectedTab === 'compose'}
+
{:else if selectedTab === 'sprocket'}
Sprocket Script Management
@@ -1878,7 +2064,12 @@
{#if expandedEvents.has(event.id)}
-
{JSON.stringify(event, null, 2)}
+
+
{JSON.stringify(event, null, 2)}
+
+
{/if}
@@ -2873,6 +3064,76 @@
margin: 0 auto;
}
+ .compose-view {
+ position: fixed;
+ top: 3em;
+ left: 200px;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+ background: transparent;
+ }
+
+ .compose-header {
+ display: flex;
+ gap: 0.5em;
+ padding: 0.5em;
+ background: transparent;
+ border-bottom: 1px solid var(--border-color);
+ }
+
+ .compose-btn {
+ padding: 0.5em 1em;
+ border: 1px solid var(--border-color);
+ border-radius: 0.25rem;
+ background: var(--button-bg);
+ color: var(--button-text);
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: background-color 0.2s, border-color 0.2s;
+ }
+
+ .compose-btn:hover {
+ background: var(--button-hover-bg);
+ border-color: var(--button-hover-border);
+ }
+
+ .publish-btn {
+ background: var(--accent-color, #007bff);
+ color: white;
+ border-color: var(--accent-color, #007bff);
+ }
+
+ .publish-btn:hover {
+ background: var(--accent-hover-color, #0056b3);
+ border-color: var(--accent-hover-color, #0056b3);
+ }
+
+ .compose-editor {
+ flex: 1;
+ padding: 0.5em;
+ }
+
+ .compose-textarea {
+ width: 100%;
+ height: 100%;
+ border: 1px solid var(--border-color);
+ border-radius: 0.25rem;
+ background: var(--bg-color);
+ color: var(--text-color);
+ font-family: 'Courier New', monospace;
+ font-size: 0.9rem;
+ padding: 1rem;
+ resize: none;
+ outline: none;
+ }
+
+ .compose-textarea:focus {
+ border-color: var(--accent-color, #007bff);
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
+ }
+
.export-view h2, .import-view h2 {
margin: 0 0 2rem 0;
color: var(--text-color);
@@ -3241,6 +3502,37 @@
padding: 1rem;
}
+ .json-container {
+ position: relative;
+ }
+
+ .copy-json-btn {
+ position: absolute;
+ top: 0.5rem;
+ right: 0.5rem;
+ background: var(--button-bg);
+ color: var(--button-text);
+ border: 1px solid var(--border-color);
+ border-radius: 0.25rem;
+ padding: 0.5rem 1rem;
+ font-size: 1.6rem;
+ cursor: pointer;
+ transition: background-color 0.2s, border-color 0.2s;
+ z-index: 10;
+ opacity: 0.8;
+ width: auto;
+ height: auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .copy-json-btn:hover {
+ background: var(--button-hover-bg);
+ border-color: var(--button-hover-border);
+ opacity: 1;
+ }
+
.event-json {
background: var(--bg-color);
border: 1px solid var(--border-color);
@@ -3478,6 +3770,10 @@
left: 60px;
}
+ .compose-view {
+ left: 60px;
+ }
+
.search-results-view {
left: 60px;
}
@@ -3533,6 +3829,10 @@
.events-view-container {
left: 160px;
}
+
+ .compose-view {
+ left: 160px;
+ }
.events-view-info {
width: 8rem;
diff --git a/app/web/src/websocket-auth.js b/app/web/src/websocket-auth.js
new file mode 100644
index 0000000..c1f2210
--- /dev/null
+++ b/app/web/src/websocket-auth.js
@@ -0,0 +1,251 @@
+/**
+ * WebSocket Authentication Module for Nostr Relays
+ * Implements NIP-42 authentication with proper challenge handling
+ */
+
+export class NostrWebSocketAuth {
+ constructor(relayUrl, userSigner, userPubkey) {
+ this.relayUrl = relayUrl;
+ this.userSigner = userSigner;
+ this.userPubkey = userPubkey;
+ this.ws = null;
+ this.challenge = null;
+ this.isAuthenticated = false;
+ this.authPromise = null;
+ }
+
+ /**
+ * Connect to relay and handle authentication
+ */
+ async connect() {
+ return new Promise((resolve, reject) => {
+ this.ws = new WebSocket(this.relayUrl);
+
+ this.ws.onopen = () => {
+ console.log('WebSocket connected to relay:', this.relayUrl);
+ resolve();
+ };
+
+ this.ws.onmessage = async (message) => {
+ try {
+ const data = JSON.parse(message.data);
+ await this.handleMessage(data);
+ } catch (error) {
+ console.error('Error parsing relay message:', error);
+ }
+ };
+
+ this.ws.onerror = (error) => {
+ console.error('WebSocket error:', error);
+ reject(new Error('Failed to connect to relay'));
+ };
+
+ this.ws.onclose = () => {
+ console.log('WebSocket connection closed');
+ this.isAuthenticated = false;
+ this.challenge = null;
+ };
+
+ // Timeout for connection
+ setTimeout(() => {
+ if (this.ws.readyState !== WebSocket.OPEN) {
+ reject(new Error('Connection timeout'));
+ }
+ }, 10000);
+ });
+ }
+
+ /**
+ * Handle incoming messages from relay
+ */
+ async handleMessage(data) {
+ const [messageType, ...params] = data;
+
+ switch (messageType) {
+ case 'AUTH':
+ // Relay sent authentication challenge
+ this.challenge = params[0];
+ console.log('Received AUTH challenge:', this.challenge);
+ await this.authenticate();
+ break;
+
+ case 'OK':
+ const [eventId, success, reason] = params;
+ if (eventId && success) {
+ console.log('Authentication successful for event:', eventId);
+ this.isAuthenticated = true;
+ if (this.authPromise) {
+ this.authPromise.resolve();
+ this.authPromise = null;
+ }
+ } else if (eventId && !success) {
+ console.error('Authentication failed:', reason);
+ if (this.authPromise) {
+ this.authPromise.reject(new Error(reason || 'Authentication failed'));
+ this.authPromise = null;
+ }
+ }
+ break;
+
+ case 'NOTICE':
+ console.log('Relay notice:', params[0]);
+ break;
+
+ default:
+ console.log('Unhandled message type:', messageType, params);
+ }
+ }
+
+ /**
+ * Authenticate with the relay using NIP-42
+ */
+ async authenticate() {
+ if (!this.challenge) {
+ throw new Error('No challenge received from relay');
+ }
+
+ if (!this.userSigner) {
+ throw new Error('No signer available for authentication');
+ }
+
+ try {
+ // Create NIP-42 authentication event
+ const authEvent = {
+ kind: 22242, // ClientAuthentication kind
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [
+ ['relay', this.relayUrl],
+ ['challenge', this.challenge]
+ ],
+ content: '',
+ pubkey: this.userPubkey
+ };
+
+ // Sign the authentication event
+ const signedAuthEvent = await this.userSigner.signEvent(authEvent);
+
+ // Send AUTH message to relay
+ const authMessage = ["AUTH", signedAuthEvent];
+ this.ws.send(JSON.stringify(authMessage));
+
+ console.log('Sent authentication event to relay');
+
+ // Wait for authentication response
+ return new Promise((resolve, reject) => {
+ this.authPromise = { resolve, reject };
+
+ // Timeout for authentication
+ setTimeout(() => {
+ if (this.authPromise) {
+ this.authPromise.reject(new Error('Authentication timeout'));
+ this.authPromise = null;
+ }
+ }, 10000);
+ });
+
+ } catch (error) {
+ console.error('Authentication error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Publish an event to the relay
+ */
+ async publishEvent(event) {
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
+ throw new Error('WebSocket not connected');
+ }
+
+ return new Promise((resolve, reject) => {
+ // Send EVENT message
+ const eventMessage = ["EVENT", event];
+ this.ws.send(JSON.stringify(eventMessage));
+
+ // Set up message handler for this specific event
+ const originalOnMessage = this.ws.onmessage;
+ const timeout = setTimeout(() => {
+ this.ws.onmessage = originalOnMessage;
+ reject(new Error('Publish timeout'));
+ }, 15000);
+
+ this.ws.onmessage = async (message) => {
+ try {
+ const data = JSON.parse(message.data);
+ const [messageType, eventId, success, reason] = data;
+
+ if (messageType === 'OK' && eventId === event.id) {
+ clearTimeout(timeout);
+ this.ws.onmessage = originalOnMessage;
+
+ if (success) {
+ console.log('Event published successfully:', eventId);
+ resolve({ success: true, eventId, reason });
+ } else {
+ console.error('Event publish failed:', reason);
+
+ // Check if authentication is required
+ if (reason && reason.includes('auth-required')) {
+ console.log('Authentication required, attempting to authenticate...');
+ try {
+ await this.authenticate();
+ // Re-send the event after authentication
+ const retryMessage = ["EVENT", event];
+ this.ws.send(JSON.stringify(retryMessage));
+ // Don't resolve yet, wait for the retry response
+ return;
+ } catch (authError) {
+ reject(new Error(`Authentication failed: ${authError.message}`));
+ return;
+ }
+ }
+
+ reject(new Error(`Publish failed: ${reason}`));
+ }
+ } else {
+ // Handle other messages normally
+ await this.handleMessage(data);
+ }
+ } catch (error) {
+ clearTimeout(timeout);
+ this.ws.onmessage = originalOnMessage;
+ reject(error);
+ }
+ };
+ });
+ }
+
+ /**
+ * Close the WebSocket connection
+ */
+ close() {
+ if (this.ws) {
+ this.ws.close();
+ this.ws = null;
+ }
+ this.isAuthenticated = false;
+ this.challenge = null;
+ }
+
+ /**
+ * Check if currently authenticated
+ */
+ getAuthenticated() {
+ return this.isAuthenticated;
+ }
+}
+
+/**
+ * Convenience function to publish an event with authentication
+ */
+export async function publishEventWithAuth(relayUrl, event, userSigner, userPubkey) {
+ const auth = new NostrWebSocketAuth(relayUrl, userSigner, userPubkey);
+
+ try {
+ await auth.connect();
+ const result = await auth.publishEvent(event);
+ return result;
+ } finally {
+ auth.close();
+ }
+}
diff --git a/pkg/version/version b/pkg/version/version
index 0ce90be..c06eba1 100644
--- a/pkg/version/version
+++ b/pkg/version/version
@@ -1 +1 @@
-v0.14.4
\ No newline at end of file
+v0.15.0
\ No newline at end of file