From a775d7b265594d575f106898585b8f1dcebbce6f Mon Sep 17 00:00:00 2001 From: daniyal Date: Thu, 15 Aug 2024 22:13:39 +0500 Subject: [PATCH 1/9] feat: implemented relay controller and use that for fetching and publishing events --- src/controllers/MetadataController.ts | 79 ++++--- src/controllers/NostrController.ts | 157 +------------- src/controllers/RelayController.ts | 293 ++++++++++++++++++++++++++ src/controllers/index.ts | 1 + src/layouts/Main.tsx | 22 +- src/utils/index.ts | 12 +- src/utils/nostr.ts | 74 ++++--- src/utils/relays.ts | 63 +++++- src/utils/url.ts | 47 +++++ 9 files changed, 505 insertions(+), 243 deletions(-) create mode 100644 src/controllers/RelayController.ts create mode 100644 src/utils/url.ts diff --git a/src/controllers/MetadataController.ts b/src/controllers/MetadataController.ts index 8f4d190..b9557bb 100644 --- a/src/controllers/MetadataController.ts +++ b/src/controllers/MetadataController.ts @@ -1,28 +1,29 @@ +import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk' import { - Filter, - SimplePool, - VerifiedEvent, - kinds, - validateEvent, - verifyEvent, Event, EventTemplate, - nip19 + Filter, + VerifiedEvent, + kinds, + nip19, + validateEvent, + 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 { NostrJoiningBlock, ProfileMetadata, RelaySet } from '../types' import { findRelayListAndUpdateCache, findRelayListInCache, getDefaultRelaySet, + getMostPopularRelays, getUserRelaySet, - isOlderThanOneWeek -} from '../utils/relays.ts' + isOlderThanOneWeek, + queryNip05, + unixNow +} from '../utils' export class MetadataController extends EventEmitter { private nostrController: NostrController @@ -51,11 +52,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 +79,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 +169,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,11 +203,15 @@ 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) @@ -250,16 +251,10 @@ export class MetadataController extends EventEmitter { authors: [hexKey] } - const pool = new SimplePool() + // find user's kind 0 event published on user's relays + const event = await relayController.fetchEvent(eventFilter, userRelays) - // 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] + if (event) { const { created_at } = event // initialize job request @@ -283,10 +278,12 @@ export class MetadataController extends EventEmitter { 'wss://relayable.org' ] - // publish job request - await this.nostrController.publishEvent(jobSignedEvent, relays) - - console.log('jobSignedEvent :>> ', jobSignedEvent) + await relayController.publish(jobSignedEvent, relays).catch((err) => { + console.error( + 'Error occurred in publish blockChain-block-number DVM job', + err + ) + }) const subscribeWithTimeout = ( subscription: NDKSubscription, diff --git a/src/controllers/NostrController.ts b/src/controllers/NostrController.ts index 19182b5..cf6c5d6 100644 --- a/src/controllers/NostrController.ts +++ b/src/controllers/NostrController.ts @@ -6,7 +6,6 @@ import NDK, { NDKUser, NostrEvent } from '@nostr-dev-kit/ndk' -import axios from 'axios' import { Event, EventTemplate, @@ -20,10 +19,8 @@ import { nip19, nip44 } from 'nostr-tools' -import { toast } from 'react-toastify' import { EventEmitter } from 'tseep' import { - setMostPopularRelaysAction, setRelayConnectionStatusAction, setRelayInfoAction, updateNsecbunkerPubkey @@ -35,17 +32,17 @@ import { RelayConnectionStatus, RelayInfoObject, RelayMap, - RelayReadStats, - RelayStats, SignedEvent } from '../types' import { compareObjects, + getDefaultRelayMap, + getMostPopularRelays, getNsecBunkerDelegatedKey, unixNow, verifySignedEvent } from '../utils' -import { getDefaultRelayMap } from '../utils/relays.ts' +import { relayController } from './' export class NostrController extends EventEmitter { private static instance: NostrController @@ -223,98 +220,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} - Returns a promise that resolves to the found event or null if not found. - */ - getEvent = async ( - filter: Filter, - relays?: string[] - ): Promise => { - // 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. * @@ -659,7 +564,7 @@ export class NostrController extends EventEmitter { getRelayMap = async ( npub: string ): Promise<{ map: RelayMap; mapUpdated?: number }> => { - const mostPopularRelays = await this.getMostPopularRelays() + const mostPopularRelays = await getMostPopularRelays() const pool = new SimplePool() @@ -750,10 +655,13 @@ export class NostrController extends EventEmitter { // If relay map is empty, use most popular relay URIs if (!relaysToPublish.length) { - relaysToPublish = await this.getMostPopularRelays() + relaysToPublish = await getMostPopularRelays() } - const publishResult = await this.publishEvent(signedEvent, relaysToPublish) + const publishResult = await relayController.publish( + signedEvent, + relaysToPublish + ) if (publishResult && publishResult.length) { return Promise.resolve( @@ -764,51 +672,6 @@ export class NostrController extends EventEmitter { 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: 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 @@ -835,7 +698,7 @@ export class NostrController extends EventEmitter { ] // publish job request - await this.publishEvent(jobSignedEvent, relays) + await relayController.publish(jobSignedEvent, relays) console.log('jobSignedEvent :>> ', jobSignedEvent) diff --git a/src/controllers/RelayController.ts b/src/controllers/RelayController.ts new file mode 100644 index 0000000..1c13cae --- /dev/null +++ b/src/controllers/RelayController.ts @@ -0,0 +1,293 @@ +import { Filter, Relay, Event } 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: 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) => { + // Check if a relay with the same URL is already connected + const relay = this.connectedRelays.find( + (relay) => + normalizeWebSocketURL(relay.url) === normalizeWebSocketURL(relayUrl) + ) + + // If a matching relay is found, return it (skip connection) + if (relay) { + return relay + } + + try { + // Attempt to connect to the relay using the provided URL + const newRelay = await Relay.connect(relayUrl) + + // Add the newly connected relay to the list of connected relays + this.connectedRelays.push(newRelay) + + // Return the newly connected relay + return newRelay + } 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 + } + } + + /** + * 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} filter - The filter criteria to find the event. + * @param {string[]} [relays] - An optional array of relay URLs to search for the event. + * @returns {Promise} - Returns a promise that resolves to the found event or null if not found. + */ + fetchEvents = async ( + filter: Filter, + relayUrls: string[] = [] + ): Promise => { + // add app relay to relays array + relayUrls.push(SIGIT_RELAY) + + // Connect to all specified relays + const relayPromises = relayUrls.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((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() // To keep track of event IDs and avoid duplicates + + // Create a promise for each relay subscription + const subPromises = relays.map((relay) => { + return new Promise((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 + } + }) + }) + }) + + // 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} filter - The filter criteria to find the event. + * @param {string[]} [relays] - An optional array of relay URLs to search for the event. + * @returns {Promise} - Returns a promise that resolves to the found event or null if not found. + */ + fetchEvent = async ( + filter: Filter, + relays: string[] = [] + ): Promise => { + 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 + relayUrls.push(SIGIT_RELAY) + + // Connect to all specified relays + const relayPromises = relayUrls.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((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((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 => { + // add app relay to relays array + relayUrls.push(SIGIT_RELAY) + + // Connect to all specified relays + const relayPromises = relayUrls.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((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(30000) // 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}`, 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() diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 47cba11..dc1f76f 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1,3 +1,4 @@ export * from './AuthController' export * from './MetadataController' export * from './NostrController' +export * from './RelayController' diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index 51c2086..7b34720 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -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]) diff --git a/src/utils/index.ts b/src/utils/index.ts index ffac72d..16b36f5 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,10 +1,12 @@ export * from './crypto' 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' diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index e9ecc8f..48ce930 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -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 => { + // 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 => { 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 => { 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 => { } } + // 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 => { 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,9 +601,8 @@ 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), + relayController.publish(signedEvent, writeRelays), timeout(1000 * 30) ]).catch((err) => { console.log('err :>> ', err) @@ -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,13 +933,9 @@ 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), + relayController.publish(wrappedEvent, relaySet.read), timeout(1000 * 30) ]).catch((err) => { // Log an error if publishing the notification event fails diff --git a/src/utils/relays.ts b/src/utils/relays.ts index a7a8227..2d2d8cd 100644 --- a/src/utils/relays.ts +++ b/src/utils/relays.ts @@ -1,9 +1,12 @@ -import { Filter, SimplePool } from 'nostr-tools' +import axios from 'axios' +import { Event, Filter } from 'nostr-tools' import { RelayList } from 'nostr-tools/kinds' -import { Event } from 'nostr-tools' +import { relayController } from '../controllers/RelayController.ts' import { localCache } from '../services' +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.ts' -import { RelayMap, RelaySet } from '../types' const READ_MARKER = 'read' const WRITE_MARKER = 'write' @@ -24,8 +27,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 +109,57 @@ 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 => { + 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: 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 +} + export { findRelayListAndUpdateCache, findRelayListInCache, - getUserRelaySet, - getDefaultRelaySet, getDefaultRelayMap, + getDefaultRelaySet, + getMostPopularRelays, + getUserRelaySet, isOlderThanOneWeek } diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 0000000..2b8181a --- /dev/null +++ b/src/utils/url.ts @@ -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() +} -- 2.34.1 From 03cb6b1732324aff887400f2dd8be9857028e4ef Mon Sep 17 00:00:00 2001 From: daniyal Date: Fri, 16 Aug 2024 11:42:28 +0500 Subject: [PATCH 2/9] chore(refactor): move getNostrJoiningBlockNumber function to a separate dvm utils file --- src/controllers/MetadataController.ts | 128 +----------------------- src/pages/profile/index.tsx | 10 +- src/pages/settings/profile/index.tsx | 9 +- src/utils/dvm.ts | 135 ++++++++++++++++++++++++++ src/utils/index.ts | 1 + 5 files changed, 150 insertions(+), 133 deletions(-) create mode 100644 src/utils/dvm.ts diff --git a/src/controllers/MetadataController.ts b/src/controllers/MetadataController.ts index b9557bb..bf1b3d8 100644 --- a/src/controllers/MetadataController.ts +++ b/src/controllers/MetadataController.ts @@ -1,11 +1,8 @@ -import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk' import { Event, - EventTemplate, Filter, VerifiedEvent, kinds, - nip19, validateEvent, verifyEvent } from 'nostr-tools' @@ -13,7 +10,7 @@ import { toast } from 'react-toastify' import { EventEmitter } from 'tseep' import { NostrController, relayController } from '.' import { localCache } from '../services' -import { NostrJoiningBlock, ProfileMetadata, RelaySet } from '../types' +import { ProfileMetadata, RelaySet } from '../types' import { findRelayListAndUpdateCache, findRelayListInCache, @@ -21,7 +18,6 @@ import { getMostPopularRelays, getUserRelaySet, isOlderThanOneWeek, - queryNip05, unixNow } from '../utils' @@ -218,128 +214,6 @@ export class MetadataController extends EventEmitter { }) } - public getNostrJoiningBlockNumber = async ( - hexKey: string - ): Promise => { - 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] - } - - // 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'] - ] - } - - // sign job request event - const jobSignedEvent = - await this.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 => { - 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 => { diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index c1822b9..a7b205b 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -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) }) diff --git a/src/pages/settings/profile/index.tsx b/src/pages/settings/profile/index.tsx index 9b2fc2d..8723c2e 100644 --- a/src/pages/settings/profile/index.tsx +++ b/src/pages/settings/profile/index.tsx @@ -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) }) diff --git a/src/utils/dvm.ts b/src/utils/dvm.ts new file mode 100644 index 0000000..ba985ed --- /dev/null +++ b/src/utils/dvm.ts @@ -0,0 +1,135 @@ +import { EventTemplate, Filter, kinds, nip19 } from 'nostr-tools' +import { queryNip05, unixNow } from '.' +import { + MetadataController, + NostrController, + relayController +} from '../controllers' +import { NostrJoiningBlock } from '../types' +import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk' + +export const getNostrJoiningBlockNumber = async ( + hexKey: string +): Promise => { + 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 => { + 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 +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 16b36f5..accc008 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './crypto' +export * from './dvm' export * from './hash' export * from './localStorage' export * from './mark' -- 2.34.1 From 66c7182fa4e0ec1f18c8bea7e937dcd5accfd262 Mon Sep 17 00:00:00 2001 From: daniyal Date: Sun, 18 Aug 2024 22:48:48 +0500 Subject: [PATCH 3/9] chore(refactor): move dvm related function to dvm utils file and relay related to relays utils file --- src/controllers/AuthController.ts | 9 +- src/controllers/NostrController.ts | 345 +---------------- src/controllers/RelayController.ts | 63 ++-- src/hooks/index.ts | 1 + src/hooks/useDidMount.ts | 12 + src/pages/settings/relays/index.tsx | 564 ++++++++++++---------------- src/utils/dvm.ts | 97 ++++- src/utils/nostr.ts | 4 +- src/utils/relays.ts | 126 ++++++- 9 files changed, 517 insertions(+), 704 deletions(-) create mode 100644 src/hooks/useDidMount.ts diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts index 33f5c82..09b20df 100644 --- a/src/controllers/AuthController.ts +++ b/src/controllers/AuthController.ts @@ -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 diff --git a/src/controllers/NostrController.ts b/src/controllers/NostrController.ts index cf6c5d6..0547ffb 100644 --- a/src/controllers/NostrController.ts +++ b/src/controllers/NostrController.ts @@ -2,47 +2,24 @@ import NDK, { NDKEvent, NDKNip46Signer, NDKPrivateKeySigner, - NDKSubscription, NDKUser, NostrEvent } from '@nostr-dev-kit/ndk' import { Event, EventTemplate, - Filter, - Relay, - SimplePool, UnsignedEvent, finalizeEvent, - kinds, nip04, nip19, nip44 } from 'nostr-tools' import { EventEmitter } from 'tseep' -import { - 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, - SignedEvent -} from '../types' -import { - compareObjects, - getDefaultRelayMap, - getMostPopularRelays, - getNsecBunkerDelegatedKey, - unixNow, - verifySignedEvent -} from '../utils' -import { relayController } from './' +import { SignedEvent } from '../types' +import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils' export class NostrController extends EventEmitter { private static instance: NostrController @@ -50,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( @@ -555,317 +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 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 => { - 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 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.') - } - - /** - * 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 relayController.publish(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)) - } - } - } } diff --git a/src/controllers/RelayController.ts b/src/controllers/RelayController.ts index 1c13cae..4ab711c 100644 --- a/src/controllers/RelayController.ts +++ b/src/controllers/RelayController.ts @@ -1,4 +1,4 @@ -import { Filter, Relay, Event } from 'nostr-tools' +import { Event, Filter, Relay } from 'nostr-tools' import { normalizeWebSocketURL, timeout } from '../utils' import { SIGIT_RELAY } from '../utils/const' @@ -7,7 +7,7 @@ import { SIGIT_RELAY } from '../utils/const' */ export class RelayController { private static instance: RelayController - public connectedRelays: Relay[] = [] + public connectedRelays = new Map() private constructor() {} @@ -34,34 +34,44 @@ export class RelayController { * @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) => { + public connectRelay = async (relayUrl: string): Promise => { // Check if a relay with the same URL is already connected - const relay = this.connectedRelays.find( - (relay) => - normalizeWebSocketURL(relay.url) === normalizeWebSocketURL(relayUrl) - ) + const normalizedWebSocketURL = normalizeWebSocketURL(relayUrl) + const relay = this.connectedRelays.get(normalizedWebSocketURL) - // If a matching relay is found, return it (skip connection) if (relay) { - return 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) } - try { - // Attempt to connect to the relay using the provided URL - const newRelay = await Relay.connect(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) - // Add the newly connected relay to the list of connected relays - this.connectedRelays.push(newRelay) + // Return the newly connected relay + return relay + } - // Return the newly connected relay - return newRelay - } catch (err) { - // Log an error message if the connection fails - console.error(`Relay connection failed: ${relayUrl}`, err) + 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 null to indicate connection failure + return null + }) + + return newRelay } /** @@ -109,6 +119,11 @@ export class RelayController { // Create a promise for each relay subscription const subPromises = relays.map((relay) => { return new Promise((resolve) => { + if (!relay.connected) { + console.log(`${relay.url} : Not connected!`, 'Skipping subscription') + return + } + // Subscribe to the relay with the specified filter const sub = relay.subscribe([filter], { // Handle incoming events @@ -274,11 +289,11 @@ export class RelayController { try { await Promise.race([ relay.publish(event), // Publish the event to the relay - timeout(30000) // Set a timeout to handle cases where publishing takes too long + 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}`, err) + console.error(`Failed to publish event on relay: ${relay.url}`, err) } }) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 16c8633..e7ec305 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1 +1,2 @@ export * from './store' +export * from './useDidMount' diff --git a/src/hooks/useDidMount.ts b/src/hooks/useDidMount.ts new file mode 100644 index 0000000..5bac96a --- /dev/null +++ b/src/hooks/useDidMount.ts @@ -0,0 +1,12 @@ +import { useRef, useEffect } from 'react' + +export const useDidMount = (callback: () => void) => { + const didMount = useRef(false) + + useEffect(() => { + if (callback && !didMount.current) { + didMount.current = true + callback() + } + }) +} diff --git a/src/pages/settings/relays/index.tsx b/src/pages/settings/relays/index.tsx index 929a093..73e8c6f 100644 --- a/src/pages/settings/relays/index.tsx +++ b/src/pages/settings/relays/index.tsx @@ -12,138 +12,41 @@ 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() const [newRelayURIerror, setNewRelayURIerror] = useState() - const [relayMap, setRelayMap] = useState( - relaysState?.map - ) - const [relaysInfo, setRelaysInfo] = useState( - relaysState?.info - ) - const [displayRelaysInfo, setDisplayRelaysInfo] = useState([]) - 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) + useDidMount(() => { + if (usersPubkey) { + getRelayMap(usersPubkey).then((newRelayMap) => { + dispatch(setRelayMapAction(newRelayMap.map)) + }) } - }, [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()) - } - } - } - } - } - - // 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 +78,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 +126,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 +157,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 +189,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 ( @@ -343,177 +217,211 @@ export const RelaysPage = () => { {relayMap && ( - {Object.keys(relayMap).map((relay, i) => ( - - - - - {relaysInfo && - relaysInfo[relay] && - relaysInfo[relay].limitation && - relaysInfo[relay].limitation?.payment_required && ( - - handleRelayInfo(relay)} - /> - - )} - - - - handleLeaveRelay(relay)} - > - - Leave - - - - - handleRelayInfo(relay)} - className={styles.showInfo} - > - Show info{' '} - {displayRelaysInfo.includes(relay) ? ( - - ) : ( - - )} - - ) : ( - '' - ) - } - /> - handleRelayWriteChange(relay, event)} - /> - - {displayRelaysInfo.includes(relay) && ( - <> - - - - {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 = ( -
    - {Object.keys(infoValue).map((valueKey) => ( -
  • - - {capitalizeFirstLetter( - valueKey.split('_').join(' ') - )} - : - {' '} - {`${infoValue[valueKey]}`} -
  • - ))} -
- ) - - break - - case 'fees': - infoValue = ( -
    - {Object.keys(infoValue).map((valueKey) => ( -
  • - - {capitalizeFirstLetter( - valueKey.split('_').join(' ') - )} - : - {' '} - {`${infoValue[valueKey].map((fee: RelayFee) => `${fee.amount} ${fee.unit}`)}`} -
  • - ))} -
- ) - break - default: - break - } - - if (Array.isArray(infoValue)) { - infoValue = infoValue.join(', ') - } - - return ( - - - {infoTitle}: - {' '} - {infoValue} - {key === 'pubkey' ? ( - { - navigator.clipboard.writeText( - hexToNpub( - (relaysInfo[relay] as any)[key] - ) - ) - - toast.success('Copied to clipboard', { - autoClose: 1000, - hideProgressBar: true - }) - }} - /> - ) : null} - - ) - })} -
-
- - )} -
-
+ {Object.keys(relayMap).map((relay) => ( + ))}
)}
) } + +type RelayItemProp = { + relayURI: string + isWriteRelay: boolean + relayInfo?: RelayInfo + handleLeaveRelay: (relay: string) => void + handleRelayWriteChange: ( + relay: string, + event: React.ChangeEvent + ) => Promise +} + +const RelayItem = ({ + relayURI, + isWriteRelay, + relayInfo, + handleLeaveRelay, + handleRelayWriteChange +}: RelayItemProp) => { + const [relayConnectionStatus, setRelayConnectionStatus] = + useState() + + const [displayRelayInfo, setDisplayRelayInfo] = useState(false) + + useDidMount(() => { + relayController.connectRelay(relayURI).then((relay) => { + if (relay && relay.connected) { + setRelayConnectionStatus(RelayConnectionState.Connected) + } else { + setRelayConnectionStatus(RelayConnectionState.NotConnected) + } + }) + }) + + return ( + + + + + {relayInfo && + relayInfo.limitation && + relayInfo.limitation?.payment_required && ( + + setDisplayRelayInfo((prev) => !prev)} + /> + + )} + + + + handleLeaveRelay(relayURI)} + > + + Leave + + + + + setDisplayRelayInfo((prev) => !prev)} + className={styles.showInfo} + > + Show info{' '} + {displayRelayInfo ? ( + + ) : ( + + )} + + ) : ( + '' + ) + } + /> + handleRelayWriteChange(relayURI, event)} + /> + + {displayRelayInfo && ( + <> + + + + {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 = ( +
    + {Object.keys(infoValue).map((valueKey) => ( +
  • + + {capitalizeFirstLetter( + valueKey.split('_').join(' ') + )} + : + {' '} + {`${infoValue[valueKey]}`} +
  • + ))} +
+ ) + + break + + case 'fees': + infoValue = ( +
    + {Object.keys(infoValue).map((valueKey) => ( +
  • + + {capitalizeFirstLetter( + valueKey.split('_').join(' ') + )} + : + {' '} + {`${infoValue[valueKey].map((fee: RelayFee) => `${fee.amount} ${fee.unit}`)}`} +
  • + ))} +
+ ) + break + default: + break + } + + if (Array.isArray(infoValue)) { + infoValue = infoValue.join(', ') + } + + return ( + + + {infoTitle}: + {' '} + {infoValue} + {key === 'pubkey' ? ( + { + 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} + + ) + })} +
+
+ + )} +
+
+ ) +} diff --git a/src/utils/dvm.ts b/src/utils/dvm.ts index ba985ed..8995ae7 100644 --- a/src/utils/dvm.ts +++ b/src/utils/dvm.ts @@ -1,12 +1,14 @@ import { EventTemplate, Filter, kinds, nip19 } from 'nostr-tools' -import { queryNip05, unixNow } from '.' +import { compareObjects, queryNip05, unixNow } from '.' import { MetadataController, NostrController, relayController } from '../controllers' -import { NostrJoiningBlock } from '../types' +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 @@ -133,3 +135,94 @@ export const getNostrJoiningBlockNumber = async ( 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 => { + 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)) + } +} diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 48ce930..f3d6736 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -603,7 +603,7 @@ export const updateUsersAppData = async (meta: Meta) => { const publishResult = await Promise.race([ relayController.publish(signedEvent, writeRelays), - timeout(1000 * 30) + timeout(40 * 1000) ]).catch((err) => { console.log('err :>> ', err) if (err.message === 'Timeout') { @@ -936,7 +936,7 @@ export const sendNotification = async (receiver: string, meta: Meta) => { // Publish the notification event to the recipient's read relays await Promise.race([ relayController.publish(wrappedEvent, relaySet.read), - timeout(1000 * 30) + timeout(40 * 1000) ]).catch((err) => { // Log an error if publishing the notification event fails console.log( diff --git a/src/utils/relays.ts b/src/utils/relays.ts index 2d2d8cd..32bfdd4 100644 --- a/src/utils/relays.ts +++ b/src/utils/relays.ts @@ -1,12 +1,13 @@ import axios from 'axios' -import { Event, Filter } from 'nostr-tools' +import { Event, Filter, kinds, UnsignedEvent } from 'nostr-tools' import { RelayList } from 'nostr-tools/kinds' -import { relayController } from '../controllers/RelayController.ts' +import { getRelayInfo, unixNow } from '.' +import { NostrController, relayController } from '../controllers' import { localCache } from '../services' 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.ts' +import { ONE_WEEK_IN_MS, SIGIT_RELAY } from './const' const READ_MARKER = 'read' const WRITE_MARKER = 'write' @@ -154,12 +155,131 @@ const getMostPopularRelays = async ( 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 => { + 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, getDefaultRelayMap, getDefaultRelaySet, getMostPopularRelays, + getRelayMap, + publishRelayMap, getUserRelaySet, isOlderThanOneWeek } -- 2.34.1 From 12765cf758602d2dd8ce37d1427df984fb4049b9 Mon Sep 17 00:00:00 2001 From: daniyal Date: Sun, 18 Aug 2024 22:55:13 +0500 Subject: [PATCH 4/9] chore: remove relay connection status from redux --- src/store/actionTypes.ts | 1 - src/store/relays/action.ts | 10 +--------- src/store/relays/reducer.ts | 9 +-------- src/store/relays/types.ts | 9 +-------- 4 files changed, 3 insertions(+), 26 deletions(-) diff --git a/src/store/actionTypes.ts b/src/store/actionTypes.ts index 7a09430..01ecf99 100644 --- a/src/store/actionTypes.ts +++ b/src/store/actionTypes.ts @@ -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' diff --git a/src/store/relays/action.ts b/src/store/relays/action.ts index 6f95840..7552565 100644 --- a/src/store/relays/action.ts +++ b/src/store/relays/action.ts @@ -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 }) diff --git a/src/store/relays/reducer.ts b/src/store/relays/reducer.ts index b4b9854..f5067bf 100644 --- a/src/store/relays/reducer.ts +++ b/src/store/relays/reducer.ts @@ -5,8 +5,7 @@ const initialState: RelaysState = { map: undefined, mapUpdated: undefined, mostPopular: undefined, - info: undefined, - connectionStatus: undefined + info: undefined } const reducer = ( @@ -26,12 +25,6 @@ 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 } diff --git a/src/store/relays/types.ts b/src/store/relays/types.ts index e1c4da8..e90ca3b 100644 --- a/src/store/relays/types.ts +++ b/src/store/relays/types.ts @@ -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 -- 2.34.1 From 2f1423a35a2472308a72b7ce6d48762e084caf33 Mon Sep 17 00:00:00 2001 From: daniyal Date: Mon, 19 Aug 2024 16:34:42 +0500 Subject: [PATCH 5/9] chore: added a timeout of 30 seconds to subscription --- src/controllers/RelayController.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/controllers/RelayController.ts b/src/controllers/RelayController.ts index 4ab711c..9bb969c 100644 --- a/src/controllers/RelayController.ts +++ b/src/controllers/RelayController.ts @@ -121,7 +121,7 @@ export class RelayController { return new Promise((resolve) => { if (!relay.connected) { console.log(`${relay.url} : Not connected!`, 'Skipping subscription') - return + return resolve() } // Subscribe to the relay with the specified filter @@ -140,6 +140,14 @@ export class RelayController { 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) }) }) -- 2.34.1 From 3ae1a74dcd5c790e9f17e76d957d769b957a1c81 Mon Sep 17 00:00:00 2001 From: daniyal Date: Mon, 19 Aug 2024 21:29:15 +0500 Subject: [PATCH 6/9] chore: compare objects before dispatching redux action --- src/pages/settings/relays/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/settings/relays/index.tsx b/src/pages/settings/relays/index.tsx index 73e8c6f..7e86964 100644 --- a/src/pages/settings/relays/index.tsx +++ b/src/pages/settings/relays/index.tsx @@ -19,6 +19,7 @@ import { setRelayMapAction } from '../../../store/actions' import { RelayConnectionState, RelayFee, RelayInfo } from '../../../types' import { capitalizeFirstLetter, + compareObjects, getRelayInfo, getRelayMap, hexToNpub, @@ -43,7 +44,9 @@ export const RelaysPage = () => { useDidMount(() => { if (usersPubkey) { getRelayMap(usersPubkey).then((newRelayMap) => { - dispatch(setRelayMapAction(newRelayMap.map)) + if (!compareObjects(relayMap, newRelayMap.map)) { + dispatch(setRelayMapAction(newRelayMap.map)) + } }) } }) -- 2.34.1 From c274879adc4097a989b0c64e0312a9d8aa7831d6 Mon Sep 17 00:00:00 2001 From: daniyal Date: Tue, 20 Aug 2024 12:53:07 +0500 Subject: [PATCH 7/9] chore: use spread operator in relay reducer for setting mostPopular relays --- src/store/relays/reducer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/store/relays/reducer.ts b/src/store/relays/reducer.ts index f5067bf..68f18a0 100644 --- a/src/store/relays/reducer.ts +++ b/src/store/relays/reducer.ts @@ -26,7 +26,7 @@ const reducer = ( } case ActionTypes.SET_MOST_POPULAR_RELAYS: - return { ...state, mostPopular: action.payload } + return { ...state, mostPopular: [...action.payload] } case ActionTypes.RESTORE_STATE: return action.payload.relays -- 2.34.1 From a7883091266849eca17a959d21289469374ce147 Mon Sep 17 00:00:00 2001 From: daniyal Date: Tue, 20 Aug 2024 13:55:31 +0500 Subject: [PATCH 8/9] chore: update tsdoc for function defination in relay controller --- src/controllers/RelayController.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/controllers/RelayController.ts b/src/controllers/RelayController.ts index 9bb969c..12f24c2 100644 --- a/src/controllers/RelayController.ts +++ b/src/controllers/RelayController.ts @@ -78,9 +78,9 @@ export class RelayController { * 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} filter - The filter criteria to find the event. - * @param {string[]} [relays] - An optional array of relay URLs to search for the event. - * @returns {Promise} - Returns a promise that resolves to the found event or null if not found. + * @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, @@ -170,9 +170,9 @@ export class RelayController { * 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} - Returns a promise that resolves to the found event or null if not found. + * @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, -- 2.34.1 From e0d6c0363951c66bdede17759ae712626a4a07a5 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 20 Aug 2024 13:52:24 +0200 Subject: [PATCH 9/9] fix(relay-controller): sigit relay immutability and relay list --- src/controllers/RelayController.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/controllers/RelayController.ts b/src/controllers/RelayController.ts index 12f24c2..83d8ab3 100644 --- a/src/controllers/RelayController.ts +++ b/src/controllers/RelayController.ts @@ -86,11 +86,8 @@ export class RelayController { filter: Filter, relayUrls: string[] = [] ): Promise => { - // add app relay to relays array - relayUrls.push(SIGIT_RELAY) - - // Connect to all specified relays - const relayPromises = relayUrls.map((relayUrl) => + // Add app relay to relays array and connect to all specified relays + const relayPromises = [...relayUrls, SIGIT_RELAY].map((relayUrl) => this.connectRelay(relayUrl) ) @@ -204,11 +201,9 @@ export class RelayController { relayUrls: string[] = [], eventHandler: (event: Event) => void ) => { - // add app relay to relays array - relayUrls.push(SIGIT_RELAY) + // Add app relay to relays array and connect to all specified relays - // Connect to all specified relays - const relayPromises = relayUrls.map((relayUrl) => + const relayPromises = [...relayUrls, SIGIT_RELAY].map((relayUrl) => this.connectRelay(relayUrl) ) @@ -263,11 +258,9 @@ export class RelayController { event: Event, relayUrls: string[] = [] ): Promise => { - // add app relay to relays array - relayUrls.push(SIGIT_RELAY) + // Add app relay to relays array and connect to all specified relays - // Connect to all specified relays - const relayPromises = relayUrls.map((relayUrl) => + const relayPromises = [...relayUrls, SIGIT_RELAY].map((relayUrl) => this.connectRelay(relayUrl) ) -- 2.34.1