feat: implemented relay controller and use that for fetching and publishing events #149
@ -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,
|
||||
|
@ -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)
|
||||
|
||||
|
293
src/controllers/RelayController.ts
Normal file
293
src/controllers/RelayController.ts
Normal 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) =>
|
||||
s marked this conversation as resolved
Outdated
|
||||
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()
|
@ -1,3 +1,4 @@
|
||||
export * from './AuthController'
|
||||
export * from './MetadataController'
|
||||
export * from './NostrController'
|
||||
export * from './RelayController'
|
||||
|
@ -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])
|
||||
|
@ -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'
|
||||
|
@ -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) => {
|
||||
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
|
||||
|
@ -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
47
src/utils/url.ts
Normal 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()
|
||||
}
|
Loading…
Reference in New Issue
Block a user
Small mismatch between comment and actual function return
fixed