You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

1184 lines
35 KiB

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;
}
}