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 517 additions and 704 deletions
Showing only changes of commit 66c7182fa4 - Show all commits

View File

@ -1,22 +1,23 @@
import { EventTemplate } from 'nostr-tools' import { EventTemplate } from 'nostr-tools'
import { MetadataController, NostrController } from '.' import { MetadataController, NostrController } from '.'
import { appPrivateRoutes } from '../routes'
import { import {
setAuthState, setAuthState,
setMetadataEvent, setMetadataEvent,
setRelayMapAction setRelayMapAction
} from '../store/actions' } from '../store/actions'
import store from '../store/store' import store from '../store/store'
import { SignedEvent } from '../types'
import { import {
base64DecodeAuthToken, base64DecodeAuthToken,
base64EncodeSignedEvent, base64EncodeSignedEvent,
compareObjects,
getAuthToken, getAuthToken,
getRelayMap,
getVisitedLink, getVisitedLink,
saveAuthToken, saveAuthToken,
compareObjects,
unixNow unixNow
} from '../utils' } from '../utils'
import { appPrivateRoutes } from '../routes'
import { SignedEvent } from '../types'
export class AuthController { export class AuthController {
private nostrController: NostrController private nostrController: NostrController
@ -75,7 +76,7 @@ export class AuthController {
}) })
) )
const relayMap = await this.nostrController.getRelayMap(pubkey) const relayMap = await getRelayMap(pubkey)
if (Object.keys(relayMap).length < 1) { if (Object.keys(relayMap).length < 1) {
// Navigate user to relays page if relay map is empty // Navigate user to relays page if relay map is empty

View File

@ -2,47 +2,24 @@ import NDK, {
NDKEvent, NDKEvent,
NDKNip46Signer, NDKNip46Signer,
NDKPrivateKeySigner, NDKPrivateKeySigner,
NDKSubscription,
NDKUser, NDKUser,
NostrEvent NostrEvent
} from '@nostr-dev-kit/ndk' } from '@nostr-dev-kit/ndk'
import { import {
Event, Event,
EventTemplate, EventTemplate,
Filter,
Relay,
SimplePool,
UnsignedEvent, UnsignedEvent,
finalizeEvent, finalizeEvent,
kinds,
nip04, nip04,
nip19, nip19,
nip44 nip44
} from 'nostr-tools' } from 'nostr-tools'
import { EventEmitter } from 'tseep' import { EventEmitter } from 'tseep'
import { import { updateNsecbunkerPubkey } from '../store/actions'
setRelayConnectionStatusAction,
setRelayInfoAction,
updateNsecbunkerPubkey
} from '../store/actions'
import { AuthState, LoginMethods } from '../store/auth/types' import { AuthState, LoginMethods } from '../store/auth/types'
import store from '../store/store' import store from '../store/store'
import { import { SignedEvent } from '../types'
RelayConnectionState, import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'
RelayConnectionStatus,
RelayInfoObject,
RelayMap,
SignedEvent
} from '../types'
import {
compareObjects,
getDefaultRelayMap,
getMostPopularRelays,
getNsecBunkerDelegatedKey,
unixNow,
verifySignedEvent
} from '../utils'
import { relayController } from './'
export class NostrController extends EventEmitter { export class NostrController extends EventEmitter {
private static instance: NostrController private static instance: NostrController
@ -50,14 +27,13 @@ export class NostrController extends EventEmitter {
private bunkerNDK: NDK | undefined private bunkerNDK: NDK | undefined
private remoteSigner: NDKNip46Signer | undefined private remoteSigner: NDKNip46Signer | undefined
private connectedRelays: Relay[] | undefined
private constructor() { private constructor() {
super() super()
} }
private getNostrObject = () => { private getNostrObject = () => {
// fix: this is not picking up type declaration from src/system/index.d.ts // fix: this is not picking up type declaration from src/system/index.d.ts
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (window.nostr) return window.nostr as any if (window.nostr) return window.nostr as any
throw new Error( throw new Error(
@ -555,317 +531,4 @@ export class NostrController extends EventEmitter {
generateDelegatedKey = (): string => { generateDelegatedKey = (): string => {
return NDKPrivateKeySigner.generate().privateKey! return NDKPrivateKeySigner.generate().privateKey!
} }
/**
* Provides relay map.
* @param npub - user's npub
* @returns - promise that resolves into relay map and a timestamp when it has been updated.
*/
getRelayMap = async (
npub: string
): Promise<{ map: RelayMap; mapUpdated?: number }> => {
const mostPopularRelays = await getMostPopularRelays()
const pool = new SimplePool()
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
const eventFilter: Filter = {
kinds: [kinds.RelayList],
authors: [npub]
}
const event = await pool
.get(mostPopularRelays, eventFilter)
.catch((err) => {
return Promise.reject(err)
})
if (event) {
// Handle founded 10002 event
const relaysMap: RelayMap = {}
// 'r' stands for 'relay'
const relayTags = event.tags.filter((tag) => tag[0] === 'r')
relayTags.forEach((tag) => {
const uri = tag[1]
const relayType = tag[2]
// if 3rd element of relay tag is undefined, relay is WRITE and READ
relaysMap[uri] = {
write: relayType ? relayType === 'write' : true,
read: relayType ? relayType === 'read' : true
}
})
this.getRelayInfo(Object.keys(relaysMap))
this.connectToRelays(Object.keys(relaysMap))
return Promise.resolve({ map: relaysMap, mapUpdated: event.created_at })
} else {
return Promise.resolve({ map: getDefaultRelayMap() })
}
}
/**
* Publishes relay map.
* @param relayMap - relay map.
* @param npub - user's npub.
* @param extraRelaysToPublish - optional relays to publish relay map.
* @returns - promise that resolves into a string representing publishing result.
*/
publishRelayMap = async (
relayMap: RelayMap,
npub: string,
extraRelaysToPublish?: string[]
): Promise<string> => {
const timestamp = unixNow()
const relayURIs = Object.keys(relayMap)
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
const tags: string[][] = relayURIs.map((relayURI) =>
[
'r',
relayURI,
relayMap[relayURI].read && relayMap[relayURI].write
? ''
: relayMap[relayURI].write
? 'write'
: 'read'
].filter((value) => value !== '')
)
const newRelayMapEvent: UnsignedEvent = {
kind: kinds.RelayList,
tags,
content: '',
pubkey: npub,
created_at: timestamp
}
const signedEvent = await this.signEvent(newRelayMapEvent)
let relaysToPublish = relayURIs
// Add extra relays if provided
if (extraRelaysToPublish) {
relaysToPublish = [...relaysToPublish, ...extraRelaysToPublish]
}
// If relay map is empty, use most popular relay URIs
if (!relaysToPublish.length) {
relaysToPublish = await getMostPopularRelays()
}
const publishResult = await relayController.publish(
signedEvent,
relaysToPublish
)
if (publishResult && publishResult.length) {
return Promise.resolve(
`Relay Map published on: ${publishResult.join('\n')}`
)
}
return Promise.reject('Publishing updated relay map was unsuccessful.')
}
/**
* Sets information about relays into relays.info app state.
* @param relayURIs - relay URIs to get information about
*/
getRelayInfo = async (relayURIs: string[]) => {
// initialize job request
const jobEventTemplate: EventTemplate = {
content: '',
created_at: unixNow(),
kind: 68001,
tags: [
['i', `${JSON.stringify(relayURIs)}`],
['j', 'relay-info']
]
}
// sign job request event
const jobSignedEvent = await this.signEvent(jobEventTemplate)
const relays = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://relayable.org'
]
// publish job request
await relayController.publish(jobSignedEvent, relays)
console.log('jobSignedEvent :>> ', jobSignedEvent)
const subscribeWithTimeout = (
subscription: NDKSubscription,
timeoutMs: number
): Promise<string> => {
return new Promise((resolve, reject) => {
const eventHandler = (event: NDKEvent) => {
subscription.stop()
resolve(event.content)
}
subscription.on('event', eventHandler)
// Set up a timeout to stop the subscription after a specified time
const timeout = setTimeout(() => {
subscription.stop() // Stop the subscription
reject(new Error('Subscription timed out')) // Reject the promise with a timeout error
}, timeoutMs)
// Handle subscription close event
subscription.on('close', () => clearTimeout(timeout))
})
}
const dvmNDK = new NDK({
explicitRelayUrls: relays
})
await dvmNDK.connect(2000)
// filter for getting DVM job's result
const sub = dvmNDK.subscribe({
kinds: [68002 as number],
'#e': [jobSignedEvent.id],
'#p': [jobSignedEvent.pubkey]
})
// asynchronously get block number from dvm job with 20 seconds timeout
const dvmJobResult = await subscribeWithTimeout(sub, 20000)
if (!dvmJobResult) {
return Promise.reject(`Relay(s) information wasn't received`)
}
let relaysInfo: RelayInfoObject
try {
relaysInfo = JSON.parse(dvmJobResult)
} catch (error) {
return Promise.reject(`Invalid relay(s) information.`)
}
if (
relaysInfo &&
!compareObjects(store.getState().relays?.info, relaysInfo)
) {
store.dispatch(setRelayInfoAction(relaysInfo))
}
}
/**
* Establishes connection to relays.
* @param relayURIs - an array of relay URIs
* @returns - promise that resolves into an array of connections
*/
connectToRelays = async (relayURIs: string[]) => {
// Copy of relay connection status
const relayConnectionsStatus: RelayConnectionStatus = JSON.parse(
JSON.stringify(store.getState().relays?.connectionStatus || {})
)
const connectedRelayURLs = this.connectedRelays
? this.connectedRelays.map((relay) => relay.url)
: []
// Check if connections already established
if (compareObjects(connectedRelayURLs, relayURIs)) {
return
}
const connections = relayURIs
.filter((relayURI) => !connectedRelayURLs.includes(relayURI))
.map((relayURI) =>
Relay.connect(relayURI)
.then((relay) => {
// put connection status into relayConnectionsStatus object
relayConnectionsStatus[relayURI] = relay.connected
? RelayConnectionState.Connected
: RelayConnectionState.NotConnected
return relay
})
.catch(() => {
relayConnectionsStatus[relayURI] = RelayConnectionState.NotConnected
})
)
const connected = await Promise.all(connections)
// put connected relays into connectedRelays private property, so it can be closed later
this.connectedRelays = connected.filter(
(relay) => relay instanceof Relay && relay.connected
) as Relay[]
if (Object.keys(relayConnectionsStatus)) {
if (
!compareObjects(
store.getState().relays?.connectionStatus,
relayConnectionsStatus
)
) {
store.dispatch(setRelayConnectionStatusAction(relayConnectionsStatus))
}
}
return Promise.resolve(relayConnectionsStatus)
}
/**
* Disconnects from relays.
* @param relayURIs - array of relay URIs to disconnect from
*/
disconnectFromRelays = async (relayURIs: string[]) => {
const connectedRelayURLs = this.connectedRelays
? this.connectedRelays.map((relay) => relay.url)
: []
relayURIs
.filter((relayURI) => connectedRelayURLs.includes(relayURI))
.forEach((relayURI) => {
if (this.connectedRelays) {
const relay = this.connectedRelays.find(
(relay) => relay.url === relayURI
)
if (relay) {
// close relay connection
relay.close()
// remove relay from connectedRelays property
this.connectedRelays = this.connectedRelays.filter(
(relay) => relay.url !== relayURI
)
}
}
})
if (store.getState().relays?.connectionStatus) {
const connectionStatus = JSON.parse(
JSON.stringify(store.getState().relays?.connectionStatus)
)
relayURIs.forEach((relay) => {
delete connectionStatus[relay]
})
if (
!compareObjects(
store.getState().relays?.connectionStatus,
connectionStatus
)
) {
// Update app state
store.dispatch(setRelayConnectionStatusAction(connectionStatus))
}
}
}
} }

View File

@ -1,4 +1,4 @@
import { Filter, Relay, Event } from 'nostr-tools' import { Event, Filter, Relay } from 'nostr-tools'
import { normalizeWebSocketURL, timeout } from '../utils' import { normalizeWebSocketURL, timeout } from '../utils'
import { SIGIT_RELAY } from '../utils/const' import { SIGIT_RELAY } from '../utils/const'
@ -7,7 +7,7 @@ import { SIGIT_RELAY } from '../utils/const'
*/ */
export class RelayController { export class RelayController {
private static instance: RelayController private static instance: RelayController
public connectedRelays: Relay[] = [] public connectedRelays = new Map<string, Relay>()
private constructor() {} private constructor() {}
@ -34,34 +34,44 @@ export class RelayController {
* @param relayUrl - The URL of the relay server to connect to. * @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. * @returns A promise that resolves to the connected relay object if successful, or `null` if the connection fails.
*/ */
public connectRelay = async (relayUrl: string) => { public connectRelay = async (relayUrl: string): Promise<Relay | null> => {
// Check if a relay with the same URL is already connected // Check if a relay with the same URL is already connected
const relay = this.connectedRelays.find( const normalizedWebSocketURL = normalizeWebSocketURL(relayUrl)
(relay) => const relay = this.connectedRelays.get(normalizedWebSocketURL)
normalizeWebSocketURL(relay.url) === normalizeWebSocketURL(relayUrl)
)
// If a matching relay is found, return it (skip connection)
if (relay) { if (relay) {
return relay // If a relay is found in connectedRelay map and is connected, just return it
if (relay.connected) return relay
// If relay is found in connectedRelay map but not connected,
// remove it from map and call connectRelay method again
this.connectedRelays.delete(relayUrl)
return this.connectRelay(relayUrl)
} }
try { // Attempt to connect to the relay using the provided URL
// Attempt to connect to the relay using the provided URL const newRelay = await Relay.connect(relayUrl)
const newRelay = await Relay.connect(relayUrl) .then((relay) => {
if (relay.connected) {
// Add the newly connected relay to the connected relays map
this.connectedRelays.set(relayUrl, relay)
// Add the newly connected relay to the list of connected relays // Return the newly connected relay
this.connectedRelays.push(newRelay) return relay
}
// Return the newly connected relay return null
return newRelay })
} catch (err) { .catch((err) => {
// Log an error message if the connection fails // Log an error message if the connection fails
console.error(`Relay connection failed: ${relayUrl}`, err) console.error(`Relay connection failed: ${relayUrl}`, err)
// Return null to indicate connection failure // Return null to indicate connection failure
return null return null
} })
return newRelay
} }
/** /**
@ -109,6 +119,11 @@ export class RelayController {
// Create a promise for each relay subscription // Create a promise for each relay subscription
const subPromises = relays.map((relay) => { const subPromises = relays.map((relay) => {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
if (!relay.connected) {
console.log(`${relay.url} : Not connected!`, 'Skipping subscription')
return
}
// Subscribe to the relay with the specified filter // Subscribe to the relay with the specified filter
const sub = relay.subscribe([filter], { const sub = relay.subscribe([filter], {
// Handle incoming events // Handle incoming events
@ -274,11 +289,11 @@ export class RelayController {
try { try {
await Promise.race([ await Promise.race([
relay.publish(event), // Publish the event to the relay relay.publish(event), // Publish the event to the relay
timeout(30000) // Set a timeout to handle cases where publishing takes too long timeout(20 * 1000) // Set a timeout to handle cases where publishing takes too long
]) ])
publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays
} catch (err) { } catch (err) {
console.error(`Failed to publish event on relay: ${relay}`, err) console.error(`Failed to publish event on relay: ${relay.url}`, err)
} }
}) })

View File

@ -1 +1,2 @@
export * from './store' export * from './store'
export * from './useDidMount'

12
src/hooks/useDidMount.ts Normal file
View File

@ -0,0 +1,12 @@
import { useRef, useEffect } from 'react'
export const useDidMount = (callback: () => void) => {
const didMount = useRef<boolean>(false)
useEffect(() => {
if (callback && !didMount.current) {
didMount.current = true
callback()
}
})
}

View File

@ -12,138 +12,41 @@ import ListItemText from '@mui/material/ListItemText'
import Switch from '@mui/material/Switch' import Switch from '@mui/material/Switch'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { NostrController } from '../../../controllers' import { Container } from '../../../components/Container'
import { useAppDispatch, useAppSelector } from '../../../hooks' import { relayController } from '../../../controllers'
import { import { useAppDispatch, useAppSelector, useDidMount } from '../../../hooks'
setRelayMapAction, import { setRelayMapAction } from '../../../store/actions'
setRelayMapUpdatedAction import { RelayConnectionState, RelayFee, RelayInfo } from '../../../types'
} from '../../../store/actions'
import {
RelayConnectionState,
RelayFee,
RelayInfoObject,
RelayMap
} from '../../../types'
import { import {
capitalizeFirstLetter, capitalizeFirstLetter,
compareObjects, getRelayInfo,
getRelayMap,
hexToNpub, hexToNpub,
publishRelayMap,
shorten shorten
} from '../../../utils' } from '../../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Container } from '../../../components/Container'
export const RelaysPage = () => { export const RelaysPage = () => {
const nostrController = NostrController.getInstance()
const relaysState = useAppSelector((state) => state.relays)
const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey) const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const [newRelayURI, setNewRelayURI] = useState<string>() const [newRelayURI, setNewRelayURI] = useState<string>()
const [newRelayURIerror, setNewRelayURIerror] = useState<string>() const [newRelayURIerror, setNewRelayURIerror] = useState<string>()
const [relayMap, setRelayMap] = useState<RelayMap | undefined>(
relaysState?.map const relayMap = useAppSelector((state) => state.relays?.map)
) const relaysInfo = useAppSelector((state) => state.relays?.info)
const [relaysInfo, setRelaysInfo] = useState<RelayInfoObject | undefined>(
relaysState?.info
)
const [displayRelaysInfo, setDisplayRelaysInfo] = useState<string[]>([])
const [relaysConnectionStatus, setRelaysConnectionStatus] = useState(
relaysState?.connectionStatus
)
const webSocketPrefix = 'wss://' const webSocketPrefix = 'wss://'
// Update relay connection status useDidMount(() => {
useEffect(() => { if (usersPubkey) {
if ( getRelayMap(usersPubkey).then((newRelayMap) => {
!compareObjects(relaysConnectionStatus, relaysState?.connectionStatus) dispatch(setRelayMapAction(newRelayMap.map))
) { })
setRelaysConnectionStatus(relaysState?.connectionStatus)
} }
}, [relaysConnectionStatus, relaysState?.connectionStatus]) })
useEffect(() => {
if (!compareObjects(relaysInfo, relaysState?.info)) {
setRelaysInfo(relaysState?.info)
}
}, [relaysInfo, relaysState?.info])
useEffect(() => {
if (!compareObjects(relayMap, relaysState?.map)) {
setRelayMap(relaysState?.map)
}
}, [relayMap, relaysState?.map])
useEffect(() => {
let isMounted = false
const fetchData = async () => {
if (usersPubkey) {
isMounted = true
// call async func to fetch relay map
const newRelayMap = await nostrController.getRelayMap(usersPubkey)
// handle fetched relay map
if (isMounted) {
if (
!relaysState?.mapUpdated ||
(newRelayMap?.mapUpdated !== undefined &&
newRelayMap?.mapUpdated > relaysState?.mapUpdated)
) {
if (
!relaysState?.map ||
!compareObjects(relaysState.map, newRelayMap)
) {
setRelayMap(newRelayMap.map)
dispatch(setRelayMapAction(newRelayMap.map))
} else {
// Update relay map updated timestamp
dispatch(setRelayMapUpdatedAction())
}
}
}
}
}
// Publishing relay map can take some time.
// This is why data fetch should happen only if relay map was received more than 5 minutes ago.
if (
usersPubkey &&
(!relaysState?.mapUpdated ||
Date.now() - relaysState?.mapUpdated > 5 * 60 * 1000) // 5 minutes
) {
fetchData()
// Update relay connection status
if (relaysConnectionStatus) {
const notConnectedRelays = Object.keys(relaysConnectionStatus).filter(
(key) =>
relaysConnectionStatus[key] === RelayConnectionState.NotConnected
)
if (notConnectedRelays.length) {
nostrController.connectToRelays(notConnectedRelays)
}
}
}
// cleanup func
return () => {
isMounted = false
}
}, [
dispatch,
usersPubkey,
relaysState?.map,
relaysState?.mapUpdated,
nostrController,
relaysConnectionStatus
])
useEffect(() => { useEffect(() => {
// Display notification if an empty relay map has been received // Display notification if an empty relay map has been received
@ -175,24 +78,23 @@ export const RelaysPage = () => {
if (usersPubkey) { if (usersPubkey) {
// Publish updated relay map. // Publish updated relay map.
const relayMapPublishingRes = await nostrController const relayMapPublishingRes = await publishRelayMap(
.publishRelayMap(relayMapCopy, usersPubkey, [relay]) relayMapCopy,
.catch((err) => handlePublishRelayMapError(err)) usersPubkey,
[relay]
).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) { if (relayMapPublishingRes) {
toast.success(relayMapPublishingRes) toast.success(relayMapPublishingRes)
setRelayMap(relayMapCopy)
dispatch(setRelayMapAction(relayMapCopy)) dispatch(setRelayMapAction(relayMapCopy))
} }
} }
nostrController.disconnectFromRelays([relay])
} }
} }
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlePublishRelayMapError = (err: any) => { const handlePublishRelayMapError = (err: any) => {
const errorPrefix = 'Error while publishing Relay Map' const errorPrefix = 'Error while publishing Relay Map'
@ -224,15 +126,14 @@ export const RelaysPage = () => {
if (usersPubkey) { if (usersPubkey) {
// Publish updated relay map // Publish updated relay map
const relayMapPublishingRes = await nostrController const relayMapPublishingRes = await publishRelayMap(
.publishRelayMap(relayMapCopy, usersPubkey) relayMapCopy,
.catch((err) => handlePublishRelayMapError(err)) usersPubkey
).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) { if (relayMapPublishingRes) {
toast.success(relayMapPublishingRes) toast.success(relayMapPublishingRes)
setRelayMap(relayMapCopy)
dispatch(setRelayMapAction(relayMapCopy)) dispatch(setRelayMapAction(relayMapCopy))
} }
} }
@ -256,29 +157,25 @@ export const RelaysPage = () => {
) )
} }
} else if (relayURI && usersPubkey) { } else if (relayURI && usersPubkey) {
const connectionStatus = await nostrController.connectToRelays([relayURI]) const relay = await relayController.connectRelay(relayURI)
if ( if (relay && relay.connected) {
connectionStatus &&
connectionStatus[relayURI] &&
connectionStatus[relayURI] === RelayConnectionState.Connected
) {
const relayMapCopy = JSON.parse(JSON.stringify(relayMap)) const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
relayMapCopy[relayURI] = { write: true, read: true } relayMapCopy[relayURI] = { write: true, read: true }
// Publish updated relay map // Publish updated relay map
const relayMapPublishingRes = await nostrController const relayMapPublishingRes = await publishRelayMap(
.publishRelayMap(relayMapCopy, usersPubkey) relayMapCopy,
.catch((err) => handlePublishRelayMapError(err)) usersPubkey
).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) { if (relayMapPublishingRes) {
setRelayMap(relayMapCopy)
setNewRelayURI('') setNewRelayURI('')
dispatch(setRelayMapAction(relayMapCopy)) dispatch(setRelayMapAction(relayMapCopy))
nostrController.getRelayInfo([relayURI]) getRelayInfo([relayURI])
toast.success(relayMapPublishingRes) toast.success(relayMapPublishingRes)
} }
@ -292,29 +189,6 @@ export const RelaysPage = () => {
} }
} }
// Handle relay open and close state
const handleRelayInfo = (relay: string) => {
if (relaysInfo) {
const info = relaysInfo[relay]
if (info) {
let displayRelaysInfoCopy: string[] = JSON.parse(
JSON.stringify(displayRelaysInfo)
)
if (displayRelaysInfoCopy.includes(relay)) {
displayRelaysInfoCopy = displayRelaysInfoCopy.filter(
(rel) => rel !== relay
)
} else {
displayRelaysInfoCopy.push(relay)
}
setDisplayRelaysInfo(displayRelaysInfoCopy)
}
}
}
return ( return (
<Container className={styles.container}> <Container className={styles.container}>
<Box className={styles.relayAddContainer}> <Box className={styles.relayAddContainer}>
@ -343,177 +217,211 @@ export const RelaysPage = () => {
</Box> </Box>
{relayMap && ( {relayMap && (
<Box className={styles.relaysContainer}> <Box className={styles.relaysContainer}>
{Object.keys(relayMap).map((relay, i) => ( {Object.keys(relayMap).map((relay) => (
<Box className={styles.relay} key={`relay_${i}`}> <RelayItem
<List> key={relay}
<ListItem> relayURI={relay}
<span isWriteRelay={relayMap[relay].write}
className={[ relayInfo={relaysInfo ? relaysInfo[relay] : undefined}
styles.connectionStatus, handleLeaveRelay={handleLeaveRelay}
relaysConnectionStatus handleRelayWriteChange={handleRelayWriteChange}
? relaysConnectionStatus[relay] === />
RelayConnectionState.Connected
? styles.connectionStatusConnected
: styles.connectionStatusNotConnected
: styles.connectionStatusUnknown
].join(' ')}
/>
{relaysInfo &&
relaysInfo[relay] &&
relaysInfo[relay].limitation &&
relaysInfo[relay].limitation?.payment_required && (
<Tooltip title="Paid Relay" arrow placement="top">
<ElectricBoltIcon
className={styles.lightningIcon}
color="warning"
onClick={() => handleRelayInfo(relay)}
/>
</Tooltip>
)}
<ListItemText primary={relay} />
<Box
className={styles.leaveRelayContainer}
onClick={() => handleLeaveRelay(relay)}
>
<LogoutIcon />
<span>Leave</span>
</Box>
</ListItem>
<Divider className={styles.relayDivider} />
<ListItem>
<ListItemText
primary="Publish to this relay?"
secondary={
relaysInfo && relaysInfo[relay] ? (
<span
onClick={() => handleRelayInfo(relay)}
className={styles.showInfo}
>
Show info{' '}
{displayRelaysInfo.includes(relay) ? (
<KeyboardArrowUpIcon
className={styles.showInfoIcon}
/>
) : (
<KeyboardArrowDownIcon
className={styles.showInfoIcon}
/>
)}
</span>
) : (
''
)
}
/>
<Switch
checked={relayMap[relay].write}
onChange={(event) => handleRelayWriteChange(relay, event)}
/>
</ListItem>
{displayRelaysInfo.includes(relay) && (
<>
<Divider className={styles.relayDivider} />
<ListItem>
<Box className={styles.relayInfoContainer}>
{relaysInfo &&
relaysInfo[relay] &&
Object.keys(relaysInfo[relay]).map((key: string) => {
const infoTitle = capitalizeFirstLetter(
key.replace('_', ' ')
)
let infoValue = (relaysInfo[relay] as any)[key]
switch (key) {
case 'pubkey':
infoValue = shorten(hexToNpub(infoValue), 15)
break
case 'limitation':
infoValue = (
<ul key={`${i}_${key}`}>
{Object.keys(infoValue).map((valueKey) => (
<li key={`${i}_${key}_${valueKey}`}>
<span
className={styles.relayInfoSubTitle}
>
{capitalizeFirstLetter(
valueKey.split('_').join(' ')
)}
:
</span>{' '}
{`${infoValue[valueKey]}`}
</li>
))}
</ul>
)
break
case 'fees':
infoValue = (
<ul>
{Object.keys(infoValue).map((valueKey) => (
<li key={`${i}_${key}_${valueKey}`}>
<span
className={styles.relayInfoSubTitle}
>
{capitalizeFirstLetter(
valueKey.split('_').join(' ')
)}
:
</span>{' '}
{`${infoValue[valueKey].map((fee: RelayFee) => `${fee.amount} ${fee.unit}`)}`}
</li>
))}
</ul>
)
break
default:
break
}
if (Array.isArray(infoValue)) {
infoValue = infoValue.join(', ')
}
return (
<span key={`${i}_${key}_container`}>
<span className={styles.relayInfoTitle}>
{infoTitle}:
</span>{' '}
{infoValue}
{key === 'pubkey' ? (
<ContentCopyIcon
className={styles.copyItem}
onClick={() => {
navigator.clipboard.writeText(
hexToNpub(
(relaysInfo[relay] as any)[key]
)
)
toast.success('Copied to clipboard', {
autoClose: 1000,
hideProgressBar: true
})
}}
/>
) : null}
</span>
)
})}
</Box>
</ListItem>
</>
)}
</List>
</Box>
))} ))}
</Box> </Box>
)} )}
</Container> </Container>
) )
} }
type RelayItemProp = {
relayURI: string
isWriteRelay: boolean
relayInfo?: RelayInfo
handleLeaveRelay: (relay: string) => void
handleRelayWriteChange: (
relay: string,
event: React.ChangeEvent<HTMLInputElement>
) => Promise<void>
}
const RelayItem = ({
relayURI,
isWriteRelay,
relayInfo,
handleLeaveRelay,
handleRelayWriteChange
}: RelayItemProp) => {
const [relayConnectionStatus, setRelayConnectionStatus] =
useState<RelayConnectionState>()
const [displayRelayInfo, setDisplayRelayInfo] = useState(false)
useDidMount(() => {
relayController.connectRelay(relayURI).then((relay) => {
if (relay && relay.connected) {
setRelayConnectionStatus(RelayConnectionState.Connected)
} else {
setRelayConnectionStatus(RelayConnectionState.NotConnected)
}
})
})
return (
<Box className={styles.relay}>
<List>
<ListItem>
<span
className={[
styles.connectionStatus,
relayConnectionStatus
? relayConnectionStatus === RelayConnectionState.Connected
? styles.connectionStatusConnected
: styles.connectionStatusNotConnected
: styles.connectionStatusUnknown
].join(' ')}
/>
{relayInfo &&
relayInfo.limitation &&
relayInfo.limitation?.payment_required && (
<Tooltip title="Paid Relay" arrow placement="top">
<ElectricBoltIcon
className={styles.lightningIcon}
color="warning"
onClick={() => setDisplayRelayInfo((prev) => !prev)}
/>
</Tooltip>
)}
<ListItemText primary={relayURI} />
<Box
className={styles.leaveRelayContainer}
onClick={() => handleLeaveRelay(relayURI)}
>
<LogoutIcon />
<span>Leave</span>
</Box>
</ListItem>
<Divider className={styles.relayDivider} />
<ListItem>
<ListItemText
primary="Publish to this relay?"
secondary={
relayInfo ? (
<span
onClick={() => setDisplayRelayInfo((prev) => !prev)}
className={styles.showInfo}
>
Show info{' '}
{displayRelayInfo ? (
<KeyboardArrowUpIcon className={styles.showInfoIcon} />
) : (
<KeyboardArrowDownIcon className={styles.showInfoIcon} />
)}
</span>
) : (
''
)
}
/>
<Switch
checked={isWriteRelay}
onChange={(event) => handleRelayWriteChange(relayURI, event)}
/>
</ListItem>
{displayRelayInfo && (
<>
<Divider className={styles.relayDivider} />
<ListItem>
<Box className={styles.relayInfoContainer}>
{relayInfo &&
Object.keys(relayInfo).map((key: string) => {
const infoTitle = capitalizeFirstLetter(
key.replace('_', ' ')
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let infoValue = (relayInfo as any)[key]
switch (key) {
case 'pubkey':
infoValue = shorten(hexToNpub(infoValue), 15)
break
case 'limitation':
infoValue = (
<ul>
{Object.keys(infoValue).map((valueKey) => (
<li key={`${relayURI}_${key}_${valueKey}`}>
<span className={styles.relayInfoSubTitle}>
{capitalizeFirstLetter(
valueKey.split('_').join(' ')
)}
:
</span>{' '}
{`${infoValue[valueKey]}`}
</li>
))}
</ul>
)
break
case 'fees':
infoValue = (
<ul>
{Object.keys(infoValue).map((valueKey) => (
<li key={`${relayURI}_${key}_${valueKey}`}>
<span className={styles.relayInfoSubTitle}>
{capitalizeFirstLetter(
valueKey.split('_').join(' ')
)}
:
</span>{' '}
{`${infoValue[valueKey].map((fee: RelayFee) => `${fee.amount} ${fee.unit}`)}`}
</li>
))}
</ul>
)
break
default:
break
}
if (Array.isArray(infoValue)) {
infoValue = infoValue.join(', ')
}
return (
<span key={`${relayURI}_${key}_container`}>
<span className={styles.relayInfoTitle}>
{infoTitle}:
</span>{' '}
{infoValue}
{key === 'pubkey' ? (
<ContentCopyIcon
className={styles.copyItem}
onClick={() => {
navigator.clipboard.writeText(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hexToNpub((relayInfo as any)[key])
)
toast.success('Copied to clipboard', {
autoClose: 1000,
hideProgressBar: true
})
}}
/>
) : null}
</span>
)
})}
</Box>
</ListItem>
</>
)}
</List>
</Box>
)
}

View File

@ -1,12 +1,14 @@
import { EventTemplate, Filter, kinds, nip19 } from 'nostr-tools' import { EventTemplate, Filter, kinds, nip19 } from 'nostr-tools'
import { queryNip05, unixNow } from '.' import { compareObjects, queryNip05, unixNow } from '.'
import { import {
MetadataController, MetadataController,
NostrController, NostrController,
relayController relayController
} from '../controllers' } from '../controllers'
import { NostrJoiningBlock } from '../types' import { NostrJoiningBlock, RelayInfoObject } from '../types'
import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk' import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
import store from '../store/store'
import { setRelayInfoAction } from '../store/actions'
export const getNostrJoiningBlockNumber = async ( export const getNostrJoiningBlockNumber = async (
hexKey: string hexKey: string
@ -133,3 +135,94 @@ export const getNostrJoiningBlockNumber = async (
return null return null
} }
/**
* Sets information about relays into relays.info app state.
* @param relayURIs - relay URIs to get information about
*/
export const getRelayInfo = async (relayURIs: string[]) => {
// initialize job request
const jobEventTemplate: EventTemplate = {
content: '',
created_at: unixNow(),
kind: 68001,
tags: [
['i', `${JSON.stringify(relayURIs)}`],
['j', 'relay-info']
]
}
const nostrController = NostrController.getInstance()
// sign job request event
const jobSignedEvent = await nostrController.signEvent(jobEventTemplate)
const relays = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://relayable.org'
]
// publish job request
await relayController.publish(jobSignedEvent, relays)
console.log('jobSignedEvent :>> ', jobSignedEvent)
const subscribeWithTimeout = (
subscription: NDKSubscription,
timeoutMs: number
): Promise<string> => {
return new Promise((resolve, reject) => {
const eventHandler = (event: NDKEvent) => {
subscription.stop()
resolve(event.content)
}
subscription.on('event', eventHandler)
// Set up a timeout to stop the subscription after a specified time
const timeout = setTimeout(() => {
subscription.stop() // Stop the subscription
reject(new Error('Subscription timed out')) // Reject the promise with a timeout error
}, timeoutMs)
// Handle subscription close event
subscription.on('close', () => clearTimeout(timeout))
})
}
const dvmNDK = new NDK({
explicitRelayUrls: relays
})
await dvmNDK.connect(2000)
// filter for getting DVM job's result
const sub = dvmNDK.subscribe({
kinds: [68002 as number],
'#e': [jobSignedEvent.id],
'#p': [jobSignedEvent.pubkey]
})
// asynchronously get block number from dvm job with 20 seconds timeout
const dvmJobResult = await subscribeWithTimeout(sub, 20000)
if (!dvmJobResult) {
return Promise.reject(`Relay(s) information wasn't received`)
}
let relaysInfo: RelayInfoObject
try {
relaysInfo = JSON.parse(dvmJobResult)
} catch (error) {
return Promise.reject(`Invalid relay(s) information.`)
}
if (
relaysInfo &&
!compareObjects(store.getState().relays?.info, relaysInfo)
) {
store.dispatch(setRelayInfoAction(relaysInfo))
}
}

View File

@ -603,7 +603,7 @@ export const updateUsersAppData = async (meta: Meta) => {
const publishResult = await Promise.race([ const publishResult = await Promise.race([
relayController.publish(signedEvent, writeRelays), relayController.publish(signedEvent, writeRelays),
timeout(1000 * 30) timeout(40 * 1000)
]).catch((err) => { ]).catch((err) => {
console.log('err :>> ', err) console.log('err :>> ', err)
if (err.message === 'Timeout') { if (err.message === 'Timeout') {
@ -936,7 +936,7 @@ export const sendNotification = async (receiver: string, meta: Meta) => {
// Publish the notification event to the recipient's read relays // Publish the notification event to the recipient's read relays
await Promise.race([ await Promise.race([
relayController.publish(wrappedEvent, relaySet.read), relayController.publish(wrappedEvent, relaySet.read),
timeout(1000 * 30) timeout(40 * 1000)
]).catch((err) => { ]).catch((err) => {
// Log an error if publishing the notification event fails // Log an error if publishing the notification event fails
console.log( console.log(

View File

@ -1,12 +1,13 @@
import axios from 'axios' import axios from 'axios'
import { Event, Filter } from 'nostr-tools' import { Event, Filter, kinds, UnsignedEvent } from 'nostr-tools'
import { RelayList } from 'nostr-tools/kinds' import { RelayList } from 'nostr-tools/kinds'
import { relayController } from '../controllers/RelayController.ts' import { getRelayInfo, unixNow } from '.'
import { NostrController, relayController } from '../controllers'
import { localCache } from '../services' import { localCache } from '../services'
import { setMostPopularRelaysAction } from '../store/actions' import { setMostPopularRelaysAction } from '../store/actions'
import store from '../store/store' import store from '../store/store'
import { RelayMap, RelayReadStats, RelaySet, RelayStats } from '../types' import { RelayMap, RelayReadStats, RelaySet, RelayStats } from '../types'
import { ONE_WEEK_IN_MS, SIGIT_RELAY } from './const.ts' import { ONE_WEEK_IN_MS, SIGIT_RELAY } from './const'
const READ_MARKER = 'read' const READ_MARKER = 'read'
const WRITE_MARKER = 'write' const WRITE_MARKER = 'write'
@ -154,12 +155,131 @@ const getMostPopularRelays = async (
return apiTopRelays return apiTopRelays
} }
/**
* Provides relay map.
* @param npub - user's npub
* @returns - promise that resolves into relay map and a timestamp when it has been updated.
*/
const getRelayMap = async (
npub: string
): Promise<{ map: RelayMap; mapUpdated?: number }> => {
const mostPopularRelays = await getMostPopularRelays()
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
const eventFilter: Filter = {
kinds: [kinds.RelayList],
authors: [npub]
}
const event = await relayController
.fetchEvent(eventFilter, mostPopularRelays)
.catch((err) => {
return Promise.reject(err)
})
if (event) {
// Handle founded 10002 event
const relaysMap: RelayMap = {}
// 'r' stands for 'relay'
const relayTags = event.tags.filter((tag) => tag[0] === 'r')
relayTags.forEach((tag) => {
const uri = tag[1]
const relayType = tag[2]
// if 3rd element of relay tag is undefined, relay is WRITE and READ
relaysMap[uri] = {
write: relayType ? relayType === 'write' : true,
read: relayType ? relayType === 'read' : true
}
})
Object.keys(relaysMap).forEach((relayUrl) => {
relayController.connectRelay(relayUrl)
})
getRelayInfo(Object.keys(relaysMap))
return Promise.resolve({ map: relaysMap, mapUpdated: event.created_at })
} else {
return Promise.resolve({ map: getDefaultRelayMap() })
}
}
/**
* Publishes relay map.
* @param relayMap - relay map.
* @param npub - user's npub.
* @param extraRelaysToPublish - optional relays to publish relay map.
* @returns - promise that resolves into a string representing publishing result.
*/
const publishRelayMap = async (
relayMap: RelayMap,
npub: string,
extraRelaysToPublish?: string[]
): Promise<string> => {
const timestamp = unixNow()
const relayURIs = Object.keys(relayMap)
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
const tags: string[][] = relayURIs.map((relayURI) =>
[
'r',
relayURI,
relayMap[relayURI].read && relayMap[relayURI].write
? ''
: relayMap[relayURI].write
? 'write'
: 'read'
].filter((value) => value !== '')
)
const newRelayMapEvent: UnsignedEvent = {
kind: kinds.RelayList,
tags,
content: '',
pubkey: npub,
created_at: timestamp
}
const nostrController = NostrController.getInstance()
const signedEvent = await nostrController.signEvent(newRelayMapEvent)
let relaysToPublish = relayURIs
// Add extra relays if provided
if (extraRelaysToPublish) {
relaysToPublish = [...relaysToPublish, ...extraRelaysToPublish]
}
// If relay map is empty, use most popular relay URIs
if (!relaysToPublish.length) {
relaysToPublish = await getMostPopularRelays()
}
const publishResult = await relayController.publish(
signedEvent,
relaysToPublish
)
if (publishResult && publishResult.length) {
return Promise.resolve(
`Relay Map published on: ${publishResult.join('\n')}`
)
}
return Promise.reject('Publishing updated relay map was unsuccessful.')
}
export { export {
findRelayListAndUpdateCache, findRelayListAndUpdateCache,
findRelayListInCache, findRelayListInCache,
getDefaultRelayMap, getDefaultRelayMap,
getDefaultRelaySet, getDefaultRelaySet,
getMostPopularRelays, getMostPopularRelays,
getRelayMap,
publishRelayMap,
getUserRelaySet, getUserRelaySet,
isOlderThanOneWeek isOlderThanOneWeek
} }