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() +}