feat: implemented relay controller and use that for fetching and publishing events #149

Merged
s merged 10 commits from relay-controller into staging 2024-08-20 12:13:23 +00:00
9 changed files with 505 additions and 243 deletions
Showing only changes of commit a775d7b265 - Show all commits

View File

@ -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) => {
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,

View File

@ -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<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.
*
@ -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<string[]> => {
const mostPopularRelaysState = store.getState().relays?.mostPopular
// return most popular relays from app state if present
if (mostPopularRelaysState) return mostPopularRelaysState
// relays in env
const { VITE_MOST_POPULAR_RELAYS } = import.meta.env
const hardcodedPopularRelays = (VITE_MOST_POPULAR_RELAYS || '').split(' ')
const url = `https://stats.nostr.band/stats_api?method=stats`
const response = await axios.get<RelayStats>(url).catch(() => undefined)
if (!response) {
return hardcodedPopularRelays //return hardcoded relay list
}
const data = response.data
if (!data) {
return hardcodedPopularRelays //return hardcoded relay list
}
const apiTopRelays = data.relay_stats.user_picks.read_relays
.slice(0, numberOfTopRelays)
.map((relay: RelayReadStats) => relay.d)
if (!apiTopRelays.length) {
return Promise.reject(`Couldn't fetch popular relays.`)
}
if (store.getState().auth?.loggedIn) {
store.dispatch(setMostPopularRelaysAction(apiTopRelays))
}
return apiTopRelays
}
/**
* Sets information about relays into relays.info app state.
* @param relayURIs - relay URIs to get information about
@ -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)

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 './MetadataController'
export * from './NostrController'
export * from './RelayController'

View File

@ -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])

View File

@ -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'

View File

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

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 { 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<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 {
findRelayListAndUpdateCache,
findRelayListInCache,
getUserRelaySet,
getDefaultRelaySet,
getDefaultRelayMap,
getDefaultRelaySet,
getMostPopularRelays,
getUserRelaySet,
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()
}