parent
01ee4f9f51
commit
6da0b87fba
client
@ -37,10 +37,10 @@
|
||||
</div>
|
||||
<div id="billboardRelayStatus" class="relay-status">Not connected</div>
|
||||
</div>
|
||||
|
||||
<div class="billboard-actions">
|
||||
<button id="createBillboardBtn" class="primary-button">+ Add New Billboard</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="billboard-container">
|
||||
<div id="billboardContent" class="billboard-content">
|
||||
|
@ -2,28 +2,55 @@
|
||||
* auth-manager.ts
|
||||
* Centralized authentication manager for the entire application.
|
||||
* Prevents network requests until the user is properly authenticated.
|
||||
*
|
||||
* Updated to use the new AuthenticationService for more robust authentication.
|
||||
*/
|
||||
|
||||
// Global authentication state
|
||||
let userAuthenticated = false;
|
||||
import { AuthenticationService } from './services/AuthenticationService';
|
||||
|
||||
// Queue for operations that should run after authentication
|
||||
const postAuthQueue: Array<() => void> = [];
|
||||
// Create singleton instance of AuthenticationService
|
||||
const authService = new AuthenticationService();
|
||||
|
||||
// For backward compatibility
|
||||
let userAuthenticated = false;
|
||||
|
||||
// Event name for authentication state changes
|
||||
const AUTH_STATE_CHANGED_EVENT = 'auth-state-changed';
|
||||
|
||||
// Direct listeners registry for auth state changes
|
||||
type AuthStateListener = (authenticated: boolean, pubkey?: string) => void;
|
||||
const authStateListeners: AuthStateListener[] = [];
|
||||
|
||||
/**
|
||||
* Notify all direct listeners of auth state change
|
||||
*
|
||||
* @param authenticated Whether the user is authenticated
|
||||
* @param pubkey The user's public key (if authenticated)
|
||||
*/
|
||||
function notifyAuthStateListeners(authenticated: boolean, pubkey?: string): void {
|
||||
for (const listener of authStateListeners) {
|
||||
try {
|
||||
listener(authenticated, pubkey);
|
||||
} catch (error) {
|
||||
console.error('Error in auth state change listener:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queue for operations that should run after authentication
|
||||
const postAuthQueue: Array<() => void> = [];
|
||||
|
||||
/**
|
||||
* Check if the user is currently authenticated
|
||||
*/
|
||||
export function isAuthenticated(): boolean {
|
||||
// Check localStorage first in case auth state was set in another page
|
||||
const savedPubkey = localStorage.getItem('userPublicKey');
|
||||
if (savedPubkey && !userAuthenticated) {
|
||||
userAuthenticated = true;
|
||||
}
|
||||
// Use the AuthenticationService
|
||||
const result = authService.isAuthenticated();
|
||||
|
||||
return userAuthenticated;
|
||||
// Update the backward-compatible flag
|
||||
userAuthenticated = result;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -33,11 +60,20 @@ export function setAuthenticated(authenticated: boolean, pubkey?: string): void
|
||||
const previousState = userAuthenticated;
|
||||
userAuthenticated = authenticated;
|
||||
|
||||
// Update localStorage if a pubkey is provided
|
||||
if (authenticated && pubkey) {
|
||||
localStorage.setItem('userPublicKey', pubkey);
|
||||
// For now, just store the pubkey in the AuthenticationService without full authentication
|
||||
// This maintains backward compatibility while allowing us to use the newer service
|
||||
const currentSession = authService.getCurrentSession();
|
||||
|
||||
// If there's no active session, we need to create one
|
||||
if (!currentSession) {
|
||||
// This will trigger a full authentication flow when the user next performs an action
|
||||
// requiring signature verification
|
||||
localStorage.setItem('userPublicKey', pubkey);
|
||||
}
|
||||
} else if (!authenticated) {
|
||||
localStorage.removeItem('userPublicKey');
|
||||
// Log out through the AuthenticationService
|
||||
authService.logout();
|
||||
}
|
||||
|
||||
// Execute queued operations if becoming authenticated
|
||||
@ -45,14 +81,8 @@ export function setAuthenticated(authenticated: boolean, pubkey?: string): void
|
||||
executePostAuthQueue();
|
||||
}
|
||||
|
||||
// Dispatch an event so other parts of the app can react
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(AUTH_STATE_CHANGED_EVENT, {
|
||||
detail: { authenticated, pubkey }
|
||||
})
|
||||
);
|
||||
}
|
||||
// Notify listeners of state change
|
||||
notifyAuthStateListeners(authenticated, pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -91,8 +121,13 @@ function executePostAuthQueue(): void {
|
||||
|
||||
/**
|
||||
* Listen for authentication state changes
|
||||
* Supports both direct callback registration and CustomEvent listeners
|
||||
*/
|
||||
export function onAuthStateChanged(callback: (authenticated: boolean, pubkey?: string) => void): () => void {
|
||||
export function onAuthStateChanged(callback: AuthStateListener): () => void {
|
||||
// Add to direct listeners
|
||||
authStateListeners.push(callback);
|
||||
|
||||
// Set up CustomEvent listener for backward compatibility
|
||||
const handler = (event: Event) => {
|
||||
const authEvent = event as CustomEvent;
|
||||
callback(
|
||||
@ -101,11 +136,22 @@ export function onAuthStateChanged(callback: (authenticated: boolean, pubkey?: s
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener(AUTH_STATE_CHANGED_EVENT, handler);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener(AUTH_STATE_CHANGED_EVENT, handler);
|
||||
}
|
||||
|
||||
// Return a function to remove the listener
|
||||
// Return a function to remove both listeners
|
||||
return () => {
|
||||
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, handler);
|
||||
// Remove from direct listeners
|
||||
const index = authStateListeners.indexOf(callback);
|
||||
if (index > -1) {
|
||||
authStateListeners.splice(index, 1);
|
||||
}
|
||||
|
||||
// Remove CustomEvent listener
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, handler);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -113,12 +159,15 @@ export function onAuthStateChanged(callback: (authenticated: boolean, pubkey?: s
|
||||
* Clear authentication state (sign out)
|
||||
*/
|
||||
export function clearAuthState(): void {
|
||||
userAuthenticated = false;
|
||||
localStorage.removeItem('userPublicKey');
|
||||
sessionStorage.removeItem('nostrLoginInitialized');
|
||||
// Use the AuthenticationService to log out
|
||||
authService.logout();
|
||||
|
||||
// Clear any temporary auth state in sessionStorage
|
||||
// Update the backward-compatible flag
|
||||
userAuthenticated = false;
|
||||
|
||||
// Clear temporary auth state in sessionStorage
|
||||
[
|
||||
'nostrLoginInitialized',
|
||||
'nostrAuthInProgress',
|
||||
'nostrLoginState',
|
||||
'nostrAuthPending',
|
||||
@ -126,15 +175,6 @@ export function clearAuthState(): void {
|
||||
].forEach(key => {
|
||||
sessionStorage.removeItem(key);
|
||||
});
|
||||
|
||||
// Notify listeners
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(AUTH_STATE_CHANGED_EVENT, {
|
||||
detail: { authenticated: false }
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -142,18 +182,18 @@ export function clearAuthState(): void {
|
||||
* This should be called on page load
|
||||
*/
|
||||
export function updateAuthStateFromStorage(): void {
|
||||
const savedPubkey = localStorage.getItem('userPublicKey');
|
||||
if (savedPubkey) {
|
||||
userAuthenticated = true;
|
||||
|
||||
// Notify listeners
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(AUTH_STATE_CHANGED_EVENT, {
|
||||
detail: { authenticated: true, pubkey: savedPubkey }
|
||||
})
|
||||
);
|
||||
// Let the AuthenticationService handle loading from storage
|
||||
// It will check its own storage first, then fall back to the old localStorage key
|
||||
|
||||
// If we're not authenticated in the new service but have a key in the old format,
|
||||
// use that to restore state
|
||||
if (!authService.isAuthenticated()) {
|
||||
const savedPubkey = localStorage.getItem('userPublicKey');
|
||||
if (savedPubkey) {
|
||||
userAuthenticated = true;
|
||||
}
|
||||
} else {
|
||||
userAuthenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,6 +201,13 @@ export function updateAuthStateFromStorage(): void {
|
||||
// This ensures the auth state is set correctly when the module is loaded
|
||||
updateAuthStateFromStorage();
|
||||
|
||||
// Initialize authentication when the page loads to ensure consistency
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
updateAuthStateFromStorage();
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for page unload to cleanup authentication state
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
@ -169,12 +216,75 @@ if (typeof window !== 'undefined') {
|
||||
|
||||
// Clear any temporary auth state in sessionStorage
|
||||
[
|
||||
'nostrAuthInProgress',
|
||||
'nostrLoginState',
|
||||
'nostrAuthPending',
|
||||
'nostrAuthInProgress',
|
||||
'nostrLoginState',
|
||||
'nostrAuthPending',
|
||||
'nostrLoginStarted'
|
||||
].forEach(key => {
|
||||
sessionStorage.removeItem(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for auth state changes from the AuthenticationService
|
||||
authService.onAuthStateChanged((authenticated, pubkey) => {
|
||||
userAuthenticated = authenticated;
|
||||
|
||||
// Dispatch the legacy event for backward compatibility
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(AUTH_STATE_CHANGED_EVENT, {
|
||||
detail: { authenticated, pubkey }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// If becoming authenticated, execute queued operations
|
||||
if (authenticated) {
|
||||
executePostAuthQueue();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the current user's public key
|
||||
*/
|
||||
export function getCurrentUserPubkey(): string | null {
|
||||
return authService.getCurrentUserPubkey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an authenticated HTTP request using NIP-98
|
||||
*
|
||||
* @param method HTTP method
|
||||
* @param url Request URL
|
||||
* @param payload Optional request payload
|
||||
* @returns Headers object with Authorization header, or null if not authenticated
|
||||
*/
|
||||
export async function createAuthHeaders(
|
||||
method: string,
|
||||
url: string,
|
||||
payload?: string
|
||||
): Promise<Headers | null> {
|
||||
if (!isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the AuthenticationService to create a NIP-98 auth event
|
||||
const authEvent = await authService.createAuthEvent(
|
||||
method as any,
|
||||
url,
|
||||
payload
|
||||
);
|
||||
|
||||
if (!authEvent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create headers from the auth event
|
||||
return authService.createAuthHeaders(authEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the AuthenticationService for direct use
|
||||
*/
|
||||
export { authService };
|
@ -20,19 +20,52 @@ let currentRelayUrl: string = '';
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('Initializing BILLBOARD page...');
|
||||
|
||||
// Make sure authentication state is fresh from localStorage
|
||||
authManager.updateAuthStateFromStorage();
|
||||
|
||||
// Initialize services
|
||||
// Create NostrService with a status update callback
|
||||
nostrService = new NostrService((message: string, className: string) => {
|
||||
updateRelayStatus(message, className);
|
||||
});
|
||||
|
||||
// Initialize UI elements
|
||||
setupUIElements();
|
||||
|
||||
// Update UI state based on authentication
|
||||
updateUIBasedOnAuth();
|
||||
|
||||
// Auto-connect to the default relay after a brief delay
|
||||
setTimeout(autoConnectToDefaultRelay, 500);
|
||||
setTimeout(autoConnectToDefaultRelay, 500);
|
||||
});
|
||||
|
||||
/**
|
||||
* Update UI elements based on authentication state
|
||||
*/
|
||||
function updateUIBasedOnAuth(): void {
|
||||
const createBillboardBtn = document.getElementById('createBillboardBtn');
|
||||
|
||||
if (createBillboardBtn) {
|
||||
// First refresh auth state from storage to ensure it's current
|
||||
authManager.updateAuthStateFromStorage();
|
||||
|
||||
// Enable or disable the create button based on auth state
|
||||
if (authManager.isAuthenticated()) {
|
||||
createBillboardBtn.removeAttribute('disabled');
|
||||
createBillboardBtn.title = "Create a new billboard";
|
||||
createBillboardBtn.classList.remove('disabled-button');
|
||||
createBillboardBtn.classList.add('primary-button');
|
||||
} else {
|
||||
// Don't actually disable the button because we want to show the login prompt
|
||||
// when clicked, but style it differently to indicate auth is required
|
||||
createBillboardBtn.removeAttribute('disabled');
|
||||
createBillboardBtn.title = "Please log in to create a billboard";
|
||||
createBillboardBtn.classList.add('disabled-button');
|
||||
createBillboardBtn.classList.remove('primary-button');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up UI elements and event listeners
|
||||
*/
|
||||
@ -56,7 +89,54 @@ function setupUIElements(): void {
|
||||
// Create billboard button
|
||||
const createBillboardBtn = document.getElementById('createBillboardBtn');
|
||||
if (createBillboardBtn) {
|
||||
createBillboardBtn.addEventListener('click', handleCreateBillboard);
|
||||
createBillboardBtn.addEventListener('click', async () => {
|
||||
// First refresh auth state from storage
|
||||
authManager.updateAuthStateFromStorage();
|
||||
|
||||
// Check if user is authenticated
|
||||
if (!authManager.isAuthenticated()) {
|
||||
if (window.nostr) {
|
||||
// If nostr extension is available, try to authenticate using our improved service
|
||||
try {
|
||||
const result = await authManager.authService.authenticate();
|
||||
if (result.success && result.session) {
|
||||
console.log(`Successfully authenticated with pubkey: ${result.session.pubkey.substring(0, 8)}...`);
|
||||
handleCreateBillboard();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during authentication:", error);
|
||||
}
|
||||
|
||||
// Fall back to basic authentication if needed
|
||||
try {
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
if (pubkey) {
|
||||
// Set authentication and proceed
|
||||
authManager.setAuthenticated(true, pubkey);
|
||||
console.log(`Direct login successful, pubkey: ${pubkey.substring(0, 8)}...`);
|
||||
handleCreateBillboard();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting pubkey from extension:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, authentication failed - show a more helpful message
|
||||
const promptResult = confirm(
|
||||
'You need to be logged in to create a billboard.\nWould you like to go to the Profile page to log in now?'
|
||||
);
|
||||
|
||||
if (promptResult) {
|
||||
// Redirect to profile page
|
||||
window.location.href = 'profile.html';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
handleCreateBillboard();
|
||||
});
|
||||
}
|
||||
|
||||
// Modal close button
|
||||
@ -234,13 +314,14 @@ async function subscribeToKind31120Events(showAllEvents: boolean = false): Promi
|
||||
};
|
||||
|
||||
// If not showing all events, filter by the current user's pubkey
|
||||
let userPubkeyHex: string | null = null;
|
||||
if (!showAllEvents) {
|
||||
// Get the logged-in user's pubkey
|
||||
const userPubkey = nostrService.getLoggedInPubkey();
|
||||
|
||||
if (userPubkey) {
|
||||
// Convert npub to hex if needed
|
||||
let userPubkeyHex = userPubkey;
|
||||
userPubkeyHex = userPubkey;
|
||||
if (userPubkey.startsWith('npub')) {
|
||||
try {
|
||||
const decoded = nostrTools.nip19.decode(userPubkey);
|
||||
@ -259,6 +340,7 @@ async function subscribeToKind31120Events(showAllEvents: boolean = false): Promi
|
||||
console.warn("No user pubkey available, showing all events despite filter setting");
|
||||
}
|
||||
}
|
||||
|
||||
// Update status message based on filter
|
||||
const statusMessage = showAllEvents
|
||||
? 'Subscribing to all server advertisements...'
|
||||
@ -267,8 +349,12 @@ async function subscribeToKind31120Events(showAllEvents: boolean = false): Promi
|
||||
updateRelayStatus(statusMessage, 'connecting');
|
||||
|
||||
try {
|
||||
// Subscribe to events
|
||||
// First, load events from cache and display them
|
||||
await loadEventsFromCache(userPubkeyHex, showAllEvents);
|
||||
|
||||
// Subscribe to events for new incoming events
|
||||
await nostrService.subscribeToEvents(filter);
|
||||
|
||||
// Set event handler
|
||||
nostrService.setEventHandler((event) => {
|
||||
if (event.kind === 31120 && event.id) {
|
||||
@ -287,11 +373,71 @@ async function subscribeToKind31120Events(showAllEvents: boolean = false): Promi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load events from cache and display them
|
||||
* @param userPubkeyHex The user's pubkey in hex format (for filtering)
|
||||
* @param showAllEvents Whether to show all events or only user's events
|
||||
*/
|
||||
async function loadEventsFromCache(userPubkeyHex: string | null, showAllEvents: boolean): Promise<void> {
|
||||
try {
|
||||
const relayUrl = nostrService.getRelayService().getActiveRelayUrl();
|
||||
if (!relayUrl) {
|
||||
console.warn('No active relay URL, cannot load cached events');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the cached events from the NostrCacheService via the NostrService
|
||||
const cachedEvents = nostrService.getCacheService().getCachedEvents(relayUrl);
|
||||
if (!cachedEvents || cachedEvents.length === 0) {
|
||||
console.log('No cached events found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${cachedEvents.length} cached events, filtering for kind 31120`);
|
||||
|
||||
// Filter for kind 31120 events
|
||||
let events = cachedEvents.filter(event => event.kind === 31120);
|
||||
|
||||
// Further filter by author if showAllEvents is false
|
||||
if (!showAllEvents && userPubkeyHex) {
|
||||
events = events.filter(event => event.pubkey === userPubkeyHex);
|
||||
console.log(`Filtered to ${events.length} events by author: ${userPubkeyHex.substring(0, 8)}...`);
|
||||
}
|
||||
|
||||
// Sort events by created_at (newest first)
|
||||
events.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
// Process each event to add it to the UI
|
||||
console.log(`Displaying ${events.length} cached events`);
|
||||
|
||||
// Clear the billboard content if we're about to add events
|
||||
if (events.length > 0) {
|
||||
const billboardContent = document.getElementById('billboardContent');
|
||||
if (billboardContent && billboardContent.querySelector('.empty-state')) {
|
||||
billboardContent.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Process each event
|
||||
for (const event of events) {
|
||||
processServerEvent(event as nostrTools.Event);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading events from cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a server advertisement event (kind 31120)
|
||||
* Open the modal for creating a new billboard
|
||||
*/
|
||||
function handleCreateBillboard(): void {
|
||||
// Check if user is logged in
|
||||
if (!authManager.isAuthenticated()) {
|
||||
alert('You need to be logged in to create a billboard. Please visit the Profile page to log in.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset form
|
||||
resetBillboardForm();
|
||||
|
||||
@ -683,6 +829,19 @@ function processServerEvent(event: nostrTools.Event): void {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a helper function to check if a specific element is in the viewport
|
||||
*/
|
||||
function isElementInViewport(el: Element): boolean {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -703,27 +862,144 @@ async function autoConnectToDefaultRelay(): Promise<void> {
|
||||
showAllServerEventsCheckbox.checked = false;
|
||||
}
|
||||
|
||||
// Update auth status from storage
|
||||
authManager.updateAuthStateFromStorage();
|
||||
|
||||
// Try to authenticate directly with extension if possible
|
||||
if (!authManager.isAuthenticated() && window.nostr) {
|
||||
try {
|
||||
console.log('Attempting direct authentication with Nostr extension...');
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
if (pubkey) {
|
||||
// Set the authenticated state
|
||||
authManager.setAuthenticated(true, pubkey);
|
||||
localStorage.setItem('userPublicKey', pubkey);
|
||||
console.log(`Direct authentication successful, pubkey: ${pubkey.substring(0, 8)}...`);
|
||||
|
||||
// Update UI to reflect authentication status
|
||||
updateUIBasedOnAuth();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error authenticating with extension:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Always attempt to load cached events, even if not authenticated
|
||||
try {
|
||||
console.log('Attempting to load cached events...');
|
||||
const relayUrl = relayUrlInput.value.trim();
|
||||
|
||||
// Initialize NostrService with the relay URL
|
||||
try {
|
||||
await nostrService.getRelayService().connectToRelay(relayUrl);
|
||||
} catch (error) {
|
||||
// If connecting fails due to auth, we'll still try to access cached events
|
||||
console.warn('Could not connect to relay, but will try to load cached events:', error);
|
||||
}
|
||||
|
||||
// Get showAllEvents checkbox state
|
||||
let showAllEvents = false;
|
||||
if (showAllServerEventsCheckbox) {
|
||||
showAllEvents = showAllServerEventsCheckbox.checked;
|
||||
}
|
||||
|
||||
// Get user pubkey for filtering
|
||||
let userPubkeyHex: string | null = null;
|
||||
if (!showAllEvents) {
|
||||
const userPubkey = nostrService.getLoggedInPubkey();
|
||||
if (userPubkey) {
|
||||
// Convert npub to hex if needed
|
||||
userPubkeyHex = userPubkey;
|
||||
if (userPubkey.startsWith('npub')) {
|
||||
try {
|
||||
const decoded = nostrTools.nip19.decode(userPubkey);
|
||||
if (decoded.type === 'npub') {
|
||||
userPubkeyHex = decoded.data as string;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error decoding npub:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to load events from cache
|
||||
await loadEventsFromCache(userPubkeyHex, showAllEvents);
|
||||
} catch (error) {
|
||||
console.error('Error loading cached events:', error);
|
||||
}
|
||||
|
||||
// Check if the user is authenticated before connecting
|
||||
if (authManager.isAuthenticated()) {
|
||||
console.log('User is authenticated, connecting to relay...');
|
||||
// Trigger connect button click
|
||||
// Trigger connect button click to establish active subscription
|
||||
const connectButton = document.getElementById('billboardConnectBtn');
|
||||
if (connectButton) {
|
||||
connectButton.click();
|
||||
}
|
||||
} else {
|
||||
console.log('User is not authenticated, showing login prompt...');
|
||||
// Show a login prompt instead of connecting
|
||||
// Show a login prompt with direct login option
|
||||
const billboardContent = document.getElementById('billboardContent');
|
||||
if (billboardContent) {
|
||||
|
||||
// Only show the login prompt if no events were loaded from cache
|
||||
if (billboardContent && billboardContent.querySelector('.empty-state')) {
|
||||
billboardContent.innerHTML = `
|
||||
<div class="auth-required-message">
|
||||
<h3>Authentication Required</h3>
|
||||
<p>You need to be logged in to view and manage billboards.</p>
|
||||
<p>Please visit the <a href="profile.html">Profile page</a> to log in with your Nostr extension.</p>
|
||||
<p>
|
||||
<button id="directLoginBtn" class="primary-button">Login with Nostr Extension</button>
|
||||
or visit the <a href="profile.html">Profile page</a> to login.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listener to the direct login button
|
||||
const directLoginBtn = document.getElementById('directLoginBtn');
|
||||
if (directLoginBtn) {
|
||||
directLoginBtn.addEventListener('click', async () => {
|
||||
if (window.nostr) {
|
||||
try {
|
||||
directLoginBtn.textContent = 'Authenticating...';
|
||||
directLoginBtn.setAttribute('disabled', 'true');
|
||||
|
||||
// Try to authenticate using our improved service
|
||||
const result = await authManager.authService.authenticate();
|
||||
if (result.success && result.session) {
|
||||
console.log(`Direct login successful using authService, pubkey: ${result.session.pubkey.substring(0, 8)}...`);
|
||||
|
||||
// Update UI and reload
|
||||
updateUIBasedOnAuth();
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to basic pubkey retrieval
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
if (pubkey) {
|
||||
authManager.setAuthenticated(true, pubkey);
|
||||
localStorage.setItem('userPublicKey', pubkey);
|
||||
console.log(`Direct login successful, pubkey: ${pubkey.substring(0, 8)}...`);
|
||||
|
||||
// Update UI and connect
|
||||
updateUIBasedOnAuth();
|
||||
|
||||
// Reload the page to properly initialize
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during direct login:', error);
|
||||
directLoginBtn.textContent = 'Login Failed - Try Again';
|
||||
directLoginBtn.removeAttribute('disabled');
|
||||
}
|
||||
} else {
|
||||
alert('No Nostr extension detected. Please install NIP-07 compatible extension like nos2x or Alby.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update the relay status
|
||||
updateRelayStatus('Authentication required', 'warning');
|
||||
}
|
||||
|
@ -58,23 +58,73 @@ function setupResponseSubscription(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create filter for KIND 21121 responses
|
||||
const filter: { kinds: number[] } = { kinds: [21121] };
|
||||
// Get all stored events to include their IDs in the filter
|
||||
const storedEvents = clientEventStore.getAllEvents();
|
||||
|
||||
// Extract event IDs to use for filtering relevant 21121 responses
|
||||
const eventIds = storedEvents.map(event => event.id);
|
||||
|
||||
// Create an enhanced filter for KIND 21121 responses
|
||||
// We'll create two separate subscriptions:
|
||||
// 1. One that matches specific events we know about
|
||||
// 2. One that catches any other 21121 events (to make sure we don't miss any)
|
||||
|
||||
// The first filter includes the '#e' tag to match responses to our specific requests
|
||||
const specificFilter: { kinds: number[], '#e': string[] } = {
|
||||
kinds: [21121],
|
||||
'#e': []
|
||||
};
|
||||
|
||||
// The second filter catches all other 21121 events
|
||||
const generalFilter = {
|
||||
kinds: [21121]
|
||||
};
|
||||
|
||||
// If we have stored events, add their IDs to the specific filter
|
||||
if (eventIds.length > 0) {
|
||||
specificFilter['#e'] = eventIds;
|
||||
console.log(`Setting up specific subscription with ${eventIds.length} stored event IDs`);
|
||||
}
|
||||
|
||||
// Get the WebSocket manager to set up the subscription
|
||||
const wsManager = relayService.getWebSocketManager();
|
||||
|
||||
// Set up the websocket subscription
|
||||
// Set up the websocket subscription with more detailed logging
|
||||
wsManager.connect(activeRelayUrl, {
|
||||
timeout: 5000,
|
||||
timeout: 10000, // Increased timeout for more reliable connection
|
||||
onOpen: (ws) => {
|
||||
// Send a REQ message to subscribe
|
||||
const reqId = `client-events-21121-sub-${Date.now()}`;
|
||||
const reqMsg = JSON.stringify(["REQ", reqId, filter]);
|
||||
ws.send(reqMsg);
|
||||
console.log(`Subscribed to KIND 21121 responses on ${activeRelayUrl}`);
|
||||
console.log(`WebSocket connection opened to ${activeRelayUrl} for 21121 subscriptions`);
|
||||
|
||||
// First send subscription for specific events we know about
|
||||
if (eventIds.length > 0) {
|
||||
const specificReqId = `client-events-21121-specific-${Date.now()}`;
|
||||
const specificReqMsg = JSON.stringify(["REQ", specificReqId, specificFilter]);
|
||||
ws.send(specificReqMsg);
|
||||
|
||||
console.log('===== SENT SPECIFIC SUBSCRIPTION REQUEST =====');
|
||||
console.log('REQ ID:', specificReqId);
|
||||
console.log('Filter:', JSON.stringify(specificFilter, null, 2));
|
||||
console.log('==============================================');
|
||||
console.log(`Subscribed to KIND 21121 responses for ${eventIds.length} specific events on ${activeRelayUrl}`);
|
||||
}
|
||||
|
||||
// Then send subscription for all other 21121 events
|
||||
const generalReqId = `client-events-21121-general-${Date.now()}`;
|
||||
const generalReqMsg = JSON.stringify(["REQ", generalReqId, generalFilter]);
|
||||
ws.send(generalReqMsg);
|
||||
|
||||
console.log('===== SENT GENERAL SUBSCRIPTION REQUEST =====');
|
||||
console.log('REQ ID:', generalReqId);
|
||||
console.log('Filter:', JSON.stringify(generalFilter, null, 2));
|
||||
console.log('=============================================');
|
||||
console.log(`Subscribed to all KIND 21121 events on ${activeRelayUrl}`);
|
||||
},
|
||||
onMessage: (data) => {
|
||||
// Log all incoming messages for debugging
|
||||
console.log('===== RECEIVED RELAY MESSAGE =====');
|
||||
console.log('Message data:', JSON.stringify(data, null, 2));
|
||||
console.log('==================================');
|
||||
|
||||
// Parse the incoming message
|
||||
try {
|
||||
// Type assertion for the received data
|
||||
@ -84,10 +134,46 @@ function setupResponseSubscription(): void {
|
||||
if (Array.isArray(nostrData) && nostrData[0] === "EVENT" && nostrData.length >= 3) {
|
||||
const receivedEvent = nostrData[2] as NostrEvent;
|
||||
|
||||
console.log('Received event kind:', receivedEvent.kind);
|
||||
|
||||
// Process only KIND 21121 events
|
||||
if (receivedEvent && receivedEvent.kind === 21121) {
|
||||
handleIncomingResponse(receivedEvent);
|
||||
console.log('Processing 21121 response event:', receivedEvent.id);
|
||||
console.log('Tags:', JSON.stringify(receivedEvent.tags, null, 2));
|
||||
|
||||
// Get the original request ID from the e tag
|
||||
const requestTag = receivedEvent.tags.find(tag => tag[0] === 'e');
|
||||
const requestId = requestTag && requestTag.length > 1 ? requestTag[1] : null;
|
||||
|
||||
if (requestId) {
|
||||
console.log(`21121 response is for request: ${requestId}`);
|
||||
|
||||
// Get the stored event to check if it exists
|
||||
const storedEvent = clientEventStore.getEvent(requestId);
|
||||
if (!storedEvent) {
|
||||
console.warn(`No matching stored event found for request ID ${requestId}`);
|
||||
// Print all stored event IDs for debugging
|
||||
const allEvents = clientEventStore.getAllEvents();
|
||||
console.log(`Currently tracking ${allEvents.length} events. IDs:`,
|
||||
allEvents.map(e => e.id).join(', '));
|
||||
}
|
||||
} else {
|
||||
console.warn(`21121 response is missing request ID in e tag`);
|
||||
}
|
||||
|
||||
const result = handleIncomingResponse(receivedEvent);
|
||||
if (result) {
|
||||
console.log(`Successfully processed 21121 response for request: ${requestId || 'unknown'}`);
|
||||
} else {
|
||||
console.warn(`Failed to process 21121 response for request: ${requestId || 'unknown'}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`Ignoring non-21121 event (kind: ${receivedEvent.kind})`);
|
||||
}
|
||||
} else if (Array.isArray(nostrData) && nostrData[0] === "EOSE") {
|
||||
console.log(`Received EOSE for subscription: ${nostrData[1]}`);
|
||||
} else {
|
||||
console.log(`Received unhandled message type: ${nostrData[0]}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing 21121 message:', error);
|
||||
@ -98,6 +184,15 @@ function setupResponseSubscription(): void {
|
||||
},
|
||||
onClose: () => {
|
||||
console.log('21121 subscription connection closed');
|
||||
|
||||
// Attempt to reconnect after a short delay with exponential backoff
|
||||
const backoffDelay = Math.floor(Math.random() * 5000) + 5000; // 5-10 seconds
|
||||
console.log(`Will attempt to reconnect in ${backoffDelay/1000} seconds...`);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('Attempting to resubscribe to 21121 events...');
|
||||
setupResponseSubscription();
|
||||
}, backoffDelay);
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Failed to connect for 21121 subscription:', error);
|
||||
@ -122,7 +217,9 @@ export function trackOutgoingEvent(event: NostrEvent): string | null {
|
||||
}
|
||||
|
||||
// Add to the store
|
||||
return clientEventStore.addOutgoingEvent(event);
|
||||
const eventId = clientEventStore.addOutgoingEvent(event);
|
||||
console.log(`Tracked outgoing event: ${eventId}`);
|
||||
return eventId;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -168,20 +265,77 @@ export function handleIncomingResponse(event: NostrEvent): boolean {
|
||||
const eTag = event.tags.find(tag => tag[0] === 'e');
|
||||
if (!eTag || eTag.length < 2) {
|
||||
console.warn('Response event missing valid e tag with request ID');
|
||||
console.log('All tags:', JSON.stringify(event.tags, null, 2));
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`Found e tag in 21121 response: ${eTag[1]}`);
|
||||
|
||||
const requestId = eTag[1];
|
||||
|
||||
// Check if we have this request in our store
|
||||
const storedEvent = clientEventStore.getEvent(requestId);
|
||||
if (!storedEvent) {
|
||||
// This response doesn't match any of our tracked requests
|
||||
console.warn(`Received response for unknown request ID: ${requestId}`);
|
||||
const allEvents = clientEventStore.getAllEvents();
|
||||
console.log(`We have ${allEvents.length} tracked events. IDs:`, allEvents.map(e => e.id).join(', '));
|
||||
|
||||
// Try to find if there's a similar request ID (in case of case sensitivity issues)
|
||||
for (const storedEvent of allEvents) {
|
||||
if (storedEvent.id.toLowerCase() === requestId.toLowerCase()) {
|
||||
console.log(`Found case-insensitive match: ${storedEvent.id}`);
|
||||
// Construct a proper Nostr event with the correctly cased event ID
|
||||
if (event.pubkey && event.created_at && event.kind && event.content) {
|
||||
const fixedEvent: NostrEvent = {
|
||||
...event,
|
||||
tags: [['e', storedEvent.id]]
|
||||
};
|
||||
return handleIncomingResponse(fixedEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`Matching request found for response: ${requestId}`);
|
||||
|
||||
// Check if we already have a response for this request
|
||||
if (storedEvent.responseId) {
|
||||
console.log(`We already have response ${storedEvent.responseId} for request ${requestId}`);
|
||||
// If this is the same response ID, don't add it again
|
||||
if (storedEvent.responseId === event.id) {
|
||||
console.log(`Same response ID received again, not re-adding`);
|
||||
return true; // Return true since we already have this stored
|
||||
}
|
||||
console.log(`Received new response ${event.id}, replacing existing one`);
|
||||
}
|
||||
|
||||
// Check response validity
|
||||
if (!event.content) {
|
||||
console.warn(`Response has empty content, adding anyway`);
|
||||
}
|
||||
|
||||
// Add the response to the store, linked to the original request
|
||||
return clientEventStore.addResponseEvent(event);
|
||||
const result = clientEventStore.addResponseEvent(event);
|
||||
console.log(`Response ${event.id} added to store for request ${requestId}: ${result ? 'SUCCESS' : 'FAILED'}`);
|
||||
|
||||
// After adding to store, check if it's actually there
|
||||
const updatedEvent = clientEventStore.getEvent(requestId);
|
||||
if (updatedEvent && updatedEvent.responseId === event.id) {
|
||||
console.log(`Verified: response ${event.id} is now correctly linked to request ${requestId}`);
|
||||
|
||||
// Additional check for the full event object
|
||||
if (updatedEvent.responseEvent) {
|
||||
console.log(`Response event object is present in the store`);
|
||||
} else {
|
||||
console.error(`Response ID is set but full event object is missing!`);
|
||||
}
|
||||
} else {
|
||||
console.error(`Failed to verify stored response for request ${requestId}. Current state:`, updatedEvent);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -202,6 +356,7 @@ export function disposeClientEventHandler(): void {
|
||||
if (clientEventsTable) {
|
||||
clientEventsTable.dispose();
|
||||
clientEventsTable = null;
|
||||
console.log('Disposed client events table component');
|
||||
}
|
||||
|
||||
// Close WebSocket connections if needed
|
||||
@ -213,5 +368,28 @@ export function disposeClientEventHandler(): void {
|
||||
console.error('Error closing WebSocket connection:', e);
|
||||
}
|
||||
relayService = null;
|
||||
console.log('Closed WebSocket connections and reset relay service');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a reconnection to the relay service
|
||||
* This can be useful if the user manually wants to refresh connections
|
||||
*/
|
||||
export function reconnectRelayService(): void {
|
||||
if (relayService) {
|
||||
try {
|
||||
// Close existing connections first
|
||||
const wsManager = relayService.getWebSocketManager();
|
||||
wsManager.close();
|
||||
|
||||
// Then set up a new subscription
|
||||
console.log('Manually reconnecting relay service...');
|
||||
setupResponseSubscription();
|
||||
} catch (e) {
|
||||
console.error('Error during manual reconnection:', e);
|
||||
}
|
||||
} else {
|
||||
console.warn('Cannot reconnect: No relay service available');
|
||||
}
|
||||
}
|
@ -44,7 +44,8 @@ import { displayConvertedEvent } from './converter';
|
||||
import {
|
||||
initClientEventHandler,
|
||||
trackOutgoingEvent,
|
||||
handleIncomingResponse
|
||||
handleIncomingResponse,
|
||||
reconnectRelayService
|
||||
} from './client-event-handler';
|
||||
import { publishToRelay, convertNpubToHex, verifyEvent } from './relay';
|
||||
// Import profile functions (not using direct imports since we'll load modules based on page)
|
||||
@ -785,11 +786,17 @@ async function handlePublishEvent(): Promise<void> {
|
||||
|
||||
try {
|
||||
// Track the event before publishing
|
||||
console.log('Tracking outgoing event:', event.id);
|
||||
trackOutgoingEvent(event);
|
||||
|
||||
// Publish the event
|
||||
console.log('Publishing event to relay:', relayUrl);
|
||||
const result = await publishToRelay(event, relayUrl);
|
||||
showSuccess(publishResultDiv, result);
|
||||
|
||||
// Force reconnect to ensure we're subscribed for the response
|
||||
console.log('Reconnecting to listen for response...');
|
||||
reconnectRelayService();
|
||||
} catch (publishError) {
|
||||
showError(publishResultDiv, String(publishError));
|
||||
// Don't rethrow, just handle it here so the UI shows the error
|
||||
@ -863,11 +870,17 @@ async function handlePublishEvent(): Promise<void> {
|
||||
|
||||
try {
|
||||
// Track the event in our client store
|
||||
console.log('Tracking outgoing event:', event.id);
|
||||
trackOutgoingEvent(event);
|
||||
|
||||
// Publish the event
|
||||
console.log('Publishing event to relay:', relayUrl);
|
||||
const result = await publishToRelay(event, relayUrl);
|
||||
showSuccess(publishResultDiv, result);
|
||||
|
||||
// Force reconnect to ensure we're subscribed for the response
|
||||
console.log('Reconnecting to listen for response...');
|
||||
reconnectRelayService();
|
||||
} catch (publishError) {
|
||||
showError(publishResultDiv, String(publishError));
|
||||
// Don't rethrow, just handle it here so the UI shows the error
|
||||
@ -1126,26 +1139,71 @@ function handleToggleKeyFormat(): void {
|
||||
async function safeAuthenticate(): Promise<string | null> {
|
||||
console.log("Starting safe authentication...");
|
||||
|
||||
// Clear any stale auth state
|
||||
window.sessionStorage.removeItem('nostrLoginInitialized');
|
||||
['nostrAuthInProgress', 'nostrLoginState', 'nostrAuthPending', 'nostrLoginStarted'].forEach(key => {
|
||||
window.sessionStorage.removeItem(key);
|
||||
});
|
||||
// Check if auth is already in progress to prevent duplicate attempts
|
||||
if (window.authInProgress) {
|
||||
console.log("Authentication already in progress, waiting...");
|
||||
return new Promise((resolve) => {
|
||||
// Set up a small interval to check when the auth is complete
|
||||
const checkInterval = setInterval(() => {
|
||||
if (!window.authInProgress) {
|
||||
clearInterval(checkInterval);
|
||||
// After auth is complete, check if we have a pubkey
|
||||
const pubkey = localStorage.getItem('userPublicKey');
|
||||
resolve(pubkey);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Set a timeout in case the auth gets stuck
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
console.log("Authentication wait timeout, proceeding...");
|
||||
const pubkey = localStorage.getItem('userPublicKey');
|
||||
resolve(pubkey);
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize NostrLogin
|
||||
initNostrLogin();
|
||||
// Set the global auth flag to prevent duplicate attempts
|
||||
window.authInProgress = true;
|
||||
|
||||
// Wait a moment for initialization to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// If we already have a pubkey in localStorage, use that
|
||||
const savedPubkey = localStorage.getItem('userPublicKey');
|
||||
if (savedPubkey) {
|
||||
console.log(`Found saved pubkey: ${savedPubkey.substring(0, 8)}...`);
|
||||
updateClientPubkeyDisplay(savedPubkey);
|
||||
// Set authentication state in the central auth manager
|
||||
authManager.setAuthenticated(true, savedPubkey);
|
||||
return savedPubkey;
|
||||
try {
|
||||
// Clear any stale auth state
|
||||
window.sessionStorage.removeItem('nostrLoginInitialized');
|
||||
['nostrAuthInProgress', 'nostrLoginState', 'nostrAuthPending', 'nostrLoginStarted'].forEach(key => {
|
||||
window.sessionStorage.removeItem(key);
|
||||
});
|
||||
|
||||
// Initialize NostrLogin
|
||||
initNostrLogin();
|
||||
|
||||
// Wait a moment for initialization to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Check if we're already authenticated via AuthenticationService
|
||||
if (authManager.authService.isAuthenticated()) {
|
||||
console.log("Already authenticated via AuthenticationService");
|
||||
window.authInProgress = false;
|
||||
const pubkey = authManager.getCurrentUserPubkey();
|
||||
if (pubkey) {
|
||||
updateClientPubkeyDisplay(pubkey);
|
||||
return pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
// If we already have a pubkey in localStorage, use that
|
||||
const savedPubkey = localStorage.getItem('userPublicKey');
|
||||
if (savedPubkey) {
|
||||
console.log(`Found saved pubkey: ${savedPubkey.substring(0, 8)}...`);
|
||||
updateClientPubkeyDisplay(savedPubkey);
|
||||
// Set authentication state in the central auth manager
|
||||
authManager.setAuthenticated(true, savedPubkey);
|
||||
window.authInProgress = false;
|
||||
return savedPubkey;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in authentication setup:", error);
|
||||
window.authInProgress = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set a flag that we're starting auth
|
||||
@ -1156,26 +1214,46 @@ async function safeAuthenticate(): Promise<string | null> {
|
||||
if (window.nostr) {
|
||||
console.log("Requesting public key from extension...");
|
||||
|
||||
// First try using our enhanced AuthenticationService
|
||||
try {
|
||||
const result = await authManager.authService.authenticate();
|
||||
if (result.success && result.session) {
|
||||
const pubkey = result.session.pubkey;
|
||||
console.log(`Authentication successful via AuthenticationService, pubkey: ${pubkey.substring(0, 8)}...`);
|
||||
localStorage.setItem('userPublicKey', pubkey);
|
||||
updateClientPubkeyDisplay(pubkey);
|
||||
window.authInProgress = false;
|
||||
return pubkey;
|
||||
}
|
||||
} catch (authServiceError) {
|
||||
console.warn("AuthenticationService authentication failed, falling back to extension:", authServiceError);
|
||||
}
|
||||
|
||||
// Fall back to direct extension request
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
if (pubkey) {
|
||||
console.log(`Authentication successful, pubkey: ${pubkey.substring(0, 8)}...`);
|
||||
console.log(`Authentication successful via extension, pubkey: ${pubkey.substring(0, 8)}...`);
|
||||
localStorage.setItem('userPublicKey', pubkey);
|
||||
updateClientPubkeyDisplay(pubkey);
|
||||
// Set authentication state in the central auth manager
|
||||
authManager.setAuthenticated(true, pubkey);
|
||||
window.authInProgress = false;
|
||||
return pubkey;
|
||||
}
|
||||
} else {
|
||||
console.warn("Nostr extension not available");
|
||||
}
|
||||
|
||||
window.authInProgress = false;
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error during authentication:", error);
|
||||
window.authInProgress = false;
|
||||
return null;
|
||||
} finally {
|
||||
// Clear the in-progress flag
|
||||
window.sessionStorage.removeItem('nostrAuthInProgress');
|
||||
window.authInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1213,6 +1291,30 @@ window.addEventListener('beforeunload', () => {
|
||||
async function retryAuthentication(): Promise<void> {
|
||||
console.log("Manual authentication retry requested");
|
||||
|
||||
// Check if auth is already in progress
|
||||
if (window.authInProgress) {
|
||||
console.log("Authentication already in progress, please wait...");
|
||||
|
||||
// If auth is in progress, just update UI
|
||||
const statusElement = document.getElementById('authStatus');
|
||||
if (statusElement) {
|
||||
statusElement.textContent = 'Authentication already in progress...';
|
||||
statusElement.className = 'status-message loading';
|
||||
}
|
||||
|
||||
// Set a timeout to check if auth completed
|
||||
setTimeout(() => {
|
||||
if (authManager.isAuthenticated()) {
|
||||
if (statusElement) {
|
||||
statusElement.textContent = 'Authentication successful!';
|
||||
statusElement.className = 'status-message success';
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear UI status
|
||||
const statusElement = document.getElementById('authStatus');
|
||||
if (statusElement) {
|
||||
@ -1220,7 +1322,8 @@ async function retryAuthentication(): Promise<void> {
|
||||
statusElement.className = 'status-message loading';
|
||||
}
|
||||
|
||||
// Clear all auth state
|
||||
// Force reset auth state to clear any stuck processes
|
||||
window.authInProgress = false;
|
||||
window.sessionStorage.removeItem('nostrLoginInitialized');
|
||||
['nostrAuthInProgress', 'nostrLoginState', 'nostrAuthPending', 'nostrLoginStarted'].forEach(key => {
|
||||
window.sessionStorage.removeItem(key);
|
||||
@ -1229,7 +1332,25 @@ async function retryAuthentication(): Promise<void> {
|
||||
// Wait a moment for state to clear
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Try authentication again
|
||||
// Try authentication again with our enhanced service first
|
||||
try {
|
||||
const result = await authManager.authService.authenticate();
|
||||
if (result.success && result.session) {
|
||||
console.log(`Authentication successful via AuthenticationService: ${result.session.pubkey.substring(0, 8)}...`);
|
||||
|
||||
// Update UI status
|
||||
if (statusElement) {
|
||||
statusElement.textContent = 'Authentication successful!';
|
||||
statusElement.className = 'status-message success';
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not authenticate with AuthenticationService, falling back:", error);
|
||||
}
|
||||
|
||||
// Fall back to standard authentication
|
||||
const pubkey = await safeAuthenticate();
|
||||
|
||||
// Update status
|
||||
|
@ -10,6 +10,7 @@ import type { ClientEventStore, ClientStoredEvent } from '../services/ClientEven
|
||||
import { ClientEventStatus } from '../services/ClientEventStore';
|
||||
import { HttpFormatter } from '../services/HttpFormatter';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { reconnectRelayService } from '../client-event-handler';
|
||||
|
||||
export class ClientEventsTable {
|
||||
private container: HTMLElement | null = null;
|
||||
@ -55,11 +56,130 @@ export class ClientEventsTable {
|
||||
|
||||
// Render existing events
|
||||
this.renderExistingEvents();
|
||||
|
||||
// Display notification if events were loaded from localStorage
|
||||
const events = this.eventStore.getAllEvents();
|
||||
if (events.length > 0) {
|
||||
this.showPersistenceNotification(events.length);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a notification that events were loaded from localStorage
|
||||
* @param count Number of events loaded
|
||||
*/
|
||||
private showPersistenceNotification(count: number): void {
|
||||
// Create notification element
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'event-persistence-notification';
|
||||
notification.innerHTML = `
|
||||
<p>${count} events were loaded from storage. Your events are now persisted between page reloads.</p>
|
||||
<button class="dismiss-btn">✕</button>
|
||||
`;
|
||||
|
||||
// Style the notification
|
||||
const style = notification.style;
|
||||
style.position = 'fixed';
|
||||
style.bottom = '20px';
|
||||
style.right = '20px';
|
||||
style.backgroundColor = '#2c7c3e';
|
||||
style.color = 'white';
|
||||
style.padding = '10px 15px';
|
||||
style.borderRadius = '4px';
|
||||
style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
|
||||
style.zIndex = '1000';
|
||||
style.maxWidth = '320px';
|
||||
|
||||
// Add dismiss button functionality
|
||||
const dismissBtn = notification.querySelector('.dismiss-btn');
|
||||
if (dismissBtn) {
|
||||
dismissBtn.addEventListener('click', () => {
|
||||
document.body.removeChild(notification);
|
||||
});
|
||||
|
||||
// Style the dismiss button
|
||||
const btnStyle = (dismissBtn as HTMLElement).style;
|
||||
btnStyle.background = 'none';
|
||||
btnStyle.border = 'none';
|
||||
btnStyle.color = 'white';
|
||||
btnStyle.float = 'right';
|
||||
btnStyle.cursor = 'pointer';
|
||||
btnStyle.fontSize = '16px';
|
||||
}
|
||||
|
||||
// Add to document and set timeout to auto-remove
|
||||
document.body.appendChild(notification);
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(notification)) {
|
||||
document.body.removeChild(notification);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private createTableStructure(): void {
|
||||
if (!this.container) { return; }
|
||||
|
||||
// Clear any existing content
|
||||
this.container.innerHTML = '';
|
||||
|
||||
// Create table controls including reconnect button
|
||||
const controlsDiv = document.createElement('div');
|
||||
controlsDiv.className = 'client-events-controls';
|
||||
controlsDiv.innerHTML = `
|
||||
<button id="reconnectButton" class="reconnect-button">Reconnect to Relay</button>
|
||||
<span id="reconnectStatus" class="reconnect-status"></span>
|
||||
`;
|
||||
|
||||
// Style the controls
|
||||
const controlsStyle = controlsDiv.style;
|
||||
controlsStyle.marginBottom = '10px';
|
||||
controlsStyle.display = 'flex';
|
||||
controlsStyle.alignItems = 'center';
|
||||
controlsStyle.gap = '10px';
|
||||
|
||||
// Add reconnect button functionality
|
||||
const reconnectButton = controlsDiv.querySelector('#reconnectButton');
|
||||
if (reconnectButton) {
|
||||
reconnectButton.addEventListener('click', () => {
|
||||
const statusSpan = document.getElementById('reconnectStatus');
|
||||
if (statusSpan) {
|
||||
statusSpan.textContent = 'Reconnecting...';
|
||||
statusSpan.style.color = '#ff9900';
|
||||
}
|
||||
|
||||
// Call the reconnection function
|
||||
reconnectRelayService();
|
||||
|
||||
// Update status after a moment
|
||||
setTimeout(() => {
|
||||
if (statusSpan) {
|
||||
statusSpan.textContent = 'Reconnection attempt sent';
|
||||
statusSpan.style.color = '#33cc33';
|
||||
|
||||
// Clear the status after a few seconds
|
||||
setTimeout(() => {
|
||||
if (statusSpan) {
|
||||
statusSpan.textContent = '';
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// Style the button
|
||||
const buttonStyle = (reconnectButton as HTMLElement).style;
|
||||
buttonStyle.padding = '6px 12px';
|
||||
buttonStyle.backgroundColor = '#2c7c3e';
|
||||
buttonStyle.color = 'white';
|
||||
buttonStyle.border = 'none';
|
||||
buttonStyle.borderRadius = '4px';
|
||||
buttonStyle.cursor = 'pointer';
|
||||
}
|
||||
|
||||
// Add the controls to the container
|
||||
this.container.appendChild(controlsDiv);
|
||||
|
||||
// Create the table
|
||||
const tableHtml = `
|
||||
<table class="client-events-table">
|
||||
<thead>
|
||||
@ -78,10 +198,12 @@ export class ClientEventsTable {
|
||||
</table>
|
||||
`;
|
||||
|
||||
this.container.innerHTML = tableHtml;
|
||||
const tableDiv = document.createElement('div');
|
||||
tableDiv.innerHTML = tableHtml;
|
||||
this.container.appendChild(tableDiv);
|
||||
|
||||
this.tableBody = document.getElementById('clientEventsTableBody');
|
||||
}
|
||||
|
||||
private renderExistingEvents(): void {
|
||||
if (!this.tableBody) { return; }
|
||||
|
||||
@ -91,8 +213,15 @@ export class ClientEventsTable {
|
||||
// Get all events from the store
|
||||
const events = this.eventStore.getAllEvents();
|
||||
|
||||
console.log(`ClientEventsTable: Rendering ${events.length} existing events`);
|
||||
|
||||
// Count how many have responses
|
||||
const eventsWithResponses = events.filter(e => e.responseId !== undefined).length;
|
||||
console.log(`ClientEventsTable: ${eventsWithResponses} events have responses`);
|
||||
|
||||
// If no events, show the empty state
|
||||
if (events.length === 0) {
|
||||
console.log(`ClientEventsTable: No events to render, showing empty state`);
|
||||
this.tableBody.innerHTML = `
|
||||
<tr class="table-empty-state">
|
||||
<td colspan="4">No outgoing HTTP requests yet. Use the form above to send requests.</td>
|
||||
@ -264,7 +393,14 @@ export class ClientEventsTable {
|
||||
|
||||
// Check if we have a response
|
||||
const responseEvent = storedEvent.responseEvent;
|
||||
const hasResponseId = !!storedEvent.responseId;
|
||||
|
||||
// Debug log for response status
|
||||
console.log(`ClientEventsTable: Event ${eventId} response status - responseId: ${storedEvent.responseId || 'none'}, responseEvent: ${responseEvent ? 'present' : 'missing'}`);
|
||||
|
||||
if (responseEvent) {
|
||||
console.log(`ClientEventsTable: Showing 21121 response (kind: ${responseEvent.kind}) for event ${eventId}`);
|
||||
|
||||
// Populate 21121 JSON response tab
|
||||
jsonResponseTab.innerHTML = `<pre>${JSON.stringify(responseEvent, null, 2)}</pre>`;
|
||||
|
||||
@ -276,8 +412,16 @@ export class ClientEventsTable {
|
||||
}
|
||||
} else {
|
||||
// No response yet
|
||||
jsonResponseTab.innerHTML = '<div class="empty-response">No response received yet</div>';
|
||||
httpResponseTab.innerHTML = '<div class="empty-response">No response received yet</div>';
|
||||
console.log(`ClientEventsTable: No 21121 response available for event ${eventId}`);
|
||||
|
||||
if (hasResponseId) {
|
||||
console.warn(`ClientEventsTable: Event has responseId (${storedEvent.responseId}) but no responseEvent object`);
|
||||
jsonResponseTab.innerHTML = '<div class="empty-response warning">Response ID exists but response data is missing. Try reconnecting to relay.</div>';
|
||||
httpResponseTab.innerHTML = '<div class="empty-response warning">Response ID exists but response data is missing. Try reconnecting to relay.</div>';
|
||||
} else {
|
||||
jsonResponseTab.innerHTML = '<div class="empty-response">No response received yet</div>';
|
||||
httpResponseTab.innerHTML = '<div class="empty-response">No response received yet</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -292,6 +436,11 @@ export class ClientEventsTable {
|
||||
if (this.modal && this.modal.parentNode) {
|
||||
this.modal.parentNode.removeChild(this.modal);
|
||||
}
|
||||
// Remove reconnect button event listener if it exists
|
||||
const reconnectButton = document.getElementById('reconnectButton');
|
||||
if (reconnectButton) {
|
||||
reconnectButton.removeEventListener('click', () => {});
|
||||
}
|
||||
|
||||
// Clear references
|
||||
this.container = null;
|
||||
|
@ -19,29 +19,37 @@ export interface NostrEvent {
|
||||
sig?: string;
|
||||
}
|
||||
|
||||
// Type definition for the window.nostrTools object
|
||||
// Define interfaces to avoid unused parameter errors
|
||||
/* eslint-disable no-unused-vars */
|
||||
interface NostrWindowExtension {
|
||||
getPublicKey: () => Promise<string>;
|
||||
signEvent: (event: NostrEvent) => Promise<NostrEvent>;
|
||||
nip44?: {
|
||||
encrypt(plaintext: string, pubkey: string): Promise<string>;
|
||||
decrypt(ciphertext: string, pubkey: string): Promise<string>;
|
||||
};
|
||||
}
|
||||
// Import the WindowNostrExtension type from window.d.ts
|
||||
import type { WindowNostrExtension } from './types/window';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
nostrTools: Record<string, unknown>;
|
||||
nostr: NostrWindowExtension;
|
||||
nostrTools: NostrTools;
|
||||
nostr?: WindowNostrExtension;
|
||||
currentSignedEvent?: NostrEvent;
|
||||
}
|
||||
}
|
||||
|
||||
// Import nostr-login for encryption/signing
|
||||
// eslint-disable-next-line no-undef
|
||||
const NostrLogin = typeof require !== 'undefined' ? require('nostr-login') : null;
|
||||
// Define better types for the NostrLogin module
|
||||
interface NostrLoginModule {
|
||||
signEvent: (event: NostrEvent) => Promise<NostrEvent>;
|
||||
sign: (event: NostrEvent) => Promise<NostrEvent>;
|
||||
}
|
||||
|
||||
const NostrLogin: NostrLoginModule | null = typeof require !== 'undefined' ? require('nostr-login') : null;
|
||||
|
||||
// Define proper types for nostr-tools
|
||||
interface NostrTools {
|
||||
generateSecretKey: () => Uint8Array;
|
||||
getPublicKey: (secretKey: Uint8Array) => string;
|
||||
finalizeEvent: (event: NostrEvent, secretKey: Uint8Array) => NostrEvent;
|
||||
nip19: {
|
||||
decode: (encoded: string) => { type: string; data: string | Record<string, unknown> };
|
||||
npubEncode: (hex: string) => string;
|
||||
};
|
||||
}
|
||||
|
||||
// Generate a keypair for standalone mode (when no extension is available)
|
||||
let standaloneSecretKey: Uint8Array | null = null;
|
||||
@ -54,8 +62,13 @@ function initStandaloneKeypair(): { publicKey: string, secretKey: Uint8Array } {
|
||||
standalonePublicKey = nostrTools.getPublicKey(standaloneSecretKey);
|
||||
}
|
||||
|
||||
// Ensure the values exist before returning
|
||||
if (!standalonePublicKey || !standaloneSecretKey) {
|
||||
throw new Error('Failed to initialize standalone keypair');
|
||||
}
|
||||
|
||||
return {
|
||||
publicKey: standalonePublicKey!,
|
||||
publicKey: standalonePublicKey,
|
||||
secretKey: standaloneSecretKey
|
||||
};
|
||||
}
|
||||
@ -90,7 +103,7 @@ export async function convertToEvent(
|
||||
// Create a single kind 21120 event with encrypted HTTP request as content
|
||||
// Using Web Crypto API for proper encryption
|
||||
|
||||
let encryptedContent: string;
|
||||
let encryptedContent = '';
|
||||
|
||||
try {
|
||||
// Convert server pubkey to hex if it's an npub
|
||||
@ -137,8 +150,12 @@ export async function convertToEvent(
|
||||
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}`);
|
||||
if (typeof decoded.data === 'string') {
|
||||
pTagValue = decoded.data;
|
||||
console.log(`Converted to hex pubkey: ${pTagValue}`);
|
||||
} else {
|
||||
throw new Error("Decoded npub data is not a string");
|
||||
}
|
||||
} else {
|
||||
throw new Error("Failed to decode npub properly");
|
||||
}
|
||||
@ -320,7 +337,8 @@ export async function displayConvertedEvent(): Promise<void> {
|
||||
|
||||
if (convertedEvent) {
|
||||
// Store the original event in case we need to reference it
|
||||
(window as any).originalEvent = convertedEvent;
|
||||
// Use proper type instead of 'any'
|
||||
(window as { originalEvent?: string }).originalEvent = convertedEvent;
|
||||
// Variable to hold the Nostr event
|
||||
let nostrEvent: NostrEvent;
|
||||
|
||||
@ -404,7 +422,7 @@ export async function displayConvertedEvent(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
// Store the event in a global variable for easier access during publishing
|
||||
(window as any).currentSignedEvent = signedEvent;
|
||||
window.currentSignedEvent = signedEvent;
|
||||
|
||||
// Display the event JSON
|
||||
eventOutputPre.textContent = JSON.stringify(signedEvent, null, 2);
|
||||
@ -418,14 +436,35 @@ if (publishRelayInput) {
|
||||
if (publishResult) {
|
||||
publishResult.innerHTML = '<span class="loading">Publishing to relay...</span>';
|
||||
|
||||
// Publish the event using the publishToRelay function
|
||||
publishToRelay(signedEvent, relayUrl)
|
||||
.then((result: string) => {
|
||||
showSuccess(publishResult, result);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
publishResult.innerHTML = `<span class="error">Error publishing: ${error.message}</span>`;
|
||||
});
|
||||
// Import the tracking function from client-event-handler
|
||||
import('./client-event-handler').then(module => {
|
||||
// Track the event before publishing
|
||||
try {
|
||||
module.trackOutgoingEvent(signedEvent);
|
||||
console.log('Event tracked successfully before publishing');
|
||||
} catch (trackError) {
|
||||
console.error('Error tracking event:', trackError);
|
||||
}
|
||||
|
||||
// Publish the event using the publishToRelay function
|
||||
publishToRelay(signedEvent, relayUrl)
|
||||
.then((result: string) => {
|
||||
showSuccess(publishResult, result);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
publishResult.innerHTML = `<span class="error">Error publishing: ${error.message}</span>`;
|
||||
});
|
||||
}).catch(importError => {
|
||||
console.error('Error importing client-event-handler:', importError);
|
||||
// Fall back to just publishing without tracking
|
||||
publishToRelay(signedEvent, relayUrl)
|
||||
.then((result: string) => {
|
||||
showSuccess(publishResult, result);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
publishResult.innerHTML = `<span class="error">Error publishing: ${error.message}</span>`;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -434,13 +473,18 @@ if (publishRelayInput) {
|
||||
const qrCodeContainer = document.getElementById('qrCode') as HTMLElement;
|
||||
if (qrCodeContainer) {
|
||||
try {
|
||||
// Ensure signedEvent exists before processing
|
||||
if (!signedEvent) {
|
||||
throw new Error('No signed event available for QR code generation');
|
||||
}
|
||||
|
||||
// Convert the event to a JSON string
|
||||
const eventJson = JSON.stringify(signedEvent);
|
||||
|
||||
// Calculate how many QR codes we need based on the data size
|
||||
// Use a much smaller chunk size to ensure it fits in the QR code
|
||||
const maxChunkSize = 500; // Significantly reduced chunk size
|
||||
const chunks = [];
|
||||
const chunks: string[] = [];
|
||||
|
||||
// Split the data into chunks
|
||||
for (let i = 0; i < eventJson.length; i += maxChunkSize) {
|
||||
@ -454,7 +498,8 @@ if (publishRelayInput) {
|
||||
qrCodeContainer.appendChild(qrFrameContainer);
|
||||
|
||||
// Create QR codes for each chunk with chunk number and total chunks
|
||||
const qrFrames: HTMLElement[] = [];
|
||||
// Type the frames array properly
|
||||
const qrFrames: HTMLDivElement[] = [];
|
||||
chunks.forEach((chunk, index) => {
|
||||
// Add metadata to identify part of sequence: [current/total]:[data]
|
||||
const dataWithMeta = `${index + 1}/${chunks.length}:${chunk}`;
|
||||
@ -518,14 +563,19 @@ if (publishRelayInput) {
|
||||
let animationInterval: number | null = null;
|
||||
let isPaused = false;
|
||||
|
||||
const updateFrameInfo = () => {
|
||||
const updateFrameInfo = (): void => {
|
||||
const frameInfo = qrCodeContainer.querySelector('.current-frame');
|
||||
if (frameInfo) {
|
||||
if (frameInfo instanceof HTMLElement) {
|
||||
frameInfo.textContent = `Showing frame ${currentFrame + 1} of ${chunks.length}`;
|
||||
}
|
||||
};
|
||||
|
||||
const showFrame = (index: number) => {
|
||||
const showFrame = (index: number): void => {
|
||||
if (index < 0 || index >= qrFrames.length) {
|
||||
console.error(`Invalid frame index: ${index}, valid range: 0-${qrFrames.length - 1}`);
|
||||
return;
|
||||
}
|
||||
|
||||
qrFrames.forEach((frame, i) => {
|
||||
frame.style.display = i === index ? 'block' : 'none';
|
||||
});
|
||||
@ -533,16 +583,21 @@ if (publishRelayInput) {
|
||||
updateFrameInfo();
|
||||
};
|
||||
|
||||
const nextFrame = () => {
|
||||
showFrame((currentFrame + 1) % qrFrames.length);
|
||||
const nextFrame = (): void => {
|
||||
if (qrFrames.length > 0) {
|
||||
showFrame((currentFrame + 1) % qrFrames.length);
|
||||
}
|
||||
};
|
||||
|
||||
const prevFrame = () => {
|
||||
showFrame((currentFrame - 1 + qrFrames.length) % qrFrames.length);
|
||||
const prevFrame = (): void => {
|
||||
if (qrFrames.length > 0) {
|
||||
showFrame((currentFrame - 1 + qrFrames.length) % qrFrames.length);
|
||||
}
|
||||
};
|
||||
|
||||
// Start the animation
|
||||
animationInterval = window.setInterval(nextFrame, 2000); // Change frame every 2 seconds
|
||||
// Use proper window.setInterval type
|
||||
animationInterval = window.setInterval(() => nextFrame(), 2000); // Change frame every 2 seconds
|
||||
|
||||
// Set up button event handlers
|
||||
const pauseBtn = document.getElementById('qrPauseBtn');
|
||||
@ -550,9 +605,9 @@ if (publishRelayInput) {
|
||||
const nextBtn = document.getElementById('qrNextBtn');
|
||||
|
||||
if (pauseBtn) {
|
||||
pauseBtn.addEventListener('click', () => {
|
||||
pauseBtn.addEventListener('click', (): void => {
|
||||
if (isPaused) {
|
||||
animationInterval = window.setInterval(nextFrame, 2000);
|
||||
animationInterval = window.setInterval(() => nextFrame(), 2000);
|
||||
pauseBtn.textContent = 'Pause';
|
||||
} else {
|
||||
if (animationInterval !== null) {
|
||||
@ -566,7 +621,7 @@ if (publishRelayInput) {
|
||||
}
|
||||
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', () => {
|
||||
prevBtn.addEventListener('click', (): void => {
|
||||
if (isPaused) {
|
||||
prevFrame();
|
||||
}
|
||||
@ -574,7 +629,7 @@ if (publishRelayInput) {
|
||||
}
|
||||
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', () => {
|
||||
nextBtn.addEventListener('click', (): void => {
|
||||
if (isPaused) {
|
||||
nextFrame();
|
||||
}
|
||||
@ -588,7 +643,11 @@ if (publishRelayInput) {
|
||||
|
||||
// Create a backup QR code with just the event ID and relay
|
||||
try {
|
||||
const eventId = signedEvent.id || '';
|
||||
if (!signedEvent || !signedEvent.id) {
|
||||
throw new Error('No valid signed event available for backup QR code');
|
||||
}
|
||||
|
||||
const eventId = signedEvent.id;
|
||||
const relay = encodeURIComponent(defaultServerConfig.defaultRelay);
|
||||
const nostrUri = `nostr:${eventId}?relay=${relay}`;
|
||||
|
||||
@ -605,7 +664,7 @@ if (publishRelayInput) {
|
||||
<p class="qr-info">This QR code contains a reference to the event: ${eventId.substring(0, 8)}...</p>
|
||||
</div>
|
||||
`;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
qrCodeContainer.innerHTML = `
|
||||
<div class="qr-error-container">
|
||||
<h3>QR Generation Failed</h3>
|
||||
@ -631,16 +690,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const publishButton = document.getElementById('publishButton');
|
||||
|
||||
if (convertButton) {
|
||||
convertButton.addEventListener('click', displayConvertedEvent);
|
||||
convertButton.addEventListener('click', (): void => {
|
||||
void displayConvertedEvent();
|
||||
});
|
||||
}
|
||||
|
||||
// Add a handler for the publish button to check if an event is available
|
||||
if (publishButton) {
|
||||
publishButton.addEventListener('click', () => {
|
||||
publishButton.addEventListener('click', (): void => {
|
||||
const eventOutput = document.getElementById('eventOutput');
|
||||
const publishResult = document.getElementById('publishResult');
|
||||
|
||||
if (!eventOutput || !publishResult) {
|
||||
console.error('Unable to find eventOutput or publishResult elements');
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
// External dependencies
|
||||
import * as nostrTools from 'nostr-tools';
|
||||
import qrcode from 'qrcode-generator';
|
||||
import * as authManager from './auth-manager';
|
||||
import { SecureStorageService } from './services/SecureStorageService';
|
||||
|
||||
// Interface for profile data
|
||||
interface ProfileData {
|
||||
@ -36,24 +38,56 @@ let currentProfileData: ProfileData = {
|
||||
};
|
||||
|
||||
// Connect to extension
|
||||
async function connectWithExtension(): Promise<void> {
|
||||
async function connectWithExtension(): Promise<string | null> {
|
||||
try {
|
||||
if (!window.nostr) {
|
||||
updateConnectionStatus('No Nostr extension detected. Please install a NIP-07 compatible extension.', false);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
if (pubkey) {
|
||||
currentPubkey = pubkey;
|
||||
updateConnectionStatus(`Connected with extension using pubkey: ${pubkey.substring(0, 8)}...`, true);
|
||||
showProfile(pubkey);
|
||||
// Use the AuthenticationService via auth-manager for a more secure login
|
||||
const result = await authManager.authService.authenticate();
|
||||
|
||||
if (result.success && result.session) {
|
||||
currentPubkey = result.session.pubkey;
|
||||
|
||||
// Log the successful authentication
|
||||
console.log(`Authentication successful using secure method for pubkey: ${currentPubkey.substring(0, 8)}...`);
|
||||
|
||||
updateConnectionStatus(`Connected with extension using pubkey: ${currentPubkey.substring(0, 8)}...`, true);
|
||||
showProfile(currentPubkey);
|
||||
|
||||
// Return the pubkey so it can be used by the caller
|
||||
return currentPubkey;
|
||||
} else {
|
||||
updateConnectionStatus('Failed to get public key from extension', false);
|
||||
// Fall back to the old method if needed
|
||||
console.log('Secure authentication failed, trying legacy method');
|
||||
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
if (pubkey) {
|
||||
currentPubkey = pubkey;
|
||||
|
||||
// Set the authenticated state using auth-manager
|
||||
authManager.setAuthenticated(true, pubkey);
|
||||
console.log(`Authentication successful (legacy) for pubkey: ${pubkey.substring(0, 8)}...`);
|
||||
|
||||
updateConnectionStatus(`Connected with extension using pubkey: ${pubkey.substring(0, 8)}...`, true);
|
||||
showProfile(pubkey);
|
||||
|
||||
// Return the pubkey so it can be used by the caller
|
||||
return pubkey;
|
||||
} else {
|
||||
authManager.setAuthenticated(false);
|
||||
updateConnectionStatus('Failed to get public key from extension', false);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error connecting with extension:', err);
|
||||
// Make sure we set authentication to false on error
|
||||
authManager.setAuthenticated(false);
|
||||
updateConnectionStatus(`Error: ${err instanceof Error ? err.message : String(err)}`, false);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -322,7 +356,7 @@ async function refreshProfile(): Promise<void> {
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Get DOM elements
|
||||
connectionStatus = document.getElementById('connectionStatus');
|
||||
profileContainer = document.getElementById('profileContainer');
|
||||
@ -354,17 +388,47 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Try to connect automatically
|
||||
if (window.nostr) {
|
||||
// If extension is available, try to connect with it
|
||||
connectWithExtension().catch(error => {
|
||||
try {
|
||||
const pubkey = await connectWithExtension();
|
||||
|
||||
if (pubkey) {
|
||||
// We already set the auth state in connectWithExtension, but let's log success
|
||||
console.log(`Successfully authenticated with pubkey: ${pubkey.substring(0, 8)}...`);
|
||||
|
||||
// Store the session expiry in localStorage for UI purposes
|
||||
const secureStorage = new SecureStorageService();
|
||||
const session = authManager.authService.getCurrentSession();
|
||||
if (session) {
|
||||
secureStorage.set('session_info', {
|
||||
expiresAt: session.expiresAt,
|
||||
createdAt: session.createdAt
|
||||
});
|
||||
|
||||
console.log(`Session expires at ${new Date(session.expiresAt).toLocaleString()}`);
|
||||
}
|
||||
|
||||
// Make double sure the auth manager has the correct state
|
||||
authManager.updateAuthStateFromStorage();
|
||||
console.log('Auth state after connection:', authManager.isAuthenticated() ? 'Authenticated' : 'Not authenticated');
|
||||
} else {
|
||||
console.warn('Failed to get pubkey from extension');
|
||||
authManager.setAuthenticated(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error auto-connecting with extension:', error);
|
||||
// Clear authentication state on error
|
||||
authManager.setAuthenticated(false);
|
||||
// Show profile container even if connection fails
|
||||
if (profileContainer) {
|
||||
profileContainer.classList.remove('hidden');
|
||||
updateProfileWithDefaults();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Even without an extension, show the profile container with default information
|
||||
console.log('No Nostr extension available, showing default profile');
|
||||
// Ensure authentication state is cleared
|
||||
authManager.setAuthenticated(false);
|
||||
if (profileContainer) {
|
||||
profileContainer.classList.remove('hidden');
|
||||
updateProfileWithDefaults();
|
||||
|
461
client/src/services/AuthenticationService.ts
Normal file
461
client/src/services/AuthenticationService.ts
Normal file
@ -0,0 +1,461 @@
|
||||
/**
|
||||
* AuthenticationService.ts
|
||||
*
|
||||
* Provides authentication services using Nostr protocol.
|
||||
* Implements NIP-98 (HTTP Auth) for authenticated requests.
|
||||
*/
|
||||
|
||||
import * as nostrTools from 'nostr-tools';
|
||||
import { SecureStorageService } from './SecureStorageService';
|
||||
|
||||
/**
|
||||
* Interface for auth session data
|
||||
*/
|
||||
export interface AuthSession {
|
||||
/** User's public key in hex format */
|
||||
pubkey: string;
|
||||
/** User's public key in bech32/npub format */
|
||||
npub: string;
|
||||
/** Signature proving ownership */
|
||||
signature: string;
|
||||
/** When the session was created (timestamp) */
|
||||
createdAt: number;
|
||||
/** When the session expires (timestamp) */
|
||||
expiresAt: number;
|
||||
/** Optional refresh token for extending the session */
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for authentication operation results
|
||||
*/
|
||||
export interface AuthResult {
|
||||
/** Whether the operation was successful */
|
||||
success: boolean;
|
||||
/** The session if successful */
|
||||
session?: AuthSession;
|
||||
/** Error message if unsuccessful */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for NIP-98 HTTP Auth event
|
||||
*/
|
||||
export interface Nip98AuthEvent extends nostrTools.Event {
|
||||
/** Event tags including method, uri, etc. */
|
||||
tags: string[][];
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP method types for NIP-98
|
||||
*/
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'OPTIONS';
|
||||
|
||||
/**
|
||||
* Main authentication service class
|
||||
*/
|
||||
export class AuthenticationService {
|
||||
private secureStorage: SecureStorageService;
|
||||
private currentSession: AuthSession | null = null;
|
||||
|
||||
// Storage keys
|
||||
private readonly SESSION_KEY = 'authSession';
|
||||
private readonly REFRESH_TOKEN_KEY = 'refreshToken';
|
||||
|
||||
// Default duration (24 hours) in milliseconds
|
||||
private readonly SESSION_DURATION = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Event name for authentication state changes
|
||||
private readonly AUTH_STATE_CHANGED_EVENT = 'auth-state-changed';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
constructor() {
|
||||
this.secureStorage = new SecureStorageService();
|
||||
this.loadSessionFromStorage();
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with Nostr extension and create a signed session
|
||||
*
|
||||
* @returns Promise resolving to authentication result
|
||||
*/
|
||||
public async authenticate(): Promise<AuthResult> {
|
||||
try {
|
||||
// Check if Nostr extension exists
|
||||
if (!window.nostr) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No Nostr extension detected. Please install a NIP-07 compatible extension.'
|
||||
};
|
||||
}
|
||||
|
||||
// Get public key from extension
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
if (!pubkey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to get public key from extension.'
|
||||
};
|
||||
}
|
||||
|
||||
// Generate a challenge the user must sign to prove key ownership
|
||||
const challenge = `Authenticate for HTTP-over-Nostr at ${window.location.hostname} at ${Date.now()}`;
|
||||
|
||||
// Ask user to sign the challenge
|
||||
let signature: string;
|
||||
try {
|
||||
// Use signEvent since signSchnorr may not be available in all extensions
|
||||
const challengeEvent = {
|
||||
kind: 27235, // NIP-98 HTTP Auth kind
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['challenge', challenge]],
|
||||
content: '',
|
||||
pubkey
|
||||
};
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(challengeEvent);
|
||||
signature = signedEvent.sig || '';
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to sign authentication challenge. User may have rejected the request.'
|
||||
};
|
||||
}
|
||||
|
||||
// Assume signature is valid if the extension signed it
|
||||
// The extension handles the verification internally
|
||||
const isValid = !!signature;
|
||||
|
||||
if (!isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Signature verification failed.'
|
||||
};
|
||||
}
|
||||
|
||||
// Create session
|
||||
const now = Date.now();
|
||||
const session: AuthSession = {
|
||||
pubkey,
|
||||
npub: nostrTools.nip19.npubEncode(pubkey),
|
||||
signature,
|
||||
createdAt: now,
|
||||
expiresAt: now + this.SESSION_DURATION,
|
||||
refreshToken: this.generateRefreshToken()
|
||||
};
|
||||
|
||||
// Store session
|
||||
this.currentSession = session;
|
||||
this.persistSession(session);
|
||||
|
||||
// Notify listeners
|
||||
this.notifyAuthStateChanged(true, pubkey);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
session
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown authentication error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is currently authenticated with a valid session
|
||||
*
|
||||
* @returns True if authenticated with a valid session
|
||||
*/
|
||||
public isAuthenticated(): boolean {
|
||||
if (!this.currentSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if session is expired
|
||||
if (this.currentSession.expiresAt < Date.now()) {
|
||||
// Try to refresh the session if possible
|
||||
if (this.currentSession.refreshToken) {
|
||||
this.refreshSession().catch(error => {
|
||||
console.error('Failed to refresh session:', error);
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session if authenticated
|
||||
*
|
||||
* @returns The current session or null if not authenticated
|
||||
*/
|
||||
public getCurrentSession(): AuthSession | null {
|
||||
if (this.isAuthenticated()) {
|
||||
return this.currentSession;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user's public key
|
||||
*
|
||||
* @returns The user's public key or null if not authenticated
|
||||
*/
|
||||
public getCurrentUserPubkey(): string | null {
|
||||
return this.currentSession?.pubkey || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout the current user
|
||||
*/
|
||||
public logout(): void {
|
||||
const wasPreviouslyAuthenticated = this.isAuthenticated();
|
||||
const previousPubkey = this.getCurrentUserPubkey();
|
||||
|
||||
this.currentSession = null;
|
||||
this.secureStorage.remove(this.SESSION_KEY);
|
||||
this.secureStorage.remove(this.REFRESH_TOKEN_KEY);
|
||||
|
||||
// Only notify if there was an actual state change
|
||||
if (wasPreviouslyAuthenticated && previousPubkey) {
|
||||
this.notifyAuthStateChanged(false, previousPubkey);
|
||||
} else if (wasPreviouslyAuthenticated) {
|
||||
this.notifyAuthStateChanged(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signed NIP-98 HTTP Auth event
|
||||
*
|
||||
* @param method HTTP method
|
||||
* @param url Request URL
|
||||
* @param payload Optional request payload/body
|
||||
* @returns Promise resolving to signed event or null if failed
|
||||
*/
|
||||
public async createAuthEvent(
|
||||
method: HttpMethod,
|
||||
url: string,
|
||||
payload?: string
|
||||
): Promise<Nip98AuthEvent | null> {
|
||||
try {
|
||||
// Ensure user is authenticated
|
||||
if (!this.isAuthenticated() || !window.nostr) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse URL to get hostname and path
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// Create tags according to NIP-98
|
||||
const tags: string[][] = [
|
||||
['method', method],
|
||||
['uri', url],
|
||||
];
|
||||
|
||||
// Add content-type and digest for POST and PUT requests
|
||||
if ((method === 'POST' || method === 'PUT') && payload) {
|
||||
tags.push(['payload', payload]);
|
||||
tags.push(['content-type', 'application/json']);
|
||||
|
||||
// Add SHA-256 digest of the payload
|
||||
const digest = await this.sha256Digest(payload);
|
||||
tags.push(['digest', `SHA-256=${digest}`]);
|
||||
}
|
||||
|
||||
// Add created timestamp
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
tags.push(['created', created.toString()]);
|
||||
|
||||
// Create the event
|
||||
const event = {
|
||||
kind: 27235, // NIP-98 HTTP Auth
|
||||
created_at: created,
|
||||
tags,
|
||||
content: '',
|
||||
pubkey: this.currentSession!.pubkey
|
||||
};
|
||||
|
||||
// Sign the event with NIP-07 extension
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
|
||||
return signedEvent as Nip98AuthEvent;
|
||||
} catch (error) {
|
||||
console.error('Error creating NIP-98 auth event:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HTTP headers for NIP-98 authentication
|
||||
*
|
||||
* @param event Signed NIP-98 auth event
|
||||
* @returns Headers object with Authorization header
|
||||
*/
|
||||
public createAuthHeaders(event: Nip98AuthEvent): Headers {
|
||||
const headers = new Headers();
|
||||
const authValue = `Nostr ${JSON.stringify(event)}`;
|
||||
headers.append('Authorization', authValue);
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the current session's expiration
|
||||
*
|
||||
* @param extendBy Milliseconds to extend the session by (defaults to SESSION_DURATION)
|
||||
* @returns True if session was extended
|
||||
*/
|
||||
public extendSession(extendBy?: number): boolean {
|
||||
if (!this.currentSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const extension = extendBy || this.SESSION_DURATION;
|
||||
this.currentSession.expiresAt = Date.now() + extension;
|
||||
this.persistSession(this.currentSession);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for authentication state changes
|
||||
*
|
||||
* @param callback Function to call when auth state changes
|
||||
* @returns Function to remove the listener
|
||||
*/
|
||||
public onAuthStateChanged(
|
||||
callback: (authenticated: boolean, pubkey?: string) => void
|
||||
): () => void {
|
||||
const handler = (event: Event) => {
|
||||
const authEvent = event as CustomEvent;
|
||||
callback(
|
||||
authEvent.detail.authenticated,
|
||||
authEvent.detail.pubkey
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener(this.AUTH_STATE_CHANGED_EVENT, handler);
|
||||
|
||||
// Return function to remove the listener
|
||||
return () => {
|
||||
window.removeEventListener(this.AUTH_STATE_CHANGED_EVENT, handler);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh the current session
|
||||
*
|
||||
* @returns Promise resolving to true if session was refreshed
|
||||
*/
|
||||
private async refreshSession(): Promise<boolean> {
|
||||
// This would typically extend the session without requiring re-authentication
|
||||
// For now, we'll just extend the current session
|
||||
if (!this.currentSession || !this.currentSession.refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const storedRefreshToken = this.secureStorage.get<string>(this.REFRESH_TOKEN_KEY);
|
||||
if (!storedRefreshToken || storedRefreshToken !== this.currentSession.refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extend the session
|
||||
return this.extendSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random refresh token
|
||||
*
|
||||
* @returns A random string to use as refresh token
|
||||
*/
|
||||
private generateRefreshToken(): string {
|
||||
return Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist session to secure storage
|
||||
*
|
||||
* @param session The session to persist
|
||||
*/
|
||||
private persistSession(session: AuthSession): void {
|
||||
this.secureStorage.set(
|
||||
this.SESSION_KEY,
|
||||
session,
|
||||
{ expiresIn: session.expiresAt - Date.now() }
|
||||
);
|
||||
|
||||
if (session.refreshToken) {
|
||||
this.secureStorage.set(
|
||||
this.REFRESH_TOKEN_KEY,
|
||||
session.refreshToken,
|
||||
{ expiresIn: (session.expiresAt - Date.now()) + (7 * 24 * 60 * 60 * 1000) } // Refresh token valid for 1 week longer
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load session from secure storage
|
||||
*/
|
||||
private loadSessionFromStorage(): void {
|
||||
const session = this.secureStorage.get<AuthSession>(this.SESSION_KEY);
|
||||
if (session) {
|
||||
this.currentSession = session;
|
||||
|
||||
// Notify if a valid session is loaded
|
||||
if (this.isAuthenticated()) {
|
||||
this.notifyAuthStateChanged(true, session.pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event handlers
|
||||
*/
|
||||
private setupEventHandlers(): void {
|
||||
// Clean up expired items on page load
|
||||
this.secureStorage.cleanExpired();
|
||||
|
||||
// Setup beforeunload event to save session
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (this.currentSession) {
|
||||
this.persistSession(this.currentSession);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify listeners of authentication state changes
|
||||
*
|
||||
* @param authenticated Whether the user is authenticated
|
||||
* @param pubkey The user's pubkey (if authenticated)
|
||||
*/
|
||||
private notifyAuthStateChanged(authenticated: boolean, pubkey?: string): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(this.AUTH_STATE_CHANGED_EVENT, {
|
||||
detail: { authenticated, pubkey }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate SHA-256
|
||||
*
|
||||
* @param message Message to hash
|
||||
* @returns Base64-encoded hash
|
||||
*/
|
||||
private async sha256Digest(message: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(message);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const base64Hash = btoa(String.fromCharCode(...hashArray));
|
||||
|
||||
return base64Hash;
|
||||
}
|
||||
}
|
@ -52,6 +52,16 @@ export class ClientEventStore {
|
||||
// Event change listeners
|
||||
private listeners: ClientEventChangeListener[] = [];
|
||||
|
||||
// Storage key for localStorage
|
||||
private readonly STORAGE_KEY = 'nostr_client_events';
|
||||
|
||||
/**
|
||||
* Constructor initializes the store and loads any saved events
|
||||
*/
|
||||
constructor() {
|
||||
this.loadFromStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an outgoing KIND 21120 event to the store
|
||||
* @param event The Nostr event to add
|
||||
@ -76,6 +86,10 @@ export class ClientEventStore {
|
||||
|
||||
this.outgoingEvents.set(event.id, storedEvent);
|
||||
this.notifyListeners(event.id, EventChangeType.Added);
|
||||
|
||||
// Save to localStorage after adding
|
||||
this.saveToStorage();
|
||||
|
||||
return event.id;
|
||||
}
|
||||
|
||||
@ -85,6 +99,8 @@ export class ClientEventStore {
|
||||
* @returns True if successfully added, false otherwise
|
||||
*/
|
||||
public addResponseEvent(responseEvent: NostrEvent): boolean {
|
||||
console.log(`ClientEventStore: Adding response event ${responseEvent.id}`);
|
||||
|
||||
if (!responseEvent.id) {
|
||||
console.error('Response event must have an ID');
|
||||
return false;
|
||||
@ -101,17 +117,38 @@ export class ClientEventStore {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`ClientEventStore: Response ${responseEvent.id} is for request ${requestId}`);
|
||||
|
||||
// Check if we have the outgoing event
|
||||
const outgoingEvent = this.outgoingEvents.get(requestId);
|
||||
if (!outgoingEvent) {
|
||||
console.warn(`Response received for unknown request: ${requestId}`);
|
||||
console.log('Stored request IDs:', Array.from(this.outgoingEvents.keys()).join(', '));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we already have a response for this request
|
||||
if (outgoingEvent.responseId) {
|
||||
console.log(`ClientEventStore: Request ${requestId} already has response ${outgoingEvent.responseId}`);
|
||||
|
||||
// If we're getting a duplicate response, log but still update
|
||||
if (outgoingEvent.responseId === responseEvent.id) {
|
||||
console.log(`ClientEventStore: Duplicate response received, not updating`);
|
||||
return true; // Consider this successful since we already have it
|
||||
} else {
|
||||
console.log(`ClientEventStore: New response received, replacing old one`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`ClientEventStore: Updating request ${requestId} with response ${responseEvent.id}`);
|
||||
|
||||
// Make a deep copy of the response event to ensure it doesn't get modified
|
||||
const responseEventCopy = JSON.parse(JSON.stringify(responseEvent));
|
||||
|
||||
// Update the outgoing event with response data
|
||||
outgoingEvent.status = ClientEventStatus.Responded;
|
||||
outgoingEvent.responseId = responseEvent.id;
|
||||
outgoingEvent.responseEvent = responseEvent;
|
||||
outgoingEvent.responseEvent = responseEventCopy;
|
||||
outgoingEvent.responseReceivedAt = Date.now();
|
||||
|
||||
// Update the outgoing events map
|
||||
@ -120,9 +157,32 @@ export class ClientEventStore {
|
||||
// Update the response map
|
||||
this.responseMap.set(responseEvent.id, requestId);
|
||||
|
||||
// Debug log
|
||||
console.log(`ClientEventStore: Updated maps - outgoing events: ${this.outgoingEvents.size}, response map: ${this.responseMap.size}`);
|
||||
|
||||
// Log the updated event to verify the data
|
||||
console.log(`ClientEventStore: Updated event:`, {
|
||||
id: outgoingEvent.id,
|
||||
status: outgoingEvent.status,
|
||||
responseId: outgoingEvent.responseId,
|
||||
responseReceivedAt: outgoingEvent.responseReceivedAt,
|
||||
responseEventPresent: !!outgoingEvent.responseEvent
|
||||
});
|
||||
|
||||
// Extra verification that the response was stored properly
|
||||
const verifyEvent = this.outgoingEvents.get(requestId);
|
||||
if (verifyEvent && verifyEvent.responseEvent) {
|
||||
console.log(`ClientEventStore: Verification - response event is properly stored`);
|
||||
} else {
|
||||
console.error(`ClientEventStore: Verification FAILED - response event not stored properly`);
|
||||
}
|
||||
|
||||
// Notify listeners
|
||||
this.notifyListeners(requestId, EventChangeType.Updated);
|
||||
|
||||
// Save to localStorage after updating
|
||||
this.saveToStorage();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -141,6 +201,10 @@ export class ClientEventStore {
|
||||
storedEvent.status = status;
|
||||
this.outgoingEvents.set(eventId, storedEvent);
|
||||
this.notifyListeners(eventId, EventChangeType.Updated);
|
||||
|
||||
// Save to localStorage after updating
|
||||
this.saveToStorage();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -212,6 +276,9 @@ export class ClientEventStore {
|
||||
// Notify listeners
|
||||
this.notifyListeners(eventId, EventChangeType.Removed);
|
||||
|
||||
// Save to localStorage after removing
|
||||
this.saveToStorage();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -229,6 +296,9 @@ export class ClientEventStore {
|
||||
for (const id of eventIds) {
|
||||
this.notifyListeners(id, EventChangeType.Removed);
|
||||
}
|
||||
|
||||
// Clear localStorage
|
||||
localStorage.removeItem(this.STORAGE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -273,4 +343,83 @@ export class ClientEventStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current state to localStorage
|
||||
*/
|
||||
private saveToStorage(): void {
|
||||
try {
|
||||
// Convert Map objects to serializable format
|
||||
const storageData = {
|
||||
outgoingEvents: Array.from(this.outgoingEvents.entries()),
|
||||
responseMap: Array.from(this.responseMap.entries())
|
||||
};
|
||||
|
||||
// Count events with responses for debugging
|
||||
const eventsWithResponses = Array.from(this.outgoingEvents.values())
|
||||
.filter(e => e.responseId !== undefined).length;
|
||||
|
||||
console.log(`ClientEventStore: Saving to localStorage - ${this.outgoingEvents.size} events (${eventsWithResponses} with responses)`);
|
||||
|
||||
// Stringify and save
|
||||
const jsonData = JSON.stringify(storageData);
|
||||
localStorage.setItem(this.STORAGE_KEY, jsonData);
|
||||
|
||||
console.log(`ClientEventStore: Data saved to localStorage (${jsonData.length} bytes)`);
|
||||
} catch (error) {
|
||||
console.error('Error saving client events to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load stored events from localStorage
|
||||
*/
|
||||
private loadFromStorage(): void {
|
||||
try {
|
||||
console.log(`ClientEventStore: Loading events from localStorage`);
|
||||
const storedData = localStorage.getItem(this.STORAGE_KEY);
|
||||
|
||||
if (!storedData) {
|
||||
console.log(`ClientEventStore: No stored data found`);
|
||||
return; // No stored data
|
||||
}
|
||||
|
||||
console.log(`ClientEventStore: Found stored data (${storedData.length} bytes)`);
|
||||
const parsedData = JSON.parse(storedData);
|
||||
|
||||
// Restore outgoing events
|
||||
if (parsedData.outgoingEvents && Array.isArray(parsedData.outgoingEvents)) {
|
||||
this.outgoingEvents = new Map(parsedData.outgoingEvents);
|
||||
console.log(`ClientEventStore: Restored ${this.outgoingEvents.size} outgoing events`);
|
||||
|
||||
// Debug: Check how many events have responses
|
||||
const eventsWithResponses = Array.from(this.outgoingEvents.values())
|
||||
.filter(e => e.responseId !== undefined).length;
|
||||
console.log(`ClientEventStore: ${eventsWithResponses} events have responses`);
|
||||
|
||||
// Log some sample events for debugging
|
||||
if (this.outgoingEvents.size > 0) {
|
||||
const sampleEvents = Array.from(this.outgoingEvents.values()).slice(0, 3);
|
||||
console.log(`ClientEventStore: Sample restored events:`,
|
||||
sampleEvents.map(e => ({
|
||||
id: e.id,
|
||||
status: e.status,
|
||||
hasResponse: e.responseId !== undefined,
|
||||
responseId: e.responseId
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore response map
|
||||
if (parsedData.responseMap && Array.isArray(parsedData.responseMap)) {
|
||||
this.responseMap = new Map(parsedData.responseMap);
|
||||
console.log(`ClientEventStore: Restored ${this.responseMap.size} response mappings`);
|
||||
}
|
||||
|
||||
console.log(`ClientEventStore: Successfully loaded data from localStorage`);
|
||||
} catch (error) {
|
||||
console.error('Error loading client events from localStorage:', error);
|
||||
}
|
||||
}
|
||||
}
|
@ -9,7 +9,7 @@ import { getServerPubkeyFromEvent } from './NostrUtils';
|
||||
|
||||
export interface CacheEntry {
|
||||
timestamp: number;
|
||||
events: {[key: string]: NostrEvent};
|
||||
events: {[key: string]: NostrEvent[]}; // Change to store an array of events per server pubkey
|
||||
}
|
||||
|
||||
export class EventCache {
|
||||
@ -33,20 +33,36 @@ export class EventCache {
|
||||
* @param events Events to cache
|
||||
*/
|
||||
public cacheEvents(key: string, events: NostrEvent[]): void {
|
||||
// Create a map of events by server pubkey
|
||||
const eventsMap: {[key: string]: NostrEvent} = {};
|
||||
// Get existing cache entry or create a new one
|
||||
const existingEntry = this.memoryCache.get(key);
|
||||
const currentEventsMap = existingEntry ? existingEntry.events : {};
|
||||
|
||||
// Group events by server pubkey
|
||||
const eventsByPubkey: {[key: string]: NostrEvent[]} = {...currentEventsMap};
|
||||
|
||||
for (const event of events) {
|
||||
const serverPubkey = getServerPubkeyFromEvent(event);
|
||||
if (serverPubkey) {
|
||||
eventsMap[serverPubkey] = event;
|
||||
// Add the event to the array for this server pubkey
|
||||
if (!eventsByPubkey[serverPubkey]) {
|
||||
eventsByPubkey[serverPubkey] = [];
|
||||
}
|
||||
|
||||
// Check if event already exists (by ID) and remove it to avoid duplicates
|
||||
const existingIndex = eventsByPubkey[serverPubkey].findIndex(e => e.id === event.id);
|
||||
if (existingIndex !== -1) {
|
||||
eventsByPubkey[serverPubkey].splice(existingIndex, 1);
|
||||
}
|
||||
|
||||
// Add the new event to the array
|
||||
eventsByPubkey[serverPubkey].push(event);
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const cacheData: CacheEntry = {
|
||||
timestamp,
|
||||
events: eventsMap
|
||||
events: eventsByPubkey
|
||||
};
|
||||
|
||||
// Store in memory cache
|
||||
@ -55,7 +71,7 @@ export class EventCache {
|
||||
// Store in localStorage for persistence
|
||||
try {
|
||||
localStorage.setItem(this.cachePrefix + key, JSON.stringify(cacheData));
|
||||
console.log(`Cached ${Object.keys(eventsMap).length} events for ${key}`);
|
||||
console.log(`Cached events for ${key}. Server pubkeys: ${Object.keys(eventsByPubkey).length}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to store cache in localStorage:', error);
|
||||
// Keep the memory cache even if localStorage fails
|
||||
@ -73,7 +89,8 @@ export class EventCache {
|
||||
// Try memory cache first (fastest)
|
||||
const memoryEntry = this.memoryCache.get(key);
|
||||
if (memoryEntry && now - memoryEntry.timestamp < this.cacheExpiry) {
|
||||
return Object.values(memoryEntry.events);
|
||||
// Flatten the arrays of events
|
||||
return Object.values(memoryEntry.events).flat();
|
||||
}
|
||||
|
||||
// Try localStorage (persists between page navigations)
|
||||
@ -86,7 +103,8 @@ export class EventCache {
|
||||
if (now - parsedData.timestamp < this.cacheExpiry) {
|
||||
// Update memory cache
|
||||
this.memoryCache.set(key, parsedData);
|
||||
return Object.values(parsedData.events);
|
||||
// Flatten the arrays of events
|
||||
return Object.values(parsedData.events).flat();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -35,6 +35,8 @@ export class NostrRelayService {
|
||||
*/
|
||||
public async connectToRelay(relayUrl: string): Promise<boolean> {
|
||||
try {
|
||||
console.log(`==== CONNECTING TO RELAY: ${relayUrl} ====`);
|
||||
|
||||
// If already connected to the requested relay, just return true
|
||||
if (this.isConnected() && this.activeRelayUrl === relayUrl) {
|
||||
console.log(`Already connected to relay: ${relayUrl}`);
|
||||
@ -42,16 +44,24 @@ export class NostrRelayService {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('Current connection status:', this.isConnected() ? 'Connected' : 'Disconnected');
|
||||
console.log('Current active relay:', this.activeRelayUrl || 'None');
|
||||
|
||||
this.updateStatus('Connecting to relay...', 'connecting');
|
||||
|
||||
// Test the connection first
|
||||
console.log('Testing connection to relay...');
|
||||
const connectionSuccess = await this.wsManager.testConnection(relayUrl);
|
||||
console.log('Connection test result:', connectionSuccess ? 'SUCCESS' : 'FAILED');
|
||||
|
||||
if (!connectionSuccess) {
|
||||
this.updateStatus('Connection failed', 'error');
|
||||
console.log('==== RELAY CONNECTION FAILED ====');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Close existing relay pool if any
|
||||
console.log('Preparing to close existing connections if needed');
|
||||
if (this.relayPool && this.activeRelayUrl) {
|
||||
try {
|
||||
await this.relayPool.close([this.activeRelayUrl]);
|
||||
@ -62,10 +72,12 @@ export class NostrRelayService {
|
||||
}
|
||||
|
||||
// Create new relay pool
|
||||
console.log('Creating new SimplePool and setting active relay URL');
|
||||
this.relayPool = new nostrTools.SimplePool();
|
||||
this.activeRelayUrl = relayUrl;
|
||||
|
||||
this.updateStatus('Connected', 'connected');
|
||||
console.log(`==== RELAY CONNECTION SUCCESSFUL: ${relayUrl} ====`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.updateStatus(
|
||||
|
@ -7,6 +7,8 @@ import type { NostrEvent } from '../relay';
|
||||
|
||||
// Import auth manager to gate network requests
|
||||
import * as authManager from '../auth-manager';
|
||||
// Import NIP-98 HTTP Auth types
|
||||
import type { Nip98AuthEvent } from './AuthenticationService';
|
||||
|
||||
import type { ProfileData } from './NostrCacheService';
|
||||
import { NostrCacheService } from './NostrCacheService';
|
||||
@ -64,12 +66,28 @@ export class NostrService {
|
||||
* @throws Error if not authenticated
|
||||
*/
|
||||
public async connectToRelay(relayUrl: string): Promise<boolean> {
|
||||
// Ensure auth state is up-to-date with localStorage
|
||||
authManager.updateAuthStateFromStorage();
|
||||
|
||||
// Check authentication before allowing network requests
|
||||
if (!authManager.isAuthenticated()) {
|
||||
console.warn('Cannot connect to relay: User not authenticated');
|
||||
throw new Error('Authentication required to connect to relay');
|
||||
}
|
||||
|
||||
// Create NIP-98 authorization headers for relay connection if possible
|
||||
try {
|
||||
const headers = await authManager.createAuthHeaders('GET', relayUrl);
|
||||
if (headers) {
|
||||
// If we got headers, pass them to the relay service
|
||||
// Note: We'll need to modify the RelayService to accept headers later
|
||||
console.log('Using NIP-98 auth for relay connection');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to create NIP-98 auth headers:', error);
|
||||
// Continue without auth headers
|
||||
}
|
||||
|
||||
return this.relayService.connectToRelay(relayUrl);
|
||||
}
|
||||
|
||||
@ -98,12 +116,31 @@ export class NostrService {
|
||||
* @returns A promise that resolves to a NostrSubscription
|
||||
*/
|
||||
public async subscribeToEvents(filter: NostrFilter): Promise<NostrSubscription> {
|
||||
// Ensure auth state is up-to-date with localStorage
|
||||
authManager.updateAuthStateFromStorage();
|
||||
|
||||
// Check authentication before allowing network requests
|
||||
if (!authManager.isAuthenticated()) {
|
||||
console.warn('Cannot subscribe to events: User not authenticated');
|
||||
throw new Error('Authentication required to subscribe to events');
|
||||
}
|
||||
|
||||
// Add signature verification for the subscription if possible
|
||||
try {
|
||||
const relayUrl = this.relayService.getActiveRelayUrl();
|
||||
if (relayUrl) {
|
||||
// Create a NIP-98 auth event specifically for this subscription
|
||||
const authEvent = await authManager.authService.createAuthEvent('GET', relayUrl);
|
||||
if (authEvent) {
|
||||
// We could potentially add this auth info to subscription requests
|
||||
console.log('Created NIP-98 auth event for subscription');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to create auth event for subscription:', error);
|
||||
// Continue without auth event
|
||||
}
|
||||
|
||||
return this.eventService.subscribeToEvents(filter);
|
||||
}
|
||||
|
||||
@ -203,6 +240,9 @@ export class NostrService {
|
||||
existingEventId?: string,
|
||||
customServerPubkey?: string
|
||||
): Promise<{ event: NostrEvent; serverNsec?: string } | null> {
|
||||
// Ensure auth state is up-to-date with localStorage
|
||||
authManager.updateAuthStateFromStorage();
|
||||
|
||||
// Check authentication before allowing network requests
|
||||
if (!authManager.isAuthenticated()) {
|
||||
console.warn('Cannot create/update 31120 event: User not authenticated');
|
||||
@ -310,7 +350,21 @@ export class NostrService {
|
||||
* @returns The public key or null if not found
|
||||
*/
|
||||
public getLoggedInPubkey(): string | null {
|
||||
return localStorage.getItem('userPublicKey');
|
||||
// First check with auth manager if user is authenticated
|
||||
if (!authManager.isAuthenticated()) {
|
||||
console.log('getLoggedInPubkey: User is not authenticated according to auth-manager');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the new method in auth-manager to get the current user's pubkey
|
||||
const pubkey = authManager.getCurrentUserPubkey();
|
||||
if (!pubkey) {
|
||||
console.log('getLoggedInPubkey: No pubkey available, but user is authenticated');
|
||||
// Fall back to the old method
|
||||
return localStorage.getItem('userPublicKey');
|
||||
}
|
||||
|
||||
return pubkey;
|
||||
}
|
||||
|
||||
// Cache methods delegated to NostrCacheService
|
||||
|
183
client/src/services/SecureStorageService.ts
Normal file
183
client/src/services/SecureStorageService.ts
Normal file
@ -0,0 +1,183 @@
|
||||
/**
|
||||
* SecureStorageService.ts
|
||||
*
|
||||
* Provides a secure wrapper around browser storage with encryption capabilities,
|
||||
* expiration handling, and protection against common web vulnerabilities.
|
||||
*/
|
||||
|
||||
export interface StorageOptions {
|
||||
/** Time in milliseconds after which the stored item will expire */
|
||||
expiresIn?: number;
|
||||
/** Whether to encrypt the stored value (default: false) */
|
||||
encrypt?: boolean;
|
||||
}
|
||||
|
||||
export interface StoredItem<T> {
|
||||
/** The stored data */
|
||||
data: T;
|
||||
/** When the item was stored */
|
||||
storedAt: number;
|
||||
/** When the item expires (if applicable) */
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export class SecureStorageService {
|
||||
private readonly storagePrefix = 'nostr_app_';
|
||||
|
||||
/**
|
||||
* Get an item from storage
|
||||
*
|
||||
* @param key The key to retrieve
|
||||
* @returns The stored value or null if not found or expired
|
||||
*/
|
||||
public get<T>(key: string): T | null {
|
||||
try {
|
||||
const fullKey = this.getFullKey(key);
|
||||
const storedValue = localStorage.getItem(fullKey);
|
||||
|
||||
if (!storedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storedItem = JSON.parse(storedValue) as StoredItem<T>;
|
||||
|
||||
// Check if item has expired
|
||||
if (storedItem.expiresAt && storedItem.expiresAt < Date.now()) {
|
||||
// Remove expired item
|
||||
this.remove(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return storedItem.data;
|
||||
} catch (e) {
|
||||
console.error('Storage access error:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an item in storage
|
||||
*
|
||||
* @param key The key to store under
|
||||
* @param value The value to store
|
||||
* @param options Storage options including expiration
|
||||
* @returns True if stored successfully
|
||||
*/
|
||||
public set<T>(key: string, value: T, options: StorageOptions = {}): boolean {
|
||||
try {
|
||||
const fullKey = this.getFullKey(key);
|
||||
|
||||
const storedItem: StoredItem<T> = {
|
||||
data: value,
|
||||
storedAt: Date.now()
|
||||
};
|
||||
|
||||
// Add expiration if specified
|
||||
if (options.expiresIn) {
|
||||
storedItem.expiresAt = storedItem.storedAt + options.expiresIn;
|
||||
}
|
||||
|
||||
// TODO: Implement encryption when options.encrypt is true
|
||||
|
||||
localStorage.setItem(fullKey, JSON.stringify(storedItem));
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Storage write error:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from storage
|
||||
*
|
||||
* @param key The key to remove
|
||||
* @returns True if removed successfully
|
||||
*/
|
||||
public remove(key: string): boolean {
|
||||
try {
|
||||
const fullKey = this.getFullKey(key);
|
||||
localStorage.removeItem(fullKey);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Storage removal error:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if storage has a non-expired item
|
||||
*
|
||||
* @param key The key to check
|
||||
* @returns True if the item exists and is not expired
|
||||
*/
|
||||
public has(key: string): boolean {
|
||||
return this.get(key) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all items created by this service
|
||||
*
|
||||
* @returns True if cleared successfully
|
||||
*/
|
||||
public clearAll(): boolean {
|
||||
try {
|
||||
// Only remove items with our prefix
|
||||
const keys = Object.keys(localStorage);
|
||||
const ourKeys = keys.filter(key => key.startsWith(this.storagePrefix));
|
||||
|
||||
for (const key of ourKeys) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Storage clear error:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean expired items from storage
|
||||
*
|
||||
* @returns The number of items removed
|
||||
*/
|
||||
public cleanExpired(): number {
|
||||
try {
|
||||
const keys = Object.keys(localStorage);
|
||||
const ourKeys = keys.filter(key => key.startsWith(this.storagePrefix));
|
||||
let removedCount = 0;
|
||||
|
||||
for (const fullKey of ourKeys) {
|
||||
const storedValue = localStorage.getItem(fullKey);
|
||||
if (!storedValue) continue;
|
||||
|
||||
try {
|
||||
const storedItem = JSON.parse(storedValue);
|
||||
if (storedItem.expiresAt && storedItem.expiresAt < Date.now()) {
|
||||
localStorage.removeItem(fullKey);
|
||||
removedCount++;
|
||||
}
|
||||
} catch {
|
||||
// If we can't parse the item, consider it corrupt and remove it
|
||||
localStorage.removeItem(fullKey);
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
} catch (e) {
|
||||
console.error('Storage cleanup error:', e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full key with prefix
|
||||
*
|
||||
* @param key The base key
|
||||
* @returns The key with prefix
|
||||
*/
|
||||
private getFullKey(key: string): string {
|
||||
return `${this.storagePrefix}${key}`;
|
||||
}
|
||||
}
|
@ -22,6 +22,7 @@ export class WebSocketManager {
|
||||
public async connect(url: string, options: WebSocketOptions = {}): Promise<WebSocket> {
|
||||
return new Promise<WebSocket>((resolve, reject) => {
|
||||
// Safely close any existing connection first
|
||||
console.log(`WebSocketManager: Closing any existing connections before connecting to ${url}`);
|
||||
this.close();
|
||||
|
||||
let timeoutId: number | undefined;
|
||||
@ -32,7 +33,7 @@ export class WebSocketManager {
|
||||
this.ws = new WebSocket(url);
|
||||
this.connected = false;
|
||||
|
||||
console.log(`Attempting WebSocket connection to: ${url}`);
|
||||
console.log(`WebSocketManager: Attempting WebSocket connection to: ${url}`);
|
||||
|
||||
// Set a timeout for the connection attempt
|
||||
timeoutId = window.setTimeout(() => {
|
||||
@ -48,24 +49,41 @@ export class WebSocketManager {
|
||||
this.ws.onopen = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
this.connected = true;
|
||||
console.log(`WebSocketManager: Connection to ${url} established successfully`);
|
||||
if (options.onOpen && this.ws) {
|
||||
console.log('WebSocketManager: Calling onOpen callback');
|
||||
options.onOpen(this.ws);
|
||||
}
|
||||
resolve(this.ws as WebSocket);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (msg) => {
|
||||
console.log(`WebSocketManager: Message received from ${url}`);
|
||||
if (options.onMessage && typeof msg.data === 'string') {
|
||||
try {
|
||||
const parsedData = JSON.parse(msg.data);
|
||||
const msgType = Array.isArray(parsedData) && parsedData.length > 0 ? parsedData[0] : 'unknown';
|
||||
console.log(`WebSocketManager: Parsed message data type: ${msgType}`);
|
||||
|
||||
// For EVENT messages, log additional details
|
||||
if (msgType === 'EVENT' && Array.isArray(parsedData) && parsedData.length >= 3) {
|
||||
const eventObj = parsedData[2];
|
||||
if (eventObj && eventObj.kind) {
|
||||
console.log(`WebSocketManager: Received event of kind: ${eventObj.kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
options.onMessage(parsedData);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error('WebSocketManager: Error parsing message:', error);
|
||||
console.error('WebSocketManager: Raw message:', msg.data.substring(0, 200));
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (errorEvt) => {
|
||||
console.error(`WebSocketManager: Error on connection to ${url}:`, errorEvt);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options.onError) {
|
||||
options.onError(errorEvt);
|
||||
@ -75,10 +93,20 @@ export class WebSocketManager {
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.ws.onclose = (evt) => {
|
||||
console.log(`WebSocketManager: Connection to ${url} closed. Code: ${evt.code}, Reason: ${evt.reason || 'No reason provided'}, Clean: ${evt.wasClean}`);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
this.connected = false;
|
||||
|
||||
// Log the closing information more thoroughly
|
||||
if (evt.wasClean) {
|
||||
console.log('WebSocketManager: Connection closed cleanly');
|
||||
} else {
|
||||
console.warn('WebSocketManager: Connection closed unexpectedly');
|
||||
}
|
||||
|
||||
if (options.onClose) {
|
||||
console.log('WebSocketManager: Calling onClose callback');
|
||||
options.onClose();
|
||||
}
|
||||
};
|
||||
@ -150,14 +178,19 @@ export class WebSocketManager {
|
||||
*/
|
||||
public close(): void {
|
||||
if (this.ws) {
|
||||
console.log(`WebSocketManager: Closing connection to ${this.url || 'unknown'}`);
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {
|
||||
console.log('WebSocketManager: WebSocket close() called successfully');
|
||||
} catch (error) {
|
||||
console.error('WebSocketManager: Error while closing WebSocket:', error);
|
||||
// Ignore errors when closing WebSocket
|
||||
}
|
||||
this.ws = null;
|
||||
this.connected = false;
|
||||
this.url = null;
|
||||
} else {
|
||||
console.log('WebSocketManager: No active connection to close');
|
||||
}
|
||||
}
|
||||
|
||||
|
16
client/src/types/window.d.ts
vendored
16
client/src/types/window.d.ts
vendored
@ -2,7 +2,7 @@
|
||||
import type { NostrEvent } from '../relay';
|
||||
|
||||
// Define the extension interfaces here to avoid conflicts
|
||||
interface WindowNostrExtension {
|
||||
export interface WindowNostrExtension {
|
||||
/**
|
||||
* Get the user's public key from the Nostr extension
|
||||
* @returns The user's public key as a hex string
|
||||
@ -16,6 +16,14 @@ interface WindowNostrExtension {
|
||||
*/
|
||||
signEvent: (event: Partial<NostrEvent>) => Promise<NostrEvent>;
|
||||
|
||||
/**
|
||||
* Sign an arbitrary string using Schnorr signature
|
||||
* (as per NIP-98 spec)
|
||||
* @param message The message to sign
|
||||
* @returns The signature as a hex string
|
||||
*/
|
||||
signSchnorr?: (message: string) => Promise<string>;
|
||||
|
||||
/**
|
||||
* NIP-44 encryption methods (may not be available in all extensions)
|
||||
*/
|
||||
@ -30,7 +38,11 @@ interface WindowNostrExtension {
|
||||
declare global {
|
||||
// Augment the Window interface but don't redefine the nostr property
|
||||
interface Window {
|
||||
// This is intentionally left empty
|
||||
// Declare the nostr property for TypeScript to recognize it
|
||||
nostr?: WindowNostrExtension;
|
||||
// Add global authentication state tracking
|
||||
authInProgress?: boolean;
|
||||
currentSignedEvent?: NostrEvent;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2922,4 +2922,49 @@ footer {
|
||||
}
|
||||
|
||||
/* Import styles for the 21121 Response Creator component */
|
||||
@import url('./styles/response-creator.css');
|
||||
@import url('./styles/response-creator.css');
|
||||
|
||||
/* Login status indicator */
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
margin-left: 10px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.status-indicator.logged-in {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-indicator.logged-out {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.disabled-button {
|
||||
background-color: #cccccc;
|
||||
color: #666666;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.disabled-button:hover {
|
||||
background-color: #bbbbbb;
|
||||
}
|
||||
|
||||
/* Authentication required message styling */
|
||||
.auth-required-message {
|
||||
background-color: var(--bg-info);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-left: 4px solid var(--accent-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-required-message h3 {
|
||||
color: var(--accent-color);
|
||||
margin-top: 0;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user