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.
457 lines
12 KiB
457 lines
12 KiB
import NDK, { NDKPrivateKeySigner, NDKEvent } from '@nostr-dev-kit/ndk'; |
|
import { DEFAULT_RELAYS } from "./constants.js"; |
|
|
|
// NDK-based Nostr client wrapper |
|
class NostrClient { |
|
constructor() { |
|
this.ndk = new NDK({ |
|
explicitRelayUrls: DEFAULT_RELAYS |
|
}); |
|
this.isConnected = false; |
|
} |
|
|
|
async connect() { |
|
console.log("Starting NDK connection to", DEFAULT_RELAYS.length, "relays..."); |
|
|
|
try { |
|
await this.ndk.connect(); |
|
this.isConnected = true; |
|
console.log("✓ NDK successfully connected to relays"); |
|
|
|
// Wait a bit for connections to stabilize |
|
await new Promise((resolve) => setTimeout(resolve, 1000)); |
|
} catch (error) { |
|
console.error("✗ NDK connection failed:", error); |
|
throw error; |
|
} |
|
} |
|
|
|
async connectToRelay(relayUrl) { |
|
console.log(`Adding relay to NDK: ${relayUrl}`); |
|
|
|
try { |
|
// For now, just update the DEFAULT_RELAYS array and reconnect |
|
// This is a simpler approach that avoids replacing the NDK instance |
|
DEFAULT_RELAYS.push(relayUrl); |
|
|
|
// Reconnect with the updated relay list |
|
await this.connect(); |
|
|
|
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 NDK subscription with filters:", filters); |
|
|
|
const subscription = this.ndk.subscribe(filters, { |
|
closeOnEose: true |
|
}); |
|
|
|
subscription.on('event', (event) => { |
|
console.log("Event received via NDK:", event); |
|
callback(event.rawEvent()); |
|
}); |
|
|
|
subscription.on('eose', () => { |
|
console.log("EOSE received via NDK"); |
|
window.dispatchEvent(new CustomEvent('nostr-eose', { |
|
detail: { subscriptionId: subscription.id } |
|
})); |
|
}); |
|
|
|
return subscription.id; |
|
} |
|
|
|
unsubscribe(subscriptionId) { |
|
console.log(`Closing NDK subscription: ${subscriptionId}`); |
|
// NDK handles subscription cleanup automatically |
|
} |
|
|
|
disconnect() { |
|
console.log("Disconnecting NDK"); |
|
// Note: NDK doesn't have a destroy method, just disconnect |
|
if (this.ndk && typeof this.ndk.disconnect === 'function') { |
|
this.ndk.disconnect(); |
|
} |
|
this.isConnected = false; |
|
} |
|
|
|
// Publish an event using NDK |
|
async publish(event) { |
|
console.log("Publishing event via NDK:", event); |
|
|
|
try { |
|
const ndkEvent = new NDKEvent(this.ndk, event); |
|
await ndkEvent.publish(); |
|
console.log("✓ Event published successfully via NDK"); |
|
return { success: true, okCount: 1, errorCount: 0 }; |
|
} catch (error) { |
|
console.error("✗ Failed to publish event via NDK:", error); |
|
throw error; |
|
} |
|
} |
|
|
|
// Get NDK instance for advanced usage |
|
getNDK() { |
|
return this.ndk; |
|
} |
|
|
|
// Get signer from NDK |
|
getSigner() { |
|
return this.ndk.signer; |
|
} |
|
|
|
// Set signer for NDK |
|
setSigner(signer) { |
|
this.ndk.signer = signer; |
|
} |
|
} |
|
|
|
// Create a global client instance |
|
export const nostrClient = new NostrClient(); |
|
|
|
// Export the class for creating new instances |
|
export { NostrClient }; |
|
|
|
// IndexedDB helpers for caching events (kind 0 profiles) |
|
const DB_NAME = "nostrCache"; |
|
const DB_VERSION = 1; |
|
const STORE_EVENTS = "events"; |
|
|
|
function openDB() { |
|
return new Promise((resolve, reject) => { |
|
try { |
|
const req = indexedDB.open(DB_NAME, DB_VERSION); |
|
req.onupgradeneeded = () => { |
|
const db = req.result; |
|
if (!db.objectStoreNames.contains(STORE_EVENTS)) { |
|
const store = db.createObjectStore(STORE_EVENTS, { keyPath: "id" }); |
|
store.createIndex("byKindAuthor", ["kind", "pubkey"], { |
|
unique: false, |
|
}); |
|
store.createIndex( |
|
"byKindAuthorCreated", |
|
["kind", "pubkey", "created_at"], |
|
{ unique: false }, |
|
); |
|
} |
|
}; |
|
req.onsuccess = () => resolve(req.result); |
|
req.onerror = () => reject(req.error); |
|
} catch (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); |
|
} |
|
} |
|
|
|
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) using NDK |
|
export async function fetchUserProfile(pubkey) { |
|
console.log(`Starting profile fetch for pubkey: ${pubkey}`); |
|
|
|
// 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); |
|
} |
|
|
|
// 2) Fetch profile using NDK |
|
try { |
|
const ndk = nostrClient.getNDK(); |
|
const user = ndk.getUser({ hexpubkey: pubkey }); |
|
|
|
// Fetch the latest profile event |
|
const profileEvent = await user.fetchProfile(); |
|
|
|
if (profileEvent) { |
|
console.log("Profile fetched via NDK:", profileEvent); |
|
|
|
// Cache the event |
|
await putEvent(profileEvent.rawEvent()); |
|
|
|
// Parse profile data |
|
const profile = parseProfileFromEvent(profileEvent.rawEvent()); |
|
|
|
// 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.rawEvent() }, |
|
}), |
|
); |
|
} |
|
} catch (e) { |
|
console.warn("Failed to dispatch profile-updated event", e); |
|
} |
|
|
|
return profile; |
|
} else { |
|
throw new Error("No profile found"); |
|
} |
|
} catch (error) { |
|
console.error("Failed to fetch profile via NDK:", error); |
|
throw error; |
|
} |
|
} |
|
|
|
// Fetch events using NDK |
|
export async function fetchEvents(filters, options = {}) { |
|
console.log(`Starting event fetch with filters:`, filters); |
|
|
|
const { |
|
timeout = 30000, |
|
limit = null |
|
} = options; |
|
|
|
try { |
|
const ndk = nostrClient.getNDK(); |
|
|
|
// Add limit to filters if specified |
|
const requestFilters = { ...filters }; |
|
if (limit) { |
|
requestFilters.limit = limit; |
|
} |
|
|
|
console.log('Fetching events via NDK with filters:', requestFilters); |
|
|
|
// Use NDK's fetchEvents method |
|
const events = await ndk.fetchEvents(requestFilters, { |
|
timeout |
|
}); |
|
|
|
console.log(`Fetched ${events.size} events via NDK`); |
|
|
|
// Convert NDK events to raw events |
|
const rawEvents = Array.from(events).map(event => event.rawEvent()); |
|
|
|
return rawEvents; |
|
} catch (error) { |
|
console.error("Failed to fetch events via NDK:", error); |
|
throw error; |
|
} |
|
} |
|
|
|
// Fetch all events with timestamp-based pagination using NDK (including delete events) |
|
export async function fetchAllEvents(options = {}) { |
|
const { |
|
limit = 100, |
|
since = null, |
|
until = null, |
|
authors = null, |
|
kinds = null, |
|
tags = null |
|
} = options; |
|
|
|
const filters = {}; |
|
|
|
if (since) filters.since = since; |
|
if (until) filters.until = until; |
|
if (authors) filters.authors = authors; |
|
if (kinds) filters.kinds = kinds; |
|
if (tags) filters.tags = tags; |
|
|
|
// Don't specify kinds filter - this will include all events including delete events (kind 5) |
|
|
|
const events = await fetchEvents(filters, { |
|
limit: limit, |
|
timeout: 30000 |
|
}); |
|
|
|
return events; |
|
} |
|
|
|
// Fetch user's events with timestamp-based pagination using NDK |
|
export async function fetchUserEvents(pubkey, options = {}) { |
|
const { |
|
limit = 100, |
|
since = null, |
|
until = null |
|
} = options; |
|
|
|
const filters = { |
|
authors: [pubkey] |
|
}; |
|
|
|
if (since) filters.since = since; |
|
if (until) filters.until = until; |
|
|
|
const events = await fetchEvents(filters, { |
|
limit: limit, |
|
timeout: 30000 |
|
}); |
|
|
|
return events; |
|
} |
|
|
|
// NIP-50 search function using NDK |
|
export async function searchEvents(searchQuery, options = {}) { |
|
const { |
|
limit = 100, |
|
since = null, |
|
until = null, |
|
kinds = null |
|
} = options; |
|
|
|
const filters = { |
|
search: searchQuery |
|
}; |
|
|
|
if (since) filters.since = since; |
|
if (until) filters.until = until; |
|
if (kinds) filters.kinds = kinds; |
|
|
|
const events = await fetchEvents(filters, { |
|
limit: limit, |
|
timeout: 30000 |
|
}); |
|
|
|
return events; |
|
} |
|
|
|
// Fetch a specific event by ID |
|
export async function fetchEventById(eventId, options = {}) { |
|
const { |
|
timeout = 10000, |
|
relays = null |
|
} = options; |
|
|
|
console.log(`Fetching event by ID: ${eventId}`); |
|
|
|
try { |
|
const ndk = nostrClient.getNDK(); |
|
|
|
const filters = { |
|
ids: [eventId] |
|
}; |
|
|
|
console.log('Fetching event via NDK with filters:', filters); |
|
|
|
// Use NDK's fetchEvents method |
|
const events = await ndk.fetchEvents(filters, { |
|
timeout |
|
}); |
|
|
|
console.log(`Fetched ${events.size} events via NDK`); |
|
|
|
// Convert NDK events to raw events |
|
const rawEvents = Array.from(events).map(event => event.rawEvent()); |
|
|
|
// Return the first event if found, null otherwise |
|
return rawEvents.length > 0 ? rawEvents[0] : null; |
|
} catch (error) { |
|
console.error("Failed to fetch event by ID via NDK:", error); |
|
throw error; |
|
} |
|
} |
|
|
|
// Fetch delete events that target a specific event ID using Nostr |
|
export async function fetchDeleteEventsByTarget(eventId, options = {}) { |
|
const { |
|
timeout = 10000 |
|
} = options; |
|
|
|
console.log(`Fetching delete events for target: ${eventId}`); |
|
|
|
try { |
|
const ndk = nostrClient.getNDK(); |
|
|
|
const filters = { |
|
kinds: [5], // Kind 5 is deletion |
|
'#e': [eventId] // e-tag referencing the target event |
|
}; |
|
|
|
console.log('Fetching delete events via NDK with filters:', filters); |
|
|
|
// Use NDK's fetchEvents method |
|
const events = await ndk.fetchEvents(filters, { |
|
timeout |
|
}); |
|
|
|
console.log(`Fetched ${events.size} delete events via NDK`); |
|
|
|
// Convert NDK events to raw events |
|
const rawEvents = Array.from(events).map(event => event.rawEvent()); |
|
|
|
return rawEvents; |
|
} catch (error) { |
|
console.error("Failed to fetch delete events via NDK:", error); |
|
throw error; |
|
} |
|
} |
|
|
|
|
|
// Initialize client connection |
|
export async function initializeNostrClient() { |
|
await nostrClient.connect(); |
|
}
|
|
|