nostr.js raw

   1  import { SimplePool } from 'nostr-tools/pool';
   2  import { EventStore } from 'applesauce-core';
   3  import { PrivateKeySigner } from 'applesauce-signers';
   4  import { getDefaultRelays, FALLBACK_RELAYS } from "./constants.js";
   5  
   6  // Dedicated pool for fallback relay queries (separate from main pool to avoid conflicts)
   7  let fallbackPool = null;
   8  
   9  function getFallbackPool() {
  10    if (!fallbackPool) {
  11      fallbackPool = new SimplePool();
  12    }
  13    return fallbackPool;
  14  }
  15  
  16  // Nostr client wrapper using nostr-tools
  17  class NostrClient {
  18    constructor() {
  19      this.pool = new SimplePool();
  20      this.eventStore = new EventStore();
  21      this.isConnected = false;
  22      this.signer = null;
  23      this.authenticatedRelays = new Set();  // Track relays we've authed to
  24      // Use dynamic relay list (supports standalone mode)
  25      this.relays = [...getDefaultRelays()];
  26    }
  27  
  28    // Refresh relay list from config (call when relay URL changes)
  29    refreshRelays() {
  30      const newRelays = getDefaultRelays();
  31      if (JSON.stringify(this.relays) !== JSON.stringify(newRelays)) {
  32        console.log("Relay list updated:", newRelays);
  33        this.relays = [...newRelays];
  34      }
  35    }
  36  
  37    // Reset client for new relay (close old connections, refresh relay list, create new pool)
  38    reset() {
  39      console.log("[NostrClient] Resetting for new relay...");
  40      // Close ALL existing connections by destroying the pool
  41      if (this.pool) {
  42        try {
  43          // Close connections to old relays first
  44          this.pool.close(this.relays);
  45        } catch (e) {
  46          console.warn("[NostrClient] Error closing old relay connections:", e);
  47        }
  48        // Destroy the pool reference completely
  49        this.pool = null;
  50      }
  51      // Create completely fresh pool
  52      this.pool = new SimplePool();
  53      this.isConnected = false;
  54      // Refresh relay list
  55      this.relays = [...getDefaultRelays()];
  56      console.log("[NostrClient] Reset complete, new relays:", this.relays);
  57    }
  58  
  59    async connect() {
  60      console.log("Starting connection to", this.relays.length, "relays...");
  61      
  62      try {
  63        // SimplePool doesn't require explicit connect
  64        this.isConnected = true;
  65        console.log("✓ Successfully initialized relay pool");
  66        
  67        // Wait a bit for connections to stabilize
  68        await new Promise((resolve) => setTimeout(resolve, 1000));
  69      } catch (error) {
  70        console.error("✗ Connection failed:", error);
  71        throw error;
  72      }
  73    }
  74  
  75    async connectToRelay(relayUrl) {
  76      console.log(`Adding relay: ${relayUrl}`);
  77      
  78      try {
  79        if (!this.relays.includes(relayUrl)) {
  80          this.relays.push(relayUrl);
  81        }
  82        console.log(`✓ Successfully added relay ${relayUrl}`);
  83        return true;
  84      } catch (error) {
  85        console.error(`✗ Failed to add relay ${relayUrl}:`, error);
  86        return false;
  87      }
  88    }
  89  
  90    subscribe(filters, callback) {
  91      console.log("Creating subscription with filters:", filters);
  92      
  93      const sub = this.pool.subscribeMany(
  94        this.relays,
  95        filters,
  96        {
  97          onevent(event) {
  98            console.log("Event received:", event);
  99            callback(event);
 100          },
 101          oneose() {
 102            console.log("EOSE received");
 103            window.dispatchEvent(new CustomEvent('nostr-eose', {
 104              detail: { subscriptionId: sub.id }
 105            }));
 106          }
 107        }
 108      );
 109  
 110      return sub;
 111    }
 112  
 113    unsubscribe(subscription) {
 114      console.log(`Closing subscription`);
 115      if (subscription && subscription.close) {
 116        subscription.close();
 117      }
 118    }
 119  
 120    disconnect() {
 121      console.log("Disconnecting relay pool");
 122      if (this.pool) {
 123        this.pool.close(this.relays);
 124      }
 125      this.isConnected = false;
 126    }
 127  
 128    // Authenticate to a relay using NIP-42
 129    async authenticateToRelay(relayUrl) {
 130      if (!this.signer) {
 131        console.warn("No signer available for auth");
 132        return false;
 133      }
 134      if (this.authenticatedRelays.has(relayUrl)) {
 135        return true;  // Already authenticated
 136      }
 137  
 138      try {
 139        const relay = await this.pool.ensureRelay(relayUrl);
 140  
 141        // Create NIP-42 AUTH event
 142        const authEvent = {
 143          kind: 22242,
 144          created_at: Math.floor(Date.now() / 1000),
 145          tags: [
 146            ["relay", relayUrl],
 147            ["challenge", relay.challenge || ""],
 148          ],
 149          content: "",
 150        };
 151  
 152        const signedAuth = await this.signer.signEvent(authEvent);
 153  
 154        // Send AUTH message
 155        await relay.auth(signedAuth);
 156        this.authenticatedRelays.add(relayUrl);
 157        console.log("✓ Authenticated to relay:", relayUrl);
 158        return true;
 159      } catch (err) {
 160        console.warn("✗ Failed to authenticate to relay:", relayUrl, err);
 161        return false;
 162      }
 163    }
 164  
 165    // Publish an event with automatic auth handling
 166    async publish(event, specificRelays = null) {
 167      if (!this.isConnected) {
 168        console.warn("Not connected to any relays, attempting to connect first");
 169        await this.connect();
 170      }
 171  
 172      const relaysToUse = specificRelays || this.relays;
 173  
 174      // First attempt
 175      let results = await this._tryPublish(event, relaysToUse);
 176  
 177      // Check for auth-required errors and retry
 178      const authRequiredRelays = [];
 179      for (let i = 0; i < results.length; i++) {
 180        const result = results[i];
 181        if (result.status === 'rejected' &&
 182            result.reason?.message?.includes('auth-required')) {
 183          authRequiredRelays.push(relaysToUse[i]);
 184        }
 185      }
 186  
 187      // If any relays need auth, authenticate and retry
 188      if (authRequiredRelays.length > 0 && this.signer) {
 189        console.log("Auth required for relays:", authRequiredRelays);
 190        for (const relayUrl of authRequiredRelays) {
 191          await this.authenticateToRelay(relayUrl);
 192        }
 193        // Retry publish to auth-required relays
 194        const retryResults = await this._tryPublish(event, authRequiredRelays);
 195        // Merge retry results back
 196        for (let i = 0; i < authRequiredRelays.length; i++) {
 197          const originalIdx = relaysToUse.indexOf(authRequiredRelays[i]);
 198          if (originalIdx >= 0) {
 199            results[originalIdx] = retryResults[i];
 200          }
 201        }
 202      }
 203  
 204      // Count successes and failures
 205      let okCount = 0;
 206      let errorCount = 0;
 207      for (const result of results) {
 208        if (result.status === 'fulfilled') {
 209          okCount++;
 210          console.log("✓ Event accepted by relay");
 211        } else {
 212          errorCount++;
 213          console.warn("✗ Relay rejected event:", result.reason);
 214        }
 215      }
 216  
 217      if (okCount === 0) {
 218        throw new Error(`Event rejected by all ${errorCount} relays`);
 219      }
 220  
 221      console.log(`✓ Event published: ${okCount} OK, ${errorCount} failed`);
 222  
 223      // Store the published event in IndexedDB
 224      await putEvents([event]);
 225      console.log("Event stored in IndexedDB");
 226  
 227      return { success: true, okCount, errorCount, event };
 228    }
 229  
 230    // Internal: try publishing to relays
 231    async _tryPublish(event, relays) {
 232      try {
 233        const promises = this.pool.publish(relays, event);
 234        return await Promise.allSettled(promises);
 235      } catch (error) {
 236        console.error("✗ Failed to publish event:", error);
 237        throw error;
 238      }
 239    }
 240  
 241    // NIP-45 COUNT query - returns event count for given filters
 242    async countEvents(relayUrl, filters) {
 243      const relay = await this.pool.ensureRelay(relayUrl);
 244      return await relay.count(filters);
 245    }
 246  
 247    // Get pool for advanced usage
 248    getPool() {
 249      return this.pool;
 250    }
 251  
 252    // Get event store
 253    getEventStore() {
 254      return this.eventStore;
 255    }
 256  
 257    // Get signer
 258    getSigner() {
 259      return this.signer;
 260    }
 261  
 262    // Set signer
 263    setSigner(signer) {
 264      this.signer = signer;
 265    }
 266  }
 267  
 268  // Create a global client instance
 269  export const nostrClient = new NostrClient();
 270  
 271  // Export the class for creating new instances
 272  export { NostrClient };
 273  
 274  // Export signer classes
 275  export { PrivateKeySigner };
 276  
 277  // Export NIP-07 helper
 278  export class Nip07Signer {
 279    async getPublicKey() {
 280      if (window.nostr) {
 281        return await window.nostr.getPublicKey();
 282      }
 283      throw new Error('NIP-07 extension not found');
 284    }
 285  
 286    async signEvent(event) {
 287      if (window.nostr) {
 288        return await window.nostr.signEvent(event);
 289      }
 290      throw new Error('NIP-07 extension not found');
 291    }
 292  
 293    async nip04Encrypt(pubkey, plaintext) {
 294      if (window.nostr && window.nostr.nip04) {
 295        return await window.nostr.nip04.encrypt(pubkey, plaintext);
 296      }
 297      throw new Error('NIP-07 extension does not support NIP-04');
 298    }
 299  
 300    async nip04Decrypt(pubkey, ciphertext) {
 301      if (window.nostr && window.nostr.nip04) {
 302        return await window.nostr.nip04.decrypt(pubkey, ciphertext);
 303      }
 304      throw new Error('NIP-07 extension does not support NIP-04');
 305    }
 306  
 307    async nip44Encrypt(pubkey, plaintext) {
 308      if (window.nostr && window.nostr.nip44) {
 309        return await window.nostr.nip44.encrypt(pubkey, plaintext);
 310      }
 311      throw new Error('NIP-07 extension does not support NIP-44');
 312    }
 313  
 314    async nip44Decrypt(pubkey, ciphertext) {
 315      if (window.nostr && window.nostr.nip44) {
 316        return await window.nostr.nip44.decrypt(pubkey, ciphertext);
 317      }
 318      throw new Error('NIP-07 extension does not support NIP-44');
 319    }
 320  }
 321  
 322  // Merge two event arrays, deduplicating by event id
 323  // Newer events (by created_at) take precedence for same id
 324  function mergeAndDeduplicateEvents(cached, relay) {
 325    const eventMap = new Map();
 326  
 327    // Add cached events first
 328    for (const event of cached) {
 329      eventMap.set(event.id, event);
 330    }
 331  
 332    // Add/update with relay events (they may be newer)
 333    for (const event of relay) {
 334      const existing = eventMap.get(event.id);
 335      if (!existing || event.created_at >= existing.created_at) {
 336        eventMap.set(event.id, event);
 337      }
 338    }
 339  
 340    // Return sorted by created_at descending (newest first)
 341    return Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at);
 342  }
 343  
 344  // IndexedDB helpers for unified event storage
 345  // This provides a local cache that all components can access
 346  const DB_NAME = "nostrCache";
 347  const DB_VERSION = 2; // Incremented for new indexes
 348  const STORE_EVENTS = "events";
 349  
 350  function openDB() {
 351    return new Promise((resolve, reject) => {
 352      try {
 353        const req = indexedDB.open(DB_NAME, DB_VERSION);
 354        req.onupgradeneeded = (event) => {
 355          const db = req.result;
 356          const oldVersion = event.oldVersion;
 357          
 358          // Create or update the events store
 359          let store;
 360          if (!db.objectStoreNames.contains(STORE_EVENTS)) {
 361            store = db.createObjectStore(STORE_EVENTS, { keyPath: "id" });
 362          } else {
 363            // Get existing store during upgrade
 364            store = req.transaction.objectStore(STORE_EVENTS);
 365          }
 366          
 367          // Create indexes if they don't exist
 368          if (!store.indexNames.contains("byKindAuthor")) {
 369            store.createIndex("byKindAuthor", ["kind", "pubkey"], {
 370              unique: false,
 371            });
 372          }
 373          if (!store.indexNames.contains("byKindAuthorCreated")) {
 374            store.createIndex(
 375              "byKindAuthorCreated",
 376              ["kind", "pubkey", "created_at"],
 377              { unique: false },
 378            );
 379          }
 380          if (!store.indexNames.contains("byKind")) {
 381            store.createIndex("byKind", "kind", { unique: false });
 382          }
 383          if (!store.indexNames.contains("byAuthor")) {
 384            store.createIndex("byAuthor", "pubkey", { unique: false });
 385          }
 386          if (!store.indexNames.contains("byCreatedAt")) {
 387            store.createIndex("byCreatedAt", "created_at", { unique: false });
 388          }
 389        };
 390        req.onsuccess = () => resolve(req.result);
 391        req.onerror = () => reject(req.error);
 392      } catch (e) {
 393        console.error("Failed to open IndexedDB", e);
 394        reject(e);
 395      }
 396    });
 397  }
 398  
 399  async function getLatestProfileEvent(pubkey) {
 400    try {
 401      const db = await openDB();
 402      return await new Promise((resolve, reject) => {
 403        const tx = db.transaction(STORE_EVENTS, "readonly");
 404        const idx = tx.objectStore(STORE_EVENTS).index("byKindAuthorCreated");
 405        const range = IDBKeyRange.bound(
 406          [0, pubkey, -Infinity],
 407          [0, pubkey, Infinity],
 408        );
 409        const req = idx.openCursor(range, "prev"); // newest first
 410        req.onsuccess = () => {
 411          const cursor = req.result;
 412          resolve(cursor ? cursor.value : null);
 413        };
 414        req.onerror = () => reject(req.error);
 415      });
 416    } catch (e) {
 417      console.warn("IDB getLatestProfileEvent failed", e);
 418      return null;
 419    }
 420  }
 421  
 422  async function putEvent(event) {
 423    try {
 424      const db = await openDB();
 425      await new Promise((resolve, reject) => {
 426        const tx = db.transaction(STORE_EVENTS, "readwrite");
 427        tx.oncomplete = () => resolve();
 428        tx.onerror = () => reject(tx.error);
 429        tx.objectStore(STORE_EVENTS).put(event);
 430      });
 431    } catch (e) {
 432      console.warn("IDB putEvent failed", e);
 433    }
 434  }
 435  
 436  // Store multiple events in IndexedDB
 437  async function putEvents(events) {
 438    if (!events || events.length === 0) return;
 439    
 440    try {
 441      const db = await openDB();
 442      await new Promise((resolve, reject) => {
 443        const tx = db.transaction(STORE_EVENTS, "readwrite");
 444        tx.oncomplete = () => resolve();
 445        tx.onerror = () => reject(tx.error);
 446        
 447        const store = tx.objectStore(STORE_EVENTS);
 448        for (const event of events) {
 449          store.put(event);
 450        }
 451      });
 452      console.log(`Stored ${events.length} events in IndexedDB`);
 453    } catch (e) {
 454      console.warn("IDB putEvents failed", e);
 455    }
 456  }
 457  
 458  // Query events from IndexedDB by filters
 459  async function queryEventsFromDB(filters) {
 460    try {
 461      const db = await openDB();
 462      const results = [];
 463      
 464      console.log("QueryEventsFromDB: Starting query with filters:", filters);
 465      
 466      for (const filter of filters) {
 467        console.log("QueryEventsFromDB: Processing filter:", filter);
 468        
 469        const events = await new Promise((resolve, reject) => {
 470          const tx = db.transaction(STORE_EVENTS, "readonly");
 471          const store = tx.objectStore(STORE_EVENTS);
 472          const allEvents = [];
 473          
 474          // Determine which index to use based on filter
 475          let req;
 476          if (filter.kinds && filter.kinds.length > 0 && filter.authors && filter.authors.length > 0) {
 477            // Use byKindAuthor index for the most specific query
 478            const kind = filter.kinds[0];
 479            const author = filter.authors[0];
 480            console.log(`QueryEventsFromDB: Using byKindAuthorCreated index for kind=${kind}, author=${author.substring(0, 8)}...`);
 481            
 482            const idx = store.index("byKindAuthorCreated");
 483            const range = IDBKeyRange.bound(
 484              [kind, author, -Infinity],
 485              [kind, author, Infinity]
 486            );
 487            req = idx.openCursor(range, "prev"); // newest first
 488          } else if (filter.kinds && filter.kinds.length > 0) {
 489            // Use byKind index
 490            console.log(`QueryEventsFromDB: Using byKind index for kind=${filter.kinds[0]}`);
 491            const idx = store.index("byKind");
 492            req = idx.openCursor(IDBKeyRange.only(filter.kinds[0]));
 493          } else if (filter.authors && filter.authors.length > 0) {
 494            // Use byAuthor index
 495            console.log(`QueryEventsFromDB: Using byAuthor index for author=${filter.authors[0].substring(0, 8)}...`);
 496            const idx = store.index("byAuthor");
 497            req = idx.openCursor(IDBKeyRange.only(filter.authors[0]));
 498          } else {
 499            // Scan all events
 500            console.log("QueryEventsFromDB: Scanning all events (no specific index)");
 501            req = store.openCursor();
 502          }
 503          
 504          req.onsuccess = (event) => {
 505            const cursor = event.target.result;
 506            if (cursor) {
 507              const evt = cursor.value;
 508              
 509              // Apply additional filters
 510              let matches = true;
 511              
 512              // Filter by kinds
 513              if (filter.kinds && filter.kinds.length > 0 && !filter.kinds.includes(evt.kind)) {
 514                matches = false;
 515              }
 516              
 517              // Filter by authors
 518              if (filter.authors && filter.authors.length > 0 && !filter.authors.includes(evt.pubkey)) {
 519                matches = false;
 520              }
 521              
 522              // Filter by since
 523              if (filter.since && evt.created_at < filter.since) {
 524                matches = false;
 525              }
 526              
 527              // Filter by until
 528              if (filter.until && evt.created_at > filter.until) {
 529                matches = false;
 530              }
 531              
 532              // Filter by IDs
 533              if (filter.ids && filter.ids.length > 0 && !filter.ids.includes(evt.id)) {
 534                matches = false;
 535              }
 536              
 537              if (matches) {
 538                allEvents.push(evt);
 539              }
 540              
 541              // Apply limit
 542              if (filter.limit && allEvents.length >= filter.limit) {
 543                console.log(`QueryEventsFromDB: Reached limit of ${filter.limit}, found ${allEvents.length} matching events`);
 544                resolve(allEvents);
 545                return;
 546              }
 547              
 548              cursor.continue();
 549            } else {
 550              console.log(`QueryEventsFromDB: Cursor exhausted, found ${allEvents.length} matching events`);
 551              resolve(allEvents);
 552            }
 553          };
 554          
 555          req.onerror = () => {
 556            console.error("QueryEventsFromDB: Cursor error:", req.error);
 557            reject(req.error);
 558          };
 559        });
 560        
 561        console.log(`QueryEventsFromDB: Found ${events.length} events for this filter`);
 562        results.push(...events);
 563      }
 564      
 565      // Sort by created_at (newest first) and apply global limit
 566      results.sort((a, b) => b.created_at - a.created_at);
 567      
 568      console.log(`QueryEventsFromDB: Returning ${results.length} total events`);
 569      return results;
 570    } catch (e) {
 571      console.error("QueryEventsFromDB failed:", e);
 572      return [];
 573    }
 574  }
 575  
 576  function parseProfileFromEvent(event) {
 577    try {
 578      const profile = JSON.parse(event.content || "{}");
 579      return {
 580        name: profile.name || profile.display_name || "",
 581        picture: profile.picture || "",
 582        banner: profile.banner || "",
 583        about: profile.about || "",
 584        nip05: profile.nip05 || "",
 585        lud16: profile.lud16 || profile.lud06 || "",
 586      };
 587    } catch (e) {
 588      return {
 589        name: "",
 590        picture: "",
 591        banner: "",
 592        about: "",
 593        nip05: "",
 594        lud16: "",
 595      };
 596    }
 597  }
 598  
 599  // Fetch user profile metadata (kind 0)
 600  export async function fetchUserProfile(pubkey) {
 601    console.log(`Starting profile fetch for pubkey: ${pubkey}`);
 602    console.log(`[fetchUserProfile] Current relay list:`, nostrClient.relays);
 603  
 604    // 1) Try cached profile first and resolve immediately if present
 605    try {
 606      const cachedEvent = await getLatestProfileEvent(pubkey);
 607      if (cachedEvent) {
 608        console.log("Using cached profile event");
 609        const profile = parseProfileFromEvent(cachedEvent);
 610        return profile;
 611      }
 612    } catch (e) {
 613      console.warn("Failed to load cached profile", e);
 614    }
 615  
 616    const filters = [{
 617      kinds: [0],
 618      authors: [pubkey],
 619      limit: 1
 620    }];
 621  
 622    // 2) Fetch profile from local relay first
 623    try {
 624      const events = await fetchEvents(filters, { timeout: 10000 });
 625  
 626      if (events.length > 0) {
 627        const profileEvent = events[0];
 628        console.log("Profile fetched from local relay:", profileEvent);
 629        return processProfileEvent(profileEvent, pubkey);
 630      }
 631    } catch (error) {
 632      console.warn("Failed to fetch profile from local relay:", error);
 633    }
 634  
 635    // 3) Try fallback relays if local relay doesn't have the profile
 636    console.log("Profile not found on local relay, trying fallback relays:", FALLBACK_RELAYS);
 637    try {
 638      const profileEvent = await fetchProfileFromFallbackRelays(pubkey, filters);
 639      if (profileEvent) {
 640        return processProfileEvent(profileEvent, pubkey);
 641      }
 642    } catch (error) {
 643      console.warn("Failed to fetch profile from fallback relays:", error);
 644    }
 645  
 646    // 4) No profile found anywhere
 647    console.log("No profile found for pubkey:", pubkey);
 648    return null;
 649  }
 650  
 651  // Helper to fetch profile from fallback relays
 652  async function fetchProfileFromFallbackRelays(pubkey, filters) {
 653    console.log(`[fetchProfileFromFallbackRelays] Querying fallback relays:`, FALLBACK_RELAYS);
 654    console.log(`[fetchProfileFromFallbackRelays] Using filters:`, JSON.stringify(filters));
 655    return new Promise((resolve) => {
 656      const events = [];
 657      const pool = getFallbackPool();
 658      let sub;
 659  
 660      const timeoutId = setTimeout(() => {
 661        if (sub) sub.close();
 662        // Return the most recent profile event
 663        if (events.length > 0) {
 664          events.sort((a, b) => b.created_at - a.created_at);
 665          resolve(events[0]);
 666        } else {
 667          resolve(null);
 668        }
 669      }, 5000);
 670  
 671      sub = pool.subscribeMany(
 672        FALLBACK_RELAYS,
 673        filters,
 674        {
 675          onevent(event) {
 676            console.log("[fetchProfileFromFallbackRelays] Event received:", event.id?.substring(0, 8), "kind:", event.kind, "pubkey:", event.pubkey?.substring(0, 8));
 677            events.push(event);
 678          },
 679          oneose() {
 680            console.log(`[fetchProfileFromFallbackRelays] EOSE received, got ${events.length} events`);
 681            clearTimeout(timeoutId);
 682            if (sub) sub.close();
 683            if (events.length > 0) {
 684              events.sort((a, b) => b.created_at - a.created_at);
 685              console.log("[fetchProfileFromFallbackRelays] Returning best event:", events[0].id?.substring(0, 8));
 686              resolve(events[0]);
 687            } else {
 688              console.log("[fetchProfileFromFallbackRelays] No events found");
 689              resolve(null);
 690            }
 691          }
 692        }
 693      );
 694    });
 695  }
 696  
 697  // Helper to process and cache a profile event
 698  async function processProfileEvent(profileEvent, pubkey) {
 699    // Cache the event
 700    await putEvent(profileEvent);
 701  
 702    // Publish the profile event to the local relay
 703    try {
 704      console.log("Publishing profile event to local relay:", profileEvent.id);
 705      await nostrClient.publish(profileEvent);
 706      console.log("Profile event successfully saved to local relay");
 707    } catch (publishError) {
 708      console.warn("Failed to publish profile to local relay:", publishError);
 709    }
 710  
 711    // Parse profile data
 712    const profile = parseProfileFromEvent(profileEvent);
 713  
 714    // Notify listeners that an updated profile is available
 715    try {
 716      if (typeof window !== "undefined" && window.dispatchEvent) {
 717        window.dispatchEvent(
 718          new CustomEvent("profile-updated", {
 719            detail: { pubkey, profile, event: profileEvent },
 720          }),
 721        );
 722      }
 723    } catch (e) {
 724      console.warn("Failed to dispatch profile-updated event", e);
 725    }
 726  
 727    return profile;
 728  }
 729  
 730  // Fetch user's relay list (NIP-65 kind 10002)
 731  export async function fetchUserRelayList(pubkey) {
 732    console.log(`[nostr] Fetching relay list for pubkey: ${pubkey?.substring(0, 8)}...`);
 733  
 734    const filters = [{
 735      kinds: [10002],
 736      authors: [pubkey],
 737      limit: 1
 738    }];
 739  
 740    // Try local relay first
 741    try {
 742      const events = await fetchEvents(filters, { timeout: 10000, useCache: true });
 743      if (events.length > 0) {
 744        const relayListEvent = events.sort((a, b) => b.created_at - a.created_at)[0];
 745        console.log("[nostr] Relay list found on local relay");
 746        return parseRelayListFromEvent(relayListEvent);
 747      }
 748    } catch (error) {
 749      console.warn("[nostr] Failed to fetch relay list from local relay:", error);
 750    }
 751  
 752    // Try fallback relays
 753    console.log("[nostr] Relay list not found locally, trying fallback relays...");
 754    try {
 755      const relayListEvent = await fetchFromFallbackRelays(filters);
 756      if (relayListEvent) {
 757        // Cache and publish to local relay
 758        await putEvent(relayListEvent);
 759        try {
 760          await nostrClient.publish(relayListEvent);
 761        } catch (e) {
 762          console.warn("[nostr] Failed to publish relay list to local relay:", e);
 763        }
 764        return parseRelayListFromEvent(relayListEvent);
 765      }
 766    } catch (error) {
 767      console.warn("[nostr] Failed to fetch relay list from fallback relays:", error);
 768    }
 769  
 770    console.log("[nostr] No relay list found for pubkey");
 771    return null;
 772  }
 773  
 774  // Parse relay list from kind 10002 event
 775  function parseRelayListFromEvent(event) {
 776    if (!event || event.kind !== 10002) return null;
 777  
 778    const relays = {
 779      read: [],
 780      write: [],
 781      all: []
 782    };
 783  
 784    for (const tag of event.tags) {
 785      if (tag[0] === 'r' && tag[1]) {
 786        const url = tag[1];
 787        const marker = tag[2]; // 'read', 'write', or undefined (both)
 788  
 789        if (marker === 'read') {
 790          relays.read.push(url);
 791        } else if (marker === 'write') {
 792          relays.write.push(url);
 793        } else {
 794          // No marker means both read and write
 795          relays.read.push(url);
 796          relays.write.push(url);
 797        }
 798        relays.all.push({ url, read: marker !== 'write', write: marker !== 'read' });
 799      }
 800    }
 801  
 802    console.log(`[nostr] Parsed relay list: ${relays.all.length} relays`);
 803    return relays;
 804  }
 805  
 806  // Generic helper to fetch from fallback relays
 807  async function fetchFromFallbackRelays(filters) {
 808    return new Promise((resolve) => {
 809      const events = [];
 810      const pool = getFallbackPool();
 811      let sub;
 812  
 813      const timeoutId = setTimeout(() => {
 814        if (sub) sub.close();
 815        if (events.length > 0) {
 816          events.sort((a, b) => b.created_at - a.created_at);
 817          resolve(events[0]);
 818        } else {
 819          resolve(null);
 820        }
 821      }, 5000);
 822  
 823      sub = pool.subscribeMany(
 824        FALLBACK_RELAYS,
 825        filters,
 826        {
 827          onevent(event) {
 828            events.push(event);
 829          },
 830          oneose() {
 831            clearTimeout(timeoutId);
 832            if (sub) sub.close();
 833            if (events.length > 0) {
 834              events.sort((a, b) => b.created_at - a.created_at);
 835              resolve(events[0]);
 836            } else {
 837              resolve(null);
 838            }
 839          }
 840        }
 841      );
 842    });
 843  }
 844  
 845  // Fetch user's contact list (kind 3) - includes follows and may have relay hints
 846  export async function fetchUserContactList(pubkey) {
 847    console.log(`[nostr] Fetching contact list for pubkey: ${pubkey?.substring(0, 8)}...`);
 848  
 849    const filters = [{
 850      kinds: [3],
 851      authors: [pubkey],
 852      limit: 1
 853    }];
 854  
 855    // Try local relay first
 856    try {
 857      const events = await fetchEvents(filters, { timeout: 10000, useCache: true });
 858      if (events.length > 0) {
 859        const contactEvent = events.sort((a, b) => b.created_at - a.created_at)[0];
 860        console.log("[nostr] Contact list found on local relay");
 861        return parseContactListFromEvent(contactEvent);
 862      }
 863    } catch (error) {
 864      console.warn("[nostr] Failed to fetch contact list from local relay:", error);
 865    }
 866  
 867    // Try fallback relays
 868    console.log("[nostr] Contact list not found locally, trying fallback relays...");
 869    try {
 870      const contactEvent = await fetchFromFallbackRelays(filters);
 871      if (contactEvent) {
 872        await putEvent(contactEvent);
 873        try {
 874          await nostrClient.publish(contactEvent);
 875        } catch (e) {
 876          console.warn("[nostr] Failed to publish contact list to local relay:", e);
 877        }
 878        return parseContactListFromEvent(contactEvent);
 879      }
 880    } catch (error) {
 881      console.warn("[nostr] Failed to fetch contact list from fallback relays:", error);
 882    }
 883  
 884    console.log("[nostr] No contact list found for pubkey");
 885    return null;
 886  }
 887  
 888  // Parse contact list from kind 3 event
 889  function parseContactListFromEvent(event) {
 890    if (!event || event.kind !== 3) return null;
 891  
 892    const follows = [];
 893    const relayHints = {};
 894  
 895    for (const tag of event.tags) {
 896      if (tag[0] === 'p' && tag[1]) {
 897        const pubkey = tag[1];
 898        const relayUrl = tag[2] || null;
 899        const petname = tag[3] || null;
 900  
 901        follows.push({ pubkey, relayUrl, petname });
 902  
 903        if (relayUrl) {
 904          if (!relayHints[relayUrl]) {
 905            relayHints[relayUrl] = [];
 906          }
 907          relayHints[relayUrl].push(pubkey);
 908        }
 909      }
 910    }
 911  
 912    // Also parse the content field which may contain relay preferences (legacy format)
 913    let legacyRelays = {};
 914    try {
 915      if (event.content) {
 916        legacyRelays = JSON.parse(event.content);
 917      }
 918    } catch (e) {
 919      // Content is not JSON, ignore
 920    }
 921  
 922    console.log(`[nostr] Parsed contact list: ${follows.length} follows, ${Object.keys(relayHints).length} relay hints`);
 923    return { follows, relayHints, legacyRelays, event };
 924  }
 925  
 926  // Fetch events
 927  export async function fetchEvents(filters, options = {}) {
 928    console.log(`Starting event fetch with filters:`, JSON.stringify(filters, null, 2));
 929    console.log(`Current relays:`, nostrClient.relays);
 930  
 931    // Ensure client is connected
 932    if (!nostrClient.isConnected || nostrClient.relays.length === 0) {
 933      console.warn("Client not connected, initializing...");
 934      await initializeNostrClient();
 935    }
 936  
 937    const {
 938      timeout = 30000,
 939      useCache = true, // Option to query from cache first
 940    } = options;
 941  
 942    // Try to get cached events first if requested
 943    let cachedEvents = [];
 944    if (useCache) {
 945      try {
 946        cachedEvents = await queryEventsFromDB(filters);
 947        if (cachedEvents.length > 0) {
 948          console.log(`Found ${cachedEvents.length} cached events in IndexedDB`);
 949        }
 950      } catch (e) {
 951        console.warn("Failed to query cached events", e);
 952      }
 953    }
 954  
 955    return new Promise((resolve, reject) => {
 956      const relayEvents = [];
 957      let sub = null;
 958  
 959      const timeoutId = setTimeout(() => {
 960        console.log(`Timeout reached after ${timeout}ms, returning ${relayEvents.length} relay events`);
 961        if (sub) sub.close();
 962  
 963        // Store all received events in IndexedDB before resolving
 964        if (relayEvents.length > 0) {
 965          putEvents(relayEvents).catch(e => console.warn("Failed to cache events", e));
 966        }
 967  
 968        // Merge cached events with relay events, deduplicate by id
 969        const mergedEvents = mergeAndDeduplicateEvents(cachedEvents, relayEvents);
 970        resolve(mergedEvents);
 971      }, timeout);
 972  
 973      try {
 974        // Generate a subscription ID for logging
 975        const subId = Math.random().toString(36).substring(7);
 976  
 977        // Validate filters before sending
 978        if (!Array.isArray(filters) || filters.length === 0) {
 979          console.error(`❌ Invalid filters: not an array or empty`, filters);
 980          resolve(cachedEvents);
 981          return;
 982        }
 983  
 984        // Ensure each filter is a valid object
 985        const validFilters = filters.filter(f => f && typeof f === 'object' && !Array.isArray(f));
 986        if (validFilters.length !== filters.length) {
 987          console.warn(`⚠️ Some filters were invalid, filtered ${filters.length} -> ${validFilters.length}`, filters);
 988        }
 989  
 990        if (validFilters.length === 0) {
 991          console.error(`❌ No valid filters remaining`);
 992          resolve(cachedEvents);
 993          return;
 994        }
 995  
 996        console.log(`📤 REQ [${subId}] to ${nostrClient.relays.join(', ')}:`, JSON.stringify(["REQ", subId, ...validFilters], null, 2));
 997  
 998        sub = nostrClient.pool.subscribeMany(
 999          nostrClient.relays,
1000          validFilters,
1001          {
1002            onevent(event) {
1003              console.log(`📥 EVENT received for REQ [${subId}]:`, {
1004                id: event.id?.substring(0, 8) + '...',
1005                kind: event.kind,
1006                pubkey: event.pubkey?.substring(0, 8) + '...',
1007                created_at: event.created_at,
1008                content_preview: event.content?.substring(0, 50)
1009              });
1010              relayEvents.push(event);
1011  
1012              // Store event immediately in IndexedDB
1013              putEvent(event).catch(e => console.warn("Failed to cache event", e));
1014            },
1015            oneose() {
1016              console.log(`✅ EOSE received for REQ [${subId}], got ${relayEvents.length} relay events`);
1017              clearTimeout(timeoutId);
1018              if (sub) sub.close();
1019  
1020              // Store all events in IndexedDB before resolving
1021              if (relayEvents.length > 0) {
1022                putEvents(relayEvents).catch(e => console.warn("Failed to cache events", e));
1023              }
1024  
1025              // Merge cached events with relay events, deduplicate by id
1026              const mergedEvents = mergeAndDeduplicateEvents(cachedEvents, relayEvents);
1027              console.log(`Merged ${cachedEvents.length} cached + ${relayEvents.length} relay = ${mergedEvents.length} total events`);
1028              resolve(mergedEvents);
1029            }
1030          }
1031        );
1032      } catch (error) {
1033        clearTimeout(timeoutId);
1034        console.error("Failed to fetch events:", error);
1035        reject(error);
1036      }
1037    });
1038  }
1039  
1040  // Fetch all events with timestamp-based pagination (including delete events)
1041  export async function fetchAllEvents(options = {}) {
1042    const {
1043      limit = 100,
1044      since = null,
1045      until = null,
1046      authors = null,
1047      kinds = null,
1048      ...rest
1049    } = options;
1050  
1051    const now = Math.floor(Date.now() / 1000);
1052    const fiveYearsAgo = now - (5 * 365 * 24 * 60 * 60);
1053  
1054    // Start with 5 years if no since specified
1055    const initialSince = since || fiveYearsAgo;
1056  
1057    const filters = [{ ...rest }];
1058    filters[0].since = initialSince;
1059    if (until) filters[0].until = until;
1060    if (authors) filters[0].authors = authors;
1061    if (kinds) filters[0].kinds = kinds;
1062    if (limit) filters[0].limit = limit;
1063  
1064    const events = await fetchEvents(filters, {
1065      timeout: 30000
1066    });
1067  
1068    return events;
1069  }
1070  
1071  // Fetch user's events with timestamp-based pagination
1072  export async function fetchUserEvents(pubkey, options = {}) {
1073    const {
1074      limit = 100,
1075      since = null,
1076      until = null
1077    } = options;
1078  
1079    const filters = [{
1080      authors: [pubkey]
1081    }];
1082    
1083    if (since) filters[0].since = since;
1084    if (until) filters[0].until = until;
1085    if (limit) filters[0].limit = limit;
1086    
1087    const events = await fetchEvents(filters, { 
1088      timeout: 30000 
1089    });
1090    
1091    return events;
1092  }
1093  
1094  // NIP-50 search function
1095  export async function searchEvents(searchQuery, options = {}) {
1096    const {
1097      limit = 100,
1098      since = null,
1099      until = null,
1100      kinds = null
1101    } = options;
1102  
1103    const filters = [{
1104      search: searchQuery
1105    }];
1106    
1107    if (since) filters[0].since = since;
1108    if (until) filters[0].until = until;
1109    if (kinds) filters[0].kinds = kinds;
1110    if (limit) filters[0].limit = limit;
1111    
1112    const events = await fetchEvents(filters, { 
1113      timeout: 30000 
1114    });
1115    
1116    return events;
1117  }
1118  
1119  // Fetch a specific event by ID
1120  export async function fetchEventById(eventId, options = {}) {
1121    const {
1122      timeout = 10000,
1123    } = options;
1124  
1125    console.log(`Fetching event by ID: ${eventId}`);
1126  
1127    try {
1128      const filters = [{
1129        ids: [eventId]
1130      }];
1131  
1132      console.log('Fetching event with filters:', filters);
1133  
1134      const events = await fetchEvents(filters, { timeout });
1135  
1136      console.log(`Fetched ${events.length} events`);
1137      
1138      // Return the first event if found, null otherwise
1139      return events.length > 0 ? events[0] : null;
1140    } catch (error) {
1141      console.error("Failed to fetch event by ID:", error);
1142      throw error;
1143    }
1144  }
1145  
1146  // Fetch delete events that target a specific event ID
1147  export async function fetchDeleteEventsByTarget(eventId, options = {}) {
1148    const {
1149      timeout = 10000
1150    } = options;
1151  
1152    console.log(`Fetching delete events for target: ${eventId}`);
1153  
1154    try {
1155      const filters = [{
1156        kinds: [5], // Kind 5 is deletion
1157        '#e': [eventId] // e-tag referencing the target event
1158      }];
1159  
1160      console.log('Fetching delete events with filters:', filters);
1161  
1162      const events = await fetchEvents(filters, { timeout });
1163  
1164      console.log(`Fetched ${events.length} delete events`);
1165      
1166      return events;
1167    } catch (error) {
1168      console.error("Failed to fetch delete events:", error);
1169      throw error;
1170    }
1171  }
1172  
1173  // Initialize client connection
1174  export async function initializeNostrClient() {
1175    // Refresh relay list to pick up any changes (important for standalone mode)
1176    nostrClient.refreshRelays();
1177    await nostrClient.connect();
1178  }
1179  
1180  // Query events from cache and relay combined
1181  // This is the main function components should use
1182  export async function queryEvents(filters, options = {}) {
1183    const {
1184      timeout = 30000,
1185      cacheFirst = true, // Try cache first before hitting relay
1186      cacheOnly = false,  // Only use cache, don't query relay
1187    } = options;
1188    
1189    let cachedEvents = [];
1190    
1191    // Try cache first
1192    if (cacheFirst || cacheOnly) {
1193      try {
1194        cachedEvents = await queryEventsFromDB(filters);
1195        console.log(`Found ${cachedEvents.length} events in cache`);
1196        
1197        if (cacheOnly || cachedEvents.length > 0) {
1198          return cachedEvents;
1199        }
1200      } catch (e) {
1201        console.warn("Failed to query cache", e);
1202      }
1203    }
1204    
1205    // If cache didn't have results and we're not cache-only, query relay
1206    if (!cacheOnly) {
1207      const relayEvents = await fetchEvents(filters, { timeout, useCache: false });
1208      console.log(`Fetched ${relayEvents.length} events from relay`);
1209      return relayEvents;
1210    }
1211    
1212    return cachedEvents;
1213  }
1214  
1215  // Export cache query function for direct access
1216  export { queryEventsFromDB };
1217  
1218  // Clear the IndexedDB cache (call when switching relays)
1219  export async function clearIndexedDBCache() {
1220    console.log("[nostr] Clearing IndexedDB cache...");
1221    try {
1222      const db = await openDB();
1223      const tx = db.transaction(STORE_EVENTS, "readwrite");
1224      const store = tx.objectStore(STORE_EVENTS);
1225      await new Promise((resolve, reject) => {
1226        const req = store.clear();
1227        req.onsuccess = () => resolve();
1228        req.onerror = () => reject(req.error);
1229      });
1230      console.log("[nostr] IndexedDB cache cleared");
1231    } catch (e) {
1232      console.warn("[nostr] Failed to clear IndexedDB cache", e);
1233    }
1234  }
1235  
1236  // Debug function to check database contents
1237  export async function debugIndexedDB() {
1238    try {
1239      const db = await openDB();
1240      const tx = db.transaction(STORE_EVENTS, "readonly");
1241      const store = tx.objectStore(STORE_EVENTS);
1242      
1243      const allEvents = await new Promise((resolve, reject) => {
1244        const req = store.getAll();
1245        req.onsuccess = () => resolve(req.result);
1246        req.onerror = () => reject(req.error);
1247      });
1248      
1249      const byKind = allEvents.reduce((acc, e) => {
1250        acc[e.kind] = (acc[e.kind] || 0) + 1;
1251        return acc;
1252      }, {});
1253      
1254      console.log("===== IndexedDB Contents =====");
1255      console.log(`Total events: ${allEvents.length}`);
1256      console.log("Events by kind:", byKind);
1257      console.log("Kind 0 events:", allEvents.filter(e => e.kind === 0));
1258      console.log("All event IDs:", allEvents.map(e => ({ id: e.id.substring(0, 8), kind: e.kind, pubkey: e.pubkey.substring(0, 8) })));
1259      console.log("==============================");
1260      
1261      return {
1262        total: allEvents.length,
1263        byKind,
1264        events: allEvents
1265      };
1266    } catch (e) {
1267      console.error("Failed to debug IndexedDB:", e);
1268      return null;
1269    }
1270  }
1271