Merge pull request 'feat: implemented relay controller and use that for fetching and publishing events' (#149) from relay-controller into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m12s
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m12s
Reviewed-on: #149 Reviewed-by: enes <enes@noreply.git.nostrdev.com>
This commit is contained in:
commit
ba7d3fadbd
@ -1,22 +1,23 @@
|
||||
import { EventTemplate } from 'nostr-tools'
|
||||
import { MetadataController, NostrController } from '.'
|
||||
import { appPrivateRoutes } from '../routes'
|
||||
import {
|
||||
setAuthState,
|
||||
setMetadataEvent,
|
||||
setRelayMapAction
|
||||
} from '../store/actions'
|
||||
import store from '../store/store'
|
||||
import { SignedEvent } from '../types'
|
||||
import {
|
||||
base64DecodeAuthToken,
|
||||
base64EncodeSignedEvent,
|
||||
compareObjects,
|
||||
getAuthToken,
|
||||
getRelayMap,
|
||||
getVisitedLink,
|
||||
saveAuthToken,
|
||||
compareObjects,
|
||||
unixNow
|
||||
} from '../utils'
|
||||
import { appPrivateRoutes } from '../routes'
|
||||
import { SignedEvent } from '../types'
|
||||
|
||||
export class AuthController {
|
||||
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) {
|
||||
// Navigate user to relays page if relay map is empty
|
||||
|
@ -1,28 +1,25 @@
|
||||
import {
|
||||
Event,
|
||||
Filter,
|
||||
SimplePool,
|
||||
VerifiedEvent,
|
||||
kinds,
|
||||
validateEvent,
|
||||
verifyEvent,
|
||||
Event,
|
||||
EventTemplate,
|
||||
nip19
|
||||
verifyEvent
|
||||
} from 'nostr-tools'
|
||||
import { NostrJoiningBlock, ProfileMetadata, RelaySet } from '../types'
|
||||
import { NostrController } from '.'
|
||||
import { toast } from 'react-toastify'
|
||||
import { queryNip05, unixNow } from '../utils'
|
||||
import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
|
||||
import { EventEmitter } from 'tseep'
|
||||
import { NostrController, relayController } from '.'
|
||||
import { localCache } from '../services'
|
||||
import { ProfileMetadata, RelaySet } from '../types'
|
||||
import {
|
||||
findRelayListAndUpdateCache,
|
||||
findRelayListInCache,
|
||||
getDefaultRelaySet,
|
||||
getMostPopularRelays,
|
||||
getUserRelaySet,
|
||||
isOlderThanOneWeek
|
||||
} from '../utils/relays.ts'
|
||||
isOlderThanOneWeek,
|
||||
unixNow
|
||||
} from '../utils'
|
||||
|
||||
export class MetadataController extends EventEmitter {
|
||||
private nostrController: NostrController
|
||||
@ -51,11 +48,9 @@ export class MetadataController extends EventEmitter {
|
||||
authors: [hexKey] // Authored by the specified key
|
||||
}
|
||||
|
||||
const pool = new SimplePool()
|
||||
|
||||
// Try to get the metadata event from a special relay (wss://purplepag.es)
|
||||
const metadataEvent = await pool
|
||||
.get([this.specialMetadataRelay], eventFilter)
|
||||
const metadataEvent = await relayController
|
||||
.fetchEvent(eventFilter, [this.specialMetadataRelay])
|
||||
.catch((err) => {
|
||||
console.error(err) // Log any errors
|
||||
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
|
||||
const mostPopularRelays = await this.nostrController.getMostPopularRelays()
|
||||
const mostPopularRelays = await getMostPopularRelays()
|
||||
|
||||
// Query the most popular relays for metadata events
|
||||
const events = await pool
|
||||
.querySync(mostPopularRelays, eventFilter)
|
||||
|
||||
const events = await relayController
|
||||
.fetchEvents(eventFilter, mostPopularRelays)
|
||||
.catch((err) => {
|
||||
console.error(err) // Log any errors
|
||||
return null // Return null if an error occurs
|
||||
@ -169,10 +165,7 @@ export class MetadataController extends EventEmitter {
|
||||
[this.specialMetadataRelay],
|
||||
hexKey
|
||||
)) ||
|
||||
(await findRelayListAndUpdateCache(
|
||||
await this.nostrController.getMostPopularRelays(),
|
||||
hexKey
|
||||
))
|
||||
(await findRelayListAndUpdateCache(await getMostPopularRelays(), hexKey))
|
||||
|
||||
return relayEvent ? getUserRelaySet(relayEvent.tags) : getDefaultRelaySet()
|
||||
}
|
||||
@ -206,143 +199,21 @@ export class MetadataController extends EventEmitter {
|
||||
await this.nostrController.signEvent(newMetadataEvent)
|
||||
}
|
||||
|
||||
await this.nostrController
|
||||
.publishEvent(signedMetadataEvent, [this.specialMetadataRelay])
|
||||
await relayController
|
||||
.publish(signedMetadataEvent, [this.specialMetadataRelay])
|
||||
.then((relays) => {
|
||||
toast.success(`Metadata event published on: ${relays.join('\n')}`)
|
||||
this.handleNewMetadataEvent(signedMetadataEvent as VerifiedEvent)
|
||||
if (relays.length) {
|
||||
toast.success(`Metadata event published on: ${relays.join('\n')}`)
|
||||
this.handleNewMetadataEvent(signedMetadataEvent as VerifiedEvent)
|
||||
} else {
|
||||
toast.error('Could not publish metadata event to any relay!')
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
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 getEmptyMetadataEvent = (): Event => {
|
||||
|
@ -2,50 +2,24 @@ import NDK, {
|
||||
NDKEvent,
|
||||
NDKNip46Signer,
|
||||
NDKPrivateKeySigner,
|
||||
NDKSubscription,
|
||||
NDKUser,
|
||||
NostrEvent
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Event,
|
||||
EventTemplate,
|
||||
Filter,
|
||||
Relay,
|
||||
SimplePool,
|
||||
UnsignedEvent,
|
||||
finalizeEvent,
|
||||
kinds,
|
||||
nip04,
|
||||
nip19,
|
||||
nip44
|
||||
} from 'nostr-tools'
|
||||
import { toast } from 'react-toastify'
|
||||
import { EventEmitter } from 'tseep'
|
||||
import {
|
||||
setMostPopularRelaysAction,
|
||||
setRelayConnectionStatusAction,
|
||||
setRelayInfoAction,
|
||||
updateNsecbunkerPubkey
|
||||
} from '../store/actions'
|
||||
import { updateNsecbunkerPubkey } from '../store/actions'
|
||||
import { AuthState, LoginMethods } from '../store/auth/types'
|
||||
import store from '../store/store'
|
||||
import {
|
||||
RelayConnectionState,
|
||||
RelayConnectionStatus,
|
||||
RelayInfoObject,
|
||||
RelayMap,
|
||||
RelayReadStats,
|
||||
RelayStats,
|
||||
SignedEvent
|
||||
} from '../types'
|
||||
import {
|
||||
compareObjects,
|
||||
getNsecBunkerDelegatedKey,
|
||||
unixNow,
|
||||
verifySignedEvent
|
||||
} from '../utils'
|
||||
import { getDefaultRelayMap } from '../utils/relays.ts'
|
||||
import { SignedEvent } from '../types'
|
||||
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'
|
||||
|
||||
export class NostrController extends EventEmitter {
|
||||
private static instance: NostrController
|
||||
@ -53,14 +27,13 @@ export class NostrController extends EventEmitter {
|
||||
private bunkerNDK: NDK | undefined
|
||||
private remoteSigner: NDKNip46Signer | undefined
|
||||
|
||||
private connectedRelays: Relay[] | undefined
|
||||
|
||||
private constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
private getNostrObject = () => {
|
||||
// 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
|
||||
|
||||
throw new Error(
|
||||
@ -223,98 +196,6 @@ export class NostrController extends EventEmitter {
|
||||
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.
|
||||
*
|
||||
@ -650,359 +531,4 @@ export class NostrController extends EventEmitter {
|
||||
generateDelegatedKey = (): string => {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
309
src/controllers/RelayController.ts
Normal file
309
src/controllers/RelayController.ts
Normal 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()
|
@ -1,3 +1,4 @@
|
||||
export * from './AuthController'
|
||||
export * from './MetadataController'
|
||||
export * from './NostrController'
|
||||
export * from './RelayController'
|
||||
|
@ -1 +1,2 @@
|
||||
export * from './store'
|
||||
export * from './useDidMount'
|
||||
|
12
src/hooks/useDidMount.ts
Normal file
12
src/hooks/useDidMount.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { AppBar } from '../components/AppBar/AppBar'
|
||||
@ -25,7 +25,6 @@ import {
|
||||
subscribeForSigits
|
||||
} from '../utils'
|
||||
import { useAppSelector } from '../hooks'
|
||||
import { SubCloser } from 'nostr-tools/abstract-pool'
|
||||
import styles from './style.module.scss'
|
||||
import { Footer } from '../components/Footer/Footer'
|
||||
|
||||
@ -36,6 +35,9 @@ export const MainLayout = () => {
|
||||
const authState = useSelector((state: State) => state.auth)
|
||||
const usersAppData = useAppSelector((state) => state.userAppData)
|
||||
|
||||
// Ref to track if `subscribeForSigits` has been called
|
||||
const hasSubscribed = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const metadataController = new MetadataController()
|
||||
|
||||
@ -103,21 +105,15 @@ export const MainLayout = () => {
|
||||
}, [dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
let subCloser: SubCloser | null = null
|
||||
|
||||
if (authState.loggedIn && usersAppData) {
|
||||
const pubkey = authState.usersPubkey || authState.keyPair?.public
|
||||
|
||||
if (pubkey) {
|
||||
subscribeForSigits(pubkey).then((res) => {
|
||||
subCloser = res || null
|
||||
})
|
||||
}
|
||||
}
|
||||
if (pubkey && !hasSubscribed.current) {
|
||||
// Call `subscribeForSigits` only if it hasn't been called before
|
||||
subscribeForSigits(pubkey)
|
||||
|
||||
return () => {
|
||||
if (subCloser) {
|
||||
subCloser.close()
|
||||
// Mark `subscribeForSigits` as called
|
||||
hasSubscribed.current = true
|
||||
}
|
||||
}
|
||||
}, [authState, usersAppData])
|
||||
|
@ -12,7 +12,12 @@ import { MetadataController } from '../../controllers'
|
||||
import { getProfileSettingsRoute } from '../../routes'
|
||||
import { State } from '../../store/rootReducer'
|
||||
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 { Container } from '../../components/Container'
|
||||
|
||||
@ -51,8 +56,7 @@ export const ProfilePage = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (pubkey) {
|
||||
metadataController
|
||||
.getNostrJoiningBlockNumber(pubkey)
|
||||
getNostrJoiningBlockNumber(pubkey)
|
||||
.then((res) => {
|
||||
setNostrJoiningBlock(res)
|
||||
})
|
||||
|
@ -26,7 +26,11 @@ import { setMetadataEvent } from '../../../store/actions'
|
||||
import { LoadingSpinner } from '../../../components/LoadingSpinner'
|
||||
import { LoginMethods } from '../../../store/auth/types'
|
||||
import { SmartToy } from '@mui/icons-material'
|
||||
import { getRoboHashPicture, unixNow } from '../../../utils'
|
||||
import {
|
||||
getNostrJoiningBlockNumber,
|
||||
getRoboHashPicture,
|
||||
unixNow
|
||||
} from '../../../utils'
|
||||
import { Container } from '../../../components/Container'
|
||||
|
||||
export const ProfileSettingsPage = () => {
|
||||
@ -71,8 +75,7 @@ export const ProfileSettingsPage = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (pubkey) {
|
||||
metadataController
|
||||
.getNostrJoiningBlockNumber(pubkey)
|
||||
getNostrJoiningBlockNumber(pubkey)
|
||||
.then((res) => {
|
||||
setNostrJoiningBlock(res)
|
||||
})
|
||||
|
@ -12,138 +12,44 @@ import ListItemText from '@mui/material/ListItemText'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { NostrController } from '../../../controllers'
|
||||
import { useAppDispatch, useAppSelector } from '../../../hooks'
|
||||
import {
|
||||
setRelayMapAction,
|
||||
setRelayMapUpdatedAction
|
||||
} from '../../../store/actions'
|
||||
import {
|
||||
RelayConnectionState,
|
||||
RelayFee,
|
||||
RelayInfoObject,
|
||||
RelayMap
|
||||
} from '../../../types'
|
||||
import { Container } from '../../../components/Container'
|
||||
import { relayController } from '../../../controllers'
|
||||
import { useAppDispatch, useAppSelector, useDidMount } from '../../../hooks'
|
||||
import { setRelayMapAction } from '../../../store/actions'
|
||||
import { RelayConnectionState, RelayFee, RelayInfo } from '../../../types'
|
||||
import {
|
||||
capitalizeFirstLetter,
|
||||
compareObjects,
|
||||
getRelayInfo,
|
||||
getRelayMap,
|
||||
hexToNpub,
|
||||
publishRelayMap,
|
||||
shorten
|
||||
} from '../../../utils'
|
||||
import styles from './style.module.scss'
|
||||
import { Container } from '../../../components/Container'
|
||||
|
||||
export const RelaysPage = () => {
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
const relaysState = useAppSelector((state) => state.relays)
|
||||
const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const [newRelayURI, setNewRelayURI] = useState<string>()
|
||||
const [newRelayURIerror, setNewRelayURIerror] = useState<string>()
|
||||
const [relayMap, setRelayMap] = useState<RelayMap | undefined>(
|
||||
relaysState?.map
|
||||
)
|
||||
const [relaysInfo, setRelaysInfo] = useState<RelayInfoObject | undefined>(
|
||||
relaysState?.info
|
||||
)
|
||||
const [displayRelaysInfo, setDisplayRelaysInfo] = useState<string[]>([])
|
||||
const [relaysConnectionStatus, setRelaysConnectionStatus] = useState(
|
||||
relaysState?.connectionStatus
|
||||
)
|
||||
|
||||
const relayMap = useAppSelector((state) => state.relays?.map)
|
||||
const relaysInfo = useAppSelector((state) => state.relays?.info)
|
||||
|
||||
const webSocketPrefix = 'wss://'
|
||||
|
||||
// Update relay connection status
|
||||
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) {
|
||||
isMounted = true
|
||||
|
||||
// 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))
|
||||
} else {
|
||||
// Update relay map updated timestamp
|
||||
dispatch(setRelayMapUpdatedAction())
|
||||
}
|
||||
}
|
||||
useDidMount(() => {
|
||||
if (usersPubkey) {
|
||||
getRelayMap(usersPubkey).then((newRelayMap) => {
|
||||
if (!compareObjects(relayMap, newRelayMap.map)) {
|
||||
dispatch(setRelayMapAction(newRelayMap.map))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
// Display notification if an empty relay map has been received
|
||||
@ -175,24 +81,23 @@ export const RelaysPage = () => {
|
||||
|
||||
if (usersPubkey) {
|
||||
// Publish updated relay map.
|
||||
const relayMapPublishingRes = await nostrController
|
||||
.publishRelayMap(relayMapCopy, usersPubkey, [relay])
|
||||
.catch((err) => handlePublishRelayMapError(err))
|
||||
const relayMapPublishingRes = await publishRelayMap(
|
||||
relayMapCopy,
|
||||
usersPubkey,
|
||||
[relay]
|
||||
).catch((err) => handlePublishRelayMapError(err))
|
||||
|
||||
if (relayMapPublishingRes) {
|
||||
toast.success(relayMapPublishingRes)
|
||||
|
||||
setRelayMap(relayMapCopy)
|
||||
|
||||
dispatch(setRelayMapAction(relayMapCopy))
|
||||
}
|
||||
}
|
||||
|
||||
nostrController.disconnectFromRelays([relay])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handlePublishRelayMapError = (err: any) => {
|
||||
const errorPrefix = 'Error while publishing Relay Map'
|
||||
|
||||
@ -224,15 +129,14 @@ export const RelaysPage = () => {
|
||||
|
||||
if (usersPubkey) {
|
||||
// Publish updated relay map
|
||||
const relayMapPublishingRes = await nostrController
|
||||
.publishRelayMap(relayMapCopy, usersPubkey)
|
||||
.catch((err) => handlePublishRelayMapError(err))
|
||||
const relayMapPublishingRes = await publishRelayMap(
|
||||
relayMapCopy,
|
||||
usersPubkey
|
||||
).catch((err) => handlePublishRelayMapError(err))
|
||||
|
||||
if (relayMapPublishingRes) {
|
||||
toast.success(relayMapPublishingRes)
|
||||
|
||||
setRelayMap(relayMapCopy)
|
||||
|
||||
dispatch(setRelayMapAction(relayMapCopy))
|
||||
}
|
||||
}
|
||||
@ -256,29 +160,25 @@ export const RelaysPage = () => {
|
||||
)
|
||||
}
|
||||
} else if (relayURI && usersPubkey) {
|
||||
const connectionStatus = await nostrController.connectToRelays([relayURI])
|
||||
const relay = await relayController.connectRelay(relayURI)
|
||||
|
||||
if (
|
||||
connectionStatus &&
|
||||
connectionStatus[relayURI] &&
|
||||
connectionStatus[relayURI] === RelayConnectionState.Connected
|
||||
) {
|
||||
if (relay && relay.connected) {
|
||||
const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
|
||||
|
||||
relayMapCopy[relayURI] = { write: true, read: true }
|
||||
|
||||
// Publish updated relay map
|
||||
const relayMapPublishingRes = await nostrController
|
||||
.publishRelayMap(relayMapCopy, usersPubkey)
|
||||
.catch((err) => handlePublishRelayMapError(err))
|
||||
const relayMapPublishingRes = await publishRelayMap(
|
||||
relayMapCopy,
|
||||
usersPubkey
|
||||
).catch((err) => handlePublishRelayMapError(err))
|
||||
|
||||
if (relayMapPublishingRes) {
|
||||
setRelayMap(relayMapCopy)
|
||||
setNewRelayURI('')
|
||||
|
||||
dispatch(setRelayMapAction(relayMapCopy))
|
||||
|
||||
nostrController.getRelayInfo([relayURI])
|
||||
getRelayInfo([relayURI])
|
||||
|
||||
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 (
|
||||
<Container className={styles.container}>
|
||||
<Box className={styles.relayAddContainer}>
|
||||
@ -343,177 +220,211 @@ export const RelaysPage = () => {
|
||||
</Box>
|
||||
{relayMap && (
|
||||
<Box className={styles.relaysContainer}>
|
||||
{Object.keys(relayMap).map((relay, i) => (
|
||||
<Box className={styles.relay} key={`relay_${i}`}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<span
|
||||
className={[
|
||||
styles.connectionStatus,
|
||||
relaysConnectionStatus
|
||||
? relaysConnectionStatus[relay] ===
|
||||
RelayConnectionState.Connected
|
||||
? styles.connectionStatusConnected
|
||||
: styles.connectionStatusNotConnected
|
||||
: styles.connectionStatusUnknown
|
||||
].join(' ')}
|
||||
/>
|
||||
{relaysInfo &&
|
||||
relaysInfo[relay] &&
|
||||
relaysInfo[relay].limitation &&
|
||||
relaysInfo[relay].limitation?.payment_required && (
|
||||
<Tooltip title="Paid Relay" arrow placement="top">
|
||||
<ElectricBoltIcon
|
||||
className={styles.lightningIcon}
|
||||
color="warning"
|
||||
onClick={() => handleRelayInfo(relay)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<ListItemText primary={relay} />
|
||||
|
||||
<Box
|
||||
className={styles.leaveRelayContainer}
|
||||
onClick={() => handleLeaveRelay(relay)}
|
||||
>
|
||||
<LogoutIcon />
|
||||
<span>Leave</span>
|
||||
</Box>
|
||||
</ListItem>
|
||||
<Divider className={styles.relayDivider} />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Publish to this relay?"
|
||||
secondary={
|
||||
relaysInfo && relaysInfo[relay] ? (
|
||||
<span
|
||||
onClick={() => handleRelayInfo(relay)}
|
||||
className={styles.showInfo}
|
||||
>
|
||||
Show info{' '}
|
||||
{displayRelaysInfo.includes(relay) ? (
|
||||
<KeyboardArrowUpIcon
|
||||
className={styles.showInfoIcon}
|
||||
/>
|
||||
) : (
|
||||
<KeyboardArrowDownIcon
|
||||
className={styles.showInfoIcon}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
checked={relayMap[relay].write}
|
||||
onChange={(event) => handleRelayWriteChange(relay, event)}
|
||||
/>
|
||||
</ListItem>
|
||||
{displayRelaysInfo.includes(relay) && (
|
||||
<>
|
||||
<Divider className={styles.relayDivider} />
|
||||
<ListItem>
|
||||
<Box className={styles.relayInfoContainer}>
|
||||
{relaysInfo &&
|
||||
relaysInfo[relay] &&
|
||||
Object.keys(relaysInfo[relay]).map((key: string) => {
|
||||
const infoTitle = capitalizeFirstLetter(
|
||||
key.replace('_', ' ')
|
||||
)
|
||||
let infoValue = (relaysInfo[relay] as any)[key]
|
||||
|
||||
switch (key) {
|
||||
case 'pubkey':
|
||||
infoValue = shorten(hexToNpub(infoValue), 15)
|
||||
|
||||
break
|
||||
|
||||
case 'limitation':
|
||||
infoValue = (
|
||||
<ul key={`${i}_${key}`}>
|
||||
{Object.keys(infoValue).map((valueKey) => (
|
||||
<li key={`${i}_${key}_${valueKey}`}>
|
||||
<span
|
||||
className={styles.relayInfoSubTitle}
|
||||
>
|
||||
{capitalizeFirstLetter(
|
||||
valueKey.split('_').join(' ')
|
||||
)}
|
||||
:
|
||||
</span>{' '}
|
||||
{`${infoValue[valueKey]}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
case 'fees':
|
||||
infoValue = (
|
||||
<ul>
|
||||
{Object.keys(infoValue).map((valueKey) => (
|
||||
<li key={`${i}_${key}_${valueKey}`}>
|
||||
<span
|
||||
className={styles.relayInfoSubTitle}
|
||||
>
|
||||
{capitalizeFirstLetter(
|
||||
valueKey.split('_').join(' ')
|
||||
)}
|
||||
:
|
||||
</span>{' '}
|
||||
{`${infoValue[valueKey].map((fee: RelayFee) => `${fee.amount} ${fee.unit}`)}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if (Array.isArray(infoValue)) {
|
||||
infoValue = infoValue.join(', ')
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={`${i}_${key}_container`}>
|
||||
<span className={styles.relayInfoTitle}>
|
||||
{infoTitle}:
|
||||
</span>{' '}
|
||||
{infoValue}
|
||||
{key === 'pubkey' ? (
|
||||
<ContentCopyIcon
|
||||
className={styles.copyItem}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
hexToNpub(
|
||||
(relaysInfo[relay] as any)[key]
|
||||
)
|
||||
)
|
||||
|
||||
toast.success('Copied to clipboard', {
|
||||
autoClose: 1000,
|
||||
hideProgressBar: true
|
||||
})
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
</Box>
|
||||
{Object.keys(relayMap).map((relay) => (
|
||||
<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>
|
||||
<ListItem>
|
||||
<span
|
||||
className={[
|
||||
styles.connectionStatus,
|
||||
relayConnectionStatus
|
||||
? relayConnectionStatus === RelayConnectionState.Connected
|
||||
? styles.connectionStatusConnected
|
||||
: styles.connectionStatusNotConnected
|
||||
: styles.connectionStatusUnknown
|
||||
].join(' ')}
|
||||
/>
|
||||
{relayInfo &&
|
||||
relayInfo.limitation &&
|
||||
relayInfo.limitation?.payment_required && (
|
||||
<Tooltip title="Paid Relay" arrow placement="top">
|
||||
<ElectricBoltIcon
|
||||
className={styles.lightningIcon}
|
||||
color="warning"
|
||||
onClick={() => setDisplayRelayInfo((prev) => !prev)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<ListItemText primary={relayURI} />
|
||||
|
||||
<Box
|
||||
className={styles.leaveRelayContainer}
|
||||
onClick={() => handleLeaveRelay(relayURI)}
|
||||
>
|
||||
<LogoutIcon />
|
||||
<span>Leave</span>
|
||||
</Box>
|
||||
</ListItem>
|
||||
<Divider className={styles.relayDivider} />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Publish to this relay?"
|
||||
secondary={
|
||||
relayInfo ? (
|
||||
<span
|
||||
onClick={() => setDisplayRelayInfo((prev) => !prev)}
|
||||
className={styles.showInfo}
|
||||
>
|
||||
Show info{' '}
|
||||
{displayRelayInfo ? (
|
||||
<KeyboardArrowUpIcon className={styles.showInfoIcon} />
|
||||
) : (
|
||||
<KeyboardArrowDownIcon className={styles.showInfoIcon} />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
checked={isWriteRelay}
|
||||
onChange={(event) => handleRelayWriteChange(relayURI, event)}
|
||||
/>
|
||||
</ListItem>
|
||||
{displayRelayInfo && (
|
||||
<>
|
||||
<Divider className={styles.relayDivider} />
|
||||
<ListItem>
|
||||
<Box className={styles.relayInfoContainer}>
|
||||
{relayInfo &&
|
||||
Object.keys(relayInfo).map((key: string) => {
|
||||
const infoTitle = capitalizeFirstLetter(
|
||||
key.replace('_', ' ')
|
||||
)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let infoValue = (relayInfo as any)[key]
|
||||
|
||||
switch (key) {
|
||||
case 'pubkey':
|
||||
infoValue = shorten(hexToNpub(infoValue), 15)
|
||||
|
||||
break
|
||||
|
||||
case 'limitation':
|
||||
infoValue = (
|
||||
<ul>
|
||||
{Object.keys(infoValue).map((valueKey) => (
|
||||
<li key={`${relayURI}_${key}_${valueKey}`}>
|
||||
<span className={styles.relayInfoSubTitle}>
|
||||
{capitalizeFirstLetter(
|
||||
valueKey.split('_').join(' ')
|
||||
)}
|
||||
:
|
||||
</span>{' '}
|
||||
{`${infoValue[valueKey]}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
case 'fees':
|
||||
infoValue = (
|
||||
<ul>
|
||||
{Object.keys(infoValue).map((valueKey) => (
|
||||
<li key={`${relayURI}_${key}_${valueKey}`}>
|
||||
<span className={styles.relayInfoSubTitle}>
|
||||
{capitalizeFirstLetter(
|
||||
valueKey.split('_').join(' ')
|
||||
)}
|
||||
:
|
||||
</span>{' '}
|
||||
{`${infoValue[valueKey].map((fee: RelayFee) => `${fee.amount} ${fee.unit}`)}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if (Array.isArray(infoValue)) {
|
||||
infoValue = infoValue.join(', ')
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={`${relayURI}_${key}_container`}>
|
||||
<span className={styles.relayInfoTitle}>
|
||||
{infoTitle}:
|
||||
</span>{' '}
|
||||
{infoValue}
|
||||
{key === 'pubkey' ? (
|
||||
<ContentCopyIcon
|
||||
className={styles.copyItem}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
hexToNpub((relayInfo as any)[key])
|
||||
)
|
||||
|
||||
toast.success('Copied to clipboard', {
|
||||
autoClose: 1000,
|
||||
hideProgressBar: true
|
||||
})
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
@ -16,7 +16,6 @@ export const SET_RELAY_MAP = 'SET_RELAY_MAP'
|
||||
export const SET_RELAY_INFO = 'SET_RELAY_INFO'
|
||||
export const SET_RELAY_MAP_UPDATED = 'SET_RELAY_MAP_UPDATED'
|
||||
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_PROCESSED_GIFT_WRAPS = 'UPDATE_PROCESSED_GIFT_WRAPS'
|
||||
|
@ -3,10 +3,9 @@ import {
|
||||
SetRelayMapAction,
|
||||
SetMostPopularRelaysAction,
|
||||
SetRelayInfoAction,
|
||||
SetRelayConnectionStatusAction,
|
||||
SetRelayMapUpdatedAction
|
||||
} from './types'
|
||||
import { RelayMap, RelayInfoObject, RelayConnectionStatus } from '../../types'
|
||||
import { RelayMap, RelayInfoObject } from '../../types'
|
||||
|
||||
export const setRelayMapAction = (payload: RelayMap): SetRelayMapAction => ({
|
||||
type: ActionTypes.SET_RELAY_MAP,
|
||||
@ -27,13 +26,6 @@ export const setMostPopularRelaysAction = (
|
||||
payload
|
||||
})
|
||||
|
||||
export const setRelayConnectionStatusAction = (
|
||||
payload: RelayConnectionStatus
|
||||
): SetRelayConnectionStatusAction => ({
|
||||
type: ActionTypes.SET_RELAY_CONNECTION_STATUS,
|
||||
payload
|
||||
})
|
||||
|
||||
export const setRelayMapUpdatedAction = (): SetRelayMapUpdatedAction => ({
|
||||
type: ActionTypes.SET_RELAY_MAP_UPDATED
|
||||
})
|
||||
|
@ -5,8 +5,7 @@ const initialState: RelaysState = {
|
||||
map: undefined,
|
||||
mapUpdated: undefined,
|
||||
mostPopular: undefined,
|
||||
info: undefined,
|
||||
connectionStatus: undefined
|
||||
info: undefined
|
||||
}
|
||||
|
||||
const reducer = (
|
||||
@ -26,14 +25,8 @@ const reducer = (
|
||||
info: { ...state.info, ...action.payload }
|
||||
}
|
||||
|
||||
case ActionTypes.SET_RELAY_CONNECTION_STATUS:
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: action.payload
|
||||
}
|
||||
|
||||
case ActionTypes.SET_MOST_POPULAR_RELAYS:
|
||||
return { ...state, mostPopular: action.payload }
|
||||
return { ...state, mostPopular: [...action.payload] }
|
||||
|
||||
case ActionTypes.RESTORE_STATE:
|
||||
return action.payload.relays
|
||||
|
@ -1,13 +1,12 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { RestoreState } from '../actions'
|
||||
import { RelayMap, RelayInfoObject, RelayConnectionStatus } from '../../types'
|
||||
import { RelayMap, RelayInfoObject } from '../../types'
|
||||
|
||||
export type RelaysState = {
|
||||
map?: RelayMap
|
||||
mapUpdated?: number
|
||||
mostPopular?: string[]
|
||||
info?: RelayInfoObject
|
||||
connectionStatus?: RelayConnectionStatus
|
||||
}
|
||||
|
||||
export interface SetRelayMapAction {
|
||||
@ -25,11 +24,6 @@ export interface SetRelayInfoAction {
|
||||
payload: RelayInfoObject
|
||||
}
|
||||
|
||||
export interface SetRelayConnectionStatusAction {
|
||||
type: typeof ActionTypes.SET_RELAY_CONNECTION_STATUS
|
||||
payload: RelayConnectionStatus
|
||||
}
|
||||
|
||||
export interface SetRelayMapUpdatedAction {
|
||||
type: typeof ActionTypes.SET_RELAY_MAP_UPDATED
|
||||
}
|
||||
@ -39,5 +33,4 @@ export type RelaysDispatchTypes =
|
||||
| SetRelayInfoAction
|
||||
| SetRelayMapUpdatedAction
|
||||
| SetMostPopularRelaysAction
|
||||
| SetRelayConnectionStatusAction
|
||||
| RestoreState
|
||||
|
228
src/utils/dvm.ts
Normal file
228
src/utils/dvm.ts
Normal 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))
|
||||
}
|
||||
}
|
@ -1,10 +1,13 @@
|
||||
export * from './crypto'
|
||||
export * from './dvm'
|
||||
export * from './hash'
|
||||
export * from './localStorage'
|
||||
export * from './misc'
|
||||
export * from './nostr'
|
||||
export * from './string'
|
||||
export * from './zip'
|
||||
export * from './utils'
|
||||
export * from './mark'
|
||||
export * from './meta'
|
||||
export * from './misc'
|
||||
export * from './nostr'
|
||||
export * from './relays'
|
||||
export * from './string'
|
||||
export * from './url'
|
||||
export * from './utils'
|
||||
export * from './zip'
|
||||
|
@ -5,7 +5,6 @@ import {
|
||||
Event,
|
||||
EventTemplate,
|
||||
Filter,
|
||||
SimplePool,
|
||||
UnsignedEvent,
|
||||
finalizeEvent,
|
||||
generateSecretKey,
|
||||
@ -18,7 +17,11 @@ import {
|
||||
} from 'nostr-tools'
|
||||
import { toast } from 'react-toastify'
|
||||
import { NIP05_REGEX } from '../constants'
|
||||
import { MetadataController, NostrController } from '../controllers'
|
||||
import {
|
||||
MetadataController,
|
||||
NostrController,
|
||||
relayController
|
||||
} from '../controllers'
|
||||
import {
|
||||
updateProcessedGiftWraps,
|
||||
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> => {
|
||||
// Initialize an array to hold relay URLs
|
||||
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 relayMap = store.getState().relays?.map
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
// check if relaysMap in redux store is undefined
|
||||
// Check if relayMap is undefined in the Redux store
|
||||
if (!relayMap) {
|
||||
// If relayMap is not present, fetch relay list metadata
|
||||
const metadataController = new MetadataController()
|
||||
const relaySet = await metadataController
|
||||
.findRelayListMetadata(usersPubkey)
|
||||
.catch((err) => {
|
||||
// Log error and return null if fetching metadata fails
|
||||
console.log(
|
||||
`An error occurred while finding relay list metadata for ${hexToNpub(usersPubkey)}`,
|
||||
err
|
||||
@ -349,41 +359,42 @@ export const getUsersAppData = async (): Promise<UserAppData | null> => {
|
||||
return null
|
||||
})
|
||||
|
||||
// Return if metadata retrieval failed
|
||||
// Return null if metadata retrieval failed
|
||||
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
|
||||
|
||||
// Add write relays to the relays array
|
||||
relays.push(...relaySet.write)
|
||||
} 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(
|
||||
(key) => relayMap[key].write
|
||||
)
|
||||
|
||||
relays.push(...writeRelays)
|
||||
}
|
||||
|
||||
// generate an identifier for user's nip78
|
||||
// Generate an identifier for the user's nip78
|
||||
const hash = await getHash('938' + usersPubkey)
|
||||
if (!hash) return null
|
||||
|
||||
// Define a filter for fetching events
|
||||
const filter: Filter = {
|
||||
kinds: [kinds.Application],
|
||||
'#d': [hash]
|
||||
}
|
||||
|
||||
const encryptedContent = await nostrController
|
||||
.getEvent(filter, relays)
|
||||
const encryptedContent = await relayController
|
||||
.fetchEvent(filter, relays)
|
||||
.then((event) => {
|
||||
if (event) return event.content
|
||||
|
||||
// if person is using sigit for first time its possible that event is null
|
||||
// so we'll return empty stringified object
|
||||
// If no event is found, return an empty stringified object
|
||||
return '{}'
|
||||
})
|
||||
.catch((err) => {
|
||||
// Log error and show a toast notification if fetching event fails
|
||||
console.log(`An error occurred in finding kind 30078 event`, err)
|
||||
toast.error(
|
||||
'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 if encrypted content retrieval fails
|
||||
if (!encryptedContent) return null
|
||||
|
||||
// Handle case where the encrypted content is an empty object
|
||||
if (encryptedContent === '{}') {
|
||||
const secret = generateSecretKey()
|
||||
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
|
||||
.nip04Decrypt(usersPubkey, encryptedContent)
|
||||
.catch((err) => {
|
||||
// Log error and show a toast notification if decryption fails
|
||||
console.log('An error occurred while decrypting app data', err)
|
||||
toast.error('An error occurred while decrypting app data')
|
||||
return null
|
||||
})
|
||||
|
||||
// Return null if decryption fails
|
||||
if (!decrypted) return null
|
||||
|
||||
// Parse the decrypted content
|
||||
const parsedContent = await parseJson<{
|
||||
blossomUrls: string[]
|
||||
keyPair: Keys
|
||||
}>(decrypted).catch((err) => {
|
||||
// Log error and show a toast notification if parsing fails
|
||||
console.log(
|
||||
'An error occurred in parsing the content of kind 30078 event',
|
||||
err
|
||||
@ -430,21 +451,26 @@ export const getUsersAppData = async (): Promise<UserAppData | null> => {
|
||||
return null
|
||||
})
|
||||
|
||||
// Return null if parsing fails
|
||||
if (!parsedContent) return null
|
||||
|
||||
const { blossomUrls, keyPair } = parsedContent
|
||||
|
||||
// Return null if no blossom URLs are found
|
||||
if (blossomUrls.length === 0) return null
|
||||
|
||||
// Fetch additional user app data from the first blossom URL
|
||||
const dataFromBlossom = await getUserAppDataFromBlossom(
|
||||
blossomUrls[0],
|
||||
keyPair.private
|
||||
)
|
||||
|
||||
// Return null if fetching data from blossom fails
|
||||
if (!dataFromBlossom) return null
|
||||
|
||||
const { sigits, processedGiftWraps } = dataFromBlossom
|
||||
|
||||
// Return the final user application data
|
||||
return {
|
||||
blossomUrls,
|
||||
keyPair,
|
||||
@ -575,10 +601,9 @@ export const updateUsersAppData = async (meta: Meta) => {
|
||||
const relayMap = (store.getState().relays as RelaysState).map!
|
||||
const writeRelays = Object.keys(relayMap).filter((key) => relayMap[key].write)
|
||||
|
||||
console.log(`publishing event kind: ${kinds.Application}`)
|
||||
const publishResult = await Promise.race([
|
||||
nostrController.publishEvent(signedEvent, writeRelays),
|
||||
timeout(1000 * 30)
|
||||
relayController.publish(signedEvent, writeRelays),
|
||||
timeout(40 * 1000)
|
||||
]).catch((err) => {
|
||||
console.log('err :>> ', err)
|
||||
if (err.message === 'Timeout') {
|
||||
@ -817,15 +842,8 @@ export const subscribeForSigits = async (pubkey: string) => {
|
||||
'#p': [pubkey]
|
||||
}
|
||||
|
||||
// Instantiate a new SimplePool for the subscription
|
||||
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
|
||||
}
|
||||
relayController.subscribeForEvents(filter, relaySet.read, (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
|
||||
if (relaySet.read.length === 0) return
|
||||
|
||||
console.log('Publishing notifications')
|
||||
// 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([
|
||||
nostrController.publishEvent(wrappedEvent, relaySet.read),
|
||||
timeout(1000 * 30)
|
||||
relayController.publish(wrappedEvent, relaySet.read),
|
||||
timeout(40 * 1000)
|
||||
]).catch((err) => {
|
||||
// Log an error if publishing the notification event fails
|
||||
console.log(
|
||||
|
@ -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 { Event } from 'nostr-tools'
|
||||
import { getRelayInfo, unixNow } from '.'
|
||||
import { NostrController, relayController } from '../controllers'
|
||||
import { localCache } from '../services'
|
||||
import { ONE_WEEK_IN_MS, SIGIT_RELAY } from './const.ts'
|
||||
import { RelayMap, RelaySet } from '../types'
|
||||
import { setMostPopularRelaysAction } from '../store/actions'
|
||||
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 WRITE_MARKER = 'write'
|
||||
@ -24,8 +28,8 @@ const findRelayListAndUpdateCache = async (
|
||||
kinds: [RelayList],
|
||||
authors: [hexKey]
|
||||
}
|
||||
const pool = new SimplePool()
|
||||
const event = await pool.get(lookUpRelays, eventFilter)
|
||||
|
||||
const event = await relayController.fetchEvent(eventFilter, lookUpRelays)
|
||||
if (event) {
|
||||
await localCache.addUserRelayListMetadata(event)
|
||||
}
|
||||
@ -106,11 +110,176 @@ const toRelaySet = (obj: RelaySet, tag: string[]): RelaySet => {
|
||||
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 {
|
||||
findRelayListAndUpdateCache,
|
||||
findRelayListInCache,
|
||||
getUserRelaySet,
|
||||
getDefaultRelaySet,
|
||||
getDefaultRelayMap,
|
||||
getDefaultRelaySet,
|
||||
getMostPopularRelays,
|
||||
getRelayMap,
|
||||
publishRelayMap,
|
||||
getUserRelaySet,
|
||||
isOlderThanOneWeek
|
||||
}
|
||||
|
47
src/utils/url.ts
Normal file
47
src/utils/url.ts
Normal 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()
|
||||
}
|
Loading…
Reference in New Issue
Block a user