diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts index 34b2f7a..c2efb4c 100644 --- a/src/controllers/AuthController.ts +++ b/src/controllers/AuthController.ts @@ -74,25 +74,16 @@ export class AuthController { }) ) - const relaysState = store.getState().relays + const relayMap = await this.nostrController.getRelayMap(pubkey) - if (relaysState) { - // Relays state is defined and there is no need to await for the latest relay map - this.nostrController.getRelayMap(pubkey).then((relayMap) => { - if (!compareObjects(relaysState?.map, relayMap)) { - store.dispatch(setRelayMapAction(relayMap.map)) - } - }) - } else { - // Relays state is not defined, await for the latest relay map - const relayMap = await this.nostrController.getRelayMap(pubkey) + if (Object.keys(relayMap).length < 1) { + // Navigate user to relays page if relay map is empty + return Promise.resolve(appPrivateRoutes.relays) + } - if (Object.keys(relayMap).length < 1) { - // Navigate user to relays page - return Promise.resolve(appPrivateRoutes.relays) - } - - store.dispatch(setRelayMapAction(relayMap.map)) + if (store.getState().auth?.loggedIn) { + if (!compareObjects(store.getState().relays?.map, relayMap.map)) + store.dispatch(setRelayMapAction(relayMap.map)) } const visitedLink = getVisitedLink() diff --git a/src/controllers/NostrController.ts b/src/controllers/NostrController.ts index 4dcf651..1b80595 100644 --- a/src/controllers/NostrController.ts +++ b/src/controllers/NostrController.ts @@ -3,7 +3,8 @@ import NDK, { NDKNip46Signer, NDKPrivateKeySigner, NDKUser, - NostrEvent + NostrEvent, + NDKSubscription } from '@nostr-dev-kit/ndk' import { Event, @@ -11,25 +12,45 @@ import { SimplePool, UnsignedEvent, Filter, + Relay, finalizeEvent, nip04, nip19, kinds } from 'nostr-tools' import { EventEmitter } from 'tseep' -import { updateNsecbunkerPubkey } from '../store/actions' +import { + updateNsecbunkerPubkey, + setMostPopularRelaysAction, + setRelayInfoAction, + setRelayConnectionStatusAction +} from '../store/actions' import { AuthState, LoginMethods } from '../store/auth/types' import store from '../store/store' -import { SignedEvent, RelayMap } from '../types' -import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils' +import { + SignedEvent, + RelayMap, + RelayStats, + ReadRelay, + RelayInfoObject, + RelayConnectionStatus, + RelayConnectionState +} from '../types' +import { + compareObjects, + getNsecBunkerDelegatedKey, + verifySignedEvent +} from '../utils' +import axios from 'axios' export class NostrController extends EventEmitter { private static instance: NostrController - private specialMetadataRelay = 'wss://purplepag.es' private bunkerNDK: NDK | undefined private remoteSigner: NDKNip46Signer | undefined + private connectedRelays: Relay[] | undefined + private constructor() { super() } @@ -390,12 +411,7 @@ export class NostrController extends EventEmitter { getRelayMap = async ( npub: string ): Promise<{ map: RelayMap; mapUpdated: number }> => { - const mostPopularRelays = import.meta.env.VITE_MOST_POPULAR_RELAYS - const hardcodedPopularRelays = (mostPopularRelays || '').split(' ') - const popularRelayURIs = [ - this.specialMetadataRelay, - ...hardcodedPopularRelays - ] + const mostPopularRelays = await this.getMostPopularRelays() const pool = new SimplePool() @@ -405,9 +421,11 @@ export class NostrController extends EventEmitter { authors: [npub] } - const event = await pool.get(popularRelayURIs, eventFilter).catch((err) => { - return Promise.reject(err) - }) + const event = await pool + .get(mostPopularRelays, eventFilter) + .catch((err) => { + return Promise.reject(err) + }) if (event) { // Handle founded 10002 event @@ -427,6 +445,10 @@ export class NostrController extends EventEmitter { } }) + this.getRelayInfo(Object.keys(relaysMap)) + + this.connectToRelays(Object.keys(relaysMap)) + return Promise.resolve({ map: relaysMap, mapUpdated: event.created_at }) } else { return Promise.reject('User relays were not found.') @@ -437,11 +459,13 @@ export class NostrController extends EventEmitter { * 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 + npub: string, + extraRelaysToPublish?: string[] ): Promise => { const timestamp = Math.floor(Date.now() / 1000) const relayURIs = Object.keys(relayMap) @@ -471,18 +495,266 @@ export class NostrController extends EventEmitter { let relaysToPublish = relayURIs - // If relay map is empty, use most popular relay URIs - if (!relaysToPublish.length) { - const mostPopularRelays = import.meta.env.VITE_MOST_POPULAR_RELAYS - const hardcodedPopularRelays = (mostPopularRelays || '').split(' ') - - relaysToPublish = [this.specialMetadataRelay, ...hardcodedPopularRelays] + // Add extra relays if provided + if (extraRelaysToPublish) { + relaysToPublish = [...relaysToPublish, ...extraRelaysToPublish] } - await this.publishEvent(signedEvent, relaysToPublish) + // If relay map is empty, use most popular relay URIs + if (!relaysToPublish.length) { + relaysToPublish = await this.getMostPopularRelays() + } - return Promise.resolve( - `Relay Map published on: ${relaysToPublish.join('\n')}` + 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 => { + 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(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: ReadRelay) => 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: Math.round(Date.now() / 1000), + 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 => { + 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)) + } + } } }