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