feat: implemented relay controller and use that for fetching and publishing events
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s

This commit is contained in:
daniyal 2024-08-15 22:13:39 +05:00
parent b8ba8d0ab4
commit a775d7b265
9 changed files with 505 additions and 243 deletions

View File

@ -1,28 +1,29 @@
import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
import { import {
Filter,
SimplePool,
VerifiedEvent,
kinds,
validateEvent,
verifyEvent,
Event, Event,
EventTemplate, EventTemplate,
nip19 Filter,
VerifiedEvent,
kinds,
nip19,
validateEvent,
verifyEvent
} from 'nostr-tools' } from 'nostr-tools'
import { NostrJoiningBlock, ProfileMetadata, RelaySet } from '../types'
import { NostrController } from '.'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { queryNip05, unixNow } from '../utils'
import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
import { EventEmitter } from 'tseep' import { EventEmitter } from 'tseep'
import { NostrController, relayController } from '.'
import { localCache } from '../services' import { localCache } from '../services'
import { NostrJoiningBlock, ProfileMetadata, RelaySet } from '../types'
import { import {
findRelayListAndUpdateCache, findRelayListAndUpdateCache,
findRelayListInCache, findRelayListInCache,
getDefaultRelaySet, getDefaultRelaySet,
getMostPopularRelays,
getUserRelaySet, getUserRelaySet,
isOlderThanOneWeek isOlderThanOneWeek,
} from '../utils/relays.ts' queryNip05,
unixNow
} from '../utils'
export class MetadataController extends EventEmitter { export class MetadataController extends EventEmitter {
private nostrController: NostrController private nostrController: NostrController
@ -51,11 +52,9 @@ export class MetadataController extends EventEmitter {
authors: [hexKey] // Authored by the specified key authors: [hexKey] // Authored by the specified key
} }
const pool = new SimplePool()
// Try to get the metadata event from a special relay (wss://purplepag.es) // Try to get the metadata event from a special relay (wss://purplepag.es)
const metadataEvent = await pool const metadataEvent = await relayController
.get([this.specialMetadataRelay], eventFilter) .fetchEvent(eventFilter, [this.specialMetadataRelay])
.catch((err) => { .catch((err) => {
console.error(err) // Log any errors console.error(err) // Log any errors
return null // Return null if an error occurs return null // Return null if an error occurs
@ -80,11 +79,12 @@ export class MetadataController extends EventEmitter {
} }
// If no valid metadata event is found from the special relay, get the most popular relays // If no valid metadata event is found from the special relay, get the most popular relays
const mostPopularRelays = await this.nostrController.getMostPopularRelays() const mostPopularRelays = await getMostPopularRelays()
// Query the most popular relays for metadata events // Query the most popular relays for metadata events
const events = await pool
.querySync(mostPopularRelays, eventFilter) const events = await relayController
.fetchEvents(eventFilter, mostPopularRelays)
.catch((err) => { .catch((err) => {
console.error(err) // Log any errors console.error(err) // Log any errors
return null // Return null if an error occurs return null // Return null if an error occurs
@ -169,10 +169,7 @@ export class MetadataController extends EventEmitter {
[this.specialMetadataRelay], [this.specialMetadataRelay],
hexKey hexKey
)) || )) ||
(await findRelayListAndUpdateCache( (await findRelayListAndUpdateCache(await getMostPopularRelays(), hexKey))
await this.nostrController.getMostPopularRelays(),
hexKey
))
return relayEvent ? getUserRelaySet(relayEvent.tags) : getDefaultRelaySet() return relayEvent ? getUserRelaySet(relayEvent.tags) : getDefaultRelaySet()
} }
@ -206,11 +203,15 @@ export class MetadataController extends EventEmitter {
await this.nostrController.signEvent(newMetadataEvent) await this.nostrController.signEvent(newMetadataEvent)
} }
await this.nostrController await relayController
.publishEvent(signedMetadataEvent, [this.specialMetadataRelay]) .publish(signedMetadataEvent, [this.specialMetadataRelay])
.then((relays) => { .then((relays) => {
toast.success(`Metadata event published on: ${relays.join('\n')}`) if (relays.length) {
this.handleNewMetadataEvent(signedMetadataEvent as VerifiedEvent) 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) => { .catch((err) => {
toast.error(err.message) toast.error(err.message)
@ -250,16 +251,10 @@ export class MetadataController extends EventEmitter {
authors: [hexKey] 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 if (event) {
const events = await pool.querySync(userRelays, eventFilter)
if (events && events.length) {
// sort events by created_at time in ascending order
events.sort((a, b) => a.created_at - b.created_at)
// get first ever event published on user's relays
const event = events[0]
const { created_at } = event const { created_at } = event
// initialize job request // initialize job request
@ -283,10 +278,12 @@ export class MetadataController extends EventEmitter {
'wss://relayable.org' 'wss://relayable.org'
] ]
// publish job request await relayController.publish(jobSignedEvent, relays).catch((err) => {
await this.nostrController.publishEvent(jobSignedEvent, relays) console.error(
'Error occurred in publish blockChain-block-number DVM job',
console.log('jobSignedEvent :>> ', jobSignedEvent) err
)
})
const subscribeWithTimeout = ( const subscribeWithTimeout = (
subscription: NDKSubscription, subscription: NDKSubscription,

View File

@ -6,7 +6,6 @@ import NDK, {
NDKUser, NDKUser,
NostrEvent NostrEvent
} from '@nostr-dev-kit/ndk' } from '@nostr-dev-kit/ndk'
import axios from 'axios'
import { import {
Event, Event,
EventTemplate, EventTemplate,
@ -20,10 +19,8 @@ import {
nip19, nip19,
nip44 nip44
} from 'nostr-tools' } from 'nostr-tools'
import { toast } from 'react-toastify'
import { EventEmitter } from 'tseep' import { EventEmitter } from 'tseep'
import { import {
setMostPopularRelaysAction,
setRelayConnectionStatusAction, setRelayConnectionStatusAction,
setRelayInfoAction, setRelayInfoAction,
updateNsecbunkerPubkey updateNsecbunkerPubkey
@ -35,17 +32,17 @@ import {
RelayConnectionStatus, RelayConnectionStatus,
RelayInfoObject, RelayInfoObject,
RelayMap, RelayMap,
RelayReadStats,
RelayStats,
SignedEvent SignedEvent
} from '../types' } from '../types'
import { import {
compareObjects, compareObjects,
getDefaultRelayMap,
getMostPopularRelays,
getNsecBunkerDelegatedKey, getNsecBunkerDelegatedKey,
unixNow, unixNow,
verifySignedEvent verifySignedEvent
} from '../utils' } from '../utils'
import { getDefaultRelayMap } from '../utils/relays.ts' import { relayController } from './'
export class NostrController extends EventEmitter { export class NostrController extends EventEmitter {
private static instance: NostrController private static instance: NostrController
@ -223,98 +220,6 @@ export class NostrController extends EventEmitter {
return NostrController.instance return NostrController.instance
} }
/**
* Function will publish provided event to the provided relays
*
* @param event - The event to publish.
* @param relays - An array of relay URLs to publish the event to.
* @returns A promise that resolves to an array of relays where the event was successfully published.
*/
publishEvent = async (event: Event, relays: string[]) => {
const simplePool = new SimplePool()
// Publish the event to all relays
const promises = simplePool.publish(relays, event)
// Use Promise.race to wait for the first successful publish
const firstSuccessfulPublish = await Promise.race(
promises.map((promise, index) =>
promise.then(() => relays[index]).catch(() => null)
)
)
if (!firstSuccessfulPublish) {
// If no publish was successful, collect the reasons for failures
const failedPublishes: unknown[] = []
const fallbackRejectionReason =
'Attempt to publish an event has been rejected with unknown reason.'
const results = await Promise.allSettled(promises)
results.forEach((res, index) => {
if (res.status === 'rejected') {
failedPublishes.push({
relay: relays[index],
error: res.reason
? res.reason.message || fallbackRejectionReason
: fallbackRejectionReason
})
}
})
throw failedPublishes
}
// Continue publishing to other relays in the background
promises.forEach((promise, index) => {
promise.catch((err) => {
console.log(`Failed to publish to ${relays[index]}`, err)
})
})
return [firstSuccessfulPublish]
}
/**
* Asynchronously retrieves an event from a set of relays based on a provided filter.
* If no relays are specified, it defaults to using connected relays.
*
* @param {Filter} filter - The filter criteria to find the event.
* @param {string[]} [relays] - An optional array of relay URLs to search for the event.
* @returns {Promise<Event | null>} - Returns a promise that resolves to the found event or null if not found.
*/
getEvent = async (
filter: Filter,
relays?: string[]
): Promise<Event | null> => {
// If no relays are provided or the provided array is empty, use connected relays if available.
if (!relays || relays.length === 0) {
relays = this.connectedRelays
? this.connectedRelays.map((relay) => relay.url)
: []
}
// If still no relays are available, reject the promise with an error message.
if (relays.length === 0) {
return Promise.reject('Provide some relays to find the event')
}
// Create a new instance of SimplePool to handle the relay connections and event retrieval.
const pool = new SimplePool()
// Attempt to retrieve the event from the specified relays using the filter criteria.
const event = await pool.get(relays, filter).catch((err) => {
// Log any errors that occur during the event retrieval process.
console.log('An error occurred in finding the event', err)
// Show an error toast notification to the user.
toast.error('An error occurred in finding the event')
// Return null if an error occurs, indicating that no event was found.
return null
})
// Return the found event, or null if an error occurred.
return event
}
/** /**
* Encrypts the given content for the specified receiver using NIP-44 encryption. * Encrypts the given content for the specified receiver using NIP-44 encryption.
* *
@ -659,7 +564,7 @@ export class NostrController extends EventEmitter {
getRelayMap = async ( getRelayMap = async (
npub: string npub: string
): Promise<{ map: RelayMap; mapUpdated?: number }> => { ): Promise<{ map: RelayMap; mapUpdated?: number }> => {
const mostPopularRelays = await this.getMostPopularRelays() const mostPopularRelays = await getMostPopularRelays()
const pool = new SimplePool() const pool = new SimplePool()
@ -750,10 +655,13 @@ export class NostrController extends EventEmitter {
// If relay map is empty, use most popular relay URIs // If relay map is empty, use most popular relay URIs
if (!relaysToPublish.length) { 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) { if (publishResult && publishResult.length) {
return Promise.resolve( return Promise.resolve(
@ -764,51 +672,6 @@ export class NostrController extends EventEmitter {
return Promise.reject('Publishing updated relay map was unsuccessful.') return Promise.reject('Publishing updated relay map was unsuccessful.')
} }
/**
* Provides most popular relays.
* @param numberOfTopRelays - number representing how many most popular relays to provide
* @returns - promise that resolves into an array of most popular relays
*/
getMostPopularRelays = async (
numberOfTopRelays: number = 30
): Promise<string[]> => {
const mostPopularRelaysState = store.getState().relays?.mostPopular
// return most popular relays from app state if present
if (mostPopularRelaysState) return mostPopularRelaysState
// relays in env
const { VITE_MOST_POPULAR_RELAYS } = import.meta.env
const hardcodedPopularRelays = (VITE_MOST_POPULAR_RELAYS || '').split(' ')
const url = `https://stats.nostr.band/stats_api?method=stats`
const response = await axios.get<RelayStats>(url).catch(() => undefined)
if (!response) {
return hardcodedPopularRelays //return hardcoded relay list
}
const data = response.data
if (!data) {
return hardcodedPopularRelays //return hardcoded relay list
}
const apiTopRelays = data.relay_stats.user_picks.read_relays
.slice(0, numberOfTopRelays)
.map((relay: RelayReadStats) => relay.d)
if (!apiTopRelays.length) {
return Promise.reject(`Couldn't fetch popular relays.`)
}
if (store.getState().auth?.loggedIn) {
store.dispatch(setMostPopularRelaysAction(apiTopRelays))
}
return apiTopRelays
}
/** /**
* Sets information about relays into relays.info app state. * Sets information about relays into relays.info app state.
* @param relayURIs - relay URIs to get information about * @param relayURIs - relay URIs to get information about
@ -835,7 +698,7 @@ export class NostrController extends EventEmitter {
] ]
// publish job request // publish job request
await this.publishEvent(jobSignedEvent, relays) await relayController.publish(jobSignedEvent, relays)
console.log('jobSignedEvent :>> ', jobSignedEvent) console.log('jobSignedEvent :>> ', jobSignedEvent)

View File

@ -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<Event | null>} - Returns a promise that resolves to the found event or null if not found.
*/
fetchEvents = async (
filter: Filter,
relayUrls: string[] = []
): Promise<Event[]> => {
// 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<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
// Check if any relays are connected
if (relays.length === 0) {
throw new Error('No relay is connected to fetch events!')
}
const events: Event[] = []
const eventIds = new Set<string>() // To keep track of event IDs and avoid duplicates
// Create a promise for each relay subscription
const subPromises = relays.map((relay) => {
return new Promise<void>((resolve) => {
// 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<Event | null>} - Returns a promise that resolves to the found event or null if not found.
*/
fetchEvent = async (
filter: Filter,
relays: string[] = []
): Promise<Event | null> => {
const events = await this.fetchEvents(filter, relays)
// Sort events by creation date in descending order
events.sort((a, b) => b.created_at - a.created_at)
// Return the most recent event, or null if no events were received
return events[0] || null
}
/**
* Subscribes to events from multiple relays.
*
* This method connects to the specified relay URLs and subscribes to events
* using the provided filter. It handles incoming events through the given
* `eventHandler` callback and manages the subscription lifecycle.
*
* @param filter - The filter criteria to apply when subscribing to events.
* @param relayUrls - An optional array of relay URLs to connect to. The default relay URL (`SIGIT_RELAY`) is added automatically.
* @param eventHandler - A callback function to handle incoming events. It receives an `Event` object.
*
*/
subscribeForEvents = async (
filter: Filter,
relayUrls: string[] = [],
eventHandler: (event: Event) => void
) => {
// add app relay to relays array
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<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
// Check if any relays are connected
if (relays.length === 0) {
throw new Error('No relay is connected to fetch events!')
}
const processedEvents: string[] = [] // To keep track of processed events
// Create a promise for each relay subscription
const subPromises = relays.map((relay) => {
return new Promise<void>((resolve) => {
// Subscribe to the relay with the specified filter
const sub = relay.subscribe([filter], {
// Handle incoming events
onevent: (e) => {
// Process event only if it hasn't been processed before
if (!processedEvents.includes(e.id)) {
processedEvents.push(e.id)
eventHandler(e) // Call the event handler with the event
}
},
// Handle the End-Of-Stream (EOSE) message
oneose: () => {
sub.close() // Close the subscription
resolve() // Resolve the promise when EOSE is received
}
})
})
})
// Wait for all subscriptions to complete
await Promise.allSettled(subPromises)
}
publish = async (
event: Event,
relayUrls: string[] = []
): Promise<string[]> => {
// add app relay to relays array
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<Relay[]>((acc, result) => {
if (result.status === 'fulfilled') {
const value = result.value
if (value) {
acc.push(value)
}
}
return acc
}, [])
// Check if any relays are connected
if (relays.length === 0) {
throw new Error('No relay is connected to publish event!')
}
const publishedOnRelays: string[] = [] // List to track which relays successfully published the event
// Create a promise for publishing the event to each connected relay
const publishPromises = relays.map(async (relay) => {
try {
await Promise.race([
relay.publish(event), // Publish the event to the relay
timeout(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()

View File

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

View File

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

View File

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

View File

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

View File

@ -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 { RelayList } from 'nostr-tools/kinds'
import { Event } from 'nostr-tools' import { relayController } from '../controllers/RelayController.ts'
import { localCache } from '../services' 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.ts'
import { RelayMap, RelaySet } from '../types'
const READ_MARKER = 'read' const READ_MARKER = 'read'
const WRITE_MARKER = 'write' const WRITE_MARKER = 'write'
@ -24,8 +27,8 @@ const findRelayListAndUpdateCache = async (
kinds: [RelayList], kinds: [RelayList],
authors: [hexKey] authors: [hexKey]
} }
const pool = new SimplePool()
const event = await pool.get(lookUpRelays, eventFilter) const event = await relayController.fetchEvent(eventFilter, lookUpRelays)
if (event) { if (event) {
await localCache.addUserRelayListMetadata(event) await localCache.addUserRelayListMetadata(event)
} }
@ -106,11 +109,57 @@ const toRelaySet = (obj: RelaySet, tag: string[]): RelaySet => {
return obj return obj
} }
/**
* Provides most popular relays.
* @param numberOfTopRelays - number representing how many most popular relays to provide
* @returns - promise that resolves into an array of most popular relays
*/
const getMostPopularRelays = async (
numberOfTopRelays: number = 30
): Promise<string[]> => {
const mostPopularRelaysState = store.getState().relays?.mostPopular
// return most popular relays from app state if present
if (mostPopularRelaysState) return mostPopularRelaysState
// relays in env
const { VITE_MOST_POPULAR_RELAYS } = import.meta.env
const hardcodedPopularRelays = (VITE_MOST_POPULAR_RELAYS || '').split(' ')
const url = `https://stats.nostr.band/stats_api?method=stats`
const response = await axios.get<RelayStats>(url).catch(() => undefined)
if (!response) {
return hardcodedPopularRelays //return hardcoded relay list
}
const data = response.data
if (!data) {
return hardcodedPopularRelays //return hardcoded relay list
}
const apiTopRelays = data.relay_stats.user_picks.read_relays
.slice(0, numberOfTopRelays)
.map((relay: RelayReadStats) => relay.d)
if (!apiTopRelays.length) {
return Promise.reject(`Couldn't fetch popular relays.`)
}
if (store.getState().auth?.loggedIn) {
store.dispatch(setMostPopularRelaysAction(apiTopRelays))
}
return apiTopRelays
}
export { export {
findRelayListAndUpdateCache, findRelayListAndUpdateCache,
findRelayListInCache, findRelayListInCache,
getUserRelaySet,
getDefaultRelaySet,
getDefaultRelayMap, getDefaultRelayMap,
getDefaultRelaySet,
getMostPopularRelays,
getUserRelaySet,
isOlderThanOneWeek isOlderThanOneWeek
} }

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

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