import { SimplePool } from 'nostr-tools/pool'; import { EventStore } from 'applesauce-core'; import { PrivateKeySigner } from 'applesauce-signers'; import { getDefaultRelays, FALLBACK_RELAYS } from "./constants.js"; // Dedicated pool for fallback relay queries (separate from main pool to avoid conflicts) let fallbackPool = null; function getFallbackPool() { if (!fallbackPool) { fallbackPool = new SimplePool(); } return fallbackPool; } // Nostr client wrapper using nostr-tools class NostrClient { constructor() { this.pool = new SimplePool(); this.eventStore = new EventStore(); this.isConnected = false; this.signer = null; // Use dynamic relay list (supports standalone mode) this.relays = [...getDefaultRelays()]; } // Refresh relay list from config (call when relay URL changes) refreshRelays() { const newRelays = getDefaultRelays(); if (JSON.stringify(this.relays) !== JSON.stringify(newRelays)) { console.log("Relay list updated:", newRelays); this.relays = [...newRelays]; } } // Reset client for new relay (close old connections, refresh relay list, create new pool) reset() { console.log("[NostrClient] Resetting for new relay..."); // Close ALL existing connections by destroying the pool if (this.pool) { try { // Close connections to old relays first this.pool.close(this.relays); } catch (e) { console.warn("[NostrClient] Error closing old relay connections:", e); } // Destroy the pool reference completely this.pool = null; } // Create completely fresh pool this.pool = new SimplePool(); this.isConnected = false; // Refresh relay list this.relays = [...getDefaultRelays()]; console.log("[NostrClient] Reset complete, new relays:", this.relays); } async connect() { console.log("Starting connection to", this.relays.length, "relays..."); try { // SimplePool doesn't require explicit connect this.isConnected = true; console.log("✓ Successfully initialized relay pool"); // Wait a bit for connections to stabilize await new Promise((resolve) => setTimeout(resolve, 1000)); } catch (error) { console.error("✗ Connection failed:", error); throw error; } } async connectToRelay(relayUrl) { console.log(`Adding relay: ${relayUrl}`); try { if (!this.relays.includes(relayUrl)) { this.relays.push(relayUrl); } console.log(`✓ Successfully added relay ${relayUrl}`); return true; } catch (error) { console.error(`✗ Failed to add relay ${relayUrl}:`, error); return false; } } subscribe(filters, callback) { console.log("Creating subscription with filters:", filters); const sub = this.pool.subscribeMany( this.relays, filters, { onevent(event) { console.log("Event received:", event); callback(event); }, oneose() { console.log("EOSE received"); window.dispatchEvent(new CustomEvent('nostr-eose', { detail: { subscriptionId: sub.id } })); } } ); return sub; } unsubscribe(subscription) { console.log(`Closing subscription`); if (subscription && subscription.close) { subscription.close(); } } disconnect() { console.log("Disconnecting relay pool"); if (this.pool) { this.pool.close(this.relays); } this.isConnected = false; } // Publish an event async publish(event, specificRelays = null) { if (!this.isConnected) { console.warn("Not connected to any relays, attempting to connect first"); await this.connect(); } try { const relaysToUse = specificRelays || this.relays; const promises = this.pool.publish(relaysToUse, event); await Promise.allSettled(promises); console.log("✓ Event published successfully"); // Store the published event in IndexedDB await putEvents([event]); console.log("Event stored in IndexedDB"); return { success: true, okCount: 1, errorCount: 0 }; } catch (error) { console.error("✗ Failed to publish event:", error); throw error; } } // Get pool for advanced usage getPool() { return this.pool; } // Get event store getEventStore() { return this.eventStore; } // Get signer getSigner() { return this.signer; } // Set signer setSigner(signer) { this.signer = signer; } } // Create a global client instance export const nostrClient = new NostrClient(); // Export the class for creating new instances export { NostrClient }; // Export signer classes export { PrivateKeySigner }; // Export NIP-07 helper export class Nip07Signer { async getPublicKey() { if (window.nostr) { return await window.nostr.getPublicKey(); } throw new Error('NIP-07 extension not found'); } async signEvent(event) { if (window.nostr) { return await window.nostr.signEvent(event); } throw new Error('NIP-07 extension not found'); } async nip04Encrypt(pubkey, plaintext) { if (window.nostr && window.nostr.nip04) { return await window.nostr.nip04.encrypt(pubkey, plaintext); } throw new Error('NIP-07 extension does not support NIP-04'); } async nip04Decrypt(pubkey, ciphertext) { if (window.nostr && window.nostr.nip04) { return await window.nostr.nip04.decrypt(pubkey, ciphertext); } throw new Error('NIP-07 extension does not support NIP-04'); } async nip44Encrypt(pubkey, plaintext) { if (window.nostr && window.nostr.nip44) { return await window.nostr.nip44.encrypt(pubkey, plaintext); } throw new Error('NIP-07 extension does not support NIP-44'); } async nip44Decrypt(pubkey, ciphertext) { if (window.nostr && window.nostr.nip44) { return await window.nostr.nip44.decrypt(pubkey, ciphertext); } throw new Error('NIP-07 extension does not support NIP-44'); } } // Merge two event arrays, deduplicating by event id // Newer events (by created_at) take precedence for same id function mergeAndDeduplicateEvents(cached, relay) { const eventMap = new Map(); // Add cached events first for (const event of cached) { eventMap.set(event.id, event); } // Add/update with relay events (they may be newer) for (const event of relay) { const existing = eventMap.get(event.id); if (!existing || event.created_at >= existing.created_at) { eventMap.set(event.id, event); } } // Return sorted by created_at descending (newest first) return Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at); } // IndexedDB helpers for unified event storage // This provides a local cache that all components can access const DB_NAME = "nostrCache"; const DB_VERSION = 2; // Incremented for new indexes const STORE_EVENTS = "events"; function openDB() { return new Promise((resolve, reject) => { try { const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = (event) => { const db = req.result; const oldVersion = event.oldVersion; // Create or update the events store let store; if (!db.objectStoreNames.contains(STORE_EVENTS)) { store = db.createObjectStore(STORE_EVENTS, { keyPath: "id" }); } else { // Get existing store during upgrade store = req.transaction.objectStore(STORE_EVENTS); } // Create indexes if they don't exist if (!store.indexNames.contains("byKindAuthor")) { store.createIndex("byKindAuthor", ["kind", "pubkey"], { unique: false, }); } if (!store.indexNames.contains("byKindAuthorCreated")) { store.createIndex( "byKindAuthorCreated", ["kind", "pubkey", "created_at"], { unique: false }, ); } if (!store.indexNames.contains("byKind")) { store.createIndex("byKind", "kind", { unique: false }); } if (!store.indexNames.contains("byAuthor")) { store.createIndex("byAuthor", "pubkey", { unique: false }); } if (!store.indexNames.contains("byCreatedAt")) { store.createIndex("byCreatedAt", "created_at", { unique: false }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); } catch (e) { console.error("Failed to open IndexedDB", e); reject(e); } }); } async function getLatestProfileEvent(pubkey) { try { const db = await openDB(); return await new Promise((resolve, reject) => { const tx = db.transaction(STORE_EVENTS, "readonly"); const idx = tx.objectStore(STORE_EVENTS).index("byKindAuthorCreated"); const range = IDBKeyRange.bound( [0, pubkey, -Infinity], [0, pubkey, Infinity], ); const req = idx.openCursor(range, "prev"); // newest first req.onsuccess = () => { const cursor = req.result; resolve(cursor ? cursor.value : null); }; req.onerror = () => reject(req.error); }); } catch (e) { console.warn("IDB getLatestProfileEvent failed", e); return null; } } async function putEvent(event) { try { const db = await openDB(); await new Promise((resolve, reject) => { const tx = db.transaction(STORE_EVENTS, "readwrite"); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.objectStore(STORE_EVENTS).put(event); }); } catch (e) { console.warn("IDB putEvent failed", e); } } // Store multiple events in IndexedDB async function putEvents(events) { if (!events || events.length === 0) return; try { const db = await openDB(); await new Promise((resolve, reject) => { const tx = db.transaction(STORE_EVENTS, "readwrite"); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); const store = tx.objectStore(STORE_EVENTS); for (const event of events) { store.put(event); } }); console.log(`Stored ${events.length} events in IndexedDB`); } catch (e) { console.warn("IDB putEvents failed", e); } } // Query events from IndexedDB by filters async function queryEventsFromDB(filters) { try { const db = await openDB(); const results = []; console.log("QueryEventsFromDB: Starting query with filters:", filters); for (const filter of filters) { console.log("QueryEventsFromDB: Processing filter:", filter); const events = await new Promise((resolve, reject) => { const tx = db.transaction(STORE_EVENTS, "readonly"); const store = tx.objectStore(STORE_EVENTS); const allEvents = []; // Determine which index to use based on filter let req; if (filter.kinds && filter.kinds.length > 0 && filter.authors && filter.authors.length > 0) { // Use byKindAuthor index for the most specific query const kind = filter.kinds[0]; const author = filter.authors[0]; console.log(`QueryEventsFromDB: Using byKindAuthorCreated index for kind=${kind}, author=${author.substring(0, 8)}...`); const idx = store.index("byKindAuthorCreated"); const range = IDBKeyRange.bound( [kind, author, -Infinity], [kind, author, Infinity] ); req = idx.openCursor(range, "prev"); // newest first } else if (filter.kinds && filter.kinds.length > 0) { // Use byKind index console.log(`QueryEventsFromDB: Using byKind index for kind=${filter.kinds[0]}`); const idx = store.index("byKind"); req = idx.openCursor(IDBKeyRange.only(filter.kinds[0])); } else if (filter.authors && filter.authors.length > 0) { // Use byAuthor index console.log(`QueryEventsFromDB: Using byAuthor index for author=${filter.authors[0].substring(0, 8)}...`); const idx = store.index("byAuthor"); req = idx.openCursor(IDBKeyRange.only(filter.authors[0])); } else { // Scan all events console.log("QueryEventsFromDB: Scanning all events (no specific index)"); req = store.openCursor(); } req.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { const evt = cursor.value; // Apply additional filters let matches = true; // Filter by kinds if (filter.kinds && filter.kinds.length > 0 && !filter.kinds.includes(evt.kind)) { matches = false; } // Filter by authors if (filter.authors && filter.authors.length > 0 && !filter.authors.includes(evt.pubkey)) { matches = false; } // Filter by since if (filter.since && evt.created_at < filter.since) { matches = false; } // Filter by until if (filter.until && evt.created_at > filter.until) { matches = false; } // Filter by IDs if (filter.ids && filter.ids.length > 0 && !filter.ids.includes(evt.id)) { matches = false; } if (matches) { allEvents.push(evt); } // Apply limit if (filter.limit && allEvents.length >= filter.limit) { console.log(`QueryEventsFromDB: Reached limit of ${filter.limit}, found ${allEvents.length} matching events`); resolve(allEvents); return; } cursor.continue(); } else { console.log(`QueryEventsFromDB: Cursor exhausted, found ${allEvents.length} matching events`); resolve(allEvents); } }; req.onerror = () => { console.error("QueryEventsFromDB: Cursor error:", req.error); reject(req.error); }; }); console.log(`QueryEventsFromDB: Found ${events.length} events for this filter`); results.push(...events); } // Sort by created_at (newest first) and apply global limit results.sort((a, b) => b.created_at - a.created_at); console.log(`QueryEventsFromDB: Returning ${results.length} total events`); return results; } catch (e) { console.error("QueryEventsFromDB failed:", e); return []; } } function parseProfileFromEvent(event) { try { const profile = JSON.parse(event.content || "{}"); return { name: profile.name || profile.display_name || "", picture: profile.picture || "", banner: profile.banner || "", about: profile.about || "", nip05: profile.nip05 || "", lud16: profile.lud16 || profile.lud06 || "", }; } catch (e) { return { name: "", picture: "", banner: "", about: "", nip05: "", lud16: "", }; } } // Fetch user profile metadata (kind 0) export async function fetchUserProfile(pubkey) { console.log(`Starting profile fetch for pubkey: ${pubkey}`); console.log(`[fetchUserProfile] Current relay list:`, nostrClient.relays); // 1) Try cached profile first and resolve immediately if present try { const cachedEvent = await getLatestProfileEvent(pubkey); if (cachedEvent) { console.log("Using cached profile event"); const profile = parseProfileFromEvent(cachedEvent); return profile; } } catch (e) { console.warn("Failed to load cached profile", e); } const filters = [{ kinds: [0], authors: [pubkey], limit: 1 }]; // 2) Fetch profile from local relay first try { const events = await fetchEvents(filters, { timeout: 10000 }); if (events.length > 0) { const profileEvent = events[0]; console.log("Profile fetched from local relay:", profileEvent); return processProfileEvent(profileEvent, pubkey); } } catch (error) { console.warn("Failed to fetch profile from local relay:", error); } // 3) Try fallback relays if local relay doesn't have the profile console.log("Profile not found on local relay, trying fallback relays:", FALLBACK_RELAYS); try { const profileEvent = await fetchProfileFromFallbackRelays(pubkey, filters); if (profileEvent) { return processProfileEvent(profileEvent, pubkey); } } catch (error) { console.warn("Failed to fetch profile from fallback relays:", error); } // 4) No profile found anywhere console.log("No profile found for pubkey:", pubkey); return null; } // Helper to fetch profile from fallback relays async function fetchProfileFromFallbackRelays(pubkey, filters) { console.log(`[fetchProfileFromFallbackRelays] Querying fallback relays:`, FALLBACK_RELAYS); console.log(`[fetchProfileFromFallbackRelays] Using filters:`, JSON.stringify(filters)); return new Promise((resolve) => { const events = []; const pool = getFallbackPool(); let sub; const timeoutId = setTimeout(() => { if (sub) sub.close(); // Return the most recent profile event if (events.length > 0) { events.sort((a, b) => b.created_at - a.created_at); resolve(events[0]); } else { resolve(null); } }, 5000); sub = pool.subscribeMany( FALLBACK_RELAYS, filters, { onevent(event) { console.log("[fetchProfileFromFallbackRelays] Event received:", event.id?.substring(0, 8), "kind:", event.kind, "pubkey:", event.pubkey?.substring(0, 8)); events.push(event); }, oneose() { console.log(`[fetchProfileFromFallbackRelays] EOSE received, got ${events.length} events`); clearTimeout(timeoutId); if (sub) sub.close(); if (events.length > 0) { events.sort((a, b) => b.created_at - a.created_at); console.log("[fetchProfileFromFallbackRelays] Returning best event:", events[0].id?.substring(0, 8)); resolve(events[0]); } else { console.log("[fetchProfileFromFallbackRelays] No events found"); resolve(null); } } } ); }); } // Helper to process and cache a profile event async function processProfileEvent(profileEvent, pubkey) { // Cache the event await putEvent(profileEvent); // Publish the profile event to the local relay try { console.log("Publishing profile event to local relay:", profileEvent.id); await nostrClient.publish(profileEvent); console.log("Profile event successfully saved to local relay"); } catch (publishError) { console.warn("Failed to publish profile to local relay:", publishError); } // Parse profile data const profile = parseProfileFromEvent(profileEvent); // Notify listeners that an updated profile is available try { if (typeof window !== "undefined" && window.dispatchEvent) { window.dispatchEvent( new CustomEvent("profile-updated", { detail: { pubkey, profile, event: profileEvent }, }), ); } } catch (e) { console.warn("Failed to dispatch profile-updated event", e); } return profile; } // Fetch user's relay list (NIP-65 kind 10002) export async function fetchUserRelayList(pubkey) { console.log(`[nostr] Fetching relay list for pubkey: ${pubkey?.substring(0, 8)}...`); const filters = [{ kinds: [10002], authors: [pubkey], limit: 1 }]; // Try local relay first try { const events = await fetchEvents(filters, { timeout: 10000, useCache: true }); if (events.length > 0) { const relayListEvent = events.sort((a, b) => b.created_at - a.created_at)[0]; console.log("[nostr] Relay list found on local relay"); return parseRelayListFromEvent(relayListEvent); } } catch (error) { console.warn("[nostr] Failed to fetch relay list from local relay:", error); } // Try fallback relays console.log("[nostr] Relay list not found locally, trying fallback relays..."); try { const relayListEvent = await fetchFromFallbackRelays(filters); if (relayListEvent) { // Cache and publish to local relay await putEvent(relayListEvent); try { await nostrClient.publish(relayListEvent); } catch (e) { console.warn("[nostr] Failed to publish relay list to local relay:", e); } return parseRelayListFromEvent(relayListEvent); } } catch (error) { console.warn("[nostr] Failed to fetch relay list from fallback relays:", error); } console.log("[nostr] No relay list found for pubkey"); return null; } // Parse relay list from kind 10002 event function parseRelayListFromEvent(event) { if (!event || event.kind !== 10002) return null; const relays = { read: [], write: [], all: [] }; for (const tag of event.tags) { if (tag[0] === 'r' && tag[1]) { const url = tag[1]; const marker = tag[2]; // 'read', 'write', or undefined (both) if (marker === 'read') { relays.read.push(url); } else if (marker === 'write') { relays.write.push(url); } else { // No marker means both read and write relays.read.push(url); relays.write.push(url); } relays.all.push({ url, read: marker !== 'write', write: marker !== 'read' }); } } console.log(`[nostr] Parsed relay list: ${relays.all.length} relays`); return relays; } // Generic helper to fetch from fallback relays async function fetchFromFallbackRelays(filters) { return new Promise((resolve) => { const events = []; const pool = getFallbackPool(); let sub; const timeoutId = setTimeout(() => { if (sub) sub.close(); if (events.length > 0) { events.sort((a, b) => b.created_at - a.created_at); resolve(events[0]); } else { resolve(null); } }, 5000); sub = pool.subscribeMany( FALLBACK_RELAYS, filters, { onevent(event) { events.push(event); }, oneose() { clearTimeout(timeoutId); if (sub) sub.close(); if (events.length > 0) { events.sort((a, b) => b.created_at - a.created_at); resolve(events[0]); } else { resolve(null); } } } ); }); } // Fetch user's contact list (kind 3) - includes follows and may have relay hints export async function fetchUserContactList(pubkey) { console.log(`[nostr] Fetching contact list for pubkey: ${pubkey?.substring(0, 8)}...`); const filters = [{ kinds: [3], authors: [pubkey], limit: 1 }]; // Try local relay first try { const events = await fetchEvents(filters, { timeout: 10000, useCache: true }); if (events.length > 0) { const contactEvent = events.sort((a, b) => b.created_at - a.created_at)[0]; console.log("[nostr] Contact list found on local relay"); return parseContactListFromEvent(contactEvent); } } catch (error) { console.warn("[nostr] Failed to fetch contact list from local relay:", error); } // Try fallback relays console.log("[nostr] Contact list not found locally, trying fallback relays..."); try { const contactEvent = await fetchFromFallbackRelays(filters); if (contactEvent) { await putEvent(contactEvent); try { await nostrClient.publish(contactEvent); } catch (e) { console.warn("[nostr] Failed to publish contact list to local relay:", e); } return parseContactListFromEvent(contactEvent); } } catch (error) { console.warn("[nostr] Failed to fetch contact list from fallback relays:", error); } console.log("[nostr] No contact list found for pubkey"); return null; } // Parse contact list from kind 3 event function parseContactListFromEvent(event) { if (!event || event.kind !== 3) return null; const follows = []; const relayHints = {}; for (const tag of event.tags) { if (tag[0] === 'p' && tag[1]) { const pubkey = tag[1]; const relayUrl = tag[2] || null; const petname = tag[3] || null; follows.push({ pubkey, relayUrl, petname }); if (relayUrl) { if (!relayHints[relayUrl]) { relayHints[relayUrl] = []; } relayHints[relayUrl].push(pubkey); } } } // Also parse the content field which may contain relay preferences (legacy format) let legacyRelays = {}; try { if (event.content) { legacyRelays = JSON.parse(event.content); } } catch (e) { // Content is not JSON, ignore } console.log(`[nostr] Parsed contact list: ${follows.length} follows, ${Object.keys(relayHints).length} relay hints`); return { follows, relayHints, legacyRelays, event }; } // Fetch events export async function fetchEvents(filters, options = {}) { console.log(`Starting event fetch with filters:`, JSON.stringify(filters, null, 2)); console.log(`Current relays:`, nostrClient.relays); // Ensure client is connected if (!nostrClient.isConnected || nostrClient.relays.length === 0) { console.warn("Client not connected, initializing..."); await initializeNostrClient(); } const { timeout = 30000, useCache = true, // Option to query from cache first } = options; // Try to get cached events first if requested let cachedEvents = []; if (useCache) { try { cachedEvents = await queryEventsFromDB(filters); if (cachedEvents.length > 0) { console.log(`Found ${cachedEvents.length} cached events in IndexedDB`); } } catch (e) { console.warn("Failed to query cached events", e); } } return new Promise((resolve, reject) => { const relayEvents = []; let sub = null; const timeoutId = setTimeout(() => { console.log(`Timeout reached after ${timeout}ms, returning ${relayEvents.length} relay events`); if (sub) sub.close(); // Store all received events in IndexedDB before resolving if (relayEvents.length > 0) { putEvents(relayEvents).catch(e => console.warn("Failed to cache events", e)); } // Merge cached events with relay events, deduplicate by id const mergedEvents = mergeAndDeduplicateEvents(cachedEvents, relayEvents); resolve(mergedEvents); }, timeout); try { // Generate a subscription ID for logging const subId = Math.random().toString(36).substring(7); // Validate filters before sending if (!Array.isArray(filters) || filters.length === 0) { console.error(`❌ Invalid filters: not an array or empty`, filters); resolve(cachedEvents); return; } // Ensure each filter is a valid object const validFilters = filters.filter(f => f && typeof f === 'object' && !Array.isArray(f)); if (validFilters.length !== filters.length) { console.warn(`⚠️ Some filters were invalid, filtered ${filters.length} -> ${validFilters.length}`, filters); } if (validFilters.length === 0) { console.error(`❌ No valid filters remaining`); resolve(cachedEvents); return; } console.log(`📤 REQ [${subId}] to ${nostrClient.relays.join(', ')}:`, JSON.stringify(["REQ", subId, ...validFilters], null, 2)); sub = nostrClient.pool.subscribeMany( nostrClient.relays, validFilters, { onevent(event) { console.log(`📥 EVENT received for REQ [${subId}]:`, { id: event.id?.substring(0, 8) + '...', kind: event.kind, pubkey: event.pubkey?.substring(0, 8) + '...', created_at: event.created_at, content_preview: event.content?.substring(0, 50) }); relayEvents.push(event); // Store event immediately in IndexedDB putEvent(event).catch(e => console.warn("Failed to cache event", e)); }, oneose() { console.log(`✅ EOSE received for REQ [${subId}], got ${relayEvents.length} relay events`); clearTimeout(timeoutId); if (sub) sub.close(); // Store all events in IndexedDB before resolving if (relayEvents.length > 0) { putEvents(relayEvents).catch(e => console.warn("Failed to cache events", e)); } // Merge cached events with relay events, deduplicate by id const mergedEvents = mergeAndDeduplicateEvents(cachedEvents, relayEvents); console.log(`Merged ${cachedEvents.length} cached + ${relayEvents.length} relay = ${mergedEvents.length} total events`); resolve(mergedEvents); } } ); } catch (error) { clearTimeout(timeoutId); console.error("Failed to fetch events:", error); reject(error); } }); } // Fetch all events with timestamp-based pagination (including delete events) export async function fetchAllEvents(options = {}) { const { limit = 100, since = null, until = null, authors = null, kinds = null, ...rest } = options; const now = Math.floor(Date.now() / 1000); const thirtyDaysAgo = now - (30 * 24 * 60 * 60); const sixMonthsAgo = now - (180 * 24 * 60 * 60); // Start with 30 days if no since specified const initialSince = since || thirtyDaysAgo; const filters = [{ ...rest }]; filters[0].since = initialSince; if (until) filters[0].until = until; if (authors) filters[0].authors = authors; if (kinds) filters[0].kinds = kinds; if (limit) filters[0].limit = limit; let events = await fetchEvents(filters, { timeout: 30000 }); // If we got few results and weren't already using a longer window, retry with 6 months const fewResultsThreshold = Math.min(20, limit / 2); if (events.length < fewResultsThreshold && initialSince > sixMonthsAgo && !since) { console.log(`[fetchAllEvents] Only got ${events.length} events, retrying with 6-month window...`); filters[0].since = sixMonthsAgo; events = await fetchEvents(filters, { timeout: 30000 }); console.log(`[fetchAllEvents] 6-month window returned ${events.length} events`); } return events; } // Fetch user's events with timestamp-based pagination export async function fetchUserEvents(pubkey, options = {}) { const { limit = 100, since = null, until = null } = options; const filters = [{ authors: [pubkey] }]; if (since) filters[0].since = since; if (until) filters[0].until = until; if (limit) filters[0].limit = limit; const events = await fetchEvents(filters, { timeout: 30000 }); return events; } // NIP-50 search function export async function searchEvents(searchQuery, options = {}) { const { limit = 100, since = null, until = null, kinds = null } = options; const filters = [{ search: searchQuery }]; if (since) filters[0].since = since; if (until) filters[0].until = until; if (kinds) filters[0].kinds = kinds; if (limit) filters[0].limit = limit; const events = await fetchEvents(filters, { timeout: 30000 }); return events; } // Fetch a specific event by ID export async function fetchEventById(eventId, options = {}) { const { timeout = 10000, } = options; console.log(`Fetching event by ID: ${eventId}`); try { const filters = [{ ids: [eventId] }]; console.log('Fetching event with filters:', filters); const events = await fetchEvents(filters, { timeout }); console.log(`Fetched ${events.length} events`); // Return the first event if found, null otherwise return events.length > 0 ? events[0] : null; } catch (error) { console.error("Failed to fetch event by ID:", error); throw error; } } // Fetch delete events that target a specific event ID export async function fetchDeleteEventsByTarget(eventId, options = {}) { const { timeout = 10000 } = options; console.log(`Fetching delete events for target: ${eventId}`); try { const filters = [{ kinds: [5], // Kind 5 is deletion '#e': [eventId] // e-tag referencing the target event }]; console.log('Fetching delete events with filters:', filters); const events = await fetchEvents(filters, { timeout }); console.log(`Fetched ${events.length} delete events`); return events; } catch (error) { console.error("Failed to fetch delete events:", error); throw error; } } // Initialize client connection export async function initializeNostrClient() { // Refresh relay list to pick up any changes (important for standalone mode) nostrClient.refreshRelays(); await nostrClient.connect(); } // Query events from cache and relay combined // This is the main function components should use export async function queryEvents(filters, options = {}) { const { timeout = 30000, cacheFirst = true, // Try cache first before hitting relay cacheOnly = false, // Only use cache, don't query relay } = options; let cachedEvents = []; // Try cache first if (cacheFirst || cacheOnly) { try { cachedEvents = await queryEventsFromDB(filters); console.log(`Found ${cachedEvents.length} events in cache`); if (cacheOnly || cachedEvents.length > 0) { return cachedEvents; } } catch (e) { console.warn("Failed to query cache", e); } } // If cache didn't have results and we're not cache-only, query relay if (!cacheOnly) { const relayEvents = await fetchEvents(filters, { timeout, useCache: false }); console.log(`Fetched ${relayEvents.length} events from relay`); return relayEvents; } return cachedEvents; } // Export cache query function for direct access export { queryEventsFromDB }; // Clear the IndexedDB cache (call when switching relays) export async function clearIndexedDBCache() { console.log("[nostr] Clearing IndexedDB cache..."); try { const db = await openDB(); const tx = db.transaction(STORE_EVENTS, "readwrite"); const store = tx.objectStore(STORE_EVENTS); await new Promise((resolve, reject) => { const req = store.clear(); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); console.log("[nostr] IndexedDB cache cleared"); } catch (e) { console.warn("[nostr] Failed to clear IndexedDB cache", e); } } // Debug function to check database contents export async function debugIndexedDB() { try { const db = await openDB(); const tx = db.transaction(STORE_EVENTS, "readonly"); const store = tx.objectStore(STORE_EVENTS); const allEvents = await new Promise((resolve, reject) => { const req = store.getAll(); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); const byKind = allEvents.reduce((acc, e) => { acc[e.kind] = (acc[e.kind] || 0) + 1; return acc; }, {}); console.log("===== IndexedDB Contents ====="); console.log(`Total events: ${allEvents.length}`); console.log("Events by kind:", byKind); console.log("Kind 0 events:", allEvents.filter(e => e.kind === 0)); console.log("All event IDs:", allEvents.map(e => ({ id: e.id.substring(0, 8), kind: e.kind, pubkey: e.pubkey.substring(0, 8) }))); console.log("=============================="); return { total: allEvents.length, byKind, events: allEvents }; } catch (e) { console.error("Failed to debug IndexedDB:", e); return null; } }