import React, { useState, useEffect, useRef } from 'react'; function App() { const [user, setUser] = useState(null); const [status, setStatus] = useState('Ready to authenticate'); const [statusType, setStatusType] = useState('info'); const [profileData, setProfileData] = useState(null); // Theme state for dark/light mode const [isDarkMode, setIsDarkMode] = useState(false); const [checkingAuth, setCheckingAuth] = useState(true); // Events log state const [events, setEvents] = useState([]); const [eventsLoading, setEventsLoading] = useState(false); const [eventsOffset, setEventsOffset] = useState(0); const [eventsHasMore, setEventsHasMore] = useState(true); const [expandedEventId, setExpandedEventId] = useState(null); // All Events log state (admin) const [allEvents, setAllEvents] = useState([]); const [allEventsLoading, setAllEventsLoading] = useState(false); const [allEventsOffset, setAllEventsOffset] = useState(0); const [allEventsHasMore, setAllEventsHasMore] = useState(true); const [expandedAllEventId, setExpandedAllEventId] = useState(null); // Profile cache for All Events Log const [profileCache, setProfileCache] = useState({}); // Function to fetch and cache profile metadata for an author async function fetchAndCacheProfile(pubkeyHex) { if (!pubkeyHex || profileCache[pubkeyHex]) { return profileCache[pubkeyHex] || null; } try { const profile = await fetchKind0FromRelay(pubkeyHex); if (profile) { setProfileCache(prev => ({ ...prev, [pubkeyHex]: { name: profile.name || `user:${pubkeyHex.slice(0, 8)}`, display_name: profile.display_name, picture: profile.picture, about: profile.about } })); return profile; } } catch (error) { console.log('Error fetching profile for', pubkeyHex.slice(0, 8), ':', error); } return null; } // Function to fetch profiles for all events in a batch async function fetchProfilesForEvents(events) { const uniqueAuthors = [...new Set(events.map(event => event.author).filter(Boolean))]; const fetchPromises = uniqueAuthors.map(author => fetchAndCacheProfile(author)); await Promise.allSettled(fetchPromises); } // Section revealer states const [expandedSections, setExpandedSections] = useState({ welcome: true, exportMine: false, exportAll: false, exportSpecific: false, importEvents: false, eventsLog: false, allEventsLog: false }); // Login view layout measurements const titleRef = useRef(null); const fileInputRef = useRef(null); const [loginPadding, setLoginPadding] = useState(16); // default fallback padding in px useEffect(() => { function updatePadding() { if (titleRef.current) { const h = titleRef.current.offsetHeight || 0; // Pad area around the text by half the title text height setLoginPadding(Math.max(0, Math.round(h / 2))); } } updatePadding(); window.addEventListener('resize', updatePadding); return () => window.removeEventListener('resize', updatePadding); }, []); // Effect to detect and track system theme preference useEffect(() => { // Check if the browser supports prefers-color-scheme const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); // Set the initial theme based on system preference setIsDarkMode(darkModeMediaQuery.matches); // Add listener to respond to system theme changes const handleThemeChange = (e) => { setIsDarkMode(e.matches); }; // Modern browsers darkModeMediaQuery.addEventListener('change', handleThemeChange); // Cleanup listener on component unmount return () => { darkModeMediaQuery.removeEventListener('change', handleThemeChange); }; }, []); useEffect(() => { // Check authentication status on page load (async () => { await checkStatus(); setCheckingAuth(false); })(); }, []); // Effect to fetch profile when user changes useEffect(() => { if (user?.pubkey) { fetchUserProfile(user.pubkey); } }, [user?.pubkey]); // Effect to fetch initial events when user is authenticated useEffect(() => { if (user?.pubkey) { fetchEvents(true); // true = reset // Also fetch all events if user is admin if (user.permission === 'admin') { fetchAllEvents(true); // true = reset } } }, [user?.pubkey, user?.permission]); function relayURL() { try { return window.location.protocol.replace('http', 'ws') + '//' + window.location.host; } catch (_) { return 'ws://localhost:3333'; } } async function checkStatus() { try { const response = await fetch('/api/auth/status'); const data = await response.json(); if (data.authenticated && data.pubkey) { // Fetch permission first, then set user and profile try { const permResponse = await fetch(`/api/permissions/${data.pubkey}`); const permData = await permResponse.json(); if (permData && permData.permission) { const fullUser = { pubkey: data.pubkey, permission: permData.permission }; setUser(fullUser); updateStatus(`Already authenticated as: ${data.pubkey.slice(0, 16)}...`, 'success'); // Fire and forget profile fetch fetchUserProfile(data.pubkey); } } catch (_) { // ignore permission fetch errors } } } catch (error) { // Ignore errors for status check } } function updateStatus(message, type = 'info') { setStatus(message); setStatusType(type); } function statusClassName() { const base = 'mt-5 mb-5 p-3 rounded'; // Return theme-appropriate status classes switch (statusType) { case 'success': return base + ' ' + getThemeClasses('bg-green-100 text-green-800', 'bg-green-900 text-green-100'); case 'error': return base + ' ' + getThemeClasses('bg-red-100 text-red-800', 'bg-red-900 text-red-100'); case 'info': default: return base + ' ' + getThemeClasses('bg-cyan-100 text-cyan-800', 'bg-cyan-900 text-cyan-100'); } } async function getChallenge() { try { const response = await fetch('/api/auth/challenge'); const data = await response.json(); return data.challenge; } catch (error) { updateStatus('Failed to get authentication challenge: ' + error.message, 'error'); throw error; } } async function loginWithExtension() { if (!window.nostr) { updateStatus('No Nostr extension found. Please install a NIP-07 compatible extension like nos2x or Alby.', 'error'); return; } try { updateStatus('Connecting to extension...', 'info'); // Get public key from extension const pubkey = await window.nostr.getPublicKey(); // Get challenge from server const challenge = await getChallenge(); // Create authentication event const authEvent = { kind: 22242, created_at: Math.floor(Date.now() / 1000), tags: [ ['relay', relayURL()], ['challenge', challenge] ], content: '' }; // Sign the event with extension const signedEvent = await window.nostr.signEvent(authEvent); // Send to server await authenticate(signedEvent); } catch (error) { updateStatus('Extension login failed: ' + error.message, 'error'); } } async function fetchKind0FromRelay(pubkeyHex, timeoutMs = 4000) { return new Promise((resolve) => { let resolved = false; let events = []; let ws; let reqSent = false; try { ws = new WebSocket(relayURL()); } catch (e) { resolve(null); return; } const subId = 'profile-' + Math.random().toString(36).slice(2); const timer = setTimeout(() => { if (ws && ws.readyState === 1) { try { ws.close(); } catch (_) {} } if (!resolved) { resolved = true; resolve(null); } }, timeoutMs); const sendRequest = () => { if (!reqSent && ws && ws.readyState === 1) { try { const req = [ 'REQ', subId, { kinds: [0], authors: [pubkeyHex] } ]; ws.send(JSON.stringify(req)); reqSent = true; } catch (_) {} } }; ws.onopen = () => { sendRequest(); }; ws.onmessage = async (msg) => { try { const data = JSON.parse(msg.data); const type = data[0]; if (type === 'AUTH') { // Handle authentication challenge const challenge = data[1]; if (!window.nostr) { clearTimeout(timer); if (!resolved) { resolved = true; resolve(null); } return; } try { // Create authentication event const authEvent = { kind: 22242, created_at: Math.floor(Date.now() / 1000), tags: [ ['relay', relayURL()], ['challenge', challenge] ], content: '' }; // Sign the auth event with extension const signedAuthEvent = await window.nostr.signEvent(authEvent); // Send AUTH response const authMessage = ['AUTH', signedAuthEvent]; console.log('DEBUG: Sending AUTH response for profile fetch challenge:', challenge.slice(0, 16) + '...'); ws.send(JSON.stringify(authMessage)); } catch (authError) { clearTimeout(timer); if (!resolved) { resolved = true; resolve(null); } } } else if (type === 'EVENT' && data[1] === subId) { const event = data[2]; if (event && event.kind === 0 && event.content) { events.push(event); } } else if (type === 'EOSE' && data[1] === subId) { try { ws.send(JSON.stringify(['CLOSE', subId])); } catch (_) {} try { ws.close(); } catch (_) {} clearTimeout(timer); if (!resolved) { resolved = true; if (events.length) { const latest = events.reduce((a, b) => (a.created_at > b.created_at ? a : b)); try { const meta = JSON.parse(latest.content); resolve(meta || null); } catch (_) { resolve(null); } } else { resolve(null); } } } else if (type === 'CLOSED' && data[1] === subId) { const message = data[2] || ''; if (message.includes('auth-required') && !reqSent) { // Wait for AUTH challenge, request will be sent after authentication return; } // Subscription was closed, finish processing clearTimeout(timer); if (!resolved) { resolved = true; if (events.length) { const latest = events.reduce((a, b) => (a.created_at > b.created_at ? a : b)); try { const meta = JSON.parse(latest.content); resolve(meta || null); } catch (_) { resolve(null); } } else { resolve(null); } } } else if (type === 'OK' && data[1] && data[1].length === 64 && !reqSent) { // This might be an OK response to our AUTH event // Send the original request now that we're authenticated sendRequest(); } } catch (_) { // ignore malformed messages } }; ws.onerror = () => { try { ws.close(); } catch (_) {} clearTimeout(timer); if (!resolved) { resolved = true; resolve(null); } }; ws.onclose = () => { clearTimeout(timer); if (!resolved) { resolved = true; if (events.length) { const latest = events.reduce((a, b) => (a.created_at > b.created_at ? a : b)); try { const meta = JSON.parse(latest.content); resolve(meta || null); } catch (_) { resolve(null); } } else { resolve(null); } } }; }); } // Function to fetch user profile metadata (kind 0) async function fetchUserProfile(pubkeyHex) { try { // Create a simple placeholder with the pubkey const placeholderProfile = { name: `user:${pubkeyHex.slice(0, 8)}`, about: 'No profile data available' }; // Always set the placeholder profile first setProfileData(placeholderProfile); // First, try to get profile kind:0 from the relay itself let relayMetadata = null; try { relayMetadata = await fetchKind0FromRelay(pubkeyHex); } catch (_) {} if (relayMetadata) { const parsed = typeof relayMetadata === 'string' ? JSON.parse(relayMetadata) : relayMetadata; setProfileData({ name: parsed.name || placeholderProfile.name, display_name: parsed.display_name, picture: parsed.picture, banner: parsed.banner, about: parsed.about || placeholderProfile.about }); return parsed; } // Fallback: try extension metadata if available if (window.nostr && window.nostr.getPublicKey) { try { if (window.nostr.getUserMetadata) { const metadata = await window.nostr.getUserMetadata(); if (metadata) { try { const parsedMetadata = typeof metadata === 'string' ? JSON.parse(metadata) : metadata; setProfileData({ name: parsedMetadata.name || placeholderProfile.name, display_name: parsedMetadata.display_name, picture: parsedMetadata.picture, banner: parsedMetadata.banner, about: parsedMetadata.about || placeholderProfile.about }); return parsedMetadata; } catch (parseError) { console.log('Error parsing user metadata:', parseError); } } } } catch (nostrError) { console.log('Could not get profile from extension:', nostrError); } } return placeholderProfile; } catch (error) { console.error('Error handling profile data:', error); return null; } } async function authenticate(signedEvent) { try { const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(signedEvent) }); const result = await response.json(); if (result.success) { setUser(result.pubkey); updateStatus('Successfully authenticated as: ' + result.pubkey.slice(0, 16) + '...', 'success'); // Check permissions after login const permResponse = await fetch(`/api/permissions/${result.pubkey}`); const permData = await permResponse.json(); if (permData && permData.permission) { setUser({pubkey: result.pubkey, permission: permData.permission}); // Fetch user profile data await fetchUserProfile(result.pubkey); } } else { updateStatus('Authentication failed: ' + result.error, 'error'); } } catch (error) { updateStatus('Authentication request failed: ' + error.message, 'error'); } } async function logout() { try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (_) {} setUser(null); setProfileData(null); // Clear events state setEvents([]); setEventsOffset(0); setEventsHasMore(true); setExpandedEventId(null); // Clear all events state setAllEvents([]); setAllEventsOffset(0); setAllEventsHasMore(true); setExpandedAllEventId(null); updateStatus('Logged out', 'info'); } // WebSocket-based function to fetch events from relay async function fetchEventsFromRelay(reset = false, limit = 50, timeoutMs = 10000) { if (!user?.pubkey) return; if (eventsLoading) return; if (!reset && !eventsHasMore) return; console.log('DEBUG: fetchEventsFromRelay called, reset:', reset, 'offset:', eventsOffset); setEventsLoading(true); return new Promise((resolve) => { let resolved = false; let receivedEvents = []; let ws; let reqSent = false; try { ws = new WebSocket(relayURL()); } catch (e) { console.error('Failed to create WebSocket:', e); setEventsLoading(false); resolve(); return; } const subId = 'events-' + Math.random().toString(36).slice(2); const timer = setTimeout(() => { if (ws && ws.readyState === 1) { try { ws.close(); } catch (_) {} } if (!resolved) { resolved = true; console.log('DEBUG: WebSocket timeout, received events:', receivedEvents.length); processEventsResponse(receivedEvents, reset); resolve(); } }, timeoutMs); const sendRequest = () => { if (!reqSent && ws && ws.readyState === 1) { try { // Request events from the authenticated user const req = [ 'REQ', subId, { authors: [user.pubkey] } ]; console.log('DEBUG: Sending WebSocket request:', req); ws.send(JSON.stringify(req)); reqSent = true; } catch (e) { console.error('Failed to send WebSocket request:', e); } } }; ws.onopen = () => { sendRequest(); }; ws.onmessage = async (msg) => { try { const data = JSON.parse(msg.data); const type = data[0]; console.log('DEBUG: WebSocket message:', type, data.length > 2 ? 'with event' : ''); if (type === 'AUTH') { // Handle authentication challenge const challenge = data[1]; if (!window.nostr) { console.error('Authentication required but no Nostr extension found'); clearTimeout(timer); if (!resolved) { resolved = true; processEventsResponse(receivedEvents, reset); resolve(); } return; } try { // Create authentication event const authEvent = { kind: 22242, created_at: Math.floor(Date.now() / 1000), tags: [ ['relay', relayURL()], ['challenge', challenge] ], content: '' }; // Sign the auth event with extension const signedAuthEvent = await window.nostr.signEvent(authEvent); // Send AUTH response const authMessage = ['AUTH', signedAuthEvent]; console.log('DEBUG: Sending AUTH response for events fetch challenge:', challenge.slice(0, 16) + '...'); ws.send(JSON.stringify(authMessage)); } catch (authError) { console.error('Failed to authenticate:', authError); clearTimeout(timer); if (!resolved) { resolved = true; processEventsResponse(receivedEvents, reset); resolve(); } } } else if (type === 'EVENT' && data[1] === subId) { const event = data[2]; if (event) { // Convert to the expected format const formattedEvent = { id: event.id, kind: event.kind, created_at: event.created_at, content: event.content || '', raw_json: JSON.stringify(event) }; receivedEvents.push(formattedEvent); } } else if (type === 'EOSE' && data[1] === subId) { try { ws.send(JSON.stringify(['CLOSE', subId])); } catch (_) {} try { ws.close(); } catch (_) {} clearTimeout(timer); if (!resolved) { resolved = true; console.log('DEBUG: EOSE received, processing events:', receivedEvents.length); processEventsResponse(receivedEvents, reset); resolve(); } } else if (type === 'CLOSED' && data[1] === subId) { const message = data[2] || ''; console.log('DEBUG: Subscription closed:', message); if (message.includes('auth-required') && !reqSent) { // Wait for AUTH challenge, request will be sent after authentication return; } // Subscription was closed, finish processing clearTimeout(timer); if (!resolved) { resolved = true; processEventsResponse(receivedEvents, reset); resolve(); } } else if (type === 'OK' && data[1] && data[1].length === 64 && !reqSent) { // This might be an OK response to our AUTH event // Send the original request now that we're authenticated sendRequest(); } } catch (e) { console.error('Error parsing WebSocket message:', e); } }; ws.onerror = (error) => { console.error('WebSocket error:', error); try { ws.close(); } catch (_) {} clearTimeout(timer); if (!resolved) { resolved = true; processEventsResponse(receivedEvents, reset); resolve(); } }; ws.onclose = () => { clearTimeout(timer); if (!resolved) { resolved = true; console.log('DEBUG: WebSocket closed, processing events:', receivedEvents.length); processEventsResponse(receivedEvents, reset); resolve(); } }; }); } // Helper function to filter out deleted events and process delete events function filterDeletedEvents(events) { // Find all delete events (kind 5) const deleteEvents = events.filter(event => event.kind === 5); // Extract the event IDs that have been deleted const deletedEventIds = new Set(); deleteEvents.forEach(deleteEvent => { try { const originalEvent = JSON.parse(deleteEvent.raw_json); // Look for 'e' tags in the delete event that reference deleted events if (originalEvent.tags) { originalEvent.tags.forEach(tag => { if (tag[0] === 'e' && tag[1]) { deletedEventIds.add(tag[1]); } }); } } catch (error) { console.error('Error parsing delete event:', error); } }); // Filter out events that have been deleted, but keep delete events themselves const filteredEvents = events.filter(event => { // Always show delete events (kind 5) if (event.kind === 5) { return true; } // Hide events that have been deleted return !deletedEventIds.has(event.id); }); console.log('DEBUG: Filtered events - original:', events.length, 'filtered:', filteredEvents.length, 'deleted IDs:', deletedEventIds.size); return filteredEvents; } function processEventsResponse(receivedEvents, reset) { try { // Filter out deleted events and ensure delete events are included const filteredEvents = filterDeletedEvents(receivedEvents); // Sort events by created_at in descending order (newest first) const sortedEvents = filteredEvents.sort((a, b) => b.created_at - a.created_at); // Apply pagination manually since we get all events from WebSocket const currentOffset = reset ? 0 : eventsOffset; const limit = 50; const paginatedEvents = sortedEvents.slice(currentOffset, currentOffset + limit); console.log('DEBUG: Processing events - total:', sortedEvents.length, 'paginated:', paginatedEvents.length, 'offset:', currentOffset); if (reset) { setEvents(paginatedEvents); setEventsOffset(paginatedEvents.length); } else { setEvents(prev => [...prev, ...paginatedEvents]); setEventsOffset(prev => prev + paginatedEvents.length); } // Check if there are more events available setEventsHasMore(currentOffset + paginatedEvents.length < sortedEvents.length); console.log('DEBUG: Events updated, displayed count:', paginatedEvents.length, 'has more:', currentOffset + paginatedEvents.length < sortedEvents.length); } catch (error) { console.error('Error processing events response:', error); } finally { setEventsLoading(false); } } // WebSocket-based function to fetch all events from relay (admin) async function fetchAllEventsFromRelay(reset = false, limit = 50, timeoutMs = 10000) { if (!user?.pubkey || user.permission !== 'admin') return; if (allEventsLoading) return; if (!reset && !allEventsHasMore) return; console.log('DEBUG: fetchAllEventsFromRelay called, reset:', reset, 'offset:', allEventsOffset); setAllEventsLoading(true); return new Promise((resolve) => { let resolved = false; let receivedEvents = []; let ws; let reqSent = false; try { ws = new WebSocket(relayURL()); } catch (e) { console.error('Failed to create WebSocket:', e); setAllEventsLoading(false); resolve(); return; } const subId = 'allevents-' + Math.random().toString(36).slice(2); const timer = setTimeout(() => { if (ws && ws.readyState === 1) { try { ws.close(); } catch (_) {} } if (!resolved) { resolved = true; console.log('DEBUG: WebSocket timeout, received all events:', receivedEvents.length); processAllEventsResponse(receivedEvents, reset); resolve(); } }, timeoutMs); const sendRequest = () => { if (!reqSent && ws && ws.readyState === 1) { try { // Request all events (no authors filter for admin) const req = [ 'REQ', subId, {} ]; console.log('DEBUG: Sending WebSocket request for all events:', req); ws.send(JSON.stringify(req)); reqSent = true; } catch (e) { console.error('Failed to send WebSocket request:', e); } } }; ws.onopen = () => { sendRequest(); }; ws.onmessage = async (msg) => { try { const data = JSON.parse(msg.data); const type = data[0]; console.log('DEBUG: WebSocket message:', type, data.length > 2 ? 'with event' : ''); if (type === 'AUTH') { // Handle authentication challenge const challenge = data[1]; if (!window.nostr) { console.error('Authentication required but no Nostr extension found'); clearTimeout(timer); if (!resolved) { resolved = true; processAllEventsResponse(receivedEvents, reset); resolve(); } return; } try { // Create authentication event const authEvent = { kind: 22242, created_at: Math.floor(Date.now() / 1000), tags: [ ['relay', relayURL()], ['challenge', challenge] ], content: '' }; // Sign the auth event with extension const signedAuthEvent = await window.nostr.signEvent(authEvent); // Send AUTH response const authMessage = ['AUTH', signedAuthEvent]; console.log('DEBUG: Sending AUTH response for all events fetch challenge:', challenge.slice(0, 16) + '...'); ws.send(JSON.stringify(authMessage)); } catch (authError) { console.error('Failed to authenticate:', authError); clearTimeout(timer); if (!resolved) { resolved = true; processAllEventsResponse(receivedEvents, reset); resolve(); } } } else if (type === 'EVENT' && data[1] === subId) { const event = data[2]; if (event) { // Convert to the expected format const formattedEvent = { id: event.id, kind: event.kind, created_at: event.created_at, content: event.content || '', author: event.pubkey || '', raw_json: JSON.stringify(event) }; receivedEvents.push(formattedEvent); } } else if (type === 'EOSE' && data[1] === subId) { try { ws.send(JSON.stringify(['CLOSE', subId])); } catch (_) {} try { ws.close(); } catch (_) {} clearTimeout(timer); if (!resolved) { resolved = true; console.log('DEBUG: EOSE received, processing all events:', receivedEvents.length); processAllEventsResponse(receivedEvents, reset); resolve(); } } else if (type === 'CLOSED' && data[1] === subId) { const message = data[2] || ''; console.log('DEBUG: All events subscription closed:', message); if (message.includes('auth-required') && !reqSent) { // Wait for AUTH challenge, request will be sent after authentication return; } // Subscription was closed, finish processing clearTimeout(timer); if (!resolved) { resolved = true; processAllEventsResponse(receivedEvents, reset); resolve(); } } else if (type === 'OK' && data[1] && data[1].length === 64 && !reqSent) { // This might be an OK response to our AUTH event // Send the original request now that we're authenticated sendRequest(); } } catch (e) { console.error('Error parsing WebSocket message:', e); } }; ws.onerror = (error) => { console.error('WebSocket error:', error); try { ws.close(); } catch (_) {} clearTimeout(timer); if (!resolved) { resolved = true; processAllEventsResponse(receivedEvents, reset); resolve(); } }; ws.onclose = () => { clearTimeout(timer); if (!resolved) { resolved = true; console.log('DEBUG: WebSocket closed, processing all events:', receivedEvents.length); processAllEventsResponse(receivedEvents, reset); resolve(); } }; }); } function processAllEventsResponse(receivedEvents, reset) { try { // Filter out deleted events and ensure delete events are included const filteredEvents = filterDeletedEvents(receivedEvents); // Sort events by created_at in descending order (newest first) const sortedEvents = filteredEvents.sort((a, b) => b.created_at - a.created_at); // Apply pagination manually since we get all events from WebSocket const currentOffset = reset ? 0 : allEventsOffset; const limit = 50; const paginatedEvents = sortedEvents.slice(currentOffset, currentOffset + limit); console.log('DEBUG: Processing all events - total:', sortedEvents.length, 'paginated:', paginatedEvents.length, 'offset:', currentOffset); if (reset) { setAllEvents(paginatedEvents); setAllEventsOffset(paginatedEvents.length); } else { setAllEvents(prev => [...prev, ...paginatedEvents]); setAllEventsOffset(prev => prev + paginatedEvents.length); } // Check if there are more events available setAllEventsHasMore(currentOffset + paginatedEvents.length < sortedEvents.length); // Fetch profiles for the new events fetchProfilesForEvents(paginatedEvents); console.log('DEBUG: All events updated, displayed count:', paginatedEvents.length, 'has more:', currentOffset + paginatedEvents.length < sortedEvents.length); } catch (error) { console.error('Error processing all events response:', error); } finally { setAllEventsLoading(false); } } // Events log functions async function fetchEvents(reset = false) { await fetchEventsFromRelay(reset); // Also fetch user's own profile for My Events Log if (user?.pubkey) { await fetchAndCacheProfile(user.pubkey); } } async function fetchAllEvents(reset = false) { await fetchAllEventsFromRelay(reset); } function toggleEventExpansion(eventId) { setExpandedEventId(current => current === eventId ? null : eventId); } function toggleAllEventExpansion(eventId) { setExpandedAllEventId(current => current === eventId ? null : eventId); } function copyEventJSON(eventJSON) { try { navigator.clipboard.writeText(eventJSON); } catch (error) { // Fallback for older browsers const textArea = document.createElement('textarea'); textArea.value = eventJSON; document.body.appendChild(textArea); textArea.select(); document.execCommand('copy'); document.body.removeChild(textArea); } } function truncateContent(content, maxLength = 100) { if (!content || content.length <= maxLength) return content; return content.substring(0, maxLength) + '...'; } function formatTimestamp(timestamp) { const date = new Date(timestamp * 1000); return date.toLocaleString(); } // Function to delete an event by publishing a kind 5 delete event async function deleteEvent(eventId, eventRawJson, eventAuthor = null) { if (!user?.pubkey) { updateStatus('Must be logged in to delete events', 'error'); return; } if (!window.nostr) { updateStatus('Nostr extension not found', 'error'); return; } try { // Parse the original event to get its details const originalEvent = JSON.parse(eventRawJson); // Permission check: users can only delete their own events, admins can delete any event const isOwnEvent = originalEvent.pubkey === user.pubkey; const isAdmin = user.permission === 'admin'; if (!isOwnEvent && !isAdmin) { updateStatus('You can only delete your own events', 'error'); return; } // Construct the delete event (kind 5) according to NIP-09 const deleteEventTemplate = { kind: 5, created_at: Math.floor(Date.now() / 1000), tags: [ ['e', originalEvent.id], ['k', originalEvent.kind.toString()] ], content: isOwnEvent ? 'Deleted by author' : 'Deleted by admin' }; // Sign the delete event with extension const signedDeleteEvent = await window.nostr.signEvent(deleteEventTemplate); // Publish the delete event to the relay via WebSocket await publishEventToRelay(signedDeleteEvent); updateStatus('Delete event published successfully', 'success'); // Refresh the event lists to reflect the deletion if (isOwnEvent) { fetchEvents(true); // Refresh My Events } if (isAdmin) { fetchAllEvents(true); // Refresh All Events } } catch (error) { updateStatus('Failed to delete event: ' + error.message, 'error'); } } // Function to publish an event to the relay via WebSocket async function publishEventToRelay(event, timeoutMs = 5000) { return new Promise((resolve, reject) => { let resolved = false; let ws; let eventSent = false; // Track auth flow so we can respond and then retry the original event let awaitingAuth = false; let authSent = false; let resentAfterAuth = false; try { ws = new WebSocket(relayURL()); } catch (e) { reject(new Error('Failed to create WebSocket connection')); return; } const timer = setTimeout(() => { console.log('DEBUG: Timeout occurred - eventSent:', eventSent, 'resolved:', resolved, 'ws.readyState:', ws?.readyState); if (ws && ws.readyState === 1) { try { ws.close(); } catch (_) {} } if (!resolved) { resolved = true; reject(new Error('Timeout publishing event - no status received')); } }, timeoutMs); const sendEvent = () => { if (!eventSent && ws && ws.readyState === 1) { try { const eventMessage = ['EVENT', event]; console.log('DEBUG: Sending event to relay:', event.id, 'kind:', event.kind); ws.send(JSON.stringify(eventMessage)); eventSent = true; } catch (e) { clearTimeout(timer); if (!resolved) { resolved = true; reject(new Error('Failed to send event: ' + e.message)); } } } }; ws.onopen = () => { sendEvent(); }; ws.onmessage = async (msg) => { try { const data = JSON.parse(msg.data); const type = data[0]; // Debug logging to understand what the relay is sending console.log('DEBUG: publishEventToRelay received message:', data); if (type === 'NOTICE') { const message = data[1] || ''; // Some relays announce auth requirement via NOTICE if (/auth/i.test(message)) { console.log('DEBUG: Relay NOTICE indicates auth required'); awaitingAuth = true; } return; } if (type === 'AUTH') { // Handle authentication challenge const challenge = data[1]; if (!window.nostr) { clearTimeout(timer); if (!resolved) { resolved = true; reject(new Error('Authentication required but no Nostr extension found')); } return; } try { // Create authentication event const authEvent = { kind: 22242, created_at: Math.floor(Date.now() / 1000), tags: [ ['relay', relayURL()], ['challenge', challenge] ], content: '' }; // Sign the auth event with extension const signedAuthEvent = await window.nostr.signEvent(authEvent); // Send AUTH response const authMessage = ['AUTH', signedAuthEvent]; ws.send(JSON.stringify(authMessage)); authSent = true; // After sending AUTH, resend the original event if it was previously rejected/blocked by auth if (awaitingAuth && !resentAfterAuth) { console.log('DEBUG: AUTH sent, resending original event'); // allow sendEvent to send again eventSent = false; resentAfterAuth = true; sendEvent(); } } catch (authError) { clearTimeout(timer); if (!resolved) { resolved = true; reject(new Error('Failed to authenticate: ' + authError.message)); } } } else if (type === 'OK') { const eventId = data[1]; const accepted = data[2]; const message = data[3] || ''; console.log('DEBUG: OK message - eventId:', eventId, 'expected:', event.id, 'match:', eventId === event.id); if (eventId === event.id) { if (accepted) { clearTimeout(timer); try { ws.close(); } catch (_) {} if (!resolved) { resolved = true; resolve(); } } else { // If auth is required, wait for AUTH flow then resend if (/auth/i.test(message)) { console.log('DEBUG: OK rejection indicates auth required, waiting for AUTH challenge'); awaitingAuth = true; return; // don't resolve/reject yet, wait for AUTH } clearTimeout(timer); try { ws.close(); } catch (_) {} if (!resolved) { resolved = true; reject(new Error('Event rejected: ' + message)); } } } else { // Some relays may send an OK related to AUTH or other events if (authSent && awaitingAuth && !resentAfterAuth && accepted) { console.log('DEBUG: OK after AUTH, resending original event'); eventSent = false; resentAfterAuth = true; sendEvent(); } } } } catch (e) { // Ignore malformed messages } }; ws.onerror = (error) => { clearTimeout(timer); try { ws.close(); } catch (_) {} if (!resolved) { resolved = true; reject(new Error('WebSocket error')); } }; ws.onclose = () => { clearTimeout(timer); if (!resolved) { resolved = true; reject(new Error('WebSocket connection closed')); } }; }); } // Section revealer functions function toggleSection(sectionKey) { setExpandedSections(prev => ({ ...prev, [sectionKey]: !prev[sectionKey] })); } function handleImportButton() { try { fileInputRef?.current?.click(); } catch (_) {} } async function handleImportChange(e) { const file = e?.target?.files && e.target.files[0]; if (!file) return; try { updateStatus('Uploading import file...', 'info'); const fd = new FormData(); fd.append('file', file); const res = await fetch('/api/import', { method: 'POST', body: fd }); if (res.ok) { updateStatus('Import started. Processing will continue in the background.', 'success'); } else { const txt = await res.text(); updateStatus('Import failed: ' + txt, 'error'); } } catch (err) { updateStatus('Import failed: ' + (err?.message || String(err)), 'error'); } finally { // reset input so selecting the same file again works if (e && e.target) e.target.value = ''; } } // ========================= // Export Specific Pubkeys UI state and handlers (admin) // ========================= const [exportPubkeys, setExportPubkeys] = useState([{ value: '' }]); function isHex64(str) { if (!str) return false; const s = String(str).trim(); return /^[0-9a-fA-F]{64}$/.test(s); } function normalizeHex(str) { return String(str || '').trim(); } function addExportPubkeyField() { // Add new field at the end of the list so it appears downwards setExportPubkeys((arr) => [...arr, { value: '' }]); } function removeExportPubkeyField(idx) { setExportPubkeys((arr) => arr.filter((_, i) => i !== idx)); } function changeExportPubkey(idx, val) { const v = normalizeHex(val); setExportPubkeys((arr) => arr.map((item, i) => (i === idx ? { value: v } : item))); } function validExportPubkeys() { return exportPubkeys .map((p) => normalizeHex(p.value)) .filter((v) => v.length > 0 && isHex64(v)); } function canExportSpecific() { // Enable only if every opened field is non-empty and a valid 64-char hex if (!exportPubkeys || exportPubkeys.length === 0) return false; return exportPubkeys.every((p) => { const v = normalizeHex(p.value); return v.length === 64 && isHex64(v); }); } function handleExportSpecific() { const vals = validExportPubkeys(); if (!vals.length) return; const qs = vals.map((v) => `pubkey=${encodeURIComponent(v)}`).join('&'); try { window.location.href = `/api/export?${qs}`; } catch (_) {} } // Theme utility functions for conditional styling function getThemeClasses(lightClass, darkClass) { return isDarkMode ? darkClass : lightClass; } // Get background color class for container panels function getPanelBgClass() { return getThemeClasses('bg-gray-200', 'bg-gray-800'); } // Get text color class for standard text function getTextClass() { return getThemeClasses('text-gray-700', 'text-gray-300'); } // Get background color for buttons function getButtonBgClass() { return getThemeClasses('bg-gray-100', 'bg-gray-700'); } // Get text color for buttons function getButtonTextClass() { return getThemeClasses('text-gray-500', 'text-gray-300'); } // Get hover classes for buttons function getButtonHoverClass() { return getThemeClasses('hover:text-gray-800', 'hover:text-gray-100'); } // Prevent UI flash: wait until we checked auth status if (checkingAuth) { return null; } return (
here you can configure all the things
Download your own events as line-delimited JSON (JSONL/NDJSON). Only events you authored will be included.
Download all stored events as line-delimited JSON (JSONL/NDJSON). This may take a while on large databases.
Enter one or more author pubkeys (64-character hex). Only valid entries will be exported.
{/* Right: controls (buttons stacked vertically + list below) */}Upload events in line-delimited JSON (JSONL/NDJSON) to import into the database.
View all your events in reverse chronological order. Click on any event to view its raw JSON.
{JSON.stringify(JSON.parse(event.raw_json), null, 2)}
View all events from all users in reverse chronological order. Click on any event to view its raw JSON.
{JSON.stringify(JSON.parse(event.raw_json), null, 2)}
{
// fallback to repo docs image if public asset missing
e.currentTarget.onerror = null;
e.currentTarget.src = "/docs/orly.png";
}}
/>
Authenticate to this Nostr relay using your browser extension.