feat: implemented relay controller and use that for fetching and publishing events #149

Merged
s merged 10 commits from relay-controller into staging 2024-08-20 12:13:23 +00:00
20 changed files with 1124 additions and 1051 deletions

View File

@ -1,22 +1,23 @@
import { EventTemplate } from 'nostr-tools' import { EventTemplate } from 'nostr-tools'
import { MetadataController, NostrController } from '.' import { MetadataController, NostrController } from '.'
import { appPrivateRoutes } from '../routes'
import { import {
setAuthState, setAuthState,
setMetadataEvent, setMetadataEvent,
setRelayMapAction setRelayMapAction
} from '../store/actions' } from '../store/actions'
import store from '../store/store' import store from '../store/store'
import { SignedEvent } from '../types'
import { import {
base64DecodeAuthToken, base64DecodeAuthToken,
base64EncodeSignedEvent, base64EncodeSignedEvent,
compareObjects,
getAuthToken, getAuthToken,
getRelayMap,
getVisitedLink, getVisitedLink,
saveAuthToken, saveAuthToken,
compareObjects,
unixNow unixNow
} from '../utils' } from '../utils'
import { appPrivateRoutes } from '../routes'
import { SignedEvent } from '../types'
export class AuthController { export class AuthController {
private nostrController: NostrController private nostrController: NostrController
@ -75,7 +76,7 @@ export class AuthController {
}) })
) )
const relayMap = await this.nostrController.getRelayMap(pubkey) const relayMap = await getRelayMap(pubkey)
if (Object.keys(relayMap).length < 1) { if (Object.keys(relayMap).length < 1) {
// Navigate user to relays page if relay map is empty // Navigate user to relays page if relay map is empty

View File

@ -1,28 +1,25 @@
import { import {
Event,
Filter, Filter,
SimplePool,
VerifiedEvent, VerifiedEvent,
kinds, kinds,
validateEvent, validateEvent,
verifyEvent, verifyEvent
Event,
EventTemplate,
nip19
} from 'nostr-tools' } from 'nostr-tools'
import { NostrJoiningBlock, ProfileMetadata, RelaySet } from '../types'
import { NostrController } from '.'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { queryNip05, unixNow } from '../utils'
import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
import { EventEmitter } from 'tseep' import { EventEmitter } from 'tseep'
import { NostrController, relayController } from '.'
import { localCache } from '../services' import { localCache } from '../services'
import { ProfileMetadata, RelaySet } from '../types'
import { import {
findRelayListAndUpdateCache, findRelayListAndUpdateCache,
findRelayListInCache, findRelayListInCache,
getDefaultRelaySet, getDefaultRelaySet,
getMostPopularRelays,
getUserRelaySet, getUserRelaySet,
isOlderThanOneWeek isOlderThanOneWeek,
} from '../utils/relays.ts' unixNow
} from '../utils'
export class MetadataController extends EventEmitter { export class MetadataController extends EventEmitter {
private nostrController: NostrController private nostrController: NostrController
@ -51,11 +48,9 @@ export class MetadataController extends EventEmitter {
authors: [hexKey] // Authored by the specified key authors: [hexKey] // Authored by the specified key
} }
const pool = new SimplePool()
// Try to get the metadata event from a special relay (wss://purplepag.es) // Try to get the metadata event from a special relay (wss://purplepag.es)
const metadataEvent = await pool const metadataEvent = await relayController
.get([this.specialMetadataRelay], eventFilter) .fetchEvent(eventFilter, [this.specialMetadataRelay])
.catch((err) => { .catch((err) => {
console.error(err) // Log any errors console.error(err) // Log any errors
return null // Return null if an error occurs return null // Return null if an error occurs
@ -80,11 +75,12 @@ export class MetadataController extends EventEmitter {
} }
// If no valid metadata event is found from the special relay, get the most popular relays // If no valid metadata event is found from the special relay, get the most popular relays
const mostPopularRelays = await this.nostrController.getMostPopularRelays() const mostPopularRelays = await getMostPopularRelays()
// Query the most popular relays for metadata events // Query the most popular relays for metadata events
const events = await pool
.querySync(mostPopularRelays, eventFilter) const events = await relayController
.fetchEvents(eventFilter, mostPopularRelays)
.catch((err) => { .catch((err) => {
console.error(err) // Log any errors console.error(err) // Log any errors
return null // Return null if an error occurs return null // Return null if an error occurs
@ -169,10 +165,7 @@ export class MetadataController extends EventEmitter {
[this.specialMetadataRelay], [this.specialMetadataRelay],
hexKey hexKey
)) || )) ||
(await findRelayListAndUpdateCache( (await findRelayListAndUpdateCache(await getMostPopularRelays(), hexKey))
await this.nostrController.getMostPopularRelays(),
hexKey
))
return relayEvent ? getUserRelaySet(relayEvent.tags) : getDefaultRelaySet() return relayEvent ? getUserRelaySet(relayEvent.tags) : getDefaultRelaySet()
} }
@ -206,143 +199,21 @@ export class MetadataController extends EventEmitter {
await this.nostrController.signEvent(newMetadataEvent) await this.nostrController.signEvent(newMetadataEvent)
} }
await this.nostrController await relayController
.publishEvent(signedMetadataEvent, [this.specialMetadataRelay]) .publish(signedMetadataEvent, [this.specialMetadataRelay])
.then((relays) => { .then((relays) => {
if (relays.length) {
toast.success(`Metadata event published on: ${relays.join('\n')}`) toast.success(`Metadata event published on: ${relays.join('\n')}`)
this.handleNewMetadataEvent(signedMetadataEvent as VerifiedEvent) this.handleNewMetadataEvent(signedMetadataEvent as VerifiedEvent)
} else {
toast.error('Could not publish metadata event to any relay!')
}
}) })
.catch((err) => { .catch((err) => {
toast.error(err.message) toast.error(err.message)
}) })
} }
public getNostrJoiningBlockNumber = async (
hexKey: string
): Promise<NostrJoiningBlock | null> => {
const relaySet = await this.findRelayListMetadata(hexKey)
const userRelays: string[] = []
// find user's relays
if (relaySet.write.length > 0) {
userRelays.push(...relaySet.write)
} else {
const metadata = await this.findMetadata(hexKey)
if (!metadata) return null
const metadataContent = this.extractProfileMetadataContent(metadata)
if (metadataContent?.nip05) {
const nip05Profile = await queryNip05(metadataContent.nip05)
if (nip05Profile && nip05Profile.pubkey === hexKey) {
userRelays.push(...nip05Profile.relays)
}
}
}
if (userRelays.length === 0) return null
// filter for finding user's first kind 0 event
const eventFilter: Filter = {
kinds: [kinds.Metadata],
authors: [hexKey]
}
const pool = new SimplePool()
// find user's kind 0 events published on user's relays
const events = await pool.querySync(userRelays, eventFilter)
if (events && events.length) {
// sort events by created_at time in ascending order
events.sort((a, b) => a.created_at - b.created_at)
// get first ever event published on user's relays
const event = events[0]
const { created_at } = event
// initialize job request
const jobEventTemplate: EventTemplate = {
content: '',
created_at: unixNow(),
kind: 68001,
tags: [
['i', `${created_at * 1000}`],
['j', 'blockChain-block-number']
]
}
// sign job request event
const jobSignedEvent =
await this.nostrController.signEvent(jobEventTemplate)
const relays = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://relayable.org'
]
// publish job request
await this.nostrController.publishEvent(jobSignedEvent, relays)
console.log('jobSignedEvent :>> ', jobSignedEvent)
const subscribeWithTimeout = (
subscription: NDKSubscription,
timeoutMs: number
): Promise<string> => {
return new Promise((resolve, reject) => {
const eventHandler = (event: NDKEvent) => {
subscription.stop()
resolve(event.content)
}
subscription.on('event', eventHandler)
// Set up a timeout to stop the subscription after a specified time
const timeout = setTimeout(() => {
subscription.stop() // Stop the subscription
reject(new Error('Subscription timed out')) // Reject the promise with a timeout error
}, timeoutMs)
// Handle subscription close event
subscription.on('close', () => clearTimeout(timeout))
})
}
const dvmNDK = new NDK({
explicitRelayUrls: relays
})
await dvmNDK.connect(2000)
// filter for getting DVM job's result
const sub = dvmNDK.subscribe({
kinds: [68002 as number],
'#e': [jobSignedEvent.id],
'#p': [jobSignedEvent.pubkey]
})
// asynchronously get block number from dvm job with 20 seconds timeout
const dvmJobResult = await subscribeWithTimeout(sub, 20000)
const encodedEventPointer = nip19.neventEncode({
id: event.id,
relays: userRelays,
author: event.pubkey,
kind: event.kind
})
return {
block: parseInt(dvmJobResult),
encodedEventPointer
}
}
return null
}
public validate = (event: Event) => validateEvent(event) && verifyEvent(event) public validate = (event: Event) => validateEvent(event) && verifyEvent(event)
public getEmptyMetadataEvent = (): Event => { public getEmptyMetadataEvent = (): Event => {

View File

@ -2,50 +2,24 @@ import NDK, {
NDKEvent, NDKEvent,
NDKNip46Signer, NDKNip46Signer,
NDKPrivateKeySigner, NDKPrivateKeySigner,
NDKSubscription,
NDKUser, NDKUser,
NostrEvent NostrEvent
} from '@nostr-dev-kit/ndk' } from '@nostr-dev-kit/ndk'
import axios from 'axios'
import { import {
Event, Event,
EventTemplate, EventTemplate,
Filter,
Relay,
SimplePool,
UnsignedEvent, UnsignedEvent,
finalizeEvent, finalizeEvent,
kinds,
nip04, nip04,
nip19, nip19,
nip44 nip44
} from 'nostr-tools' } from 'nostr-tools'
import { toast } from 'react-toastify'
import { EventEmitter } from 'tseep' import { EventEmitter } from 'tseep'
import { import { updateNsecbunkerPubkey } from '../store/actions'
setMostPopularRelaysAction,
setRelayConnectionStatusAction,
setRelayInfoAction,
updateNsecbunkerPubkey
} from '../store/actions'
import { AuthState, LoginMethods } from '../store/auth/types' import { AuthState, LoginMethods } from '../store/auth/types'
import store from '../store/store' import store from '../store/store'
import { import { SignedEvent } from '../types'
RelayConnectionState, import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'
RelayConnectionStatus,
RelayInfoObject,
RelayMap,
RelayReadStats,
RelayStats,
SignedEvent
} from '../types'
import {
compareObjects,
getNsecBunkerDelegatedKey,
unixNow,
verifySignedEvent
} from '../utils'
import { getDefaultRelayMap } from '../utils/relays.ts'
export class NostrController extends EventEmitter { export class NostrController extends EventEmitter {
private static instance: NostrController private static instance: NostrController
@ -53,14 +27,13 @@ export class NostrController extends EventEmitter {
private bunkerNDK: NDK | undefined private bunkerNDK: NDK | undefined
private remoteSigner: NDKNip46Signer | undefined private remoteSigner: NDKNip46Signer | undefined
private connectedRelays: Relay[] | undefined
private constructor() { private constructor() {
super() super()
} }
private getNostrObject = () => { private getNostrObject = () => {
// fix: this is not picking up type declaration from src/system/index.d.ts // fix: this is not picking up type declaration from src/system/index.d.ts
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (window.nostr) return window.nostr as any if (window.nostr) return window.nostr as any
throw new Error( throw new Error(
@ -223,98 +196,6 @@ export class NostrController extends EventEmitter {
return NostrController.instance return NostrController.instance
} }
/**
* Function will publish provided event to the provided relays
*
* @param event - The event to publish.
* @param relays - An array of relay URLs to publish the event to.
* @returns A promise that resolves to an array of relays where the event was successfully published.
*/
publishEvent = async (event: Event, relays: string[]) => {
const simplePool = new SimplePool()
// Publish the event to all relays
const promises = simplePool.publish(relays, event)
// Use Promise.race to wait for the first successful publish
const firstSuccessfulPublish = await Promise.race(
promises.map((promise, index) =>
promise.then(() => relays[index]).catch(() => null)
)
)
if (!firstSuccessfulPublish) {
// If no publish was successful, collect the reasons for failures
const failedPublishes: unknown[] = []
const fallbackRejectionReason =
'Attempt to publish an event has been rejected with unknown reason.'
const results = await Promise.allSettled(promises)
results.forEach((res, index) => {
if (res.status === 'rejected') {
failedPublishes.push({
relay: relays[index],
error: res.reason
? res.reason.message || fallbackRejectionReason
: fallbackRejectionReason
})
}
})
throw failedPublishes
}
// Continue publishing to other relays in the background
promises.forEach((promise, index) => {
promise.catch((err) => {
console.log(`Failed to publish to ${relays[index]}`, err)
})
})
return [firstSuccessfulPublish]
}
/**
* Asynchronously retrieves an event from a set of relays based on a provided filter.
* If no relays are specified, it defaults to using connected relays.
*
* @param {Filter} filter - The filter criteria to find the event.
* @param {string[]} [relays] - An optional array of relay URLs to search for the event.
* @returns {Promise<Event | null>} - Returns a promise that resolves to the found event or null if not found.
*/
getEvent = async (
filter: Filter,
relays?: string[]
): Promise<Event | null> => {
// If no relays are provided or the provided array is empty, use connected relays if available.
if (!relays || relays.length === 0) {
relays = this.connectedRelays
? this.connectedRelays.map((relay) => relay.url)
: []
}
// If still no relays are available, reject the promise with an error message.
if (relays.length === 0) {
return Promise.reject('Provide some relays to find the event')
}
// Create a new instance of SimplePool to handle the relay connections and event retrieval.
const pool = new SimplePool()
// Attempt to retrieve the event from the specified relays using the filter criteria.
const event = await pool.get(relays, filter).catch((err) => {
// Log any errors that occur during the event retrieval process.
console.log('An error occurred in finding the event', err)
// Show an error toast notification to the user.
toast.error('An error occurred in finding the event')
// Return null if an error occurs, indicating that no event was found.
return null
})
// Return the found event, or null if an error occurred.
return event
}
/** /**
* Encrypts the given content for the specified receiver using NIP-44 encryption. * Encrypts the given content for the specified receiver using NIP-44 encryption.
* *
@ -650,359 +531,4 @@ export class NostrController extends EventEmitter {
generateDelegatedKey = (): string => { generateDelegatedKey = (): string => {
return NDKPrivateKeySigner.generate().privateKey! return NDKPrivateKeySigner.generate().privateKey!
} }
/**
* Provides relay map.
* @param npub - user's npub
* @returns - promise that resolves into relay map and a timestamp when it has been updated.
*/
getRelayMap = async (
npub: string
): Promise<{ map: RelayMap; mapUpdated?: number }> => {
const mostPopularRelays = await this.getMostPopularRelays()
const pool = new SimplePool()
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
const eventFilter: Filter = {
kinds: [kinds.RelayList],
authors: [npub]
}
const event = await pool
.get(mostPopularRelays, eventFilter)
.catch((err) => {
return Promise.reject(err)
})
if (event) {
// Handle founded 10002 event
const relaysMap: RelayMap = {}
// 'r' stands for 'relay'
const relayTags = event.tags.filter((tag) => tag[0] === 'r')
relayTags.forEach((tag) => {
const uri = tag[1]
const relayType = tag[2]
// if 3rd element of relay tag is undefined, relay is WRITE and READ
relaysMap[uri] = {
write: relayType ? relayType === 'write' : true,
read: relayType ? relayType === 'read' : true
}
})
this.getRelayInfo(Object.keys(relaysMap))
this.connectToRelays(Object.keys(relaysMap))
return Promise.resolve({ map: relaysMap, mapUpdated: event.created_at })
} else {
return Promise.resolve({ map: getDefaultRelayMap() })
}
}
/**
* Publishes relay map.
* @param relayMap - relay map.
* @param npub - user's npub.
* @param extraRelaysToPublish - optional relays to publish relay map.
* @returns - promise that resolves into a string representing publishing result.
*/
publishRelayMap = async (
relayMap: RelayMap,
npub: string,
extraRelaysToPublish?: string[]
): Promise<string> => {
const timestamp = unixNow()
const relayURIs = Object.keys(relayMap)
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
const tags: string[][] = relayURIs.map((relayURI) =>
[
'r',
relayURI,
relayMap[relayURI].read && relayMap[relayURI].write
? ''
: relayMap[relayURI].write
? 'write'
: 'read'
].filter((value) => value !== '')
)
const newRelayMapEvent: UnsignedEvent = {
kind: kinds.RelayList,
tags,
content: '',
pubkey: npub,
created_at: timestamp
}
const signedEvent = await this.signEvent(newRelayMapEvent)
let relaysToPublish = relayURIs
// Add extra relays if provided
if (extraRelaysToPublish) {
relaysToPublish = [...relaysToPublish, ...extraRelaysToPublish]
}
// If relay map is empty, use most popular relay URIs
if (!relaysToPublish.length) {
relaysToPublish = await this.getMostPopularRelays()
}
const publishResult = await this.publishEvent(signedEvent, relaysToPublish)
if (publishResult && publishResult.length) {
return Promise.resolve(
`Relay Map published on: ${publishResult.join('\n')}`
)
}
return Promise.reject('Publishing updated relay map was unsuccessful.')
}
/**
* Provides most popular relays.
* @param numberOfTopRelays - number representing how many most popular relays to provide
* @returns - promise that resolves into an array of most popular relays
*/
getMostPopularRelays = async (
numberOfTopRelays: number = 30
): Promise<string[]> => {
const mostPopularRelaysState = store.getState().relays?.mostPopular
// return most popular relays from app state if present
if (mostPopularRelaysState) return mostPopularRelaysState
// relays in env
const { VITE_MOST_POPULAR_RELAYS } = import.meta.env
const hardcodedPopularRelays = (VITE_MOST_POPULAR_RELAYS || '').split(' ')
const url = `https://stats.nostr.band/stats_api?method=stats`
const response = await axios.get<RelayStats>(url).catch(() => undefined)
if (!response) {
return hardcodedPopularRelays //return hardcoded relay list
}
const data = response.data
if (!data) {
return hardcodedPopularRelays //return hardcoded relay list
}
const apiTopRelays = data.relay_stats.user_picks.read_relays
.slice(0, numberOfTopRelays)
.map((relay: RelayReadStats) => relay.d)
if (!apiTopRelays.length) {
return Promise.reject(`Couldn't fetch popular relays.`)
}
if (store.getState().auth?.loggedIn) {
store.dispatch(setMostPopularRelaysAction(apiTopRelays))
}
return apiTopRelays
}
/**
* Sets information about relays into relays.info app state.
* @param relayURIs - relay URIs to get information about
*/
getRelayInfo = async (relayURIs: string[]) => {
// initialize job request
const jobEventTemplate: EventTemplate = {
content: '',
created_at: unixNow(),
kind: 68001,
tags: [
['i', `${JSON.stringify(relayURIs)}`],
['j', 'relay-info']
]
}
// sign job request event
const jobSignedEvent = await this.signEvent(jobEventTemplate)
const relays = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://relayable.org'
]
// publish job request
await this.publishEvent(jobSignedEvent, relays)
console.log('jobSignedEvent :>> ', jobSignedEvent)
const subscribeWithTimeout = (
subscription: NDKSubscription,
timeoutMs: number
): Promise<string> => {
return new Promise((resolve, reject) => {
const eventHandler = (event: NDKEvent) => {
subscription.stop()
resolve(event.content)
}
subscription.on('event', eventHandler)
// Set up a timeout to stop the subscription after a specified time
const timeout = setTimeout(() => {
subscription.stop() // Stop the subscription
reject(new Error('Subscription timed out')) // Reject the promise with a timeout error
}, timeoutMs)
// Handle subscription close event
subscription.on('close', () => clearTimeout(timeout))
})
}
const dvmNDK = new NDK({
explicitRelayUrls: relays
})
await dvmNDK.connect(2000)
// filter for getting DVM job's result
const sub = dvmNDK.subscribe({
kinds: [68002 as number],
'#e': [jobSignedEvent.id],
'#p': [jobSignedEvent.pubkey]
})
// asynchronously get block number from dvm job with 20 seconds timeout
const dvmJobResult = await subscribeWithTimeout(sub, 20000)
if (!dvmJobResult) {
return Promise.reject(`Relay(s) information wasn't received`)
}
let relaysInfo: RelayInfoObject
try {
relaysInfo = JSON.parse(dvmJobResult)
} catch (error) {
return Promise.reject(`Invalid relay(s) information.`)
}
if (
relaysInfo &&
!compareObjects(store.getState().relays?.info, relaysInfo)
) {
store.dispatch(setRelayInfoAction(relaysInfo))
}
}
/**
* Establishes connection to relays.
* @param relayURIs - an array of relay URIs
* @returns - promise that resolves into an array of connections
*/
connectToRelays = async (relayURIs: string[]) => {
// Copy of relay connection status
const relayConnectionsStatus: RelayConnectionStatus = JSON.parse(
JSON.stringify(store.getState().relays?.connectionStatus || {})
)
const connectedRelayURLs = this.connectedRelays
? this.connectedRelays.map((relay) => relay.url)
: []
// Check if connections already established
if (compareObjects(connectedRelayURLs, relayURIs)) {
return
}
const connections = relayURIs
.filter((relayURI) => !connectedRelayURLs.includes(relayURI))
.map((relayURI) =>
Relay.connect(relayURI)
.then((relay) => {
// put connection status into relayConnectionsStatus object
relayConnectionsStatus[relayURI] = relay.connected
? RelayConnectionState.Connected
: RelayConnectionState.NotConnected
return relay
})
.catch(() => {
relayConnectionsStatus[relayURI] = RelayConnectionState.NotConnected
})
)
const connected = await Promise.all(connections)
// put connected relays into connectedRelays private property, so it can be closed later
this.connectedRelays = connected.filter(
(relay) => relay instanceof Relay && relay.connected
) as Relay[]
if (Object.keys(relayConnectionsStatus)) {
if (
!compareObjects(
store.getState().relays?.connectionStatus,
relayConnectionsStatus
)
) {
store.dispatch(setRelayConnectionStatusAction(relayConnectionsStatus))
}
}
return Promise.resolve(relayConnectionsStatus)
}
/**
* Disconnects from relays.
* @param relayURIs - array of relay URIs to disconnect from
*/
disconnectFromRelays = async (relayURIs: string[]) => {
const connectedRelayURLs = this.connectedRelays
? this.connectedRelays.map((relay) => relay.url)
: []
relayURIs
.filter((relayURI) => connectedRelayURLs.includes(relayURI))
.forEach((relayURI) => {
if (this.connectedRelays) {
const relay = this.connectedRelays.find(
(relay) => relay.url === relayURI
)
if (relay) {
// close relay connection
relay.close()
// remove relay from connectedRelays property
this.connectedRelays = this.connectedRelays.filter(
(relay) => relay.url !== relayURI
)
}
}
})
if (store.getState().relays?.connectionStatus) {
const connectionStatus = JSON.parse(
JSON.stringify(store.getState().relays?.connectionStatus)
)
relayURIs.forEach((relay) => {
delete connectionStatus[relay]
})
if (
!compareObjects(
store.getState().relays?.connectionStatus,
connectionStatus
)
) {
// Update app state
store.dispatch(setRelayConnectionStatusAction(connectionStatus))
}
}
}
} }

View File

@ -0,0 +1,309 @@
import { Event, Filter, Relay } from 'nostr-tools'
import { normalizeWebSocketURL, timeout } from '../utils'
import { SIGIT_RELAY } from '../utils/const'
/**
* Singleton class to manage relay operations.
*/
export class RelayController {
private static instance: RelayController
public connectedRelays = new Map<string, Relay>()
private constructor() {}
/**
* Provides the singleton instance of RelayController.
*
* @returns The singleton instance of RelayController.
*/
public static getInstance(): RelayController {
if (!RelayController.instance) {
RelayController.instance = new RelayController()
}
return RelayController.instance
}
/**
* Connects to a relay server if not already connected.
*
* This method checks if a relay with the given URL is already in the list of connected relays.
* If it is not connected, it attempts to establish a new connection.
* On successful connection, the relay is added to the list of connected relays and returned.
* If the connection fails, an error is logged and `null` is returned.
*
* @param relayUrl - The URL of the relay server to connect to.
* @returns A promise that resolves to the connected relay object if successful, or `null` if the connection fails.
*/
public connectRelay = async (relayUrl: string): Promise<Relay | null> => {
// Check if a relay with the same URL is already connected
const normalizedWebSocketURL = normalizeWebSocketURL(relayUrl)
const relay = this.connectedRelays.get(normalizedWebSocketURL)
if (relay) {
// If a relay is found in connectedRelay map and is connected, just return it
if (relay.connected) return relay
// If relay is found in connectedRelay map but not connected,
// remove it from map and call connectRelay method again
this.connectedRelays.delete(relayUrl)
return this.connectRelay(relayUrl)
}
// Attempt to connect to the relay using the provided URL
const newRelay = await Relay.connect(relayUrl)
.then((relay) => {
if (relay.connected) {
// Add the newly connected relay to the connected relays map
this.connectedRelays.set(relayUrl, relay)
// Return the newly connected relay
return relay
}
return null
})
.catch((err) => {
// Log an error message if the connection fails
console.error(`Relay connection failed: ${relayUrl}`, err)
// Return null to indicate connection failure
return null
})
return newRelay
}
/**
* Asynchronously retrieves multiple event from a set of relays based on a provided filter.
* If no relays are specified, it defaults to using connected relays.
*
* @param filter - The filter criteria to find the event.
* @param relays - An optional array of relay URLs to search for the event.
* @returns Returns a promise that resolves with an array of events.
*/
fetchEvents = async (
filter: Filter,
relayUrls: string[] = []
): Promise<Event[]> => {
// Add app relay to relays array and connect to all specified relays
const relayPromises = [...relayUrls, SIGIT_RELAY].map((relayUrl) =>
this.connectRelay(relayUrl)
)
// Use Promise.allSettled to wait for all promises to settle
const results = await Promise.allSettled(relayPromises)
// Extract non-null values from fulfilled promises in a single pass
const relays = results.reduce<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
// Check if any relays are connected
if (relays.length === 0) {
throw new Error('No relay is connected to fetch events!')
}
const events: Event[] = []
const eventIds = new Set<string>() // To keep track of event IDs and avoid duplicates
// Create a promise for each relay subscription
const subPromises = relays.map((relay) => {
return new Promise<void>((resolve) => {
if (!relay.connected) {
console.log(`${relay.url} : Not connected!`, 'Skipping subscription')
return resolve()
}
// Subscribe to the relay with the specified filter
const sub = relay.subscribe([filter], {
// Handle incoming events
onevent: (e) => {
// Add the event to the array if it's not a duplicate
if (!eventIds.has(e.id)) {
eventIds.add(e.id) // Record the event ID
events.push(e) // Add the event to the array
}
},
// Handle the End-Of-Stream (EOSE) message
oneose: () => {
sub.close() // Close the subscription
resolve() // Resolve the promise when EOSE is received
}
})
// add a 30 sec of timeout to subscription
setTimeout(() => {
if (!sub.closed) {
sub.close()
resolve()
}
}, 30 * 1000)
})
})
// Wait for all subscriptions to complete
await Promise.allSettled(subPromises)
// It is possible that different relays will send different events and events array may contain more events then specified limit in filter
// To fix this issue we'll first sort these events and then return only limited events
if (filter.limit) {
// Sort events by creation date in descending order
events.sort((a, b) => b.created_at - a.created_at)
return events.slice(0, filter.limit)
}
return events
}
/**
* Asynchronously retrieves an event from a set of relays based on a provided filter.
* If no relays are specified, it defaults to using connected relays.
*
* @param filter - The filter criteria to find the event.
* @param relays - An optional array of relay URLs to search for the event.
* @returns Returns a promise that resolves to the found event or null if not found.
*/
fetchEvent = async (
filter: Filter,
relays: string[] = []
): Promise<Event | null> => {
const events = await this.fetchEvents(filter, relays)
// Sort events by creation date in descending order
events.sort((a, b) => b.created_at - a.created_at)
// Return the most recent event, or null if no events were received
return events[0] || null
}
/**
* Subscribes to events from multiple relays.
*
* This method connects to the specified relay URLs and subscribes to events
* using the provided filter. It handles incoming events through the given
* `eventHandler` callback and manages the subscription lifecycle.
*
* @param filter - The filter criteria to apply when subscribing to events.
* @param relayUrls - An optional array of relay URLs to connect to. The default relay URL (`SIGIT_RELAY`) is added automatically.
* @param eventHandler - A callback function to handle incoming events. It receives an `Event` object.
*
*/
subscribeForEvents = async (
filter: Filter,
relayUrls: string[] = [],
eventHandler: (event: Event) => void
) => {
// Add app relay to relays array and connect to all specified relays
const relayPromises = [...relayUrls, SIGIT_RELAY].map((relayUrl) =>
this.connectRelay(relayUrl)
)
// Use Promise.allSettled to wait for all promises to settle
const results = await Promise.allSettled(relayPromises)
// Extract non-null values from fulfilled promises in a single pass
const relays = results.reduce<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
// Check if any relays are connected
if (relays.length === 0) {
throw new Error('No relay is connected to fetch events!')
}
const processedEvents: string[] = [] // To keep track of processed events
// Create a promise for each relay subscription
const subPromises = relays.map((relay) => {
return new Promise<void>((resolve) => {
// Subscribe to the relay with the specified filter
const sub = relay.subscribe([filter], {
// Handle incoming events
onevent: (e) => {
// Process event only if it hasn't been processed before
if (!processedEvents.includes(e.id)) {
processedEvents.push(e.id)
eventHandler(e) // Call the event handler with the event
}
},
// Handle the End-Of-Stream (EOSE) message
oneose: () => {
sub.close() // Close the subscription
resolve() // Resolve the promise when EOSE is received
}
})
})
})
// Wait for all subscriptions to complete
await Promise.allSettled(subPromises)
}
publish = async (
event: Event,
relayUrls: string[] = []
): Promise<string[]> => {
// Add app relay to relays array and connect to all specified relays
const relayPromises = [...relayUrls, SIGIT_RELAY].map((relayUrl) =>
this.connectRelay(relayUrl)
)
// Use Promise.allSettled to wait for all promises to settle
const results = await Promise.allSettled(relayPromises)
// Extract non-null values from fulfilled promises in a single pass
const relays = results.reduce<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
// Check if any relays are connected
if (relays.length === 0) {
throw new Error('No relay is connected to publish event!')
}
const publishedOnRelays: string[] = [] // List to track which relays successfully published the event
// Create a promise for publishing the event to each connected relay
const publishPromises = relays.map(async (relay) => {
try {
await Promise.race([
relay.publish(event), // Publish the event to the relay
timeout(20 * 1000) // Set a timeout to handle cases where publishing takes too long
])
publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays
} catch (err) {
console.error(`Failed to publish event on relay: ${relay.url}`, err)
}
})
// Wait for all publish operations to complete (either fulfilled or rejected)
await Promise.allSettled(publishPromises)
// Return the list of relay URLs where the event was published
return publishedOnRelays
}
}
export const relayController = RelayController.getInstance()

View File

@ -1,3 +1,4 @@
export * from './AuthController' export * from './AuthController'
export * from './MetadataController' export * from './MetadataController'
export * from './NostrController' export * from './NostrController'
export * from './RelayController'

View File

@ -1 +1,2 @@
export * from './store' export * from './store'
export * from './useDidMount'

12
src/hooks/useDidMount.ts Normal file
View File

@ -0,0 +1,12 @@
import { useRef, useEffect } from 'react'
export const useDidMount = (callback: () => void) => {
const didMount = useRef<boolean>(false)
useEffect(() => {
if (callback && !didMount.current) {
didMount.current = true
callback()
}
})
}

View File

@ -1,5 +1,5 @@
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { AppBar } from '../components/AppBar/AppBar' import { AppBar } from '../components/AppBar/AppBar'
@ -25,7 +25,6 @@ import {
subscribeForSigits subscribeForSigits
} from '../utils' } from '../utils'
import { useAppSelector } from '../hooks' import { useAppSelector } from '../hooks'
import { SubCloser } from 'nostr-tools/abstract-pool'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Footer } from '../components/Footer/Footer' import { Footer } from '../components/Footer/Footer'
@ -36,6 +35,9 @@ export const MainLayout = () => {
const authState = useSelector((state: State) => state.auth) const authState = useSelector((state: State) => state.auth)
const usersAppData = useAppSelector((state) => state.userAppData) const usersAppData = useAppSelector((state) => state.userAppData)
// Ref to track if `subscribeForSigits` has been called
const hasSubscribed = useRef(false)
useEffect(() => { useEffect(() => {
const metadataController = new MetadataController() const metadataController = new MetadataController()
@ -103,21 +105,15 @@ export const MainLayout = () => {
}, [dispatch]) }, [dispatch])
useEffect(() => { useEffect(() => {
let subCloser: SubCloser | null = null
if (authState.loggedIn && usersAppData) { if (authState.loggedIn && usersAppData) {
const pubkey = authState.usersPubkey || authState.keyPair?.public const pubkey = authState.usersPubkey || authState.keyPair?.public
if (pubkey) { if (pubkey && !hasSubscribed.current) {
subscribeForSigits(pubkey).then((res) => { // Call `subscribeForSigits` only if it hasn't been called before
subCloser = res || null subscribeForSigits(pubkey)
})
}
}
return () => { // Mark `subscribeForSigits` as called
if (subCloser) { hasSubscribed.current = true
subCloser.close()
} }
} }
}, [authState, usersAppData]) }, [authState, usersAppData])

View File

@ -12,7 +12,12 @@ import { MetadataController } from '../../controllers'
import { getProfileSettingsRoute } from '../../routes' import { getProfileSettingsRoute } from '../../routes'
import { State } from '../../store/rootReducer' import { State } from '../../store/rootReducer'
import { NostrJoiningBlock, ProfileMetadata } from '../../types' import { NostrJoiningBlock, ProfileMetadata } from '../../types'
import { getRoboHashPicture, hexToNpub, shorten } from '../../utils' import {
getNostrJoiningBlockNumber,
getRoboHashPicture,
hexToNpub,
shorten
} from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
@ -51,8 +56,7 @@ export const ProfilePage = () => {
useEffect(() => { useEffect(() => {
if (pubkey) { if (pubkey) {
metadataController getNostrJoiningBlockNumber(pubkey)
.getNostrJoiningBlockNumber(pubkey)
.then((res) => { .then((res) => {
setNostrJoiningBlock(res) setNostrJoiningBlock(res)
}) })

View File

@ -26,7 +26,11 @@ import { setMetadataEvent } from '../../../store/actions'
import { LoadingSpinner } from '../../../components/LoadingSpinner' import { LoadingSpinner } from '../../../components/LoadingSpinner'
import { LoginMethods } from '../../../store/auth/types' import { LoginMethods } from '../../../store/auth/types'
import { SmartToy } from '@mui/icons-material' import { SmartToy } from '@mui/icons-material'
import { getRoboHashPicture, unixNow } from '../../../utils' import {
getNostrJoiningBlockNumber,
getRoboHashPicture,
unixNow
} from '../../../utils'
import { Container } from '../../../components/Container' import { Container } from '../../../components/Container'
export const ProfileSettingsPage = () => { export const ProfileSettingsPage = () => {
@ -71,8 +75,7 @@ export const ProfileSettingsPage = () => {
useEffect(() => { useEffect(() => {
if (pubkey) { if (pubkey) {
metadataController getNostrJoiningBlockNumber(pubkey)
.getNostrJoiningBlockNumber(pubkey)
.then((res) => { .then((res) => {
setNostrJoiningBlock(res) setNostrJoiningBlock(res)
}) })

View File

@ -12,138 +12,44 @@ import ListItemText from '@mui/material/ListItemText'
import Switch from '@mui/material/Switch' import Switch from '@mui/material/Switch'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { NostrController } from '../../../controllers' import { Container } from '../../../components/Container'
import { useAppDispatch, useAppSelector } from '../../../hooks' import { relayController } from '../../../controllers'
import { import { useAppDispatch, useAppSelector, useDidMount } from '../../../hooks'
setRelayMapAction, import { setRelayMapAction } from '../../../store/actions'
setRelayMapUpdatedAction import { RelayConnectionState, RelayFee, RelayInfo } from '../../../types'
} from '../../../store/actions'
import {
RelayConnectionState,
RelayFee,
RelayInfoObject,
RelayMap
} from '../../../types'
import { import {
capitalizeFirstLetter, capitalizeFirstLetter,
compareObjects, compareObjects,
getRelayInfo,
getRelayMap,
hexToNpub, hexToNpub,
publishRelayMap,
shorten shorten
} from '../../../utils' } from '../../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Container } from '../../../components/Container'
export const RelaysPage = () => { export const RelaysPage = () => {
const nostrController = NostrController.getInstance()
const relaysState = useAppSelector((state) => state.relays)
const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey) const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const [newRelayURI, setNewRelayURI] = useState<string>() const [newRelayURI, setNewRelayURI] = useState<string>()
const [newRelayURIerror, setNewRelayURIerror] = useState<string>() const [newRelayURIerror, setNewRelayURIerror] = useState<string>()
const [relayMap, setRelayMap] = useState<RelayMap | undefined>(
relaysState?.map const relayMap = useAppSelector((state) => state.relays?.map)
) const relaysInfo = useAppSelector((state) => state.relays?.info)
const [relaysInfo, setRelaysInfo] = useState<RelayInfoObject | undefined>(
relaysState?.info
)
const [displayRelaysInfo, setDisplayRelaysInfo] = useState<string[]>([])
const [relaysConnectionStatus, setRelaysConnectionStatus] = useState(
relaysState?.connectionStatus
)
const webSocketPrefix = 'wss://' const webSocketPrefix = 'wss://'
// Update relay connection status useDidMount(() => {
useEffect(() => {
if (
!compareObjects(relaysConnectionStatus, relaysState?.connectionStatus)
) {
setRelaysConnectionStatus(relaysState?.connectionStatus)
}
}, [relaysConnectionStatus, relaysState?.connectionStatus])
useEffect(() => {
if (!compareObjects(relaysInfo, relaysState?.info)) {
setRelaysInfo(relaysState?.info)
}
}, [relaysInfo, relaysState?.info])
useEffect(() => {
if (!compareObjects(relayMap, relaysState?.map)) {
setRelayMap(relaysState?.map)
}
}, [relayMap, relaysState?.map])
useEffect(() => {
let isMounted = false
const fetchData = async () => {
if (usersPubkey) { if (usersPubkey) {
isMounted = true getRelayMap(usersPubkey).then((newRelayMap) => {
if (!compareObjects(relayMap, newRelayMap.map)) {
// call async func to fetch relay map
const newRelayMap = await nostrController.getRelayMap(usersPubkey)
// handle fetched relay map
if (isMounted) {
if (
!relaysState?.mapUpdated ||
(newRelayMap?.mapUpdated !== undefined &&
newRelayMap?.mapUpdated > relaysState?.mapUpdated)
) {
if (
!relaysState?.map ||
!compareObjects(relaysState.map, newRelayMap)
) {
setRelayMap(newRelayMap.map)
dispatch(setRelayMapAction(newRelayMap.map)) dispatch(setRelayMapAction(newRelayMap.map))
} else {
// Update relay map updated timestamp
dispatch(setRelayMapUpdatedAction())
} }
})
} }
} })
}
}
// Publishing relay map can take some time.
// This is why data fetch should happen only if relay map was received more than 5 minutes ago.
if (
usersPubkey &&
(!relaysState?.mapUpdated ||
Date.now() - relaysState?.mapUpdated > 5 * 60 * 1000) // 5 minutes
) {
fetchData()
// Update relay connection status
if (relaysConnectionStatus) {
const notConnectedRelays = Object.keys(relaysConnectionStatus).filter(
(key) =>
relaysConnectionStatus[key] === RelayConnectionState.NotConnected
)
if (notConnectedRelays.length) {
nostrController.connectToRelays(notConnectedRelays)
}
}
}
// cleanup func
return () => {
isMounted = false
}
}, [
dispatch,
usersPubkey,
relaysState?.map,
relaysState?.mapUpdated,
nostrController,
relaysConnectionStatus
])
useEffect(() => { useEffect(() => {
// Display notification if an empty relay map has been received // Display notification if an empty relay map has been received
@ -175,24 +81,23 @@ export const RelaysPage = () => {
if (usersPubkey) { if (usersPubkey) {
// Publish updated relay map. // Publish updated relay map.
const relayMapPublishingRes = await nostrController const relayMapPublishingRes = await publishRelayMap(
.publishRelayMap(relayMapCopy, usersPubkey, [relay]) relayMapCopy,
.catch((err) => handlePublishRelayMapError(err)) usersPubkey,
[relay]
).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) { if (relayMapPublishingRes) {
toast.success(relayMapPublishingRes) toast.success(relayMapPublishingRes)
setRelayMap(relayMapCopy)
dispatch(setRelayMapAction(relayMapCopy)) dispatch(setRelayMapAction(relayMapCopy))
} }
} }
nostrController.disconnectFromRelays([relay])
} }
} }
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlePublishRelayMapError = (err: any) => { const handlePublishRelayMapError = (err: any) => {
const errorPrefix = 'Error while publishing Relay Map' const errorPrefix = 'Error while publishing Relay Map'
@ -224,15 +129,14 @@ export const RelaysPage = () => {
if (usersPubkey) { if (usersPubkey) {
// Publish updated relay map // Publish updated relay map
const relayMapPublishingRes = await nostrController const relayMapPublishingRes = await publishRelayMap(
.publishRelayMap(relayMapCopy, usersPubkey) relayMapCopy,
.catch((err) => handlePublishRelayMapError(err)) usersPubkey
).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) { if (relayMapPublishingRes) {
toast.success(relayMapPublishingRes) toast.success(relayMapPublishingRes)
setRelayMap(relayMapCopy)
dispatch(setRelayMapAction(relayMapCopy)) dispatch(setRelayMapAction(relayMapCopy))
} }
} }
@ -256,29 +160,25 @@ export const RelaysPage = () => {
) )
} }
} else if (relayURI && usersPubkey) { } else if (relayURI && usersPubkey) {
const connectionStatus = await nostrController.connectToRelays([relayURI]) const relay = await relayController.connectRelay(relayURI)
if ( if (relay && relay.connected) {
connectionStatus &&
connectionStatus[relayURI] &&
connectionStatus[relayURI] === RelayConnectionState.Connected
) {
const relayMapCopy = JSON.parse(JSON.stringify(relayMap)) const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
relayMapCopy[relayURI] = { write: true, read: true } relayMapCopy[relayURI] = { write: true, read: true }
// Publish updated relay map // Publish updated relay map
const relayMapPublishingRes = await nostrController const relayMapPublishingRes = await publishRelayMap(
.publishRelayMap(relayMapCopy, usersPubkey) relayMapCopy,
.catch((err) => handlePublishRelayMapError(err)) usersPubkey
).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) { if (relayMapPublishingRes) {
setRelayMap(relayMapCopy)
setNewRelayURI('') setNewRelayURI('')
dispatch(setRelayMapAction(relayMapCopy)) dispatch(setRelayMapAction(relayMapCopy))
nostrController.getRelayInfo([relayURI]) getRelayInfo([relayURI])
toast.success(relayMapPublishingRes) toast.success(relayMapPublishingRes)
} }
@ -292,29 +192,6 @@ export const RelaysPage = () => {
} }
} }
// Handle relay open and close state
const handleRelayInfo = (relay: string) => {
if (relaysInfo) {
const info = relaysInfo[relay]
if (info) {
let displayRelaysInfoCopy: string[] = JSON.parse(
JSON.stringify(displayRelaysInfo)
)
if (displayRelaysInfoCopy.includes(relay)) {
displayRelaysInfoCopy = displayRelaysInfoCopy.filter(
(rel) => rel !== relay
)
} else {
displayRelaysInfoCopy.push(relay)
}
setDisplayRelaysInfo(displayRelaysInfoCopy)
}
}
}
return ( return (
<Container className={styles.container}> <Container className={styles.container}>
<Box className={styles.relayAddContainer}> <Box className={styles.relayAddContainer}>
@ -343,39 +220,86 @@ export const RelaysPage = () => {
</Box> </Box>
{relayMap && ( {relayMap && (
<Box className={styles.relaysContainer}> <Box className={styles.relaysContainer}>
{Object.keys(relayMap).map((relay, i) => ( {Object.keys(relayMap).map((relay) => (
<Box className={styles.relay} key={`relay_${i}`}> <RelayItem
key={relay}
relayURI={relay}
isWriteRelay={relayMap[relay].write}
relayInfo={relaysInfo ? relaysInfo[relay] : undefined}
handleLeaveRelay={handleLeaveRelay}
handleRelayWriteChange={handleRelayWriteChange}
/>
))}
</Box>
)}
</Container>
)
}
type RelayItemProp = {
relayURI: string
isWriteRelay: boolean
relayInfo?: RelayInfo
handleLeaveRelay: (relay: string) => void
handleRelayWriteChange: (
relay: string,
event: React.ChangeEvent<HTMLInputElement>
) => Promise<void>
}
const RelayItem = ({
relayURI,
isWriteRelay,
relayInfo,
handleLeaveRelay,
handleRelayWriteChange
}: RelayItemProp) => {
const [relayConnectionStatus, setRelayConnectionStatus] =
useState<RelayConnectionState>()
const [displayRelayInfo, setDisplayRelayInfo] = useState(false)
useDidMount(() => {
relayController.connectRelay(relayURI).then((relay) => {
if (relay && relay.connected) {
setRelayConnectionStatus(RelayConnectionState.Connected)
} else {
setRelayConnectionStatus(RelayConnectionState.NotConnected)
}
})
})
return (
<Box className={styles.relay}>
<List> <List>
<ListItem> <ListItem>
<span <span
className={[ className={[
styles.connectionStatus, styles.connectionStatus,
relaysConnectionStatus relayConnectionStatus
? relaysConnectionStatus[relay] === ? relayConnectionStatus === RelayConnectionState.Connected
RelayConnectionState.Connected
? styles.connectionStatusConnected ? styles.connectionStatusConnected
: styles.connectionStatusNotConnected : styles.connectionStatusNotConnected
: styles.connectionStatusUnknown : styles.connectionStatusUnknown
].join(' ')} ].join(' ')}
/> />
{relaysInfo && {relayInfo &&
relaysInfo[relay] && relayInfo.limitation &&
relaysInfo[relay].limitation && relayInfo.limitation?.payment_required && (
relaysInfo[relay].limitation?.payment_required && (
<Tooltip title="Paid Relay" arrow placement="top"> <Tooltip title="Paid Relay" arrow placement="top">
<ElectricBoltIcon <ElectricBoltIcon
className={styles.lightningIcon} className={styles.lightningIcon}
color="warning" color="warning"
onClick={() => handleRelayInfo(relay)} onClick={() => setDisplayRelayInfo((prev) => !prev)}
/> />
</Tooltip> </Tooltip>
)} )}
<ListItemText primary={relay} /> <ListItemText primary={relayURI} />
<Box <Box
className={styles.leaveRelayContainer} className={styles.leaveRelayContainer}
onClick={() => handleLeaveRelay(relay)} onClick={() => handleLeaveRelay(relayURI)}
> >
<LogoutIcon /> <LogoutIcon />
<span>Leave</span> <span>Leave</span>
@ -386,20 +310,16 @@ export const RelaysPage = () => {
<ListItemText <ListItemText
primary="Publish to this relay?" primary="Publish to this relay?"
secondary={ secondary={
relaysInfo && relaysInfo[relay] ? ( relayInfo ? (
<span <span
onClick={() => handleRelayInfo(relay)} onClick={() => setDisplayRelayInfo((prev) => !prev)}
className={styles.showInfo} className={styles.showInfo}
> >
Show info{' '} Show info{' '}
{displayRelaysInfo.includes(relay) ? ( {displayRelayInfo ? (
<KeyboardArrowUpIcon <KeyboardArrowUpIcon className={styles.showInfoIcon} />
className={styles.showInfoIcon}
/>
) : ( ) : (
<KeyboardArrowDownIcon <KeyboardArrowDownIcon className={styles.showInfoIcon} />
className={styles.showInfoIcon}
/>
)} )}
</span> </span>
) : ( ) : (
@ -408,22 +328,22 @@ export const RelaysPage = () => {
} }
/> />
<Switch <Switch
checked={relayMap[relay].write} checked={isWriteRelay}
onChange={(event) => handleRelayWriteChange(relay, event)} onChange={(event) => handleRelayWriteChange(relayURI, event)}
/> />
</ListItem> </ListItem>
{displayRelaysInfo.includes(relay) && ( {displayRelayInfo && (
<> <>
<Divider className={styles.relayDivider} /> <Divider className={styles.relayDivider} />
<ListItem> <ListItem>
<Box className={styles.relayInfoContainer}> <Box className={styles.relayInfoContainer}>
{relaysInfo && {relayInfo &&
relaysInfo[relay] && Object.keys(relayInfo).map((key: string) => {
Object.keys(relaysInfo[relay]).map((key: string) => {
const infoTitle = capitalizeFirstLetter( const infoTitle = capitalizeFirstLetter(
key.replace('_', ' ') key.replace('_', ' ')
) )
let infoValue = (relaysInfo[relay] as any)[key] // eslint-disable-next-line @typescript-eslint/no-explicit-any
let infoValue = (relayInfo as any)[key]
switch (key) { switch (key) {
case 'pubkey': case 'pubkey':
@ -433,12 +353,10 @@ export const RelaysPage = () => {
case 'limitation': case 'limitation':
infoValue = ( infoValue = (
<ul key={`${i}_${key}`}> <ul>
{Object.keys(infoValue).map((valueKey) => ( {Object.keys(infoValue).map((valueKey) => (
<li key={`${i}_${key}_${valueKey}`}> <li key={`${relayURI}_${key}_${valueKey}`}>
<span <span className={styles.relayInfoSubTitle}>
className={styles.relayInfoSubTitle}
>
{capitalizeFirstLetter( {capitalizeFirstLetter(
valueKey.split('_').join(' ') valueKey.split('_').join(' ')
)} )}
@ -456,10 +374,8 @@ export const RelaysPage = () => {
infoValue = ( infoValue = (
<ul> <ul>
{Object.keys(infoValue).map((valueKey) => ( {Object.keys(infoValue).map((valueKey) => (
<li key={`${i}_${key}_${valueKey}`}> <li key={`${relayURI}_${key}_${valueKey}`}>
<span <span className={styles.relayInfoSubTitle}>
className={styles.relayInfoSubTitle}
>
{capitalizeFirstLetter( {capitalizeFirstLetter(
valueKey.split('_').join(' ') valueKey.split('_').join(' ')
)} )}
@ -480,7 +396,7 @@ export const RelaysPage = () => {
} }
return ( return (
<span key={`${i}_${key}_container`}> <span key={`${relayURI}_${key}_container`}>
<span className={styles.relayInfoTitle}> <span className={styles.relayInfoTitle}>
{infoTitle}: {infoTitle}:
</span>{' '} </span>{' '}
@ -490,9 +406,8 @@ export const RelaysPage = () => {
className={styles.copyItem} className={styles.copyItem}
onClick={() => { onClick={() => {
navigator.clipboard.writeText( navigator.clipboard.writeText(
hexToNpub( // eslint-disable-next-line @typescript-eslint/no-explicit-any
(relaysInfo[relay] as any)[key] hexToNpub((relayInfo as any)[key])
)
) )
toast.success('Copied to clipboard', { toast.success('Copied to clipboard', {
@ -511,9 +426,5 @@ export const RelaysPage = () => {
)} )}
</List> </List>
</Box> </Box>
))}
</Box>
)}
</Container>
) )
} }

View File

@ -16,7 +16,6 @@ export const SET_RELAY_MAP = 'SET_RELAY_MAP'
export const SET_RELAY_INFO = 'SET_RELAY_INFO' export const SET_RELAY_INFO = 'SET_RELAY_INFO'
export const SET_RELAY_MAP_UPDATED = 'SET_RELAY_MAP_UPDATED' export const SET_RELAY_MAP_UPDATED = 'SET_RELAY_MAP_UPDATED'
export const SET_MOST_POPULAR_RELAYS = 'SET_MOST_POPULAR_RELAYS' export const SET_MOST_POPULAR_RELAYS = 'SET_MOST_POPULAR_RELAYS'
export const SET_RELAY_CONNECTION_STATUS = 'SET_RELAY_CONNECTION_STATUS'
export const UPDATE_USER_APP_DATA = 'UPDATE_USER_APP_DATA' export const UPDATE_USER_APP_DATA = 'UPDATE_USER_APP_DATA'
export const UPDATE_PROCESSED_GIFT_WRAPS = 'UPDATE_PROCESSED_GIFT_WRAPS' export const UPDATE_PROCESSED_GIFT_WRAPS = 'UPDATE_PROCESSED_GIFT_WRAPS'

View File

@ -3,10 +3,9 @@ import {
SetRelayMapAction, SetRelayMapAction,
SetMostPopularRelaysAction, SetMostPopularRelaysAction,
SetRelayInfoAction, SetRelayInfoAction,
SetRelayConnectionStatusAction,
SetRelayMapUpdatedAction SetRelayMapUpdatedAction
} from './types' } from './types'
import { RelayMap, RelayInfoObject, RelayConnectionStatus } from '../../types' import { RelayMap, RelayInfoObject } from '../../types'
export const setRelayMapAction = (payload: RelayMap): SetRelayMapAction => ({ export const setRelayMapAction = (payload: RelayMap): SetRelayMapAction => ({
type: ActionTypes.SET_RELAY_MAP, type: ActionTypes.SET_RELAY_MAP,
@ -27,13 +26,6 @@ export const setMostPopularRelaysAction = (
payload payload
}) })
export const setRelayConnectionStatusAction = (
payload: RelayConnectionStatus
): SetRelayConnectionStatusAction => ({
type: ActionTypes.SET_RELAY_CONNECTION_STATUS,
payload
})
export const setRelayMapUpdatedAction = (): SetRelayMapUpdatedAction => ({ export const setRelayMapUpdatedAction = (): SetRelayMapUpdatedAction => ({
type: ActionTypes.SET_RELAY_MAP_UPDATED type: ActionTypes.SET_RELAY_MAP_UPDATED
}) })

View File

@ -5,8 +5,7 @@ const initialState: RelaysState = {
map: undefined, map: undefined,
mapUpdated: undefined, mapUpdated: undefined,
mostPopular: undefined, mostPopular: undefined,
info: undefined, info: undefined
connectionStatus: undefined
} }
const reducer = ( const reducer = (
@ -26,14 +25,8 @@ const reducer = (
info: { ...state.info, ...action.payload } info: { ...state.info, ...action.payload }
} }
case ActionTypes.SET_RELAY_CONNECTION_STATUS:
return {
...state,
connectionStatus: action.payload
}
case ActionTypes.SET_MOST_POPULAR_RELAYS: case ActionTypes.SET_MOST_POPULAR_RELAYS:
return { ...state, mostPopular: action.payload } return { ...state, mostPopular: [...action.payload] }
case ActionTypes.RESTORE_STATE: case ActionTypes.RESTORE_STATE:
return action.payload.relays return action.payload.relays

View File

@ -1,13 +1,12 @@
import * as ActionTypes from '../actionTypes' import * as ActionTypes from '../actionTypes'
import { RestoreState } from '../actions' import { RestoreState } from '../actions'
import { RelayMap, RelayInfoObject, RelayConnectionStatus } from '../../types' import { RelayMap, RelayInfoObject } from '../../types'
export type RelaysState = { export type RelaysState = {
map?: RelayMap map?: RelayMap
mapUpdated?: number mapUpdated?: number
mostPopular?: string[] mostPopular?: string[]
info?: RelayInfoObject info?: RelayInfoObject
connectionStatus?: RelayConnectionStatus
} }
export interface SetRelayMapAction { export interface SetRelayMapAction {
@ -25,11 +24,6 @@ export interface SetRelayInfoAction {
payload: RelayInfoObject payload: RelayInfoObject
} }
export interface SetRelayConnectionStatusAction {
type: typeof ActionTypes.SET_RELAY_CONNECTION_STATUS
payload: RelayConnectionStatus
}
export interface SetRelayMapUpdatedAction { export interface SetRelayMapUpdatedAction {
type: typeof ActionTypes.SET_RELAY_MAP_UPDATED type: typeof ActionTypes.SET_RELAY_MAP_UPDATED
} }
@ -39,5 +33,4 @@ export type RelaysDispatchTypes =
| SetRelayInfoAction | SetRelayInfoAction
| SetRelayMapUpdatedAction | SetRelayMapUpdatedAction
| SetMostPopularRelaysAction | SetMostPopularRelaysAction
| SetRelayConnectionStatusAction
| RestoreState | RestoreState

228
src/utils/dvm.ts Normal file
View File

@ -0,0 +1,228 @@
import { EventTemplate, Filter, kinds, nip19 } from 'nostr-tools'
import { compareObjects, queryNip05, unixNow } from '.'
import {
MetadataController,
NostrController,
relayController
} from '../controllers'
import { NostrJoiningBlock, RelayInfoObject } from '../types'
import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
import store from '../store/store'
import { setRelayInfoAction } from '../store/actions'
export const getNostrJoiningBlockNumber = async (
hexKey: string
): Promise<NostrJoiningBlock | null> => {
const metadataController = new MetadataController()
const relaySet = await metadataController.findRelayListMetadata(hexKey)
const userRelays: string[] = []
// find user's relays
if (relaySet.write.length > 0) {
userRelays.push(...relaySet.write)
} else {
const metadata = await metadataController.findMetadata(hexKey)
if (!metadata) return null
const metadataContent =
metadataController.extractProfileMetadataContent(metadata)
if (metadataContent?.nip05) {
const nip05Profile = await queryNip05(metadataContent.nip05)
if (nip05Profile && nip05Profile.pubkey === hexKey) {
userRelays.push(...nip05Profile.relays)
}
}
}
if (userRelays.length === 0) return null
// filter for finding user's first kind 0 event
const eventFilter: Filter = {
kinds: [kinds.Metadata],
authors: [hexKey]
}
// find user's kind 0 event published on user's relays
const event = await relayController.fetchEvent(eventFilter, userRelays)
if (event) {
const { created_at } = event
// initialize job request
const jobEventTemplate: EventTemplate = {
content: '',
created_at: unixNow(),
kind: 68001,
tags: [
['i', `${created_at * 1000}`],
['j', 'blockChain-block-number']
]
}
const nostrController = NostrController.getInstance()
// sign job request event
const jobSignedEvent = await nostrController.signEvent(jobEventTemplate)
const relays = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://relayable.org'
]
await relayController.publish(jobSignedEvent, relays).catch((err) => {
console.error(
'Error occurred in publish blockChain-block-number DVM job',
err
)
})
const subscribeWithTimeout = (
subscription: NDKSubscription,
timeoutMs: number
): Promise<string> => {
return new Promise((resolve, reject) => {
const eventHandler = (event: NDKEvent) => {
subscription.stop()
resolve(event.content)
}
subscription.on('event', eventHandler)
// Set up a timeout to stop the subscription after a specified time
const timeout = setTimeout(() => {
subscription.stop() // Stop the subscription
reject(new Error('Subscription timed out')) // Reject the promise with a timeout error
}, timeoutMs)
// Handle subscription close event
subscription.on('close', () => clearTimeout(timeout))
})
}
const dvmNDK = new NDK({
explicitRelayUrls: relays
})
await dvmNDK.connect(2000)
// filter for getting DVM job's result
const sub = dvmNDK.subscribe({
kinds: [68002 as number],
'#e': [jobSignedEvent.id],
'#p': [jobSignedEvent.pubkey]
})
// asynchronously get block number from dvm job with 20 seconds timeout
const dvmJobResult = await subscribeWithTimeout(sub, 20000)
const encodedEventPointer = nip19.neventEncode({
id: event.id,
relays: userRelays,
author: event.pubkey,
kind: event.kind
})
return {
block: parseInt(dvmJobResult),
encodedEventPointer
}
}
return null
}
/**
* Sets information about relays into relays.info app state.
* @param relayURIs - relay URIs to get information about
*/
export const getRelayInfo = async (relayURIs: string[]) => {
// initialize job request
const jobEventTemplate: EventTemplate = {
content: '',
created_at: unixNow(),
kind: 68001,
tags: [
['i', `${JSON.stringify(relayURIs)}`],
['j', 'relay-info']
]
}
const nostrController = NostrController.getInstance()
// sign job request event
const jobSignedEvent = await nostrController.signEvent(jobEventTemplate)
const relays = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://relayable.org'
]
// publish job request
await relayController.publish(jobSignedEvent, relays)
console.log('jobSignedEvent :>> ', jobSignedEvent)
const subscribeWithTimeout = (
subscription: NDKSubscription,
timeoutMs: number
): Promise<string> => {
return new Promise((resolve, reject) => {
const eventHandler = (event: NDKEvent) => {
subscription.stop()
resolve(event.content)
}
subscription.on('event', eventHandler)
// Set up a timeout to stop the subscription after a specified time
const timeout = setTimeout(() => {
subscription.stop() // Stop the subscription
reject(new Error('Subscription timed out')) // Reject the promise with a timeout error
}, timeoutMs)
// Handle subscription close event
subscription.on('close', () => clearTimeout(timeout))
})
}
const dvmNDK = new NDK({
explicitRelayUrls: relays
})
await dvmNDK.connect(2000)
// filter for getting DVM job's result
const sub = dvmNDK.subscribe({
kinds: [68002 as number],
'#e': [jobSignedEvent.id],
'#p': [jobSignedEvent.pubkey]
})
// asynchronously get block number from dvm job with 20 seconds timeout
const dvmJobResult = await subscribeWithTimeout(sub, 20000)
if (!dvmJobResult) {
return Promise.reject(`Relay(s) information wasn't received`)
}
let relaysInfo: RelayInfoObject
try {
relaysInfo = JSON.parse(dvmJobResult)
} catch (error) {
return Promise.reject(`Invalid relay(s) information.`)
}
if (
relaysInfo &&
!compareObjects(store.getState().relays?.info, relaysInfo)
) {
store.dispatch(setRelayInfoAction(relaysInfo))
}
}

View File

@ -1,10 +1,13 @@
export * from './crypto' export * from './crypto'
export * from './dvm'
export * from './hash' export * from './hash'
export * from './localStorage' export * from './localStorage'
export * from './misc'
export * from './nostr'
export * from './string'
export * from './zip'
export * from './utils'
export * from './mark' export * from './mark'
export * from './meta' export * from './meta'
export * from './misc'
export * from './nostr'
export * from './relays'
export * from './string'
export * from './url'
export * from './utils'
export * from './zip'

View File

@ -5,7 +5,6 @@ import {
Event, Event,
EventTemplate, EventTemplate,
Filter, Filter,
SimplePool,
UnsignedEvent, UnsignedEvent,
finalizeEvent, finalizeEvent,
generateSecretKey, generateSecretKey,
@ -18,7 +17,11 @@ import {
} from 'nostr-tools' } from 'nostr-tools'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { NIP05_REGEX } from '../constants' import { NIP05_REGEX } from '../constants'
import { MetadataController, NostrController } from '../controllers' import {
MetadataController,
NostrController,
relayController
} from '../controllers'
import { import {
updateProcessedGiftWraps, updateProcessedGiftWraps,
updateUserAppData as updateUserAppDataAction updateUserAppData as updateUserAppDataAction
@ -328,20 +331,27 @@ export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => {
} }
} }
/**
* Fetches user application data based on user's public key and stored metadata.
*
* @returns The user application data or null if an error occurs or no data is found.
*/
export const getUsersAppData = async (): Promise<UserAppData | null> => { export const getUsersAppData = async (): Promise<UserAppData | null> => {
// Initialize an array to hold relay URLs
const relays: string[] = [] const relays: string[] = []
// Retrieve the user's public key and relay map from the Redux store
const usersPubkey = (store.getState().auth as AuthState).usersPubkey! const usersPubkey = (store.getState().auth as AuthState).usersPubkey!
const relayMap = store.getState().relays?.map const relayMap = store.getState().relays?.map
const nostrController = NostrController.getInstance() // Check if relayMap is undefined in the Redux store
// check if relaysMap in redux store is undefined
if (!relayMap) { if (!relayMap) {
// If relayMap is not present, fetch relay list metadata
const metadataController = new MetadataController() const metadataController = new MetadataController()
const relaySet = await metadataController const relaySet = await metadataController
.findRelayListMetadata(usersPubkey) .findRelayListMetadata(usersPubkey)
.catch((err) => { .catch((err) => {
// Log error and return null if fetching metadata fails
console.log( console.log(
`An error occurred while finding relay list metadata for ${hexToNpub(usersPubkey)}`, `An error occurred while finding relay list metadata for ${hexToNpub(usersPubkey)}`,
err err
@ -349,41 +359,42 @@ export const getUsersAppData = async (): Promise<UserAppData | null> => {
return null return null
}) })
// Return if metadata retrieval failed // Return null if metadata retrieval failed
if (!relaySet) return null if (!relaySet) return null
// Ensure relay list is not empty // Ensure that the relay list is not empty
if (relaySet.write.length === 0) return null if (relaySet.write.length === 0) return null
// Add write relays to the relays array
relays.push(...relaySet.write) relays.push(...relaySet.write)
} else { } else {
// filter write relays from user's relayMap stored in redux store // If relayMap exists, filter and add write relays from the stored map
const writeRelays = Object.keys(relayMap).filter( const writeRelays = Object.keys(relayMap).filter(
(key) => relayMap[key].write (key) => relayMap[key].write
) )
relays.push(...writeRelays) relays.push(...writeRelays)
} }
// generate an identifier for user's nip78 // Generate an identifier for the user's nip78
const hash = await getHash('938' + usersPubkey) const hash = await getHash('938' + usersPubkey)
if (!hash) return null if (!hash) return null
// Define a filter for fetching events
const filter: Filter = { const filter: Filter = {
kinds: [kinds.Application], kinds: [kinds.Application],
'#d': [hash] '#d': [hash]
} }
const encryptedContent = await nostrController const encryptedContent = await relayController
.getEvent(filter, relays) .fetchEvent(filter, relays)
.then((event) => { .then((event) => {
if (event) return event.content if (event) return event.content
// if person is using sigit for first time its possible that event is null // If no event is found, return an empty stringified object
// so we'll return empty stringified object
return '{}' return '{}'
}) })
.catch((err) => { .catch((err) => {
// Log error and show a toast notification if fetching event fails
console.log(`An error occurred in finding kind 30078 event`, err) console.log(`An error occurred in finding kind 30078 event`, err)
toast.error( toast.error(
'An error occurred in finding kind 30078 event for data storage' 'An error occurred in finding kind 30078 event for data storage'
@ -391,8 +402,10 @@ export const getUsersAppData = async (): Promise<UserAppData | null> => {
return null return null
}) })
// Return null if encrypted content retrieval fails
if (!encryptedContent) return null if (!encryptedContent) return null
// Handle case where the encrypted content is an empty object
if (encryptedContent === '{}') { if (encryptedContent === '{}') {
const secret = generateSecretKey() const secret = generateSecretKey()
const pubKey = getPublicKey(secret) const pubKey = getPublicKey(secret)
@ -408,20 +421,28 @@ export const getUsersAppData = async (): Promise<UserAppData | null> => {
} }
} }
// Get an instance of the NostrController
const nostrController = NostrController.getInstance()
// Decrypt the encrypted content
const decrypted = await nostrController const decrypted = await nostrController
.nip04Decrypt(usersPubkey, encryptedContent) .nip04Decrypt(usersPubkey, encryptedContent)
.catch((err) => { .catch((err) => {
// Log error and show a toast notification if decryption fails
console.log('An error occurred while decrypting app data', err) console.log('An error occurred while decrypting app data', err)
toast.error('An error occurred while decrypting app data') toast.error('An error occurred while decrypting app data')
return null return null
}) })
// Return null if decryption fails
if (!decrypted) return null if (!decrypted) return null
// Parse the decrypted content
const parsedContent = await parseJson<{ const parsedContent = await parseJson<{
blossomUrls: string[] blossomUrls: string[]
keyPair: Keys keyPair: Keys
}>(decrypted).catch((err) => { }>(decrypted).catch((err) => {
// Log error and show a toast notification if parsing fails
console.log( console.log(
'An error occurred in parsing the content of kind 30078 event', 'An error occurred in parsing the content of kind 30078 event',
err err
@ -430,21 +451,26 @@ export const getUsersAppData = async (): Promise<UserAppData | null> => {
return null return null
}) })
// Return null if parsing fails
if (!parsedContent) return null if (!parsedContent) return null
const { blossomUrls, keyPair } = parsedContent const { blossomUrls, keyPair } = parsedContent
// Return null if no blossom URLs are found
if (blossomUrls.length === 0) return null if (blossomUrls.length === 0) return null
// Fetch additional user app data from the first blossom URL
const dataFromBlossom = await getUserAppDataFromBlossom( const dataFromBlossom = await getUserAppDataFromBlossom(
blossomUrls[0], blossomUrls[0],
keyPair.private keyPair.private
) )
// Return null if fetching data from blossom fails
if (!dataFromBlossom) return null if (!dataFromBlossom) return null
const { sigits, processedGiftWraps } = dataFromBlossom const { sigits, processedGiftWraps } = dataFromBlossom
// Return the final user application data
return { return {
blossomUrls, blossomUrls,
keyPair, keyPair,
@ -575,10 +601,9 @@ export const updateUsersAppData = async (meta: Meta) => {
const relayMap = (store.getState().relays as RelaysState).map! const relayMap = (store.getState().relays as RelaysState).map!
const writeRelays = Object.keys(relayMap).filter((key) => relayMap[key].write) const writeRelays = Object.keys(relayMap).filter((key) => relayMap[key].write)
console.log(`publishing event kind: ${kinds.Application}`)
const publishResult = await Promise.race([ const publishResult = await Promise.race([
nostrController.publishEvent(signedEvent, writeRelays), relayController.publish(signedEvent, writeRelays),
timeout(1000 * 30) timeout(40 * 1000)
]).catch((err) => { ]).catch((err) => {
console.log('err :>> ', err) console.log('err :>> ', err)
if (err.message === 'Timeout') { if (err.message === 'Timeout') {
@ -817,15 +842,8 @@ export const subscribeForSigits = async (pubkey: string) => {
'#p': [pubkey] '#p': [pubkey]
} }
// Instantiate a new SimplePool for the subscription relayController.subscribeForEvents(filter, relaySet.read, (event) => {
const pool = new SimplePool()
// Subscribe to the specified relays with the defined filter
return pool.subscribeMany(relaySet.read, [filter], {
// Define a callback function to handle received events
onevent: (event) => {
processReceivedEvent(event) // Process the received event processReceivedEvent(event) // Process the received event
}
}) })
} }
@ -915,14 +933,10 @@ export const sendNotification = async (receiver: string, meta: Meta) => {
// Ensure relay list is not empty // Ensure relay list is not empty
if (relaySet.read.length === 0) return if (relaySet.read.length === 0) return
console.log('Publishing notifications')
// Publish the notification event to the recipient's read relays // Publish the notification event to the recipient's read relays
const nostrController = NostrController.getInstance()
// Attempt to publish the event to the relays, with a timeout of 2 minutes
await Promise.race([ await Promise.race([
nostrController.publishEvent(wrappedEvent, relaySet.read), relayController.publish(wrappedEvent, relaySet.read),
timeout(1000 * 30) timeout(40 * 1000)
]).catch((err) => { ]).catch((err) => {
// Log an error if publishing the notification event fails // Log an error if publishing the notification event fails
console.log( console.log(

View File

@ -1,9 +1,13 @@
import { Filter, SimplePool } from 'nostr-tools' import axios from 'axios'
import { Event, Filter, kinds, UnsignedEvent } from 'nostr-tools'
import { RelayList } from 'nostr-tools/kinds' import { RelayList } from 'nostr-tools/kinds'
import { Event } from 'nostr-tools' import { getRelayInfo, unixNow } from '.'
import { NostrController, relayController } from '../controllers'
import { localCache } from '../services' import { localCache } from '../services'
import { ONE_WEEK_IN_MS, SIGIT_RELAY } from './const.ts' import { setMostPopularRelaysAction } from '../store/actions'
import { RelayMap, RelaySet } from '../types' import store from '../store/store'
import { RelayMap, RelayReadStats, RelaySet, RelayStats } from '../types'
import { ONE_WEEK_IN_MS, SIGIT_RELAY } from './const'
const READ_MARKER = 'read' const READ_MARKER = 'read'
const WRITE_MARKER = 'write' const WRITE_MARKER = 'write'
@ -24,8 +28,8 @@ const findRelayListAndUpdateCache = async (
kinds: [RelayList], kinds: [RelayList],
authors: [hexKey] authors: [hexKey]
} }
const pool = new SimplePool()
const event = await pool.get(lookUpRelays, eventFilter) const event = await relayController.fetchEvent(eventFilter, lookUpRelays)
if (event) { if (event) {
await localCache.addUserRelayListMetadata(event) await localCache.addUserRelayListMetadata(event)
} }
@ -106,11 +110,176 @@ const toRelaySet = (obj: RelaySet, tag: string[]): RelaySet => {
return obj return obj
} }
/**
* Provides most popular relays.
* @param numberOfTopRelays - number representing how many most popular relays to provide
* @returns - promise that resolves into an array of most popular relays
*/
const getMostPopularRelays = async (
numberOfTopRelays: number = 30
): Promise<string[]> => {
const mostPopularRelaysState = store.getState().relays?.mostPopular
// return most popular relays from app state if present
if (mostPopularRelaysState) return mostPopularRelaysState
// relays in env
const { VITE_MOST_POPULAR_RELAYS } = import.meta.env
const hardcodedPopularRelays = (VITE_MOST_POPULAR_RELAYS || '').split(' ')
const url = `https://stats.nostr.band/stats_api?method=stats`
const response = await axios.get<RelayStats>(url).catch(() => undefined)
if (!response) {
return hardcodedPopularRelays //return hardcoded relay list
}
const data = response.data
if (!data) {
return hardcodedPopularRelays //return hardcoded relay list
}
const apiTopRelays = data.relay_stats.user_picks.read_relays
.slice(0, numberOfTopRelays)
.map((relay: RelayReadStats) => relay.d)
if (!apiTopRelays.length) {
return Promise.reject(`Couldn't fetch popular relays.`)
}
if (store.getState().auth?.loggedIn) {
store.dispatch(setMostPopularRelaysAction(apiTopRelays))
}
return apiTopRelays
}
/**
* Provides relay map.
* @param npub - user's npub
* @returns - promise that resolves into relay map and a timestamp when it has been updated.
*/
const getRelayMap = async (
npub: string
): Promise<{ map: RelayMap; mapUpdated?: number }> => {
const mostPopularRelays = await getMostPopularRelays()
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
const eventFilter: Filter = {
kinds: [kinds.RelayList],
authors: [npub]
}
const event = await relayController
.fetchEvent(eventFilter, mostPopularRelays)
.catch((err) => {
return Promise.reject(err)
})
if (event) {
// Handle founded 10002 event
const relaysMap: RelayMap = {}
// 'r' stands for 'relay'
const relayTags = event.tags.filter((tag) => tag[0] === 'r')
relayTags.forEach((tag) => {
const uri = tag[1]
const relayType = tag[2]
// if 3rd element of relay tag is undefined, relay is WRITE and READ
relaysMap[uri] = {
write: relayType ? relayType === 'write' : true,
read: relayType ? relayType === 'read' : true
}
})
Object.keys(relaysMap).forEach((relayUrl) => {
relayController.connectRelay(relayUrl)
})
getRelayInfo(Object.keys(relaysMap))
return Promise.resolve({ map: relaysMap, mapUpdated: event.created_at })
} else {
return Promise.resolve({ map: getDefaultRelayMap() })
}
}
/**
* Publishes relay map.
* @param relayMap - relay map.
* @param npub - user's npub.
* @param extraRelaysToPublish - optional relays to publish relay map.
* @returns - promise that resolves into a string representing publishing result.
*/
const publishRelayMap = async (
relayMap: RelayMap,
npub: string,
extraRelaysToPublish?: string[]
): Promise<string> => {
const timestamp = unixNow()
const relayURIs = Object.keys(relayMap)
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
const tags: string[][] = relayURIs.map((relayURI) =>
[
'r',
relayURI,
relayMap[relayURI].read && relayMap[relayURI].write
? ''
: relayMap[relayURI].write
? 'write'
: 'read'
].filter((value) => value !== '')
)
const newRelayMapEvent: UnsignedEvent = {
kind: kinds.RelayList,
tags,
content: '',
pubkey: npub,
created_at: timestamp
}
const nostrController = NostrController.getInstance()
const signedEvent = await nostrController.signEvent(newRelayMapEvent)
let relaysToPublish = relayURIs
// Add extra relays if provided
if (extraRelaysToPublish) {
relaysToPublish = [...relaysToPublish, ...extraRelaysToPublish]
}
// If relay map is empty, use most popular relay URIs
if (!relaysToPublish.length) {
relaysToPublish = await getMostPopularRelays()
}
const publishResult = await relayController.publish(
signedEvent,
relaysToPublish
)
if (publishResult && publishResult.length) {
return Promise.resolve(
`Relay Map published on: ${publishResult.join('\n')}`
)
}
return Promise.reject('Publishing updated relay map was unsuccessful.')
}
export { export {
findRelayListAndUpdateCache, findRelayListAndUpdateCache,
findRelayListInCache, findRelayListInCache,
getUserRelaySet,
getDefaultRelaySet,
getDefaultRelayMap, getDefaultRelayMap,
getDefaultRelaySet,
getMostPopularRelays,
getRelayMap,
publishRelayMap,
getUserRelaySet,
isOlderThanOneWeek isOlderThanOneWeek
} }

47
src/utils/url.ts Normal file
View File

@ -0,0 +1,47 @@
/**
* Normalizes a given URL by performing the following operations:
*
* 1. Ensures that the URL has a protocol by defaulting to 'wss://' if no protocol is provided.
* 2. Creates a `URL` object to easily manipulate and normalize the URL components.
* 3. Normalizes the pathname by:
* - Replacing multiple consecutive slashes with a single slash.
* - Removing the trailing slash if it exists.
* 4. Removes the port number if it is the default port for the protocol:
* - Port `80` for 'ws:' (WebSocket) protocol.
* - Port `443` for 'wss:' (WebSocket Secure) protocol.
* 5. Sorts the query parameters alphabetically.
* 6. Clears any fragment (hash) identifier from the URL.
*
* @param urlString - The URL string to be normalized.
* @returns A normalized URL string.
*/
export function normalizeWebSocketURL(urlString: string): string {
// If the URL string does not contain a protocol (e.g., "http://", "https://"),
// prepend "wss://" (WebSocket Secure) by default.
if (urlString.indexOf('://') === -1) urlString = 'wss://' + urlString
// Create a URL object from the provided URL string.
const url = new URL(urlString)
// Normalize the pathname by replacing multiple consecutive slashes with a single slash.
url.pathname = url.pathname.replace(/\/+/g, '/')
// Remove the trailing slash from the pathname if it exists.
if (url.pathname.endsWith('/')) url.pathname = url.pathname.slice(0, -1)
// Remove the port number if it is 80 for "ws:" protocol or 443 for "wss:" protocol, as these are default ports.
if (
(url.port === '80' && url.protocol === 'ws:') ||
(url.port === '443' && url.protocol === 'wss:')
)
url.port = ''
// Sort the search parameters alphabetically.
url.searchParams.sort()
// Clear any hash fragment from the URL.
url.hash = ''
// Return the normalized URL as a string.
return url.toString()
}