@ -1,4 +1,4 @@
import { DEFAULT _RELAYS } from './constants.js' ;
import { DEFAULT _RELAYS } from "./constants.js" ;
// Simple WebSocket relay manager
// Simple WebSocket relay manager
class NostrClient {
class NostrClient {
@ -8,9 +8,9 @@ class NostrClient {
}
}
async connect ( ) {
async connect ( ) {
console . log ( 'Starting connection to' , DEFAULT _RELAYS . length , 'relays...' ) ;
console . log ( "Starting connection to" , DEFAULT _RELAYS . length , "relays..." ) ;
const connectionPromises = DEFAULT _RELAYS . map ( relayUrl => {
const connectionPromises = DEFAULT _RELAYS . map ( ( relayUrl ) => {
return new Promise ( ( resolve ) => {
return new Promise ( ( resolve ) => {
try {
try {
console . log ( ` Attempting to connect to ${ relayUrl } ` ) ;
console . log ( ` Attempting to connect to ${ relayUrl } ` ) ;
@ -27,7 +27,11 @@ class NostrClient {
} ;
} ;
ws . onclose = ( event ) => {
ws . onclose = ( event ) => {
console . warn ( ` Connection closed to ${ relayUrl } : ` , event . code , event . reason ) ;
console . warn (
` Connection closed to ${ relayUrl } : ` ,
event . code ,
event . reason ,
) ;
} ;
} ;
ws . onmessage = ( event ) => {
ws . onmessage = ( event ) => {
@ -35,7 +39,11 @@ class NostrClient {
try {
try {
this . handleMessage ( relayUrl , JSON . parse ( event . data ) ) ;
this . handleMessage ( relayUrl , JSON . parse ( event . data ) ) ;
} catch ( error ) {
} catch ( error ) {
console . error ( ` Failed to parse message from ${ relayUrl } : ` , error , event . data ) ;
console . error (
` Failed to parse message from ${ relayUrl } : ` ,
error ,
event . data ,
) ;
}
}
} ;
} ;
@ -48,7 +56,6 @@ class NostrClient {
resolve ( false ) ;
resolve ( false ) ;
}
}
} , 5000 ) ;
} , 5000 ) ;
} catch ( error ) {
} catch ( error ) {
console . error ( ` Failed to create WebSocket for ${ relayUrl } : ` , error ) ;
console . error ( ` Failed to create WebSocket for ${ relayUrl } : ` , error ) ;
resolve ( false ) ;
resolve ( false ) ;
@ -58,10 +65,12 @@ class NostrClient {
const results = await Promise . all ( connectionPromises ) ;
const results = await Promise . all ( connectionPromises ) ;
const successfulConnections = results . filter ( Boolean ) . length ;
const successfulConnections = results . filter ( Boolean ) . length ;
console . log ( ` Connected to ${ successfulConnections } / ${ DEFAULT _RELAYS . length } relays ` ) ;
console . log (
` Connected to ${ successfulConnections } / ${ DEFAULT _RELAYS . length } relays ` ,
) ;
// Wait a bit more for connections to stabilize
// Wait a bit more for connections to stabilize
await new Promise ( resolve => setTimeout ( resolve , 1000 ) ) ;
await new Promise ( ( resolve ) => setTimeout ( resolve , 1000 ) ) ;
}
}
handleMessage ( relayUrl , message ) {
handleMessage ( relayUrl , message ) {
@ -70,18 +79,22 @@ class NostrClient {
console . log ( ` Message type: ${ type } , subscriptionId: ${ subscriptionId } ` ) ;
console . log ( ` Message type: ${ type } , subscriptionId: ${ subscriptionId } ` ) ;
if ( type === 'EVENT' ) {
if ( type === "EVENT" ) {
console . log ( ` Received EVENT for subscription ${ subscriptionId } : ` , event ) ;
console . log ( ` Received EVENT for subscription ${ subscriptionId } : ` , event ) ;
if ( this . subscriptions . has ( subscriptionId ) ) {
if ( this . subscriptions . has ( subscriptionId ) ) {
console . log ( ` Found callback for subscription ${ subscriptionId } , executing... ` ) ;
console . log (
` Found callback for subscription ${ subscriptionId } , executing... ` ,
) ;
const callback = this . subscriptions . get ( subscriptionId ) ;
const callback = this . subscriptions . get ( subscriptionId ) ;
callback ( event ) ;
callback ( event ) ;
} else {
} else {
console . warn ( ` No callback found for subscription ${ subscriptionId } ` ) ;
console . warn ( ` No callback found for subscription ${ subscriptionId } ` ) ;
}
}
} else if ( type === 'EOSE' ) {
} else if ( type === "EOSE" ) {
console . log ( ` End of stored events for subscription ${ subscriptionId } from ${ relayUrl } ` ) ;
console . log (
} else if ( type === 'NOTICE' ) {
` End of stored events for subscription ${ subscriptionId } from ${ relayUrl } ` ,
) ;
} else if ( type === "NOTICE" ) {
console . warn ( ` Notice from ${ relayUrl } : ` , subscriptionId ) ;
console . warn ( ` Notice from ${ relayUrl } : ` , subscriptionId ) ;
} else {
} else {
console . log ( ` Unknown message type ${ type } from ${ relayUrl } : ` , message ) ;
console . log ( ` Unknown message type ${ type } from ${ relayUrl } : ` , message ) ;
@ -90,16 +103,21 @@ class NostrClient {
subscribe ( filters , callback ) {
subscribe ( filters , callback ) {
const subscriptionId = Math . random ( ) . toString ( 36 ) . substring ( 7 ) ;
const subscriptionId = Math . random ( ) . toString ( 36 ) . substring ( 7 ) ;
console . log ( ` Creating subscription ${ subscriptionId } with filters: ` , filters ) ;
console . log (
` Creating subscription ${ subscriptionId } with filters: ` ,
filters ,
) ;
this . subscriptions . set ( subscriptionId , callback ) ;
this . subscriptions . set ( subscriptionId , callback ) ;
const subscription = [ 'REQ' , subscriptionId , filters ] ;
const subscription = [ "REQ" , subscriptionId , filters ] ;
console . log ( ` Subscription message: ` , JSON . stringify ( subscription ) ) ;
console . log ( ` Subscription message: ` , JSON . stringify ( subscription ) ) ;
let sentCount = 0 ;
let sentCount = 0 ;
for ( const [ relayUrl , ws ] of this . relays ) {
for ( const [ relayUrl , ws ] of this . relays ) {
console . log ( ` Checking relay ${ relayUrl } , readyState: ${ ws . readyState } ( ${ ws . readyState === WebSocket . OPEN ? 'OPEN' : 'NOT OPEN' } ) ` ) ;
console . log (
` Checking relay ${ relayUrl } , readyState: ${ ws . readyState } ( ${ ws . readyState === WebSocket . OPEN ? "OPEN" : "NOT OPEN" } ) ` ,
) ;
if ( ws . readyState === WebSocket . OPEN ) {
if ( ws . readyState === WebSocket . OPEN ) {
try {
try {
ws . send ( JSON . stringify ( subscription ) ) ;
ws . send ( JSON . stringify ( subscription ) ) ;
@ -113,14 +131,16 @@ class NostrClient {
}
}
}
}
console . log ( ` Subscription ${ subscriptionId } sent to ${ sentCount } / ${ this . relays . size } relays ` ) ;
console . log (
` Subscription ${ subscriptionId } sent to ${ sentCount } / ${ this . relays . size } relays ` ,
) ;
return subscriptionId ;
return subscriptionId ;
}
}
unsubscribe ( subscriptionId ) {
unsubscribe ( subscriptionId ) {
this . subscriptions . delete ( subscriptionId ) ;
this . subscriptions . delete ( subscriptionId ) ;
const closeMessage = [ 'CLOSE' , subscriptionId ] ;
const closeMessage = [ "CLOSE" , subscriptionId ] ;
for ( const [ relayUrl , ws ] of this . relays ) {
for ( const [ relayUrl , ws ] of this . relays ) {
if ( ws . readyState === WebSocket . OPEN ) {
if ( ws . readyState === WebSocket . OPEN ) {
@ -142,9 +162,9 @@ class NostrClient {
export const nostrClient = new NostrClient ( ) ;
export const nostrClient = new NostrClient ( ) ;
// IndexedDB helpers for caching events (kind 0 profiles)
// IndexedDB helpers for caching events (kind 0 profiles)
const DB _NAME = 'nostrCache' ;
const DB _NAME = "nostrCache" ;
const DB _VERSION = 1 ;
const DB _VERSION = 1 ;
const STORE _EVENTS = 'events' ;
const STORE _EVENTS = "events" ;
function openDB ( ) {
function openDB ( ) {
return new Promise ( ( resolve , reject ) => {
return new Promise ( ( resolve , reject ) => {
@ -153,9 +173,15 @@ function openDB() {
req . onupgradeneeded = ( ) => {
req . onupgradeneeded = ( ) => {
const db = req . result ;
const db = req . result ;
if ( ! db . objectStoreNames . contains ( STORE _EVENTS ) ) {
if ( ! db . objectStoreNames . contains ( STORE _EVENTS ) ) {
const store = db . createObjectStore ( STORE _EVENTS , { keyPath : 'id' } ) ;
const store = db . createObjectStore ( STORE _EVENTS , { keyPath : "id" } ) ;
store . createIndex ( 'byKindAuthor' , [ 'kind' , 'pubkey' ] , { unique : false } ) ;
store . createIndex ( "byKindAuthor" , [ "kind" , "pubkey" ] , {
store . createIndex ( 'byKindAuthorCreated' , [ 'kind' , 'pubkey' , 'created_at' ] , { unique : false } ) ;
unique : false ,
} ) ;
store . createIndex (
"byKindAuthorCreated" ,
[ "kind" , "pubkey" , "created_at" ] ,
{ unique : false } ,
) ;
}
}
} ;
} ;
req . onsuccess = ( ) => resolve ( req . result ) ;
req . onsuccess = ( ) => resolve ( req . result ) ;
@ -170,10 +196,13 @@ async function getLatestProfileEvent(pubkey) {
try {
try {
const db = await openDB ( ) ;
const db = await openDB ( ) ;
return await new Promise ( ( resolve , reject ) => {
return await new Promise ( ( resolve , reject ) => {
const tx = db . transaction ( STORE _EVENTS , 'readonly' ) ;
const tx = db . transaction ( STORE _EVENTS , "readonly" ) ;
const idx = tx . objectStore ( STORE _EVENTS ) . index ( 'byKindAuthorCreated' ) ;
const idx = tx . objectStore ( STORE _EVENTS ) . index ( "byKindAuthorCreated" ) ;
const range = IDBKeyRange . bound ( [ 0 , pubkey , - Infinity ] , [ 0 , pubkey , Infinity ] ) ;
const range = IDBKeyRange . bound (
const req = idx . openCursor ( range , 'prev' ) ; // newest first
[ 0 , pubkey , - Infinity ] ,
[ 0 , pubkey , Infinity ] ,
) ;
const req = idx . openCursor ( range , "prev" ) ; // newest first
req . onsuccess = ( ) => {
req . onsuccess = ( ) => {
const cursor = req . result ;
const cursor = req . result ;
resolve ( cursor ? cursor . value : null ) ;
resolve ( cursor ? cursor . value : null ) ;
@ -181,7 +210,7 @@ async function getLatestProfileEvent(pubkey) {
req . onerror = ( ) => reject ( req . error ) ;
req . onerror = ( ) => reject ( req . error ) ;
} ) ;
} ) ;
} catch ( e ) {
} catch ( e ) {
console . warn ( 'IDB getLatestProfileEvent failed' , e ) ;
console . warn ( "IDB getLatestProfileEvent failed" , e ) ;
return null ;
return null ;
}
}
}
}
@ -190,29 +219,36 @@ async function putEvent(event) {
try {
try {
const db = await openDB ( ) ;
const db = await openDB ( ) ;
await new Promise ( ( resolve , reject ) => {
await new Promise ( ( resolve , reject ) => {
const tx = db . transaction ( STORE _EVENTS , 'readwrite' ) ;
const tx = db . transaction ( STORE _EVENTS , "readwrite" ) ;
tx . oncomplete = ( ) => resolve ( ) ;
tx . oncomplete = ( ) => resolve ( ) ;
tx . onerror = ( ) => reject ( tx . error ) ;
tx . onerror = ( ) => reject ( tx . error ) ;
tx . objectStore ( STORE _EVENTS ) . put ( event ) ;
tx . objectStore ( STORE _EVENTS ) . put ( event ) ;
} ) ;
} ) ;
} catch ( e ) {
} catch ( e ) {
console . warn ( 'IDB putEvent failed' , e ) ;
console . warn ( "IDB putEvent failed" , e ) ;
}
}
}
}
function parseProfileFromEvent ( event ) {
function parseProfileFromEvent ( event ) {
try {
try {
const profile = JSON . parse ( event . content || '{}' ) ;
const profile = JSON . parse ( event . content || "{}" ) ;
return {
return {
name : profile . name || profile . display _name || '' ,
name : profile . name || profile . display _name || "" ,
picture : profile . picture || '' ,
picture : profile . picture || "" ,
banner : profile . banner || '' ,
banner : profile . banner || "" ,
about : profile . about || '' ,
about : profile . about || "" ,
nip05 : profile . nip05 || '' ,
nip05 : profile . nip05 || "" ,
lud16 : profile . lud16 || profile . lud06 || ''
lud16 : profile . lud16 || profile . lud06 || "" ,
} ;
} ;
} catch ( e ) {
} catch ( e ) {
return { name : '' , picture : '' , banner : '' , about : '' , nip05 : '' , lud16 : '' } ;
return {
name : "" ,
picture : "" ,
banner : "" ,
about : "" ,
nip05 : "" ,
lud16 : "" ,
} ;
}
}
}
}
@ -229,7 +265,9 @@ export async function fetchUserProfile(pubkey) {
function cleanup ( ) {
function cleanup ( ) {
if ( subscriptionId ) {
if ( subscriptionId ) {
try { nostrClient . unsubscribe ( subscriptionId ) ; } catch { }
try {
nostrClient . unsubscribe ( subscriptionId ) ;
} catch { }
}
}
if ( debounceTimer ) clearTimeout ( debounceTimer ) ;
if ( debounceTimer ) clearTimeout ( debounceTimer ) ;
if ( overallTimer ) clearTimeout ( overallTimer ) ;
if ( overallTimer ) clearTimeout ( overallTimer ) ;
@ -239,20 +277,20 @@ export async function fetchUserProfile(pubkey) {
try {
try {
const cachedEvent = await getLatestProfileEvent ( pubkey ) ;
const cachedEvent = await getLatestProfileEvent ( pubkey ) ;
if ( cachedEvent ) {
if ( cachedEvent ) {
console . log ( 'Using cached profile event' ) ;
console . log ( "Using cached profile event" ) ;
const profile = parseProfileFromEvent ( cachedEvent ) ;
const profile = parseProfileFromEvent ( cachedEvent ) ;
resolved = true ; // resolve immediately with cache
resolved = true ; // resolve immediately with cache
resolve ( profile ) ;
resolve ( profile ) ;
}
}
} catch ( e ) {
} catch ( e ) {
console . warn ( 'Failed to load cached profile' , e ) ;
console . warn ( "Failed to load cached profile" , e ) ;
}
}
// 2) Set overall timeout
// 2) Set overall timeout
overallTimer = setTimeout ( ( ) => {
overallTimer = setTimeout ( ( ) => {
if ( ! newestEvent ) {
if ( ! newestEvent ) {
console . log ( 'Profile fetch timeout reached' ) ;
console . log ( "Profile fetch timeout reached" ) ;
if ( ! resolved ) reject ( new Error ( 'Profile fetch timeout' ) ) ;
if ( ! resolved ) reject ( new Error ( "Profile fetch timeout" ) ) ;
} else if ( ! resolved ) {
} else if ( ! resolved ) {
resolve ( parseProfileFromEvent ( newestEvent ) ) ;
resolve ( parseProfileFromEvent ( newestEvent ) ) ;
}
}
@ -261,18 +299,21 @@ export async function fetchUserProfile(pubkey) {
// 3) Wait a bit to ensure connections are ready and then subscribe without limit
// 3) Wait a bit to ensure connections are ready and then subscribe without limit
setTimeout ( ( ) => {
setTimeout ( ( ) => {
console . log ( 'Starting subscription after connection delay...' ) ;
console . log ( "Starting subscription after connection delay..." ) ;
subscriptionId = nostrClient . subscribe (
subscriptionId = nostrClient . subscribe (
{
{
kinds : [ 0 ] ,
kinds : [ 0 ] ,
authors : [ pubkey ]
authors : [ pubkey ] ,
} ,
} ,
( event ) => {
( event ) => {
// Collect all kind 0 events and pick the newest by created_at
// Collect all kind 0 events and pick the newest by created_at
if ( ! event || event . kind !== 0 ) return ;
if ( ! event || event . kind !== 0 ) return ;
console . log ( 'Profile event received:' , event ) ;
console . log ( "Profile event received:" , event ) ;
if ( ! newestEvent || ( event . created _at || 0 ) > ( newestEvent . created _at || 0 ) ) {
if (
! newestEvent ||
( event . created _at || 0 ) > ( newestEvent . created _at || 0 )
) {
newestEvent = event ;
newestEvent = event ;
}
}
@ -286,13 +327,15 @@ export async function fetchUserProfile(pubkey) {
// Notify listeners that an updated profile is available
// Notify listeners that an updated profile is available
try {
try {
if ( typeof window !== 'undefined' && window . dispatchEvent ) {
if ( typeof window !== "undefined" && window . dispatchEvent ) {
window . dispatchEvent ( new CustomEvent ( 'profile-updated' , {
window . dispatchEvent (
detail : { pubkey , profile , event : newestEvent }
new CustomEvent ( "profile-updated" , {
} ) ) ;
detail : { pubkey , profile , event : newestEvent } ,
} ) ,
) ;
}
}
} catch ( e ) {
} catch ( e ) {
console . warn ( 'Failed to dispatch profile-updated event' , e ) ;
console . warn ( "Failed to dispatch profile-updated event" , e ) ;
}
}
if ( ! resolved ) {
if ( ! resolved ) {
@ -304,7 +347,7 @@ export async function fetchUserProfile(pubkey) {
cleanup ( ) ;
cleanup ( ) ;
}
}
} , 800 ) ;
} , 800 ) ;
}
} ,
) ;
) ;
} , 2000 ) ;
} , 2000 ) ;
} ) ;
} ) ;